Vue3移动开发
Vue3移动开发
Vue3 移动开发
一、项目初始化
Vant
官网:
https://vant-contrib.gitee.io/vant/v3/#/zh-CN
1、使用Vite搭建项目环境
npm create @vitejs/app
在弹出的窗口中添加项目名称:vant-app-demo
。
输入项目名称以后,选择模板,这里我们选择的是Vue
.同时选择使用的语言是JavaScript
.
下面需要完成项目的初始化。
进入项目名称文件夹,安装所有的依赖,最后执行npm run dev
启动项目
cd vant-app-demo
npm install
npm run dev
2、Vant 安装与基本使用
npm i vant@next -S
在main.js
文件中,导入vant
import { createApp } from "vue";
import App from "./App.vue";
import Vant from "vant"; //导入vant
import "vant/lib/index.css"; //导入样式
createApp(App).use(Vant).mount("#app"); //使用vant
在App.vue
组件中进行测试:
<img alt="Vue logo" src="./assets/logo.png" />
<HelloWorld msg="Hello Vue 3 + Vite" />
<van-button type="primary">按钮</van-button><!--使用Vant中的按钮-->
3、移动端REM适配
这里,我们要做移动的适配处理。
Vant
默认使用 px
作为样式单位,
如果需要使用 rem 单位进行适配,推荐使用以下两个工具:
postcss-pxtorem 是一款 PostCSS 插件,用于将 px 单位转化为 rem 单位,而使用rem必须有一个参考的基准值,有下面工具指定
lib-flexible 用于设置 rem 基准值(对html标签的字体大小进行动态设置)
安装lib-flexible
工具:
npm i amfe-flexible
安装以后,在main.js
文件中进行导入:
import { createApp } from "vue";
import App from "./App.vue";
import Vant from "vant";
import "vant/lib/index.css";
import "amfe-flexible";//导入,用于设置rem基准值
createApp(App).use(Vant).mount("#app");
然后审查元素(这里将浏览器切换到移动环境中查看),可以看到html
标签的中添加了style='font-size:37.5px'
,这个值会根据不同适配进行动态的改变。
lib-flexible
工具的方案:把一行分为10分,每份的大小除以10。
当前我们演示的设备是iPhone 6/7/8
,这些设备的宽度为375,所以除以10,就是37.5像素。所以切换到不同的设备,该值会发生变化。从而针对不同的设备实现了适配。
下面安装postcss-pxtorem
工具
通过该工具可以将px
转为rem
.
npm install postcss-pxtorem -D
下面进行配置:
在项目的根目录下面创建 postcss.config.js
文件
将如下代码拷贝到该文件中(下面的代码在官网中的Rem 布局适配
:https://vant-contrib.gitee.io/vant/v3/#/zh-CN/advanced-usage
)
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 37.5,
propList: ['*'],
},
},
};
然后重新启动项目
项目启动以后,刷新浏览器,查看添加的vant
的按钮的样式,发现单位都被转换成了rem
这里不光是vant
组件的单位进行了转换,同时我们自己定义的单位也会发生变化。
在App.vue
组件中(把原有的默认内容删除掉了),添加了box
这个div
,并且添加了样式,然后返回浏览器中审查元素,发现单位进行了转换
<template>
<div class="box"></div>
<van-button type="primary">按钮</van-button>
</template>
<script setup></script>
<style>
.box {
width: 200px;
height: 100px;
background-color: blue;
}
</style>
这里需要注意的一点就是这个工具
不能转换行内样式中的px
,例如如下所示:
<div class="box" style="padding: 30px">Hello World</div>
在浏览器中进行查看,发现没有转换。
4、关于postcss配置介绍
PostCSS
是一个处理CSS
的处理工具,本身的功能比较单一,它主要负责解析css
代码,再交由插件来进行处理。不同的插件有不同的功能。 postcss.config.js
文件
- Autoprefixer 插件可以实现自动添加浏览器相关的声明前缀
- PostCSS Preset Env 插件可以让你使用更新的
CSS
语法特性并实现向下兼容 - postcss-pxtorem 可以实现将
px
转换为rem
- …
// PostCss配置文件
module.exports = {
// 配置所需要的插件
plugins: {
// 配置使用postcss-pxtorem插件
//作用把px转成rem
"postcss-pxtorem": {
//rootValue:根元素的值
//我们这里使用的是lib-flexible的REM适配方案:它的原理是:把一行分为10份,每份就是10分之一,所以rootValue应该设置为你的设计稿宽度的十分之一
//假如我们的设计稿是宽是750px,所以应该设置为75,大多数设计稿的原型都是以iphone6为原型,iphone6设备的宽度是750.
//但是Vant建议设置为37.5,因为Vant是基于375写的。
//所以这里的缺点就是使用咱们设计稿的尺寸都必须除以2(比较麻烦),如果我们这里直接指定75,那么使用vant样式的组件就会变的非常小
//有没有更好的办法呢?
// 我们想:如果是是Vant的样式,就按照37.5来转换,如果是我们自己的样式,就按照75来转换。
// rootValue支持两种处理方式:一种是直接返回数字,另外就是函数,通过函数可以实现动态的处理。
// postcss-pxtorem处理每个CSS文件的时候都会调用该方法,并且它会把处理的`css`文件的相关信息通过
//参数传递给该函数。下面这里我们解构出来的就是文件路径的属性。看一下路径中是否包含vant,如果是就是处理vant的css,否则就是处理我们
//自己的css
rootValue({ file }) {
return file.indexOf("vant") !== -1 ? 37.5 : 75;
},
// rootValue: 37.5,
// 配置要转换的css属性
// *:表示所有,如果只想转换height属性,可以采用如下的写法:propList:['height'],我们大多的情况就是转换所有
propList: ["*"],
},
},
};
这里,我们修改一下App.vue
这个组件中的样式
<style>
.box {
width: 750px;
height: 100px;
background-color: blue;
}
</style>
这里我们把宽度设置了750px
,整好是撑满一整行。
5、封装请求模块
安装:
npm install axios
在src
目录下面创建utils
目录,该目录保存的就是常用的工具操作。在该目录下面创建request.js
文件。
import axios from "axios";
const request = axios.create({
baseURL: "http://localhost:3005/",
});
//请求拦截器
//响应拦截器
export default request;
目前,先配置baseURL
,后面在配置其它的内容,例如请求拦截器和响应拦截器等
二、登录功能
1、创建登录路由
npm install vue-router@4
以上安装了最新版本的vue-router
.
下面在src
目录下面创建router
目录,在该目录下面创建index.js
文件,在该文件中完成路由的配置。
index.js
文件中代码如下所示:()
在下面的代码中,首先从vue-router
中导入createRouter
以及createWebHashHistory
.
createRouter
用来创建路由实例
createWebHashHistory
创建hash
路由
Layout
组件用来完成整个后台管理页面的布局。
在routes
数组中,定义了相应的路由规则
import { createRouter, createWebHashHistory } from "vue-router";
const routes = [
{
path: "/login",
name: "login",
component: () => import("../views/login/index.vue"),
},
];
//创建路由实例
const router = createRouter({
//history模式:createWebHistory
history: createWebHashHistory(), //使用hash模式
routes,
});
export default router;
下面在src
目录下面创建views
目录,在该目录下面创建login
目录,然后在创建index.vue
.
该组件中的内容如下所示:
<template>
<div>登录</div>
</template>
<script>
export default {};
</script>
<style></style>
下面,返回到main.js
文件中使用创建好的路由对象
import { createApp } from "vue";
import App from "./App.vue";
import Vant from "vant";
import "vant/lib/index.css";
import "amfe-flexible";
import router from "./router"; //导入路由对象
createApp(App).use(Vant).use(router).mount("#app"); //使用路由
同时在App.vue
文件中,添加router-view
,指定路由的出口。
<template>
<router-view></router-view>
</template>
<script setup></script>
<style></style>
在浏览器中输入http://localhost:3000/#/login
查看效果。
2、实现登录布局结构
<template>
<div class="login-container">
<!-- 导航栏 -->
<van-nav-bar title="登录" />
<!-- 导航栏结束 -->
<!-- 登录表单 -->
<van-form>
<van-field name="userName" placeholder="请输入用户名" />
<van-field name="userPwd" placeholder="请输入密码" />
<div style="margin: 16px">
<van-button block type="primary" native-type="submit">
提交
</van-button>
</div>
</van-form>
<!-- 登录表单结束 -->
</div>
</template>
<script>
export default {};
</script>
<style></style>
3、登录布局实现
这里我们首先设置一下,导航栏的样式。由于很多页面都会用到导航栏,所以可以将样式定义在全局的文件中,而不是单独的定义在登录组件中。
在src
目录下面创建styles
目录,在该目录下面创建index.css
文件,该文件中的代码如下所示:
.page-nav-bar {
background-color: #3296fa;
}
.page-nav-bar .van-nav-bar__title {/* 这里的van-nav-bar__title,可以查看原有的导航栏的样式*/
color: #fff;
}
在登录组件的导航栏中使用该样式。(在main.js
文件中导入import "./styles/index.css";
)
<van-nav-bar title="登录" class="page-nav-bar" />
下面给登录表单的文本框与密码框添加对应的图标。
对应的官网:
https://vant-contrib.gitee.io/vant/v3/#/zh-CN/field
<van-form>
<van-field name="userName" placeholder="请输入用户名" left-icon="manager" />
<van-field name="userPwd" placeholder="请输入密码" left-icon="lock"/>
</van-field>
<div style="margin: 16px">
<van-button block type="primary" native-type="submit">
提交
</van-button>
</div>
</van-form>
这里给手机号与验证码添加了left-icon
设置了图标。同时设置了“发送验证码”的按钮
4、实现基本登录功能
注意:这里只是实现基本登录效果,把服务端返回的内容打印出来,不涉及到登录状态的提示与登录状态的存储操作。
首先在src
目录下面创建api
目录,在该目录下面创建user.js
文件,该文件中封装了用户请求的相关处理模块。
// 封装用户相关的请求模块
import request from "../utils/request";
export const login = (data) => {
return request({
method: "POST",
url: "/user/login",
data,
});
};
下面修改login.vue
组件中的内容
<div class="login-container">
<!-- 导航栏 -->
<van-nav-bar title="登录" class="page-nav-bar" />
<!-- 导航栏结束 -->
<!-- 登录表单 -->
<van-form @submit="onSubmit">
<van-field
name="userName"
placeholder="请输入用户名"
v-model="userName"
left-icon="manager"
:rules="userFormRules.userName"
/>
<van-field
name="userPwd"
placeholder="请输入密码"
v-model="userPwd"
left-icon="lock"
:rules="userFormRules.userPwd"
>
</van-field>
<div style="margin: 16px">
<van-button block type="primary" native-type="submit">
登录
</van-button>
</div>
</van-form>
<!-- 登录表单结束 -->
</div>
给每个van-field
绑定了v-model
,同时给van-form
添加了@submit
事件。
<script>
import { reactive, toRefs } from "vue";
import { login } from "../../api/user"; //导入login方法,进行请求的发送
function useSubmit(user) {
const onSubmit = async () => {
//1、获取表单数据
//2、表单验证
//3、提交表单请求
Toast.loading({
message: "登录中...",
forbidClick: true, //禁用背景点击
duration: 0, //持续时间,默认是2000毫秒,如果为0则持续展示
});
const res = await login(user);
if (res.data.code === 0) {
store.commit("setUser", res.data);
Toast.success("用户登录成功");
} else {
Toast.fail("用户名或密码错误");
}
//4、根据请求响应结果处理后续操作。
};
return {
onSubmit,
};
}
export default {
setup() {
const user = reactive({
userName: "", //用户名
userPwd: "", //用户密码
});
return {
...toRefs(user),
...useSubmit(user),
};
},
};
</script>
在setup
方法中,定义user
响应式对象,最后返回。同时将外部定义的useSubmit
方法进行调用。
5、登录状态提示
const onSubmit = async () => {
//1、获取表单数据
//2、表单验证
//3、提交表单请求
Toast.loading({
message: "登录中...",
forbidClick: true, //禁用背景点击
duration: 0, //持续时间,默认是2000毫秒,如果为0则持续展示
});
const res = await login(user);
if (res.data.code === 0) {
store.commit("setUser", res.data);
Toast.success("用户登录成功");
} else {
Toast.fail("用户名或密码错误");
}
//4、根据请求响应结果处理后续操作。
};
return {
onSubmit,
};
}
这里使用了Toast
组件,同时需要进行导入:
import { Toast } from "vant";
由于网络比较快,可能看不到登录中...
这个提示,所以可以把网络设置慢一些。
可以修改NetWork
中的No throttling
中的Slow 3G
6、表单验证功能
在setup
函数中定义校验规则,并且将其返回。
setup() {
const user = reactive({
userName: "", //用户名
userPwd: "", //用户密码
});
//定义校验规则
const userFormRules = {
userName: [{ required: true, message: "请填写用户名" }],
userPwd: [
{
required: true,
message: "请填写密码",
},
{
pattern: /^\d{6}$/,
message: "密码格式错误",
},
],
};
return {
...toRefs(user),
...useSubmit(user),
userFormRules, //返回校验规则
};
},
在表单中使用校验规则:
<van-field
name="userName"
placeholder="请输入用户名"
v-model="userName"
left-icon="manager"
:rules="userFormRules.userName"
/>
<van-field
name="userPwd"
placeholder="请输入密码"
v-model="userPwd"
left-icon="lock"
:rules="userFormRules.userPwd"
>
</van-field>
7、处理用户Token
用户登录成功以后,会返回token
数据。
Token
是用户登录成功之后服务端返回的一个身份令牌,在项目中经常要使用。
例如:访问需要授权的API
接口。
校验页面的访问权限等。
同时,这里我们还需要将token
数据进行存储,这样在访问其它的页面组件的时候,就可以获取token
数据来进行校验。
关于token
数据存储在哪儿呢?
可以存储到本地:
存储到本地的问题是,数据不是响应式的。
存储到Vuex
中,获取方便,并且是响应式的。但是存储到Vuex
中也是有一定的问题的,就是当我们刷新浏览器的时候,数据就会丢失,所以还是需要把token
数据存放到本地,存储到本地的目的就是为了进行持久化。
所以这里我们需要在登录成功以后,把token
数据存储到vuex
中,这样可以实现响应式,在本地存储就是为了解决持久化的问题。
安装最新版本的Vuex
npm install vuex@next --save
下面在src
目录下面创建store
目录,在store
目录中index.js
文件,该文件中的代码如下所示:
import { createStore } from "vuex";
const store = createStore({
state: {
//存储当前登录用户信息,包含token等数据
user: null,
},
mutations: {
setUser(state, data) {
state.user = data;
},
},
});
export default store;
在上面的代码中,创建了store
容器,同时指定了state
对象,在该对象中定义user
属性存储登录用户信息。
在mutations
中定义setUser
方法,完成用户信息的更新。
下面,要实现的就是,当登录成功以后,更新user
这个状态属性。
当然,这里首先要做的就是把store
注入到Vue
的实例中。
import { createApp } from "vue";
import App from "./App.vue";
import Vant from "vant";
import "vant/lib/index.css";
import "amfe-flexible";
import "./styles/index.css";
import router from "./router";
import store from "./store"; //导入store
createApp(App).use(Vant).use(router).use(store).mount("#app"); //完成store的注册操作
在main.js
文件中,我们导入了store
,并且注册到了Vue
实例中。
下面返回到views/login/index.vue
页面中,把登录的信息存储到store
容器中。
import { reactive, toRefs, ref } from "vue";
import { login, sendSms } from "../../api/user";
import { Toast } from "vant";
import { useStore } from "vuex"; //导入useStore
在上面的代码中导入useStore
.
export default {
setup() {
const loginForm = ref();
//获取store
const store = useStore();
在setup
函数中,调用useStore
方法,获取store
容器。
return {
...toRefs(user),
...useSubmit(user, store),//在调用useSubmit方法的时候传递store容器
userFormRules,
loginForm,
};
//用户登录
function useSubmit(user, store) {
const onSubmit = async () => {
//1、获取表单数据
//2、表单验证
//3、提交表单请求
Toast.loading({
message: "登录中...",
forbidClick: true, //禁用背景点击
duration: 0, //持续时间,默认是2000毫秒,如果为0则持续展示
});
const res = await login(user);
if (res.data.code === 0) {
store.commit("setUser", res.data);
Toast.success("用户登录成功");
} else {
Toast.fail("用户名或密码错误");
}
//4、根据请求响应结果处理后续操作。
};
return {
onSubmit,
};
}
登录成功以后,获取到返回的数据,同时调用store
中的commit
方法完成数据
的保存
现在,我们虽然把登录成功的数据,存储到Vuex
中,但是当我们刷新浏览器的时候,Vuex
中的数据还是会丢失的。所以这里,我们还需要将其存储到本地中。
下面修改一下store/index.js
文件中的代码:
import { createStore } from "vuex";
const TOKEN_KEY = "TOUTIAO_USER";
const store = createStore({
state: {
//存储当前登录用户信息,包含token等数据
// user: null,
user: JSON.parse(window.localStorage.getItem(TOKEN_KEY)),
},
mutations: {
setUser(state, data) {
state.user = data;
window.localStorage.setItem(TOKEN_KEY, JSON.stringify(state.user));
},
},
});
export default store;
在mutations
中的setUser
方法中,将登录成功的用户数据存储到了localStorage
中,在存储的时候,将数据转成了字符串。
同时在state
中获取数据的时候,就从localStorage
中获取,当然获取的时候,再将其转换成对象的形式。
下面,我们可以在App.vue
中做一下测试:
<template>
<div>
<router-view></router-view>
</div>
</template>
<script>
import { useStore } from "vuex";
export default {
setup() {
const store = useStore();
console.log(store.state.user);
},
};
</script>
<style></style>
通过查看浏览器的控制台,可以查看到对应的登录用户的token
数据
8、封装本地存储操作
在我们的项目中,有很多的地方都需要获取本地存储的数据,如果每次都写:
JSON.parse(window.localStorage.getItem(TOKEN_KEY)),
window.localStorage.setItem(TOKEN_KEY, JSON.stringify(state.user));
就比较麻烦了。所以这里我们建议把操作本地数据单独的封装到一个模块中。
在utils
目录下面创建storage.js
文件,该文件中的代码如下所示:
// 存储数据
export const setItem = (key, value) => {
//将数组,对象类型的数据转换为JSON格式的字符串进行存储
if (typeof value === "object") {
value = JSON.stringify(value);
}
window.localStorage.setItem(key, value);
};
//获取数据
export const getItem = (key) => {
const data = window.localStorage.getItem(key);
//这里使用try..catch的,而不是通过if判断一下是否为json格式的字符串,然后在通过parse进行转换呢,目的就是是为了方便处理,因为对字符串进行判断看一下是否为json格式的字符串,比较麻烦一些。还需要通过正则表达式来完成。而通过try..catch比较方便
// 如果data不是一个有效的json格式字符串,JSON.parse就会出错。
try {
return JSON.parse(data);
} catch (e) {
return data;
}
};
//删除数据
export const removeItem = (key) => {
window.localStorage.removeItem(key);
下面返回到store/index.js
文件中,修改对应的代码,这里使用我们上面封装好的代码。
import { createStore } from "vuex";
import { getItem, setItem } from "../utils/storage";
const TOKEN_KEY = "TOUTIAO_USER";
const store = createStore({
state: {
//存储当前登录用户信息,包含token等数据
// user: null,
// user: JSON.parse(window.localStorage.getItem(TOKEN_KEY)),
user: getItem(TOKEN_KEY),
},
mutations: {
setUser(state, data) {
state.user = data;
setItem(TOKEN_KEY, state.user);
// window.localStorage.setItem(TOKEN_KEY, JSON.stringify(state.user));
},
},
});
export default store;
在上面的代码中,我们导入getItem
和setItem
两个方法,然后在存储登录用户信息,和获取登录用户信息的时候,直接使用这两个方法,这样就非常简单了。
下面返回浏览器进行测试。
把以前localStorage
中存储的内容删除掉。
然后重新输入用户名和密码,发现对应的localStorage
中存储了对应的数据、
三、个人信息管理
这里,我们主要实现个人信息的展示,以及实现个人信息管理的页面布局实现
1、TabBar处理
在个人信息管理页面的底部,我们发现有一组“标签导航栏”。
而且这一组“标签导航栏”在每个页面中都有。
那么我们应该怎样处理呢?
第一种方案:在每个页面中都创建一遍,但是这种做法比较麻烦,所以不建议这样做。
第二种方案:将其单独的封装成一个 组件,这样每个页面中需要标签导航栏的时候,直接使用该组件就可以了。但是,这里我们也不建议这样做,因为,当我们切换到不同的页面的时候,这个组件都会重新被渲染加载,这样就会影响性能。
第三种方式:我们做一个父路由,把底部的标签导航栏放在父路由中,同时在父路由中留一个路由的出口。对应的其它页面都都渲染到这个路由出口的位置。这样,当我们进行页面的切换的时候,就不需要重新加载底部的“导航栏”了
在views
目录下面创建layout
目前,同时在该目录下面创建index.vue
文件,该文件中的代码如下所示:
在下面代码中,先来指定子路由的出口,后面在对标签导航栏进行设置
<template>
<div class="layout-container">
<!-- 子路由的出口 -->
<router-view></router-view>
<!-- 标签导航栏 -->
</div>
</template>
<script>
export default {};
</script>
<style></style>
配置路由规则:
const routes = [
{
path: "/login",
name: "login",
component: () => import("../views/login/index.vue"),
},
{ //配置路由
path: "/",
name: "layout",
component: () => import("../views/layout/index.vue"),
},
];
下面,我们返回到index.vue
页面,开始制作底部的标签导航栏
https://vant-contrib.gitee.io/vant/v3/#/zh-CN/tabbar
<template>
<div class="layout-container">
<!-- 子路由的出口 -->
<router-view></router-view>
<!-- 标签导航栏 -->
<van-tabbar v-model="active">
<van-tabbar-item icon="home-o">标签</van-tabbar-item>
<van-tabbar-item icon="search">标签</van-tabbar-item>
<van-tabbar-item icon="friends-o">标签</van-tabbar-item>
<van-tabbar-item icon="setting-o">标签</van-tabbar-item>
</van-tabbar>
</div>
</template>
<script>
import { ref } from "vue";
export default {
setup() {
const active = ref(0);
return {
active,
};
},
};
</script>
<style></style>
在上面的代码中,我们直接把官方文档中的tabbar
组件,拿过来了。v-model
的取值为active
,而active
这个对象的默认值为0
,也就是让人第一项导航栏进行选中。icon
属性表示的就是导航栏中每一项的图标,下面把文字修改一下:
<van-tabbar-item icon="home-o">首页</van-tabbar-item>
<van-tabbar-item icon="search">问答</van-tabbar-item>
<van-tabbar-item icon="friends-o">视频</van-tabbar-item>
<van-tabbar-item icon="setting-o">我的</van-tabbar-item>
下面把视频
与我的
图标更换一下:
<van-tabbar v-model="active">
<van-tabbar-item icon="home-o">首页</van-tabbar-item>
<van-tabbar-item icon="search">问答</van-tabbar-item>
<van-tabbar-item icon="video-o">视频</van-tabbar-item>
<van-tabbar-item icon="contact">我的</van-tabbar-item>
</van-tabbar>
这里我们还是使用的vant
中提供好的一些图标,当然这里我们也可以使用自定义图标
,在官方文档中也已经明确的告诉我们怎样使用自定义图标了。
下面,我们要实现的就是,单击不同的项,展示出各自对应的页面。
首先在views
目录中,创建对应的文件夹与文件。
创建home
文件夹,在该文件夹中创建index.vue
文件,表示首页,该文件的初步内容为:
<template>
<div>Home Page</div>
</template>
<script>
export default {};
</script>
<style></style>
my/index.vue
<template>
<div>我 的</div>
</template>
<script>
export default {};
</script>
<style></style>
video/index.vue
<template>
<div>视频</div>
</template>
<script>
export default {};
</script>
<style></style>
qa/index.vue
<template>
<div>问答</div>
</template>
<script>
export default {};
</script>
<style></style>
初步的页面创建好以后,下面需要配置对应的子路由了。router/index.js
设置路由
{
path: "/",
name: "layout",
component: () => import("../views/layout/index.vue"),
children: [
{
path: "", //默认子路由(默认展示home页面),只能有一个
name: "home",
component: () => import("../views/home/index.vue"),
},
{
path: "/qa",
name: "qa",
component: () => import("../views/qa/index.vue"),
},
{
path: "/video",
name: "video",
component: () => import("../views/video/index.vue"),
},
{
path: "/my",
name: "my",
component: () => import("../views/my/index.vue"),
},
],
},
在值的路由中,最开始设置了默认子路由。
下面返回到浏览器中,进行测试,发现默认展示的就是home
组件中的内容,但是当我们单击tabbar
中的每一项的时候,还没有进行切换。
原因是,这里我们需要给tabbar
开启路由功能。
返回到views/layout/index.vue
页面中。
<van-tabbar route v-model="active">
<van-tabbar-item icon="home-o" to="/">首页</van-tabbar-item>
<van-tabbar-item icon="search" to="/qa">问答</van-tabbar-item>
<van-tabbar-item icon="video-o" to="/video">视频</van-tabbar-item>
<van-tabbar-item icon="contact" to="/my">我的</van-tabbar-item>
</van-tabbar>
第一步:给tabber
组件添加了route
属性,表示开启了路由的模式
第二步:给每个tabbar-item
添加了to
属性,指定了对应的路由地址、
这时候,可以发现浏览器进行测试。
2、未登录布局实现
下面修改views/my/index.vue
文件中的代码,代码如下所示:
<template>
<div class="my-container">
<div class="header not-login">
<div class="login-btn" @click="this.$router.push('/login')">
<img class="mobile-img" src="../../assets/mobile.png" alt="" />
<span class="text">注册 / 登录</span>
</div>
</div>
</div>
</template>
<script>
export default {};
</script>
<style>
.my-container .header {
height: 361px;
background: url("../../assets/banner.png");
background-size: cover;
}
.my-container .not-login { /* 没有登录的效果样式*/
display: flex;
justify-content: center;
align-items: center;
}
.my-container .not-login .login-btn {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.mobile-img {
width: 132px;
height: 132px;
margin-bottom: 15px;
}
.text {
font-size: 28px;
color: #fff;
}
</style>
在上面的代码中,设置了基本的布局和对应的css
样式。
到单击图标的时候,跳转到登录页面。
但是,这里当我们单击了图标,跳转到登录页面以后,又不想登录了,想返回上一页。
下面,返回到/login/index.vue
文件
<!-- 导航栏 -->
<van-nav-bar title="登录" class="page-nav-bar">
<template #left>
<van-icon name="cross" size="18" />
</template>
</van-nav-bar>
在导航栏中,使用了插槽,定义左侧的图标,为cross
图标。
这里参考文档实现:
https://vant-contrib.gitee.io/vant/v3/#/zh-CN/nav-bar
当在浏览器中展示的时候,发现cross
图标的颜色与背景色基本上是一致的,所以看不清楚。
这里我们可以修改一下图标的颜色。
在styles/index.css
文件中定义的是全局的样式,在这里给图标添加对应的样式:
.page-nav-bar {
background-color: #3296fa;
}
.page-nav-bar .van-nav-bar__title {
color: #fff;
}
/* 图标的样式*/
.page-nav-bar .van-icon {
color: #fff;
}
下面实现当单击cross
图标的时候,进行返回的操作:
<van-nav-bar title="登录" class="page-nav-bar">
<template #left>
<van-icon name="cross" size="18" @click="this.$router.back()" />
</template>
</van-nav-bar>
这里通过$router.back
方法进行返回操作。
3、已登录布局实现
在模板中,增加了登录后的布局
<template>
<div class="my-container">
<!-- 未登录布局 -->
<div class="header not-login">
<div class="login-btn" @click="this.$router.push('/login')">
<img class="mobile-img" src="../../assets/mobile.png" alt="" />
<span class="text">注册 / 登录</span>
</div>
</div>
<!-- 登录后的布局 -->
<div class="header user-info">
<div class="base-info">
<div class="left">
<van-image
src="https://img.yzcdn.cn/vant/cat.jpeg"
class="avatar"
round
fit="cover"
></van-image>
<span class="name">博学谷头条号</span>
</div>
<div class="right">
<van-button size="mini" round>编辑资料</van-button>
</div>
</div>
<div class="data-stats">
<div class="data-item">
<span class="count">10</span>
<span class="text">头条</span>
</div>
<div class="data-item">
<span class="count">10</span>
<span class="text">关注</span>
</div>
<div class="data-item">
<span class="count">10</span>
<span class="text">粉丝</span>
</div>
<div class="data-item">
<span class="count">10</span>
<span class="text">获赞</span>
</div>
</div>
</div>
</div>
</template>
对应的css
样式
.user-info .base-info {
height: 231px;
padding: 70px 32px 23px;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
}
.user-info .left {
display: flex;
align-items: center;
}
.user-info .left .avatar {
width: 132px;
height: 132px;
margin-right: 13px;
border: 1px solid #fff;
}
.user-info .left .name {
font-size: 16px;
color: #fff;
}
.user-info .data-stats {
/* background-color: #ccc; */
display: flex;
/* height: 130px; */
}
.user-info .data-stats .data-item {
height: 130px;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #fff;
}
.user-info .data-stats .count {
font-size: 36px;
}
.user-info .data-stats .text {
font-size: 23px;
}
4、宫格导航布局
这块内容与上一小节的布局基本上一样的,所以这里可以采用自定义样式的方式来实现布局
但是这里我们主要是通过Vant
中的Grid宫格
组件来完成。
在"登录布局"后面实现宫格布局。这里把Grid
组件基本使用的代码拷贝过来,测试一下:
<!-- 导航 -->
<van-grid>
<van-grid-item icon="photo-o" text="文字" />
<van-grid-item icon="photo-o" text="文字" />
<van-grid-item icon="photo-o" text="文字" />
<van-grid-item icon="photo-o" text="文字" />
</van-grid>
这里可以测试,看一下效果。
<!-- 导航 -->
<van-grid :column-num="2">
<van-grid-item icon="photo-o" text="文字" />
<van-grid-item icon="photo-o" text="文字" />
</van-grid>
这里我们只保留两项内容,同时我们希望这两项内容平分这一行,所以这里指定了column-num
这个属性。该属性的作用就是:自定义列数。这里指定是两列。
下面,我们换一下文字与图标:
<van-grid :column-num="2" clickable>
<van-grid-item icon="star-o" text="收藏" />
<van-grid-item icon="clock-o" text="历史" />
</van-grid>
指定了icon
和text
两个属性的值。
指定了样式:
.van-grid-item__icon {
color: #eb5253;
}
.van-grid-item__text {
font-size: 14px;
}
同时给van-grid
添加了clickable
属性,该属性的作用: 是否开启格子点击反馈
.
这里可以看一下效果。
关于单击后,跳转到其它的页面,我们后面实现。
5、消息通知与退出登录布局
这里,我们使用cell
这个单元格组件来完成布局。
<van-cell title="消息通知" is-link />
<van-cell title="小智同学" is-link />
<van-cell title="退出登录" />
修改了title
属性的取值,取消了其它无关的属性。同时添加了is-link
属性,添加了该属性以后会在右侧出现箭头。
下面给退出登录
添加对应的样式
<van-cell title="退出登录" class="logout-cell" />
对应的css
样式。
.logout-cell {
text-align: center;
color: #d86262;
margin-top: 9px;
height: 120px;
}
6、处理页面显示状态
现在关于个人中心页面的整个结构的布局已经完成了,下面来实现对应的功能状态。
下面我们要获取到用户的登录信息,如果能够获取到展示的是图片以及博学谷头条
这块内容,否则展示的是登录/注册
这块内容。
我们可以从vuex
容器中获取对应的用户信息。
下面修改views/my/index.vue
文件中的代码
<script>
import { computed } from "vue";
import { useStore } from "vuex";
export default {
setup() {
const store = useStore();
const userState = computed(() => store.state.user);
return {
userState,
};
},
};
</script>
导入computed
计算属性,从vuex
中导入useStore
。在setup
函数中通过useStore
获取store
容器,然后从容器中获取user
登录用户的信息。最终返回。
在模板中就可以进行判断
<!-- 未登录布局 -->
<div v-if="!userState" class="header not-login">
<!-- 登录后的布局 -->
<div v-else class="header user-info">
注意的是:退出按钮也是在用户登录以后才会展示出来。
<van-cell v-if="userState" title="退出登录" class="logout-cell" />
下面可以进行测试,查看一下效果。
测试完成以后,还有一个问题需要处理一下,就是底部导航栏中的我的
,如果是没有登录,这里让这一项显示“未登录”
下面返回到/layout/index.vue
文件中,修改一下代码
import { ref, computed } from "vue";
import { useStore } from "vuex";
export default {
setup() {
const active = ref(0);
const store = useStore();
const userState = computed(() => store.state.user);
return {
active,
userState,
};
},
};
对应模板中的代码修改:
<van-tabbar-item icon="contact" to="/my">{{
userState ? "我的" : "未登录"
}}</van-tabbar-item>
现在,我们可以返回到浏览器端把localStoreage
中的数据删除掉,查看一下对应的效果。
当我们重新登录以后,应该返回到原来的页面。
这里,我们在views/login/index.vue
文件中。导入以前构建好的路由对象。
import { useStore } from "vuex";
import router from "../../router";
然后在登录成功以后返回到原来的页面
if (res.data.code === 0) {
store.commit("setUser", res.data);
Toast.success("用户登录成功");
router.back(); //返回到原来的页面,这种写法不是很严禁,后面再来强调另外一种写法。
// router.push("/my");
} else {
Toast.fail("用户名或密码错误");
}
下面可以返回到浏览器中进行演示.
7、退出登录
当单击退出登录按钮的时候,完成用户的退出,这时展示的页面还是当前页面,但是展示的信息为:注册/登录
,同时不在展示退出登录
这个按钮。
首先给退出登录
添加单击事件
<van-cell
v-if="userState"
title="退出登录"
class="logout-cell"
@click="onLogout"
/>
onLogout
函数的定义如下所示:
import { computed } from "vue";
import { useStore } from "vuex";
import { Dialog } from "vant";
function useLogout() {
const onLogout = () => {
//1、退出提示
Dialog.confirm({
title: "确认退出吗?",
})
.then(() => {
// on confirm
console.log("确认执行这里");
})
.catch(() => {
// on cancel
console.log("取消执行这里");
});
//2、确认退出:清除登录状态(Vuex容器中的用户数据和本地存储中的用户数据)
};
return { onLogout };
}
在useLogout
方法中返回了onLogout
函数,在onLogout
函数中,主要做两件事情:
第一:给出退出登录的提示
第二:当确认退出后,要清除登录状态。
在上面的代码中我们使用Dialog
组件,给出相应的退出提示,所以这里先导入Dialog
组件。
关于该组件可以参数官网:https://vant-contrib.gitee.io/vant/v3/#/zh-CN/dialog
在这里我们选择的是“消息确认”这个效果,大家可以根据需要选择其他的效果。
在这里还要注意,在setup
函数中要调用useLogout
函数,并且进行解构返回。这样在模板中才能够使用onLogout
setup() {
const store = useStore();
const userState = computed(() => store.state.user);
return {
userState,
...useLogout(), //调用useLogout函数,并且对返回的结果进行解构
};
},
下面,我们要实现的是第二步:
清除登录状态。
setup() {
const store = useStore();
const userState = computed(() => store.state.user);
return {
userState,
...useLogout(store),
};
},
在setup
方法中,当调用useLogout
方法的时候,传递store
容器。
function useLogout(store) {
const onLogout = () => {
//1、退出提示
Dialog.confirm({
title: "确认退出吗?",
})
.then(() => {
//2、确认退出:清除登录状态(Vuex容器中的用户数据和本地存储中的用户数据)
store.commit("setUser", null);
})
.catch(() => {
// on cancel
console.log("取消执行这里");
});
};
return { onLogout };
}·
在useLogout
方法中接收传递过来的store
容器,同时通过commit
方法提交mutations
,这里,对应的方法就是setUser
方法,给该方法传递的值为null
.
这样在store/index.js
文件中的mutations
中的setUser
方法中,会将state.user
的值设置为null
,同时本地存储中的值也为null
这里可以进行测试。
8、展示当前登录用户信息
这里,我们需要获取登录用户完整的信息,例如:用户名,用户头像,发布文章数目,关注的数目,粉丝数目,被点赞数。
这里,需要发送一个新的请求来获取这些数据。
在src/api/user.js
文件中,添加一个新的方法如下所示:
// 封装用户相关的请求模块
import request from "../utils/request";
import store from "../store"; //导入store
export const login = (data) => {
return request({
method: "POST",
url: "/user/login",
data,
});
};
//获取用户自己完整的信息
export const getUserInfo = (id) => {
return request({
method: "GET",
url: "/userInfo/get",
params: { userId: id },
headers: {
Authorization: `Bearer ${store.state.user.myToken}`,
},
});
};
在上面的代码中,添加了getUserInfo
方法,该方法根据传递过来的用户编号,发送一个get
请求,目的就是根据用户编号查询对应的完整的信息。
在这里,需要注意的就是这里需要指定headers
属性,该属性中的Authorization
中存储的就是发送到服务端的token
数据,token
数据是从store
容器中获取的。
现在返回到views/my/index.vue
中调用getUserInfo
方法来获取对应的数据。
import { Dialog } from "vant";
import { getUserInfo } from "../../api/user";
导入我们定义的getUserInfo
这个方法。
在调用getUserInfo
方法发送异步请求之前,定义了一个响应式对象state
,该对象中的userInfo
属性中存储的就是我们要获取的数据
注意:这里需要导入reactive
函数。
在什么时候发送请求呢?
在onMounted
钩子函数中发送异步请求。
但是,这里也需要注意一个细节:未登录的用户也可以访问当前的组件,但是这时候,没有必要发送异步请求
所以这里需要判断store
容器中是否能够获取到登录用户信息,如果能够获取到,才可以发送异步请求,获取登录用户的完整信息,最终填充到userInfo
中。最后将state
对象的内容进行解构,然后通过toRefs
函数将其包裹,这样解构的内容又被转换成响应式的了。注意:toRefs
函数需要导入
import { computed, onMounted, reactive, toRefs } from "vue";
setup() {
const store = useStore();
const userState = computed(() => store.state.user);
const state = reactive({
userInfo: {
userName: "", //用户名
art_count: 0, //发布的文章数目
follow_count: 0, //关注的数目
fans_count: 0, //粉丝数目
like_count: 0, //被点赞数目
},
});
onMounted(() => {
//登录以后,才加载对应的数据
if (store.state.user) {
loadUserInfo(state, store.state.user);
}
});
return {
...toRefs(state),
userState,
...useLogout(store),
};
},
请求的发送,需要调用loadUserInfo
方法来完成,需要传递state
以及store
容器中的用户数据。
//加载用户信息数据
function loadUserInfo(state, userData) {
// console.log("data=", userData.myToken);
getUserInfo(userData.data.id).then((res) => {
state.userInfo = res.data;
});
}
在loadUserInfo
方法中,调用了getUserInfo
方法发送异步请求,获取完整用户数据,将其填充到state
对象中的userInfo
中。
下面修改模板中的内容:
<span class="name">{{ userInfo.userName }}</span>
<div class="data-item">
<span class="count">{{ userInfo.art_count }}</span>
<span class="text">头条</span>
</div>
<div class="data-item">
<span class="count">{{ userInfo.follow_count }}</span>
<span class="text">关注</span>
</div>
<div class="data-item">
<span class="count">{{ userInfo.fans_count }}</span>
<span class="text">粉丝</span>
</div>
<div class="data-item">
<span class="count">{{ userInfo.like_count }}</span>
<span class="text">获赞</span>
</div>
这时候,可以返回浏览器进行查看。
9、用户头像处理
在上一小节中,我们并没有处理用户头像,这一小节我们单独来处理一下用户头像的问题
用户的头像是存储在服务端,而服务端返回的头像的路径中,并没有包好服务端的地址。
所以,我们在展示用户头像的时候,需要添加上服务端的地址
<van-image
:src="'http://localhost:3005' + userInfo.photo"
class="avatar"
round
fit="cover"
></van-image>
在my.vue
页面中的image
组件中指定了图片的服务端
地址。
同时,还需要在state
对象中的userInfo
中定义photo
属性。
const state = reactive({
userInfo: {
userName: "",
art_count: 0,
follow_count: 0,
fans_count: 0,
like_count: 0,
photo: "", //定义photo
},
});
这时返回到浏览器可以看到,图片已经展示出来了。
但是,这样写是有问题的。
问题是:项目在开发阶段与生产阶段请求的服务端的地址有可能是不一样的。这样导致后期还要修改服务端的请求地址,非常麻烦。
这里可以通过设置环境变量来解决。
Vite
设置环境变量官方地址:
https://cn.vitejs.dev/guide/env-and-mode.html
在项目的根目录下面创建.env.development
文件,表示在开发环境中使用的环境变量内容
内容为:
VITE_APP_URL='http://localhost:3005'
注意:前缀一定是VITE_
(这里需要重新启动服务)
下面修改一下my.vue
文件中的代码
const url = import.meta.env.VITE_APP_URL; //获取环境变量中的值
onMounted(() => {
//登录以后,才加载对应的数据
if (store.state.user) {
loadUserInfo(state, store.state.user);
}
});
return {
...toRefs(state),
userState,
...useLogout(store),
url, //将其返回
};
},
模板中的应用:
<van-image
:src="`${url}` + userInfo.photo"
class="avatar"
round
fit="cover"
></van-image>
这里使用了模板字符串,获取url
属性中的值。
10、优化设置Token
我们在请求服务端的很多的接口的时候,都需要通过授权才能够访问。
而如果我们每次发送请求的时候,都写headers
请求头,会比较麻烦。
headers: {
Authorization: `Bearer ${store.state.user.myToken}`,
},
其实,这里我们可以通过axios
的请求拦截器来完成处理。因为我们通过axios
发送请求的时候,都会先去执行对应的axios
的请求拦截器。在请求拦截器中,设置token
数据,这样发送到服务端的数据中,就有了对应的token
数据了。从而,不需要单独的在设置token
数据了。
下面返回到utils/request.js
文件,添加请求拦截器。
import axios from "axios";
import store from "../store";
const request = axios.create({
baseURL: import.meta.env.VITE_APP_URL, //这里也从环境变量中获取服务端的地址
});
//请求拦截器
request.interceptors.request.use(
function (config) {
//请求发送会经过这里
//config:本次请求的请求配置对象
const user = store.state.user;
if (user && user.myToken) {
config.headers.Authorization = `Bearer ${user.myToken}`;
}
//注意:这里务必要返回config配置对象,否则请求就停在这里出不去了。
return config;
},
function (err) {
//如果请求出错了,会执行这里。
return Promise.reject(err);
}
);
//响应拦截器
export default request;
这里,将api/user.js
文件中的请求头内容删除掉。
// 封装用户相关的请求模块
import request from "../utils/request";
// import store from "../store";
export const login = (data) => {
return request({
method: "POST",
url: "/user/login",
data,
});
};
//获取用户自己完整的信息
export const getUserInfo = (id) => {
return request({
method: "GET",
url: "/userInfo/get",
params: { userId: id },
// headers: {
// Authorization: `Bearer ${store.state.user.myToken}`,
// },
});
};
返回浏览器,监视请求,查看是请求头中否有有Authorization
这一项内容。
四、首页功能实现
1、头部布局实现
下面修改views/home/index.vue
文件
在下面的代码中,使用了nav-bar
这个导航组件,同时在其插槽中添加了button
按钮。
并且为按钮添加了相应的css
样式。
<template>
<div class="home-container">
<van-nav-bar class="page-nav-bar">
<template #right>
<van-button
type="info"
size="small"
round
icon="search"
class="search-button"
>搜索</van-button
>
</template>
</van-nav-bar>
</div>
</template>
<script>
export default {};
</script>
<style>
.home-container .search-button {
width: 300px;
height: 30px;
background-color: #5babfb;
border: none;
font-size: 18px;
color: #fff;
}
</style>
2、文章频道列表构建
关于文章的频道列表,这里主要使用了Vant
中的Tab
标签页组件。
Tab
标签页组件提供的功能非常的强大,这里我们使用基础用法就可以。
在导航栏组件的下面添加Tab标签组件
<!-- 频道列表 -->
<van-tabs v-model:active="active" animated swipeable>
<van-tab title="标签 1">内容 1</van-tab>
<van-tab title="标签 2">内容 2</van-tab>
<van-tab title="标签 3">内容 3</van-tab>
<van-tab title="标签 4">内容 4</van-tab>
</van-tabs>
关于active
属性的作用:默认选中的页签的索引值,页签的索引值是从0开始计算的。
<script>
import { ref } from "vue";
export default {
setup() {
const active = ref(2);
return { active };
},
};
</script>
通过 animated
属性可以开启切换标签内容时的转场动画的效果。
通过 swipeable
属性可以开启滑动切换标签页。
这里,我们按住鼠标左键,可以体验到对应的滑动切换的效果。
但是,要注意的是,默认情况下只能在内容区域才能够实现滑动切换的效果,如果在空白区域是没有滑动切换的效果的。
当然,这不能满足我们的需求,后面我们在进行相应的处理。
3、样式调整
<van-tabs class="channel-tabs" v-model:active="active" animated swipeable>
<van-tab title="标签 1">内容 1</van-tab>
<van-tab title="标签 2">内容 2</van-tab>
<van-tab title="标签 3">内容 3</van-tab>
<van-tab title="标签 5">内容 5</van-tab>
<van-tab title="标签 6">内容 6</van-tab>
<van-tab title="标签 7">内容 7</van-tab>
</van-tabs>
这里,我们首先给van-tabs
添加了一个类选择器。并且又添加了几项导航条。
下面添加对应的样式:
.channel-tabs .van-tab {
min-width: 120px;
border-right: 1px solid #edeff3;
font-size: 16px;
color: #777;
}
这里,每个导航项对应的样式为van-tab
,关于这一点,我们可以通过审查元素进行查看。
在上面的样式中,我们设置了文字的颜色,但是当我们选中某个导航项的时候,希望改变颜色。
.channel-tabs .van-tab--active {
color: #333;
}
这里通过审查,发现选中某项的时候,会添加一个van-tab--active
选择器。
下面,我们修改底部条的样式。
默认情况下底部条的颜色为红色,这里我们希望修改成蓝色,同时修改一下它的宽度。
.channel-tabs .van-tabs__line {
background-color: #3296fa;
width: 20px;
height: 6px;
}
这里也是通过审查元素可以查看到,对应的底部条的类选择器为van-tabs__line
.
4、频道列表汉堡按钮处理
当我们单击该按钮的时候,会出现一个编辑频道的页面,关于这块内容我们先不处理。
这里我们先把该按钮的基本样式效果实现一下。
<van-tabs class="channel-tabs" v-model:active="active" animated swipeable>
<van-tab title="标签 1">内容 1</van-tab>
<van-tab title="标签 2">内容 2</van-tab>
<van-tab title="标签 3">内容 3</van-tab>
<van-tab title="标签 5">内容 5</van-tab>
<van-tab title="标签 6">内容 6</van-tab>
<van-tab title="标签 7">内容 7</van-tab>
<template #nav-right>
<van-icon name="wap-nav" class="hamburger-btn"></van-icon>
</template>
</van-tabs>
在tab
组件中,我们使用了插槽,关于插槽这块内容,可以参考官方文档
https://vant-contrib.gitee.io/vant/v3/#/zh-CN/tab#tabs-slots
在这里,我们指定的是nav-left
,表示的是标签栏右侧内容
.
然后在插槽中使用了van-icon
来指定了一个wap-nav
图标。
下面给该图标指定了对应的css
样式。
.channel-tabs .hamburger-btn {
/* 固定定位 */
position: fixed;
/* 最右侧 */
right: 0;
display: flex;
justify-content: center;
align-items: center;
width: 66px;
height: 32px;
background-color: #fff;
/* 设置透明度 */
opacity: 0.902;
/* 设置图标的大小 */
font-size: 23px;
}
但是,这里我们遇到了一个问题:当我们展示到列表中的最后一项的时候,无法展示全。即时让wap-nav
图标设置了透明度,效果上也不是很好。
这里我们可以在wap-nav
图标前面在添加一个占位符。这样可以显示出最后一个列表项。
<template #nav-right>
<i class="placeholder"></i>
<van-icon name="wap-nav" class="hamburger-btn"></van-icon>
</template>
对应的css
样式
.channel-tabs .placeholder {
flex-shrink: 0;
width: 66px;
height: 32px;
}
这里我们指定了占位符的宽度与高度,但是测试的时候发现宽度不起作用。
因为整个列表都是使用flex
布局,而且内部的每一项都平分了对应的宽度,所以这里再添加一个占位符,它的宽度就不起作用了。
这里我们给占位符添加了一个flex-shrink
属性,并且取值为0,表示占位符不参与整个flex
宽度的计算。
5、获取列表数据
这里,我们获取频道列表的真实数据。
首先构建一个发送请求的方法。
在src/api/user.js
文件中添加对应的方法
// 获取用户频道列表
export const getUserChannels = () => {
return request({
method: "GET",
url: "/user/channels",
});
};
下面返回到views/home/index.vue
中添加对应的方法
<script>
import { ref, onMounted, reactive, toRefs } from "vue";
import { getUserChannels } from "../../api/user";
function loadChannels(state) {
getUserChannels().then((res) => {
state.channels = res.data;
});
}
export default {
setup() {
const state = reactive({
channels: [],
});
const active = ref(0);
onMounted(() => {
loadChannels(state);
});
return {
...toRefs(state),
active,
};
},
};
</script>
在上面的代码中,导入我们定义好的getUserChannels
方法,然后在onMounted
钩子函数中调用loadChnennes
方法,发送异步请求获取数据。将服务端返回的列表数据填充到state
中的channels
数组中。
然后把state
对象进行解构,在通过toRefs
函数将其转换成响应式,返回模板。
模板的处理如下所示:
<!-- 频道列表 -->
<van-tabs class="channel-tabs" v-model:active="active" animated swipeable>
<van-tab
:title="channel.name"
v-for="channel in channels"
:key="channel.id"
>{{ channel.name }}的内容</van-tab
>
<template #nav-right>
<i class="placeholder"></i>
<van-icon name="wap-nav" class="hamburger-btn"></van-icon>
</template>
</van-tabs>
在上面的代码中,进行了循环遍历,展示列表项。
最后返回浏览器进行测试。
6、创建列表组件
从这一小节开始,我们要对文章列表进行处理。
当我们单击某个频道的的时候,会展示该频道下的对应的文章内容。
基本的思路:
在这里,我们可以创建一个文章列表组件,到我们单击某个频道的时候,把该频道的编号传递到文章列表组件中,该组件根据接收到的频道的编号,获取对应的文章数据。
在views/home
目录下面创建一个components
目录,在该目录下面创建article-list.vue
文件。
该文件中的初步代码如下所示:
<template>
<div class="article-list">文章列表组件</div>
</template>
<script>
import { onMounted } from "vue";
export default {
props: {
channel: {
type: Object,
required: true,
},
},
setup(props) {
onMounted(() => {
console.log(props.channel);
});
},
};
</script>
<style></style>
通过props
接收传递过来的频道数据,注意steup
方法的第一个参数为props
.同时在onMounted
钩子函数中,把频道数据打印出来了。
现在返回到home/index.vue
这个组件中,使用一下article-list.vue
组件。并且完成频道数据的传递。
import { getUserChannels } from "../../api/user";
import ArticleList from "./components/article-list.vue";
并且完成组件的注册
export default {
components: {
ArticleList,
},
setup() {
修改一下模板的内容。
<van-tabs class="channel-tabs" v-model:active="active">
<van-tab
:title="channel.name"
v-for="channel in channels"
:key="channel.id"
>
<!--使用了article-list组件,并且传递了频道数据-->
<article-list :channel="channel"></article-list>
</van-tab>
<template #nav-right>
<i class="placeholder"></i>
<van-icon name="wap-nav" class="hamburger-btn"></van-icon>
</template>
</van-tabs>
在tab
组件中使用了article-list
这个文章列表组件,并且传递了对应的频道数据。
7、List组件基本使用
这一小节,我们重点来看一下关于文章列表组件中的列表应该怎样来实现?
这里我们使用的是Vant
中的List
列表组件:该组件有下拉刷新的效果,瀑布流滚动加载,用于展示长列表,当列表即将滚动到底部时,会触发事件并加载更多列表项。
https://vant-contrib.gitee.io/vant/v3/#/zh-CN/list
下面我们先来看一下该List
组件的基本使用。
这里,我们把基本的代码拷贝到article-list.vue
组件中进行测试,发现了一个问题,我们看不到loading
的不断展示的状态,同时如果数据没有了应该呈现出“没有更多了”这个信息。
在官网的实例中,都展示了以上内容。
而在我们的项目中却没有展示出来。
原因就是页面底部的状态栏,将其内容遮盖住了。而且底部的状态是一个固定的定位。
所以这里,我们给整个页面加上一个底部的填充距,就可以了。
我们返回到views/home/index.vue
页面,添加如下的样式:
.home-container {
padding-bottom: 100px;
}
.home-container .search-button {
width: 300px;
下面,我们看一下List组件
中的内容的含义:
<template>
<div class="article-list">
<van-list
v-model:loading="state.loading"
:finished="state.finished"
finished-text="没有更多了"
@load="onLoad"
>
<van-cell v-for="item in state.list" :key="item" :title="item" />
</van-list>
</div>
</template>
<script>
import { reactive } from "vue";
export default {
props: {
channel: {
type: Object,
required: true,
},
},
setup(props) {
const state = reactive({
list: [], //存储列表数据的数组
loading: false, // 控制加载中loading的状态,如果为true,展示“加载中..”
finished: false, // 控制数据加载结束的状态。当所有的数据加载完成后,该属性的值为true,这时会显示“没有更多了”
});
const onLoad = () => {
// 1、发送异步请求获取数据
// 异步更新数据
// setTimeout 仅做示例,真实场景中一般为 ajax 请求
setTimeout(() => {
// 2、获取到服务端返回的数据,将其填充到list数组中
for (let i = 0; i < 10; i++) {
state.list.push(state.list.length + 1);
}
// 3、本次数据加载结束之后要把加载状态设置为结束,loading关闭以后才能够触发下一次的加载更多
//在讲解的时候,把后面的注释掉,然后看一下结果
state.loading = false;
// 4、数据全部加载完成
if (state.list.length >= 40) {
// 如果没有数据了,把finished设置为true,之后不再触发加载更多
state.finished = true;
}
}, 1000);
};
return {
state,
onLoad,
};
},
};
</script>
<style></style>
load 事件
:
- List 初始化后会触发一次 load 事件,用于加载第一屏的数据。
- 如果一次请求加载的数据条数较少,导致列表内容无法铺满当前屏幕,List 会继续触发 load 事件,直到内容铺满屏幕或数据全部加载完成。
const onLoad = () => {
console.log("load");
在onLoad
方法中,我们打印了load
这个字符串,返回到浏览器中以后,刷新一下,发现输出了两次。输出两次的原因就是上面所描述的内容。
loading 属性
:控制加载中的 loading 状态- 非加载中,loading 为 false,此时会根据列表滚动位置判断是否触发 load 事件(列表内容不足一屏幕时,会直接触发)
- 加载中,loading 为 true,表示正在发送异步请求,此时不会触发 load 事件
- 在每次请求完毕后,需要手动将 loading 设置为 false,表示本次加载结束
finished 属性
:控制加载结束的状态
+- 所有数据加载结束,finished 为 true,此时不会触发 load 事件
总结:
List 列表组件:瀑布流滚动加载,用于展示长列表。
List 组件通过 loading 和 finished 两个变量控制加载状态,
当组件初始化或滚动到到底部时,会触发 load 事件并将 loading 自动设置成 true,此时可以发起异步操作并更新数据,数据更新完毕后,将 loading 设置成 false 即可。
若数据已全部加载完毕,则直接将 finished 设置成 true 即可。
8、请求列表数据
在src/api
目录下面创建一个article.js
文件,该文件中封装的就是:“文章请求模块”
具体的实现代码如下所示:
/*
文章请求模块
*/
import request from "../utils/request";
// 获取文章列表数据
export const getArticles = (params) => {
return request({
method: "GET",
url: "/articles",
params,
});
};
在上面的代码中,发送了get
请求,并且传递了对应的参数,来获取指定频道下的文章列表数据。
下面返回到views/home/components
目录下的article-list.vue
文件,修改对应的代码:
在下面的代码中,我们首先导入了getArticles
方法
然后重点修改的就是onLoad
这个方法:在该方法中调用的就是前面我们编写的getArticles
方法来发送异步请求,获取文章列表数据。
在发送请求的时候,需要构建对应的参数,包含了当前频道的编号,以及当前页码值。当前页码值最开始的时候为1,表示最开始是浏览第一页的数据。
当获取到服务端返回的数据以后,将其解构出来,添加到list
这个数组中。
修改loading
状态的值,将其修改为false
,这样才会去触发load
事件,重新加载数据。
服务端返回的数据都是放在了results
数组中,所以判断一下该数组中是否还有数据,如果有,修改pageNumber
这个状态属性的值,它的值是服务端返回的,最开始初始值为null
,这样在下次触发load
事件以后,会调用onLoad
方法,这样会加载下一页的数据。
如果results
数组中没有数据了,表示当前已经加载完所有数据,修改finished
这个状态属性的值为true
.
<script>
import { reactive } from "vue";
import { getArticles } from "../../../api/article";
export default {
props: {
channel: {
type: Object,
required: true,
},
},
setup(props) {
const state = reactive({
list: [],
loading: false,
finished: false,
pageNumber: null, //请求获取下一页的页码值
});
const onLoad = async () => {
console.log("load");
const params = {
channel_id: props.channel.id,
pageNumber: state.pageNumber || 1,//最开始是1,表示加载第一页数据,当下次加载的时候,获取的就是下一页的数据
};
const { data } = await getArticles(params);
const results = data.list; //results是一个数组,将其内部的成员解构出来填充到list数组中
state.list.push(...results);
//本次数据加载结束之后要把加载状态设置为结束
state.loading = false;
if (results.length > 0) {
//更新获取下一页的页码值。
state.pageNumber = data.p_num;
} else {
//results中没有服务端返回的数据了,将finished设置为true,不在加载更多了
state.finished = true;
}
};
return {
state,
onLoad,
};
},
};
</script>
获取完数据以后,下面要做的就是修改模板,这样就可以展示出对应的数据了。
<template>
<div class="article-list">
<van-list
v-model:loading="state.loading"
:finished="state.finished"
finished-text="没有更多了"
@load="onLoad"
>
<van-cell
v-for="(article, index) in state.list"
:key="index"
:title="article.title"
class="cell-list"
/>
</van-list>
</div>
</template>
让van-cell
展示的就是list
数组中的内容。
同时这里还指定了cell-list
这个样式,该样式如下:
<style>
.cell-list {
min-height: 100px;
}
</style>
如果,不添加样式,会不断的触发load
事件。
同时,这里还需要注意:将home/index.vue
中的其它属性删除掉,只保留active
属性就可以了,否则当单击频道名称进行切换的时候,不会触发对应的load
事件。
<van-tabs class="channel-tabs" v-model:active="active">
9、请求失败的处理
在获取文章列表数据的时候,由于网络原因,导致了请求失败,应该怎样处理呢?
其实List
组件已经为我们提供了对应的请求失败处理的方式。
修改article-list.vue
组件中的代码
<van-list
v-model:loading="state.loading"
:finished="state.finished"
finished-text="没有更多了"
v-model:error="state.error"
error-text="请求失败,点击重新加载"
@load="onLoad"
>
这里给list
组件添加了v-model:error
以及error-text
属性。
下面,在state
对象中定义error
这个状态属性。该状态属性默认值为false
const state = reactive({
list: [],
loading: false,
finished: false,
error: false, //控制列表加载失败的提示状态
pageNumber: null, //请求获取下一页的页码值
});
下面将onLoad
方法中的代码,都放在try...catch
中。如果出现了异常就会执行catch
中代码。
在catch
的代码中,我们将error
这个状态属性的值修改为true
,这样就会展示对应的错误提示信息。
同时,既然出现了错误了,就不需要在发送请求了,所以将loading
的状态值修改为false
.
const onLoad = async () => {
try {
console.log("load");
const params = {
channel_id: props.channel.id,
pageNumber: state.pageNumber || 1,
};
const { data } = await getArticles(params);
//模拟随机失败的情况
if (Math.random() > 0.5) {
JSON.parse("aaaaa");
}
const results = data.list; //results是一个数组,将其内部的成员解构出来填充到list数组中
state.list.push(...results);
//本次数据加载结束之后要把加载状态设置为结束
state.loading = false;
if (results.length > 0) {
//更新获取下一页的页码值。
state.pageNumber = data.p_num;
} else {
//results中没有服务端返回的数据了,将finished设置为true,不在加载更多了
state.finished = true;
}
} catch (err) {
//展示错误提示状态
state.error = true;
//请求失败了,不需要在发送请求了,所以将loading修改为false(在进行请求发送的时候该值为true)
state.loading = false;
}
};
为了能够模式出错的情况,这里我们通过JSON.parse
转换一个字符串,我们知道parse
这个方法可以将字符串json
格式的内容转换成json
对象,如果转换的字符串不符合json
格式会抛出异常,同时这里我们使用了随机数,来模拟出现错误的随机情况。
下面可以返回到浏览器端进行测试,测试完以后将“模拟随机失败情况的代码”注释掉。
10、下拉刷新效果
按住列表,向下拉动,然后松开鼠标,会有一个下拉刷新的效果。
这里我们会使用Vant
中的PullRefresh
下拉刷新组件来完成。
这里我们是向下拉动的时候,刷新列表数据。所以我们应该使用PullRefresh
这个组件,将List
这个组件包裹起来。
返回到article-list.vue
组件,进行代码修改:
<van-pull-refresh v-model="state.isreFreshLoading" @refresh="onRefresh">
<van-list
v-model:loading="state.loading"
:finished="state.finished"
finished-text="没有更多了"
v-model:error="state.error"
error-text="请求失败,点击重新加载"
@load="onLoad"
>
<van-cell
v-for="(article, index) in state.list"
:key="index"
:title="article.title"
class="cell-list"
/>
</van-list>
</van-pull-refresh>
在上面的代码中,我们使用van-pull-refresh
组件包裹了van-list
这个组件。
这里还需要定义isreFreshLoading
这个状态属性,该属性的作用:控制下拉刷新的loading
状态。
@refresh
事件是当下拉刷新的时候触发该事件,从而调用onRefresh
函数。
const state = reactive({
list: [],
loading: false,
finished: false,
error: false, //控制列表加载失败的提示状态
pageNumber: null, //请求获取下一页的页码值
isreFreshLoading: false, //控制下拉刷新的loading状态
});
在state
中定义了isreFreshLoading
这个状态属性,初始值为false
import { reactive } from "vue";
import { getArticles } from "../../../api/article";
function useRefresh() {
const onRefresh = () => {
console.log("useRefresh");
};
return {
onRefresh,
};
}
在上面的代码中,我们创建了useRefresh
函数,该函数返回了onRefresh
函数。
我们需要在setup
函数中,调用useRefresh
函数,并且将返回结果进行解构,返回,这样模块中就可以使用onRefresh
return {
state,
onLoad,
...useRefresh(),
};
可以返回浏览器进行测试。
下面继续完善一下onRefresh
方法的内容:
在该方法中主要完成三件事情:
第一:发送异步请求,获取最新的数据。
第二:将获取到的数据追加到列表的顶部
第三:关闭下拉刷新的loading状态。
在发送异步请求的时候,对应的参数中需要添加referrer
这个参数(服务端会随机生成一些测试数据),表示要下拉获取最新数据。pageNumber
的值还是1。
同时还要注意的是在调用useRefresh
方法的时候,传递了state
状态以及频道的编号。
function useRefresh(state, id) {
const onRefresh = async () => {
try {
//1、请求获取数据
const params = {
channel_id: id,
pageNumber: 1,
referrer: "load",
};
const { data } = await getArticles(params);
const results = data.list;
//2、将数据追加到列表的顶部
state.list.unshift(...results);
//3、关闭下拉刷新的loading状态
state.isreFreshLoading = false;
} catch (err) {
console.log("请求失败", err);
}
};
return {
onRefresh,
};
}
在调用useRefresh
方法的时候,进行参数的传递。
return {
state,
onLoad,
//调用useRefresh方法,传递参数
...useRefresh(state, props.channel.id),
};
下面我们要实现的是,当下拉获取到最新数据以后,给用户一个相应的提示,这样对用户来说是比较友好的体验。
在PullRefresh 下拉刷新
这个组件中,是有这种下拉成功提示效果的。
通过文档,我们可以看到下拉刷新的这个组件中有一个属性:success-text
.
这个属性可以设置刷新成功后的顶部提示文案。
<van-pull-refresh
v-model="state.isreFreshLoading"
@refresh="onRefresh"
success-text="刷新成功"
>
添加完success-text
属性以后,可以返回到浏览器看一下对应的效果。
下面我们可以继续完善一下提示信息,例如:刷新成功,更新了多少条数据。
<van-pull-refresh
v-model="state.isreFreshLoading"
@refresh="onRefresh"
:success-text="state.refreshSuccessText"
>
这里我们让success-text
这个属性绑定了refreshSuccessText
这个状态值。
下面在state
中定义该状态值。
const state = reactive({
list: [],
loading: false,
finished: false,
error: false, //控制列表加载失败的提示状态
pageNumber: null, //请求获取下一页的页码值
isreFreshLoading: false, //控制下拉刷新的loading状态
refreshSuccessText: "刷新成功",
});
在onRefresh
方法当获取了服务端返回的数据以后,统计出个数然后修改了state.refreshSuccessText
这个状态属性的值。
const { data } = await getArticles(params);
const results = data.list;
//2、将数据追加到列表的顶部
state.list.unshift(...results);
//3、关闭下拉刷新的loading状态
state.isreFreshLoading = false;
//更新下拉刷新成功提示的文本
state.refreshSuccessText = `刷新成功,更新了${results.length}条数据`;
下面返回浏览器中进行测试,发现可以展示出更新的数据条数。
但是问题是,展示的停留时间太短了。
<van-pull-refresh
v-model="state.isreFreshLoading"
@refresh="onRefresh"
:success-text="state.refreshSuccessText"
success-duration="1500"
>
这里给PullRefresh
组件添加了 success-duration
这个属性,指定时间值,这里我们指定的是1500
毫秒,默认的是500毫秒
。
最后,我们再来处理一下下拉刷新失败的情况,如果下拉刷新失败了,这里就给用户一个提示就可以了,用户就知道可以继续下拉刷新。
const onRefresh = async () => {
try {
//1、请求获取数据
const params = {
channel_id: id,
pageNumber: 1,
referrer: "load",
};
const { data } = await getArticles(params);
//模拟随机失败的情况
if (Math.random() > 0.5) {
JSON.parse("aaaaa");
}
const results = data.list;
//2、将数据追加到列表的顶部
state.list.unshift(...results);
//3、关闭下拉刷新的loading状态
state.isreFreshLoading = false;
//更新下拉刷新成功提示的文本
state.refreshSuccessText = `刷新成功,更新了${results.length}条数据`;
} catch (err) {
//出现错误将下拉刷新的loading状态关闭
state.isreFreshLoading = false;
state.refreshSuccessText = "刷新失败";
}
};
在上面的代码中,修改了catch
中的代码,展示了下拉失败给用户的提示信息,
同时,这里还是模拟了随机失败的情况。
下面可以返回浏览器进行测试。
11、固定头部
当我们滚动文章列表的内容的时候,头部区域也随着文章列表进行了滚动。
而我们希望的是,当文章列表在滚动的时候,让头部的内容进行固定。
这里我们可以直接给home/index.vue
页面中的NavBar
组件中添加一个fixed
属性,让其固定定位。
<van-nav-bar class="page-nav-bar" fixed>
通过演示,我们可以看到头部区域确实固定住了,但是频道区域的内容却无法显示了。原因是:对头部区域进行固定的定位以后,就脱离了标准的文档流了。而下面的内容都是标准的文档流,所以就跑到顶部了,这样正好被头部区域给覆盖了。
下面我们也需要对频道列表的内容设置一个固定定位:
通过审查元素,我们可以看到频道列表对应的样式选择器为:.van-tabs__wrap
下面为其添加固定定位(最外层添加了一个channel-tabs
选择器):
.channel-tabs .van-tabs__wrap {
position: fixed;
top: 46px;
/* 设置层级,如果不设置则显示不出来 */
z-index: 1;
left: 0;
right: 0;
}
设置好以上的样式以后,进行测试,发现频道列表展示出来了。
但是,文章列表也出现了一个问题:就是最开始的数据没有显示出来。
原因就是当频道列表与头部内容都固定定位以后,下面的文章列表内容又跑上来,被遮盖住了。
这里,我们可以给文章列表添加一个上边距,留出一定的空间就可以了。
返回到views/home/components
目录下的article-list.vue
文件
最外层有一个div
,为其添加样式就可以了
<div class="article-list">
添加样式
<style>
.cell-list {
min-height: 100px;
}
/*设置margin-top值*/
.article-list {
margin-top: 80px;
}
</style>
返回浏览器进行测试
12、记住列表滚动位置
假如我们在推荐
频道下面,浏览到某条文章,然后切换到Vue
频道下面,拖动滚动条浏览到某篇文章,再切换到推荐
频道下面,发现原有的位置发生了变化。也就是说,不同频道的列表滚动会相互的影响 。
为什么列表滚动会相互影响?
造成这个问题的原因是因为列表的滚动并不是在自己内部滚动,而是整个body
页面在滚动,也就是说不论你是在a
频道还是在b
频道,其实滚动的都是body
元素。
怎样解决?
让每个频道中的文章列表产生自己的滚动容器,这样就不会在相互影响了。
若何让文章列表产生自己的滚动容器呢?
我们可以给容器设置一个固定的高度,同时设置一个溢出滚动( 如果溢出,则应该提供滚动机制),我们这里是垂直滚动,所以设置:overflow-y:auto
就可以了。
我们找到views/home/components
目录下的article-list.vue
文件。
给最外层的article-list
这个div
添加样式。
<style>
.cell-list {
min-height: 100px;
}
.article-list {
margin-top: 80px;
height: 100%;
overflow-y: auto;
}
</style>
这里我们设置的高度值是100%
,但是发现原有的内容没有了,原因是:我们这里设置的高度百分比,继承的是父元素,而其父元素高度值为0,所以这里没有效果了。
这里我们给一个具体的高度值就可以了,这里经过测量发现高度值为530px
就可以。
.article-list {
margin-top: 80px;
height: 530px;
overflow-y: auto;
}
经过测试发现没有问题。
13、文章列表项组件创建
关于文章列表中的内容,不仅要展示文字,同时还要展示图片。而且,我们有很多组件中都会展示类似的内容。所以我们可以将文章列表组件中的每一项内容单独的抽离出一个组件,称作文章列表项组件。
这里我们是在src/components
目录下面创建article-item
目录,在该目录下面创建一个index.vue
文件,该文件中的初步代码如下所示:
<template>
<div class="article-item">文章列表项</div>
</template>
<script>
export default {
props: {
article: {
type: Object,
required: true,
},
},
};
</script>
<style></style>
该组件需要接收传递过来的文章对象。
下面使用一下该组件。
返回到views/home/components/article-list.vue
组件中使用article-item
这个组件。
import { reactive } from "vue";
import { getArticles } from "../../../api/article";
import ArticleItem from "../../../components/article-item/index.vue";
导入ArticleItem
这个组件。
export default {
components: {
ArticleItem,
},
props: {
channel: {
type: Object,
required: true,
},
},
完成ArticleItem
这个组件的注册操作。
<van-list
v-model:loading="state.loading"
:finished="state.finished"
finished-text="没有更多了"
v-model:error="state.error"
error-text="请求失败,点击重新加载"
@load="onLoad"
>
<!-- <van-cell
v-for="(article, index) in state.list"
:key="index"
:title="article.title"
class="cell-list"
/> -->
<article-item
v-for="article in state.list"
:key="article.id"
:article="article"
class="cell-list"
></article-item>
</van-list>
在list
组件中对article-item
这个组件进行遍历,在遍历的过程中传递了文章对象。
可以查看效果
现在基本的文章列表项组件创建完成了。
下面要实现的就是要获取对应的数据了。
14、展示列表项内容
关于列表项的整体结构,这里使用的是Cell
单元格组件。
下面把基本的格式,拷贝到components/article-item/index.vue
文件中。
<template>
<van-cell class="article-item" title="单元格" value="内容" label="描述信息" />
</template>
这里在单元格中填充的内容,不仅有文本,还有图片,所以这里需要用到单元格的插槽。
<template>
<van-cell class="article-item">
<!-- 使用 title 插槽来自定义标题 -->
<template #title>
<span class="title">{{ article.title }}</span>
</template>
<!-- 使用label插槽:自定义标题下方的描述信息 -->
<template #label>
<span>{{ article.aut_name }}</span>
<span>{{ article.comm_count }}评论</span>
<span>{{ article.pubdate }}</span>
</template>
<!-- 使用 right-icon 插槽来自定义右侧图标 -->
<template #right-icon>
<van-image
width="100"
height="100"
src="https://img.yzcdn.cn/vant/cat.jpeg"
/></template>
</van-cell>
</template>
现在返回到浏览器中,可以查看默认的效果。
下面对图片进行处理。
关于文章列表项中要展示的图片,有三种情况:
第一种情况:没有图片
第二种情况:只展示一张图片
第三种情况:展示三张图片。
下面先来处理展示一张图片的情况。
这里判断一下article
对象中的type
属性的取值,如果是1
表示只展示一张图片
<!-- 使用 right-icon 插槽来自定义右侧图标 -->
<template #right-icon>
<van-image
v-if="article.type === 1"
width="100"
height="100"
:src="`${url}` + article.images[0]"
/></template>
关于url
地址的确定:
<script>
export default {
props: {
article: {
type: Object,
required: true,
},
},
setup() {
//确定url地址
const url = import.meta.env.VITE_APP_URL;
return {
url,
};
},
};
</script>
下面来处理三张图片的展示的情况:
关于三张图片的处理,是在label
插槽中完成的,同时这里是对images
数组进行遍历
这里我们添加了cover-item
这个div
,也是为了方便后期的样式处理操作的。
<!-- 使用label插槽:自定义标题下方的描述信息 -->
<template #label>
<div v-if="article.type === 3" class="cover-wrap">
<div
class="cover-item"
v-for="(img, index) in article.images"
:key="index"
>
<van-image width="100" height="100" :src="`${url}` + img"></van-image>
</div>
</div>
<span>{{ article.aut_name }}</span>
<span>{{ article.comm_count }}评论</span>
<span>{{ article.pubdate }}</span>
</template>
处理完了一张图片与三张图片的情况以后,剩余的就是没有图片的情况。
15、文章列表项样式处理
样式设定:https://vant-contrib.gitee.io/vant/v3/#/zh-CN/style
注意:van-multi-ellipsis--l2
样式,在vant
官网中有说明
<template>
<van-cell class="article-item">
<!-- 使用 title 插槽来自定义标题 -->
<template #title>
<span class="title van-multi-ellipsis--l2"
>{{ article.title }}卡了撒娇发顺丰父级阿斯利康地方静安寺的发送</span
>
</template>
<!-- 使用label插槽:自定义标题下方的描述信息 -->
<template #label>
<div v-if="article.type === 3" class="cover-wrap">
<div
class="cover-item"
v-for="(img, index) in article.images"
:key="index"
>
<van-image
fit="conver"
:src="`${url}` + img"
class="cover-item-img"
></van-image>
</div>
</div>
<div class="label-info-wrap">
<span>{{ article.aut_name }}</span>
<span>{{ article.comm_count }}评论</span>
<span>{{ article.pubdate }}</span>
</div>
</template>
<!-- 使用 right-icon 插槽来自定义右侧图标 -->
<template #right-icon>
<van-image
v-if="article.type === 1"
class="right-cover"
:src="`${url}` + article.images[0]"
fit="cover"
/></template>
</van-cell>
</template>
<script>
export default {
props: {
article: {
type: Object,
required: true,
},
},
setup() {
const url = import.meta.env.VITE_APP_URL;
return {
url,
};
},
};
</script>
<style>
.article-item .title {
font-size: 20px;
color: #3a3a3a;
}
.article-item .van-cell__value {
flex: unset;
width: 232px;
height: 146px;
padding-left: 25px;
}
.article-item .right-cover {
width: 132px;
height: 146px;
}
.article-item .label-info-wrap span {
font-size: 14px;
color: #b4b4b4;
margin-right: 25px;
}
.article-item .cover-wrap {
display: flex;
}
.article-item .cover-wrap .cover-item {
/* 平分宽度 */
flex: 1;
padding-right: 4px;
}
</style>
16、处理日期时间
关于日期的处理,这里我们使用Day.js
这个库来完成。
官网:https://dayjs.fenxianglu.cn/
npm install dayjs
在untils
目录下面创建dayjs.js
文件,该文件中的代码如下所示:这里我们希望最终展示的日期是’几个月前’,几天前
这种效果,
所以需要用到day.js
中的相对时间处理的插件来完成:
插件的官方地址:https://dayjs.fenxianglu.cn/category/plugin.html#%E7%9B%B8%E5%AF%B9%E6%97%B6%E9%97%B4
import dayjs from "dayjs";
//加载中文语言包,dayjs默认语言是英文的,我们这里配置为中文
import "dayjs/locale/zh-cn";
import relativeTime from "dayjs/plugin/relativeTime";
// 配置相对时间的插件
dayjs.extend(relativeTime);
//全局使用语言包
dayjs.locale("zh-cn");
export default dayjs;
返回到article-item/index.vue
文件中使用一下day.js
提供的日期处理,
<script>
import dayjs from "../../utils/dayjs";
导入utils
目录中的dayjs.js
文件中的内容。
setup() {
const url = import.meta.env.VITE_APP_URL;
return {
url,
dayjs,
};
},
在setup
函数中将其返回。
在模板中就可以使用了
<div class="label-info-wrap">
<span>{{ article.aut_name }}</span>
<span>{{ article.comm_count }}评论</span>
<span>{{ dayjs().to(dayjs(article.pubdate)) }}</span>
</div>
dayjs()
获取当前的时间,to(dayjs(article.pubdate))
表示的含义:计算出当前时间到article.pubdate
这个时间的相对时间。
https://dayjs.fenxianglu.cn/category/display.html#%E6%97%B6%E9%97%B4%E5%88%B0%E5%BD%93%E5%89%8D
五、频道编辑
当单击频道栏目导航条右侧的按钮的时候,会呈现出一个编辑频道的页面,在这个页面中可以添加频道内容,以及移除对应的频道
1、弹出层组件应用
当单击频道栏目导航条右侧的按钮的时候,会呈现一个页面,在该页面中展示了对应的频道信息。但是这里需要注意的是该页面,是一个弹出层,而不是通过路由跳转到的新页面。
这里我们会使用Vant
中的Popup
弹出层组件来实现。
这里我们使用的是带有“关闭图标”的弹出层
返回到views/home/index.vue
文件,在其频道列表
对应的Tabs
组件下面添加频道编辑的弹出层组件
<!-- 频道编辑弹出层 -->
<van-popup
v-model:show="isChannelEditShow"
closeable
position="bottom"
:style="{ height: '30%' }"
/>
<!-- 频道编辑弹出层 -->
这里需要定义isChannelEditShow
状态属性控制弹出层的显示与隐藏。
const state = reactive({
channels: [],
isChannelEditShow: false, // 控制编辑频道弹出层的显示与隐藏
});
当单击面包按钮的时候,将其弹出。
<van-icon
name="wap-nav"
class="hamburger-btn"
@click="isChannelEditShow = true"
></van-icon>
将isChannelEditShow
这个状态属性修改为true
。
<!-- 频道编辑弹出层 -->
<van-popup
v-model:show="isChannelEditShow"
closeable
position="bottom"
close-icon-position="top-left"
:style="{ height: '100%' }"
/>
<!-- 频道编辑弹出层 -->
在上面的代码中,我们将整个弹出层的高度调整为了100%
,同时控制了关闭按钮的显示位置,将其显示在顶部左侧区域。
下面返回到浏览器中进行测试。
2、创建频道编辑组件
关于弹出层中展示的频道内容,这里我们将其放到一个单独的组件中,这样管理起来更加的方便。
在views/home/components
目录下面创建channel-edit.vue
这个组件,该组件中的初步代码如下所示:
<template>
<div class="channel-edit">频道编辑</div>
</template>
<script>
export default {};
</script>
<style></style>
现在返回到views/home/index.vue
中使用一下channel-edit
这个组件。
import ArticleList from "./components/article-list.vue";
import ChannelEdit from "./components/channel-edit.vue";
导入ChannelEdit
组件,下面需要完成对应的注册:
export default {
components: {
ArticleList,
ChannelEdit,
},
在弹出层组件中使用ChannelEdit
组件。
<!-- 频道编辑弹出层 -->
<van-popup
v-model:show="isChannelEditShow"
closeable
position="bottom"
close-icon-position="top-left"
:style="{ height: '100%' }"
>
<channel-edit></channel-edit>
</van-popup>
返回到浏览器中进行测试。
3、页面布局实现
首先让文字与关闭按钮直接有一定的填充距
<style>
.channel-edit {
padding: 85px 0;
}
</style>
关于“我的频道”这项内容通过Cell
单元格组件来完成布局。
<template>
<div class="channel-edit">
<van-cell>
<!--使用了cell中的title插槽-->
<template #title> 我的频道 </template>
<van-button type="danger" round size="mini" plain>编辑</van-button>
</van-cell>
<!--使用Grid宫格组件展示内容,并且使用的是带有格子间距的Grid组件--->
<van-grid :gutter="10">
<van-grid-item v-for="value in 8" :key="value" text="文字" />
</van-grid>
<!-- 频道推荐 -->
<van-cell>
<template #title> 频道推荐 </template>
</van-cell>
<van-grid :gutter="10">
<van-grid-item v-for="value in 8" :key="value" text="文字" />
</van-grid>
</div>
</template>
4、样式调整
设置标题的样式:
<template #title>
<span class="title-text">我的频道</span>
</template>
<template #title>
<span class="title-text">频道推荐</span>
</template>
css
样式
.channel-edit {
padding: 85px 0;
}
.channel-edit .title-text {
font-size: 20px;
color: #333;
}
编辑按钮的样式:
<van-button type="danger" round size="mini" plain class="edit-btn"
>编辑</van-button
.channel-edit .edit-btn {
width: 85px;
height: 38px;
font-size: 16px;
}
下面给Grid
宫格组件添加基本的样式:
<van-grid-item
class="grid-item"
v-for="value in 8"
:key="value"
text="文字"
/>
<van-grid-item
v-for="value in 8"
:key="value"
text="文字"
class="grid-item"
/>
样式
.channel-edit .grid-item {
width: 86px;
height: 66px;
background-color: #f4f5f6;
}
下面设置宫格内的文字样式:通过审查元素,发现对应的选择器为van-grid-item__text
.channel-edit .van-grid-item__text {
font-size: 16px;
color: #222;
}
下面,我们要实现的效果就是给频道推荐
栏目中的每个频道名称前面添加一个加号,后期单击该加号,可以把频道添加到我的频道
中。
<van-grid :gutter="10" class="recommend-grid">
<van-grid-item
v-for="value in 8"
:key="value"
text="文字"
class="grid-item"
icon="plus"
/>
</van-grid>
给"推荐频道"对应的Grid-Item
中添加了icon=plus
图标,同时给Grid
组件添加了一个类选择器recommend-grid
.
下面设置样式:
.channel-edit .recommend-grid .van-grid-item__content {
/* 水平排列 */
flex-direction: row;
}
这里审查元素,发现图标对应的父元素是.van-grid-item__content
,这里让其元素进行水平排列就可以了。
下面控制一下图标的大小,这里还是审查元素,找到图标的选择器(选择器为:van-icon
),为其设置大小。同时控制一下文字与图标之间的间距
.channel-edit .recommend-grid .van-icon {
/* 图标的大小 */
font-size: 16px;
margin-right: 5px;
}
通过查看效果,发现文字与图标没有对齐。
这里选中“文字”发现有一个上边距,这里将上边距去掉就可以了。
.channel-edit .van-grid-item__text {
font-size: 16px;
color: #222;
/*取消上边距*/
margin-top: 0;
}
同时,这里还要考虑一下“文字”比较多以后,会出现换行的情况:
<van-grid-item
v-for="value in 8"
:key="value"
text="文字你好"
class="grid-item"
icon="plus"
/>
在上面的"推荐频道中的Grid"中的text
属性中又添加了两个字以后,发现换行了。
这里我们不希望换行。
.channel-edit .recommend-grid .van-grid-item__content {
/* 水平排列 */
flex-direction: row;
/* 进制换行 */
white-space: nowrap;
}
当然,如果文字比现在还多的话,效果就不好看了,当然,这种情况我们也不用担心,因为在后台系统中,当添加频道的名称的时候一定会限制文字的个数的。
下面要考虑的是,当单击“编辑”按钮的时候,“我的频道”中的文字的右上角会出现一个关闭的按钮。
<van-grid :gutter="10" class="my-grid">
<van-grid-item
class="grid-item"
v-for="value in 8"
:key="value"
text="文字"
icon="clear"
/>
</van-grid>
这里给grid
组件添加一个类选择器my-grid
,同时给grid-item
添加一个icon
图标。
下面添加对应的样式,这里使用了绝对定位。
.channel-edit .my-grid .grid-item .van-icon-clear {
position: absolute;
right: -5px;
top: -5px;
font-size: 17px;
z-index: 2;
color: #cacaca;
}
通过审查元素,发现图标的类选择器为.van-icon-clear
.
这里控制了定位的right
与left
的位置,同时控制了图标的大小以及颜色。
最后,通过浏览发现,Grid
组件的边框线在图标的前面,所以通过z-index
控制了显示的层级。让其图标在Grid
的上层显示
5、展示“我的频道”数据
我的频道
中的数据,其实就是我们前面在首页中的频道列表中的数据
这里我们为了获取"我的频道"中的数据可以重新发送异步请求来获取,也可以把首页中的频道列表中的数据直接传递过来。
这里我们返回到views/home/index.vue
文件中,
<!-- 频道编辑弹出层 -->
<van-popup
v-model:show="isChannelEditShow"
closeable
position="bottom"
close-icon-position="top-left"
:style="{ height: '100%' }"
>
<channel-edit :my-channels="channels"></channel-edit>
</van-popup>
<!-- 频道编辑弹出层 -->
给channel-edit
组件中传递了channels
这个数组中的内容。
下面返回到home/components/channel-edit.vue
这个组件中,接受传递过来的数据。
<script>
export default {
props: {
myChannels: {
type: Array,
required: true,
},
},
setup(props) {
// console.log(props.myChannels);
},
};
</script>
把接受到的数据在模板中进行展示。
<van-grid :gutter="10" class="my-grid">
<van-grid-item
class="grid-item"
v-for="channel in myChannels"
:key="channel.id"
:text="channel.name"
icon="clear"
/>
</van-grid>
这时候,返回到浏览器中查看对应的效果。
6、处理激活频道高亮
当我们单击了首页中的频道列表中的某一项的时候,切换到频道的编辑组件的时候,在我的频道
中的对应的频道会被选中。
下面说一下基本的实现思路:
第一:将首页中的激活的标签索引传递给频道编辑组件
第二:在频道编辑组件中遍历“我的频道列表”的时候判断遍历项的索引是否等于激活的频道标签的索引,如果相等则添加一个高亮的css
类名。
在views/home/index.vue
文件中,将所选中的频道的编号传递到channel-edit
组件中。
<!-- 频道编辑弹出层 -->
<van-popup
v-model:show="isChannelEditShow"
closeable
position="bottom"
close-icon-position="top-left"
:style="{ height: '100%' }"
>
<channel-edit :my-channels="channels" :active="active"></channel-edit>
</van-popup>
<!-- 频道编辑弹出层 -->
在上面的代码中,传递了active
下面返回到home/components目录下的
channel-edit.vue`中进行接收。
export default {
props: {
myChannels: {
type: Array,
required: true,
},
active: {
type: Number,
required: true,
},
},
下面要做的就是修改我的频道
的模板内容。
<van-grid :gutter="10" class="my-grid">
<van-grid-item
class="grid-item"
v-for="channel in myChannels"
:key="channel.id"
icon="clear"
><span class="text">{{ channel.name }}</span></van-grid-item
>
</van-grid>
这里是在van-grid-item
内部展示频道的名称,同时添加了一个类选择。
.channel-edit .text {
font-size: 14px;
color: #222;
margin-top: 0;
}
下面继续修改模板中的内容:
<van-grid :gutter="10" class="my-grid">
<van-grid-item
class="grid-item"
v-for="(channel, index) in myChannels"
:key="channel.id"
icon="clear"
><span class="text" :class="{ active: index === active }">{{
channel.name
}}</span></van-grid-item
>
</van-grid>
这里我们给span
标签动态绑定了一个class
属性。
:class:就是v-bind:class
绑定的就是一个对象,该对象中的属性active表示要作用的类名,对象中的value值表示最终计算出的布尔值,这个布尔值如果为true,就把active这个类名作用在sapn标签上,否则不会。这里我们在使用v-for进行遍历的时候,取出对应的索引值给了index,然后判断index的值是否与父组件中传递过来的active的值是否一致。
添加样式(定义一个active
的类名样式)
.channel-edit .grid-item .active {
color: red;
}
可以在浏览器中进行查看。
7、获取所有频道
这一小节,我们要实现的功能是获取推荐频道
中的数据内容。
推荐频道中展示的频道数据其实就是 “所有频道-我的频道”剩余的频道。
也就是说,当某个频道属于“我的频道”内容的时候,就不会在“推荐频道”中展示了。
所以实现的步骤分为两步:
第一:获取所有频道
第二:基于所有频道和我的频道计算出推荐频道。
这里我们首先来实现第一步的操作。
在src/api
目录下面创建channel.js
文件,该文件中的代码如下所示:
//频道请求模块
import request from "../utils/request";
//获取所有的频道
export const getAllChannels = () => {
return request({
method: "GET",
url: "/channels",
});
};
下面返回到views/home/compoents/channel-edit.vue
组件中发送异步请求,获取所有的频道数据。
setup(props) {
// console.log(props.myChannels);
const state = reactive({
allChannels: [],
});
onMounted(() => {
loadAllChannels(state);
});
},
定义状态对象state
,该对象中包含了一个allChannels
数组,用来存储所有的频道数据。
在onMounted
钩子函数中调用loadAllChannels
方法,用来获取所有的频道数据。把state
对象传递到该方法中。
import { onMounted, reactive } from "vue";
import { getAllChannels } from "../../../api/channel";
function loadAllChannels(state) {
getAllChannels().then((res) => {
state.allChannels = res.data;
console.log(state.allChannels);
});
}
在loadAllChannels
方法中,调用了getAllChannels
方法发送异步请求,所以这里需要导入getChannels
方法,获取到服务端返回的全部频道数据以后,将其存储到state
中的allChannels
数组中。
这里可以进行输出打印,看一下是否能够获取到服务端返回的频道数据。
8、展示推荐频道
这一小节,我们要实现的就是:基于所有频道和我的频道计算出推荐频道。
也就是推荐频道中展示的频道数据其实就是 “所有频道-我的频道”剩余的频道。
具体的实现代码如下所示:
setup(props) {
// console.log(props.myChannels);
const state = reactive({
allChannels: [],
});
//计算出推荐频道的内容,这里通过计算属性来完成
const recommendChannels = computed(() => {
const channels = []; //存储频道推荐中的频道数据
state.allChannels.forEach((channel) => {
//通过find遍历数组,找到满足条件的元素
const result = props.myChannels.find((myChannel) => {
return myChannel.id === channel.id;
});
//如果我的频道中不包括该频道项,则收集到推荐频道中
if (!result) {
channels.push(channel);
}
});
return channels;
});
onMounted(() => {
loadAllChannels(state);
});
return {
recommendChannels,
};
},
在setup
函数中,我们创建了一个计算属性recommendChannels
来完成。
最终将其返回,这样在模板中就可以使用了。
<!-- 频道推荐 -->
<van-cell>
<template #title>
<span class="title-text">频道推荐</span>
</template>
</van-cell>
<van-grid :gutter="10" class="recommend-grid">
<van-grid-item
v-for="channel in recommendChannels"
:key="channel.id"
:text="channel.name"
class="grid-item"
icon="plus"
/>
</van-grid>
在模板中对计算属性返回的数组内容进行遍历。
最后在浏览器中查看对应的结果。
9、添加频道
当单击“推荐频道”中的某个频道的时候,会将其添加到我的频道
中。
<van-grid :gutter="10" class="recommend-grid">
<van-grid-item
v-for="channel in recommendChannels"
:key="channel.id"
:text="channel.name"
class="grid-item"
icon="plus"
@click="onAddChannel(channel)"
/>
</van-grid>
这里,我们给推荐频道
中的每个频道项都添加了单击事件,该事件触发以后,会执行onAddChannel
函数。
在setup
方法中定义onAddChannel
函数,并且返回
//添加频道
const onAddChannel = (channel) => {
props.myChannels.push(channel);
};
onMounted(() => {
loadAllChannels(state);
});
return {
recommendChannels,
onAddChannel, //返回onAddChannel函数
};
在onAddChannel
函数的内部,就是将传递过来的所要添加的频道,添加到了myChannels
这个数组中了。因为myChannels
这个数组中存储的就是我的频道
中的频道数据。
在浏览器中进行测试的时候,发现当我们单击了“推荐频道”中的某个频道的时候,自动的添加到了我的频道
中,并且对应的项会从“推荐频道”中移除。
但是,问题是我们没有写移除的代码。
原因就是我们前面所使用的计算属性,实现的这个效果。
计算数据会观察内部所依赖的数据的变化,如果所依赖的数据发生了变化,则计算属性会重新计算。
在计算属性中,我们使用到了myChannels
这个数组,所以当这个数组发生了变化以后,会重新执行计算属性。
10、处理编辑状态
在这一小节中,我们要实现的功能是,关于"我的频道"的编辑功能。
当我们单击编辑按钮的时候,在我的频道
中的每个频道名称的右上角都会出现一个叉号的图标,当我们单击某个频道,这个频道就会被移除,添加到推荐频道
列表中。
下面我们先来实现一下,当单击了编辑按钮以后,让频道名称的右上角出现叉号图标。
这里需要对图标做一个相应的处理。
<van-grid :gutter="10" class="my-grid">
<van-grid-item
class="grid-item"
v-for="(channel, index) in myChannels"
:key="channel.id"
>
<template #default>
<van-icon name="clear"></van-icon>
<span class="text" :class="{ active: index === active }">{{
channel.name
}}</span>
</template>
</van-grid-item>
</van-grid>
在这里,我们将图标通过van-icon
组件展示,并且这里将其频道的名称以及图标都放到了插槽中。
最开始的时候,这个图标是不展示的,当单击了编辑按钮的时候才会进行展示。
const state = reactive({
allChannels: [],
isEdit: false, //控制编辑状态的显示与隐藏
});
这里,我们定义了一个状态属性isEdit
,用来控制编辑的状态,初始值为false
<van-icon name="clear" v-show="isEdit"></van-icon>
这里,我们通过v-show
来控制图标的展示和隐藏。
下面单击编辑按钮的时候,修改isEdit
这个状态属性的值。
<van-button
type="danger"
round
size="mini"
plain
class="edit-btn"
@click="isEdit = !isEdit"
>{{ isEdit ? "完成" : "编辑" }}</van-button
>
当isEdit
属性的值为true
的时候,按钮上的文字显示完成
,否则显示编辑
这里还有一个小的细节问题:“推荐”频道,不能删除。
这样在推荐
频道的右上角就不能出现叉号了。
const state = reactive({
allChannels: [],
isEdit: false, //控制编辑状态的显示与隐藏
fiexChannels: [1], //不允许删除的频道的编号存储到该数组中
});
在state
中定义了fiexChannels
这个属性,该属性的取值是一个数组,数组中存储的就是不允许删除的的频道的编号,这里我们设置的值为1,表示不能删除"推荐"频道。
然后在模板中,在展示叉号这个图标的时候进行相应的判断。
<van-icon
name="clear"
v-show="isEdit && !fiexChannels.includes(channel.id)"
></van-icon>
返回到浏览器中进行测试。
11、切换频道
当我们单击了编辑按钮后,又点击了我的频道
中的某个频道会将其移除,但是如果没有单击编辑按钮,而单击了某个频道,就会跳转到该频道对应的文章列表。
这里不管是移除频道,还是完成对应的跳转,都需要给我的频道
中的每个频道添加对应的点击事件。
<van-grid :gutter="10" class="my-grid">
<van-grid-item
class="grid-item"
v-for="(channel, index) in myChannels"
:key="channel.id"
@click="onMyChannelClick(channel, index)"
>
当点击事件触发以后,调用onMyChannelClick
这个函数,传递了频道数据,以及对应的索引值。
function useMyChannelClick(state) {
const onMyChannelClick = (channel, index) => {
//这里通过state中的isEdit属性判断是否处于编辑状态
if (state.isEdit) {
//编辑状态,则执行删除频道
} else {
// 非编辑状态,执行切换频道
}
};
return {
onMyChannelClick,
};
}
在setup
函数中,调用useMyChannelClick
函数。
return {
...toRefs(state),
recommendChannels,
onAddChannel,
...useMyChannelClick(state),
};
下面我们就来看一下在非编辑状态下,怎样实现切换频道。
当我们单击了我的频道
中的某个频道以后,只需要将对应的索引值,传递给父组件,交个父组件中的active
就可以了。这样就可以将父组件中的对应的频道选中。
这里需要触发父组件中传递过来的事件:
setup(props, { emit }) {
}
这里对setup
第二个参数进行解构,获取emit
return {
...toRefs(state),
recommendChannels,
onAddChannel,
...useMyChannelClick(state, emit),
};
将emit
传递给useMyChannelClick
函数。
function useMyChannelClick(state, emit) {
const onMyChannelClick = (channel, index) => {
if (state.isEdit) {
//编辑状态,则执行删除频道
} else {
// 非编辑状态,执行切换频道
emit("update-active", index);
}
};
return {
onMyChannelClick,
};
}
在onMyChannelClick
函数的内部,通过emit
触发了update-active
事件,并且传递了index
索引的值。
emits: ["update-active"],
setup(props, { emit }) {
这里也需要通过emits
来声明一下update-active
这个事件。
下面返回到父组件中,进行相应的处理。
views/home/index.vue
<channel-edit
:my-channels="channels"
:active="active"
@update-active="onUpdateActive"
></channel-edit>
在使用channelEdit
这个组件的时候,为其传递了update-active
这个事件,当在其内部触发了update-active
这个事件以后,就会执行onUpdateActive
这个函数,在setup
方法中实现onUpdateActive
这个函数。注意:当调用onUpdateActive
这个函数的时候,会传递对应的频道的index
索引值。
在接收到这个索引值以后,赋值给active
这个响应式对象,注意:这里需要为其的value
属性赋值、
同时,修改isChannelEditShow
这个状态的值,将其修改为false
,这样就会关闭对应的窗口。
const onUpdateActive = (index) => {
active.value = index;
state.isChannelEditShow = false; //将频道的图层关闭掉
};
return {
...toRefs(state),
active,
onUpdateActive,
};
最后,将onUpdateActive
函数返,这样在模板中才能够使用。
返回浏览器进行测试。
12、删除频道
基本思路:当我们单击了“我的频道”中的某个频道的时候,获取对应的索引值,然后将其从数组中移除掉就可以了。
当我们在调用useMyChannelClick
方法的时候,将其myChannels
数组传递到该方法中。
return {
...toRefs(state),
recommendChannels,
onAddChannel,
...useMyChannelClick(state, emit, props.myChannels),
};
下面完善一下useMyChannelClick
方法的实现。
function useMyChannelClick(state, emit, myChannels) {
const onMyChannelClick = (channel, index) => {
if (state.isEdit) {
//编辑状态,则执行删除频道
myChannels.splice(index, 1);
} else {
// 非编辑状态,执行切换频道
emit("update-active", index);
}
};
return {
onMyChannelClick,
};
}
在onMyChannelClick
方法中,根据索引值删除myChannels
数组中的内容就可以了。因为这时候修改了myChannels
这个数组以后,会自动执行计算属性中的内容。
这时候,在浏览器中查看对应的效果。
但是,这里还有一个小的问题:
当我们在首页中,选中React
这个频道名称以后,然后在单击面包按钮进行频道列表以后,React
这个频道会被高亮选中,这时候单击编辑按钮,同时单击React
频道名称前面的Vue
这个频道,我们发现Vue
频道被删除了,但是当前的被选中的频道变成了NodeJs
而不是React
.
而实际情况是,选中的频道还是React
.
造成这个问题的原因:当React
这个频道处于选中的时候,对应的active
这个属性的值为2. 而现在把其前面的Vue
删除以后,整个频道的索引值发生了变化,这时候React
的索引值变为1
,但是active
这个属性的还是为2,表示NodeJS
这个频道项就被选中了。
应该怎样处理呢?
如果删除频道的索引值小于等于active
的值,我们让激活频道的索引减去1.
在
return {
...toRefs(state),
recommendChannels,
onAddChannel,
...useMyChannelClick(state, emit, props),
};
在调用useMyChannelClick
方法的时候,直接传递了props
function useMyChannelClick(state, emit, { myChannels, active }) {
const onMyChannelClick = (channel, index) => {
if (state.isEdit) {
//编辑状态,则执行删除频道
if (index <= active) {
//让激活频道的索引减去1.
emit("update-active", active - 1);
}
myChannels.splice(index, 1);
} else {
// 非编辑状态,执行切换频道
emit("update-active", index);
}
};
return {
onMyChannelClick,
};
}
在useMyChannelClick
方法中,将myChannels
和active
解构出来。
然后判断如果要删除的频道的索引值小于等于active
,表明删除的就是激活的频道前面的频道,这时候让active
减去1,然后触发update-active
事件。
该事件触发以后,会调用父组件中的,onUpdateActive
方法,在该方法中完成了active
属性值的修改。
但是,这里又面临了一个新的问题,当我们调用onUpdateActive
方法的时候,会将频道列表给关闭掉,但是当前这种情况下,我们是不希望关闭频道列表的。
所以下面的处理:
返回到父组件views/home/index.vue
中,给onUpdateActive
方法添加了一个isChannelEditShow
参数,默认值为true
.
const onUpdateActive = (index, isChannelEditShow = true) => {
active.value = index;
state.isChannelEditShow = isChannelEditShow; //将频道的图层关闭掉
};
返回到channel-edit.vue
组件。
if (state.isEdit) {
//编辑状态,则执行删除频道
if (index <= active) {
//让激活频道的索引减去1.
emit("update-active", active - 1, true);
}
myChannels.splice(index, 1);
} else {
// 非编辑状态,执行切换频道
emit("update-active", index, false);
}
当编辑状态触发update-active
事件传递的第三个参数是true
,当然这里也可以不用传递,因为onUpdateActive
中的isChannelEditShow
参数的默认值为true
.
而在非编辑状态下,传递的是false
但是,这里还有一个问题:就是关于“推荐频道”,虽然这里其右上角没有展示对应的叉号
图标,但是对应还是有绑定事件的,所以当在编辑状态下的时候,我们去单击“推荐”频道,还是会将其删除掉的。
所以在删除的时候,还是需要做一些判断的。
const onMyChannelClick = (channel, index) => {
if (state.isEdit) {
//编辑状态,则执行删除频道
//如果是推荐频道(也就是固定的频道,不允许删除)
if (state.fiexChannels.includes(channel.id)) {
return;
}
if (index <= active) {
//让激活频道的索引减去1.
emit("update-active", active - 1, true);
}
myChannels.splice(index, 1);
} else {
// 非编辑状态,执行切换频道
emit("update-active", index, false);
}
};
在onMyChannelClick
方法中,如果处于了编辑状态,则判断fiexChannels
数组中是否包含要删除的频道的编号,如果包含,则直接返回,不进行删除操作。
13、数据持久化-分析
现在,关于频道的基本功能,我们都实现了。
但是如果我们在这删除了某个频道或者添加了某个频道以后,我们有刷新了浏览器以后,发现最终的频道又恢复到默认的状态下了,这是因为我们没有对频道中的数据做相应的持久化。
同时,这里还需要注意:关于频道列表展示的内容,不管用户是否登录都可以访问。
如果,用户在未登录的状态下进行操作,当他登录了以后,看到的是登录下操作的频道数据,如果推出登录看到的就是未登录下操作的数据。
同时,如果用户在登录下操作完频道列表以后,换了其它的设备又登录了系统看到的还是登录后操作的列表数据。
而未登录状态下操作的频道列表,只在当前设备中才能看到。
不登录的情况下:频道列表数据存储在本地,不支持同步功能
在登录的情况下:频道列表数据存储在服务端,更换不同的设备以后可以同步数据。
14、数据持久化-添加频道
在添加频道的时候,如果用户没有登录,则存储在本地,如果已经登录,则存储到服务端。
我们知道,如果用户登录了以后,会将登录信息存储到vuex
中,所以这里我们判断其对应的容器中是否有登录用户的数据,如果没有表示用户没有登录。
这里需要将频道信息存储到本地,所以这里需要导入useStore
和setItem
两个方法。
import { getAllChannels } from "../../../api/channel";
import { useStore } from "vuex";
import { setItem } from "../../../utils/storage";
setup(props, { emit }) {
// console.log(props.myChannels);
const store = useStore();//获取容器
const state = reactive({
allChannels: [],
isEdit: false, //控制编辑状态的显示与隐藏
fiexChannels: [1], //不允许删除的频道的编号存储到该数组中
});
在setup
方法中通过useStore
函数获取对应的store
容器。
//添加频道
const onAddChannel = (channel) => {
props.myChannels.push(channel);
//数据持久化处理
if (store.state.user) {
// 已经登录,把数据存储到服务端
} else {
// 未登录,把数据存储到本地
setItem("TOUTIAO_CHANNELS", props.myChannels);
}
};
判断一下容器中是否存储了登录用户信息的数据,如果没有表示未登录,直接将props.myChannels
中的内容存储到本地。
在进行测试的时候,先将localStorage
中存储的用户信息删除掉。
下面我们来看一下用户登录以后的处理:
在src/api
目录下的channel.js
文件中添加如下方法
//添加用户频道
export const addUserChannel = (channel) => {
return request({
method: "POST",
url: "/user/addChannels",
data: channel,
});
};
以上方法的channel
参数就是要添加的频道数据
下面返回到channel-edit.vue
这个组件中,发送异步请求
import { getAllChannels, addUserChannel } from "../../../api/channel";
//添加频道
const onAddChannel = async (channel) => {
props.myChannels.push(channel);
//数据持久化处理
if (store.state.user) {
// 已经登录,把数据存储到服务端
try {
//构建添加频道的数据
const channelContent = {
userId: store.state.user.data.id, //登录用户的编号
channel,
};
await addUserChannel(channelContent);//完成频道的添加
} catch (err) {
console.log(err);
}
} else {
// 未登录,把数据存储到本地
setItem("TOUTIAO_CHANNELS", props.myChannels);
}
};
在上面的代码中,首先导入了addUserChannel
这个方法。
在所对应的添加频道的方法onAddChannel
中,调用addUserChannel
方法发送异步请求。
发送到服务端的数据中,包含了对应的用户编号以及所添加的频道数据。
下面我们测试一下,当我们在登录状态下,添加完对应的频道以后,对应的将其保存到服务端中,
这时候,我们刷新浏览器可以看到,我们添加完的频道内容(这时候,也是从服务端获取的频道数据)
当我们退出登录以后,发现还是展示的是登录后的频道列表数据,原因是:在以前的时候,我们也是直接发送了请求获取服务端的默认admin
这个用户的频道列表数据,而实际上应该是在没有登录的时候,获取的是本地存储的频道列表数据。
关于这块内容,我们后面在进行讲解
15、数据持久化–删除频道
删除频道的思路与添加频道的思路是一样的
如果未登录,操作的是本地存储,如果已经登录则操作的是服务端存储的频道数据。
关于删除的操作,我们原来是在views/home/components
目录下的channel-edit.vue
组件中的useMyChannelClick
方法中的onMyChannelClick
方法完成的。
下面需要修改该方法中的代码。
我们先来看一下没有登录的情况
function useMyChannelClick(state, emit, { myChannels, active }, store) {
const onMyChannelClick = (channel, index) => {
if (state.isEdit) {
//编辑状态,则执行删除频道
//如果是推荐频道(也就是固定的频道,不允许删除)
if (state.fiexChannels.includes(channel.id)) {
return;
}
if (index <= active) {
//让激活频道的索引减去1.
emit("update-active", active - 1, true);
}
myChannels.splice(index, 1);
//判断用户是否已经登录
if (store.state.user) {
} else {
//如果用户没有登录,将数据更新到本地。也就是把myChannels数组中的内容重新保存到本地,这样会把原有的内容给覆盖掉
setItem("TOUTIAO_CHANNELS", myChannels);
}
} else {
// 非编辑状态,执行切换频道
emit("update-active", index, false);
}
};
return {
onMyChannelClick,
};
}
在onMyChannelClick
方法的内部,把对应的频道数据从myChannels
这个数组中移除完成以后,判断用户是否已经登录了,如果没有登录,直接将myChnnels
数组中的内容保存到本地就可以了,这样会把原有的内容给覆盖掉了。
以上就完成了用户没有登录的时候,删除频道数据的操作。
注意:在调用useMyChannelClick
方法的时候,需要传递store
容器对象。
return {
...toRefs(state),
recommendChannels,
onAddChannel,
...useMyChannelClick(state, emit, props, store),
};
下面,我们再来看一下登录后的处理:
在src/api/channel.js
中增加一个发送异步请求的方法:
//删除用户指定频道
export const deleteUserChannel = (channelId, userId) => {
return request({
method: "DELETE",
url: "/user/channels",
data: {
channelId,
userId,
},
});
};
这里发送了一个delete
请求。
下面返回到channel-edit.vue
组件中,调用deleteUserChannel
方法来发送异步请求。
import {
getAllChannels,
addUserChannel,
deleteUserChannel,
} from "../../../api/channel";
这里先将deleteUserChannel
方法导入进来,然后
在onMyChannelClick
方法中,判断一下用户是否已经登录了,如果登录了,就调用deleteUserChannel
方法发送异步请求,这里传递了频道的编号以及用户的编号。
还要注意这里需要将onMyChannelClick
方法修改为async
形式
function useMyChannelClick(state, emit, { myChannels, active }, store) {
const onMyChannelClick = async (channel, index) => {
if (state.isEdit) {
//编辑状态,则执行删除频道
//如果是推荐频道(也就是固定的频道,不允许删除)
if (state.fiexChannels.includes(channel.id)) {
return;
}
if (index <= active) {
//让激活频道的索引减去1.
emit("update-active", active - 1, true);
}
myChannels.splice(index, 1);
//判断用户是否已经登录
if (store.state.user) {
try {
//发送请求,删除用户指定的频道
await deleteUserChannel(channel.id, store.state.user.data.id);
} catch (err) {
console.log(err);
}
} else {
//如果用户没有登录,将数据更新到本地。也就是把myChannels数组中的内容重新保存到本地,这样会把原有的内容给覆盖掉
setItem("TOUTIAO_CHANNELS", myChannels);
}
} else {
// 非编辑状态,执行切换频道
emit("update-active", index, false);
}
};
return {
onMyChannelClick,
};
}
下面返回到浏览器端进行测试。
16、展示频道列表数据
在前面的小节中,我们完成了频道数据的添加与删除。
但是,我们并没有处理,当用户没有登录的时候,如果要获取频道列表数据应该是从本地获取的情况。这一小节我们来获取一下这种情况。
这里我们先说一下基本的思路:如果用户登录了,在获取频道数据的时候,发送异步请求,从服务端获取频道列表数据,如果用户没有登录,则从本地获取。
如果本地没有存储,则给用户展示默认的几个频道列表数据。
下面我们返回到views/home/index.vue
组件,找到loadChannels
方法,该方法就是以前我们写的获取频道列表数据的方法。
const store = useStore(); //获取store容器
async function loadChannels(state, store) {
try {
let channels = [];
if (store.state.user) {
//如果已经登录,发送请求获取登录用户具有的频道列表数据
const { data } = await getCurrentChannels(store.state.user.data.id);
channels = data;
} else {
//如果没有登录,判断本地是否有频道列表数据
const localChannels = await getItem("TOUTIAO_CHANNELS");//注意:这里是await 。
//如果有,直接使用
if (localChannels) {
channels = localChannels;
} else {
//如果本地没有,则获取默认的频道列表数据
const { data } = await getUserChannels();
channels = data;
}
}
state.channels = channels;
} catch (err) {
console.log(err);
}
}
在上面的代码中,我们判断用户是否已经登录了,如果登录了调用getCurrentChannels
方法来发送异步请求,获取当前用户具有的频道列表数据。
如果没有登录,从本地获取,如果能够获取,就使用,获取不到重新调用以前写的getUserChannels
方法来获取默认的用户列表数据。
在调用loadChannels
方法的时候,需要传递store
容器
setup() {
const store = useStore(); //获取store容器
onMounted(() => {
loadChannels(state, store);//传递store
});
}
同时需要导入getCurrentChannels
,useStore
,getItem
import { getUserChannels, getCurrentChannels } from "../../api/user";
import ArticleList from "./components/article-list.vue";
import ChannelEdit from "./components/channel-edit.vue";
import { useStore } from "vuex";
import { getItem } from "../../utils/storage";
关于getCurrentChannels
方法,需要在api
目录下的user.js
文件中实现:
export const getCurrentChannels = (id) => {
return request({
method: "GET",
url: "/user/currentChannels",
params: { userId: id },
});
};
最后,返回浏览器进行测试
更多推荐
所有评论(0)