# vue2-toutiao
**Repository Path**: vsdeveloper/vue2-toutiao
## Basic Information
- **Project Name**: vue2-toutiao
- **Description**: vue2 + vue-router + vuex + vant
- **Primary Language**: JavaScript
- **License**: MIT
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 4
- **Forks**: 1
- **Created**: 2021-03-12
- **Last Updated**: 2022-10-16
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# 移动端项目 - 《黑马头条》
> 线上项目演示地址:http://toutiao.liulongbin.top/
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
## 1. 初始化项目
### 1.1 创建基本的项目结构
1. 运行如下的命令:
```bash
vue create toutiao
```
2. 清空 `App.vue` 组件中的代码,并删除 `components` 目录下的 `HelloWorld.vue` 组件
3. 清空 `/src/router/index.js` 路由模块:
```js
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
// 清空路由规则
const routes = []
const router = new VueRouter({
routes
})
export default router
```
4. 执行 `npm run serve` 命令,把项目运行起来看效果
5. 添加 `.prettierrc` 的配置文件:
```json
{
"semi": false,
"singleQuote": true,
"trailingComma": "none"
}
```
6. 在 `.eslintrc.js` 配置文件中,添加如下的规则:
```js
rules: {
'space-before-function-paren': 0
}
```
### 1.2 配置 vant 组件库
> 官网地址:https://vant-contrib.gitee.io/vant/#/zh-CN/
完整导入:
```js
import Vue from 'vue'
import Vant from 'vant'
import 'vant/lib/index.css'
Vue.use(Vant)
```
### 1.3 Vant 组件库的 rem 布局适配
> 参考文档:https://vant-contrib.gitee.io/vant/#/zh-CN/advanced-usage#rem-bu-ju-gua-pei
#### 1.3.1 配置 postcss-pxtorem
1. 运行如下的命令:
```bash
npm install postcss-pxtorem -D
```
2. 在 vue 项目根目录下,创建 postcss 的配置文件 `postcss.config.js`,并初始化如下的配置:
```js
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 37.5, // 根节点的 font-size 值
propList: ['*'] // 要处理的属性列表,* 代表所有属性
}
}
}
```
3. 关于 px -> rem 的换算:
```
iphone6
375px = 10rem
37.5px = 1rem
1px = 1/37.5rem
12px = 12/37.5rem = 0.32rem
```
#### 1.3.2 配置 amfe-flexible
1. 运行如下的命令:
```bash
npm i amfe-flexible -S
```
2. 在 main.js 入口文件中导入 `amfe-flexible`:
```js
import 'amfe-flexible'
```
### 1.4 配置 axios
1. 安装:
```bash
npm i axios -S
```
2. 创建 `/src/utils/request.js` 模块:
```js
import axios from 'axios'
const instance = axios.create({
// 请求根路径
baseURL: 'http://toutiao-app.itheima.net'
})
export default instance
```
## 2. 登录功能
### 2.1 使用路由渲染登录组件
1. 创建 `/src/views/Login/Login.vue` 登录组件:
```vue
登录组件
```
2. 修改路由模块,导入`Login.vue` 登录组件并声明路由规则:
```js
import Vue from 'vue'
import VueRouter from 'vue-router'
// 1. 导入组件
import Login from '@/views/Login/Login.vue'
Vue.use(VueRouter)
const routes = [
// 2. 登录组件的路由规则
{ path: '/login', component: Login, name: 'login' }
]
const router = new VueRouter({
routes
})
export default router
```
3. 在 `App.vue` 中声明路由占位符:
```vue
```
### 2.2 渲染登录组件的头部区域
> 基于 vant 导航组件下的 `NavBar 导航栏组件`,渲染 Login.vue 登录组件的头部区域
1. 渲染登录组件的 header 头部区域:
```xml
```
2. 基于 vant 展示组件下的 `Sticy 粘性布局` 组件,实现 header 区域的吸顶效果:
```xml
```
### 2.3 覆盖 NavBar 组件的默认样式
> 3 种实现方案:
>
> 1. 定义**全局样式表**,通过审查元素的方式,找到对应的 class 类名后,进行样式的覆盖操作。
> 2. 通过**定制主题**的方式,直接覆盖 vant 组件库中的 less 变量;
> 3. 通过**定制主题**的方式,自定义 less 主题文件,基于文件的方式覆盖默认的 less 变量;
>
> 参考地址:https://vant-contrib.gitee.io/vant/#/zh-CN/theme
#### 方案1:全局样式表
1. 在 `src` 目录下新建 `index.less` 全局样式表,通过审查元素的方式找到对应的 class 类名,进行样式的覆盖:
```less
// 覆盖 NavBar 组件的默认样式
.van-nav-bar {
background-color: #007bff;
.van-nav-bar__title {
color: white;
font-size: 14px;
}
}
```
2. 在 `main.js` 中导入全局样式表即可:
```js
// 导入 Vant 和 组件的样式表
import Vant from 'vant'
import 'vant/lib/index.css'
// 导入全局样式表
+ import './index.less'
// 注册全局插件
Vue.use(Vant)
```
#### 方案2:定制主题 - 直接覆盖变量
1. 修改 `main.js` 中导入 vant 样式的代码,把 `.css` 的后缀名改为 `.less` 后缀名:
```js
// 导入 Vant 和 组件的样式表
import Vant from 'vant'
// 这里要把 .css 后缀名改为 .less
import 'vant/lib/index.less'
```
2. 在项目根目录下新建 `vue.config.js` 配置文件:
```js
module.exports = {
css: {
loaderOptions: {
less: {
modifyVars: {
// 直接覆盖变量,注意:变量名之前不需要加 @ 符号
'nav-bar-background-color': '#007bff',
'nav-bar-title-text-color': 'white',
'nav-bar-title-font-size': '14px'
}
}
}
}
}
```
#### 方案3:定制主题 - 基于 less 文件
1. 修改 `main.js` 中导入 vant 样式的代码,把 `.css` 的后缀名改为 `.less` 后缀名:
```js
// 导入 Vant 和 组件的样式表
import Vant from 'vant'
// 这里要把 .css 后缀名改为 .less
import 'vant/lib/index.less'
```
2. 在 `src` 目录下新建 `cover.less` 主题文件,用来覆盖 vant 默认主题中的 less 变量:
```less
@blue: #007bff;
@white: white;
@font-14: 14px;
// NavBar
@nav-bar-background-color: @blue;
@nav-bar-title-text-color: @white;
@nav-bar-title-font-size: @font-14;
```
3. 在项目根目录下新建 `vue.config.js` 配置文件:
```js
const path = require('path')
// 自定义主题的文件路径
const coverPath = path.join(__dirname, './src/cover.less')
module.exports = {
css: {
loaderOptions: {
less: {
modifyVars: {
// 通过 less 文件覆盖(文件路径为绝对路径)
hack: `true; @import "${coverPath}";`
}
}
}
}
}
```
### 2.4 实现登录功能
1. 渲染 DOM 结构:
```xml
登录
```
2. 声明 data 数据和表单验证规则对象:
```js
data() {
return {
// 登录的表单数据对象
formLogin: {
mobile: '13888888888',
code: '246810'
},
// 登录表单的验证规则对象
formLoginRules: {
mobile: [
{ required: true, message: '请填写手机号', trigger: 'onBlur' },
{
pattern: /^1\d{10}$/,
message: '请填写正确的手机号',
trigger: 'onBlur'
}
],
code: [{ required: true, message: '请填写密码', trigger: 'onBlur' }]
}
}
},
```
3. 在 `src/api/` 目录下,封装 `user.js` 模块,对外提供登录的 API 方法:
```js
import axios from '@/utils/request'
// 登录
export const login = data => {
return axios.post('/v1_0/authorizations', data)
}
```
4. 在 `Login.vue` 组件中,声明 `onSubmit` 方法如下:
```js
import { login } from '@/api/user'
methods: {
// 组件内自定义的方法
async onSubmit() {
const { data: res } = await login(this.formLogin)
console.log(res)
if (res.message === 'OK') {
// 把登录成功的结果,存储到 vuex 中
}
}
}
```
### 2.5 把 token 存储到 vuex
1. 在 vuex 模块中声明 state 数据节点:
```js
export default new Vuex.Store({
state: {
// 登录成功之后的 token 信息
tokenInfo: {}
}
})
```
2. 声明 `updateTokenInfo` 方法:
```js
mutations: {
// 更新 token 的信息
updateTokenInfo(state, payload) {
state.tokenInfo = payload
}
},
```
3. 在 `Login.vue` 组件中,通过 `mapMutations` 辅助方法,把 `updateTokenInfo` 方法映射到当前组件中使用:
```js
// 1. 按需导入辅助方法
import { mapMutations } from 'vuex'
export default {
// 2. 映射 mutations 中的方法
...mapMutations(['updateTokenInfo']),
// 3. 组件内自定义的方法
async onSubmit() {
const { data: res } = await login(this.formLogin)
console.log(res)
if (res.message === 'OK') {
// 4. 更新 state 中的 token 信息
this.updateTokenInfo(res.data)
// 5. 跳转到主页
this.$router.push('/')
}
}
}
```
### 2.6 持久化存储 state
1. 定义 `initState` 对象:
```js
// 初始的 state 数据
let initState = {
// 登录成功之后的 token 信息
tokenInfo: {}
}
```
2. 读取本地存储中的 state 数据:
```js
// 读取本地存储中的数据
const stateStr = localStorage.getItem('state')
// 判断是否有数据
if (stateStr) {
initState = JSON.parse(stateStr)
}
```
3. 为 vuex 中的 state 赋值:
```js
export default new Vuex.Store({
state: initState,
// 省略其它代码...
})
```
### 2.7 通过拦截器添加 token 认证
1. 在 `/src/utils/request.js` 模块中,声明请求拦截器:
```js
instance.interceptors.request.use(
config => {
return config
},
error => {
return Promise.reject(error)
}
)
```
2. 在 `request.js` 模块中导入 vuex 的 `store` 模块:
```js
import store from '@/store/index'
```
3. 添加 token 认证信息:
```js
instance.interceptors.request.use(
config => {
// 1. 获取 token 值
const tokenStr = store.state.tokenInfo.token
// 2. 判断 token 值是否存在
if (tokenStr) {
// 3. 添加身份认证字段
config.headers.Authorization = 'Bearer ' + tokenStr
}
return config
},
function(error) {
return Promise.reject(error)
}
)
```
## 3. 主页布局
### 3.1 实现 Layout 组件的布局
1. 在 `src/views/Layout` 目录下新建 `Layout.vue` 组件:
```vue
```
2. 在路由模块中导入 `Layout.vue` 组件,并声明路由规则:
```js
import Layout from '@/views/Layout/Layout.vue'
const routes = [
{ path: '/login', component: Login, name: 'login' },
// Layout 组件的路由规则
{ path: '/', component: Layout }
]
```
4. 渲染 TabBar 区域:
```xml
首页
我的
```
美化样式:
```less
.van-tabbar {
border-top: 1px solid #f8f8f8;
}
```
### 3.2 基于路由渲染 Home 和 User 组件
1. 在 `views` 目录下分别声明 `Home.vue` 和 `User.vue` 组件:
- Home.vue 组件的基本结构:
```vue
```
- User.vue 组件的基本结构:
```vue
User 页面
```
2. 在路由模块中导入 Home 和 User 组件,并声明对应的路由规则:
```js
import Home from '@/views/Home/Home.vue'
import User from '@/views/User/User.vue'
const routes = [
{ path: '/login', component: Login, name: 'login' },
{
path: '/',
component: Layout,
children: [
// 默认子路由
{ path: '', component: Home, name: 'home' },
{ path: '/user', component: User, name: 'user' }
]
}
]
```
3. 在 `Layout.vue` 组件中声明路由占位符:
```xml
```
### 3.3 获取频道列表的数据
1. 在 `/src/api` 目录下新建 `home.js` 模块:
```js
import axios from '@/utils/request'
// 获取频道列表
export const getChannelList = () => {
return axios.get('/v1_0/user/channels')
}
```
2. 在 `Home.vue` 组件中按需导入 `getChannelList` 方法:
```js
// 按需导入获取频道列表数据的 API 方法
import { getChannelList } from '@/api/home'
```
3. 在 `data` 节点中声明 `channels` 数组,存放频道列表的数据:
```js
data() {
return {
// 频道列表
channels: []
}
}
```
4. 在 `created` 生命周期函数中预调用 `getChannels` 方法,获取频道列表的数据:
```js
created() {
this.getChannels()
}
```
5. 在 `Home.vue` 组件的 `methods` 节点中声明 `getChannels` 方法如下:
```js
// 获取频道列表的数据
async getChannels() {
const { data: res } = await getChannelList()
// 判断数据是否请求成功
if (res.message === 'OK') {
this.channels = res.data.channels
}
}
```
### 3.4 渲染频道列表结构
> 基于 Vant 导航组件下的 Tab 标签页组件,渲染出频道列表的基础结构
1. 渲染频道列表的 DOM 结构:
```xml
{{item.name}}
```
2. 在 `src/views/Home` 目录下新建 `ArticleList.vue` 组件:
```xml
AritcleList 组件 --- {{id}}
```
3. 在 `Home.vue` 组件中导入、注册并使用 `ArticleList` 组件
导入:
```js
import ArticleList from './ArticleList.vue'
```
注册:
```js
components: {
ArticleList
}
```
使用:
```xml
```
4. 定制主题:定制选中项的高亮颜色:
```less
// cover.less
// Tab 标签页
@tabs-bottom-bar-color: @blue;
```
### 3.5 根据频道 Id 获取文章列表数据
1. 在 `@/api` 目录下的 `home.js` 模块中,声明获取文章列表数据的方法:
```js
// 根据频道 Id 获取文章列表数据
export const getArticleList = id => {
return axios.get('/v1_1/articles', {
params: {
channel_id: id, // 频道id
timestamp: Date.now(), // 时间戳整数 单位毫秒
with_top: 1
}
})
}
```
2. 在 `ArticleList.vue` 组件中按需导入获取文章列表数据的方法:
```js
import { getArticleList } from '@/api/home'
```
3. 在 `ArticleList.vue` 组件的 data 节点下声明文章列表的数组:
```js
data() {
return {
// 文章列表的数据
articles: []
}
},
```
4. 在 `created` 生命周期函数中预调用 `getArticleList` 方法:
```js
created() {
this.getArticleList()
},
```
5. 在 `methods` 节点下声明 `getArticleList` 方法如下:
```js
methods: {
// 获取文章列表数据
async getArticleList() {
const { data: res } = await getArticleList(this.id)
// 判断数据是否请求成功
if (res.message === 'OK') {
this.articles = res.data.results
}
}
}
```
### 3.6 渲染文章列表的 DOM 结构
1. 渲染基本的标题和文章信息:
```xml
{{item.title}}
{{item.aut_name}} {{item.comm_count}}评论 {{item.pubdate}}
```
并美化样式:
```less
.label-box {
display: flex;
justify-content: space-between;
align-items: center;
}
```
2. 根据 `item.cover.type` 的值,按需渲染1张或3张图片:
```xml
{{item.title}}
{{item.aut_name}} {{item.comm_count}}评论 {{item.pubdate}}
```
并美化图片的样式:
```less
.thumb {
// 矩形黄金比例:0.618
width: 113px;
height: 70px;
background-color: #f8f8f8;
object-fit: cover;
}
.title-box {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.thumb-box {
display: flex;
justify-content: space-between;
}
```
### 3.7 实现 van-tabs 的吸顶效果
1. 在 `Home.vue` 组件中,为 `van-tabs` 组件添加 `sticky` 属性,即可开启纵向滚动吸顶效果。
2. 同时,为 `van-tabs` 组件添加 `offset-top="1.22667rem"` 属性,即可控制吸顶时距离顶部的位置。
### 3.8 实现上拉加载更多
> 基于 Vant 展示组件下的 List 列表组件,可以轻松实现上拉加载更多的效果。
1. 在 `ArticleList.vue` 组件中,使用 `van-list` 组件把文章对应的 `van-cell` 组件包裹起来,并提供如下的属性:
```xml
```
2. 在 data 中声明如下两个数据项,默认值都为 false:
```js
data() {
return {
// 是否正在加载数据
loading: false,
// 数据是否加载完毕
finished: false
}
}
```
3. 声明 `@load` 事件的处理函数如下:
```js
// 触发了上拉加载更多的操作
onLoad() {
this.getArticleList()
},
```
4. 修改 `getArticleList` 函数如下:
```js
// 获取文章列表数据
async getArticleList(isRefresh) {
const { data: res } = await getArticleList(this.id)
if (res.message === 'OK') {
// 旧数据后面,拼接新数据
this.articles = [...this.articles, ...res.data.results]
// 数据加载完之后,需要把 loading 设置为 false,方便下次发起 Ajax 请求
this.loading = false
// 判断所有数据是否加载完成
if (res.data.results.length === 0) {
this.finished = true
}
}
},
```
5. 注释掉 `created` 声明周期函数中,请求首屏数据的方法调用。因为 `van-list` 组件初次被渲染时,会立即触发一次 `@load` 事件:
```js
created() {
// this.getArticleList()
},
```
### 3.9 实现下拉刷新
> 基于 Vant 反馈组件下的 PullRefresh 下拉刷新,可以轻松实现下拉刷新的效果。
1. 在 `ArticleList.vue` 组件中,使用 `van-pull-refresh` 组件把 `van-list` 列表组件包裹起来,并定义如下两个属性:
```xml
```
2. 在 `data` 中定义如下的数据节点:
```js
data() {
return {
// 是否正在刷新列表数据
refreshing: false
}
},
```
3. 在 `ArticleList.vue` 组件中声明 `@refresh` 的事件处理函数如下:
```js
// 触发了下拉刷新
onRefresh() {
// true 表示当前以下拉刷新的方式,请求列表的数据
this.getArticleList(true)
}
```
4. 进一步改造 `getArticleList` 函数如下:
```js
// 获取文章列表数据
async getArticleList(isRefresh) {
const { data: res } = await getArticleList(this.id)
if (res.message === 'OK') {
if (isRefresh) {
// 当前为:下拉刷新
this.articles = [...res.data.results, ...this.articles] // 新数据在前,旧数据在后
// 数据加载完成之后,把 refreshing 设置为 false,方便下次发起 Ajax 请求
this.refreshing = false
} else {
// 当前为上拉加载更多
this.articles = [...this.articles, ...res.data.results] // 旧数据在前,新数据在后
// 数据加载完之后,需要把 loading 设置为 false,方便下次发起 Ajax 请求
this.loading = false
}
// 判断所有数据是否加载完成
if (res.data.results.length === 0) {
this.finished = true
}
}
},
```
## 4. 文章列表
### 4.1 格式化时间
> dayjs 中文官网:https://dayjs.fenxianglu.cn/
1. 安装 `dayjs` 包:
```bash
npm install dayjs --save
```
2. 在 `main.js` 入口文件中导入 `dayjs` 相关的模块:
```js
// 导入 dayjs 的核心模块
import dayjs from 'dayjs'
// 导入计算相对时间的插件
import relativeTime from 'dayjs/plugin/relativeTime'
// 导入本地化的语言包
import zh from 'dayjs/locale/zh-cn'
```
3. 配置插件和语言包:
```js
// 配置插件
dayjs.extend(relativeTime)
// 配置语言包
dayjs.locale(zh)
```
4. 定义格式化时间的全局过滤器:
```js
Vue.filter('dateFormat', dt => {
return dayjs().to(dt)
})
```
5. 在 `ArticleList.vue` 组件中,使用全局过滤器格式化时间:
```xml
{{item.aut_name}} {{item.comm_count}}评论 {{item.pubdate | dateFormat}}
```
### 4.2 文章列表图片的懒加载
> 基于 Vant 展示组件下的 Lazyload 懒加载指令,实现图片的懒加载效果
1. 在 `main.js` 入口文件中,按需导入 Lazyload 指令:
```js
import Vant, { Lazyload } from 'vant'
```
2. 在 `main.js` 中将 `Lazyload` 注册为全局可用的指令:
```js
Vue.use(Lazyload)
```
3. 在 `ArticleList.vue` 组件中,为 `
` 标签删除 `src` 属性,并应用 `v-lazy` 指令,指令的值是`要展示的图片地址`:
```xml
```
### 4.3 把文章信息抽离为单个组件
1. 在 `@/views/Home` 目录之下新建 `ArticleInfo.vue` 组件,并声明 DOM 结构:
```xml
{{article.title}}
{{article.aut_name}} {{article.comm_count}}评论 {{article.pubdate | dateFormat}}
```
2. 定义 `props` 属性:
```js
export default {
name: 'ArticleInfo',
props: {
// 要渲染的文章信息对象
article: {
type: Object,
required: true
}
}
}
```
3. 美化组件样式:
```less
.article-info-container {
border-top: 1px solid #f8f8f8;
}
.label-box {
display: flex;
justify-content: space-between;
align-items: center;
}
.thumb {
width: 113px;
height: 70px;
background-color: #f8f8f8;
object-fit: cover;
}
.title-box {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.thumb-box {
display: flex;
justify-content: space-between;
}
```
4. 在 `ArticleList.vue` 组件中导入、注册、并使用 `ArticleInfo.vue` 组件:
导入:
```js
import ArticleInfo from './ArticleInfo.vue'
```
注册:
```js
components: {
ArticleInfo
}
```
使用:
```xml
```
### 4.4 解决 js 中大数的问题
> js 中的安全数字:
>
> ```js
> > Number.MAX_SAFE_INTEGER
> > 9007199254740991
>
> > Number.isSafeInteger(1323819148127502300)
> > false
> ```
> id 的值已经超出了 JavaScript 中最大的 Number 数值,会导致 JS 无法正确的进行数字的处理和运算,例如:
>
> ```js
> > 1323819148127502300 + 1 === 1323819148127502300 + 2
> > true
> ```
解决方案:**json-bigint**(https://www.npmjs.com/package/json-bigint)
1. 安装依赖包:
```bash
npm i json-bigint -S
```
2. 在 `@/utils/request.js` 模块中导入 `json-bigint` 模块:
```js
import bigInt from 'json-bigint'
```
3. 声明处理大数问题的方法:
```js
// 处理大数问题
const transBigInt = data => {
try {
// 尝试着进行大数的处理
return bigInt.parse(data)
} catch {
// 大数处理失败时的后备方案
return JSON.parse(data)
}
}
```
4. 在调用 `axios.create()` 方法期间,指定 `transformResponse` 选项:
```js
const instance = axios.create({
// 请求根路径
baseURL: 'http://toutiao-app.itheima.net',
transformResponse: [transBigInt]
})
```
5. 在 `ArticleList.vue` 组件中使用**文章Id** 时,需要调用 `.toString()` 方法,把**大数对象**转为**字符串表示的数字**:
```xml
```
## 5. 反馈操作
### 5.1 展示反馈相关的动作面板
1. 在 `ActionInfo.vue` 组件中,为关闭按钮绑定点击事件处理函数:
```xml
```
2. 在 `methods` 节点中声明 `onCloseClick` 事件处理函数如下:
```js
// 点击了叉号按钮
onCloseClick() {
// 展示动作面板
this.show = true
},
```
3. 在 `ActionInfo.vue` 组件中声明**动作面板**的 DOM 结构:
```xml
```
4. 在 `data` 中声明布尔值 `show`,用来控制动作面板的展示与隐藏:
```js
data() {
return {
// 控制 ActionSheet 的显示与隐藏
show: false
}
}
```
### 5.2 渲染第一个面板的数据
1. 在 `ArticleInfo.vue` 组件的 `data` 中声明 `actions` 数组:
```js
data() {
return {
// 第一个面板的可选项列表
actions: [
{ name: '不感兴趣' },
{ name: '反馈垃圾内容' },
{ name: '拉黑作者' }
],
}
}
```
2. 在动作面板中,循环渲染第一个面板的列表项:
```xml
```
3. 在 `style` 节点中声明 `center-title` 美化每一项的样式:
```less
.center-title {
text-align: center;
}
```
4. 在 `methods` 中声明 `onCellClick` 如下:
```js
// 点击第一层的 item 项
onCellClick(info) {
if (info.name === '不感兴趣') {
console.log('不感兴趣')
this.show = false
} else if (info.name === '拉黑作者') {
console.log('拉黑作者')
this.show = false
} else if (info.name === '反馈垃圾内容') {
// TODO:展示第二个面板的数据
}
},
```
### 5.3 渲染第二个面板的结构
1. 在 `ArticleInfo.vue` 组件中,找到动作面板对应的结构,并声明第二个面板对应的 DOM 结构:
```xml
```
2. 按需控制两个面板的显示与隐藏:
- 在 data 中声明布尔值 `showFirstAction`,用来控制第一个面板的显示与隐藏(true:显示;false:隐藏):
```js
data() {
return {
// 是否展示第一个面板
showFirstAction: true,
}
}
```
- 使用 `v-if` 和 `v-else` 指令,控制两个面板的显示与隐藏:
```xml
```
- 点击**反馈垃圾内容**显示第二个面板:
```js
// 点击第一层的 item 项
onCellClick(info) {
if (info.name === '不感兴趣') {
console.log('不感兴趣')
this.show = false
} else if (info.name === '拉黑作者') {
console.log('拉黑作者')
this.show = false
} else if (info.name === '反馈垃圾内容') {
// 将布尔值改为 false,即可显示第二个面板
this.showFirstAction = false
}
},
```
- 点击**返回**按钮,显示第一个面板:
```jsx
```
3. 在动作面板关闭后,为了方便下次能够直接看到第一个面板,需要把 `showFirstAction` 的值重置为 `true`:
```js
// 监听 Sheet 关闭完成后的事件
onSheetClose() {
// 下次默认渲染第一个面板
this.showFirstAction = true
},
```
### 5.4 渲染第二个面板的数据
1. 在 `@/api/constant` 目录下,新建 `reports.js` 模块,用来定义第二个面板要用到的**常量数据**:
```js
// 以模块的方式导出 举报文章 时,后端接口约定的举报类型
const reports = [
{
value: 0,
label: '其它问题'
},
{
value: 1,
label: '标题夸张'
},
{
value: 2,
label: '低俗色情'
},
{
value: 3,
label: '错别字多'
},
{
value: 4,
label: '旧闻重复'
},
{
value: 6,
label: '内容不实'
},
{
value: 8,
label: '侵权'
},
{
value: 5,
label: '广告软文'
},
{
value: 7,
label: '涉嫌违法犯罪'
}
]
export default reports
```
2. 在 `ArticleInfo.vue` 组件中导入,并把常量数据挂载为 data 节点:
```js
import reports from '@/api/constant/reports'
export default {
name: 'ArticleInfo',
data() {
return {
// 第二个面板要用到的列表数据
reports
}
}
}
```
3. 在第二个面板中循环渲染列表数据,并为每一项绑定点击事件处理函数 `onFeedbackCellClick`:
```xml
```
4. 在 `methods` 节点下定义 `onFeedbackCellClick` 处理函数:
```js
// 点击了反馈面板中的按钮
onFeedbackCellClick(info) {
// 关闭动作面板
this.show = false
}
```
### 5.5 指定动作面板的挂载位置
1. 默认情况下,我们在 `ArticleInfo.vue` 组件中使用的 `ActionSheet` 组件,因此它会被渲染到 `List 列表组件` 内部
- 导致的问题:动作面板中的内容上下滑动时,会导致 `List 列表组件的` 下拉刷新
2. 解决方案:把 `ActionList` 组件,通过 `get-container` 属性,挂载到 `body` 元素下:
```xml
```
### 5.6 将文章设为不感兴趣
1. 在 `@/api/home.js` 模块中声明如下的 API 方法:
```js
// 将文章设置为不感兴趣
export const dislikeArticle = artId => {
return axios.post('/v1_0/article/dislikes', {
target: artId
})
}
```
2. 在 `ArticleInfo.vue` 组件中,按需导入 `dislikeArticle` 方法:
```js
import { dislikeArticle } from '@/api/home'
```
3. 在 `onCellClick` 方法中调用不感兴趣的 API 接口:
```js
if (info.name === '不感兴趣') {
// 调用接口,将此文章设置为不感兴趣
const { data: res } = await dislikeArticle(
this.article.art_id.toString()
)
// 接口调用成功
if (res.message === 'OK') {
// TODO:将此文章从列表中移除
console.log(this.article.art_id.toString())
}
// 关闭动作面板
this.show = false
}
```
### 5.7 从列表中移除不感兴趣的文章
1. 在 `ArticleInfo.vue` 组件中,通过 `this.$emit()` 触发自定义事件,把要删除的文章 Id 传递给父组件:
```js
// 接口调用成功
if (res.message === 'OK') {
// TODO:将此文章从列表中移除
this.$emit('remove-article', this.article.art_id.toString())
}
```
2. 在 `ArticleList.vue` 组件中,监听 `ArticleInfo.vue` 组件的 `remove-article` 自定义事件:
```xml
```
3. 在 `ArticleList.vue` 组件中,声明 `onArticleRemove` 函数如下:
```js
// 触发了删除文章的自定义事件
onArticleRemove(artId) {
this.articles = this.articles.filter(x => x.art_id.toString() !== artId)
}
```
### 5.8 实现举报文章的功能
1. 在 `@/api/home.js` 模块中声明如下的方法:
```js
// 举报文章
export const reportArticle = (artId, type) => {
return axios.post('/v1_0/article/reports', {
target: artId, // 文章的 Id
type // 举报的类型
})
}
```
2. 在 `ArticleInfo.vue` 中按需导入 `reportArticle` 方法:
```js
import { dislikeArticle, reportArticle } from '@/api/home'
```
3. 在点击动作面板中反馈选项的时候,调用接口反馈提交反馈信息:
```xml
```
4. 声明 `onFeedbackCellClick` 如下:
```js
// 点击了反馈面板中的按钮
async onFeedbackCellClick(info) {
// 发起请求,反馈文章的问题
const { data: res } = await reportArticle(
this.article.art_id.toString(), // 文章的 Id
info.value // 文章存在的问题编号
)
if (res.message === 'OK') {
// 反馈成功,从列表中移除对应的文章
this.$emit('remove-article', this.article.art_id.toString())
}
// 关闭动作面板
this.show = false
}
```
## 6. 频道管理
### 6.1 渲染频道管理的小图标
1. 在 `Home.vue` 组件中,渲染小图标的基本结构:
```xml
```
2. 美化标签页和小图标的样式:
```less
// 设置 tabs 容器的样式
/deep/ .van-tabs__wrap {
padding-right: 30px;
background-color: #fff;
}
// 设置小图标的样式
.moreChannels {
position: fixed;
top: 62px;
right: 8px;
z-index: 999;
}
```
### 6.2 渲染频道管理的 DOM 结构
1. 渲染基本的 DOM 结构:
```xml
```
2. 美化样式:
```less
.van-popup,
.popup-container {
background-color: transparent;
height: 100%;
width: 100%;
}
.popup-container {
display: flex;
flex-direction: column;
}
.pop-header {
height: 90px;
background-color: #007bff;
color: white;
text-align: center;
font-size: 14px;
position: relative;
.title {
width: 100%;
position: absolute;
bottom: 15px;
}
}
.pop-body {
flex: 1;
overflow: scroll;
padding: 8px;
background-color: white;
}
.my-channel-box,
.more-channel-box {
.channel-title {
display: flex;
justify-content: space-between;
font-size: 14px;
line-height: 28px;
padding: 0 6px;
}
}
.channel-item {
font-size: 12px;
text-align: center;
line-height: 36px;
background-color: #fafafa;
margin: 5px;
}
```
3. 在 data 中声明 `show` 来控制 popup 组件的显示与隐藏:
```js
data() {
return {
// 控制弹出层组件的显示与隐藏
show: false
}
}
```
4. 在点击 `+` 号小图标时展示弹出层:
```xml
```
### 6.3 动态计算更多频道的列表数据
> 后台没有提供直接获取**更多频道**的 API 接口,需要程序员动态地进行计算:
>
> 更多频道 = 所有频道 - 我的频道
>
> 此时,需要先获取到所有频道地列表数据,再使用计算属性动态地进行筛选即可
1. 请求所有频道的列表数据:
- 在 `@/api/home.js` 模块中封装 `getAllChannels` 方法:
```js
// 获取所有频道列表
export const getAllChannels = () => {
return axios.get('/v1_0/channels')
}
```
- 在 `Home.vue` 组件中按需导入 `getAllChannels` 方法:
```js
import { getChannelList, getAllChannels } from '@/api/home'
```
- 在 `data` 中声明 `allChannels` 的数组,用来存放所有的频道列表数据:
```js
data() {
return {
// 所有频道的列表数据
allChannels: []
}
}
```
- 在 `created` 声明周期函数中,预调用 `this.getAllChannels()` 方法:
```js
created() {
this.getChannels()
// 请求所有的频道列表数据
this.getAllChannels()
},
```
- 在 `methods` 中定义 `getAllChannels` 方法如下:
```js
// 获取所有频道列表的数据
async getAllChannels() {
const { data: res } = await getAllChannels()
if (res.message === 'OK') {
this.allChannels = res.data.channels
}
},
```
2. 在 `Home.vue` 组件中声明 `moreChannels` 的计算属性:
```js
computed: {
// 更多频道的数据
moreChannels() {
// 1. 对数组进行 filter 过滤,返回的是符合条件的新数组
return this.allChannels.filter(x => {
// 判断当前循环项,是否在 “我的频道” 列表之中
const index = this.channels.findIndex(y => y.id === x.id)
// 如果不在,则 return true,表示需要把这一项存储到返回的新数组之中
if (index === -1) return true
})
}
},
```
3. 修改更多频道列表的数据源:
```xml
{{item.name}}
```
### 6.4 渲染删除的徽标
1. 在 `Home.vue` 组件的 data 节点下声明布尔值 `isEdit`,来控制当前是否处于编辑状态:
```js
data() {
return {
// 频道数据是否处于编辑状态
isEdit: false
}
}
```
2. 为编辑按钮绑定点击事件,动态切换 `isEdit` 的值和渲染的文本内容:
```xml
{{isEdit ? '完成' : '编辑'}}
```
3. 在我的频道中,渲染删除的徽标,并使用 v-if 控制其显示与隐藏:
```xml
{{item.name}}
```
4. 美化删除徽标的样式:
```less
.cross-badge {
position: absolute;
right: -3px;
top: 0;
border: none;
}
```
### 6.5 实现删除频道的功能
> 注意:“推荐”这个频道不允许被删除!
1. 为频道的 Item 项绑定点击事件处理函数:
```xml
```
2. 在 `methods` 中声明点击事件处理函数:
```js
// 移除频道
removeChannel(id) {
// 如果当前不处于编辑状态,直接 return
if (!this.isEdit) return
// 如果当前要删除的 Id 等于 0,则不允许被删除
if (id === 0) return
// 对频道列表进行过滤
this.channels = this.channels.filter(x => x.id !== id)
}
```
3. 不为 “推荐” 频道展示 “删除” 的徽标:
```xml
```
4. 删除完毕之后,需要把最新的频道列表数据保存到后台数据库中:
- 在 `@/api/home.js` 中定义更新频道列表数据的 API 方法:
```js
// 更新我的频道列表
export const updateMyChannels = data => {
return axios.put('/v1_0/user/channels', data)
}
```
- 修改 `Home.vue` 组件的 methods 节点中声明更新频道数据的 `updateChannels` 方法:
```js
// 更新频道数据
async updateChannels() {
// 1. 处理要发送到服务器的 data 数据
const data = {
channels: this.channels
.filter(x => x.id !== 0) // 不需要把 “推荐” 发送给后端
.map((x, i) => ({
id: x.id, // 频道的 Id
seq: i + 1 // 频道的序号(给后端排序用的)
}))
}
// 2. 调用 API 接口,把频道数据存储到后端
const { data: res } = await updateMyChannels(data)
if (res.message === 'OK') {
// 3. 通过 notify 弹框提示用户更新成功
this.$notify({ type: 'success', message: '更新成功', duration: 1000 })
}
},
```
- 在 `removeChannel` 方法中调用 `updateChannels` 方法,更新数据库中的频道数据:
```js
// 移除频道
removeChannel(id) {
// 如果当前不处于编辑状态,直接 return
if (!this.isEdit) return
// 如果当前要删除的 Id 等于 0,则不允许被删除
if (id === 0) return
// 对频道列表进行过滤
this.channels = this.channels.filter(x => x.id !== id)
this.updateChannels()
},
```
### 6.6 实现新增频道的功能
1. 为**更多频道列表**中的频道 Item 项绑定点击事件处理函数:
```xml
{{item.name}}
```
2. 在 `methods` 中声明 `addChannel` 如下:
```js
// 新增频道
addChannel(channel) {
// 向前端数据中新增频道信息
this.channels.push(channel)
// 把前端数据保存到后台数据库中
this.updateChannels()
}
```
### 6.7 弹出层关闭时重置编辑的状态
1. 监听弹出层关闭完成后的事件:
```xml
```
2. 声明 `onPopupClosed` 方法如下:
```js
// 监听关闭弹出层且动画结束后触发的事件
onPopupClosed() {
this.isEdit = false
}
```
### 6.8 实现频道的点击联动效果
1. 在 `Home.vue` 组件中声明 `activeTabIndex` 索引值,用来记录激活的 tab 标签页的索引:
```js
data() {
return {
// 激活的 Tab 标签页索引,默认激活第一项
activeTabIndex: 0
}
}
```
2. 为 `van-tabs` 组件通过 `v-model` 指令双向绑定激活项的索引:
```xml
```
3. 在点击**我的频道 Item 项**时,把索引值传递到点击事件的处理函数中:
```xml
```
4. 改造 `removeChannel` 方法如下:
```js
// 移除频道
removeChannel(id, index) {
// 当前不处于编辑状态
if (!this.isEdit) {
// 为 tab 标签页的激活项索引重新赋值
this.activeTabIndex = index
// 关闭弹出层
this.show = false
return
}
// 如果当前要删除的 Id 等于 0,则不允许被删除
if (id === 0) return
// 对频道列表进行过滤
this.channels = this.channels.filter(x => x.id !== id)
this.updateChannels()
},
```
## 7. 文章搜索
### 7.1 基于路由渲染搜索组件
1. 在 `@/views/` 目录下新建 `Search` 文件夹,并创建 `Search.vue` 和 `SearchResult.vue` 组件。
2. 在路由模块中导入上述的两个组件,并声明对应的路由规则:
```js
// 导入搜索相关的组件
import Search from '@/views/Search/Search.vue'
import SearchResult from '@/views/Search/SearchResult.vue'
const routes = [
{ path: '/login', component: Login, name: 'login' },
{
path: '/',
component: Layout,
children: [
{ path: '', component: Home, name: 'home' },
{ path: '/user', component: User, name: 'user' }
]
},
// 搜索组件的路由规则
{ path: '/search', component: Search, name: 'search' },
// 搜索结果组件的路由规则
{ path: '/search/:kw', component: SearchResult, name: 'search-result' }
]
```
3. 在 `Home.vue` 组件中,为 NavBar 右侧的搜索图标绑定点击事件处理函数,通过编程式导航跳转到搜索组件页面:
```xml
```
### 7.2 渲染搜索页面的 Header 区域
1. 在 `Search.vue` 组件中声明如下的 DOM 结构:
```xml
```
2. 在 `data` 中声明 `kw` 关键词:
```js
data() {
return {
// 搜索关键词
kw: ''
}
}
```
3. 美化样式:
```less
.search-header {
height: 46px;
display: flex;
align-items: center;
background-color: #007bff;
overflow: hidden;
// 后退按钮
.goback {
padding-left: 14px;
}
// 搜索组件
.van-search {
flex: 1;
}
}
```
### 7.3 实现搜索框自动获得焦点
1. 为 `van-search` 组件添加 `ref` 引用:
```xml
```
2. 在 `mounted` 生命周期函数中,获取组件的引用,并通过 DOM 操作查找到 input 输入框,使其获得焦点:
```js
mounted() {
// 如果搜索组件的 ref 引用存在,则获取下面的 input 输入框,使其自动获得焦点
this.$refs.search && this.$refs.search.querySelector('input').focus()
}
```
### 7.4 实现输入框的防抖操作
1. 在 `data` 中声明 `timerId`,用来存储延时器的 Id:
```js
data() {
return {
// 延时器的 Id
timerId: null
}
}
```
2. 监听搜索组件的 `input` 输入事件:
```xml
```
3. 在 `methods` 中声明 `onInput` 处理函数:
```js
// 监听文本框的输入事件
onInput() {
// 清除延时器
clearTimeout(this.timerId)
// 判断是否输入了内容
if (this.kw.length === 0) return
// 创建延时器
this.timerId = setTimeout(() => {
console.log(this.kw)
}, 500)
}
```
### 7.5 渲染搜索建议列表数据
1. 在 `@/api/` 目录下新建 `search.js` 模块:
```js
import axios from '@/utils/request'
// 获取搜索关键词的列表
export const getSuggList = kw => {
return axios.get('/v1_0/suggestion', {
params: {
q: kw
}
})
}
```
2. 在 `Search.vue` 组件的 `data` 中声明搜索建议的数组:
```js
data() {
return {
// 建议列表
suggList: []
}
}
```
3. 在 `Search.vue` 组件中按需导入 `getSuggList` 方法:
```js
import { getKwList } from '@/api/search'
```
4. 定义 `getKeywordsList` 方法如下:
```js
// 请求搜索关键词的列表
async getKeywordsList() {
const { data: res } = await getSuggList(this.kw)
if (res.message === 'OK') {
this.suggList = res.data.options
}
}
```
5. 修改 `onInput` 方法:
```js
// 监听文本框的输入事件
onInput() {
// 清除延时器
clearTimeout(this.timerId)
// 判断是否输入了内容
if (this.kw.length === 0) {
this.suggList = []
return
}
// 创建延时器
this.timerId = setTimeout(() => {
// TODO:请求搜索建议的关键词
this.getKeywordsList()
}, 500)
},
```
6. 基于 `v-for` 指令循环渲染搜索建议列表:
```xml
```
7. 美化样式:
```less
.sugg-list {
.sugg-item {
padding: 0 15px;
border-bottom: 1px solid #f8f8f8;
font-size: 14px;
line-height: 50px;
// 实现省略号的三行代码
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
```
### 7.6 高亮搜索关键词
1. 把插值表达式改造为 `v-html` 指令:
```xml
```
2. 在 `methods` 中声明 `hightlightKeywords` 方法:
```js
// 高亮关键词
hightlightKeywords(arr) {
// 1. 创建正则实例,其中:
// 修饰符 i 表示执行对大小写不敏感的匹配
// 修饰符 g 表示全局匹配(查找所有匹配而非在找到第一个匹配后停止)
const reg = new RegExp(this.kw, 'ig')
// 2. 循环数组中的每一项,返回一个处理好的新数组
return arr.map(x => {
// 2.1 调用字符串的 .replace(正则, 替换的函数) 方法进行替换
const result = x.replace(reg, val => {
// 2.2 return 一个替换的结果
return `${val}`
})
// 3. 当前 map 循环需要 return 一个处理的结果
return result
})
}
```
3. 改造 `getKeywordsList` 方法:
```js
// 请求搜索关键词的列表
async getKeywordsList() {
const { data: res } = await getSuggList(this.kw)
if (res.message === 'OK') {
// this.suggList = res.data.options
// 调用 hightlightKeywords 方法,对关键字进行高亮处理
this.suggList = this.hightlightKeywords(res.data.options)
}
},
```
### 7.7 渲染搜索历史的 DOM 结构
1. 在 `data` 中定义假数据:
```js
data() {
return {
// 搜索历史
history: ['API', 'java', 'css', '前端', '后台接口', 'python']
}
}
```
2. 渲染搜索历史的 DOM 结构:
```xml
```
3. 美化样式:
```less
.search-icon {
font-size: 16px;
line-height: inherit;
}
.history-list {
padding: 0 10px;
.history-item {
display: inline-block;
font-size: 12px;
padding: 8px 14px;
background-color: #efefef;
margin: 10px 8px 0px 8px;
border-radius: 10px;
}
}
```
4. 根据搜索关键字 `kw` 的 length 是否为 0,再结合 `v-if` 和 `v-else` 指令,实现**搜索建议**和**搜索历史**的按需展示:
```xml
```
### 7.8 存储搜索关键词
> 1. 关键词去重
> 2. 最新的关键词插入到头部位置
> 3. 通过 Set 对象实现数组的去重
1. 改造 `getKeywordsList` 方法:
```js
// 请求搜索关键词的列表
async getKeywordsList() {
const { data: res } = await getSuggList(this.kw)
if (res.message === 'OK') {
this.suggList = this.hightlightKeywords(res.data.options)
// 1. 创建一个 Set 对象,用来去重
const set = new Set([this.kw, ...this.history])
// 2. 把去重之后的结果,转化成数组,存放到 history 数组中
this.history = Array.from(set)
}
},
```
2. 定义 watch 侦听器,监视数组的变化,持久化存储到 `localStorage` 中:
```js
watch: {
// 监视 history 数组的变化,持久化存储到本地
history(newVal) {
window.localStorage.setItem('searchHistory', JSON.stringify(newVal))
}
}
```
3. 在 `data` 中初始化 `history` 数组:
```js
data() {
return {
// 搜索历史
history: JSON.parse(window.localStorage.getItem('searchHistory') || '[]')
}
}
```
### 7.9 跳转到搜索结果页
1. 为搜索建议的 item 项绑定 `click` 点击事件处理函数:
```xml
```
2. 为历史列表的 item 项绑定 `click` 点击事件处理函数:
```xml
{{tag}}
```
3. 在 `Search.vue` 组件中声明如下的 methods 处理函数:
```js
// 点击搜索结果 Or 搜索历史,跳转到搜索结果页
gotoSearchResult(e) {
// 1. 获取到搜索关键字
const q = typeof e === 'string' ? e : e.target.innerText
// 2. 编程式导航 + 命名路由
this.$router.push({
// 2.1 路由名称
name: 'search-result',
// 2.2 路由参数
params: {
kw: q
}
})
}
```
4. 渲染搜索结果页面的基本 DOM 结构:
```vue
```
### 7.10 实现数据请求和上拉加载更多
1. 在 `@/api/search.js` 模块中封装 `getSearchResult` 方法:
```js
// 根据关键词查询搜索结果列表的数据
export const getSearchResult = (q, page) => {
return axios.get('/v1_0/search', {
params: {
q,
page
}
})
}
```
2. 在 `SearchResult.vue` 组件中,通过 `van-list` 组件实现上拉加载更多的效果:
```xml
```
3. 在 `data` 中声明对应的数据节点:
```js
data() {
return {
// 页码值
page: 1,
// 搜索的结果
searchResult: [],
// 是否正在请求数据
loading: false,
// 数据是否已经加载完毕
finished: false
}
}
```
4. 导入接口和 `ArticleInfo.vue` 组件:
```js
// 导入 API 接口
import { getSearchResult } from '@/api/search'
// 导入组件
import ArticleInfo from '@/views/Home/ArticleInfo.vue'
// 注册组件
components: {
ArticleInfo
}
```
5. 在 methods 中声明 `onLoad` 处理函数如下:
```js
// 加载数据
async onLoad() {
// 请求数据
const { data: res } = await getSearchResult(this.kw, this.page)
// 判断是否请求成功
if (res.message === 'OK') {
// 拼接数据
this.searchResult = [...this.searchResult, ...res.data.results]
// 重置加载状态
this.loading = false
// 页码值 + 1
this.page += 1
// 判断数据是否加载完毕
if (res.data.results.length === 0) {
this.finished = true
}
}
}
```
### 7.11 自定义关闭按钮的显示与隐藏
1. 在 `ArticleInfo.vue` 组件中,新增名为 `closable` 的 props 节点:
```js
props: {
// 是否展示关闭按钮
closable: {
type: Boolean,
// 默认值为 true,表示展示关闭按钮
default: true
}
}
```
2. 使用 `v-if` 动态控制关闭按钮的展示与隐藏:
```xml
```
3. 在 `SearchResult.vue` 组件中使用 `ArticleInfo.vue` 组件时,不展示关闭按钮:
```xml
```
## 8. 文章详情
### 8.1 通过路由渲染详情页组件
1. 在 `@/views/ArticleDetail` 目录下,新建 `ArticleDetail.vue` 组件:
```vue
文章详情
```
2. 在 `@/router/index.js` 路由模块中,声明详情页的路由规则:
```js
// 导入文章详情页
import ArticleDetail from '@/views/ArticleDetail/ArticleDetail.vue'
const routes = [
// 省略其它代码...
// 文章详情页的路由规则
{
path: '/article/:artId',
component: ArticleDetail,
name: 'article-detail',
props: true // 开启路由的 props 传参
}
]
```
3. 在文章列表页面和搜索结果页面,为 `` 组件绑定 `click` 事件处理函数,通过编程式导航跳转到文章详情页:
```jsx
methods: {
// 跳转到文章详情页
gotoDetail(artId) {
// 编程式导航 + 命名路由
this.$router.push({
name: 'article-detail',
params: { // 导航参数
artId
}
})
}
}
```
4. 在 `ArticleInfo.vue` 组件中,为最外层包裹性质的容器绑定 `click` 事件处理函数,通过 `$emit()` 触发自定义的 `click` 事件:
```xml
```
### 8.2 渲染文章详情页的基本结构
1. 声明如下的 DOM 结构:
```xml
小程序
已关注
关注
好好学习, 天天向上
End
已点赞
点赞
```
2. 美化样式:
```less
.article-container {
padding: 10px;
margin-top: 46px;
}
.art-title {
font-size: 16px;
font-weight: bold;
margin: 10px 0;
}
.art-content {
font-size: 12px;
line-height: 24px;
width: 100%;
overflow-x: scroll;
word-break: break-all;
}
.van-cell {
padding: 5px 0;
&::after {
display: none;
}
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: #f8f8f8;
margin-right: 5px;
border: none;
}
.like-box {
display: flex;
justify-content: center;
}
```
### 8.3 请求并渲染文章详情
1. 在 `@/api` 目录下新建 `article.js` 模块:
```js
import axios from '@/utils/request'
// 获取文章详情数据
export const getArticleInfo = artId => {
return axios.get(`/v1_0/articles/${artId}`)
}
```
2. 在 `ArticleDetail.vue` 组件中发起数据请求:
```js
// 1. 按需导入 API 接口
import { getArticleInfo } from '@/api/article'
export default {
name: 'ArticleDetail',
props: {
// 文章Id
artId: {
type: [String, Number],
required: true
}
},
data() {
return {
// 2. 定义文章详情的数据
article: {}
}
},
// 4. 页面初次加载时,请求详情数据
created() {
this.getArticleDetail()
},
methods: {
// 3. 声明获取文章数据的方法
async getArticleDetail() {
const { data: res } = await getArticleInfo(this.artId)
if (res.message === 'OK') {
console.log(res)
this.article = res.data
}
}
}
}
```
3. 渲染文章详情的数据:
```xml
{{article.title}}
已关注
关注
End
已点赞
点赞
```
### 8.4 实现关注和取消关注的功能
1. 在 `@/api/article.js` 模块中声明如下两个接口:
```js
// 关注用户
export const followUser = uid => {
return axios.post('/v1_0/user/followings', {
target: uid
})
}
// 取消关注用户
export const unfollowUser = uid => {
return axios.delete(`/v1_0/user/followings/${uid}`)
}
```
2. 在 `@/utils/request.js` 模块中,修改 `transBigInt` 函数如下:
```js
// 处理大数问题
const transBigInt = data => {
// 如果接口请求成功后没有响应任何数据,则直接返回空字符串
if (!data) return ''
try {
return bigInt.parse(data)
} catch {
return JSON.parse(data)
}
}
```
3. 在 `ArticleDetail.vue` 组件中,为关注按钮绑定点击事件处理函数:
```xml
已关注
关注
```
4. 按需导入相关的 API 方法,并定义 `setFollow` 方法如下:
```js
// 1. 按需导入 API 接口
import { getArticleInfo, followUser, unfollowUser } from '@/api/article'
// 修改关注状态
async setFollow(followState) {
if (followState) {
// 调用关注的接口
await followUser(this.article.aut_id.toString())
this.$toast.success('关注成功!')
} else {
// 调用取消关注的接口
await unfollowUser(this.article.aut_id.toString())
this.$toast.success('取消关注成功!')
}
// 修改文章的状态
this.article.is_followed = followState
}
```
### 8.5 实现点赞和取消点赞的功能
1. 在 `@/api/article.js` 模块中定义如下的方法:
```js
/**
* 点赞
* @param {*} id 文章Id
* @returns
*/
export const addLike = id => {
return axios.post('/v1_0/article/likings', {
target: id
})
}
/**
* 取消点赞
* @param {*} id 文章Id
* @returns
*/
export const delLike = id => {
return axios.delete(`/v1_0/article/likings/${id}`)
}
```
2. 在 `ArticleDetail.vue` 中,为点赞按钮绑定点击事件处理函数:
```xml
已点赞
点赞
```
3. 导入对应的 API 方法,并声明 `setLike` 方法如下:
```js
// 1. 按需导入 API 接口
import {
getArticleInfo,
followUser,
unfollowUser,
addLike,
delLike
} from '@/api/article'
// 修改点赞状态
async setLike(likeState) {
if (likeState) {
// 调用点赞的接口
await addLike(this.article.art_id.toString())
this.$toast.success('点赞成功!')
} else {
// 调用取消点赞的接口
await delLike(this.article.art_id.toString())
this.$toast.success('取消点赞成功!')
}
this.article.attitude = likeState ? 1 : -1
}
```
## 9. 文章评论
### 9.1 渲染评论组件的基本结构
1. 在 `@/views/ArticleDetail` 目录下新建 `ArtCmt.vue` 组件:
```vue
基于字体的图标集,可以通过 Icon 组件使用,也可以在其他组件中通过 icon 属性引用。基于字体的图标集,可以通过 Icon 组件使用,也可以在其他组件中通过 icon 属性引用。
```
2. 美化样式:
```less
.cmt-list {
padding: 10px;
.cmt-item {
padding: 15px 0;
+ .cmt-item {
border-top: 1px solid #f8f8f8;
}
.cmt-header {
display: flex;
align-items: center;
justify-content: space-between;
.cmt-header-left {
display: flex;
align-items: center;
.avatar {
width: 40px;
height: 40px;
background-color: #f8f8f8;
border-radius: 50%;
margin-right: 15px;
}
.uname {
font-size: 12px;
}
}
}
.cmt-body {
font-size: 14px;
line-height: 28px;
text-indent: 2em;
margin-top: 6px;
word-break: break-all;
}
.cmt-footer {
font-size: 12px;
color: gray;
margin-top: 10px;
}
}
}
```
3. 在 `ArticleDetal.vue` 组件中导入并使用 `ArtCmt.vue` 组件:
```jsx
// 导入组件
import ArtCmt from './ArtCmt.vue'
// 注册组件
components: {
ArtCmt
}
```
### 9.2 请求并渲染评论列表的数据
> 带有评论的文章链接地址:http://localhost:8080/#/article/1323570687952027648
1. 在 `@/api/article.js` 中定义获取评论数据的接口:
```js
// 获取文章的评论列表
export const getCmtList = (artId, offset) => {
return axios.get('/v1_0/comments', {
params: {
// a表示对文章的评论 ,c表示对评论的回复
type: 'a',
// 文章的 Id
source: artId,
// 获取评论数据的偏移量,值为评论id,表示从此id的数据向后取,不传表示从第一页开始读取数据
offset
}
})
}
```
2. 在 `ArtCmt.vue` 组件中声明文章 Id 的 props:
```js
props: {
// 文章的 Id
artId: {
type: [String, Number],
required: true
}
},
```
3. 在 `ArticleDetail.vue` 组件中使用 `` 组件时,把文章 Id 传递到评论组件中:
```xml
```
4. 在 `ArtCmt.vue` 组件的 data 中声明如下的数据:
```js
data() {
return {
// 偏移量
offset: null,
// 是否正在加载数据
loading: false,
// 数据是否加载完毕了
finished: false,
// 评论列表的数据
cmtlist: []
}
},
```
5. 将 `class="cmt-list"` 的 div 替换为 `` 组件:
```xml
```
6. 定义 `onLoad` 函数如下:
```js
methods: {
// 触发了加载数据的事件
async onLoad() {
const { data: res } = await getCmtList(this.artId, this.offset)
if (res.message === 'OK') {
// 为偏移量赋值
this.offset = res.data.last_id
// 为评论列表数据赋值
this.cmtlist = [...this.cmtlist, ...res.data.results]
// 重置 loading 和 finished
this.loading = false
if (res.data.results.length === 0) {
this.finished = true
}
}
}
}
```
### 9.3 实现评论的点赞和取消点赞的功能
1. 在 `@/api/article.js` 模块中定义如下两个 API 接口:
```js
// 评论点赞
export const addCmtLike = cmtId => {
return axios.post('/v1_0/comment/likings', {
target: cmtId
})
}
// 评论取消点赞
export const removeCmtLike = cmtId => {
return axios.delete(`/v1_0/comment/likings/${cmtId}`)
}
```
2. 为点赞和取消点赞的图片绑定点击事件处理函数:
```xml
```
3. 按需导入评论点赞的 API 方法:
```js
import { getCmtList, addCmtLike, removeCmtLike } from '@/api/article'
```
4. 在 `methods` 中声明 `setLike` 方法如下:
```js
// 切换评论的点赞与取消点赞
async setLike(likeState, cmt) {
// 获取评论的 Id
const cmtId = cmt.com_id.toString()
if (likeState) {
// 点赞
await addCmtLike(cmtId)
this.$toast.success('点赞成功!')
} else {
// 取消点赞
await removeCmtLike(cmtId)
this.$toast.success('取消点赞成功!')
}
// 切换当前评论的点赞状态
cmt.is_liking = likeState
}
```
### 9.4 渲染发布评论的基本结构
1. 渲染基本的 DOM 结构:
```xml
发布
```
2. 在 `data` 中定义 `cmtCount` 的值:
```js
data() {
return {
// 评论数量
cmtCount: 0
}
},
```
3. 在请求数据的方法中,为 `cmtCount` 赋值:
```js
// 触发了加载数据的事件
async onLoad() {
const { data: res } = await getCmtList(this.artId, this.offset)
console.log(res)
if (res.message === 'OK') {
this.offset = res.data.last_id
// 为评论数量赋值
this.cmtCount = res.data.total_count
this.cmtlist = [...this.cmtlist, ...res.data.results]
this.loading = false
if (res.data.results.length === 0) {
this.finished = true
}
}
},
```
4. 美化样式:
```less
// 外层容器
.art-cmt-container-1 {
padding-bottom: 46px;
}
.art-cmt-container-2 {
padding-bottom: 80px;
}
// 发布评论的盒子 - 1
.add-cmt-box {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
box-sizing: border-box;
background-color: white;
display: flex;
justify-content: space-between;
align-items: center;
height: 46px;
line-height: 46px;
padding-left: 10px;
.ipt-cmt-div {
flex: 1;
border: 1px solid #efefef;
border-radius: 15px;
height: 30px;
font-size: 12px;
line-height: 30px;
padding-left: 15px;
margin-left: 10px;
background-color: #f8f8f8;
}
.icon-box {
width: 40%;
display: flex;
justify-content: space-evenly;
line-height: 0;
}
}
.child {
width: 20px;
height: 20px;
background: #f2f3f5;
}
// 发布评论的盒子 - 2
.cmt-box {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 80px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
padding-left: 10px;
box-sizing: border-box;
background-color: white;
textarea {
flex: 1;
height: 66%;
border: 1px solid #efefef;
background-color: #f8f8f8;
resize: none;
border-radius: 6px;
padding: 5px;
}
.van-button {
height: 100%;
border: none;
}
}
```
### 9.5 实现 textarea 的按需展示
1. 在 `data` 中定义布尔值 `isShowCmtInput` 用来控制 textarea 的显示与隐藏:
```js
data() {
return {
// 是否展示评论的输入框
isShowCmtInput: false
}
}
```
2. 使用 v-if 和 v-else 指令控制两个区域的按需展示:
```xml
```
3. 点击发表评论的 div,展示 textarea 所在的盒子:
```jsx
发表评论
// 点击发表评论的 div, 展示 textarea 所在的盒子
showTextarea() {
this.isShowCmtInput = true
// 让文本框自动获得焦点
this.$nextTick(() => {
this.$refs.cmtIpt.focus()
})
},
```
4. 在 textarea 失去焦点时,重置布尔值为 false:
```jsx
// 文本框失去焦点
onCmtIptBlur() {
this.isShowCmtInput = false
}
```
5. 动态控制 `ArtCmt.vue` 组件外层容器底部的 padding 距离:
```xml
```
### 9.6 点击评论按钮平滑滚动到评论列表
1. 为评论的小图标绑定 click 点击事件处理函数:
```xml
```
2. 在 `methods` 中声明 `scrollToCmtList` 函数如下:
```js
// 实现滚动条平滑滚动的方法
scrollToCmtList() {
// 1.1 返回文档在垂直方向已滚动的像素值
const now = window.scrollY
// 1.2 目标位置(文章信息区域的高度)
let dist = document.querySelector('.article-container').offsetHeight
// 1.3 可滚动高度 = 整个文档的高度 - 浏览器窗口的视口(viewport)高度
const avalibleHeight = document.documentElement.scrollHeight - window.innerHeight
// 2.1 如果【目标高度】 大于 【可滚动的高度】
if (dist > avalibleHeight) {
// 2.2 就把目标高度,设置为可滚动的高度
dist = avalibleHeight
}
// 3. 动态计算出步长值
const step = (dist - now) / 10
setTimeout(() => {
// 4.2 如果当前的滚动的距离不大于 1px,则直接滚动到目标位置,并退出递归
if (Math.abs(step) <= 1) {
return window.scrollTo(0, dist)
}
// 4.1 每隔 10ms 执行一次滚动,并递归地进行下一次的滚动
window.scrollTo(0, now + step)
this.scrollToCmtList()
}, 10)
}
```
### 9.7 发布评论
1. 在 `@/api/article.js` 模块中定义如下的 API 方法:
```js
// 对文章发表评论
export const pubComment = (artId, content) => {
return axios.post('/v1_0/comments', {
target: artId, // 文章的 id
content // 评论的内容
})
}
```
2. 在 `ArtCmt.vue` 组件中按需导入 API 方法:
```js
import {
getCmtList,
addCmtLike,
removeCmtLike,
pubComment
} from '@/api/article'
```
3. 为输入框添加 `v-model.trim` 的双向数据绑定:
```jsx
data() {
return {
// 评论内容
cmt: ''
}
}
```
4. 动态控制**发布按钮**的禁用状态:
```xml
发布
```
5. 修改输入框的 `blur` 事件处理函数:
```jsx
// 文本框失去焦点
onCmtIptBlur() {
// 延迟隐藏的操作,否则无法触发按钮的 click 事件处理函数
setTimeout(() => {
this.isShowCmtInput = false
})
},
```
6. 为发布文章的按钮绑定点击事件处理函数:
```xml
发布
```
7. 声明 `pubCmt` 事件处理函数:
```js
// 发布新评论
async pubCmt() {
// 转存评论内容
const cmt = this.cmt
// 清空评论文本框
this.cmt = ''
// 隐藏输入区域
this.isShowCmtInput = false
// 发布评论
const { data: res } = await pubComment(this.artId, cmt)
if (res.message === 'OK') {
// 更新评论数据(头部插入)
this.cmtlist = [res.data.new_obj, ...this.cmtlist]
// 提示成功
this.$toast.success('评论成功!')
// 总评论数 +1
this.cmtCount++
}
}
```
## 10. 个人中心
### 10.1 渲染个人中心页面的基本结构
1. 声明个人中心页面的基本 DOM 结构:
```xml
用户名
申请认证
```
2. 美化样式:
```less
.user-container {
.user-card {
background-color: #007bff;
color: white;
padding-top: 20px;
.van-cell {
background: #007bff;
color: white;
&::after {
display: none;
}
.avatar {
width: 60px;
height: 60px;
background-color: #fff;
border-radius: 50%;
margin-right: 10px;
}
.username {
font-size: 14px;
font-weight: bold;
}
}
}
.user-data {
display: flex;
justify-content: space-evenly;
align-items: center;
font-size: 14px;
padding: 30px 0;
.user-data-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 33.33%;
}
}
}
```
### 10.2 获取并渲染用户的基本信息
1. 在 `@/api/user.js` 模块中,定义获取用户信息的 API 接口:
```js
// 获取用户的基本信息
export const getUserInfo = () => {
return axios.get('/v1_0/user')
}
```
2. 在 `User.vue` 组件中调用接口,获取用户的基本信息:
```js
// 按需导入 API 接口
import { getUserInfo } from '@/api/user'
export default {
name: 'User',
data() {
return {
// 用户的基本信息
user: {}
}
},
created() {
// 在组件初始化的时候,请求用户信息
this.initUserInfo()
},
methods: {
// 初始化用户的基本信息
async initUserInfo() {
const { data: res } = await getUserInfo()
if (res.message === 'OK') {
console.log(res)
this.user = res.data
}
}
}
}
```
3. 渲染用户的基本信息:
```xml
{{user.name}}
申请认证
{{user.art_count}}
动态
{{user.follow_count}}
关注
{{user.fans_count}}
粉丝
```
### 10.3 把用户信息存储到 vuex
> 为了方便在多个页面之间共享用户的信息,可以把用户的信息存储到 vuex 中
1. 在 vuex 的 state 节点下,声明 `user` 数据节点:
```js
// 初始的 state 数据
let initState = {
// 登录成功之后的 token 信息
tokenInfo: {},
// 用户的基本信息
user: {}
}
```
2. 在 `mutaions` 节点下新增 `updateUserInfo` 函数:
```js
// 更新用户的基本信息
updateUserInfo(state, payload) {
state.user = payload
this.commit('saveToStorage')
}
```
3. 在 `User.vue` 组件中按需导入 vuex 的辅助函数:
```js
import { mapState, mapMutations } from 'vuex'
```
4. 注释掉 `data` 节点下的 `user` 节点,并使用 `mapState` 把数据映射为 computed 计算属性:
```js
export default {
name: 'User',
data() {
return {
// 用户的信息对象
// user: {}
}
},
computed: {
// 映射需要的 state 数据
...mapState(['user'])
},
}
```
5. 使用 `mapMutations` 把方法映射为当前组件的 `methods` 处理函数,并进行调用:
```js
methods: {
// 1. 映射需要的 mutations 方法
...mapMutations(['updateUserInfo']),
// 初始化用户的基本信息
async initUserInfo() {
const { data: res } = await getUserInfo()
if (res.message === 'OK') {
console.log(res)
// 2. 注释掉下面这一行,不再把数据存储到当前组件的 data 中
// this.user = res.data
// 3. 把数据存储到 vuex 中
this.updateUserInfo(res.data)
}
}
}
```
6. 为了防止 `user` 为空对象时导致的数据渲染失败问题,可以为 `User.vue` 最外层的 div 元素添加 `v-if` 指令的判断:
```xml
```
### 10.4 实现退出登录的功能
1. 为退出登录的 `van-cell` 组件绑定 `click` 点击事件处理函数:
```xml
```
2. 在 vuex 的 mutations 中定义 `cleanState` 方法:
```js
// 清空 state 中的关键数据
cleanState(state) {
state.tokenInfo = {}
state.user = {}
// 清空本地存储
window.localStorage.clear()
}
```
3. 在 `User.vue` 组件中通过 `mapMutations` 辅助函数,把 `cleanState` 映射到当前组件中:
```js
methods: {
// 映射需要的 mutations 方法
...mapMutations(['updateUserInfo', 'cleanState']),
}
```
4. 在 `User.vue` 组件的 methods 中声明 `logout` 方法如下:
```js
// 退出登录
async logout() {
// 1. 询问用户是否退出登录
const confirmResult = await this.$dialog
.confirm({
title: '提示',
message: '确认退出登录吗?'
})
.catch(err => err)
// 2. 用户取消了退出登录
if (confirmResult === 'cancel') return
// 3. 执行退出登录的操作
// 3.1 调用 mutations 中的方法,清空 vuex 中的数据
this.cleanState()
// 3.2 跳转到登录页面
this.$router.push('/login')
}
```
### 10.5 跳转到编辑用户资料的页面
1. 在 `@/views/User` 目录下新建 `UserEdit.vue` 组件:
```vue
```
2. 在 `@/router/index.js` 路由模块中导入 `UserEdit.vue` 组件并声明对应的路由规则:
```js
// 导入编辑用户信息的组件
import UserEdit from '@/views/User/UserEdit.vue'
const routes = [
// 编辑用户信息的路由规则
{
path: '/user/edit',
component: UserEdit,
name: 'user-edit'
}
]
```
3. 在 `User.vue` 组件中,为**编辑资料**对应的 `van-cell` 组件添加 `to` 属性绑定:
```xml
```
### 10.6 渲染用户的基本资料
1. 在 `@/api/user.js` 模块中定义如下的 API 方法:
```js
// 获取用户的简介信息
export const getProfile = () => {
return axios.get('/v1_0/user/profile')
}
```
2. 在 `@/store/index.js` 模块中定义 `profile` 节点来存放用户的简介,并提供更新 `profile` 的 mutations 方法:
```js
// 初始的 state 数据
let initState = {
// 登录成功之后的 token 信息
tokenInfo: {},
// 用户的基本信息
user: {},
// 用户的简介
profile: {}
}
export default new Vuex.Store({
state: initState,
mutations: {
// 更新用户的简介信息
updateProfile(state, payload) {
state.profile = payload
this.commit('saveToStorage')
},
// 清空 state 中的关键数据
cleanState(state) {
state.tokenInfo = {}
state.user = {}
state.profile = {}
// 清空本地存储
window.localStorage.clear()
}
}
})
```
3. 在 `UserEdit.vue` 组件中请求用户的简介信息:
```js
// 1. 按需导入 API 和辅助函数
import { getProfile } from '@/api/user'
import { mapState, mapMutations } from 'vuex'
export default {
name: 'UserEdit',
// 2. 页面首次被加载时请求用户的简介
created() {
this.getUserProfile()
},
computed: {
// 2.1 映射数据
...mapState(['profile'])
},
methods: {
// 2.2 映射方法
...mapMutations(['updateProfile']),
// 3. 获取用户的简介信息
async getUserProfile() {
const { data: res } = await getProfile()
if (res.message === 'OK') {
console.log(res)
this.updateProfile(res.data)
}
}
}
}
```
4. 渲染用户的基本资料:
```xml
```
5. 美化样式:
```less
.user-edit-container {
padding-top: 46px;
.avatar {
width: 50px;
height: 50px;
}
}
```
### 10.7 展示修改名称的对话框
1. 在 `data` 中声明如下的数据:
```js
data() {
return {
// 是否展示修改用户名的对话框
isShowNameDialog: false,
// 名称
username: ''
}
}
```
2. 渲染对话框的基本 DOM 结构:
```xml
```
3. 为**名称**对应的 `van-cell` 绑定 click 事件处理函数:
```xml
```
4. 定义 `showNameDialog` 方法:
```js
// 展示修改名称的对话框
showNameDialog() {
// 显示修改之前的旧名称
this.username = this.profile.name
this.isShowNameDialog = true
// 让对话框中的文本框自动获得焦点
this.$nextTick(() => {
this.$refs.unameRef.focus()
})
},
```
5. 定义对话框 `before-close` 时对应的处理函数:
```js
// 用户名对话框 - 关闭之前
onNameDialogBeforeClose(action, done) {
// 1. 取消
if (action !== 'confirm') {
done()
return
}
// 2. 确认
if (this.username.length === 0 || this.username.length > 7) {
// 长度不合法
this.$notify({
type: 'warning',
message: '名称的长度为1-7个字符',
duration: 2000
})
done(false)
return
}
// 3. TODO:发起请求修改名称
done(false)
}
```
### 10.8 发起请求修改名称
1. 在 `@/api/user.js` 模块下新增 API:
```js
// 修改姓名,生日,性别都使用此接口,修改传参即可
export const updateProfile = data => {
return axios.patch('/v1_0/user/profile', data)
}
```
2. 修改 `onNameDialogBeforeClose` 方法,预调用 `updateUserProfile` 方法:
```js
import { getProfile, updateProfile } from '@/api/user'
// 用户名对话框 - 关闭之前
onNameDialogBeforeClose(action, done) {
// 省略其它代码...
// 3. TODO:发起请求修改名称
this.updateUserProfile(
{ name: this.username },
'名称被占用,请更换后重试!',
done
)
}
```
3. 定义 `updateUserProfile` 方法如下:
```js
// 更新用户简介的方法
async updateUserProfile(data, errMsg, done) {
try {
// 3.1 发起请求,更新数据库
const { data: res } = await updateProfile(data)
if (res.message === 'OK') {
// 重新请求用户的数据
this.getUserProfile()
// 提示用户成功
this.$toast.success('修改成功!')
// 关闭对话框
done && done()
}
} catch {
// 3.2 如果网络请求失败,则对用户进行友好提示
this.$notify({
type: 'warning',
message: errMsg,
duration: 2000
})
done && done(false)
}
}
```
### 10.9 修改生日
1. 在 `UserEdit.vue` 组件的 data 中定义如下的数据:
```js
data() {
// 是否展示选择出生日期的 ActionSheet
isShowBirth: false,
// 最小的可选的日期
minDate: new Date(1900, 0, 1),
// 最大的可选日期
maxDate: new Date(2030, 10, 1),
// 当前日期
currentDate: new Date()
}
```
2. 基于 `van-action-sheet` 和 `van-datetime-picker` 渲染修改生日的 DOM 结构:
```xml
```
3. 点击**生日**的 `van-cell` 展示日期选择控件:
```xml
```
4. 定义 `onPickerCancel` 和 `onPickerConfirm` 方法如下:
```js
// 日期控件 - 取消
onPickerCancel() {
this.isShowBirth = false
},
// 日期控件 - 确认
onPickerConfirm(value) {
// 1. 隐藏选择日期的 ActionSheet
this.isShowBirth = false
// 2. 格式化时间
const dt = new Date(value)
const y = dt.getFullYear()
const m = (dt.getMonth() + 1).toString().padStart(2, '0')
const d = dt.getDate().toString().padStart(2, '0')
const dtStr = `${y}-${m}-${d}`
// 3. 更新出生日期
this.updateUserProfile({ birthday: dtStr }, '更新生日失败,请稍后再试!')
}
```
### 10.10 更新用户头像
> 借助于 file 文件选择框,实现更新用户头像的功能
1. 在 `@/api/user.js` 模块中定义如下的 API 接口:
```js
// 更新用户的头像
export const updateUserPhoto = fd => {
return axios.patch('/v1_0/user/photo', fd)
}
```
2. 在 `UserEdit.vue` 组件中按需导入 `updateUserPhoto` 的 API 方法:
```js
import { getProfile, updateProfile, updateUserPhoto } from '@/api/user'
```
3. 在**头像**对应的 `van-cell` 组件中,添加隐藏的 `input:file` 文件选择框:
```xml
```
4. 定义 `choosePhoto` 事件处理函数如下:
```js
// 选择头像的照片
choosePhoto() {
// 模拟点击操作
this.$refs.iptFile.click()
},
```
5. 定义 `onFileChange` 事件处理函数如下:
```js
// 文件选择框的选中项发生了变化
async onFileChange(e) {
// 1. 获取选中的文件列表
const files = e.target.files
// 2. 判断选中的个数是否为 0
if (files.length === 0) return
// 3.1 创建 FormData 实例
const fd = new FormData()
// 3.2 添加用户的头像
fd.append('photo', files[0])
// 4.1 调用接口
const { data: res } = await updateUserPhoto(fd)
if (res.message === 'OK') {
// 4.2 重新拉取数据
this.getUserProfile()
}
}
```
## 11. 小思同学
### 11.0 认识 websocket
#### 11.0.1 什么是 websocket
和 http 协议类似,websocket 也是是一个网络通信协议,是用来满足前后端数据通信的。
#### 11.0.2 websocket 相比于 HTTP 的优势
HTTP 协议:客户端与服务器建立通信连接之后,服务器端只能**被动地**响应客户端的请求,无法主动给客户端发送消息。
websocket 协议:客户端与服务器建立通信连接之后,服务器端可以主动给客户端推送消息了!!!
#### 11.0.3 websocket 主要的应用场景
需要服务端主动向客户端发送数据的场景,比如我们现在要做的**智能聊天**
#### 11.0.4 HTTP 协议和 websocket 协议对比图

### 11.1 渲染小思同学的页面
1. 在 `@/views/Chat` 目录下新建 `Chat.vue` 组件:
```vue
```
2. 在 `@/router/index.js` 路由模块中,导入组件并声明小思聊天的路由规则:
```js
// 导入小思同学的组件页面
import Chat from '@/views/Chat/Chat.vue'
const routes = [
// 小思聊天的路由规则
{
path: '/chat',
component: Chat,
name: 'chat'
}
]
```
3. 在 `@/views/User/User.vue` 组件中,为**小思同学**对应的 `van-cell` 组件添加 `to` 属性:
```xml
```
### 11.2 动态渲染聊天消息
1. 在 data 中声明 `list` 数组,用来存放机器人和用户的聊天消息内容:
```js
data() {
return {
// 用户填写的内容
word: '',
// 所有的聊天消息
list: [
// 1. 只根据 name 属性,即可判断出这个消息应该渲染到左侧还是右侧
{ name: 'xs', msg: 'hi,你好!我是小思' },
{ name: 'me', msg: '我是编程小王子' }
]
}
},
```
2. 动态渲染聊天消息:
```xml
```
3. 动态渲染用户的头像:
```jsx
// 1. 按需导入辅助函数
import { mapState } from 'vuex'
computed: {
// 2. 把用户的信息,映射为当前组件的计算属性
...mapState(['profile'])
}
```
4. 用户点击按钮,把消息存储到 `list` 数组中:
```js
methods: {
send() {
// 1. 判断内容是否为空
if (!this.word) return
// 2. 添加聊天消息到 list 列表中
this.list.push({
name: 'me',
msg: this.word
})
// 3. 清空文本框的内容
this.word = ''
}
},
```
### 11.3 配置 websocket 客户端
1. 安装 websocket 客户端相关的包:
```bash
npm i socket.io-client@4.0.0 -S
# 如果 npm 无法成功安装 socket.io-client,可以尝试用 yarn 来装包
```
参考官方文档进行使用:https://socket.io/docs/v4/client-initialization/
2. 在 `Chat.vue` 组件中,导入 `socket.io-client` 模块:
```js
// 1.1 导入 socket.io-client 包
import { io } from 'socket.io-client'
// 1.2 定义变量,存储 websocket 实例
let socket = null
```
3. 在 `Chat.vue` 组件的 created 生命周期函数中,创建 websocket 实例对象:
```js
created() {
// 2. 创建客户端 websocket 的实例
socket = io('ws://www.liulongbin.top:9999')
}
```
4. 在 `Chat.vue` 组件的 beforeDestroy 生命周期函数中,关闭 websocket 连接并销毁 websocket 实例对象:
```js
// 组件被销毁之前,清空 sock 对象
beforeDestroy() {
// 关闭连接
socket.close()
// 销毁 websocket 实例对象
socket = null
},
```
5. 在 `created` 生命周期函数中,监听 websocket 实例对象的 `connect`、`message`、`disconnect` 事件:
```js
created() {
// 创建客户端 websocket 的实例
socket = io('ws://www.liulongbin.top:9999')
// 建立连接的事件
socket.on('connect', () => {
console.log('connect')
})
// 接收到消息的事件
socket.on('message', msg => {
// 把服务器发送过来的消息,存储到 list 数组中
this.list.push({
name: 'xs',
msg
})
})
// 关闭的事件
socket.on('disconnect', () => {
console.log('disconnect')
})
},
```
6. 在 `message` 事件中,把服务器发送到客户端的消息,存储到 `list` 数组中:
```js
// 接收到消息的事件
socket.on('message', msg => {
// 把服务器发送过来的消息,存储到 list 数组中
this.list.push({
name: 'xs',
msg
})
})
```
7. 客户端调用 `socket.emit('send', 消息内容)` 方法把消息发送给 websocket 服务器:
```js
// 向服务端发送消息
send() {
// 判断内容是否为空
if (!this.word) return
// 添加聊天消息到 list 列表中
this.list.push({
name: 'me',
msg: this.word
})
// 把消息发送给 websocket 服务器
socket.emit('send', this.word)
// 清空文本框的内容
this.word = ''
}
```
### 11.4 自动滚动到底部
> https://developer.mozilla.org/zh-CN/docs/web/api/element/scrollintoview
1. 在 `methods` 中声明 `scrollToBottom` 方法:
```js
// 滚动到页面底部
scrollToBottom() {
// 获取到所有的聊天 Item 项
const chatItem = document.querySelectorAll('.chat-item')
// 获取到最后一项对应的 DOM 元素
const lastItem = chatItem[chatItem.length - 1]
// 调用 scrollIntoView() 方法,显示这个元素
lastItem.scrollIntoView({
behavior: 'smooth',
block: 'end',
inline: 'nearest'
})
}
```
2. 在 `Chat.vue` 组件中定义 `watch` 侦听器,监视 `list` 数组的变化,从而自动滚动到页面底部:
```js
watch: {
list() {
// 监视到 list 数据变化后,等下次 DOM 更新完毕,再执行滚动到底部的操作
this.$nextTick(() => {
this.scrollToBottom()
})
}
},
```
## 12. 页面权限控制
### 12.1 未登录不允许访问 User 页面
1. 在 `@/router/index.js` 模块中声明全局前置导航守卫:
```js
// 导入 store 模块,方便拿到 store 中的数据
import store from '@/store/index'
// 导航守卫
router.beforeEach((to, from, next) => {
const tokenInfo = store.state.tokenInfo // {token, refresh_token}
// 访问的是有权限的页面
if (to.path === '/user') {
if (!tokenInfo.token) {
// token 的值不存在,强制跳转到登录页
next('/login?pre=' + to.fullPath)
} else {
// token 的值存在,放行
next()
}
} else {
// 访问的是普通页面
next()
}
})
```
2. 在 `Login.vue` 组件中,修改 `onSubmit` 方法如下:
```js
async onSubmit() {
const { data: res } = await login(this.formLogin)
if (res.message === 'OK') {
this.updateTokenInfo(res.data)
// 1. 判断是否携带了 pre 参数
if (this.$route.query.pre) {
// 1.1 如果有,则跳转到指定页面
this.$router.push(this.$route.query.pre)
} else {
// 1.2 如果没有,则跳转到 / 主页
this.$router.push('/')
}
}
}
```
### 12.2 登录状态下不允许访问登录页
在路由导航守卫中添加如下的判断条件:
```js
else if (to.path === '/login') {
if (!tokenInfo.token) {
next()
} else {
next(false)
}
}
```
## 13. Token 过期处理
> 两种主流方案:
>
> 1. 只要发现 Token 过期,则强制用户跳转到登录页,并清空本地和 Store 中的关键数据!
> 2. 如果发现 Token 过期,则自动基于 refresh_token 无感知地请求一个新 Token 回来,在替换掉旧 Token 的同时,继续上次未完成的请求!
### 13.1 方案1:强制跳转到登录页
1. 在 `@/utils/request.js` 模块中,导入 Store 和 Router 模块:
```js
import store from '@/store/index'
import router from '@/router/index'
```
2. 在 axios 的 `instance` 实例上声明响应拦截器,如果身份认证失败,则强制用户跳转到登录页:
```js
// 响应拦截器
instance.interceptors.response.use(
response => {
// 响应成功
return response
},
error => {
// 响应失败时,处理未授权的情况:
// 1. 判断是否为未授权(Token 过期)
if (error.response && error.response.status === 401) {
// 2. 清空 Store 中的关键数据
store.commit('cleanState')
// 3. 跳转到登录页
router.push('/login?pre=' + router.currentRoute.fullPath)
}
return Promise.reject(error)
}
)
```
### 13.2 方案2:无感知刷新 Token
1. 在 `@/utils/request.js` 模块中,导入 Store 和 Router 模块:
```js
import store from '@/store/index'
import router from '@/router/index'
```
2. 在 axios 的 `instance` 实例上声明响应拦截器,如果身份认证失败,则根据 `refresh_token` 重新请求一个有效的新 Token 回来:
```js
// 响应拦截器
instance.interceptors.response.use(
response => {
return response
},
async error => {
// 1. 从 vuex 中获取 token 对象
const tokenInfo = store.state.tokenInfo
// 2. 判断是否为未授权(Token 过期)
if (error.response && error.response.status === 401 && tokenInfo.token) {
try {
// 3.1 TODO: 发起请求,根据 refresh_token 重新请求一个有效的新 token
// 3.2 TODO: 更新 Store 中的 Token
// 3.3 基于上次未完成的配置,重新发起请求
return instance(error.config)
} catch {
// 4. 证明 refresh_token 也失效了:
// 4.1 则清空 Store 中的关键数据
store.commit('cleanState')
// 4.2 并强制跳转到登录页
router.push({
path: '/login?pre=' + router.currentRoute.fullPath
})
}
}
return Promise.reject(error)
}
)
```
3. 发起请求,根据 `refresh_token` 重新请求一个有效的新 token:
```js
const { data: res } = await axios({
method: 'PUT',
url: 'http://toutiao-app.itheima.net/v1_0/authorizations',
headers: {
Authorization: `Bearer ${tokenInfo.refresh_token}`
}
})
```
4. 更新 Store 中的 Token:
```js
store.commit('updateTokenInfo', {
refresh_token: tokenInfo.refresh_token,
token: res.data.token
})
```
## 14. 项目优化
### 14.1 保持组件的状态
> 结合 vue 内置的 keep-alive 组件,可以实现组件的状态保持。
>
> 官方文档地址:https://cn.vuejs.org/v2/api/#keep-alive
#### 14.1.1 实现 Layout 组件的状态保持
1. 在 `App.vue` 组件中,在 `` 路由占位符之外包裹一层 `` 组件,从而实现 Layout 组件的状态保持:
```xml
```
2. 通过步骤 1,的确实现了 Layout 组件的状态保持。但是随之而来的:详情页也被缓存了,导致了文章数据不会动态刷新的问题。
3. 可以通过 `` 组件提供的 `include` 属性,来有条件的缓存组件:
```xml
```
#### 14.1.2 实现 Home 组件的状态保持
> 点击 tabBar 实现 Home 页面和 User 页面切换展示的时候,发现 Home 组件的状态每次都会被刷新
1. 在 `Layout.vue` 组件中,在 `` 路由占位符之外包裹一层 `` 组件,从而实现 Home 组件的状态保持:
```xml
首页
我的
```
2. 通过步骤 1,的确实现了 Home 组件的状态保持。但是随之而来的,`User.vue` 组件也被缓存了,导致修改用户头像后,头像不刷新的问题。
3. 可以在被缓存的 `User.vue` 组件中,声明 `activated` 和 `deactivated` 声明周期函数,来监听组件**被激活**和**被缓存**的状态变化:
```js
created() {
// 把下面这一行注释掉,因为 activated 在组件首次加载时也会调用一次
// this.initUserInfo()
},
// 被激活了
activated() {
// 只要组件被激活了,就重新初始化用户的信息
this.initUserInfo()
},
// 被缓存了
deactivated() {
console.log('被缓存了')
},
```
#### 14.1.3 实现 SearchResult 组件的状态保持
1. 在 `App.vue` 组件中,为 `` 组件添加要缓存的组件名称:
```xml
```
2. 在 `SearchResult.vue` 组件的 data 中声明 `preKw` 节点,用来缓存上次的搜索关键词:
```js
data() {
return {
// 缓存的搜索关键词
preKw: ''
}
}
```
3. 在 `SearchResult.vue` 组件中定义 `activated` 和 `deactivated` 声明周期如下:
```js
// 组件被激活
activated() {
// 如果上一次的 kw 不为空,且这次的 kw 和上次缓存的 kw 值不同,则需要重新请求列表数据
if (this.preKw !== '' && this.kw !== this.preKw) {
// 1. 重置数据
this.page = 1
this.searchResult = []
this.loading = false
this.finished = false
// 2. 重新请求列表数据
this.onLoad()
}
},
// 组件被缓存
deactivated() {
// 组件被缓存时,将搜索关键词保存到 data 中
this.preKw = this.kw
},
```
### 14.2 详情页代码高亮
> 基于 highlight.js 美化详情页的代码片段
1. 运行如下的命令,在项目中安装 `highlight.js`:
```bash
npm i highlight.js@10.6.0 -S
```
2. 在 `index.html` 页面的 `` 标签中引入 `highlight.js` 的样式表:
```xml
```
3. 在 `ArticleDetail.vue` 组件中导入 `highlight.js` 模块:
```js
// 导入 highlight.js 模块
import hljs from 'highlight.js'
```
4. 在 `ArticleDetail.vue` 组件的 `updated` 声明周期函数中,对位置内容进行高亮处理:
```js
// 1. 当组件的 DOM 更新完毕之后
updated() {
// 2. 判断是否有文章的内容
if (this.article.content) {
// 3. 对文章的内容进行高亮处理
hljs.highlightAll()
}
},
```
### 14.3 添加文章加载的 loading 效果
1. 在 `ArticleDetail.vue` 组件中,在**文章信息区域**和**文章评论组件**之外包裹一个 div 元素:
```xml
```
2. 添加 `loading` 样式:
```less
.loading {
margin-top: 50px;
}
```
## 15. 打包发布
### 15.1 初步打包发布
1. 在终端下运行如下的打包命令:
```bash
npm run build
```
2. 基于 `Node + Express` 手写一个 web 服务器,对外托管 web 项目:
```js
// app.js
// 导入 express 模块
const express = require('express')
// 创建 express 的服务器实例
const app = express()
// 1. 将 dist 目录托管为静态资源服务器
app.use(express.static('./dist'))
// 调用 app.listen 方法,指定端口号并启动web服务器
app.listen(3001, function () {
console.log('Express server running at http://127.0.0.1:3001')
})
```
### 15.2 优化网络传输时的文件体积
> 基于 Express 的 express-compression 中间件,可以在服务器端对文件进行压缩。
>
> 压缩后文件网络传输的体积会大幅变小,客户端在接收到压缩的文件后会自动进行解压。
1. 在终端下运行如下的命令:
```bash
npm i express-compression -S
```
2. 在 `app.js` 中导入并使用网络传输压缩的中间件:
```js
// 导入 express 模块
const express = require('express')
// 创建 express 的服务器实例
const app = express()
// 2. 安装并配置网络传输压缩的中间件
// 注意:必须在托管静态资源配置此中间件
const compression = require('express-compression')
app.use(compression())
// 1. 将 dist 目录托管为静态资源服务器
app.use(express.static('./dist'))
// 调用 app.listen 方法,指定端口号并启动web服务器
app.listen(3001, function () {
console.log('Express server running at http://127.0.0.1:3001')
})
```
3. 最终的效果截图:

### 15.3 移除代码中所有的 console
1. 运行如下的命令,安装 Babel 插件:
```bash
npm install babel-plugin-transform-remove-console --save-dev
```
2. 在 `babel.config.js` 中新增如下的 `plugins` 数组节点:
```js
module.exports = {
presets: ['@vue/cli-plugin-babel/preset'],
// 配置移除 console 的插件
plugins: ['transform-remove-console']
}
```
3. 重新运行打包的命令:
```bash
npm run build
```
### 15.4 生成打包报告
1. 打开 `package.json` 配置文件,为 `scripts` 节点下的 `build` 命令添加 `--report` 参数:
```json
{
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build --report",
"lint": "vue-cli-service lint"
}
}
```
2. 重新运行打包的命令:
```bash
npm run build
```
3. 打包完成后,发现在 `dist` 目录下多了一个名为 `report.html` 的文件。在浏览器中打开此文件,会看到详细的打包报告。
### 15.5 基于 externals 优化打包的体积
> 未配置 `externals` 之前,项目中使用 `import` 导入的第三方模块,在最终打包时,会被打包合并到一个 js 文件中。最后导致项目体积过大的问题。
> 配置了 `externals` 之后,webpack 在进行打包时,会把 `externals` 节点下声明的第三方包排除在外。因此最终打包生成的 js 文件中,不会包含 `externals` 节点下的包。这样就优化了打包后项目的体积。
1. 在项目根目录下找到 `vue.config.js` 配置文件,在里面新增 `configureWebpack` 节点如下:
```js
module.exports = {
// 省略其它代码...
// 增强 vue-cli 的 webpack 配置项
configureWebpack: {
// 打包优化
externals: {
// import 时的包名称: window 全局的成员名称
'highlight.js': 'hljs'
}
}
}
```
2. 打开 `public` 目录下的 `index.html` 文件,在 `body` 结束标签之前,新增如下的资源引用:
```html
<%= htmlWebpackPlugin.options.title %>
```
3. 重新运行打包发布的命令,对比配置 `externals` 前后文件的体积变化。
### 15.6 完整的 externals 配置项
1. 在 `vue.config.js` 配置文件中,找到 `configureWebpack` 下的 `externals`,添加如下的配置项:
```js
// 增强 vue-cli 的 webpack 配置项
configureWebpack: {
// 打包优化
externals: {
'highlight.js': 'hljs',
vue: 'Vue',
'vue-router': 'VueRouter',
vuex: 'Vuex',
axios: 'axios',
vant: 'vant',
'socket.io-client': 'io',
dayjs: 'dayjs',
'bignumber.js': 'BigNumber'
}
}
```
2. 在 `/public/index.html` 文件的 `` 结束标签之前,添加如下的样式引用:
```html
```
3. 在 `/public/index.html` 文件的 `` 结束标签之前,添加如下的 js 引用:
```html
```
### 15.7 只在生产阶段对项目进行打包优化
> 1. 在 development 开发阶段的需求是:急速的打包生成体验、不需要移除 console、也不需要对打包的体积进行优化
>
> 2. 在 production 生产阶段的需求是:移除 console、基于 externals 对打包的体积进行优化
>
>
>
> 3. 问题:如何判断当前打包期间的运行模式?
>
> // 获取当前编译的环境 development 或 production
>
> const env = process.env.NODE_ENV
1. 在 `babel.config.js` 配置文件中,先获取到当前打包的模式,再决定是否使用`移除 console` 的 Babel 插件:
```js
// 1. 获取当前编译的环境 development 或 production
const env = process.env.NODE_ENV
// 2. 当前是否处于发布模式
const isProd = env === 'production' ? true : false
// 3.1 插件的数组
const plugins = []
// 3.2 判断是否处于发布模式
if (isProd) {
plugins.push('transform-remove-console')
}
module.exports = {
presets: ['@vue/cli-plugin-babel/preset'],
// 4. 动态的向外导出插件的数组
plugins
}
```
2. 在 `vue.config.js` 配置文件中,先获取到当前打包的模式,再决定是否开启 `externals` 特性:
```js
// 1. 获取当前编译的环境 development 或 production
const env = process.env.NODE_ENV
// 2. 当前是否处于发布模式
const isProd = env === 'production' ? true : false
// 3. 自定义的 webpack 配置项
const customWebpackConfig = {
externals: {
'highlight.js': 'hljs',
vue: 'Vue',
'vue-router': 'VueRouter',
vuex: 'Vuex',
axios: 'axios',
vant: 'vant',
'socket.io-client': 'io',
dayjs: 'dayjs',
'bignumber.js': 'BigNumber'
}
}
module.exports = {
// 省略其它配置节点...
// 4. 增强 vue-cli 的 webpack 配置项
configureWebpack: isProd ? customWebpackConfig : {},
}
```
### 15.8 在 index.html 中按需引入 css 和 js
> 由于 externals 节点是按需生效的。为了与之匹配,index.html 页面中的 css 样式和 js 脚本也要按需进行引入。
>
> 问题:在 index.html 页面中,如何判断当前的打包模式呢?
>
> 答案:可以对 html-webpack-plugin 插件进行自定义配置,从而支持在 index.html 页面中获取到当前的打包模式。
1. 在 `vue.config.js` 中新增 `chainWebpack` 节点,可以**对 webpack 已有的配置进行修改**:
```js
module.exports = {
// 省略其它配置节点...
// 对 webpack 已有的配置进行修改
chainWebpack: config => { /* 在这个函数中对 webpack 已有的配置进行修改 */ }
}
```
具体代码示例如下:
```js
module.exports = {
// 省略其它配置节点...
// 对 webpack 已有的配置进行修改
chainWebpack: config => {
config.plugin('html').tap(args => {
// 打印 html 插件的参数项
// console.log(args)
// 当前是否处于发布模式
args[0].isProd = isProd
return args
})
}
}
```
2. 在 `index.html` 中,根据 `html-webpack-plugin` 插件提供的 `<% %>` 模板语法,按需渲染 link 和 script 标签:
```html
<% if (htmlWebpackPlugin.options.isProd) { %>
<% } %>
<% if (htmlWebpackPlugin.options.isProd) { %>
<% } %>
```