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文件

// 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;

在上面的代码中,我们导入getItemsetItem两个方法,然后在存储登录用户信息,和获取登录用户信息的时候,直接使用这两个方法,这样就非常简单了。

下面返回浏览器进行测试。

把以前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>

指定了icontext两个属性的值。

指定了样式:

.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.

这里控制了定位的rightleft的位置,同时控制了图标的大小以及颜色。

最后,通过浏览发现,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方法中,将myChannelsactive解构出来。

然后判断如果要删除的频道的索引值小于等于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中,所以这里我们判断其对应的容器中是否有登录用户的数据,如果没有表示用户没有登录。

这里需要将频道信息存储到本地,所以这里需要导入useStoresetItem两个方法。

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
    });
}

同时需要导入getCurrentChannelsuseStore,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 },
  });
};

最后,返回浏览器进行测试

Logo

前往低代码交流专区

更多推荐