feat(frontend): Align admin and user API integration

This commit is contained in:
2026-04-18 18:21:31 +08:00
parent 3adb088eca
commit 2247d235ec
74 changed files with 8626 additions and 647 deletions
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
*/ */
+37
View File
@@ -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 {
+6
View File
@@ -0,0 +1,6 @@
# Mock 模式配置
# 用于无后端情况下的前端界面展示
VITE_APP_TITLE=HAOVP视频平台
VITE_API_BASE_URL=/api
VITE_MOCK=true
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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', () => {
// 未读私信数量 // 未读私信数量
+26 -1
View File
@@ -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>
+8 -1
View File
@@ -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',