refactor(admin): Rework permission routes and operations dashboard

This commit is contained in:
2026-04-24 17:34:41 +08:00
parent 600ff66bd3
commit 833dd3451a
22 changed files with 1423 additions and 568 deletions
+13 -7
View File
@@ -13,8 +13,14 @@ function normalizeAdmin(raw: any): AdminUser {
avatar: raw?.avatar || '', avatar: raw?.avatar || '',
email: raw?.email || '', email: raw?.email || '',
phone: raw?.phone || '', phone: raw?.phone || '',
role: firstRole?.code === 'ADMIN_SUPER' ? 'admin' : 'manager', role: firstRole?.code || '',
roleName: firstRole?.name || '', roleName: firstRole?.name || '',
roles: Array.isArray(raw?.roles) ? raw.roles.map((item: any) => item.code || item.name || '') : [],
roleNames: Array.isArray(raw?.roles) ? raw.roles.map((item: any) => item.name || item.code || '') : [],
roleIds: Array.isArray(raw?.roleIds)
? raw.roleIds.map((item: any) => Number(item))
: (Array.isArray(raw?.roles) ? raw.roles.map((item: any) => Number(item.id || 0)).filter((item: number) => item > 0) : []),
permissions: [],
status: Number(raw?.status ?? 0), status: Number(raw?.status ?? 0),
lastLoginTime: raw?.lastLoginTime || '', lastLoginTime: raw?.lastLoginTime || '',
lastLoginIp: raw?.lastLoginIp || '', lastLoginIp: raw?.lastLoginIp || '',
@@ -70,15 +76,15 @@ export const adminApi = {
nickname: string nickname: string
email?: string email?: string
phone?: string phone?: string
role?: string roleIds: number[]
}): Promise<void> { }): Promise<void> {
return request.post('/user', { return request.post('/user', {
username: data.username, username: data.username,
password: data.password, password: data.password,
realName: data.nickname, realName: data.nickname,
email: data.email, ...(data.email ? { email: data.email } : {}),
phone: data.phone, ...(data.phone ? { phone: data.phone } : {}),
roleIds: [data.role === 'admin' ? 1 : 2] roleIds: data.roleIds
}) })
}, },
@@ -91,13 +97,13 @@ export const adminApi = {
nickname?: string nickname?: string
email?: string email?: string
phone?: string phone?: string
role?: string roleIds: number[]
}): Promise<void> { }): Promise<void> {
const payload: Record<string, any> = { const payload: Record<string, any> = {
id: data.id, id: data.id,
username: data.username, username: data.username,
realName: data.nickname, realName: data.nickname,
roleIds: [data.role === 'admin' ? 1 : 2] roleIds: data.roleIds
} }
if (data.email) payload.email = data.email if (data.email) payload.email = data.email
@@ -10,6 +10,7 @@ export type { LoginParams, LoginResult, CaptchaResult, AdminUser }
function normalizeAdminInfo(raw: any): AdminUser { function normalizeAdminInfo(raw: any): AdminUser {
const roles = Array.isArray(raw?.roles) ? raw.roles : [] const roles = Array.isArray(raw?.roles) ? raw.roles : []
const permissions = Array.isArray(raw?.permissions) ? raw.permissions : []
const roleCode = roles[0] || '' const roleCode = roles[0] || ''
return { return {
@@ -21,6 +22,10 @@ function normalizeAdminInfo(raw: any): AdminUser {
phone: '', phone: '',
role: roleCode === 'ADMIN_SUPER' ? 'super-admin' : roleCode || 'admin', role: roleCode === 'ADMIN_SUPER' ? 'super-admin' : roleCode || 'admin',
roleName: roleCode === 'ADMIN_SUPER' ? '超级管理员' : roleCode || '管理员', roleName: roleCode === 'ADMIN_SUPER' ? '超级管理员' : roleCode || '管理员',
roles,
roleNames: roles,
permissions,
roleIds: Array.isArray(raw?.roleIds) ? raw.roleIds.map((item: any) => Number(item)) : [],
status: 1, status: 1,
lastLoginTime: '', lastLoginTime: '',
lastLoginIp: '', lastLoginIp: '',
@@ -50,7 +55,9 @@ export const authApi = {
tokenType: result.tokenType || 'Bearer', tokenType: result.tokenType || 'Bearer',
expiresIn: result.expiresIn || 0, expiresIn: result.expiresIn || 0,
adminId: result.adminId || result.id || 0, adminId: result.adminId || result.id || 0,
username: result.username || '' username: result.username || '',
roles: Array.isArray(result.roles) ? result.roles : [],
permissions: Array.isArray(result.permissions) ? result.permissions : []
} }
}, },
@@ -1,8 +1,27 @@
import request from '@/utils/request.ts' import request from '@/utils/request.ts'
import type { PageResult, PlatformComment, CommentQueryParams } from '@/types' import type { PageResult, PlatformComment, CommentQueryParams } from '@/types'
function normalizeComment(raw: any): PlatformComment {
return {
id: Number(raw?.id || 0),
videoId: Number(raw?.videoId || 0),
videoTitle: raw?.videoTitle || '',
userId: Number(raw?.userId || 0),
userNickname: raw?.userNickname || '',
userAvatar: raw?.userAvatar || '',
parentId: Number(raw?.parentId || 0),
rootId: Number(raw?.rootId || 0),
content: raw?.content || '',
likeCount: Number(raw?.likeCount || 0),
replyCount: Number(raw?.replyCount || 0),
isTop: Number(raw?.isTop ?? raw?.topStatus ?? 0),
status: Number(raw?.status ?? 1),
createTime: raw?.createTime || ''
}
}
function normalizeCommentPage(raw: any, page: number, size: number): PageResult<PlatformComment> { function normalizeCommentPage(raw: any, page: number, size: number): PageResult<PlatformComment> {
const records = Array.isArray(raw?.records) ? raw.records : [] const records = Array.isArray(raw?.records) ? raw.records.map(normalizeComment) : []
const total = Number(raw?.total || records.length || 0) const total = Number(raw?.total || records.length || 0)
const shouldPaginateLocally = (!raw?.total || raw.total === 0) && records.length > size const shouldPaginateLocally = (!raw?.total || raw.total === 0) && records.length > size
@@ -43,6 +62,10 @@ export const commentApi = {
return request.delete(`/platform/comment/${commentId}`) return request.delete(`/platform/comment/${commentId}`)
}, },
restore(commentId: number): Promise<void> {
return request.put(`/platform/comment/${commentId}/restore`)
},
/** /**
* 获取评论统计数据 * 获取评论统计数据
*/ */
@@ -0,0 +1,79 @@
import request from '@/utils/request.ts'
import type { AdminPermission, AdminRole, PageResult, PageParams } from '@/types'
function normalizePermission(raw: any): AdminPermission {
return {
id: Number(raw?.id || 0),
name: raw?.name || '',
code: raw?.code || '',
description: raw?.description || '',
createTime: raw?.createTime || ''
}
}
function normalizeRole(raw: any): AdminRole {
const permissions = Array.isArray(raw?.permissions) ? raw.permissions.map(normalizePermission) : []
return {
id: Number(raw?.id || 0),
name: raw?.name || '',
code: raw?.code || '',
description: raw?.description || '',
permissionIds: Array.isArray(raw?.permissionIds)
? raw.permissionIds.map((item: any) => Number(item))
: permissions.map((item: AdminPermission) => item.id),
permissions,
createTime: raw?.createTime || ''
}
}
function normalizeRolePage(raw: any, page: number, size: number): PageResult<AdminRole> {
const records = Array.isArray(raw?.records) ? raw.records.map(normalizeRole) : []
const total = Number(raw?.total || records.length || 0)
return {
records,
total,
size: Number(raw?.size || size),
current: Number(raw?.current || page),
pages: Number(raw?.pages || Math.ceil(total / size) || 1)
}
}
export const roleApi = {
async getList(params: PageParams & { keyword?: string }): Promise<PageResult<AdminRole>> {
const page = params.page || 1
const size = params.size || 10
const result = await request.get<any>('/role/list', params)
return normalizeRolePage(result, page, size)
},
async getAll(): Promise<AdminRole[]> {
const result = await request.get<any[]>('/role/all')
return Array.isArray(result) ? result.map(normalizeRole) : []
},
async getDetail(id: number): Promise<AdminRole> {
const result = await request.get<any>(`/role/${id}`)
return normalizeRole(result)
},
create(data: { name: string; code: string; description?: string; permissionIds: number[] }): Promise<void> {
return request.post('/role', data)
},
update(data: { id: number; name: string; code: string; description?: string; permissionIds?: number[] }): Promise<void> {
return request.put('/role', data)
},
updatePermissions(id: number, permissionIds: number[]): Promise<void> {
return request.put(`/role/${id}/permissions`, permissionIds)
},
delete(id: number): Promise<void> {
return request.delete(`/role/${id}`)
},
async getPermissions(): Promise<AdminPermission[]> {
const result = await request.get<any[]>('/role/permissions/list')
return Array.isArray(result) ? result.map(normalizePermission) : []
}
}
@@ -1,6 +1,14 @@
import request from '@/utils/request.ts' import request from '@/utils/request.ts'
import { useUserStore } from '@/stores'
import type { DashboardData, StatisticsData, TrendData } from '@/types' import type { DashboardData, StatisticsData, TrendData } from '@/types'
function formatLocalDate(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getDateKey(input?: string) { function getDateKey(input?: string) {
if (!input) return '' if (!input) return ''
return String(input).slice(0, 10) return String(input).slice(0, 10)
@@ -12,7 +20,7 @@ function buildDailySeries<T>(items: T[], getDate: (item: T) => string, getValue:
for (let i = 6; i >= 0; i--) { for (let i = 6; i >= 0; i--) {
const d = new Date(today) const d = new Date(today)
d.setDate(today.getDate() - i) d.setDate(today.getDate() - i)
days.push(d.toISOString().slice(0, 10)) days.push(formatLocalDate(d))
} }
const map = new Map(days.map((day) => [day, 0])) const map = new Map(days.map((day) => [day, 0]))
@@ -91,11 +99,19 @@ export const statisticsApi = {
* 获取仪表盘数据 * 获取仪表盘数据
*/ */
async getDashboard(): Promise<DashboardData> { async getDashboard(): Promise<DashboardData> {
const userStore = useUserStore()
const [dashboard, users, videos, comments] = await Promise.all([ const [dashboard, users, videos, comments] = await Promise.all([
request.get<any>('/dashboard/stats'), request.get<any>('/dashboard/stats'),
request.get<any>('/platform/user/list', { page: 1, size: 200 }), userStore.hasPermission('user:manage')
request.get<any>('/platform/content/video/list', { page: 1, size: 200 }), ? request.get<any>('/platform/user/list', { page: 1, size: 200 })
request.get<any>('/platform/comment/list', { page: 1, size: 200 }) : Promise.resolve({ records: [] }),
userStore.hasPermission('video:manage')
? request.get<any>('/platform/content/video/list', { page: 1, size: 200 })
: Promise.resolve({ records: [] }),
userStore.hasPermission('comment:manage')
? request.get<any>('/platform/comment/list', { page: 1, size: 200 })
: Promise.resolve({ records: [] })
]) ])
return normalizeDashboard( return normalizeDashboard(
+1
View File
@@ -39,6 +39,7 @@ declare module 'vue' {
ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSpace: typeof import('element-plus/es')['ElSpace']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
@@ -159,62 +159,67 @@ onMounted(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.tags-view-container { .tags-view-container {
height: 34px; height: 36px;
width: 100%; width: 100%;
background: #fff; background: transparent;
border-bottom: 1px solid #d8dce5;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 0 3px 0 rgba(0, 0, 0, 0.04);
.tags-view-wrapper { .tags-view-wrapper {
height: 100%;
.tags-view-item { .tags-view-item {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
height: 26px; height: 30px;
line-height: 26px; line-height: 30px;
border: 1px solid #d8dce5; border: 1px solid rgba(203, 213, 225, 0.9);
color: #495060; color: #475569;
background: #fff; background: #fff;
padding: 0 8px; padding: 0 10px;
font-size: 12px; font-size: 12px;
margin-left: 5px; margin-left: 6px;
margin-top: 4px; margin-top: 3px;
border-radius: 2px; border-radius: 10px;
text-decoration: none; text-decoration: none;
transition: all 0.18s ease;
&:first-of-type { &:first-of-type {
margin-left: 10px; margin-left: 2px;
} }
&.active { &.active {
background-color: #42b983; background: linear-gradient(90deg, #10b981, #34d399);
color: #fff; color: #fff;
border-color: #42b983; border-color: transparent;
box-shadow: 0 10px 20px rgba(16, 185, 129, 0.2);
&::before { &::before {
content: ''; content: '';
background: #fff; background: #fff;
display: inline-block; display: inline-block;
width: 8px; width: 7px;
height: 8px; height: 7px;
border-radius: 50%; border-radius: 50%;
position: relative; position: relative;
margin-right: 4px; margin-right: 6px;
} }
} }
&:hover:not(.active) { &:hover:not(.active) {
color: #495060; color: #0f172a;
border-color: rgba(96, 165, 250, 0.34);
background: #f8fbff;
} }
.close-icon { .close-icon {
margin-left: 5px; margin-left: 6px;
font-size: 12px; font-size: 12px;
border-radius: 50%; border-radius: 50%;
padding: 2px;
&:hover { &:hover {
background-color: #b4bccc; background-color: rgba(15, 23, 42, 0.16);
color: #fff; color: #fff;
} }
} }
@@ -233,14 +238,15 @@ onMounted(() => {
font-weight: 400; font-weight: 400;
color: #333; color: #333;
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3); box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
border: 1px solid rgba(226, 232, 240, 0.9);
li { li {
margin: 0; margin: 0;
padding: 7px 16px; padding: 8px 16px;
cursor: pointer; cursor: pointer;
&:hover { &:hover {
background: #eee; background: #f8fafc;
} }
} }
} }
@@ -36,7 +36,7 @@ export const asyncRoutes: RouteRecordRaw[] = [
path: 'dashboard', path: 'dashboard',
name: 'Dashboard', name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'), component: () => import('@/views/dashboard/index.vue'),
meta: { title: '仪表盘', icon: 'Odometer', affix: true } meta: { title: '仪表盘', icon: 'Odometer', affix: true, permission: 'operation:log' }
} }
] ]
}, },
@@ -50,13 +50,13 @@ export const asyncRoutes: RouteRecordRaw[] = [
path: 'list', path: 'list',
name: 'UserList', name: 'UserList',
component: () => import('@/views/user/list.vue'), component: () => import('@/views/user/list.vue'),
meta: { title: '用户列表', icon: 'List' } meta: { title: '用户列表', icon: 'List', permission: 'user:manage' }
}, },
{ {
path: 'detail/:id', path: 'detail/:id',
name: 'UserDetail', name: 'UserDetail',
component: () => import('@/views/user/detail.vue'), component: () => import('@/views/user/detail.vue'),
meta: { title: '用户详情', hidden: true, activeMenu: '/user/list' } meta: { title: '用户详情', hidden: true, activeMenu: '/user/list', permission: 'user:manage' }
} }
] ]
}, },
@@ -70,19 +70,19 @@ export const asyncRoutes: RouteRecordRaw[] = [
path: 'list', path: 'list',
name: 'VideoList', name: 'VideoList',
component: () => import('@/views/video/list.vue'), component: () => import('@/views/video/list.vue'),
meta: { title: '视频列表', icon: 'List' } meta: { title: '视频列表', icon: 'List', permission: 'video:manage' }
}, },
{ {
path: 'review', path: 'review',
name: 'VideoReview', name: 'VideoReview',
component: () => import('@/views/video/review.vue'), component: () => import('@/views/video/review.vue'),
meta: { title: '视频审核', icon: 'Check' } meta: { title: '视频审核', icon: 'Check', permission: 'video:manage' }
}, },
{ {
path: 'detail/:id', path: 'detail/:id',
name: 'VideoDetail', name: 'VideoDetail',
component: () => import('@/views/video/detail.vue'), component: () => import('@/views/video/detail.vue'),
meta: { title: '视频详情', hidden: true, activeMenu: '/video/list' } meta: { title: '视频详情', hidden: true, activeMenu: '/video/list', permission: 'video:manage' }
} }
] ]
}, },
@@ -96,19 +96,19 @@ export const asyncRoutes: RouteRecordRaw[] = [
path: 'category', path: 'category',
name: 'Category', name: 'Category',
component: () => import('@/views/content/category.vue'), component: () => import('@/views/content/category.vue'),
meta: { title: '分类管理', icon: 'Grid' } meta: { title: '分类管理', icon: 'Grid', permission: 'category:manage' }
}, },
{ {
path: 'tag', path: 'tag',
name: 'Tag', name: 'Tag',
component: () => import('@/views/content/tag.vue'), component: () => import('@/views/content/tag.vue'),
meta: { title: '标签管理', icon: 'PriceTag' } meta: { title: '标签管理', icon: 'PriceTag', permission: 'tag:manage' }
}, },
{ {
path: 'comment', path: 'comment',
name: 'Comment', name: 'Comment',
component: () => import('@/views/content/comment.vue'), component: () => import('@/views/content/comment.vue'),
meta: { title: '评论管理', icon: 'ChatDotSquare' } meta: { title: '评论管理', icon: 'ChatDotSquare', permission: 'comment:manage' }
} }
] ]
}, },
@@ -122,7 +122,13 @@ export const asyncRoutes: RouteRecordRaw[] = [
path: 'admin', path: 'admin',
name: 'AdminUser', name: 'AdminUser',
component: () => import('@/views/system/admin.vue'), component: () => import('@/views/system/admin.vue'),
meta: { title: '管理员管理', icon: 'UserFilled' } meta: { title: '管理员管理', icon: 'UserFilled', permission: 'user:manage' }
},
{
path: 'role',
name: 'AdminRole',
component: () => import('@/views/system/role.vue'),
meta: { title: '角色管理', icon: 'Key', permission: 'role:manage' }
} }
] ]
}, },
@@ -150,6 +156,29 @@ const router = createRouter({
// 路由白名单 // 路由白名单
const whiteList = ['/login', '/404'] const whiteList = ['/login', '/404']
const resolveRoutePath = (parentPath: string, childPath: string) => {
const parent = parentPath === '/' ? '' : parentPath.replace(/\/$/, '')
const child = childPath.replace(/^\//, '')
return `${parent}/${child}` || '/'
}
const getDefaultAuthorizedPath = (userStore: ReturnType<typeof useUserStore>) => {
const preferredPaths = ['/dashboard', '/video/review', '/video/list', '/content/comment', '/content/category', '/content/tag', '/system/admin', '/user/list', '/system/role']
const availablePaths = asyncRoutes.flatMap((route) =>
(route.children || [])
.filter((child) => !child.meta?.hidden)
.map((child) => ({
path: resolveRoutePath(route.path, child.path),
permission: child.meta?.permission as string | undefined
}))
.filter((item) => !item.permission || userStore.hasPermission(item.permission))
.map((item) => item.path)
)
return preferredPaths.find((path) => availablePaths.includes(path)) || availablePaths[0] || '/404'
}
// 路由前置守卫 // 路由前置守卫
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
// 开始进度条 // 开始进度条
@@ -169,12 +198,29 @@ router.beforeEach(async (to, _from, next) => {
NProgress.done() NProgress.done()
} else { } else {
// 检查是否已获取用户信息 // 检查是否已获取用户信息
if (userStore.userInfo) { const shouldRefreshUserInfo =
next() !userStore.userInfo ||
} else { !Array.isArray(userStore.userInfo.roles) ||
!Array.isArray(userStore.userInfo.permissions)
if (shouldRefreshUserInfo) {
try { try {
// 获取用户信息 // 获取用户信息
await userStore.fetchUserInfo() await userStore.fetchUserInfo()
if (to.path === '/' || to.path === '/dashboard') {
const defaultPath = getDefaultAuthorizedPath(userStore)
if (defaultPath !== to.path) {
next(defaultPath)
NProgress.done()
return
}
}
const requiredPermission = to.meta?.permission as string | undefined
if (requiredPermission && !userStore.hasPermission(requiredPermission)) {
next('/404')
NProgress.done()
return
}
next() next()
} catch (error) { } catch (error) {
// 获取用户信息失败,清除登录状态 // 获取用户信息失败,清除登录状态
@@ -182,6 +228,22 @@ router.beforeEach(async (to, _from, next) => {
next(`/login?redirect=${to.path}`) next(`/login?redirect=${to.path}`)
NProgress.done() NProgress.done()
} }
} else {
if (to.path === '/' || to.path === '/dashboard') {
const defaultPath = getDefaultAuthorizedPath(userStore)
if (defaultPath !== to.path) {
next(defaultPath)
NProgress.done()
return
}
}
const requiredPermission = to.meta?.permission as string | undefined
if (requiredPermission && !userStore.hasPermission(requiredPermission)) {
next('/404')
NProgress.done()
return
}
next()
} }
} }
} else { } else {
@@ -21,6 +21,8 @@ export const useUserStore = defineStore('admin-user', () => {
const nickname = computed(() => userInfo.value?.nickname) const nickname = computed(() => userInfo.value?.nickname)
const avatar = computed(() => userInfo.value?.avatar) const avatar = computed(() => userInfo.value?.avatar)
const role = computed(() => userInfo.value?.role) const role = computed(() => userInfo.value?.role)
const roles = computed(() => userInfo.value?.roles || [])
const permissions = computed(() => userInfo.value?.permissions || [])
// Actions // Actions
function setToken(accessToken: string, refresh?: string) { function setToken(accessToken: string, refresh?: string) {
@@ -68,6 +70,26 @@ export const useUserStore = defineStore('admin-user', () => {
localStorage.removeItem(USER_INFO_KEY) localStorage.removeItem(USER_INFO_KEY)
} }
function hasPermission(permission: string | string[]) {
const currentPermissions = permissions.value
const currentRoles = roles.value
if (currentRoles.includes('ADMIN_SUPER') || currentRoles.includes('super-admin')) {
return true
}
if (Array.isArray(permission)) {
return permission.some((item) => currentPermissions.includes(item))
}
return currentPermissions.includes(permission)
}
function hasRole(targetRole: string | string[]) {
const currentRoles = roles.value
if (Array.isArray(targetRole)) {
return targetRole.some((item) => currentRoles.includes(item))
}
return currentRoles.includes(targetRole)
}
return { return {
// State // State
token, token,
@@ -80,12 +102,16 @@ export const useUserStore = defineStore('admin-user', () => {
nickname, nickname,
avatar, avatar,
role, role,
roles,
permissions,
// Actions // Actions
setToken, setToken,
setUserInfo, setUserInfo,
login, login,
fetchUserInfo, fetchUserInfo,
logout, logout,
clearAuth clearAuth,
hasPermission,
hasRole
} }
}) })
@@ -74,6 +74,34 @@ a {
.el-table__row:hover > td { .el-table__row:hover > td {
background: #f8fbff !important; background: #f8fbff !important;
} }
.cell:has(> .el-button.is-text) {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.el-button.is-text {
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
line-height: 28px;
background: rgba(248, 250, 252, 0.86);
border: 1px solid rgba(226, 232, 240, 0.92);
}
.el-button.is-text + .el-button.is-text {
margin-left: 0;
}
.el-button.is-text:hover,
.el-button.is-text:focus-visible {
background: rgba(239, 246, 255, 0.95);
border-color: rgba(147, 197, 253, 0.9);
}
} }
.el-dialog { .el-dialog {
@@ -90,6 +118,63 @@ a {
border-radius: 22px; border-radius: 22px;
} }
.search-card {
width: fit-content;
max-width: 100%;
.el-card__body {
padding: 18px 20px;
}
.el-form--inline {
display: flex;
flex-wrap: wrap;
gap: 12px 22px;
align-items: center;
}
.el-form--inline .el-form-item {
margin-right: 0;
margin-bottom: 0;
}
.el-form-item__label {
padding-right: 10px;
color: #475569;
font-weight: 600;
}
.el-input {
width: 220px;
}
.el-select {
width: 148px;
}
.el-date-editor.el-input__wrapper {
width: 334px;
}
}
@media (max-width: 768px) {
.search-card {
width: 100%;
.el-form--inline {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.el-input,
.el-select,
.el-date-editor.el-input__wrapper {
width: 100%;
}
}
}
// 工具类 // 工具类
.text-primary { .text-primary {
color: $primary-color !important; color: $primary-color !important;
@@ -32,6 +32,10 @@ export interface AdminUser {
phone: string phone: string
role: string role: string
roleName?: string roleName?: string
roles?: string[]
roleNames?: string[]
permissions?: string[]
roleIds?: number[]
status: number status: number
lastLoginTime: string lastLoginTime: string
lastLoginIp: string lastLoginIp: string
@@ -46,6 +50,8 @@ export interface LoginResult {
expiresIn: number expiresIn: number
adminId: number adminId: number
username: string username: string
roles?: string[]
permissions?: string[]
} }
// 验证码响应 // 验证码响应
@@ -252,3 +258,21 @@ export interface DashboardData {
recentComments: PlatformComment[] recentComments: PlatformComment[]
recentUsers: PlatformUser[] recentUsers: PlatformUser[]
} }
export interface AdminPermission {
id: number
name: string
code: string
description?: string
createTime?: string
}
export interface AdminRole {
id: number
name: string
code: string
description?: string
permissionIds: number[]
permissions: AdminPermission[]
createTime?: string
}
@@ -5,6 +5,7 @@
<div class="card-header"> <div class="card-header">
<span>分类管理</span> <span>分类管理</span>
<el-button <el-button
v-if="userStore.hasPermission('category:manage')"
type="primary" type="primary"
@click="handleAdd" @click="handleAdd"
> >
@@ -34,6 +35,7 @@
> >
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
v-if="userStore.hasPermission('category:manage')"
type="primary" type="primary"
text text
@click="handleEdit(row)" @click="handleEdit(row)"
@@ -41,6 +43,7 @@
编辑 编辑
</el-button> </el-button>
<el-button <el-button
v-if="userStore.hasPermission('category:manage')"
type="primary" type="primary"
text text
@click="handleAddChild(row)" @click="handleAddChild(row)"
@@ -48,6 +51,7 @@
添加子分类 添加子分类
</el-button> </el-button>
<el-button <el-button
v-if="userStore.hasPermission('category:manage')"
type="danger" type="danger"
text text
@click="handleDelete(row)" @click="handleDelete(row)"
@@ -120,8 +124,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores'
import { categoryApi, type Category } from '@/api/category.ts' import { categoryApi, type Category } from '@/api/category.ts'
const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
const categories = ref<Category[]>([]) const categories = ref<Category[]>([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
@@ -1,30 +1,5 @@
<template> <template>
<div class="comment-page"> <div class="comment-page">
<el-card
shadow="never"
class="hero-card"
>
<div class="hero-content">
<div>
<div class="eyebrow">
Comment Control
</div>
<h2>评论治理与互动内容清理</h2>
<p>快速检索评论、查看用户与视频关联信息,并对异常评论做删除处置。</p>
</div>
<div class="hero-metrics">
<div class="metric-box">
<strong>{{ total }}</strong>
<span>当前结果</span>
</div>
<div class="metric-box">
<strong>{{ queryParams.status === 0 ? '已删除' : queryParams.status === 1 ? '正常' : '全部' }}</strong>
<span>评论状态</span>
</div>
</div>
</div>
</el-card>
<el-card <el-card
shadow="never" shadow="never"
class="search-card" class="search-card"
@@ -32,8 +7,12 @@
<el-form <el-form
:model="queryParams" :model="queryParams"
inline inline
class="page-search-form"
> >
<el-form-item label="关键词"> <el-form-item
label="关键词"
class="keyword-item"
>
<el-input <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
placeholder="评论内容" placeholder="评论内容"
@@ -41,7 +20,10 @@
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
/> />
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item
label="状态"
class="status-item"
>
<el-select <el-select
v-model="queryParams.status" v-model="queryParams.status"
placeholder="全部" placeholder="全部"
@@ -57,7 +39,7 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item class="action-item">
<el-button <el-button
type="primary" type="primary"
@click="handleSearch" @click="handleSearch"
@@ -186,16 +168,33 @@
</el-table-column> </el-table-column>
<el-table-column <el-table-column
label="操作" label="操作"
width="100" width="140"
> >
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
v-if="userStore.hasPermission('comment:manage') && row.status === 1"
type="danger" type="danger"
text text
@click="handleDelete(row)" @click="handleDelete(row)"
> >
删除 删除
</el-button> </el-button>
<el-button
v-if="userStore.hasPermission('comment:manage') && row.status === 0"
type="primary"
text
@click="handleRestore(row)"
>
恢复
</el-button>
<el-button
v-if="userStore.hasPermission('comment:manage')"
type="info"
text
disabled
>
{{ row.isTop === 1 ? '已置顶' : '普通' }}
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -217,10 +216,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores'
import { commentApi } from '@/api/comment.ts' import { commentApi } from '@/api/comment.ts'
import { formatDateTime } from '@/utils/format.ts' import { formatDateTime } from '@/utils/format.ts'
import type { PlatformComment, CommentQueryParams } from '@/types' import type { PlatformComment, CommentQueryParams } from '@/types'
const userStore = useUserStore()
const queryParams = reactive<CommentQueryParams>({ page: 1, size: 10, keyword: '', status: undefined }) const queryParams = reactive<CommentQueryParams>({ page: 1, size: 10, keyword: '', status: undefined })
const loading = ref(false) const loading = ref(false)
const comments = ref<PlatformComment[]>([]) const comments = ref<PlatformComment[]>([])
@@ -253,77 +254,58 @@ const handleDelete = async (row: PlatformComment) => {
} catch {} } catch {}
} }
const handleRestore = async (row: PlatformComment) => {
try {
await ElMessageBox.confirm('确定要恢复该评论吗?', '提示', { type: 'info' })
await commentApi.restore(row.id)
ElMessage.success('恢复成功')
loadComments()
} catch {}
}
onMounted(() => loadComments()) onMounted(() => loadComments())
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.comment-page { .comment-page {
.hero-card {
margin-bottom: 20px;
:deep(.el-card__body) {
padding: 0;
}
}
.hero-content {
padding: 24px 28px;
border-radius: 22px;
background:
radial-gradient(circle at top right, rgba(244, 114, 182, 0.18), transparent 28%),
linear-gradient(135deg, #3f1d44, #7e22ce 58%, #ec4899);
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
h2 {
margin: 8px 0 10px;
font-size: 28px;
}
p {
margin: 0;
max-width: 720px;
color: rgba(255, 255, 255, 0.82);
line-height: 1.7;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
color: rgba(255, 255, 255, 0.72);
}
}
.hero-metrics {
display: flex;
gap: 12px;
}
.metric-box {
min-width: 96px;
padding: 12px 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.12);
display: grid;
gap: 4px;
strong {
font-size: 22px;
color: #fff;
}
span {
font-size: 12px;
color: rgba(255, 255, 255, 0.78);
}
}
.search-card { .search-card {
margin-bottom: 20px; margin-bottom: 20px;
width: 100%;
}
.page-search-form {
display: grid;
grid-template-columns: minmax(260px, 1.5fr) 180px auto;
gap: 14px 18px;
align-items: center;
:deep(.el-form-item) {
margin: 0;
}
:deep(.el-form-item__content) {
width: 100%;
min-width: 0;
}
:deep(.el-input),
:deep(.el-select) {
width: 100%;
}
.action-item {
justify-self: end;
:deep(.el-form-item__content) {
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
}
.action-item :deep(.el-button + .el-button) {
margin-left: 0;
} }
.overview-row { .overview-row {
@@ -379,13 +361,31 @@ onMounted(() => loadComments())
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
}
@media (max-width: 768px) { @media (max-width: 1280px) {
.comment-page { .page-search-form {
.hero-content { grid-template-columns: minmax(240px, 1fr) 180px;
flex-direction: column;
align-items: flex-start; .action-item {
grid-column: 1 / -1;
justify-self: start;
}
}
}
@media (max-width: 768px) {
.page-search-form {
grid-template-columns: 1fr;
.action-item {
grid-column: auto;
justify-self: stretch;
}
.action-item :deep(.el-form-item__content) {
justify-content: flex-start;
flex-wrap: wrap;
}
} }
} }
} }
@@ -25,6 +25,7 @@
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-button <el-button
v-if="userStore.hasPermission('tag:manage')"
type="primary" type="primary"
@click="handleAdd" @click="handleAdd"
> >
@@ -66,6 +67,7 @@
> >
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
v-if="userStore.hasPermission('tag:manage')"
type="primary" type="primary"
text text
@click="handleEdit(row)" @click="handleEdit(row)"
@@ -73,6 +75,7 @@
编辑 编辑
</el-button> </el-button>
<el-button <el-button
v-if="userStore.hasPermission('tag:manage')"
type="danger" type="danger"
text text
@click="handleDelete(row)" @click="handleDelete(row)"
@@ -132,9 +135,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores'
import { categoryApi, type Tag } from '@/api/category.ts' import { categoryApi, type Tag } from '@/api/category.ts'
import { formatDateTime } from '@/utils/format.ts' import { formatDateTime } from '@/utils/format.ts'
const userStore = useUserStore()
const loading = ref(false) const loading = ref(false)
const tags = ref<Tag[]>([]) const tags = ref<Tag[]>([])
const total = ref(0) const total = ref(0)
@@ -1,34 +1,5 @@
<template> <template>
<div class="dashboard-page"> <div class="dashboard-page">
<el-card
shadow="never"
class="hero-card"
>
<div class="hero-content">
<div>
<div class="eyebrow">
Platform Overview
</div>
<h2>HAOVP 平台治理总览</h2>
<p>集中查看用户增长、内容分发、播放热度与审核压力,支撑平台运营与内容治理决策。</p>
</div>
<div class="hero-badges">
<el-tag
type="primary"
effect="dark"
>
统一运营视图
</el-tag>
<el-tag
type="success"
effect="dark"
>
实时业务概览
</el-tag>
</div>
</div>
</el-card>
<el-row <el-row
:gutter="20" :gutter="20"
class="overview-row" class="overview-row"
@@ -44,10 +15,11 @@
<div class="overview-head"> <div class="overview-head">
<div> <div>
<strong>运营看板</strong> <strong>运营看板</strong>
<span>从用户、内容、播放和审核四条主链快速查看平台状态。</span> <span>聚合用户、内容与审核核心数据。</span>
</div> </div>
<div class="overview-actions"> <div class="overview-actions">
<el-button <el-button
v-if="userStore.hasPermission('video:manage')"
type="primary" type="primary"
text text
@click="goToVideoList" @click="goToVideoList"
@@ -55,6 +27,7 @@
视频列表 视频列表
</el-button> </el-button>
<el-button <el-button
v-if="userStore.hasPermission('video:manage')"
type="primary" type="primary"
text text
@click="goToReview" @click="goToReview"
@@ -74,11 +47,11 @@
<strong>{{ statistics.pendingReview }}</strong> <strong>{{ statistics.pendingReview }}</strong>
</div> </div>
<div class="overview-item"> <div class="overview-item">
<span>总评论数</span> <span>总评论</span>
<strong>{{ formatNumber(statistics.totalComments) }}</strong> <strong>{{ formatNumber(statistics.totalComments) }}</strong>
</div> </div>
<div class="overview-item"> <div class="overview-item">
<span>总弹幕数</span> <span>总弹幕</span>
<strong>{{ formatNumber(statistics.totalDanmakus) }}</strong> <strong>{{ formatNumber(statistics.totalDanmakus) }}</strong>
</div> </div>
</div> </div>
@@ -95,7 +68,7 @@
<div class="overview-head"> <div class="overview-head">
<div> <div>
<strong>治理提醒</strong> <strong>治理提醒</strong>
<span>优先处理最影响运营节奏的问题。</span> <span>优先处理待办与新增波动。</span>
</div> </div>
</div> </div>
<div class="alert-list"> <div class="alert-list">
@@ -112,6 +85,24 @@
<strong>{{ statistics.newUsersToday }}</strong> <strong>{{ statistics.newUsersToday }}</strong>
</div> </div>
</div> </div>
<div class="alert-actions">
<el-button
v-if="userStore.hasPermission('video:manage')"
type="primary"
text
@click="goToReview"
>
处理审核
</el-button>
<el-button
v-if="userStore.hasPermission('video:manage')"
type="primary"
text
@click="goToVideoList"
>
查看内容
</el-button>
</div>
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
@@ -242,6 +233,7 @@
</div> </div>
<div class="stat-footer"> <div class="stat-footer">
<el-button <el-button
v-if="userStore.hasPermission('video:manage')"
type="primary" type="primary"
size="small" size="small"
text text
@@ -326,6 +318,7 @@
<div class="card-header"> <div class="card-header">
<span>热门视频</span> <span>热门视频</span>
<el-button <el-button
v-if="userStore.hasPermission('video:manage')"
type="primary" type="primary"
text text
@click="goToVideoList" @click="goToVideoList"
@@ -379,6 +372,7 @@
<div class="card-header"> <div class="card-header">
<span>最新评论</span> <span>最新评论</span>
<el-button <el-button
v-if="userStore.hasPermission('comment:manage')"
type="primary" type="primary"
text text
@click="goToCommentList" @click="goToCommentList"
@@ -422,11 +416,13 @@
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue' import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { useUserStore } from '@/stores'
import { statisticsApi } from '@/api/statistics.ts' import { statisticsApi } from '@/api/statistics.ts'
import { formatNumber, formatTime } from '@/utils/format.ts' import { formatNumber, formatTime } from '@/utils/format.ts'
import type { StatisticsData, PlatformVideo, PlatformComment, TrendData } from '@/types' import type { StatisticsData, PlatformVideo, PlatformComment, TrendData } from '@/types'
const router = useRouter() const router = useRouter()
const userStore = useUserStore()
// 统计数据 // 统计数据
const statistics = ref<StatisticsData>({ const statistics = ref<StatisticsData>({
@@ -632,9 +628,13 @@ onUnmounted(() => {
.overview-card { .overview-card {
border-radius: 20px; border-radius: 20px;
height: 100%;
:deep(.el-card__body) { :deep(.el-card__body) {
padding: 22px; padding: 22px;
height: 100%;
display: grid;
align-content: start;
} }
} }
@@ -666,7 +666,7 @@ onUnmounted(() => {
.overview-grid { .overview-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; gap: 12px;
} }
@@ -694,50 +694,11 @@ onUnmounted(() => {
gap: 10px; gap: 10px;
} }
.hero-card { .alert-actions {
margin-bottom: 20px; margin-top: 14px;
:deep(.el-card__body) {
padding: 0;
}
}
.hero-content {
padding: 24px 28px;
border-radius: 22px;
background:
radial-gradient(circle at top right, rgba(56, 189, 248, 0.18), transparent 28%),
linear-gradient(135deg, #0f172a, #1d4ed8 58%, #0f766e);
color: #fff;
display: flex; display: flex;
align-items: center; justify-content: flex-end;
justify-content: space-between; gap: 12px;
gap: 20px;
h2 {
margin: 8px 0 10px;
font-size: 28px;
}
p {
margin: 0;
max-width: 720px;
color: rgba(255, 255, 255, 0.82);
line-height: 1.7;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
color: rgba(255, 255, 255, 0.72);
}
}
.hero-badges {
display: flex;
gap: 10px;
align-self: flex-start;
} }
.stat-cards { .stat-cards {
@@ -837,7 +798,6 @@ onUnmounted(() => {
@media (max-width: 768px) { @media (max-width: 768px) {
.dashboard-page { .dashboard-page {
.hero-content,
.overview-head { .overview-head {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -846,6 +806,10 @@ onUnmounted(() => {
.overview-grid { .overview-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.alert-actions {
justify-content: flex-start;
}
} }
} }
</style> </style>
@@ -1,12 +1,18 @@
<template> <template>
<div class="admin-layout"> <div
class="admin-layout"
:class="{
collapsed: appStore.sidebarCollapsed,
mobile: appStore.device === 'mobile'
}"
>
<aside <aside
class="layout-sidebar" class="layout-sidebar"
:class="{ collapsed: appStore.sidebarCollapsed }" :class="{ collapsed: appStore.sidebarCollapsed }"
> >
<div class="sidebar-topline"> <div class="sidebar-topline">
<span class="topline-pill">ADMIN</span> <span class="topline-pill">ADMIN</span>
<span v-if="!appStore.sidebarCollapsed">治理与运营控制台</span> <span v-if="!appStore.sidebarCollapsed">管理控制台</span>
</div> </div>
<div class="sidebar-brand"> <div class="sidebar-brand">
@@ -38,6 +44,8 @@
<el-sub-menu <el-sub-menu
v-if="route.children && route.children.length > 1" v-if="route.children && route.children.length > 1"
:index="route.path" :index="route.path"
:popper-class="appStore.sidebarCollapsed ? 'sidebar-menu-popper' : ''"
:popper-offset="12"
> >
<template #title> <template #title>
<el-icon> <el-icon>
@@ -75,6 +83,7 @@
<div class="header-left"> <div class="header-left">
<button <button
class="collapse-btn" class="collapse-btn"
:aria-label="appStore.sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'"
@click="toggleSidebar" @click="toggleSidebar"
> >
<el-icon> <el-icon>
@@ -87,7 +96,7 @@
@click="searchVisible = true" @click="searchVisible = true"
> >
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
<span>搜索后台页面与管理能力</span> <span>搜索页面、功能与命令</span>
<kbd>/</kbd> <kbd>/</kbd>
</button> </button>
</div> </div>
@@ -98,7 +107,7 @@
<div class="header-right"> <div class="header-right">
<div class="header-chip"> <div class="header-chip">
<strong>{{ currentSection }}</strong> <strong>{{ currentWorkspaceLabel }}</strong>
<span>当前工作区</span> <span>当前工作区</span>
</div> </div>
<div class="header-chip"> <div class="header-chip">
@@ -125,10 +134,20 @@
</button> </button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="admins"> <el-dropdown-item
v-if="userStore.hasPermission('user:manage')"
command="admins"
>
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
管理员管理 管理员管理
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item
v-if="userStore.hasPermission('role:manage')"
command="roles"
>
<el-icon><Key /></el-icon>
角色管理
</el-dropdown-item>
<el-dropdown-item <el-dropdown-item
divided divided
command="logout" command="logout"
@@ -143,9 +162,9 @@
</header> </header>
<section class="layout-subbar"> <section class="layout-subbar">
<div class="subbar-copy"> <div class="subbar-copy compact">
<span class="subbar-pill">HAOVP Admin</span> <span class="subbar-pill">{{ currentSection }}</span>
<span class="subbar-text">集中处理用户、视频、评论与系统治理链路。</span> <span class="subbar-count">{{ visitedViews.length }} 个标签</span>
</div> </div>
<TagsView /> <TagsView />
</section> </section>
@@ -228,12 +247,30 @@ const userStore = useUserStore()
const appStore = useAppStore() const appStore = useAppStore()
const tagsViewStore = useTagsViewStore() const tagsViewStore = useTagsViewStore()
const activeMenu = computed(() => route.path) const activeMenu = computed(() => String(route.meta?.activeMenu || route.path))
const menuRoutes = computed(() => asyncRoutes.filter((item) => !item.meta?.hidden && item.children?.length)) const hasRouteAccess = (item: any) => {
const permission = item.meta?.permission as string | undefined
if (!permission) return true
return userStore.hasPermission(permission)
}
const menuRoutes = computed(() =>
asyncRoutes
.filter((item) => !item.meta?.hidden && item.children?.length)
.map((item) => ({
...item,
children: (item.children || []).filter((child: any) => !child.meta?.hidden && hasRouteAccess(child))
}))
.filter((item) => item.children && item.children.length)
)
const visitedViews = computed(() => tagsViewStore.visitedViews)
const cachedViews = computed(() => tagsViewStore.cachedViews) const cachedViews = computed(() => tagsViewStore.cachedViews)
const key = computed(() => route.fullPath) const key = computed(() => route.fullPath)
const currentSection = computed(() => (route.meta?.title as string) || '仪表盘') const currentSection = computed(() => (route.meta?.title as string) || '仪表盘')
const currentRoleLabel = computed(() => userStore.userInfo?.roleName || userStore.role || '管理员') const currentRoleLabel = computed(() =>
userStore.userInfo?.roleNames?.join(' / ') || userStore.userInfo?.roleName || userStore.role || '管理员'
)
const currentWorkspaceLabel = computed(() => (route.matched[0]?.meta?.title as string) || '当前工作区')
const searchVisible = ref(false) const searchVisible = ref(false)
const searchKeyword = ref('') const searchKeyword = ref('')
const searchInputRef = ref<HTMLInputElement | null>(null) const searchInputRef = ref<HTMLInputElement | null>(null)
@@ -247,7 +284,7 @@ const resolveMenuPath = (parentPath: string, childPath: string) => {
const searchItems = computed(() => const searchItems = computed(() =>
asyncRoutes.flatMap((route) => asyncRoutes.flatMap((route) =>
(route.children || []) (route.children || [])
.filter((child: any) => !child.meta?.hidden) .filter((child: any) => !child.meta?.hidden && hasRouteAccess(child))
.map((child: any) => ({ .map((child: any) => ({
path: resolveMenuPath(route.path, child.path), path: resolveMenuPath(route.path, child.path),
title: String(child.meta?.title || ''), title: String(child.meta?.title || ''),
@@ -286,6 +323,9 @@ const handleCommand = async (command: string) => {
case 'admins': case 'admins':
router.push('/system/admin') router.push('/system/admin')
break break
case 'roles':
router.push('/system/role')
break
case 'logout': case 'logout':
try { try {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', { await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
@@ -343,10 +383,20 @@ onUnmounted(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.admin-layout { .admin-layout {
--sidebar-width: 248px;
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
grid-template-columns: 248px minmax(0, 1fr); grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
background: #f4f7fb; background: #f4f7fb;
transition: grid-template-columns 0.28s ease;
&.collapsed {
--sidebar-width: 84px;
}
&.mobile {
--sidebar-width: 72px;
}
} }
.layout-sidebar { .layout-sidebar {
@@ -355,13 +405,13 @@ onUnmounted(() => {
#0f172a; #0f172a;
color: #e2e8f0; color: #e2e8f0;
border-right: 1px solid rgba(148, 163, 184, 0.12); border-right: 1px solid rgba(148, 163, 184, 0.12);
position: sticky;
top: 0;
height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: width 0.3s ease; transition: width 0.28s ease;
z-index: 20;
&.collapsed {
width: 72px;
}
} }
.sidebar-topline { .sidebar-topline {
@@ -425,25 +475,115 @@ onUnmounted(() => {
.sidebar-menu { .sidebar-menu {
border-right: none; border-right: none;
background: transparent; background: transparent;
padding: 8px 0 18px;
:deep(.el-menu) {
background: transparent;
border-right: none;
}
:deep(.el-menu-item), :deep(.el-menu-item),
:deep(.el-sub-menu__title) { :deep(.el-sub-menu__title) {
color: #cbd5e1; height: 50px;
border-radius: 12px; color: #dbe7f5;
margin: 4px 10px; border-radius: 14px;
margin: 6px 10px;
padding-left: 16px !important;
font-weight: 500;
transition: background-color 0.18s ease, color 0.18s ease;
&:hover { &:hover {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.07);
color: #fff;
} }
} }
:deep(.el-menu-item.is-active) { :deep(.el-menu-item.is-active) {
color: #8bd3ff; color: #f8fbff;
background: linear-gradient(90deg, rgba(59, 130, 246, 0.22), rgba(14, 165, 233, 0.08)); background: linear-gradient(90deg, rgba(30, 64, 175, 0.52), rgba(14, 165, 233, 0.18));
box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.16);
}
:deep(.el-sub-menu.is-opened > .el-sub-menu__title) {
background: rgba(255, 255, 255, 0.06);
color: #fff;
}
:deep(.el-sub-menu .el-menu) {
margin: 6px 10px 8px;
padding: 8px 0;
border-radius: 18px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03));
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.08);
} }
:deep(.el-sub-menu .el-menu-item) { :deep(.el-sub-menu .el-menu-item) {
background: rgba(255, 255, 255, 0.02); height: 44px;
margin-left: 10px;
margin-right: 10px;
padding-left: 24px !important;
background: transparent;
color: #d2dceb;
&::before {
content: '';
width: 6px;
height: 6px;
margin-right: 10px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.45);
flex-shrink: 0;
}
}
:deep(.el-sub-menu .el-menu-item.is-active::before) {
background: #8bd3ff;
}
:deep(.el-sub-menu .el-menu-item:hover) {
background: rgba(255, 255, 255, 0.06);
}
:deep(.el-sub-menu .el-menu-item.is-active) {
background: linear-gradient(90deg, rgba(30, 64, 175, 0.42), rgba(14, 165, 233, 0.12));
}
:deep(.el-menu-item .el-icon),
:deep(.el-sub-menu__title .el-icon) {
font-size: 17px;
}
:deep(.el-menu--collapse .el-menu-item),
:deep(.el-menu--collapse .el-sub-menu__title) {
padding-left: 0 !important;
justify-content: center;
margin-left: 12px;
margin-right: 12px;
}
:deep(.el-menu--collapse .el-sub-menu__title .el-sub-menu__icon-arrow) {
display: none;
}
:deep(.el-menu--popup) {
border-radius: 16px;
padding: 10px;
background: #111827;
border: 1px solid rgba(148, 163, 184, 0.12);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
}
:deep(.el-menu--popup .el-menu-item) {
min-width: 168px;
margin: 0 0 6px;
padding-left: 16px !important;
color: #dbe7f5;
background: transparent;
border-radius: 12px;
}
:deep(.el-menu--popup .el-menu-item:last-child) {
margin-bottom: 0;
} }
} }
@@ -451,6 +591,7 @@ onUnmounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 100vh;
} }
.layout-header { .layout-header {
@@ -477,14 +618,22 @@ onUnmounted(() => {
} }
.collapse-btn { .collapse-btn {
width: 38px; width: 42px;
height: 38px; height: 42px;
border-radius: 12px; border-radius: 14px;
background: #f8fafc; background: linear-gradient(180deg, #ffffff, #f8fafc);
color: #475569; color: #334155;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid rgba(148, 163, 184, 0.2);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
&:hover {
background: #fff;
color: #0f172a;
transform: translateY(-1px);
}
} }
.header-search { .header-search {
@@ -575,7 +724,7 @@ onUnmounted(() => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
padding: 10px 20px; padding: 12px 20px;
background: #fff; background: #fff;
border-bottom: 1px solid rgba(226, 232, 240, 0.78); border-bottom: 1px solid rgba(226, 232, 240, 0.78);
} }
@@ -585,6 +734,10 @@ onUnmounted(() => {
align-items: center; align-items: center;
gap: 10px; gap: 10px;
min-width: 0; min-width: 0;
&.compact {
flex-shrink: 0;
}
} }
.subbar-pill { .subbar-pill {
@@ -596,12 +749,9 @@ onUnmounted(() => {
white-space: nowrap; white-space: nowrap;
} }
.subbar-text { .subbar-count {
font-size: 12px; font-size: 12px;
color: #64748b; color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.layout-content { .layout-content {
@@ -610,7 +760,7 @@ onUnmounted(() => {
background: background:
radial-gradient(circle at top, rgba(14, 165, 233, 0.06), transparent 30%), radial-gradient(circle at top, rgba(14, 165, 233, 0.06), transparent 30%),
#f4f7fb; #f4f7fb;
overflow: auto; overflow: visible;
} }
.fade-transform-enter-active, .fade-transform-enter-active,
@@ -629,14 +779,6 @@ onUnmounted(() => {
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.admin-layout {
grid-template-columns: 88px minmax(0, 1fr);
}
.layout-sidebar {
width: 88px;
}
.sidebar-topline span:last-child, .sidebar-topline span:last-child,
.brand-copy, .brand-copy,
.header-chip, .header-chip,
@@ -751,4 +893,40 @@ onUnmounted(() => {
padding: 16px; padding: 16px;
} }
} }
:global(.sidebar-menu-popper.el-menu--popup) {
min-width: 190px;
padding: 10px;
border-radius: 18px;
background:
linear-gradient(180deg, rgba(17, 24, 39, 0.98), rgba(15, 23, 42, 0.98)),
#111827;
border: 1px solid rgba(148, 163, 184, 0.14);
box-shadow: 0 22px 46px rgba(15, 23, 42, 0.28);
}
:global(.sidebar-menu-popper.el-menu--popup .el-menu-item) {
height: 42px;
line-height: 42px;
margin: 0 0 6px;
padding: 0 14px !important;
border-radius: 12px;
color: #dbe7f5;
background: transparent;
transition: background-color 0.18s ease, color 0.18s ease;
}
:global(.sidebar-menu-popper.el-menu--popup .el-menu-item:last-child) {
margin-bottom: 0;
}
:global(.sidebar-menu-popper.el-menu--popup .el-menu-item:hover) {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
:global(.sidebar-menu-popper.el-menu--popup .el-menu-item.is-active) {
background: linear-gradient(90deg, rgba(30, 64, 175, 0.52), rgba(14, 165, 233, 0.18));
color: #f8fbff;
}
</style> </style>
@@ -47,28 +47,6 @@
:rules="loginRules" :rules="loginRules"
class="login-form" class="login-form"
> >
<div class="captcha-banner">
<div>
<strong>安全验证</strong>
<span>当前后台登录必须填写验证码,点击图片可刷新。</span>
</div>
<el-image
:src="captchaImage"
fit="contain"
class="captcha-image"
@click="refreshCaptcha"
>
<template #error>
<div
class="captcha-error"
@click="refreshCaptcha"
>
点击刷新
</div>
</template>
</el-image>
</div>
<el-form-item prop="username"> <el-form-item prop="username">
<el-input <el-input
v-model="loginForm.username" v-model="loginForm.username"
@@ -192,7 +170,6 @@ const handleLogin = async () => {
const redirect = (route.query.redirect as string) || '/' const redirect = (route.query.redirect as string) || '/'
router.replace(redirect) router.replace(redirect)
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.message || '登录失败')
refreshCaptcha() refreshCaptcha()
} finally { } finally {
loading.value = false loading.value = false
@@ -317,49 +294,6 @@ onMounted(() => {
line-height: 1.7; line-height: 1.7;
} }
.captcha-banner {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 18px;
padding: 14px 16px;
border-radius: 18px;
background: #f8fafc;
strong {
display: block;
margin-bottom: 4px;
color: #18191c;
font-size: 14px;
}
span {
color: #64748b;
font-size: 12px;
line-height: 1.6;
}
}
.captcha-image {
width: 120px;
height: 42px;
cursor: pointer;
flex-shrink: 0;
}
.captcha-error {
width: 120px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
background: #eef2f7;
color: #64748b;
font-size: 12px;
border-radius: 12px;
}
.captcha-btn { .captcha-btn {
width: 100px; width: 100px;
height: 32px; height: 32px;
@@ -408,9 +342,5 @@ onMounted(() => {
padding: 22px 18px 20px; padding: 22px 18px 20px;
} }
.captcha-banner {
flex-direction: column;
align-items: flex-start;
}
} }
</style> </style>
@@ -25,6 +25,7 @@
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-button <el-button
v-if="userStore.hasPermission('user:manage')"
type="primary" type="primary"
@click="handleAdd" @click="handleAdd"
> >
@@ -77,10 +78,17 @@
/> />
<el-table-column <el-table-column
label="角色" label="角色"
width="100" width="180"
> >
<template #default="{ row }"> <template #default="{ row }">
<el-tag>{{ row.roleName || (row.role === 'admin' ? '超级管理员' : '管理员') }}</el-tag> <el-space wrap>
<el-tag
v-for="roleName in (row.roleNames?.length ? row.roleNames : [row.roleName || row.role || '未分配'])"
:key="roleName"
>
{{ roleName }}
</el-tag>
</el-space>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column
@@ -108,6 +116,7 @@
> >
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
v-if="userStore.hasPermission('user:manage')"
type="primary" type="primary"
text text
@click="handleEdit(row)" @click="handleEdit(row)"
@@ -115,6 +124,7 @@
编辑 编辑
</el-button> </el-button>
<el-button <el-button
v-if="userStore.hasPermission('user:manage')"
:type="row.status === 1 ? 'warning' : 'success'" :type="row.status === 1 ? 'warning' : 'success'"
text text
@click="handleToggleStatus(row)" @click="handleToggleStatus(row)"
@@ -122,6 +132,7 @@
{{ row.status === 1 ? '禁用' : '启用' }} {{ row.status === 1 ? '禁用' : '启用' }}
</el-button> </el-button>
<el-button <el-button
v-if="userStore.hasPermission('user:manage')"
type="warning" type="warning"
text text
@click="handleResetPassword(row)" @click="handleResetPassword(row)"
@@ -193,19 +204,18 @@
</el-form-item> </el-form-item>
<el-form-item <el-form-item
label="角色" label="角色"
prop="role" prop="roleIds"
> >
<el-select <el-select
v-model="form.role" v-model="form.roleIds"
placeholder="请选择角色" placeholder="请选择角色"
multiple
> >
<el-option <el-option
label="管理员" v-for="role in roleOptions"
value="manager" :key="role.id"
/> :label="role.name"
<el-option :value="role.id"
label="超级管理员"
value="admin"
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -242,12 +252,17 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { adminApi, type AdminUser } from '@/api/admin.ts' import { adminApi, type AdminUser } from '@/api/admin.ts'
import { roleApi } from '@/api/role.ts'
import { useUserStore } from '@/stores'
import { formatDateTime } from '@/utils/format.ts' import { formatDateTime } from '@/utils/format.ts'
import type { AdminRole } from '@/types'
const userStore = useUserStore()
const queryParams = reactive({ page: 1, size: 10, keyword: '' }) const queryParams = reactive({ page: 1, size: 10, keyword: '' })
const loading = ref(false) const loading = ref(false)
const adminList = ref<AdminUser[]>([]) const adminList = ref<AdminUser[]>([])
const total = ref(0) const total = ref(0)
const roleOptions = ref<AdminRole[]>([])
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogTitle = ref('添加管理员') const dialogTitle = ref('添加管理员')
const submitting = ref(false) const submitting = ref(false)
@@ -258,14 +273,14 @@ const form = reactive({
nickname: '', nickname: '',
email: '', email: '',
phone: '', phone: '',
role: 'manager', roleIds: [] as number[],
password: '' password: ''
}) })
const rules: FormRules = { const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }], nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }], roleIds: [{ required: true, message: '请选择角色', trigger: 'change' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }] password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
} }
@@ -282,6 +297,14 @@ const loadAdminList = async () => {
} }
} }
const loadRoleOptions = async () => {
try {
roleOptions.value = await roleApi.getAll()
} catch (error) {
ElMessage.error('加载角色失败')
}
}
const handleSearch = () => { queryParams.page = 1; loadAdminList() } const handleSearch = () => { queryParams.page = 1; loadAdminList() }
const handlePageChange = (page: number) => { queryParams.page = page; loadAdminList() } const handlePageChange = (page: number) => { queryParams.page = page; loadAdminList() }
@@ -291,7 +314,7 @@ const resetForm = () => {
form.nickname = '' form.nickname = ''
form.email = '' form.email = ''
form.phone = '' form.phone = ''
form.role = 'manager' form.roleIds = []
form.password = '' form.password = ''
} }
@@ -307,7 +330,7 @@ const handleEdit = (row: AdminUser) => {
form.nickname = row.nickname form.nickname = row.nickname
form.email = row.email || '' form.email = row.email || ''
form.phone = row.phone || '' form.phone = row.phone || ''
form.role = row.role form.roleIds = row.roleIds || []
form.password = '' form.password = ''
dialogTitle.value = '编辑管理员' dialogTitle.value = '编辑管理员'
dialogVisible.value = true dialogVisible.value = true
@@ -339,10 +362,24 @@ const handleSubmit = async () => {
submitting.value = true submitting.value = true
try { try {
if (form.id) { if (form.id) {
await adminApi.update({ id: form.id, username: form.username, nickname: form.nickname, email: form.email, phone: form.phone, role: form.role }) await adminApi.update({
id: form.id,
username: form.username,
nickname: form.nickname,
email: form.email,
phone: form.phone,
roleIds: form.roleIds
})
ElMessage.success('更新成功') ElMessage.success('更新成功')
} else { } else {
await adminApi.create({ username: form.username, password: form.password, nickname: form.nickname, email: form.email, phone: form.phone, role: form.role }) await adminApi.create({
username: form.username,
password: form.password,
nickname: form.nickname,
email: form.email,
phone: form.phone,
roleIds: form.roleIds
})
ElMessage.success('添加成功') ElMessage.success('添加成功')
} }
dialogVisible.value = false dialogVisible.value = false
@@ -355,7 +392,10 @@ const handleSubmit = async () => {
}) })
} }
onMounted(() => loadAdminList()) onMounted(() => {
loadRoleOptions()
loadAdminList()
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -0,0 +1,351 @@
<template>
<div class="role-page">
<el-card shadow="never">
<template #header>
<div class="card-header">
<el-form
:inline="true"
:model="queryParams"
>
<el-form-item>
<el-input
v-model="queryParams.keyword"
placeholder="角色名称/编码"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="handleSearch"
>
搜索
</el-button>
</el-form-item>
</el-form>
<el-button
type="primary"
@click="handleAdd"
>
<el-icon><Plus /></el-icon>新增角色
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="roleList"
>
<el-table-column
prop="id"
label="ID"
width="80"
/>
<el-table-column
prop="name"
label="角色名称"
width="180"
/>
<el-table-column
prop="code"
label="角色编码"
width="180"
/>
<el-table-column
prop="description"
label="描述"
min-width="220"
/>
<el-table-column
label="权限"
min-width="260"
>
<template #default="{ row }">
<el-space wrap>
<el-tag
v-for="permission in row.permissions"
:key="permission.id"
>
{{ permission.name }}
</el-tag>
</el-space>
</template>
</el-table-column>
<el-table-column
label="操作"
width="220"
>
<template #default="{ row }">
<el-button
type="primary"
text
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="warning"
text
@click="handleAuthorize(row)"
>
授权
</el-button>
<el-button
type="danger"
text
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="620px"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="90px"
>
<el-form-item
label="角色名称"
prop="name"
>
<el-input
v-model="form.name"
placeholder="请输入角色名称"
/>
</el-form-item>
<el-form-item
label="角色编码"
prop="code"
>
<el-input
v-model="form.code"
placeholder="请输入角色编码,如 VIDEO_REVIEWER"
/>
</el-form-item>
<el-form-item label="描述">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入角色描述"
/>
</el-form-item>
<el-form-item
label="权限"
prop="permissionIds"
>
<el-select
v-model="form.permissionIds"
multiple
filterable
placeholder="请选择权限"
style="width: 100%;"
>
<el-option
v-for="permission in permissions"
:key="permission.id"
:label="`${permission.name} (${permission.code})`"
:value="permission.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">
取消
</el-button>
<el-button
type="primary"
:loading="submitting"
@click="handleSubmit"
>
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { roleApi } from '@/api/role.ts'
import type { AdminPermission, AdminRole } from '@/types'
const queryParams = reactive({ page: 1, size: 10, keyword: '' })
const loading = ref(false)
const submitting = ref(false)
const total = ref(0)
const roleList = ref<AdminRole[]>([])
const permissions = ref<AdminPermission[]>([])
const dialogVisible = ref(false)
const dialogTitle = ref('新增角色')
const formRef = ref<FormInstance>()
const form = reactive({
id: undefined as number | undefined,
name: '',
code: '',
description: '',
permissionIds: [] as number[]
})
const rules: FormRules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
permissionIds: [{ required: true, message: '请选择权限', trigger: 'change' }]
}
const loadRoles = async () => {
loading.value = true
try {
const result = await roleApi.getList(queryParams)
roleList.value = result.records
total.value = result.total
} catch {
ElMessage.error('加载角色失败')
} finally {
loading.value = false
}
}
const loadPermissions = async () => {
try {
permissions.value = await roleApi.getPermissions()
} catch {
ElMessage.error('加载权限失败')
}
}
const resetForm = () => {
form.id = undefined
form.name = ''
form.code = ''
form.description = ''
form.permissionIds = []
}
const handleSearch = () => {
queryParams.page = 1
loadRoles()
}
const handlePageChange = (page: number) => {
queryParams.page = page
loadRoles()
}
const handleAdd = () => {
resetForm()
dialogTitle.value = '新增角色'
dialogVisible.value = true
}
const fillForm = (row: AdminRole) => {
form.id = row.id
form.name = row.name
form.code = row.code
form.description = row.description || ''
form.permissionIds = row.permissionIds || []
}
const handleEdit = (row: AdminRole) => {
fillForm(row)
dialogTitle.value = '编辑角色'
dialogVisible.value = true
}
const handleAuthorize = (row: AdminRole) => {
fillForm(row)
dialogTitle.value = '角色授权'
dialogVisible.value = true
}
const handleDelete = async (row: AdminRole) => {
try {
await ElMessageBox.confirm(`确定删除角色「${row.name}」吗?`, '提示', { type: 'warning' })
await roleApi.delete(row.id)
ElMessage.success('删除成功')
loadRoles()
} catch {
// ignore
}
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
if (form.id) {
await roleApi.update({
id: form.id,
name: form.name,
code: form.code,
description: form.description,
permissionIds: form.permissionIds
})
await roleApi.updatePermissions(form.id, form.permissionIds)
ElMessage.success('更新成功')
} else {
await roleApi.create({
name: form.name,
code: form.code,
description: form.description,
permissionIds: form.permissionIds
})
ElMessage.success('新增成功')
}
dialogVisible.value = false
loadRoles()
} catch {
ElMessage.error('操作失败')
} finally {
submitting.value = false
}
})
}
onMounted(() => {
loadPermissions()
loadRoles()
})
</script>
<style scoped lang="scss">
.role-page {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
</style>
@@ -1,30 +1,5 @@
<template> <template>
<div class="user-list-page"> <div class="user-list-page">
<el-card
shadow="never"
class="hero-card"
>
<div class="hero-content">
<div>
<div class="eyebrow">
User Control
</div>
<h2>用户治理与账号状态管理</h2>
<p>集中处理用户检索、详情查看、状态启停与密码重置,适合后台运营与问题排查。</p>
</div>
<div class="hero-metrics">
<div class="metric-box">
<strong>{{ total }}</strong>
<span>当前结果</span>
</div>
<div class="metric-box">
<strong>{{ queryParams.status === 0 ? '禁用' : queryParams.status === 1 ? '正常' : '全部' }}</strong>
<span>筛选状态</span>
</div>
</div>
</div>
</el-card>
<el-card <el-card
shadow="never" shadow="never"
class="search-card" class="search-card"
@@ -32,8 +7,12 @@
<el-form <el-form
:model="queryParams" :model="queryParams"
inline inline
class="page-search-form"
> >
<el-form-item label="关键词"> <el-form-item
label="关键词"
class="keyword-item"
>
<el-input <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
placeholder="用户名/昵称/手机号" placeholder="用户名/昵称/手机号"
@@ -41,7 +20,10 @@
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
/> />
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item
label="状态"
class="status-item"
>
<el-select <el-select
v-model="queryParams.status" v-model="queryParams.status"
placeholder="全部" placeholder="全部"
@@ -57,7 +39,10 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="注册时间"> <el-form-item
label="注册时间"
class="date-item"
>
<el-date-picker <el-date-picker
v-model="dateRange" v-model="dateRange"
type="daterange" type="daterange"
@@ -68,7 +53,7 @@
@change="handleDateChange" @change="handleDateChange"
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item class="action-item">
<el-button <el-button
type="primary" type="primary"
@click="handleSearch" @click="handleSearch"
@@ -134,6 +119,7 @@
<div class="card-header"> <div class="card-header">
<span>用户列表</span> <span>用户列表</span>
<el-button <el-button
v-if="userStore.hasPermission('user:manage')"
type="primary" type="primary"
@click="handleExport" @click="handleExport"
> >
@@ -227,6 +213,7 @@
> >
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
v-if="userStore.hasPermission('user:manage')"
type="primary" type="primary"
text text
@click="handleDetail(row)" @click="handleDetail(row)"
@@ -234,6 +221,7 @@
详情 详情
</el-button> </el-button>
<el-button <el-button
v-if="userStore.hasPermission('user:manage')"
type="danger" type="danger"
text text
@click="handleStatus(row, 0)" @click="handleStatus(row, 0)"
@@ -241,6 +229,7 @@
设为禁用 设为禁用
</el-button> </el-button>
<el-button <el-button
v-if="userStore.hasPermission('user:manage')"
type="success" type="success"
text text
@click="handleStatus(row, 1)" @click="handleStatus(row, 1)"
@@ -248,6 +237,7 @@
设为正常 设为正常
</el-button> </el-button>
<el-button <el-button
v-if="userStore.hasPermission('user:manage')"
type="warning" type="warning"
text text
@click="handleResetPassword(row)" @click="handleResetPassword(row)"
@@ -357,10 +347,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores'
import { userApi } from '@/api/user.ts' import { userApi } from '@/api/user.ts'
import { formatDateTime, formatNumber } from '@/utils/format.ts' import { formatDateTime, formatNumber } from '@/utils/format.ts'
import type { PlatformUser, UserQueryParams } from '@/types' import type { PlatformUser, UserQueryParams } from '@/types'
const userStore = useUserStore()
// 查询参数 // 查询参数
const queryParams = reactive<UserQueryParams>({ const queryParams = reactive<UserQueryParams>({
page: 1, page: 1,
@@ -492,78 +485,51 @@ onMounted(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.user-list-page { .user-list-page {
.hero-card {
margin-bottom: 20px;
:deep(.el-card__body) {
padding: 0;
}
}
.hero-content {
padding: 24px 28px;
border-radius: 22px;
background:
radial-gradient(circle at top right, rgba(56, 189, 248, 0.18), transparent 28%),
linear-gradient(135deg, #0f172a, #1d4ed8 58%, #0f766e);
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
h2 {
margin: 8px 0 10px;
font-size: 28px;
}
p {
margin: 0;
max-width: 720px;
color: rgba(255, 255, 255, 0.82);
line-height: 1.7;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
color: rgba(255, 255, 255, 0.72);
}
}
.hero-metrics {
display: flex;
gap: 12px;
}
.metric-box {
min-width: 96px;
padding: 12px 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.12);
display: grid;
gap: 4px;
strong {
font-size: 22px;
color: #fff;
}
span {
font-size: 12px;
color: rgba(255, 255, 255, 0.78);
}
}
.search-card { .search-card {
margin-bottom: 20px; margin-bottom: 20px;
width: 100%;
:deep(.el-card__body) { :deep(.el-card__body) {
padding-bottom: 0; padding: 18px 20px;
} }
} }
.page-search-form {
display: grid;
grid-template-columns: minmax(260px, 1.35fr) 180px minmax(320px, 1.2fr) auto;
gap: 14px 18px;
align-items: center;
:deep(.el-form-item) {
margin: 0;
}
:deep(.el-form-item__content) {
width: 100%;
min-width: 0;
}
:deep(.el-input),
:deep(.el-select),
:deep(.el-date-editor.el-input__wrapper) {
width: 100%;
}
.action-item {
justify-self: end;
:deep(.el-form-item__content) {
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
}
.action-item :deep(.el-button + .el-button) {
margin-left: 0;
}
.overview-row { .overview-row {
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -628,13 +594,31 @@ onMounted(() => {
justify-content: flex-end; justify-content: flex-end;
} }
} }
}
@media (max-width: 768px) { @media (max-width: 1380px) {
.user-list-page { .page-search-form {
.hero-content { grid-template-columns: minmax(240px, 1fr) 180px minmax(280px, 1fr);
flex-direction: column;
align-items: flex-start; .action-item {
grid-column: 1 / -1;
justify-self: start;
}
}
}
@media (max-width: 768px) {
.page-search-form {
grid-template-columns: 1fr;
.action-item {
grid-column: auto;
justify-self: stretch;
}
.action-item :deep(.el-form-item__content) {
justify-content: flex-start;
flex-wrap: wrap;
}
} }
} }
} }
@@ -8,8 +8,12 @@
<el-form <el-form
:model="queryParams" :model="queryParams"
inline inline
class="page-search-form"
> >
<el-form-item label="关键词"> <el-form-item
label="关键词"
class="keyword-item"
>
<el-input <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
placeholder="视频标题" placeholder="视频标题"
@@ -17,7 +21,10 @@
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
/> />
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item
label="状态"
class="status-item"
>
<el-select <el-select
v-model="queryParams.status" v-model="queryParams.status"
placeholder="全部" placeholder="全部"
@@ -37,7 +44,7 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item class="action-item">
<el-button <el-button
type="primary" type="primary"
@click="handleSearch" @click="handleSearch"
@@ -155,6 +162,7 @@
> >
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
v-if="userStore.hasPermission('video:manage')"
type="primary" type="primary"
text text
@click="handleDetail(row)" @click="handleDetail(row)"
@@ -162,7 +170,7 @@
详情 详情
</el-button> </el-button>
<el-button <el-button
v-if="row.status === 1" v-if="row.status === 1 && userStore.hasPermission('video:manage')"
type="danger" type="danger"
text text
@click="handleDisable(row)" @click="handleDisable(row)"
@@ -192,12 +200,14 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores'
import { videoApi } from '@/api/video.ts' import { videoApi } from '@/api/video.ts'
import { formatDateTime, formatDuration, formatFileSize, formatNumber } from '@/utils/format.ts' import { formatDateTime, formatDuration, formatFileSize, formatNumber } from '@/utils/format.ts'
import { resolveMediaUrl } from '@/utils/media.ts' import { resolveMediaUrl } from '@/utils/media.ts'
import type { PlatformVideo, VideoQueryParams } from '@/types' import type { PlatformVideo, VideoQueryParams } from '@/types'
const router = useRouter() const router = useRouter()
const userStore = useUserStore()
const queryParams = reactive<VideoQueryParams>({ const queryParams = reactive<VideoQueryParams>({
page: 1, page: 1,
size: 10, size: 10,
@@ -264,7 +274,46 @@ onMounted(() => loadVideoList())
<style scoped lang="scss"> <style scoped lang="scss">
.video-list-page { .video-list-page {
.search-card { margin-bottom: 20px; } .search-card {
margin-bottom: 20px;
width: 100%;
}
.page-search-form {
display: grid;
grid-template-columns: minmax(260px, 1.5fr) 180px auto;
gap: 14px 18px;
align-items: center;
:deep(.el-form-item) {
margin: 0;
}
:deep(.el-form-item__content) {
width: 100%;
min-width: 0;
}
:deep(.el-input),
:deep(.el-select) {
width: 100%;
}
.action-item {
justify-self: end;
:deep(.el-form-item__content) {
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
}
.action-item :deep(.el-button + .el-button) {
margin-left: 0;
}
.video-info { .video-info {
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -275,5 +324,32 @@ onMounted(() => loadVideoList())
.author { display: flex; align-items: center; gap: 8px; } .author { display: flex; align-items: center; gap: 8px; }
.stats { font-size: 12px; color: #666; display: flex; gap: 12px; } .stats { font-size: 12px; color: #666; display: flex; gap: 12px; }
.pagination { margin-top: 20px; display: flex; justify-content: flex-end; } .pagination { margin-top: 20px; display: flex; justify-content: flex-end; }
@media (max-width: 1280px) {
.page-search-form {
grid-template-columns: minmax(240px, 1fr) 180px;
.action-item {
grid-column: 1 / -1;
justify-self: start;
}
}
}
@media (max-width: 768px) {
.page-search-form {
grid-template-columns: 1fr;
.action-item {
grid-column: auto;
justify-self: stretch;
}
.action-item :deep(.el-form-item__content) {
justify-content: flex-start;
flex-wrap: wrap;
}
}
}
} }
</style> </style>
@@ -1,30 +1,5 @@
<template> <template>
<div class="video-review-page"> <div class="video-review-page">
<el-card
shadow="never"
class="hero-card"
>
<div class="hero-content">
<div>
<div class="eyebrow">
Review Queue
</div>
<h2>视频审核与发布管控</h2>
<p>优先处理待审核内容,快速完成预览、通过、拒绝和详情查看,保持内容链路流畅。</p>
</div>
<div class="hero-metrics">
<div class="metric-box">
<strong>{{ total }}</strong>
<span>当前列表</span>
</div>
<div class="metric-box">
<strong>{{ pendingCount }}</strong>
<span>待审核</span>
</div>
</div>
</div>
</el-card>
<el-card <el-card
shadow="never" shadow="never"
class="search-card" class="search-card"
@@ -32,8 +7,12 @@
<el-form <el-form
:model="queryParams" :model="queryParams"
inline inline
class="review-search-form"
> >
<el-form-item label="关键词"> <el-form-item
label="关键词"
class="keyword-item"
>
<el-input <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
placeholder="视频标题" placeholder="视频标题"
@@ -41,7 +20,10 @@
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
/> />
</el-form-item> </el-form-item>
<el-form-item label="审核状态"> <el-form-item
label="审核状态"
class="status-item"
>
<el-select <el-select
v-model="queryParams.auditStatus" v-model="queryParams.auditStatus"
placeholder="全部" placeholder="全部"
@@ -61,7 +43,10 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="分类"> <el-form-item
label="分类"
class="category-item"
>
<el-select <el-select
v-model="queryParams.categoryId" v-model="queryParams.categoryId"
placeholder="全部" placeholder="全部"
@@ -75,7 +60,7 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item class="action-item">
<el-button <el-button
type="primary" type="primary"
@click="handleSearch" @click="handleSearch"
@@ -230,6 +215,7 @@
> >
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
v-if="userStore.hasPermission('video:manage')"
type="primary" type="primary"
text text
@click="handlePreview(row)" @click="handlePreview(row)"
@@ -237,7 +223,7 @@
预览 预览
</el-button> </el-button>
<el-button <el-button
v-if="row.auditStatus === 0" v-if="row.auditStatus === 0 && userStore.hasPermission('video:manage')"
type="success" type="success"
text text
@click="handleReview(row, 1)" @click="handleReview(row, 1)"
@@ -245,7 +231,7 @@
通过 通过
</el-button> </el-button>
<el-button <el-button
v-if="row.auditStatus === 0" v-if="row.auditStatus === 0 && userStore.hasPermission('video:manage')"
type="danger" type="danger"
text text
@click="handleReview(row, 3)" @click="handleReview(row, 3)"
@@ -253,6 +239,7 @@
拒绝 拒绝
</el-button> </el-button>
<el-button <el-button
v-if="userStore.hasPermission('video:manage')"
type="info" type="info"
text text
@click="handleDetail(row)" @click="handleDetail(row)"
@@ -295,12 +282,14 @@
关闭 关闭
</el-button> </el-button>
<el-button <el-button
v-if="userStore.hasPermission('video:manage')"
type="success" type="success"
@click="handleReview(previewVideo!, 1)" @click="handleReview(previewVideo!, 1)"
> >
通过 通过
</el-button> </el-button>
<el-button <el-button
v-if="userStore.hasPermission('video:manage')"
type="danger" type="danger"
@click="handleReview(previewVideo!, 3)" @click="handleReview(previewVideo!, 3)"
> >
@@ -329,6 +318,7 @@
取消 取消
</el-button> </el-button>
<el-button <el-button
v-if="userStore.hasPermission('video:manage')"
type="danger" type="danger"
@click="confirmReject" @click="confirmReject"
> >
@@ -343,6 +333,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores'
import AdminVideoPlayer from '@/components/video/AdminVideoPlayer.vue' import AdminVideoPlayer from '@/components/video/AdminVideoPlayer.vue'
import { videoApi } from '@/api/video.ts' import { videoApi } from '@/api/video.ts'
import { categoryApi } from '@/api/category.ts' import { categoryApi } from '@/api/category.ts'
@@ -351,6 +342,7 @@ import { resolveMediaUrl } from '@/utils/media.ts'
import type { PlatformVideo, VideoQueryParams, Category } from '@/types' import type { PlatformVideo, VideoQueryParams, Category } from '@/types'
const router = useRouter() const router = useRouter()
const userStore = useUserStore()
const queryParams = reactive<VideoQueryParams>({ const queryParams = reactive<VideoQueryParams>({
page: 1, page: 1,
@@ -398,8 +390,12 @@ const loadVideoList = async () => {
const loadPendingCount = async () => { const loadPendingCount = async () => {
try { try {
const stats = await videoApi.getStats() const pendingPage = await videoApi.getList({
pendingCount.value = stats.pendingReview page: 1,
size: 1,
auditStatus: 0
})
pendingCount.value = pendingPage.total
} catch (error) { } catch (error) {
console.error('加载统计失败:', error) console.error('加载统计失败:', error)
} }
@@ -515,75 +511,47 @@ onMounted(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.video-review-page { .video-review-page {
.hero-card {
margin-bottom: 20px;
:deep(.el-card__body) {
padding: 0;
}
}
.hero-content {
padding: 24px 28px;
border-radius: 22px;
background:
radial-gradient(circle at top right, rgba(251, 191, 36, 0.18), transparent 28%),
linear-gradient(135deg, #3f2a0f, #92400e 58%, #f59e0b);
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
h2 {
margin: 8px 0 10px;
font-size: 28px;
}
p {
margin: 0;
max-width: 720px;
color: rgba(255, 255, 255, 0.82);
line-height: 1.7;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
color: rgba(255, 255, 255, 0.72);
}
}
.hero-metrics {
display: flex;
gap: 12px;
}
.metric-box {
min-width: 96px;
padding: 12px 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.12);
display: grid;
gap: 4px;
strong {
font-size: 22px;
color: #fff;
}
span {
font-size: 12px;
color: rgba(255, 255, 255, 0.78);
}
}
.search-card { .search-card {
margin-bottom: 20px; margin-bottom: 20px;
width: 100%;
:deep(.el-card__body) { :deep(.el-card__body) {
padding-bottom: 0; padding: 18px 20px;
}
}
.review-search-form {
display: grid;
grid-template-columns: minmax(260px, 1.5fr) 200px 160px auto;
gap: 14px 18px;
align-items: center;
:deep(.el-form-item) {
margin: 0;
}
:deep(.el-form-item__content) {
width: 100%;
min-width: 0;
}
:deep(.el-input),
:deep(.el-select) {
width: 100%;
}
.action-item {
justify-self: end;
:deep(.el-form-item__content) {
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
.action-item :deep(.el-button + .el-button) {
margin-left: 0;
} }
} }
@@ -687,13 +655,31 @@ onMounted(() => {
height: 100%; height: 100%;
} }
} }
}
@media (max-width: 768px) { @media (max-width: 1280px) {
.video-review-page { .review-search-form {
.hero-content { grid-template-columns: minmax(240px, 1fr) 180px 160px;
flex-direction: column;
align-items: flex-start; .action-item {
grid-column: 1 / -1;
justify-self: start;
}
}
}
@media (max-width: 768px) {
.review-search-form {
grid-template-columns: 1fr;
.action-item {
grid-column: auto;
justify-self: stretch;
}
.action-item :deep(.el-form-item__content) {
justify-content: flex-start;
flex-wrap: wrap;
}
} }
} }
} }