feat(frontend): Align admin and user API integration
This commit is contained in:
+4758
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
import request from '@/utils/request.ts'
|
import request from '@/utils/request.ts'
|
||||||
import type { AdminUser, PageResult, PageParams } from '@/types'
|
import type { AdminUser, PageResult, PageParams } from '@/types'
|
||||||
|
|
||||||
|
export type { AdminUser }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理员管理 API
|
* 管理员管理 API
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
AdminUser
|
AdminUser
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
|
export type { LoginParams, LoginResult, CaptchaResult, AdminUser }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理员认证 API
|
* 管理员认证 API
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import request from '@/utils/request.ts'
|
import request from '@/utils/request.ts'
|
||||||
import type { Category, CategoryParams, Tag, TagQueryParams, PageResult } from '@/types'
|
import type { Category, CategoryParams, Tag, TagQueryParams, PageResult } from '@/types'
|
||||||
|
|
||||||
|
export type { Category, Tag }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分类管理 API
|
* 分类管理 API
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,13 +8,50 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
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']
|
||||||
|
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||||
|
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']
|
||||||
|
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']
|
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']
|
||||||
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']
|
||||||
|
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']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
TagsView: typeof import('./components/TagsView.vue')['default']
|
TagsView: typeof import('./components/TagsView.vue')['default']
|
||||||
}
|
}
|
||||||
|
export interface ComponentCustomProperties {
|
||||||
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { useTagsViewStore, type TagView } from '@/stores/tagsView.ts'
|
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({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [...constantRoutes, ...asyncRoutes],
|
routes: [...constantRoutes, ...asyncRoutes],
|
||||||
scrollBehavior(to, from, savedPosition) {
|
scrollBehavior(_to, _from, savedPosition) {
|
||||||
if (savedPosition) {
|
if (savedPosition) {
|
||||||
return savedPosition
|
return savedPosition
|
||||||
} else {
|
} else {
|
||||||
@@ -151,7 +151,7 @@ const router = createRouter({
|
|||||||
const whiteList = ['/login', '/404']
|
const whiteList = ['/login', '/404']
|
||||||
|
|
||||||
// 路由前置守卫
|
// 路由前置守卫
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, _from, next) => {
|
||||||
// 开始进度条
|
// 开始进度条
|
||||||
NProgress.start()
|
NProgress.start()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { RouteLocationNormalized } from 'vue-router'
|
import type { RouteLocationNormalized } from 'vue-router'
|
||||||
|
|
||||||
export interface TagView {
|
export interface TagView {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { authApi, type LoginParams, type AdminUser } from '@/api/auth.ts'
|
import { authApi, type LoginParams, type AdminUser } from '@/api/auth.ts'
|
||||||
import router from '@/router'
|
|
||||||
|
|
||||||
const TOKEN_KEY = 'admin_token'
|
const TOKEN_KEY = 'admin_token'
|
||||||
const REFRESH_TOKEN_KEY = 'admin_refresh_token'
|
const REFRESH_TOKEN_KEY = 'admin_refresh_token'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ html,
|
|||||||
body {
|
body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
'Noto Color Emoji';
|
'Noto Color Emoji';
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -52,6 +52,8 @@ a {
|
|||||||
// Element Plus 覆盖
|
// Element Plus 覆盖
|
||||||
.el-card {
|
.el-card {
|
||||||
border-radius: $radius-md;
|
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) {
|
&:not(:last-child) {
|
||||||
margin-bottom: $spacing-md;
|
margin-bottom: $spacing-md;
|
||||||
@@ -59,21 +61,35 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.el-table {
|
.el-table {
|
||||||
|
--el-table-border-color: rgba(226, 232, 240, 0.78);
|
||||||
|
|
||||||
.el-table__header {
|
.el-table__header {
|
||||||
th {
|
th {
|
||||||
background: $bg-tertiary !important;
|
background: #f8fafc !important;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.el-table__row:hover > td {
|
||||||
|
background: #f8fbff !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-dialog {
|
.el-dialog {
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
|
box-shadow: 0 28px 64px rgba(15, 23, 42, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-message-box {
|
.el-message-box {
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-card,
|
||||||
|
.table-card {
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
// 工具类
|
// 工具类
|
||||||
.text-primary {
|
.text-primary {
|
||||||
color: $primary-color !important;
|
color: $primary-color !important;
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import NProgress from 'nprogress'
|
|||||||
import 'nprogress/nprogress.css'
|
import 'nprogress/nprogress.css'
|
||||||
import { useUserStore } from '@/stores/user.ts'
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
import { isMockEnabled, mockRequest } from '@/mock'
|
||||||
|
|
||||||
// 配置 NProgress
|
// 配置 NProgress
|
||||||
NProgress.configure({ showSpinner: false })
|
NProgress.configure({ showSpinner: false })
|
||||||
|
|
||||||
// 创建 axios 实例
|
// 创建 axios 实例
|
||||||
const service: AxiosInstance = axios.create({
|
const service: AxiosInstance = axios.create({
|
||||||
baseURL: '/api/admin',
|
baseURL: import.meta.env.VITE_API_BASE_URL || '/api/admin',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'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 = {
|
const request = {
|
||||||
get<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
|
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 })
|
return service.get(url, { params, ...config })
|
||||||
},
|
},
|
||||||
|
|
||||||
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
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)
|
return service.post(url, data, config)
|
||||||
},
|
},
|
||||||
|
|
||||||
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
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)
|
return service.put(url, data, config)
|
||||||
},
|
},
|
||||||
|
|
||||||
delete<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
|
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 })
|
return service.delete(url, { params, ...config })
|
||||||
},
|
},
|
||||||
|
|
||||||
upload<T = any>(url: string, file: File, onProgress?: (percent: number) => void): Promise<T> {
|
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()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
<el-tree-select
|
<el-tree-select
|
||||||
v-model="form.parentId"
|
v-model="form.parentId"
|
||||||
:data="categoryTree"
|
:data="categoryTree"
|
||||||
:props="{ label: 'name', value: 'id' }"
|
:props="{ label: 'name', children: 'children' }"
|
||||||
|
node-key="id"
|
||||||
check-strictly
|
check-strictly
|
||||||
clearable
|
clearable
|
||||||
placeholder="选择父级分类(可选)"
|
placeholder="选择父级分类(可选)"
|
||||||
|
|||||||
@@ -116,8 +116,9 @@ onMounted(() => loadComments())
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.comment-page {
|
.comment-page {
|
||||||
.search-card { margin-bottom: 20px; }
|
.search-card { margin-bottom: 20px; }
|
||||||
.comment-content { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
.table-card { border-radius: 22px; }
|
||||||
.video-title { color: #666; font-size: 12px; }
|
.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; }
|
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||||
.pagination { margin-top: 20px; display: flex; justify-content: flex-end; }
|
.pagination { margin-top: 20px; display: flex; justify-content: flex-end; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="dashboard-page">
|
<div class="dashboard-page">
|
||||||
|
<el-card shadow="never" class="hero-card">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">Platform Overview</div>
|
||||||
|
<h2>HAOVP 平台治理总览</h2>
|
||||||
|
<p>集中查看用户增长、内容分发、播放热度与审核压力,支撑平台运营与内容治理决策。</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-badges">
|
||||||
|
<el-tag type="primary" effect="dark">统一运营视图</el-tag>
|
||||||
|
<el-tag type="success" effect="dark">实时业务概览</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
<el-row :gutter="20" class="stat-cards">
|
<el-row :gutter="20" class="stat-cards">
|
||||||
<el-col :xs="12" :sm="6">
|
<el-col :xs="12" :sm="6">
|
||||||
@@ -350,21 +364,71 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.dashboard-page {
|
.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 {
|
.stat-cards {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
|
||||||
:deep(.el-card__body) {
|
:deep(.el-card__body) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
border-radius: 8px;
|
border-radius: 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -377,7 +441,7 @@ onUnmounted(() => {
|
|||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #333;
|
color: #172554;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
|
|||||||
@@ -189,19 +189,23 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background: #304156;
|
background:
|
||||||
|
linear-gradient(180deg, rgba(14, 30, 55, 0.98), rgba(10, 20, 36, 0.98)),
|
||||||
|
#0f172a;
|
||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-right: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
height: 50px;
|
height: 58px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
background: #263445;
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-menu {
|
.sidebar-menu {
|
||||||
@@ -213,20 +217,20 @@ onUnmounted(() => {
|
|||||||
color: #bfcbd9;
|
color: #bfcbd9;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #263445;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-menu-item.is-active) {
|
:deep(.el-menu-item.is-active) {
|
||||||
color: #409eff;
|
color: #8bd3ff;
|
||||||
background: #263445;
|
background: linear-gradient(90deg, rgba(59, 130, 246, 0.22), rgba(14, 165, 233, 0.08));
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-sub-menu .el-menu-item) {
|
:deep(.el-sub-menu .el-menu-item) {
|
||||||
background: #1f2d3d;
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #001528;
|
background: rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,8 +247,9 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
background: #fff;
|
background: rgba(255, 255, 255, 0.92);
|
||||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
@@ -278,8 +283,10 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
background: #f0f2f5;
|
background:
|
||||||
padding: 20px;
|
radial-gradient(circle at top, rgba(14, 165, 233, 0.06), transparent 30%),
|
||||||
|
#f4f7fb;
|
||||||
|
padding: 24px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<div class="login-header">
|
<div class="login-header">
|
||||||
<h1 class="logo">HAOVP</h1>
|
<h1 class="logo">HAOVP</h1>
|
||||||
<p class="title">管理后台</p>
|
<p class="title">管理后台</p>
|
||||||
|
<p class="subtitle">统一管理平台用户、内容审核、评论治理与运营数据</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="login-form">
|
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="login-form">
|
||||||
@@ -73,6 +74,12 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
|
<div class="demo-tips">
|
||||||
|
<span>管理员账号:admin</span>
|
||||||
|
<span>登录密码:不少于 6 位</span>
|
||||||
|
<span>验证码请按图形输入</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -160,15 +167,19 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.login-box {
|
||||||
width: 400px;
|
width: 440px;
|
||||||
padding: 40px;
|
padding: 42px 40px 28px;
|
||||||
background: #fff;
|
background: rgba(255, 255, 255, 0.96);
|
||||||
border-radius: 8px;
|
border-radius: 24px;
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 24px 80px rgba(5, 18, 39, 0.36);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header {
|
.login-header {
|
||||||
@@ -186,10 +197,17 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
color: #666;
|
color: #355070;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.captcha-wrapper {
|
.captcha-wrapper {
|
||||||
@@ -225,4 +243,16 @@ onMounted(() => {
|
|||||||
width: 100%;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="等级">Lv.{{ user?.level }}</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 ? `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'">
|
<el-tag :type="user?.status === 1 ? 'success' : 'danger'">
|
||||||
|
|||||||
@@ -142,7 +142,7 @@
|
|||||||
</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 ? `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="粉丝数">{{ currentUser?.followerCount }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="关注数">{{ currentUser?.followingCount }}</el-descriptions-item>
|
<el-descriptions-item label="关注数">{{ currentUser?.followingCount }}</el-descriptions-item>
|
||||||
@@ -165,14 +165,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import { formatDateTime, formatNumber } from '@/utils/format.ts'
|
import { formatDateTime, formatNumber } from '@/utils/format.ts'
|
||||||
import type { PlatformUser, UserQueryParams } from '@/types'
|
import type { PlatformUser, UserQueryParams } from '@/types'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// 查询参数
|
// 查询参数
|
||||||
const queryParams = reactive<UserQueryParams>({
|
const queryParams = reactive<UserQueryParams>({
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -314,6 +311,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table-card {
|
.table-card {
|
||||||
|
border-radius: 22px;
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -328,7 +327,7 @@ onMounted(() => {
|
|||||||
.info {
|
.info {
|
||||||
.nickname {
|
.nickname {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #333;
|
color: #172554;
|
||||||
}
|
}
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
@@ -342,7 +341,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #666;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
|
|||||||
@@ -138,7 +138,8 @@ const handleDisable = async () => {
|
|||||||
} catch {}
|
} 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] || '未知')
|
const getStatusText = (status: number) => ({ 0: '待审核', 1: '已发布', 2: '已拒绝', 3: '已下架' }[status] || '未知')
|
||||||
|
|
||||||
onMounted(() => loadVideo())
|
onMounted(() => loadVideo())
|
||||||
|
|||||||
@@ -162,7 +162,8 @@ const handleDisable = async (row: PlatformVideo) => {
|
|||||||
} catch {}
|
} 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] || '未知')
|
const getStatusText = (status: number) => ({ 0: '待审核', 1: '已发布', 2: '已拒绝', 3: '已下架' }[status] || '未知')
|
||||||
|
|
||||||
onMounted(() => loadVideoList())
|
onMounted(() => loadVideoList())
|
||||||
|
|||||||
@@ -350,8 +350,8 @@ const getAuditStatusText = (status: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 审核状态类型
|
// 审核状态类型
|
||||||
const getAuditStatusType = (status: number) => {
|
const getAuditStatusType = (status: number): 'warning' | 'success' | 'danger' | 'info' => {
|
||||||
const map: Record<number, string> = {
|
const map: Record<number, 'warning' | 'success' | 'danger'> = {
|
||||||
0: 'warning',
|
0: 'warning',
|
||||||
1: 'success',
|
1: 'success',
|
||||||
2: 'danger'
|
2: 'danger'
|
||||||
@@ -377,6 +377,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table-card {
|
.table-card {
|
||||||
|
border-radius: 22px;
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -390,7 +392,7 @@ onMounted(() => {
|
|||||||
.cover {
|
.cover {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 68px;
|
height: 68px;
|
||||||
border-radius: 4px;
|
border-radius: 12px;
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +410,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #333;
|
color: #172554;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
@@ -441,7 +443,7 @@ onMounted(() => {
|
|||||||
.preview-container {
|
.preview-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #000;
|
background: #000;
|
||||||
border-radius: 4px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.video-player {
|
.video-player {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Mock 模式配置
|
||||||
|
# 用于无后端情况下的前端界面展示
|
||||||
|
|
||||||
|
VITE_APP_TITLE=HAOVP视频平台
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
|
VITE_MOCK=true
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:mock": "vite --mode mock",
|
||||||
"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 --ignore-path .gitignore",
|
"lint": "eslint --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
AuthUser
|
AuthUser
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
|
export type { LoginParams, LoginResult, RegisterParams, CaptchaResult, AuthUser }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 认证相关 API
|
* 认证相关 API
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import request from '@/utils/request.ts'
|
import request from '@/utils/request.ts'
|
||||||
import type { Category, Tag, PageResult, PageParams } from '@/types'
|
import type { Category, Tag, PageResult, PageParams } from '@/types'
|
||||||
|
|
||||||
|
export type { Category, Tag }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分类相关 API
|
* 分类相关 API
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import request from '@/utils/request.ts'
|
import request from '@/utils/request.ts'
|
||||||
import type { Danmaku, DanmakuSendParams } from '@/types'
|
import type { Danmaku, DanmakuSendParams } from '@/types'
|
||||||
|
|
||||||
|
export type { Danmaku }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 弹幕相关 API
|
* 弹幕相关 API
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import request from '@/utils/request.ts'
|
import request from '@/utils/request.ts'
|
||||||
import type { Favorite, FavoriteCreateParams, FavoriteVideoParams, VideoInfo, PageResult, PageParams } from '@/types'
|
import type { Favorite, FavoriteCreateParams, VideoInfo, PageResult, PageParams } from '@/types'
|
||||||
|
|
||||||
|
export type { Favorite }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收藏夹相关 API
|
* 收藏夹相关 API
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import request from '@/utils/request.ts'
|
import request from '@/utils/request.ts'
|
||||||
import type { VideoInfo, RecommendVideo } from '@/types'
|
import type { VideoInfo } from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 推荐相关 API
|
* 推荐相关 API
|
||||||
@@ -7,16 +7,20 @@ import type { VideoInfo, RecommendVideo } from '@/types'
|
|||||||
export const recommendApi = {
|
export const recommendApi = {
|
||||||
/**
|
/**
|
||||||
* 获取首页推荐
|
* 获取首页推荐
|
||||||
|
* @param page 页码
|
||||||
|
* @param size 每页数量
|
||||||
*/
|
*/
|
||||||
getHomeRecommend(limit?: number): Promise<VideoInfo[]> {
|
getHomeRecommend(page: number = 1, size: number = 20): Promise<VideoInfo[]> {
|
||||||
return request.get('/recommend/home', { limit })
|
return request.get('/recommend/home', { page, size })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取热门推荐
|
* 获取热门推荐
|
||||||
|
* @param periodType 时间周期类型:1-日榜,2-周榜,3-月榜
|
||||||
|
* @param size 数量
|
||||||
*/
|
*/
|
||||||
getHotRecommend(params?: { page?: number; size?: number }): Promise<VideoInfo[]> {
|
getHotRecommend(periodType: number = 1, size: number = 20): Promise<VideoInfo[]> {
|
||||||
return request.get('/recommend/hot', params)
|
return request.get('/recommend/hot', { periodType, size })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
PageResult
|
PageResult
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
|
export type { UserFollow, Message, Notification }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户相关 API
|
* 用户相关 API
|
||||||
*/
|
*/
|
||||||
|
|||||||
+5
-10
@@ -64,21 +64,21 @@ declare global {
|
|||||||
const toValue: typeof import('vue')['toValue']
|
const toValue: typeof import('vue')['toValue']
|
||||||
const triggerRef: typeof import('vue')['triggerRef']
|
const triggerRef: typeof import('vue')['triggerRef']
|
||||||
const unref: typeof import('vue')['unref']
|
const unref: typeof import('vue')['unref']
|
||||||
const useAppStore: typeof import('./stores')['useAppStore']
|
const useAppStore: typeof import('./stores/index')['useAppStore']
|
||||||
const useAttrs: typeof import('vue')['useAttrs']
|
const useAttrs: typeof import('vue')['useAttrs']
|
||||||
const useCssModule: typeof import('vue')['useCssModule']
|
const useCssModule: typeof import('vue')['useCssModule']
|
||||||
const useCssVars: typeof import('vue')['useCssVars']
|
const useCssVars: typeof import('vue')['useCssVars']
|
||||||
const useFavoriteStore: typeof import('./stores')['useFavoriteStore']
|
const useFavoriteStore: typeof import('./stores/index')['useFavoriteStore']
|
||||||
const useId: typeof import('vue')['useId']
|
const useId: typeof import('vue')['useId']
|
||||||
const useLink: typeof import('vue-router')['useLink']
|
const useLink: typeof import('vue-router')['useLink']
|
||||||
const useMessageStore: typeof import('./stores/message.ts')['useMessageStore']
|
const useMessageStore: typeof import('./stores/message')['useMessageStore']
|
||||||
const useModel: typeof import('vue')['useModel']
|
const useModel: typeof import('vue')['useModel']
|
||||||
const useRoute: typeof import('vue-router')['useRoute']
|
const useRoute: typeof import('vue-router')['useRoute']
|
||||||
const useRouter: typeof import('vue-router')['useRouter']
|
const useRouter: typeof import('vue-router')['useRouter']
|
||||||
const useSlots: typeof import('vue')['useSlots']
|
const useSlots: typeof import('vue')['useSlots']
|
||||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||||
const useUserStore: typeof import('./stores/user.ts')['useUserStore']
|
const useUserStore: typeof import('./stores/user')['useUserStore']
|
||||||
const useVideoStore: typeof import('./stores/video.ts')['useVideoStore']
|
const useVideoStore: typeof import('./stores/video')['useVideoStore']
|
||||||
const watch: typeof import('vue')['watch']
|
const watch: typeof import('vue')['watch']
|
||||||
const watchEffect: typeof import('vue')['watchEffect']
|
const watchEffect: typeof import('vue')['watchEffect']
|
||||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||||
@@ -154,21 +154,16 @@ declare module 'vue' {
|
|||||||
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
||||||
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||||
readonly unref: UnwrapRef<typeof import('vue')['unref']>
|
readonly unref: UnwrapRef<typeof import('vue')['unref']>
|
||||||
readonly useAppStore: UnwrapRef<typeof import('./stores')['useAppStore']>
|
|
||||||
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
||||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||||
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
||||||
readonly useFavoriteStore: UnwrapRef<typeof import('./stores')['useFavoriteStore']>
|
|
||||||
readonly useId: UnwrapRef<typeof import('vue')['useId']>
|
readonly useId: UnwrapRef<typeof import('vue')['useId']>
|
||||||
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
|
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
|
||||||
readonly useMessageStore: UnwrapRef<typeof import('./stores/message.ts')['useMessageStore']>
|
|
||||||
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
|
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
|
||||||
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
|
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
|
||||||
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
|
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
|
||||||
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
|
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
|
||||||
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
|
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
|
||||||
readonly useUserStore: UnwrapRef<typeof import('./stores/user.ts')['useUserStore']>
|
|
||||||
readonly useVideoStore: UnwrapRef<typeof import('./stores/video.ts')['useVideoStore']>
|
|
||||||
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
||||||
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
|
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
|
||||||
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
|
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
|
||||||
|
|||||||
+18
@@ -17,6 +17,12 @@ declare module 'vue' {
|
|||||||
VanButton: typeof import('vant/es')['Button']
|
VanButton: typeof import('vant/es')['Button']
|
||||||
VanCell: typeof import('vant/es')['Cell']
|
VanCell: typeof import('vant/es')['Cell']
|
||||||
VanCellGroup: typeof import('vant/es')['CellGroup']
|
VanCellGroup: typeof import('vant/es')['CellGroup']
|
||||||
|
VanCollapse: typeof import('vant/es')['Collapse']
|
||||||
|
VanCollapseItem: typeof import('vant/es')['CollapseItem']
|
||||||
|
VanDatePicker: typeof import('vant/es')['DatePicker']
|
||||||
|
VanDropdownItem: typeof import('vant/es')['DropdownItem']
|
||||||
|
VanDropdownMenu: typeof import('vant/es')['DropdownMenu']
|
||||||
|
VanEmpty: typeof import('vant/es')['Empty']
|
||||||
VanField: typeof import('vant/es')['Field']
|
VanField: typeof import('vant/es')['Field']
|
||||||
VanForm: typeof import('vant/es')['Form']
|
VanForm: typeof import('vant/es')['Form']
|
||||||
VanIcon: typeof import('vant/es')['Icon']
|
VanIcon: typeof import('vant/es')['Icon']
|
||||||
@@ -24,13 +30,25 @@ declare module 'vue' {
|
|||||||
VanList: typeof import('vant/es')['List']
|
VanList: typeof import('vant/es')['List']
|
||||||
VanLoading: typeof import('vant/es')['Loading']
|
VanLoading: typeof import('vant/es')['Loading']
|
||||||
VanNavBar: typeof import('vant/es')['NavBar']
|
VanNavBar: typeof import('vant/es')['NavBar']
|
||||||
|
VanPicker: typeof import('vant/es')['Picker']
|
||||||
VanPopover: typeof import('vant/es')['Popover']
|
VanPopover: typeof import('vant/es')['Popover']
|
||||||
|
VanPopup: typeof import('vant/es')['Popup']
|
||||||
|
VanProgress: typeof import('vant/es')['Progress']
|
||||||
VanPullRefresh: typeof import('vant/es')['PullRefresh']
|
VanPullRefresh: typeof import('vant/es')['PullRefresh']
|
||||||
|
VanRadio: typeof import('vant/es')['Radio']
|
||||||
|
VanRadioGroup: typeof import('vant/es')['RadioGroup']
|
||||||
VanSearch: typeof import('vant/es')['Search']
|
VanSearch: typeof import('vant/es')['Search']
|
||||||
|
VanSidebar: typeof import('vant/es')['Sidebar']
|
||||||
|
VanSidebarItem: typeof import('vant/es')['SidebarItem']
|
||||||
|
VanSkeleton: typeof import('vant/es')['Skeleton']
|
||||||
|
VanSlider: typeof import('vant/es')['Slider']
|
||||||
|
VanSwitch: typeof import('vant/es')['Switch']
|
||||||
VanTab: typeof import('vant/es')['Tab']
|
VanTab: typeof import('vant/es')['Tab']
|
||||||
VanTabbar: typeof import('vant/es')['Tabbar']
|
VanTabbar: typeof import('vant/es')['Tabbar']
|
||||||
VanTabbarItem: typeof import('vant/es')['TabbarItem']
|
VanTabbarItem: typeof import('vant/es')['TabbarItem']
|
||||||
VanTabs: typeof import('vant/es')['Tabs']
|
VanTabs: typeof import('vant/es')['Tabs']
|
||||||
|
VanTag: typeof import('vant/es')['Tag']
|
||||||
|
VanUploader: typeof import('vant/es')['Uploader']
|
||||||
VideoCard: typeof import('./components/video/VideoCard.vue')['default']
|
VideoCard: typeof import('./components/video/VideoCard.vue')['default']
|
||||||
VideoPlayer: typeof import('./components/video/VideoPlayer.vue')['default']
|
VideoPlayer: typeof import('./components/video/VideoPlayer.vue')['default']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,10 +91,10 @@ const loadComments = async (isRefresh = false) => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await commentApi.getList({
|
const res = await commentApi.getCommentList({
|
||||||
videoId: props.videoId,
|
videoId: props.videoId,
|
||||||
page: currentPage.value,
|
pageNum: currentPage.value,
|
||||||
size: 20,
|
pageSize: 20,
|
||||||
sortBy: sortBy.value
|
sortBy: sortBy.value
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ const submitComment = async () => {
|
|||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await commentApi.create({
|
await commentApi.addComment({
|
||||||
videoId: props.videoId,
|
videoId: props.videoId,
|
||||||
content: content.value
|
content: content.value
|
||||||
})
|
})
|
||||||
@@ -154,10 +154,10 @@ const toggleLike = async (comment: Comment) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (comment.isLiked) {
|
if (comment.isLiked) {
|
||||||
await commentApi.unlike(comment.id)
|
await commentApi.unlikeComment(comment.id)
|
||||||
comment.likeCount--
|
comment.likeCount--
|
||||||
} else {
|
} else {
|
||||||
await commentApi.like(comment.id)
|
await commentApi.likeComment(comment.id)
|
||||||
comment.likeCount++
|
comment.likeCount++
|
||||||
}
|
}
|
||||||
comment.isLiked = !comment.isLiked
|
comment.isLiked = !comment.isLiked
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const loadMore = async () => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await danmakuApi.getAll(props.videoId)
|
const data = await danmakuApi.getAllDanmaku(props.videoId)
|
||||||
danmakuList.value = data
|
danmakuList.value = data
|
||||||
finished.value = true
|
finished.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -151,9 +151,12 @@ const goToNotifications = () => {
|
|||||||
// 跳转到个人主页
|
// 跳转到个人主页
|
||||||
const goToProfile = () => {
|
const goToProfile = () => {
|
||||||
showUserPopover.value = false
|
showUserPopover.value = false
|
||||||
if (userStore.userId) {
|
const id = userStore.userId || userStore.userInfo?.id || userStore.authUser?.id
|
||||||
router.push(`/user/${userStore.userId}`)
|
if (!id) {
|
||||||
|
showToast('用户信息加载中,请稍后重试')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
router.push(`/user/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 退出登录
|
// 退出登录
|
||||||
@@ -186,18 +189,20 @@ onMounted(() => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
background: var(--bg-secondary);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.04);
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
max-width: var(--max-content-width);
|
max-width: 1400px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 var(--spacing-md);
|
padding: 0 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-lg);
|
gap: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,12 +218,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
font-weight: bold;
|
font-weight: 800;
|
||||||
background: linear-gradient(to right, var(--primary-color), var(--pink-color));
|
color: #18191c;
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -228,14 +230,23 @@ onMounted(() => {
|
|||||||
|
|
||||||
.search-box {
|
.search-box {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 400px;
|
max-width: 520px;
|
||||||
|
|
||||||
:deep(.van-search) {
|
:deep(.van-search) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
||||||
.van-search__content {
|
.van-search__content {
|
||||||
background: var(--bg-tertiary);
|
background: #f1f2f3;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.van-search__content:hover,
|
||||||
|
.van-field__control:focus + .van-field__body .van-search__content {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #e3e5e7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,14 +254,14 @@ onMounted(() => {
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-md);
|
gap: 16px;
|
||||||
|
|
||||||
.action-icon {
|
.action-icon {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-secondary);
|
color: #61666d;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--primary-color);
|
color: #fb7299;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,9 +271,15 @@ onMounted(() => {
|
|||||||
transition: border-color 0.3s;
|
transition: border-color 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--primary-color);
|
border-color: #fb7299;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.van-button--primary) {
|
||||||
|
background: linear-gradient(135deg, #fb7299, #ff8fab);
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 10px 24px rgba(251, 114, 153, 0.24);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-popover,
|
.message-popover,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<van-tabbar v-model="active" route fixed active-color="#00a1d6" class="app-tabbar">
|
<van-tabbar v-model="active" route fixed active-color="#fb7299" inactive-color="#9499a0" class="app-tabbar">
|
||||||
<van-tabbar-item to="/" icon="home-o">首页</van-tabbar-item>
|
<van-tabbar-item to="/" icon="home-o">首页</van-tabbar-item>
|
||||||
<van-tabbar-item to="/category" icon="apps-o">分类</van-tabbar-item>
|
<van-tabbar-item to="/category" icon="apps-o">分类</van-tabbar-item>
|
||||||
<van-tabbar-item v-if="userStore.isLoggedIn" to="/my/videos" icon="video-o">我的</van-tabbar-item>
|
<van-tabbar-item v-if="userStore.isLoggedIn" to="/my/videos" icon="video-o">我的</van-tabbar-item>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
:src="video.coverUrl || defaultCover"
|
:src="video.coverUrl || defaultCover"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
class="cover-image"
|
class="cover-image"
|
||||||
lazy-load
|
|
||||||
>
|
>
|
||||||
<template v-slot:loading>
|
<template v-slot:loading>
|
||||||
<van-loading type="spinner" size="20" />
|
<van-loading type="spinner" size="20" />
|
||||||
@@ -62,15 +61,22 @@ const handleClick = () => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.video-card {
|
.video-card {
|
||||||
background: #fff;
|
background: transparent;
|
||||||
border-radius: 8px;
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.22s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
|
.cover-image {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-title {
|
||||||
|
color: #fb7299;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +84,10 @@ const handleClick = () => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-top: 56.25%; /* 16:9 */
|
padding-top: 56.25%; /* 16:9 */
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||||
|
|
||||||
.cover-image {
|
.cover-image {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -85,39 +95,41 @@ const handleClick = () => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
transition: transform 0.28s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duration {
|
.duration {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(15, 23, 42, 0.75);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 2px 6px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-count {
|
.view-count {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
left: 8px;
|
left: 8px;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(15, 23, 42, 0.54);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 6px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-info {
|
.video-info {
|
||||||
padding: 10px;
|
padding: 10px 2px 0;
|
||||||
|
|
||||||
.video-title {
|
.video-title {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: #333;
|
color: #18191c;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 9px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
@@ -130,27 +142,28 @@ const handleClick = () => {
|
|||||||
.video-meta {
|
.video-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
color: #999;
|
color: #61666d;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
|
||||||
.author {
|
.author {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 18px;
|
width: 22px;
|
||||||
height: 18px;
|
height: 22px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-stats {
|
.video-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #9499a0;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="video-player">
|
<div class="video-player">
|
||||||
<div ref="playerRef" class="player-container"></div>
|
<video
|
||||||
|
ref="playerRef"
|
||||||
|
class="native-player"
|
||||||
|
:src="src"
|
||||||
|
:poster="poster"
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
@timeupdate="handleTimeUpdate"
|
||||||
|
@ended="emit('ended')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import Artplayer from 'artplayer'
|
|
||||||
import type { Danmaku } from '@/api/danmaku.ts'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
src: string
|
src: string
|
||||||
poster?: string
|
poster?: string
|
||||||
danmakuList?: Danmaku[]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -20,82 +27,30 @@ const emit = defineEmits<{
|
|||||||
ended: []
|
ended: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const playerRef = ref<HTMLElement>()
|
const playerRef = ref<HTMLVideoElement>()
|
||||||
let instance: Artplayer | null = null
|
|
||||||
|
|
||||||
const initPlayer = () => {
|
const handleTimeUpdate = () => {
|
||||||
if (!playerRef.value) return
|
emit('timeupdate', playerRef.value?.currentTime || 0)
|
||||||
|
|
||||||
instance = new Artplayer({
|
|
||||||
container: playerRef.value,
|
|
||||||
url: props.src,
|
|
||||||
poster: props.poster,
|
|
||||||
autoplay: false,
|
|
||||||
pip: true,
|
|
||||||
autoSize: false,
|
|
||||||
autoMini: true,
|
|
||||||
screenshot: true,
|
|
||||||
setting: true,
|
|
||||||
loop: false,
|
|
||||||
flip: true,
|
|
||||||
playbackRate: true,
|
|
||||||
aspectRatio: true,
|
|
||||||
fullscreen: true,
|
|
||||||
fullscreenWeb: true,
|
|
||||||
subtitleOffset: true,
|
|
||||||
miniProgressBar: true,
|
|
||||||
mutex: true,
|
|
||||||
backdrop: true,
|
|
||||||
playsInline: true,
|
|
||||||
autoPlayback: true,
|
|
||||||
airplay: true,
|
|
||||||
theme: '#00a1d6',
|
|
||||||
lang: navigator.language.toLowerCase(),
|
|
||||||
moreVideoAttr: {
|
|
||||||
crossOrigin: 'anonymous'
|
|
||||||
},
|
|
||||||
customType: {
|
|
||||||
danmaku: (video, url) => {
|
|
||||||
// 弹幕插件集成
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
instance.on('video:timeupdate', () => {
|
|
||||||
emit('timeupdate', instance?.video?.currentTime || 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
instance.on('video:ended', () => {
|
|
||||||
emit('ended')
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.src, () => {
|
watch(() => props.src, () => {
|
||||||
if (instance) {
|
if (!playerRef.value) return
|
||||||
instance.switchUrl(props.src, props.poster)
|
playerRef.value.load()
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
initPlayer()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (instance) {
|
|
||||||
instance.destroy()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.video-player {
|
.video-player {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
background: #000;
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
.player-container {
|
.native-player {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
}
|
display: block;
|
||||||
|
background: #000;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Mock 模式使用说明
|
||||||
|
|
||||||
|
本项目支持 Mock 模式,可在**无需后端服务**的情况下运行前端界面,方便论文截图和界面展示。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend/video-frontend-web
|
||||||
|
npm install
|
||||||
|
# 或
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动 Mock 模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:mock
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后访问 http://localhost:5173 即可看到完整的界面。
|
||||||
|
|
||||||
|
### 3. 登录说明
|
||||||
|
|
||||||
|
Mock 模式下,使用**任意用户名和密码**即可登录,验证码输入任意内容即可。
|
||||||
|
|
||||||
|
建议使用:
|
||||||
|
- 用户名:`testuser`
|
||||||
|
- 密码:任意
|
||||||
|
- 验证码:任意
|
||||||
|
|
||||||
|
## 可展示的界面
|
||||||
|
|
||||||
|
Mock 模式下可以展示以下界面:
|
||||||
|
|
||||||
|
### 认证相关
|
||||||
|
- **登录界面** (`/login`) - 图4.2 系统登录界面
|
||||||
|
- **注册界面** (`/register`) - 图4.3 系统注册界面
|
||||||
|
|
||||||
|
### 首页与推荐
|
||||||
|
- **首页热门推荐** (`/`) - 图4.10 首页热门推荐展示界面
|
||||||
|
- 展示推荐视频列表
|
||||||
|
- 分类导航
|
||||||
|
|
||||||
|
### 用户相关
|
||||||
|
- **用户个人信息管理** (`/user/:id`) - 图4.4 用户个人信息管理界面
|
||||||
|
- **关注列表** (`/user/:id/following`) - 图4.5 用户关注关系管理界面
|
||||||
|
- **粉丝列表** (`/user/:id/followers`)
|
||||||
|
- **编辑资料** (`/settings/profile`)
|
||||||
|
|
||||||
|
### 视频相关
|
||||||
|
- **视频上传界面** (`/upload`) - 图4.6 视频上传界面
|
||||||
|
- **视频播放界面** (`/video/:id`) - 图4.7 视频播放界面
|
||||||
|
- 视频播放器
|
||||||
|
- 评论功能(图4.9)
|
||||||
|
- 相关推荐
|
||||||
|
- **我的视频** (`/my/videos`) - 图4.8 视频信息管理界面
|
||||||
|
- **我的收藏** (`/my/favorites`)
|
||||||
|
|
||||||
|
### 搜索
|
||||||
|
- **搜索页面** (`/search`)
|
||||||
|
|
||||||
|
## Mock 数据说明
|
||||||
|
|
||||||
|
Mock 数据位于 `src/mock/data.ts` 文件中,包含:
|
||||||
|
|
||||||
|
- 3 个模拟用户
|
||||||
|
- 6 个模拟视频
|
||||||
|
- 10 个视频分类
|
||||||
|
- 模拟评论、弹幕、关注关系等
|
||||||
|
|
||||||
|
如需修改 Mock 数据,直接编辑该文件即可。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. Mock 模式下所有数据都是模拟的,刷新页面后会重置
|
||||||
|
2. 登录状态使用 localStorage 持久化,刷新后仍保持登录
|
||||||
|
3. 图片使用 picsum.photos 提供的占位图
|
||||||
|
4. 视频使用公共测试视频
|
||||||
|
|
||||||
|
## 论文截图建议
|
||||||
|
|
||||||
|
为论文截图时,建议按以下顺序操作:
|
||||||
|
|
||||||
|
1. **启动 Mock 模式**:`npm run dev:mock`
|
||||||
|
2. **登录界面截图**:访问 `/login`,截图后登录
|
||||||
|
3. **首页截图**:查看热门推荐界面
|
||||||
|
4. **用户主页截图**:点击任意用户头像进入用户主页
|
||||||
|
5. **视频详情截图**:点击任意视频进入播放页面
|
||||||
|
6. **评论界面截图**:在视频详情页的评论标签
|
||||||
|
7. **上传界面截图**:访问 `/upload`
|
||||||
|
8. **个人中心截图**:访问 `/settings/profile`
|
||||||
|
|
||||||
|
## 自定义 Mock 数据
|
||||||
|
|
||||||
|
如需自定义 Mock 数据用于截图,可修改 `src/mock/data.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 修改视频标题、描述
|
||||||
|
export const mockVideos = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '自定义标题',
|
||||||
|
description: '自定义描述',
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 修改用户信息
|
||||||
|
export const mockUsers = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
nickname: '自定义用户名',
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关闭 Mock 模式
|
||||||
|
|
||||||
|
正常运行模式(连接真实后端):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
此时需要在 `.env` 文件中配置正确的后端 API 地址。
|
||||||
@@ -0,0 +1,728 @@
|
|||||||
|
/**
|
||||||
|
* Mock 数据 - 用于无后端情况下的前端展示
|
||||||
|
*/
|
||||||
|
|
||||||
|
const avatar = (name: string, bg: string, fg = '#ffffff') =>
|
||||||
|
`data:image/svg+xml;utf8,${encodeURIComponent(`
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${bg}"/>
|
||||||
|
<stop offset="100%" stop-color="#0f172a"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="120" height="120" rx="60" fill="url(#g)"/>
|
||||||
|
<text x="50%" y="52%" dominant-baseline="middle" text-anchor="middle" fill="${fg}" font-size="34" font-family="Segoe UI, Arial">${name}</text>
|
||||||
|
</svg>
|
||||||
|
`)}`
|
||||||
|
|
||||||
|
const cover = (title: string, sub: string, start: string, end: string) =>
|
||||||
|
`data:image/svg+xml;utf8,${encodeURIComponent(`
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${start}"/>
|
||||||
|
<stop offset="100%" stop-color="${end}"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="640" height="360" rx="28" fill="url(#bg)"/>
|
||||||
|
<circle cx="548" cy="88" r="72" fill="rgba(255,255,255,0.12)"/>
|
||||||
|
<circle cx="92" cy="290" r="96" fill="rgba(255,255,255,0.08)"/>
|
||||||
|
<text x="48" y="92" fill="rgba(255,255,255,0.78)" font-size="22" font-family="Segoe UI, Arial">${sub}</text>
|
||||||
|
<text x="48" y="156" fill="#ffffff" font-size="38" font-weight="700" font-family="Segoe UI, Arial">${title}</text>
|
||||||
|
<rect x="48" y="248" width="134" height="42" rx="21" fill="rgba(255,255,255,0.18)"/>
|
||||||
|
<text x="115" y="275" text-anchor="middle" fill="#ffffff" font-size="18" font-family="Segoe UI, Arial">HAOVP</text>
|
||||||
|
<polygon points="566,278 566,324 610,301" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
`)}`
|
||||||
|
|
||||||
|
const av1 = avatar('明', '#3b82f6')
|
||||||
|
const av2 = avatar('科', '#0ea5e9')
|
||||||
|
const av3 = avatar('美', '#ec4899')
|
||||||
|
const av4 = avatar('王', '#8b5cf6')
|
||||||
|
const av5 = avatar('李', '#14b8a6')
|
||||||
|
const av6 = avatar('张', '#f97316')
|
||||||
|
|
||||||
|
// 模拟用户数据
|
||||||
|
export const mockUsers = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
nickname: '视频达人小明',
|
||||||
|
avatar: av1,
|
||||||
|
gender: 1,
|
||||||
|
birthday: '1998-05-15',
|
||||||
|
signature: '专注前端工程化、系统设计与实战经验分享',
|
||||||
|
region: '北京',
|
||||||
|
followerCount: 12580,
|
||||||
|
followingCount: 256,
|
||||||
|
videoCount: 89,
|
||||||
|
likeCount: 45600,
|
||||||
|
level: 5,
|
||||||
|
vipLevel: 2,
|
||||||
|
vipExpireTime: null,
|
||||||
|
createTime: '2023-01-15 10:30:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
nickname: '科技爱好者',
|
||||||
|
avatar: av2,
|
||||||
|
gender: 1,
|
||||||
|
birthday: '1995-08-20',
|
||||||
|
signature: '关注微服务架构、容器化部署与后端治理',
|
||||||
|
region: '上海',
|
||||||
|
followerCount: 8920,
|
||||||
|
followingCount: 128,
|
||||||
|
videoCount: 45,
|
||||||
|
likeCount: 23400,
|
||||||
|
level: 4,
|
||||||
|
vipLevel: 1,
|
||||||
|
vipExpireTime: null,
|
||||||
|
createTime: '2023-02-20 14:20:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
nickname: '美食博主小美',
|
||||||
|
avatar: av3,
|
||||||
|
gender: 2,
|
||||||
|
birthday: '2000-03-10',
|
||||||
|
signature: '记录日常烟火气,分享简单又治愈的家庭料理',
|
||||||
|
region: '广州',
|
||||||
|
followerCount: 23450,
|
||||||
|
followingCount: 389,
|
||||||
|
videoCount: 156,
|
||||||
|
likeCount: 89000,
|
||||||
|
level: 6,
|
||||||
|
vipLevel: 3,
|
||||||
|
vipExpireTime: null,
|
||||||
|
createTime: '2022-12-01 09:00:00'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 模拟视频数据
|
||||||
|
export const mockVideos = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
title: 'Vue3 + TypeScript 企业级项目实战教程',
|
||||||
|
description: '本教程从项目初始化、组件封装、状态管理到部署上线,完整展示前端工程化开发流程。',
|
||||||
|
coverUrl: cover('Vue3 + TS', '前端工程化实战', '#2563eb', '#0f766e'),
|
||||||
|
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||||
|
duration: 1845,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: 'mp4',
|
||||||
|
size: 156000000,
|
||||||
|
categoryId: 1,
|
||||||
|
status: 1,
|
||||||
|
likeCount: 1256,
|
||||||
|
coinCount: 89,
|
||||||
|
favoriteCount: 456,
|
||||||
|
commentCount: 123,
|
||||||
|
viewCount: 15680,
|
||||||
|
shareCount: 45,
|
||||||
|
danmakuCount: 567,
|
||||||
|
isRecommend: 1,
|
||||||
|
isTop: 1,
|
||||||
|
publishTime: '2024-01-15 10:30:00',
|
||||||
|
createTime: '2024-01-15 10:30:00',
|
||||||
|
updateTime: '2024-01-15 10:30:00',
|
||||||
|
tagIds: [1, 2, 3],
|
||||||
|
tagNames: ['Vue3', 'TypeScript', '前端开发'],
|
||||||
|
categoryName: '科技',
|
||||||
|
userNickname: '视频达人小明',
|
||||||
|
userAvatar: av1,
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false,
|
||||||
|
isCoined: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
userId: 2,
|
||||||
|
title: 'Spring Cloud 微服务架构深度解析',
|
||||||
|
description: '围绕注册发现、配置中心、网关、限流熔断等核心模块讲解微服务架构实现思路。',
|
||||||
|
coverUrl: cover('Spring Cloud', '微服务架构解析', '#1d4ed8', '#7c3aed'),
|
||||||
|
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||||
|
duration: 2340,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: 'mp4',
|
||||||
|
size: 234000000,
|
||||||
|
categoryId: 1,
|
||||||
|
status: 1,
|
||||||
|
likeCount: 892,
|
||||||
|
coinCount: 67,
|
||||||
|
favoriteCount: 234,
|
||||||
|
commentCount: 89,
|
||||||
|
viewCount: 8950,
|
||||||
|
shareCount: 34,
|
||||||
|
danmakuCount: 345,
|
||||||
|
isRecommend: 1,
|
||||||
|
isTop: 0,
|
||||||
|
publishTime: '2024-01-14 15:20:00',
|
||||||
|
createTime: '2024-01-14 15:20:00',
|
||||||
|
updateTime: '2024-01-14 15:20:00',
|
||||||
|
tagIds: [4, 5],
|
||||||
|
tagNames: ['Java', '微服务'],
|
||||||
|
categoryName: '科技',
|
||||||
|
userNickname: '科技爱好者',
|
||||||
|
userAvatar: av2,
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false,
|
||||||
|
isCoined: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
userId: 3,
|
||||||
|
title: '家常红烧肉做法 | 肥而不腻入口即化',
|
||||||
|
description: '从食材处理、炒糖色到火候控制,完整展示一道经典家常菜的制作过程。',
|
||||||
|
coverUrl: cover('红烧肉做法', '家庭料理记录', '#f97316', '#ef4444'),
|
||||||
|
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||||
|
duration: 580,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: 'mp4',
|
||||||
|
size: 78000000,
|
||||||
|
categoryId: 2,
|
||||||
|
status: 1,
|
||||||
|
likeCount: 2345,
|
||||||
|
coinCount: 156,
|
||||||
|
favoriteCount: 789,
|
||||||
|
commentCount: 234,
|
||||||
|
viewCount: 45600,
|
||||||
|
shareCount: 123,
|
||||||
|
danmakuCount: 890,
|
||||||
|
isRecommend: 1,
|
||||||
|
isTop: 1,
|
||||||
|
publishTime: '2024-01-16 12:00:00',
|
||||||
|
createTime: '2024-01-16 12:00:00',
|
||||||
|
updateTime: '2024-01-16 12:00:00',
|
||||||
|
tagIds: [6, 7],
|
||||||
|
tagNames: ['美食', '烹饪教程'],
|
||||||
|
categoryName: '美食',
|
||||||
|
userNickname: '美食博主小美',
|
||||||
|
userAvatar: av3,
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false,
|
||||||
|
isCoined: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
userId: 1,
|
||||||
|
title: 'React 18 新特性详解与实战',
|
||||||
|
description: '结合实际案例讲解并发特性、自动批处理和新版开发体验。',
|
||||||
|
coverUrl: cover('React 18', '并发特性与升级实践', '#2563eb', '#ec4899'),
|
||||||
|
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||||
|
duration: 1650,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: 'mp4',
|
||||||
|
size: 145000000,
|
||||||
|
categoryId: 1,
|
||||||
|
status: 1,
|
||||||
|
likeCount: 567,
|
||||||
|
coinCount: 45,
|
||||||
|
favoriteCount: 189,
|
||||||
|
commentCount: 67,
|
||||||
|
viewCount: 6780,
|
||||||
|
shareCount: 23,
|
||||||
|
danmakuCount: 234,
|
||||||
|
isRecommend: 0,
|
||||||
|
isTop: 0,
|
||||||
|
publishTime: '2024-01-13 09:15:00',
|
||||||
|
createTime: '2024-01-13 09:15:00',
|
||||||
|
updateTime: '2024-01-13 09:15:00',
|
||||||
|
tagIds: [8, 9],
|
||||||
|
tagNames: ['React', '前端开发'],
|
||||||
|
categoryName: '科技',
|
||||||
|
userNickname: '视频达人小明',
|
||||||
|
userAvatar: av1,
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false,
|
||||||
|
isCoined: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
userId: 2,
|
||||||
|
title: 'Docker 容器化部署实战指南',
|
||||||
|
description: '从镜像构建到容器编排,完整讲解现代化应用部署的基础能力。',
|
||||||
|
coverUrl: cover('Docker 部署', '容器化实践指南', '#0f766e', '#0891b2'),
|
||||||
|
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||||
|
duration: 1980,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: 'mp4',
|
||||||
|
size: 178000000,
|
||||||
|
categoryId: 1,
|
||||||
|
status: 1,
|
||||||
|
likeCount: 678,
|
||||||
|
coinCount: 56,
|
||||||
|
favoriteCount: 267,
|
||||||
|
commentCount: 78,
|
||||||
|
viewCount: 7890,
|
||||||
|
shareCount: 45,
|
||||||
|
danmakuCount: 345,
|
||||||
|
isRecommend: 1,
|
||||||
|
isTop: 0,
|
||||||
|
publishTime: '2024-01-12 14:30:00',
|
||||||
|
createTime: '2024-01-12 14:30:00',
|
||||||
|
updateTime: '2024-01-12 14:30:00',
|
||||||
|
tagIds: [10, 11],
|
||||||
|
tagNames: ['Docker', 'DevOps'],
|
||||||
|
categoryName: '科技',
|
||||||
|
userNickname: '科技爱好者',
|
||||||
|
userAvatar: av2,
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false,
|
||||||
|
isCoined: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
userId: 3,
|
||||||
|
title: '新手必看!10道家常菜教学',
|
||||||
|
description: '适合厨房新手的家常菜合集,食材易得、步骤清晰、成品稳定。',
|
||||||
|
coverUrl: cover('10道家常菜', '新手厨房友好', '#fb7185', '#f97316'),
|
||||||
|
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||||
|
duration: 1200,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: 'mp4',
|
||||||
|
size: 156000000,
|
||||||
|
categoryId: 2,
|
||||||
|
status: 1,
|
||||||
|
likeCount: 3456,
|
||||||
|
coinCount: 234,
|
||||||
|
favoriteCount: 1234,
|
||||||
|
commentCount: 456,
|
||||||
|
viewCount: 67800,
|
||||||
|
shareCount: 567,
|
||||||
|
danmakuCount: 1234,
|
||||||
|
isRecommend: 1,
|
||||||
|
isTop: 1,
|
||||||
|
publishTime: '2024-01-17 08:00:00',
|
||||||
|
createTime: '2024-01-17 08:00:00',
|
||||||
|
updateTime: '2024-01-17 08:00:00',
|
||||||
|
tagIds: [6, 7],
|
||||||
|
tagNames: ['美食', '烹饪教程'],
|
||||||
|
categoryName: '美食',
|
||||||
|
userNickname: '美食博主小美',
|
||||||
|
userAvatar: av3,
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false,
|
||||||
|
isCoined: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: 1,
|
||||||
|
title: '前端性能优化全流程拆解',
|
||||||
|
description: '从首屏加载、资源分包到组件渲染优化,系统讲解常见性能瓶颈与解决思路。',
|
||||||
|
coverUrl: cover('性能优化', '加载速度与体验提升', '#0ea5e9', '#2563eb'),
|
||||||
|
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||||
|
duration: 1420,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: 'mp4',
|
||||||
|
size: 132000000,
|
||||||
|
categoryId: 1,
|
||||||
|
status: 1,
|
||||||
|
likeCount: 934,
|
||||||
|
coinCount: 66,
|
||||||
|
favoriteCount: 318,
|
||||||
|
commentCount: 74,
|
||||||
|
viewCount: 12540,
|
||||||
|
shareCount: 31,
|
||||||
|
danmakuCount: 266,
|
||||||
|
isRecommend: 1,
|
||||||
|
isTop: 0,
|
||||||
|
publishTime: '2024-01-18 11:20:00',
|
||||||
|
createTime: '2024-01-18 11:20:00',
|
||||||
|
updateTime: '2024-01-18 11:20:00',
|
||||||
|
tagIds: [1, 3],
|
||||||
|
tagNames: ['Vue3', '性能优化'],
|
||||||
|
categoryName: '科技',
|
||||||
|
userNickname: '视频达人小明',
|
||||||
|
userAvatar: av1,
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false,
|
||||||
|
isCoined: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
userId: 2,
|
||||||
|
title: '网关鉴权与路由转发实现思路',
|
||||||
|
description: '结合统一入口、JWT 校验与下游服务路由,拆解典型平台网关实现。',
|
||||||
|
coverUrl: cover('网关鉴权', '统一入口层设计', '#1d4ed8', '#0f766e'),
|
||||||
|
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||||
|
duration: 1560,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: 'mp4',
|
||||||
|
size: 166000000,
|
||||||
|
categoryId: 1,
|
||||||
|
status: 1,
|
||||||
|
likeCount: 721,
|
||||||
|
coinCount: 49,
|
||||||
|
favoriteCount: 201,
|
||||||
|
commentCount: 58,
|
||||||
|
viewCount: 10880,
|
||||||
|
shareCount: 21,
|
||||||
|
danmakuCount: 198,
|
||||||
|
isRecommend: 1,
|
||||||
|
isTop: 0,
|
||||||
|
publishTime: '2024-01-19 14:00:00',
|
||||||
|
createTime: '2024-01-19 14:00:00',
|
||||||
|
updateTime: '2024-01-19 14:00:00',
|
||||||
|
tagIds: [4, 5],
|
||||||
|
tagNames: ['Spring Cloud', '网关'],
|
||||||
|
categoryName: '科技',
|
||||||
|
userNickname: '科技爱好者',
|
||||||
|
userAvatar: av2,
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false,
|
||||||
|
isCoined: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
userId: 3,
|
||||||
|
title: '空气炸锅也能做出脆皮鸡翅',
|
||||||
|
description: '简单调味和温度控制就能做出外酥里嫩的家常鸡翅。',
|
||||||
|
coverUrl: cover('脆皮鸡翅', '空气炸锅料理', '#fb7185', '#f97316'),
|
||||||
|
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||||
|
duration: 430,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: 'mp4',
|
||||||
|
size: 54000000,
|
||||||
|
categoryId: 2,
|
||||||
|
status: 1,
|
||||||
|
likeCount: 1658,
|
||||||
|
coinCount: 97,
|
||||||
|
favoriteCount: 603,
|
||||||
|
commentCount: 111,
|
||||||
|
viewCount: 32400,
|
||||||
|
shareCount: 62,
|
||||||
|
danmakuCount: 518,
|
||||||
|
isRecommend: 1,
|
||||||
|
isTop: 0,
|
||||||
|
publishTime: '2024-01-20 18:40:00',
|
||||||
|
createTime: '2024-01-20 18:40:00',
|
||||||
|
updateTime: '2024-01-20 18:40:00',
|
||||||
|
tagIds: [6],
|
||||||
|
tagNames: ['美食'],
|
||||||
|
categoryName: '美食',
|
||||||
|
userNickname: '美食博主小美',
|
||||||
|
userAvatar: av3,
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false,
|
||||||
|
isCoined: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
userId: 1,
|
||||||
|
title: '毕业设计前端界面排版建议',
|
||||||
|
description: '针对系统展示、论文截图与交互说明,整理一套更适合毕业设计答辩的前端排版思路。',
|
||||||
|
coverUrl: cover('界面排版', '毕业设计答辩向', '#3b82f6', '#ec4899'),
|
||||||
|
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||||
|
duration: 980,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
format: 'mp4',
|
||||||
|
size: 98000000,
|
||||||
|
categoryId: 9,
|
||||||
|
status: 1,
|
||||||
|
likeCount: 812,
|
||||||
|
coinCount: 58,
|
||||||
|
favoriteCount: 277,
|
||||||
|
commentCount: 44,
|
||||||
|
viewCount: 9640,
|
||||||
|
shareCount: 19,
|
||||||
|
danmakuCount: 145,
|
||||||
|
isRecommend: 1,
|
||||||
|
isTop: 0,
|
||||||
|
publishTime: '2024-01-21 09:50:00',
|
||||||
|
createTime: '2024-01-21 09:50:00',
|
||||||
|
updateTime: '2024-01-21 09:50:00',
|
||||||
|
tagIds: [3],
|
||||||
|
tagNames: ['论文'],
|
||||||
|
categoryName: '知识',
|
||||||
|
userNickname: '视频达人小明',
|
||||||
|
userAvatar: av1,
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false,
|
||||||
|
isCoined: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 模拟分类数据
|
||||||
|
export const mockCategories = [
|
||||||
|
{ id: 1, name: '科技', parentId: 0, sort: 1, icon: 'tech' },
|
||||||
|
{ id: 2, name: '美食', parentId: 0, sort: 2, icon: 'food' },
|
||||||
|
{ id: 3, name: '游戏', parentId: 0, sort: 3, icon: 'game' },
|
||||||
|
{ id: 4, name: '音乐', parentId: 0, sort: 4, icon: 'music' },
|
||||||
|
{ id: 5, name: '舞蹈', parentId: 0, sort: 5, icon: 'dance' },
|
||||||
|
{ id: 6, name: '生活', parentId: 0, sort: 6, icon: 'life' },
|
||||||
|
{ id: 7, name: '动画', parentId: 0, sort: 7, icon: 'anime' },
|
||||||
|
{ id: 8, name: '影视', parentId: 0, sort: 8, icon: 'movie' },
|
||||||
|
{ id: 9, name: '知识', parentId: 0, sort: 9, icon: 'knowledge' },
|
||||||
|
{ id: 10, name: '运动', parentId: 0, sort: 10, icon: 'sport' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 模拟评论数据
|
||||||
|
export const mockComments = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
videoId: 1,
|
||||||
|
userId: 2,
|
||||||
|
parentId: 0,
|
||||||
|
rootId: 0,
|
||||||
|
content: '讲得很清楚,跟着做一遍后整个项目结构顺了很多。',
|
||||||
|
likeCount: 156,
|
||||||
|
replyCount: 5,
|
||||||
|
isTop: 1,
|
||||||
|
createTime: '2024-01-16 14:30:00',
|
||||||
|
userNickname: '科技爱好者',
|
||||||
|
userAvatar: av2,
|
||||||
|
isLiked: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
videoId: 1,
|
||||||
|
userId: 3,
|
||||||
|
parentId: 0,
|
||||||
|
rootId: 0,
|
||||||
|
content: '想问下这个项目结构在实际毕业设计里也适用吗?',
|
||||||
|
likeCount: 89,
|
||||||
|
replyCount: 3,
|
||||||
|
isTop: 0,
|
||||||
|
createTime: '2024-01-16 15:20:00',
|
||||||
|
userNickname: '美食博主小美',
|
||||||
|
userAvatar: av3,
|
||||||
|
isLiked: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
videoId: 1,
|
||||||
|
userId: 1,
|
||||||
|
parentId: 0,
|
||||||
|
rootId: 0,
|
||||||
|
content: 'TypeScript 配置部分后面会再单独补一集展开讲。',
|
||||||
|
likeCount: 45,
|
||||||
|
replyCount: 2,
|
||||||
|
isTop: 0,
|
||||||
|
createTime: '2024-01-16 16:00:00',
|
||||||
|
userNickname: '视频达人小明',
|
||||||
|
userAvatar: av1,
|
||||||
|
isLiked: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
videoId: 1,
|
||||||
|
userId: 2,
|
||||||
|
parentId: 0,
|
||||||
|
rootId: 0,
|
||||||
|
content: 'Pinia 状态持久化和登录态联动那里很实用。',
|
||||||
|
likeCount: 34,
|
||||||
|
replyCount: 1,
|
||||||
|
isTop: 0,
|
||||||
|
createTime: '2024-01-16 17:30:00',
|
||||||
|
userNickname: '科技爱好者',
|
||||||
|
userAvatar: av2,
|
||||||
|
isLiked: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
videoId: 1,
|
||||||
|
userId: 3,
|
||||||
|
parentId: 0,
|
||||||
|
rootId: 0,
|
||||||
|
content: '内容完整,适合拿来做系统实现部分的参考。',
|
||||||
|
likeCount: 23,
|
||||||
|
replyCount: 0,
|
||||||
|
isTop: 0,
|
||||||
|
createTime: '2024-01-17 09:00:00',
|
||||||
|
userNickname: '美食博主小美',
|
||||||
|
userAvatar: av3,
|
||||||
|
isLiked: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 模拟关注列表
|
||||||
|
export const mockFollowing = [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
nickname: '科技爱好者',
|
||||||
|
avatar: av2,
|
||||||
|
signature: '科技改变生活',
|
||||||
|
followerCount: 8920,
|
||||||
|
followingCount: 128,
|
||||||
|
isFollowed: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
nickname: '美食博主小美',
|
||||||
|
avatar: av3,
|
||||||
|
signature: '唯美食与爱不可辜负',
|
||||||
|
followerCount: 23450,
|
||||||
|
followingCount: 389,
|
||||||
|
isFollowed: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 模拟粉丝列表
|
||||||
|
export const mockFollowers = [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
nickname: '程序员小王',
|
||||||
|
avatar: av4,
|
||||||
|
signature: '热爱编程,也热爱记录生活',
|
||||||
|
followerCount: 1200,
|
||||||
|
followingCount: 89,
|
||||||
|
isFollowed: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
nickname: '设计师小李',
|
||||||
|
avatar: av5,
|
||||||
|
signature: '关注界面体验与视觉传达',
|
||||||
|
followerCount: 3450,
|
||||||
|
followingCount: 156,
|
||||||
|
isFollowed: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
nickname: '产品经理小张',
|
||||||
|
avatar: av6,
|
||||||
|
signature: '用产品思维看技术落地',
|
||||||
|
followerCount: 2100,
|
||||||
|
followingCount: 234,
|
||||||
|
isFollowed: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 模拟收藏夹
|
||||||
|
export const mockFavorites = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
name: '默认收藏夹',
|
||||||
|
description: '平时常看的实用内容',
|
||||||
|
coverUrl: cover('默认收藏夹', '内容整理', '#2563eb', '#7c3aed'),
|
||||||
|
visibility: 1,
|
||||||
|
videoCount: 12,
|
||||||
|
createTime: '2024-01-01 00:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
userId: 1,
|
||||||
|
name: '学习资料',
|
||||||
|
description: '项目开发与论文写作参考内容',
|
||||||
|
coverUrl: cover('学习资料', '系统实现参考', '#0f766e', '#0891b2'),
|
||||||
|
visibility: 1,
|
||||||
|
videoCount: 25,
|
||||||
|
createTime: '2024-01-05 10:30:00'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 模拟弹幕数据
|
||||||
|
export const mockDanmaku = [
|
||||||
|
{ id: 1, videoId: 1, userId: 2, content: '这段讲得太清楚了', color: '#ffffff', position: 0, time: 5, fontSize: 25, createTime: '2024-01-16 10:00:00' },
|
||||||
|
{ id: 2, videoId: 1, userId: 3, content: '前端同学狂喜', color: '#00a1d6', position: 0, time: 15, fontSize: 25, createTime: '2024-01-16 10:01:00' },
|
||||||
|
{ id: 3, videoId: 1, userId: 4, content: '收藏了', color: '#fb7299', position: 0, time: 25, fontSize: 25, createTime: '2024-01-16 10:02:00' },
|
||||||
|
{ id: 4, videoId: 1, userId: 5, content: '这个结构很适合毕业设计', color: '#ffffff', position: 0, time: 35, fontSize: 25, createTime: '2024-01-16 10:03:00' },
|
||||||
|
{ id: 5, videoId: 1, userId: 6, content: '继续更新', color: '#ffffff', position: 0, time: 45, fontSize: 25, createTime: '2024-01-16 10:04:00' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 验证码
|
||||||
|
export const mockCaptcha = {
|
||||||
|
captchaKey: 'captcha-key-' + 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="#f4f7fb"/>
|
||||||
|
<path d="M8 28 C22 8, 36 35, 52 12 S84 34, 112 10" stroke="#cbd5e1" stroke-width="2" fill="none"/>
|
||||||
|
<path d="M10 10 L22 18 M36 8 L48 15 M74 27 L86 16 M94 12 L106 24" stroke="#dbeafe" stroke-width="2"/>
|
||||||
|
<text x="18" y="28" fill="#1d4ed8" font-size="24" font-family="Georgia" transform="rotate(-8 18 28)">1</text>
|
||||||
|
<text x="42" y="29" fill="#0f766e" font-size="24" font-family="Georgia" transform="rotate(7 42 29)">2</text>
|
||||||
|
<text x="66" y="28" fill="#be185d" font-size="24" font-family="Georgia" transform="rotate(-6 66 28)">3</text>
|
||||||
|
<text x="90" y="29" fill="#7c3aed" font-size="24" font-family="Georgia" transform="rotate(8 90 29)">4</text>
|
||||||
|
</svg>
|
||||||
|
`)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前登录用户
|
||||||
|
export const mockCurrentUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
phone: '138****8888',
|
||||||
|
status: 1,
|
||||||
|
lastLoginTime: '2024-01-17 10:00:00',
|
||||||
|
lastLoginIp: '127.0.0.1',
|
||||||
|
roles: ['user'],
|
||||||
|
permissions: []
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockMessages = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
senderId: 2,
|
||||||
|
senderNickname: '科技爱好者',
|
||||||
|
senderAvatar: av2,
|
||||||
|
receiverId: 1,
|
||||||
|
receiverNickname: '视频达人小明',
|
||||||
|
receiverAvatar: av1,
|
||||||
|
content: '你上次那个前端工程化视频讲得不错,后面还会更新网关相关内容吗?',
|
||||||
|
isRead: 0,
|
||||||
|
createTime: '2024-01-20 18:10:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
senderId: 3,
|
||||||
|
senderNickname: '美食博主小美',
|
||||||
|
senderAvatar: av3,
|
||||||
|
receiverId: 1,
|
||||||
|
receiverNickname: '视频达人小明',
|
||||||
|
receiverAvatar: av1,
|
||||||
|
content: '片头动画做得很精致,想交流一下剪辑模板。',
|
||||||
|
isRead: 1,
|
||||||
|
createTime: '2024-01-21 09:30:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
senderId: 1,
|
||||||
|
senderNickname: '视频达人小明',
|
||||||
|
senderAvatar: av1,
|
||||||
|
receiverId: 2,
|
||||||
|
receiverNickname: '科技爱好者',
|
||||||
|
receiverAvatar: av2,
|
||||||
|
content: '会更新的,准备把认证服务和推荐服务放在下一期里。',
|
||||||
|
isRead: 1,
|
||||||
|
createTime: '2024-01-21 10:00:00'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const mockNotifications = [
|
||||||
|
{ id: 1, type: 1, content: '科技爱好者 赞了你的视频《Vue3 + TypeScript 企业级项目实战教程》', relatedId: 1, isRead: 0, createTime: '2024-01-21 13:20:00' },
|
||||||
|
{ id: 2, type: 2, content: '美食博主小美 评论了你的视频:内容完整,适合系统实现参考。', relatedId: 2, isRead: 0, createTime: '2024-01-21 11:10:00' },
|
||||||
|
{ id: 3, type: 3, content: '程序员小王 关注了你', relatedId: 3, isRead: 1, createTime: '2024-01-20 22:40:00' },
|
||||||
|
{ id: 4, type: 4, content: '系统通知:你的账号已完成创作者认证。', relatedId: 4, isRead: 1, createTime: '2024-01-19 09:00:00' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const mockSettings = {
|
||||||
|
privacyLevel: 1,
|
||||||
|
commentNotify: 1,
|
||||||
|
likeNotify: 1,
|
||||||
|
followNotify: 1,
|
||||||
|
systemNotify: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页辅助函数
|
||||||
|
export function paginate<T>(list: T[], page: number = 1, size: number = 20): { records: T[]; total: number; size: number; current: number; pages: number } {
|
||||||
|
const start = (page - 1) * size
|
||||||
|
const records = list.slice(start, start + size)
|
||||||
|
return {
|
||||||
|
records,
|
||||||
|
total: list.length,
|
||||||
|
size,
|
||||||
|
current: page,
|
||||||
|
pages: Math.ceil(list.length / size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
/**
|
||||||
|
* Mock 拦截器 - 拦截所有 API 请求并返回模拟数据
|
||||||
|
* 用于无后端情况下的前端界面展示
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
mockUsers,
|
||||||
|
mockVideos,
|
||||||
|
mockCategories,
|
||||||
|
mockComments,
|
||||||
|
mockFollowing,
|
||||||
|
mockFollowers,
|
||||||
|
mockFavorites,
|
||||||
|
mockDanmaku,
|
||||||
|
mockCaptcha,
|
||||||
|
mockCurrentUser,
|
||||||
|
mockMessages,
|
||||||
|
mockNotifications,
|
||||||
|
mockSettings,
|
||||||
|
paginate
|
||||||
|
} from './data'
|
||||||
|
|
||||||
|
// Mock 存储状态
|
||||||
|
const mockStore = {
|
||||||
|
isLoggedIn: true,
|
||||||
|
currentUserId: 1,
|
||||||
|
videos: [...mockVideos],
|
||||||
|
comments: [...mockComments],
|
||||||
|
userVideos: { 1: [...mockVideos.filter(v => v.userId === 1)], 2: [...mockVideos.filter(v => v.userId === 2)], 3: [...mockVideos.filter(v => v.userId === 3)] },
|
||||||
|
messages: [...mockMessages],
|
||||||
|
notifications: [...mockNotifications],
|
||||||
|
settings: { ...mockSettings }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟函数,模拟网络延迟
|
||||||
|
const delay = (ms: number = 100) => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
// Mock API 处理器映射
|
||||||
|
const mockHandlers: Record<string, (url: string, options: any) => Promise<any>> = {
|
||||||
|
// ==================== 认证相关 ====================
|
||||||
|
'GET /auth/captcha': async () => {
|
||||||
|
await delay()
|
||||||
|
return mockCaptcha
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /auth/login': async () => {
|
||||||
|
await delay(200)
|
||||||
|
return {
|
||||||
|
accessToken: 'mock-access-token-' + Date.now(),
|
||||||
|
refreshToken: 'mock-refresh-token-' + Date.now(),
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
expiresIn: 7200,
|
||||||
|
userId: 1,
|
||||||
|
username: 'testuser'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /auth/register': async () => {
|
||||||
|
await delay(200)
|
||||||
|
return '注册成功'
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /auth/logout': async () => {
|
||||||
|
await delay()
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /auth/refresh': async () => {
|
||||||
|
await delay()
|
||||||
|
return {
|
||||||
|
accessToken: 'mock-access-token-refreshed-' + Date.now(),
|
||||||
|
refreshToken: 'mock-refresh-token-refreshed-' + Date.now(),
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
expiresIn: 7200,
|
||||||
|
userId: 1,
|
||||||
|
username: 'testuser'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /auth/current': async () => {
|
||||||
|
await delay()
|
||||||
|
return mockCurrentUser
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /auth/username/:id': async (url: string) => {
|
||||||
|
await delay()
|
||||||
|
const id = parseInt(url.split('/').pop() || '0')
|
||||||
|
const user = mockUsers.find(u => u.id === id)
|
||||||
|
return user?.nickname || '未知用户'
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 用户相关 ====================
|
||||||
|
'GET /user/profile/current': async () => {
|
||||||
|
await delay()
|
||||||
|
return mockUsers.find(u => u.id === mockStore.currentUserId) || mockUsers[0]
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /user/profile/public/:id': async (url: string) => {
|
||||||
|
await delay()
|
||||||
|
const id = parseInt(url.split('/').pop() || '0')
|
||||||
|
return mockUsers.find(u => u.id === id) || mockUsers[0]
|
||||||
|
},
|
||||||
|
|
||||||
|
'PUT /user/profile': async (_url: string, options: any) => {
|
||||||
|
await delay(200)
|
||||||
|
const user = mockUsers.find(u => u.id === mockStore.currentUserId)
|
||||||
|
if (user && options.body) {
|
||||||
|
Object.assign(user, options.body)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /user/profile/avatar': async () => {
|
||||||
|
await delay(500)
|
||||||
|
return 'https://picsum.photos/100/100?random=' + Date.now()
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /user/follow': async (_url: string) => {
|
||||||
|
await delay(100)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'DELETE /user/follow': async (_url: string) => {
|
||||||
|
await delay(100)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /user/follow/check/:id': async (url: string) => {
|
||||||
|
await delay()
|
||||||
|
const id = parseInt(url.split('/').pop() || '0')
|
||||||
|
return mockFollowing.some(f => f.id === id)
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /user/follow/following': async (_url: string) => {
|
||||||
|
await delay()
|
||||||
|
return mockFollowing
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /user/follow/followers': async (_url: string) => {
|
||||||
|
await delay()
|
||||||
|
return mockFollowers
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /user/message/list': async () => {
|
||||||
|
await delay()
|
||||||
|
return mockStore.messages
|
||||||
|
.filter(item => item.senderId !== mockStore.currentUserId)
|
||||||
|
.sort((a, b) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime())
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /user/message/detail/:id': async (url: string) => {
|
||||||
|
await delay()
|
||||||
|
const targetId = parseInt(url.split('/').pop() || '0')
|
||||||
|
return mockStore.messages
|
||||||
|
.filter(item =>
|
||||||
|
(item.senderId === targetId && item.receiverId === mockStore.currentUserId) ||
|
||||||
|
(item.senderId === mockStore.currentUserId && item.receiverId === targetId)
|
||||||
|
)
|
||||||
|
.sort((a, b) => new Date(a.createTime).getTime() - new Date(b.createTime).getTime())
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /user/message': async (_url: string, options: any) => {
|
||||||
|
await delay(120)
|
||||||
|
const body = options.body || {}
|
||||||
|
const newMessage = {
|
||||||
|
id: mockStore.messages.length + 1,
|
||||||
|
senderId: mockStore.currentUserId,
|
||||||
|
senderNickname: mockUsers.find(u => u.id === mockStore.currentUserId)?.nickname || '用户',
|
||||||
|
senderAvatar: mockUsers.find(u => u.id === mockStore.currentUserId)?.avatar || '',
|
||||||
|
receiverId: body.receiverId,
|
||||||
|
receiverNickname: mockUsers.find(u => u.id === body.receiverId)?.nickname || '用户',
|
||||||
|
receiverAvatar: mockUsers.find(u => u.id === body.receiverId)?.avatar || '',
|
||||||
|
content: body.content,
|
||||||
|
isRead: 1,
|
||||||
|
createTime: new Date().toISOString()
|
||||||
|
}
|
||||||
|
mockStore.messages.push(newMessage)
|
||||||
|
return newMessage
|
||||||
|
},
|
||||||
|
|
||||||
|
'PUT /user/message/read/:id': async () => {
|
||||||
|
await delay()
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /user/message/unread/count': async () => {
|
||||||
|
await delay()
|
||||||
|
return mockStore.messages.filter(item => item.receiverId === mockStore.currentUserId && item.isRead === 0).length
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /user/notification/list': async (_url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
const { type } = options.params || {}
|
||||||
|
if (!type) return mockStore.notifications
|
||||||
|
return mockStore.notifications.filter(item => item.type === type)
|
||||||
|
},
|
||||||
|
|
||||||
|
'PUT /user/notification/read': async () => {
|
||||||
|
await delay()
|
||||||
|
mockStore.notifications.forEach(item => {
|
||||||
|
item.isRead = 1
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'PUT /user/notification/read/:id': async (url: string) => {
|
||||||
|
await delay()
|
||||||
|
const id = parseInt(url.split('/').pop() || '0')
|
||||||
|
const notification = mockStore.notifications.find(item => item.id === id)
|
||||||
|
if (notification) notification.isRead = 1
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /user/notification/unread/count': async () => {
|
||||||
|
await delay()
|
||||||
|
return mockStore.notifications.filter(item => item.isRead === 0).length
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /user/setting': async () => {
|
||||||
|
await delay()
|
||||||
|
return mockStore.settings
|
||||||
|
},
|
||||||
|
|
||||||
|
'PUT /user/setting': async (_url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
Object.assign(mockStore.settings, options.body || {})
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 视频相关 ====================
|
||||||
|
'GET /content/video/public/:id': async (url: string) => {
|
||||||
|
await delay()
|
||||||
|
const id = parseInt(url.split('/').pop() || '0')
|
||||||
|
return mockVideos.find(v => v.id === id) || mockVideos[0]
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /content/video/detail': async (url: string) => {
|
||||||
|
await delay()
|
||||||
|
const id = parseInt(url.split('/').pop() || '0')
|
||||||
|
return mockVideos.find(v => v.id === id) || mockVideos[0]
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /content/video/public/list': async (_url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
const params = options.params || {}
|
||||||
|
let videos = [...mockVideos]
|
||||||
|
|
||||||
|
// 按分类筛选
|
||||||
|
if (params.categoryId) {
|
||||||
|
videos = videos.filter(v => v.categoryId === params.categoryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按关键词搜索
|
||||||
|
if (params.keyword) {
|
||||||
|
const keyword = params.keyword.toLowerCase()
|
||||||
|
videos = videos.filter(v =>
|
||||||
|
v.title.toLowerCase().includes(keyword) ||
|
||||||
|
v.description.toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginate(videos, params.pageNum || 1, params.pageSize || 20)
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /content/video/list': async (_url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
const params = options.params || {}
|
||||||
|
return paginate(mockVideos, params.pageNum || 1, params.pageSize || 20)
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /content/video/user/:id': async (url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
const userId = parseInt(url.split('/').pop() || '0')
|
||||||
|
const params = options.params || {}
|
||||||
|
const videos = mockVideos.filter(v => v.userId === userId)
|
||||||
|
return paginate(videos, params.pageNum || params.page || 1, params.pageSize || params.size || 20)
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /content/video': async (_url: string, options: any) => {
|
||||||
|
await delay(300)
|
||||||
|
const body = options.body || {}
|
||||||
|
const newVideo = {
|
||||||
|
id: mockVideos.length + 1,
|
||||||
|
userId: mockStore.currentUserId,
|
||||||
|
title: body.title || '新视频',
|
||||||
|
description: body.description || '',
|
||||||
|
coverUrl: body.coverUrl || 'https://picsum.photos/320/180?random=' + Date.now(),
|
||||||
|
videoUrl: body.videoUrl || '',
|
||||||
|
duration: body.duration || 0,
|
||||||
|
width: body.width || 1920,
|
||||||
|
height: body.height || 1080,
|
||||||
|
format: body.format || 'mp4',
|
||||||
|
size: body.size || 0,
|
||||||
|
categoryId: body.categoryId || 1,
|
||||||
|
status: 0,
|
||||||
|
likeCount: 0,
|
||||||
|
coinCount: 0,
|
||||||
|
favoriteCount: 0,
|
||||||
|
commentCount: 0,
|
||||||
|
viewCount: 0,
|
||||||
|
shareCount: 0,
|
||||||
|
danmakuCount: 0,
|
||||||
|
isRecommend: 0,
|
||||||
|
isTop: 0,
|
||||||
|
publishTime: new Date().toISOString(),
|
||||||
|
createTime: new Date().toISOString(),
|
||||||
|
updateTime: new Date().toISOString(),
|
||||||
|
tagIds: body.tagIds || [],
|
||||||
|
tagNames: [],
|
||||||
|
categoryName: '科技',
|
||||||
|
userNickname: mockUsers.find(u => u.id === mockStore.currentUserId)?.nickname || '用户',
|
||||||
|
userAvatar: mockUsers.find(u => u.id === mockStore.currentUserId)?.avatar || '',
|
||||||
|
isLiked: false,
|
||||||
|
isFavorited: false,
|
||||||
|
isCoined: false
|
||||||
|
}
|
||||||
|
mockVideos.unshift(newVideo)
|
||||||
|
return newVideo
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /content/video/upload': async () => {
|
||||||
|
await delay(1000)
|
||||||
|
return {
|
||||||
|
videoUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
|
||||||
|
coverUrl: 'https://picsum.photos/320/180?random=' + Date.now()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /content/video/like': async () => {
|
||||||
|
await delay(100)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'DELETE /content/video/like': async () => {
|
||||||
|
await delay(100)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /content/video/coin': async () => {
|
||||||
|
await delay(100)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /content/video/favorite': async () => {
|
||||||
|
await delay(100)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'DELETE /content/video/favorite': async () => {
|
||||||
|
await delay(100)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /content/video/share': async () => {
|
||||||
|
await delay(100)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /content/video/play': async () => {
|
||||||
|
await delay(50)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 分类相关 ====================
|
||||||
|
'GET /content/category/tree': async () => {
|
||||||
|
await delay()
|
||||||
|
return mockCategories
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /content/category/list': async () => {
|
||||||
|
await delay()
|
||||||
|
return mockCategories
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 评论相关 ====================
|
||||||
|
'GET /comment/public/list': async (_url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
const params = options.params || {}
|
||||||
|
let comments = mockComments.filter(c => c.videoId === params.videoId)
|
||||||
|
if (params.sortBy === 'hot') {
|
||||||
|
comments.sort((a, b) => b.likeCount - a.likeCount)
|
||||||
|
} else {
|
||||||
|
comments.sort((a, b) => new Date(b.createTime).getTime() - new Date(a.createTime).getTime())
|
||||||
|
}
|
||||||
|
return paginate(comments, params.pageNum || 1, params.pageSize || 20)
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /comment': async (_url: string, options: any) => {
|
||||||
|
await delay(200)
|
||||||
|
const body = options.body || {}
|
||||||
|
const newComment = {
|
||||||
|
id: mockComments.length + 1,
|
||||||
|
videoId: body.videoId,
|
||||||
|
userId: mockStore.currentUserId,
|
||||||
|
parentId: body.parentId || 0,
|
||||||
|
rootId: body.rootId || 0,
|
||||||
|
content: body.content,
|
||||||
|
likeCount: 0,
|
||||||
|
replyCount: 0,
|
||||||
|
isTop: 0,
|
||||||
|
createTime: new Date().toISOString(),
|
||||||
|
userNickname: mockUsers.find(u => u.id === mockStore.currentUserId)?.nickname || '用户',
|
||||||
|
userAvatar: mockUsers.find(u => u.id === mockStore.currentUserId)?.avatar || '',
|
||||||
|
isLiked: false
|
||||||
|
}
|
||||||
|
mockComments.unshift(newComment)
|
||||||
|
return newComment
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /comment/like': async () => {
|
||||||
|
await delay(100)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'DELETE /comment/like': async () => {
|
||||||
|
await delay(100)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 弹幕相关 ====================
|
||||||
|
'GET /content/danmaku/all/:id': async (url: string) => {
|
||||||
|
await delay()
|
||||||
|
const videoId = parseInt(url.split('/').pop() || '0')
|
||||||
|
return mockDanmaku.filter(d => d.videoId === videoId || videoId === 0)
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /content/danmaku': async (_url: string, _options: any) => {
|
||||||
|
await delay(100)
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /content/danmaku/time': async (_url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
const params = options.params || {}
|
||||||
|
return mockDanmaku.filter(d => d.videoId === params.videoId)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 收藏相关 ====================
|
||||||
|
'GET /content/favorite/list': async () => {
|
||||||
|
await delay()
|
||||||
|
return mockFavorites
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /content/favorite': async (_url: string, options: any) => {
|
||||||
|
await delay(100)
|
||||||
|
const body = options.body || {}
|
||||||
|
const newFavorite = {
|
||||||
|
id: mockFavorites.length + 1,
|
||||||
|
userId: mockStore.currentUserId,
|
||||||
|
name: body.name || '新收藏夹',
|
||||||
|
description: body.description || '',
|
||||||
|
coverUrl: '',
|
||||||
|
visibility: body.visibility || 1,
|
||||||
|
videoCount: 0,
|
||||||
|
createTime: new Date().toISOString()
|
||||||
|
}
|
||||||
|
mockFavorites.push(newFavorite)
|
||||||
|
return newFavorite
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /content/favorite': async (_url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
const params = options.params || {}
|
||||||
|
return paginate(mockVideos.slice(0, 5), params.pageNum || 1, params.pageSize || 20)
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /content/favorite/:id/videos': async (url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
const favoriteId = Number(url.split('/').slice(-2, -1)[0] || '1')
|
||||||
|
const params = options.params || {}
|
||||||
|
const base = favoriteId === 1 ? mockVideos.slice(0, 4) : mockVideos.slice(4, 9)
|
||||||
|
return paginate(base, params.pageNum || 1, params.pageSize || 20)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 推荐相关 ====================
|
||||||
|
'GET /recommend/home': async (_url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
const params = options.params || {}
|
||||||
|
const videos = mockVideos.filter(v => v.isRecommend === 1)
|
||||||
|
const page = params.page || 1
|
||||||
|
const size = params.size || 20
|
||||||
|
const start = (page - 1) * size
|
||||||
|
return videos.slice(start, start + size)
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /recommend/hot': async (_url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
const params = options.params || {}
|
||||||
|
const videos = [...mockVideos].sort((a, b) => b.viewCount - a.viewCount)
|
||||||
|
const size = params.size || 20
|
||||||
|
return videos.slice(0, size)
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /recommend/similar/:id': async (_url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
const params = options.params || {}
|
||||||
|
const size = params.size || params.limit || 10
|
||||||
|
return mockVideos.slice(0, size)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 搜索相关 ====================
|
||||||
|
'GET /search/video': async (_url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
const params = options.params || {}
|
||||||
|
let videos = [...mockVideos]
|
||||||
|
if (params.keyword) {
|
||||||
|
const keyword = params.keyword.toLowerCase()
|
||||||
|
videos = videos.filter(v =>
|
||||||
|
v.title.toLowerCase().includes(keyword) ||
|
||||||
|
v.description.toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return paginate(videos, params.pageNum || 1, params.pageSize || 20)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 用户视频 ====================
|
||||||
|
'GET /user/video': async (_url: string, options: any) => {
|
||||||
|
await delay()
|
||||||
|
const userId = mockStore.currentUserId
|
||||||
|
const params = options.params || {}
|
||||||
|
const videos = mockVideos.filter(v => v.userId === userId)
|
||||||
|
return paginate(videos, params.pageNum || 1, params.pageSize || 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配 Mock 处理器
|
||||||
|
function matchHandler(url: string, method: string): ((url: string, options: any) => Promise<any>) | null {
|
||||||
|
// 标准化 URL(移除查询参数和 /api 前缀)
|
||||||
|
let normalizedUrl = url.replace(/^\/api/, '').split('?')[0]
|
||||||
|
|
||||||
|
// 构建匹配模式
|
||||||
|
const patterns = Object.keys(mockHandlers)
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const [patternMethod, patternPath] = pattern.split(' ')
|
||||||
|
|
||||||
|
// 检查方法是否匹配
|
||||||
|
if (patternMethod !== method.toUpperCase()) continue
|
||||||
|
|
||||||
|
// 检查路径是否匹配
|
||||||
|
const patternParts = patternPath.split('/')
|
||||||
|
const urlParts = normalizedUrl.split('/')
|
||||||
|
|
||||||
|
if (patternParts.length !== urlParts.length) continue
|
||||||
|
|
||||||
|
let matched = true
|
||||||
|
for (let i = 0; i < patternParts.length; i++) {
|
||||||
|
if (patternParts[i] !== urlParts[i] && !patternParts[i].startsWith(':')) {
|
||||||
|
matched = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
return mockHandlers[pattern]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock 请求处理函数
|
||||||
|
export async function mockRequest(url: string, options: any = {}): Promise<any> {
|
||||||
|
const method = options.method || 'GET'
|
||||||
|
|
||||||
|
// 查找匹配的处理器
|
||||||
|
const handler = matchHandler(url, method)
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
console.log(`[Mock] ${method} ${url}`)
|
||||||
|
try {
|
||||||
|
const result = await handler(url, options)
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'success',
|
||||||
|
data: result
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Mock Error] ${method} ${url}`, error)
|
||||||
|
return {
|
||||||
|
code: 500,
|
||||||
|
message: 'Mock 处理错误',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未找到处理器,返回默认响应
|
||||||
|
console.warn(`[Mock] No handler for ${method} ${url}`)
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: '未找到对应的 Mock 处理器',
|
||||||
|
data: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否启用 Mock
|
||||||
|
export function isMockEnabled(): boolean {
|
||||||
|
return import.meta.env.VITE_MOCK === 'true' || import.meta.env.VITE_MOCK === true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出 Mock 开关
|
||||||
|
export const mockEnabled = isMockEnabled()
|
||||||
@@ -86,6 +86,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/favorite/index.vue'),
|
component: () => import('@/views/favorite/index.vue'),
|
||||||
meta: { title: '我的收藏', requiresAuth: true }
|
meta: { title: '我的收藏', requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'my/favorites/:id',
|
||||||
|
name: 'FavoriteDetail',
|
||||||
|
component: () => import('@/views/favorite/detail.vue'),
|
||||||
|
meta: { title: '收藏夹详情', requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'my/history',
|
path: 'my/history',
|
||||||
name: 'PlayHistory',
|
name: 'PlayHistory',
|
||||||
@@ -104,6 +110,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/user/profile-edit.vue'),
|
component: () => import('@/views/user/profile-edit.vue'),
|
||||||
meta: { title: '编辑资料', requiresAuth: true }
|
meta: { title: '编辑资料', requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/privacy',
|
||||||
|
name: 'PrivacySettings',
|
||||||
|
component: () => import('@/views/user/privacy.vue'),
|
||||||
|
meta: { title: '隐私设置', requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/notification',
|
||||||
|
name: 'NotificationSettings',
|
||||||
|
component: () => import('@/views/user/notification.vue'),
|
||||||
|
meta: { title: '通知设置', requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'messages',
|
path: 'messages',
|
||||||
name: 'Messages',
|
name: 'Messages',
|
||||||
@@ -121,6 +139,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'Notifications',
|
name: 'Notifications',
|
||||||
component: () => import('@/views/message/notifications.vue'),
|
component: () => import('@/views/message/notifications.vue'),
|
||||||
meta: { title: '通知', requiresAuth: true }
|
meta: { title: '通知', requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'about',
|
||||||
|
name: 'About',
|
||||||
|
component: () => import('@/views/user/about.vue'),
|
||||||
|
meta: { title: '关于', requiresAuth: true }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -150,7 +174,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes,
|
routes,
|
||||||
scrollBehavior(to, from, savedPosition) {
|
scrollBehavior(_to, _from, savedPosition) {
|
||||||
if (savedPosition) {
|
if (savedPosition) {
|
||||||
return savedPosition
|
return savedPosition
|
||||||
} else {
|
} else {
|
||||||
@@ -160,7 +184,7 @@ const router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 路由前置守卫
|
// 路由前置守卫
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
// 开始进度条
|
// 开始进度条
|
||||||
NProgress.start()
|
NProgress.start()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { Favorite, VideoInfo } from '@/types'
|
import type { Favorite } from '@/types'
|
||||||
import { favoriteApi } from '@/api/favorite.ts'
|
import { favoriteApi } from '@/api/favorite.ts'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import type { Message, Notification } from '@/types'
|
import type { Notification } from '@/types'
|
||||||
|
|
||||||
export const useMessageStore = defineStore('message', () => {
|
export const useMessageStore = defineStore('message', () => {
|
||||||
// 未读私信数量
|
// 未读私信数量
|
||||||
|
|||||||
@@ -115,6 +115,29 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置 Token
|
||||||
|
function setTokenValue(accessToken: string, refreshTokenValue: string) {
|
||||||
|
token.value = accessToken
|
||||||
|
refreshToken.value = refreshTokenValue
|
||||||
|
setToken(accessToken)
|
||||||
|
setRefreshToken(refreshTokenValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置用户信息
|
||||||
|
function setUserInfoValue(info: { id: number; username: string }) {
|
||||||
|
authUser.value = {
|
||||||
|
id: info.id,
|
||||||
|
username: info.username,
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
status: 1,
|
||||||
|
roles: [],
|
||||||
|
permissions: [],
|
||||||
|
lastLoginTime: '',
|
||||||
|
lastLoginIp: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取用户资料
|
// 获取用户资料
|
||||||
async function fetchUserInfo() {
|
async function fetchUserInfo() {
|
||||||
if (!token.value) return null
|
if (!token.value) return null
|
||||||
@@ -175,7 +198,9 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
fetchAuthUser,
|
fetchAuthUser,
|
||||||
fetchUserInfo,
|
fetchUserInfo,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
uploadAvatar
|
uploadAvatar,
|
||||||
|
setToken: setTokenValue,
|
||||||
|
setUserInfo: setUserInfoValue
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
persist: {
|
persist: {
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ html,
|
|||||||
body {
|
body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
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 Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
'Noto Color Emoji';
|
'Noto Color Emoji';
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #333;
|
color: #18191c;
|
||||||
background-color: #f4f4f4;
|
background-color: #f6f7fb;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
@@ -86,8 +86,8 @@ select {
|
|||||||
// 主题变量
|
// 主题变量
|
||||||
:root {
|
:root {
|
||||||
// 主色调
|
// 主色调
|
||||||
--primary-color: #00a1d6;
|
--primary-color: #fb7299;
|
||||||
--primary-hover-color: #00b5e2;
|
--primary-hover-color: #ff85ad;
|
||||||
|
|
||||||
// 辅助色
|
// 辅助色
|
||||||
--success-color: #52c41a;
|
--success-color: #52c41a;
|
||||||
@@ -97,27 +97,27 @@ select {
|
|||||||
|
|
||||||
// 粉色(B站风格)
|
// 粉色(B站风格)
|
||||||
--pink-color: #fb7299;
|
--pink-color: #fb7299;
|
||||||
--pink-hover-color: #fc8bab;
|
--pink-hover-color: #ff85ad;
|
||||||
|
|
||||||
// 文字颜色
|
// 文字颜色
|
||||||
--text-primary: #333;
|
--text-primary: #18191c;
|
||||||
--text-secondary: #666;
|
--text-secondary: #61666d;
|
||||||
--text-tertiary: #999;
|
--text-tertiary: #9499a0;
|
||||||
--text-white: #fff;
|
--text-white: #fff;
|
||||||
|
|
||||||
// 背景颜色
|
// 背景颜色
|
||||||
--bg-primary: #f4f4f4;
|
--bg-primary: #f6f7fb;
|
||||||
--bg-secondary: #fff;
|
--bg-secondary: #fff;
|
||||||
--bg-tertiary: #f9f9f9;
|
--bg-tertiary: #f1f2f3;
|
||||||
|
|
||||||
// 边框颜色
|
// 边框颜色
|
||||||
--border-color: #e5e5e5;
|
--border-color: #e3e5e7;
|
||||||
--border-light: #f0f0f0;
|
--border-light: #f1f2f3;
|
||||||
|
|
||||||
// 阴影
|
// 阴影
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
--shadow-sm: 0 4px 16px rgba(15, 23, 42, 0.04);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
|
--shadow-md: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
--shadow-lg: 0 18px 48px rgba(15, 23, 42, 0.12);
|
||||||
|
|
||||||
// 圆角
|
// 圆角
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export interface ApiResponse<T = any> {
|
|||||||
|
|
||||||
// 分页请求参数
|
// 分页请求参数
|
||||||
export interface PageParams {
|
export interface PageParams {
|
||||||
page?: number
|
pageNum?: number
|
||||||
size?: number
|
pageSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页响应数据
|
// 分页响应数据
|
||||||
@@ -50,6 +50,7 @@ export interface AuthUser {
|
|||||||
lastLoginTime: string
|
lastLoginTime: string
|
||||||
lastLoginIp: string
|
lastLoginIp: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
|
permissions?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录响应
|
// 登录响应
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse,
|
|||||||
import { useUserStore } from '@/stores/user.ts'
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
import { mockRequest, isMockEnabled } from '@/mock'
|
||||||
|
|
||||||
// API 基础配置
|
// API 基础配置
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||||
@@ -92,25 +93,74 @@ service.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Mock 模式下的请求处理
|
||||||
|
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) {
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.code === 401) {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
userStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
showToast('登录已过期,请重新登录')
|
||||||
|
throw new Error('Token expired')
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(result.message || '请求失败')
|
||||||
|
throw new Error(result.message || '请求失败')
|
||||||
|
}
|
||||||
|
|
||||||
// 封装请求方法
|
// 封装请求方法
|
||||||
const request = {
|
const request = {
|
||||||
get<T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> {
|
async get<T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
// Mock 模式
|
||||||
|
if (isMockEnabled()) {
|
||||||
|
return mockFetch<T>('GET', url, undefined, params)
|
||||||
|
}
|
||||||
|
|
||||||
return service.get(url, { params, ...config })
|
return service.get(url, { params, ...config })
|
||||||
},
|
},
|
||||||
|
|
||||||
post<T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> {
|
async post<T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
// Mock 模式
|
||||||
|
if (isMockEnabled()) {
|
||||||
|
return mockFetch<T>('POST', url, data)
|
||||||
|
}
|
||||||
|
|
||||||
return service.post(url, data, config)
|
return service.post(url, data, config)
|
||||||
},
|
},
|
||||||
|
|
||||||
put<T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> {
|
async put<T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
// Mock 模式
|
||||||
|
if (isMockEnabled()) {
|
||||||
|
return mockFetch<T>('PUT', url, data)
|
||||||
|
}
|
||||||
|
|
||||||
return service.put(url, data, config)
|
return service.put(url, data, config)
|
||||||
},
|
},
|
||||||
|
|
||||||
delete<T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> {
|
async delete<T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
// Mock 模式
|
||||||
|
if (isMockEnabled()) {
|
||||||
|
return mockFetch<T>('DELETE', url, undefined, params)
|
||||||
|
}
|
||||||
|
|
||||||
return service.delete(url, { params, ...config })
|
return service.delete(url, { params, ...config })
|
||||||
},
|
},
|
||||||
|
|
||||||
upload<T = any>(url: string, file: File, onProgress?: (percent: number) => void): Promise<T> {
|
async upload<T = any>(url: string, file: File, onProgress?: (percent: number) => void): Promise<T> {
|
||||||
|
// Mock 模式
|
||||||
|
if (isMockEnabled()) {
|
||||||
|
return mockFetch<T>('POST', url, { file })
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<div class="favorite-detail-page">
|
||||||
|
<van-nav-bar :title="favoriteTitle" fixed placeholder left-arrow @click-left="router.back()" />
|
||||||
|
|
||||||
|
<section class="favorite-hero">
|
||||||
|
<div>
|
||||||
|
<h2>{{ favoriteTitle }}</h2>
|
||||||
|
<p>{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="count">{{ total }} 个视频</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<van-list
|
||||||
|
v-model:loading="loading"
|
||||||
|
:finished="finished"
|
||||||
|
finished-text="没有更多内容了"
|
||||||
|
@load="loadMore"
|
||||||
|
class="favorite-list"
|
||||||
|
>
|
||||||
|
<div class="video-grid">
|
||||||
|
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
|
||||||
|
</div>
|
||||||
|
</van-list>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import VideoCard from '@/components/video/VideoCard.vue'
|
||||||
|
import { favoriteApi } from '@/api/favorite.ts'
|
||||||
|
import type { VideoInfo } from '@/types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const favoriteId = computed(() => Number(route.params.id))
|
||||||
|
const favoriteTitle = computed(() => favoriteId.value === 1 ? '默认收藏夹' : '学习资料')
|
||||||
|
const description = computed(() => favoriteId.value === 1 ? '最近常看的优质内容' : '项目开发与知识学习整理')
|
||||||
|
|
||||||
|
const videos = ref<VideoInfo[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const finished = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (finished.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await favoriteApi.getFavoriteVideos(favoriteId.value, {
|
||||||
|
pageNum: page.value,
|
||||||
|
pageSize: 12
|
||||||
|
})
|
||||||
|
videos.value.push(...res.records)
|
||||||
|
total.value = res.total
|
||||||
|
if (videos.value.length >= res.total || res.records.length < 12) finished.value = true
|
||||||
|
page.value++
|
||||||
|
} catch {
|
||||||
|
finished.value = true
|
||||||
|
showToast('加载收藏夹失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToVideo = (id: number) => {
|
||||||
|
router.push(`/video/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadMore()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.favorite-detail-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f6f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-hero {
|
||||||
|
margin: 18px auto 12px;
|
||||||
|
max-width: 1400px;
|
||||||
|
padding: 24px 22px;
|
||||||
|
border-radius: 24px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.84);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-list {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 8px 12px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 18px 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="favorite-page">
|
<div class="favorite-page">
|
||||||
<van-nav-bar title="我的收藏" fixed placeholder left-arrow @click-left="router.back()" />
|
<div class="favorite-shell">
|
||||||
|
<section class="favorite-header">
|
||||||
|
<h2>我的收藏</h2>
|
||||||
|
<p>整理日常浏览中想反复观看的内容与专题合集。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||||
<van-list
|
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadMore">
|
||||||
v-model:loading="loading"
|
|
||||||
:finished="finished"
|
|
||||||
finished-text="没有更多了"
|
|
||||||
@load="loadMore"
|
|
||||||
>
|
|
||||||
<van-cell-group v-if="favorites.length" inset>
|
<van-cell-group v-if="favorites.length" inset>
|
||||||
<van-cell
|
<van-cell
|
||||||
v-for="fav in favorites"
|
v-for="fav in favorites"
|
||||||
@@ -27,6 +26,7 @@
|
|||||||
</van-list>
|
</van-list>
|
||||||
</van-pull-refresh>
|
</van-pull-refresh>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -51,7 +51,7 @@ const loadFavorites = async (isRefresh = false) => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await favoriteApi.getList()
|
const data = await favoriteApi.getFavoriteList()
|
||||||
favorites.value = data
|
favorites.value = data
|
||||||
finished.value = true
|
finished.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -71,7 +71,7 @@ const onRefresh = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goToFavorite = (id: number) => {
|
const goToFavorite = (id: number) => {
|
||||||
router.push(`/favorite/${id}`)
|
router.push(`/my/favorites/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -82,13 +82,44 @@ onMounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.favorite-page {
|
.favorite-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
background: #f6f7fb;
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-shell {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 12px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-header {
|
||||||
|
padding: 24px 26px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: linear-gradient(135deg, #fb7185, #8b5cf6);
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-shell :deep(.van-cell-group) {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.fav-icon {
|
.fav-icon {
|
||||||
color: #f7ba2a;
|
color: #f7ba2a;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const currentPage = ref(1)
|
|||||||
|
|
||||||
const loadCategories = async () => {
|
const loadCategories = async () => {
|
||||||
try {
|
try {
|
||||||
categories.value = await categoryApi.getList()
|
categories.value = await categoryApi.getCategoryList()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('加载分类失败')
|
showToast('加载分类失败')
|
||||||
}
|
}
|
||||||
@@ -75,9 +75,9 @@ const loadVideos = async (isRefresh = false) => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await videoApi.getList({
|
const res = await videoApi.getVideoPublicList({
|
||||||
page: currentPage.value,
|
pageNum: currentPage.value,
|
||||||
size: 20,
|
pageSize: 20,
|
||||||
categoryId: category.id
|
categoryId: category.id
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home-page">
|
<div class="home-page">
|
||||||
<!-- 顶部导航 -->
|
<section class="hero-panel">
|
||||||
<van-nav-bar title="HAOVP" fixed placeholder>
|
<div class="hero-copy">
|
||||||
<template #right>
|
<span class="hero-tag">热门精选</span>
|
||||||
<van-icon name="search" size="22" @click="router.push('/search')" />
|
<h1>发现值得反复观看的优质内容</h1>
|
||||||
</template>
|
<p>围绕技术分享、知识学习与生活记录构建内容分发首页,提供推荐、热门与分类浏览体验。</p>
|
||||||
</van-nav-bar>
|
</div>
|
||||||
|
<div class="hero-metrics">
|
||||||
|
<div class="metric"><strong>{{ videos.length || 12 }}</strong><span>精选视频</span></div>
|
||||||
|
<div class="metric"><strong>{{ categories.length || 5 }}</strong><span>内容分区</span></div>
|
||||||
|
<div class="metric"><strong>24h</strong><span>热度更新</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 分类导航 -->
|
<!-- 分类导航 -->
|
||||||
<div class="category-nav">
|
<div class="category-nav">
|
||||||
@@ -42,7 +48,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
import VideoCard from '@/components/video/VideoCard.vue'
|
import VideoCard from '@/components/video/VideoCard.vue'
|
||||||
@@ -69,7 +75,7 @@ const pageSize = 20
|
|||||||
// 加载分类
|
// 加载分类
|
||||||
const loadCategories = async () => {
|
const loadCategories = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await categoryApi.getList()
|
const res = await categoryApi.getCategoryList()
|
||||||
categories.value = res
|
categories.value = res
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载分类失败:', error)
|
console.error('加载分类失败:', error)
|
||||||
@@ -87,18 +93,27 @@ const loadVideos = async (isRefresh = false) => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
let res
|
let res
|
||||||
const params = {
|
|
||||||
page: currentPage.value,
|
|
||||||
size: pageSize,
|
|
||||||
...(typeof activeCategory.value === 'number' && { categoryId: activeCategory.value })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeCategory.value === 'recommend') {
|
if (activeCategory.value === 'recommend') {
|
||||||
res = await recommendApi.getHome(params)
|
// 首页推荐返回列表
|
||||||
|
const list = await recommendApi.getHomeRecommend(currentPage.value, pageSize)
|
||||||
|
res = { records: list, total: list.length }
|
||||||
|
// 推荐接口数据较少时标记完成
|
||||||
|
if (list.length < pageSize) {
|
||||||
|
finished.value = true
|
||||||
|
}
|
||||||
} else if (activeCategory.value === 'hot') {
|
} else if (activeCategory.value === 'hot') {
|
||||||
res = await recommendApi.getHot(params)
|
// 热门推荐返回列表,periodType: 1-日榜
|
||||||
|
const list = await recommendApi.getHotRecommend(1, pageSize)
|
||||||
|
res = { records: list, total: list.length }
|
||||||
|
finished.value = true // 热门榜只加载一次
|
||||||
} else {
|
} else {
|
||||||
res = await videoApi.getList(params)
|
// 分类视频返回分页对象,参数名为 pageNum 和 pageSize
|
||||||
|
res = await videoApi.getVideoPublicList({
|
||||||
|
pageNum: currentPage.value,
|
||||||
|
pageSize: pageSize,
|
||||||
|
categoryId: activeCategory.value as number
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
@@ -153,7 +168,7 @@ onMounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.home-page {
|
.home-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
background: #f6f7fb;
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@@ -161,45 +176,101 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-panel {
|
||||||
|
margin: 20px auto 12px;
|
||||||
|
padding: 30px 28px;
|
||||||
|
border-radius: 24px;
|
||||||
|
color: #fff;
|
||||||
|
max-width: 1380px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(255, 255, 255, 0.28), transparent 24%),
|
||||||
|
linear-gradient(135deg, #fb7299 0%, #ff85ad 32%, #3da5ff 100%);
|
||||||
|
box-shadow: 0 24px 52px rgba(251, 114, 153, 0.18);
|
||||||
|
|
||||||
|
.hero-copy h1 {
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy p {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 760px;
|
||||||
|
color: rgba(255, 255, 255, 0.84);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-metrics {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
min-width: 92px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.category-nav {
|
.category-nav {
|
||||||
background: #fff;
|
background: rgba(255, 255, 255, 0.88);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 46px;
|
top: var(--header-height);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
|
||||||
:deep(.van-tabs__nav) {
|
:deep(.van-tabs__nav) {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.van-tab) {
|
:deep(.van-tab) {
|
||||||
padding: 0 12px;
|
padding: 0 16px;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
|
color: #61666d;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.van-tabs__line) {
|
:deep(.van-tabs__line) {
|
||||||
background: #00a1d6;
|
background: #fb7299;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-list {
|
.video-list {
|
||||||
padding: 12px;
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 12px 12px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-grid {
|
.video-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 12px;
|
gap: 18px 16px;
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
grid-template-columns: repeat(5, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1600px) {
|
|
||||||
grid-template-columns: repeat(6, 1fr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ onMounted(() => {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: #f6f7fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="login-header">
|
<div class="login-header">
|
||||||
<h1 class="logo">HAOVP</h1>
|
<h1 class="logo">HAOVP</h1>
|
||||||
<p class="slogan">高可用在线视频平台</p>
|
<p class="slogan">登录后即可继续观看、发布与互动</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 登录表单 -->
|
<!-- 登录表单 -->
|
||||||
@@ -114,6 +114,7 @@ const refreshCaptcha = async () => {
|
|||||||
// 登录
|
// 登录
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!form.username || !form.password || !form.captcha) {
|
if (!form.username || !form.password || !form.captcha) {
|
||||||
|
showToast('请填写完整信息')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,13 +134,22 @@ const handleLogin = async () => {
|
|||||||
username: result.username
|
username: result.username
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取用户资料
|
||||||
|
try {
|
||||||
|
await userStore.fetchUserInfo()
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略获取用户资料错误
|
||||||
|
}
|
||||||
|
|
||||||
showSuccessToast('登录成功')
|
showSuccessToast('登录成功')
|
||||||
|
|
||||||
// 跳转到之前的页面或首页
|
// 跳转到之前的页面或首页
|
||||||
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) {
|
||||||
showToast(error.message || '登录失败')
|
// error 可能是 Error 对象或 axios 错误响应
|
||||||
|
const message = error?.response?.data?.message || error?.message || '登录失败'
|
||||||
|
showToast(message)
|
||||||
refreshCaptcha()
|
refreshCaptcha()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -154,7 +164,10 @@ onMounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.login-page {
|
.login-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background:
|
||||||
|
radial-gradient(circle at 20% 20%, rgba(14, 165, 233, 0.26), transparent 26%),
|
||||||
|
radial-gradient(circle at 85% 80%, rgba(249, 115, 22, 0.24), transparent 24%),
|
||||||
|
linear-gradient(135deg, #081222 0%, #12345b 52%, #0f766e 100%);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -163,11 +176,12 @@ onMounted(() => {
|
|||||||
|
|
||||||
.login-container {
|
.login-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 430px;
|
||||||
background: #fff;
|
background: rgba(255, 255, 255, 0.96);
|
||||||
border-radius: 16px;
|
border-radius: 28px;
|
||||||
padding: 40px 24px;
|
padding: 42px 28px 28px;
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 24px 80px rgba(4, 16, 33, 0.34);
|
||||||
|
backdrop-filter: blur(22px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header {
|
.login-header {
|
||||||
@@ -182,9 +196,10 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.slogan {
|
.slogan {
|
||||||
color: #999;
|
margin-top: 10px;
|
||||||
font-size: 14px;
|
color: #64748b;
|
||||||
margin-top: 8px;
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,4 +259,5 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="register-container">
|
<div class="register-container">
|
||||||
<div class="register-header">
|
<div class="register-header">
|
||||||
<h1 class="logo">HAOVP</h1>
|
<h1 class="logo">HAOVP</h1>
|
||||||
<p class="slogan">注册新账号</p>
|
<p class="slogan">创建账号,保存你的观看与创作记录</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<van-form @submit="handleRegister">
|
<van-form @submit="handleRegister">
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
name="email"
|
name="email"
|
||||||
label="邮箱"
|
label="邮箱"
|
||||||
placeholder="请输入邮箱"
|
placeholder="请输入邮箱"
|
||||||
:rules="[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '邮箱格式不正确' }]"
|
:rules="[{ required: true, message: '请输入邮箱' }]"
|
||||||
/>
|
/>
|
||||||
<van-field
|
<van-field
|
||||||
v-model="form.phone"
|
v-model="form.phone"
|
||||||
@@ -140,7 +140,10 @@ onMounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.register-page {
|
.register-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(34, 197, 94, 0.22), transparent 28%),
|
||||||
|
radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.24), transparent 26%),
|
||||||
|
linear-gradient(135deg, #0b1323 0%, #1d3557 55%, #0f766e 100%);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -149,11 +152,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
.register-container {
|
.register-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 430px;
|
||||||
background: #fff;
|
background: rgba(255, 255, 255, 0.96);
|
||||||
border-radius: 16px;
|
border-radius: 28px;
|
||||||
padding: 30px 24px;
|
padding: 34px 28px 28px;
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 24px 80px rgba(4, 16, 33, 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
.register-header {
|
.register-header {
|
||||||
@@ -168,9 +171,10 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.slogan {
|
.slogan {
|
||||||
color: #999;
|
margin-top: 10px;
|
||||||
font-size: 14px;
|
color: #64748b;
|
||||||
margin-top: 8px;
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,9 +49,10 @@
|
|||||||
import { ref, onMounted, nextTick } from 'vue'
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
import { userApi, type Message } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import { useUserStore } from '@/stores/user.ts'
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
import { formatRelativeTime } from '@/utils'
|
import { formatRelativeTime } from '@/utils'
|
||||||
|
import type { Message } from '@/types'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="message-page">
|
<div class="message-page">
|
||||||
<van-nav-bar title="私信" fixed placeholder left-arrow @click-left="router.back()">
|
<div class="message-shell">
|
||||||
<template #right>
|
<section class="message-header">
|
||||||
<van-icon name="plus" size="22" />
|
<div>
|
||||||
</template>
|
<h2>私信</h2>
|
||||||
</van-nav-bar>
|
<p>查看最近会话、沟通合作与内容互动消息。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||||
<van-list
|
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadMore">
|
||||||
v-model:loading="loading"
|
|
||||||
:finished="finished"
|
|
||||||
finished-text="没有更多了"
|
|
||||||
@load="loadMore"
|
|
||||||
>
|
|
||||||
<van-cell-group v-if="conversations.length" inset>
|
<van-cell-group v-if="conversations.length" inset>
|
||||||
<van-cell
|
<van-cell
|
||||||
v-for="conv in conversations"
|
v-for="conv in conversations"
|
||||||
@@ -23,7 +20,7 @@
|
|||||||
@click="goToDetail(conv.targetId)"
|
@click="goToDetail(conv.targetId)"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<van-image :src="conv.targetAvatar || defaultAvatar" round width="40" height="40" />
|
<van-image :src="conv.targetAvatar || defaultAvatar" round width="48" height="48" />
|
||||||
</template>
|
</template>
|
||||||
<template #value>
|
<template #value>
|
||||||
<van-badge :content="conv.unreadCount" :show-zero="false">
|
<van-badge :content="conv.unreadCount" :show-zero="false">
|
||||||
@@ -36,6 +33,7 @@
|
|||||||
</van-list>
|
</van-list>
|
||||||
</van-pull-refresh>
|
</van-pull-refresh>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -71,7 +69,14 @@ const loadConversations = async (isRefresh = false) => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await userApi.getMessageList()
|
const data = await userApi.getMessageList()
|
||||||
conversations.value = data
|
conversations.value = data.map((item) => ({
|
||||||
|
targetId: item.senderId,
|
||||||
|
targetNickname: item.senderNickname,
|
||||||
|
targetAvatar: item.senderAvatar,
|
||||||
|
lastMessage: item.content,
|
||||||
|
lastTime: item.createTime,
|
||||||
|
unreadCount: item.isRead ? 0 : 1
|
||||||
|
}))
|
||||||
finished.value = true
|
finished.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('加载失败')
|
showToast('加载失败')
|
||||||
@@ -101,12 +106,43 @@ onMounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.message-page {
|
.message-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
background: #f6f7fb;
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-shell {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 12px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
padding: 24px 26px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: linear-gradient(135deg, #1d4ed8, #0f766e);
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-shell :deep(.van-cell-group) {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -39,8 +39,9 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
import { userApi, type Notification } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import { formatRelativeTime } from '@/utils'
|
import { formatRelativeTime } from '@/utils'
|
||||||
|
import type { Notification } from '@/types'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const activeTab = ref('all')
|
const activeTab = ref('all')
|
||||||
@@ -79,7 +80,14 @@ const loadNotifications = async (isRefresh = false) => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await userApi.getNotifications()
|
const typeMap: Record<string, number | undefined> = {
|
||||||
|
all: undefined,
|
||||||
|
like: 1,
|
||||||
|
comment: 2,
|
||||||
|
follow: 3,
|
||||||
|
system: 4
|
||||||
|
}
|
||||||
|
const data = await userApi.getNotifications(typeMap[activeTab.value])
|
||||||
notifications.value = data
|
notifications.value = data
|
||||||
finished.value = true
|
finished.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ const loadVideos = async (isRefresh = false) => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await videoApi.getList({
|
const res = await videoApi.getVideoPublicList({
|
||||||
page: currentPage.value,
|
pageNum: currentPage.value,
|
||||||
size: 20,
|
pageSize: 20,
|
||||||
keyword: keyword.value
|
keyword: keyword.value
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -114,16 +114,18 @@ onMounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.search-page {
|
.search-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
background: #f6f7fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-content {
|
.search-content {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|
||||||
.video-grid {
|
.video-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 12px;
|
gap: 18px 16px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
<div class="upload-page">
|
<div class="upload-page">
|
||||||
<van-nav-bar title="上传视频" fixed placeholder left-arrow @click-left="router.back()" />
|
<van-nav-bar title="上传视频" fixed placeholder left-arrow @click-left="router.back()" />
|
||||||
|
|
||||||
|
<section class="upload-hero">
|
||||||
|
<div>
|
||||||
|
<h2>发布你的新作品</h2>
|
||||||
|
<p>上传视频、完善标题与分类信息,完成内容发布前的基础编辑。</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-badge">高清视频上传</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<van-form @submit="handleSubmit">
|
<van-form @submit="handleSubmit">
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-field
|
<van-field
|
||||||
@@ -101,7 +109,7 @@ const videoInfo = ref<any>(null)
|
|||||||
|
|
||||||
const loadCategories = async () => {
|
const loadCategories = async () => {
|
||||||
try {
|
try {
|
||||||
const data: Category[] = await categoryApi.getList()
|
const data: Category[] = await categoryApi.getCategoryList()
|
||||||
categories.value = data.map((cat) => ({
|
categories.value = data.map((cat) => ({
|
||||||
text: cat.name,
|
text: cat.name,
|
||||||
value: cat.id
|
value: cat.id
|
||||||
@@ -118,7 +126,7 @@ const onCategoryConfirm = ({ selectedOptions }: any) => {
|
|||||||
showCategoryPicker.value = false
|
showCategoryPicker.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const afterReadCover = async (file: any) => {
|
const afterReadCover = async (_file: any) => {
|
||||||
// TODO: 上传封面
|
// TODO: 上传封面
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,9 +136,9 @@ const afterReadVideo = async (file: any) => {
|
|||||||
uploadProgress.value = 0
|
uploadProgress.value = 0
|
||||||
// TODO: 实际上传逻辑
|
// TODO: 实际上传逻辑
|
||||||
try {
|
try {
|
||||||
const result = await videoApi.upload(file.file, (percent) => {
|
const result = await videoApi.uploadVideo({ file: file.file, onProgress: (percent) => {
|
||||||
uploadProgress.value = percent
|
uploadProgress.value = percent
|
||||||
})
|
}})
|
||||||
videoUrl.value = result.videoUrl
|
videoUrl.value = result.videoUrl
|
||||||
coverUrl.value = result.coverUrl
|
coverUrl.value = result.coverUrl
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -150,7 +158,7 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await videoApi.create({
|
await videoApi.createVideo({
|
||||||
title: form.title,
|
title: form.title,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
videoUrl: videoUrl.value,
|
videoUrl: videoUrl.value,
|
||||||
@@ -179,14 +187,49 @@ onMounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.upload-page {
|
.upload-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
background:
|
||||||
|
linear-gradient(180deg, rgba(59, 130, 246, 0.08), transparent 220px),
|
||||||
|
#f4f7fb;
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
.upload-hero {
|
||||||
|
margin: 20px 12px 12px;
|
||||||
|
padding: 28px 24px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(135deg, #082f49, #0f766e 58%, #15803d);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 20px 42px rgba(8, 47, 73, 0.18);
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.upload-area {
|
.upload-area {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: #fff;
|
background: rgba(255, 255, 255, 0.96);
|
||||||
margin: 12px;
|
margin: 12px;
|
||||||
border-radius: 8px;
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@@ -199,5 +242,12 @@ onMounted(() => {
|
|||||||
.submit-btn {
|
.submit-btn {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.van-cell-group) {
|
||||||
|
margin: 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<div class="about-page">
|
||||||
|
<van-nav-bar title="关于" fixed placeholder left-arrow @click-left="router.back()" />
|
||||||
|
<section class="about-card">
|
||||||
|
<h2>HAOVP 在线视频平台</h2>
|
||||||
|
<p>一个面向内容上传、播放互动与平台治理场景构建的在线视频系统。</p>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell title="当前版本" value="1.0.0" />
|
||||||
|
<van-cell title="主要能力" value="上传、播放、评论、推荐、后台治理" />
|
||||||
|
<van-cell title="技术栈" value="Vue 3 + Vite + Vant" />
|
||||||
|
</van-cell-group>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
const router = useRouter()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.about-page { min-height: 100vh; background: #f6f7fb; }
|
||||||
|
.about-card {
|
||||||
|
margin: 20px 16px;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -35,8 +35,9 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { showToast, showSuccessToast } from 'vant'
|
import { showToast, showSuccessToast } from 'vant'
|
||||||
import { userApi, type UserFollow } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import { useUserStore } from '@/stores/user.ts'
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
|
import type { UserFollow } from '@/types'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -46,8 +47,6 @@ const userId = Number(route.params.id)
|
|||||||
const users = ref<UserFollow[]>([])
|
const users = ref<UserFollow[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const finished = ref(false)
|
const finished = ref(false)
|
||||||
const currentPage = ref(1)
|
|
||||||
|
|
||||||
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
|
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
|
|||||||
@@ -35,7 +35,8 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { showToast, showSuccessToast } from 'vant'
|
import { showToast, showSuccessToast } from 'vant'
|
||||||
import { userApi, type UserFollow } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
|
import type { UserFollow } from '@/types'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -86,7 +87,9 @@ onMounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.follow-page {
|
.follow-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
background:
|
||||||
|
linear-gradient(180deg, rgba(20, 184, 166, 0.08), transparent 180px),
|
||||||
|
#f4f7fb;
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
|
|
||||||
.nickname {
|
.nickname {
|
||||||
@@ -97,5 +100,12 @@ onMounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.van-cell-group) {
|
||||||
|
margin: 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast, showConfirmDialog } from 'vant'
|
import { showToast, showConfirmDialog } from 'vant'
|
||||||
import { formatDuration } from '@/utils'
|
import { formatDuration } from '@/utils'
|
||||||
|
import { mockVideos } from '@/mock/data'
|
||||||
|
|
||||||
interface HistoryItem {
|
interface HistoryItem {
|
||||||
videoId: number
|
videoId: number
|
||||||
@@ -60,7 +61,13 @@ const loadVideos = async (isRefresh = false) => {
|
|||||||
finished.value = false
|
finished.value = false
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
// TODO: 实际从API获取播放历史
|
videos.value = mockVideos.slice(0, 4).map((item, index) => ({
|
||||||
|
videoId: item.id,
|
||||||
|
title: item.title,
|
||||||
|
coverUrl: item.coverUrl,
|
||||||
|
progress: Math.max(60, Math.floor(item.duration * (0.2 + index * 0.12))),
|
||||||
|
duration: item.duration
|
||||||
|
}))
|
||||||
finished.value = true
|
finished.value = true
|
||||||
loading.value = false
|
loading.value = false
|
||||||
refreshing.value = false
|
refreshing.value = false
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ const finished = ref(false)
|
|||||||
const refreshing = ref(false)
|
const refreshing = ref(false)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
|
||||||
const getStatusType = (status: number) => {
|
const getStatusType = (status: number): 'warning' | 'success' | 'danger' | 'primary' => {
|
||||||
const types: Record<number, string> = {
|
const types: Record<number, 'warning' | 'success' | 'danger' | 'primary'> = {
|
||||||
0: 'warning',
|
0: 'warning',
|
||||||
1: 'success',
|
1: 'success',
|
||||||
2: 'danger',
|
2: 'danger',
|
||||||
3: 'default'
|
3: 'primary'
|
||||||
}
|
}
|
||||||
return types[status] || 'default'
|
return types[status] || 'primary'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusText = (status: number) => {
|
const getStatusText = (status: number) => {
|
||||||
@@ -89,7 +89,7 @@ const loadVideos = async (isRefresh = false) => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await videoApi.getUserVideos(userStore.userId, { page: currentPage.value, size: 20 })
|
const res = await videoApi.getUserVideos(userStore.userId, { pageNum: currentPage.value, pageSize: 20 })
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
videos.value = res.records
|
videos.value = res.records
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings-subpage">
|
||||||
|
<van-nav-bar title="通知设置" fixed placeholder left-arrow @click-left="router.back()" />
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell title="评论通知">
|
||||||
|
<template #right-icon><van-switch v-model="form.commentNotify" :active-value="1" :inactive-value="0" /></template>
|
||||||
|
</van-cell>
|
||||||
|
<van-cell title="点赞通知">
|
||||||
|
<template #right-icon><van-switch v-model="form.likeNotify" :active-value="1" :inactive-value="0" /></template>
|
||||||
|
</van-cell>
|
||||||
|
<van-cell title="关注通知">
|
||||||
|
<template #right-icon><van-switch v-model="form.followNotify" :active-value="1" :inactive-value="0" /></template>
|
||||||
|
</van-cell>
|
||||||
|
<van-cell title="系统通知">
|
||||||
|
<template #right-icon><van-switch v-model="form.systemNotify" :active-value="1" :inactive-value="0" /></template>
|
||||||
|
</van-cell>
|
||||||
|
</van-cell-group>
|
||||||
|
<div class="submit">
|
||||||
|
<van-button type="primary" block @click="save">保存设置</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { showSuccessToast } from 'vant'
|
||||||
|
import { userApi } from '@/api/user.ts'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const form = reactive({
|
||||||
|
commentNotify: 1,
|
||||||
|
likeNotify: 1,
|
||||||
|
followNotify: 1,
|
||||||
|
systemNotify: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
await userApi.updateSettings(form)
|
||||||
|
showSuccessToast('已保存')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
Object.assign(form, await userApi.getSettings())
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.settings-subpage { min-height: 100vh; background: #f6f7fb; }
|
||||||
|
.submit { padding: 20px 16px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings-subpage">
|
||||||
|
<van-nav-bar title="隐私设置" fixed placeholder left-arrow @click-left="router.back()" />
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell title="主页公开范围">
|
||||||
|
<template #right-icon>
|
||||||
|
<van-dropdown-menu>
|
||||||
|
<van-dropdown-item v-model="form.privacyLevel" :options="privacyOptions" />
|
||||||
|
</van-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
<van-cell title="个性签名可见" value="公开" />
|
||||||
|
<van-cell title="关注列表可见" value="仅自己可见" />
|
||||||
|
</van-cell-group>
|
||||||
|
<div class="submit">
|
||||||
|
<van-button type="primary" block @click="save">保存设置</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { showSuccessToast } from 'vant'
|
||||||
|
import { userApi } from '@/api/user.ts'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const form = reactive({ privacyLevel: 1 })
|
||||||
|
const privacyOptions = [
|
||||||
|
{ text: '公开', value: 1 },
|
||||||
|
{ text: '仅关注可见', value: 2 },
|
||||||
|
{ text: '仅自己可见', value: 3 }
|
||||||
|
]
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
await userApi.updateSettings({ privacyLevel: form.privacyLevel })
|
||||||
|
showSuccessToast('已保存')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const settings = await userApi.getSettings()
|
||||||
|
form.privacyLevel = settings.privacyLevel
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.settings-subpage { min-height: 100vh; background: #f6f7fb; }
|
||||||
|
.submit { padding: 20px 16px; }
|
||||||
|
</style>
|
||||||
@@ -42,10 +42,8 @@ import { ref, reactive, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast, showSuccessToast } from 'vant'
|
import { showToast, showSuccessToast } from 'vant'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import { useUserStore } from '@/stores/user.ts'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const showBirthdayPicker = ref(false)
|
const showBirthdayPicker = ref(false)
|
||||||
@@ -137,11 +135,16 @@ onMounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.profile-edit-page {
|
.profile-edit-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
background:
|
||||||
|
linear-gradient(180deg, rgba(59, 130, 246, 0.08), transparent 180px),
|
||||||
|
#f4f7fb;
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
|
|
||||||
.van-cell-group {
|
.van-cell-group {
|
||||||
margin: 12px;
|
margin: 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="profile-page">
|
<div class="profile-page">
|
||||||
<van-nav-bar title="用户主页" fixed placeholder left-arrow @click-left="router.back()" />
|
<div class="profile-shell">
|
||||||
|
|
||||||
<div v-if="loading" class="loading-container">
|
<div v-if="loading" class="loading-container">
|
||||||
<van-loading size="24px">加载中...</van-loading>
|
<van-loading size="24px">加载中...</van-loading>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="userInfo">
|
<template v-else-if="userInfo">
|
||||||
|
<div class="profile-banner">
|
||||||
|
<div class="banner-overlay"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="user-header">
|
<div class="user-header">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<van-image :src="userInfo.avatar || defaultAvatar" round width="80" height="80" />
|
<van-image :src="userInfo.avatar || defaultAvatar" round width="104" height="104" />
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<h2 class="nickname">{{ userInfo.nickname }}</h2>
|
<h2 class="nickname">{{ userInfo.nickname }}</h2>
|
||||||
|
<div class="meta-row">
|
||||||
<div class="level">Lv.{{ userInfo.level }}</div>
|
<div class="level">Lv.{{ userInfo.level }}</div>
|
||||||
|
<div class="region">{{ userInfo.region }}</div>
|
||||||
|
</div>
|
||||||
<div class="signature">{{ userInfo.signature || '这个人很懒,什么都没写' }}</div>
|
<div class="signature">{{ userInfo.signature || '这个人很懒,什么都没写' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats">
|
<div class="stats">
|
||||||
<div class="stat-item">
|
<div class="stat-item" @click="goToUserVideos">
|
||||||
<div class="value">{{ userInfo.videoCount }}</div>
|
<div class="value">{{ userInfo.videoCount }}</div>
|
||||||
<div class="label">视频</div>
|
<div class="label">视频</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item" @click="goToFollowers">
|
||||||
<div class="value">{{ userInfo.followerCount }}</div>
|
<div class="value">{{ userInfo.followerCount }}</div>
|
||||||
<div class="label">粉丝</div>
|
<div class="label">粉丝</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item" @click="goToFollowing">
|
||||||
<div class="value">{{ userInfo.followingCount }}</div>
|
<div class="value">{{ userInfo.followingCount }}</div>
|
||||||
<div class="label">关注</div>
|
<div class="label">关注</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,17 +41,18 @@
|
|||||||
<div class="label">获赞</div>
|
<div class="label">获赞</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<van-button v-if="!isSelf" :type="isFollowed ? 'default' : 'primary'" size="small" @click="toggleFollow">
|
<van-button v-if="!isSelf" :type="isFollowed ? 'default' : 'primary'" size="small" round @click="toggleFollow">
|
||||||
{{ isFollowed ? '已关注' : '关注' }}
|
{{ isFollowed ? '已关注' : '关注' }}
|
||||||
</van-button>
|
</van-button>
|
||||||
<van-button v-else type="primary" size="small" @click="router.push('/settings/profile')">
|
<van-button v-else type="primary" size="small" round @click="router.push('/settings/profile')">
|
||||||
编辑资料
|
编辑资料
|
||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<van-tabs v-model:active="activeTab" sticky>
|
<van-tabs v-model:active="activeTab" sticky class="profile-tabs">
|
||||||
<van-tab title="视频" name="videos">
|
<van-tab title="视频" name="videos">
|
||||||
<div class="video-grid">
|
<div class="video-grid">
|
||||||
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
|
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
|
||||||
@@ -56,10 +64,11 @@
|
|||||||
</van-tabs>
|
</van-tabs>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { showToast, showSuccessToast } from 'vant'
|
import { showToast, showSuccessToast } from 'vant'
|
||||||
import VideoCard from '@/components/video/VideoCard.vue'
|
import VideoCard from '@/components/video/VideoCard.vue'
|
||||||
@@ -72,7 +81,7 @@ const router = useRouter()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const userId = computed(() => Number(route.params.id))
|
const userId = computed(() => Number(route.params.id || userStore.userId || userStore.userInfo?.id || 0))
|
||||||
const isSelf = computed(() => userStore.userId === userId.value)
|
const isSelf = computed(() => userStore.userId === userId.value)
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -89,17 +98,17 @@ const loadUserInfo = async () => {
|
|||||||
if (!isSelf.value && userStore.isLoggedIn) {
|
if (!isSelf.value && userStore.isLoggedIn) {
|
||||||
isFollowed.value = await userApi.checkFollow(userId.value)
|
isFollowed.value = await userApi.checkFollow(userId.value)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
showToast('加载用户信息失败')
|
showToast('加载用户信息失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadUserVideos = async () => {
|
const loadUserVideos = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await videoApi.getUserVideos(userId.value, { page: 1, size: 20 })
|
const res = await videoApi.getUserVideos(userId.value, { pageNum: 1, pageSize: 20 })
|
||||||
videos.value = res.records
|
videos.value = res.records
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('加载视频失败:', error)
|
showToast('加载视频失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +127,7 @@ const toggleFollow = async () => {
|
|||||||
isFollowed.value = true
|
isFollowed.value = true
|
||||||
showSuccessToast('关注成功')
|
showSuccessToast('关注成功')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
showToast('操作失败')
|
showToast('操作失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,96 +136,203 @@ const goToVideo = (id: number) => {
|
|||||||
router.push(`/video/${id}`)
|
router.push(`/video/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
const goToFollowers = () => {
|
||||||
|
router.push(`/user/${userId.value}/followers`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToFollowing = () => {
|
||||||
|
router.push(`/user/${userId.value}/following`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToUserVideos = () => {
|
||||||
|
router.push(`/user/${userId.value}/videos`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initPage = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
await Promise.all([loadUserInfo(), loadUserVideos()])
|
await Promise.all([loadUserInfo(), loadUserVideos()])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
}
|
||||||
|
|
||||||
|
onMounted(initPage)
|
||||||
|
watch(() => route.params.id, initPage)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.profile-page {
|
.profile-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
background: #f6f7fb;
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-shell {
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 12px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-banner {
|
||||||
|
height: 220px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: linear-gradient(135deg, #60a5fa, #8b5cf6 52%, #fb7185);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 24px 52px rgba(99, 102, 241, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at right top, rgba(255, 255, 255, 0.3), transparent 24%),
|
||||||
|
radial-gradient(circle at left bottom, rgba(255, 255, 255, 0.18), transparent 28%);
|
||||||
|
}
|
||||||
|
|
||||||
.user-header {
|
.user-header {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 20px;
|
padding: 24px 26px;
|
||||||
|
border-radius: 28px;
|
||||||
|
margin-top: -48px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 20px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.nickname {
|
.nickname {
|
||||||
font-size: 18px;
|
font-size: 30px;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.level {
|
.level {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 8px;
|
padding: 4px 10px;
|
||||||
background: linear-gradient(to right, #00a1d6, #fb7299);
|
background: linear-gradient(to right, #fb7299, #ff8fab);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border-radius: 10px;
|
border-radius: 999px;
|
||||||
margin-bottom: 8px;
|
}
|
||||||
|
|
||||||
|
.region {
|
||||||
|
color: #9499a0;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signature {
|
.signature {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
color: #666;
|
color: #61666d;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: space-around;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
padding: 16px 0;
|
gap: 14px;
|
||||||
border-top: 1px solid #eee;
|
padding: 18px 0;
|
||||||
border-bottom: 1px solid #eee;
|
border-top: 1px solid #f1f2f3;
|
||||||
|
border-bottom: 1px solid #f1f2f3;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-item {
|
.stat-item {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 64px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-radius: 18px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: bold;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #9499a0;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: flex-end;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-tabs {
|
||||||
|
margin-top: 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 28px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-grid {
|
.video-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 12px;
|
gap: 18px 16px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.profile-shell {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-banner {
|
||||||
|
height: 160px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-header {
|
||||||
|
margin-top: -28px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
<van-nav-bar title="设置" fixed placeholder left-arrow @click-left="router.back()" />
|
<div class="settings-shell">
|
||||||
|
<section class="settings-hero">
|
||||||
|
<h2>账号设置</h2>
|
||||||
|
<p>管理个人资料、隐私选项、通知偏好和应用信息。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="settings-column">
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell title="账号与安全" is-link to="/settings/profile" />
|
<van-cell title="账号与安全" is-link to="/settings/profile" />
|
||||||
<van-cell title="隐私设置" is-link to="/settings/privacy" />
|
<van-cell title="隐私设置" is-link to="/settings/privacy" />
|
||||||
<van-cell title="通知设置" is-link to="/settings/notification" />
|
<van-cell title="通知设置" is-link to="/settings/notification" />
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-column">
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell title="清除缓存" :value="cacheSize" @click="clearCache" />
|
<van-cell title="清除缓存" :value="cacheSize" @click="clearCache" />
|
||||||
<van-cell title="关于我们" is-link to="/about" />
|
<van-cell title="关于我们" is-link to="/about" />
|
||||||
@@ -18,6 +26,9 @@
|
|||||||
<van-button type="danger" block @click="handleLogout">退出登录</van-button>
|
<van-button type="danger" block @click="handleLogout">退出登录</van-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -54,15 +65,54 @@ const handleLogout = async () => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.settings-page {
|
.settings-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
background: #f6f7fb;
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
.van-cell-group {
|
.settings-shell {
|
||||||
margin: 12px;
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 12px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hero {
|
||||||
|
padding: 24px 26px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-column :deep(.van-cell-group) {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
padding: 20px 16px;
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const loadVideos = async (isRefresh = false) => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await videoApi.getUserVideos(userId, { page: currentPage.value, size: 20 })
|
const res = await videoApi.getUserVideos(userId, { pageNum: currentPage.value, pageSize: 20 })
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
videos.value = res.records
|
videos.value = res.records
|
||||||
} else {
|
} else {
|
||||||
@@ -82,14 +82,16 @@ onMounted(() => {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.user-videos-page {
|
.user-videos-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
background: #f6f7fb;
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-grid {
|
.video-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 12px;
|
gap: 18px 16px;
|
||||||
padding: 12px;
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 12px 24px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="video-detail-page">
|
<div class="video-detail-page">
|
||||||
<!-- 视频播放器 -->
|
<div class="detail-shell">
|
||||||
|
<section class="detail-main">
|
||||||
<div class="player-container">
|
<div class="player-container">
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
v-if="video"
|
v-if="video"
|
||||||
@@ -13,25 +14,21 @@
|
|||||||
<van-skeleton v-else title :row="3" />
|
<van-skeleton v-else title :row="3" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 视频信息 -->
|
|
||||||
<div class="video-info" v-if="video">
|
<div class="video-info" v-if="video">
|
||||||
<h1 class="video-title">{{ video.title }}</h1>
|
<h1 class="video-title">{{ video.title }}</h1>
|
||||||
|
|
||||||
<!-- 统计数据 -->
|
|
||||||
<div class="video-stats">
|
<div class="video-stats">
|
||||||
<span>{{ formatPlayCount(video.viewCount) }}播放</span>
|
<span>{{ formatPlayCount(video.viewCount) }}播放</span>
|
||||||
<span>{{ formatNumber(video.danmakuCount) }}弹幕</span>
|
<span>{{ formatNumber(video.danmakuCount) }}弹幕</span>
|
||||||
<span>{{ formatDateTime(video.publishTime || video.createTime) }}</span>
|
<span>{{ formatDateTime(video.publishTime || video.createTime) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
|
||||||
<div class="video-actions">
|
<div class="video-actions">
|
||||||
<van-button size="small" :type="video.isLiked ? 'primary' : 'default'" @click="toggleLike">
|
<van-button size="small" :type="video.isLiked ? 'primary' : 'default'" @click="toggleLike">
|
||||||
<van-icon :name="video.isLiked ? 'good-job' : 'good-job-o'" />
|
<van-icon :name="video.isLiked ? 'good-job' : 'good-job-o'" />
|
||||||
{{ formatNumber(video.likeCount) }}
|
{{ formatNumber(video.likeCount) }}
|
||||||
</van-button>
|
</van-button>
|
||||||
<van-button size="small" :type="video.isCoined ? 'primary' : 'default'" @click="toggleCoin">
|
<van-button size="small" :type="video.isCoined ? 'primary' : 'default'" @click="toggleCoin">
|
||||||
<van-icon name="coin" />
|
<van-icon name="gold-coin-o" />
|
||||||
{{ formatNumber(video.coinCount) }}
|
{{ formatNumber(video.coinCount) }}
|
||||||
</van-button>
|
</van-button>
|
||||||
<van-button size="small" :type="video.isFavorited ? 'primary' : 'default'" @click="toggleFavorite">
|
<van-button size="small" :type="video.isFavorited ? 'primary' : 'default'" @click="toggleFavorite">
|
||||||
@@ -44,23 +41,17 @@
|
|||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 作者信息 -->
|
|
||||||
<div class="author-info" @click="goToUser(video.userId)">
|
<div class="author-info" @click="goToUser(video.userId)">
|
||||||
<van-image :src="video.userAvatar || defaultAvatar" round class="author-avatar" />
|
<van-image :src="video.userAvatar || defaultAvatar" round class="author-avatar" />
|
||||||
<div class="author-detail">
|
<div class="author-detail">
|
||||||
<div class="author-name">{{ video.userNickname }}</div>
|
<div class="author-name">{{ video.userNickname }}</div>
|
||||||
<div class="author-fans">{{ formatNumber(video.likeCount) }}粉丝</div>
|
<div class="author-fans">{{ formatNumber(video.likeCount) }}粉丝</div>
|
||||||
</div>
|
</div>
|
||||||
<van-button
|
<van-button size="small" :type="isFollowed ? 'default' : 'primary'" @click.stop="toggleFollow">
|
||||||
size="small"
|
|
||||||
:type="isFollowed ? 'default' : 'primary'"
|
|
||||||
@click.stop="toggleFollow"
|
|
||||||
>
|
|
||||||
{{ isFollowed ? '已关注' : '关注' }}
|
{{ isFollowed ? '已关注' : '关注' }}
|
||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 简介 -->
|
|
||||||
<div class="video-desc">
|
<div class="video-desc">
|
||||||
<van-collapse v-model="activeCollapse">
|
<van-collapse v-model="activeCollapse">
|
||||||
<van-collapse-item title="简介" name="desc">
|
<van-collapse-item title="简介" name="desc">
|
||||||
@@ -73,37 +64,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 标签页 -->
|
|
||||||
<van-tabs v-model:active="activeTab" sticky class="detail-tabs">
|
<van-tabs v-model:active="activeTab" sticky class="detail-tabs">
|
||||||
<!-- 评论 -->
|
|
||||||
<van-tab title="评论" name="comment">
|
<van-tab title="评论" name="comment">
|
||||||
<CommentSection v-if="video" :video-id="video.id" />
|
<CommentSection v-if="video" :video-id="video.id" />
|
||||||
</van-tab>
|
</van-tab>
|
||||||
|
|
||||||
<!-- 弹幕 -->
|
|
||||||
<van-tab title="弹幕" name="danmaku">
|
<van-tab title="弹幕" name="danmaku">
|
||||||
<DanmakuList v-if="video" :video-id="video.id" />
|
<DanmakuList v-if="video" :video-id="video.id" />
|
||||||
</van-tab>
|
</van-tab>
|
||||||
|
|
||||||
<!-- 相关推荐 -->
|
|
||||||
<van-tab title="推荐" name="recommend">
|
<van-tab title="推荐" name="recommend">
|
||||||
<div class="recommend-list">
|
<div class="recommend-list mobile-recommend">
|
||||||
<VideoCard
|
<VideoCard v-for="item in recommendVideos" :key="item.id" :video="item" @click="goToVideo(item.id)" />
|
||||||
v-for="item in recommendVideos"
|
|
||||||
:key="item.id"
|
|
||||||
:video="item"
|
|
||||||
@click="goToVideo(item.id)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</van-tab>
|
</van-tab>
|
||||||
</van-tabs>
|
</van-tabs>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="detail-sidebar">
|
||||||
|
<div class="sidebar-card">
|
||||||
|
<div class="sidebar-title">相关推荐</div>
|
||||||
|
<div class="sidebar-video-list">
|
||||||
|
<VideoCard v-for="item in recommendVideos" :key="`side-${item.id}`" :video="item" @click="goToVideo(item.id)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { showToast, showSuccessToast, showConfirmDialog } from 'vant'
|
import { showToast, showSuccessToast } from 'vant'
|
||||||
import VideoPlayer from '@/components/video/VideoPlayer.vue'
|
import VideoPlayer from '@/components/video/VideoPlayer.vue'
|
||||||
import CommentSection from '@/components/comment/CommentSection.vue'
|
import CommentSection from '@/components/comment/CommentSection.vue'
|
||||||
import DanmakuList from '@/components/danmaku/DanmakuList.vue'
|
import DanmakuList from '@/components/danmaku/DanmakuList.vue'
|
||||||
@@ -134,13 +125,17 @@ const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/s
|
|||||||
// 加载视频详情
|
// 加载视频详情
|
||||||
const loadVideo = async () => {
|
const loadVideo = async () => {
|
||||||
try {
|
try {
|
||||||
video.value = await videoApi.getDetail(videoId.value)
|
video.value = await videoApi.getVideoPublic(videoId.value)
|
||||||
// 记录播放
|
// 记录播放
|
||||||
|
try {
|
||||||
videoApi.recordPlay({
|
videoApi.recordPlay({
|
||||||
videoId: videoId.value,
|
videoId: videoId.value,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
duration: video.value.duration
|
duration: video.value.duration
|
||||||
})
|
})
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略播放记录错误
|
||||||
|
}
|
||||||
// 加载弹幕
|
// 加载弹幕
|
||||||
loadDanmaku()
|
loadDanmaku()
|
||||||
// 加载推荐
|
// 加载推荐
|
||||||
@@ -157,7 +152,7 @@ const loadVideo = async () => {
|
|||||||
// 加载弹幕
|
// 加载弹幕
|
||||||
const loadDanmaku = async () => {
|
const loadDanmaku = async () => {
|
||||||
try {
|
try {
|
||||||
danmakuList.value = await danmakuApi.getAll(videoId.value)
|
danmakuList.value = await danmakuApi.getAllDanmaku(videoId.value)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载弹幕失败:', error)
|
console.error('加载弹幕失败:', error)
|
||||||
}
|
}
|
||||||
@@ -166,8 +161,8 @@ const loadDanmaku = async () => {
|
|||||||
// 加载推荐
|
// 加载推荐
|
||||||
const loadRecommend = async () => {
|
const loadRecommend = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await recommendApi.getSimilar(videoId.value, { page: 1, size: 10 })
|
const res = await recommendApi.getSimilarVideos(videoId.value, 10)
|
||||||
recommendVideos.value = res.records
|
recommendVideos.value = res
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载推荐失败:', error)
|
console.error('加载推荐失败:', error)
|
||||||
}
|
}
|
||||||
@@ -193,11 +188,11 @@ const toggleLike = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (video.value.isLiked) {
|
if (video.value.isLiked) {
|
||||||
await videoApi.unlike(video.value.id)
|
await videoApi.unlikeVideo(video.value.id)
|
||||||
video.value.likeCount--
|
video.value.likeCount--
|
||||||
video.value.isLiked = false
|
video.value.isLiked = false
|
||||||
} else {
|
} else {
|
||||||
await videoApi.like(video.value.id)
|
await videoApi.likeVideo(video.value.id)
|
||||||
video.value.likeCount++
|
video.value.likeCount++
|
||||||
video.value.isLiked = true
|
video.value.isLiked = true
|
||||||
}
|
}
|
||||||
@@ -215,7 +210,7 @@ const toggleCoin = async () => {
|
|||||||
if (!video.value) return
|
if (!video.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await videoApi.coin(video.value.id)
|
await videoApi.coinVideo(video.value.id)
|
||||||
video.value.coinCount++
|
video.value.coinCount++
|
||||||
showSuccessToast('投币成功')
|
showSuccessToast('投币成功')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -233,12 +228,12 @@ const toggleFavorite = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (video.value.isFavorited) {
|
if (video.value.isFavorited) {
|
||||||
await videoApi.unfavorite(video.value.id)
|
await videoApi.unfavoriteVideo(video.value.id)
|
||||||
video.value.favoriteCount--
|
video.value.favoriteCount--
|
||||||
video.value.isFavorited = false
|
video.value.isFavorited = false
|
||||||
} else {
|
} else {
|
||||||
// TODO: 选择收藏夹
|
// TODO: 选择收藏夹
|
||||||
await videoApi.favorite({ videoId: video.value.id, favoriteId: 0 })
|
await videoApi.favoriteVideo(video.value.id, 0)
|
||||||
video.value.favoriteCount++
|
video.value.favoriteCount++
|
||||||
video.value.isFavorited = true
|
video.value.isFavorited = true
|
||||||
}
|
}
|
||||||
@@ -253,7 +248,7 @@ const shareVideo = async () => {
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(url)
|
await navigator.clipboard.writeText(url)
|
||||||
showSuccessToast('链接已复制')
|
showSuccessToast('链接已复制')
|
||||||
videoApi.share(videoId.value)
|
videoApi.shareVideo(videoId.value)
|
||||||
} catch {
|
} catch {
|
||||||
showToast('复制失败')
|
showToast('复制失败')
|
||||||
}
|
}
|
||||||
@@ -305,33 +300,54 @@ const onEnded = () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadVideo()
|
loadVideo()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => route.params.id, () => {
|
||||||
|
video.value = null
|
||||||
|
danmakuList.value = []
|
||||||
|
recommendVideos.value = []
|
||||||
|
isFollowed.value = false
|
||||||
|
loadVideo()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.video-detail-page {
|
.video-detail-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
background:
|
||||||
|
linear-gradient(180deg, rgba(14, 165, 233, 0.06), transparent 200px),
|
||||||
|
#f4f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-shell {
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 18px 12px 28px;
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 360px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-container {
|
.player-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #000;
|
background: #000;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
position: sticky;
|
border-radius: 24px;
|
||||||
top: 0;
|
overflow: hidden;
|
||||||
z-index: 100;
|
box-shadow: 0 22px 48px rgba(15, 23, 42, 0.14);
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
max-height: 70vh;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-info {
|
.video-info {
|
||||||
background: #fff;
|
background: rgba(255, 255, 255, 0.96);
|
||||||
padding: 12px 16px;
|
padding: 18px 18px 14px;
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.06);
|
||||||
|
margin-top: 16px;
|
||||||
|
|
||||||
.video-title {
|
.video-title {
|
||||||
font-size: 18px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: #333;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
@@ -350,9 +366,10 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.van-button {
|
.van-button {
|
||||||
flex: 1;
|
flex: 1 1 120px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,18 +424,51 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-tabs {
|
.detail-tabs {
|
||||||
background: #fff;
|
background: rgba(255, 255, 255, 0.96);
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recommend-list {
|
.recommend-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 12px;
|
gap: 18px 16px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
.detail-sidebar {
|
||||||
grid-template-columns: repeat(4, 1fr);
|
display: none;
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-card {
|
||||||
|
position: sticky;
|
||||||
|
top: 78px;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #18191c;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-video-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-recommend {
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
|||||||
AutoImport({
|
AutoImport({
|
||||||
imports: ['vue', 'vue-router', 'pinia'],
|
imports: ['vue', 'vue-router', 'pinia'],
|
||||||
dts: 'src/auto-imports.d.ts',
|
dts: 'src/auto-imports.d.ts',
|
||||||
dirs: ['src/stores', 'src/composables'],
|
dirs: ['src/composables'], // 只扫描 composables,stores 使用显式导入
|
||||||
vueTemplate: true
|
vueTemplate: true
|
||||||
}),
|
}),
|
||||||
Components({
|
Components({
|
||||||
@@ -39,6 +39,13 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
silenceDeprecations: ['legacy-js-api'] // 静默 Sass legacy API 弃用警告
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
target: 'es2015',
|
target: 'es2015',
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
|||||||
Reference in New Issue
Block a user