feat(frontend): Align admin and user API integration
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type { AdminUser, PageResult, PageParams } from '@/types'
|
||||
|
||||
export type { AdminUser }
|
||||
|
||||
/**
|
||||
* 管理员管理 API
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
AdminUser
|
||||
} from '@/types'
|
||||
|
||||
export type { LoginParams, LoginResult, CaptchaResult, AdminUser }
|
||||
|
||||
/**
|
||||
* 管理员认证 API
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type { Category, CategoryParams, Tag, TagQueryParams, PageResult } from '@/types'
|
||||
|
||||
export type { Category, Tag }
|
||||
|
||||
/**
|
||||
* 分类管理 API
|
||||
*/
|
||||
|
||||
@@ -8,13 +8,50 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
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']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
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']
|
||||
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
TagsView: typeof import('./components/TagsView.vue')['default']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTagsViewStore, type TagView } from '@/stores/tagsView.ts'
|
||||
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import type {
|
||||
AdminUser,
|
||||
Category,
|
||||
DashboardData,
|
||||
PageResult,
|
||||
PlatformComment,
|
||||
PlatformUser,
|
||||
PlatformVideo,
|
||||
StatisticsData,
|
||||
Tag,
|
||||
TrendData
|
||||
} from '@/types'
|
||||
|
||||
const now = '2026-03-22 10:00:00'
|
||||
|
||||
export const mockAdminUsers: AdminUser[] = [
|
||||
{
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
nickname: '平台总管理员',
|
||||
avatar: 'https://picsum.photos/seed/admin1/120/120',
|
||||
email: 'admin@haovp.com',
|
||||
phone: '13800000001',
|
||||
role: 'admin',
|
||||
status: 1,
|
||||
lastLoginTime: now,
|
||||
lastLoginIp: '127.0.0.1',
|
||||
createTime: '2025-12-01 09:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'reviewer',
|
||||
nickname: '内容审核员',
|
||||
avatar: 'https://picsum.photos/seed/admin2/120/120',
|
||||
email: 'review@haovp.com',
|
||||
phone: '13800000002',
|
||||
role: 'manager',
|
||||
status: 1,
|
||||
lastLoginTime: '2026-03-22 08:40:00',
|
||||
lastLoginIp: '10.10.0.7',
|
||||
createTime: '2025-12-08 14:20:00'
|
||||
}
|
||||
]
|
||||
|
||||
export const mockCategories: Category[] = [
|
||||
{ id: 1, name: '科技', parentId: 0, sort: 1, icon: 'cpu' },
|
||||
{ id: 2, name: '编程开发', parentId: 1, sort: 1, icon: 'code' },
|
||||
{ id: 3, name: '微服务架构', parentId: 1, sort: 2, icon: 'share' },
|
||||
{ id: 4, name: '美食', parentId: 0, sort: 2, icon: 'food' },
|
||||
{ id: 5, name: '校园生活', parentId: 0, sort: 3, icon: 'school' }
|
||||
]
|
||||
|
||||
export const mockTags: Tag[] = [
|
||||
{ id: 1, name: 'Vue3', useCount: 48, createTime: '2026-03-01 09:00:00' },
|
||||
{ id: 2, name: 'Spring Cloud', useCount: 31, createTime: '2026-03-02 09:00:00' },
|
||||
{ id: 3, name: 'FFmpeg', useCount: 17, createTime: '2026-03-03 09:00:00' },
|
||||
{ id: 4, name: 'Redis', useCount: 22, createTime: '2026-03-04 09:00:00' },
|
||||
{ id: 5, name: '微服务', useCount: 36, createTime: '2026-03-05 09:00:00' }
|
||||
]
|
||||
|
||||
export const mockPlatformUsers: PlatformUser[] = [
|
||||
{
|
||||
id: 101,
|
||||
username: 'xm_dev',
|
||||
nickname: '视频达人小明',
|
||||
avatar: 'https://picsum.photos/seed/user101/120/120',
|
||||
email: 'xm@haovp.com',
|
||||
phone: '13911112222',
|
||||
gender: 1,
|
||||
birthday: '1999-06-18',
|
||||
signature: '专注前端工程化与系统设计',
|
||||
status: 1,
|
||||
followerCount: 12580,
|
||||
followingCount: 256,
|
||||
videoCount: 32,
|
||||
likeCount: 45600,
|
||||
level: 5,
|
||||
vipLevel: 2,
|
||||
createTime: '2025-11-18 12:00:00',
|
||||
updateTime: '2026-03-21 18:20:00'
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
username: 'cloud_lab',
|
||||
nickname: '科技爱好者',
|
||||
avatar: 'https://picsum.photos/seed/user102/120/120',
|
||||
email: 'cloud@haovp.com',
|
||||
phone: '13911113333',
|
||||
gender: 1,
|
||||
birthday: '1998-04-22',
|
||||
signature: '关注后端架构、容器化与云原生',
|
||||
status: 1,
|
||||
followerCount: 8920,
|
||||
followingCount: 128,
|
||||
videoCount: 24,
|
||||
likeCount: 23400,
|
||||
level: 4,
|
||||
vipLevel: 1,
|
||||
createTime: '2025-11-23 09:30:00',
|
||||
updateTime: '2026-03-22 07:55:00'
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
username: 'food_girl',
|
||||
nickname: '美食博主小美',
|
||||
avatar: 'https://picsum.photos/seed/user103/120/120',
|
||||
email: 'food@haovp.com',
|
||||
phone: '13911114444',
|
||||
gender: 2,
|
||||
birthday: '2000-03-10',
|
||||
signature: '记录温暖的烟火气与家常味道',
|
||||
status: 1,
|
||||
followerCount: 23450,
|
||||
followingCount: 389,
|
||||
videoCount: 56,
|
||||
likeCount: 89000,
|
||||
level: 6,
|
||||
vipLevel: 3,
|
||||
createTime: '2025-10-09 15:20:00',
|
||||
updateTime: '2026-03-20 19:00:00'
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
username: 'campus_vision',
|
||||
nickname: '校园影像社',
|
||||
avatar: 'https://picsum.photos/seed/user104/120/120',
|
||||
email: 'campus@haovp.com',
|
||||
phone: '13911115555',
|
||||
gender: 0,
|
||||
birthday: '2001-09-01',
|
||||
signature: '分享校园活动、毕业纪念与Vlog',
|
||||
status: 0,
|
||||
followerCount: 3120,
|
||||
followingCount: 87,
|
||||
videoCount: 12,
|
||||
likeCount: 9520,
|
||||
level: 3,
|
||||
vipLevel: 0,
|
||||
createTime: '2025-12-20 10:10:00',
|
||||
updateTime: '2026-03-18 16:42:00'
|
||||
}
|
||||
]
|
||||
|
||||
export const mockPlatformVideos: PlatformVideo[] = [
|
||||
{
|
||||
id: 201,
|
||||
userId: 101,
|
||||
username: 'xm_dev',
|
||||
userNickname: '视频达人小明',
|
||||
userAvatar: 'https://picsum.photos/seed/user101/120/120',
|
||||
title: 'Vue3 + TypeScript 企业级项目实战教程',
|
||||
description: '从项目初始化、组件封装到状态管理与部署,完整展示前端工程化实践。',
|
||||
coverUrl: 'https://picsum.photos/seed/video201/640/360',
|
||||
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||
duration: 1845,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
format: 'mp4',
|
||||
size: 156000000,
|
||||
categoryId: 2,
|
||||
categoryName: '编程开发',
|
||||
status: 1,
|
||||
auditStatus: 1,
|
||||
auditReason: '',
|
||||
likeCount: 1256,
|
||||
coinCount: 89,
|
||||
favoriteCount: 456,
|
||||
commentCount: 123,
|
||||
viewCount: 15680,
|
||||
shareCount: 45,
|
||||
danmakuCount: 567,
|
||||
isRecommend: 1,
|
||||
isTop: 1,
|
||||
publishTime: '2026-03-10 10:30:00',
|
||||
createTime: '2026-03-10 09:00:00',
|
||||
updateTime: '2026-03-10 10:30:00'
|
||||
},
|
||||
{
|
||||
id: 202,
|
||||
userId: 102,
|
||||
username: 'cloud_lab',
|
||||
userNickname: '科技爱好者',
|
||||
userAvatar: 'https://picsum.photos/seed/user102/120/120',
|
||||
title: 'Spring Cloud Gateway 与 JWT 鉴权流程演示',
|
||||
description: '讲解统一网关入口、令牌校验、路由转发与限流治理的核心思路。',
|
||||
coverUrl: 'https://picsum.photos/seed/video202/640/360',
|
||||
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||
duration: 1320,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
format: 'mp4',
|
||||
size: 128000000,
|
||||
categoryId: 3,
|
||||
categoryName: '微服务架构',
|
||||
status: 0,
|
||||
auditStatus: 0,
|
||||
auditReason: '',
|
||||
likeCount: 665,
|
||||
coinCount: 54,
|
||||
favoriteCount: 212,
|
||||
commentCount: 47,
|
||||
viewCount: 9821,
|
||||
shareCount: 28,
|
||||
danmakuCount: 193,
|
||||
isRecommend: 1,
|
||||
isTop: 0,
|
||||
publishTime: '',
|
||||
createTime: '2026-03-21 21:15:00',
|
||||
updateTime: '2026-03-21 21:15:00'
|
||||
},
|
||||
{
|
||||
id: 203,
|
||||
userId: 103,
|
||||
username: 'food_girl',
|
||||
userNickname: '美食博主小美',
|
||||
userAvatar: 'https://picsum.photos/seed/user103/120/120',
|
||||
title: '家常红烧肉做法 | 肥而不腻入口即化',
|
||||
description: '展示食材准备、火候控制与成品摆盘过程。',
|
||||
coverUrl: 'https://picsum.photos/seed/video203/640/360',
|
||||
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||
duration: 580,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
format: 'mp4',
|
||||
size: 78000000,
|
||||
categoryId: 4,
|
||||
categoryName: '美食',
|
||||
status: 1,
|
||||
auditStatus: 1,
|
||||
auditReason: '',
|
||||
likeCount: 2345,
|
||||
coinCount: 156,
|
||||
favoriteCount: 789,
|
||||
commentCount: 234,
|
||||
viewCount: 45600,
|
||||
shareCount: 123,
|
||||
danmakuCount: 890,
|
||||
isRecommend: 1,
|
||||
isTop: 1,
|
||||
publishTime: '2026-03-15 18:20:00',
|
||||
createTime: '2026-03-15 17:40:00',
|
||||
updateTime: '2026-03-15 18:20:00'
|
||||
},
|
||||
{
|
||||
id: 204,
|
||||
userId: 104,
|
||||
username: 'campus_vision',
|
||||
userNickname: '校园影像社',
|
||||
userAvatar: 'https://picsum.photos/seed/user104/120/120',
|
||||
title: '毕业季校园纪念短片',
|
||||
description: '包含操场、教学楼、毕业合影等镜头,配合轻音乐剪辑。',
|
||||
coverUrl: 'https://picsum.photos/seed/video204/640/360',
|
||||
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||
duration: 420,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
format: 'mp4',
|
||||
size: 66000000,
|
||||
categoryId: 5,
|
||||
categoryName: '校园生活',
|
||||
status: 2,
|
||||
auditStatus: 2,
|
||||
auditReason: '封面含未授权人物肖像,需重新提交',
|
||||
likeCount: 118,
|
||||
coinCount: 8,
|
||||
favoriteCount: 24,
|
||||
commentCount: 5,
|
||||
viewCount: 1302,
|
||||
shareCount: 3,
|
||||
danmakuCount: 16,
|
||||
isRecommend: 0,
|
||||
isTop: 0,
|
||||
publishTime: '',
|
||||
createTime: '2026-03-19 20:20:00',
|
||||
updateTime: '2026-03-20 09:30:00'
|
||||
},
|
||||
{
|
||||
id: 205,
|
||||
userId: 102,
|
||||
username: 'cloud_lab',
|
||||
userNickname: '科技爱好者',
|
||||
userAvatar: 'https://picsum.photos/seed/user102/120/120',
|
||||
title: '内容服务中的 FFmpeg 转码与 MinIO 存储实践',
|
||||
description: '结合上传流程、封面提取与对象存储实现完整媒体处理链路。',
|
||||
coverUrl: 'https://picsum.photos/seed/video205/640/360',
|
||||
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||
duration: 1680,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
format: 'mp4',
|
||||
size: 189000000,
|
||||
categoryId: 3,
|
||||
categoryName: '微服务架构',
|
||||
status: 3,
|
||||
auditStatus: 1,
|
||||
auditReason: '',
|
||||
likeCount: 902,
|
||||
coinCount: 77,
|
||||
favoriteCount: 338,
|
||||
commentCount: 62,
|
||||
viewCount: 14220,
|
||||
shareCount: 41,
|
||||
danmakuCount: 288,
|
||||
isRecommend: 0,
|
||||
isTop: 0,
|
||||
publishTime: '2026-03-08 13:00:00',
|
||||
createTime: '2026-03-08 11:40:00',
|
||||
updateTime: '2026-03-17 12:00:00'
|
||||
}
|
||||
]
|
||||
|
||||
export const mockPlatformComments: PlatformComment[] = [
|
||||
{
|
||||
id: 301,
|
||||
videoId: 201,
|
||||
videoTitle: 'Vue3 + TypeScript 企业级项目实战教程',
|
||||
userId: 102,
|
||||
userNickname: '科技爱好者',
|
||||
userAvatar: 'https://picsum.photos/seed/user102/120/120',
|
||||
parentId: 0,
|
||||
rootId: 0,
|
||||
content: '网关鉴权和前端权限路由那一段讲得很清楚,适合论文演示。',
|
||||
likeCount: 156,
|
||||
replyCount: 5,
|
||||
isTop: 1,
|
||||
status: 1,
|
||||
createTime: '2026-03-20 09:30:00'
|
||||
},
|
||||
{
|
||||
id: 302,
|
||||
videoId: 202,
|
||||
videoTitle: 'Spring Cloud Gateway 与 JWT 鉴权流程演示',
|
||||
userId: 101,
|
||||
userNickname: '视频达人小明',
|
||||
userAvatar: 'https://picsum.photos/seed/user101/120/120',
|
||||
parentId: 0,
|
||||
rootId: 0,
|
||||
content: '建议补一段 Sentinel 限流效果的说明,会更完整。',
|
||||
likeCount: 48,
|
||||
replyCount: 1,
|
||||
isTop: 0,
|
||||
status: 1,
|
||||
createTime: '2026-03-21 10:12:00'
|
||||
},
|
||||
{
|
||||
id: 303,
|
||||
videoId: 203,
|
||||
videoTitle: '家常红烧肉做法 | 肥而不腻入口即化',
|
||||
userId: 104,
|
||||
userNickname: '校园影像社',
|
||||
userAvatar: 'https://picsum.photos/seed/user104/120/120',
|
||||
parentId: 0,
|
||||
rootId: 0,
|
||||
content: '成品看起来很不错,步骤也很清晰。',
|
||||
likeCount: 27,
|
||||
replyCount: 0,
|
||||
isTop: 0,
|
||||
status: 1,
|
||||
createTime: '2026-03-16 19:12:00'
|
||||
},
|
||||
{
|
||||
id: 304,
|
||||
videoId: 204,
|
||||
videoTitle: '毕业季校园纪念短片',
|
||||
userId: 103,
|
||||
userNickname: '美食博主小美',
|
||||
userAvatar: 'https://picsum.photos/seed/user103/120/120',
|
||||
parentId: 0,
|
||||
rootId: 0,
|
||||
content: '该评论因包含联系方式已被系统删除。',
|
||||
likeCount: 3,
|
||||
replyCount: 0,
|
||||
isTop: 0,
|
||||
status: 0,
|
||||
createTime: '2026-03-20 08:20:00'
|
||||
}
|
||||
]
|
||||
|
||||
export const mockStatistics: StatisticsData = {
|
||||
totalUsers: 18324,
|
||||
newUsersToday: 126,
|
||||
activeUsersToday: 4280,
|
||||
userGrowthRate: 8.6,
|
||||
totalVideos: 2648,
|
||||
newVideosToday: 43,
|
||||
pendingReview: 7,
|
||||
videoGrowthRate: 6.2,
|
||||
totalViews: 5823400,
|
||||
viewsToday: 128600,
|
||||
viewGrowthRate: 12.4,
|
||||
totalLikes: 986300,
|
||||
totalComments: 138500,
|
||||
totalDanmakus: 94200,
|
||||
userTrend: createTrend([58, 72, 69, 88, 95, 110, 126]),
|
||||
videoTrend: createTrend([18, 22, 25, 31, 29, 37, 43]),
|
||||
viewTrend: createTrend([86200, 94500, 90800, 103200, 118600, 121000, 128600])
|
||||
}
|
||||
|
||||
export const mockDashboardData: DashboardData = {
|
||||
statistics: mockStatistics,
|
||||
hotVideos: [...mockPlatformVideos].sort((a, b) => b.viewCount - a.viewCount).slice(0, 4),
|
||||
recentComments: [...mockPlatformComments].sort((a, b) => b.createTime.localeCompare(a.createTime)).slice(0, 4),
|
||||
recentUsers: [...mockPlatformUsers].sort((a, b) => b.createTime.localeCompare(a.createTime)).slice(0, 4)
|
||||
}
|
||||
|
||||
export function paginate<T>(list: T[], page = 1, size = 10): PageResult<T> {
|
||||
const start = (page - 1) * size
|
||||
const records = list.slice(start, start + size)
|
||||
return {
|
||||
records,
|
||||
total: list.length,
|
||||
size,
|
||||
current: page,
|
||||
pages: Math.max(1, Math.ceil(list.length / size))
|
||||
}
|
||||
}
|
||||
|
||||
function createTrend(values: number[]): TrendData[] {
|
||||
return ['03-16', '03-17', '03-18', '03-19', '03-20', '03-21', '03-22'].map((date, index) => ({
|
||||
date,
|
||||
value: values[index]
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
mockAdminUsers,
|
||||
mockCategories,
|
||||
mockDashboardData,
|
||||
mockPlatformComments,
|
||||
mockPlatformUsers,
|
||||
mockPlatformVideos,
|
||||
mockStatistics,
|
||||
mockTags,
|
||||
paginate
|
||||
} from './data'
|
||||
|
||||
const delay = (ms = 120) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const mockStore = {
|
||||
adminUsers: [...mockAdminUsers],
|
||||
users: [...mockPlatformUsers],
|
||||
videos: [...mockPlatformVideos],
|
||||
comments: [...mockPlatformComments],
|
||||
categories: [...mockCategories],
|
||||
tags: [...mockTags]
|
||||
}
|
||||
|
||||
function normalize(url: string) {
|
||||
return url.replace(/^\/api\/admin/, '')
|
||||
}
|
||||
|
||||
function matchPath(path: string, pattern: string) {
|
||||
const pathParts = path.split('/').filter(Boolean)
|
||||
const patternParts = pattern.split('/').filter(Boolean)
|
||||
if (pathParts.length !== patternParts.length) return false
|
||||
return patternParts.every((part, index) => part.startsWith(':') || part === pathParts[index])
|
||||
}
|
||||
|
||||
function pathParam(path: string, indexFromEnd = 1) {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
return Number(parts[parts.length - indexFromEnd])
|
||||
}
|
||||
|
||||
function filterByKeyword<T>(list: T[], getter: (item: T) => string, keyword?: string) {
|
||||
if (!keyword) return list
|
||||
const needle = keyword.toLowerCase()
|
||||
return list.filter((item) => getter(item).toLowerCase().includes(needle))
|
||||
}
|
||||
|
||||
export async function mockRequest(url: string, options: any = {}) {
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
const path = normalize(url).split('?')[0]
|
||||
const params = options.params || {}
|
||||
const body = options.body || {}
|
||||
|
||||
await delay()
|
||||
|
||||
if (method === 'GET' && path === '/auth/captcha') {
|
||||
return ok({
|
||||
captchaKey: `admin-captcha-${Date.now()}`,
|
||||
captchaImage: `data:image/svg+xml;utf8,${encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
|
||||
<rect width="120" height="40" rx="8" fill="#eef2ff"/>
|
||||
<path d="M8 27 C22 9, 40 34, 56 12 S88 34, 112 10" stroke="#c7d2fe" stroke-width="2" fill="none"/>
|
||||
<text x="16" y="28" fill="#1d4ed8" font-size="24" font-family="Georgia" transform="rotate(-8 16 28)">2</text>
|
||||
<text x="40" y="28" fill="#0f766e" font-size="24" font-family="Georgia" transform="rotate(7 40 28)">4</text>
|
||||
<text x="66" y="28" fill="#be185d" font-size="24" font-family="Georgia" transform="rotate(-6 66 28)">6</text>
|
||||
<text x="92" y="28" fill="#7c3aed" font-size="24" font-family="Georgia" transform="rotate(8 92 28)">8</text>
|
||||
</svg>
|
||||
`)}`
|
||||
})
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/auth/login') {
|
||||
return ok({
|
||||
accessToken: `admin-token-${Date.now()}`,
|
||||
refreshToken: `admin-refresh-${Date.now()}`,
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 7200,
|
||||
adminId: 1,
|
||||
username: body.username || 'admin'
|
||||
})
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/auth/logout') {
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/auth/info') {
|
||||
return ok(mockStore.adminUsers[0])
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/statistics/dashboard') {
|
||||
return ok(mockDashboardData)
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/statistics/overview') {
|
||||
return ok(mockStatistics)
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/statistics/user/trend') {
|
||||
return ok(mockStatistics.userTrend)
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/statistics/video/trend') {
|
||||
return ok(mockStatistics.videoTrend)
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/statistics/view/trend') {
|
||||
return ok(mockStatistics.viewTrend)
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/platform/user/stats') {
|
||||
const { totalUsers, newUsersToday, activeUsersToday, userGrowthRate } = mockStatistics
|
||||
return ok({ totalUsers, newUsersToday, activeUsersToday, userGrowthRate })
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/platform/content/video/stats') {
|
||||
const { totalVideos, newVideosToday, pendingReview, videoGrowthRate } = mockStatistics
|
||||
return ok({ totalVideos, newVideosToday, pendingReview, videoGrowthRate })
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/admin/stats') {
|
||||
return ok({
|
||||
totalComments: mockStatistics.totalComments,
|
||||
commentsToday: 84,
|
||||
commentGrowthRate: 5.8
|
||||
})
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/platform/user/list') {
|
||||
let list = [...mockStore.users]
|
||||
list = filterByKeyword(list, (item) => `${item.username} ${item.nickname} ${item.phone}`, params.keyword)
|
||||
if (params.status !== undefined && params.status !== '') {
|
||||
list = list.filter((item) => item.status === Number(params.status))
|
||||
}
|
||||
return ok(paginate(list, Number(params.page || 1), Number(params.size || 10)))
|
||||
}
|
||||
|
||||
if (method === 'GET' && matchPath(path, '/platform/user/:id')) {
|
||||
return ok(mockStore.users.find((item) => item.id === pathParam(path)) || mockStore.users[0])
|
||||
}
|
||||
|
||||
if (method === 'PUT' && matchPath(path, '/platform/user/:id/status/:status')) {
|
||||
const user = mockStore.users.find((item) => item.id === pathParam(path, 2))
|
||||
if (user) user.status = pathParam(path)
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'PUT' && matchPath(path, '/platform/user/:id/reset-password')) {
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/platform/content/video/list') {
|
||||
let list = [...mockStore.videos]
|
||||
list = filterByKeyword(list, (item) => item.title, params.keyword)
|
||||
if (params.status !== undefined && params.status !== '') {
|
||||
list = list.filter((item) => item.status === Number(params.status))
|
||||
}
|
||||
if (params.auditStatus !== undefined && params.auditStatus !== '') {
|
||||
list = list.filter((item) => item.auditStatus === Number(params.auditStatus))
|
||||
}
|
||||
if (params.categoryId) {
|
||||
list = list.filter((item) => item.categoryId === Number(params.categoryId))
|
||||
}
|
||||
return ok(paginate(list, Number(params.page || 1), Number(params.size || 10)))
|
||||
}
|
||||
|
||||
if (method === 'GET' && matchPath(path, '/platform/content/video/:id')) {
|
||||
return ok(mockStore.videos.find((item) => item.id === pathParam(path)) || mockStore.videos[0])
|
||||
}
|
||||
|
||||
if (method === 'POST' && matchPath(path, '/platform/content/video/review/:id')) {
|
||||
const video = mockStore.videos.find((item) => item.id === pathParam(path))
|
||||
if (video) {
|
||||
video.auditStatus = Number(body.status)
|
||||
video.auditReason = body.reason || ''
|
||||
video.status = Number(body.status) === 1 ? 1 : 2
|
||||
video.publishTime = Number(body.status) === 1 ? '2026-03-22 10:30:00' : ''
|
||||
}
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'PUT' && matchPath(path, '/platform/content/video/disable/:id')) {
|
||||
const video = mockStore.videos.find((item) => item.id === pathParam(path))
|
||||
if (video) video.status = 3
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/admin/list') {
|
||||
let list = [...mockStore.comments]
|
||||
list = filterByKeyword(list, (item) => item.content, params.keyword)
|
||||
if (params.status !== undefined && params.status !== '') {
|
||||
list = list.filter((item) => item.status === Number(params.status))
|
||||
}
|
||||
return ok(paginate(list, Number(params.page || 1), Number(params.size || 10)))
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && matchPath(path, '/admin/:id')) {
|
||||
const comment = mockStore.comments.find((item) => item.id === pathParam(path))
|
||||
if (comment) comment.status = 0
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/platform/content/category/list') {
|
||||
return ok([...mockStore.categories].sort((a, b) => a.sort - b.sort))
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/platform/content/category') {
|
||||
mockStore.categories.push({
|
||||
id: mockStore.categories.length + 10,
|
||||
name: body.name,
|
||||
parentId: body.parentId || 0,
|
||||
sort: body.sort || 0,
|
||||
icon: body.icon || ''
|
||||
})
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'PUT' && path === '/platform/content/category') {
|
||||
const category = mockStore.categories.find((item) => item.id === body.id)
|
||||
if (category) Object.assign(category, body)
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && matchPath(path, '/platform/content/category/:id')) {
|
||||
mockStore.categories = mockStore.categories.filter((item) => item.id !== pathParam(path))
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/content/tag/page') {
|
||||
const list = filterByKeyword([...mockStore.tags], (item) => item.name, params.keyword)
|
||||
return ok(paginate(list, Number(params.page || 1), Number(params.size || 10)))
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/content/tag') {
|
||||
mockStore.tags.unshift({
|
||||
id: mockStore.tags.length + 1,
|
||||
name: body.name,
|
||||
useCount: 0,
|
||||
createTime: '2026-03-22 10:00:00'
|
||||
})
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'PUT' && path === '/content/tag') {
|
||||
const tag = mockStore.tags.find((item) => item.id === body.id)
|
||||
if (tag) tag.name = body.name
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && matchPath(path, '/content/tag/:id')) {
|
||||
mockStore.tags = mockStore.tags.filter((item) => item.id !== pathParam(path))
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/user/list') {
|
||||
const list = filterByKeyword([...mockStore.adminUsers], (item) => `${item.username} ${item.nickname}`, params.keyword)
|
||||
return ok(paginate(list, Number(params.page || 1), Number(params.size || 10)))
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/user') {
|
||||
mockStore.adminUsers.push({
|
||||
id: mockStore.adminUsers.length + 1,
|
||||
username: body.username,
|
||||
nickname: body.nickname,
|
||||
avatar: 'https://picsum.photos/seed/newadmin/120/120',
|
||||
email: body.email || '',
|
||||
phone: body.phone || '',
|
||||
role: body.role || 'manager',
|
||||
status: 1,
|
||||
lastLoginTime: '',
|
||||
lastLoginIp: '',
|
||||
createTime: '2026-03-22 10:00:00'
|
||||
})
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'PUT' && path === '/user') {
|
||||
const admin = mockStore.adminUsers.find((item) => item.id === body.id)
|
||||
if (admin) Object.assign(admin, body)
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'PUT' && matchPath(path, '/user/:id/status/:status')) {
|
||||
const admin = mockStore.adminUsers.find((item) => item.id === pathParam(path, 2))
|
||||
if (admin) admin.status = pathParam(path)
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
if (method === 'PUT' && matchPath(path, '/user/:id/reset-password')) {
|
||||
return ok(null)
|
||||
}
|
||||
|
||||
return {
|
||||
code: 404,
|
||||
message: `未找到 Mock 处理器: ${method} ${path}`,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
|
||||
function ok(data: any) {
|
||||
return {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data
|
||||
}
|
||||
}
|
||||
|
||||
export function isMockEnabled() {
|
||||
return import.meta.env.VITE_MOCK === 'true' || import.meta.env.VITE_MOCK === true
|
||||
}
|
||||
@@ -138,7 +138,7 @@ export const asyncRoutes: RouteRecordRaw[] = [
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [...constantRoutes, ...asyncRoutes],
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
scrollBehavior(_to, _from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
@@ -151,7 +151,7 @@ const router = createRouter({
|
||||
const whiteList = ['/login', '/404']
|
||||
|
||||
// 路由前置守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
// 开始进度条
|
||||
NProgress.start()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
export interface TagView {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { authApi, type LoginParams, type AdminUser } from '@/api/auth.ts'
|
||||
import router from '@/router'
|
||||
|
||||
const TOKEN_KEY = 'admin_token'
|
||||
const REFRESH_TOKEN_KEY = 'admin_refresh_token'
|
||||
|
||||
@@ -11,7 +11,7 @@ html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
font-size: 14px;
|
||||
@@ -52,6 +52,8 @@ a {
|
||||
// Element Plus 覆盖
|
||||
.el-card {
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14) !important;
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.06) !important;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: $spacing-md;
|
||||
@@ -59,21 +61,35 @@ a {
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-border-color: rgba(226, 232, 240, 0.78);
|
||||
|
||||
.el-table__header {
|
||||
th {
|
||||
background: $bg-tertiary !important;
|
||||
background: #f8fafc !important;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.el-table__row:hover > td {
|
||||
background: #f8fbff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: $radius-md;
|
||||
box-shadow: 0 28px 64px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.el-message-box {
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.search-card,
|
||||
.table-card {
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
// 工具类
|
||||
.text-primary {
|
||||
color: $primary-color !important;
|
||||
|
||||
@@ -4,13 +4,14 @@ import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import router from '@/router'
|
||||
import { isMockEnabled, mockRequest } from '@/mock'
|
||||
|
||||
// 配置 NProgress
|
||||
NProgress.configure({ showSpinner: false })
|
||||
|
||||
// 创建 axios 实例
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL: '/api/admin',
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api/admin',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -109,25 +110,55 @@ service.interceptors.response.use(
|
||||
}
|
||||
)
|
||||
|
||||
async function mockFetch<T>(method: string, url: string, data?: any, params?: any): Promise<T> {
|
||||
const result = await mockRequest(url, {
|
||||
method,
|
||||
body: data,
|
||||
params
|
||||
})
|
||||
|
||||
if (result.code === 200 || result.code === 0) {
|
||||
return result.data
|
||||
}
|
||||
|
||||
ElMessage.error(result.message || '请求失败')
|
||||
throw new Error(result.message || '请求失败')
|
||||
}
|
||||
|
||||
// 封装请求方法
|
||||
const request = {
|
||||
get<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
if (isMockEnabled()) {
|
||||
return mockFetch<T>('GET', url, undefined, params)
|
||||
}
|
||||
return service.get(url, { params, ...config })
|
||||
},
|
||||
|
||||
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
if (isMockEnabled()) {
|
||||
return mockFetch<T>('POST', url, data)
|
||||
}
|
||||
return service.post(url, data, config)
|
||||
},
|
||||
|
||||
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
if (isMockEnabled()) {
|
||||
return mockFetch<T>('PUT', url, data)
|
||||
}
|
||||
return service.put(url, data, config)
|
||||
},
|
||||
|
||||
delete<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
if (isMockEnabled()) {
|
||||
return mockFetch<T>('DELETE', url, undefined, params)
|
||||
}
|
||||
return service.delete(url, { params, ...config })
|
||||
},
|
||||
|
||||
upload<T = any>(url: string, file: File, onProgress?: (percent: number) => void): Promise<T> {
|
||||
if (isMockEnabled()) {
|
||||
return mockFetch<T>('POST', url, { file })
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
<el-tree-select
|
||||
v-model="form.parentId"
|
||||
:data="categoryTree"
|
||||
:props="{ label: 'name', value: 'id' }"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
node-key="id"
|
||||
check-strictly
|
||||
clearable
|
||||
placeholder="选择父级分类(可选)"
|
||||
|
||||
@@ -116,8 +116,9 @@ onMounted(() => loadComments())
|
||||
<style scoped lang="scss">
|
||||
.comment-page {
|
||||
.search-card { margin-bottom: 20px; }
|
||||
.comment-content { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.video-title { color: #666; font-size: 12px; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<el-card shadow="never" class="hero-card">
|
||||
<div class="hero-content">
|
||||
<div>
|
||||
<div class="eyebrow">Platform Overview</div>
|
||||
<h2>HAOVP 平台治理总览</h2>
|
||||
<p>集中查看用户增长、内容分发、播放热度与审核压力,支撑平台运营与内容治理决策。</p>
|
||||
</div>
|
||||
<div class="hero-badges">
|
||||
<el-tag type="primary" effect="dark">统一运营视图</el-tag>
|
||||
<el-tag type="success" effect="dark">实时业务概览</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="20" class="stat-cards">
|
||||
<el-col :xs="12" :sm="6">
|
||||
@@ -350,21 +364,71 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard-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-badges {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -377,7 +441,7 @@ onUnmounted(() => {
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
color: #172554;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
|
||||
@@ -189,19 +189,23 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #304156;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(14, 30, 55, 0.98), rgba(10, 20, 36, 0.98)),
|
||||
#0f172a;
|
||||
transition: width 0.3s;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.12);
|
||||
|
||||
.logo {
|
||||
height: 50px;
|
||||
height: 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
background: #263445;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
@@ -213,20 +217,20 @@ onUnmounted(() => {
|
||||
color: #bfcbd9;
|
||||
|
||||
&:hover {
|
||||
background: #263445;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
color: #409eff;
|
||||
background: #263445;
|
||||
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: #1f2d3d;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
|
||||
&:hover {
|
||||
background: #001528;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,8 +247,9 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
|
||||
backdrop-filter: blur(16px);
|
||||
z-index: 10;
|
||||
|
||||
.header-left {
|
||||
@@ -278,8 +283,10 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.main {
|
||||
background: #f0f2f5;
|
||||
padding: 20px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(14, 165, 233, 0.06), transparent 30%),
|
||||
#f4f7fb;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<div class="login-header">
|
||||
<h1 class="logo">HAOVP</h1>
|
||||
<p class="title">管理后台</p>
|
||||
<p class="subtitle">统一管理平台用户、内容审核、评论治理与运营数据</p>
|
||||
</div>
|
||||
|
||||
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="login-form">
|
||||
@@ -73,6 +74,12 @@
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="demo-tips">
|
||||
<span>管理员账号:admin</span>
|
||||
<span>登录密码:不少于 6 位</span>
|
||||
<span>验证码请按图形输入</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -160,15 +167,19 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(19, 110, 255, 0.22), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(26, 188, 156, 0.2), transparent 32%),
|
||||
linear-gradient(135deg, #081224 0%, #11284a 48%, #0f766e 100%);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
width: 440px;
|
||||
padding: 42px 40px 28px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 24px 80px rgba(5, 18, 39, 0.36);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
@@ -186,10 +197,17 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #666;
|
||||
color: #355070;
|
||||
font-size: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 10px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-wrapper {
|
||||
@@ -225,4 +243,16 @@ onMounted(() => {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.demo-tips {
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #eff6ff, #ecfeff);
|
||||
color: #355070;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="等级">Lv.{{ user?.level }}</el-descriptions-item>
|
||||
<el-descriptions-item label="VIP等级">
|
||||
{{ user?.vipLevel > 0 ? `VIP${user?.vipLevel}` : '普通用户' }}
|
||||
{{ (user?.vipLevel ?? 0) > 0 ? `VIP${user?.vipLevel}` : '普通用户' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="user?.status === 1 ? 'success' : 'danger'">
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="等级">Lv.{{ currentUser?.level }}</el-descriptions-item>
|
||||
<el-descriptions-item label="VIP等级">
|
||||
{{ currentUser?.vipLevel > 0 ? `VIP${currentUser?.vipLevel}` : '普通用户' }}
|
||||
{{ (currentUser?.vipLevel ?? 0) > 0 ? `VIP${currentUser?.vipLevel}` : '普通用户' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="粉丝数">{{ currentUser?.followerCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="关注数">{{ currentUser?.followingCount }}</el-descriptions-item>
|
||||
@@ -165,14 +165,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
import { formatDateTime, formatNumber } from '@/utils/format.ts'
|
||||
import type { PlatformUser, UserQueryParams } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive<UserQueryParams>({
|
||||
page: 1,
|
||||
@@ -314,6 +311,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border-radius: 22px;
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -328,7 +327,7 @@ onMounted(() => {
|
||||
.info {
|
||||
.nickname {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: #172554;
|
||||
}
|
||||
|
||||
.username {
|
||||
@@ -342,7 +341,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
|
||||
@@ -138,7 +138,8 @@ const handleDisable = async () => {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const getStatusType = (status: number) => ({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' }[status] || 'info')
|
||||
const getStatusType = (status: number): 'warning' | 'success' | 'danger' | 'info' =>
|
||||
({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' }[status] as 'warning' | 'success' | 'danger' | 'info') || 'info'
|
||||
const getStatusText = (status: number) => ({ 0: '待审核', 1: '已发布', 2: '已拒绝', 3: '已下架' }[status] || '未知')
|
||||
|
||||
onMounted(() => loadVideo())
|
||||
|
||||
@@ -162,7 +162,8 @@ const handleDisable = async (row: PlatformVideo) => {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const getStatusType = (status: number) => ({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' }[status] || 'info')
|
||||
const getStatusType = (status: number): 'warning' | 'success' | 'danger' | 'info' =>
|
||||
({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' }[status] as 'warning' | 'success' | 'danger' | 'info') || 'info'
|
||||
const getStatusText = (status: number) => ({ 0: '待审核', 1: '已发布', 2: '已拒绝', 3: '已下架' }[status] || '未知')
|
||||
|
||||
onMounted(() => loadVideoList())
|
||||
|
||||
@@ -350,8 +350,8 @@ const getAuditStatusText = (status: number) => {
|
||||
}
|
||||
|
||||
// 审核状态类型
|
||||
const getAuditStatusType = (status: number) => {
|
||||
const map: Record<number, string> = {
|
||||
const getAuditStatusType = (status: number): 'warning' | 'success' | 'danger' | 'info' => {
|
||||
const map: Record<number, 'warning' | 'success' | 'danger'> = {
|
||||
0: 'warning',
|
||||
1: 'success',
|
||||
2: 'danger'
|
||||
@@ -377,6 +377,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border-radius: 22px;
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -390,7 +392,7 @@ onMounted(() => {
|
||||
.cover {
|
||||
width: 120px;
|
||||
height: 68px;
|
||||
border-radius: 4px;
|
||||
border-radius: 12px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
@@ -408,7 +410,7 @@ onMounted(() => {
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: #172554;
|
||||
margin-bottom: 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -441,7 +443,7 @@ onMounted(() => {
|
||||
.preview-container {
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: 4px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
.video-player {
|
||||
|
||||
Reference in New Issue
Block a user