diff --git a/frontend/video-frontend-admin/.env.mock b/frontend/video-frontend-admin/.env.mock new file mode 100644 index 0000000..747db74 --- /dev/null +++ b/frontend/video-frontend-admin/.env.mock @@ -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 diff --git a/frontend/video-frontend-admin/.eslintignore b/frontend/video-frontend-admin/.eslintignore new file mode 100644 index 0000000..bdf2bb9 --- /dev/null +++ b/frontend/video-frontend-admin/.eslintignore @@ -0,0 +1,4 @@ +dist +node_modules +src/auto-imports.d.ts +src/components.d.ts diff --git a/frontend/video-frontend-admin/.eslintrc.cjs b/frontend/video-frontend-admin/.eslintrc.cjs new file mode 100644 index 0000000..18fd4e3 --- /dev/null +++ b/frontend/video-frontend-admin/.eslintrc.cjs @@ -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: '^_' }] + } +} diff --git a/frontend/video-frontend-admin/package-lock.json b/frontend/video-frontend-admin/package-lock.json index 9a8da42..8e399ce 100644 --- a/frontend/video-frontend-admin/package-lock.json +++ b/frontend/video-frontend-admin/package-lock.json @@ -11,6 +11,7 @@ "@element-plus/icons-vue": "^2.3.1", "axios": "^1.6.8", "dayjs": "^1.11.10", + "dplayer": "^1.27.1", "echarts": "^5.5.0", "element-plus": "^2.6.3", "nprogress": "^0.2.0", @@ -2056,6 +2057,12 @@ "dev": true, "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": { "version": "2.3.0", "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2319,6 +2326,28 @@ "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": { "version": "1.0.1", "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" } }, + "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": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", diff --git a/frontend/video-frontend-admin/package.json b/frontend/video-frontend-admin/package.json index b02923e..39f46fe 100644 --- a/frontend/video-frontend-admin/package.json +++ b/frontend/video-frontend-admin/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "vue-tsc && vite build", "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" }, "dependencies": { @@ -15,6 +15,7 @@ "vue-router": "^4.3.0", "pinia": "^2.1.7", "axios": "^1.6.8", + "dplayer": "^1.27.1", "element-plus": "^2.6.3", "@element-plus/icons-vue": "^2.3.1", "echarts": "^5.5.0", diff --git a/frontend/video-frontend-admin/src/api/admin.ts b/frontend/video-frontend-admin/src/api/admin.ts index e902be7..23de437 100644 --- a/frontend/video-frontend-admin/src/api/admin.ts +++ b/frontend/video-frontend-admin/src/api/admin.ts @@ -3,6 +3,42 @@ import type { AdminUser, PageResult, PageParams } from '@/types' 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 { + 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 */ @@ -10,15 +46,19 @@ export const adminApi = { /** * 获取管理员列表 */ - getList(params: PageParams & { keyword?: string }): Promise> { - return request.get('/user/list', params) + async getList(params: PageParams & { keyword?: string }): Promise> { + const page = params.page || 1 + const size = params.size || 10 + const result = await request.get('/user/list', params) + return normalizeAdminPage(result, page, size) }, /** * 获取管理员详情 */ - getDetail(id: number): Promise { - return request.get(`/user/${id}`) + async getDetail(id: number): Promise { + const result = await request.get(`/user/${id}`) + return normalizeAdmin(result) }, /** @@ -32,7 +72,14 @@ export const adminApi = { phone?: string role?: string }): Promise { - 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: { id: number + username: string nickname?: string email?: string phone?: string role?: string }): Promise { - return request.put('/user', data) + const payload: Record = { + 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 { - return request.put(`/user/${id}/reset-password`) + return request.put(`/user/${id}/reset-password`, undefined, { + params: { + newPassword: 'admin123' + } + }) } } diff --git a/frontend/video-frontend-admin/src/api/auth.ts b/frontend/video-frontend-admin/src/api/auth.ts index d42990e..c3c6cd2 100644 --- a/frontend/video-frontend-admin/src/api/auth.ts +++ b/frontend/video-frontend-admin/src/api/auth.ts @@ -8,6 +8,26 @@ import type { 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 */ @@ -22,8 +42,16 @@ export const authApi = { /** * 登录 */ - login(params: LoginParams): Promise { - return request.post('/auth/login', params) + async login(params: LoginParams): Promise { + const result = await request.post('/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 { - return request.get('/auth/info') + async getInfo(): Promise { + const result = await request.get('/auth/info') + return normalizeAdminInfo(result) } } diff --git a/frontend/video-frontend-admin/src/api/category.ts b/frontend/video-frontend-admin/src/api/category.ts index 92de149..c439d6b 100644 --- a/frontend/video-frontend-admin/src/api/category.ts +++ b/frontend/video-frontend-admin/src/api/category.ts @@ -41,27 +41,37 @@ export const categoryApi = { * 获取标签列表 */ getTags(params?: TagQueryParams): Promise> { - 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 { - return request.post('/content/tag', params) + return request.post('/platform/content/tag', params) }, /** * 更新标签 */ updateTag(params: { id: number; name: string }): Promise { - return request.put('/content/tag', params) + return request.put('/platform/content/tag', params) }, /** * 删除标签 */ deleteTag(tagId: number): Promise { - return request.delete(`/content/tag/${tagId}`) + return request.delete(`/platform/content/tag/${tagId}`) } } diff --git a/frontend/video-frontend-admin/src/api/comment.ts b/frontend/video-frontend-admin/src/api/comment.ts index ea17dee..d7989a2 100644 --- a/frontend/video-frontend-admin/src/api/comment.ts +++ b/frontend/video-frontend-admin/src/api/comment.ts @@ -1,6 +1,20 @@ import request from '@/utils/request.ts' import type { PageResult, PlatformComment, CommentQueryParams } from '@/types' +function normalizeCommentPage(raw: any, page: number, size: number): PageResult { + 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 */ @@ -8,22 +22,25 @@ export const commentApi = { /** * 获取评论列表 */ - getList(params: CommentQueryParams): Promise> { - return request.get('/admin/list', params) + async getList(params: CommentQueryParams): Promise> { + const page = params.page || 1 + const size = params.size || 10 + const result = await request.get('/platform/comment/list', params) + return normalizeCommentPage(result, page, size) }, /** * 获取评论详情 */ getDetail(commentId: number): Promise { - return request.get(`/admin/${commentId}`) + return request.get(`/platform/comment/${commentId}`) }, /** * 删除评论 */ delete(commentId: number): Promise { - return request.delete(`/admin/${commentId}`) + return request.delete(`/platform/comment/${commentId}`) }, /** @@ -34,6 +51,6 @@ export const commentApi = { commentsToday: number commentGrowthRate: number }> { - return request.get('/admin/stats') + return request.get('/platform/comment/stats') } } diff --git a/frontend/video-frontend-admin/src/api/statistics.ts b/frontend/video-frontend-admin/src/api/statistics.ts index 8bc0f48..5180667 100644 --- a/frontend/video-frontend-admin/src/api/statistics.ts +++ b/frontend/video-frontend-admin/src/api/statistics.ts @@ -1,6 +1,88 @@ import request from '@/utils/request.ts' import type { DashboardData, StatisticsData, TrendData } from '@/types' +function getDateKey(input?: string) { + if (!input) return '' + return String(input).slice(0, 10) +} + +function buildDailySeries(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 */ @@ -8,35 +90,51 @@ export const statisticsApi = { /** * 获取仪表盘数据 */ - getDashboard(): Promise { - return request.get('/statistics/dashboard') + async getDashboard(): Promise { + const [dashboard, users, videos, comments] = await Promise.all([ + request.get('/dashboard/stats'), + request.get('/platform/user/list', { page: 1, size: 200 }), + request.get('/platform/content/video/list', { page: 1, size: 200 }), + request.get('/platform/comment/list', { page: 1, size: 200 }) + ]) + + return normalizeDashboard( + dashboard, + users?.records || [], + videos?.records || [], + comments?.records || [] + ) }, /** * 获取统计数据 */ - getStatistics(): Promise { - return request.get('/statistics/overview') + async getStatistics(): Promise { + const dashboard = await this.getDashboard() + return dashboard.statistics }, /** * 获取用户趋势 */ - getUserTrend(days: number = 7): Promise { - return request.get('/statistics/user/trend', { days }) + async getUserTrend(_days: number = 7): Promise { + const dashboard = await this.getDashboard() + return dashboard.statistics.userTrend }, /** * 获取视频趋势 */ - getVideoTrend(days: number = 7): Promise { - return request.get('/statistics/video/trend', { days }) + async getVideoTrend(_days: number = 7): Promise { + const dashboard = await this.getDashboard() + return dashboard.statistics.videoTrend }, /** * 获取播放趋势 */ - getViewTrend(days: number = 7): Promise { - return request.get('/statistics/view/trend', { days }) + async getViewTrend(_days: number = 7): Promise { + const dashboard = await this.getDashboard() + return dashboard.statistics.viewTrend } } diff --git a/frontend/video-frontend-admin/src/api/user.ts b/frontend/video-frontend-admin/src/api/user.ts index 8a0f0d3..1855e9d 100644 --- a/frontend/video-frontend-admin/src/api/user.ts +++ b/frontend/video-frontend-admin/src/api/user.ts @@ -1,6 +1,45 @@ import request from '@/utils/request.ts' 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 { + 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 */ @@ -8,15 +47,19 @@ export const userApi = { /** * 获取用户列表 */ - getList(params: UserQueryParams): Promise> { - return request.get('/platform/user/list', params) + async getList(params: UserQueryParams): Promise> { + const page = params.page || 1 + const size = params.size || 10 + const result = await request.get('/platform/user/list', params) + return normalizeUserPage(result, page, size) }, /** * 获取用户详情 */ - getDetail(userId: number): Promise { - return request.get(`/platform/user/${userId}`) + async getDetail(userId: number): Promise { + const result = await request.get(`/platform/user/${userId}`) + return normalizeUser(result) }, /** @@ -30,7 +73,11 @@ export const userApi = { * 重置用户密码 */ resetPassword(userId: number): Promise { - return request.put(`/platform/user/${userId}/reset-password`) + return request.put(`/platform/user/${userId}/reset-password`, undefined, { + params: { + newPassword: 'NewPass@123' + } + }) }, /** diff --git a/frontend/video-frontend-admin/src/api/video.ts b/frontend/video-frontend-admin/src/api/video.ts index 24d5c58..8c0c262 100644 --- a/frontend/video-frontend-admin/src/api/video.ts +++ b/frontend/video-frontend-admin/src/api/video.ts @@ -1,6 +1,78 @@ import request from '@/utils/request.ts' 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 { + 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 */ @@ -8,24 +80,33 @@ export const videoApi = { /** * 获取视频列表 */ - getList(params: VideoQueryParams): Promise> { - return request.get('/platform/content/video/list', params) + async getList(params: VideoQueryParams): Promise> { + const page = params.page || 1 + const size = params.size || 10 + const result = await request.get('/platform/content/video/list', { + ...params, + status: params.status ?? params.auditStatus + }) + return normalizeVideoPage(result, page, size) }, /** * 获取视频详情 */ - getDetail(videoId: number): Promise { - return request.get(`/platform/content/video/${videoId}`) + async getDetail(videoId: number): Promise { + const result = await request.get(`/platform/content/video/${videoId}`) + return normalizeVideo(result) }, /** * 审核视频 */ review(params: VideoReviewParams): Promise { - return request.post(`/platform/content/video/review/${params.videoId}`, { - status: params.status, - reason: params.reason + return request.post(`/platform/content/video/review/${params.videoId}`, undefined, { + params: { + status: params.status, + reason: params.reason + } }) }, @@ -39,12 +120,13 @@ export const videoApi = { /** * 获取视频统计数据 */ - getStats(): Promise<{ + async getStats(): Promise<{ totalVideos: number newVideosToday: number pendingReview: number videoGrowthRate: number }> { - return request.get('/platform/content/video/stats') + const result = await request.get('/dashboard/stats') + return normalizeVideoStats(result) } } diff --git a/frontend/video-frontend-admin/src/components.d.ts b/frontend/video-frontend-admin/src/components.d.ts index d6978aa..8789539 100644 --- a/frontend/video-frontend-admin/src/components.d.ts +++ b/frontend/video-frontend-admin/src/components.d.ts @@ -7,15 +7,14 @@ export {} declare module 'vue' { export interface GlobalComponents { + AdminVideoPlayer: typeof import('./components/video/AdminVideoPlayer.vue')['default'] Breadcrumb: typeof import('./components/Breadcrumb.vue')['default'] - ElAside: typeof import('element-plus/es')['ElAside'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'] ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] ElCol: typeof import('element-plus/es')['ElCol'] - ElContainer: typeof import('element-plus/es')['ElContainer'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] @@ -25,12 +24,10 @@ declare module 'vue' { ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] - ElHeader: typeof import('element-plus/es')['ElHeader'] ElIcon: typeof import('element-plus/es')['ElIcon'] ElImage: typeof import('element-plus/es')['ElImage'] ElInput: typeof import('element-plus/es')['ElInput'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] - ElMain: typeof import('element-plus/es')['ElMain'] ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] diff --git a/frontend/video-frontend-admin/src/components/Breadcrumb.vue b/frontend/video-frontend-admin/src/components/Breadcrumb.vue index 6f6cf0a..da0e27e 100644 --- a/frontend/video-frontend-admin/src/components/Breadcrumb.vue +++ b/frontend/video-frontend-admin/src/components/Breadcrumb.vue @@ -1,10 +1,22 @@ diff --git a/frontend/video-frontend-admin/src/components/video/AdminVideoPlayer.vue b/frontend/video-frontend-admin/src/components/video/AdminVideoPlayer.vue new file mode 100644 index 0000000..4d52bc8 --- /dev/null +++ b/frontend/video-frontend-admin/src/components/video/AdminVideoPlayer.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/frontend/video-frontend-admin/src/types/dplayer.d.ts b/frontend/video-frontend-admin/src/types/dplayer.d.ts new file mode 100644 index 0000000..0d6b440 --- /dev/null +++ b/frontend/video-frontend-admin/src/types/dplayer.d.ts @@ -0,0 +1 @@ +declare module 'dplayer' diff --git a/frontend/video-frontend-admin/src/types/index.ts b/frontend/video-frontend-admin/src/types/index.ts index 3c0159f..d66efd9 100644 --- a/frontend/video-frontend-admin/src/types/index.ts +++ b/frontend/video-frontend-admin/src/types/index.ts @@ -31,6 +31,7 @@ export interface AdminUser { email: string phone: string role: string + roleName?: string status: number lastLoginTime: string lastLoginIp: string @@ -72,13 +73,15 @@ export interface PlatformUser { gender: number birthday: string signature: string - status: number + region?: string + status?: number | null followerCount: number followingCount: number videoCount: number likeCount: number level: number vipLevel: number + vipExpireTime?: string createTime: string updateTime: string } @@ -109,7 +112,7 @@ export interface PlatformVideo { size: number categoryId: number categoryName: string - status: number // 0:待审核,1:已发布,2:审核拒绝,3:已下架 + status: number // 0:待审核,1:已发布,2:已下架,3:审核拒绝 auditStatus: number auditReason: string likeCount: number @@ -140,7 +143,7 @@ export interface VideoQueryParams extends PageParams { // 视频审核参数 export interface VideoReviewParams { videoId: number - status: number // 1:通过, 2:拒绝 + status: number // 1:通过, 3:拒绝 reason?: string } @@ -148,9 +151,12 @@ export interface VideoReviewParams { export interface Category { id: number name: string + code?: string parentId: number + level?: number sort: number icon: string + status?: number children?: Category[] } @@ -158,9 +164,12 @@ export interface Category { export interface CategoryParams { id?: number name: string + code?: string parentId?: number + level?: number sort?: number icon?: string + status?: number } // 标签信息 diff --git a/frontend/video-frontend-admin/src/utils/media.ts b/frontend/video-frontend-admin/src/utils/media.ts new file mode 100644 index 0000000..83c24ee --- /dev/null +++ b/frontend/video-frontend-admin/src/utils/media.ts @@ -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(/^\/+/, '')}` +} diff --git a/frontend/video-frontend-admin/src/utils/request.ts b/frontend/video-frontend-admin/src/utils/request.ts index 0c4f944..7c6975a 100644 --- a/frontend/video-frontend-admin/src/utils/request.ts +++ b/frontend/video-frontend-admin/src/utils/request.ts @@ -8,6 +8,7 @@ import { isMockEnabled, mockRequest } from '@/mock' // 配置 NProgress NProgress.configure({ showSpinner: false }) +let handlingUnauthorized = false // 创建 axios 实例 const service: AxiosInstance = axios.create({ @@ -56,20 +57,26 @@ service.interceptors.response.use( // Token 过期 if (res.code === 401) { - ElMessageBox.confirm('登录状态已过期,请重新登录', '提示', { - confirmButtonText: '重新登录', - cancelButtonText: '取消', - type: 'warning' - }).then(() => { + if (!handlingUnauthorized) { + handlingUnauthorized = true const userStore = useUserStore() - userStore.logout() - router.push('/login') - }) + userStore.clearAuth() + if (router.currentRoute.value.path !== '/login') { + ElMessageBox.confirm('登录状态已过期,请重新登录', '提示', { + confirmButtonText: '重新登录', + cancelButtonText: '取消', + type: 'warning' + }).finally(() => { + router.push('/login') + }) + } + } } return Promise.reject(new Error(res.message || '请求失败')) } + handlingUnauthorized = false return res.data }, (error) => { @@ -81,12 +88,18 @@ service.interceptors.response.use( case 400: message = '请求参数错误' break - case 401: + case 401: { message = '未授权,请登录' const userStore = useUserStore() - userStore.logout() - router.push('/login') + if (!handlingUnauthorized) { + handlingUnauthorized = true + userStore.clearAuth() + if (router.currentRoute.value.path !== '/login') { + router.push('/login') + } + } break + } case 403: message = '拒绝访问' break diff --git a/frontend/video-frontend-admin/src/views/content/category.vue b/frontend/video-frontend-admin/src/views/content/category.vue index bcc89be..c4a5aea 100644 --- a/frontend/video-frontend-admin/src/views/content/category.vue +++ b/frontend/video-frontend-admin/src/views/content/category.vue @@ -4,29 +4,78 @@ - - - - + + + + - - - - + + + + - + - + @@ -68,11 +131,25 @@ const submitting = ref(false) const form = reactive({ id: undefined as number | undefined, name: '', + code: '', parentId: 0, + level: 1, 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 buildTree = (list: Category[], parentId: number = 0): Category[] => { return list.filter(item => item.parentId === parentId).map(item => ({ @@ -97,9 +174,12 @@ const loadCategories = async () => { const resetForm = () => { form.id = undefined form.name = '' + form.code = '' form.parentId = 0 + form.level = 1 form.sort = 0 form.icon = '' + form.status = 1 } const handleAdd = () => { @@ -118,9 +198,12 @@ const handleAddChild = (row: Category) => { const handleEdit = (row: Category) => { form.id = row.id form.name = row.name + form.code = row.code || '' form.parentId = row.parentId + form.level = row.level || 1 form.sort = row.sort form.icon = row.icon || '' + form.status = row.status ?? 1 dialogTitle.value = '编辑分类' dialogVisible.value = true } @@ -142,10 +225,27 @@ const handleSubmit = async () => { submitting.value = true try { 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('更新成功') } 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('添加成功') } dialogVisible.value = false diff --git a/frontend/video-frontend-admin/src/views/content/comment.vue b/frontend/video-frontend-admin/src/views/content/comment.vue index 0d4d8de..6aaf305 100644 --- a/frontend/video-frontend-admin/src/views/content/comment.vue +++ b/frontend/video-frontend-admin/src/views/content/comment.vue @@ -1,58 +1,201 @@ - +