项目地址:https://gitee.com/csheng-gitee/vue3-big-event-admin
技术栈:VUE3 + Pinia + Pnpm
(本项目暂不用 typescript)
一、前期准备工作
1、创建项目
npm install -g pnpm
pnpm create vue
2、ESLint 配置
- (1) 禁用
prettier
插件,下载ESLint
插件; - (2) 在 vscode 的
setting.json
设置自动修复:
// 开启 eslint 自动修复
"editor.codeActionsOnSave": {
"source.fixAll": true
},
// 关闭保存自动格式化
"editor.formatOnSave": false,
- (3) 在项目根目录的
.gitignore.cjs
配置 ESLint 规则:
rules: {
'prettier/prettier': [
'warn',
{
singleQuote: true, // 单引号
semi: false, // 无分号
printWidth: 80, // 每行宽度至多80字符
trailingComma: 'none', // 不加对象|数组最后逗号
endOfLine: 'auto' // 换行符号不限制(win mac 不一致)
}
],
'vue/multi-word-component-names': [
'warn',
{
ignores: ['index'] // vue组件名称多单词组成(忽略index.vue)
}
],
'vue/no-setup-props-destructure': ['off'], // 关闭 props 解构的校验
// 添加未定义变量错误提示,create-vue@3.6.3 关闭,这里加上是为了支持下一个章节演示。
'no-undef': 'error'
}
3、husky 代码检查
- (1) husky 配置:
此操作须在 bash 窗口输入(&&符号)
pnpm dlx husky-init && pnpm install
修改 .husky/pre-commit
文件
pnpm lint
缺点:默认进行的是全量检查,耗时问题,历史问题。继续步骤2
- (2) lint-staged 配置:
pnpm i lint-staged -D
配置 package.json
{
"scripts": {
....
"lint-staged": "lint-staged"
},
....
"lint-staged": {
"*.{js,ts,vue}": [
"eslint --fix"
]
}
}
修改 .husky/pre-commit
文件
pnpm lint-staged
4、目录调整
(1)删除 目录下的所有文件:
(2)修改:
(3)新增 目录结构
(4)拷贝全局样式和图片,并且安装预处理器支持
pnpm add sass -D
5、VueRouter4 路由语法(可忽略)
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
// createWebHashHistory:hash 模式 / createWebHistory:history 模式
// import.meta.env.BASE_URL :vite.config.js 中的 base 基地址配置
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: []
})
export default router
// vite.config.js
export default defineConfig({
....
base: '/', // 基地址
....
})
6、按需引入 Element-Plus
安装 Element-Plus:
pnpm install element-plus
按需导入:
pnpm add -D unplugin-vue-components unplugin-auto-import
// vite.config.js
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
...
export default defineConfig({
plugins: [
...
AutoImport({
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
],
....
})
引入组件:
<!-- App.vue -->
<el-button type="primary">Primary</el-button>
<el-button type="success">Success</el-button>
彩蛋:components
文件夹下的组件会自动注册:
<!-- components/TestDemo.vue -->
<test-demo />
7、Pinia 构建用户仓库和持久化
- (1) 构建用户仓库
// stores/user.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('big-user', () => {
const token = ref('')
const setToken = (newToken) => (token.value = newToken)
return { token, setToken }
})
<!-- App.vue -->
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
</script>
<template>
<div>
<p>token:{{ userStore.token }}</p>
<el-button type="primary" @click="userStore.setToken('Bearer csheng')"
>登录</el-button
>
<el-button type="success" @click="userStore.setToken('')">退出</el-button>
</div>
</template>
<style scoped></style>
- (2) 持久化
pnpm add pinia-plugin-persistedstate -D
// main.js
....
import persist from 'pinia-plugin-persistedstate'
....
app.use(createPinia().use(persist))
...
// stores/user.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore(
'big-user',
() => {
const token = ref('')
const setToken = (newToken) => (token.value = newToken)
return { token, setToken }
},
{
persist: true
}
)
- (3) 配置仓库统一管理
// stores/index.js
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(persist)
export default pinia
export * from './modules/user'
// stores/modules/user.js
原代码不变,仅路径更改了
// main.js
....
import pinia from '@/stores'
const app = createApp(App)
app.use(pinia)
app.use(router)
// App.vue
import { useUserStore } from '@/stores'
8、数据交互 - 请求工具设计
pnpm add axios
新建 utils/request.js
import axios from 'axios'
const baseURL = 'http://big-event-vue-api-t.itheima.net'
const instance = axios.create({
// TODO 1. 基础地址,超时时间
})
instance.interceptors.request.use(
(config) => {
// TODO 2. 携带token
return config
},
(err) => Promise.reject(err)
)
instance.interceptors.response.use(
(res) => {
// TODO 3. 处理业务失败
// TODO 4. 摘取核心响应数据
return res
},
(err) => {
// TODO 5. 处理401错误
return Promise.reject(err)
}
)
export default instance
完整版:
import axios from 'axios'
import { useUserStore } from '@/stores'
import { ElMessage } from 'element-plus'
import router from '@/router'
const baseURL = 'http://big-event-vue-api-t.itheima.net'
const instance = axios.create({
// TODO 1. 基础地址,超时时间
baseURL,
timeout: 10000
})
// 请求拦截器
instance.interceptors.request.use(
(config) => {
// TODO 2. 携带token
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = userStore.token
}
return config
},
(err) => Promise.reject(err)
)
// 响应拦截器
instance.interceptors.response.use(
(res) => {
// TODO 4. 摘取核心响应数据
if (res.data.code === 0) {
return res
}
// TODO 3. 处理业务失败(错误提示,抛出错误)
ElMessage.error(res.data.message || '服务异常')
return Promise.reject(res.data)
},
(err) => {
// TODO 5. 处理401错误(权限不足 或 token 过期)
if (err.response?.status === 401) {
router.push('/login')
}
// 错误的默认情况(只要给提示)
ElMessage.error(err.response.data.message || '服务异常')
return Promise.reject(err)
}
)
export default instance
export { baseURL }
9、整体路由设计
path | 文件 | 功能 | 路由级别 |
---|---|---|---|
/login | views/login/LoginPage.vue | 登录&注册 | 一级路由 |
/ | views/layout/LayoutContainer.vue | 布局架子 | 一级路由 |
├─ /article/manage | views/article/ArticleManage.vue | 文章管理 | 2级路由 |
├─ /article/channel | views/article/ArticleChannel.vue | 频道管理 | 2级路由 |
├─ /user/profile | views/user/UserProfile.vue | 个人详情 | 2级路由 |
├─ /user/avatar | views/user/UserAvatar.vue | 更换头像 | 2级路由 |
├─ /user/password | views/user/UserPassword.vue | 重置密码 | 2级路由 |
- (1) 新建文件:
- (2) 配置路由规则:
// router/index.js
routes: [
{
path: '/login',
component: () => import('@/views/login/LoginPage.vue')
},
{
path: '/',
component: () => import('@/views/layout/LayoutContainer.vue'),
redirect: '/article/manage',
children: [
{
path: '/article/manage',
component: () => import('@/views/article/ArticleManage.vue')
},
{
path: '/article/channel',
component: () => import('@/views/article/ArticleChannel.vue')
},
{
path: '/user/profile',
component: () => import('@/views/user/UserProfile.vue')
},
{
path: '/user/avatar',
component: () => import('@/views/user/UserAvatar.vue')
},
{
path: '/user/password',
component: () => import('@/views/user/UserPassword.vue')
}
]
}
]
<!-- App.vue -->
<script setup></script>
<template>
<router-view></router-view>
</template>
<style scoped></style>
<!-- layout/LayoutContainer -->
<template>
<div>
<p>布局架子</p>
<router-view></router-view>
</div>
</template>
二、登录注册&layout架子
1、登录&注册
静态结构&基本切换
安装 element-plus
图标库:
pnpm install @element-plus/icons-vue
静态结构准备:
<!-- login/LoginPage.vue -->
<script setup>
import { User, Lock } from '@element-plus/icons-vue'
import { ref } from 'vue'
const isRegister = ref(true) // 是否注册
</script>
<template>
<el-row class="login-page">
<el-col :span="12" class="bg"></el-col>
<el-col :span="6" :offset="3" class="form">
<!-- 注册表单 -->
<el-form ref="form" size="large" autocomplete="off" v-if="isRegister">
<el-form-item>
<h1>注册</h1>
</el-form-item>
<el-form-item>
<el-input :prefix-icon="User" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item>
<el-input
:prefix-icon="Lock"
type="password"
placeholder="请输入密码"
></el-input>
</el-form-item>
<el-form-item>
<el-input
:prefix-icon="Lock"
type="password"
placeholder="请输入再次密码"
></el-input>
</el-form-item>
<el-form-item>
<el-button class="button" type="primary" auto-insert-space>
注册
</el-button>
</el-form-item>
<el-form-item class="flex">
<el-link type="info" :underline="false" @click="isRegister = false">
← 返回
</el-link>
</el-form-item>
</el-form>
<!-- 登录表单 -->
<el-form ref="form" size="large" autocomplete="off" v-else>
<el-form-item>
<h1>登录</h1>
</el-form-item>
<el-form-item>
<el-input :prefix-icon="User" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item>
<el-input
name="password"
:prefix-icon="Lock"
type="password"
placeholder="请输入密码"
></el-input>
</el-form-item>
<el-form-item class="flex">
<div class="flex">
<el-checkbox>记住我</el-checkbox>
<el-link type="primary" :underline="false">忘记密码?</el-link>
</div>
</el-form-item>
<el-form-item>
<el-button class="button" type="primary" auto-insert-space
>登录</el-button
>
</el-form-item>
<el-form-item class="flex">
<el-link type="info" :underline="false" @click="isRegister = true">
注册 →
</el-link>
</el-form-item>
</el-form>
</el-col>
</el-row>
</template>
<style lang="scss" scoped>
.login-page {
height: 100vh;
background-color: #fff;
.bg {
background:
url('@/assets/logo2.png') no-repeat 60% center / 240px auto,
url('@/assets/login_bg.jpg') no-repeat center / cover;
border-radius: 0 20px 20px 0;
}
.form {
display: flex;
flex-direction: column;
justify-content: center;
user-select: none;
.title {
margin: 0 auto;
}
.button {
width: 100%;
}
.flex {
width: 100%;
display: flex;
justify-content: space-between;
}
}
}
</style>
表单校验
<!-- login/LoginPage.vue -->
<script setup>
...
// 注册Form信息
const formModel = ref({
username: '',
password: '',
repassword: ''
})
// rules规则
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 5, max: 10, message: '用户名必须是5-10位的字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{
pattern: /^\S{6,15}$/,
message: '密码必须是6-15位的非空字符',
trigger: 'blur'
}
],
repassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{
pattern: /^\S{6,15}$/,
message: '密码必须是6-15的非空字符',
trigger: 'blur'
},
{
validator: (rule, value, callback) => {
if (value !== formModel.value.password) {
callback(new Error('两次输入密码不一致!'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
...
</script>
<template>
<el-form :model="formModel" :rules="rules" ....>
<el-form-item prop="username">
<el-input v-model="formModel.username"></el-input>
</el-form-item>
....
</el-form>
</template>
注册功能
// api/user.js
import request from '@/utils/request'
// 注册
export const userRegisterService = ({ username, password, repassword }) =>
request.post('/api/reg', { username, password, repassword })
// login/LoginPage.vue
import { userRegisterService } from '@/api/user'
....
const form = ref() // form表单实例
....
// 注册
const register = async () => {
await form.value.validate()
await userRegisterService(formModel.value)
ElMessage.success('注册成功')
isRegister.value = false
}
/************************************************/
<el-button @click="register" ....>注册</el-button>
// .eslintrc.cjs
module.exports = {
....
globals: {
ElMessage: 'readonly',
ElMessageBox: 'readonly',
ElLoading: 'readonly'
}
}
登录功能
// api/user.js
// 登录
export const userLoginService = ({ username, password }) =>
request.post('/api/login', { username, password })
// login/LoginPage.vue
....
// isRegister 每次变化都清空表单
watch(isRegister, () => {
formModel.value = {
username: '',
password: '',
repassword: ''
}
})
// 登录
const userStore = useUserStore()
const router = useRouter()
const login = async () => {
await form.value.validate()
// 加载效果
const loading = ElLoading.service({
lock: true,
text: '登录中',
background: 'rgba(0, 0, 0, 0.7)'
})
const res = await userLoginService(formModel.value)
loading.close() // 关闭加载效果
userStore.setToken(res.data.token)
ElMessage.success('登录成功')
router.push('/')
}
/************************************************/
<el-button @click="login" ....>登录</el-button>
登录访问拦截
// router/index.js
// 登录访问拦截
router.beforeEach((to) => {
const userStore = useUserStore()
if (!userStore.token && to.path !== '/login') return '/login'
})
记住账号密码(补充)
pnpm add js-base64
pnpm add js-cookie
import { Base64 } from 'js-base64'
import Cookies from 'js-cookie'
const isRemember = ref(false) // 是否记住账号密码
// 登录
const login = async () => {
await form.value.validate()
const loading = ElLoading.service({
lock: true,
text: '登录中',
background: 'rgba(0, 0, 0, 0.7)'
})
const res = await userLoginService(formModel.value)
loading.close()
// 如果记住密码
if (isRemember.value) {
// 转码
const { username, password } = formModel.value
const localForm = {
username: Base64.encode(username),
password: Base64.encode(password)
}
// 存到 Cookies
Cookies.set('LOCAL_KEY', JSON.stringify(localForm))
} else {
Cookies.remove('LOCAL_KEY')
}
userStore.setToken(res.data.token)
ElMessage.success('登录成功')
router.push('/')
}
// 查询是否存了用户名和密码
onMounted(() => {
const localForm = Cookies.get('LOCAL_KEY')
if (localForm) {
isRemember.value = true
// 解码回填
try {
const { username, password } = JSON.parse(localForm)
formModel.value.username = Base64.decode(username)
formModel.value.password = Base64.decode(password)
} catch (error) {
console.error('本地数据解析失败~', error)
}
} else {
isRemember.value = false
}
})
/*---------------------------------------------------*/
<el-checkbox v-model="isRemember">记住我</el-checkbox>
2、layout 架子
<!-- layout/LayoutContainer.vue -->
<script setup>
import {
Management,
Promotion,
UserFilled,
User,
Crop,
EditPen,
SwitchButton,
CaretBottom
} from '@element-plus/icons-vue'
import avatar from '@/assets/default.png'
</script>
<template>
<!-- 容器 -->
<el-container class="layout-container">
<!-- 左侧 -->
<el-aside width="200px">
<!-- logo -->
<div class="el-aside__logo"></div>
<!-- 菜单项 -->
<el-menu
active-text-color="#ffd04b"
background-color="#232323"
:default-active="$route.path"
text-color="#fff"
router
>
<!-- 一级菜单 -->
<el-menu-item index="/article/channel">
<el-icon><Management /></el-icon>
<span>文章分类</span>
</el-menu-item>
<el-menu-item index="/article/manage">
<el-icon><Promotion /></el-icon>
<span>文章管理</span>
</el-menu-item>
<!-- 带有二级菜单的一级菜单 -->
<el-sub-menu index="/user">
<!-- 一级 -->
<template #title>
<el-icon><UserFilled /></el-icon>
<span>个人中心</span>
</template>
<!-- 二级 -->
<el-menu-item index="/user/profile">
<el-icon><User /></el-icon>
<span>基本资料</span>
</el-menu-item>
<!-- 二级 -->
<el-menu-item index="/user/avatar">
<el-icon><Crop /></el-icon>
<span>更换头像</span>
</el-menu-item>
<!-- 二级 -->
<el-menu-item index="/user/password">
<el-icon><EditPen /></el-icon>
<span>重置密码</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<!-- 右侧 -->
<el-container>
<!-- 头部 -->
<el-header>
<!-- 最左 -->
<div>当前用户:<strong>陈升</strong></div>
<!-- 最右 -->
<el-dropdown placement="bottom-end">
<span class="el-dropdown__box">
<el-avatar :src="avatar" />
<el-icon><CaretBottom /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile" :icon="User"
>基本资料</el-dropdown-item
>
<el-dropdown-item command="avatar" :icon="Crop"
>更换头像</el-dropdown-item
>
<el-dropdown-item command="password" :icon="EditPen"
>重置密码</el-dropdown-item
>
<el-dropdown-item command="logout" :icon="SwitchButton"
>退出登录</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<!-- 主要内容 -->
<el-main>
<router-view></router-view>
</el-main>
<el-footer>大事件 ©2023 Created by 陈升</el-footer>
</el-container>
</el-container>
</template>
<style lang="scss" scoped>
.layout-container {
height: 100vh;
.el-aside {
background-color: #232323;
&__logo {
height: 120px;
background: url('@/assets/logo.png') no-repeat center / 120px auto;
}
.el-menu {
border-right: none;
}
}
.el-header {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #fff;
.el-dropdown__box {
display: flex;
align-items: center;
.el-icon {
color: #999;
margin-left: 10px;
}
&:active,
&:focus {
outline: none;
}
}
}
.el-footer {
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #666;
}
}
</style>
3、用户基本信息获取&渲染
// api/user.js
// 获取用户基本信息
export const userGetInfoService = () => request.get('/my/userinfo')
// stores/modules/user.js
import { userGetInfoService } from '@/api/user'
....
const user = ref({})
const getUser = async () => {
const res = await userGetInfoService()
user.value = res.data.data
}
// layout/LayoutContainer.vue
import { useUserStore } from '@/stores'
import { onMounted } from 'vue'
const userStore = useUserStore()
onMounted(() => {
userStore.getUser()
})
<div>当前用户:<strong>{{userStore.user.nickname || userStore.user.username}}</strong></div>
<el-avatar :src="userStore.user.user_pic || avatar" />
4、退出登录
// stores/modules/user.js
const setUser = (newUser) => (user.value = newUser)
<!-- layout/LayoutContainer -->
<script setup>
....
// 点击下拉项
const router = useRouter()
const onCommand = async (command) => {
if (command === 'logout') {
await ElMessageBox.confirm('你确认退出大事件吗?', '温馨提示', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
})
userStore.setToken('')
userStore.setUser({})
router.push('/login')
} else {
router.push(`/user/${command}`)
}
}
</script>
<el-dropdown @command="onCommand">....
三、文章分类
1、PageContainer 封装
<!-- components/PageContainer.vue -->
<script setup>
defineProps({
title: { required: true, type: String }
})
</script>
<template>
<el-card class="page-container">
<template #header>
<div class="header">
<span>{{ title }}</span>
<div class="extra">
<!-- 具名插槽 -->
<slot name="extra"></slot>
</div>
</div>
</template>
<!-- 默认插槽 -->
<slot></slot>
</el-card>
</template>
<style lang="scss" scoped>
.page-container {
min-height: 100%;
box-sizing: border-box;
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
}
</style>
<!-- article/ArticleChannel.vue -->
<script setup></script>
<template>
<PageContainer title="文章分类">
<template #extra>
<el-button type="primary">添加分类</el-button>
</template>
主体部分
</PageContainer>
</template>
<!-- article/ArticleManage.vue -->
<script setup></script>
<template>
<PageContainer title="文章管理">
<template #extra>
<el-button type="primary">添加文章</el-button>
</template>
主体部分
</PageContainer>
</template>
2、文章分类渲染
// api/article.js
import request from '@/utils/request'
// 获取文章分类信息
export const artGetChannelsService = () => request.get('/my/cate/list')
<!-- article/ArticleChannel.vue -->
<script setup>
import { artGetChannelsService } from '@/api/article'
import { ref } from 'vue'
// 获取文章分类数据
const loading = ref(false)
const channelList = ref([])
const getChannelList = async () => {
loading.value = true
const res = await artGetChannelsService()
channelList.value = res.data.data
loading.value = false
}
getChannelList()
// 编辑
const onEditChannel = (row) => {
console.log(row)
}
// 删除
const onDelChannel = (row) => {
console.log(row)
}
</script>
<template>
<PageContainer title="文章分类">
....
<el-table v-loading="loading" :data="channelList" style="width: 100%">
<el-table-column label="序号" width="100" type="index"> </el-table-column>
<el-table-column label="分类名称" prop="cate_name"></el-table-column>
<el-table-column label="分类别名" prop="cate_alias"></el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button
:icon="Edit"
circle
plain
type="primary"
@click="onEditChannel(row)"
></el-button>
<el-button
:icon="Delete"
circle
plain
type="danger"
@click="onDelChannel(row)"
></el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="没有数据" />
</template>
</el-table>
</PageContainer>
</template>
3、文章分类添加/编辑/删除
// api/article.js
// 添加文章分类
export const artAddChannelService = (data) => request.post('/my/cate/add', data)
// 编辑文章分类
export const artEditChannelService = (data) =>
request.put('/my/cate/info', data)
// 删除文章分类
export const artDelChannelService = (id) =>
request.delete('/my/cate/del', { params: { id } })
四、文章管理
1、文章列表渲染
<!-- article/ArticleManage.vue -->
<script setup>
import { Delete, Edit } from '@element-plus/icons-vue'
import { ref } from 'vue'
// 暂时使用的假数据
const articleList = ref([
{
id: 5961,
title: '新的文章啊',
pub_date: '2022-07-10 14:53:52.604',
state: '已发布',
cate_name: '体育'
},
{
id: 5962,
title: '新的文章啊',
pub_date: '2022-07-10 14:54:30.904',
state: null,
cate_name: '体育'
}
])
// 编辑文章
const onEditArticle = (row) => {
console.log(row)
}
// 删除文章
const onDeleteArticle = (row) => {
console.log(row)
}
</script>
<template>
<PageContainer title="文章管理">
<template #extra>
<el-button type="primary">添加文章</el-button>
</template>
<!-- 筛选项 -->
<el-form inline>
<el-form-item label="文章分类:">
<el-select>
<el-option label="新闻" value="111"></el-option>
<el-option label="体育" value="222"></el-option>
</el-select>
</el-form-item>
<el-form-item label="发布状态:">
<el-select>
<el-option label="已发布" value="已发布"></el-option>
<el-option label="草稿" value="草稿"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary">搜索</el-button>
<el-button>重置</el-button>
</el-form-item>
</el-form>
<!-- 表格区域 -->
<el-table :data="articleList" style="width: 100%">
<el-table-column label="文章标题" width="400">
<template #default="{ row }">
<el-link type="primary" :underline="false">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column label="分类" prop="cate_name"></el-table-column>
<el-table-column label="发布时间" prop="pub_date"></el-table-column>
<el-table-column label="状态" prop="state"></el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button
:icon="Edit"
circle
plain
type="primary"
@click="onEditArticle(row)"
></el-button>
<el-button
:icon="Delete"
circle
plain
type="danger"
@click="onDeleteArticle(row)"
></el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="没有数据" />
</template>
</el-table>
</PageContainer>
</template>
2、中英国际化处理
<!-- App.vue -->
<script setup>
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>
<template>
<el-config-provider :locale="zhCn">
<router-view></router-view>
</el-config-provider>
</template>
<style scoped></style>
3、文章分类选择
<!-- article/components/ChannelSelect.vue -->
<script setup>
import { artGetChannelsService } from '@/api/article'
import { ref } from 'vue'
// 双向绑定
defineProps({
modelValue: {
type: [Number, String]
},
width: {
type: String
}
})
const emit = defineEmits(['update:modelValue'])
// 获取文章分类数据
const channelList = ref([])
const getChannelList = async () => {
const res = await artGetChannelsService()
channelList.value = res.data.data
}
getChannelList()
</script>
<template>
<el-select
:modelValue="modelValue"
:style="{ width }"
@update:modelValue="emit('update:modelValue', $event)"
>
<el-option
v-for="item in channelList"
:key="item.id"
:label="item.cate_name"
:value="item.id"
></el-option>
</el-select>
</template>
<style lang="scss" scoped></style>
<!-- article/components/ArticleManage.vue -->
<script setup>
....
import ChannelSelect from './components/ChannelSelect.vue'
// 列表请求参数
const params = ref({
pagenum: 1,
pagesize: 5,
cate_id: '',
state: ''
})
....
</script>
<template>
<channel-select v-model="params.cate_id"></channel-select>
....
<el-form-item label="发布状态:">
<el-select v-model="params.state">...</el-select>
</el-form-item>
</template>
4、渲染文章列表
// api/article.js
// 获取文章列表
export const artGetListService = (params) =>
request.get('/my/article/list', { params })
// utils/format.js
import { dayjs } from 'element-plus'
export const formatTime = (time) => dayjs(time).format('YYYY年MM月DD日')
// article/components/ArticleManage.vue
import { artGetListService } from '@/api/article'
import { formatTime } from '@/utils/format'
....
const articleList = ref([]) // 文章列表
const total = ref(0) // 总条数
// 获取用户列表
const getArticleList = async () => {
const res = await artGetListService(params.value)
articleList.value = res.data.data
total.value = res.data.total
}
getArticleList()
/*---------------------------------------------*/
<el-table-column label="发布时间">
<template #default="{ row }">
{{ formatTime(row.pub_date) }}
</template>
</el-table-column>
5、分页
const loading = ref(false)
// 获取用户列表
const getArticleList = async () => {
loading.value = true
const res = await artGetListService(params.value)
loading.value = false
....
}
// 分页
const onSizeChange = (size) => {
params.value.pagenum = 1
params.value.pagesize = size
getArticleList()
}
const onCurrentChange = (page) => {
params.value.pagenum = page
getArticleList()
}
/*---------------------------------------------*/
<el-table v-loading="loading" ....
<el-pagination
v-model:current-page="params.pagenum"
v-model:page-size="params.pagesize"
:page-sizes="[2, 3, 4, 5, 10]"
layout="jumper, total, sizes, prev, pager, next"
background
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
6、搜索&重置
// 搜索和重置
const onSearch = () => {
params.value.pagenum = 1
getArticleList()
}
const onReset = () => {
params.value.pagenum = 1
params.value.cate_id = ''
params.value.state = ''
getArticleList()
}
/*---------------------------------------------------------*/
<el-button @click="onSearch" type="primary">搜索</el-button>
<el-button @click="onReset">重置</el-button>
7、新增/编辑打开抽屉
新建 article/components/ArticleEdit.vue
<script setup>
import { ref } from 'vue'
import ChannelSelect from './ChannelSelect.vue'
// 默认表单(初始化)
const defaultForm = {
title: '',
cate_id: '',
cover_img: '',
content: '',
state: ''
}
// 抽屉表单(备份)
const formModel = ref({
title: '',
cate_id: '',
cover_img: '',
content: '',
state: ''
})
// 抽屉
const visibleDrawer = ref(false)
const open = async (row) => {
visibleDrawer.value = true
if (row?.id) {
console.log('编辑回显')
} else {
console.log('新增回显')
formModel.value = { ...defaultForm.value }
}
}
defineExpose({ open })
</script>
<template>
<!-- 抽屉 -->`
<el-drawer
v-model="visibleDrawer"
:title="formModel.id ? '编辑文章' : '添加文章'"
direction="rtl"
size="35%"
>
<!-- 发表文章表单 -->
<el-form ref="formRef" :model="formModel" label-width="100px">
<el-form-item label="文章标题" prop="title">
<el-input v-model="formModel.title" placeholder="请输入标题"></el-input>
</el-form-item>
<el-form-item label="文章分类" prop="cate_id">
<channel-select v-model="formModel.cate_id" width="100%" />
</el-form-item>
<el-form-item label="文章封面" prop="cover_img">文件上传</el-form-item>
<el-form-item label="文章内容" prop="content">
<div class="editor">富文本编辑器</div>
</el-form-item>
<el-form-item>
<el-button type="primary">发布</el-button>
<el-button type="info">草稿</el-button>
</el-form-item>
</el-form>
</el-drawer>
</template>
<style lang="scss" scoped></style>
article/ArticleMange.vue
<script setup>
const articleEditRef = ref() // 抽屉
// 新增文章
const onAddArticle = () => {
articleEditRef.value.open()
}
// 编辑文章
const onEditArticle = (row) => {
articleEditRef.value.open(row)
}
</script>
<template>
...
<!-- 抽屉 -->
<article-edit ref="articleEditRef"></article-edit>
</template>
8、文件上传
article/components/ArticleEdit.vue
<script setup>
import { Plus } from '@element-plus/icons-vue'
....
// 文件上传
const imgUrl = ref('')
const onUploadFile = (uploadFile) => {
// 外观
imgUrl.value = URL.createObjectURL(uploadFile.raw)
// 实际赋值位置
formModel.value.cover_img = uploadFile.raw
}
</script>
<template>
....
<el-upload
class="avatar-uploader"
:auto-upload="false"
:show-file-list="false"
:on-change="onUploadFile"
>
<img v-if="imgUrl" :src="imgUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
....
</template>
<style lang="scss" scoped>
.avatar-uploader {
:deep() {
.avatar {
width: 178px;
height: 178px;
display: block;
}
.el-upload {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}
.el-upload:hover {
border-color: var(--el-color-primary);
}
.el-icon.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 178px;
height: 178px;
text-align: center;
}
}
}
</style>
9、富文本编辑器 vue-quill
pnpm add @vueup/vue-quill@latest
article/components/ArticleEdit.vue
<script setup>
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
....
</script>
<template>
....
<div class="editor">
<quill-editor
theme="snow"
v-model:content="formModel.content"
contentType="html"
/>
</div>
</template>
<style lang="scss" scoped>
....
.editor {
width: 100%;
:deep(.ql-editor) {
min-height: 200px;
}
}
</style>
10、添加/编辑文章
api/article.js
// 添加文章
export const artPublishService = (data) => request.post('/my/article/add', data)
// 获取文章详情
export const artGetDetailService = (id) =>
request.get('my/article/info', { params: { id } })
// 编辑文章
export const artEditService = (data) => request.put('my/article/info', data)
article/ArticleManage.vue
<script setup>
// 添加/修改 成功后的操作
const onSuccess = (type) => {
if (type === 'add') {
// 添加完毕,更新跳转到渲染最后一页;编辑完毕,渲染当前页
const lastPage = Math.ceil((total.value + 1) / params.value.pagesize)
params.value.pagenum = lastPage
}
getArticleList()
}
</script>
<template>
<article-edit ref="articleEditRef" @success="onSuccess"></article-edit>
</template>
article/ArticleEdit.vue
<script setup>
import {
artPublishService,
artGetDetailService,
artEditService
} from '@/api/article'
import { ElMessage } from 'element-plus'
import { baseURL } from '@/utils/request'
import axios from 'axios'
import { nextTick } from 'vue'
const editorRef = ref()
const open = async (row) => {
visibleDrawer.value = true
if (row?.id) {
const res = await artGetDetailService(row.id)
formModel.value = res.data.data
imgUrl.value = baseURL + formModel.value.cover_img
// 将 在线地址 转换成 file 格式
formModel.value.cover_img = await imageUrlToFile(
imgUrl.value,
formModel.value.cover_img
)
} else {
formModel.value = { ...defaultForm.value }
imgUrl.value = ''
nextTick(() => {
editorRef.value.setHTML('')
})
}
}
// 将网络图片地址转换为File对象
async function imageUrlToFile(url, fileName) {
try {
// 第一步:使用axios获取网络图片数据
const response = await axios.get(url, { responseType: 'arraybuffer' })
const imageData = response.data
// 第二步:将图片数据转换为Blob对象
const blob = new Blob([imageData], {
type: response.headers['content-type']
})
// 第三步:创建一个新的File对象
const file = new File([blob], fileName, { type: blob.type })
return file
} catch (error) {
console.error('将图片转换为File对象时发生错误:', error)
throw error
}
}
// 添加/修改 成功后的操作
const emit = defineEmits(['success'])
// 发布/草稿
const onPublish = async (state) => {
formModel.value.state = state
// 因为参数带有文件上传,所以要转换为 FormData 格式
const fd = new FormData()
for (let key in formModel.value) {
fd.append(key, formModel.value[key])
}
if (formModel.value.id) {
await artEditService(fd)
ElMessage.success('编辑成功')
visibleDrawer.value = false
emit('success', 'edit')
} else {
await artPublishService(fd)
ElMessage.success('添加成功')
visibleDrawer.value = false
emit('success', 'add')
}
}
</script>
<template>
<quill-editor ref="editorRef" />
<el-button @click="onPublish('已发布')" type="primary">发布</el-button>
<el-button @click="onPublish('草稿')" type="info">草稿</el-button>
</template>
11、删除文章
api/article.js
export const artDelService = (id) =>
request.delete('my/article/info', { params: { id } })
article/ArticleManage.vue
import { ElMessageBox } from 'element-plus'
import { artDelService } from '@/api/article'
// 删除文章
const onDeleteArticle = async (row) => {
await ElMessageBox.confirm('你确认删除该文章信息吗?', '温馨提示', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消'
})
await artDelService(row.id)
ElMessage.success('删除成功')
getArticleList()
}
五、个人中心
1、基本资料
api/user.js
// 更新用户基本信息
export const userUpdateInfoService = ({ id, nickname, email }) =>
request.put('/my/userinfo', { id, nickname, email })
user/UserProfile.vue
<script setup>
import PageContainer from '@/components/PageContainer.vue'
import { ref } from 'vue'
import { userUpdateInfoService } from '@/api/user'
import { useUserStore } from '@/stores'
// pinia 用户数据
const {
user: { nickname, email, username, id },
getUser
} = useUserStore()
// 表单数据
const userInfo = ref({
username,
nickname,
email,
id
})
// 表单校验规则
const rules = {
nickname: [
{ required: true, message: '请输入用户昵称', trigger: 'blur' },
{
pattern: /^\S{2,10}$/,
message: '昵称必须是2-10位的非空字符',
trigger: 'blur'
}
],
email: [
{ required: true, message: '请输入用户邮箱', trigger: 'blur' },
{
type: 'email',
message: '请输入正确的邮箱地址',
trigger: 'blur'
}
]
}
const formRef = ref()
const onSumbit = async () => {
const valid = await formRef.value.validate()
if (valid) {
await userUpdateInfoService(userInfo.value)
await getUser()
ElMessage.success('修改成功')
}
}
</script>
<template>
<page-container title="基本资料">
<el-row>
<el-col :span="12">
<el-form
ref="formRef"
:model="userInfo"
:rules="rules"
label-width="100px"
size="large"
>
<el-form-item label="登录名称">
<el-input v-model="userInfo.username" disabled></el-input>
</el-form-item>
<el-form-item label="用户昵称" prop="nickname">
<el-input v-model="userInfo.nickname"></el-input>
</el-form-item>
<el-form-item label="用户邮箱" prop="email">
<el-input v-model="userInfo.email"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSumbit">提交修改</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
</page-container>
</template>
2、更换头像
api/user.js
// 上传头像
export const userUploadAvatarService = (avatar) =>
request.patch('/my/update/avatar', { avatar })
user/UserAvatar.vue
<script setup>
import PageContainer from '@/components/PageContainer.vue'
import { Plus, Upload } from '@element-plus/icons-vue'
import { ref } from 'vue'
import { useUserStore } from '@/stores'
import { userUploadAvatarService } from '@/api/user'
const userStore = useUserStore()
const uploadRef = ref()
const imgUrl = ref(userStore.user.user_pic)
// 选择图片
const onUploadFile = (file) => {
// 基于 FileReader 读取图片做预览
const reader = new FileReader()
reader.readAsDataURL(file.raw)
reader.onload = () => {
imgUrl.value = reader.result
}
}
// 上传图片
const onUpdateAvatar = async () => {
await userUploadAvatarService(imgUrl.value)
await userStore.getUser()
ElMessage.success('更换头像成功')
}
</script>
<template>
<page-container title="更换头像">
<el-row>
<el-col :span="12">
<el-upload
ref="uploadRef"
class="avatar-uploader"
:auto-upload="false"
:show-file-list="false"
:on-change="onUploadFile"
>
<img v-if="imgUrl" :src="imgUrl" class="avatar" />
<img v-else src="@/assets/avatar.jpg" width="278" />
</el-upload>
<br />
<el-button
@click="uploadRef.$el.querySelector('input').click()"
type="primary"
:icon="Plus"
size="large"
>
选择图片
</el-button>
<el-button
type="success"
:icon="Upload"
size="large"
@click="onUpdateAvatar"
>
上传头像
</el-button>
</el-col>
</el-row>
</page-container>
</template>
<style lang="scss" scoped>
.avatar-uploader {
:deep() {
.avatar {
width: 278px;
height: 278px;
display: block;
}
.el-upload {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
}
.el-upload:hover {
border-color: var(--el-color-primary);
}
.el-icon.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 278px;
height: 278px;
text-align: center;
}
}
}
</style>
3、重置密码
api/user.js
// 更新密码
export const userUpdatePassService = ({ old_pwd, new_pwd, re_pwd }) =>
request.patch('/my/updatepwd', { old_pwd, new_pwd, re_pwd })
user/UserPassword.vue
<script setup>
import PageContainer from '@/components/PageContainer.vue'
import { ref } from 'vue'
import { userUpdatePassService } from '@/api/user'
import { useUserStore } from '@/stores'
import { useRouter } from 'vue-router'
const formRef = ref()
const pwdForm = ref({
old_pwd: '',
new_pwd: '',
re_pwd: ''
})
const checkOldSame = (rule, value, callback) => {
if (value === pwdForm.value.old_pwd) {
callback(new Error('新密码不能与原密码相同'))
} else {
callback()
}
}
const checkNewSame = (rule, value, callback) => {
if (value !== pwdForm.value.new_pwd) {
callback(new Error('确认密码与新密码不一致'))
} else {
callback()
}
}
const rules = {
old_pwd: [
{ required: true, message: '请输入原密码', trigger: 'blur' },
{
pattern: /^\S{6,15}$/,
message: '密码长度必须是6-15位的非空字符串',
trigger: 'blur'
}
],
new_pwd: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{
pattern: /^\S{6,15}$/,
message: '密码长度必须是6-15位的非空字符串',
trigger: 'blur'
},
{ validator: checkOldSame, trigger: 'blur' }
],
re_pwd: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{
pattern: /^\S{6,15}$/,
message: '密码长度必须是6-15位的非空字符串',
trigger: 'blur'
},
{ validator: checkNewSame, trigger: 'blur' }
]
}
// 修改密码
const userStore = useUserStore()
const router = useRouter()
const onSubmit = async () => {
const valid = await formRef.value.validate()
if (valid) {
await userUpdatePassService(pwdForm.value)
ElMessage.success('更换密码成功')
// 清空token和user信息,并且返回登录页
userStore.setToken('')
userStore.setUser({})
router.push('/login')
}
}
// 重置密码
const onReset = () => {
formRef.value.resetFields()
}
</script>
<template>
<page-container title="重置密码">
<el-row>
<el-col :span="12">
<el-form
ref="formRef"
:model="pwdForm"
:rules="rules"
label-width="100px"
size="large"
>
<el-form-item label="原密码" prop="old_pwd">
<el-input v-model="pwdForm.old_pwd" type="password"></el-input>
</el-form-item>
<el-form-item label="新密码" prop="new_pwd">
<el-input v-model="pwdForm.new_pwd" type="password"></el-input>
</el-form-item>
<el-form-item label="确认新密码" prop="re_pwd">
<el-input v-model="pwdForm.re_pwd" type="password"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit">修改密码</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
</page-container>
</template>