refactor(project): Reorganize frontend and backend directories

This commit is contained in:
2026-03-18 10:45:15 +08:00
parent cc9eaf4cdf
commit 92a139e470
513 changed files with 28251 additions and 287 deletions
+14
View File
@@ -0,0 +1,14 @@
<template>
<router-view />
</template>
<script setup lang="ts">
// App 根组件
</script>
<style>
#app {
width: 100%;
min-height: 100vh;
}
</style>
@@ -0,0 +1,62 @@
import request from '@/utils/request.ts'
import type {
LoginParams,
LoginResult,
RegisterParams,
CaptchaResult,
AuthUser
} from '@/types'
/**
* 认证相关 API
*/
export const authApi = {
/**
* 用户登录
*/
login(params: LoginParams): Promise<LoginResult> {
return request.post('/auth/login', params)
},
/**
* 用户注册
*/
register(params: RegisterParams): Promise<string> {
return request.post('/auth/register', params)
},
/**
* 获取验证码
*/
getCaptcha(): Promise<CaptchaResult> {
return request.get('/auth/captcha')
},
/**
* 刷新 Token
*/
refreshToken(refreshToken: string): Promise<LoginResult> {
return request.post('/auth/refresh', { refreshToken })
},
/**
* 用户登出
*/
logout(): Promise<void> {
return request.post('/auth/logout')
},
/**
* 获取当前用户信息
*/
getCurrentUser(): Promise<AuthUser> {
return request.get('/auth/current')
},
/**
* 根据用户ID获取用户名
*/
getUsername(userId: number): Promise<string> {
return request.get(`/auth/username/${userId}`)
}
}
@@ -0,0 +1,47 @@
import request from '@/utils/request.ts'
import type { Category, Tag, PageResult, PageParams } from '@/types'
/**
* 分类相关 API
*/
export const categoryApi = {
/**
* 获取分类树
*/
getCategoryTree(): Promise<Category[]> {
return request.get('/content/category/tree')
},
/**
* 获取分类列表
*/
getCategoryList(): Promise<Category[]> {
return request.get('/content/category/list')
}
}
/**
* 标签相关 API
*/
export const tagApi = {
/**
* 获取标签列表
*/
getTagList(): Promise<Tag[]> {
return request.get('/content/tag/list')
},
/**
* 分页获取标签
*/
getTagPage(params?: PageParams): Promise<PageResult<Tag>> {
return request.get('/content/tag/page', params)
},
/**
* 获取视频标签
*/
getVideoTags(videoId: number): Promise<Tag[]> {
return request.get(`/content/tag/video/${videoId}`)
}
}
@@ -0,0 +1,114 @@
import request from '@/utils/request.ts'
import type { Comment, CommentSendParams, CommentQueryParams, PageResult } from '@/types'
/**
* 评论相关 API
*/
export const commentApi = {
/**
* 发表评论
*/
addComment(params: CommentSendParams): Promise<Comment> {
return request.post('/comment', params)
},
/**
* 获取评论列表(公开接口)
*/
getCommentList(params: CommentQueryParams): Promise<PageResult<Comment>> {
return request.get('/comment/public/list', params)
},
/**
* 获取评论详情(公开接口)
*/
getCommentDetail(id: number): Promise<Comment> {
return request.get(`/comment/public/detail/${id}`)
},
/**
* 删除评论
*/
deleteComment(id: number): Promise<void> {
return request.delete(`/comment/${id}`)
},
/**
* 点赞评论
*/
likeComment(id: number): Promise<void> {
return request.post(`/comment/like/${id}`)
},
/**
* 取消点赞评论
*/
unlikeComment(id: number): Promise<void> {
return request.delete(`/comment/like/${id}`)
},
/**
* 置顶评论
*/
topComment(id: number): Promise<void> {
return request.post(`/comment/top/${id}`)
},
/**
* 取消置顶评论
*/
untopComment(id: number): Promise<void> {
return request.delete(`/comment/top/${id}`)
},
/**
* 获取评论统计信息(公开接口)
*/
getCommentStat(videoId: number): Promise<{ total: number; hotCount: number }> {
return request.get(`/comment/public/stat/${videoId}`)
},
// ==================== 回复相关 ====================
/**
* 添加回复
*/
addReply(params: { commentId: number; content: string; replyId?: number }): Promise<Comment> {
return request.post('/comment/reply', params)
},
/**
* 获取评论的回复列表(公开接口)
*/
getReplyList(commentId: number, params?: { page?: number; size?: number }): Promise<PageResult<Comment>> {
return request.get(`/comment/reply/public/list/${commentId}`, params)
},
/**
* 获取评论的热门回复(公开接口)
*/
getHotReplies(commentId: number, limit?: number): Promise<Comment[]> {
return request.get(`/comment/reply/public/hot/${commentId}`, { limit })
},
/**
* 删除回复
*/
deleteReply(id: number): Promise<void> {
return request.delete(`/comment/reply/${id}`)
},
/**
* 点赞回复
*/
likeReply(id: number): Promise<void> {
return request.post(`/comment/reply/like/${id}`)
},
/**
* 取消点赞回复
*/
unlikeReply(id: number): Promise<void> {
return request.delete(`/comment/reply/like/${id}`)
}
}
@@ -0,0 +1,28 @@
import request from '@/utils/request.ts'
import type { Danmaku, DanmakuSendParams } from '@/types'
/**
* 弹幕相关 API
*/
export const danmakuApi = {
/**
* 发送弹幕
*/
sendDanmaku(params: DanmakuSendParams): Promise<Danmaku> {
return request.post('/content/danmaku', params)
},
/**
* 获取视频指定时间段的弹幕
*/
getDanmakuByTime(videoId: number, startTime: number, endTime: number): Promise<Danmaku[]> {
return request.get('/content/danmaku/time', { videoId, startTime, endTime })
},
/**
* 获取视频所有弹幕
*/
getAllDanmaku(videoId: number): Promise<Danmaku[]> {
return request.get(`/content/danmaku/all/${videoId}`)
}
}
@@ -0,0 +1,42 @@
import request from '@/utils/request.ts'
import type { Favorite, FavoriteCreateParams, FavoriteVideoParams, VideoInfo, PageResult, PageParams } from '@/types'
/**
* 收藏夹相关 API
*/
export const favoriteApi = {
/**
* 获取用户收藏夹列表
*/
getFavoriteList(): Promise<Favorite[]> {
return request.get('/content/favorite/list')
},
/**
* 创建收藏夹
*/
createFavorite(params: FavoriteCreateParams): Promise<Favorite> {
return request.post('/content/favorite', params)
},
/**
* 更新收藏夹
*/
updateFavorite(params: Partial<Favorite> & { id: number }): Promise<void> {
return request.put('/content/favorite', params)
},
/**
* 删除收藏夹
*/
deleteFavorite(id: number): Promise<void> {
return request.delete(`/content/favorite/${id}`)
},
/**
* 获取收藏夹中的视频
*/
getFavoriteVideos(favoriteId: number, params?: PageParams): Promise<PageResult<VideoInfo>> {
return request.get(`/content/favorite/${favoriteId}/videos`, params)
}
}
@@ -0,0 +1,30 @@
import request from '@/utils/request.ts'
/**
* 文件上传 API
*/
export const uploadApi = {
/**
* 上传图片
*/
uploadImage(file: File, onProgress?: (percent: number) => void): Promise<string> {
return request.upload('/user/profile/avatar', file, onProgress)
},
/**
* 上传视频
*/
uploadVideo(file: File, onProgress?: (percent: number) => void): Promise<{ videoUrl: string; coverUrl: string }> {
return request.upload('/content/video/upload', file, onProgress)
}
}
// 导出所有 API
export * from './auth.ts'
export * from './user.ts'
export * from './video.ts'
export * from './comment.ts'
export * from './category.ts'
export * from './danmaku.ts'
export * from './favorite.ts'
export * from './recommend.ts'
@@ -0,0 +1,52 @@
import request from '@/utils/request.ts'
import type { VideoInfo, RecommendVideo } from '@/types'
/**
* 推荐相关 API
*/
export const recommendApi = {
/**
* 获取首页推荐
*/
getHomeRecommend(limit?: number): Promise<VideoInfo[]> {
return request.get('/recommend/home', { limit })
},
/**
* 获取热门推荐
*/
getHotRecommend(params?: { page?: number; size?: number }): Promise<VideoInfo[]> {
return request.get('/recommend/hot', params)
},
/**
* 获取相似视频推荐
*/
getSimilarVideos(videoId: number, limit?: number): Promise<VideoInfo[]> {
return request.get(`/recommend/similar/${videoId}`, { limit })
},
/**
* 获取标签视频推荐
*/
getTagVideos(tagId: number, params?: { page?: number; size?: number }): Promise<VideoInfo[]> {
return request.get(`/recommend/tag/${tagId}`, params)
},
/**
* 记录用户行为
*/
recordBehavior(params: {
videoId: number
actionType: 'view' | 'like' | 'coin' | 'favorite' | 'share' | 'comment'
}): Promise<void> {
return request.post('/recommend/behavior', params)
},
/**
* 更新用户兴趣
*/
updateInterest(params: { tagIds: number[] }): Promise<void> {
return request.post('/recommend/interest/update', params)
}
}
+183
View File
@@ -0,0 +1,183 @@
import request from '@/utils/request.ts'
import type {
UserInfo,
UserProfileUpdateParams,
UserFollow,
UserSetting,
Message,
MessageSendParams,
Notification,
PageParams,
PageResult
} from '@/types'
/**
* 用户相关 API
*/
export const userApi = {
// ==================== 用户资料 ====================
/**
* 获取当前用户资料
*/
getCurrentProfile(): Promise<UserInfo> {
return request.get('/user/profile/current')
},
/**
* 获取指定用户资料
*/
getPublicProfile(userId: number): Promise<UserInfo> {
return request.get(`/user/profile/public/${userId}`)
},
/**
* 更新用户资料
*/
updateProfile(params: UserProfileUpdateParams): Promise<void> {
return request.put('/user/profile', params)
},
/**
* 上传头像
*/
uploadAvatar(file: File, onProgress?: (percent: number) => void): Promise<string> {
return request.upload('/user/profile/avatar', file, onProgress)
},
/**
* 搜索用户
*/
searchUsers(keyword: string, params?: PageParams): Promise<PageResult<UserInfo>> {
return request.get('/user/profile/search', { keyword, ...params })
},
// ==================== 用户关注 ====================
/**
* 关注用户
*/
follow(followId: number): Promise<void> {
return request.post(`/user/follow/${followId}`)
},
/**
* 取消关注
*/
unfollow(followId: number): Promise<void> {
return request.delete(`/user/follow/${followId}`)
},
/**
* 获取关注列表
*/
getFollowing(userId: number, params?: PageParams): Promise<UserFollow[]> {
return request.get(`/user/follow/following/${userId}`, params)
},
/**
* 获取粉丝列表
*/
getFollowers(userId: number, params?: PageParams): Promise<UserFollow[]> {
return request.get(`/user/follow/followers/${userId}`, params)
},
/**
* 检查是否已关注
*/
checkFollow(followId: number): Promise<boolean> {
return request.get(`/user/follow/check/${followId}`)
},
// 兼容旧名称
followUser: function(followId: number): Promise<void> {
return this.follow(followId)
},
unfollowUser: function(followId: number): Promise<void> {
return this.unfollow(followId)
},
// ==================== 用户私信 ====================
/**
* 发送私信
*/
sendMessage(params: MessageSendParams): Promise<Message> {
return request.post('/user/message', params)
},
/**
* 获取私信会话列表
*/
getMessageList(params?: PageParams): Promise<Message[]> {
return request.get('/user/message/list', params)
},
/**
* 获取与指定用户的私信记录
*/
getMessageDetail(targetId: number, params?: PageParams): Promise<Message[]> {
return request.get(`/user/message/detail/${targetId}`, params)
},
/**
* 标记私信为已读
*/
markMessageRead(senderId: number): Promise<void> {
return request.put(`/user/message/read/${senderId}`)
},
/**
* 获取未读私信数量
*/
getUnreadMessageCount(): Promise<number> {
return request.get('/user/message/unread/count')
},
// ==================== 用户通知 ====================
/**
* 获取通知列表
*/
getNotifications(type?: number, params?: PageParams): Promise<Notification[]> {
return request.get('/user/notification/list', { type, ...params })
},
/**
* 标记所有通知为已读
*/
markAllNotificationsRead(): Promise<void> {
return request.put('/user/notification/read')
},
/**
* 标记指定通知为已读
*/
markNotificationRead(notificationId: number): Promise<void> {
return request.put(`/user/notification/read/${notificationId}`)
},
/**
* 获取未读通知数量
*/
getUnreadNotificationCount(): Promise<number> {
return request.get('/user/notification/unread/count')
},
// ==================== 用户设置 ====================
/**
* 获取用户设置
*/
getSettings(): Promise<UserSetting> {
return request.get('/user/setting')
},
/**
* 更新用户设置
*/
updateSettings(params: Partial<UserSetting>): Promise<void> {
return request.put('/user/setting', params)
}
}
@@ -0,0 +1,142 @@
import request from '@/utils/request.ts'
import type {
VideoInfo,
VideoCreateParams,
VideoUpdateParams,
VideoQueryParams,
VideoUploadParams,
PlayRecord,
PageResult
} from '@/types'
/**
* 视频相关 API
*/
export const videoApi = {
// ==================== 视频上传 ====================
/**
* 上传视频文件
*/
uploadVideo(params: VideoUploadParams): Promise<{ videoUrl: string; coverUrl: string }> {
return request.upload('/content/video/upload', params.file, params.onProgress)
},
/**
* 创建视频记录
*/
createVideo(params: VideoCreateParams): Promise<VideoInfo> {
return request.post('/content/video', params)
},
// ==================== 视频查询 ====================
/**
* 获取视频详情(公开接口)
*/
getVideoPublic(id: number): Promise<VideoInfo> {
return request.get(`/content/video/public/${id}`)
},
/**
* 获取视频列表(公开接口)
*/
getVideoPublicList(params?: VideoQueryParams): Promise<PageResult<VideoInfo>> {
return request.get('/content/video/public/list', params)
},
/**
* 获取视频详情(需登录)
*/
getVideo(id: number): Promise<VideoInfo> {
return request.get(`/content/video/${id}`)
},
/**
* 获取视频列表(需登录)
*/
getVideoList(params?: VideoQueryParams): Promise<PageResult<VideoInfo>> {
return request.get('/content/video/list', params)
},
/**
* 获取用户视频列表
*/
getUserVideos(userId: number, params?: VideoQueryParams): Promise<PageResult<VideoInfo>> {
return request.get(`/content/video/user/${userId}`, params)
},
// ==================== 视频管理 ====================
/**
* 更新视频信息
*/
updateVideo(params: VideoUpdateParams): Promise<void> {
return request.put('/content/video', params)
},
/**
* 删除视频
*/
deleteVideo(id: number): Promise<void> {
return request.delete(`/content/video/${id}`)
},
// ==================== 视频互动 ====================
/**
* 点赞视频
*/
likeVideo(videoId: number): Promise<void> {
return request.post(`/content/video/like/${videoId}`)
},
/**
* 取消点赞
*/
unlikeVideo(videoId: number): Promise<void> {
return request.delete(`/content/video/like/${videoId}`)
},
/**
* 投币
*/
coinVideo(videoId: number): Promise<void> {
return request.post(`/content/video/coin/${videoId}`)
},
/**
* 收藏视频
*/
favoriteVideo(videoId: number, favoriteId: number): Promise<void> {
return request.post('/content/video/favorite', { videoId, favoriteId })
},
/**
* 取消收藏
*/
unfavoriteVideo(videoId: number): Promise<void> {
return request.delete(`/content/video/favorite/${videoId}`)
},
/**
* 分享视频
*/
shareVideo(videoId: number): Promise<void> {
return request.post(`/content/video/share/${videoId}`)
},
/**
* 记录播放
*/
recordPlay(params: PlayRecord): Promise<void> {
return request.post('/content/video/play', params)
},
/**
* 增加评论数量(内部接口)
*/
incrementCommentCount(videoId: number, count: number): Promise<void> {
return request.post(`/content/video/count/comment/${videoId}/${count}`)
}
}
+177
View File
@@ -0,0 +1,177 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAppStore: typeof import('./stores')['useAppStore']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useFavoriteStore: typeof import('./stores')['useFavoriteStore']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useMessageStore: typeof import('./stores/message.ts')['useMessageStore']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useUserStore: typeof import('./stores/user.ts')['useUserStore']
const useVideoStore: typeof import('./stores/video.ts')['useVideoStore']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly useAppStore: UnwrapRef<typeof import('./stores')['useAppStore']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useFavoriteStore: UnwrapRef<typeof import('./stores')['useFavoriteStore']>
readonly useId: UnwrapRef<typeof import('vue')['useId']>
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 useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
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 watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
}
}
+37
View File
@@ -0,0 +1,37 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
AppHeader: typeof import('./components/layout/AppHeader.vue')['default']
AppTabbar: typeof import('./components/layout/AppTabbar.vue')['default']
CommentSection: typeof import('./components/comment/CommentSection.vue')['default']
DanmakuList: typeof import('./components/danmaku/DanmakuList.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
VanBadge: typeof import('vant/es')['Badge']
VanButton: typeof import('vant/es')['Button']
VanCell: typeof import('vant/es')['Cell']
VanCellGroup: typeof import('vant/es')['CellGroup']
VanField: typeof import('vant/es')['Field']
VanForm: typeof import('vant/es')['Form']
VanIcon: typeof import('vant/es')['Icon']
VanImage: typeof import('vant/es')['Image']
VanList: typeof import('vant/es')['List']
VanLoading: typeof import('vant/es')['Loading']
VanNavBar: typeof import('vant/es')['NavBar']
VanPopover: typeof import('vant/es')['Popover']
VanPullRefresh: typeof import('vant/es')['PullRefresh']
VanSearch: typeof import('vant/es')['Search']
VanTab: typeof import('vant/es')['Tab']
VanTabbar: typeof import('vant/es')['Tabbar']
VanTabbarItem: typeof import('vant/es')['TabbarItem']
VanTabs: typeof import('vant/es')['Tabs']
VideoCard: typeof import('./components/video/VideoCard.vue')['default']
VideoPlayer: typeof import('./components/video/VideoPlayer.vue')['default']
}
}
@@ -0,0 +1,261 @@
<template>
<div class="comment-section">
<!-- 评论输入 -->
<div class="comment-input">
<van-field
v-model="content"
type="textarea"
placeholder="发一条友善的评论吧"
rows="2"
autosize
maxlength="500"
show-word-limit
/>
<div class="input-actions">
<van-button type="primary" size="small" :loading="submitting" @click="submitComment">
发表评论
</van-button>
</div>
</div>
<!-- 评论列表 -->
<div class="comment-list">
<van-tabs v-model:active="sortBy" @change="loadComments">
<van-tab title="最热" name="hot" />
<van-tab title="最新" name="time" />
</van-tabs>
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多评论了"
@load="loadMore"
>
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-avatar">
<van-image :src="comment.userAvatar || defaultAvatar" round fit="cover" />
</div>
<div class="comment-content">
<div class="comment-header">
<span class="username">{{ comment.userNickname }}</span>
<span class="time">{{ formatRelativeTime(comment.createTime) }}</span>
</div>
<div class="comment-text">{{ comment.content }}</div>
<div class="comment-actions">
<span @click="toggleLike(comment)">
<van-icon :name="comment.isLiked ? 'good-job' : 'good-job-o'" />
{{ comment.likeCount || '' }}
</span>
<span @click="replyTo(comment)">
<van-icon name="chat-o" />
回复
</span>
</div>
</div>
</div>
</van-list>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { showToast, showSuccessToast } from 'vant'
import { commentApi } from '@/api/comment.ts'
import { useUserStore } from '@/stores/user.ts'
import { formatRelativeTime } from '@/utils'
import type { Comment } from '@/types'
const props = defineProps<{
videoId: number
}>()
const userStore = useUserStore()
const content = ref('')
const sortBy = ref<'hot' | 'time'>('hot')
const comments = ref<Comment[]>([])
const loading = ref(false)
const finished = ref(false)
const submitting = 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 loadComments = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
comments.value = []
finished.value = false
}
loading.value = true
try {
const res = await commentApi.getList({
videoId: props.videoId,
page: currentPage.value,
size: 20,
sortBy: sortBy.value
})
if (isRefresh) {
comments.value = res.records
} else {
comments.value.push(...res.records)
}
if (comments.value.length >= res.total || res.records.length < 20) {
finished.value = true
}
currentPage.value++
} catch (error) {
showToast('加载评论失败')
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!finished.value) {
loadComments()
}
}
const submitComment = async () => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
if (!content.value.trim()) {
showToast('请输入评论内容')
return
}
submitting.value = true
try {
await commentApi.create({
videoId: props.videoId,
content: content.value
})
content.value = ''
showSuccessToast('评论成功')
loadComments(true)
} catch (error: any) {
showToast(error.message || '评论失败')
} finally {
submitting.value = false
}
}
const toggleLike = async (comment: Comment) => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
try {
if (comment.isLiked) {
await commentApi.unlike(comment.id)
comment.likeCount--
} else {
await commentApi.like(comment.id)
comment.likeCount++
}
comment.isLiked = !comment.isLiked
} catch (error) {
showToast('操作失败')
}
}
const replyTo = (comment: Comment) => {
content.value = `@${comment.userNickname} `
}
onMounted(() => {
loadComments()
})
</script>
<style scoped lang="scss">
.comment-section {
background: #fff;
padding: 12px;
}
.comment-input {
margin-bottom: 16px;
.input-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
}
.comment-list {
.comment-item {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.comment-avatar {
flex-shrink: 0;
width: 36px;
height: 36px;
}
.comment-content {
flex: 1;
min-width: 0;
.comment-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.username {
font-size: 14px;
font-weight: 500;
color: #333;
}
.time {
font-size: 12px;
color: #999;
}
}
.comment-text {
font-size: 14px;
color: #333;
line-height: 1.6;
word-break: break-all;
}
.comment-actions {
display: flex;
gap: 16px;
margin-top: 8px;
font-size: 12px;
color: #999;
span {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
&:hover {
color: #00a1d6;
}
}
}
}
}
}
</style>
@@ -0,0 +1,120 @@
<template>
<div class="danmaku-list">
<van-tabs v-model:active="activeTab">
<van-tab title="弹幕列表" name="list">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多弹幕了"
@load="loadMore"
>
<div v-for="item in danmakuList" :key="item.id" class="danmaku-item">
<div class="danmaku-time">{{ formatDanmakuTime(item.time) }}</div>
<div class="danmaku-content" :style="{ color: item.color }">
{{ item.content }}
</div>
<div class="danmaku-user">{{ item.userId }}</div>
</div>
</van-list>
</van-tab>
<van-tab title="弹幕设置" name="settings">
<van-cell-group inset>
<van-cell title="弹幕显示">
<van-switch v-model="settings.showDanmaku" />
</van-cell>
<van-cell title="弹幕透明度">
<van-slider v-model="settings.opacity" :min="0" :max="100" />
</van-cell>
<van-cell title="弹幕大小">
<van-slider v-model="settings.fontSize" :min="12" :max="36" />
</van-cell>
<van-cell title="弹幕速度">
<van-slider v-model="settings.speed" :min="1" :max="10" />
</van-cell>
</van-cell-group>
</van-tab>
</van-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { danmakuApi, type Danmaku } from '@/api/danmaku.ts'
const props = defineProps<{
videoId: number
}>()
const activeTab = ref('list')
const danmakuList = ref<Danmaku[]>([])
const loading = ref(false)
const finished = ref(false)
const settings = reactive({
showDanmaku: true,
opacity: 80,
fontSize: 24,
speed: 5
})
const formatDanmakuTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
const loadMore = async () => {
if (finished.value) return
loading.value = true
try {
const data = await danmakuApi.getAll(props.videoId)
danmakuList.value = data
finished.value = true
} catch (error) {
finished.value = true
} finally {
loading.value = false
}
}
onMounted(() => {
loadMore()
})
</script>
<style scoped lang="scss">
.danmaku-list {
background: #fff;
padding: 12px;
.danmaku-item {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
font-size: 14px;
.danmaku-time {
width: 50px;
color: #999;
font-size: 12px;
}
.danmaku-content {
flex: 1;
padding: 0 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.danmaku-user {
width: 60px;
text-align: right;
color: #999;
font-size: 12px;
}
}
}
</style>
@@ -0,0 +1,323 @@
<template>
<header class="app-header">
<div class="header-content">
<!-- Logo -->
<div class="logo" @click="router.push('/')">
<svg viewBox="0 0 100 100" class="logo-icon">
<defs>
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#00a1d6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fb7299;stop-opacity:1" />
</linearGradient>
</defs>
<circle cx="50" cy="50" r="45" fill="url(#logoGradient)" />
<polygon points="40,30 40,70 75,50" fill="white" />
</svg>
<span class="logo-text">HAOVP</span>
</div>
<!-- 搜索框 -->
<div class="search-box">
<van-search
v-model="searchKeyword"
placeholder="搜索视频、用户"
shape="round"
@search="handleSearch"
>
<template #right-icon>
<van-icon name="search" @click="handleSearch" />
</template>
</van-search>
</div>
<!-- 右侧操作区 -->
<div class="header-actions">
<!-- 投稿按钮 -->
<van-button
v-if="userStore.isLoggedIn"
type="primary"
size="small"
round
@click="router.push('/upload')"
>
<van-icon name="plus" />
投稿
</van-button>
<!-- 未登录 -->
<template v-if="!userStore.isLoggedIn">
<van-button type="primary" size="small" round @click="router.push('/login')">
登录
</van-button>
</template>
<!-- 已登录 -->
<template v-else>
<!-- 消息 -->
<van-popover v-model:show="showMessagePopover" trigger="click" placement="bottom-end">
<div class="message-popover">
<div class="message-item" @click="goToMessages">
<van-icon name="chat-o" />
<span>私信</span>
<van-badge v-if="messageStore.unreadMessageCount" :content="messageStore.unreadMessageCount" />
</div>
<div class="message-item" @click="goToNotifications">
<van-icon name="bell" />
<span>通知</span>
<van-badge v-if="messageStore.unreadNotificationCount" :content="messageStore.unreadNotificationCount" />
</div>
</div>
<template #reference>
<van-badge :content="messageStore.totalUnreadCount || ''" :show-zero="false">
<van-icon name="bell" size="24" class="action-icon" />
</van-badge>
</template>
</van-popover>
<!-- 用户头像 -->
<van-popover v-model:show="showUserPopover" trigger="click" placement="bottom-end">
<div class="user-popover">
<div class="user-info">
<van-image
:src="userStore.userInfo?.avatar || defaultAvatar"
round
width="48"
height="48"
/>
<div class="user-detail">
<div class="nickname">{{ userStore.userInfo?.nickname }}</div>
<div class="level">Lv.{{ userStore.userInfo?.level || 1 }}</div>
</div>
</div>
<van-cell-group inset>
<van-cell title="个人主页" icon="user-o" @click="goToProfile" />
<van-cell title="我的视频" icon="video-o" @click="router.push('/my/videos')" />
<van-cell title="我的收藏" icon="star-o" @click="router.push('/my/favorites')" />
<van-cell title="播放历史" icon="clock-o" @click="router.push('/my/history')" />
<van-cell title="设置" icon="setting-o" @click="router.push('/settings')" />
<van-cell title="退出登录" icon="revoke" @click="handleLogout" />
</van-cell-group>
</div>
<template #reference>
<van-image
:src="userStore.userInfo?.avatar || defaultAvatar"
round
width="32"
height="32"
class="avatar"
/>
</template>
</van-popover>
</template>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore, useMessageStore } from '@/stores'
import { showConfirmDialog, showToast } from 'vant'
const router = useRouter()
const userStore = useUserStore()
const messageStore = useMessageStore()
const searchKeyword = ref('')
const showMessagePopover = ref(false)
const showUserPopover = ref(false)
const defaultAvatar = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSI1MCIgZmlsbD0iI2NjYyIvPjxjaXJjbGUgY3g9IjUwIiBjeT0iMzUiIHI9IjE1IiBmaWxsPSIjOTk5Ii8+PGVsbGlwc2UgY3g9IjUwIiBjeT0iNzUiIHJ4PSIyNSIgcnk9IjIwIiBmaWxsPSIjOTk5Ii8+PC9zdmc+'
// 搜索
const handleSearch = () => {
if (searchKeyword.value.trim()) {
router.push({ path: '/search', query: { keyword: searchKeyword.value.trim() } })
}
}
// 跳转到私信
const goToMessages = () => {
showMessagePopover.value = false
router.push('/messages')
}
// 跳转到通知
const goToNotifications = () => {
showMessagePopover.value = false
router.push('/notifications')
}
// 跳转到个人主页
const goToProfile = () => {
showUserPopover.value = false
if (userStore.userId) {
router.push(`/user/${userStore.userId}`)
}
}
// 退出登录
const handleLogout = async () => {
try {
await showConfirmDialog({
title: '提示',
message: '确定要退出登录吗?'
})
await userStore.logout()
showToast('已退出登录')
router.push('/')
} catch {
// 取消操作
}
}
// 初始化
onMounted(() => {
if (userStore.isLoggedIn) {
messageStore.fetchAllUnreadCount()
}
})
</script>
<style scoped lang="scss">
.app-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--header-height);
background: var(--bg-secondary);
box-shadow: var(--shadow-sm);
z-index: 100;
.header-content {
max-width: var(--max-content-width);
height: 100%;
margin: 0 auto;
padding: 0 var(--spacing-md);
display: flex;
align-items: center;
gap: var(--spacing-lg);
}
}
.logo {
display: flex;
align-items: center;
gap: var(--spacing-sm);
cursor: pointer;
.logo-icon {
width: 36px;
height: 36px;
}
.logo-text {
font-size: 20px;
font-weight: bold;
background: linear-gradient(to right, var(--primary-color), var(--pink-color));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@media (max-width: 768px) {
display: none;
}
}
}
.search-box {
flex: 1;
max-width: 400px;
:deep(.van-search) {
padding: 0;
background: transparent;
.van-search__content {
background: var(--bg-tertiary);
}
}
}
.header-actions {
display: flex;
align-items: center;
gap: var(--spacing-md);
.action-icon {
cursor: pointer;
color: var(--text-secondary);
&:hover {
color: var(--primary-color);
}
}
.avatar {
cursor: pointer;
border: 2px solid transparent;
transition: border-color 0.3s;
&:hover {
border-color: var(--primary-color);
}
}
}
.message-popover,
.user-popover {
padding: var(--spacing-sm);
min-width: 160px;
}
.message-popover {
.message-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
border-radius: var(--radius-sm);
&:hover {
background: var(--bg-tertiary);
}
span {
flex: 1;
}
}
}
.user-popover {
.user-info {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md);
border-bottom: 1px solid var(--border-light);
.user-detail {
.nickname {
font-weight: 500;
}
.level {
font-size: 12px;
color: var(--primary-color);
}
}
}
:deep(.van-cell-group) {
margin-top: var(--spacing-sm);
}
}
@media (max-width: 768px) {
.search-box {
display: none;
}
}
</style>
@@ -0,0 +1,41 @@
<template>
<van-tabbar v-model="active" route fixed active-color="#00a1d6" class="app-tabbar">
<van-tabbar-item to="/" icon="home-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-else to="/login" icon="user-o">我的</van-tabbar-item>
</van-tabbar>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useUserStore } from '@/stores'
const route = useRoute()
const userStore = useUserStore()
const active = ref(0)
// 根据路由设置激活项
watch(
() => route.path,
(path) => {
if (path === '/' || path.startsWith('/video')) {
active.value = 0
} else if (path.startsWith('/category')) {
active.value = 1
} else if (path.startsWith('/my') || path.startsWith('/user')) {
active.value = 2
}
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.app-tabbar {
@media (min-width: 768px) {
display: none;
}
}
</style>
@@ -0,0 +1,162 @@
<template>
<div class="video-card" @click="handleClick">
<!-- 封面 -->
<div class="video-cover">
<van-image
:src="video.coverUrl || defaultCover"
fit="cover"
class="cover-image"
lazy-load
>
<template v-slot:loading>
<van-loading type="spinner" size="20" />
</template>
</van-image>
<!-- 时长 -->
<span class="duration">{{ formatDuration(video.duration) }}</span>
<!-- 播放量 -->
<span class="view-count">{{ formatPlayCount(video.viewCount) }}播放</span>
</div>
<!-- 视频信息 -->
<div class="video-info">
<h3 class="video-title">{{ video.title }}</h3>
<div class="video-meta">
<span class="author">
<van-image
:src="video.userAvatar || defaultAvatar"
round
class="avatar"
fit="cover"
/>
{{ video.userNickname || '匿名用户' }}
</span>
</div>
<div class="video-stats">
<span><van-icon name="good-job-o" /> {{ formatNumber(video.likeCount) }}</span>
<span><van-icon name="chat-o" /> {{ formatNumber(video.commentCount) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { VideoInfo } from '@/types'
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
const props = defineProps<{
video: VideoInfo
}>()
const emit = defineEmits<{
click: [id: number]
}>()
const defaultCover = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120"%3E%3Crect fill="%23f0f0f0" width="200" height="120"/%3E%3Ctext x="50%25" y="50%25" fill="%23ccc" text-anchor="middle" dy=".3em"%3E%E6%97%A0%E5%B0%81%E9%9D%A2%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 handleClick = () => {
emit('click', props.video.id)
}
</script>
<style scoped lang="scss">
.video-card {
background: #fff;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
.video-cover {
position: relative;
width: 100%;
padding-top: 56.25%; /* 16:9 */
.cover-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
}
.view-count {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
}
}
.video-info {
padding: 10px;
.video-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin: 0 0 8px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
min-height: 40px;
}
.video-meta {
display: flex;
align-items: center;
font-size: 12px;
color: #999;
margin-bottom: 6px;
.author {
display: flex;
align-items: center;
gap: 4px;
}
.avatar {
width: 18px;
height: 18px;
}
}
.video-stats {
display: flex;
gap: 12px;
font-size: 12px;
color: #999;
span {
display: flex;
align-items: center;
gap: 4px;
}
}
}
</style>
@@ -0,0 +1,101 @@
<template>
<div class="video-player">
<div ref="playerRef" class="player-container"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import Artplayer from 'artplayer'
import type { Danmaku } from '@/api/danmaku.ts'
const props = defineProps<{
src: string
poster?: string
danmakuList?: Danmaku[]
}>()
const emit = defineEmits<{
timeupdate: [time: number]
ended: []
}>()
const playerRef = ref<HTMLElement>()
let instance: Artplayer | null = null
const initPlayer = () => {
if (!playerRef.value) return
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, () => {
if (instance) {
instance.switchUrl(props.src, props.poster)
}
})
onMounted(() => {
initPlayer()
})
onUnmounted(() => {
if (instance) {
instance.destroy()
}
})
</script>
<style scoped lang="scss">
.video-player {
width: 100%;
background: #000;
.player-container {
width: 100%;
height: 100%;
aspect-ratio: 16 / 9;
}
}
</style>
+23
View File
@@ -0,0 +1,23 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import router from './router'
// Vant 组件按需引入由 unplugin-vue-components 自动完成
// 样式已自动引入
// 全局样式
import '@/styles/index.scss'
const app = createApp(App)
// Pinia 状态管理
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
// 路由
app.use(router)
app.mount('#app')
@@ -0,0 +1,189 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/stores/user.ts'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
// 配置 NProgress
NProgress.configure({ showSpinner: false })
// 路由配置
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/views/layout/MainLayout.vue'),
children: [
{
path: '',
name: 'Home',
component: () => import('@/views/home/index.vue'),
meta: { title: '首页' }
},
{
path: 'video/:id',
name: 'Video',
component: () => import('@/views/video/detail.vue'),
meta: { title: '视频详情' }
},
{
path: 'search',
name: 'Search',
component: () => import('@/views/search/index.vue'),
meta: { title: '搜索' }
},
{
path: 'category/:id?',
name: 'Category',
component: () => import('@/views/home/category.vue'),
meta: { title: '分类' }
},
{
path: 'user/:id',
name: 'UserProfile',
component: () => import('@/views/user/profile.vue'),
meta: { title: '用户主页' }
},
{
path: 'user/:id/videos',
name: 'UserVideos',
component: () => import('@/views/user/videos.vue'),
meta: { title: '用户视频' }
},
{
path: 'user/:id/followers',
name: 'UserFollowers',
component: () => import('@/views/user/followers.vue'),
meta: { title: '粉丝' }
},
{
path: 'user/:id/following',
name: 'UserFollowing',
component: () => import('@/views/user/following.vue'),
meta: { title: '关注' }
}
]
},
// 需要登录的路由
{
path: '/',
component: () => import('@/views/layout/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: 'upload',
name: 'Upload',
component: () => import('@/views/upload/index.vue'),
meta: { title: '上传视频', requiresAuth: true }
},
{
path: 'my/videos',
name: 'MyVideos',
component: () => import('@/views/user/my-videos.vue'),
meta: { title: '我的视频', requiresAuth: true }
},
{
path: 'my/favorites',
name: 'MyFavorites',
component: () => import('@/views/favorite/index.vue'),
meta: { title: '我的收藏', requiresAuth: true }
},
{
path: 'my/history',
name: 'PlayHistory',
component: () => import('@/views/user/history.vue'),
meta: { title: '播放历史', requiresAuth: true }
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/user/settings.vue'),
meta: { title: '设置', requiresAuth: true }
},
{
path: 'settings/profile',
name: 'ProfileEdit',
component: () => import('@/views/user/profile-edit.vue'),
meta: { title: '编辑资料', requiresAuth: true }
},
{
path: 'messages',
name: 'Messages',
component: () => import('@/views/message/index.vue'),
meta: { title: '私信', requiresAuth: true }
},
{
path: 'messages/:id',
name: 'MessageDetail',
component: () => import('@/views/message/detail.vue'),
meta: { title: '私信详情', requiresAuth: true }
},
{
path: 'notifications',
name: 'Notifications',
component: () => import('@/views/message/notifications.vue'),
meta: { title: '通知', requiresAuth: true }
}
]
},
// 登录注册
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录' }
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/login/register.vue'),
meta: { title: '注册' }
},
// 404
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
meta: { title: '页面未找到' }
}
]
// 创建路由实例
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
})
// 路由前置守卫
router.beforeEach((to, from, next) => {
// 开始进度条
NProgress.start()
// 设置页面标题
const title = to.meta.title as string
document.title = title ? `${title} - HAOVP` : 'HAOVP - 在线视频平台'
// 检查登录状态
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
next({
path: '/login',
query: { redirect: to.fullPath }
})
} else {
next()
}
})
// 路由后置守卫
router.afterEach(() => {
// 结束进度条
NProgress.done()
})
export default router
@@ -0,0 +1,102 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
// 主题 (light, dark, auto)
const theme = ref<'light' | 'dark' | 'auto'>('auto')
// 语言
const locale = ref('zh-CN')
// 侧边栏是否展开
const sidebarCollapsed = ref(false)
// 是否显示弹幕
const danmakuVisible = ref(true)
// 弹幕设置
const danmakuSettings = ref({
fontSize: 24,
opacity: 1,
speed: 1,
showTop: true,
showBottom: true,
showScroll: true
})
// 播放器设置
const playerSettings = ref({
autoplay: false,
defaultQuality: 'auto',
defaultPlaybackRate: 1
})
// 设置主题
function setTheme(newTheme: 'light' | 'dark' | 'auto') {
theme.value = newTheme
applyTheme(newTheme)
}
// 应用主题
function applyTheme(theme: 'light' | 'dark' | 'auto') {
const root = document.documentElement
if (theme === 'auto') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.classList.toggle('dark', prefersDark)
} else {
root.classList.toggle('dark', theme === 'dark')
}
}
// 设置语言
function setLocale(newLocale: string) {
locale.value = newLocale
// TODO: 实现 i18n
}
// 切换侧边栏
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
// 设置弹幕可见性
function setDanmakuVisible(visible: boolean) {
danmakuVisible.value = visible
}
// 更新弹幕设置
function updateDanmakuSettings(settings: Partial<typeof danmakuSettings.value>) {
danmakuSettings.value = { ...danmakuSettings.value, ...settings }
}
// 更新播放器设置
function updatePlayerSettings(settings: Partial<typeof playerSettings.value>) {
playerSettings.value = { ...playerSettings.value, ...settings }
}
// 初始化应用
function initApp() {
applyTheme(theme.value)
}
return {
// 状态
theme,
locale,
sidebarCollapsed,
danmakuVisible,
danmakuSettings,
playerSettings,
// 方法
setTheme,
setLocale,
toggleSidebar,
setDanmakuVisible,
updateDanmakuSettings,
updatePlayerSettings,
initApp
}
}, {
persist: true
})
@@ -0,0 +1,102 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Favorite, VideoInfo } from '@/types'
import { favoriteApi } from '@/api/favorite.ts'
import { showToast } from 'vant'
export const useFavoriteStore = defineStore('favorite', () => {
// 收藏夹列表
const favorites = ref<Favorite[]>([])
// 当前选中的收藏夹
const currentFavorite = ref<Favorite | null>(null)
// 是否加载中
const loading = ref(false)
// 获取收藏夹列表
async function fetchFavorites() {
if (loading.value) return
loading.value = true
try {
const result = await favoriteApi.getFavoriteList()
favorites.value = result
} catch (error) {
console.error('获取收藏夹列表失败:', error)
} finally {
loading.value = false
}
}
// 创建收藏夹
async function createFavorite(params: { name: string; description?: string; visibility: number }) {
try {
const result = await favoriteApi.createFavorite(params)
favorites.value.unshift(result)
showToast('创建成功')
return result
} catch (error) {
console.error('创建收藏夹失败:', error)
throw error
}
}
// 更新收藏夹
async function updateFavorite(params: Partial<Favorite> & { id: number }) {
try {
await favoriteApi.updateFavorite(params)
const index = favorites.value.findIndex(f => f.id === params.id)
if (index >= 0) {
favorites.value[index] = { ...favorites.value[index], ...params }
}
showToast('更新成功')
} catch (error) {
console.error('更新收藏夹失败:', error)
throw error
}
}
// 删除收藏夹
async function deleteFavorite(id: number) {
try {
await favoriteApi.deleteFavorite(id)
favorites.value = favorites.value.filter(f => f.id !== id)
showToast('删除成功')
} catch (error) {
console.error('删除收藏夹失败:', error)
throw error
}
}
// 设置当前收藏夹
function setCurrentFavorite(favorite: Favorite | null) {
currentFavorite.value = favorite
}
// 更新收藏夹中的视频数量
function updateVideoCount(favoriteId: number, delta: number) {
const favorite = favorites.value.find(f => f.id === favoriteId)
if (favorite) {
favorite.videoCount += delta
}
}
return {
// 状态
favorites,
currentFavorite,
loading,
// 方法
fetchFavorites,
createFavorite,
updateFavorite,
deleteFavorite,
setCurrentFavorite,
updateVideoCount
}
}, {
persist: {
paths: ['favorites']
}
})
@@ -0,0 +1,5 @@
export { useUserStore } from './user.ts'
export { useVideoStore } from './video.ts'
export { useFavoriteStore } from './favorite.ts'
export { useMessageStore } from './message.ts'
export { useAppStore } from './app.ts'
@@ -0,0 +1,89 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { userApi } from '@/api/user.ts'
import type { Message, Notification } from '@/types'
export const useMessageStore = defineStore('message', () => {
// 未读私信数量
const unreadMessageCount = ref(0)
// 未读通知数量
const unreadNotificationCount = ref(0)
// 通知列表
const notifications = ref<Notification[]>([])
// 计算总未读数
const totalUnreadCount = computed(() => {
return unreadMessageCount.value + unreadNotificationCount.value
})
// 获取未读私信数量
async function fetchUnreadMessageCount() {
try {
const count = await userApi.getUnreadMessageCount()
unreadMessageCount.value = count
} catch (error) {
console.error('获取未读私信数量失败:', error)
}
}
// 获取未读通知数量
async function fetchUnreadNotificationCount() {
try {
const count = await userApi.getUnreadNotificationCount()
unreadNotificationCount.value = count
} catch (error) {
console.error('获取未读通知数量失败:', error)
}
}
// 获取所有未读数量
async function fetchAllUnreadCount() {
await Promise.all([
fetchUnreadMessageCount(),
fetchUnreadNotificationCount()
])
}
// 标记私信为已读
function decrementMessageUnread(count = 1) {
unreadMessageCount.value = Math.max(0, unreadMessageCount.value - count)
}
// 标记通知为已读
function decrementNotificationUnread(count = 1) {
unreadNotificationCount.value = Math.max(0, unreadNotificationCount.value - count)
}
// 清空所有未读私信
function clearMessageUnread() {
unreadMessageCount.value = 0
}
// 清空所有未读通知
function clearNotificationUnread() {
unreadNotificationCount.value = 0
}
return {
// 状态
unreadMessageCount,
unreadNotificationCount,
notifications,
// 计算属性
totalUnreadCount,
// 方法
fetchUnreadMessageCount,
fetchUnreadNotificationCount,
fetchAllUnreadCount,
decrementMessageUnread,
decrementNotificationUnread,
clearMessageUnread,
clearNotificationUnread
}
}, {
persist: {
paths: ['unreadMessageCount', 'unreadNotificationCount']
}
})
@@ -0,0 +1,184 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api/auth.ts'
import { userApi } from '@/api/user.ts'
import type { LoginParams, RegisterParams, UserInfo, AuthUser, CaptchaResult } from '@/types'
import { getToken, setToken, removeToken, getRefreshToken, setRefreshToken, removeRefreshToken } from '@/utils/storage.ts'
import { showToast } from 'vant'
export const useUserStore = defineStore('user', () => {
// 状态
const token = ref<string | null>(getToken())
const refreshToken = ref<string | null>(getRefreshToken())
const userInfo = ref<UserInfo | null>(null)
const authUser = ref<AuthUser | null>(null)
const captcha = ref<CaptchaResult | null>(null)
// 计算属性
const isLoggedIn = computed(() => !!token.value)
const userId = computed(() => authUser.value?.id || userInfo.value?.id)
const username = computed(() => authUser.value?.username || userInfo.value?.nickname)
// 获取验证码
async function getCaptcha() {
try {
const result = await authApi.getCaptcha()
captcha.value = result
return result
} catch (error) {
console.error('获取验证码失败:', error)
throw error
}
}
// 登录
async function login(params: LoginParams) {
try {
const result = await authApi.login(params)
token.value = result.accessToken
refreshToken.value = result.refreshToken
setToken(result.accessToken)
setRefreshToken(result.refreshToken)
// 获取用户信息
await fetchAuthUser()
await fetchUserInfo()
return result
} catch (error) {
console.error('登录失败:', error)
throw error
}
}
// 注册
async function register(params: RegisterParams) {
try {
const result = await authApi.register(params)
showToast('注册成功')
return result
} catch (error) {
console.error('注册失败:', error)
throw error
}
}
// 登出
async function logout() {
try {
await authApi.logout()
} catch (error) {
console.error('登出请求失败:', error)
} finally {
// 清除本地状态
token.value = null
refreshToken.value = null
userInfo.value = null
authUser.value = null
removeToken()
removeRefreshToken()
}
}
// 刷新 Token
async function refreshAccessToken() {
if (!refreshToken.value) {
logout()
return false
}
try {
const result = await authApi.refreshToken(refreshToken.value)
token.value = result.accessToken
refreshToken.value = result.refreshToken
setToken(result.accessToken)
setRefreshToken(result.refreshToken)
return true
} catch (error) {
console.error('刷新Token失败:', error)
logout()
return false
}
}
// 获取认证用户信息
async function fetchAuthUser() {
if (!token.value) return null
try {
const result = await authApi.getCurrentUser()
authUser.value = result
return result
} catch (error) {
console.error('获取认证用户信息失败:', error)
throw error
}
}
// 获取用户资料
async function fetchUserInfo() {
if (!token.value) return null
try {
const result = await userApi.getCurrentProfile()
userInfo.value = result
return result
} catch (error) {
console.error('获取用户资料失败:', error)
throw error
}
}
// 更新用户资料
async function updateProfile(params: Partial<UserInfo>) {
try {
await userApi.updateProfile(params)
// 重新获取用户资料
await fetchUserInfo()
} catch (error) {
console.error('更新用户资料失败:', error)
throw error
}
}
// 上传头像
async function uploadAvatar(file: File, onProgress?: (percent: number) => void) {
try {
const avatarUrl = await userApi.uploadAvatar(file, onProgress)
if (userInfo.value) {
userInfo.value.avatar = avatarUrl
}
return avatarUrl
} catch (error) {
console.error('上传头像失败:', error)
throw error
}
}
return {
// 状态
token,
refreshToken,
userInfo,
authUser,
captcha,
// 计算属性
isLoggedIn,
userId,
username,
// 方法
getCaptcha,
login,
register,
logout,
refreshAccessToken,
fetchAuthUser,
fetchUserInfo,
updateProfile,
uploadAvatar
}
}, {
persist: {
paths: ['token', 'refreshToken', 'userInfo', 'authUser']
}
})
@@ -0,0 +1,118 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { VideoInfo, PlayRecord } from '@/types'
import { videoApi } from '@/api/video.ts'
export const useVideoStore = defineStore('video', () => {
// 当前播放的视频
const currentVideo = ref<VideoInfo | null>(null)
// 播放进度
const playProgress = ref<number>(0)
// 播放历史(本地存储)
const playHistory = ref<PlayRecord[]>([])
// 是否正在播放
const isPlaying = ref(false)
// 弹幕开关
const danmakuEnabled = ref(true)
// 音量
const volume = ref(0.8)
// 播放速度
const playbackRate = ref(1)
// 设置当前视频
function setCurrentVideo(video: VideoInfo | null) {
currentVideo.value = video
}
// 更新播放进度
function updateProgress(progress: number) {
playProgress.value = progress
}
// 切换播放状态
function togglePlaying(playing: boolean) {
isPlaying.value = playing
}
// 切换弹幕
function toggleDanmaku(enabled: boolean) {
danmakuEnabled.value = enabled
}
// 设置音量
function setVolume(vol: number) {
volume.value = vol
}
// 设置播放速度
function setPlaybackRate(rate: number) {
playbackRate.value = rate
}
// 记录播放
async function recordPlay(videoId: number, progress: number, duration: number) {
try {
await videoApi.recordPlay({ videoId, progress, duration })
// 更新本地播放历史
const index = playHistory.value.findIndex(item => item.videoId === videoId)
const record: PlayRecord = { videoId, progress, duration }
if (index >= 0) {
playHistory.value[index] = record
} else {
playHistory.value.unshift(record)
// 最多保存100条记录
if (playHistory.value.length > 100) {
playHistory.value = playHistory.value.slice(0, 100)
}
}
} catch (error) {
console.error('记录播放失败:', error)
}
}
// 获取播放进度
function getPlayProgress(videoId: number): number {
const record = playHistory.value.find(item => item.videoId === videoId)
return record?.progress || 0
}
// 清除当前视频
function clearCurrentVideo() {
currentVideo.value = null
playProgress.value = 0
isPlaying.value = false
}
return {
// 状态
currentVideo,
playProgress,
playHistory,
isPlaying,
danmakuEnabled,
volume,
playbackRate,
// 方法
setCurrentVideo,
updateProgress,
togglePlaying,
toggleDanmaku,
setVolume,
setPlaybackRate,
recordPlay,
getPlayProgress,
clearCurrentVideo
}
}, {
persist: {
paths: ['playHistory', 'danmakuEnabled', 'volume', 'playbackRate']
}
})
@@ -0,0 +1,254 @@
// 全局样式入口
// 重置样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
font-size: 14px;
line-height: 1.5;
color: #333;
background-color: #f4f4f4;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: inherit;
text-decoration: none;
}
ul,
ol {
list-style: none;
}
img {
max-width: 100%;
height: auto;
}
button {
border: none;
background: none;
cursor: pointer;
font-family: inherit;
}
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
}
// 滚动条样式
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
&:hover {
background: #a8a8a8;
}
}
// NProgress 样式覆盖
#nprogress {
.bar {
background: linear-gradient(to right, #00a1d6, #fb7299) !important;
height: 3px !important;
}
.peg {
box-shadow: 0 0 10px #00a1d6, 0 0 5px #00a1d6 !important;
}
}
// 主题变量
:root {
// 主色调
--primary-color: #00a1d6;
--primary-hover-color: #00b5e2;
// 辅助色
--success-color: #52c41a;
--warning-color: #faad14;
--error-color: #ff4d4f;
--info-color: #1890ff;
// 粉色(B站风格)
--pink-color: #fb7299;
--pink-hover-color: #fc8bab;
// 文字颜色
--text-primary: #333;
--text-secondary: #666;
--text-tertiary: #999;
--text-white: #fff;
// 背景颜色
--bg-primary: #f4f4f4;
--bg-secondary: #fff;
--bg-tertiary: #f9f9f9;
// 边框颜色
--border-color: #e5e5e5;
--border-light: #f0f0f0;
// 阴影
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
// 圆角
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
// 间距
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
// 布局
--header-height: 60px;
--sidebar-width: 200px;
--max-content-width: 1400px;
}
// 暗色主题
.dark {
--text-primary: #e5e5e5;
--text-secondary: #a0a0a0;
--text-tertiary: #666;
--bg-primary: #1a1a1a;
--bg-secondary: #252525;
--bg-tertiary: #2a2a2a;
--border-color: #333;
--border-light: #2a2a2a;
}
// 常用类
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-ellipsis-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.text-ellipsis-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column {
display: flex;
flex-direction: column;
}
.page-container {
max-width: var(--max-content-width);
margin: 0 auto;
padding: var(--spacing-md);
}
.card {
background: var(--bg-secondary);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
// 骨架屏动画
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
// 淡入动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
// 滑入动画
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
}
// 缩放动画
.scale-enter-active,
.scale-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.scale-enter-from,
.scale-leave-to {
transform: scale(0.9);
opacity: 0;
}
@@ -0,0 +1,339 @@
// API 响应通用结构
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
// 分页请求参数
export interface PageParams {
page?: number
size?: number
}
// 分页响应数据
export interface PageResult<T> {
records: T[]
total: number
size: number
current: number
pages: number
}
// 用户信息
export interface UserInfo {
id: number
nickname: string
avatar: string
gender: number
birthday: string
signature: string
region: string
followerCount: number
followingCount: number
videoCount: number
likeCount: number
level: number
vipLevel: number
vipExpireTime: string | null
isFollowed?: boolean
createTime: string
}
// 认证用户
export interface AuthUser {
id: number
username: string
email: string
phone: string
status: number
lastLoginTime: string
lastLoginIp: string
roles: string[]
}
// 登录响应
export interface LoginResult {
accessToken: string
refreshToken: string
tokenType: string
expiresIn: number
userId: number
username: string
}
// 验证码响应
export interface CaptchaResult {
captchaKey: string
captchaImage: string
}
// 登录请求
export interface LoginParams {
username: string
password: string
captcha: string
captchaKey: string
}
// 注册请求
export interface RegisterParams {
username: string
password: string
confirmPassword: string
email: string
phone: string
captcha: string
captchaKey: string
}
// 视频信息
export interface VideoInfo {
id: number
userId: number
title: string
description: string
coverUrl: string
videoUrl: string
duration: number
width: number
height: number
format: string
size: number
categoryId: number
status: number
likeCount: number
coinCount: number
favoriteCount: number
commentCount: number
viewCount: number
shareCount: number
danmakuCount: number
isRecommend: number
isTop: number
publishTime: string
createTime: string
updateTime: string
tagIds?: number[]
tagNames?: string[]
categoryName?: string
userNickname?: string
userAvatar?: string
isLiked?: boolean
isFavorited?: boolean
isCoined?: boolean
}
// 视频上传参数
export interface VideoUploadParams {
file: File
onProgress?: (percent: number) => void
}
// 视频创建参数
export interface VideoCreateParams {
title: string
description: string
videoUrl: string
coverUrl: string
duration: number
width: number
height: number
format: string
size: number
categoryId: number
tagIds?: number[]
}
// 视频更新参数
export interface VideoUpdateParams {
id: number
title?: string
description?: string
coverUrl?: string
categoryId?: number
tagIds?: number[]
}
// 视频查询参数
export interface VideoQueryParams extends PageParams {
keyword?: string
categoryId?: number
tagId?: number
userId?: number
status?: number
sortBy?: string
sortOrder?: 'asc' | 'desc'
}
// 分类信息
export interface Category {
id: number
name: string
parentId: number
sort: number
icon: string
children?: Category[]
}
// 标签信息
export interface Tag {
id: number
name: string
useCount: number
}
// 弹幕信息
export interface Danmaku {
id: number
videoId: number
userId: number
content: string
color: string
position: number
time: number
fontSize: number
createTime: string
}
// 弹幕发送参数
export interface DanmakuSendParams {
videoId: number
content: string
color: string
position: number
time: number
fontSize: number
}
// 评论信息
export interface Comment {
id: number
videoId: number
userId: number
parentId: number
rootId: number
content: string
likeCount: number
replyCount: number
isTop: number
createTime: string
userNickname?: string
userAvatar?: string
isLiked?: boolean
replies?: Comment[]
}
// 评论发送参数
export interface CommentSendParams {
videoId: number
parentId?: number
rootId?: number
content: string
}
// 评论查询参数
export interface CommentQueryParams extends PageParams {
videoId: number
rootId?: number
sortBy?: 'hot' | 'time'
}
// 收藏夹信息
export interface Favorite {
id: number
userId: number
name: string
description: string
coverUrl: string
visibility: number
videoCount: number
createTime: string
}
// 收藏夹创建参数
export interface FavoriteCreateParams {
name: string
description?: string
coverUrl?: string
visibility: number
}
// 收藏视频参数
export interface FavoriteVideoParams {
videoId: number
favoriteId: number
}
// 用户关注
export interface UserFollow {
id: number
nickname: string
avatar: string
signature: string
followerCount: number
followingCount: number
isFollowed?: boolean
}
// 私信信息
export interface Message {
id: number
senderId: number
senderNickname: string
senderAvatar: string
receiverId: number
receiverNickname: string
receiverAvatar: string
content: string
isRead: number
createTime: string
}
// 私信发送参数
export interface MessageSendParams {
receiverId: number
content: string
}
// 通知信息
export interface Notification {
id: number
type: number // 1:点赞,2:评论,3:关注,4:系统通知
content: string
relatedId: number
isRead: number
createTime: string
}
// 用户设置
export interface UserSetting {
privacyLevel: number // 1:公开,2:仅关注,3:私密
commentNotify: number
likeNotify: number
followNotify: number
systemNotify: number
}
// 用户资料更新参数
export interface UserProfileUpdateParams {
nickname?: string
gender?: number
birthday?: string
signature?: string
region?: string
}
// 播放记录
export interface PlayRecord {
videoId: number
progress: number
duration: number
}
// 推荐视频
export interface RecommendVideo {
videoId: number
score: number
reason: string
}
@@ -0,0 +1,199 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
// 配置 dayjs
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
/**
* 格式化日期时间
*/
export function formatDateTime(date: string | Date, format = 'YYYY-MM-DD HH:mm:ss'): string {
return dayjs(date).format(format)
}
/**
* 格式化日期
*/
export function formatDate(date: string | Date): string {
return dayjs(date).format('YYYY-MM-DD')
}
/**
* 格式化时间
*/
export function formatTime(date: string | Date): string {
return dayjs(date).format('HH:mm:ss')
}
/**
* 相对时间(如:3 天前)
*/
export function formatRelativeTime(date: string | Date): string {
return dayjs(date).fromNow()
}
/**
* 格式化视频时长(秒转为 mm:ss 或 hh:mm:ss)
*/
export function formatDuration(seconds: number): string {
if (!seconds || seconds < 0) return '00:00'
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
const pad = (n: number) => n.toString().padStart(2, '0')
if (hours > 0) {
return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`
}
return `${pad(minutes)}:${pad(secs)}`
}
/**
* 格式化文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + units[i]
}
/**
* 格式化数字(如:1000 -> 1千,10000 -> 1万)
*/
export function formatNumber(num: number): string {
if (num < 1000) {
return num.toString()
} else if (num < 10000) {
return (num / 1000).toFixed(1) + '千'
} else if (num < 100000000) {
return (num / 10000).toFixed(1) + '万'
} else {
return (num / 100000000).toFixed(1) + '亿'
}
}
/**
* 格式化播放量
*/
export function formatPlayCount(count: number): string {
return formatNumber(count)
}
/**
* 防抖函数
*/
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay = 300
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null
return function (this: any, ...args: Parameters<T>) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
/**
* 节流函数
*/
export function throttle<T extends (...args: any[]) => any>(
fn: T,
delay = 300
): (...args: Parameters<T>) => void {
let lastTime = 0
return function (this: any, ...args: Parameters<T>) {
const now = Date.now()
if (now - lastTime >= delay) {
fn.apply(this, args)
lastTime = now
}
}
}
/**
* 深拷贝
*/
export function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (Array.isArray(obj)) {
return obj.map((item) => deepClone(item)) as any
}
const cloned = {} as T
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
cloned[key] = deepClone(obj[key])
}
}
return cloned
}
/**
* 生成随机字符串
*/
export function randomString(length = 16): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
/**
* 复制文本到剪贴板
*/
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const result = document.execCommand('copy')
document.body.removeChild(textarea)
return result
}
}
/**
* 获取 URL 参数
*/
export function getQueryParam(name: string): string | null {
const url = new URL(window.location.href)
return url.searchParams.get(name)
}
/**
* 构建 URL 参数
*/
export function buildQueryString(params: Record<string, any>): string {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, String(value))
}
})
return searchParams.toString()
}
@@ -0,0 +1,132 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { useUserStore } from '@/stores/user.ts'
import { showToast } from 'vant'
import router from '@/router'
// API 基础配置
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
const TIMEOUT = 30000
// 创建 axios 实例
const service: AxiosInstance = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const userStore = useUserStore()
// 添加 Token
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const { data } = response
// 业务状态码判断
if (data.code === 200) {
return data.data
}
// Token 过期
if (data.code === 401) {
const userStore = useUserStore()
userStore.logout()
router.push('/login')
showToast('登录已过期,请重新登录')
return Promise.reject(new Error('Token expired'))
}
// 其他业务错误
showToast(data.message || '请求失败')
return Promise.reject(new Error(data.message || '请求失败'))
},
(error) => {
console.error('响应错误:', error)
// 网络错误
if (error.code === 'ECONNABORTED') {
showToast('请求超时,请稍后重试')
} else if (error.message.includes('Network Error')) {
showToast('网络错误,请检查网络连接')
} else if (error.response) {
const status = error.response.status
switch (status) {
case 401:
const userStore = useUserStore()
userStore.logout()
router.push('/login')
showToast('登录已过期,请重新登录')
break
case 403:
showToast('没有权限访问')
break
case 404:
showToast('请求的资源不存在')
break
case 500:
showToast('服务器错误,请稍后重试')
break
default:
showToast(`请求失败: ${status}`)
}
}
return Promise.reject(error)
}
)
// 封装请求方法
const request = {
get<T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, { params, ...config })
},
post<T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config)
},
put<T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config)
},
delete<T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, { params, ...config })
},
upload<T = any>(url: string, file: File, onProgress?: (percent: number) => void): Promise<T> {
const formData = new FormData()
formData.append('file', file)
return service.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
if (progressEvent.total && onProgress) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percent)
}
}
})
}
}
export default request
export { service as axiosInstance }
@@ -0,0 +1,151 @@
// 本地存储工具
const TOKEN_KEY = 'haovp_token'
const REFRESH_TOKEN_KEY = 'haovp_refresh_token'
const USER_INFO_KEY = 'haovp_user_info'
/**
* 获取 Token
*/
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
/**
* 设置 Token
*/
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
/**
* 移除 Token
*/
export function removeToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
/**
* 获取刷新 Token
*/
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY)
}
/**
* 设置刷新 Token
*/
export function setRefreshToken(token: string): void {
localStorage.setItem(REFRESH_TOKEN_KEY, token)
}
/**
* 移除刷新 Token
*/
export function removeRefreshToken(): void {
localStorage.removeItem(REFRESH_TOKEN_KEY)
}
/**
* 获取用户信息
*/
export function getUserInfo(): any {
const info = localStorage.getItem(USER_INFO_KEY)
return info ? JSON.parse(info) : null
}
/**
* 设置用户信息
*/
export function setUserInfo(info: object): void {
localStorage.setItem(USER_INFO_KEY, JSON.stringify(info))
}
/**
* 移除用户信息
*/
export function removeUserInfo(): void {
localStorage.removeItem(USER_INFO_KEY)
}
/**
* 清除所有认证信息
*/
export function clearAuth(): void {
removeToken()
removeRefreshToken()
removeUserInfo()
}
/**
* 检查是否登录
*/
export function isAuthenticated(): boolean {
return !!getToken()
}
/**
* 通用本地存储
*/
export const storage = {
get<T = any>(key: string): T | null {
const value = localStorage.getItem(key)
if (value) {
try {
return JSON.parse(value)
} catch {
return value as any
}
}
return null
},
set(key: string, value: any): void {
if (typeof value === 'string') {
localStorage.setItem(key, value)
} else {
localStorage.setItem(key, JSON.stringify(value))
}
},
remove(key: string): void {
localStorage.removeItem(key)
},
clear(): void {
localStorage.clear()
}
}
/**
* Session 存储
*/
export const session = {
get<T = any>(key: string): T | null {
const value = sessionStorage.getItem(key)
if (value) {
try {
return JSON.parse(value)
} catch {
return value as any
}
}
return null
},
set(key: string, value: any): void {
if (typeof value === 'string') {
sessionStorage.setItem(key, value)
} else {
sessionStorage.setItem(key, JSON.stringify(value))
}
},
remove(key: string): void {
sessionStorage.removeItem(key)
},
clear(): void {
sessionStorage.clear()
}
}
@@ -0,0 +1,38 @@
<template>
<div class="not-found">
<div class="content">
<h1>404</h1>
<p>页面未找到</p>
<van-button type="primary" to="/">返回首页</van-button>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.not-found {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
.content {
text-align: center;
h1 {
font-size: 120px;
color: #00a1d6;
margin: 0;
}
p {
font-size: 24px;
color: #666;
margin: 20px 0 30px;
}
}
}
</style>
@@ -0,0 +1,94 @@
<template>
<div class="favorite-page">
<van-nav-bar title="我的收藏" fixed placeholder left-arrow @click-left="router.back()" />
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="favorites.length" inset>
<van-cell
v-for="fav in favorites"
:key="fav.id"
:title="fav.name"
:label="`${fav.videoCount}个视频`"
is-link
@click="goToFavorite(fav.id)"
>
<template #icon>
<van-icon name="star" class="fav-icon" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无收藏夹" />
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { favoriteApi, type Favorite } from '@/api/favorite.ts'
const router = useRouter()
const favorites = ref<Favorite[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const loadFavorites = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
favorites.value = []
finished.value = false
}
loading.value = true
try {
const data = await favoriteApi.getList()
favorites.value = data
finished.value = true
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadFavorites()
}
const onRefresh = () => {
loadFavorites(true)
}
const goToFavorite = (id: number) => {
router.push(`/favorite/${id}`)
}
onMounted(() => {
loadFavorites()
})
</script>
<style scoped lang="scss">
.favorite-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.fav-icon {
color: #f7ba2a;
margin-right: 8px;
font-size: 20px;
}
}
</style>
@@ -0,0 +1,146 @@
<template>
<div class="category-page">
<van-nav-bar title="分类" fixed placeholder />
<div class="category-content">
<van-sidebar v-model="activeCategory">
<van-sidebar-item
v-for="cat in categories"
:key="cat.id"
:title="cat.name"
/>
</van-sidebar>
<div class="video-list">
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<div class="video-grid">
<VideoCard
v-for="video in videos"
:key="video.id"
:video="video"
@click="goToVideo(video.id)"
/>
</div>
</van-list>
</van-pull-refresh>
</div>
</div>
<AppTabbar />
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import AppTabbar from '@/components/layout/AppTabbar.vue'
import { categoryApi, type Category } from '@/api/category.ts'
import { videoApi } from '@/api/video.ts'
import type { VideoInfo } from '@/types'
const router = useRouter()
const categories = ref<Category[]>([])
const activeCategory = ref(0)
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const loadCategories = async () => {
try {
categories.value = await categoryApi.getList()
} catch (error) {
showToast('加载分类失败')
}
}
const loadVideos = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
const category = categories.value[activeCategory.value]
if (!category) return
loading.value = true
try {
const res = await videoApi.getList({
page: currentPage.value,
size: 20,
categoryId: category.id
})
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
}
if (videos.value.length >= res.total || res.records.length < 20) {
finished.value = true
}
currentPage.value++
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
watch(activeCategory, () => {
loadVideos(true)
})
onMounted(() => {
loadCategories()
})
</script>
<style scoped lang="scss">
.category-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
}
.category-content {
display: flex;
height: calc(100vh - 46px);
.video-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}
</style>
@@ -0,0 +1,205 @@
<template>
<div class="home-page">
<!-- 顶部导航 -->
<van-nav-bar title="HAOVP" fixed placeholder>
<template #right>
<van-icon name="search" size="22" @click="router.push('/search')" />
</template>
</van-nav-bar>
<!-- 分类导航 -->
<div class="category-nav">
<van-tabs v-model:active="activeCategory" sticky shrink @change="onCategoryChange">
<van-tab title="推荐" name="recommend" />
<van-tab title="热门" name="hot" />
<van-tab v-for="cat in categories" :key="cat.id" :title="cat.name" :name="cat.id" />
</van-tabs>
</div>
<!-- 视频列表 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
class="video-list"
>
<div class="video-grid">
<VideoCard
v-for="video in videos"
:key="video.id"
:video="video"
@click="goToVideo(video.id)"
/>
</div>
</van-list>
</van-pull-refresh>
<!-- 底部导航 -->
<AppTabbar />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import AppTabbar from '@/components/layout/AppTabbar.vue'
import { videoApi } from '@/api/video.ts'
import { categoryApi, type Category } from '@/api/category.ts'
import { recommendApi } from '@/api/recommend.ts'
import type { VideoInfo } from '@/types'
const router = useRouter()
// 分类
const categories = ref<Category[]>([])
const activeCategory = ref<string | number>('recommend')
// 视频列表
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const pageSize = 20
// 加载分类
const loadCategories = async () => {
try {
const res = await categoryApi.getList()
categories.value = res
} catch (error) {
console.error('加载分类失败:', error)
}
}
// 加载视频列表
const loadVideos = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
loading.value = true
try {
let res
const params = {
page: currentPage.value,
size: pageSize,
...(typeof activeCategory.value === 'number' && { categoryId: activeCategory.value })
}
if (activeCategory.value === 'recommend') {
res = await recommendApi.getHome(params)
} else if (activeCategory.value === 'hot') {
res = await recommendApi.getHot(params)
} else {
res = await videoApi.getList(params)
}
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
}
// 判断是否还有更多
if (videos.value.length >= res.total || res.records.length < pageSize) {
finished.value = true
}
currentPage.value++
} catch (error: any) {
showToast(error.message || '加载失败')
finished.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
// 加载更多
const loadMore = () => {
if (!finished.value) {
loadVideos()
}
}
// 下拉刷新
const onRefresh = () => {
loadVideos(true)
}
// 分类切换
const onCategoryChange = () => {
loadVideos(true)
}
// 跳转视频详情
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
loadCategories()
loadVideos()
})
</script>
<style scoped lang="scss">
.home-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
@media (min-width: 768px) {
padding-bottom: 0;
}
}
.category-nav {
background: #fff;
position: sticky;
top: 46px;
z-index: 100;
:deep(.van-tabs__nav) {
padding: 0 12px;
}
:deep(.van-tab) {
padding: 0 12px;
font-size: 14px;
}
:deep(.van-tabs__line) {
background: #00a1d6;
}
}
.video-list {
padding: 12px;
}
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
@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>
@@ -0,0 +1,47 @@
<template>
<div class="main-layout">
<!-- 顶部导航 -->
<AppHeader />
<!-- 主内容区 -->
<main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
<!-- 底部导航(移动端) -->
<AppTabbar />
</div>
</template>
<script setup lang="ts">
import AppHeader from '@/components/layout/AppHeader.vue'
import AppTabbar from '@/components/layout/AppTabbar.vue'
import { useAppStore } from '@/stores'
import { onMounted } from 'vue'
const appStore = useAppStore()
onMounted(() => {
appStore.initApp()
})
</script>
<style scoped lang="scss">
.main-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
padding-top: var(--header-height);
padding-bottom: 50px; // 移动端 tabbar 高度
@media (min-width: 768px) {
padding-bottom: 0;
}
}
</style>
@@ -0,0 +1,247 @@
<template>
<div class="login-page">
<div class="login-container">
<!-- Logo -->
<div class="login-header">
<h1 class="logo">HAOVP</h1>
<p class="slogan">高可用在线视频平台</p>
</div>
<!-- 登录表单 -->
<van-form @submit="handleLogin" class="login-form">
<!-- 验证码 -->
<div class="captcha-row">
<van-image
:src="captchaImage"
fit="contain"
class="captcha-image"
@click="refreshCaptcha"
>
<template v-slot:error>
<div class="captcha-error" @click="refreshCaptcha">点击刷新</div>
</template>
</van-image>
</div>
<van-cell-group inset>
<van-field
v-model="form.username"
name="username"
placeholder="用户名/邮箱/手机号"
:rules="[{ required: true, message: '请输入用户名' }]"
left-icon="user-o"
/>
<van-field
v-model="form.password"
type="password"
name="password"
placeholder="密码"
:rules="[{ required: true, message: '请输入密码' }]"
left-icon="lock"
/>
<van-field
v-model="form.captcha"
name="captcha"
placeholder="验证码"
:rules="[{ required: true, message: '请输入验证码' }]"
left-icon="shield-o"
>
<template #button>
<van-image
:src="captchaImage"
fit="contain"
class="captcha-btn"
@click="refreshCaptcha"
/>
</template>
</van-field>
</van-cell-group>
<div class="login-actions">
<van-button
round
block
type="primary"
native-type="submit"
:loading="loading"
color="#00a1d6"
>
登录
</van-button>
</div>
</van-form>
<!-- 其他登录方式 -->
<div class="login-footer">
<router-link to="/register" class="link">注册账号</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { authApi, type CaptchaResult } from '@/api/auth.ts'
import { useUserStore } from '@/stores/user.ts'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loading = ref(false)
const captchaKey = ref('')
const captchaImage = ref('')
const form = reactive({
username: '',
password: '',
captcha: ''
})
// 获取验证码
const refreshCaptcha = async () => {
try {
const res: CaptchaResult = await authApi.getCaptcha()
captchaKey.value = res.captchaKey
captchaImage.value = res.captchaImage
} catch (error) {
showToast('获取验证码失败')
}
}
// 登录
const handleLogin = async () => {
if (!form.username || !form.password || !form.captcha) {
return
}
loading.value = true
try {
const result = await authApi.login({
username: form.username,
password: form.password,
captcha: form.captcha,
captchaKey: captchaKey.value
})
// 保存登录信息
userStore.setToken(result.accessToken, result.refreshToken)
userStore.setUserInfo({
id: result.userId,
username: result.username
})
showSuccessToast('登录成功')
// 跳转到之前的页面或首页
const redirect = route.query.redirect as string
router.replace(redirect || '/')
} catch (error: any) {
showToast(error.message || '登录失败')
refreshCaptcha()
} finally {
loading.value = false
}
}
onMounted(() => {
refreshCaptcha()
})
</script>
<style scoped lang="scss">
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-container {
width: 100%;
max-width: 400px;
background: #fff;
border-radius: 16px;
padding: 40px 24px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.login-header {
text-align: center;
margin-bottom: 32px;
.logo {
font-size: 36px;
font-weight: bold;
color: #00a1d6;
margin: 0;
}
.slogan {
color: #999;
font-size: 14px;
margin-top: 8px;
}
}
.captcha-row {
display: flex;
justify-content: center;
margin-bottom: 16px;
.captcha-image {
height: 40px;
cursor: pointer;
}
.captcha-error {
width: 100px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
color: #999;
font-size: 12px;
cursor: pointer;
}
}
.login-form {
:deep(.van-cell-group--inset) {
margin: 0;
}
:deep(.van-field) {
padding: 12px 16px;
}
.captcha-btn {
height: 32px;
cursor: pointer;
}
}
.login-actions {
margin-top: 24px;
}
.login-footer {
text-align: center;
margin-top: 24px;
.link {
color: #00a1d6;
font-size: 14px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
</style>
@@ -0,0 +1,196 @@
<template>
<div class="register-page">
<div class="register-container">
<div class="register-header">
<h1 class="logo">HAOVP</h1>
<p class="slogan">注册新账号</p>
</div>
<van-form @submit="handleRegister">
<van-cell-group inset>
<van-field
v-model="form.username"
name="username"
label="用户名"
placeholder="请输入用户名"
:rules="[{ required: true, message: '请输入用户名' }]"
/>
<van-field
v-model="form.email"
name="email"
label="邮箱"
placeholder="请输入邮箱"
:rules="[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '邮箱格式不正确' }]"
/>
<van-field
v-model="form.phone"
name="phone"
label="手机号"
placeholder="请输入手机号"
/>
<van-field
v-model="form.password"
type="password"
name="password"
label="密码"
placeholder="请输入密码"
:rules="[{ required: true, message: '请输入密码' }, { pattern: /^.{6,}$/, message: '密码至少6位' }]"
/>
<van-field
v-model="form.confirmPassword"
type="password"
name="confirmPassword"
label="确认密码"
placeholder="请再次输入密码"
:rules="[{ required: true, message: '请确认密码' }, { validator: validatePassword, message: '两次密码不一致' }]"
/>
<van-field
v-model="form.captcha"
name="captcha"
label="验证码"
placeholder="请输入验证码"
:rules="[{ required: true, message: '请输入验证码' }]"
>
<template #button>
<van-image :src="captchaImage" fit="contain" class="captcha-btn" @click="refreshCaptcha" />
</template>
</van-field>
</van-cell-group>
<div class="register-actions">
<van-button round block type="primary" native-type="submit" :loading="loading" color="#00a1d6">
注册
</van-button>
</div>
</van-form>
<div class="register-footer">
<router-link to="/login" class="link">已有账号?去登录</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { authApi, type CaptchaResult } from '@/api/auth.ts'
const router = useRouter()
const loading = ref(false)
const captchaKey = ref('')
const captchaImage = ref('')
const form = reactive({
username: '',
email: '',
phone: '',
password: '',
confirmPassword: '',
captcha: ''
})
const validatePassword = () => {
return form.password === form.confirmPassword
}
const refreshCaptcha = async () => {
try {
const res: CaptchaResult = await authApi.getCaptcha()
captchaKey.value = res.captchaKey
captchaImage.value = res.captchaImage
} catch (error) {
showToast('获取验证码失败')
}
}
const handleRegister = async () => {
if (!validatePassword()) {
showToast('两次密码不一致')
return
}
loading.value = true
try {
await authApi.register({
username: form.username,
email: form.email,
phone: form.phone,
password: form.password,
confirmPassword: form.confirmPassword,
captcha: form.captcha,
captchaKey: captchaKey.value
})
showSuccessToast('注册成功')
router.push('/login')
} catch (error: any) {
showToast(error.message || '注册失败')
refreshCaptcha()
} finally {
loading.value = false
}
}
onMounted(() => {
refreshCaptcha()
})
</script>
<style scoped lang="scss">
.register-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.register-container {
width: 100%;
max-width: 400px;
background: #fff;
border-radius: 16px;
padding: 30px 24px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.register-header {
text-align: center;
margin-bottom: 24px;
.logo {
font-size: 32px;
font-weight: bold;
color: #00a1d6;
margin: 0;
}
.slogan {
color: #999;
font-size: 14px;
margin-top: 8px;
}
}
.register-actions {
margin-top: 20px;
}
.register-footer {
text-align: center;
margin-top: 20px;
.link {
color: #00a1d6;
font-size: 14px;
text-decoration: none;
}
}
.captcha-btn {
height: 32px;
cursor: pointer;
}
</style>
@@ -0,0 +1,177 @@
<template>
<div class="message-detail-page">
<van-nav-bar title="聊天详情" fixed placeholder left-arrow @click-left="router.back()" />
<div class="message-list" ref="listRef">
<div
v-for="msg in messages"
:key="msg.id"
:class="['message-item', { 'is-self': msg.senderId === userStore.userId }]"
>
<van-image
v-if="msg.senderId !== userStore.userId"
:src="msg.senderAvatar || defaultAvatar"
round
width="36"
height="36"
class="avatar"
/>
<div class="message-content">
<div class="bubble">{{ msg.content }}</div>
<div class="time">{{ formatRelativeTime(msg.createTime) }}</div>
</div>
<van-image
v-if="msg.senderId === userStore.userId"
:src="userStore.userInfo?.avatar || defaultAvatar"
round
width="36"
height="36"
class="avatar"
/>
</div>
</div>
<div class="message-input">
<van-field
v-model="content"
placeholder="输入消息..."
:border="false"
@keyup.enter="sendMessage"
/>
<van-button type="primary" size="small" :loading="sending" @click="sendMessage">
发送
</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import { userApi, type Message } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import { formatRelativeTime } from '@/utils'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const targetId = Number(route.params.id)
const messages = ref<Message[]>([])
const content = ref('')
const sending = ref(false)
const listRef = ref<HTMLElement>()
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 loadMessages = async () => {
try {
const data = await userApi.getMessageDetail(targetId)
messages.value = data
await nextTick()
scrollToBottom()
} catch (error) {
showToast('加载失败')
}
}
const sendMessage = async () => {
if (!content.value.trim()) return
sending.value = true
try {
const msg = await userApi.sendMessage({
receiverId: targetId,
content: content.value
})
messages.value.push(msg)
content.value = ''
await nextTick()
scrollToBottom()
} catch (error) {
showToast('发送失败')
} finally {
sending.value = false
}
}
const scrollToBottom = () => {
if (listRef.value) {
listRef.value.scrollTop = listRef.value.scrollHeight
}
}
onMounted(() => {
loadMessages()
})
</script>
<style scoped lang="scss">
.message-detail-page {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.message-list {
flex: 1;
overflow-y: auto;
padding: 12px;
.message-item {
display: flex;
gap: 8px;
margin-bottom: 12px;
&.is-self {
flex-direction: row-reverse;
.message-content {
align-items: flex-end;
}
.bubble {
background: #00a1d6;
color: #fff;
}
}
.avatar {
flex-shrink: 0;
}
.message-content {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 70%;
.bubble {
padding: 10px 14px;
background: #fff;
border-radius: 8px;
word-break: break-all;
}
.time {
font-size: 12px;
color: #999;
}
}
}
}
.message-input {
display: flex;
gap: 8px;
padding: 8px 12px;
background: #fff;
border-top: 1px solid #eee;
.van-field {
flex: 1;
}
}
</style>
@@ -0,0 +1,112 @@
<template>
<div class="message-page">
<van-nav-bar title="私信" fixed placeholder left-arrow @click-left="router.back()">
<template #right>
<van-icon name="plus" size="22" />
</template>
</van-nav-bar>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="conversations.length" inset>
<van-cell
v-for="conv in conversations"
:key="conv.targetId"
:title="conv.targetNickname"
:label="conv.lastMessage"
is-link
@click="goToDetail(conv.targetId)"
>
<template #icon>
<van-image :src="conv.targetAvatar || defaultAvatar" round width="40" height="40" />
</template>
<template #value>
<van-badge :content="conv.unreadCount" :show-zero="false">
<span class="time">{{ formatRelativeTime(conv.lastTime) }}</span>
</van-badge>
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无私信" />
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { userApi } from '@/api/user.ts'
import { formatRelativeTime } from '@/utils'
interface Conversation {
targetId: number
targetNickname: string
targetAvatar: string
lastMessage: string
lastTime: string
unreadCount: number
}
const router = useRouter()
const conversations = ref<Conversation[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
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 loadConversations = async (isRefresh = false) => {
if (isRefresh) {
conversations.value = []
finished.value = false
}
loading.value = true
try {
const data = await userApi.getMessageList()
conversations.value = data
finished.value = true
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadConversations()
}
const onRefresh = () => {
loadConversations(true)
}
const goToDetail = (targetId: number) => {
router.push(`/messages/${targetId}`)
}
onMounted(() => {
loadConversations()
})
</script>
<style scoped lang="scss">
.message-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.time {
font-size: 12px;
color: #999;
}
}
</style>
@@ -0,0 +1,117 @@
<template>
<div class="notifications-page">
<van-nav-bar title="通知" fixed placeholder left-arrow @click-left="router.back()" />
<van-tabs v-model:active="activeTab">
<van-tab title="全部" name="all" />
<van-tab title="点赞" name="like" />
<van-tab title="评论" name="comment" />
<van-tab title="关注" name="follow" />
<van-tab title="系统" name="system" />
</van-tabs>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="notifications.length" inset>
<van-cell
v-for="notif in notifications"
:key="notif.id"
:title="notif.content"
:label="formatRelativeTime(notif.createTime)"
>
<template #icon>
<van-icon :name="getIcon(notif.type)" :color="getColor(notif.type)" class="notif-icon" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无通知" />
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { userApi, type Notification } from '@/api/user.ts'
import { formatRelativeTime } from '@/utils'
const router = useRouter()
const activeTab = ref('all')
const notifications = ref<Notification[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const getIcon = (type: number) => {
const icons: Record<number, string> = {
1: 'good-job',
2: 'chat-o',
3: 'user-o',
4: 'bell'
}
return icons[type] || 'info-o'
}
const getColor = (type: number) => {
const colors: Record<number, string> = {
1: '#f56c6c',
2: '#e6a23c',
3: '#409eff',
4: '#909399'
}
return colors[type] || '#909399'
}
const loadNotifications = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
notifications.value = []
finished.value = false
}
loading.value = true
try {
const data = await userApi.getNotifications()
notifications.value = data
finished.value = true
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadNotifications()
}
const onRefresh = () => {
loadNotifications(true)
}
onMounted(() => {
loadNotifications()
})
</script>
<style scoped lang="scss">
.notifications-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.notif-icon {
font-size: 20px;
margin-right: 8px;
}
}
</style>
@@ -0,0 +1,130 @@
<template>
<div class="search-page">
<van-nav-bar title="搜索" fixed placeholder>
<template #right>
<van-icon name="search" size="22" @click="handleSearch" />
</template>
</van-nav-bar>
<div class="search-content">
<van-search
v-model="keyword"
placeholder="搜索视频、用户"
shape="round"
@search="handleSearch"
/>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<div class="video-grid">
<VideoCard
v-for="video in videos"
:key="video.id"
:video="video"
@click="goToVideo(video.id)"
/>
</div>
</van-list>
</van-pull-refresh>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import { videoApi } from '@/api/video.ts'
import type { VideoInfo } from '@/types'
const router = useRouter()
const route = useRoute()
const keyword = ref('')
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const loadVideos = async (isRefresh = false) => {
if (!keyword.value.trim()) return
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
loading.value = true
try {
const res = await videoApi.getList({
page: currentPage.value,
size: 20,
keyword: keyword.value
})
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
}
if (videos.value.length >= res.total || res.records.length < 20) {
finished.value = true
}
currentPage.value++
} catch (error) {
showToast('搜索失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
}
const handleSearch = () => {
loadVideos(true)
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
if (route.query.keyword) {
keyword.value = route.query.keyword as string
loadVideos(true)
}
})
</script>
<style scoped lang="scss">
.search-page {
min-height: 100vh;
background: #f5f5f5;
}
.search-content {
padding: 12px;
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-top: 12px;
}
}
</style>
@@ -0,0 +1,203 @@
<template>
<div class="upload-page">
<van-nav-bar title="上传视频" fixed placeholder left-arrow @click-left="router.back()" />
<van-form @submit="handleSubmit">
<van-cell-group inset>
<van-field
v-model="form.title"
name="title"
label="标题"
placeholder="请输入视频标题"
:rules="[{ required: true, message: '请输入标题' }]"
/>
<van-field
v-model="form.description"
name="description"
label="简介"
type="textarea"
rows="3"
autosize
placeholder="请输入视频简介"
/>
<van-field
v-model="categoryName"
is-link
readonly
name="category"
label="分类"
placeholder="请选择分类"
@click="showCategoryPicker = true"
/>
<van-field name="cover" label="封面">
<template #input>
<van-uploader
v-model="coverFile"
max-count="1"
accept="image/*"
:after-read="afterReadCover"
/>
</template>
</van-field>
</van-cell-group>
<div class="upload-area">
<van-uploader
v-model="videoFile"
max-count="1"
accept="video/*"
:after-read="afterReadVideo"
>
<van-button type="primary" block>选择视频文件</van-button>
</van-uploader>
<div v-if="uploadProgress > 0" class="progress">
<van-progress :percentage="uploadProgress" />
<span>上传中 {{ uploadProgress }}%</span>
</div>
</div>
<div class="submit-btn">
<van-button type="primary" block native-type="submit" :loading="submitting">
发布视频
</van-button>
</div>
</van-form>
<van-popup v-model:show="showCategoryPicker" position="bottom" round>
<van-picker
:columns="categories"
@confirm="onCategoryConfirm"
@cancel="showCategoryPicker = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { videoApi } from '@/api/video.ts'
import { categoryApi, type Category } from '@/api/category.ts'
const router = useRouter()
const form = reactive({
title: '',
description: '',
categoryId: 0
})
const categoryName = ref('')
const coverFile = ref<any[]>([])
const videoFile = ref<any[]>([])
const uploadProgress = ref(0)
const submitting = ref(false)
const showCategoryPicker = ref(false)
const categories = ref<{ text: string; value: number }[]>([])
const videoUrl = ref('')
const coverUrl = ref('')
const videoInfo = ref<any>(null)
const loadCategories = async () => {
try {
const data: Category[] = await categoryApi.getList()
categories.value = data.map((cat) => ({
text: cat.name,
value: cat.id
}))
} catch (error) {
console.error('加载分类失败:', error)
}
}
const onCategoryConfirm = ({ selectedOptions }: any) => {
const option = selectedOptions[0]
form.categoryId = option.value
categoryName.value = option.text
showCategoryPicker.value = false
}
const afterReadCover = async (file: any) => {
// TODO: 上传封面
}
const afterReadVideo = async (file: any) => {
if (!file.file) return
uploadProgress.value = 0
// TODO: 实际上传逻辑
try {
const result = await videoApi.upload(file.file, (percent) => {
uploadProgress.value = percent
})
videoUrl.value = result.videoUrl
coverUrl.value = result.coverUrl
} catch (error) {
showToast('上传失败')
}
}
const handleSubmit = async () => {
if (!videoUrl.value) {
showToast('请先上传视频')
return
}
if (!form.categoryId) {
showToast('请选择分类')
return
}
submitting.value = true
try {
await videoApi.create({
title: form.title,
description: form.description,
videoUrl: videoUrl.value,
coverUrl: coverUrl.value,
duration: videoInfo.value?.duration || 0,
width: videoInfo.value?.width || 0,
height: videoInfo.value?.height || 0,
format: videoInfo.value?.format || '',
size: videoInfo.value?.size || 0,
categoryId: form.categoryId
})
showSuccessToast('发布成功')
router.push('/my/videos')
} catch (error: any) {
showToast(error.message || '发布失败')
} finally {
submitting.value = false
}
}
onMounted(() => {
loadCategories()
})
</script>
<style scoped lang="scss">
.upload-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.upload-area {
padding: 16px;
background: #fff;
margin: 12px;
border-radius: 8px;
.progress {
margin-top: 12px;
text-align: center;
font-size: 14px;
color: #666;
}
}
.submit-btn {
padding: 16px;
}
}
</style>
@@ -0,0 +1,114 @@
<template>
<div class="follow-page">
<van-nav-bar title="粉丝" fixed placeholder left-arrow @click-left="router.back()" />
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group inset>
<van-cell v-for="user in users" :key="user.id" @click="goToProfile(user.id)">
<template #icon>
<van-image :src="user.avatar || defaultAvatar" round width="40" height="40" />
</template>
<template #title>
<span class="nickname">{{ user.nickname }}</span>
</template>
<template #label>
<span class="signature">{{ user.signature || '这个人很懒' }}</span>
</template>
<template #value>
<van-button size="small" :type="user.isFollowed ? 'default' : 'primary'" @click.stop="toggleFollow(user)">
{{ user.isFollowed ? '已关注' : '关注' }}
</van-button>
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="!loading && !users.length" description="暂无粉丝" />
</van-list>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { userApi, type UserFollow } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const userId = Number(route.params.id)
const users = ref<UserFollow[]>([])
const loading = 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 loadUsers = async () => {
loading.value = true
try {
const data = await userApi.getFollowers(userId)
users.value = data
finished.value = true
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!finished.value) loadUsers()
}
const toggleFollow = async (user: UserFollow) => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
try {
if (user.isFollowed) {
await userApi.unfollow(user.id)
user.isFollowed = false
showSuccessToast('已取消关注')
} else {
await userApi.follow(user.id)
user.isFollowed = true
showSuccessToast('关注成功')
}
} catch (error) {
showToast('操作失败')
}
}
const goToProfile = (id: number) => {
router.push(`/user/${id}`)
}
onMounted(() => {
loadUsers()
})
</script>
<style scoped lang="scss">
.follow-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.nickname {
font-weight: 500;
}
.signature {
font-size: 12px;
color: #999;
}
}
</style>
@@ -0,0 +1,101 @@
<template>
<div class="follow-page">
<van-nav-bar title="关注" fixed placeholder left-arrow @click-left="router.back()" />
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group inset>
<van-cell v-for="user in users" :key="user.id" @click="goToProfile(user.id)">
<template #icon>
<van-image :src="user.avatar || defaultAvatar" round width="40" height="40" />
</template>
<template #title>
<span class="nickname">{{ user.nickname }}</span>
</template>
<template #label>
<span class="signature">{{ user.signature || '这个人很懒' }}</span>
</template>
<template #value>
<van-button size="small" type="default" @click.stop="unfollow(user)">
已关注
</van-button>
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="!loading && !users.length" description="暂无关注" />
</van-list>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { userApi, type UserFollow } from '@/api/user.ts'
const router = useRouter()
const route = useRoute()
const userId = Number(route.params.id)
const users = ref<UserFollow[]>([])
const loading = ref(false)
const finished = ref(false)
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 () => {
loading.value = true
try {
const data = await userApi.getFollowing(userId)
users.value = data
finished.value = true
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
}
}
const loadMore = () => {
if (!finished.value) loadUsers()
}
const unfollow = async (user: UserFollow) => {
try {
await userApi.unfollow(user.id)
users.value = users.value.filter((u) => u.id !== user.id)
showSuccessToast('已取消关注')
} catch (error) {
showToast('操作失败')
}
}
const goToProfile = (id: number) => {
router.push(`/user/${id}`)
}
onMounted(() => {
loadUsers()
})
</script>
<style scoped lang="scss">
.follow-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.nickname {
font-weight: 500;
}
.signature {
font-size: 12px;
color: #999;
}
}
</style>
@@ -0,0 +1,117 @@
<template>
<div class="history-page">
<van-nav-bar title="播放历史" fixed placeholder left-arrow @click-left="router.back()">
<template #right>
<van-icon name="delete-o" size="22" @click="clearHistory" />
</template>
</van-nav-bar>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="videos.length" inset>
<van-cell v-for="item in videos" :key="item.videoId" @click="goToVideo(item.videoId)">
<template #icon>
<van-image :src="item.coverUrl" width="80" height="45" fit="cover" radius="4" />
</template>
<template #title>
<div class="video-title">{{ item.title }}</div>
</template>
<template #label>
<div class="video-progress">
观看至 {{ formatDuration(item.progress) }} / {{ formatDuration(item.duration) }}
</div>
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无播放记录" />
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showConfirmDialog } from 'vant'
import { formatDuration } from '@/utils'
interface HistoryItem {
videoId: number
title: string
coverUrl: string
progress: number
duration: number
}
const router = useRouter()
const videos = ref<HistoryItem[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const loadVideos = async (isRefresh = false) => {
if (isRefresh) {
videos.value = []
finished.value = false
}
loading.value = true
// TODO: 实际从API获取播放历史
finished.value = true
loading.value = false
refreshing.value = false
}
const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
}
const clearHistory = async () => {
try {
await showConfirmDialog({
title: '提示',
message: '确定要清空播放历史吗?'
})
videos.value = []
showToast('已清空')
} catch {
// 取消
}
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
loadVideos(true)
})
</script>
<style scoped lang="scss">
.history-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.video-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-progress {
font-size: 12px;
color: #999;
}
}
</style>
@@ -0,0 +1,147 @@
<template>
<div class="my-videos-page">
<van-nav-bar title="我的视频" fixed placeholder left-arrow @click-left="router.back()">
<template #right>
<van-icon name="plus" size="22" @click="router.push('/upload')" />
</template>
</van-nav-bar>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="videos.length" inset>
<van-cell v-for="video in videos" :key="video.id" @click="goToVideo(video.id)">
<template #icon>
<van-image :src="video.coverUrl" width="80" height="45" fit="cover" radius="4" />
</template>
<template #title>
<div class="video-title">{{ video.title }}</div>
</template>
<template #label>
<div class="video-stats">
<span>{{ formatNumber(video.viewCount) }}播放</span>
<span>{{ formatNumber(video.likeCount) }}赞</span>
</div>
</template>
<template #value>
<van-tag :type="getStatusType(video.status)">{{ getStatusText(video.status) }}</van-tag>
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无视频">
<van-button type="primary" round @click="router.push('/upload')">去上传</van-button>
</van-empty>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { videoApi } from '@/api/video.ts'
import { useUserStore } from '@/stores/user.ts'
import { formatNumber } from '@/utils'
import type { VideoInfo } from '@/types'
const router = useRouter()
const userStore = useUserStore()
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const getStatusType = (status: number) => {
const types: Record<number, string> = {
0: 'warning',
1: 'success',
2: 'danger',
3: 'default'
}
return types[status] || 'default'
}
const getStatusText = (status: number) => {
const texts: Record<number, string> = {
0: '审核中',
1: '已发布',
2: '审核拒绝',
3: '已下架'
}
return texts[status] || '未知'
}
const loadVideos = async (isRefresh = false) => {
if (!userStore.userId) return
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
loading.value = true
try {
const res = await videoApi.getUserVideos(userStore.userId, { page: currentPage.value, size: 20 })
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
}
if (videos.value.length >= res.total || res.records.length < 20) {
finished.value = true
}
currentPage.value++
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
loadVideos(true)
})
</script>
<style scoped lang="scss">
.my-videos-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.video-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-stats {
display: flex;
gap: 12px;
font-size: 12px;
color: #999;
}
}
</style>
@@ -0,0 +1,147 @@
<template>
<div class="profile-edit-page">
<van-nav-bar title="编辑资料" fixed placeholder left-arrow @click-left="router.back()">
<template #right>
<van-button type="primary" size="small" :loading="saving" @click="handleSave">保存</van-button>
</template>
</van-nav-bar>
<van-form @submit="handleSave">
<van-cell-group inset>
<van-cell title="头像" is-link @click="chooseAvatar">
<template #value>
<van-image :src="form.avatar || defaultAvatar" round width="48" height="48" fit="cover" />
</template>
</van-cell>
<van-field v-model="form.nickname" name="nickname" label="昵称" placeholder="请输入昵称" />
<van-field v-model="form.signature" name="signature" label="签名" type="textarea" rows="2" autosize placeholder="介绍一下自己" maxlength="100" show-word-limit />
<van-field name="gender" label="性别">
<template #input>
<van-radio-group v-model="form.gender" direction="horizontal">
<van-radio :value="0">保密</van-radio>
<van-radio :value="1">男</van-radio>
<van-radio :value="2">女</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field v-model="form.birthday" name="birthday" label="生日" is-link readonly placeholder="选择生日" @click="showBirthdayPicker = true" />
<van-field v-model="form.region" name="region" label="地区" placeholder="请输入地区" />
</van-cell-group>
</van-form>
<van-popup v-model:show="showBirthdayPicker" position="bottom" round>
<van-date-picker v-model="selectedDate" title="选择生日" @confirm="onBirthdayConfirm" @cancel="showBirthdayPicker = false" />
</van-popup>
<input ref="avatarInput" type="file" accept="image/*" style="display: none" @change="onAvatarChange" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
const router = useRouter()
const userStore = useUserStore()
const saving = ref(false)
const showBirthdayPicker = ref(false)
const avatarInput = ref<HTMLInputElement>()
const selectedDate = ref(['2000', '01', '01'])
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 form = reactive({
avatar: '',
nickname: '',
signature: '',
gender: 0,
birthday: '',
region: ''
})
const loadProfile = async () => {
try {
const profile = await userApi.getCurrentProfile()
form.avatar = profile.avatar || ''
form.nickname = profile.nickname || ''
form.signature = profile.signature || ''
form.gender = profile.gender || 0
form.birthday = profile.birthday || ''
form.region = profile.region || ''
if (form.birthday) {
selectedDate.value = form.birthday.split('-')
}
} catch (error) {
showToast('加载资料失败')
}
}
const chooseAvatar = () => {
avatarInput.value?.click()
}
const onAvatarChange = async (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
try {
const url = await userApi.uploadAvatar(file)
form.avatar = url
showSuccessToast('头像上传成功')
} catch (error) {
showToast('头像上传失败')
}
}
const onBirthdayConfirm = ({ selectedValues }: any) => {
form.birthday = selectedValues.join('-')
showBirthdayPicker.value = false
}
const handleSave = async () => {
if (!form.nickname.trim()) {
showToast('请输入昵称')
return
}
saving.value = true
try {
await userApi.updateProfile({
nickname: form.nickname,
signature: form.signature,
gender: form.gender,
birthday: form.birthday,
region: form.region
})
if (form.avatar) {
// 头像已单独上传
}
showSuccessToast('保存成功')
router.back()
} catch (error) {
showToast('保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
loadProfile()
})
</script>
<style scoped lang="scss">
.profile-edit-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.van-cell-group {
margin: 12px;
}
}
</style>
@@ -0,0 +1,222 @@
<template>
<div class="profile-page">
<van-nav-bar title="用户主页" fixed placeholder left-arrow @click-left="router.back()" />
<div v-if="loading" class="loading-container">
<van-loading size="24px">加载中...</van-loading>
</div>
<template v-else-if="userInfo">
<div class="user-header">
<div class="user-info">
<van-image :src="userInfo.avatar || defaultAvatar" round width="80" height="80" />
<div class="info">
<h2 class="nickname">{{ userInfo.nickname }}</h2>
<div class="level">Lv.{{ userInfo.level }}</div>
<div class="signature">{{ userInfo.signature || '这个人很懒,什么都没写' }}</div>
</div>
</div>
<div class="stats">
<div class="stat-item">
<div class="value">{{ userInfo.videoCount }}</div>
<div class="label">视频</div>
</div>
<div class="stat-item">
<div class="value">{{ userInfo.followerCount }}</div>
<div class="label">粉丝</div>
</div>
<div class="stat-item">
<div class="value">{{ userInfo.followingCount }}</div>
<div class="label">关注</div>
</div>
<div class="stat-item">
<div class="value">{{ userInfo.likeCount }}</div>
<div class="label">获赞</div>
</div>
</div>
<div class="actions">
<van-button v-if="!isSelf" :type="isFollowed ? 'default' : 'primary'" size="small" @click="toggleFollow">
{{ isFollowed ? '已关注' : '关注' }}
</van-button>
<van-button v-else type="primary" size="small" @click="router.push('/settings/profile')">
编辑资料
</van-button>
</div>
</div>
<van-tabs v-model:active="activeTab" sticky>
<van-tab title="视频" name="videos">
<div class="video-grid">
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
</div>
</van-tab>
<van-tab title="收藏" name="favorites">
<van-empty description="暂无收藏" />
</van-tab>
</van-tabs>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import { userApi } from '@/api/user.ts'
import { videoApi } from '@/api/video.ts'
import { useUserStore } from '@/stores/user.ts'
import type { UserInfo, VideoInfo } from '@/types'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const userId = computed(() => Number(route.params.id))
const isSelf = computed(() => userStore.userId === userId.value)
const loading = ref(true)
const userInfo = ref<UserInfo | null>(null)
const isFollowed = ref(false)
const activeTab = ref('videos')
const videos = ref<VideoInfo[]>([])
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 loadUserInfo = async () => {
try {
userInfo.value = await userApi.getPublicProfile(userId.value)
if (!isSelf.value && userStore.isLoggedIn) {
isFollowed.value = await userApi.checkFollow(userId.value)
}
} catch (error) {
showToast('加载用户信息失败')
}
}
const loadUserVideos = async () => {
try {
const res = await videoApi.getUserVideos(userId.value, { page: 1, size: 20 })
videos.value = res.records
} catch (error) {
console.error('加载视频失败:', error)
}
}
const toggleFollow = async () => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
try {
if (isFollowed.value) {
await userApi.unfollow(userId.value)
isFollowed.value = false
showSuccessToast('已取消关注')
} else {
await userApi.follow(userId.value)
isFollowed.value = true
showSuccessToast('关注成功')
}
} catch (error) {
showToast('操作失败')
}
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(async () => {
loading.value = true
await Promise.all([loadUserInfo(), loadUserVideos()])
loading.value = false
})
</script>
<style scoped lang="scss">
.profile-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
}
.loading-container {
display: flex;
justify-content: center;
padding: 40px;
}
.user-header {
background: #fff;
padding: 20px;
.user-info {
display: flex;
gap: 16px;
margin-bottom: 16px;
.info {
flex: 1;
.nickname {
font-size: 18px;
font-weight: bold;
margin: 0 0 4px;
}
.level {
display: inline-block;
padding: 2px 8px;
background: linear-gradient(to right, #00a1d6, #fb7299);
color: #fff;
font-size: 12px;
border-radius: 10px;
margin-bottom: 8px;
}
.signature {
font-size: 14px;
color: #666;
}
}
}
.stats {
display: flex;
justify-content: space-around;
padding: 16px 0;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
.stat-item {
text-align: center;
.value {
font-size: 18px;
font-weight: bold;
}
.label {
font-size: 12px;
color: #999;
margin-top: 4px;
}
}
}
.actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 16px;
}
}
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 12px;
}
</style>
@@ -0,0 +1,68 @@
<template>
<div class="settings-page">
<van-nav-bar title="设置" fixed placeholder left-arrow @click-left="router.back()" />
<van-cell-group inset>
<van-cell title="账号与安全" is-link to="/settings/profile" />
<van-cell title="隐私设置" is-link to="/settings/privacy" />
<van-cell title="通知设置" is-link to="/settings/notification" />
</van-cell-group>
<van-cell-group inset>
<van-cell title="清除缓存" :value="cacheSize" @click="clearCache" />
<van-cell title="关于我们" is-link to="/about" />
<van-cell title="版本号" value="1.0.0" />
</van-cell-group>
<div class="logout-btn">
<van-button type="danger" block @click="handleLogout">退出登录</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog, showToast, showSuccessToast } from 'vant'
import { useUserStore } from '@/stores/user.ts'
const router = useRouter()
const userStore = useUserStore()
const cacheSize = ref('0KB')
const clearCache = () => {
showToast('缓存已清除')
cacheSize.value = '0KB'
}
const handleLogout = async () => {
try {
await showConfirmDialog({
title: '提示',
message: '确定要退出登录吗?'
})
await userStore.logout()
showSuccessToast('已退出登录')
router.push('/')
} catch {
// 取消
}
}
</script>
<style scoped lang="scss">
.settings-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
.van-cell-group {
margin: 12px;
}
.logout-btn {
padding: 20px 16px;
}
}
</style>
@@ -0,0 +1,95 @@
<template>
<div class="user-videos-page">
<van-nav-bar title="用户视频" fixed placeholder left-arrow @click-left="router.back()" />
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<div class="video-grid">
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
</div>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import { videoApi } from '@/api/video.ts'
import type { VideoInfo } from '@/types'
const router = useRouter()
const route = useRoute()
const userId = Number(route.params.id)
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const loadVideos = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
loading.value = true
try {
const res = await videoApi.getUserVideos(userId, { page: currentPage.value, size: 20 })
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
}
if (videos.value.length >= res.total || res.records.length < 20) {
finished.value = true
}
currentPage.value++
} catch (error) {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
loadVideos(true)
})
</script>
<style scoped lang="scss">
.user-videos-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
}
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 12px;
}
</style>
@@ -0,0 +1,424 @@
<template>
<div class="video-detail-page">
<!-- 视频播放器 -->
<div class="player-container">
<VideoPlayer
v-if="video"
:src="video.videoUrl"
:poster="video.coverUrl"
:danmaku-list="danmakuList"
@timeupdate="onTimeUpdate"
@ended="onEnded"
/>
<van-skeleton v-else title :row="3" />
</div>
<!-- 视频信息 -->
<div class="video-info" v-if="video">
<h1 class="video-title">{{ video.title }}</h1>
<!-- 统计数据 -->
<div class="video-stats">
<span>{{ formatPlayCount(video.viewCount) }}播放</span>
<span>{{ formatNumber(video.danmakuCount) }}弹幕</span>
<span>{{ formatDateTime(video.publishTime || video.createTime) }}</span>
</div>
<!-- 操作按钮 -->
<div class="video-actions">
<van-button size="small" :type="video.isLiked ? 'primary' : 'default'" @click="toggleLike">
<van-icon :name="video.isLiked ? 'good-job' : 'good-job-o'" />
{{ formatNumber(video.likeCount) }}
</van-button>
<van-button size="small" :type="video.isCoined ? 'primary' : 'default'" @click="toggleCoin">
<van-icon name="coin" />
{{ formatNumber(video.coinCount) }}
</van-button>
<van-button size="small" :type="video.isFavorited ? 'primary' : 'default'" @click="toggleFavorite">
<van-icon :name="video.isFavorited ? 'star' : 'star-o'" />
{{ formatNumber(video.favoriteCount) }}
</van-button>
<van-button size="small" @click="shareVideo">
<van-icon name="share-o" />
分享
</van-button>
</div>
<!-- 作者信息 -->
<div class="author-info" @click="goToUser(video.userId)">
<van-image :src="video.userAvatar || defaultAvatar" round class="author-avatar" />
<div class="author-detail">
<div class="author-name">{{ video.userNickname }}</div>
<div class="author-fans">{{ formatNumber(video.likeCount) }}粉丝</div>
</div>
<van-button
size="small"
:type="isFollowed ? 'default' : 'primary'"
@click.stop="toggleFollow"
>
{{ isFollowed ? '已关注' : '关注' }}
</van-button>
</div>
<!-- 简介 -->
<div class="video-desc">
<van-collapse v-model="activeCollapse">
<van-collapse-item title="简介" name="desc">
<div class="desc-content">{{ video.description || '暂无简介' }}</div>
<div class="video-tags" v-if="video.tagNames?.length">
<van-tag v-for="tag in video.tagNames" :key="tag" plain>{{ tag }}</van-tag>
</div>
</van-collapse-item>
</van-collapse>
</div>
</div>
<!-- 标签页 -->
<van-tabs v-model:active="activeTab" sticky class="detail-tabs">
<!-- 评论 -->
<van-tab title="评论" name="comment">
<CommentSection v-if="video" :video-id="video.id" />
</van-tab>
<!-- 弹幕 -->
<van-tab title="弹幕" name="danmaku">
<DanmakuList v-if="video" :video-id="video.id" />
</van-tab>
<!-- 相关推荐 -->
<van-tab title="推荐" name="recommend">
<div class="recommend-list">
<VideoCard
v-for="item in recommendVideos"
:key="item.id"
:video="item"
@click="goToVideo(item.id)"
/>
</div>
</van-tab>
</van-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showSuccessToast, showConfirmDialog } from 'vant'
import VideoPlayer from '@/components/video/VideoPlayer.vue'
import CommentSection from '@/components/comment/CommentSection.vue'
import DanmakuList from '@/components/danmaku/DanmakuList.vue'
import VideoCard from '@/components/video/VideoCard.vue'
import { videoApi } from '@/api/video.ts'
import { danmakuApi, type Danmaku } from '@/api/danmaku.ts'
import { recommendApi } from '@/api/recommend.ts'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import type { VideoInfo } from '@/types'
import { formatDateTime, formatNumber, formatPlayCount } from '@/utils'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const videoId = computed(() => Number(route.params.id))
const video = ref<VideoInfo | null>(null)
const danmakuList = ref<Danmaku[]>([])
const recommendVideos = ref<VideoInfo[]>([])
const isFollowed = ref(false)
const activeTab = ref('comment')
const activeCollapse = ref<string[]>([])
const currentTime = ref(0)
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 loadVideo = async () => {
try {
video.value = await videoApi.getDetail(videoId.value)
// 记录播放
videoApi.recordPlay({
videoId: videoId.value,
progress: 0,
duration: video.value.duration
})
// 加载弹幕
loadDanmaku()
// 加载推荐
loadRecommend()
// 检查关注状态
if (userStore.isLoggedIn) {
checkFollowStatus()
}
} catch (error: any) {
showToast(error.message || '加载失败')
}
}
// 加载弹幕
const loadDanmaku = async () => {
try {
danmakuList.value = await danmakuApi.getAll(videoId.value)
} catch (error) {
console.error('加载弹幕失败:', error)
}
}
// 加载推荐
const loadRecommend = async () => {
try {
const res = await recommendApi.getSimilar(videoId.value, { page: 1, size: 10 })
recommendVideos.value = res.records
} catch (error) {
console.error('加载推荐失败:', error)
}
}
// 检查关注状态
const checkFollowStatus = async () => {
if (!video.value) return
try {
isFollowed.value = await userApi.checkFollow(video.value.userId)
} catch (error) {
console.error('检查关注状态失败:', error)
}
}
// 点赞
const toggleLike = async () => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
if (!video.value) return
try {
if (video.value.isLiked) {
await videoApi.unlike(video.value.id)
video.value.likeCount--
video.value.isLiked = false
} else {
await videoApi.like(video.value.id)
video.value.likeCount++
video.value.isLiked = true
}
} catch (error: any) {
showToast(error.message || '操作失败')
}
}
// 投币
const toggleCoin = async () => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
if (!video.value) return
try {
await videoApi.coin(video.value.id)
video.value.coinCount++
showSuccessToast('投币成功')
} catch (error: any) {
showToast(error.message || '投币失败')
}
}
// 收藏
const toggleFavorite = async () => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
if (!video.value) return
try {
if (video.value.isFavorited) {
await videoApi.unfavorite(video.value.id)
video.value.favoriteCount--
video.value.isFavorited = false
} else {
// TODO: 选择收藏夹
await videoApi.favorite({ videoId: video.value.id, favoriteId: 0 })
video.value.favoriteCount++
video.value.isFavorited = true
}
} catch (error: any) {
showToast(error.message || '操作失败')
}
}
// 分享
const shareVideo = async () => {
const url = window.location.href
try {
await navigator.clipboard.writeText(url)
showSuccessToast('链接已复制')
videoApi.share(videoId.value)
} catch {
showToast('复制失败')
}
}
// 关注
const toggleFollow = async () => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
if (!video.value) return
try {
if (isFollowed.value) {
await userApi.unfollow(video.value.userId)
isFollowed.value = false
showSuccessToast('已取消关注')
} else {
await userApi.follow(video.value.userId)
isFollowed.value = true
showSuccessToast('关注成功')
}
} catch (error: any) {
showToast(error.message || '操作失败')
}
}
// 跳转用户主页
const goToUser = (userId: number) => {
router.push(`/user/${userId}`)
}
// 跳转视频
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
// 播放时间更新
const onTimeUpdate = (time: number) => {
currentTime.value = time
}
// 播放结束
const onEnded = () => {
// 播放完成逻辑
}
onMounted(() => {
loadVideo()
})
</script>
<style scoped lang="scss">
.video-detail-page {
min-height: 100vh;
background: #f5f5f5;
}
.player-container {
width: 100%;
background: #000;
aspect-ratio: 16 / 9;
position: sticky;
top: 0;
z-index: 100;
@media (min-width: 768px) {
max-height: 70vh;
}
}
.video-info {
background: #fff;
padding: 12px 16px;
.video-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0 0 8px;
line-height: 1.4;
}
.video-stats {
display: flex;
gap: 16px;
font-size: 13px;
color: #999;
margin-bottom: 12px;
}
.video-actions {
display: flex;
gap: 8px;
margin-bottom: 16px;
.van-button {
flex: 1;
}
}
.author-info {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
cursor: pointer;
.author-avatar {
width: 40px;
height: 40px;
}
.author-detail {
flex: 1;
.author-name {
font-size: 15px;
font-weight: 500;
color: #333;
}
.author-fans {
font-size: 12px;
color: #999;
margin-top: 2px;
}
}
}
.video-desc {
margin-top: 12px;
.desc-content {
font-size: 14px;
color: #666;
line-height: 1.6;
white-space: pre-wrap;
}
.video-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
}
}
.detail-tabs {
background: #fff;
margin-top: 12px;
}
.recommend-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 12px;
@media (min-width: 768px) {
grid-template-columns: repeat(4, 1fr);
}
}
</style>
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}