初版代码提交
This commit is contained in:
@@ -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>
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 © 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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user