一荸百、目錄結構
image.png
image.png
二挫望、各目錄代碼內容
api/artaicle.js :
import request from '@/utils/request'
// 分類:獲取文章分類
export const artGetChannelsService = () => request.get('/my/cate/list')
// 分類:添加文章分類
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 }
})
// 文章:獲取文章列表
export const artGetListService = (params) =>
request.get('/my/article/list', {
params
})
// 文章:添加文章
// 注意:data需要是一個formData格式的對象
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)
// 文章:刪除文章接口
export const artDelService = (id) =>
request.delete('/my/article/info', { params: { id } })
api/user.js
import request from '@/utils/request'
// 注冊接口
export const userRegisterService = ({ username, password, repassword }) =>
request.post('/api/reg', { username, password, repassword })
// 登錄接口
export const userLoginService = ({ username, password }) =>
request.post('/api/login', { username, password })
// 獲取用戶基本信息
export const userGetInfoService = () => request.get('/my/userinfo')
// 更新用戶基本信息
export const userUpdateInfoService = ({ id, nickname, email }) =>
request.put('/my/userinfo', { id, nickname, email })
// 更新用戶頭像
export const userUpdateAvatarService = (avatar) =>
request.patch('/my/update/avatar', { avatar })
// 更新用戶密碼
export const userUpdatePasswordService = ({ old_pwd, new_pwd, re_pwd }) =>
request.patch('/my/updatepwd', { old_pwd, new_pwd, re_pwd })
assets/main.js
body {
margin: 0;
background-color: #f5f5f5;
}
/* fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
transform: translateX(-30px);
opacity: 0;
}
.fade-slide-leave-to {
transform: translateX(30px);
opacity: 0;
}
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;
justify-content: space-between;
align-items: center;
}
}
</style>
components/TestDemo.vue
<template>
<div>我是test測試的組件</div>
</template>
router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores'
// createRouter 創(chuàng)建路由實例
// 配置 history 模式
// 1. history模式:createWebHistory 地址欄不帶 #
// 2. hash模式: createWebHashHistory 地址欄帶 #
// console.log(import.meta.env.DEV)
// vite 中的環(huán)境變量 import.meta.env.BASE_URL 就是 vite.config.js 中的 base 配置項
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
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')
}
]
}
]
})
// 登錄訪問攔截 => 默認是直接放行的
// 根據返回值決定埋虹,是放行還是攔截
// 返回值:
// 1. undefined / true 直接放行
// 2. false 攔回from的地址頁面
// 3. 具體路徑 或 路徑對象 攔截到對應的地址
// '/login' { name: 'login' }
router.beforeEach((to) => {
// 如果沒有token, 且訪問的是非登錄頁霜大,攔截到登錄姻成,其他情況正常放行
const useStore = useUserStore()
if (!useStore.token && to.path !== '/login') return '/login'
})
export default router
stores/modules/counter.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 數(shù)字計數(shù)器模塊
export const useCountStore = defineStore('big-count', () => {
const count = ref(100)
const add = (n) => {
count.value += n
}
return {
count,
add
}
})
stores/modules/user.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { userGetInfoService } from '../../api/user'
// 用戶模塊 token setToken removeToken
export const useUserStore = defineStore(
'big-user',
() => {
const token = ref('')
const setToken = (newToken) => {
token.value = newToken
}
const removeToken = () => {
token.value = ''
}
const user = ref({})
const getUser = async () => {
const res = await userGetInfoService() // 請求獲取數(shù)據
user.value = res.data.data
}
const setUser = (obj) => {
user.value = obj
}
return {
token,
setToken,
removeToken,
user,
getUser,
setUser
}
},
{
persist: true
}
)
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'
export * from './modules/counter'
// import { useUserStore } from './modules/user'
// export { useUserStore }
// import { useCountStore } from './modules/counter'
// export { useCountStore }
utils/format.js
import { dayjs } from 'element-plus'
export const formatTime = (time) => dayjs(time).format('YYYY年MM月DD日')
utils/request.js
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 useStore = useUserStore()
if (useStore.token) {
config.headers.Authorization = useStore.token
}
return config
},
(err) => Promise.reject(err)
)
// 響應攔截器
instance.interceptors.response.use(
(res) => {
// TODO 4. 摘取核心響應數(shù)據
if (res.data.code === 0) {
return res
}
// TODO 3. 處理業(yè)務失敗
// 處理業(yè)務失敗, 給錯誤提示等限,拋出錯誤
ElMessage.error(res.data.message || '服務異常')
return Promise.reject(res.data)
},
(err) => {
// TODO 5. 處理401錯誤
// 錯誤的特殊情況 => 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 }
views/article/components/ArticleEdit.vue
<script setup>
import { ref } from 'vue'
import ChannelSelect from './ChannelSelect.vue'
import { Plus } from '@element-plus/icons-vue'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import {
artPublishService,
artGetDetailService,
artEditService
} from '@/api/article'
import { baseURL } from '@/utils/request'
import axios from 'axios'
// 控制抽屜顯示隱藏
const visibleDrawer = ref(false)
// 默認數(shù)據
const defaultForm = {
title: '', // 標題
cate_id: '', // 分類id
cover_img: '', // 封面圖片 file 對象
content: '', // string 內容
state: '' // 狀態(tài)
}
// 準備數(shù)據
const formModel = ref({ ...defaultForm })
// 圖片上傳相關邏輯
const imgUrl = ref('')
const onSelectFile = (uploadFile) => {
imgUrl.value = URL.createObjectURL(uploadFile.raw) // 預覽圖片
// 立刻將圖片對象屋彪,存入 formModel.value.cover_img 將來用于提交
formModel.value.cover_img = uploadFile.raw
}
// 提交
const emit = defineEmits(['success'])
const onPublish = async (state) => {
// 將已發(fā)布還是草稿狀態(tài),存入 formModel
formModel.value.state = state
// 注意:當前接口握玛,需要的是 formData 對象
// 將普通對象 => 轉換成 => formData對象
const fd = new FormData()
for (let key in formModel.value) {
fd.append(key, formModel.value[key])
}
// 發(fā)請求
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')
}
}
// 組件對外暴露一個方法 open,基于open傳來的參數(shù)挠铲,區(qū)分添加還是編輯
// open({}) => 表單無需渲染冕屯,說明是添加
// open({ id, ..., ... }) => 表單需要渲染,說明是編輯
// open調用后拂苹,可以打開抽屜
const editorRef = ref()
const open = async (row) => {
visibleDrawer.value = true // 顯示抽屜
if (row.id) {
// 需要基于 row.id 發(fā)送請求安聘,獲取編輯對應的詳情數(shù)據,進行回顯
const res = await artGetDetailService(row.id)
formModel.value = res.data.data
// 圖片需要單獨處理回顯
imgUrl.value = baseURL + formModel.value.cover_img
// 注意:提交給后臺,需要的數(shù)據格式浴韭,是file對象格式
// 需要將網絡圖片地址 => 轉換成 file對象丘喻,存儲起來, 將來便于提交
const file = await imageUrlToFileObject(
imgUrl.value,
formModel.value.cover_img
)
formModel.value.cover_img = file
} else {
formModel.value = { ...defaultForm } // 基于默認的數(shù)據,重置form數(shù)據
// 這里重置了表單的數(shù)據念颈,但是圖片上傳img地址泉粉,富文本編輯器內容 => 需要手動重置
imgUrl.value = ''
editorRef.value.setHTML('')
}
}
// 將網絡圖片地址轉換為 File 對象的函數(shù)
async function imageUrlToFileObject(imageUrl, filename) {
try {
// 使用 Axios 下載圖片數(shù)據
const response = await axios.get(imageUrl, { responseType: 'arraybuffer' })
// 將下載的數(shù)據轉換成 Blob 對象
const blob = new Blob([response.data], {
type: response.headers['content-type']
})
// 創(chuàng)建 File 對象
const file = new File([blob], filename, {
type: response.headers['content-type']
})
return file
} catch (error) {
console.error('Error converting image URL to File object:', error)
return null
}
}
defineExpose({
open
})
</script>
<template>
<el-drawer
v-model="visibleDrawer"
:title="formModel.id ? '編輯文章' : '添加文章'"
direction="rtl"
size="50%"
>
<!-- 發(fā)表文章表單 -->
<el-form :model="formModel" ref="formRef" 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%"
></channel-select>
</el-form-item>
<el-form-item label="文章封面" prop="cover_img">
<!-- 此處需要關閉 element-plus 的自動上傳,不需要配置 action 等參數(shù)
只需要做前端的本地預覽圖片即可榴芳,無需在提交前上傳圖標
語法:URL.createObjectURL(...) 創(chuàng)建本地預覽的地址嗡靡,來預覽
-->
<el-upload
class="avatar-uploader"
:show-file-list="false"
:auto-upload="false"
:on-change="onSelectFile"
>
<img v-if="imgUrl" :src="imgUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item label="文章內容" prop="content">
<div class="editor">
<quill-editor
ref="editorRef"
v-model:content="formModel.content"
content-type="html"
theme="snow"
></quill-editor>
</div>
</el-form-item>
<el-form-item>
<el-button @click="onPublish('已發(fā)布')" type="primary">發(fā)布</el-button>
<el-button @click="onPublish('草稿')" type="info">草稿</el-button>
</el-form-item>
</el-form>
</el-drawer>
</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;
}
}
}
.editor {
width: 100%;
:deep(.ql-editor) {
min-height: 200px;
}
}
</style>
views/article/components/channelEdit.vue
<script setup>
import { ref } from 'vue'
import { artEditChannelService, artAddChannelService } from '@/api/article.js'
const dialogVisible = ref(false)
const formRef = ref()
const formModel = ref({
cate_name: '',
cate_alias: ''
})
const rules = {
cate_name: [
{ required: true, message: '請輸入分類名稱', trigger: 'blur' },
{
pattern: /^\S{1,10}$/,
message: '分類名必須是 1-10 位的非空字符',
trigger: 'blur'
}
],
cate_alias: [
{ required: true, message: '請輸入分類別名', trigger: 'blur' },
{
pattern: /^[a-zA-Z0-9]{1,15}$/,
message: '分類名必須是 1-15 位的字母或數(shù)字',
trigger: 'blur'
}
]
}
const emit = defineEmits(['success'])
const onSubmit = async () => {
await formRef.value.validate()
const isEdit = formModel.value.id
if (isEdit) {
await artEditChannelService(formModel.value)
ElMessage.success('編輯成功')
} else {
await artAddChannelService(formModel.value)
ElMessage.success('添加成功')
}
dialogVisible.value = false
emit('success')
}
// 組件對外暴露一個方法 open,基于open傳來的參數(shù)窟感,區(qū)分添加還是編輯
// open({}) => 表單無需渲染叽躯,說明是添加
// open({ id, cate_name, ... }) => 表單需要渲染,說明是編輯
// open調用后肌括,可以打開彈窗
const open = (row) => {
dialogVisible.value = true
formModel.value = { ...row } // 添加 → 重置了表單內容点骑,編輯 → 存儲了需要回顯的數(shù)據
}
// 向外暴露方法
defineExpose({
open
})
</script>
<template>
<el-dialog
v-model="dialogVisible"
:title="formModel.id ? '編輯分類' : '添加分類'"
width="30%"
>
<el-form
ref="formRef"
:model="formModel"
:rules="rules"
label-width="100px"
style="padding-right: 30px"
>
<el-form-item label="分類名稱" prop="cate_name">
<el-input
v-model="formModel.cate_name"
placeholder="請輸入分類名稱"
></el-input>
</el-form-item>
<el-form-item label="分類別名" prop="cate_alias">
<el-input
v-model="formModel.cate_alias"
placeholder="請輸入分類別名"
></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="onSubmit"> 確認 </el-button>
</span>
</template>
</el-dialog>
</template>
views/article/components/ChannelSelect.vue
<script setup>
import { artGetChannelsService } from '@/api/article.js'
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>
<!-- label 展示給用戶看的,value 收集起來提交給后臺的 -->
<el-select
:modelValue="modelValue"
@update:modelValue="emit('update:modelValue', $event)"
:style="{ width }"
>
<el-option
v-for="channel in channelList"
:key="channel.id"
:label="channel.cate_name"
:value="channel.id"
></el-option>
</el-select>
</template>
views/article/ArticleChannel.vue
<script setup>
import { ref } from 'vue'
import { Edit, Delete } from '@element-plus/icons-vue'
import { artGetChannelsService, artDelChannelService } from '../../api/article'
import ChannelEdit from './components/ChannelEdit.vue'
const channelList = ref([])
const loading = ref(false)
const dialog = ref()
const getChannelList = async () => {
loading.value = true
const res = await artGetChannelsService()
channelList.value = res.data.data
loading.value = false
}
getChannelList()
const onDelChannel = async (row) => {
await ElMessageBox.confirm('你確認要刪除該分類么', '溫馨提示', {
type: 'warning',
confirmButtonText: '確認',
cancelButtonText: '取消'
})
await artDelChannelService(row.id)
ElMessage.success('刪除成功')
getChannelList()
}
const onEditChannel = (row) => {
dialog.value.open(row)
}
const onAddChannel = () => {
dialog.value.open({})
}
const onSuccess = () => {
getChannelList()
}
</script>
<template>
<page-container title="文章分類">
<template #extra>
<el-button @click="onAddChannel">添加分類</el-button>
</template>
<el-table v-loading="loading" :data="channelList" style="width: 100%">
<el-table-column type="index" label="序號" width="100"></el-table-column>
<el-table-column prop="cate_name" label="分類名稱"></el-table-column>
<el-table-column prop="cate_alias" label="分類別名"></el-table-column>
<el-table-column label="操作" width="150">
<!-- row 就是 channelList 的一項谍夭, $index 下標 -->
<template #default="{ row, $index }">
<el-button
:icon="Edit"
circle
plain
type="primary"
@click="onEditChannel(row, $index)"
></el-button>
<el-button
:icon="Delete"
circle
plain
type="danger"
@click="onDelChannel(row, $index)"
></el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="沒有數(shù)據"></el-empty>
</template>
</el-table>
<channel-edit ref="dialog" @success="onSuccess"></channel-edit>
</page-container>
</template>
<style lang="scss" scoped></style>
views/article/ArticleManage.vue
<script setup>
import { ref } from 'vue'
import { Delete, Edit } from '@element-plus/icons-vue'
import ChannelSelect from './components/ChannelSelect.vue'
import ArticleEdit from './components/ArticleEdit.vue'
import { artGetListService, artDelService } from '@/api/article.js'
import { formatTime } from '@/utils/format.js'
const articleList = ref([]) // 文章列表
const total = ref(0) // 總條數(shù)
const loading = ref(false) // loading狀態(tài)
// 定義請求參數(shù)對象
const params = ref({
pagenum: 1, // 當前頁
pagesize: 5, // 當前生效的每頁條數(shù)
cate_id: '',
state: ''
})
// 基于params參數(shù)黑滴,獲取文章列表
const getArticleList = async () => {
loading.value = true
const res = await artGetListService(params.value)
articleList.value = res.data.data
total.value = res.data.total
loading.value = false
}
getArticleList()
// 處理分頁邏輯
const onSizeChange = (size) => {
// console.log('當前每頁條數(shù)', size)
// 只要是每頁條數(shù)變化了,那么原本正在訪問的當前頁意義不大了紧索,數(shù)據大概率已經不在原來那一頁了
// 重新從第一頁渲染即可
params.value.pagenum = 1
params.value.pagesize = size
// 基于最新的當前頁 和 每頁條數(shù)袁辈,渲染數(shù)據
getArticleList()
}
const onCurrentChange = (page) => {
// 更新當前頁
params.value.pagenum = page
// 基于最新的當前頁,渲染數(shù)據
getArticleList()
}
// 搜索邏輯 => 按照最新的條件珠漂,重新檢索晚缩,從第一頁開始展示
const onSearch = () => {
params.value.pagenum = 1 // 重置頁面
getArticleList()
}
// 重置邏輯 => 將篩選條件清空,重新檢索媳危,從第一頁開始展示
const onReset = () => {
params.value.pagenum = 1 // 重置頁面
params.value.cate_id = ''
params.value.state = ''
getArticleList()
}
const articleEditRef = ref()
// 添加邏輯
const onAddArticle = () => {
articleEditRef.value.open({})
}
// 編輯邏輯
const onEditArticle = (row) => {
articleEditRef.value.open(row)
}
// 刪除邏輯
const onDeleteArticle = async (row) => {
// 提示用戶是否要刪除
await ElMessageBox.confirm('此操作將永久刪除該文件, 是否繼續(xù)?', '提示', {
confirmButtonText: '確定',
cancelButtonText: '取消',
type: 'warning'
})
await artDelService(row.id)
ElMessage.success('刪除成功')
// 重新渲染列表
getArticleList()
}
// 添加或者編輯 成功的回調
const onSuccess = (type) => {
if (type === 'add') {
// 如果是添加荞彼,最好渲染最后一頁
const lastPage = Math.ceil((total.value + 1) / params.value.pagesize)
// 更新成最大頁碼數(shù),再渲染
params.value.pagenum = lastPage
}
getArticleList()
}
</script>
<template>
<page-container title="文章管理">
<template #extra>
<el-button type="primary" @click="onAddArticle">添加文章</el-button>
</template>
<!-- 表單區(qū)域 -->
<el-form inline>
<el-form-item label="文章分類:">
<!-- Vue2 => v-model :value 和 @input 的簡寫 -->
<!-- Vue3 => v-model :modelValue 和 @update:modelValue 的簡寫 -->
<channel-select v-model="params.cate_id"></channel-select>
<!-- Vue3 => v-model:cid :cid 和 @update:cid 的簡寫 -->
<!-- <channel-select v-model:cid="params.cate_id"></channel-select> -->
</el-form-item>
<el-form-item label="發(fā)布狀態(tài):">
<!-- 這里后臺標記發(fā)布狀態(tài)待笑,就是通過中文標記的鸣皂,已發(fā)布 / 草稿 -->
<el-select v-model="params.state">
<el-option label="已發(fā)布" value="已發(fā)布"></el-option>
<el-option label="草稿" value="草稿"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="onSearch" type="primary">搜索</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 表格區(qū)域 -->
<el-table :data="articleList" v-loading="loading">
<el-table-column label="文章標題" prop="title">
<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="發(fā)表時間" prop="pub_date">
<template #default="{ row }">
{{ formatTime(row.pub_date) }}
</template>
</el-table-column>
<el-table-column label="狀態(tài)" prop="state"></el-table-column>
<!-- 利用作用域插槽 row 可以獲取當前行的數(shù)據 => v-for 遍歷 item -->
<el-table-column label="操作">
<template #default="{ row }">
<el-button
circle
plain
type="primary"
:icon="Edit"
@click="onEditArticle(row)"
></el-button>
<el-button
circle
plain
type="danger"
:icon="Delete"
@click="onDeleteArticle(row)"
></el-button>
</template>
</el-table-column>
</el-table>
<!-- 分頁區(qū)域 -->
<el-pagination
v-model:current-page="params.pagenum"
v-model:page-size="params.pagesize"
:page-sizes="[2, 3, 5, 10]"
:background="true"
layout="jumper, total, sizes, prev, pager, next"
:total="total"
@size-change="onSizeChange"
@current-change="onCurrentChange"
style="margin-top: 20px; justify-content: flex-end"
/>
<!-- 添加編輯的抽屜 -->
<article-edit ref="articleEditRef" @success="onSuccess"></article-edit>
</page-container>
</template>
<style lang="scss" scoped></style>
views/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'
import { useUserStore } from '@/stores'
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
onMounted(() => {
userStore.getUser()
})
const handleCommand = async (key) => {
if (key === 'logout') {
// 退出操作
await ElMessageBox.confirm('你確認要進行退出么', '溫馨提示', {
type: 'warning',
confirmButtonText: '確認',
cancelButtonText: '取消'
})
// 清除本地的數(shù)據 (token + user信息)
userStore.removeToken()
userStore.setUser({})
router.push('/login')
} else {
// 跳轉操作
router.push(`/user/${key}`)
}
}
</script>
<template>
<!--
el-menu 整個菜單組件
:default-active="$route.path" 配置默認高亮的菜單項
router router選項開啟,el-menu-item 的 index 就是點擊跳轉的路徑
el-menu-item 菜單項
index="/article/channel" 配置的是訪問的跳轉路徑暮蹂,配合default-active的值寞缝,實現(xiàn)高亮
-->
<el-container class="layout-container">
<el-aside width="200px">
<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">
<!-- 多級菜單的標題 - 具名插槽 title -->
<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>{{
userStore.user.nickname || userStore.user.username
}}</strong>
</div>
<el-dropdown placement="bottom-end" @command="handleCommand">
<!-- 展示給用戶,默認看到的 -->
<span class="el-dropdown__box">
<el-avatar :src="userStore.user.user_pic || 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 {
background-color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
.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>
views/logon/LoginPage.vue
<script setup>
import { userRegisterService, userLoginService } from '@/api/user.js'
import { User, Lock } from '@element-plus/icons-vue'
import { ref, watch } from 'vue'
import { useUserStore } from '@/stores'
import { useRouter } from 'vue-router'
const isRegister = ref(false)
const form = ref()
// 整個的用于提交的form數(shù)據對象
const formModel = ref({
username: '',
password: '',
repassword: ''
})
// 整個表單的校驗規(guī)則
// 1. 非空校驗 required: true message消息提示仰泻, trigger觸發(fā)校驗的時機 blur change
// 2. 長度校驗 min:xx, max: xx
// 3. 正則校驗 pattern: 正則規(guī)則 \S 非空字符
// 4. 自定義校驗 => 自己寫邏輯校驗 (校驗函數(shù))
// validator: (rule, value, callback)
// (1) rule 當前校驗規(guī)則相關的信息
// (2) value 所校驗的表單元素目前的表單值
// (3) callback 無論成功還是失敗荆陆,都需要 callback 回調
// - callback() 校驗成功
// - callback(new Error(錯誤信息)) 校驗失敗
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) => {
// 判斷 value 和 當前 form 中收集的 password 是否一致
if (value !== formModel.value.password) {
callback(new Error('兩次輸入密碼不一致'))
} else {
callback() // 就算校驗成功,也需要callback
}
},
trigger: 'blur'
}
]
}
const register = async () => {
// 注冊成功之前集侯,先進行校驗被啼,校驗成功 → 請求帜消,校驗失敗 → 自動提示
await form.value.validate()
await userRegisterService(formModel.value)
ElMessage.success('注冊成功')
isRegister.value = false
}
const userStore = useUserStore()
const router = useRouter()
const login = async () => {
await form.value.validate()
const res = await userLoginService(formModel.value)
userStore.setToken(res.data.token)
ElMessage.success('登錄成功')
router.push('/')
}
// 切換的時候,重置表單內容
watch(isRegister, () => {
formModel.value = {
username: '',
password: '',
repassword: ''
}
})
</script>
<template>
<!--
1. 結構相關
el-row表示一行趟据,一行分成24份
el-col表示列
(1) :span="12" 代表在一行中券犁,占12份 (50%)
(2) :span="6" 表示在一行中术健,占6份 (25%)
(3) :offset="3" 代表在一行中汹碱,左側margin份數(shù)
el-form 整個表單組件
el-form-item 表單的一行 (一個表單域)
el-input 表單元素(輸入框)
2. 校驗相關
(1) el-form => :model="ruleForm" 綁定的整個form的數(shù)據對象 { xxx, xxx, xxx }
(2) el-form => :rules="rules" 綁定的整個rules規(guī)則對象 { xxx, xxx, xxx }
(3) 表單元素 => v-model="ruleForm.xxx" 給表單元素,綁定form的子屬性
(4) el-form-item => prop配置生效的是哪個校驗規(guī)則 (和rules中的字段要對應)
-->
<el-row class="login-page">
<el-col :span="12" class="bg"></el-col>
<el-col :span="6" :offset="3" class="form">
<!-- 注冊相關表單 -->
<el-form
:model="formModel"
:rules="rules"
ref="form"
size="large"
autocomplete="off"
v-if="isRegister"
>
<el-form-item>
<h1>注冊</h1>
</el-form-item>
<el-form-item prop="username">
<el-input
v-model="formModel.username"
:prefix-icon="User"
placeholder="請輸入用戶名"
></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="formModel.password"
:prefix-icon="Lock"
type="password"
placeholder="請輸入密碼"
></el-input>
</el-form-item>
<el-form-item prop="repassword">
<el-input
v-model="formModel.repassword"
:prefix-icon="Lock"
type="password"
placeholder="請輸入再次密碼"
></el-input>
</el-form-item>
<el-form-item>
<el-button
@click="register"
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
:model="formModel"
:rules="rules"
ref="form"
size="large"
autocomplete="off"
v-else
>
<el-form-item>
<h1>登錄</h1>
</el-form-item>
<el-form-item prop="username">
<el-input
v-model="formModel.username"
:prefix-icon="User"
placeholder="請輸入用戶名"
></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="formModel.password"
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
@click="login"
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>
views/user/UserAvatar.vue
<script setup>
import { ref } from 'vue'
import { Plus, Upload } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores'
import { userUpdateAvatarService } from '@/api/user'
const userStore = useUserStore()
const imgUrl = ref(userStore.user.user_pic)
const uploadRef = ref()
const onSelectFile = (uploadFile) => {
// 基于 FileReader 讀取圖片做預覽
const reader = new FileReader()
reader.readAsDataURL(uploadFile.raw)
reader.onload = () => {
imgUrl.value = reader.result
}
}
const onUpdateAvatar = async () => {
// 發(fā)送請求更新頭像
await userUpdateAvatarService(imgUrl.value)
// userStore 重新渲染
await userStore.getUser()
// 提示用戶
ElMessage.success('頭像更新成功')
}
</script>
<template>
<page-container title="更換頭像">
<el-upload
ref="uploadRef"
:auto-upload="false"
class="avatar-uploader"
:show-file-list="false"
:on-change="onSelectFile"
>
<img v-if="imgUrl" :src="imgUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
<br />
<el-button
@click="uploadRef.$el.querySelector('input').click()"
type="primary"
:icon="Plus"
size="large"
>選擇圖片</el-button
>
<el-button
@click="onUpdateAvatar"
type="success"
:icon="Upload"
size="large"
>上傳頭像</el-button
>
</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>
views/user/UserPassword.vue
<script setup>
import { ref } from 'vue'
import { userUpdatePasswordService } 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 checkDifferent = (rule, value, callback) => {
// 校驗新密碼和原密碼不能一樣
if (value === pwdForm.value.old_pwd) {
callback(new Error('新密碼不能與原密碼一樣'))
} else {
callback()
}
}
const checkSameAsNewPwd = (rule, value, callback) => {
// 校驗確認密碼必須和新密碼一樣
if (value !== pwdForm.value.new_pwd) {
callback(new Error('確認密碼必須和新密碼一樣'))
} else {
callback()
}
}
const rules = ref({
old_pwd: [
{ required: true, message: '請輸入原密碼', trigger: 'blur' },
{ min: 6, max: 15, message: '原密碼長度在6-15位之間', trigger: 'blur' }
],
new_pwd: [
{ required: true, message: '請輸入新密碼', trigger: 'blur' },
{ min: 6, max: 15, message: '新密碼長度在6-15位之間', trigger: 'blur' },
{ validator: checkDifferent, trigger: 'blur' }
],
re_pwd: [
{ required: true, message: '請再次輸入新密碼', trigger: 'blur' },
{ min: 6, max: 15, message: '確認密碼長度在6-15位之間', trigger: 'blur' },
{ validator: checkSameAsNewPwd, trigger: 'blur' }
]
})
const userStore = useUserStore()
const router = useRouter()
const submitForm = async () => {
await formRef.value.validate()
await userUpdatePasswordService(pwdForm.value)
ElMessage.success('密碼修改成功')
// 密碼修改成功后咳促,退出重新登錄
// 清空本地存儲的 token 和 個人信息
userStore.setToken('')
userStore.setUser({})
// 攔截登錄
router.push('/login')
}
const resetForm = () => {
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"
>
<el-form-item label="原密碼" prop="old_pwd">
<el-input v-model="pwdForm.old_pwd" show-password></el-input>
</el-form-item>
<el-form-item label="新密碼" prop="new_pwd">
<el-input v-model="pwdForm.new_pwd" show-password></el-input>
</el-form-item>
<el-form-item label="確認密碼" prop="re_pwd">
<el-input v-model="pwdForm.re_pwd" show-password></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm">修改密碼</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form></el-col
>
</el-row>
</page-container>
</template>
views/user/UserProfile.vue
<script setup>
import PageContainer from '@/components/PageContainer.vue'
import { ref } from 'vue'
import { useUserStore } from '@/stores'
import { userUpdateInfoService } from '@/api/user'
const formRef = ref()
// 是在使用倉庫中數(shù)據的初始值 (無需響應式) 解構無問題
const {
user: { email, id, nickname, username },
getUser
} = useUserStore()
const form = ref({
id,
username,
nickname,
email
})
const rules = ref({
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', 'change']
}
]
})
const submitForm = async () => {
// 等待校驗結果
await formRef.value.validate()
// 提交修改
await userUpdateInfoService(form.value)
// 通知 user 模塊,進行數(shù)據的更新
getUser()
// 提示用戶
ElMessage.success('修改成功')
}
</script>
<template>
<page-container title="基本資料">
<!-- 表單部分 -->
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="登錄名稱">
<el-input v-model="form.username" disabled></el-input>
</el-form-item>
<el-form-item label="用戶昵稱" prop="nickname">
<el-input v-model="form.nickname"></el-input>
</el-form-item>
<el-form-item label="用戶郵箱" prop="email">
<el-input v-model="form.email"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm">提交修改</el-button>
</el-form-item>
</el-form>
</page-container>
</template>
App.vue
<script setup>
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>
<template>
<div>
<!-- App.vue只需要留一個路由出口 router-view即可 -->
<el-config-provider :locale="zhCn">
<router-view></router-view>
</el-config-provider>
</div>
</template>
<style scoped></style>
main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import pinia from '@/stores/index'
import '@/assets/main.scss'
const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')
.eslintrc.cjs
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {
ecmaVersion: 'latest'
},
rules: {
// prettier專注于代碼的美觀度 (格式化工具)
// 前置:
// 1. 禁用格式化插件 prettier format on save 關閉
// 2. 安裝Eslint插件, 并配置保存時自動修復
'prettier/prettier': [
'warn',
{
singleQuote: true, // 單引號
semi: false, // 無分號
printWidth: 80, // 每行寬度至多80字符
trailingComma: 'none', // 不加對象|數(shù)組最后逗號
endOfLine: 'auto' // 換行符號不限制(win mac 不一致)
}
],
// ESLint關注于規(guī)范, 如果不符合規(guī)范勘伺,報錯
'vue/multi-word-component-names': [
'warn',
{
ignores: ['index'] // vue組件名稱多單詞組成(忽略index.vue)
}
],
'vue/no-setup-props-destructure': ['off'], // 關閉 props 解構的校驗 (props解構丟失響應式)
// 添加未定義變量錯誤提示跪腹,create-vue@3.6.3 關閉,這里加上是為了支持下一個章節(jié)演示飞醉。
'no-undef': 'error'
},
globals: {
ElMessage: 'readonly',
ElMessageBox: 'readonly',
ElLoading: 'readonly'
}
}
vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
],
base: '/',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})