refactor(admin): Rework permission routes and operations dashboard
This commit is contained in:
@@ -13,8 +13,14 @@ function normalizeAdmin(raw: any): AdminUser {
|
||||
avatar: raw?.avatar || '',
|
||||
email: raw?.email || '',
|
||||
phone: raw?.phone || '',
|
||||
role: firstRole?.code === 'ADMIN_SUPER' ? 'admin' : 'manager',
|
||||
role: firstRole?.code || '',
|
||||
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),
|
||||
lastLoginTime: raw?.lastLoginTime || '',
|
||||
lastLoginIp: raw?.lastLoginIp || '',
|
||||
@@ -70,15 +76,15 @@ export const adminApi = {
|
||||
nickname: string
|
||||
email?: string
|
||||
phone?: string
|
||||
role?: string
|
||||
roleIds: number[]
|
||||
}): Promise<void> {
|
||||
return request.post('/user', {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
realName: data.nickname,
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
roleIds: [data.role === 'admin' ? 1 : 2]
|
||||
...(data.email ? { email: data.email } : {}),
|
||||
...(data.phone ? { phone: data.phone } : {}),
|
||||
roleIds: data.roleIds
|
||||
})
|
||||
},
|
||||
|
||||
@@ -91,13 +97,13 @@ export const adminApi = {
|
||||
nickname?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
role?: string
|
||||
roleIds: number[]
|
||||
}): Promise<void> {
|
||||
const payload: Record<string, any> = {
|
||||
id: data.id,
|
||||
username: data.username,
|
||||
realName: data.nickname,
|
||||
roleIds: [data.role === 'admin' ? 1 : 2]
|
||||
roleIds: data.roleIds
|
||||
}
|
||||
|
||||
if (data.email) payload.email = data.email
|
||||
|
||||
@@ -10,6 +10,7 @@ export type { LoginParams, LoginResult, CaptchaResult, AdminUser }
|
||||
|
||||
function normalizeAdminInfo(raw: any): AdminUser {
|
||||
const roles = Array.isArray(raw?.roles) ? raw.roles : []
|
||||
const permissions = Array.isArray(raw?.permissions) ? raw.permissions : []
|
||||
const roleCode = roles[0] || ''
|
||||
|
||||
return {
|
||||
@@ -21,6 +22,10 @@ function normalizeAdminInfo(raw: any): AdminUser {
|
||||
phone: '',
|
||||
role: roleCode === 'ADMIN_SUPER' ? 'super-admin' : roleCode || 'admin',
|
||||
roleName: roleCode === 'ADMIN_SUPER' ? '超级管理员' : roleCode || '管理员',
|
||||
roles,
|
||||
roleNames: roles,
|
||||
permissions,
|
||||
roleIds: Array.isArray(raw?.roleIds) ? raw.roleIds.map((item: any) => Number(item)) : [],
|
||||
status: 1,
|
||||
lastLoginTime: '',
|
||||
lastLoginIp: '',
|
||||
@@ -50,7 +55,9 @@ export const authApi = {
|
||||
tokenType: result.tokenType || 'Bearer',
|
||||
expiresIn: result.expiresIn || 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 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> {
|
||||
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 shouldPaginateLocally = (!raw?.total || raw.total === 0) && records.length > size
|
||||
|
||||
@@ -43,6 +62,10 @@ export const commentApi = {
|
||||
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 { useUserStore } from '@/stores'
|
||||
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) {
|
||||
if (!input) return ''
|
||||
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--) {
|
||||
const d = new Date(today)
|
||||
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]))
|
||||
@@ -91,11 +99,19 @@ export const statisticsApi = {
|
||||
* 获取仪表盘数据
|
||||
*/
|
||||
async getDashboard(): Promise<DashboardData> {
|
||||
const userStore = useUserStore()
|
||||
|
||||
const [dashboard, users, videos, comments] = await Promise.all([
|
||||
request.get<any>('/dashboard/stats'),
|
||||
request.get<any>('/platform/user/list', { page: 1, size: 200 }),
|
||||
request.get<any>('/platform/content/video/list', { page: 1, size: 200 }),
|
||||
request.get<any>('/platform/comment/list', { page: 1, size: 200 })
|
||||
userStore.hasPermission('user:manage')
|
||||
? request.get<any>('/platform/user/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(
|
||||
|
||||
@@ -39,6 +39,7 @@ declare module 'vue' {
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSpace: typeof import('element-plus/es')['ElSpace']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
|
||||
@@ -159,62 +159,67 @@ onMounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tags-view-container {
|
||||
height: 34px;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
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);
|
||||
background: transparent;
|
||||
|
||||
.tags-view-wrapper {
|
||||
height: 100%;
|
||||
|
||||
.tags-view-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
border: 1px solid #d8dce5;
|
||||
color: #495060;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
border: 1px solid rgba(203, 213, 225, 0.9);
|
||||
color: #475569;
|
||||
background: #fff;
|
||||
padding: 0 8px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
margin-top: 4px;
|
||||
border-radius: 2px;
|
||||
margin-left: 6px;
|
||||
margin-top: 3px;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
transition: all 0.18s ease;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: 10px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #42b983;
|
||||
background: linear-gradient(90deg, #10b981, #34d399);
|
||||
color: #fff;
|
||||
border-color: #42b983;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 10px 20px rgba(16, 185, 129, 0.2);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
background: #fff;
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
margin-right: 4px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: #495060;
|
||||
color: #0f172a;
|
||||
border-color: rgba(96, 165, 250, 0.34);
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
margin-left: 5px;
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
|
||||
&:hover {
|
||||
background-color: #b4bccc;
|
||||
background-color: rgba(15, 23, 42, 0.16);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
@@ -233,14 +238,15 @@ onMounted(() => {
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 7px 16px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #eee;
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export const asyncRoutes: RouteRecordRaw[] = [
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
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',
|
||||
name: 'UserList',
|
||||
component: () => import('@/views/user/list.vue'),
|
||||
meta: { title: '用户列表', icon: 'List' }
|
||||
meta: { title: '用户列表', icon: 'List', permission: 'user:manage' }
|
||||
},
|
||||
{
|
||||
path: 'detail/:id',
|
||||
name: 'UserDetail',
|
||||
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',
|
||||
name: 'VideoList',
|
||||
component: () => import('@/views/video/list.vue'),
|
||||
meta: { title: '视频列表', icon: 'List' }
|
||||
meta: { title: '视频列表', icon: 'List', permission: 'video:manage' }
|
||||
},
|
||||
{
|
||||
path: 'review',
|
||||
name: 'VideoReview',
|
||||
component: () => import('@/views/video/review.vue'),
|
||||
meta: { title: '视频审核', icon: 'Check' }
|
||||
meta: { title: '视频审核', icon: 'Check', permission: 'video:manage' }
|
||||
},
|
||||
{
|
||||
path: 'detail/:id',
|
||||
name: 'VideoDetail',
|
||||
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',
|
||||
name: 'Category',
|
||||
component: () => import('@/views/content/category.vue'),
|
||||
meta: { title: '分类管理', icon: 'Grid' }
|
||||
meta: { title: '分类管理', icon: 'Grid', permission: 'category:manage' }
|
||||
},
|
||||
{
|
||||
path: 'tag',
|
||||
name: 'Tag',
|
||||
component: () => import('@/views/content/tag.vue'),
|
||||
meta: { title: '标签管理', icon: 'PriceTag' }
|
||||
meta: { title: '标签管理', icon: 'PriceTag', permission: 'tag:manage' }
|
||||
},
|
||||
{
|
||||
path: 'comment',
|
||||
name: 'Comment',
|
||||
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',
|
||||
name: 'AdminUser',
|
||||
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 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) => {
|
||||
// 开始进度条
|
||||
@@ -169,12 +198,29 @@ router.beforeEach(async (to, _from, next) => {
|
||||
NProgress.done()
|
||||
} else {
|
||||
// 检查是否已获取用户信息
|
||||
if (userStore.userInfo) {
|
||||
next()
|
||||
} else {
|
||||
const shouldRefreshUserInfo =
|
||||
!userStore.userInfo ||
|
||||
!Array.isArray(userStore.userInfo.roles) ||
|
||||
!Array.isArray(userStore.userInfo.permissions)
|
||||
|
||||
if (shouldRefreshUserInfo) {
|
||||
try {
|
||||
// 获取用户信息
|
||||
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()
|
||||
} catch (error) {
|
||||
// 获取用户信息失败,清除登录状态
|
||||
@@ -182,6 +228,22 @@ router.beforeEach(async (to, _from, next) => {
|
||||
next(`/login?redirect=${to.path}`)
|
||||
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 {
|
||||
|
||||
@@ -21,6 +21,8 @@ export const useUserStore = defineStore('admin-user', () => {
|
||||
const nickname = computed(() => userInfo.value?.nickname)
|
||||
const avatar = computed(() => userInfo.value?.avatar)
|
||||
const role = computed(() => userInfo.value?.role)
|
||||
const roles = computed(() => userInfo.value?.roles || [])
|
||||
const permissions = computed(() => userInfo.value?.permissions || [])
|
||||
|
||||
// Actions
|
||||
function setToken(accessToken: string, refresh?: string) {
|
||||
@@ -68,6 +70,26 @@ export const useUserStore = defineStore('admin-user', () => {
|
||||
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 {
|
||||
// State
|
||||
token,
|
||||
@@ -80,12 +102,16 @@ export const useUserStore = defineStore('admin-user', () => {
|
||||
nickname,
|
||||
avatar,
|
||||
role,
|
||||
roles,
|
||||
permissions,
|
||||
// Actions
|
||||
setToken,
|
||||
setUserInfo,
|
||||
login,
|
||||
fetchUserInfo,
|
||||
logout,
|
||||
clearAuth
|
||||
clearAuth,
|
||||
hasPermission,
|
||||
hasRole
|
||||
}
|
||||
})
|
||||
|
||||
@@ -74,6 +74,34 @@ a {
|
||||
.el-table__row:hover > td {
|
||||
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 {
|
||||
@@ -90,6 +118,63 @@ a {
|
||||
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 {
|
||||
color: $primary-color !important;
|
||||
|
||||
@@ -32,6 +32,10 @@ export interface AdminUser {
|
||||
phone: string
|
||||
role: string
|
||||
roleName?: string
|
||||
roles?: string[]
|
||||
roleNames?: string[]
|
||||
permissions?: string[]
|
||||
roleIds?: number[]
|
||||
status: number
|
||||
lastLoginTime: string
|
||||
lastLoginIp: string
|
||||
@@ -46,6 +50,8 @@ export interface LoginResult {
|
||||
expiresIn: number
|
||||
adminId: number
|
||||
username: string
|
||||
roles?: string[]
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
// 验证码响应
|
||||
@@ -252,3 +258,21 @@ export interface DashboardData {
|
||||
recentComments: PlatformComment[]
|
||||
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">
|
||||
<span>分类管理</span>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('category:manage')"
|
||||
type="primary"
|
||||
@click="handleAdd"
|
||||
>
|
||||
@@ -34,6 +35,7 @@
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('category:manage')"
|
||||
type="primary"
|
||||
text
|
||||
@click="handleEdit(row)"
|
||||
@@ -41,6 +43,7 @@
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('category:manage')"
|
||||
type="primary"
|
||||
text
|
||||
@click="handleAddChild(row)"
|
||||
@@ -48,6 +51,7 @@
|
||||
添加子分类
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('category:manage')"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleDelete(row)"
|
||||
@@ -120,8 +124,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { categoryApi, type Category } from '@/api/category.ts'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
const categories = ref<Category[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
<template>
|
||||
<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
|
||||
shadow="never"
|
||||
class="search-card"
|
||||
@@ -32,8 +7,12 @@
|
||||
<el-form
|
||||
:model="queryParams"
|
||||
inline
|
||||
class="page-search-form"
|
||||
>
|
||||
<el-form-item label="关键词">
|
||||
<el-form-item
|
||||
label="关键词"
|
||||
class="keyword-item"
|
||||
>
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="评论内容"
|
||||
@@ -41,7 +20,10 @@
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-form-item
|
||||
label="状态"
|
||||
class="status-item"
|
||||
>
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="全部"
|
||||
@@ -57,7 +39,7 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-form-item class="action-item">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSearch"
|
||||
@@ -186,16 +168,33 @@
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
width="100"
|
||||
width="140"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('comment:manage') && row.status === 1"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</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>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -217,10 +216,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { commentApi } from '@/api/comment.ts'
|
||||
import { formatDateTime } from '@/utils/format.ts'
|
||||
import type { PlatformComment, CommentQueryParams } from '@/types'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const queryParams = reactive<CommentQueryParams>({ page: 1, size: 10, keyword: '', status: undefined })
|
||||
const loading = ref(false)
|
||||
const comments = ref<PlatformComment[]>([])
|
||||
@@ -253,77 +254,58 @@ const handleDelete = async (row: PlatformComment) => {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleRestore = async (row: PlatformComment) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要恢复该评论吗?', '提示', { type: 'info' })
|
||||
await commentApi.restore(row.id)
|
||||
ElMessage.success('恢复成功')
|
||||
loadComments()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(() => loadComments())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.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 {
|
||||
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 {
|
||||
@@ -379,13 +361,31 @@ onMounted(() => loadComments())
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.comment-page {
|
||||
.hero-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('tag:manage')"
|
||||
type="primary"
|
||||
@click="handleAdd"
|
||||
>
|
||||
@@ -66,6 +67,7 @@
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('tag:manage')"
|
||||
type="primary"
|
||||
text
|
||||
@click="handleEdit(row)"
|
||||
@@ -73,6 +75,7 @@
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('tag:manage')"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleDelete(row)"
|
||||
@@ -132,9 +135,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { categoryApi, type Tag } from '@/api/category.ts'
|
||||
import { formatDateTime } from '@/utils/format.ts'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
const tags = ref<Tag[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
@@ -1,34 +1,5 @@
|
||||
<template>
|
||||
<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
|
||||
:gutter="20"
|
||||
class="overview-row"
|
||||
@@ -44,10 +15,11 @@
|
||||
<div class="overview-head">
|
||||
<div>
|
||||
<strong>运营看板</strong>
|
||||
<span>从用户、内容、播放和审核四条主链快速查看平台状态。</span>
|
||||
<span>聚合用户、内容与审核核心数据。</span>
|
||||
</div>
|
||||
<div class="overview-actions">
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('video:manage')"
|
||||
type="primary"
|
||||
text
|
||||
@click="goToVideoList"
|
||||
@@ -55,6 +27,7 @@
|
||||
视频列表
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('video:manage')"
|
||||
type="primary"
|
||||
text
|
||||
@click="goToReview"
|
||||
@@ -74,11 +47,11 @@
|
||||
<strong>{{ statistics.pendingReview }}</strong>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<span>总评论数</span>
|
||||
<span>总评论</span>
|
||||
<strong>{{ formatNumber(statistics.totalComments) }}</strong>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<span>总弹幕数</span>
|
||||
<span>总弹幕</span>
|
||||
<strong>{{ formatNumber(statistics.totalDanmakus) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +68,7 @@
|
||||
<div class="overview-head">
|
||||
<div>
|
||||
<strong>治理提醒</strong>
|
||||
<span>优先处理最影响运营节奏的问题。</span>
|
||||
<span>优先处理待办与新增波动。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert-list">
|
||||
@@ -112,6 +85,24 @@
|
||||
<strong>{{ statistics.newUsersToday }}</strong>
|
||||
</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-col>
|
||||
</el-row>
|
||||
@@ -242,6 +233,7 @@
|
||||
</div>
|
||||
<div class="stat-footer">
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('video:manage')"
|
||||
type="primary"
|
||||
size="small"
|
||||
text
|
||||
@@ -326,6 +318,7 @@
|
||||
<div class="card-header">
|
||||
<span>热门视频</span>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('video:manage')"
|
||||
type="primary"
|
||||
text
|
||||
@click="goToVideoList"
|
||||
@@ -379,6 +372,7 @@
|
||||
<div class="card-header">
|
||||
<span>最新评论</span>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('comment:manage')"
|
||||
type="primary"
|
||||
text
|
||||
@click="goToCommentList"
|
||||
@@ -422,11 +416,13 @@
|
||||
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as echarts from 'echarts'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { statisticsApi } from '@/api/statistics.ts'
|
||||
import { formatNumber, formatTime } from '@/utils/format.ts'
|
||||
import type { StatisticsData, PlatformVideo, PlatformComment, TrendData } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 统计数据
|
||||
const statistics = ref<StatisticsData>({
|
||||
@@ -632,9 +628,13 @@ onUnmounted(() => {
|
||||
|
||||
.overview-card {
|
||||
border-radius: 20px;
|
||||
height: 100%;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 22px;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,7 +666,7 @@ onUnmounted(() => {
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -694,50 +694,11 @@ onUnmounted(() => {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
.alert-actions {
|
||||
margin-top: 14px;
|
||||
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-badges {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-self: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
@@ -837,7 +798,6 @@ onUnmounted(() => {
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-page {
|
||||
.hero-content,
|
||||
.overview-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -846,6 +806,10 @@ onUnmounted(() => {
|
||||
.overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<div
|
||||
class="admin-layout"
|
||||
:class="{
|
||||
collapsed: appStore.sidebarCollapsed,
|
||||
mobile: appStore.device === 'mobile'
|
||||
}"
|
||||
>
|
||||
<aside
|
||||
class="layout-sidebar"
|
||||
:class="{ collapsed: appStore.sidebarCollapsed }"
|
||||
>
|
||||
<div class="sidebar-topline">
|
||||
<span class="topline-pill">ADMIN</span>
|
||||
<span v-if="!appStore.sidebarCollapsed">治理与运营控制台</span>
|
||||
<span v-if="!appStore.sidebarCollapsed">管理控制台</span>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-brand">
|
||||
@@ -38,6 +44,8 @@
|
||||
<el-sub-menu
|
||||
v-if="route.children && route.children.length > 1"
|
||||
:index="route.path"
|
||||
:popper-class="appStore.sidebarCollapsed ? 'sidebar-menu-popper' : ''"
|
||||
:popper-offset="12"
|
||||
>
|
||||
<template #title>
|
||||
<el-icon>
|
||||
@@ -75,6 +83,7 @@
|
||||
<div class="header-left">
|
||||
<button
|
||||
class="collapse-btn"
|
||||
:aria-label="appStore.sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<el-icon>
|
||||
@@ -87,7 +96,7 @@
|
||||
@click="searchVisible = true"
|
||||
>
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索后台页面与管理能力</span>
|
||||
<span>搜索页面、功能与命令</span>
|
||||
<kbd>/</kbd>
|
||||
</button>
|
||||
</div>
|
||||
@@ -98,7 +107,7 @@
|
||||
|
||||
<div class="header-right">
|
||||
<div class="header-chip">
|
||||
<strong>{{ currentSection }}</strong>
|
||||
<strong>{{ currentWorkspaceLabel }}</strong>
|
||||
<span>当前工作区</span>
|
||||
</div>
|
||||
<div class="header-chip">
|
||||
@@ -125,10 +134,20 @@
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<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-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="userStore.hasPermission('role:manage')"
|
||||
command="roles"
|
||||
>
|
||||
<el-icon><Key /></el-icon>
|
||||
角色管理
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
divided
|
||||
command="logout"
|
||||
@@ -143,9 +162,9 @@
|
||||
</header>
|
||||
|
||||
<section class="layout-subbar">
|
||||
<div class="subbar-copy">
|
||||
<span class="subbar-pill">HAOVP Admin</span>
|
||||
<span class="subbar-text">集中处理用户、视频、评论与系统治理链路。</span>
|
||||
<div class="subbar-copy compact">
|
||||
<span class="subbar-pill">{{ currentSection }}</span>
|
||||
<span class="subbar-count">{{ visitedViews.length }} 个标签</span>
|
||||
</div>
|
||||
<TagsView />
|
||||
</section>
|
||||
@@ -228,12 +247,30 @@ const userStore = useUserStore()
|
||||
const appStore = useAppStore()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const menuRoutes = computed(() => asyncRoutes.filter((item) => !item.meta?.hidden && item.children?.length))
|
||||
const activeMenu = computed(() => String(route.meta?.activeMenu || route.path))
|
||||
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 key = computed(() => route.fullPath)
|
||||
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 searchKeyword = ref('')
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
@@ -247,7 +284,7 @@ const resolveMenuPath = (parentPath: string, childPath: string) => {
|
||||
const searchItems = computed(() =>
|
||||
asyncRoutes.flatMap((route) =>
|
||||
(route.children || [])
|
||||
.filter((child: any) => !child.meta?.hidden)
|
||||
.filter((child: any) => !child.meta?.hidden && hasRouteAccess(child))
|
||||
.map((child: any) => ({
|
||||
path: resolveMenuPath(route.path, child.path),
|
||||
title: String(child.meta?.title || ''),
|
||||
@@ -286,6 +323,9 @@ const handleCommand = async (command: string) => {
|
||||
case 'admins':
|
||||
router.push('/system/admin')
|
||||
break
|
||||
case 'roles':
|
||||
router.push('/system/role')
|
||||
break
|
||||
case 'logout':
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
@@ -343,10 +383,20 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-layout {
|
||||
--sidebar-width: 248px;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
||||
background: #f4f7fb;
|
||||
transition: grid-template-columns 0.28s ease;
|
||||
|
||||
&.collapsed {
|
||||
--sidebar-width: 84px;
|
||||
}
|
||||
|
||||
&.mobile {
|
||||
--sidebar-width: 72px;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
@@ -355,13 +405,13 @@ onUnmounted(() => {
|
||||
#0f172a;
|
||||
color: #e2e8f0;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.12);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
&.collapsed {
|
||||
width: 72px;
|
||||
}
|
||||
transition: width 0.28s ease;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.sidebar-topline {
|
||||
@@ -425,25 +475,115 @@ onUnmounted(() => {
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
padding: 8px 0 18px;
|
||||
|
||||
:deep(.el-menu) {
|
||||
background: transparent;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item),
|
||||
:deep(.el-sub-menu__title) {
|
||||
color: #cbd5e1;
|
||||
border-radius: 12px;
|
||||
margin: 4px 10px;
|
||||
height: 50px;
|
||||
color: #dbe7f5;
|
||||
border-radius: 14px;
|
||||
margin: 6px 10px;
|
||||
padding-left: 16px !important;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.18s ease, color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
color: #8bd3ff;
|
||||
background: linear-gradient(90deg, rgba(59, 130, 246, 0.22), rgba(14, 165, 233, 0.08));
|
||||
color: #f8fbff;
|
||||
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) {
|
||||
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;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
@@ -477,14 +618,22 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #ffffff, #f8fafc);
|
||||
color: #334155;
|
||||
display: inline-flex;
|
||||
align-items: 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 {
|
||||
@@ -575,7 +724,7 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 20px;
|
||||
padding: 12px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.78);
|
||||
}
|
||||
@@ -585,6 +734,10 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
|
||||
&.compact {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.subbar-pill {
|
||||
@@ -596,12 +749,9 @@ onUnmounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subbar-text {
|
||||
.subbar-count {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
@@ -610,7 +760,7 @@ onUnmounted(() => {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(14, 165, 233, 0.06), transparent 30%),
|
||||
#f4f7fb;
|
||||
overflow: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.fade-transform-enter-active,
|
||||
@@ -629,14 +779,6 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.admin-layout {
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
width: 88px;
|
||||
}
|
||||
|
||||
.sidebar-topline span:last-child,
|
||||
.brand-copy,
|
||||
.header-chip,
|
||||
@@ -751,4 +893,40 @@ onUnmounted(() => {
|
||||
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>
|
||||
|
||||
@@ -47,28 +47,6 @@
|
||||
:rules="loginRules"
|
||||
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-input
|
||||
v-model="loginForm.username"
|
||||
@@ -192,7 +170,6 @@ const handleLogin = async () => {
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
router.replace(redirect)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '登录失败')
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -317,49 +294,6 @@ onMounted(() => {
|
||||
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 {
|
||||
width: 100px;
|
||||
height: 32px;
|
||||
@@ -408,9 +342,5 @@ onMounted(() => {
|
||||
padding: 22px 18px 20px;
|
||||
}
|
||||
|
||||
.captcha-banner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('user:manage')"
|
||||
type="primary"
|
||||
@click="handleAdd"
|
||||
>
|
||||
@@ -77,10 +78,17 @@
|
||||
/>
|
||||
<el-table-column
|
||||
label="角色"
|
||||
width="100"
|
||||
width="180"
|
||||
>
|
||||
<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>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
@@ -108,6 +116,7 @@
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('user:manage')"
|
||||
type="primary"
|
||||
text
|
||||
@click="handleEdit(row)"
|
||||
@@ -115,6 +124,7 @@
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('user:manage')"
|
||||
:type="row.status === 1 ? 'warning' : 'success'"
|
||||
text
|
||||
@click="handleToggleStatus(row)"
|
||||
@@ -122,6 +132,7 @@
|
||||
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('user:manage')"
|
||||
type="warning"
|
||||
text
|
||||
@click="handleResetPassword(row)"
|
||||
@@ -193,19 +204,18 @@
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="角色"
|
||||
prop="role"
|
||||
prop="roleIds"
|
||||
>
|
||||
<el-select
|
||||
v-model="form.role"
|
||||
v-model="form.roleIds"
|
||||
placeholder="请选择角色"
|
||||
multiple
|
||||
>
|
||||
<el-option
|
||||
label="管理员"
|
||||
value="manager"
|
||||
/>
|
||||
<el-option
|
||||
label="超级管理员"
|
||||
value="admin"
|
||||
v-for="role in roleOptions"
|
||||
:key="role.id"
|
||||
:label="role.name"
|
||||
:value="role.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -242,12 +252,17 @@
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
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 type { AdminRole } from '@/types'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const queryParams = reactive({ page: 1, size: 10, keyword: '' })
|
||||
const loading = ref(false)
|
||||
const adminList = ref<AdminUser[]>([])
|
||||
const total = ref(0)
|
||||
const roleOptions = ref<AdminRole[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('添加管理员')
|
||||
const submitting = ref(false)
|
||||
@@ -258,14 +273,14 @@ const form = reactive({
|
||||
nickname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role: 'manager',
|
||||
roleIds: [] as number[],
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ 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' }]
|
||||
}
|
||||
|
||||
@@ -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 handlePageChange = (page: number) => { queryParams.page = page; loadAdminList() }
|
||||
|
||||
@@ -291,7 +314,7 @@ const resetForm = () => {
|
||||
form.nickname = ''
|
||||
form.email = ''
|
||||
form.phone = ''
|
||||
form.role = 'manager'
|
||||
form.roleIds = []
|
||||
form.password = ''
|
||||
}
|
||||
|
||||
@@ -307,7 +330,7 @@ const handleEdit = (row: AdminUser) => {
|
||||
form.nickname = row.nickname
|
||||
form.email = row.email || ''
|
||||
form.phone = row.phone || ''
|
||||
form.role = row.role
|
||||
form.roleIds = row.roleIds || []
|
||||
form.password = ''
|
||||
dialogTitle.value = '编辑管理员'
|
||||
dialogVisible.value = true
|
||||
@@ -339,10 +362,24 @@ const handleSubmit = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
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('更新成功')
|
||||
} 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('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
@@ -355,7 +392,10 @@ const handleSubmit = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => loadAdminList())
|
||||
onMounted(() => {
|
||||
loadRoleOptions()
|
||||
loadAdminList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<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
|
||||
shadow="never"
|
||||
class="search-card"
|
||||
@@ -32,8 +7,12 @@
|
||||
<el-form
|
||||
:model="queryParams"
|
||||
inline
|
||||
class="page-search-form"
|
||||
>
|
||||
<el-form-item label="关键词">
|
||||
<el-form-item
|
||||
label="关键词"
|
||||
class="keyword-item"
|
||||
>
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="用户名/昵称/手机号"
|
||||
@@ -41,7 +20,10 @@
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-form-item
|
||||
label="状态"
|
||||
class="status-item"
|
||||
>
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="全部"
|
||||
@@ -57,7 +39,10 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="注册时间">
|
||||
<el-form-item
|
||||
label="注册时间"
|
||||
class="date-item"
|
||||
>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
@@ -68,7 +53,7 @@
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-form-item class="action-item">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSearch"
|
||||
@@ -134,6 +119,7 @@
|
||||
<div class="card-header">
|
||||
<span>用户列表</span>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('user:manage')"
|
||||
type="primary"
|
||||
@click="handleExport"
|
||||
>
|
||||
@@ -227,6 +213,7 @@
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('user:manage')"
|
||||
type="primary"
|
||||
text
|
||||
@click="handleDetail(row)"
|
||||
@@ -234,6 +221,7 @@
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('user:manage')"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleStatus(row, 0)"
|
||||
@@ -241,6 +229,7 @@
|
||||
设为禁用
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('user:manage')"
|
||||
type="success"
|
||||
text
|
||||
@click="handleStatus(row, 1)"
|
||||
@@ -248,6 +237,7 @@
|
||||
设为正常
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('user:manage')"
|
||||
type="warning"
|
||||
text
|
||||
@click="handleResetPassword(row)"
|
||||
@@ -357,10 +347,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
import { formatDateTime, formatNumber } from '@/utils/format.ts'
|
||||
import type { PlatformUser, UserQueryParams } from '@/types'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive<UserQueryParams>({
|
||||
page: 1,
|
||||
@@ -492,78 +485,51 @@ onMounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.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 {
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
: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 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -628,13 +594,31 @@ onMounted(() => {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.user-list-page {
|
||||
.hero-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@media (max-width: 1380px) {
|
||||
.page-search-form {
|
||||
grid-template-columns: minmax(240px, 1fr) 180px minmax(280px, 1fr);
|
||||
|
||||
.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
|
||||
:model="queryParams"
|
||||
inline
|
||||
class="page-search-form"
|
||||
>
|
||||
<el-form-item label="关键词">
|
||||
<el-form-item
|
||||
label="关键词"
|
||||
class="keyword-item"
|
||||
>
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="视频标题"
|
||||
@@ -17,7 +21,10 @@
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-form-item
|
||||
label="状态"
|
||||
class="status-item"
|
||||
>
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="全部"
|
||||
@@ -37,7 +44,7 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-form-item class="action-item">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSearch"
|
||||
@@ -155,6 +162,7 @@
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('video:manage')"
|
||||
type="primary"
|
||||
text
|
||||
@click="handleDetail(row)"
|
||||
@@ -162,7 +170,7 @@
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1"
|
||||
v-if="row.status === 1 && userStore.hasPermission('video:manage')"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleDisable(row)"
|
||||
@@ -192,12 +200,14 @@
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
import { formatDateTime, formatDuration, formatFileSize, formatNumber } from '@/utils/format.ts'
|
||||
import { resolveMediaUrl } from '@/utils/media.ts'
|
||||
import type { PlatformVideo, VideoQueryParams } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const queryParams = reactive<VideoQueryParams>({
|
||||
page: 1,
|
||||
size: 10,
|
||||
@@ -264,7 +274,46 @@ onMounted(() => loadVideoList())
|
||||
|
||||
<style scoped lang="scss">
|
||||
.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 {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -275,5 +324,32 @@ onMounted(() => loadVideoList())
|
||||
.author { display: flex; align-items: center; gap: 8px; }
|
||||
.stats { font-size: 12px; color: #666; display: flex; gap: 12px; }
|
||||
.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>
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
<template>
|
||||
<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
|
||||
shadow="never"
|
||||
class="search-card"
|
||||
@@ -32,8 +7,12 @@
|
||||
<el-form
|
||||
:model="queryParams"
|
||||
inline
|
||||
class="review-search-form"
|
||||
>
|
||||
<el-form-item label="关键词">
|
||||
<el-form-item
|
||||
label="关键词"
|
||||
class="keyword-item"
|
||||
>
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="视频标题"
|
||||
@@ -41,7 +20,10 @@
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核状态">
|
||||
<el-form-item
|
||||
label="审核状态"
|
||||
class="status-item"
|
||||
>
|
||||
<el-select
|
||||
v-model="queryParams.auditStatus"
|
||||
placeholder="全部"
|
||||
@@ -61,7 +43,10 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-form-item
|
||||
label="分类"
|
||||
class="category-item"
|
||||
>
|
||||
<el-select
|
||||
v-model="queryParams.categoryId"
|
||||
placeholder="全部"
|
||||
@@ -75,7 +60,7 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-form-item class="action-item">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSearch"
|
||||
@@ -230,6 +215,7 @@
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('video:manage')"
|
||||
type="primary"
|
||||
text
|
||||
@click="handlePreview(row)"
|
||||
@@ -237,7 +223,7 @@
|
||||
预览
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.auditStatus === 0"
|
||||
v-if="row.auditStatus === 0 && userStore.hasPermission('video:manage')"
|
||||
type="success"
|
||||
text
|
||||
@click="handleReview(row, 1)"
|
||||
@@ -245,7 +231,7 @@
|
||||
通过
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.auditStatus === 0"
|
||||
v-if="row.auditStatus === 0 && userStore.hasPermission('video:manage')"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleReview(row, 3)"
|
||||
@@ -253,6 +239,7 @@
|
||||
拒绝
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('video:manage')"
|
||||
type="info"
|
||||
text
|
||||
@click="handleDetail(row)"
|
||||
@@ -295,12 +282,14 @@
|
||||
关闭
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('video:manage')"
|
||||
type="success"
|
||||
@click="handleReview(previewVideo!, 1)"
|
||||
>
|
||||
通过
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('video:manage')"
|
||||
type="danger"
|
||||
@click="handleReview(previewVideo!, 3)"
|
||||
>
|
||||
@@ -329,6 +318,7 @@
|
||||
取消
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('video:manage')"
|
||||
type="danger"
|
||||
@click="confirmReject"
|
||||
>
|
||||
@@ -343,6 +333,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/stores'
|
||||
import AdminVideoPlayer from '@/components/video/AdminVideoPlayer.vue'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
import { categoryApi } from '@/api/category.ts'
|
||||
@@ -351,6 +342,7 @@ import { resolveMediaUrl } from '@/utils/media.ts'
|
||||
import type { PlatformVideo, VideoQueryParams, Category } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const queryParams = reactive<VideoQueryParams>({
|
||||
page: 1,
|
||||
@@ -398,8 +390,12 @@ const loadVideoList = async () => {
|
||||
|
||||
const loadPendingCount = async () => {
|
||||
try {
|
||||
const stats = await videoApi.getStats()
|
||||
pendingCount.value = stats.pendingReview
|
||||
const pendingPage = await videoApi.getList({
|
||||
page: 1,
|
||||
size: 1,
|
||||
auditStatus: 0
|
||||
})
|
||||
pendingCount.value = pendingPage.total
|
||||
} catch (error) {
|
||||
console.error('加载统计失败:', error)
|
||||
}
|
||||
@@ -515,75 +511,47 @@ onMounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.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 {
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
: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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.video-review-page {
|
||||
.hero-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@media (max-width: 1280px) {
|
||||
.review-search-form {
|
||||
grid-template-columns: minmax(240px, 1fr) 180px 160px;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user