# 极客园M端
# 第一章:项目起步
# 01-项目介绍
目标:了解项目背景,了解项目功能。
项目背景:
- 它对标 CSDN 博客园 等竞品,致力成为全球知名的IT技术交流平台,它包含 技术文章,问答内容,视频解答 的专业IT资讯平台,它提供原创,优质,完整内容的专业IT社区。它是
极客园
IT资讯社区。
项目功能:
极客园-个人端M
是一款移动web应用。- 主要功能有:
- 首页-文章频道,文章列表,更多操作
- 详情-文章详情,文章评论,评论回复,点赞,收藏,关注
- 登录-短信登录
- 个人-信息展示,信息编辑
项目物料:http://geek.itheima.net
总结: 我们知道项目的大致功能即可。
# 02-使用技术
目标:了解使用技术
开发依赖大致如下:
- 基础环境:
nodejs12+
vscode
vuecli4.x
- 配套工具:
eslint
babel
less
- 使用技术:
vue2.6.12
vue-router
vue-vuex
vant
iconfont
dayjs
socket.io-client
postcss-px-to-viewport
项目中的解决方案:
- 使用vue-cli创建vue单页应用解决方案
- 使用vue-router实现前端路由解决方案
- 使用vue-vuex实现状态管理解决方案
- 使用vant快速搭建移动界面解决方案
- 使用json-bigint处理最大安全整数解决方案
- 使用iconfont实现前端多色字体图标解决方案
- 使用dayjs处理相对时间计算解决方案
- 使用soket.io实现即时通讯解决方案
- 使用postcss-px-to-viewport 实现移动端适配解决方
总结: 我们大概知道用了那些东西即可。
# 03-创建项目
目标:知道如何使用vue-cli创建项目
大致步骤:
- 在某个目录打开命令行工具输入创建项目的命令
- 安装项目需求选择具体的工具,然后等待创建吧
- 最后进入创建好的项目,启动项目即可
具体如下:
- 执行创建命令
vue create geek-client-mobile
- 选中自定义创建
- 选择Vue版本,依赖Babel降级ES6语法,依赖vue-router,依赖vuex,使用css预处理器,使用代码风格校验。
- 选择vue2.0版本
- 是否使用历史模式API,输入 n
- 选择less这种css预处理器
- 选择 通用语法风格配置
- 语法风格校验的时机,保存代码校验,提交代码校验且自动修复。
- 选择使用不同的配置文件对于所依赖工具
- 是否记录此次操作记录,输入 n
- 最后等待安装即可,安装完毕进入项目目录,执行
npm run serve
即可启动项目。
总结: 我们可以使用vuecli根据自己项目需求创建合适的项目。
# 04-调整目录
目标:根据项目功能调整下目录结构
大致步骤:
- 配置文件解释说明
- 调整src下目录结构
落地内容:
- 根目录和配置文件。都是自动生成的,了解作用即可
├─node_modules
├─public
├─src
├─.browserslistrc # 适配浏览器列表
├─.editorconfig # 提供给编辑器的配置
├─.eslintrc.js # eslint代码风格配置
├─.gitignore # git忽略文件配置
├─.babel.config.js # babelES降级配置
├─package-lock.json # 包下载版本说明文件
├─package.json # 项目包说明文件
├─postcss.config.js # postcss,css预处理器后处理器配置
├─README.md # 说明MD文件
└─vue.config.js # vue-cli的配置文件
- src 目录结构如下,仅供参考 (分模块的思维才重要)
├─api # 接口函数
├─assets # 项目资源
│ ├─images # 图片
│ └─styles # less代码
├─components # 全局组件,通用组件
├─router # 路由
├─store # 状态
├─utils # 工具
└─views # 路由组件(页面)
├─article # 文章详情
├─home # 首页
├─question # 问答
├─user # 用户模块
└─video # 视频
总结: 做好开发前的准备,调整下项目结构。
# 第二章:项目架构
# 01-引入vant
目的:在项目中引入Vant组件库
大致步骤:
- 从官方了解引入Vant的几种方式 引入vant方式 (opens new window)
- 自动按需(推荐)- 打包体积小
- 手动按需
- 全部引入
- 我们采用 全部引入 方式
- 开发过程中使用方便,一次引入全局使用。
- 后续做打包优化可以降低打包体积。
落地代码:
- 安装
npm i vant
- 引入
main.js
import Vant from 'vant'
import 'vant/lib/index.css'
Vue.use(Vant)
- 测试
App.vue
<van-button type="primary">按钮</van-button>
总结: 如果后续不做打包优化 自动按需引入
推荐,但是我们为了开发方便后续也会做打包优化使用 全部引入
方式。
# 02-适配单位
目标:了解移动端适配方案,实现这些适配方案用到的尺寸单位。
大致步骤:
- 回忆下移动端等比例适配的单位,rem 或者 vw+vh
- 在项目中使用
vw
来演示下适配的过程 - 总结
vw
单位在做适配的方法
落地过程:
- rem适配,vw适配
// 目标:等比例适配
// 1. iphone6 375px 盒子 100*100
// 2. iphone6 plus 414px 盒子 110.4*110.4
// rem适配
// 1. iphone6 html===>font-size:37.5px
// 2. iphone6 plus html===>font-size:41.4px
// 3. height:2.666rem; width:2.666rem;
// vw适配
// 1. height: 26.667vw; width: 26.667vw;
- 演示 vw 效果
<div class="box"></div>
.box {
height: 26.66667vw;
width: 26.66667vw;
}
- 总结下方案,设备宽度是
375px
那么1vw === 3.75px
,需要将px单位换成vw即可。
总结: 知道如果使用vw换算px单位,实现适配效果。但是手动换算效率太低,下一节来讲如果进行自动适配。
# 03-进行适配
目标:在项目中加入vw的适配方案
大致步骤:
- 安装 postcss-px-to-viewport 插件
- 新建一个 postcss.config.js 的配置文件
- 添加插件配置 参考 浏览器适配 (opens new window)
落地代码:
- 安装
npm install postcss-px-to-viewport --save-dev
- 配置
postcss.config.js
module.exports = {
plugins: {
'postcss-px-to-viewport': {
viewportWidth: 375,
}
}
}
- 注意,该插件对行内样式无效,建议样式通过类来定义。
- postcss 可以认为是后处理器,对css代码做后续的处理 (转单位,加私有前缀..)
总结: 通过postcss-px-to-viewport插件解决移动端适配。
# 04-约定路由
目标:约定好项目的路由设计
大致步骤:
- 先了解各个页面布局的基本构成
- 再约定好路径和组件的映射关系
落地规则:
路径 | 组件 | 功能 |
---|---|---|
/ | Home+Tabbar | 首页 |
/question | Question+Tabbar | 问答 |
/video | Video+Tabbar | 视频 |
/user | User+Tabbar | 用户 |
/user/profile | UserProfile | 用户资料 |
/user/chat | UserChat | 小智同学 |
/article | Article | 文章详情 |
总结: 全部采用一级路由来实现,不使用嵌套路由。(方便后续做组件缓存)
# 05-命名视图-概念
目标:在不使用嵌套路由情况下,如何实现共同布局的复用。
官方话术: 有时候想同时 (同级) 展示多个视图,而不是嵌套展示,例如创建一个布局,有 sidebar
(侧导航) 和 main
(主内容) 两个视图,这个时候命名视图就派上用场了。参考地址 (opens new window)
学习步骤:
- 什么是命名视图?
- router-view组件有name属性,默认是default,可以指定名称。
- 使用场景在哪里?
- 一个路由规则,可以通过命名视图,指定多个组件,组件是同级的,而不是嵌套关系。
落地分析:
- 嵌套视图:
- 命名视图:
总结: 在不使用嵌套视图情况下,可以使用命名视图来复用公用的布局内容。
# 06-命名视图-应用
目标:使用命名视图完成项目路由的基本实现。
大致步骤:
- 定义一个tabbar组件
- 定义 首页 问答 视频 用户 文章详情 等组件
- 使用定义路由规则,使用命名视图,组织页面。
落的代码:
src/components/app-tabbar.vue
底部tab切换组件
<template>
<div class="app-tabbar">tab</div>
</template>
<script>
export default {
name: 'AppTabbar'
}
</script>
<style scoped lang="less"></style>
src/views
中 首页 问答 视频 用户 文章详情 等组件
<template>
<div class="home-page">首页</div>
</template>
<script>
export default {
name: 'HomePage'
}
</script>
<style scoped lang="less"></style>
<template>
<div class="question-page">问答</div>
</template>
<script>
export default {
name: 'QuestionPage'
}
</script>
<style scoped lang="less"></style>
<template>
<div class="article-page">文章详情</div>
</template>
<script>
export default {
name: 'ArticlePage'
}
</script>
<style scoped lang="less"></style>
<template>
<div class="user-page">用户</div>
</template>
<script>
export default {
name: 'UserPage'
}
</script>
<style scoped lang="less"></style>
<template>
<div class="video-page">视频</div>
</template>
<script>
export default {
name: 'VideoPage'
}
</script>
<style scoped lang="less"></style>
src/router/index.js
路由规则
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
// 路由懒加载方式
const Tabbar = () => import('@/components/app-tabbar.vue')
const Home = () => import('@/views/home')
const Question = () => import('@/views/question')
const Video = () => import('@/views/video')
const User = () => import('@/views/user')
const Artcile = () => import('@/views/article')
const routes = [
// 路由规则
{ path: '/', components: { default: Home, tabbar: Tabbar } },
{ path: '/question', components: { default: Question, tabbar: Tabbar } },
{ path: '/video', components: { default: Video, tabbar: Tabbar } },
{ path: '/user', components: { default: User, tabbar: Tabbar } },
{ path: '/article', component: Artcile }
]
const router = new VueRouter({
routes
})
export default router
App.vue
组织视图
<template>
<div id="app">
<RouterView class="body" />
<RouterView class="footer" name="tabbar" />
</div>
</template>
<style lang="less" scoped>
#app {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.body {
flex: 1;
overflow: hidden;
}
.footer {
height: 50px;
}
}
</style>
总结: 可以使用命名视图再一层路由视图情况下,复用tabbar组件。
# 07-实现tab栏
目标:使用vant的tabbar组件完成底部tab栏
大致步骤:
- 先去vant阅读下tabbar组件的模板代码
- 再在app-tabbar.vue组件使用基础用法
- 开启路由功能,指定跳转地址,更改文字。
落的代码:
src/components/app-tabbar.vue
<template>
<van-tabbar route>
<van-tabbar-item to="/" icon="home-o">首页</van-tabbar-item>
<van-tabbar-item to="/question" icon="search">问答</van-tabbar-item>
<van-tabbar-item to="/video" icon="friends-o">视频</van-tabbar-item>
<van-tabbar-item to="/user" icon="setting-o">我的</van-tabbar-item>
</van-tabbar>
</template>
<script>
export default {
name: 'AppTabbar'
}
</script>
总结: van-tabbar的route是开启路由,van-tabbar-item的to属性是指定跳转地址。图标稍后处理。
# 08-字体图标
目标:掌握如何使用svg的字体图标库iconfont
大致步骤:
- 在iconfont.com上生成字体图标的js文件
- 在public下index.html头部引入该文件
- 使用固定的svg语法来使用图标
落地代码:
- 第一步:在public下index.html头部引入该文件
//at.alicdn.com/t/font_2496877_pghtj7rdgrh.js
- 第二步:组件中需要加入通用css代码
<style type="text/css">
.icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
</style>
- 第三步:挑选相应图标并获取类名,应用于页面
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-xxx"></use>
</svg>
总结: 把字体图标库的js文件引入在index.html,使用svg标签通过类名来指定图标。但是每次这样使用较为麻烦,结构比较多,还有样式,封装成组件。
# 09-图标组件
目的:封装geek-icon组件来使用字体图标
大致步骤:
- 在components下定义geek-icon组件
- 使用div.geek-icon包裹svg格式代码
- 暴露props属性,name来指定图标
落地代码:
- 定义
src/components/geek-icon.vue
<template>
<div class="geek-icon">
<svg class="icon" aria-hidden="true">
<use :xlink:href="`#icon-${name}`"></use>
</svg>
</div>
</template>
<script>
export default {
name: 'GeekIcon',
props: {
name: {
type: String,
default: ''
}
}
}
</script>
<style scoped lang="less">
.geek-icon {
display: inline-block;
position: relative;
.icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
}
</style>
- 使用
<template>
<div class="home-page">
+ 首页 <geek-icon style="font-size:50px;color:green" name="weixin"></geek-icon>
</div>
</template>
<script>
+import GeekIcon from '@/components/geek-icon'
export default {
name: 'HomePage',
+ components: { GeekIcon }
}
</script>
<style scoped lang="less"></style>
总结: 使用geek-icon组件name是图标名称,不需要加上icon。geek-icon的颜色和字体大小可控制图标颜色。
注意: weixin图标才可以测试改颜色,其他图标没有放开颜色的设置,需要去iconfont上进行设置。
# 10-Vue插件
目标:掌握定义一个vue插件模块
大致步骤:
- 背景:项目开发过程中会有大量的一些自己写的全局组件,指令,过滤器,原型函数,如果都在main中书写,main的代码就很混乱,不利于维护。
- 插件:可以扩展vue的原有功能在一个独立的js模块中。
- 步骤:
- 定义一个js模块
- 导出一个对象
- 对象中有一个install属性指向的是一个函数
- 函数的默认参数是 Vue
- 你可以基于Vue做扩展功能
落地代码:
- 定义插件
src/components/index.js
import GeekIcon from '@/components/geek-icon'
export default {
install (Vue) {
// 在这里扩展Vue功能
Vue.component(GeekIcon.name, GeekIcon)
}
}
- 使用插件
src/main.js
// 导入插件
import Geek from '@/components'
// 使用插件
Vue.use(Geek)
总结: 我们在项目中一般会把 全局组件,过滤器,指令,定义在一个插件模块中。js模块的格式就是一个对象中有install函数即可。
# 11-改造底部Tab
目标:自定义使用底部tab的图标
大致步骤:
- 需要使用icon插槽自定义图标
- 需要使用icon插槽的props作用域数据切换图标状态
- 需要使用/deep/来覆盖组件内部样式
落地代码:src/components/app-tabbar.vue
<template>
<van-tabbar :border="false" route>
<van-tabbar-item to="/">
<span>首页</span>
<template #icon="props">
<geek-icon :name="props.active ? 'home-sel' : 'home'" />
</template>
</van-tabbar-item>
<van-tabbar-item to="/question">
<span>问答</span>
<template #icon="props">
<geek-icon :name="props.active ? 'qa-sel' : 'qa'" />
</template>
</van-tabbar-item>
<van-tabbar-item to="/video">
<span>视频</span>
<template #icon="props">
<geek-icon :name="props.active ? 'video-sel' : 'video'" />
</template>
</van-tabbar-item>
<van-tabbar-item to="/user">
<span>我的</span>
<template #icon="props">
<geek-icon :name="props.active ? 'mine-sel' : 'mine'" />
</template>
</van-tabbar-item>
</van-tabbar>
</template>
<script>
export default {
name: 'GeekTabbar'
}
</script>
<style scoped lang="less">
.van-tabbar {
background: #F7F8FA;
position: static;
}
/deep/ .van-tabbar-item--active {
color: #FC6627;
background-color: #F7F8FA
}
/deep/ .van-tabbar-item__icon {
font-size: 20px;
}
/deep/ .van-tabbar-item__text {
font-size: 10px;
}
</style>
总结: 使用van-tabbar-item的作用域插槽icon可以完成图标自定义和状态切换。
样式中用到了几个颜色,这几个颜色是将来其他组件也会大量使用的,建议定义为全局变量。
# 12-Less全局变量
目标:定项目的Less全局变量
大致步骤:
- 注意,如果你将less变量定义在一个less文件中,在使用变量的地方就需要引入这个文件,麻烦。
- 所以,vue-cli考虑到这一点,提供了配置。 参考配置 (opens new window)
落地代码:
- 添加配置
vue.config.js
修改配置文件需要重启项目
module.exports = {
css: {
loaderOptions: {
less: {
// 这里定义不需要加@,使用的时候需要加@
globalVars: {
'geek-color': '#FC6627',
'geek-gray-color': '#F7F8FA'
}
}
}
}
}
- 使用变量
src/components/app-tabbar.vue
.van-tabbar {
+ background: @geek-gray-color;
position: static;
}
/deep/ .van-tabbar-item--active {
+ color: @geek-color;
+ background-color: @geek-gray-color
}
总结: 在vue.config.js中添加配置就可以定义全局需要使用的less变量。
# 13-Vuex管理状态
目标:使用vuex来管理项目中需要共享的数据模块
大致步骤:
- 定义
user
用户模块,维护用户 token 等信息,且需要同步本地存储。 - 在 store 中使用
user
模块
落地代码:
- user模块
src/store/modules/user.js
// 用户模块
export default {
namespaced: true,
state () {
return {
token: localStorage.getItem('geek-store-token')
}
},
getters: {},
mutations: {
setToken (state, token) {
state.token = token
localStorage.setItem('geek-store-token', token)
}
},
actions: {}
}
- 使用模块
src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
user
}
})
总结: 今后组件间共享的数据由vuex来管理。
# 14-请求工具-基础
目的:为了能单独维护axios的配置,提取一个请求工具模块。
大致步骤:
- 安装
axios
且导入到文件src/utils/request.js
- 创建一个新的axios实例,配置
baseURL
timeout
- 导出一个通过新axios实例调用接口的函数,返回值promise
落地代码: src/utils/request.js
import axios from 'axios'
// 新axios实例
const instance = axios.create({
baseURL: 'http://geek.itheima.net/',
timeout: 5000
})
// 导出一个新axios实例调用接口的函数,返回值promise
export default ({ url, method = 'get', params, data, headers }) => {
const promise = instance({ url, method, params, data, headers })
return promise
}
总结: 将来通过导出的函数调接口即可。配置可以统一在这个文件进行维护。
# 15-请求工具-赋能
目的:让请求工具能够处理,携带token问题,token失效问题。
大致步骤:
- 请求头携带token保持登录状态
- 处理token失效问题,跳转登录页面,需要把在哪个页面失效的页面地址传递给登录页面,登录后回跳。
落地步骤:
- 请求头携带token保持登录状态
导入 vuex
仓库,然后在 请求拦截器 获取token信息,有就修改 config
的配置信息即可。
import store from '@/store'
// 请求拦截器
instance.interceptors.request.use(config => {
const token = store.state.user.token
if (token) config.headers.Authorization = `Bearer ${token}`
return config
}, err => Promise.reject(err))
- 处理token失效问题,跳转登录页面,需要把在哪个页面失效的页面地址传递给登录页面,登录后回跳。
import router from '@/router'
// 响应拦截器
instance.interceptors.response.use(res => res, err => {
if (err.response && err.response.status === 401) {
// token失效
store.commit('user/setToken', '')
router.push('/login?returnUrl=' + encodeURIComponent(router.currentRoute.fullPath))
}
return Promise.reject(err)
})
总结: 我们可以在request.js
配置扩展 axios 功能,处理项目需要的业务。
# 16-await异常处理
目的:解决
async await
发请求,处理异常时候多出try catch
代码块问题。
大致步骤:
- 使用封装好的请求函数调用接口处理成功和异常,总结
try catch
处理异常的缺点:需要额外的代码。 - 使用
await-to-js
处理promise请求,处理成功和异常情况。 - 优化
request.js
的请求函数,让返回的promise支持await-to-js
写法。
落地代码:
- 演示
request.js
调用接口处理成功和异常
- 演示
import request from '@/utils/request'
export default {
name: 'HomePage',
async created () {
try {
const res = await request({ url: 'v1_0/channels2' })
console.log('成功', res.data)
} catch (e) {
console.log('失败', e.message)
}
}
}
我们发现:async await 调用接口,处理异常需要额外加 try catch 的代码块,代码层次增多,不好阅读。
- 使用
await-to-js
配合request.js
调用接口处理成功和异常
- 使用
import request from '@/utils/request'
import to from 'await-to-js'
export default {
name: 'HomePage',
async created () {
const [err, res] = await to(request({ url: 'v1_0/channels' }))
if (err) console.log('失败', err.message)
else console.log('成功', res.data)
}
}
我们可以将 to 在request.js 工具中就使用,以后就不用每次引入 await-to-js,调用to函数。
- 优化
request.js
代码
- 安装
npm i await-to-js
- 导入
import to from 'await-to-js'
- 使用
export default ({ url, method = 'get', params, data, headers }) => { const promise = instance({ url, method, params, data, headers }) + return to(promise) }
import request from '@/utils/request' export default { name: 'HomePage', async created () { const [err, res] = await request({ url: 'v1_0/channels' }) if (err) console.log('失败', err.message) else console.log('成功', res.data) } }
- 优化
总结: 使用await-to-js之后,可以通过是否存在 err 判断是否出现异常。不用使用try catch 增加代码。
# 17-接口API函数
目的:知道提取API函数的目的,掌握这种套路。
发现问题:
- 问题:调用一个接口,需要传入:请求地址,请求方式,请求参数,等等。如果这个接口需要在多个组件调用,那么相同的代码需要写多次。
- 解决:将接口的调用再次封装成为一个函数,只暴露请求参数。这样可以提高代码复用。
大致步骤:
- 根据接口文档封装一个接口函数
- 再需要数据的组件导入调用函数
落的代码:(在首页获取频道信息)
- 定义API函数
src/api/channel.js
import request from '@/utils/request'
/**
* 获取所有频道
*/
export const getAllChannels = () => {
return request({ url: 'v1_0/channels' })
}
- 调用API函数
src/views/home/index.vue
import { getAllChannels } from '@/api/channel'
export default {
name: 'HomePage',
async created () {
// 不使用err不写err即可,但是,号需要写
const [, res] = await getAllChannels()
console.log(res.data)
}
}
总结: 以后调用接口,先写API函数,然后再调用API函数获取数据。
# 第三章:登录模块
# 01-登录-导航守卫
目的:访问用户相关页面都需要进行登录,做一个导航守卫进行登录拦截。
大致步骤:
- 约定好将来用户相关的页面 路由地址以
/user
开头 - 在
src/router/index.js
添加前置导航守卫 - 通过vuex中是否有
token
数据来判断是否登录,进行登录拦截。
落的代码: src/router/index.js
import store from '@/store'
// 导航守卫
router.beforeEach((to, from, next) => {
const token = store.state.user.token
// 没登录却访问user下的路由
if (!token && to.path.startsWith('/user')) {
return next('/login?returnUrl=' + encodeURIComponent(to.fullPath))
}
// 其他情况放行
next()
})
总结: 使用前置导航守卫拦截未登录却访问用户页面的情况。
# 02-登录-组件布局
目的:完成登录页面基础布局,路由规则配置。
大致步骤:
- 定义登录组件
- 配置路由规则
- 完成基础布局
落的代码:
- 登录组件
src/views/login/index.vue
<template>
<div class="login-page">
<div class="back">
<!-- .native绑定组件的原生事件,属于组件根元素 -->
<!-- $router.back() 返回上一次访问路由,forward go -->
<geek-icon @click.native="$router.back()" name="esay-close"></geek-icon>
</div>
<h3 class="title">短信登录</h3>
<!-- 表单 -->
<van-form>
<van-field placeholder="请输入手机号"></van-field>
<van-field placeholder="请输入验证码"></van-field>
</van-form>
<van-button>登录</van-button>
</div>
</template>
<script>
export default {
name: 'LoginPage'
}
</script>
<style scoped lang="less">
.login-page {
padding: 0 32px;
.back {
height: 60px;
display: flex;
align-items: center;
.geek-icon {
font-size: 20px;
color: #ccc;
position: relative;
left: -15px;
}
}
.title {
font-size: 22px;
line-height: 1;
padding: 30px 0;
}
.van-cell {
padding: 20px 0;
&::after{
left: 0;
right: 0;
}
}
.van-button {
width: 100%;
margin-top: 40px;
height: 50px;
color: #fff;
font-size: 16px;
border: none;
background: linear-gradient(to right,#FF9999,#FFA179);
}
}
</style>
- 路由规则
src/router/index.js
+const Login = () => import('@/views/login')
const routes = [
// ...
+ { path: '/login', component: Login }
]
总结: 注意van-form和van-field是表单结构中的form和input他们需要结合使用。
# 03-登录-表单校验
目标:完成表单项校验和提交时整体校验。
大致步骤:
- 通过文档了解vant校验的套路
- 完成单个表单项的校验
- 完成提交时整体校验
落地代码:
- 根据约定vant文档总结如下:
1. 通过van-field组件的rules属性指定校验规则,校验单个表单项
2. 校验规则具体参考:https://vant-contrib.gitee.io/vant/#/zh-CN/form#rule-shu-ju-jie-gou
3. 通过van-form组件提供的validate函数校验全部表单项
- 完成单个表单项校验
data () {
return {
form: {
mobile: '',
code: ''
},
rules: {
mobile: [
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不对' }
],
code: [
{ required: true, message: '请输入手机号' },
{ pattern: /^\d{6}$/, message: '验证码是6个数字' }
]
}
}
},
<van-field placeholder="请输入手机号" v-model="form.mobile" :rules="rules.mobile"></van-field>
<van-field placeholder="请输入验证码" v-model="form.code" :rules="rules.code"></van-field>
- 完成提交时整体校验
<van-form class="form" ref="form">
methods: {
login () {
this.$refs.form.validate().then(() => {
console.log('校验成功')
})
}
}
总结: 掌握单个表单项验证和整体验证可以完成大部分业务。
# 04-登录-默认登录
目的:能够通过默认246810短信验证码完成登录
大致步骤:
- 由于短信业务需要接入第三方运营商需要买短信包,所以提供了246810默认的短信验证码。
- 编写登录 API 接口函数
- 使用 手机号 和 默认的短信验证码246810 进行登录
- 成功后存储 token 信息
落地代码:
- 编写登录 API 接口函数
src/api/index.js
import request from '@/utils/request'
/**
* 登录
* @param {String} mobile - 手机号
* @param {String} code - 验证码
* @returns Promise
*/
export const userLogin = ({ mobile, code }) => {
return request({
url: 'v1_0/authorizations',
method: 'post',
data: { mobile, code }
})
}
- 使用 手机号 和 默认的短信验证码 进行登录
src/views/login/index.vue
import { userLogin } from '@/api/user'
methods: {
async login () {
// 校验
await this.$refs.form.validate()
// 登录
const [err, res] = await userLogin(this.form)
// 失败
if (err) return this.$toast.fail('登录失败')
// 成功
console.log(res)
}
}
- 成功后存储 token
src/views/login/index.vue
methods: {
async login () {
// 校验
await this.$refs.form.validate()
// 登录
const [err, res] = await userLogin(this.form)
// 失败
if (err) return this.$toast.fail('登录失败')
// 成功
+ this.$store.commit('user/setToken', res.data.data.token)
+ this.$router.push(this.$route.query.returnUrl || '/')
}
}
总结: 定义API---->调用API----->得到数据,会是以后的常态。我们存储token是后续会使用的。
# 05-登录-短信登录
目的:完成发送短信验证码登录功能。
大致步骤:
- 准备 发送验证码 按钮
- 编写发送短信API接口
- 点击 发送验证码 按钮,
- 判断是否已经发送,
- 校验手机号,
- 调用发送短信API,
- 成功后开启60秒倒计时,不可再次发送
落地代码:
- 准备 发送验证码 按钮
src/views/login/index.vue
<van-field placeholder="请输入验证码" v-model="form.code" :rules="rules.code">
<template #button>
<span class="send">发送验证码</span>
</template>
</van-field>
.send {
font-size: 12px;
color: #A5A6AB;
}
- 编写发送短信API接口
src/api/index.js
/**
* 发送短信验证码
* @param {String} mobile - 手机号
* @returns Promise
*/
export const sendMessage = (mobile) => {
return request({
url: `/v1_0/sms/codes/${mobile}`
})
}
- 点击 发送验证码 按钮,校验手机号,调用发送短信API,成功后开启60秒倒计时,不可再次发送
src/views/login/index.vue
// 倒计时秒数
second: 0,
// 定时器ID
timer: null
<template #button>
<span @click="send()" class="send">
{{second===0?'发送验证码':`${second}秒后发送`}}
</span>
</template>
methods: {
// 省略....
async send () {
// 已发送,不做事
if (this.second > 0) return
// 校验
await this.$refs.form.validate('mobile')
// 发短信
const [err] = await sendMessage(this.form.mobile)
// 失败
if (err) return this.$toast.fail('发送失败')
// 成功 倒计时
this.second = 60
if (this.timer) clearInterval(this.timer)
this.timer = setInterval(() => {
this.second--
if (this.second <= 0) clearInterval(this.timer)
}, 1000)
}
},
beforeDestroy () {
if (this.timer) clearInterval(this.timer)
}
+<van-field v-model="form.mobile" name="mobile"
总结: 对应定时器,最好在销毁组件前清除定时器。
# 第四章:首页模块
# 01-首页-频道展示
目的:完成首页频道TAB展示
大致步骤:
- 使用van-tabs组件完成基础结构
- 放置搜索按钮和频道按钮再tab组件右侧
- 获取频道数据,渲染van-tabs组件
落地代码:
- 使用van-tabs组件完成基础结构
src/views/home/index.vue
<van-tabs>
<van-tab v-for="index in 8" :title="'标签 ' + index">
内容 {{ index }}
</van-tab>
</van-tabs>
::v-deep .van-tabs {
height: 100%;
display: flex;
flex-direction: column;
.van-tabs__line {
background: @geek-color;
height: 2px;
width: 32px;
}
.van-tab {
color: #9EA1AE;
}
.van-tab--active {
font-size: 18px;
color: #333;
}
.van-tabs__wrap {
padding-right: 86px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.van-tabs__content {
flex: 1;
overflow: hidden;
}
.van-tab__pane {
height: 100%;
}
}
- 放置搜索按钮和频道按钮再van-tabs组件下面,定位到右上角
src/views/home/index.vue
<!-- 按钮 -->
<div class="btn-wrapper">
<geek-icon name="search"></geek-icon>
<geek-icon name="channel"></geek-icon>
</div>
.home-page {
.btn-wrapper {
position: absolute;
right: 0;
top: 0;
width: 86px;
height: 44px;
background: #fff;
display: flex;
align-items: center;
.geek-icon {
flex: 1;
text-align: center;
font-size: 18px;
}
&::before {
content: "";
width: 20px;
height: 44px;
position: absolute;
left: -20px;
top: 0;
background: linear-gradient(to right, rgba(255,255,255,0), #fff);
}
}
}
- 获取频道数据,渲染van-tabs组件
src/api/channel.js
/**
* 获取我的频道(未登录会返回默认的一些频道)
*/
export const getMyChannels = () => {
return request({ url: 'v1_0/user/channels' })
}
src/views/home/index.vue
data () {
return {
myChannels: []
}
},
async created () {
// 不使用err不写err即可,但是,号需要写
const [, res] = await getMyChannels()
this.myChannels = res.data.data.channels
}
<van-tabs>
<van-tab :key="item.id" v-for="item in myChannels" :title="item.name">
内容 {{ item.id }}
</van-tab>
</van-tabs>
总结: 完成tab使用,改造样式布局,获取频道数据,渲染即可。
# 02-首页-文章组件
目的:完成文章列表组件与文章单项组件
大概步骤:
- 准备文章单项组件
- 准备文章列表组件
- 首页使用文章列表组件
- 首页van-tabs样式修改
落的步骤:
- 准备文章单项组件
src/views/home/components/article-item.vue
1)无图
<div class="article-item van-hairline--bottom">
<p class="title van-multi-ellipsis--l2">美国强行关闭33家伊朗网站 伊总统办公室警告:不利于伊核谈判</p>
<div class="info">
<span>小兵张嘎</span>
<span>17评论</span>
<span>1天前</span>
<geek-icon name="esay-close"></geek-icon>
</div>
</div>
2)三图
<div class="article-item van-hairline--bottom">
<p class="title van-multi-ellipsis--l2">美国强行关闭33家伊朗网站 伊总统办公室警告:不利于伊核谈判</p>
<img class="img" src="https://inews.gtimg.com/newsapp_ls/0/13685285688_294195/0" alt="">
<img class="img" src="https://inews.gtimg.com/newsapp_ls/0/13685285688_294195/0" alt="">
<img class="img" src="https://inews.gtimg.com/newsapp_ls/0/13685285688_294195/0" alt="">
<div class="info">
<span>小兵张嘎</span>
<span>17评论</span>
<span>1天前</span>
<geek-icon name="esay-close"></geek-icon>
</div>
</div>
3)单图,title处加了w66的类名需要注意
<div class="article-item van-hairline--bottom">
<p class="title van-multi-ellipsis--l2 w66">美国强行关闭33家伊朗网站 伊总统办公室警告:不利于伊核谈判</p>
<img class="img" src="https://inews.gtimg.com/newsapp_ls/0/13685285688_294195/0" alt="">
<div class="info">
<span>小兵张嘎</span>
<span>17评论</span>
<span>1天前</span>
<geek-icon name="esay-close"></geek-icon>
</div>
</div>
其他代码
<script>
export default {
name: 'ArticleItem'
}
</script>
<style scoped lang="less">
.article-item {
padding: 15px 0;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.title {
width: 100%;
margin: 0;
line-height: 22px;
font-size: 16px;
color: #333;
margin-bottom: 8px;
max-height: 44px;
&.w66 {
width: 66%;
}
}
.img {
width: 112px;
height: 74px;
border-radius: 4px;
margin-bottom: 8px;
}
.info {
width: 100%;
color: #A5A6AB;
font-size: 12px;
position: relative;
span {
margin-right: 12px;
}
.geek-icon {
float: right;
font-size: 14px;
}
}
}
</style>
- 准备文章列表组件
src/views/home/components/article-list.vue
<template>
<div class="article-list">
<article-item v-for="i in 10" :key="i" />
</div>
</template>
<script>
import ArticleItem from './article-item.vue'
export default {
name: 'ArticleList',
components: {
ArticleItem
}
}
</script>
<style scoped lang="less">
.article-list {
height: 100%;
overflow-y: auto;
padding: 0 16px;
}
</style>
- 首页使用文章列表组件
src/views/home/index.vue
import { getMyChannels } from '@/api/channel'
+import ArticleList from './components/article-list.vue'
export default {
name: 'HomePage',
+ components: { ArticleList },
<van-tabs>
<van-tab :key="item.id" v-for="item in myChannels" :title="item.name">
+ <article-list></article-list>
</van-tab>
</van-tabs>
- 首页van-tabs样式修改
src/views/home/index.vue
::v-deep .van-tabs {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
.van-tabs__line {
background: @geek-color;
height: 2px;
width: 32px;
}
.van-tab {
color: #9EA1AE;
}
.van-tab--active {
font-size: 18px;
color: #333;
}
.van-tabs__wrap {
padding-right: 86px;
}
+ .van-tabs__content {
+ flex: 1;
+ overflow: hidden;
+ }
+ .van-tab__pane {
+ height: 100%;
+ }
}
总结: 注意下单图无图三图结构情况,注意下flex布局占满剩余高度产生滚动条。
# 03-首页-上拉加载
目的:实现上拉加载效果
大致步骤:
- 使用van-list组件,知道需要使用属性和事件作用
- 模拟上拉加载效果
落地代码:src/views/home/components/article-list.vue
- 使用van-list组件,知道需要使用属性和事件作用
<template>
<div class="article-list">
<van-list v-model="loading" :finished="finished" @load="onLoad()" finished-text="没有更多了">
<article-item v-for="i in 10" :key="i" />
</van-list>
</div>
</template>
<script>
import ArticleItem from './article-item.vue'
export default {
name: 'ArticleList',
components: {
ArticleItem
},
data () {
return {
// 正在加载
loading: false,
// 数据全部加载完毕
finished: false
}
},
methods: {
onLoad () {
console.log('上拉加载')
}
}
}
</script>
v-model="loading"
是加载中状态,控制显示加载中效果,:finished="finished"
是显示是否完全加载完毕数据,
@load="onLoad()"
是上拉加载后事件,当列表最底部进入可视区(能看见列表末尾)就会触发上拉加载事件。
- 模拟上拉加载效果
<article-item v-for="(item, i) in articles" :key="i" />
data () {
return {
// 正在加载
loading: false,
// 数据全部加载完毕
finished: false,
// 文章列表
+ articles: []
}
},
methods: {
onLoad () {
// 模拟请求
setTimeout(() => {
// 加载完毕
this.loading = false
// 判断还有没有数据
if (this.articles.length < 40) {
// 设置数据,追加
const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
this.articles.push(...data)
} else {
// 设置数据全部加载完毕
this.finished = true
}
}, 1000)
}
}
总结: 知道van-list的属性和事件,知道加载数据的套路。
# 04-首页-下拉刷新
目的:实现下拉刷新效果
大致步骤:src/views/home/components/article-list.vue
- 使用 van-pull-refresh 组件,知道需要使用的属性和事件作用
- 模拟下拉刷新效果
落地代码:
- 使用 van-pull-refresh 组件,知道需要使用的属性和事件作用
+ <van-pull-refresh
+ v-model="refreshing"
+ @refresh="onRefresh"
+ success-text="刷新成功"
+ >
<van-list
v-model="loading"
:finished="finished"
@load="onLoad()"
finished-text="没有更多了"
>
<article-item v-for="i in articles" :key="i" />
</van-list>
+ </van-pull-refresh>
data () {
return {
// 正在加载
loading: false,
// 数据全部加载完毕
finished: false,
// 文章列表
articles: [],
// 正在刷新
+ refreshing: false
}
},
methods: {
+ onRefresh () {
+ console.log('下拉刷新')
+ },
v-model="refreshing"
是刷新中状态,控制显示刷新中效果,loading-text"
是设置刷新中的提示文字,
@refresh="onRefresh()"
是下拉刷新后事件,当用户向下拖动到一定距离后会触发下拉刷新事件。
- 模拟下拉刷新效果
onRefresh () {
// 模拟请求
setTimeout(() => {
// 刷新完毕
this.refreshing = false
// 设置数据全部加载完毕为:未加载完
this.finished = false
// 设置数据,替换
const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
this.articles = data
}, 1000)
},
需要注意下的是,刷新完毕后需要 将 finished
改成 false
代表还可以继续上拉加载数据,因为重置了列表数据。
总结: 知道 van-pull-refresh 组件 v-model 的作用是控制刷新中状态,@refresh 是监听下拉刷新事件。然后由于重置了数据,需要将列表改成还有更多数据状态。
# 05-首页-接入数据
目的:调用接口获取文章列表真实的数据,完成数据渲染。
大致步骤:
- 每个频道对应自己的列表,需要传入频道ID给文章列表组件。
- 编写获取文章列表的API接口函数
- 再上拉加载和下拉加载的位置接入真实的接口
- 渲染单项文章组件
落地代码:
- 每个频道对应自己的列表,需要传入频道ID给文章列表组件。
src/views/home/index.vue
<van-tabs>
<van-tab :key="item.id" v-for="item in myChannels" :title="item.name">
+ <article-list :channelId="item.id"></article-list>
</van-tab>
</van-tabs>
src/views/home/components/article-list.vue
props: {
channelId: {
type: Number,
default: 0
}
},
- 编写获取文章列表的API接口函数
src/api/article.js
import request from '@/utils/request'
/**
* 根据频道获取频道
* @param {Number} channelId - 频道ID
* @param {Number} timestamp - 时间戳
* @returns
*/
export const getArticlesByChannel = (channelId, timestamp) => {
return request({
url: '/v1_0/articles',
method: 'get',
params: {
channel_id: channelId,
timestamp
}
})
}
- 在上拉加载和下拉加载的位置接入真实的接口
src/views/home/components/article-list.vue
import { getArticlesByChannel } from '@/api/article'
data () {
return {
// 正在加载
loading: false,
// 数据全部加载完毕
finished: false,
// 文章列表
articles: [],
// 正在刷新
refreshing: false,
// 时间戳
+ timestamp: Date.now()
}
},
async onRefresh () {
// 下拉刷新
// 1. 重置时间戳:回到第一页
// 2. 获取数据
// 3. 重置全部数据加载完成:可以再次加载更多
// 4. 替换当前列表数据戳
// 5. 记录下一次请求的时间
// 6. 结束加刷新操作
this.timestamp = Date.now()
const [, res] = await getArticlesByChannel(this.channelId, this.timestamp)
this.finished = false
this.articles = res.data.data.results
this.timestamp = res.data.data.pre_timestamp
this.refreshing = false
},
async onLoad () {
// 上拉加载
// 1. 获取数据
// 2. 判断下一页是否还有数据:当前时间戳未空,也就是没有更多了
// 2.1 如果有:记录当前的数据时间戳,下一次请求使用
// 2.2 如没有:设置没有更多数据
// 3. 当前列表追加数据
// 4. 结束上拉加载操作
const [err, res] = await getArticlesByChannel(this.channelId, this.timestamp)
if (err) return this.$toast.fail('加载失败') // 加载失败
if (res.data.data.pre_timestamp) {
this.timestamp = res.data.data.pre_timestamp
} else {
this.finished = true
}
this.articles.push(...res.data.data.results)
this.loading = false
}
}
- 渲染单项文章组件
src/views/home/components/article-list.vue
传入文章信息
<article-item v-for="(item, i) in articles" :key="i" :article="item" />
src/views/home/components/article-item.vue
使用文章信息
props: {
article: {
type: Object,
default: () => ({})
}
}
<template>
<div class="article-item van-hairline--bottom">
<p class="title van-multi-ellipsis--l2" :class="{w66: article.cover.type===1}">{{article.title}}</p>
<img v-for="(url,i) in article.cover.images" :key="i" class="img" :src="url" alt="">
<div class="info">
<span>{{article.aut_name}}</span>
<span>{{article.comm_count}}评论</span>
<span>{{article.pubdate}}</span>
<geek-icon name="esay-close"></geek-icon>
</div>
</div>
</template>
总结: 使用传入的频道ID获取数据,在上拉和下拉后等所有的逻辑完成后,结束加载或刷新状态。
# 06-首页-处理时间
目的:把文章发布时间转换为相对时间,例如:2个月内,1分钟内。
大致步骤:
- 定义一个过滤器,使用过滤器证明可用。
- 然后在过滤中实现转换逻辑,采用 dayjs 时间库处理。
- 安装导入 dayjs
- 使用 relativeTime 模块转化相对时间
- 使用 locale 默认语言本地化
落地代码:
- 定义一个过滤器,使用过滤器证明可用。
src/components/index.js
export default {
install (Vue) {
// 在这里扩展Vue功能
Vue.component(GeekIcon.name, GeekIcon)
+ // 全局注册过滤器
+ Vue.filter('relativeTime', (value) => {
+ return '1周内'
+ })
}
}
src/views/home/components/article-item.vue
<span>{{article.pubdate|relativeTime}}</span>
- 然后在过滤中实现转换逻辑,采用 dayjs 时间库处理。
src/components/index.js
1)安装
npm i dayjs
2)导入
import dayjs from 'dayjs'
3)使用 相对时间 插件
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
4)进行时间转换
// 全局注册过滤器
Vue.filter('relativeTime', (value) => {
+ return dayjs(value).toNow()
})
5)本地化,语言
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
总结: 知道过滤器的注册使用,知道dayjs的基本使用。
# 07-首页-组件缓存
目的:首页的状态(当前浏览的频道,频道里头文章)需要保持,当你切换到其他页面时。
大致步骤:
- 知道如何通过组件缓存来保存组件状态
- 知道如何指定某些组件进行缓存
- 知道如何判断组件确实缓存了
落地代码:
src/App.vue
- 使用 keep-alive 来缓存组件
<div id="app">
+ <keep-alive>
<RouterView class="body" />
+ </keep-alive>
<RouterView class="footer" name="tabbar" />
</div>
那么将来 RouterView
动态展示的组件都会被缓存
- 使用 include 来指定需要被缓存的组件
<keep-alive include="HomePage">
属性的值是需要缓存组件的name名称,如果多个组件可以 逗号 分隔 a,b,c
- 我们可以通过 devtools 观察组件是否缓存
看见 inactive 代表当前组件被缓存,或者切换下组件看看组件数据状态是否保存住,还有组件被缓存了再次进入组件是不会触发 created 钩子的这也可以判断。
总结: 知道使用keep-alive组件缓存组件,知道使用include来制定被缓存组件。
# 08-首页-阅读位置
目的:组件缓存只会缓存状态数据,而滚动的位置(阅读位置)没有保持,我们需要完成阅读位置保持。
大致步骤:
- 我们需要知道如何监听:进入组件(激活组件)
- 我们需要在阅读列表的时候记录当前滚动位置
- 我们需要在进入组件的时候还原之前滚动位置
落地代码:
src/views/home/components/article-list.vue
activated
和deactivated
将会在keep-alive
树内的所有嵌套组件中触发 参考文档 (opens new window)- 我们需要在阅读列表的时候记录当前滚动位置
// 阅读位置 (data中申明)
scrollTop: 0
<div class="article-list" ref="ArticleList" @scroll="rememberScroll">
methods: {
// 滚动监听
rememberScroll () {
this.scrollTop = this.$refs.ArticleList.scrollTop
},
- 我们需要在进入组件的时候还原之前滚动位置
// 激活组件
activated () {
this.$refs.ArticleList.scrollTop = this.scrollTop
},
注意: src/views/home/index.vue
如果当前列表被隐藏,滚动失效,需要设置 animated 有动画的时候,所有列表不会隐藏,激活的时候滚动生效。
<van-tabs animated>
总结: 监听 article-list 的 scroll 事件记录 scrollTop , 然后在组件触发 activated 事件设置 article-list 的 scrollTop 最后注意 van-tabs 加上 animated 属性。
先定义API函数,组件中调用即可。
- van-tabs 组件实现频道
- van-list 组件实现上拉加载
- van-pull-refresh 组件实现下拉刷新
- dayjs relativeTime模块 定义过滤器 处理时间
- keep-alive vue内置组件做了缓存
- 记录位置,组件激活 activated 还原位置
回顾 promise 知识
- 异步操作有回调函数,如果有嵌套,形成逻辑不是特别清楚的代码,回调地狱
- promise 可以解决回调地狱问题,可以让异步操作使用then串联执行
// promise
ajax('url1').then(data=>{
return ajax('url2')
}).then(data=>{
})
- async await 可以让promise的代码更加简洁
const getData = async () = >{
await ajax('url1')
await ajax('url2')
}
# 第五章:频道管理
# 01-频道-组件准备
目的:定义频道管理组件,准备一个弹出层组件,在首页组件使用频道管理组件。
大致步骤:
- 了解 van-popup 组件基本用法
- 定义频道管理组件,使用van-popup组件,修改样式
- 在首页组件使用频道管理组件
- 实现:value来控制van-popup的显示
- 实现@input来控制van-popup的隐藏
- 简写成v-model
落地代码:
- 了解 van-popup 组件基本用法 参考文档 (opens new window)
1. v-model 或者 value 控制 弹出层显示和隐藏,值类型 true false
2. position 弹出的位置 left top bottom right
3. closeable 是否显示关闭图标
4. 事件 click-close-icon 点击关闭按钮的事件
5. 弹层大小,图标大小,使用样式覆盖控制
- 定义频道管理组件,使用van-popup组件,修改样式
定义组件 src/views/home/components/article-channel.vue
<template>
<van-popup
:value="value"
@click-close-icon="$emit('input', false)"
closeable
position="left"
>
<div class="article-channel">
频道管理
</div>
</van-popup>
</template>
<script>
export default {
name: 'ArticleChannel',
props: {
value: {
type: Boolean,
default: false
}
}
}
</script>
<style scoped lang="less">
.van-popup {
width: 100%;
height: 100%;
::v-deep .van-popup__close-icon {
font-size: 20px;
right: 12px;
top: 12px;
}
}
.article-channel {
margin-top: 44px;
}
</style>
使用组件 src/views/home/index.vue
+import ArticleChannel from './components/article-channel.vue'
export default {
name: 'HomePage',
+ components: { ArticleList, ArticleChannel },
<!-- 按钮 -->
<div class="btn-wrapper">
<geek-icon name="search"></geek-icon>
+ <geek-icon name="channel" @click.native="showChannel=true"></geek-icon>
</div>
<!-- 频道 -->
+ <article-channel v-model="showChannel"></article-channel>
data () {
return {
myChannels: [],
+ // 控制频道组件显示隐藏
+ showChannel: false
}
},
总结: 封装频道管理组件,实现v-model指令,控制van-popup显示隐藏。
# 02-频道-组件布局
目的:完成频道组件的基础布局,了解需要动态交互的点。
大致步骤:
- 准备HTML结构
- 准备CSS样式
- 了解动态修改的类名
落地代码:src/views/home/components/article-channel.vue
- 准备HTML结构
<template>
<van-popup
:value="value"
@click-close-icon="$emit('input', false)"
closeable
position="left"
>
+ <div class="article-channel">
+ <div class="head">
+ <h3>我的频道<small>点击进入频道</small></h3>
+ <a class="edit" href="javascript:;">编辑</a>
+ </div>
+ <div class="body">
+ <a href="javascript:;" class="active">推荐</a>
+ <a href="javascript:;" v-for="i in 8" :key="i">频道{{i}}</a>
+ </div>
+ <div class="head" style="margin-top:12px">
+ <h3>频道推荐</h3>
+ </div>
+ <div class="body">
+ <a href="javascript:;" v-for="i in 15" :key="i">+ 频道{{i}}</a>
+ </div>
+ </div>
</van-popup>
</template>
- 准备CSS样式
.article-channel {
margin-top: 44px;
.head {
padding: 0 16px;
display: flex;
justify-content: space-between;
justify-items: center;
padding-bottom: 12px;
h3 {
font-size: 16px;
color: #333;
margin: 0;
small {
font-size: 12px;
color: #999;
margin-left: 10px;
}
}
.edit {
float: right;
height: 22px;
width: 52px;
line-height: 22px;
text-align: center;
color: #DE644B;
border-radius: 11px;
border: 1px solid #DE644B;
font-size: 12px;
&.active {
color: #fff;
background: #DE644B;
}
}
}
.body {
padding: 0 6px 0 16px;
a {
display: inline-block;
padding: 0 8px;
font-size:14px;
color: #3A3948;
background: #F7F8FA;
height: 36px;
line-height: 36px;
min-width: 78px;
margin-right: 10px;
margin-bottom: 12px;
border-radius: 18px;
text-align: center;
&.active {
color: @geek-color;
}
}
}
}
- 了解动态修改的类名
1. 编辑按钮----点击后---->完成按钮,需要加上active类名
2. 我的频道按钮,如果是当前浏览频道,需要加上active类名
总结: 准备好布局,知道 active 类名的作用。
# 03-频道-渲染我的频道
目的:完成我的频道渲染与激活当前浏览频道。
大致步骤:
- 完成我的频道渲染,数据从首页组件传入。
- 完成当前浏览频道激活,首页组件准备当前浏览频道索引,传人数据给频道组件
落地代码:
- 完成我的频道渲染,数据从首页组件传入。
src/views/home/index.vue
传入我的频道数据
<!-- 频道 -->
<article-channel
v-model="showChannel"
+ :myChannels="myChannels"
>
</article-channel>
src/views/home/components/article-channel.vue
使用我的频道数据
props: {
value: {
type: Boolean,
default: false
},
+ myChannels: {
+ type: Array,
+ default: () => []
+ }
<div class="head">
<h3>我的频道<small>点击进入频道</small></h3>
<a class="edit" href="javascript:;">编辑</a>
</div>
<div class="body">
+ <a
+ href="javascript:;"
+ v-for="(item,i) in myChannels"
+ :key="item.id"
+ >
+ {{item.name}}
+ </a>
</div>
- 完成当前浏览频道激活,首页组件准备当前浏览频道索引,传人数据给频道组件。
src/views/home/index.vue
浏览频道索引,传给频道组件
data () {
return {
myChannels: [],
showChannel: false,
+ activeIndex: 0
}
},
+ <van-tabs animated v-model="activeIndex">
<van-tab :key="item.id" v-for="item in myChannels" :title="item.name">
<article-list :channelId="item.id"></article-list>
</van-tab>
</van-tabs>
<!-- 频道 -->
<article-channel
v-model="showChannel"
+ :activeIndex="activeIndex"
:myChannels="myChannels"
>
</article-channel>
src/views/home/components/article-channel.vue
使用浏览频道索引
props: {
value: {
type: Boolean,
default: false
},
myChannels: {
type: Array,
default: () => []
},
+ activeIndex: {
+ type: Number,
+ default: 0
+ }
}
<div class="head">
<h3>我的频道<small>点击进入频道</small></h3>
<a class="edit" href="javascript:;">编辑</a>
</div>
<div class="body">
<a
href="javascript:;"
+ :class="{active:activeIndex===i}"
v-for="(item,i) in myChannels"
:key="item.id"
>
{{item.name}}
</a>
</div>
总结: 我的频道和当前浏览频道索引均在首页组件,准备好之后传递给频道组件,使用即可。
# 04-频道-渲染可选频道
目的:完成可选频道的渲染
大致步骤:
- 知道可选频道数据:就是所有频道除去我的频道剩余频道。
- 获取所有频道数据
- 使用计算属性得到可选频道数据
- 进行数据渲染即可
落地代码:src/views/home/components/article-channel.vue
- 获取所有频道数据
data () {
return {
allChannels: []
}
},
created () {
this.getAllChannels()
},
methods: {
async getAllChannels () {
const [, res] = await getAllChannels()
this.allChannels = res.data.data.results
}
}
- 使用计算属性得到可选频道数据
computed: {
// 可选频道
optionalChannels () {
// 在myChannels中找不到的就是可选的
return this.allChannels.filter(item => !this.myChannels.find(c => c.id === item.id))
}
},
- 进行数据渲染即可
<div class="head" style="margin-top:12px">
<h3>频道推荐</h3>
</div>
<div class="body">
<a href="javascript:;" v-for="item in optionalChannels" :key="item.id">+ {{item.name}}</a>
</div>
总结: 使用计算属性,全部频道-我的频道=可选频道。
# 05-频道-点击进入频道
目的:频道组件点击频道,关闭频道对话框,首页切换到对应频道。技术点掌握sync的使用。
大致步骤:
- 点击频道将当前的点击按钮索引传递给父组件,父组件接收后修改tabs的值即可完成切换
- 如果传给父组件的事件时 update:属性名称,父组件传值 :属性名称。可以简写
属性名称.sync
落地代码:
- 完成点击进入频道功能
src/views/home/components/article-channel.vue
<a
href="javascript:;"
:class="{active:activeIndex===i}"
v-for="(item,i) in myChannels"
:key="item.id"
+ @click="enterChannel(i)"
>
// 进入频道
enterChannel (index) {
// 关闭对话框
this.$emit('input', false)
// 传递频道索引
this.$emit('update:activeIndex', index)
}
src/views/home/index.vue
<!-- 频道 -->
<article-channel
v-model="showChannel"
:myChannels="myChannels"
:activeIndex="activeIndex"
+ @update:activeIndex="activeIndex=$event"
>
</article-channel>
.sync
也是一个语法糖,可以简写:abc="数据"
@update:abc="数据=$event"
代码,其实这段代码就是实现了双向数据绑定,但是组件不能使用多个v-model
,所以提供了.sync
来简写代码。
<!-- 频道 -->
<article-channel
v-model="showChannel"
:myChannels="myChannels"
- :activeIndex="activeIndex"
- @update:activeIndex="activeIndex=$event"
+ :activeIndex.sync="activeIndex"
>
</article-channel>
总结: 知道 .sync
语法糖作用和简写代码的规则,:abc="数据"
@update:abc="数据=$event"
。
# 06-频道-支持本地操作
目的:未登录情况下获取我的频道都是写死的,服务端未提供修改接口,需要本地存储,本地修改。
大致步骤:
- 按照流程图在 原来的API函数实现
- 修改home组件中调用API函数的地方(因为数据结构会有变化)
落地代码:
- 按照流程图在 原来的API函数实现
src/api/channel.js
/**
* 获取我的频道(未登录会返回默认的一些频道)
*/
export const getMyChannels = async () => {
if (!store.state.user.token) {
const localData = JSON.parse(localStorage.getItem(KEY) || '[]')
if (localData.length) {
// 保持和res一样的结构,使用这个API的地方就无需修改
return localData
} else {
// 获取数据本地缓存
const [, res] = await request({ url: 'v1_0/user/channels' })
// 只存数组
localStorage.setItem(KEY, JSON.stringify(res.data.data.channels))
return res.data.data.channels
}
} else {
const [, res] = await request({ url: 'v1_0/user/channels' })
return res.data.data.channels
}
}
- 修改home组件中调用API函数的地方
src/views/home/index.vue
async created () {
// 不使用err不写err即可,但是,号需要写
const channels = await getMyChannels()
this.myChannels = channels
}
总结: 在API函数完成获取我的频道逻辑业务,降低组件的业务复杂的,提高可复用性。
补充: 登录状态的切换,需要更新频道数据。
watch: {
'$store.state.user.token': async function () {
const channels = await getMyChannels()
this.myChannels = channels
this.activeIndex = 0
}
}
# 07-频道-添加
目的:完成我的频道的添加操作,支持本地和线上。
大致步骤:
- 按照流程图 定义API函数实现
- 绑定点击事件,调用API函数添加
落地代码:
api/channel.js
API函数
/**
* 添加频道
* @param {Array<object>} myChannels - 我的频道集合
* @param {Number} myChannels.id - 频道ID
* @param {String} myChannels.name - 频道名称
* @param {Number} myChannels.seq - 频道名称
*/
export const addChannel = async (myChannels) => {
if (!store.state.user.token) {
// 1. 获取本地
const localData = JSON.parse(localStorage.getItem(KEY) || '[]')
// 2. 最后一项就是需要更新的
const { id, name } = myChannels[myChannels.length - 1]
// 3. 追加新频道
localData.push({ id, name })
// 4. 存储本地
localStorage.setItem(KEY, JSON.stringify(localData))
} else {
await request({
url: '/v1_0/user/channels',
method: 'put',
data: { channels: myChannels }
})
}
}
views/home/components/article-channel.vue
添加频道
<div class="head" style="margin-top:12px">
<h3>频道推荐</h3>
</div>
<div class="body">
+ <a @click="addChannel(item)" href="javascript:;" v-for="item in optionalChannels" :key="item.id">+ {{item.name}}</a>
</div>
// 添加频道
async addChannel (item) {
// 1. 使用重置式添加频道数据,准备重置式的数据
const newMyChannels = []
this.myChannels.forEach((c, i) => {
if (i !== 0) {
newMyChannels.push({
id: c.id,
name: c.name,
seq: i
})
}
})
newMyChannels.push({ ...item, seq: newMyChannels.length + 1 })
// 2. 去做添加频道操作
await addChannel(newMyChannels)
// 3. 成功:更新我的频道
this.myChannels.push(item)
}
总结: 再添加频道到API函数处理登录和未登录的添加操作,使用重置的方式更新我的频道,需要再点击添加频道按钮后自己组织数据。
# 08-频道-切换编辑
目的:显示删除按钮,完成编辑状态切换
大致步骤:
- 准备删除按钮
- 切换编辑状态
落地代码:
- 切换编辑
src/views/home/article-channel.vue
准备删除按钮(编辑状态,不是推荐,不是当前激活频道,才可以显示)
.body {
padding: 0 6px 0 16px;
a {
+ position: relative;
+ .geek-icon {
+ position: absolute;
+ top: -5px;
+ right: -5px;
+ line-height: 1;
+ }
<a
href="javascript:;"
:class="{active:activeIndex===i}"
v-for="(item,i) in myChannels"
:key="item.id"
@click="enterChannel(i)"
>
{{item.name}}
+ <geek-icon v-show="isEdit && i!==0 && i!== activeIndex" name="tag-close"></geek-icon>
</a>
切换编辑状态(关闭弹出层后,需要改成不编辑状态)
<div class="head">
<h3>我的频道<small>点击进入频道</small></h3>
+ <a class="edit" @click="isEdit=!isEdit" href="javascript:;" :class="{active:isEdit}">
+ {{isEdit?'完成':'编辑'}}
+ </a>
</div>
data () {
return {
// 全部频道
allChannels: [],
+ // 是否编辑
+ isEdit: false
}
},
<van-popup
:value="value"
@click-close-icon="$emit('input', false)"
closeable
position="left"
+ @closed="isEdit=false"
>
# 09-频道-删除
目的:完成我的频道的删除操作,支持本地和线上。
大致步骤:
- 按照流程图 定义API函数实现
- 绑定点击事件,调用API函数删除
落地代码:
- API函数
api/channel.js
/**
* 删除频道
* @param {Number} id - 频道ID
*/
export const delChannel = async (id) => {
if (!store.state.user.token) {
// 1. 获取本地
const localData = JSON.parse(localStorage.getItem(KEY) || '[]')
// 2. 删除频道
const index = localData.findIndex(item => item.id === id)
localData.splice(index, 1)
// 3. 存储本地
localStorage.setItem(KEY, JSON.stringify(localData))
} else {
await request({
url: '/v1_0/user/channels/' + id,
method: 'delete'
})
}
}
- 删除频道
views/home/components/article.vue
<!-- 给组件绑定click事件被认为是自定义事件不会被点击触发,需要加载native表示绑定的是原生事件,需要加上stop阻止事件冒泡触发进入频道的点击 -->
<geek-icon @click.native.stop="delChannel(item.id)"
// 删除频道
async delChannel (id) {
// 删除操作
await delChannel(id)
// 成功:更新我的频道
const index = this.myChannels.findIndex(item => item.id === id)
this.myChannels.splice(index, 1)
}
# 第六章:文章详情
# 01-文章详情-顶部导航
目的:完成文章详情布局,顶部导航
大致步骤:
- 完成跳转
- 顶部导航
落地代码:
- 完成跳转
src/views/home/components/article-item.vue
<div class="article-item van-hairline--bottom" @click="$router.push('/article?id='+article.art_id)">
- 顶部导航
src/views/article/index.vue
点击返回按钮回退历史,固定定位,右侧按钮通过right插槽使用,全局样式按钮黑色
<van-nav-bar left-arrow @click-left="$router.back()" fixed>
<template #right>
<van-icon name="ellipsis" size="5.4vw"></van-icon>
</template>
</van-nav-bar>
src/assets/styles/index.less
// 全局生效的样式
.van-nav-bar .van-icon{
color: #333;
}
src/main.js
import '@/assets/styles/index.less'
# 02-文章详情-主体内容
目的:完成文章详情布局,标题,时间,作者,内容
大致步骤:
- 使用基础结构
- 了解结构划分
落地代码:
src/views/article/index.vue
<!-- 文章主体 -->
<div class="article-wrapper">
<!-- 头部:标题 时间 作者 -->
<div class="header">
<h3 class="title">第二十二节:Java语言基础-详细讲解位运算符与流程控制语句</h3>
<div class="time">
<span>2019年03月11日</span>
<span>|</span>
<span>1186 阅读</span>
<span>|</span>
<span>61 评论</span>
</div>
<div class="author van-hairline--bottom">
<van-image
round
width="10vw"
height="10vw"
src="https://img01.yzcdn.cn/vant/cat.jpeg"
/>
<span class="name">黑马先锋</span>
<van-button round size="small" color="#FC6627">+关注</van-button>
</div>
</div>
<!-- 内容:文章内容 -->
<div class="main">
<div class="html">
<p v-for="i in 10" :key="i">我会把书籍分成两类,一类是全面型,一类是犀利型.前面介绍了一本全面型的书籍,接下来介绍的这本的特点是非常犀利,这类书籍的特点是作者能找对重点(2/8原则掌握的很好),在重点位置深入挖掘.这本书的作者John Resig也是JQuery的作者,他显然是个足够犀利的人儿.JQuery从未承诺解决所有问题,但再一些重点部位的突破,让这个类库如此流行.这本书并没有着重介绍JQuery,还是基于原生的JavaScript和DOM API.</p>
</div>
<div class="space"></div>
</div>
<!-- 评论:评论组件 -->
</div>
.article-wrapper {
height: 100%;
overflow-y: auto;
padding: 44px 0 50px;
// 头部
.header {
padding: 0 16px;
.title {
font-size: 20px;
font-weight: normal;
padding: 10px 0;
margin: 0;
}
.time {
font-size: 12px;
color: #999;
span:nth-child(2n) {
margin: 0 5px;
color: #ccc;
position: relative;
top: -1px;
}
}
.author {
display: flex;
align-items: center;
padding: 10px 0 ;
.name {
flex: 1;
padding-left: 10px;
font-size: 16px;
}
}
}
// 内容
.main {
.space {
height: 16px;
background: @geek-gray-color;
}
.html {
word-break: break-all;
width: 100%;
overflow: hidden;
padding: 20px 16px;
/deep/ img {
max-width:100%;
background: #f9f9f9;
}
/deep/ pre {
white-space: pre-wrap;
code {
white-space: pre;
}
}
}
}
}
src/assets/styles/index.less
* {
box-sizing: border-box;
}
总结: article-wrapper容器装:header main comment组件
# 03-文章详情-渲染页面
目的:获取文章详情数据进行渲染 7974 这个文章数据不错哦
大致步骤:
- 定义API接口函数
- 定义数据,获取数据
- 渲染页面
落地代码:
- 定义API接口函数
src/api/article.js
/**
* 获取文章详情
* @param {String} id - 文章ID
* @returns
*/
export const getArticle = (id) => {
return request({
url: '/v1_0/articles/' + id
})
}
- 定义数据,获取数据
src/views/article/index.vue
import { getArticle } from '@/api/article'
export default {
name: 'ArticlePage',
data () {
return {
article: {}
}
},
created () {
this.getArticle()
},
methods: {
async getArticle () {
const [, res] = await getArticle(this.$route.query.id)
this.article = res.data.data
}
}
}
- 渲染页面
src/views/article/index.vue
<!-- 文章主体 -->
<div class="article-wrapper">
<!-- 头部:标题 时间 作者 -->
<div class="header">
<h3 class="title">{{article.title}}</h3>
<div class="time">
<span>{{article.pubdate}}</span>
<span>|</span>
<span>{{article.read_count}} 阅读</span>
<span>|</span>
<span>{{article.comm_count}} 评论</span>
</div>
<div class="author van-hairline--bottom">
<van-image round width="10vw" height="10vw" :src="article.aut_photo"/>
<span class="name">{{article.aut_name}}</span>
<van-button round size="small" color="#FC6627">+ 关注</van-button>
</div>
</div>
<!-- 内容:文章内容 -->
<div class="main">
<div class="html" v-html="article.content"></div>
<div class="space"></div>
</div>
<!-- 评论:评论组件 -->
</div>
总结: 富文本内容使用v-html渲染
# 04-文章详情-导航交互
目的:完成在滚动页面时候,头部被卷起后,在顶部导航显示作者信息。
大致步骤:
- 准备顶部导航作者信息
- 监听滚动,显示隐藏作者信息
落地代码:
- 准备顶部导航作者信息
src/views/article/index.vue
<!-- 导航 -->
<van-nav-bar left-arrow @click-left="$router.back()" fixed>
+ <template #title>
+ <div class="nav-author">
+ <van-image round width="7vw" height="7vw" :src="article.aut_photo"/>
+ <span class="name">{{article.aut_name}}</span>
+ <span class="line">|</span>
+ <span class="follow">关注</span>
+ </div>
+ </template>
<template #right>
<van-icon name="ellipsis" size="5.4vw"></van-icon>
</template>
</van-nav-bar>
/deep/ .van-nav-bar__title {
max-width: 270px;
width: 270px;
}
.nav-author {
display: flex;
justify-content: flex-start;
align-items: center;
> span {
font-size: 14px;
padding-left: 5px;
}
.line {
color: #ccc;
position: relative;
top: -1px;
}
.follow {
color: @geek-color;
}
}
- 监听滚动,显示隐藏作者信息
src/views/article/index.vue
<!-- 文章主体 -->
<div class="article-wrapper" ref="wrapper" @scroll="onScroll">
<!-- 头部:标题 时间 作者 -->
<div class="header" ref="header">
data () {
return {
article: {},
showNavAuthor: false
}
},
methods: {
// 监听滚动
onScroll () {
const scrollTop = this.$refs.wrapper.scrollTop
const headerHeight = this.$refs.header.offsetHeight
this.showNavAuthor = scrollTop > headerHeight
},
<template #title>
<div class="nav-author" v-show="showNavAuthor">
总结: 通过ref获取wrapper的卷起高度 比较 header的高度,大于就显示头部导航的作者信息
# 05-文章详情-关注作者
目的:完成关注作者和取消关注功能
大致步骤:
- 定义关注作者和取消关注的API接口函数
- 绑定点击事件,完成关注和取消关注操作
- 切换关注按钮的显示
落地代码:
- API接口函数
src/api/user.js
/**
* 关注 和 取消关注
* @param {*} authorId - 作者ID
* @param {*} isFollow - 是否关注
* @returns Promise
*/
export const followAuthor = (authorId, isFollow) => {
if (isFollow) {
return request({
url: '/v1_0/user/followings',
method: 'post',
data: { target: authorId }
})
} else {
return request({
url: '/v1_0/user/followings/' + authorId,
method: 'delete'
})
}
}
- 组件中处理:关注 和 取消关注
src/views/article/index.vue
// 关注&取消关注
async followAuthor () {
const newStatus = !this.article.is_followed
const [err] = await followAuthor(this.article.aut_id, newStatus)
if (err) {
return this.$toast.success('操作失败')
}
this.$toast.success(newStatus ? '关注成功' : '取消关注')
this.article.is_followed = newStatus
},
- 切换按钮
<span @click="followAuthor()" class="follow" :class="{un:article.is_followed}">
{{article.is_followed?'取消关注':'关注'}}
</span>
<van-button v-if="article.is_followed" @click="followAuthor()" round size="small" >取消关注</van-button>
<van-button v-else @click="followAuthor()" round size="small" color="#FC6627">+ 关注</van-button>
.follow {
color: @geek-color;
+ // 不加& .follow .un 后代选择器
+ // 加上& .follow.un 交集选择器
+ &.un {
+ color: #999;
+ }
}
# 06-文章详情-骨架效果
目的:在文章加载过程中加上骨架效果
大致步骤:
- 定义一个加载中状态数据
- 再请求前改成加载中,请求后改成加载完成
- 根据数据显示骨架效果
落地代码: src/views/article/index.vue
- 数据
data () {
return {
article: {},
showNavAuthor: false,
+ loading: false
}
},
- 状态设置
// 文章详情
async getArticle () {
+ this.loading = true
const [, res] = await getArticle(this.$route.query.id)
this.article = res.data.data
+ this.loading = false
}
- 骨架效果
<!-- 骨架组件 -->
<div v-if="loading" class="article-skeleton">
<van-skeleton title :row="12" />
</div>
<!-- 文章主体 -->
<div v-else class="article-wrapper" ref="wrapper" @scroll="onScroll">
.article-skeleton {
padding-top: 60px;
}
# 07-文章详情-代码高亮
目的:技术类文章,会有code标签写的代码,给代码加上高亮样式
大致步骤:
- 安装
highlight.js
插件 - 分析使用过程:先有html结构,使用插件给结构加样式。
- 决定封装成指令
落地代码:
- 安装
npm i highlight.js@10.7.2
- 使用分析
使用:
// 文档结构加载完毕事件
document.addEventListener('DOMContentLoaded', (event) => {
// 找到所有的 pre 下的 code 标签
document.querySelectorAll('pre code').forEach((el) => {
// 转化成代码结构的html标签
hljs.highlightElement(el);
});
})
风格:
// 存放风格样式文件目录
geek-client-mobile\node_modules\highlight.js\styles
- 指令封装
src/components/index.js
由于是某一个标签内的结构需要加上样式需要操作dom,而且需要内容渲染完毕后去操作,使用指令比较合适。
import hljs from 'highlight.js'
import 'highlight.js/styles/vs2015.css'
// install函数 定义全局指令
Vue.directive('highlight', (el) => {
const codeList = el.querySelectorAll('pre code')
codeList.forEach((code) => {
hljs.highlightElement(code)
})
})
- 使用指令
src/views/article/index.vue
<!-- 内容:文章内容 -->
<div class="main" v-html="article.content" v-highlight></div>
总结:使用自定义指令来实现代码高亮效果。
# 第七章:评论回复
# 01-文章评论-组件布局
目的:准备评论回复组件的基础布局结构,了解结构
大致步骤:
- 准备基础布局结构
- 使用组件
- 分析了解布局结构
落地代码:src/views/article/components/article-comment.vue
<template>
<div class="article-comment">
<!-- 全部评论 -->
<van-sticky offset-top="11.73333vw">
<div class="title van-hairline--bottom">
<span>全部评论 (0)</span>
<span>0 点赞</span>
</div>
</van-sticky>
<!-- 评论列表 -->
<div class="list">
<div class="item van-hairline--bottom" v-for="i in 10" :key="i">
<van-image round width="10vw" height="10vw" src="https://img01.yzcdn.cn/vant/cat.jpeg"/>
<div class="info">
<p>
<span class="name">清风徐来</span>
<span class="zan">0 <geek-icon name="like2" /></span>
</p>
<p class="cont">说的不错!</p>
<p>
<span class="reply">回复 <i class="van-icon van-icon-arrow"></i></span>
<span class="time">2小时内</span>
</p>
</div>
</div>
</div>
<!-- 底部工具 -->
<div class="footer"></div>
</div>
</template>
<script>
export default {
name: 'ArticleComment'
}
</script>
<style scoped lang="less">
.article-comment {
.title {
height: 50px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16px;
background: #fff;
span {
font-size: 16px;
&:last-child {
color: #ccc;
font-size: 14px;
}
}
}
.list {
padding: 0 16px;
.item {
display: flex;
padding: 10px 0;
.info {
padding-left: 10px;
flex: 1;
p {
margin: 0;
.name {
font-size: 16px;
}
.zan {
font-size: 14px;
float: right;
color: #999;
.geek-icon {
font-size: 12px;
position: relative;
top: -1px;
}
}
&.cont {
font-size: 14px;
color: #666;
padding: 10px 0;
word-break: break-all;
padding-right: 40px;
}
.reply {
min-width: 60px;
height: 24px;
text-align: center;
line-height: 28px;
font-size: 12px;
background: @geek-gray-color;
display: inline-block;
border-radius: 14px;
color: #666;
.van-icon {
position: relative;
top: 1px;
}
}
.time {
font-size: 12px;
color: #999;
margin-left: 10px;
}
}
}
}
}
}
</style>
- 使用组件
src/views/article/index.vue
<!-- 评论:评论组件 -->
<article-comment />
import ArticleComment from './components/article-comment.vue'
export default {
name: 'ArticlePage',
components: { ArticleComment },
- 分析了解布局结构
1. 全部评论,盒子需要滚动到顶部固定定位,使用van-sticky offset-top="11.73333vw"
2. 评论列表,
3. 底部工具
# 02-文章评论-渲染列表
目的:完成文章评论列表上拉加载,渲染列表
大致步骤:
- 使用van-list组件完成上拉加载效果,初始化就加载一次
- 定义API函数获取文章评论
- 使用API函数获取数据,完成列表渲染
落地代码:
- 使用van-list组件完成上拉加载效果
src/views/article/components/article-comment.vue
<!-- 评论列表 -->
<div class="list">
+ <van-list v-model="loading" :finished="finished" finished-text="没有评论了" @load="onLoad">
<div class="item van-hairline--bottom" v-for="i in 10" :key="i">
<van-image round width="10vw" height="10vw" src="https://img01.yzcdn.cn/vant/cat.jpeg"/>
<div class="info">
<p>
<span class="name">清风徐来</span>
<span class="zan">0 <geek-icon name="like2" /></span>
</p>
<p class="cont">说的不错!</p>
<p>
<span class="reply">回复 <i class="van-icon van-icon-arrow"></i></span>
<span class="time">2小时内</span>
</p>
</div>
</div>
+ </van-list>
</div>
data () {
return {
loading: false,
finished: false
}
},
methods: {
onLoad () {
console.log('加载评论')
}
}
- 定义API函数获取文章评论
src/api/article.js
/**
* 获取文章评论
* @param {String} articleId - 文章ID
* @param {String} offset - 上一页数据最后一个ID,做为下一页请求的偏移量
*/
export const getCommentsByArticle = (articleId, offset) => {
return request({
url: '/v1_0/comments',
params: { type: 'a', source: articleId, offset }
})
}
- 使用API函数获取数据,完成列表渲染
传人文章对象 src/views/article/index.vue
<!-- 评论:评论组件 -->
<article-comment :article="article" />
获取数据 src/views/article/components/article-comment.vue
import { getCommentsByArticle } from '@/api/articles'
data () {
return {
loading: false,
finished: false,
offset: null,
comments: []
}
},
methods: {
async onLoad () {
// 获取数据
const [, res] = await getCommentsByArticle(this.article.art_id, this.offset)
// 这次数据最后一条ID和所有数据最后一条ID相同,没有数据了
if (res.data.data.last_id === res.data.data.end_id) {
// 加载完毕
this.finished = true
} else {
// 记录偏移量,为下次请求准备
this.offset = res.data.data.last_id
}
// 追加评论数据
this.comments.push(...res.data.data.results)
// 结束加载状态
this.loading = false
}
}
进行渲染列表 src/views/article/components/article-comment.vue
<div class="item van-hairline--bottom" v-for="item in comments" :key="item.com_id">
<van-image round width="10vw" height="10vw" :src="item.aut_photo"/>
<div class="info">
<p>
<span class="name">{{item.aut_name}}</span>
<span class="zan">{{item.like_count}}
<geek-icon :name="item.is_liking?'like-sel':'like2'" />
</span>
</p>
<p class="cont">{{item.content}}</p>
<p>
<span class="reply">{{item.reply_count}}回复 <i class="van-icon van-icon-arrow"></i></span>
<span class="time">{{item.pubdate|relativeTime}}</span>
</p>
</div>
</div>
渲染总评数量
<div class="title van-hairline--bottom">
<span>全部评论 ({{article.comm_count}})</span>
<span>{{article.like_count}} 点赞</span>
</div>
# 03-文章评论-底部工具
目的:完成底部工具栏的布局和渲染。
大致步骤:
- 完成底部工具栏布局,了解结构和样式
- 使用 article 完成渲染
落地代码: src/views/article/components/article-comment.vue
- 工具栏结构
<!-- 底部工具 -->
<div class="footer van-hairline--top">
<div class="input"><i class="van-icon van-icon-edit"></i></div>
<div class="btn"><geek-icon name="comment"></geek-icon><p>评论</p><i>0</i></div>
<div class="btn"><geek-icon name="like"></geek-icon><p>点赞</p></div>
<div class="btn"><geek-icon name="collect"></geek-icon><p>收藏</p></div>
<div class="btn"><geek-icon name="share"></geek-icon><p>分享</p></div>
</div>
- 工具栏样式
.footer {
position: fixed;
left: 0;
bottom: 0;
height: 50px;
background: #fff;
display: flex;
width: 100%;
align-items: center;
.input {
margin-left: 10px;
width: 200px;
height: 34px;
background: @geek-gray-color;
border-radius: 17px;
line-height: 36px;
padding-left: 10px;
.van-icon {
color: #999;
}
}
.btn {
flex: 1;
text-align: center;
position: relative;
p {
margin: 0;
font-size: 10px;
}
.geek-icon {
font-size: 18px;
}
i {
height: 16px;
min-width: 16px;
padding: 0 3px;
background: @geek-color;
color: #fff;
font-size: 10px;
position: absolute;
right: 0;
top: -4px;
line-height: 16px;
border-radius: 8px;
font-style: normal;
}
}
}
- 完成渲染
<!-- 底部工具 -->
<div class="footer van-hairline--top">
<div class="input"><i class="van-icon van-icon-edit"></i></div>
<div class="btn">
<geek-icon name="comment"></geek-icon><p>评论</p>
<i v-if="article.comm_count">{{article.comm_count}}</i>
</div>
<div class="btn">
<geek-icon :name="article.attitude===1?'like-sel':'like'"></geek-icon>
<p>点赞</p>
</div>
<div class="btn">
<geek-icon :name="article.is_collected?'collect-sel':'collect'"></geek-icon>
<p>收藏</p>
</div>
<div class="btn"><geek-icon name="share"></geek-icon><p>分享</p></div>
</div>
注意: attitude === 1 代表点赞,其他代表未点赞。可以通过dev-tools切换数据,测试状态。
# 04-文章评论-传送功能
目的:评论按钮滚动到评论区,再次点击回到顶部。
大致步骤:
- 分析:评论区位置 = 头部高度 + 内容高度 , 组件初始化就需要加载评论。
index.vue
组件操作滚动方便,article-wrapper
是滚动容器- 点击评论按钮,触发自定义时间,父组件给
article-commont
绑定,进行切换位置
落地代码:
- 触发事件
src/views/article/components/article-comment.vue
+ <div class="btn" @click="$emit('click-comment')">
<geek-icon name="comment"></geek-icon><p>评论</p>
<i v-if="article.comm_count">{{article.comm_count}}</i>
</div>
- 绑定事件
src/views/article/index.vue
data () {
return {
article: {},
showNavAuthor: false,
loading: false,
// 是否滚动到评论位置
+ toComment: false
}
},
<!-- 评论:评论组件 -->
<article-comment :article="article" @click-comment="srollToComment" />
<!-- 内容:文章内容 -->
<div class="main" ref="main">
// 滚动到评论
srollToComment () {
const headerHeight = this.$refs.header.offsetHeight
const mainHeight = this.$refs.main.offsetHeight
// 是否滚动到评论,切换状态
this.toComment = !this.toComment
// 来回切换
if (this.toComment) {
this.$refs.wrapper.scrollTop = headerHeight + mainHeight
} else {
this.$refs.wrapper.scrollTop = 0
}
},
- 默认加载评论(否则页面下面没数据撑开高度,滚动不过去)
src/views/article/components/article-comment.vue
created () {
// 开启加载中效果
this.loading = true
this.onLoad()
},
# 05-文章评论-点赞
目的:对文章点赞,取消点赞。
大致步骤:
- 定义给文章点赞API
- 绑定点击,完成点赞
落地代码:
- API函数
src/api/article.js
/**
* 点赞文章,取消点赞
* @param {String} articleId - 文章ID
* @param {Boolean} isLike - 是否点赞
*/
export const likeArticle = (articleId, isLike) => {
if (isLike) {
return request({
url: '/v1_0/article/likings',
method: 'post',
data: { target: articleId }
})
} else {
return request({
url: '/v1_0/article/likings/' + articleId,
method: 'delete'
})
}
}
- 处理逻辑
src/views/article/components/article-comment.vue
<div class="btn" @click="likeArticle">
<geek-icon :name="article.attitude===1?'like-sel':'like'"></geek-icon>
<p>点赞</p>
</div>
+ import { getCommentsByArticle, likeArticle } from '@/api/articles'
methods: {
async likeArticle () {
if (this.article.attitude === 1) {
const [err] = await likeArticle(this.article.art_id, false)
if (err) return this.$toast.fail('操作失败')
this.article.attitude = -1
} else {
const [err] = await likeArticle(this.article.art_id, true)
if (err) return this.$toast.fail('操作失败')
this.article.attitude = 1
}
this.$toast.success('操作成功')
},
# 06-文章评论-收藏
目标:对文章收藏,取消收藏。
大致步骤:
- 定义给收藏文章API
- 绑定点击,完成收藏
落地代码:
- API函数
src/api/article.js
/**
* 收藏文章,取消收藏
* @param {String} articleId - 文章ID
* @param {Boolean} isCollect - 是否收藏
*/
export const collectArticle = (articleId, isCollect) => {
if (isCollect) {
return request({
url: '/v1_0/article/collections',
method: 'post',
data: { target: articleId }
})
} else {
return request({
url: '/v1_0/article/collections/' + articleId,
method: 'delete'
})
}
}
- 处理逻辑
<div class="btn" @click="collectArticle">
<geek-icon :name="article.is_collected?'collect-sel':'collect'"></geek-icon>
<p>收藏</p>
</div>
+ import { getCommentsByArticle, likeArticle, collectArticle } from '@/api/articles'
async collectArticle () {
const [err] = await collectArticle(this.article.art_id, !this.article.is_collected)
if (err) return this.$toast.fail('操作失败')
this.article.is_collected = !this.article.is_collected
this.$toast.success('操作成功')
},
# 07-评论回复-回复弹层
目的:完成点击回复按钮,弹出回复页面,完成页面布局。
大致步骤:
准备弹窗,点击回复切换
完成头部,和底部
完成回复列表
落地代码: src/views/article/components/article-comment.vue
- 准备弹窗
<!-- 回复弹层 -->
<van-popup v-model="reply.open" position="right">
<!-- 头 -->
<van-nav-bar left-arrow @click-left="reply.open=false" title="0条回复" />
<div class="reply-wrapper list">
<!-- 列表 -->
<div class="item van-hairline--bottom" v-for="i in 10" :key="i">
<van-image round width="10vw" height="10vw" src="https://img01.yzcdn.cn/vant/cat.jpeg"/>
<div class="info">
<p>
<span class="name">清风徐来</span>
<span class="zan">0 <geek-icon name="like2" /></span>
</p>
<p class="cont">说的不错!</p>
<p><span class="time" style="margin-left:0">2小时内</span></p>
</div>
</div>
</div>
<!-- 底 -->
<div class="footer van-hairline--top" style="position:static">
<div class="input big"><i class="van-icon van-icon-edit"></i></div>
<div class="btn">
<geek-icon name="collect"></geek-icon><p>收藏</p>
</div>
<div class="btn"><geek-icon name="share"></geek-icon><p>分享</p></div>
</div>
</van-popup>
.input {
+ &.big {
+ width: 260px;
+ }
// 最下面加如下样式
.van-popup {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.reply-wrapper {
width: 100%;
height: 100%;
overflow-y: auto;
}
data () {
return {
loading: false,
finished: false,
offset: null,
comments: [],
+ // 回复业务需要的数据
+ reply: {
+ open: false
+ }
}
},
- 点击回复打开
<span class="reply" @click="clickReply(item)">
clickReply (item) {
// 打开评论弹窗
this.reply.open = true
},
# 08-评论回复-列表渲染
目的:完成评论的回复列表数据获取和渲染
大致步骤:
- 编写获取回复列表API函数
- 打开对话框记录当前被回复的评论信息对象,打开的时候,需要重置数据
- 使用van-list加载数据,但是不能默认加载,当打开回复对话框
落地代码:
- API函数
src/api/article.js
/**
* 获取评论回复
* @param {String} commentId - 评论ID
* @param {String} offset - 上一页数据最后一个ID,做为下一页请求的偏移量
*/
export const getReplysByComment = (commentId, offset) => {
return request({
url: '/v1_0/comments',
params: { type: 'c', source: commentId, offset }
})
}
- 打开对话框
src/views/article/components/article-comment.vue
1)准备数据
// 回复业务需要的数据
currComments: {},
reply: {
open: false,
loading: false,
finished: false,
offset: null,
list: []
}
2)打开组件
clickReply (item) {
// 记录当前评论
this.currComments = item
// 打开评论弹窗
this.reply.open = true
// 加载效果
this.reply.loading = true
// 重置加载完毕
this.reply.finished = false
// 重置数据
this.reply.list = []
// 重置请求参数
this.reply.offset = null
// 开启加载
this.loadReply()
},
<van-list :immediate-check="false" v-model="reply.loading" :finished="reply.finished" finished-text="没有回复了" @load="loadReply">
3)获取数据:
+ import { getCommentsByArticle, likeArticle, collectArticle, getReplysByComment } from '@/api/article'
async loadReply () {
const [, res] = await getReplysByComment(this.currComments.com_id, this.reply.offset)
this.reply.list.push(...res.data.data.results)
if (res.data.data.end_id === res.data.data.last_id) {
this.reply.finished = true
} else {
this.reply.offset = res.data.data.last_id
}
this.reply.loading = false
},
4)进行渲染:
<!-- 列表 -->
<div class="item van-hairline--bottom" v-for="item in reply.list" :key="item.com_id">
<van-image round width="10vw" height="10vw" :src="item.aut_photo"/>
<div class="info">
<p>
<span class="name">{{item.aut_name}}</span>
<span class="zan">{{item.like_count}} <geek-icon name="like2" /></span>
</p>
<p class="cont">{{item.content}}</p>
<p><span class="time" style="margin-left:0">{{item.pubdate|relativeTime}}</span></p>
</div>
</div>
# 09-填写文章评论
目的:发表对文章评论
大致步骤:
- 准备填写文字的弹出框
- 定义API接口函数,点击发表提交评论
落地代码:
- 准备填写文字的弹出框
src/views/article/components/article-comment.vue
1)弹出框
<!-- 评论&回复 -->
<van-popup v-model="showInput" position="bottom">
<van-nav-bar left-arrow @click-left="showInput=false" title="评论文章" right-text="发表" />
<van-field
v-model="text"
rows="3"
autosize
type="textarea"
maxlength="100"
placeholder="请输入评论"
show-word-limit
/>
</van-popup>
::v-deep .van-nav-bar__text {
color: @geek-color;
}
::v-deep .van-field__control {
background: @geek-gray-color;
padding: 5px 10px;
margin-bottom: 5px;
border-radius: 4px;
}
2)数据
// 显示输入弹窗
showInput: false,
// 输入框值
text: ''
3)打开对话框
<!-- 底部工具 -->
<div class="footer van-hairline--top">
<div class="input" @click="showInput=true">
- 定义API接口函数,点击发表提交评论,更新数据,关闭对话框
src/api/article.js
/**
* 对文章进行评论,对评论进行回复
* @param {*} target - 评论:文章ID,回复:评论ID
* @param {*} content - 内容
* @param {*} articleId - 回复:文章ID
* @returns
*/
export const commentOrReply = (target, content, articleId = null) => {
return request({
url: '/v1_0/comments',
method: 'post',
data: { target, content, art_id: articleId }
})
}
src/views/article/components/article-comment.vue
<van-nav-bar
left-arrow
@click-left="showInput=false"
title="评论文章"
right-text="发表"
+ @click-right="submit()"
/>
import {
getCommentsByArticle,
likeArticle,
collectArticle,
getReplysByComment,
+ commentOrReply
} from '@/api/articles'
async submit () {
// 1. 提交文章评论
// 1.1. 校验是否输入内容
// 1.2. 调用接口
// 1.3. 失败提示
// 1.4. 更新评论列表+更新评论数量+关闭对话框+清除输入内容+成功提示
if (!this.text) return this.$toast('请输入评论')
const [err, res] = await commentOrReply(this.article.art_id, this.text)
if (err) return this.$toast.fail('评论失败')
this.comments.unshift(res.data.data.new_obj)
this.article.comm_count++
this.showInput = false
this.text = ''
this.$toast.success('评论成功')
},
# 10-填写评论回复
目的:发表对评论的回复
大致步骤:
- 关闭回复列表弹出框清除评论对象,(判断此操作是回复还是评论,根据对象是否有数据)
- 点击发表按钮,在
submit
函数,合并提交回复功能
落地代码:
- 关闭回复列表弹出框清除评论对象,动态渲染输入框 placeholder 和 弹出框标题
<!-- 回复弹层 -->
<van-popup v-model="reply.open" @closed="currComments={}" position="right">
<!-- 评论&回复 -->
<van-popup v-model="showInput" position="bottom">
<van-nav-bar
left-arrow
@click-left="showInput=false"
+ :title="currComments.com_id?'回复评论':'评论文章'"
right-text="发表"
@click-right="submit()"
/>
<van-field
v-model="text"
rows="3"
autosize
type="textarea"
maxlength="100"
+ :placeholder="currComments.com_id?`@${currComments.aut_name}`:'请输入评论'"
show-word-limit
/>
</van-popup>
- 合并提交回复功能
async submit () {
+ if (!this.currComments.com_id) {
// 1. 提交文章评论
// 1.1. 校验是否输入内容
// 1.2. 调用接口
// 1.3. 失败提示
// 1.4. 更新评论列表+更新评论数量+关闭对话框+清除输入内容+成功提示
if (!this.text) return this.$toast('请输入评论')
const [err, res] = await commentOrReply(this.article.art_id, this.text)
if (err) return this.$toast.fail('评论失败')
this.comments.unshift(res.data.data.new_obj)
this.article.comm_count++
this.showInput = false
this.text = ''
this.$toast.success('评论成功')
+ } else {
+ // 2. 提交评论回复
+ // 2.1. 校验是否输入内容
+ // 2.2. 调用接口
+ // 2.3. 失败提示
+ // 2.4. 更新回复列表+更新评论数量+更新回复数量+关闭对话框+清除输入内容+成功提示
+ if (!this.text) return this.$toast('请输入回复')
+ const [err, res] = await commentOrReply(this.currComments.com_id, this.text, this.article.art_id)
+ if (err) return this.$toast.fail('回复失败')
+ this.reply.list.unshift(res.data.data.new_obj)
+ this.article.comm_count++
+ this.currComments.reply_count++
+ this.showInput = false
+ this.text = ''
+ this.$toast.success('回复成功')
+ }
},
# 第八章:个人中心
# 01-个人中心-基础布局
目的:完成个人中心首页基础布局
大致步骤:
- 根据笔记完成布局
- 了解布局结构样式
落地代码: src/user/index.vue
- 根据笔记完成布局
<template>
<div class='user-page'>
<div class="user-profile">
<div class="info">
<van-image round fit="cover" src="https://img01.yzcdn.cn/vant/cat.jpeg" />
<h3 class="name">清风徐来</h3>
<router-link class="btn" to="/user/profile">个人信息<van-icon name="arrow" /></router-link>
</div>
<van-row>
<van-col span="6">
<p>0</p>
<p>动态</p>
</van-col>
<van-col span="6">
<p>0</p>
<p>关注</p>
</van-col>
<van-col span="6">
<p>0</p>
<p>粉丝</p>
</van-col>
<van-col span="6">
<p>0</p>
<p>被赞</p>
</van-col>
</van-row>
<van-row class="user-links">
<van-col span="6">
<geek-icon name="message"/>消息通知
</van-col>
<van-col span="6">
<geek-icon name="mycollect"/>我的收藏
</van-col>
<van-col span="6">
<geek-icon name="history"/>阅读历史
</van-col>
<van-col span="6">
<geek-icon name="myworks"/>我的作品
</van-col>
</van-row>
</div>
<div class="more">
<p>更多服务</p>
<van-row>
<van-col span="6">
<geek-icon name="feedback2"/>用户反馈
</van-col>
<van-col span="6">
<geek-icon @click.native="$router.push('/user/chat')" name="xiaozhi"/>小智同学
</van-col>
<van-col span="6">
</van-col>
<van-col span="6">
</van-col>
</van-row>
</div>
</div>
</template>
<script>
export default {
name: 'UserPage'
}
</script>
<style scoped lang='less'></style>
.user-page {
background: #fafafa;
height: 100%;
}
.more {
margin: 0 15px;
background-color: #fff;
border-radius: 10px;
height: 120px;
p {
font-size: 16px;
padding: 10px 15px;
margin: 0;
}
.van-row {
padding: 15px 0;
font-size: 12px;
text-align: center;
.geek-icon {
display: block;
font-size: 22px;
padding-bottom: 5px;
}
}
}
.user {
&-profile {
width: 100%;
padding: 0 15px;
height: 240px;
display: block;
background: linear-gradient(318deg,#B2B5DB 0%,#565482 70%,#494675 100%);
color: #fff;
border-bottom-left-radius: 400px 60px;
border-bottom-right-radius: 400px 60px;
margin-bottom: 48px;
.info {
display: flex;
padding: 40px 0;
height: 130px;
align-items: center;
.van-image {
width: 50px;
height: 50px;
border: 3px solid #7674a2;
}
.name {
font-size: 20px;
font-weight: normal;
margin-left: 10px;
flex: 1;
}
.btn {
color: #fff;
line-height: 1;
font-size: 12px;
.van-icon {
position: relative;
top: 1px;
}
}
}
p {
margin: 0;
text-align: center;
height: 20px;
}
}
&-links {
padding: 20px 0;
font-size: 12px;
text-align: center;
background-color: #fff;
border-radius: 10px;
color: #333;
position: relative;
top: 20px;
.geek-icon {
display: block;
font-size: 22px;
padding-bottom: 5px;
}
}
}
- 了解布局结构样式
1. info 作者信息
2. van-row 统计信息
3. van-row.user-links 用户信息相关链接
4. .more 更多服务
# 02-个人中心-渲染页面
目的:获取当前用户信息渲染页面
大致步骤:
- 定义API函数
- 组件初始获取数据
- 进行渲染
落地代码:
- 定义API函数
src/api/user.js
/**
* 获取当前用户的信息(资料和统计)
*/
export const getUserInfo = () => {
return request({ url: '/v1_0/user' })
}
- 个人中心组件初始化获取数据
src/views/user/index.vue
import { getUserInfo } from '@/api/user'
export default {
name: 'UserPage',
data () {
return {
user: {}
}
},
created () {
this.getUserInfo()
},
methods: {
// 获取个人信息
async getUserInfo () {
const [, res] = await getUserInfo()
this.user = res.data.data
}
}
}
- 进行渲染
<div class="info">
<van-image round fit="cover" :src="user.photo" />
<h3 class="name">{{user.name}}</h3>
<router-link class="btn" to="/user/profile">个人信息<van-icon name="arrow" /></router-link>
</div>
<van-row>
<van-col span="6">
<p>{{user.art_count}}</p>
<p>动态</p>
</van-col>
<van-col span="6">
<p>{{user.follow_count}}</p>
<p>关注</p>
</van-col>
<van-col span="6">
<p>{{user.fans_count}}</p>
<p>粉丝</p>
</van-col>
<van-col span="6">
<p>{{user.like_count}}</p>
<p>被赞</p>
</van-col>
</van-row>
# 03-编辑资料-页面布局
目的:完成编辑资料页面布局,了解结构
大致步骤:
- 配置路由组件
- 根据笔记完成布局
- 了解布局结构样式
落地代码:
- 组件
src/views/user/profile/index.vue
<template>
<div class='user-profile-page'>
<van-nav-bar left-arrow @click-left="$router.back()" title="个人信息"></van-nav-bar>
<van-cell-group>
<van-cell is-link title="头像" center>
<van-image
slot="default"
fit="cover"
round
src="https://img01.yzcdn.cn/vant/cat.jpeg"
/>
</van-cell>
<van-cell is-link title="昵称" value="清风徐来" />
</van-cell-group>
<van-cell-group style="margin-top:12px">
<van-cell is-link title="性别" value="男" />
<van-cell is-link title="生日" value="2020-10-10" />
</van-cell-group>
<div class="logout">
<span>退出登录</span>
</div>
</div>
</template>
<script>
export default {
name: 'UserProfilePage'
}
</script>
<style lang="less" scoped>
.user-profile-page {
background: #f8f8f8;
.van-image {
display: block;
float: right;
width: 30px;
height: 30px;
}
.van-cell__title {
width: 50px;
flex: none;
}
}
.logout {
text-align: center;
position: absolute;
left: 0;
bottom: 30px;
width: 100%;
color: @geek-color;
}
</style>
- 路由
src/router/index.js
const UserProfile = () => import('@/views/user/profile')
{ path: '/user/profile', component: UserProfile }
# 04-编辑资料-回显数据
目的,获取个人资料回显页面
大致步骤:
- 定义API函数
- 组件初始获取数据
- 进行渲染
落地代码:
- 定义API函数
src/api/user.js
/**
* 获取当前用户的资料
*/
export const getUserProfile = () => {
return request({ url: '/v1_0/user/profile' })
}
- 组件初始获取数据
src/views/user/profile.vue
import { getUserProfile } from '@/api/user'
export default {
name: 'UserProfilePage',
data () {
return {
user: {}
}
},
created () {
this.getUserProfile()
},
methods: {
async getUserProfile () {
const [, res] = await getUserProfile()
this.user = res.data.data
}
}
}
- 进行渲染
src/views/user/profile.vue
<van-cell-group>
+ <van-cell is-link title="头像" center>
<van-image
slot="default"
fit="cover"
round
:src="user.photo"
/>
</van-cell>
+ <van-cell is-link title="昵称" :value="user.name||'未填写'" />
</van-cell-group>
<van-cell-group style="margin-top:12px">
+ <van-cell is-link title="性别" :value="user.gender===0?'男':'女'" />
+ <van-cell is-link title="生日" :value="user.birthday||'未填写'" />
</van-cell-group>
# 05-编辑资料-退出登录
目的:退出登录
大致步骤:
- 绑定事件
- 处理函数,弹出确认框
- 确认,删除token,跳转首页,提示退出
落地代码:
- 绑定事件
<span @click="logout">退出登录</span>
- 弹出确认框
logout () {
this.$dialog.confirm({
title: '温馨提示',
message: '您确认退出极客园吗?',
theme: 'round-button'
}).then(() => {
this.$store.commit('user/setToken', '')
this.$router.push('/')
this.$toast.success('退出登录')
}).catch(() => {})
}
# 06-编辑资料-修改头像
目的:点击头像,弹出选项,点击本地上传,完成修改头像
大致步骤:
- 使用vant的动作面板组件,显示 本地上传 拍照 取消 选项
- 调用本地上传,触发 input type="file" 的点击事件
- 监听选择文件完成,调用封装号的API上传函数
- 失败提示,成功(更新数据+提示)
大致步骤:
- api函数
src/api/user.js
/**
* 修改头像
* @param {Object} formData - {photo:'文件数据'}
*/
export const updateUserPhoto = (formData) => {
return request({
url: '/v1_0/user/photo',
method: 'patch',
data: formData
})
}
- 点击头像,显示动作面板
data () {
return {
user: {},
+ // 修改头像相关数据
+ showPhoto: false
}
},
<van-cell is-link title="头像" center @click="showPhoto=true">
<!-- 修改头像-弹出层 -->
<van-action-sheet
v-model="showPhoto"
:actions="[{ name: '拍照', value: 0 },{ name: '本地选择', value: 1 }]"
@select="onPhotoSelect"
cancel-text="取消"
/>
- 点击本地上传,调用file输入框
<input @change="updatePhoto" type="file" ref="file" style="display:none">
// 选择修改头像-动作面板选项
onPhotoSelect (item) {
if (item.value === 1) {
// 本地选择
this.$refs.file.click()
this.showPhoto = false
}
},
- 选择文件后完成上传
// 修改头像
async updatePhoto () {
// 选择图片后的文件信息对象
const file = this.$refs.file.files[0]
// 打开资源管理器,不选选图片,点击取消 file 是空的
if (file) {
// 上传图片
// 1. 包装一个formData对象,字段名字photo指向的是选择的图片
const formData = new FormData()
formData.append('photo', file)
// 2. 调用API接口
const [err, res] = await updateUserPhoto(formData)
if (err) return this.$toast.fail('修改失败')
// 3. 显示上传成功的头像,成功提示
this.user.photo = res.data.data.photo
this.$toast.success('修改成功')
}
},
# 07-编辑资料-修改昵称
目的:调用弹出框修改昵称
落地代码:
- 准备弹出框
- 定义API函数
- 完成保存
落地代码:
- 准备弹出框
src/views/user/profile/index.vue
data () {
return {
user: {},
// 修改头像相关数据
showPhoto: false,
+ // 修改用户名称
+ showName: false,
+ name: ''
}
},
<van-cell is-link title="昵称" @click="openNamePopup()" :value="user.name||'未填写'" />
// 打开修改名称弹出框
openNamePopup () {
this.showName = true
this.name = this.user.name
},
<!-- 修改昵称 -->
<van-popup class="my-popup" v-model="showName" position="right">
<van-nav-bar
left-arrow
@click-left="showName=false"
right-text="保存"
title="修改昵称"
/>
<van-field v-model="name"></van-field>
</van-popup>
.my-popup {
width: 100%;
height: 100%;
/deep/ .van-nav-bar__text {
color: @geek-color;
}
/deep/ .van-field__control {
background: @geek-gray-color;
padding: 10px;
border-radius: 4px;
}
}
- API函数
src/api/user.js
/**
* 修改用户
* @param {Object} user - 用户对象
*/
export const updateUser = (user) => {
return request({
url: '/v1_0/user/profile',
method: 'patch',
data: user
})
}
- 完成保存
src/views/user/profile/index.vue
<van-nav-bar
left-arrow
@click-left="showName=false"
+ @click-right="saveName()"
right-text="保存"
title="修改昵称"
/>
// 保存昵称
async saveName () {
if (!this.name) return this.$toast('请输入昵称')
const [err] = await updateUserProfile({ name: this.name })
if (err) return this.$toast.fail('更新失败')
// 更新单元格中的昵称
this.user.name = this.name
this.showName = false
this.$toast.success('更新成功')
},
# 08-编辑资料-修改性别
目的:完成修改性别
大致步骤:
- 准备动作面板
- 选择后保存
大致步骤:
- 准备动作面板
data () {
return {
user: {},
// 修改头像相关数据
showPhoto: false,
// 修改用户名称
showName: false,
name: '',
+ // 修改用户性别
+ showGender: false
}
},
<!-- 修改性别 -->
<van-action-sheet
v-model="showGender"
:actions="[{ name: '男', value: 0 }, { name: '女', value: 1 }]"
@select="saveGender"
cancel-text="取消"
/>
<van-cell is-link title="性别" @click="showGender=true" :value="user.gender===0?'男':'女'" />
- 选择后保存
// 修改用户性别
async saveGender (item) {
const [err] = await updateUser({ gender: item.value })
if (err) return this.$toast.fail('更新失败')
this.user.gender = item.value
this.showGender = false
this.$toast.success('更新成功')
},
# 09-编辑资料-修改生日
目的:完成修改生日
大致步骤:
- 准备日期选择器组件
- 确认时间后保存生日
落地代码:
- 准备日期选择器组件
data () {
return {
user: {},
// 修改头像相关数据
showPhoto: false,
// 修改用户名称
showName: false,
name: '',
// 修改用户性别
showGender: false,
+ // 修改生日
+ showBirthday: false,
+ birthday: new Date(),
+ minDate: new Date('1960-01-01'),
+ maxDate: new Date()
}
},
<!-- 修改生日 -->
<van-popup v-model="showBirthday" position="bottom">
<van-datetime-picker
v-model="birthday"
type="date"
title="选择年月日"
:min-date="minDate"
:max-date="maxDate"
@cancel="showBirthday=false"
@confirm="saveBirthday"
/>
</van-popup>
<van-cell is-link title="生日" @click="openBirthdayPopup" :value="user.birthday||'未填写'" />
// 打开日期选择器
openBirthdayPopup () {
this.showBirthday = true
// 给日期控件赋值,当前用户的生日
this.birthday = new Date(this.user.birthday)
},
- 确认时间后保存生日
// 修改用户生日
async saveBirthday () {
// 转换格式
const date = dayjs(this.birthday).format('YYYY-MM-DD')
const [err] = await updateUser({ birthday: date })
if (err) return this.$toast.fail('更新失败')
this.user.birthday = date
this.showBirthday = false
this.$toast.success('更新成功')
},
# 10-小智同学-基础布局
目的:搭建小智同学的基础布局
大致步骤:
- 完成布局
- 了解结构
- 准备用户头像数据
落地代码:src/views/user/chat.vue
<template>
<div class="user-chat-page">
<van-nav-bar
fixed
left-arrow
@click-left="$router.back()"
title="小智同学"
></van-nav-bar>
<!-- 聊天列表 -->
<div class="chat-list" ref="list">
<div class="chat-item left">
<geek-icon name="xiaozhi" />
<div class="chat-pao">我是小智同学,需要什么帮助吗?</div>
</div>
<div class="chat-item right">
<div class="chat-pao">报名前端</div>
<van-image fit="cover" round :src="myAvatar" />
</div>
</div>
<!-- 输入框 -->
<div class="reply-container van-hairline--top">
<van-field
v-model.trim="value"
left-icon="edit"
placeholder="请描述您的问题"
></van-field>
<span class="send">发送</span>
</div>
</div>
</template>
<script>
import { getUserProfile } from '@/api/user'
export default {
name: 'UserChatPage',
data () {
return {
myAvatar: '',
// list: [
// { name: 'xz', msg: '我是小智同学,需要什么帮助吗?' },
// { name: 'my', msg: '报名前端' }
// ],
value: ''
}
},
async created () {
const [, res] = await getUserProfile()
this.myAvatar = res.data.data.photo
}
}
</script>
<style lang="less" scoped>
.user-chat-page {
height: 100%;
width: 100%;
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
background:#fff;
padding: 46px 0 50px 0;
.chat-list {
height: 100%;
overflow-y: scroll;
.chat-item{
padding: 10px;
.van-image{
vertical-align: middle;
width: 40px;
height: 40px;
}
.geek-icon {
font-size: 40px;
line-height: 0;
}
.chat-pao{
vertical-align: top;
display: inline-block;
min-width: 40px;
max-width: 70%;
min-height: 40px;
line-height: 20px;
border-radius: 4px;
position: relative;
padding: 10px;
background-color: @geek-gray-color;
word-break: break-all;
font-size: 14px;
color: #333;
&::before{
content: "";
width: 6px;
height: 6px;
position: absolute;
top: 15px;
background: @geek-gray-color;
}
}
}
}
}
.chat-item.right{
text-align: right;
.chat-pao{
margin-left: 0;
margin-right: 10px;
&::before{
right: -3px;
transform: rotate(45deg);
}
}
}
.chat-item.left{
text-align: left;
.chat-pao{
margin-left: 10px;
margin-right: 0;
&::before{
left: -3px;
transform: rotate(-135deg);
}
}
}
.reply-container {
position: fixed;
left: 0;
bottom: 0;
height: 49px;
width: 100%;
background: #fff;
z-index: 9999;
display: flex;
align-items: center;
padding: 0 10px;
> .van-field {
flex: 1;
background: #F7F8FA;
height: 32px;
border-radius: 16px;
padding: 0 10px;
line-height: 32px;
::v-deep .van-field__left-icon .van-icon {
color: #ccc;
}
}
> .send {
margin-left: 10px;
font-size: 14px;
color: #999;
}
}
</style>
# 11-小智同学-认识websocket
目的:认识websocket https://websocket.org/
什么是 websocket ?
- 是一种网络通信协议,和 HTTP 协议 一样。
为什么需要websocket ?
- 因为 HTTP 协议有一个缺陷:通信只能由客户端发起。
理解 websokect 通讯过程
了解 websocket 代码含义 https://websocket.org/echo.html
http://jsbin.com/muqamiqimu/edit?js,console
// 创建ws实例,建立连接
var ws = new WebSocket("wss://echo.websocket.org");
// 连接成功事件
ws.onopen = function(evt) {
console.log("Connection open ...");
// 发送消息
ws.send("Hello WebSockets!");
};
// 接受消息事件
ws.onmessage = function(evt) {
console.log( "Received Message: " + evt.data);
// 关闭连接
ws.close();
};
// 关闭连接事件
ws.onclose = function(evt) {
console.log("Connection closed.");
};
我们项目中使用 socket.io-client 来实现客户端代码,它是基于 websocket 的库。
# 12-小智同学-完成聊天
目标:使用socket.io-client完成聊天
大致步骤:
- 先了解下socket.io-client的使用
- 在组件中实现聊天
落地代码:
1) 先了解下socket.io-client的使用
- 仓库地址 https://github.com/socketio/socket.io-client
- 官方文档 https://socket.io/ https://socketio.bootcss.com/docs/
- 安装
npm i socket.io-client
import socketIO from 'socket.io-client'
const io = socketIO('http:localhost:8080')
// 建立连接
io.on('connect', ()=>{
console.log('建立连接')
// 发送消息
io.emit('message', {msg:'你好'})
})
// 接受消息:data 是后台发的数据
io.on('message', (data)=>{
console.log('收到消息')
// 关闭连接
io.close()
})
2) 在组件中实现聊天
- 完成列表渲染
data () {
return {
myAvatar: '',
+ list: [
+ { name: 'xz', msg: '我是小智同学,需要什么帮助吗?' },
+ { name: 'my', msg: '报名前端' }
+ ],
value: ''
}
},
<!-- 聊天列表 -->
<div class="chat-list" ref="list">
<div class="chat-item"
v-for="(item,i) in list"
:key="i"
:class="{left:item.name==='xz',right:item.name==='my'}"
>
<geek-icon v-if="item.name==='xz'" name="xiaozhi" />
<div class="chat-pao">{{item.msg}}</div>
<van-image v-if="item.name==='my'" fit="cover" round :src="myAvatar" />
</div>
</div>
- 开始聊天
<span class="send" @click="send()">发送</span>
import { getUserProfile } from '@/api/user'
import soekctIO from 'socket.io-client'
import { baseURL } from '@/utils/request'
export default {
name: 'UserChatPage',
data () {
return {
myAvatar: '',
list: [
// { name: 'xz', msg: '我是小智同学,需要什么帮助吗?' },
// { name: 'my', msg: '我要学前端' }
],
value: ''
}
},
async created () {
const [, res] = await getUserProfile()
this.myAvatar = res.data.data.photo
// 1. 建立连接
this.io = soekctIO(baseURL, {
query: {
token: this.$store.state.user.token
},
transports: ['websocket']
})
// 2. 连接成功
this.io.on('connect', () => {
this.list.push({ name: 'xz', msg: '我是小智同学,需要什么帮助吗?' })
})
// 3. 接收消息
this.io.on('message', data => {
this.list.push({ name: 'xz', msg: data.msg })
})
},
beforeDestroy () {
// 4. 关闭连接
this.io.close()
},
methods: {
send () {
if (!this.value) return this.$toast('请输入内容')
// 发送消息
this.io.emit('message', { msg: this.value, timestamp: Date.now() })
this.list.push({ name: 'my', msg: this.value })
this.value = ''
}
}
}
3)滚动底部:
$nextTick
等DOM更新完毕执行下一件事情
// 4. 接收消息
this.io.on('message', data => {
this.list.push({ name: 'xz', msg: data.msg })
+ this.scrollBottom()
})
},
beforeDestroy () {
// 5. 断开连接
this.io.close()
},
methods: {
// 3. 发送消息
send () {
if (!this.value) return this.$toast('请输入内容')
// 发消息
this.io.emit('message', { msg: this.value, timestamp: Date.now() })
// 存聊天记录
this.list.push({ name: 'my', msg: this.value })
this.value = ''
+ this.scrollBottom()
},
+ scrollBottom () {
+ this.$nextTick(() => {
+ // 思路:滚动的距离 = 可滚动的高度 - 自身高度
+ const scrollHeight = this.$refs.list.scrollHeight
+ const offsetHeight = this.$refs.list.offsetHeight
+ this.$refs.list.scrollTop = scrollHeight - offsetHeight
+ })
+ }
}
# 第九章:项目收尾
# 01-刷新token
目的:无感延长token有效期
大致步骤:
- 登录完毕后台返回:token 1-2小时 refresh_token 30天,利用它可以让登录延续30天。
- 理解使用 refresh_token 过程
- vuex 本地存储 需要记录 refresh_token
- 当你发请求的时候出现401,响应拦截器中
- 拿着refresh_token向后台获取新的有效的token
- 把本地的token更新成新的token
- 继续发送之前失败的 请求 需要返回promise
- 如果你用 refresh_token 去获取token的时候失败了,无效了,去登录
落地代码:
- 存储refresh_token
src/store/modules/user.js
// 用户模块共享数据管理
export default {
namespaced: true,
// 状态
state () {
return {
// 令牌 初始化从本地获取
token: localStorage.getItem('geek-client-mobile-token'),
+ refreshToken: localStorage.getItem('geek-client-mobile-refreshToken')
}
},
// 修改
mutations: {
setToken (state, token) {
// 1. 修改vuex的数据
state.token = token
// 2. 同步修改本地
localStorage.setItem('geek-client-mobile-token', token)
},
+ setRefreshToken (state, refreshToken) {
+ // 1. 修改vuex的数据
+ state.refreshToken = refreshToken
+ // 2. 同步修改本地
+ localStorage.setItem('geek-client-mobile-refreshToken', refreshToken)
+ }
}
}
- 刷新token的API
src/api/user.js
import request, { baseURL } from '@/utils/request'
import axios from 'axios'
/**
* 刷新token
* @param {String} refreshToken - 保存的refresh_token
* @returns
*/
export const refreshTokenAPI = (refreshToken) => {
return axios({
url: baseURL + 'v1_0/authorizations',
method: 'put',
headers: {
Authorization: `Bearer ${refreshToken}`
}
})
}
- 响应拦截器,调用API
src/utils/request.js
import { refreshTokenAPI } from '@/api/user'
// 4. token失效处理
+instance.interceptors.response.use(res => res, async err => {
// 1. 判断状态码401
// 2. 删除token
// 3. 跳转登录,当前路由的完整地址需要传递给登录页面。因为:将来登录完事需要回跳
if (err.response && err.response.status === 401) {
+ // ★1. 使用refresh_token更新token不能使用instance,请求拦截器中会覆盖请求头
+ const [err2, res] = await to(refreshTokenAPI(store.state.user.refreshToken))
+ // ★2. 刷新token失败
+ if (err2) {
+ store.commit('user/setToken', '')
+ // 组件:this.$route.path 路径 fullPath 完整路径,带参数的
+ // 通过 router实例 获取当前路由信息对象 currentRoute
+ // 回跳地址:/order?id=100&name=tom ===> 跳转登录地址 /login?returnUrl=/order?id=100&name=tom
+ // 地址可能回出现特殊字符 & 需要转换成url编码 encodeURIComponent
+ // 转义后 /login?returnUrl=%2Forder%3Fid%3D100%26name%3Dtom
+ router.push('/login?returnUrl=' + encodeURIComponent(router.currentRoute.fullPath))
+ } else {
+ // ★3. 刷新token成功
+ store.commit('user/setToken', res.data.data.token)
+ // ★4. 继续发送失败的请求,需要成功
+ // err.config 是之前失败的请求配置
+ return instance(err.config)
+ }
}
return Promise.reject(err)
})
- 推出登录
src/views/user/profile/index.vue
}).then(() => {
// 确认后退出
this.$store.commit('user/setToken', '')
+ this.$store.commit('user/setRefreshToken', '')
this.$router.push('/')
}).catch(e => {})
# 02-打包部署
目的:打包之后部署至服务器
大致步骤:
- 修改打包后资源引用为相对路径
- 执行 npm run build 打包出资源
- 上传到服务器,使用node托管
落地内容:
- 修改打包后资源引用为相对路径
// vue-cli的配置文件
module.exports = {
+ publicPath: './',
css: {
loaderOptions: {
less: {
// less 的全局变量
globalVars: {
'geek-color': '#FC6627',
'geek-gray-color': '#F7F8FA'
}
}
}
}
}
- 执行 npm run build 打包出资源
- 上传到服务器,使用node托管
- 将来工作中一般只需要把你打包的资源给后台或者运维即可,托管的服务器也不一定是node。
# 03-项目总结
目的:掌握项目技术解决方案,业务解决方案。
- 能够使用vant组件库构建移动界面
- 能够使用 postcss-px-to-viewport 完成移动端适配
- 能够使用 命名视图 完成页面局部内容复用
- 能够使用 svg 方式的多色字体图标
- 能够封装 request 请求工具
- 能够使用 await-to-js 处理await代码异常
- 能够使用 token 完成用户鉴权
- 能够实现 移动端 上拉加载下拉刷新效果
- 能够实现 移动端 骨架效果
- 能够使用 dayjs 处理时间
- 能够使用 keep-alive 针对某组件缓存
- 能够实现 列表滚动位置记忆功能
- 能够使用 highlight.js 对富文本代码 高亮功能
- 能够实现 本地与线上 频道管理
- 能够实现 评论与回复 功能
- 能够使用 socket.io-client 完成双向通讯
- 能够掌握 用户无感 刷新token