0%

ElementPlus实战项目

简介

Element UI 是一款基于 Vue.js 2.0 的开源桌面端组件库,旨在帮助开发者快速构建现代化、高效且风格统一的 Web 应用程序。ElementPlusElement UI 的官方升级版本,专为 Vue 3.0 设计,完全兼容 Vue 3 的 Composition API 和 TypeScript,并继承了 Element UI 的核心设计理念与组件生态。我们现在使用ElementPlus来实现一套后台管理项目。

项目简介

我们这次实现的项目叫‘realworld’,是一个简单的博客网站;这个项目是一个专门用户练手的demo,有各种语言的实现方式,地址是:https://main--realworld-docs.netlify.app/;我们可以找到数据库表、接口标准,前后端的数据格式等等;我使用Java简单实现了后端项目,代码地址为:https://gitee.com/qiuli-zero/realworld-background-demo.git;前端的代码地址为:https://gitee.com/qiuli-zero/background-management-demo.git

构建项目

新建Vue项目

我们可以参考之前的文章‘Vue3入门’-‘构建工程项目’,我们这次构建一个名称为‘background-management-demo’的项目

引入ElementPlus

安装ElementPlus依赖
1
npm install element-plus
全局导入elementplus

修改main.ts

1
2
3
4
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

createApp(App).use(ElementPlus).mount('#app')

引入ElementPlus图标

安装依赖
1
npm install @element-plus/icons-vue
全局导入

修改main.ts

1
2
3
4
5
6
7
8
import * as ElementPlusIconsVue from "@element-plus/icons-vue";

const app = createApp(App);
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}

app.use(ElementPlus).mount('#app')

引入Axios(实现调用后端项目接口)

安装依赖
1
2
npm install axios
npm install --save-dev @types/node
封装axios工具

在项目的根目录下创建.env文件,注意该文件和package.json同级,在其中定义环境变量

1
2
# 输入你自己的url
VITE_APP_BASE_API = 'http://127.0.0.1:4523/m1/7123645-6846544-default/api'

新建文件‘src/utils/request.ts’,添加如下内容:

1
2
3
4
5
6
7
import axios from "axios";

const instance = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API as string,
})

export default instance;
定义接口所需类型(类似java中的属性类)

我们先来新建用户相关接口,根据官方文档https://main--realworld-docs.netlify.app/specifications/backend/endpoints/中的注册接口,我们来定义入参及返回信息

新建文件‘src/types/index.d.ts’,添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 入参
export interface User {
username?: string;
email: string;
password: string;
}

// 返回信息
export interface UserInfo {
email: string;
token?: string;
username: string;
bio: string;
image: string;
}
封装api

新建文件‘src/api/index.ts’,添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import request from '@/utils/request';

import type {
User,
UserInfo
} from '@/types';

export const register =
(
// 请求参数
params: {
user: User;
}
):
// 返回值类型
Promise<{ data: { user: UserInfo } }> =>
request({
method: 'POST',
url: '/user',
data: params,
});
在vue中调用api

修改App.vue文件,添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
<div class="mb-4">
<el-button @click="handleRegister">注册</el-button>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { register } from '@/api';
import type { User } from './types';

const user = ref<User>({
username: 'admin',
password: '123456',
email: "123456@qq.com"
});

const handleRegister = async () => {
const res = await register({
user: user.value
});
console.log(res);
}
</script>

使用Apifox来mock后端接口

现在我们已经可以调用后端接口了,但是一个关键问题来了,后端接口在哪里?在本项目中,我们搭建了一个后端demo,但是现在有一个更方便的选择,使用Apifox来’仿冒‘一个接口,这个接口有我们定义的url、调用方式(GET、POST等)、参数和返回值,我们在测试时可以先调用它,这样我们可以专注于前端项目;这里简单说说如何使用Apifox

新建项目

image-20250920185701211

新建接口

新建接口并定义名称、访问方式、URL、参数名称等

image-20250920185736884

image-20250920185832049

新建Mock期望

点击’Mock‘-’新建期望‘

image-20250920190007119

填写期望名称、参数、返回值,点击’保存‘

image-20250920190109685

保存为快捷请求

在上一步保存的期望后面点击’快捷请求‘-’本地Mock‘

image-20250920190232303

在快捷请求页面检查各项参数无误后点击保存

image-20250920190401064

我们点击刚才保存好的快捷请求,现在我们可以看到各项参数,按照参数调用,就可以获取我们设置好的返回值

image-20250920190523525

引入vue-router(实现页面跳转)

安装依赖
1
npm install vue-router
创建路由文件

新建文件‘src/router/index.ts’文件,添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { createRouter, createWebHashHistory } from "vue-router";
const routes = [
{
path: "/",
name: "Home",
component: () => import("@/views/home/index.vue"),
},
{
path: "/register",
name: "Register",
component: () => import("@/views/register/index.vue"),
}
];

const router = createRouter({
history: createWebHashHistory(),
routes,
});

export default router;
将路由对象引入main.ts中
1
2
3
import router from './router'

app.use(router).use(ElementPlus).mount('#app')
创建页面文件
创建首页

新建文件‘src/views/home/index.vue’,添加如下内容:

1
2
3
4
5
6
7
8
<template>
<div>
this is home page
</div>
<div class="txt-r">
<router-link to="/register">没有账号?去注册</router-link>
</div>
</template>
创建注册页

新建文件‘src/views/register/index.vue’,添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<template>
<div>
<el-form ref="formRef" :rules="rules" :model="user" label-width="86px">
<h3 class="title">系统注册</h3>
<el-form-item label="用户名" prop="username">
<el-input v-model="user.username" placeholder="请输入用户名" prefix-icon="user"></el-input>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="user.email" placeholder="请输入邮箱" prefix-icon="message"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="user.password" type="password" placeholder="请输入密码" prefix-icon="lock"></el-input>
</el-form-item>
<el-form-item label>
<el-button type="primary" @click="doRegister">注册</el-button>
</el-form-item>
</el-form>
</div>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router';
import { register } from '@/api';
import { ref, computed } from 'vue'
import type { User } from '@/types';
const router = useRouter();

const user = ref<User>({
email: '',
username: '',
password: ''
});

const doRegister = async () => {
try {
const res = await register({ user: user.value });
console.log(res.data.user);
router.push({ name: 'Home' });
} catch (error) {
console.error(error)
}
}

const rules = computed(() => {
return {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
email: [{ required: true, message: '请输入邮箱', trigger: ['change', 'blur'] }],
password: [{ required: true, min: 6, message: '密码最少要6位', trigger: ['change', 'blur'] }],
}
})
</script>
测试

我们启动项目后进入首页

image-20250919161351599

点击链接’没有账号,请注册‘,跳转到注册页面

image-20250919162810352

可以看到已经成功跳转

引入pinia(存储和管理应用状态)

Pinia 是 Vue.js 官方推荐的状态管理库,它的核心作用是提供一个集中、响应式且易于维护的地方来存储和管理你的应用状态,并支持在组件(页面)之间高效地共享和操作这些数据。我们现在使用pinia来存储和管理我们的登录数据。

安装依赖
1
npm install pinia
引入依赖

修改main.ts文件

1
2
3
4
5
import { createPinia } from 'pinia'

const pinia = createPinia()

app.use(pinia)
新增存储文件

新建文件‘src/stores/user.ts’,添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { ref } from 'vue'
import { defineStore } from 'pinia'
import type { UserInfo } from '@/types'

export const useUserStore = defineStore('user', () => {
// 1. 定义状态 (State)
const userInfo = ref<UserInfo | null>(null);
// 2. 定义动作 (Actions)
const setUser = (user: UserInfo) => {
userInfo.value = user
};
// 3. 返回状态和动作
return {
userInfo,
setUser
};
})
  1. 这段代码定义了一个名为 user 的 Pinia Store,用于管理应用程序中的用户信息。
  • defineStore 是 Pinia 提供的核心函数,用于定义一个 Store。
  • 第一个参数 ‘user’ 是这个 Store 的唯一 ID。在整个应用程序中,您可以通过这个 ID 来获取和使用这个 Store。
  • 第二个参数是一个箭头函数 () => { … } ,这个函数定义了 Store 的核心逻辑,包括状态(state)、获取器(getters,这里没有显式定义)和动作(actions)。
  1. const userInfo = ref<UserInfo | null>(null); :
  • 这行代码定义了一个响应式状态 userInfo 。
  • ref 是 Vue 3 提供的响应式 API,用于创建一个响应式引用。当 userInfo.value 的值改变时,所有使用 userInfo 的组件都会自动更新。
  • <UserInfo | null> 是 TypeScript 的类型注解,表示 userInfo 的值可以是 UserInfo 类型(您之前定义的那个用户接口),也可以是 null 。
  • null 是 userInfo 的初始值,表示在应用程序启动时,用户尚未登录或其信息还未被获取。
  1. const setUser = (user: UserInfo) => { userInfo.value = user }; :
  • 这行代码定义了一个名为 setUser 的“动作”(Action)。
  • 动作是用于修改 Store 状态的方法。
  • setUser 接收一个 user 参数,其类型为 UserInfo 。
  • 它的作用是将传入的 user 对象赋值给 userInfo.value ,从而更新 Store 中的用户状态。
  1. return { userInfo, setUser }; :
  • 这个 return 语句是 defineStore 函数的第二个参数(箭头函数)的返回值。
  • 它暴露了 userInfo 状态和 setUser 动作,使得其他组件可以通过 userStore 实例来访问和操作它们。
注册时将用户信息保存到store

修改’src/register/index.vue‘文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts">
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
const { setUser } = userStore;

const doRegister = async () => {
try {
console.log('user.value', user.value);
const res = await register({ user: user.value });
console.log(res);
setUser(res.data);
router.push({ name: 'Home' });
} catch (error) {
console.error(error)
}
}
</script>

调用’setUser‘方法,将调用后端接口的返回值’res‘中的数据放入;

注册成功后展示用户信息

再修改’src/home/index.vue‘文件,展示用户信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<div>
<p>this is home page</p>
<p>用户名:{{ userInfo?.username }}</p>
<p>邮箱:{{ userInfo?.email }}</p>
</div>
<div class="txt-r" v-if="!userInfo">
<router-link to="/register">没有账号,请注册</router-link>
</div>
</template>

<script setup>
import { storeToRefs } from "pinia"
import { useUserStore } from '@/stores/user';

const userStore = useUserStore();
const { userInfo } = storeToRefs(userStore);
</script>
测试

我们刷新页面来到主页

image-20250920173847329

点击链接跳转到注册页面

image-20250920175439836

填写资料

image-20250920175621293

点击注册跳转回首页

image-20250920175655827

可以看到用户信息已经被渲染出来了

使用localStorage存储用户信息

​ 现在我们实现了用户登录,但是只要刷新一下页面,用户信息就没有了,这是怎么回事呢?这是 Vue 单页面应用(SPA)开发中一个非常典型的情况:页面刷新后,Vue 应用的内存状态(例如 Vuex 或组件 data 中的数据)会被重置,导致登录状态和用户信息丢失

简单来说,虽然登录后拿到了用户信息,但如果只把它们保存在 Vue 组件或 Vuex 的状态管理里,这些数据就像暂时记在电脑内存里,浏览器一刷新,内存清空,数据自然就没了。

解决的方案就是数据的持久化,解决方法的核心思路是:将关键数据保存在一个刷新后也不会丢失的地方(持久化),并在页面初始化时将其读回内存。我们选择使用**localStorage**存储登录令牌’token‘;

封装storage存取

新建文件‘src/utils/storage.ts’,添加如下内容,在文件中封装localStorage的操作

1
2
3
4
5
6
7
8
9
10
11
12
export const storage = (key: string) => ({
get<T>(): T | null {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : null;
},
set<T>(value: T) {
window.localStorage.setItem(key, JSON.stringify(value));
},
remove() {
window.localStorage.removeItem(key);
},
});
新增接口调用方法getUser

在文件’src/api/index.ts‘中添加接口调用方法

1
2
3
4
5
6
7
export const getCurrentUser =
():
Promise<{ data: { user: UserInfo } }> =>
request({
method: 'GET',
url: `/user`,
});
新增身份验证方法

在文件’src/stores/user.ts‘中添加身份验证方法

1
2
3
4
5
6
const verifyAuth = async () => {
if (!userInfo.value && userStorage.get()) {
const res = await getUser();
setUser(res.data.user)
}
}
页面刷新时重新加载用户信息

修改文件’main.ts‘

1
2
3
4
5
import useUserStore from '@/stores/user';

const userStore = useUserStore();
const { verifyAuth } = userStore;
await verifyAuth();
axios请求拦截器在请求头中追加token

修改文件’src/utils/request.tx‘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { storage } from "@/utils/storage";

instance.interceptors.request.use(
(config) => {
const userStorage = storage('user');
const token = userStorage.get();
if (token) {
const newConfig = { ...config };
newConfig.headers.Authorization = `Bearer ${token}`;
return newConfig;
}
return config;
},
(error) => Promise.reject(error)
)

可以看到,我们在请求头(headers)中添加了一个名称为‘Authorization’的token,而且该token是以‘Bearer’开头的

页面整体布局

我们的页面整体布局如下图:
微信图片_20240603164717.png

从左侧的栏目中我们可以看到有’文章管理‘、’评论管理‘、’个人设置‘3个类别,类别’文章管理‘中有’全部文章‘、’我的文章‘两个页面,我们就先从这里入手编写页面子路由;

页面子路由

首先我们来实现页面间的跳转,我们现在新定义三个页面:’整体布局‘、’全部文章‘和’我的文章‘,当访问根路径(http://localhost:5173/)时直接重定向到’整体布局‘页面(默认加载’全部文章‘子页面),同时’整体布局‘页面中有指向’全部文章‘、’我的文章‘页面的链接,点击链接时加载对应的子页面

新增’全部文章‘、’我的文章‘两个页面

新建文件‘src/views/articles/AllArticles.vue’和‘src/views/articles/MyArticles.vue’两个文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!--AllArticles.vue-->
<template>
<p>AllArticles</p>
</template>

<script setup></script>

<style scoped></style>

<!-- MyArticles.vue -->
<template>
<p>MyArticles</p>
</template>

<script setup></script>

<style scoped></style>
新增layout页面

新建文件‘src/views/layout/index.vue’,添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<router-link to="/article/all">
全部文章
</router-link>
<router-link to="/article/me">
我的文章
</router-link>
<div>
<router-view></router-view>
</div>
</template>

<script setup></script>

<style scoped></style>
修改路由规则

接下来修改路由,我们希望当进入本项目时(即访问根目录时)直接进入’文章管理‘-’全部文章‘,点击’我的文章‘时切换到’我的文章‘页面

修改’src/router/index.ts‘文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import { createRouter, createWebHashHistory } from "vue-router";
import Layout from "@/views/layout/index.vue";

const routers = [
{
path: "/",
name: "Home",
component: Layout, // 使用布局组件
redirect: '/article', // 访问"/"时重定向到"/article"
children: [ // 嵌套路由定义
{
path: "article", // 实际路径为 "/article"
name: "文章管理",
meta: { // 路由元信息
requiresAuth: true, // 需要认证
icon: "user" // 图标
},
redirect: '/article/all', // 访问"/article"时重定向到"/article/all"
children: [ // 更深层的嵌套路由
{
path: "/article/all", // 完整路径
name: "全部文章",
meta: {
requiresAuth: true,
icon: "avatar"
},
component: () => import("@/views/articles/AllArticles.vue"), // 懒加载组件
},
{
path: "/article/me",
name: "我的文章",
meta: {
requiresAuth: true,
icon: "avatar"
},
component: () => import("@/views/articles/MyArticles.vue"),
}
],
},
]
}
]

const router = createRouter({
history: createWebHashHistory(),
routes: routers,
})

export default router;

我们稍微解释一下整个逻辑

  • 访问根路径 /时,会重定向/article(由 redirect属性指定)。
  • 根路径使用 Layout组件作为布局。
  • children数组定义了嵌套路由。这意味着当路径匹配时,<router-view>会被渲染到 Layout组件中预留的 <router-view>位置,从而实现页面布局和内容的嵌套。
  • 文章管理路由下,又定义了一层 children,形成了嵌套路由的嵌套全部文章我的文章文章管理的子页面。
  • meta字段包含了路由元信息,这里标记了这些路由需要认证 (requiresAuth: true),可用于路由守卫进行权限检查。icon可能用于在导航菜单中显示图标。
测试

我们重新启动项目,当我们访问’http://localhost:5173/‘时,重定向到’http://localhost:5173/#/article/all‘

image-20250921173738731

当我们点击链接’我的文章‘,会重定向到’我的文章‘页面

image-20250921173819925

侧方导航栏(SidebarNavigation

我们来实现侧方导航栏,导航栏中实现上一步实现的子页面跳转,需要用到的组件有’ele-menu‘、’el-sum-menu‘、’el-menu-item‘

1717569380224.png

新增导航栏组件

新建文件‘src/layout/components/PageSidebar.vue’,添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<template>
<div>
<!-- router : 属性启用路由模式 -->
<!-- :collapse="isCollapse" : 控制侧边栏折叠状态 -->
<el-menu :default-active="defaultActive" router :collapse="isCollapse">
<template v-for="(item, i) in treeData" :key="item.path">
<el-sub-menu :index="item.path" v-if="item.children && item.children.length > 0">
<template #title>
<el-icon v-if="item.meta.icon">
<component :is="item.meta.icon"></component>
</el-icon>
<span>{{ item.name }}</span>
</template>
<template v-for="(child, ci) in item.children" :key="ci">
<el-menu-item :index="child.path">
<el-icon>
<component :is="child.meta.icon"></component>
</el-icon>
{{ child.name }}
</el-menu-item>
</template>
</el-sub-menu>
</template>
</el-menu>
</div>
</template>

<script setup>
import { computed, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';

// 全局路由器实例 获取 Vue Router 的全局路由器实例
const router = useRouter();
// 当前路由对象
// 包含信息:
// - path - 当前路由路径
// - params - 动态路由参数
// - query - URL 查询参数
// - hash - URL 哈希值
// - name - 路由名称(如果配置了)
// - meta - 路由元信息
const route = useRoute();

// 菜单数据生成
// 1. 从 Vue Router 获取所有路由配置
// 2. 过滤出需要认证的路由( meta.requiresAuth 为 true)
// 3. 自动构建菜单树结构
const treeData = router.getRoutes().filter((v) => v.meta && v.meta.requiresAuth);

// 活动菜单项控制
// 使用 computed 属性动态计算当前活动菜单
// 优先使用当前路由路径,否则使用第一个菜单项的路径
const defaultActive = computed(() => route.path || treeData.value[0].path)

// 响应式功能
// 使用 ref 管理侧边栏折叠状态
const isCollapse = ref(false)
</script>

<style scoped></style>

这个组件的整体逻辑是利用全局路由对象’useRouter()‘和当前路由对象’useRoute()‘生成导航栏,将路由中的信息遍历后渲染(赋值)给导航栏组件

修改布局组件

修改’src/views/layout/index.vue‘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div>
<page-sidebar></page-sidebar>
</div>
<div>
<router-view></router-view>
</div>
</template>

<script setup>
import PageSidebar from '@/views/layout/components/PageSidebar.vue'
</script>

<style scoped></style>

这里可能你会有个疑问,明明导入的是’PageSidebar‘,怎么在template中变成了’page-sidebar’标签;这里我们介绍一下Vue的自动转换机制:1. 导入时使用 PascalCase :这是 JavaScript/TypeScript 的命名约定;2. 模板中使用 kebab-case :这是 HTML 的命名约定,更符合 HTML 标准;3. Vue 自动处理转换 :Vue 编译器会自动将 PascalCase 组件名转换为 kebab-case 标签名;

简单来说,Vue会自动帮助我们将导入的组件转换为从大小写处用’-‘分开的标签

测试

现在让我们来看看效果

image-20250922112612125

可以看到,我们需要的效果达成了

美化样式

删除项目中之前引入的样式

修改文件style.css(与main.ts同级)

1
// import './style.css'
修改App.vue中的样式

修改App.vue文件(与main.ts同级)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
}

#app {
height: 100%;
width: 100%;
/* 隐藏超出容器范围的内容,防止滚动条出现 */
overflow: hidden;
}
</style>
  • 注意删除’scoped‘,否则配置的style只能在该组件中生效
安装sass
1
npm install -D sass

简单介绍一下sass:Sass(Syntactically Awesome Stylesheets)是一款CSS预处理器(CSS预编译器)

。它扩展了CSS的基础功能,为你提供了变量、嵌套规则、混合宏(mixins)、函数等强大的编程特性,旨在让编写和维护样式代码更高效、更优雅

修改侧边导航栏页面

修改’src/views/layout/components/PageSidebar.vue’

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div>
<div style="text-align: center;">
<span class="cursor" @click="isCollapse = !isCollapse">
<el-icon v-if="isCollapse">
<expand></expand>
</el-icon>
<el-icon v-else>
<fold></fold>
</el-icon>
</span>
</div>
<el-menu background-color="#000" text-color="#fff" :default-active="defaultActive" router:collapse="isCollapse">
。。。代码不变
</el-menu>
</div>
</template>

......
照旧不变

我们为导航栏添加了一个折叠功能

修改布局页面

修改’src/views/layout/index.vue‘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<template>
<div class="page-container">
<header>我是Header</header>
<main>
<div class="left">
<page-sidebar></page-sidebar>
</div>
<div class="right">
<router-view></router-view>
</div>
</main>
</div>
</template>

<script setup>
import PageSidebar from '@/views/layout/components/PageSidebar.vue'
</script>

<style lang="scss">
.page-container {
display: flex;
flex-direction: column;
height: 100vh; /* 使用视口高度确保填满整个屏幕 */
width: 100%;
overflow: hidden;

>header {
height: 54px;
background: #000;
color: #fff;
flex-shrink: 0;
}

>main {
display: flex;
flex: 1;
overflow: hidden;

>.left {
height: 100%;
background-color: #000;
color: #fff;
}

>.right {
flex: 1;
overflow: auto;
background-color: #f5f7f9;

>.main-body {
padding: 16px 16px 30px;
overflow: auto;
height: 100%;
box-sizing: border-box;
}
}
}
}
</style>
修改注册页面

修改’src/views/register/index.vue‘

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div>
<el-form class="reg" ref="formRef" :rules="rules" :model="user" label-width="86px">
。。。
</template>

<style lang="scss" scoped>
.reg {
width: 480px;
margin: 200px auto 0;
text-align: center;
}
</style>
测试

image-20250922163648379

页眉(Header

我们现在想在’Header‘的右角添加显示用户信息和注销的按钮

修改’src/stores/user.ts‘文件
1
2
3
4
5
6
7
// 新增状态属性
const isLoggedIn = computed(() => !!userInfo.value);
// 新增方法
const removeUser = () => {
userInfo.value = null;
userStorage.remove();
};
新增页眉页面

新增文件’src/views/layout/components/PageHeader.vue’,添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
<template>
<div class="header-cont">
<div>
<h1>
<router-link to="/">
<!-- {{ userInfo.value?.name || '后台管理系统' }} -->
后台管理系统
</router-link>
</h1>
</div>
<div>
<template v-if="isLoggedIn">
<el-dropdown trigger="click" @command="handleCommand">
<div>
{{ userInfo.username }}
<el-icon>
<caret-bottom />
</el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="toPersonal">个人信息</el-dropdown-item>
<el-dropdown-item command="toLogout">Logout</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template v-if="!isLoggedIn">
<el-button type="primary" @click="router.push('/register')">注册/登录</el-button>
</template>
</div>
</div>
</template>

<script setup>
import { ref } from 'vue';
import { computed } from 'vue';

import { useRouter } from 'vue-router';
const router = useRouter();

import useUserStore from '@/stores/user';
const userStore = useUserStore();
const { removeUser } = userStore;

import { storeToRefs } from 'pinia';
const { userInfo, isLoggedIn } = storeToRefs(userStore);

const commands = ({
toPersonal: () => {
console.log('toPersonal')
},
toLogout: () => {
console.log('toLogout')
removeUser();
router.push('/register');
}
})

function handleCommand(command) {
commands[command] && commands[command]();
}
</script>

<style scoped>
.header-cont {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 100%;

a {
color: inherit;
text-decoration: none;
}

h1 {
margin: 0;
font-size: 20px;
}
}
</style>

我们在页眉组件中定义了一个下拉菜单,当判断用户已经注册(登录),就显示这个下拉菜单,如果判断用户没有注册,就显示一个’注册/登录‘按钮;在下拉菜单中,我们设置两个属性,分别是’个人信息‘和’注销(Logout)‘;设置函数’handleCommand‘,当点击不同属性,发送不同命令触发不同的处理方法

修改布局页面

修改’src/views/layout/index.vue’

我们在布局组件中添加新增的PageHeader.vue

1
2
3
4
5
6
7
8
9
10
11
<template>
<div class="page-container">
<header>
<page-header></page-header>
</header>
</div>
</template>

<script setup>
import PageHeader from '@/views/layout/components/PageHeader.vue';
</script>
测试

首先我们重启项目,使数据清空,然后访问根目录,根据我们写的路由,会重定向到’布局‘页面

image-20250925111636979

接着我们点击’注册/登录‘按钮,进入注册页面,填写信息

image-20250925111742517

点击’注册‘按钮,跳转到’布局‘页面,此时已经完成注册,页眉处显示下拉菜单

image-20250925113830914

登录/个人信息模块

个人信息模块

现在我们页眉(Header)组件中下拉菜单的’个人信息‘选项是没有作用的,这是因为我们还没有编辑个人信息页面,现在我们希望实现:1. 创建个人信息页面,在其中可以查看和修改个人信息; 2. 在侧方导航栏中加入’个人设置‘-’个人信息‘路由;3. 点击下拉菜单的’个人信息‘选项,跳转到’个人信息‘页面;

我们先看看个人信息页面应该是什么样子:
个人信息页面

新增修改个人信息接口

我们首先新增一个’修改个人信息‘的接口,修改文件’src/api/index.ts‘, 添加接口

1
2
3
4
5
6
7
8
9
10
11
12
13
// 修改用户信息
export const updateUser =
(
params: {
user: UserInfo;
}
):
Promise<{ data: { user: UserInfo } }> =>
request({
method: 'POST',
url: '/updateUser',
data: params,
});
新增’个人信息‘页面

创建文件’src/views/personal/index.vue‘,添加如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
<template>
<div class="main-body">
<el-form ref="formRef" class="profile-form" v-model="user" label-width="86px">
<h3>个人信息</h3>
<el-form-item label="用户名">
<el-input v-model="user.username" placeholder="请输入用户名" prefix-icon="user"></el-input>
</el-form-item>
<el-form-item label="头像">
<el-input v-model="user.image" placeholder="请输入头像" prefix-icon="picture"></el-input>
</el-form-item>
<el-form-item label="简介">
<el-input v-model="user.bio" placeholder="请输入简介" prefix-icon="user"></el-input>
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="user.email" placeholder="请输入邮箱" prefix-icon="user"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input v-model="user.password" type="password" placeholder="请输入密码" prefix-icon="lock"></el-input>
</el-form-item>
<el-form-item label>
<el-button type="primary" @click="handlerUpdateUser" class="w100p">
修改
</el-button>
</el-form-item>
</el-form>
</div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import useUserStore from '@/stores/user';
import { updateUser } from '@/api';
import type { UserInfo } from '@/types';
import { ElMessageBox } from 'element-plus';

const userStore = useUserStore();
const { userInfo } = storeToRefs(userStore);
const { setUser } = userStore;

const user = ref<UserInfo>({
email: '',
password: '',
username: '',
bio: '',
image: '',
});

const handlerUpdateUser = async () => {
try {
console.log('user.value', user.value);
const res = await updateUser({ user: user.value });
setUser(res.data.user);
console.log('update user success', res.data.user);

ElMessageBox.alert('更新成功', '修改用户', {
confirmButtonText: 'OK'
});

} catch (error) {
console.error(error)
}
}

onMounted(() => {
if (userInfo.value) {
user.value = {
email: userInfo.value.email,
password: '',
username: userInfo.value.username,
bio: userInfo.value.bio,
image: userInfo.value.image
}
}
})

</script>

<style scoped lang="scss">
.profile-form {
width: 60%;
margin-right: auto;
}
</style>
添加子路由

修改文件’src/router/index.ts‘,在路由中添加如下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
path: "/personal",
name: "个人设置",
meta: {
// 路由元信息
requiresAuth: true, // 需要认证
icon: "setting" // 图标
},
children: [
{
path: "/personal/me",
name: "个人信息",
meta: {
requiresAuth: true,
icon: "chat-dot-round"
},
component: () => import("@/views/personal/index.vue")
},
],
}

修改文件’src/views/layout/components/PageHeader.vue‘,添加子路由跳转

1
2
3
4
toPersonal: () => {
console.log('toPersonal')
router.push('/personal/me');
},
测试

访问根路径(http://localhost/5173),看到侧方导航栏已经有’个人设置‘-’个人信息‘路由,点击可以跳转到’个人信息‘页面(点击页眉的’个人信息‘选项也可以跳转)

image-20250925235725128

登录模块

我们现在只写了注册页面,现在我们来写一个必不可少的登录页面,我们来看看登录页面什么样子

image-20250926154540134

新增登录接口

修改文件’src/api/index.ts‘,添加登录接口调用

1
2
3
4
5
6
7
8
9
10
11
12
13
// 登录
export const doLogin =
(
params: {
user: User;
}
):
Promise<{ data: { user: UserInfo } }> =>
request({
method: 'POST',
url: '/user/login',
data: params,
});
新增登录页面

新建文件’src/views/login/index.vue‘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<template>
<div>
<el-form class="login" ref="formRef" :model="user" :rules="rules" label-width="86px">
<h3>登录</h3>
<el-form-item label="用户名" prop="email">
<el-input v-model="user.email" placeholder="请输入用户名" prefix-icon="user"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="user.password" type="password" placeholder="请输入密码" prefix-icon="lock"></el-input>
</el-form-item>
<el-form-item label>
<el-button type="primary" class="w100p" @click="doLogin">登录</el-button>
</el-form-item>
<div class="txt-r">
<router-link to="/register">没有账号?去注册</router-link>
</div>
</el-form>
</div>
</template>

<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();

import useUserStore from '@/stores/user';
const userStore = useUserStore();
const { setUser } = userStore;

import { computed, reactive, ref } from 'vue';
import type { User } from '@/types';
const user = reactive<User>({
email: '',
password: ''
})

const formRef = ref();

const rules = computed(() => {
return {
email: [{ required: true, message: '请输入用户名', trigger: ['change', 'blur'] }],
password: [{ required: true, min: 6, message: '请输入密码', trigger: ['change', 'blur'] }],
}
})

import { login } from '@/api';

function doLogin() {
console.log(formRef.value);
formRef.value.validate(async (valid: any) => {
if (!valid) {
return;
}
try {
console.log('user', user);
const res = await login({ user: user });
console.log(res);
setUser(res.data.user);
router.push({ name: 'Home' });
} catch (error) {
console.error(error);
}
})
}
</script>

<style lang="scss" scoped>
.login {
width: 480px;
margin: 200px auto 0;
text-align: center;
}
</style>
添加子路由

修改文件’src/router/index.ts‘

1
2
3
4
5
{
path: "/login",
name: "登录",
component: () => import("@/views/login/index.vue")
}
添加导航守卫

修改文件’src/router/index.ts‘,添加如下代码:

1
2
3
4
5
6
7
8
import useUserStore from '@/stores/user';
router.beforeEach(async (to) => {
const userStore = useUserStore();
const { isLoggedIn } = userStore;
if (to.meta.requiresAuth && !isLoggedIn) {
return { name: '登录' };
}
})

这个函数是一个 Vue Router 全局前置守卫 ,用于在路由跳转前进行权限验证。执行流程如下:

  1. 获取用户状态 :从 Pinia store 中获取用户登录状态
  2. 检查路由权限 :判断目标路由是否需要认证 ( to.meta.requiresAuth )
  3. 验证登录状态 :如果路由需要认证但用户未登录 ( !isLoggedIn )
  4. 重定向到登录页 :返回登录页面的路由配置
测试

我们注册后,直接访问登录页面’http://localhost:5173/#/login‘

image-20250928221834757

输入用户名(邮箱)和密码,然后点击登录,登录成功后跳转到根目录(我们已经将根目录重定向到’全部文章‘页面),如果没有登录(可以点击页眉右侧的’注销‘按钮),无论访问哪个页面,都会重定向到’登录‘页面

文章模块

显示文章(分页查询)

现在我们处理好了注册、登录页面,但是文章页面还没有内容显示,我们希望能将文章内容显示出来,如下所示:

image-20250930190930846

新增文章查询接口

修改文件’src/types/index.d.ts‘,新增文章数据类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export interface CreateArticle {
title: string;
description: string;
body: string;
tagList: string[];
}

export interface Article extends CreateArticle {
slug: string;
createdAt: string;
updatedAt: string;
favorited: boolean;
favoritesCount: number;
author: Author;
}

export interface ArticleSearchParams {
// '?'表示该属性可选的,即可以不传该属性
tag?: string;
author?: string;
favorited?: string;
offset?: number;
limit?: number;
}

// 分页信息
export interface PageInfo {
currentPage: number;
pageSize: number;
total: number;
articles: Article[];
}

修改文件’src/api/index.ts‘,新增’查询文章信息‘接口,我们这里的文章查询接口是一个分页查询

1
2
3
4
5
6
7
8
9
10
11
// 获取文章信息
export const getArticles =
(params: ArticleSearchParams):
Promise<{ data: { articles: Article[]; articlesCount: number; }; }> => {
return request({
method: 'GET',
url: '/articles',
// 用于 GET 请求 :当 method 为 'GET' 时, params 对象中的数据会被序列化为 URL 查询参数 (Query Parameters) ,并附加到 URL 的末尾。
params,
})
}
修改’全部文章‘页面

修改文件’src/views/articles/AllArticles.vue‘,调用’文章查询‘接口查询文章信息,并将其填充到<el-table>中,并使用el-pagination作为分页组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<script setup lang="ts">
import { ref } from 'vue';
import { getArticles } from '@/api';
import type { ArticleSearchParams, PageInfo } from '@/types';

let pageArticles = ref<PageInfo>({
currentPage: 1,
pageSize: 10,
total: 0,
articles: []
});

const fetchArticles = async (offset: number, pageSize: number) => {
const params: ArticleSearchParams = {
'offset': offset,
'limit': pageSize
};
try {
console.log(params);
const res = await getArticles(params);
console.log(res);
pageArticles.value.articles = res.data.articles;
pageArticles.value.total = res.data.articlesCount;
} catch (error) {
console.error(error);
}
};

const handleSizeChange = (val: number) => {
pageArticles.value.pageSize = val;
fetchArticles((pageArticles.value.currentPage - 1) * val, val);
};

const handleCurrentChange = (val: number) => {
pageArticles.value.currentPage = val;
fetchArticles((val - 1) * pageArticles.value.pageSize, pageArticles.value.pageSize);
};

fetchArticles(0, pageArticles.value.pageSize);

</script>

<template>
<div class="main-body">
<el-table :data="pageArticles.articles" style="width: 100%;" border>
<el-table-column prop="slug" label="ID" width="240" :showOverflowTooltip="true"></el-table-column>
<el-table-column prop="title" label="标题" width="240" :showOverflowTooltip="true"></el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="240" :showOverflowTooltip="true"></el-table-column>
<el-table-column prop="author" label="作者"></el-table-column>
<el-table-column label="标签" align="center" prop="tagList">
<template #default="scope">
<el-tag class="a_tag" v-for="tag in scope.row.tagList">
{{ tag }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!--分页组件-->
<el-pagination v-model:current-page="pageArticles.currentPage" :page-size="pageArticles.pageSize"
:page-sizes="[5, 10, 15, 20, 25, 30]" :total="pageArticles.total" layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
</template>

<style lang="scss" scoped>
.pagination {
margin-top: 20px;
float: right
}

.a_tag {
margin: 0 3px;
}
</style>
测试

image-20251001162750443

添加分页查询条件

文章多了,我们当然需要按照一定条件进行查询过滤,这里我们选择用作者和标签进行过滤

添加标签查询接口

在文件‘src/api/index.ts’中添加标签查询接口

1
2
3
4
5
6
7
8
9
// 获取标签信息
export const getTags =
():
Promise<{ data: { tags: string[] }; }> => {
return request({
method: 'GET',
url: '/tags',
})
}
修改‘全部文章’页面

修改文件‘src\views\articles\AllArticles.vue’

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getArticles, getTags } from '@/api';
import type { ArticleSearchParams, PageInfo } from '@/types';

let pageArticles = ref<PageInfo>({
currentPage: 1,
pageSize: 10,
total: 0,
articles: []
});

const filters = ref({
name: ''
})

const selectedValue = ref<string | undefined>();

const tags = ref<string[]>([]);

const doSearch = async () => {
pageArticles.value.pageSize = 10;
pageArticles.value.currentPage = 1;
fetchArticles(pageArticles.value.pageSize * (pageArticles.value.currentPage - 1), pageArticles.value.pageSize);
}

const fetchArticles = async (offset: number, pageSize: number) => {
const params: ArticleSearchParams = {
'offset': offset,
'limit': pageSize
};
if (filters.value.name !== '') {
params['author'] = filters.value.name;
}
if (selectedValue.value !== undefined) {
params['tag'] = selectedValue.value;
}
try {
console.log(params);
const res = await getArticles(params);
console.log(res);
pageArticles.value.articles = res.data.articles;
pageArticles.value.total = res.data.articlesCount;
} catch (error) {
console.error(error);
}
};

const handleSizeChange = (val: number) => {
pageArticles.value.pageSize = val;
fetchArticles((pageArticles.value.currentPage - 1) * val, val);
};

const handleCurrentChange = (val: number) => {
pageArticles.value.currentPage = val;
fetchArticles((val - 1) * pageArticles.value.pageSize, pageArticles.value.pageSize);
};

onMounted(async () => {
try {
const res = await getTags();
console.log(res);
tags.value = res.data.tags;
} catch (error) {
console.error(error);
}
});

fetchArticles(0, pageArticles.value.pageSize);

</script>

<template>
<div class="main-body">
<!--工具栏-->
<div class="toolbar">
<el-form :inline="true" :model="filters">
<el-form-item>
<el-input v-model="filters.name" placeholder="请输入作者"></el-input>
</el-form-item>
<el-form-item>
<el-select v-model="selectedValue" placeholder="请选择标签" style="width: 240px;">
<el-option v-for="(option, index) in tags" :key="index" :label="option"
:value="option"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="doSearch">查询</el-button>
</el-form-item>
</el-form>
</div>
<!--文章列表---->
<el-table :data="pageArticles.articles" style="width: 100%;" border>
<el-table-column prop="slug" label="ID" width="240" :showOverflowTooltip="true"></el-table-column>
<el-table-column prop="title" label="标题" width="240" :showOverflowTooltip="true"></el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="240" :showOverflowTooltip="true"></el-table-column>
<el-table-column prop="author.username" label="作者"></el-table-column>
<el-table-column label="标签" align="center" prop="tagList">
<template #default="scope">
<el-tag class="a_tag" v-for="tag in scope.row.tagList">
{{ tag }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!--分页组件-->
<el-pagination v-model:current-page="pageArticles.currentPage" :page-size="pageArticles.pageSize"
:page-sizes="[5, 10, 15, 20, 25, 30]" :total="pageArticles.total" layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
</template>

<style lang="scss" scoped>
.pagination {
margin-top: 20px;
float: right
}

.a_tag {
margin: 0 3px;
}
</style>
测试

进入文章页面,输入作者名称,点击‘查询’,可以看到查找出的对应文章

image-20251013135849424

点击下拉菜单‘请选择标签’,可以看到所有标签名称

image-20251013140001482

选择对应的标签,点击‘查询’,可以看到查询出的对应文章

image-20251013140156440

文章的增删改查

我们现在来实现文章的增删该查,在‘我的文章’页面,我们需要实现的功能如下:(1)点击侧边栏‘我的文章’,展示出当前登录用户添加的文章(分页展示);(2)点击添加文章按钮,弹出添加文章页面,编辑文章信息后,点击确定,实现添加文章功能;(3)点击编辑按钮,弹出编辑文章页面(就是添加文章页面,但是要带出当前文章信息),编辑完成后,点击确定按钮保存;(4)点击删除按钮,删除对应文章

image-20251016174346734

image-20251016174414575

新增接口

修改文件‘src/api/index.ts’,添加接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 根据slug获取文章信息
export const getArticleBySlug = (slug: string): Promise<{ data: { article: Article } }> => {
return request({
method: 'GET',
url: `/articles/${slug}`,
})
};

// 根据slug删除文章
export const deleteArticleBySlug = (slug: string): Promise<void> => {
return request({
method: 'DELETE',
url: `/articles/${slug}`,
})
};

// 修改文章信息
export const updateArticle = (
slug: string,
params: {
article: UpdateArticle;
}
): Promise<{ data: { article: Article } }> => {
return request({
method: 'PUT',
url: `/articles/${slug}`,
data: params,
})
};

// 新增文章
export const createArticle = (
params: { article: CreateArticle }
): Promise<{ data: { article: Article } }> =>
request({
method: 'POST',
url: '/articles',
data: params,
});
修改‘我的文章’页面

修改文件‘src/views/articles/MyArticles.vue’,添加如下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
<script setup lang="ts">
import type { CreateArticle, PageInfo } from '@/types';
import { createArticle, getArticles, getArticleBySlug, deleteArticleBySlug, updateArticle } from '@/api';
import { onBeforeMount, ref } from 'vue';
import { storeToRefs } from 'pinia';
import useUserStore from '@/stores/user';
import type { ArticleSearchParams } from '@/types';
import { el, tr } from 'element-plus/es/locales.mjs';
import { ElMessageBox } from 'element-plus';

const createForm = ref<CreateArticle>({
title: '',
description: '',
body: '',
tagList: [],
});

let pageArticles = ref<PageInfo>({
currentPage: 1,
pageSize: 10,
total: 0,
articles: []
});

const article_slug = ref('');

const createVisible = ref(false);

const title = ref('新增文章');

const tag = ref('');

const addArticleTag = () => {
if (tag.value === '') {
return;
}
createForm.value.tagList.push(tag.value);
tag.value = '';
};

const delArticleTag = (index: number) => {
createForm.value.tagList.splice(index, 1);
}

const handleCreateArticle = async () => {
try {
title.value = '新增文章';
createForm.value.title = '';
createForm.value.description = '';
createForm.value.body = '';
createForm.value.tagList = [];
article_slug.value = '';
createVisible.value = true;
} catch (error) {
console.error(error);
}
};

const handleClose = () => {
createVisible.value = false;
};

const handleConfirm = async () => {
if (article_slug.value === '') {
const res = await createArticle({ article: createForm.value });
console.log(res);
} else {
const res = await updateArticle(article_slug.value, { article: createForm.value });
console.log(res);
}
// 关闭窗口
handleClose();
// 渲染文章列表
fetchArticles(0, pageArticles.value.pageSize);
};

const handleSizeChange = (val: number) => {
pageArticles.value.pageSize = val;
fetchArticles((pageArticles.value.currentPage - 1) * val, val);
};

// 执行编辑文章
const handleEdit = async (slug: string) => {
try {
title.value = '编辑文章';
createVisible.value = true;
const res = await getArticleBySlug(slug as string);
createForm.value = res.data.article;
article_slug.value = slug;
} catch (error) {
console.error(error);
}
}

// 执行删除文章
const handleDelete = async (slug: string) => {
ElMessageBox.confirm('将要删除本条记录,是否继续?', '删除文章', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
draggable: true,
}).then(async () => {
try {
await deleteArticleBySlug(slug);
fetchArticles(0, pageArticles.value.pageSize);
} catch (error) {
console.error(error);
}
})
}

const handleCurrentChange = (val: number) => {
pageArticles.value.currentPage = val;
fetchArticles((val - 1) * pageArticles.value.pageSize, pageArticles.value.pageSize);
};

const fetchArticles = async (offset: number, pageSize: number) => {
const userStore = useUserStore();
const { userInfo } = storeToRefs(userStore);

const params: ArticleSearchParams = {
'offset': offset,
'limit': pageSize,
'author': userInfo.value?.username || '',
};

try {
const res = await getArticles(params);
pageArticles.value.articles = res.data.articles;
pageArticles.value.total = res.data.articlesCount;
} catch (error) {
console.error(error);
}
};

fetchArticles(0, pageArticles.value.pageSize);
</script>

<template>
<div class="main-body">
<!--工具栏-->
<el-form :inline="true">
<el-form-item>
<el-button type="primary" @click="handleCreateArticle">添加文章</el-button>
</el-form-item>
</el-form>
<!--文章列表---->
<el-table :data="pageArticles.articles" style="width: 100%;" border>
<el-table-column prop="slug" label="ID" width="240" :showOverflowTooltip="true"></el-table-column>
<el-table-column prop="title" label="标题" width="240" :showOverflowTooltip="true"></el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="240" :showOverflowTooltip="true"></el-table-column>
<el-table-column prop="author.username" label="作者"></el-table-column>
<el-table-column label="标签" align="center" prop="tagList">
<template #default="scope">
<el-tag class="a_tag" v-for="tag in scope.row.tagList">
{{ tag }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button link type="primary" @click="handleEdit(scope.row.slug)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(scope.row.slug)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--分页组件-->
<el-pagination v-model:current-page="pageArticles.currentPage" :page-size="pageArticles.pageSize"
:page-sizes="[5, 10, 15, 20, 25, 30]" :total="pageArticles.total" layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>

<!--新增文章页面-->
<el-dialog v-model="createVisible" :title="title" width="500px" :before-close="handleClose">
<el-form ref="formRef" :model="createForm" label-width="80px">
<el-form-item label="文章标题">
<el-input v-model="createForm.title" placeholder="请输入文章标题"></el-input>
</el-form-item>
<el-form-item label="文章简介">
<el-input v-model="createForm.description" placeholder="请输入文章简介"></el-input>
</el-form-item>
<el-form-item label="文章内容">
<el-input v-model="createForm.body" placeholder="请输入文章内容"></el-input>
</el-form-item>
<el-form-item label="文章标签">
<el-input v-model="tag" placeholder="请输入文章标签,按回车键添加标签"
@keypress.enter.prevent="addArticleTag"></el-input>
<div v-if="createForm.tagList.length">
<el-tag v-for="(tag, index) in createForm.tagList" :key="tag + index" closable
@close="delArticleTag(index)">
{{ tag }}
</el-tag>
</div>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</span>
</template>
</el-dialog>
</template>

<style scoped lang="scss">
.pagination {
margin-top: 20px;
float: right
}
</style>
测试

在侧边栏点击‘我的文章’,展示当前用户添加的文章

image-20251016180119815

点击左上角‘添加文章’按钮,在弹出的编辑框中填写文章信息(添加标签的方法是填入一个标签后按回车键,再添加下一个),点击‘确定’,添加文章

image-20251016180241260

可以看到刚才添加的文章

image-20251016180427136

点击‘编辑’,编辑文章信息后点击‘确定’,修改文章信息

image-20251016180609405

刚才修改的文章信息

image-20251016180647000

点击‘删除’,删除对应的文章

image-20251016180722413