初版代码提交

This commit is contained in:
冯聪聪
2026-05-17 15:55:06 +08:00
parent 208212d84c
commit c50669ee36
70 changed files with 3647 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style>
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
</style>
+13
View File
@@ -0,0 +1,13 @@
import request from '@/utils/request'
export function getUserList(params) {
return request.get('/api/admin/user/list', { params })
}
export function resetUserPassword(id, newPassword) {
return request.put(`/api/admin/user/${id}/reset-password`, null, { params: { newPassword } })
}
export function updateUserStatus(id, status) {
return request.put(`/api/admin/user/${id}/status`, null, { params: { status } })
}
+69
View File
@@ -0,0 +1,69 @@
import request from '@/utils/request'
export function getPublicArticles(params) {
return request.get('/api/public/article/list', { params })
}
export function getArticleDetail(id) {
return request.get(`/api/public/article/${id}`)
}
export function createArticle(data) {
return request.post('/api/app/article', data)
}
export function updateArticle(id, data) {
return request.put(`/api/app/article/${id}`, data)
}
export function deleteArticle(id) {
return request.delete(`/api/app/article/${id}`)
}
export function getMyArticles(params) {
return request.get('/api/app/article/mine', { params })
}
export function moveToRecycleBin(id) {
return request.put(`/api/app/article/${id}/recycle`)
}
export function restoreArticle(id) {
return request.put(`/api/app/article/${id}/restore`)
}
export function toggleArticleLike(id) {
return request.post(`/api/app/article/${id}/like`)
}
export function getCategories() {
return request.get('/api/public/category/list')
}
export function getTags() {
return request.get('/api/public/tag/list')
}
export function createCategory(data) {
return request.post('/api/admin/category', data)
}
export function updateCategory(id, data) {
return request.put(`/api/admin/category/${id}`, data)
}
export function deleteCategory(id) {
return request.delete(`/api/admin/category/${id}`)
}
export function createTag(name) {
return request.post('/api/admin/tag', null, { params: { name } })
}
export function getAdminArticles(params) {
return request.get('/api/admin/article/list', { params })
}
export function adminDeleteArticle(id) {
return request.delete(`/api/admin/article/${id}`)
}
+41
View File
@@ -0,0 +1,41 @@
import request from '@/utils/request'
export function adminLogin(data) {
return request.post('/api/public/admin/login', data)
}
export function userLogin(data) {
return request.post('/api/public/user/login', data)
}
export function register(data) {
return request.post('/api/public/user/register', data)
}
export function getCaptcha() {
return request.get('/api/public/captcha')
}
export function refreshToken(token) {
return request.post('/api/public/refresh-token', null, { params: { refreshToken: token } })
}
export function logout() {
return request.post('/api/app/user/logout')
}
export function getCurrentUser() {
return request.get('/api/app/user/me')
}
export function getUserInfo(id) {
return request.get(`/api/app/user/${id}`)
}
export function followUser(id) {
return request.post(`/api/app/user/${id}/follow`)
}
export function unfollowUser(id) {
return request.delete(`/api/app/user/${id}/follow`)
}
+29
View File
@@ -0,0 +1,29 @@
import request from '@/utils/request'
export function getArticleComments(articleId) {
return request.get(`/api/app/comment/article/${articleId}`)
}
export function createComment(data) {
return request.post('/api/app/comment', data)
}
export function deleteComment(id) {
return request.delete(`/api/app/comment/${id}`)
}
export function toggleCommentLike(id) {
return request.post(`/api/app/comment/${id}/like`)
}
export function getAdminComments(params) {
return request.get('/api/admin/comment/list', { params })
}
export function auditComment(id, status) {
return request.put(`/api/admin/comment/${id}/audit`, null, { params: { status } })
}
export function adminDeleteComment(id) {
return request.delete(`/api/admin/comment/${id}`)
}
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="admin-layout">
<el-container>
<el-aside width="220px">
<div class="admin-logo">管理后台</div>
<el-menu :default-active="$route.path" router background-color="#304156" text-color="#bfcbd9" active-text-color="#409eff">
<el-menu-item index="/admin">
<el-icon><DataAnalysis /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/admin/users">
<el-icon><User /></el-icon>
<span>用户管理</span>
</el-menu-item>
<el-menu-item index="/admin/articles">
<el-icon><Document /></el-icon>
<span>文章管理</span>
</el-menu-item>
<el-menu-item index="/admin/comments">
<el-icon><ChatDotSquare /></el-icon>
<span>评论管理</span>
</el-menu-item>
<el-menu-item index="/admin/categories">
<el-icon><Menu /></el-icon>
<span>分类管理</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="admin-header">
<el-button @click="$router.push('/')">返回前台</el-button>
<el-button @click="handleLogout">退出</el-button>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</div>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
async function handleLogout() {
await userStore.logout()
router.push('/login')
}
</script>
<style scoped>
.admin-layout {
min-height: 100vh;
}
.el-aside {
background: #304156;
}
.admin-logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 18px;
font-weight: bold;
}
.admin-header {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
}
.el-main {
background: #f5f7fa;
}
</style>
+41
View File
@@ -0,0 +1,41 @@
<template>
<div class="app-layout">
<el-container>
<el-aside width="200px">
<el-menu :default-active="$route.path" router>
<el-menu-item index="/app/dashboard">
<el-icon><DataAnalysis /></el-icon>
<span>控制台</span>
</el-menu-item>
<el-menu-item index="/app/articles">
<el-icon><Document /></el-icon>
<span>我的文章</span>
</el-menu-item>
<el-menu-item index="/app/editor">
<el-icon><EditPen /></el-icon>
<span>写文章</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view />
</el-main>
</el-container>
</div>
</template>
<script setup>
</script>
<style scoped>
.app-layout {
min-height: 100vh;
}
.el-aside {
background: #fff;
border-right: 1px solid #e4e7ed;
}
.el-main {
background: #f5f7fa;
}
</style>
+106
View File
@@ -0,0 +1,106 @@
<template>
<div class="jog-layout">
<el-header class="header">
<div class="header-content">
<router-link to="/" class="logo">Jog</router-link>
<el-menu mode="horizontal" :ellipsis="false" class="nav-menu">
<el-menu-item index="home">
<router-link to="/">首页</router-link>
</el-menu-item>
</el-menu>
<div class="header-right">
<template v-if="userStore.isLoggedIn">
<el-dropdown>
<span class="user-info">
<el-avatar :size="28" :src="userStore.userInfo?.avatar" />
{{ userStore.userInfo?.nickname || '用户' }}
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="$router.push('/app/dashboard')">控制台</el-dropdown-item>
<el-dropdown-item v-if="userStore.isAdmin" @click="$router.push('/admin')">管理后台</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<template v-else>
<el-button type="primary" @click="$router.push('/login')">登录</el-button>
<el-button @click="$router.push('/register')">注册</el-button>
</template>
</div>
</div>
</el-header>
<el-main class="main">
<router-view />
</el-main>
<el-footer class="footer">Jog &copy; 2026</el-footer>
</div>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
async function handleLogout() {
await userStore.logout()
router.push('/')
}
</script>
<style scoped>
.jog-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
border-bottom: 1px solid #e4e7ed;
background: #fff;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
height: 60px;
padding: 0 20px;
}
.logo {
font-size: 20px;
font-weight: bold;
color: #409eff;
text-decoration: none;
margin-right: 40px;
}
.nav-menu {
flex: 1;
border-bottom: none;
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.main {
flex: 1;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 20px;
}
.footer {
text-align: center;
color: #909399;
border-top: 1px solid #e4e7ed;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
+119
View File
@@ -0,0 +1,119 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录' },
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/Register.vue'),
meta: { title: '注册' },
},
{
path: '/',
component: () => import('@/layouts/BlogLayout.vue'),
children: [
{
path: '',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { title: '首页' },
},
{
path: 'article/:id',
name: 'ArticleDetail',
component: () => import('@/views/ArticleDetail.vue'),
meta: { title: '文章详情' },
},
],
},
{
path: '/app',
component: () => import('@/layouts/AppLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/app/Dashboard.vue'),
meta: { title: '控制台', requiresAuth: true },
},
{
path: 'articles',
name: 'MyArticles',
component: () => import('@/views/app/MyArticles.vue'),
meta: { title: '我的文章', requiresAuth: true },
},
{
path: 'editor',
name: 'NewArticle',
component: () => import('@/views/app/ArticleEditor.vue'),
meta: { title: '写文章', requiresAuth: true },
},
{
path: 'editor/:id',
name: 'EditArticle',
component: () => import('@/views/app/ArticleEditor.vue'),
meta: { title: '编辑文章', requiresAuth: true },
},
],
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: '',
name: 'AdminDashboard',
component: () => import('@/views/admin/Dashboard.vue'),
meta: { title: '管理后台', requiresAuth: true, requiresAdmin: true },
},
{
path: 'users',
name: 'AdminUsers',
component: () => import('@/views/admin/Users.vue'),
meta: { title: '用户管理', requiresAuth: true, requiresAdmin: true },
},
{
path: 'articles',
name: 'AdminArticles',
component: () => import('@/views/admin/Articles.vue'),
meta: { title: '文章管理', requiresAuth: true, requiresAdmin: true },
},
{
path: 'comments',
name: 'AdminComments',
component: () => import('@/views/admin/Comments.vue'),
meta: { title: '评论管理', requiresAuth: true, requiresAdmin: true },
},
{
path: 'categories',
name: 'AdminCategories',
component: () => import('@/views/admin/Categories.vue'),
meta: { title: '分类管理', requiresAuth: true, requiresAdmin: true },
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
document.title = to.meta.title ? `${to.meta.title} - 博客系统` : '博客系统'
const token = localStorage.getItem('access_token')
if (to.meta.requiresAuth && !token) {
next({ name: 'Login', query: { redirect: to.fullPath } })
} else {
next()
}
})
export default router
+80
View File
@@ -0,0 +1,80 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { adminLogin, userLogin, register, getCurrentUser, logout as apiLogout } from '@/api/auth'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('access_token') || '')
const refreshToken = ref(localStorage.getItem('refresh_token') || '')
const userInfo = ref(JSON.parse(localStorage.getItem('user_info') || 'null'))
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => userInfo.value?.role === 'ADMIN')
async function loginAdmin(data) {
const res = await adminLogin(data)
const loginData = res.data
setTokens(loginData.accessToken, loginData.refreshToken)
userInfo.value = { ...loginData.userInfo, role: 'ADMIN' }
localStorage.setItem('user_info', JSON.stringify(userInfo.value))
return loginData
}
async function loginUser(data) {
const res = await userLogin(data)
const loginData = res.data
setTokens(loginData.accessToken, loginData.refreshToken)
userInfo.value = { ...loginData.userInfo, role: 'USER' }
localStorage.setItem('user_info', JSON.stringify(userInfo.value))
return loginData
}
async function registerUser(data) {
return await register(data)
}
async function fetchUserInfo() {
const res = await getCurrentUser()
const role = isAdmin.value ? 'ADMIN' : 'USER'
userInfo.value = { ...res.data, role }
localStorage.setItem('user_info', JSON.stringify(userInfo.value))
}
async function logout() {
try {
await apiLogout()
} catch {
// ignore
}
clearAuth()
}
function setTokens(accessToken, refresh) {
token.value = accessToken
refreshToken.value = refresh
localStorage.setItem('access_token', accessToken)
localStorage.setItem('refresh_token', refresh)
}
function clearAuth() {
token.value = ''
refreshToken.value = ''
userInfo.value = null
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user_info')
}
return {
token,
refreshToken,
userInfo,
isLoggedIn,
isAdmin,
loginAdmin,
loginUser,
registerUser,
fetchUserInfo,
logout,
clearAuth,
}
})
+74
View File
@@ -0,0 +1,74 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const request = axios.create({
baseURL: '',
timeout: 15000,
})
request.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
request.interceptors.response.use(
(response) => {
const res = response.data
if (res.code !== 200) {
ElMessage.error(res.message || '请求失败')
if (res.code === 401) {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user_info')
router.push({ name: 'Login' })
}
return Promise.reject(new Error(res.message))
}
return res
},
async (error) => {
if (error.response) {
const status = error.response.status
if (status === 401) {
const refreshToken = localStorage.getItem('refresh_token')
if (refreshToken) {
try {
const res = await axios.post('/api/public/refresh-token', null, {
params: { refreshToken },
})
if (res.data.code === 200) {
localStorage.setItem('access_token', res.data.data.accessToken)
localStorage.setItem('refresh_token', res.data.data.refreshToken)
error.config.headers.Authorization = `Bearer ${res.data.data.accessToken}`
return request(error.config)
}
} catch {
// refresh failed
}
}
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user_info')
router.push({ name: 'Login' })
} else if (status === 403) {
ElMessage.error('无权限访问')
} else if (status === 429) {
ElMessage.error('请求过于频繁,请稍后再试')
} else {
ElMessage.error(error.response.data?.message || '服务器错误')
}
} else {
ElMessage.error('网络连接失败')
}
return Promise.reject(error)
}
)
export default request
+170
View File
@@ -0,0 +1,170 @@
<template>
<div class="article-detail" v-loading="loading">
<template v-if="article">
<h1>{{ article.title }}</h1>
<div class="meta">
<span><el-icon><User /></el-icon> {{ article.authorNickname }}</span>
<span><el-icon><View /></el-icon> {{ article.viewCount }}</span>
<span><el-icon><ChatDotSquare /></el-icon> {{ article.commentCount }}</span>
<span><el-icon><Timer /></el-icon> {{ formatDate(article.publishedAt) }}</span>
<el-tag v-if="article.categoryName" size="small">{{ article.categoryName }}</el-tag>
</div>
<div class="tags">
<el-tag v-for="tag in article.tags" :key="tag.id" type="info" size="small">{{ tag.name }}</el-tag>
</div>
<el-divider />
<div class="content" v-html="article.content"></div>
<el-divider />
<div class="actions">
<el-button :type="article.liked ? 'danger' : 'default'" @click="handleLike">
<el-icon><Star /></el-icon> {{ article.liked ? '已点赞' : '点赞' }} ({{ article.likeCount }})
</el-button>
</div>
<div class="comments-section">
<h3>评论 ({{ article.commentCount }})</h3>
<div v-if="userStore.isLoggedIn" class="comment-form">
<el-input v-model="newComment" type="textarea" :rows="3" placeholder="写下你的评论..." />
<el-button type="primary" @click="submitComment" :loading="submitting" style="margin-top: 8px">发表评论</el-button>
</div>
<div v-else class="login-tip">
<router-link to="/login">登录</router-link> 后可以评论
</div>
<div class="comment-list">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-header">
<el-avatar :size="32" :src="comment.avatar" />
<span class="nickname">{{ comment.nickname }}</span>
<span class="time">{{ formatDate(comment.createdAt) }}</span>
</div>
<div class="comment-body">{{ comment.content }}</div>
<div class="comment-actions">
<el-button text size="small" @click="handleReply(comment)">
<el-icon><ChatDotSquare /></el-icon> 回复
</el-button>
<el-button text size="small" @click="toggleCommentLike(comment)">
<el-icon><Star /></el-icon> {{ comment.likeCount }}
</el-button>
</div>
<div v-if="comment.children && comment.children.length" class="replies">
<div v-for="reply in comment.children" :key="reply.id" class="reply-item">
<span class="nickname">{{ reply.nickname }}</span>
<span v-if="reply.replyToNickname" class="reply-to">回复 {{ reply.replyToNickname }}</span>
{{ reply.content }}
<span class="time">{{ formatDate(reply.createdAt) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getArticleDetail, toggleArticleLike } from '@/api/article'
import { getArticleComments, createComment, toggleCommentLike } from '@/api/comment'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
const route = useRoute()
const userStore = useUserStore()
const article = ref(null)
const comments = ref([])
const loading = ref(true)
const newComment = ref('')
const submitting = ref(false)
const replyTo = ref(null)
async function loadArticle() {
loading.value = true
const res = await getArticleDetail(route.params.id)
article.value = res.data
loading.value = false
}
async function loadComments() {
const res = await getArticleComments(route.params.id)
comments.value = res.data
}
async function handleLike() {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
return
}
const res = await toggleArticleLike(article.value.id)
article.value.liked = res.data
article.value.likeCount += res.data ? 1 : -1
}
function handleReply(comment) {
replyTo.value = comment
newComment.value = `@${comment.nickname} `
}
async function submitComment() {
if (!newComment.value.trim()) return
submitting.value = true
try {
const data = {
articleId: article.value.id,
content: newComment.value,
}
if (replyTo.value) {
data.parentId = replyTo.value.id
data.replyToUserId = replyTo.value.userId
}
await createComment(data)
ElMessage.success('评论成功')
newComment.value = ''
replyTo.value = null
loadComments()
} finally {
submitting.value = false
}
}
async function handleCommentLike(comment) {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
return
}
const res = await toggleCommentLike(comment.id)
comment.liked = res.data
comment.likeCount += res.data ? 1 : -1
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString('zh-CN')
}
onMounted(() => {
loadArticle()
loadComments()
})
</script>
<style scoped>
.article-detail { max-width: 800px; margin: 0 auto; }
.article-detail h1 { font-size: 28px; margin-bottom: 16px; }
.meta { display: flex; gap: 16px; align-items: center; color: #909399; font-size: 14px; margin-bottom: 12px; }
.meta span { display: flex; align-items: center; gap: 4px; }
.tags { display: flex; gap: 8px; margin-bottom: 16px; }
.content { line-height: 1.8; font-size: 16px; }
.actions { margin: 20px 0; }
.comments-section { margin-top: 30px; }
.comment-form { margin-bottom: 20px; }
.login-tip { color: #909399; margin-bottom: 20px; }
.comment-item { padding: 16px 0; border-bottom: 1px solid #f0f0f0; }
.comment-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.nickname { font-weight: 500; }
.time { color: #909399; font-size: 12px; }
.comment-body { margin-left: 40px; }
.comment-actions { margin-left: 40px; margin-top: 4px; }
.replies { margin-left: 40px; padding: 12px; background: #f5f7fa; border-radius: 4px; margin-top: 8px; }
.reply-item { padding: 4px 0; font-size: 14px; }
.reply-to { color: #409eff; }
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="home">
<div class="hero">
<h1>欢迎来到博客系统</h1>
<p>分享知识记录生活</p>
</div>
<div class="filter-bar">
<el-select v-model="filters.categoryId" placeholder="选择分类" clearable @change="loadArticles">
<el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
<el-input v-model="filters.keyword" placeholder="搜索文章" clearable style="width: 300px" @keyup.enter="loadArticles">
<template #append>
<el-button @click="loadArticles"><el-icon><Search /></el-icon></el-button>
</template>
</el-input>
</div>
<div class="article-list">
<el-card v-for="article in articles" :key="article.id" class="article-card" @click="goDetail(article.id)">
<div class="article-content">
<h3>{{ article.title }}</h3>
<p class="summary">{{ article.summary }}</p>
<div class="meta">
<span><el-icon><User /></el-icon> {{ article.authorNickname }}</span>
<span><el-icon><View /></el-icon> {{ article.viewCount }}</span>
<span><el-icon><ChatDotSquare /></el-icon> {{ article.commentCount }}</span>
<span><el-icon><Timer /></el-icon> {{ formatDate(article.publishedAt) }}</span>
</div>
</div>
<el-image v-if="article.coverImage" :src="article.coverImage" class="cover" fit="cover" />
</el-card>
</div>
<el-pagination
v-model:current-page="filters.page"
:page-size="filters.pageSize"
:total="total"
layout="prev, pager, next"
@current-change="loadArticles"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getPublicArticles, getCategories } from '@/api/article'
const router = useRouter()
const articles = ref([])
const categories = ref([])
const total = ref(0)
const filters = ref({ page: 1, pageSize: 15, categoryId: null, keyword: '' })
async function loadArticles() {
const res = await getPublicArticles(filters.value)
articles.value = res.data.records
total.value = res.data.total
}
async function loadCategories() {
const res = await getCategories()
categories.value = res.data
}
function goDetail(id) {
router.push(`/article/${id}`)
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('zh-CN')
}
onMounted(() => {
loadArticles()
loadCategories()
})
</script>
<style scoped>
.hero {
text-align: center;
padding: 60px 0 30px;
}
.hero h1 { font-size: 32px; color: #303133; }
.hero p { color: #909399; font-size: 16px; }
.filter-bar {
display: flex;
gap: 16px;
margin-bottom: 20px;
align-items: center;
}
.article-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.article-card {
cursor: pointer;
transition: box-shadow 0.3s;
}
.article-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.article-card :deep(.el-card__body) {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.article-content { flex: 1; }
.article-content h3 { margin: 0 0 8px; font-size: 18px; }
.summary { color: #606266; font-size: 14px; margin: 0 0 12px; }
.meta { display: flex; gap: 16px; color: #909399; font-size: 13px; }
.meta span { display: flex; align-items: center; gap: 4px; }
.cover { width: 160px; height: 100px; border-radius: 4px; margin-left: 16px; }
.el-pagination { margin-top: 20px; justify-content: center; }
</style>
+134
View File
@@ -0,0 +1,134 @@
<template>
<div class="login-page">
<el-card class="login-card">
<h2>登录</h2>
<el-tabs v-model="loginType">
<el-tab-pane label="管理员登录" name="admin">
<el-form :model="adminForm" :rules="rules" ref="adminFormRef" @submit.prevent="handleAdminLogin">
<el-form-item prop="account">
<el-input v-model="adminForm.account" placeholder="用户名或手机号" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="adminForm.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item v-if="showCaptcha" prop="captchaCode">
<div style="display: flex; gap: 8px;">
<el-input v-model="adminForm.captchaCode" placeholder="验证码" />
<img :src="captchaImage" @click="loadCaptcha" style="cursor: pointer; height: 40px;" />
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">登录</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="用户登录" name="user">
<el-form :model="userForm" :rules="rules" ref="userFormRef" @submit.prevent="handleUserLogin">
<el-form-item prop="email">
<el-input v-model="userForm.email" placeholder="邮箱" prefix-icon="Message" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="userForm.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<el-checkbox v-model="userForm.rememberMe">记住我</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">登录</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<div class="footer-link">
还没有账号<router-link to="/register">立即注册</router-link>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { getCaptcha } from '@/api/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loginType = ref('user')
const loading = ref(false)
const showCaptcha = ref(false)
const captchaImage = ref('')
const adminForm = ref({ account: '', password: '', captchaKey: '', captchaCode: '' })
const userForm = ref({ email: '', password: '', rememberMe: false })
const adminFormRef = ref()
const userFormRef = ref()
const rules = {
account: [{ required: true, message: '请输入账号', trigger: 'blur' }],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
captchaCode: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
}
async function loadCaptcha() {
const res = await getCaptcha()
adminForm.value.captchaKey = res.data.captchaKey
captchaImage.value = res.data.captchaImage
showCaptcha.value = true
}
async function handleAdminLogin() {
await adminFormRef.value.validate()
loading.value = true
try {
await userStore.loginAdmin(adminForm.value)
ElMessage.success('登录成功')
router.push(route.query.redirect || '/admin')
} catch (e) {
if (e?.response?.data?.code === 1008) {
loadCaptcha()
}
} finally {
loading.value = false
}
}
async function handleUserLogin() {
await userFormRef.value.validate()
loading.value = true
try {
await userStore.loginUser(userForm.value)
ElMessage.success('登录成功')
router.push(route.query.redirect || '/app/dashboard')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f7fa;
}
.login-card {
width: 420px;
padding: 20px;
}
.login-card h2 {
text-align: center;
margin-bottom: 20px;
}
.footer-link {
text-align: center;
margin-top: 16px;
color: #909399;
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<template>
<div class="register-page">
<el-card class="register-card">
<h2>注册</h2>
<el-form :model="form" :rules="rules" ref="formRef" @submit.prevent="handleRegister">
<el-form-item prop="email">
<el-input v-model="form.email" placeholder="邮箱" prefix-icon="Message" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码(8-32位,含大小写字母、数字和特殊字符中的至少三类)" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" placeholder="确认密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" style="width: 100%">注册</el-button>
</el-form-item>
</el-form>
<div class="footer-link">
已有账号<router-link to="/login">立即登录</router-link>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const formRef = ref()
const form = ref({ email: '', password: '', confirmPassword: '' })
const validateConfirm = (rule, value, callback) => {
if (value !== form.value.password) {
callback(new Error('两次密码不一致'))
} else {
callback()
}
}
const rules = {
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, max: 32, message: '密码长度8-32位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{ validator: validateConfirm, trigger: 'blur' },
],
}
async function handleRegister() {
await formRef.value.validate()
loading.value = true
try {
await userStore.registerUser(form.value)
ElMessage.success('注册成功,请查看邮箱完成验证')
router.push('/login')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.register-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f7fa;
}
.register-card {
width: 420px;
padding: 20px;
}
.register-card h2 {
text-align: center;
margin-bottom: 20px;
}
.footer-link {
text-align: center;
margin-top: 16px;
color: #909399;
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<div>
<h2>文章管理</h2>
<div style="margin-bottom: 16px; display: flex; gap: 12px;">
<el-input v-model="keyword" placeholder="搜索文章" clearable style="width: 300px" @keyup.enter="loadArticles">
<template #append>
<el-button @click="loadArticles"><el-icon><Search /></el-icon></el-button>
</template>
</el-input>
<el-select v-model="status" placeholder="状态" clearable @change="loadArticles">
<el-option label="草稿" :value="0" />
<el-option label="已发布" :value="1" />
<el-option label="回收站" :value="2" />
</el-select>
</div>
<el-table :data="articles" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" />
<el-table-column prop="authorNickname" label="作者" width="120" />
<el-table-column prop="viewCount" label="浏览" width="80" />
<el-table-column prop="likeCount" label="点赞" width="80" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row)">{{ statusText(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button text type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="15"
:total="total"
layout="prev, pager, next"
@current-change="loadArticles"
style="margin-top: 20px; justify-content: center;"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getAdminArticles, adminDeleteArticle } from '@/api/article'
import { ElMessage, ElMessageBox } from 'element-plus'
const articles = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
const keyword = ref('')
const status = ref(null)
async function loadArticles() {
loading.value = true
const res = await getAdminArticles({ page: page.value, pageSize: 15, keyword: keyword.value, status: status.value })
articles.value = res.data.records
total.value = res.data.total
loading.value = false
}
async function handleDelete(id) {
await ElMessageBox.confirm('确定删除该文章?', '警告', { type: 'warning' })
await adminDeleteArticle(id)
ElMessage.success('已删除')
loadArticles()
}
function statusType(row) {
return [null, 'success', 'warning', 'info'][row.status] || ''
}
function statusText(row) {
return ['草稿', '已发布', '回收站'][row.status] || '未知'
}
onMounted(loadArticles)
</script>
+108
View File
@@ -0,0 +1,108 @@
<template>
<div>
<h2>分类管理</h2>
<div style="margin-bottom: 16px;">
<el-button type="primary" @click="showAddDialog">新增分类</el-button>
</div>
<el-table :data="categories" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="articleCount" label="文章数" width="100" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'danger'">{{ row.enabled ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button text type="primary" @click="showEditDialog(row)">编辑</el-button>
<el-button text type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑分类' : '新增分类'" width="400px">
<el-form :model="form" label-width="80px">
<el-form-item label="名称">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getCategories, createCategory, updateCategory, deleteCategory } from '@/api/article'
import { ElMessage, ElMessageBox } from 'element-plus'
const categories = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref(null)
const form = ref({ name: '', description: '', enabled: true, sort: 0 })
async function loadCategories() {
loading.value = true
const res = await getCategories()
categories.value = res.data
loading.value = false
}
function showAddDialog() {
isEdit.value = false
editingId.value = null
form.value = { name: '', description: '', enabled: true, sort: 0 }
dialogVisible.value = true
}
function showEditDialog(row) {
isEdit.value = true
editingId.value = row.id
form.value = { name: row.name, description: row.description, enabled: row.enabled, sort: row.sort }
dialogVisible.value = true
}
async function handleSubmit() {
if (!form.value.name) {
ElMessage.warning('请输入分类名称')
return
}
if (isEdit.value) {
await updateCategory(editingId.value, form.value)
} else {
await createCategory(form.value)
}
ElMessage.success('操作成功')
dialogVisible.value = false
loadCategories()
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除分类"${row.name}"`, '提示')
try {
await deleteCategory(row.id)
ElMessage.success('已删除')
loadCategories()
} catch {
// error handled by interceptor
}
}
onMounted(loadCategories)
</script>
+75
View File
@@ -0,0 +1,75 @@
<template>
<div>
<h2>评论管理</h2>
<div style="margin-bottom: 16px; display: flex; gap: 12px;">
<el-select v-model="status" placeholder="状态" clearable @change="loadComments">
<el-option label="待审核" :value="0" />
<el-option label="已通过" :value="1" />
<el-option label="已屏蔽" :value="2" />
</el-select>
</div>
<el-table :data="comments" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="nickname" label="评论者" width="120" />
<el-table-column prop="content" label="内容" show-overflow-tooltip />
<el-table-column prop="articleId" label="文章ID" width="80" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="[null, 'success', 'danger'][row.status] || 'info'">
{{ ['待审核', '已通过', '已屏蔽', '已删除'][row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button text type="success" @click="handleAudit(row.id, 1)">通过</el-button>
<el-button text type="warning" @click="handleAudit(row.id, 2)">屏蔽</el-button>
<el-button text type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="15"
:total="total"
layout="prev, pager, next"
@current-change="loadComments"
style="margin-top: 20px; justify-content: center;"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getAdminComments, auditComment, adminDeleteComment } from '@/api/comment'
import { ElMessage, ElMessageBox } from 'element-plus'
const comments = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
const status = ref(null)
async function loadComments() {
loading.value = true
const res = await getAdminComments({ page: page.value, pageSize: 15, status: status.value })
comments.value = res.data.records
total.value = res.data.total
loading.value = false
}
async function handleAudit(id, auditStatus) {
await auditComment(id, auditStatus)
ElMessage.success('操作成功')
loadComments()
}
async function handleDelete(id) {
await ElMessageBox.confirm('确定删除该评论?', '警告', { type: 'warning' })
await adminDeleteComment(id)
ElMessage.success('已删除')
loadComments()
}
onMounted(loadComments)
</script>
@@ -0,0 +1,22 @@
<template>
<div>
<h2>管理后台仪表盘</h2>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="6">
<el-card><el-statistic title="用户数" :value="0" /></el-card>
</el-col>
<el-col :span="6">
<el-card><el-statistic title="文章数" :value="0" /></el-card>
</el-col>
<el-col :span="6">
<el-card><el-statistic title="评论数" :value="0" /></el-card>
</el-col>
<el-col :span="6">
<el-card><el-statistic title="今日访问" :value="0" /></el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
</script>
+73
View File
@@ -0,0 +1,73 @@
<template>
<div>
<h2>用户管理</h2>
<div style="margin-bottom: 16px; display: flex; gap: 12px;">
<el-input v-model="keyword" placeholder="搜索用户" clearable style="width: 300px" @keyup.enter="loadUsers">
<template #append>
<el-button @click="loadUsers"><el-icon><Search /></el-icon></el-button>
</template>
</el-input>
</div>
<el-table :data="users" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="nickname" label="昵称" width="150" />
<el-table-column prop="email" label="邮箱" />
<el-table-column label="操作" width="240">
<template #default="{ row }">
<el-button text type="warning" @click="handleResetPassword(row.id)">重置密码</el-button>
<el-button text :type="row.status === 1 ? 'success' : 'danger'" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '解锁' : '锁定' }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="15"
:total="total"
layout="prev, pager, next"
@current-change="loadUsers"
style="margin-top: 20px; justify-content: center;"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getUserList, resetUserPassword, updateUserStatus } from '@/api/admin'
import { ElMessage, ElMessageBox } from 'element-plus'
const users = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
const keyword = ref('')
async function loadUsers() {
loading.value = true
const res = await getUserList({ page: page.value, pageSize: 15, keyword: keyword.value })
users.value = res.data.records
total.value = res.data.total
loading.value = false
}
async function handleResetPassword(id) {
const { value } = await ElMessageBox.prompt('请输入新密码', '重置密码', {
inputPattern: /^.{8,32}$/,
inputErrorMessage: '密码长度8-32位',
})
await resetUserPassword(id, value)
ElMessage.success('密码已重置')
}
async function handleToggleStatus(row) {
const newStatus = row.status === 0 ? 1 : 0
const action = newStatus === 1 ? '锁定' : '解锁'
await ElMessageBox.confirm(`确定${action}该用户?`, '提示')
await updateUserStatus(row.id, newStatus)
ElMessage.success(`${action}`)
loadUsers()
}
onMounted(loadUsers)
</script>
@@ -0,0 +1,159 @@
<template>
<div class="article-editor">
<h2>{{ isEdit ? '编辑文章' : '写文章' }}</h2>
<el-form :model="form" label-width="80px">
<el-form-item label="标题">
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="form.categoryId" placeholder="选择分类" clearable>
<el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-select v-model="form.tagIds" multiple placeholder="选择标签(最多5个)" :max-collapse-tags="5">
<el-option v-for="tag in tags" :key="tag.id" :label="tag.name" :value="tag.id" />
</el-select>
</el-form-item>
<el-form-item label="内容">
<div class="editor-wrapper">
<div v-if="editor" class="toolbar">
<el-button-group>
<el-button size="small" @click="editor.chain().focus().toggleBold().run()" :type="editor.isActive('bold') ? 'primary' : ''">B</el-button>
<el-button size="small" @click="editor.chain().focus().toggleItalic().run()" :type="editor.isActive('italic') ? 'primary' : ''">I</el-button>
<el-button size="small" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :type="editor.isActive('heading', { level: 2 }) ? 'primary' : ''">H2</el-button>
<el-button size="small" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()" :type="editor.isActive('heading', { level: 3 }) ? 'primary' : ''">H3</el-button>
<el-button size="small" @click="editor.chain().focus().toggleBulletList().run()" :type="editor.isActive('bulletList') ? 'primary' : ''">列表</el-button>
<el-button size="small" @click="editor.chain().focus().toggleBlockquote().run()" :type="editor.isActive('blockquote') ? 'primary' : ''">引用</el-button>
<el-button size="small" @click="editor.chain().focus().setHorizontalRule().run()">分割线</el-button>
</el-button-group>
</div>
<EditorContent :editor="editor" class="editor-content" />
</div>
</el-form-item>
<el-form-item label="摘要">
<el-input v-model="form.summary" type="textarea" :rows="2" placeholder="文章摘要" />
</el-form-item>
<el-form-item label="可见性">
<el-radio-group v-model="form.visibility">
<el-radio :value="0">公开</el-radio>
<el-radio :value="1">私密</el-radio>
<el-radio :value="2">仅粉丝</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit(false)" :loading="saving">保存草稿</el-button>
<el-button type="success" @click="handleSubmit(true)" :loading="saving">发布</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import { createArticle, updateArticle, getArticleDetail, getCategories, getTags } from '@/api/article'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const saving = ref(false)
const categories = ref([])
const tags = ref([])
const form = ref({
title: '',
categoryId: null,
tagIds: [],
summary: '',
visibility: 0,
allowComment: true,
})
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({ placeholder: '开始写作...' }),
],
content: '',
})
async function loadCategories() {
const res = await getCategories()
categories.value = res.data
}
async function loadTags() {
const res = await getTags()
tags.value = res.data
}
async function loadArticle() {
if (!isEdit.value) return
const res = await getArticleDetail(route.params.id)
const article = res.data
form.value.title = article.title
form.value.categoryId = article.categoryId
form.value.tagIds = article.tags?.map(t => t.id) || []
form.value.summary = article.summary
form.value.visibility = article.visibility
if (editor.value) {
editor.value.commands.setContent(article.content || '')
}
}
async function handleSubmit(publish) {
if (!form.value.title) {
ElMessage.warning('请输入标题')
return
}
saving.value = true
try {
const data = {
...form.value,
content: editor.value?.getHTML() || '',
publish,
}
if (isEdit.value) {
await updateArticle(route.params.id, data)
ElMessage.success('更新成功')
} else {
const res = await createArticle(data)
ElMessage.success(publish ? '发布成功' : '已保存草稿')
if (!isEdit.value) {
router.push(`/app/editor/${res.data}`)
}
}
} finally {
saving.value = false
}
}
onMounted(() => {
loadCategories()
loadTags()
loadArticle()
})
onBeforeUnmount(() => {
editor.value?.destroy()
})
</script>
<style scoped>
.article-editor { max-width: 900px; }
.editor-wrapper { border: 1px solid #dcdfe6; border-radius: 4px; width: 100%; }
.toolbar { padding: 8px; border-bottom: 1px solid #dcdfe6; background: #fafafa; }
.editor-content { min-height: 400px; padding: 16px; }
.editor-content :deep(.tiptap) { outline: none; min-height: 360px; }
.editor-content :deep(.tiptap p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
color: #adb5bd;
pointer-events: none;
height: 0;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<template>
<div>
<h2>控制台</h2>
<p>欢迎{{ userStore.userInfo?.nickname }}</p>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="8">
<el-card><el-statistic title="我的文章" :value="0" /></el-card>
</el-col>
<el-col :span="8">
<el-card><el-statistic title="总浏览量" :value="0" /></el-card>
</el-col>
<el-col :span="8">
<el-card><el-statistic title="获赞数" :value="0" /></el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
</script>
+72
View File
@@ -0,0 +1,72 @@
<template>
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>我的文章</h2>
<el-button type="primary" @click="$router.push('/app/editor')">写文章</el-button>
</div>
<el-table :data="articles" v-loading="loading">
<el-table-column prop="title" label="标题" />
<el-table-column prop="categoryName" label="分类" width="120" />
<el-table-column prop="viewCount" label="浏览" width="80" />
<el-table-column prop="likeCount" label="点赞" width="80" />
<el-table-column prop="publishedAt" label="发布时间" width="180">
<template #default="{ row }">{{ formatDate(row.publishedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button text type="primary" @click="$router.push(`/app/editor/${row.id}`)">编辑</el-button>
<el-button text type="warning" @click="handleRecycle(row.id)">回收站</el-button>
<el-button text type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
:page-size="15"
:total="total"
layout="prev, pager, next"
@current-change="loadArticles"
style="margin-top: 20px; justify-content: center;"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getMyArticles, moveToRecycleBin, deleteArticle } from '@/api/article'
import { ElMessage, ElMessageBox } from 'element-plus'
const articles = ref([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
async function loadArticles() {
loading.value = true
const res = await getMyArticles({ page: page.value, pageSize: 15 })
articles.value = res.data.records
total.value = res.data.total
loading.value = false
}
async function handleRecycle(id) {
await ElMessageBox.confirm('确定移入回收站?', '提示')
await moveToRecycleBin(id)
ElMessage.success('已移入回收站')
loadArticles()
}
async function handleDelete(id) {
await ElMessageBox.confirm('确定永久删除?此操作不可恢复', '警告', { type: 'warning' })
await deleteArticle(id)
ElMessage.success('已删除')
loadArticles()
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
onMounted(loadArticles)
</script>