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