refactor(admin): Rework content governance and video review workspace

This commit is contained in:
2026-04-22 16:44:45 +08:00
parent a6ae978ed0
commit 79f255bf6c
34 changed files with 3742 additions and 769 deletions
+4
View File
@@ -0,0 +1,4 @@
VITE_MOCK=true
VITE_API_BASE_URL=/api/admin
VITE_PROXY_TARGET=http://192.168.1.101:9000
VITE_MEDIA_BASE_URL=http://192.168.1.101:9100/video-platform
@@ -0,0 +1,4 @@
dist
node_modules
src/auto-imports.d.ts
src/components.d.ts
@@ -0,0 +1,29 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended'
],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 'latest',
sourceType: 'module',
extraFileExtensions: ['.vue']
},
rules: {
'no-console': 'off',
'no-empty': ['error', { allowEmptyCatch: true }],
'vue/multi-word-component-names': 'off',
'vue/no-template-shadow': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }]
}
}
+35
View File
@@ -11,6 +11,7 @@
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.8", "axios": "^1.6.8",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"dplayer": "^1.27.1",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"element-plus": "^2.6.3", "element-plus": "^2.6.3",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
@@ -2056,6 +2057,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/balloon-css": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/balloon-css/-/balloon-css-1.2.0.tgz",
"integrity": "sha512-urXwkHgwp6GsXVF+it01485Z2Cj4pnW02ICnM0TemOlkKmCNnDLmyy+ZZiRXBpwldUXO+aRNr7Hdia4CBvXJ5A==",
"license": "MIT"
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2319,6 +2326,28 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/dplayer": {
"version": "1.27.1",
"resolved": "https://registry.npmmirror.com/dplayer/-/dplayer-1.27.1.tgz",
"integrity": "sha512-2laBMXs5V1B9zPwJ7eAIw/OBo+Xjvy03i4GHTk3Cg+IWbrq8rKMFO0fFr6ClAYotYOCcFGOvaJDkOZcgKllsCA==",
"license": "MIT",
"dependencies": {
"axios": "1.2.3",
"balloon-css": "^1.0.3",
"promise-polyfill": "8.3.0"
}
},
"node_modules/dplayer/node_modules/axios": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.2.3.tgz",
"integrity": "sha512-pdDkMYJeuXLZ6Xj/Q5J3Phpe+jbGdsSzlQaFVkMQzRUL05+6+tetX8TV3p4HrU4kzuO9bt+io/yGQxuyxA/xcw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3798,6 +3827,12 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/promise-polyfill": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
"license": "MIT"
},
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+2 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "vue-tsc && vite build", "build": "vue-tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "lint": "eslint --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix src",
"format": "prettier --write src" "format": "prettier --write src"
}, },
"dependencies": { "dependencies": {
@@ -15,6 +15,7 @@
"vue-router": "^4.3.0", "vue-router": "^4.3.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"axios": "^1.6.8", "axios": "^1.6.8",
"dplayer": "^1.27.1",
"element-plus": "^2.6.3", "element-plus": "^2.6.3",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"echarts": "^5.5.0", "echarts": "^5.5.0",
+69 -7
View File
@@ -3,6 +3,42 @@ import type { AdminUser, PageResult, PageParams } from '@/types'
export type { AdminUser } export type { AdminUser }
function normalizeAdmin(raw: any): AdminUser {
const firstRole = Array.isArray(raw?.roles) ? raw.roles[0] : undefined
return {
id: Number(raw?.id || 0),
username: raw?.username || '',
nickname: raw?.realName || raw?.nickname || raw?.username || '',
avatar: raw?.avatar || '',
email: raw?.email || '',
phone: raw?.phone || '',
role: firstRole?.code === 'ADMIN_SUPER' ? 'admin' : 'manager',
roleName: firstRole?.name || '',
status: Number(raw?.status ?? 0),
lastLoginTime: raw?.lastLoginTime || '',
lastLoginIp: raw?.lastLoginIp || '',
createTime: raw?.createTime || ''
}
}
function normalizeAdminPage(raw: any, page: number, size: number): PageResult<AdminUser> {
const records = Array.isArray(raw?.records) ? raw.records.map(normalizeAdmin) : []
const total = Number(raw?.total || records.length || 0)
const shouldPaginateLocally = (!raw?.total || raw.total === 0) && records.length > size
const pagedRecords = shouldPaginateLocally
? records.slice((page - 1) * size, page * size)
: records
return {
records: pagedRecords,
total: shouldPaginateLocally ? records.length : total,
size: Number(raw?.size || size),
current: Number(raw?.current || page),
pages: Number(raw?.pages || Math.ceil((shouldPaginateLocally ? records.length : total) / size) || 1)
}
}
/** /**
* 管理员管理 API * 管理员管理 API
*/ */
@@ -10,15 +46,19 @@ export const adminApi = {
/** /**
* 获取管理员列表 * 获取管理员列表
*/ */
getList(params: PageParams & { keyword?: string }): Promise<PageResult<AdminUser>> { async getList(params: PageParams & { keyword?: string }): Promise<PageResult<AdminUser>> {
return request.get('/user/list', params) const page = params.page || 1
const size = params.size || 10
const result = await request.get<any>('/user/list', params)
return normalizeAdminPage(result, page, size)
}, },
/** /**
* 获取管理员详情 * 获取管理员详情
*/ */
getDetail(id: number): Promise<AdminUser> { async getDetail(id: number): Promise<AdminUser> {
return request.get(`/user/${id}`) const result = await request.get<any>(`/user/${id}`)
return normalizeAdmin(result)
}, },
/** /**
@@ -32,7 +72,14 @@ export const adminApi = {
phone?: string phone?: string
role?: string role?: string
}): Promise<void> { }): Promise<void> {
return request.post('/user', data) 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]
})
}, },
/** /**
@@ -40,12 +87,23 @@ export const adminApi = {
*/ */
update(data: { update(data: {
id: number id: number
username: string
nickname?: string nickname?: string
email?: string email?: string
phone?: string phone?: string
role?: string role?: string
}): Promise<void> { }): Promise<void> {
return request.put('/user', data) const payload: Record<string, any> = {
id: data.id,
username: data.username,
realName: data.nickname,
roleIds: [data.role === 'admin' ? 1 : 2]
}
if (data.email) payload.email = data.email
if (data.phone) payload.phone = data.phone
return request.put('/user', payload)
}, },
/** /**
@@ -66,6 +124,10 @@ export const adminApi = {
* 重置密码 * 重置密码
*/ */
resetPassword(id: number): Promise<void> { resetPassword(id: number): Promise<void> {
return request.put(`/user/${id}/reset-password`) return request.put(`/user/${id}/reset-password`, undefined, {
params: {
newPassword: 'admin123'
}
})
} }
} }
+33 -4
View File
@@ -8,6 +8,26 @@ import type {
export type { LoginParams, LoginResult, CaptchaResult, AdminUser } export type { LoginParams, LoginResult, CaptchaResult, AdminUser }
function normalizeAdminInfo(raw: any): AdminUser {
const roles = Array.isArray(raw?.roles) ? raw.roles : []
const roleCode = roles[0] || ''
return {
id: Number(raw?.adminId || raw?.id || 0),
username: raw?.username || '',
nickname: raw?.realName || raw?.username || '管理员',
avatar: raw?.avatar || '',
email: '',
phone: '',
role: roleCode === 'ADMIN_SUPER' ? 'super-admin' : roleCode || 'admin',
roleName: roleCode === 'ADMIN_SUPER' ? '超级管理员' : roleCode || '管理员',
status: 1,
lastLoginTime: '',
lastLoginIp: '',
createTime: ''
}
}
/** /**
* 管理员认证 API * 管理员认证 API
*/ */
@@ -22,8 +42,16 @@ export const authApi = {
/** /**
* 登录 * 登录
*/ */
login(params: LoginParams): Promise<LoginResult> { async login(params: LoginParams): Promise<LoginResult> {
return request.post('/auth/login', params) const result = await request.post<any>('/auth/login', params)
return {
accessToken: result.accessToken || result.token || '',
refreshToken: result.refreshToken || '',
tokenType: result.tokenType || 'Bearer',
expiresIn: result.expiresIn || 0,
adminId: result.adminId || result.id || 0,
username: result.username || ''
}
}, },
/** /**
@@ -36,7 +64,8 @@ export const authApi = {
/** /**
* 获取当前管理员信息 * 获取当前管理员信息
*/ */
getInfo(): Promise<AdminUser> { async getInfo(): Promise<AdminUser> {
return request.get('/auth/info') const result = await request.get<any>('/auth/info')
return normalizeAdminInfo(result)
} }
} }
@@ -41,27 +41,37 @@ export const categoryApi = {
* 获取标签列表 * 获取标签列表
*/ */
getTags(params?: TagQueryParams): Promise<PageResult<Tag>> { getTags(params?: TagQueryParams): Promise<PageResult<Tag>> {
return request.get('/content/tag/page', params) return request.get(
'/content/tag/page',
{
pageNum: params?.page || params?.pageNum || 1,
pageSize: params?.size || params?.pageSize || 10,
keyword: params?.keyword || ''
},
{
baseURL: '/api'
}
)
}, },
/** /**
* 添加标签 * 添加标签
*/ */
createTag(params: { name: string }): Promise<void> { createTag(params: { name: string }): Promise<void> {
return request.post('/content/tag', params) return request.post('/platform/content/tag', params)
}, },
/** /**
* 更新标签 * 更新标签
*/ */
updateTag(params: { id: number; name: string }): Promise<void> { updateTag(params: { id: number; name: string }): Promise<void> {
return request.put('/content/tag', params) return request.put('/platform/content/tag', params)
}, },
/** /**
* 删除标签 * 删除标签
*/ */
deleteTag(tagId: number): Promise<void> { deleteTag(tagId: number): Promise<void> {
return request.delete(`/content/tag/${tagId}`) return request.delete(`/platform/content/tag/${tagId}`)
} }
} }
@@ -1,6 +1,20 @@
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 normalizeCommentPage(raw: any, page: number, size: number): PageResult<PlatformComment> {
const records = Array.isArray(raw?.records) ? raw.records : []
const total = Number(raw?.total || records.length || 0)
const shouldPaginateLocally = (!raw?.total || raw.total === 0) && records.length > size
return {
records: shouldPaginateLocally ? records.slice((page - 1) * size, page * size) : records,
total: shouldPaginateLocally ? records.length : total,
size: Number(raw?.size || size),
current: Number(raw?.current || page),
pages: Number(raw?.pages || Math.ceil((shouldPaginateLocally ? records.length : total) / size) || 1)
}
}
/** /**
* 评论管理 API * 评论管理 API
*/ */
@@ -8,22 +22,25 @@ export const commentApi = {
/** /**
* 获取评论列表 * 获取评论列表
*/ */
getList(params: CommentQueryParams): Promise<PageResult<PlatformComment>> { async getList(params: CommentQueryParams): Promise<PageResult<PlatformComment>> {
return request.get('/admin/list', params) const page = params.page || 1
const size = params.size || 10
const result = await request.get<any>('/platform/comment/list', params)
return normalizeCommentPage(result, page, size)
}, },
/** /**
* 获取评论详情 * 获取评论详情
*/ */
getDetail(commentId: number): Promise<PlatformComment> { getDetail(commentId: number): Promise<PlatformComment> {
return request.get(`/admin/${commentId}`) return request.get(`/platform/comment/${commentId}`)
}, },
/** /**
* 删除评论 * 删除评论
*/ */
delete(commentId: number): Promise<void> { delete(commentId: number): Promise<void> {
return request.delete(`/admin/${commentId}`) return request.delete(`/platform/comment/${commentId}`)
}, },
/** /**
@@ -34,6 +51,6 @@ export const commentApi = {
commentsToday: number commentsToday: number
commentGrowthRate: number commentGrowthRate: number
}> { }> {
return request.get('/admin/stats') return request.get('/platform/comment/stats')
} }
} }
@@ -1,6 +1,88 @@
import request from '@/utils/request.ts' import request from '@/utils/request.ts'
import type { DashboardData, StatisticsData, TrendData } from '@/types' import type { DashboardData, StatisticsData, TrendData } from '@/types'
function getDateKey(input?: string) {
if (!input) return ''
return String(input).slice(0, 10)
}
function buildDailySeries<T>(items: T[], getDate: (item: T) => string, getValue: (item: T) => number) {
const today = new Date()
const days: string[] = []
for (let i = 6; i >= 0; i--) {
const d = new Date(today)
d.setDate(today.getDate() - i)
days.push(d.toISOString().slice(0, 10))
}
const map = new Map(days.map((day) => [day, 0]))
items.forEach((item) => {
const key = getDateKey(getDate(item))
if (!key || !map.has(key)) return
map.set(key, (map.get(key) || 0) + getValue(item))
})
return days.map((day) => ({
date: day.slice(5),
value: map.get(day) || 0
}))
}
function computeGrowth(series: TrendData[]) {
if (series.length < 2) return 0
const today = series[series.length - 1].value
const yesterday = series[series.length - 2].value
if (yesterday === 0) return today > 0 ? 100 : 0
return Number((((today - yesterday) / yesterday) * 100).toFixed(1))
}
function normalizeDashboard(payload: any, users: any[] = [], videos: any[] = [], comments: any[] = []): DashboardData {
const userStats = payload?.userStats || {}
const videoStats = payload?.videoStats || {}
const commentStats = payload?.commentStats || {}
const userTrend = buildDailySeries(users, (item) => item.createTime, () => 1)
const videoTrend = buildDailySeries(videos, (item) => item.createTime || item.publishTime, () => 1)
const viewTrend = buildDailySeries(videos, (item) => item.publishTime || item.createTime, (item) => Number(item.viewCount || 0))
const totalViews = videos.reduce((sum, item) => sum + Number(item.viewCount || 0), 0)
const totalDanmakus = videos.reduce((sum, item) => sum + Number(item.danmakuCount || 0), 0)
const totalLikes = videos.reduce((sum, item) => sum + Number(item.likeCount || 0), 0)
const todayViews = viewTrend[viewTrend.length - 1]?.value || 0
const hotVideos = [...videos]
.sort((a, b) => Number(b.viewCount || 0) - Number(a.viewCount || 0))
.slice(0, 5)
const recentComments = [...comments]
.sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || '')))
.slice(0, 5)
const recentUsers = [...users]
.sort((a, b) => String(b.createTime || '').localeCompare(String(a.createTime || '')))
.slice(0, 5)
return {
statistics: {
totalUsers: Number(userStats.totalUsers || 0),
newUsersToday: Number(userStats.newUsersToday || userStats.todayNewUsers || 0),
activeUsersToday: Number(userStats.activeUsersToday || userStats.activeUsers || 0),
userGrowthRate: Number(userStats.userGrowthRate || computeGrowth(userTrend)),
totalVideos: Number(videoStats.totalVideos || 0),
newVideosToday: Number(videoStats.newVideosToday || videoStats.todayNewVideos || 0),
pendingReview: Number(videoStats.pendingReview || videoStats.pendingReviewVideos || 0),
videoGrowthRate: Number(videoStats.videoGrowthRate || computeGrowth(videoTrend)),
totalViews,
viewsToday: todayViews,
viewGrowthRate: Number(videoStats.viewGrowthRate || computeGrowth(viewTrend)),
totalLikes,
totalComments: Number(commentStats.commentCount || commentStats.totalComments || 0),
totalDanmakus,
userTrend,
videoTrend,
viewTrend
},
hotVideos,
recentComments,
recentUsers
}
}
/** /**
* 统计数据 API * 统计数据 API
*/ */
@@ -8,35 +90,51 @@ export const statisticsApi = {
/** /**
* 获取仪表盘数据 * 获取仪表盘数据
*/ */
getDashboard(): Promise<DashboardData> { async getDashboard(): Promise<DashboardData> {
return request.get('/statistics/dashboard') 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 })
])
return normalizeDashboard(
dashboard,
users?.records || [],
videos?.records || [],
comments?.records || []
)
}, },
/** /**
* 获取统计数据 * 获取统计数据
*/ */
getStatistics(): Promise<StatisticsData> { async getStatistics(): Promise<StatisticsData> {
return request.get('/statistics/overview') const dashboard = await this.getDashboard()
return dashboard.statistics
}, },
/** /**
* 获取用户趋势 * 获取用户趋势
*/ */
getUserTrend(days: number = 7): Promise<TrendData[]> { async getUserTrend(_days: number = 7): Promise<TrendData[]> {
return request.get('/statistics/user/trend', { days }) const dashboard = await this.getDashboard()
return dashboard.statistics.userTrend
}, },
/** /**
* 获取视频趋势 * 获取视频趋势
*/ */
getVideoTrend(days: number = 7): Promise<TrendData[]> { async getVideoTrend(_days: number = 7): Promise<TrendData[]> {
return request.get('/statistics/video/trend', { days }) const dashboard = await this.getDashboard()
return dashboard.statistics.videoTrend
}, },
/** /**
* 获取播放趋势 * 获取播放趋势
*/ */
getViewTrend(days: number = 7): Promise<TrendData[]> { async getViewTrend(_days: number = 7): Promise<TrendData[]> {
return request.get('/statistics/view/trend', { days }) const dashboard = await this.getDashboard()
return dashboard.statistics.viewTrend
} }
} }
+52 -5
View File
@@ -1,6 +1,45 @@
import request from '@/utils/request.ts' import request from '@/utils/request.ts'
import type { PageResult, PlatformUser, UserQueryParams } from '@/types' import type { PageResult, PlatformUser, UserQueryParams } from '@/types'
function normalizeUser(raw: any): PlatformUser {
return {
id: Number(raw?.id || 0),
username: raw?.username || '',
nickname: raw?.nickname || raw?.username || '',
avatar: raw?.avatar || '',
email: raw?.email || '',
phone: raw?.phone || '',
gender: Number(raw?.gender ?? 0),
birthday: raw?.birthday || '',
signature: raw?.signature || '',
region: raw?.region || '',
status: raw?.status ?? null,
followerCount: Number(raw?.followerCount || 0),
followingCount: Number(raw?.followingCount || 0),
videoCount: Number(raw?.videoCount || 0),
likeCount: Number(raw?.likeCount || 0),
level: Number(raw?.level || 0),
vipLevel: Number(raw?.vipLevel || 0),
createTime: raw?.createTime || '',
updateTime: raw?.updateTime || '',
vipExpireTime: raw?.vipExpireTime || ''
}
}
function normalizeUserPage(raw: any, page: number, size: number): PageResult<PlatformUser> {
const records = Array.isArray(raw?.records) ? raw.records.map(normalizeUser) : []
const total = Number(raw?.total || records.length || 0)
const shouldPaginateLocally = (!raw?.total || raw.total === 0) && records.length > size
return {
records: shouldPaginateLocally ? records.slice((page - 1) * size, page * size) : records,
total: shouldPaginateLocally ? records.length : total,
size: Number(raw?.size || size),
current: Number(raw?.current || page),
pages: Number(raw?.pages || Math.ceil((shouldPaginateLocally ? records.length : total) / size) || 1)
}
}
/** /**
* 用户管理 API * 用户管理 API
*/ */
@@ -8,15 +47,19 @@ export const userApi = {
/** /**
* 获取用户列表 * 获取用户列表
*/ */
getList(params: UserQueryParams): Promise<PageResult<PlatformUser>> { async getList(params: UserQueryParams): Promise<PageResult<PlatformUser>> {
return request.get('/platform/user/list', params) const page = params.page || 1
const size = params.size || 10
const result = await request.get<any>('/platform/user/list', params)
return normalizeUserPage(result, page, size)
}, },
/** /**
* 获取用户详情 * 获取用户详情
*/ */
getDetail(userId: number): Promise<PlatformUser> { async getDetail(userId: number): Promise<PlatformUser> {
return request.get(`/platform/user/${userId}`) const result = await request.get<any>(`/platform/user/${userId}`)
return normalizeUser(result)
}, },
/** /**
@@ -30,7 +73,11 @@ export const userApi = {
* 重置用户密码 * 重置用户密码
*/ */
resetPassword(userId: number): Promise<void> { resetPassword(userId: number): Promise<void> {
return request.put(`/platform/user/${userId}/reset-password`) return request.put(`/platform/user/${userId}/reset-password`, undefined, {
params: {
newPassword: 'NewPass@123'
}
})
}, },
/** /**
+91 -9
View File
@@ -1,6 +1,78 @@
import request from '@/utils/request.ts' import request from '@/utils/request.ts'
import type { PageResult, PlatformVideo, VideoQueryParams, VideoReviewParams } from '@/types' import type { PageResult, PlatformVideo, VideoQueryParams, VideoReviewParams } from '@/types'
function normalizeVideo(raw: any): PlatformVideo {
const derivedStatus =
typeof raw?.status === 'number'
? raw.status
: raw?.reviewStatus === 3
? 2
: raw?.reviewStatus === 2
? 3
: raw?.publishTime
? 1
: 0
return {
id: Number(raw?.id || 0),
userId: Number(raw?.userId || 0),
username: raw?.username || '',
userNickname: raw?.userNickname || '',
userAvatar: raw?.userAvatar || '',
title: raw?.title || '',
description: raw?.description || '',
coverUrl: raw?.coverUrl || '',
videoUrl: raw?.videoUrl || '',
duration: Number(raw?.duration || 0),
width: Number(raw?.width || 0),
height: Number(raw?.height || 0),
format: raw?.format || '',
size: Number(raw?.size || 0),
categoryId: Number(raw?.categoryId || 0),
categoryName: raw?.categoryName || '',
status: derivedStatus,
auditStatus: raw?.auditStatus ?? raw?.reviewStatus ?? derivedStatus,
auditReason: raw?.auditReason || raw?.reviewReason || '',
likeCount: Number(raw?.likeCount || 0),
coinCount: Number(raw?.coinCount || 0),
favoriteCount: Number(raw?.favoriteCount || 0),
commentCount: Number(raw?.commentCount || 0),
viewCount: Number(raw?.viewCount || 0),
shareCount: Number(raw?.shareCount || 0),
danmakuCount: Number(raw?.danmakuCount || 0),
isRecommend: Number(raw?.isRecommend || 0),
isTop: Number(raw?.isTop || 0),
publishTime: raw?.publishTime || '',
createTime: raw?.createTime || raw?.reviewTime || raw?.publishTime || '',
updateTime: raw?.updateTime || raw?.reviewTime || raw?.publishTime || ''
}
}
function normalizeVideoPage(raw: any, page: number, size: number): PageResult<PlatformVideo> {
const records = Array.isArray(raw?.records) ? raw.records.map(normalizeVideo) : []
const total = Number(raw?.total || records.length || 0)
const shouldPaginateLocally = (!raw?.total || raw.total === 0) && records.length > size
return {
records: shouldPaginateLocally ? records.slice((page - 1) * size, page * size) : records,
total: shouldPaginateLocally ? records.length : total,
size: Number(raw?.size || size),
current: Number(raw?.current || page),
pages: Number(raw?.pages || Math.ceil((shouldPaginateLocally ? records.length : total) / size) || 1)
}
}
function normalizeVideoStats(payload: any) {
const videoStats = payload?.videoStats || payload || {}
return {
totalVideos: Number(videoStats.totalVideos || 0),
newVideosToday: Number(videoStats.newVideosToday || 0),
pendingReview: Number(videoStats.pendingReview || 0),
videoGrowthRate: Number(videoStats.videoGrowthRate || 0)
}
}
/** /**
* 视频管理 API * 视频管理 API
*/ */
@@ -8,24 +80,33 @@ export const videoApi = {
/** /**
* 获取视频列表 * 获取视频列表
*/ */
getList(params: VideoQueryParams): Promise<PageResult<PlatformVideo>> { async getList(params: VideoQueryParams): Promise<PageResult<PlatformVideo>> {
return request.get('/platform/content/video/list', params) const page = params.page || 1
const size = params.size || 10
const result = await request.get<any>('/platform/content/video/list', {
...params,
status: params.status ?? params.auditStatus
})
return normalizeVideoPage(result, page, size)
}, },
/** /**
* 获取视频详情 * 获取视频详情
*/ */
getDetail(videoId: number): Promise<PlatformVideo> { async getDetail(videoId: number): Promise<PlatformVideo> {
return request.get(`/platform/content/video/${videoId}`) const result = await request.get<any>(`/platform/content/video/${videoId}`)
return normalizeVideo(result)
}, },
/** /**
* 审核视频 * 审核视频
*/ */
review(params: VideoReviewParams): Promise<void> { review(params: VideoReviewParams): Promise<void> {
return request.post(`/platform/content/video/review/${params.videoId}`, { return request.post(`/platform/content/video/review/${params.videoId}`, undefined, {
status: params.status, params: {
reason: params.reason status: params.status,
reason: params.reason
}
}) })
}, },
@@ -39,12 +120,13 @@ export const videoApi = {
/** /**
* 获取视频统计数据 * 获取视频统计数据
*/ */
getStats(): Promise<{ async getStats(): Promise<{
totalVideos: number totalVideos: number
newVideosToday: number newVideosToday: number
pendingReview: number pendingReview: number
videoGrowthRate: number videoGrowthRate: number
}> { }> {
return request.get('/platform/content/video/stats') const result = await request.get<any>('/dashboard/stats')
return normalizeVideoStats(result)
} }
} }
+1 -4
View File
@@ -7,15 +7,14 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AdminVideoPlayer: typeof import('./components/video/AdminVideoPlayer.vue')['default']
Breadcrumb: typeof import('./components/Breadcrumb.vue')['default'] Breadcrumb: typeof import('./components/Breadcrumb.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
@@ -25,12 +24,10 @@ declare module 'vue' {
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage'] ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
@@ -1,10 +1,22 @@
<template> <template>
<el-breadcrumb class="breadcrumb" separator="/"> <el-breadcrumb
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path"> class="breadcrumb"
<span v-if="index === breadcrumbs.length - 1" class="no-redirect"> separator="/"
>
<el-breadcrumb-item
v-for="(item, index) in breadcrumbs"
:key="item.path"
>
<span
v-if="index === breadcrumbs.length - 1"
class="no-redirect"
>
{{ item.meta?.title }} {{ item.meta?.title }}
</span> </span>
<a v-else @click.prevent="handleLink(item)"> <a
v-else
@click.prevent="handleLink(item)"
>
{{ item.meta?.title }} {{ item.meta?.title }}
</a> </a>
</el-breadcrumb-item> </el-breadcrumb-item>
@@ -20,13 +20,32 @@
</router-link> </router-link>
</el-scrollbar> </el-scrollbar>
<ul v-show="visible" class="context-menu" :style="{ left: left + 'px', top: top + 'px' }"> <ul
<li @click="refreshSelectedView(selectedTag!)">刷新</li> v-show="visible"
<li v-if="!selectedTag?.affix" @click="closeSelectedView(selectedTag!)">关闭</li> class="context-menu"
<li @click="closeOthersViews">关闭其他</li> :style="{ left: left + 'px', top: top + 'px' }"
<li @click="closeLeftViews">关闭左侧</li> >
<li @click="closeRightViews">关闭右侧</li> <li @click="refreshSelectedView(selectedTag!)">
<li @click="closeAllViews">关闭所有</li> 刷新
</li>
<li
v-if="!selectedTag?.affix"
@click="closeSelectedView(selectedTag!)"
>
关闭
</li>
<li @click="closeOthersViews">
关闭其他
</li>
<li @click="closeLeftViews">
关闭左侧
</li>
<li @click="closeRightViews">
关闭右侧
</li>
<li @click="closeAllViews">
关闭所有
</li>
</ul> </ul>
</div> </div>
</template> </template>
@@ -0,0 +1,144 @@
<template>
<div class="admin-video-player-shell">
<div
ref="containerRef"
class="admin-video-player"
/>
<div
v-if="errorMessage"
class="player-error"
>
<strong>视频暂不可播放</strong>
<span>{{ errorMessage }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import DPlayer from 'dplayer'
type DPlayerInstance = InstanceType<typeof DPlayer>
const props = defineProps<{
src: string
poster?: string
}>()
const containerRef = ref<HTMLDivElement | null>(null)
const errorMessage = ref('')
let player: DPlayerInstance | null = null
const destroyPlayer = () => {
if (player) {
player.destroy()
player = null
}
}
const initPlayer = async () => {
if (!containerRef.value) return
if (!props.src) {
errorMessage.value = '缺少可播放的视频地址'
destroyPlayer()
return
}
destroyPlayer()
await nextTick()
errorMessage.value = ''
player = new DPlayer({
container: containerRef.value,
theme: '#00AEEC',
autoplay: false,
screenshot: true,
hotkey: true,
loop: false,
volume: 0.7,
mutex: true,
airplay: true,
playbackSpeed: [0.75, 1, 1.25, 1.5, 2],
video: {
url: props.src,
pic: props.poster,
type: 'auto'
}
})
player.on('error', () => {
errorMessage.value = '当前视频文件不存在或存储服务不可访问'
})
}
onMounted(() => {
initPlayer()
})
onBeforeUnmount(() => {
destroyPlayer()
})
watch(() => [props.src, props.poster], () => {
initPlayer()
})
</script>
<style scoped lang="scss">
.admin-video-player {
width: 100%;
height: 100%;
background: #000;
}
.admin-video-player-shell {
width: 100%;
height: 100%;
position: relative;
background: #000;
}
.player-error {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 8px;
padding: 24px;
text-align: center;
background: rgba(5, 10, 18, 0.76);
color: #e2e8f0;
strong {
font-size: 18px;
}
span {
font-size: 13px;
color: rgba(226, 232, 240, 0.82);
}
}
.admin-video-player:deep(.dplayer) {
width: 100%;
height: 100%;
border-radius: 16px;
overflow: hidden;
background: #000;
}
.admin-video-player:deep(.dplayer-controller) {
padding-bottom: 4px;
}
.admin-video-player:deep(.dplayer-menu),
.admin-video-player:deep(.dplayer-setting-box) {
border-radius: 12px;
overflow: hidden;
}
.admin-video-player:deep(.dplayer-controller .dplayer-icons .dplayer-icon:hover) {
color: #00aeec;
}
</style>
+1
View File
@@ -0,0 +1 @@
declare module 'dplayer'
@@ -31,6 +31,7 @@ export interface AdminUser {
email: string email: string
phone: string phone: string
role: string role: string
roleName?: string
status: number status: number
lastLoginTime: string lastLoginTime: string
lastLoginIp: string lastLoginIp: string
@@ -72,13 +73,15 @@ export interface PlatformUser {
gender: number gender: number
birthday: string birthday: string
signature: string signature: string
status: number region?: string
status?: number | null
followerCount: number followerCount: number
followingCount: number followingCount: number
videoCount: number videoCount: number
likeCount: number likeCount: number
level: number level: number
vipLevel: number vipLevel: number
vipExpireTime?: string
createTime: string createTime: string
updateTime: string updateTime: string
} }
@@ -109,7 +112,7 @@ export interface PlatformVideo {
size: number size: number
categoryId: number categoryId: number
categoryName: string categoryName: string
status: number // 0:待审核,1:已发布,2:审核拒绝,3:已下架 status: number // 0:待审核,1:已发布,2:已下架,3:审核拒绝
auditStatus: number auditStatus: number
auditReason: string auditReason: string
likeCount: number likeCount: number
@@ -140,7 +143,7 @@ export interface VideoQueryParams extends PageParams {
// 视频审核参数 // 视频审核参数
export interface VideoReviewParams { export interface VideoReviewParams {
videoId: number videoId: number
status: number // 1:通过, 2:拒绝 status: number // 1:通过, 3:拒绝
reason?: string reason?: string
} }
@@ -148,9 +151,12 @@ export interface VideoReviewParams {
export interface Category { export interface Category {
id: number id: number
name: string name: string
code?: string
parentId: number parentId: number
level?: number
sort: number sort: number
icon: string icon: string
status?: number
children?: Category[] children?: Category[]
} }
@@ -158,9 +164,12 @@ export interface Category {
export interface CategoryParams { export interface CategoryParams {
id?: number id?: number
name: string name: string
code?: string
parentId?: number parentId?: number
level?: number
sort?: number sort?: number
icon?: string icon?: string
status?: number
} }
// 标签信息 // 标签信息
@@ -0,0 +1,28 @@
function inferMediaBaseUrl() {
const explicitBase = import.meta.env.VITE_MEDIA_BASE_URL || ''
if (explicitBase) return explicitBase.replace(/\/$/, '')
const proxyTarget = import.meta.env.VITE_PROXY_TARGET || ''
if (!proxyTarget) return ''
try {
const url = new URL(proxyTarget)
if (url.port === '9000') {
url.port = '9100'
url.pathname = '/video-platform'
}
return url.toString().replace(/\/$/, '')
} catch {
return proxyTarget.replace(/\/$/, '')
}
}
const MEDIA_BASE_URL = inferMediaBaseUrl()
export function resolveMediaUrl(url?: string | null) {
if (!url) return ''
if (/^default\//i.test(url)) return ''
if (/^(data:|blob:|https?:\/\/)/i.test(url)) return url
if (!MEDIA_BASE_URL) return url
return `${MEDIA_BASE_URL}/${url.replace(/^\/+/, '')}`
}
@@ -8,6 +8,7 @@ import { isMockEnabled, mockRequest } from '@/mock'
// 配置 NProgress // 配置 NProgress
NProgress.configure({ showSpinner: false }) NProgress.configure({ showSpinner: false })
let handlingUnauthorized = false
// 创建 axios 实例 // 创建 axios 实例
const service: AxiosInstance = axios.create({ const service: AxiosInstance = axios.create({
@@ -56,20 +57,26 @@ service.interceptors.response.use(
// Token 过期 // Token 过期
if (res.code === 401) { if (res.code === 401) {
ElMessageBox.confirm('登录状态已过期,请重新登录', '提示', { if (!handlingUnauthorized) {
confirmButtonText: '重新登录', handlingUnauthorized = true
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const userStore = useUserStore() const userStore = useUserStore()
userStore.logout() userStore.clearAuth()
router.push('/login') if (router.currentRoute.value.path !== '/login') {
}) ElMessageBox.confirm('登录状态已过期,请重新登录', '提示', {
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}).finally(() => {
router.push('/login')
})
}
}
} }
return Promise.reject(new Error(res.message || '请求失败')) return Promise.reject(new Error(res.message || '请求失败'))
} }
handlingUnauthorized = false
return res.data return res.data
}, },
(error) => { (error) => {
@@ -81,12 +88,18 @@ service.interceptors.response.use(
case 400: case 400:
message = '请求参数错误' message = '请求参数错误'
break break
case 401: case 401: {
message = '未授权,请登录' message = '未授权,请登录'
const userStore = useUserStore() const userStore = useUserStore()
userStore.logout() if (!handlingUnauthorized) {
router.push('/login') handlingUnauthorized = true
userStore.clearAuth()
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
}
break break
}
case 403: case 403:
message = '拒绝访问' message = '拒绝访问'
break break
@@ -4,29 +4,78 @@
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>分类管理</span> <span>分类管理</span>
<el-button type="primary" @click="handleAdd"> <el-button
type="primary"
@click="handleAdd"
>
<el-icon><Plus /></el-icon>添加分类 <el-icon><Plus /></el-icon>添加分类
</el-button> </el-button>
</div> </div>
</template> </template>
<el-table v-loading="loading" :data="categories" row-key="id" default-expand-all> <el-table
<el-table-column prop="name" label="分类名称" /> v-loading="loading"
<el-table-column prop="sort" label="排序" width="100" /> :data="categories"
<el-table-column label="操作" width="200"> row-key="id"
default-expand-all
>
<el-table-column
prop="name"
label="分类名称"
/>
<el-table-column
prop="sort"
label="排序"
width="100"
/>
<el-table-column
label="操作"
width="200"
>
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" text @click="handleEdit(row)">编辑</el-button> <el-button
<el-button type="primary" text @click="handleAddChild(row)">添加子分类</el-button> type="primary"
<el-button type="danger" text @click="handleDelete(row)">删除</el-button> text
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="primary"
text
@click="handleAddChild(row)"
>
添加子分类
</el-button>
<el-button
type="danger"
text
@click="handleDelete(row)"
>
删除
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="400px"> <el-dialog
<el-form :model="form" label-width="80px"> v-model="dialogVisible"
<el-form-item label="分类名称" required> :title="dialogTitle"
<el-input v-model="form.name" placeholder="请输入分类名称" /> width="400px"
>
<el-form
:model="form"
label-width="80px"
>
<el-form-item
label="分类名称"
required
>
<el-input
v-model="form.name"
placeholder="请输入分类名称"
/>
</el-form-item> </el-form-item>
<el-form-item label="父级分类"> <el-form-item label="父级分类">
<el-tree-select <el-tree-select
@@ -40,15 +89,29 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="排序"> <el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" /> <el-input-number
v-model="form.sort"
:min="0"
/>
</el-form-item> </el-form-item>
<el-form-item label="图标"> <el-form-item label="图标">
<el-input v-model="form.icon" placeholder="图标URL" /> <el-input
v-model="form.icon"
placeholder="图标URL"
/>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button> 取消
</el-button>
<el-button
type="primary"
:loading="submitting"
@click="handleSubmit"
>
确定
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -68,11 +131,25 @@ const submitting = ref(false)
const form = reactive({ const form = reactive({
id: undefined as number | undefined, id: undefined as number | undefined,
name: '', name: '',
code: '',
parentId: 0, parentId: 0,
level: 1,
sort: 0, sort: 0,
icon: '' icon: '',
status: 1
}) })
const createCategoryCode = (name: string) => {
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 20)
return `cat-${slug || 'item'}-${Date.now().toString().slice(-6)}`
}
const categoryTree = computed(() => { const categoryTree = computed(() => {
const buildTree = (list: Category[], parentId: number = 0): Category[] => { const buildTree = (list: Category[], parentId: number = 0): Category[] => {
return list.filter(item => item.parentId === parentId).map(item => ({ return list.filter(item => item.parentId === parentId).map(item => ({
@@ -97,9 +174,12 @@ const loadCategories = async () => {
const resetForm = () => { const resetForm = () => {
form.id = undefined form.id = undefined
form.name = '' form.name = ''
form.code = ''
form.parentId = 0 form.parentId = 0
form.level = 1
form.sort = 0 form.sort = 0
form.icon = '' form.icon = ''
form.status = 1
} }
const handleAdd = () => { const handleAdd = () => {
@@ -118,9 +198,12 @@ const handleAddChild = (row: Category) => {
const handleEdit = (row: Category) => { const handleEdit = (row: Category) => {
form.id = row.id form.id = row.id
form.name = row.name form.name = row.name
form.code = row.code || ''
form.parentId = row.parentId form.parentId = row.parentId
form.level = row.level || 1
form.sort = row.sort form.sort = row.sort
form.icon = row.icon || '' form.icon = row.icon || ''
form.status = row.status ?? 1
dialogTitle.value = '编辑分类' dialogTitle.value = '编辑分类'
dialogVisible.value = true dialogVisible.value = true
} }
@@ -142,10 +225,27 @@ const handleSubmit = async () => {
submitting.value = true submitting.value = true
try { try {
if (form.id) { if (form.id) {
await categoryApi.update({ id: form.id, name: form.name, parentId: form.parentId, sort: form.sort, icon: form.icon }) await categoryApi.update({
id: form.id,
name: form.name,
code: form.code || createCategoryCode(form.name),
parentId: form.parentId,
level: form.level,
sort: form.sort,
icon: form.icon,
status: form.status
})
ElMessage.success('更新成功') ElMessage.success('更新成功')
} else { } else {
await categoryApi.create({ name: form.name, parentId: form.parentId, sort: form.sort, icon: form.icon }) await categoryApi.create({
name: form.name,
code: createCategoryCode(form.name),
parentId: form.parentId,
level: form.parentId === 0 ? 1 : undefined,
sort: form.sort,
icon: form.icon,
status: 1
})
ElMessage.success('添加成功') ElMessage.success('添加成功')
} }
dialogVisible.value = false dialogVisible.value = false
@@ -1,58 +1,201 @@
<template> <template>
<div class="comment-page"> <div class="comment-page">
<el-card shadow="never" class="search-card"> <el-card
<el-form :model="queryParams" inline> 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"
>
<el-form
:model="queryParams"
inline
>
<el-form-item label="关键词"> <el-form-item label="关键词">
<el-input v-model="queryParams.keyword" placeholder="评论内容" clearable @keyup.enter="handleSearch" /> <el-input
v-model="queryParams.keyword"
placeholder="评论内容"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable> <el-select
<el-option label="正常" :value="1" /> v-model="queryParams.status"
<el-option label="已删除" :value="0" /> placeholder="全部"
clearable
>
<el-option
label="正常"
:value="1"
/>
<el-option
label="已删除"
:value="0"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button> <el-button
<el-button @click="handleReset">重置</el-button> type="primary"
@click="handleSearch"
>
搜索
</el-button>
<el-button @click="handleReset">
重置
</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card shadow="never" class="table-card"> <el-row
<el-table v-loading="loading" :data="comments"> :gutter="20"
<el-table-column prop="id" label="ID" width="80" /> class="overview-row"
<el-table-column label="评论内容" min-width="300"> >
<el-col
:xs="24"
:md="8"
>
<el-card
shadow="hover"
class="overview-card"
>
<span>总结果数</span>
<strong>{{ total }}</strong>
</el-card>
</el-col>
<el-col
:xs="24"
:md="8"
>
<el-card
shadow="hover"
class="overview-card"
>
<span>当前页码</span>
<strong>{{ queryParams.page }}</strong>
</el-card>
</el-col>
<el-col
:xs="24"
:md="8"
>
<el-card
shadow="hover"
class="overview-card"
>
<span>每页数量</span>
<strong>{{ queryParams.size }}</strong>
</el-card>
</el-col>
</el-row>
<el-card
shadow="never"
class="table-card"
>
<el-table
v-loading="loading"
:data="comments"
>
<el-table-column
prop="id"
label="ID"
width="80"
/>
<el-table-column
label="评论内容"
min-width="300"
>
<template #default="{ row }"> <template #default="{ row }">
<div class="comment-content">{{ row.content }}</div> <div class="comment-content">
{{ row.content }}
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="视频" width="150"> <el-table-column
label="视频"
width="150"
>
<template #default="{ row }"> <template #default="{ row }">
<span class="video-title">{{ row.videoTitle }}</span> <span class="video-title">{{ row.videoTitle }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="用户" width="120"> <el-table-column
label="用户"
width="120"
>
<template #default="{ row }"> <template #default="{ row }">
<div class="user-info"> <div class="user-info">
<el-avatar :src="row.userAvatar" :size="24">{{ row.userNickname?.charAt(0) }}</el-avatar> <el-avatar
:src="row.userAvatar"
:size="24"
>
{{ row.userNickname?.charAt(0) }}
</el-avatar>
<span>{{ row.userNickname }}</span> <span>{{ row.userNickname }}</span>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="likeCount" label="点赞" width="80" /> <el-table-column
<el-table-column label="状态" width="80"> prop="likeCount"
label="点赞"
width="80"
/>
<el-table-column
label="状态"
width="80"
>
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'"> <el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '已删除' }} {{ row.status === 1 ? '正常' : '已删除' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180"> <el-table-column
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template> prop="createTime"
</el-table-column> label="创建时间"
<el-table-column label="操作" width="100"> width="180"
>
<template #default="{ row }"> <template #default="{ row }">
<el-button type="danger" text @click="handleDelete(row)">删除</el-button> {{ formatDateTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column
label="操作"
width="100"
>
<template #default="{ row }">
<el-button
type="danger"
text
@click="handleDelete(row)"
>
删除
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -115,11 +258,135 @@ onMounted(() => loadComments())
<style scoped lang="scss"> <style scoped lang="scss">
.comment-page { .comment-page {
.search-card { margin-bottom: 20px; } .hero-card {
.table-card { border-radius: 22px; } margin-bottom: 20px;
.comment-content { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; color: #172554; }
.video-title { color: #64748b; font-size: 12px; } :deep(.el-card__body) {
.user-info { display: flex; align-items: center; gap: 8px; } padding: 0;
.pagination { margin-top: 20px; display: flex; justify-content: flex-end; } }
}
.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;
}
.overview-row {
margin-bottom: 20px;
}
.overview-card {
border-radius: 20px;
:deep(.el-card__body) {
padding: 18px 20px;
display: grid;
gap: 6px;
}
span {
font-size: 12px;
color: #94a3b8;
}
strong {
font-size: 24px;
color: #18191c;
}
}
.table-card {
border-radius: 22px;
}
.comment-content {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
color: #172554;
}
.video-title {
color: #64748b;
font-size: 12px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
@media (max-width: 768px) {
.comment-page {
.hero-content {
flex-direction: column;
align-items: flex-start;
}
}
} }
</style> </style>
@@ -3,31 +3,82 @@
<el-card shadow="never"> <el-card shadow="never">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<el-form :inline="true" :model="queryParams"> <el-form
:inline="true"
:model="queryParams"
>
<el-form-item> <el-form-item>
<el-input v-model="queryParams.keyword" placeholder="标签名称" clearable @keyup.enter="handleSearch" /> <el-input
v-model="queryParams.keyword"
placeholder="标签名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button> <el-button
type="primary"
@click="handleSearch"
>
搜索
</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-button type="primary" @click="handleAdd"> <el-button
type="primary"
@click="handleAdd"
>
<el-icon><Plus /></el-icon>添加标签 <el-icon><Plus /></el-icon>添加标签
</el-button> </el-button>
</div> </div>
</template> </template>
<el-table v-loading="loading" :data="tags"> <el-table
<el-table-column prop="id" label="ID" width="80" /> v-loading="loading"
<el-table-column prop="name" label="标签名称" /> :data="tags"
<el-table-column prop="useCount" label="使用次数" width="120" /> >
<el-table-column prop="createTime" label="创建时间" width="180"> <el-table-column
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template> prop="id"
</el-table-column> label="ID"
<el-table-column label="操作" width="150"> width="80"
/>
<el-table-column
prop="name"
label="标签名称"
/>
<el-table-column
prop="useCount"
label="使用次数"
width="120"
/>
<el-table-column
prop="createTime"
label="创建时间"
width="180"
>
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" text @click="handleEdit(row)">编辑</el-button> {{ formatDateTime(row.createTime) }}
<el-button type="danger" text @click="handleDelete(row)">删除</el-button> </template>
</el-table-column>
<el-table-column
label="操作"
width="150"
>
<template #default="{ row }">
<el-button
type="primary"
text
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
text
@click="handleDelete(row)"
>
删除
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -43,15 +94,36 @@
</div> </div>
</el-card> </el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="400px"> <el-dialog
<el-form :model="form" label-width="80px"> v-model="dialogVisible"
<el-form-item label="标签名称" required> :title="dialogTitle"
<el-input v-model="form.name" placeholder="请输入标签名称" /> width="400px"
>
<el-form
:model="form"
label-width="80px"
>
<el-form-item
label="标签名称"
required
>
<el-input
v-model="form.name"
placeholder="请输入标签名称"
/>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button> 取消
</el-button>
<el-button
type="primary"
:loading="submitting"
@click="handleSubmit"
>
确定
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -1,29 +1,149 @@
<template> <template>
<div class="dashboard-page"> <div class="dashboard-page">
<el-card shadow="never" class="hero-card"> <el-card
shadow="never"
class="hero-card"
>
<div class="hero-content"> <div class="hero-content">
<div> <div>
<div class="eyebrow">Platform Overview</div> <div class="eyebrow">
Platform Overview
</div>
<h2>HAOVP 平台治理总览</h2> <h2>HAOVP 平台治理总览</h2>
<p>集中查看用户增长、内容分发、播放热度与审核压力,支撑平台运营与内容治理决策。</p> <p>集中查看用户增长、内容分发、播放热度与审核压力,支撑平台运营与内容治理决策。</p>
</div> </div>
<div class="hero-badges"> <div class="hero-badges">
<el-tag type="primary" effect="dark">统一运营视图</el-tag> <el-tag
<el-tag type="success" effect="dark">实时业务概览</el-tag> type="primary"
effect="dark"
>
统一运营视图
</el-tag>
<el-tag
type="success"
effect="dark"
>
实时业务概览
</el-tag>
</div> </div>
</div> </div>
</el-card> </el-card>
<el-row
:gutter="20"
class="overview-row"
>
<el-col
:xs="24"
:lg="16"
>
<el-card
shadow="hover"
class="overview-card"
>
<div class="overview-head">
<div>
<strong>运营看板</strong>
<span>从用户、内容、播放和审核四条主链快速查看平台状态。</span>
</div>
<div class="overview-actions">
<el-button
type="primary"
text
@click="goToVideoList"
>
视频列表
</el-button>
<el-button
type="primary"
text
@click="goToReview"
>
去审核
</el-button>
</div>
</div>
<div class="overview-grid">
<div class="overview-item">
<span>活跃用户</span>
<strong>{{ statistics.activeUsersToday.toLocaleString() }}</strong>
</div>
<div class="overview-item">
<span>待审核视频</span>
<strong>{{ statistics.pendingReview }}</strong>
</div>
<div class="overview-item">
<span>总评论数</span>
<strong>{{ formatNumber(statistics.totalComments) }}</strong>
</div>
<div class="overview-item">
<span>总弹幕数</span>
<strong>{{ formatNumber(statistics.totalDanmakus) }}</strong>
</div>
</div>
</el-card>
</el-col>
<el-col
:xs="24"
:lg="8"
>
<el-card
shadow="hover"
class="overview-card"
>
<div class="overview-head">
<div>
<strong>治理提醒</strong>
<span>优先处理最影响运营节奏的问题。</span>
</div>
</div>
<div class="alert-list">
<div class="alert-item">
<span>待审核内容</span>
<strong>{{ statistics.pendingReview }}</strong>
</div>
<div class="alert-item">
<span>今日新增视频</span>
<strong>{{ statistics.newVideosToday }}</strong>
</div>
<div class="alert-item">
<span>今日新增用户</span>
<strong>{{ statistics.newUsersToday }}</strong>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 统计卡片 --> <!-- 统计卡片 -->
<el-row :gutter="20" class="stat-cards"> <el-row
<el-col :xs="12" :sm="6"> :gutter="20"
<el-card shadow="hover" class="stat-card"> class="stat-cards"
<div class="stat-icon" style="background: linear-gradient(135deg, #667eea, #764ba2)"> >
<el-icon :size="28"><User /></el-icon> <el-col
:xs="12"
:sm="6"
>
<el-card
shadow="hover"
class="stat-card"
>
<div
class="stat-icon"
style="background: linear-gradient(135deg, #667eea, #764ba2)"
>
<el-icon :size="28">
<User />
</el-icon>
</div> </div>
<div class="stat-info"> <div class="stat-info">
<div class="stat-value">{{ statistics.totalUsers.toLocaleString() }}</div> <div class="stat-value">
<div class="stat-label">总用户数</div> {{ statistics.totalUsers.toLocaleString() }}
</div>
<div class="stat-label">
总用户数
</div>
<div class="stat-footer"> <div class="stat-footer">
<span>今日新增 {{ statistics.newUsersToday }}</span> <span>今日新增 {{ statistics.newUsersToday }}</span>
<span :class="statistics.userGrowthRate >= 0 ? 'up' : 'down'"> <span :class="statistics.userGrowthRate >= 0 ? 'up' : 'down'">
@@ -33,14 +153,29 @@
</div> </div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :xs="12" :sm="6"> <el-col
<el-card shadow="hover" class="stat-card"> :xs="12"
<div class="stat-icon" style="background: linear-gradient(135deg, #f093fb, #f5576c)"> :sm="6"
<el-icon :size="28"><VideoCamera /></el-icon> >
<el-card
shadow="hover"
class="stat-card"
>
<div
class="stat-icon"
style="background: linear-gradient(135deg, #f093fb, #f5576c)"
>
<el-icon :size="28">
<VideoCamera />
</el-icon>
</div> </div>
<div class="stat-info"> <div class="stat-info">
<div class="stat-value">{{ statistics.totalVideos.toLocaleString() }}</div> <div class="stat-value">
<div class="stat-label">总视频数</div> {{ statistics.totalVideos.toLocaleString() }}
</div>
<div class="stat-label">
总视频数
</div>
<div class="stat-footer"> <div class="stat-footer">
<span>今日上传 {{ statistics.newVideosToday }}</span> <span>今日上传 {{ statistics.newVideosToday }}</span>
<span :class="statistics.videoGrowthRate >= 0 ? 'up' : 'down'"> <span :class="statistics.videoGrowthRate >= 0 ? 'up' : 'down'">
@@ -50,14 +185,29 @@
</div> </div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :xs="12" :sm="6"> <el-col
<el-card shadow="hover" class="stat-card"> :xs="12"
<div class="stat-icon" style="background: linear-gradient(135deg, #4facfe, #00f2fe)"> :sm="6"
<el-icon :size="28"><View /></el-icon> >
<el-card
shadow="hover"
class="stat-card"
>
<div
class="stat-icon"
style="background: linear-gradient(135deg, #4facfe, #00f2fe)"
>
<el-icon :size="28">
<View />
</el-icon>
</div> </div>
<div class="stat-info"> <div class="stat-info">
<div class="stat-value">{{ formatNumber(statistics.totalViews) }}</div> <div class="stat-value">
<div class="stat-label">总播放量</div> {{ formatNumber(statistics.totalViews) }}
</div>
<div class="stat-label">
总播放量
</div>
<div class="stat-footer"> <div class="stat-footer">
<span>今日播放 {{ formatNumber(statistics.viewsToday) }}</span> <span>今日播放 {{ formatNumber(statistics.viewsToday) }}</span>
<span :class="statistics.viewGrowthRate >= 0 ? 'up' : 'down'"> <span :class="statistics.viewGrowthRate >= 0 ? 'up' : 'down'">
@@ -67,16 +217,36 @@
</div> </div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :xs="12" :sm="6"> <el-col
<el-card shadow="hover" class="stat-card"> :xs="12"
<div class="stat-icon" style="background: linear-gradient(135deg, #fa709a, #fee140)"> :sm="6"
<el-icon :size="28"><DocumentChecked /></el-icon> >
<el-card
shadow="hover"
class="stat-card"
>
<div
class="stat-icon"
style="background: linear-gradient(135deg, #fa709a, #fee140)"
>
<el-icon :size="28">
<DocumentChecked />
</el-icon>
</div> </div>
<div class="stat-info"> <div class="stat-info">
<div class="stat-value text-warning">{{ statistics.pendingReview }}</div> <div class="stat-value text-warning">
<div class="stat-label">待审核视频</div> {{ statistics.pendingReview }}
</div>
<div class="stat-label">
待审核视频
</div>
<div class="stat-footer"> <div class="stat-footer">
<el-button type="primary" size="small" text @click="goToReview"> <el-button
type="primary"
size="small"
text
@click="goToReview"
>
去审核 去审核
</el-button> </el-button>
</div> </div>
@@ -86,51 +256,113 @@
</el-row> </el-row>
<!-- 图表区域 --> <!-- 图表区域 -->
<el-row :gutter="20" class="chart-row"> <el-row
<el-col :xs="24" :lg="16"> :gutter="20"
class="chart-row"
>
<el-col
:xs="24"
:lg="16"
>
<el-card shadow="hover"> <el-card shadow="hover">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>数据趋势</span> <span>数据趋势</span>
<el-radio-group v-model="chartType" size="small"> <el-radio-group
<el-radio-button label="user">用户</el-radio-button> v-model="chartType"
<el-radio-button label="video">视频</el-radio-button> size="small"
<el-radio-button label="view">播放</el-radio-button> >
<el-radio-button label="user">
用户
</el-radio-button>
<el-radio-button label="video">
视频
</el-radio-button>
<el-radio-button label="view">
播放
</el-radio-button>
</el-radio-group> </el-radio-group>
</div> </div>
</template> </template>
<div ref="trendChartRef" class="chart-container"></div> <div
ref="trendChartRef"
class="chart-container"
/>
<div
v-if="!hasTrendData"
class="chart-empty"
>
暂无趋势数据,等待更多用户与内容行为积累
</div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :xs="24" :lg="8"> <el-col
:xs="24"
:lg="8"
>
<el-card shadow="hover"> <el-card shadow="hover">
<template #header> <template #header>
<span>互动数据</span> <span>互动数据</span>
</template> </template>
<div ref="pieChartRef" class="chart-container"></div> <div
ref="pieChartRef"
class="chart-container"
/>
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<!-- 列表区域 --> <!-- 列表区域 -->
<el-row :gutter="20" class="list-row"> <el-row
<el-col :xs="24" :lg="12"> :gutter="20"
class="list-row"
>
<el-col
:xs="24"
:lg="12"
>
<el-card shadow="hover"> <el-card shadow="hover">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>热门视频</span> <span>热门视频</span>
<el-button type="primary" text @click="goToVideoList">查看更多</el-button> <el-button
type="primary"
text
@click="goToVideoList"
>
查看更多
</el-button>
</div> </div>
</template> </template>
<el-table :data="hotVideos" style="width: 100%"> <el-table
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip /> :data="hotVideos"
<el-table-column prop="userNickname" label="作者" width="100" /> style="width: 100%"
<el-table-column prop="viewCount" label="播放" width="80"> >
<el-table-column
prop="title"
label="标题"
min-width="200"
show-overflow-tooltip
/>
<el-table-column
prop="userNickname"
label="作者"
width="100"
/>
<el-table-column
prop="viewCount"
label="播放"
width="80"
>
<template #default="{ row }"> <template #default="{ row }">
{{ formatNumber(row.viewCount) }} {{ formatNumber(row.viewCount) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="likeCount" label="点赞" width="80"> <el-table-column
prop="likeCount"
label="点赞"
width="80"
>
<template #default="{ row }"> <template #default="{ row }">
{{ formatNumber(row.likeCount) }} {{ formatNumber(row.likeCount) }}
</template> </template>
@@ -138,18 +370,43 @@
</el-table> </el-table>
</el-card> </el-card>
</el-col> </el-col>
<el-col :xs="24" :lg="12"> <el-col
:xs="24"
:lg="12"
>
<el-card shadow="hover"> <el-card shadow="hover">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>最新评论</span> <span>最新评论</span>
<el-button type="primary" text @click="goToCommentList">查看更多</el-button> <el-button
type="primary"
text
@click="goToCommentList"
>
查看更多
</el-button>
</div> </div>
</template> </template>
<el-table :data="recentComments" style="width: 100%"> <el-table
<el-table-column prop="content" label="内容" min-width="200" show-overflow-tooltip /> :data="recentComments"
<el-table-column prop="userNickname" label="用户" width="100" /> style="width: 100%"
<el-table-column prop="createTime" label="时间" width="120"> >
<el-table-column
prop="content"
label="内容"
min-width="200"
show-overflow-tooltip
/>
<el-table-column
prop="userNickname"
label="用户"
width="100"
/>
<el-table-column
prop="createTime"
label="时间"
width="120"
>
<template #default="{ row }"> <template #default="{ row }">
{{ formatTime(row.createTime) }} {{ formatTime(row.createTime) }}
</template> </template>
@@ -162,7 +419,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { 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 { statisticsApi } from '@/api/statistics.ts' import { statisticsApi } from '@/api/statistics.ts'
@@ -204,6 +461,11 @@ const trendChartRef = ref<HTMLElement>()
const pieChartRef = ref<HTMLElement>() const pieChartRef = ref<HTMLElement>()
let trendChart: echarts.ECharts | null = null let trendChart: echarts.ECharts | null = null
let pieChart: echarts.ECharts | null = null let pieChart: echarts.ECharts | null = null
const hasTrendData = computed(() =>
statistics.value.userTrend.some((item) => item.value > 0) ||
statistics.value.videoTrend.some((item) => item.value > 0) ||
statistics.value.viewTrend.some((item) => item.value > 0)
)
// 加载数据 // 加载数据
const loadDashboardData = async () => { const loadDashboardData = async () => {
@@ -364,6 +626,74 @@ onUnmounted(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.dashboard-page { .dashboard-page {
.overview-row {
margin-bottom: 20px;
}
.overview-card {
border-radius: 20px;
:deep(.el-card__body) {
padding: 22px;
}
}
.overview-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
strong {
display: block;
font-size: 18px;
color: #18191c;
}
span {
display: inline-block;
margin-top: 6px;
font-size: 13px;
color: #64748b;
}
}
.overview-actions {
display: flex;
gap: 10px;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.overview-item,
.alert-item {
background: #f8fafc;
border-radius: 16px;
padding: 14px 16px;
display: grid;
gap: 6px;
span {
font-size: 12px;
color: #94a3b8;
}
strong {
font-size: 20px;
color: #18191c;
}
}
.alert-list {
display: grid;
gap: 10px;
}
.hero-card { .hero-card {
margin-bottom: 20px; margin-bottom: 20px;
@@ -475,6 +805,17 @@ onUnmounted(() => {
.chart-container { .chart-container {
height: 300px; height: 300px;
} }
.chart-empty {
margin-top: -300px;
height: 300px;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 13px;
pointer-events: none;
}
} }
.list-row { .list-row {
@@ -485,4 +826,26 @@ onUnmounted(() => {
} }
} }
} }
@media (max-width: 992px) {
.dashboard-page {
.overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
}
@media (max-width: 768px) {
.dashboard-page {
.hero-content,
.overview-head {
flex-direction: column;
align-items: flex-start;
}
.overview-grid {
grid-template-columns: 1fr;
}
}
}
</style> </style>
@@ -1,10 +1,21 @@
<template> <template>
<div class="error-page"> <div class="error-page">
<div class="error-content"> <div class="error-content">
<h1 class="error-code">404</h1> <h1 class="error-code">
<h2 class="error-message">页面未找到</h2> 404
<p class="error-description">抱歉,您访问的页面不存在</p> </h1>
<el-button type="primary" @click="goHome">返回首页</el-button> <h2 class="error-message">
页面未找到
</h2>
<p class="error-description">
抱歉,您访问的页面不存在
</p>
<el-button
type="primary"
@click="goHome"
>
返回首页
</el-button>
</div> </div>
</div> </div>
</template> </template>
@@ -1,12 +1,28 @@
<template> <template>
<el-container class="layout-container"> <div class="admin-layout">
<!-- 侧边栏 --> <aside
<el-aside :width="sidebarWidth" class="sidebar"> class="layout-sidebar"
<div class="logo"> :class="{ collapsed: appStore.sidebarCollapsed }"
<span v-if="!appStore.sidebarCollapsed">HAOVP</span> >
<span v-else>H</span> <div class="sidebar-topline">
<span class="topline-pill">ADMIN</span>
<span v-if="!appStore.sidebarCollapsed">治理与运营控制台</span>
</div> </div>
<el-scrollbar>
<div class="sidebar-brand">
<div class="brand-mark">
<span>H</span>
</div>
<div
v-if="!appStore.sidebarCollapsed"
class="brand-copy"
>
<strong>HAOVP Console</strong>
<span>Platform Control</span>
</div>
</div>
<el-scrollbar class="sidebar-scroll">
<el-menu <el-menu
:default-active="activeMenu" :default-active="activeMenu"
:collapse="appStore.sidebarCollapsed" :collapse="appStore.sidebarCollapsed"
@@ -15,8 +31,10 @@
router router
class="sidebar-menu" class="sidebar-menu"
> >
<template v-for="route in menuRoutes" :key="route.path"> <template
<!-- 有子菜单 --> v-for="route in menuRoutes"
:key="route.path"
>
<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"
@@ -30,7 +48,7 @@
<el-menu-item <el-menu-item
v-for="child in route.children.filter((c: any) => !c.meta?.hidden)" v-for="child in route.children.filter((c: any) => !c.meta?.hidden)"
:key="child.path" :key="child.path"
:index="`${route.path}/${child.path}`" :index="resolveMenuPath(route.path, child.path)"
> >
<el-icon> <el-icon>
<component :is="child.meta?.icon" /> <component :is="child.meta?.icon" />
@@ -38,10 +56,9 @@
<span>{{ child.meta?.title }}</span> <span>{{ child.meta?.title }}</span>
</el-menu-item> </el-menu-item>
</el-sub-menu> </el-sub-menu>
<!-- 单个菜单项 -->
<el-menu-item <el-menu-item
v-else-if="route.children && route.children.length === 1" v-else-if="route.children && route.children.length === 1"
:index="`${route.path}/${route.children[0].path}`" :index="resolveMenuPath(route.path, route.children[0].path)"
> >
<el-icon> <el-icon>
<component :is="route.children[0].meta?.icon" /> <component :is="route.children[0].meta?.icon" />
@@ -51,33 +68,71 @@
</template> </template>
</el-menu> </el-menu>
</el-scrollbar> </el-scrollbar>
</el-aside> </aside>
<el-container class="main-container"> <div class="layout-main">
<!-- 顶部导航 --> <header class="layout-header">
<el-header class="header">
<div class="header-left"> <div class="header-left">
<el-icon class="collapse-btn" @click="toggleSidebar"> <button
<component :is="appStore.sidebarCollapsed ? 'Expand' : 'Fold'" /> class="collapse-btn"
</el-icon> @click="toggleSidebar"
>
<el-icon>
<component :is="appStore.sidebarCollapsed ? 'Expand' : 'Fold'" />
</el-icon>
</button>
<button
class="header-search"
@click="searchVisible = true"
>
<el-icon><Search /></el-icon>
<span>搜索后台页面与管理能力</span>
<kbd>/</kbd>
</button>
</div>
<div class="header-center">
<Breadcrumb /> <Breadcrumb />
</div> </div>
<div class="header-right"> <div class="header-right">
<el-dropdown trigger="click" @command="handleCommand"> <div class="header-chip">
<div class="user-info"> <strong>{{ currentSection }}</strong>
<el-avatar :src="userStore.avatar" :size="32"> <span>当前工作区</span>
</div>
<div class="header-chip">
<strong>{{ currentRoleLabel }}</strong>
<span>权限角色</span>
</div>
<el-dropdown
trigger="click"
@command="handleCommand"
>
<button class="user-entry">
<el-avatar
:src="userStore.avatar"
:size="34"
>
{{ userStore.nickname?.charAt(0) }} {{ userStore.nickname?.charAt(0) }}
</el-avatar> </el-avatar>
<span class="nickname">{{ userStore.nickname }}</span> <div class="user-copy">
<strong>{{ userStore.nickname || '管理员' }}</strong>
<span>{{ userStore.username || 'admin' }}</span>
</div>
<el-icon><ArrowDown /></el-icon> <el-icon><ArrowDown /></el-icon>
</div> </button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="profile"> <el-dropdown-item command="admins">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
个人中心 管理员管理
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item divided command="logout"> <el-dropdown-item
divided
command="logout"
>
<el-icon><SwitchButton /></el-icon> <el-icon><SwitchButton /></el-icon>
退出登录 退出登录
</el-dropdown-item> </el-dropdown-item>
@@ -85,27 +140,81 @@
</template> </template>
</el-dropdown> </el-dropdown>
</div> </div>
</el-header> </header>
<!-- 标签页导航 --> <section class="layout-subbar">
<TagsView /> <div class="subbar-copy">
<span class="subbar-pill">HAOVP Admin</span>
<span class="subbar-text">集中处理用户、视频、评论与系统治理链路。</span>
</div>
<TagsView />
</section>
<!-- 主内容区 --> <main class="layout-content">
<el-main class="main">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in"> <transition
name="fade-transform"
mode="out-in"
>
<keep-alive :include="cachedViews"> <keep-alive :include="cachedViews">
<component :is="Component" :key="key" /> <component
:is="Component"
:key="key"
/>
</keep-alive> </keep-alive>
</transition> </transition>
</router-view> </router-view>
</el-main> </main>
</el-container> </div>
</el-container>
<el-dialog
v-model="searchVisible"
width="560px"
class="global-search-dialog"
align-center
:show-close="false"
destroy-on-close
>
<div class="search-panel">
<div class="search-input-shell">
<el-icon><Search /></el-icon>
<input
ref="searchInputRef"
v-model.trim="searchKeyword"
type="text"
placeholder="搜索页面,例如:仪表盘、评论、标签、管理员"
@keydown.enter.prevent="goToSearchResult"
>
</div>
<div class="search-result-list">
<button
v-for="item in filteredSearchItems"
:key="item.path"
class="search-result-item"
@click="goToPath(item.path)"
>
<div class="search-result-copy">
<strong>{{ item.title }}</strong>
<span>{{ item.section }}</span>
</div>
<code>{{ item.path }}</code>
</button>
<div
v-if="!filteredSearchItems.length"
class="search-empty"
>
没有匹配的页面
</div>
</div>
</div>
</el-dialog>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
import { useUserStore, useAppStore, useTagsViewStore } from '@/stores' import { useUserStore, useAppStore, useTagsViewStore } from '@/stores'
@@ -119,33 +228,63 @@ const userStore = useUserStore()
const appStore = useAppStore() const appStore = useAppStore()
const tagsViewStore = useTagsViewStore() const tagsViewStore = useTagsViewStore()
// 侧边栏宽度
const sidebarWidth = computed(() => (appStore.sidebarCollapsed ? '64px' : '210px'))
// 当前激活菜单
const activeMenu = computed(() => route.path) const activeMenu = computed(() => route.path)
const menuRoutes = computed(() => asyncRoutes.filter((item) => !item.meta?.hidden && item.children?.length))
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 searchVisible = ref(false)
const searchKeyword = ref('')
const searchInputRef = ref<HTMLInputElement | null>(null)
// 菜单路由 const resolveMenuPath = (parentPath: string, childPath: string) => {
const menuRoutes = computed(() => { const parent = parentPath === '/' ? '' : parentPath.replace(/\/$/, '')
return asyncRoutes.filter((route) => !route.meta?.hidden && route.children?.length) const child = childPath.replace(/^\//, '')
return `${parent}/${child}` || '/'
}
const searchItems = computed(() =>
asyncRoutes.flatMap((route) =>
(route.children || [])
.filter((child: any) => !child.meta?.hidden)
.map((child: any) => ({
path: resolveMenuPath(route.path, child.path),
title: String(child.meta?.title || ''),
section: String(route.meta?.title || child.meta?.title || '')
}))
)
)
const filteredSearchItems = computed(() => {
const keyword = searchKeyword.value.toLowerCase()
if (!keyword) return searchItems.value
return searchItems.value.filter((item) =>
item.title.toLowerCase().includes(keyword) ||
item.section.toLowerCase().includes(keyword) ||
item.path.toLowerCase().includes(keyword)
)
}) })
// 缓存的页面
const cachedViews = computed(() => tagsViewStore.cachedViews)
// 路由key
const key = computed(() => route.fullPath)
// 切换侧边栏
const toggleSidebar = () => { const toggleSidebar = () => {
appStore.toggleSidebar() appStore.toggleSidebar()
} }
// 下拉菜单命令 const goToPath = (path: string) => {
searchVisible.value = false
searchKeyword.value = ''
router.push(path)
}
const goToSearchResult = () => {
if (!filteredSearchItems.value.length) return
goToPath(filteredSearchItems.value[0].path)
}
const handleCommand = async (command: string) => { const handleCommand = async (command: string) => {
switch (command) { switch (command) {
case 'profile': case 'admins':
router.push('/profile') router.push('/system/admin')
break break
case 'logout': case 'logout':
try { try {
@@ -157,140 +296,323 @@ const handleCommand = async (command: string) => {
await userStore.logout() await userStore.logout()
router.push('/login') router.push('/login')
} catch { } catch {
// 取消 // ignore
} }
break break
} }
} }
// 响应式布局 const handleKeydown = (event: KeyboardEvent) => {
if (event.key === '/' && !searchVisible.value) {
const target = event.target as HTMLElement | null
const tag = target?.tagName?.toLowerCase()
if (tag === 'input' || tag === 'textarea' || target?.isContentEditable) return
event.preventDefault()
searchVisible.value = true
}
}
const handleResize = () => { const handleResize = () => {
const width = window.innerWidth if (window.innerWidth < 900) {
if (width < 768) {
appStore.setDevice('mobile') appStore.setDevice('mobile')
} else { } else {
appStore.setDevice('desktop') appStore.setDevice('desktop')
} }
} }
watch(searchVisible, async (value) => {
if (value) {
await nextTick()
searchInputRef.value?.focus()
} else {
searchKeyword.value = ''
}
})
onMounted(() => { onMounted(() => {
handleResize() handleResize()
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
window.addEventListener('keydown', handleKeydown)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
window.removeEventListener('keydown', handleKeydown)
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.layout-container { .admin-layout {
height: 100vh; min-height: 100vh;
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
background: #f4f7fb;
} }
.sidebar { .layout-sidebar {
background: background:
linear-gradient(180deg, rgba(14, 30, 55, 0.98), rgba(10, 20, 36, 0.98)), linear-gradient(180deg, rgba(12, 20, 34, 0.98), rgba(17, 24, 39, 0.98)),
#0f172a; #0f172a;
transition: width 0.3s; color: #e2e8f0;
overflow: hidden;
border-right: 1px solid rgba(148, 163, 184, 0.12); border-right: 1px solid rgba(148, 163, 184, 0.12);
.logo {
height: 58px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
font-weight: bold;
background: rgba(255, 255, 255, 0.04);
letter-spacing: 0.08em;
}
.sidebar-menu {
border-right: none;
background: transparent;
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
color: #bfcbd9;
&:hover {
background: rgba(255, 255, 255, 0.05);
}
}
:deep(.el-menu-item.is-active) {
color: #8bd3ff;
background: linear-gradient(90deg, rgba(59, 130, 246, 0.22), rgba(14, 165, 233, 0.08));
}
:deep(.el-sub-menu .el-menu-item) {
background: rgba(255, 255, 255, 0.02);
&:hover {
background: rgba(255, 255, 255, 0.06);
}
}
}
}
.main-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; transition: width 0.3s ease;
&.collapsed {
width: 72px;
}
} }
.header { .sidebar-topline {
min-height: 34px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
font-size: 12px;
color: rgba(255, 255, 255, 0.72);
}
.topline-pill {
padding: 4px 8px;
border-radius: 999px;
background: rgba(56, 189, 248, 0.14);
color: #dff6fd;
font-size: 11px;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 16px;
}
.brand-mark {
width: 42px;
height: 42px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #00aeec, #ff6699);
color: #fff;
font-size: 20px;
font-weight: 700;
}
.brand-copy {
display: grid;
gap: 4px;
strong {
color: #fff;
font-size: 16px;
}
span {
color: #94a3b8;
font-size: 12px;
}
}
.sidebar-scroll {
flex: 1;
}
.sidebar-menu {
border-right: none;
background: transparent;
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
color: #cbd5e1;
border-radius: 12px;
margin: 4px 10px;
&:hover {
background: rgba(255, 255, 255, 0.05);
}
}
:deep(.el-menu-item.is-active) {
color: #8bd3ff;
background: linear-gradient(90deg, rgba(59, 130, 246, 0.22), rgba(14, 165, 233, 0.08));
}
:deep(.el-sub-menu .el-menu-item) {
background: rgba(255, 255, 255, 0.02);
}
}
.layout-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.layout-header {
min-height: 68px;
padding: 0 20px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 18px;
background: rgba(255, 255, 255, 0.94);
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
backdrop-filter: blur(18px);
}
.header-left,
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.header-center {
min-width: 0;
}
.collapse-btn {
width: 38px;
height: 38px;
border-radius: 12px;
background: #f8fafc;
color: #475569;
display: inline-flex;
align-items: center;
justify-content: center;
}
.header-search {
min-width: 320px;
height: 40px;
border-radius: 999px;
background: #f1f5f9;
padding: 0 14px;
display: flex;
align-items: center;
gap: 8px;
color: #94a3b8;
border: 1px solid transparent;
transition: all 0.2s ease;
span {
flex: 1;
text-align: left;
color: #64748b;
font-size: 13px;
}
kbd {
min-width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: rgba(255, 255, 255, 0.9);
color: #475569;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.26);
}
&:hover {
border-color: rgba(59, 130, 246, 0.16);
background: #fff;
}
}
.header-chip {
min-width: 88px;
padding: 8px 12px;
border-radius: 14px;
background: #f8fafc;
display: grid;
justify-items: center;
strong {
font-size: 13px;
color: #18191c;
}
span {
font-size: 11px;
color: #94a3b8;
}
}
.user-entry {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px;
border-radius: 999px;
background: #f8fafc;
}
.user-copy {
display: grid;
text-align: left;
strong {
font-size: 13px;
color: #18191c;
}
span {
font-size: 11px;
color: #94a3b8;
}
}
.layout-subbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 20px; gap: 16px;
background: rgba(255, 255, 255, 0.92); padding: 10px 20px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06); background: #fff;
backdrop-filter: blur(16px); border-bottom: 1px solid rgba(226, 232, 240, 0.78);
z-index: 10;
.header-left {
display: flex;
align-items: center;
gap: 16px;
.collapse-btn {
font-size: 20px;
cursor: pointer;
color: #666;
&:hover {
color: #409eff;
}
}
}
.header-right {
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
.nickname {
color: #333;
}
}
}
} }
.main { .subbar-copy {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.subbar-pill {
padding: 4px 9px;
border-radius: 999px;
background: rgba(59, 130, 246, 0.12);
color: #2563eb;
font-size: 11px;
white-space: nowrap;
}
.subbar-text {
font-size: 12px;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.layout-content {
flex: 1;
padding: 24px;
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;
padding: 24px;
overflow: auto; overflow: auto;
} }
// 过渡动画
.fade-transform-enter-active, .fade-transform-enter-active,
.fade-transform-leave-active { .fade-transform-leave-active {
transition: all 0.3s; transition: all 0.3s;
@@ -305,4 +627,128 @@ onUnmounted(() => {
opacity: 0; opacity: 0;
transform: translateX(30px); transform: translateX(30px);
} }
@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,
.subbar-copy {
display: none;
}
.header-search {
min-width: 0;
}
}
:deep(.global-search-dialog .el-dialog__body) {
padding: 18px;
}
.search-panel {
display: grid;
gap: 14px;
}
.search-input-shell {
height: 48px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid rgba(148, 163, 184, 0.18);
padding: 0 14px;
display: flex;
align-items: center;
gap: 10px;
color: #64748b;
input {
flex: 1;
background: transparent;
color: #0f172a;
font-size: 14px;
}
}
.search-result-list {
display: grid;
gap: 10px;
max-height: 360px;
overflow: auto;
}
.search-result-item {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(226, 232, 240, 0.9);
background: #fff;
padding: 12px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
text-align: left;
&:hover {
border-color: rgba(59, 130, 246, 0.22);
background: #f8fbff;
}
code {
color: #64748b;
font-size: 12px;
}
}
.search-result-copy {
display: grid;
gap: 4px;
strong {
color: #0f172a;
font-size: 14px;
}
span {
color: #94a3b8;
font-size: 12px;
}
}
.search-empty {
border-radius: 14px;
background: #f8fafc;
color: #94a3b8;
font-size: 13px;
text-align: center;
padding: 18px 12px;
}
@media (max-width: 768px) {
.layout-header,
.layout-subbar {
padding: 12px;
}
.layout-header {
grid-template-columns: 1fr;
}
.header-left,
.header-right {
width: 100%;
justify-content: space-between;
}
.layout-content {
padding: 16px;
}
}
</style> </style>
@@ -1,92 +1,140 @@
<template> <template>
<div class="login-container"> <div class="admin-login-page">
<div class="login-box"> <div class="login-shell">
<div class="login-header"> <section class="login-showcase">
<h1 class="logo">HAOVP</h1> <div class="showcase-copy">
<p class="title">管理后台</p> <p class="eyebrow">
<p class="subtitle">统一管理平台用户、内容审核、评论治理与运营数据</p> HAOVP Admin
</div> </p>
<h1>平台治理、内容审核与运营数据统一入口</h1>
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="login-form"> <p class="lead">
<!-- 验证码 --> 在同一套控制台中处理用户、视频、评论和后台统计,适合日常运营值守和答辩演示场景。
<div class="captcha-wrapper"> </p>
<el-image
:src="captchaImage"
fit="contain"
class="captcha-image"
@click="refreshCaptcha"
>
<template #error>
<div class="captcha-error" @click="refreshCaptcha">点击刷新</div>
</template>
</el-image>
</div> </div>
<el-form-item prop="username"> <div class="showcase-grid">
<el-input <div class="showcase-card">
v-model="loginForm.username" <strong>内容治理</strong>
placeholder="用户名" <span>审核视频、清理评论、维护分类与标签。</span>
prefix-icon="User" </div>
size="large" <div class="showcase-card">
/> <strong>用户管理</strong>
</el-form-item> <span>查看用户详情、状态、行为与运营指标。</span>
</div>
<div class="showcase-card">
<strong>实时概览</strong>
<span>通过仪表盘追踪增长、播放和互动变化。</span>
</div>
</div>
</section>
<el-form-item prop="password"> <section class="login-panel">
<el-input <div class="panel-header">
v-model="loginForm.password" <h1 class="logo">
type="password" HAOVP
placeholder="密码" </h1>
prefix-icon="Lock" <p class="title">
size="large" 管理后台登录
show-password </p>
@keyup.enter="handleLogin" <p class="subtitle">
/> 统一管理平台用户、内容审核、评论治理与运营数据
</el-form-item> </p>
</div>
<el-form-item prop="captcha"> <el-form
<el-input ref="loginFormRef"
v-model="loginForm.captcha" :model="loginForm"
placeholder="验证码" :rules="loginRules"
prefix-icon="Key" class="login-form"
size="large" >
@keyup.enter="handleLogin" <div class="captcha-banner">
> <div>
<template #append> <strong>安全验证</strong>
<el-image <span>当前后台登录必须填写验证码,点击图片可刷新。</span>
:src="captchaImage" </div>
fit="contain" <el-image
class="captcha-btn" :src="captchaImage"
@click="refreshCaptcha" fit="contain"
/> class="captcha-image"
</template> @click="refreshCaptcha"
</el-input> >
</el-form-item> <template #error>
<div
class="captcha-error"
@click="refreshCaptcha"
>
点击刷新
</div>
</template>
</el-image>
</div>
<el-form-item> <el-form-item prop="username">
<el-button <el-input
type="primary" v-model="loginForm.username"
size="large" placeholder="用户名"
:loading="loading" prefix-icon="User"
class="login-btn" size="large"
@click="handleLogin" />
> </el-form-item>
登 录
</el-button>
</el-form-item>
</el-form>
<div class="demo-tips"> <el-form-item prop="password">
<span>管理员账号:admin</span> <el-input
<span>登录密码:不少于 6 位</span> v-model="loginForm.password"
<span>验证码请按图形输入</span> type="password"
</div> placeholder="密码"
prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item prop="captcha">
<el-input
v-model="loginForm.captcha"
placeholder="验证码"
prefix-icon="Key"
size="large"
@keyup.enter="handleLogin"
>
<template #append>
<el-image
:src="captchaImage"
fit="contain"
class="captcha-btn"
@click="refreshCaptcha"
/>
</template>
</el-input>
</el-form-item>
<div class="submit-row">
<el-button
type="primary"
size="large"
:loading="loading"
class="login-btn"
@click="handleLogin"
>
登 录
</el-button>
</div>
</el-form>
<div class="demo-tips">
<span>管理员账号:admin</span>
<span>登录密码:不少于 6 位</span>
<span>验证码请按图形输入</span>
</div>
</section>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { authApi, type CaptchaResult } from '@/api/auth.ts' import { authApi, type CaptchaResult } from '@/api/auth.ts'
import { useUserStore } from '@/stores/user.ts' import { useUserStore } from '@/stores/user.ts'
@@ -115,7 +163,6 @@ const loginRules: FormRules = {
captcha: [{ required: true, message: '请输入验证码', trigger: 'blur' }] captcha: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
} }
// 获取验证码
const refreshCaptcha = async () => { const refreshCaptcha = async () => {
try { try {
const res: CaptchaResult = await authApi.getCaptcha() const res: CaptchaResult = await authApi.getCaptcha()
@@ -126,7 +173,6 @@ const refreshCaptcha = async () => {
} }
} }
// 登录
const handleLogin = async () => { const handleLogin = async () => {
if (!loginFormRef.value) return if (!loginFormRef.value) return
@@ -143,8 +189,6 @@ const handleLogin = async () => {
}) })
ElMessage.success('登录成功') ElMessage.success('登录成功')
// 跳转到之前的页面或首页
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) {
@@ -162,86 +206,175 @@ onMounted(() => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.login-container { .admin-login-page {
min-height: 100vh; min-height: 100vh;
background:
radial-gradient(circle at 18% 18%, rgba(56, 189, 248, 0.16), transparent 24%),
radial-gradient(circle at 82% 82%, rgba(34, 197, 94, 0.14), transparent 22%),
linear-gradient(135deg, #08111f 0%, #11284a 52%, #0f766e 100%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 28px 20px;
}
.login-shell {
width: min(1180px, 100%);
display: grid;
grid-template-columns: minmax(0, 1.15fr) 440px;
gap: 20px;
}
.login-showcase,
.login-panel {
border-radius: 28px;
}
.login-showcase {
padding: 34px 32px;
color: #fff;
background: background:
radial-gradient(circle at top left, rgba(19, 110, 255, 0.22), transparent 28%), radial-gradient(circle at top right, rgba(255, 255, 255, 0.18), transparent 28%),
radial-gradient(circle at bottom right, rgba(26, 188, 156, 0.2), transparent 32%), linear-gradient(135deg, rgba(15, 23, 42, 0.92), rgba(29, 78, 216, 0.88));
linear-gradient(135deg, #081224 0%, #11284a 48%, #0f766e 100%); box-shadow: 0 24px 80px rgba(5, 18, 39, 0.34);
} }
.login-box { .eyebrow {
width: 440px; margin: 0 0 12px;
padding: 42px 40px 28px; font-size: 12px;
background: rgba(255, 255, 255, 0.96); letter-spacing: 0.18em;
border-radius: 24px; text-transform: uppercase;
box-shadow: 0 24px 80px rgba(5, 18, 39, 0.36); color: rgba(255, 255, 255, 0.68);
backdrop-filter: blur(20px);
} }
.login-header { .showcase-copy h1 {
text-align: center; margin: 0 0 12px;
margin-bottom: 30px; font-size: 40px;
line-height: 1.18;
}
.logo { .lead {
font-size: 36px; margin: 0;
font-weight: bold; max-width: 620px;
background: linear-gradient(to right, #00a1d6, #fb7299); color: rgba(255, 255, 255, 0.82);
-webkit-background-clip: text; line-height: 1.8;
-webkit-text-fill-color: transparent; }
background-clip: text;
margin: 0;
}
.title { .showcase-grid {
color: #355070; display: grid;
gap: 12px;
margin-top: 28px;
}
.showcase-card {
padding: 18px 20px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.08);
strong {
display: block;
margin-bottom: 6px;
font-size: 16px; font-size: 16px;
margin-top: 8px;
} }
.subtitle { span {
margin-top: 10px; color: rgba(255, 255, 255, 0.76);
color: #6b7280;
font-size: 13px; font-size: 13px;
line-height: 1.7; line-height: 1.7;
} }
} }
.captcha-wrapper { .login-panel {
display: flex; padding: 30px 28px 26px;
justify-content: center; background: rgba(255, 255, 255, 0.96);
margin-bottom: 20px; box-shadow: 0 24px 80px rgba(5, 18, 39, 0.34);
}
.captcha-image { .panel-header {
height: 50px; margin-bottom: 22px;
cursor: pointer; }
.logo {
margin: 0;
font-size: 34px;
font-weight: 800;
background: linear-gradient(135deg, #00a1d6, #fb7299);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.title {
color: #355070;
font-size: 16px;
margin-top: 8px;
}
.subtitle {
margin-top: 10px;
color: #6b7280;
font-size: 13px;
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;
} }
.captcha-error { span {
width: 150px; color: #64748b;
height: 50px; font-size: 12px;
display: flex; line-height: 1.6;
align-items: center;
justify-content: center;
background: #f5f5f5;
color: #999;
cursor: pointer;
} }
} }
.login-form { .captcha-image {
.captcha-btn { width: 120px;
width: 100px; height: 42px;
height: 32px; cursor: pointer;
cursor: pointer; flex-shrink: 0;
} }
.login-btn { .captcha-error {
width: 100%; 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;
cursor: pointer;
}
.submit-row {
margin-top: 20px;
}
.login-btn {
width: 100%;
background: linear-gradient(135deg, #00a1d6, #2f80ed);
border: none;
box-shadow: 0 14px 28px rgba(0, 161, 214, 0.24);
} }
.demo-tips { .demo-tips {
@@ -255,4 +388,29 @@ onMounted(() => {
font-size: 12px; font-size: 12px;
border: 1px solid rgba(59, 130, 246, 0.12); border: 1px solid rgba(59, 130, 246, 0.12);
} }
@media (max-width: 960px) {
.login-shell {
grid-template-columns: 1fr;
}
.login-showcase {
display: none;
}
}
@media (max-width: 768px) {
.admin-login-page {
padding: 16px 12px;
}
.login-panel {
padding: 22px 18px 20px;
}
.captcha-banner {
flex-direction: column;
align-items: flex-start;
}
}
</style> </style>
@@ -3,57 +3,131 @@
<el-card shadow="never"> <el-card shadow="never">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<el-form :inline="true" :model="queryParams"> <el-form
:inline="true"
:model="queryParams"
>
<el-form-item> <el-form-item>
<el-input v-model="queryParams.keyword" placeholder="用户名/昵称" clearable @keyup.enter="handleSearch" /> <el-input
v-model="queryParams.keyword"
placeholder="用户名/昵称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button> <el-button
type="primary"
@click="handleSearch"
>
搜索
</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-button type="primary" @click="handleAdd"> <el-button
type="primary"
@click="handleAdd"
>
<el-icon><Plus /></el-icon>添加管理员 <el-icon><Plus /></el-icon>添加管理员
</el-button> </el-button>
</div> </div>
</template> </template>
<el-table v-loading="loading" :data="adminList"> <el-table
<el-table-column prop="id" label="ID" width="80" /> v-loading="loading"
<el-table-column label="管理员信息" min-width="200"> :data="adminList"
>
<el-table-column
prop="id"
label="ID"
width="80"
/>
<el-table-column
label="管理员信息"
min-width="200"
>
<template #default="{ row }"> <template #default="{ row }">
<div class="admin-info"> <div class="admin-info">
<el-avatar :src="row.avatar" :size="40">{{ row.nickname?.charAt(0) }}</el-avatar> <el-avatar
:src="row.avatar"
:size="40"
>
{{ row.nickname?.charAt(0) }}
</el-avatar>
<div class="info"> <div class="info">
<div class="nickname">{{ row.nickname }}</div> <div class="nickname">
<div class="username">@{{ row.username }}</div> {{ row.nickname }}
</div>
<div class="username">
@{{ row.username }}
</div>
</div> </div>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="email" label="邮箱" width="180" /> <el-table-column
<el-table-column prop="phone" label="手机号" width="120" /> prop="email"
<el-table-column label="角色" width="100"> label="邮箱"
width="180"
/>
<el-table-column
prop="phone"
label="手机号"
width="120"
/>
<el-table-column
label="角色"
width="100"
>
<template #default="{ row }"> <template #default="{ row }">
<el-tag>{{ row.role === 'admin' ? '超级管理员' : '管理员' }}</el-tag> <el-tag>{{ row.roleName || (row.role === 'admin' ? '超级管理员' : '管理员') }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="80"> <el-table-column
label="状态"
width="80"
>
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'"> <el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '禁用' }} {{ row.status === 1 ? '正常' : '禁用' }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="lastLoginTime" label="最后登录" width="180"> <el-table-column
<template #default="{ row }">{{ formatDateTime(row.lastLoginTime) }}</template> prop="lastLoginTime"
</el-table-column> label="最后登录"
<el-table-column label="操作" width="200"> width="180"
>
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" text @click="handleEdit(row)">编辑</el-button> {{ formatDateTime(row.lastLoginTime) }}
<el-button :type="row.status === 1 ? 'warning' : 'success'" text @click="handleToggleStatus(row)"> </template>
</el-table-column>
<el-table-column
label="操作"
width="200"
>
<template #default="{ row }">
<el-button
type="primary"
text
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
:type="row.status === 1 ? 'warning' : 'success'"
text
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }} {{ row.status === 1 ? '禁用' : '启用' }}
</el-button> </el-button>
<el-button type="warning" text @click="handleResetPassword(row)">重置密码</el-button> <el-button
type="warning"
text
@click="handleResetPassword(row)"
>
重置密码
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -69,33 +143,96 @@
</div> </div>
</el-card> </el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px"> <el-dialog
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px"> v-model="dialogVisible"
<el-form-item label="用户名" prop="username" v-if="!form.id"> :title="dialogTitle"
<el-input v-model="form.username" placeholder="请输入用户名" /> width="500px"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="80px"
>
<el-form-item
v-if="!form.id"
label="用户名"
prop="username"
>
<el-input
v-model="form.username"
placeholder="请输入用户名"
/>
</el-form-item> </el-form-item>
<el-form-item label="昵称" prop="nickname"> <el-form-item
<el-input v-model="form.nickname" placeholder="请输入昵称" /> label="昵称"
prop="nickname"
>
<el-input
v-model="form.nickname"
placeholder="请输入昵称"
/>
</el-form-item> </el-form-item>
<el-form-item label="邮箱" prop="email"> <el-form-item
<el-input v-model="form.email" placeholder="请输入邮箱" /> label="邮箱"
prop="email"
>
<el-input
v-model="form.email"
placeholder="请输入邮箱"
/>
</el-form-item> </el-form-item>
<el-form-item label="手机号" prop="phone"> <el-form-item
<el-input v-model="form.phone" placeholder="请输入手机号" /> label="手机号"
prop="phone"
>
<el-input
v-model="form.phone"
placeholder="请输入手机号"
/>
</el-form-item> </el-form-item>
<el-form-item label="角色" prop="role"> <el-form-item
<el-select v-model="form.role" placeholder="请选择角色"> label="角色"
<el-option label="管理员" value="manager" /> prop="role"
<el-option label="超级管理员" value="admin" /> >
<el-select
v-model="form.role"
placeholder="请选择角色"
>
<el-option
label="管理员"
value="manager"
/>
<el-option
label="超级管理员"
value="admin"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="password" v-if="!form.id"> <el-form-item
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password /> v-if="!form.id"
label="密码"
prop="password"
>
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button> 取消
</el-button>
<el-button
type="primary"
:loading="submitting"
@click="handleSubmit"
>
确定
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -202,7 +339,7 @@ const handleSubmit = async () => {
submitting.value = true submitting.value = true
try { try {
if (form.id) { if (form.id) {
await adminApi.update({ id: form.id, 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, role: form.role })
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, role: form.role })
@@ -11,33 +11,55 @@
</div> </div>
</template> </template>
<el-skeleton :loading="loading" animated> <el-skeleton
:loading="loading"
animated
>
<template #default> <template #default>
<el-descriptions :column="3" border> <el-descriptions
<el-descriptions-item label="用户ID">{{ user?.id }}</el-descriptions-item> :column="3"
<el-descriptions-item label="用户名">{{ user?.username }}</el-descriptions-item> border
<el-descriptions-item label="昵称">{{ user?.nickname }}</el-descriptions-item> >
<el-descriptions-item label="邮箱">{{ user?.email }}</el-descriptions-item> <el-descriptions-item label="用户ID">
<el-descriptions-item label="手机号">{{ user?.phone }}</el-descriptions-item> {{ user?.id }}
</el-descriptions-item>
<el-descriptions-item label="昵称">
{{ user?.nickname }}
</el-descriptions-item>
<el-descriptions-item label="性别"> <el-descriptions-item label="性别">
{{ user?.gender === 1 ? '男' : user?.gender === 2 ? '女' : '未知' }} {{ user?.gender === 1 ? '男' : user?.gender === 2 ? '女' : '未知' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="等级">Lv.{{ user?.level }}</el-descriptions-item> <el-descriptions-item label="地区">
{{ user?.region || '未知' }}
</el-descriptions-item>
<el-descriptions-item label="等级">
Lv.{{ user?.level }}
</el-descriptions-item>
<el-descriptions-item label="VIP等级"> <el-descriptions-item label="VIP等级">
{{ (user?.vipLevel ?? 0) > 0 ? `VIP${user?.vipLevel}` : '普通用户' }} {{ (user?.vipLevel ?? 0) > 0 ? `VIP${user?.vipLevel}` : '普通用户' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="状态"> <el-descriptions-item label="粉丝数">
<el-tag :type="user?.status === 1 ? 'success' : 'danger'"> {{ user?.followerCount }}
{{ user?.status === 1 ? '正常' : '禁用' }}
</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="粉丝数">{{ user?.followerCount }}</el-descriptions-item> <el-descriptions-item label="关注数">
<el-descriptions-item label="关注数">{{ user?.followingCount }}</el-descriptions-item> {{ user?.followingCount }}
<el-descriptions-item label="视频数">{{ user?.videoCount }}</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="获赞数">{{ user?.likeCount }}</el-descriptions-item> <el-descriptions-item label="视频数">
<el-descriptions-item label="注册时间">{{ formatDateTime(user?.createTime) }}</el-descriptions-item> {{ user?.videoCount }}
<el-descriptions-item label="更新时间">{{ formatDateTime(user?.updateTime) }}</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="个人签名" :span="3"> <el-descriptions-item label="获赞数">
{{ user?.likeCount }}
</el-descriptions-item>
<el-descriptions-item label="注册时间">
{{ formatDateTime(user?.createTime) }}
</el-descriptions-item>
<el-descriptions-item label="生日">
{{ user?.birthday || '未设置' }}
</el-descriptions-item>
<el-descriptions-item
label="个人签名"
:span="3"
>
{{ user?.signature || '暂无' }} {{ user?.signature || '暂无' }}
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
@@ -1,8 +1,38 @@
<template> <template>
<div class="user-list-page"> <div class="user-list-page">
<!-- 搜索表单 --> <el-card
<el-card shadow="never" class="search-card"> shadow="never"
<el-form :model="queryParams" inline> 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"
>
<el-form
:model="queryParams"
inline
>
<el-form-item label="关键词"> <el-form-item label="关键词">
<el-input <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
@@ -12,9 +42,19 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable> <el-select
<el-option label="正常" :value="1" /> v-model="queryParams.status"
<el-option label="禁用" :value="0" /> placeholder="全部"
clearable
>
<el-option
label="正常"
:value="1"
/>
<el-option
label="禁用"
:value="0"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="注册时间"> <el-form-item label="注册时间">
@@ -29,7 +69,10 @@
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleSearch"> <el-button
type="primary"
@click="handleSearch"
>
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
搜索 搜索
</el-button> </el-button>
@@ -41,41 +84,117 @@
</el-form> </el-form>
</el-card> </el-card>
<!-- 数据表格 --> <el-row
<el-card shadow="never" class="table-card"> :gutter="20"
class="overview-row"
>
<el-col
:xs="24"
:md="8"
>
<el-card
shadow="hover"
class="overview-card"
>
<span>总结果数</span>
<strong>{{ total }}</strong>
</el-card>
</el-col>
<el-col
:xs="24"
:md="8"
>
<el-card
shadow="hover"
class="overview-card"
>
<span>当前页码</span>
<strong>{{ queryParams.page }}</strong>
</el-card>
</el-col>
<el-col
:xs="24"
:md="8"
>
<el-card
shadow="hover"
class="overview-card"
>
<span>每页数量</span>
<strong>{{ queryParams.size }}</strong>
</el-card>
</el-col>
</el-row>
<el-card
shadow="never"
class="table-card"
>
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>用户列表</span> <span>用户列表</span>
<el-button type="primary" @click="handleExport"> <el-button
type="primary"
@click="handleExport"
>
<el-icon><Download /></el-icon> <el-icon><Download /></el-icon>
导出 导出
</el-button> </el-button>
</div> </div>
</template> </template>
<el-table v-loading="loading" :data="userList" style="width: 100%"> <el-table
<el-table-column prop="id" label="ID" width="80" /> v-loading="loading"
<el-table-column label="用户信息" min-width="200"> :data="userList"
style="width: 100%"
>
<el-table-column
prop="id"
label="ID"
width="80"
/>
<el-table-column
label="用户信息"
min-width="200"
>
<template #default="{ row }"> <template #default="{ row }">
<div class="user-info"> <div class="user-info">
<el-avatar :src="row.avatar" :size="40"> <el-avatar
:src="row.avatar"
:size="40"
>
{{ row.nickname?.charAt(0) }} {{ row.nickname?.charAt(0) }}
</el-avatar> </el-avatar>
<div class="info"> <div class="info">
<div class="nickname">{{ row.nickname }}</div> <div class="nickname">
<div class="username">@{{ row.username }}</div> {{ row.nickname }}
</div>
<div class="username">
@{{ row.username }}
</div>
</div> </div>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="email" label="邮箱" width="180" show-overflow-tooltip /> <el-table-column
<el-table-column prop="phone" label="手机号" width="120" /> prop="region"
<el-table-column label="等级" width="80"> label="地区"
width="120"
/>
<el-table-column
label="等级"
width="80"
>
<template #default="{ row }"> <template #default="{ row }">
<el-tag type="warning">Lv.{{ row.level }}</el-tag> <el-tag type="warning">
Lv.{{ row.level }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="数据" width="200"> <el-table-column
label="数据"
width="200"
>
<template #default="{ row }"> <template #default="{ row }">
<div class="stats"> <div class="stats">
<span>视频 {{ row.videoCount }}</span> <span>视频 {{ row.videoCount }}</span>
@@ -84,31 +203,55 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="80"> <el-table-column
label="签名"
min-width="180"
>
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'"> <span>{{ row.signature || '暂无签名' }}</span>
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="createTime" label="注册时间" width="180"> <el-table-column
prop="createTime"
label="注册时间"
width="180"
>
<template #default="{ row }"> <template #default="{ row }">
{{ formatDateTime(row.createTime) }} {{ formatDateTime(row.createTime) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180" fixed="right"> <el-table-column
label="操作"
width="240"
fixed="right"
>
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" text @click="handleDetail(row)"> <el-button
type="primary"
text
@click="handleDetail(row)"
>
详情 详情
</el-button> </el-button>
<el-button <el-button
:type="row.status === 1 ? 'danger' : 'success'" type="danger"
text text
@click="handleStatus(row)" @click="handleStatus(row, 0)"
> >
{{ row.status === 1 ? '禁用' : '启用' }} 设为禁用
</el-button> </el-button>
<el-button type="warning" text @click="handleResetPassword(row)"> <el-button
type="success"
text
@click="handleStatus(row, 1)"
>
设为正常
</el-button>
<el-button
type="warning"
text
@click="handleResetPassword(row)"
>
重置密码 重置密码
</el-button> </el-button>
</template> </template>
@@ -130,32 +273,80 @@
</el-card> </el-card>
<!-- 用户详情对话框 --> <!-- 用户详情对话框 -->
<el-dialog v-model="detailVisible" title="用户详情" width="600px"> <el-dialog
<el-descriptions :column="2" border> v-model="detailVisible"
<el-descriptions-item label="用户ID">{{ currentUser?.id }}</el-descriptions-item> title="用户详情"
<el-descriptions-item label="用户名">{{ currentUser?.username }}</el-descriptions-item> width="600px"
<el-descriptions-item label="昵称">{{ currentUser?.nickname }}</el-descriptions-item> >
<el-descriptions-item label="邮箱">{{ currentUser?.email }}</el-descriptions-item> <el-descriptions
<el-descriptions-item label="手机号">{{ currentUser?.phone }}</el-descriptions-item> :column="2"
border
>
<el-descriptions-item label="用户ID">
{{ currentUser?.id }}
</el-descriptions-item>
<el-descriptions-item label="用户名">
{{ currentUser?.username }}
</el-descriptions-item>
<el-descriptions-item label="昵称">
{{ currentUser?.nickname }}
</el-descriptions-item>
<el-descriptions-item label="邮箱">
{{ currentUser?.email }}
</el-descriptions-item>
<el-descriptions-item label="手机号">
{{ currentUser?.phone }}
</el-descriptions-item>
<el-descriptions-item label="性别"> <el-descriptions-item label="性别">
{{ currentUser?.gender === 1 ? '男' : currentUser?.gender === 2 ? '女' : '未知' }} {{ currentUser?.gender === 1 ? '男' : currentUser?.gender === 2 ? '女' : '未知' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="等级">Lv.{{ currentUser?.level }}</el-descriptions-item> <el-descriptions-item label="等级">
Lv.{{ currentUser?.level }}
</el-descriptions-item>
<el-descriptions-item label="VIP等级"> <el-descriptions-item label="VIP等级">
{{ (currentUser?.vipLevel ?? 0) > 0 ? `VIP${currentUser?.vipLevel}` : '普通用户' }} {{ (currentUser?.vipLevel ?? 0) > 0 ? `VIP${currentUser?.vipLevel}` : '普通用户' }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="粉丝数">{{ currentUser?.followerCount }}</el-descriptions-item> <el-descriptions-item label="粉丝数">
<el-descriptions-item label="关注数">{{ currentUser?.followingCount }}</el-descriptions-item> {{ currentUser?.followerCount }}
<el-descriptions-item label="视频数">{{ currentUser?.videoCount }}</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="获赞数">{{ currentUser?.likeCount }}</el-descriptions-item> <el-descriptions-item label="关注数">
{{ currentUser?.followingCount }}
</el-descriptions-item>
<el-descriptions-item label="视频数">
{{ currentUser?.videoCount }}
</el-descriptions-item>
<el-descriptions-item label="获赞数">
{{ currentUser?.likeCount }}
</el-descriptions-item>
<el-descriptions-item label="状态"> <el-descriptions-item label="状态">
<el-tag :type="currentUser?.status === 1 ? 'success' : 'danger'"> <el-tag
{{ currentUser?.status === 1 ? '正常' : '禁用' }} :type="
currentUser?.status === 1
? 'success'
: currentUser?.status === 0
? 'danger'
: 'info'
"
>
{{
currentUser?.status === 1
? '正常'
: currentUser?.status === 0
? '禁用'
: '未知'
}}
</el-tag> </el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="注册时间">{{ formatDateTime(currentUser?.createTime) }}</el-descriptions-item> <el-descriptions-item label="注册时间">
<el-descriptions-item label="更新时间">{{ formatDateTime(currentUser?.updateTime) }}</el-descriptions-item> {{ formatDateTime(currentUser?.createTime) }}
<el-descriptions-item label="个人签名" :span="2"> </el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ formatDateTime(currentUser?.updateTime) }}
</el-descriptions-item>
<el-descriptions-item
label="个人签名"
:span="2"
>
{{ currentUser?.signature || '暂无' }} {{ currentUser?.signature || '暂无' }}
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
@@ -255,8 +446,7 @@ const handleDetail = async (row: PlatformUser) => {
} }
// 切换状态 // 切换状态
const handleStatus = async (row: PlatformUser) => { const handleStatus = async (row: PlatformUser, newStatus: 0 | 1) => {
const newStatus = row.status === 1 ? 0 : 1
const action = newStatus === 0 ? '禁用' : '启用' const action = newStatus === 0 ? '禁用' : '启用'
try { try {
@@ -302,6 +492,70 @@ 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;
@@ -310,6 +564,30 @@ onMounted(() => {
} }
} }
.overview-row {
margin-bottom: 20px;
}
.overview-card {
border-radius: 20px;
:deep(.el-card__body) {
padding: 18px 20px;
display: grid;
gap: 6px;
}
span {
font-size: 12px;
color: #94a3b8;
}
strong {
font-size: 24px;
color: #18191c;
}
}
.table-card { .table-card {
border-radius: 22px; border-radius: 22px;
@@ -351,4 +629,13 @@ onMounted(() => {
} }
} }
} }
@media (max-width: 768px) {
.user-list-page {
.hero-content {
flex-direction: column;
align-items: flex-start;
}
}
}
</style> </style>
@@ -1,59 +1,155 @@
<template> <template>
<div class="video-detail-page"> <div class="video-detail-page">
<el-page-header @back="goBack"> <el-page-header @back="goBack">
<template #content><span class="page-title">视频详情</span></template> <template #content>
<span class="page-title">视频详情</span>
</template>
</el-page-header> </el-page-header>
<el-skeleton :loading="loading" animated class="mt-20"> <el-skeleton
:loading="loading"
animated
class="mt-20"
>
<template #default> <template #default>
<el-row :gutter="20" v-if="video"> <el-row
v-if="video"
:gutter="20"
>
<el-col :span="16"> <el-col :span="16">
<el-card shadow="never"> <el-card shadow="never">
<template #header><span>基本信息</span></template> <template #header>
<el-descriptions :column="2" border> <span>基本信息</span>
<el-descriptions-item label="视频ID">{{ video.id }}</el-descriptions-item> </template>
<el-descriptions-item label="状态"> <el-descriptions
<el-tag :type="getStatusType(video.status)">{{ getStatusText(video.status) }}</el-tag> :column="2"
border
>
<el-descriptions-item label="视频ID">
{{ video.id }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusType(video.status)">
{{ getStatusText(video.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item
label="标题"
:span="2"
>
{{ video.title }}
</el-descriptions-item>
<el-descriptions-item
label="简介"
:span="2"
>
{{ video.description || '暂无' }}
</el-descriptions-item>
<el-descriptions-item label="作者">
{{ video.userNickname }}
</el-descriptions-item>
<el-descriptions-item label="分类">
{{ video.categoryName }}
</el-descriptions-item>
<el-descriptions-item label="时长">
{{ formatDuration(video.duration) }}
</el-descriptions-item>
<el-descriptions-item label="大小">
{{ formatFileSize(video.size) }}
</el-descriptions-item>
<el-descriptions-item label="格式">
{{ video.format }}
</el-descriptions-item>
<el-descriptions-item label="分辨率">
{{ video.width }} x {{ video.height }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDateTime(video.createTime) }}
</el-descriptions-item>
<el-descriptions-item label="发布时间">
{{ formatDateTime(video.publishTime) }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="标题" :span="2">{{ video.title }}</el-descriptions-item>
<el-descriptions-item label="简介" :span="2">{{ video.description || '暂无' }}</el-descriptions-item>
<el-descriptions-item label="作者">{{ video.userNickname }}</el-descriptions-item>
<el-descriptions-item label="分类">{{ video.categoryName }}</el-descriptions-item>
<el-descriptions-item label="时长">{{ formatDuration(video.duration) }}</el-descriptions-item>
<el-descriptions-item label="大小">{{ formatFileSize(video.size) }}</el-descriptions-item>
<el-descriptions-item label="格式">{{ video.format }}</el-descriptions-item>
<el-descriptions-item label="分辨率">{{ video.width }} x {{ video.height }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(video.createTime) }}</el-descriptions-item>
<el-descriptions-item label="发布时间">{{ formatDateTime(video.publishTime) }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
<el-card shadow="never" class="mt-20"> <el-card
<template #header><span>视频预览</span></template> shadow="never"
<video :src="video.videoUrl" controls class="video-player" /> class="mt-20"
>
<template #header>
<span>视频预览</span>
</template>
<div class="video-player-shell">
<AdminVideoPlayer
:src="playerVideoUrl"
:poster="playerPosterUrl"
/>
</div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-card shadow="never"> <el-card shadow="never">
<template #header><span>数据统计</span></template> <template #header>
<el-descriptions :column="1" border> <span>数据统计</span>
<el-descriptions-item label="播放量">{{ formatNumber(video.viewCount) }}</el-descriptions-item> </template>
<el-descriptions-item label="点赞数">{{ formatNumber(video.likeCount) }}</el-descriptions-item> <el-descriptions
<el-descriptions-item label="投币数">{{ formatNumber(video.coinCount) }}</el-descriptions-item> :column="1"
<el-descriptions-item label="收藏数">{{ formatNumber(video.favoriteCount) }}</el-descriptions-item> border
<el-descriptions-item label="评论数">{{ formatNumber(video.commentCount) }}</el-descriptions-item> >
<el-descriptions-item label="弹幕数">{{ formatNumber(video.danmakuCount) }}</el-descriptions-item> <el-descriptions-item label="播放量">
<el-descriptions-item label="分享数">{{ formatNumber(video.shareCount) }}</el-descriptions-item> {{ formatNumber(video.viewCount) }}
</el-descriptions-item>
<el-descriptions-item label="点赞数">
{{ formatNumber(video.likeCount) }}
</el-descriptions-item>
<el-descriptions-item label="投币数">
{{ formatNumber(video.coinCount) }}
</el-descriptions-item>
<el-descriptions-item label="收藏数">
{{ formatNumber(video.favoriteCount) }}
</el-descriptions-item>
<el-descriptions-item label="评论数">
{{ formatNumber(video.commentCount) }}
</el-descriptions-item>
<el-descriptions-item label="弹幕数">
{{ formatNumber(video.danmakuCount) }}
</el-descriptions-item>
<el-descriptions-item label="分享数">
{{ formatNumber(video.shareCount) }}
</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
<el-card shadow="never" class="mt-20"> <el-card
<template #header><span>操作</span></template> shadow="never"
class="mt-20"
>
<template #header>
<span>操作</span>
</template>
<div class="action-buttons"> <div class="action-buttons">
<el-button v-if="video.status === 0" type="success" @click="handleReview(1)">通过审核</el-button> <el-button
<el-button v-if="video.status === 0" type="danger" @click="handleReview(2)">拒绝</el-button> v-if="video.status === 0"
<el-button v-if="video.status === 1" type="warning" @click="handleDisable">下架视频</el-button> type="success"
@click="handleReview(1)"
>
通过审核
</el-button>
<el-button
v-if="video.status === 0"
type="danger"
@click="handleReview(3)"
>
拒绝
</el-button>
<el-button
v-if="video.status === 1"
type="warning"
@click="handleDisable"
>
下架视频
</el-button>
</div> </div>
</el-card> </el-card>
</el-col> </el-col>
@@ -61,22 +157,40 @@
</template> </template>
</el-skeleton> </el-skeleton>
<el-dialog v-model="rejectVisible" title="拒绝原因" width="400px"> <el-dialog
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="请输入拒绝原因" /> v-model="rejectVisible"
title="拒绝原因"
width="400px"
>
<el-input
v-model="rejectReason"
type="textarea"
:rows="4"
placeholder="请输入拒绝原因"
/>
<template #footer> <template #footer>
<el-button @click="rejectVisible = false">取消</el-button> <el-button @click="rejectVisible = false">
<el-button type="danger" @click="confirmReject">确认拒绝</el-button> 取消
</el-button>
<el-button
type="danger"
@click="confirmReject"
>
确认拒绝
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import AdminVideoPlayer from '@/components/video/AdminVideoPlayer.vue'
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 type { PlatformVideo } from '@/types' import type { PlatformVideo } from '@/types'
const router = useRouter() const router = useRouter()
@@ -85,6 +199,8 @@ const loading = ref(false)
const video = ref<PlatformVideo | null>(null) const video = ref<PlatformVideo | null>(null)
const rejectVisible = ref(false) const rejectVisible = ref(false)
const rejectReason = ref('') const rejectReason = ref('')
const playerVideoUrl = computed(() => resolveMediaUrl(video.value?.videoUrl))
const playerPosterUrl = computed(() => resolveMediaUrl(video.value?.coverUrl))
const goBack = () => router.back() const goBack = () => router.back()
@@ -106,6 +222,10 @@ const handleReview = async (status: number) => {
rejectVisible.value = true rejectVisible.value = true
return return
} }
if (status === 3) {
rejectVisible.value = true
return
}
try { try {
await ElMessageBox.confirm('确定要通过该视频的审核吗?', '提示', { type: 'success' }) await ElMessageBox.confirm('确定要通过该视频的审核吗?', '提示', { type: 'success' })
await videoApi.review({ videoId: video.value!.id, status: 1 }) await videoApi.review({ videoId: video.value!.id, status: 1 })
@@ -120,7 +240,7 @@ const confirmReject = async () => {
return return
} }
try { try {
await videoApi.review({ videoId: video.value!.id, status: 2, reason: rejectReason.value }) await videoApi.review({ videoId: video.value!.id, status: 3, reason: rejectReason.value })
ElMessage.success('已拒绝') ElMessage.success('已拒绝')
rejectVisible.value = false rejectVisible.value = false
loadVideo() loadVideo()
@@ -139,8 +259,8 @@ const handleDisable = async () => {
} }
const getStatusType = (status: number): 'warning' | 'success' | 'danger' | 'info' => const getStatusType = (status: number): 'warning' | 'success' | 'danger' | 'info' =>
({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' }[status] as 'warning' | 'success' | 'danger' | 'info') || 'info' ({ 0: 'warning', 1: 'success', 2: 'info', 3: 'danger' }[status] as 'warning' | 'success' | 'danger' | 'info') || 'info'
const getStatusText = (status: number) => ({ 0: '待审核', 1: '已发布', 2: '已拒绝', 3: '已下架' }[status] || '未知') const getStatusText = (status: number) => ({ 0: '待审核', 1: '已发布', 2: '已下架', 3: '已拒绝' }[status] || '未知')
onMounted(() => loadVideo()) onMounted(() => loadVideo())
</script> </script>
@@ -149,7 +269,13 @@ onMounted(() => loadVideo())
.video-detail-page { .video-detail-page {
.page-title { font-size: 18px; font-weight: 500; } .page-title { font-size: 18px; font-weight: 500; }
.mt-20 { margin-top: 20px; } .mt-20 { margin-top: 20px; }
.video-player { width: 100%; max-height: 400px; } .video-player-shell {
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 16px;
overflow: hidden;
}
.action-buttons { display: flex; flex-direction: column; gap: 10px; } .action-buttons { display: flex; flex-direction: column; gap: 10px; }
} }
</style> </style>
@@ -1,20 +1,47 @@
<template> <template>
<div class="video-list-page"> <div class="video-list-page">
<!-- 搜索表单 --> <!-- 搜索表单 -->
<el-card shadow="never" class="search-card"> <el-card
<el-form :model="queryParams" inline> shadow="never"
class="search-card"
>
<el-form
:model="queryParams"
inline
>
<el-form-item label="关键词"> <el-form-item label="关键词">
<el-input v-model="queryParams.keyword" placeholder="视频标题" clearable @keyup.enter="handleSearch" /> <el-input
v-model="queryParams.keyword"
placeholder="视频标题"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable> <el-select
<el-option label="待审核" :value="0" /> v-model="queryParams.status"
<el-option label="已发布" :value="1" /> placeholder="全部"
<el-option label="已下架" :value="3" /> clearable
>
<el-option
label="待审核"
:value="0"
/>
<el-option
label="已发布"
:value="1"
/>
<el-option
label="已下架"
:value="3"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleSearch"> <el-button
type="primary"
@click="handleSearch"
>
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
搜索 搜索
</el-button> </el-button>
@@ -27,19 +54,41 @@
</el-card> </el-card>
<!-- 数据表格 --> <!-- 数据表格 -->
<el-card shadow="never" class="table-card"> <el-card
<el-table v-loading="loading" :data="videoList" style="width: 100%"> shadow="never"
<el-table-column prop="id" label="ID" width="80" /> class="table-card"
<el-table-column label="视频" min-width="300"> >
<el-table
v-loading="loading"
:data="videoList"
style="width: 100%"
>
<el-table-column
prop="id"
label="ID"
width="80"
/>
<el-table-column
label="视频"
min-width="300"
>
<template #default="{ row }"> <template #default="{ row }">
<div class="video-info"> <div class="video-info">
<el-image :src="row.coverUrl" fit="cover" class="cover"> <el-image
:src="resolveMediaUrl(row.coverUrl)"
fit="cover"
class="cover"
>
<template #error> <template #error>
<div class="cover-error"><el-icon><VideoCamera /></el-icon></div> <div class="cover-error">
<el-icon><VideoCamera /></el-icon>
</div>
</template> </template>
</el-image> </el-image>
<div class="info"> <div class="info">
<div class="title">{{ row.title }}</div> <div class="title">
{{ row.title }}
</div>
<div class="meta"> <div class="meta">
<span>{{ formatDuration(row.duration) }}</span> <span>{{ formatDuration(row.duration) }}</span>
<span>{{ formatFileSize(row.size) }}</span> <span>{{ formatFileSize(row.size) }}</span>
@@ -48,16 +97,31 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="作者" width="120"> <el-table-column
label="作者"
width="120"
>
<template #default="{ row }"> <template #default="{ row }">
<div class="author"> <div class="author">
<el-avatar :src="row.userAvatar" :size="24">{{ row.userNickname?.charAt(0) }}</el-avatar> <el-avatar
:src="row.userAvatar"
:size="24"
>
{{ row.userNickname?.charAt(0) }}
</el-avatar>
<span>{{ row.userNickname }}</span> <span>{{ row.userNickname }}</span>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="categoryName" label="分类" width="100" /> <el-table-column
<el-table-column label="数据" width="200"> prop="categoryName"
label="分类"
width="100"
/>
<el-table-column
label="数据"
width="200"
>
<template #default="{ row }"> <template #default="{ row }">
<div class="stats"> <div class="stats">
<span>播放 {{ formatNumber(row.viewCount) }}</span> <span>播放 {{ formatNumber(row.viewCount) }}</span>
@@ -65,18 +129,46 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="100"> <el-table-column
label="状态"
width="100"
>
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag> <el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180"> <el-table-column
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template> prop="createTime"
</el-table-column> label="创建时间"
<el-table-column label="操作" width="150" fixed="right"> width="180"
>
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" text @click="handleDetail(row)">详情</el-button> {{ formatDateTime(row.createTime) }}
<el-button v-if="row.status === 1" type="danger" text @click="handleDisable(row)">下架</el-button> </template>
</el-table-column>
<el-table-column
label="操作"
width="150"
fixed="right"
>
<template #default="{ row }">
<el-button
type="primary"
text
@click="handleDetail(row)"
>
详情
</el-button>
<el-button
v-if="row.status === 1"
type="danger"
text
@click="handleDisable(row)"
>
下架
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -102,6 +194,7 @@ import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
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 type { PlatformVideo, VideoQueryParams } from '@/types' import type { PlatformVideo, VideoQueryParams } from '@/types'
const router = useRouter() const router = useRouter()
@@ -163,8 +256,8 @@ const handleDisable = async (row: PlatformVideo) => {
} }
const getStatusType = (status: number): 'warning' | 'success' | 'danger' | 'info' => const getStatusType = (status: number): 'warning' | 'success' | 'danger' | 'info' =>
({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' }[status] as 'warning' | 'success' | 'danger' | 'info') || 'info' ({ 0: 'warning', 1: 'success', 2: 'info', 3: 'danger' }[status] as 'warning' | 'success' | 'danger' | 'info') || 'info'
const getStatusText = (status: number) => ({ 0: '待审核', 1: '已发布', 2: '已拒绝', 3: '已下架' }[status] || '未知') const getStatusText = (status: number) => ({ 0: '待审核', 1: '已发布', 2: '已下架', 3: '已拒绝' }[status] || '未知')
onMounted(() => loadVideoList()) onMounted(() => loadVideoList())
</script> </script>
@@ -1,8 +1,38 @@
<template> <template>
<div class="video-review-page"> <div class="video-review-page">
<!-- 搜索表单 --> <el-card
<el-card shadow="never" class="search-card"> shadow="never"
<el-form :model="queryParams" inline> 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"
>
<el-form
:model="queryParams"
inline
>
<el-form-item label="关键词"> <el-form-item label="关键词">
<el-input <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
@@ -12,14 +42,31 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="审核状态"> <el-form-item label="审核状态">
<el-select v-model="queryParams.auditStatus" placeholder="全部" clearable> <el-select
<el-option label="待审核" :value="0" /> v-model="queryParams.auditStatus"
<el-option label="已通过" :value="1" /> placeholder="全部"
<el-option label="已拒绝" :value="2" /> clearable
>
<el-option
label="待审核"
:value="0"
/>
<el-option
label="已通过"
:value="1"
/>
<el-option
label="已拒绝"
:value="3"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="分类"> <el-form-item label="分类">
<el-select v-model="queryParams.categoryId" placeholder="全部" clearable> <el-select
v-model="queryParams.categoryId"
placeholder="全部"
clearable
>
<el-option <el-option
v-for="cat in categories" v-for="cat in categories"
:key="cat.id" :key="cat.id"
@@ -29,7 +76,10 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleSearch"> <el-button
type="primary"
@click="handleSearch"
>
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
搜索 搜索
</el-button> </el-button>
@@ -41,23 +91,77 @@
</el-form> </el-form>
</el-card> </el-card>
<!-- 数据表格 --> <el-row
<el-card shadow="never" class="table-card"> :gutter="20"
class="overview-row"
>
<el-col
:xs="24"
:md="8"
>
<el-card
shadow="hover"
class="overview-card"
>
<span>筛选状态</span>
<strong>{{ getAuditStatusText(queryParams.auditStatus ?? 0) }}</strong>
</el-card>
</el-col>
<el-col
:xs="24"
:md="8"
>
<el-card
shadow="hover"
class="overview-card"
>
<span>当前页码</span>
<strong>{{ queryParams.page }}</strong>
</el-card>
</el-col>
<el-col
:xs="24"
:md="8"
>
<el-card
shadow="hover"
class="overview-card"
>
<span>每页数量</span>
<strong>{{ queryParams.size }}</strong>
</el-card>
</el-col>
</el-row>
<el-card
shadow="never"
class="table-card"
>
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>待审核视频 ({{ total }})</span> <span>审核队列</span>
<el-tag type="danger" v-if="pendingCount > 0"> <el-tag
v-if="pendingCount > 0"
type="danger"
>
有 {{ pendingCount }} 个视频待审核 有 {{ pendingCount }} 个视频待审核
</el-tag> </el-tag>
</div> </div>
</template> </template>
<el-table v-loading="loading" :data="videoList" style="width: 100%"> <el-table
<el-table-column label="视频" min-width="300"> v-loading="loading"
:data="videoList"
style="width: 100%"
>
<el-table-column
label="视频"
min-width="320"
>
<template #default="{ row }"> <template #default="{ row }">
<div class="video-info"> <div class="video-info">
<el-image <el-image
:src="row.coverUrl" :src="resolveMediaUrl(row.coverUrl)"
fit="cover" fit="cover"
class="cover" class="cover"
> >
@@ -68,7 +172,9 @@
</template> </template>
</el-image> </el-image>
<div class="info"> <div class="info">
<div class="title">{{ row.title }}</div> <div class="title">
{{ row.title }}
</div>
<div class="meta"> <div class="meta">
<span>{{ formatDuration(row.duration) }}</span> <span>{{ formatDuration(row.duration) }}</span>
<span>{{ formatFileSize(row.size) }}</span> <span>{{ formatFileSize(row.size) }}</span>
@@ -77,32 +183,57 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="作者" width="120"> <el-table-column
label="作者"
width="140"
>
<template #default="{ row }"> <template #default="{ row }">
<div class="author"> <div class="author">
<el-avatar :src="row.userAvatar" :size="24"> <el-avatar
:src="row.userAvatar"
:size="24"
>
{{ row.userNickname?.charAt(0) }} {{ row.userNickname?.charAt(0) }}
</el-avatar> </el-avatar>
<span>{{ row.userNickname }}</span> <span>{{ row.userNickname }}</span>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="categoryName" label="分类" width="100" /> <el-table-column
<el-table-column label="审核状态" width="100"> prop="categoryName"
label="分类"
width="110"
/>
<el-table-column
label="审核状态"
width="100"
>
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="getAuditStatusType(row.auditStatus)"> <el-tag :type="getAuditStatusType(row.auditStatus)">
{{ getAuditStatusText(row.auditStatus) }} {{ getAuditStatusText(row.auditStatus) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="createTime" label="提交时间" width="180"> <el-table-column
prop="createTime"
label="提交时间"
width="180"
>
<template #default="{ row }"> <template #default="{ row }">
{{ formatDateTime(row.createTime) }} {{ formatDateTime(row.createTime) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200" fixed="right"> <el-table-column
label="操作"
width="220"
fixed="right"
>
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" text @click="handlePreview(row)"> <el-button
type="primary"
text
@click="handlePreview(row)"
>
预览 预览
</el-button> </el-button>
<el-button <el-button
@@ -117,18 +248,21 @@
v-if="row.auditStatus === 0" v-if="row.auditStatus === 0"
type="danger" type="danger"
text text
@click="handleReview(row, 2)" @click="handleReview(row, 3)"
> >
拒绝 拒绝
</el-button> </el-button>
<el-button type="info" text @click="handleDetail(row)"> <el-button
type="info"
text
@click="handleDetail(row)"
>
详情 详情
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<!-- 分页 -->
<div class="pagination"> <div class="pagination">
<el-pagination <el-pagination
v-model:current-page="queryParams.page" v-model:current-page="queryParams.page"
@@ -142,25 +276,44 @@
</div> </div>
</el-card> </el-card>
<!-- 视频预览对话框 --> <el-dialog
<el-dialog v-model="previewVisible" title="视频预览" width="800px" destroy-on-close> v-model="previewVisible"
title="视频预览"
width="820px"
destroy-on-close
>
<div class="preview-container"> <div class="preview-container">
<video <AdminVideoPlayer
v-if="previewVideo" v-if="previewVideo"
:src="previewVideo.videoUrl" :src="previewVideoUrl"
controls :poster="previewPosterUrl"
class="video-player" class="video-player"
/> />
</div> </div>
<template #footer> <template #footer>
<el-button @click="previewVisible = false">关闭</el-button> <el-button @click="previewVisible = false">
<el-button type="success" @click="handleReview(previewVideo!, 1)">通过</el-button> 关闭
<el-button type="danger" @click="handleReview(previewVideo!, 2)">拒绝</el-button> </el-button>
<el-button
type="success"
@click="handleReview(previewVideo!, 1)"
>
通过
</el-button>
<el-button
type="danger"
@click="handleReview(previewVideo!, 3)"
>
拒绝
</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 拒绝原因对话框 --> <el-dialog
<el-dialog v-model="rejectVisible" title="拒绝原因" width="400px"> v-model="rejectVisible"
title="拒绝原因"
width="420px"
>
<el-form> <el-form>
<el-form-item label="拒绝原因"> <el-form-item label="拒绝原因">
<el-input <el-input
@@ -172,50 +325,56 @@
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="rejectVisible = false">取消</el-button> <el-button @click="rejectVisible = false">
<el-button type="danger" @click="confirmReject">确认拒绝</el-button> 取消
</el-button>
<el-button
type="danger"
@click="confirmReject"
>
确认拒绝
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } 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 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'
import { formatDateTime, formatDuration, formatFileSize } from '@/utils/format.ts' import { formatDateTime, formatDuration, formatFileSize } from '@/utils/format.ts'
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 queryParams = reactive<VideoQueryParams>({ const queryParams = reactive<VideoQueryParams>({
page: 1, page: 1,
size: 10, size: 10,
keyword: '', keyword: '',
categoryId: undefined, categoryId: undefined,
auditStatus: 0 // 默认待审核 auditStatus: 0
}) })
// 数据
const loading = ref(false) const loading = ref(false)
const videoList = ref<PlatformVideo[]>([]) const videoList = ref<PlatformVideo[]>([])
const total = ref(0) const total = ref(0)
const pendingCount = ref(0) const pendingCount = ref(0)
const categories = ref<Category[]>([]) const categories = ref<Category[]>([])
// 预览
const previewVisible = ref(false) const previewVisible = ref(false)
const previewVideo = ref<PlatformVideo | null>(null) const previewVideo = ref<PlatformVideo | null>(null)
const previewVideoUrl = computed(() => resolveMediaUrl(previewVideo.value?.videoUrl))
const previewPosterUrl = computed(() => resolveMediaUrl(previewVideo.value?.coverUrl))
// 拒绝
const rejectVisible = ref(false) const rejectVisible = ref(false)
const rejectReason = ref('') const rejectReason = ref('')
const rejectVideo = ref<PlatformVideo | null>(null) const rejectVideo = ref<PlatformVideo | null>(null)
// 加载分类
const loadCategories = async () => { const loadCategories = async () => {
try { try {
categories.value = await categoryApi.getList() categories.value = await categoryApi.getList()
@@ -224,7 +383,6 @@ const loadCategories = async () => {
} }
} }
// 加载视频列表
const loadVideoList = async () => { const loadVideoList = async () => {
loading.value = true loading.value = true
try { try {
@@ -238,7 +396,6 @@ const loadVideoList = async () => {
} }
} }
// 加载待审核数量
const loadPendingCount = async () => { const loadPendingCount = async () => {
try { try {
const stats = await videoApi.getStats() const stats = await videoApi.getStats()
@@ -248,13 +405,11 @@ const loadPendingCount = async () => {
} }
} }
// 搜索
const handleSearch = () => { const handleSearch = () => {
queryParams.page = 1 queryParams.page = 1
loadVideoList() loadVideoList()
} }
// 重置
const handleReset = () => { const handleReset = () => {
queryParams.keyword = '' queryParams.keyword = ''
queryParams.categoryId = undefined queryParams.categoryId = undefined
@@ -262,7 +417,6 @@ const handleReset = () => {
handleSearch() handleSearch()
} }
// 分页
const handleSizeChange = (size: number) => { const handleSizeChange = (size: number) => {
queryParams.size = size queryParams.size = size
loadVideoList() loadVideoList()
@@ -273,21 +427,17 @@ const handlePageChange = (page: number) => {
loadVideoList() loadVideoList()
} }
// 预览
const handlePreview = (row: PlatformVideo) => { const handlePreview = (row: PlatformVideo) => {
previewVideo.value = row previewVideo.value = row
previewVisible.value = true previewVisible.value = true
} }
// 详情
const handleDetail = (row: PlatformVideo) => { const handleDetail = (row: PlatformVideo) => {
router.push(`/video/detail/${row.id}`) router.push(`/video/detail/${row.id}`)
} }
// 审核
const handleReview = async (row: PlatformVideo, status: number) => { const handleReview = async (row: PlatformVideo, status: number) => {
if (status === 2) { if (status === 3) {
// 拒绝需要填写原因
rejectVideo.value = row rejectVideo.value = row
rejectReason.value = '' rejectReason.value = ''
rejectVisible.value = true rejectVisible.value = true
@@ -309,15 +459,13 @@ const handleReview = async (row: PlatformVideo, status: number) => {
ElMessage.success('审核通过') ElMessage.success('审核通过')
loadVideoList() loadVideoList()
loadPendingCount() loadPendingCount()
} catch (error) { } catch {
// 取消 // ignore
} }
} }
// 确认拒绝
const confirmReject = async () => { const confirmReject = async () => {
if (!rejectVideo.value) return if (!rejectVideo.value) return
if (!rejectReason.value.trim()) { if (!rejectReason.value.trim()) {
ElMessage.warning('请输入拒绝原因') ElMessage.warning('请输入拒绝原因')
return return
@@ -326,35 +474,34 @@ const confirmReject = async () => {
try { try {
await videoApi.review({ await videoApi.review({
videoId: rejectVideo.value.id, videoId: rejectVideo.value.id,
status: 2, status: 3,
reason: rejectReason.value reason: rejectReason.value
}) })
ElMessage.success('已拒绝') ElMessage.success('已拒绝')
rejectVisible.value = false rejectVisible.value = false
loadVideoList() loadVideoList()
loadPendingCount() loadPendingCount()
} catch (error) { } catch {
ElMessage.error('操作失败') ElMessage.error('操作失败')
} }
} }
// 审核状态文本
const getAuditStatusText = (status: number) => { const getAuditStatusText = (status: number) => {
const map: Record<number, string> = { const map: Record<number, string> = {
0: '待审核', 0: '待审核',
1: '已通过', 1: '已通过',
2: '已拒绝' 2: '已下架',
3: '已拒绝'
} }
return map[status] || '未知' return map[status] || '未知'
} }
// 审核状态类型
const getAuditStatusType = (status: number): 'warning' | 'success' | 'danger' | 'info' => { const getAuditStatusType = (status: number): 'warning' | 'success' | 'danger' | 'info' => {
const map: Record<number, 'warning' | 'success' | 'danger'> = { const map: Record<number, 'warning' | 'success' | 'danger' | 'info'> = {
0: 'warning', 0: 'warning',
1: 'success', 1: 'success',
2: 'danger' 2: 'info',
3: 'danger'
} }
return map[status] || 'info' return map[status] || 'info'
} }
@@ -368,6 +515,70 @@ 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;
@@ -376,6 +587,30 @@ onMounted(() => {
} }
} }
.overview-row {
margin-bottom: 20px;
}
.overview-card {
border-radius: 20px;
:deep(.el-card__body) {
padding: 18px 20px;
display: grid;
gap: 6px;
}
span {
font-size: 12px;
color: #94a3b8;
}
strong {
font-size: 24px;
color: #18191c;
}
}
.table-card { .table-card {
border-radius: 22px; border-radius: 22px;
@@ -445,10 +680,20 @@ onMounted(() => {
background: #000; background: #000;
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
aspect-ratio: 16 / 9;
.video-player { .video-player {
width: 100%; width: 100%;
max-height: 450px; height: 100%;
}
}
}
@media (max-width: 768px) {
.video-review-page {
.hero-content {
flex-direction: column;
align-items: flex-start;
} }
} }
} }
+40 -35
View File
@@ -1,45 +1,50 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { resolve } from 'path' import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite' import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite' import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [ const env = loadEnv(mode, __dirname, '')
vue(), const proxyTarget = env.VITE_PROXY_TARGET || 'http://192.168.1.101:9000'
AutoImport({
imports: ['vue', 'vue-router', 'pinia'], return {
resolvers: [ElementPlusResolver()], plugins: [
dts: 'src/auto-imports.d.ts' vue(),
}), AutoImport({
Components({ imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()], resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts' dts: 'src/auto-imports.d.ts'
}) }),
], Components({
resolve: { resolvers: [ElementPlusResolver()],
alias: { dts: 'src/components.d.ts'
'@': resolve(__dirname, 'src') })
} ],
}, resolve: {
server: { alias: {
port: 3100, '@': resolve(__dirname, 'src')
proxy: {
'/api': {
target: 'http://localhost:9000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
} }
} },
}, server: {
build: { port: 3100,
rollupOptions: { proxy: {
output: { '/api': {
manualChunks: { target: proxyTarget,
'element-plus': ['element-plus'], changeOrigin: true,
'echarts': ['echarts'], rewrite: (path) => path.replace(/^\/api/, '')
'vendor': ['vue', 'vue-router', 'pinia'] }
}
},
build: {
rollupOptions: {
output: {
manualChunks: {
'element-plus': ['element-plus'],
echarts: ['echarts'],
vendor: ['vue', 'vue-router', 'pinia']
}
} }
} }
} }