refactor(frontend): Rework home and video detail interactions

This commit is contained in:
2026-04-24 17:35:13 +08:00
parent 833dd3451a
commit 59af57c0fa
38 changed files with 2693 additions and 986 deletions
@@ -7,6 +7,7 @@ import type {
VideoUploadParams,
VideoUploadResult,
PlayRecord,
PlayHistoryItem,
PageResult
} from '@/types'
@@ -134,6 +135,20 @@ export const videoApi = {
return request.post('/content/video/play', params)
},
/**
* 获取播放历史
*/
getPlayHistory(params?: { pageNum?: number; pageSize?: number }): Promise<PageResult<PlayHistoryItem>> {
return request.get('/content/video/history', params)
},
/**
* 清空播放历史
*/
clearPlayHistory(): Promise<void> {
return request.delete('/content/video/history')
},
/**
* 增加评论数量(内部接口)
*/
-1
View File
@@ -31,7 +31,6 @@ declare module 'vue' {
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']
VanRadio: typeof import('vant/es')['Radio']
@@ -113,7 +113,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { showToast, showSuccessToast } from 'vant'
import { showToast, showSuccessToast } from '@/utils/toast'
import { commentApi } from '@/api/comment.ts'
import { useUserStore } from '@/stores/user.ts'
import { formatRelativeTime } from '@/utils'
@@ -74,72 +74,128 @@
</div>
<div class="banner-actions">
<button
class="avatar-entry"
@click="goToProfileOrLogin"
>
<van-image
:src="displayAvatar"
round
width="36"
height="36"
/>
</button>
<button
class="banner-action"
@click="router.push('/notifications')"
>
<van-badge
:content="messageStore.unreadNotificationCount || ''"
:show-zero="false"
<template v-if="userStore.isLoggedIn">
<div
class="hover-card-anchor"
@mouseenter="openHoverPanel('avatar', $event)"
@mouseleave="scheduleHoverClose()"
>
<button
class="avatar-entry"
@click="goToProfileOrLogin"
>
<van-image
:src="displayAvatar"
round
width="36"
height="36"
/>
</button>
<div
v-if="showHoverPanel('avatar')"
class="hover-card profile-card"
@mouseenter="openHoverPanel('avatar', $event)"
@mouseleave="scheduleHoverClose()"
>
<button class="hover-menu-item" @click="goToSelfProfile">个人中心</button>
<button class="hover-menu-item" @click="router.push('/my/videos')">我的作品</button>
<button class="hover-menu-item" @click="router.push('/my/history')">历史</button>
<button class="hover-menu-item" @click="router.push('/settings')">设置</button>
<button class="hover-menu-item danger" @click="handleLogout">退出登录</button>
</div>
</div>
<div
class="hover-card-anchor"
@mouseenter="openHoverPanel('message', $event)"
@mouseleave="scheduleHoverClose()"
>
<button
class="banner-action"
@click="router.push('/notifications')"
>
<van-badge
:content="messageStore.unreadNotificationCount || ''"
:show-zero="false"
>
<van-icon
name="bell"
size="18"
/>
</van-badge>
<span>消息</span>
</button>
<div
v-if="showHoverPanel('message')"
class="hover-card"
@mouseenter="openHoverPanel('message', $event)"
@mouseleave="scheduleHoverClose()"
>
<button class="hover-menu-item" @click="goToNotificationTab('comment')">评论通知</button>
<button class="hover-menu-item" @click="goToNotificationTab('like')">点赞通知</button>
<button class="hover-menu-item" @click="goToNotificationTab('system')">系统通知</button>
</div>
</div>
<button
class="banner-action"
@click="router.push('/my/favorites')"
>
<van-icon
name="bell"
name="star-o"
size="18"
/>
</van-badge>
<span>消息</span>
</button>
<button
class="banner-action"
@click="router.push('/my/favorites')"
<span>收藏</span>
</button>
<button
class="banner-action"
@click="router.push('/my/history')"
>
<van-icon
name="clock-o"
size="18"
/>
<span>历史</span>
</button>
</template>
<template v-else>
<button
class="banner-action auth-action"
@click="router.push('/login')"
>
<span>登录</span>
</button>
<button
class="banner-action auth-action"
@click="router.push('/register')"
>
<span>注册</span>
</button>
</template>
<div
class="hover-card-anchor"
@mouseenter="openHoverPanel('creator', $event)"
@mouseleave="scheduleHoverClose()"
>
<van-icon
name="star-o"
size="18"
/>
<span>收藏</span>
</button>
<button
class="banner-action"
@click="router.push('/my/history')"
>
<van-icon
name="clock-o"
size="18"
/>
<span>历史</span>
</button>
<button
class="banner-action"
@click="router.push(userStore.isLoggedIn ? '/my/videos' : '/login')"
>
<van-icon
name="video-o"
size="18"
/>
<span>创作中心</span>
</button>
<button
class="upload-entry"
@click="router.push(userStore.isLoggedIn ? '/upload' : '/login')"
>
<van-icon
name="upgrade"
size="16"
/>
<span>投稿</span>
</button>
<button
class="upload-entry"
@click="router.push(userStore.isLoggedIn ? '/upload' : '/login')"
>
<van-icon
name="upgrade"
size="16"
/>
<span>投稿</span>
</button>
<div
v-if="showHoverPanel('creator')"
class="hover-card"
@mouseenter="openHoverPanel('creator', $event)"
@mouseleave="scheduleHoverClose()"
>
<button class="hover-menu-item" @click="router.push('/upload')">上传视频</button>
<button class="hover-menu-item" @click="router.push('/my/videos')">我的稿件</button>
<button class="hover-menu-item" @click="goToCreatorOverview">数据概览</button>
</div>
</div>
</div>
</div>
</div>
@@ -200,55 +256,121 @@
</div>
<div class="sticky-actions">
<button
class="avatar-entry compact"
@click="goToProfileOrLogin"
>
<van-image
:src="displayAvatar"
round
width="32"
height="32"
/>
</button>
<button
class="icon-action"
@click="router.push('/notifications')"
>
<van-badge
:content="messageStore.unreadNotificationCount || ''"
:show-zero="false"
<template v-if="userStore.isLoggedIn">
<div
class="hover-card-anchor"
@mouseenter="openHoverPanel('avatar', $event)"
@mouseleave="scheduleHoverClose()"
>
<button
class="avatar-entry compact"
@click="goToProfileOrLogin"
>
<van-image
:src="displayAvatar"
round
width="32"
height="32"
/>
</button>
<div
v-if="showHoverPanel('avatar')"
class="hover-card profile-card compact-card"
@mouseenter="openHoverPanel('avatar', $event)"
@mouseleave="scheduleHoverClose()"
>
<button class="hover-menu-item" @click="goToSelfProfile">个人中心</button>
<button class="hover-menu-item" @click="router.push('/my/videos')">我的作品</button>
<button class="hover-menu-item" @click="router.push('/my/history')">历史</button>
<button class="hover-menu-item" @click="router.push('/settings')">设置</button>
<button class="hover-menu-item danger" @click="handleLogout">退出登录</button>
</div>
</div>
<div
class="hover-card-anchor"
@mouseenter="openHoverPanel('message', $event)"
@mouseleave="scheduleHoverClose()"
>
<button
class="icon-action"
@click="router.push('/notifications')"
>
<van-badge
:content="messageStore.unreadNotificationCount || ''"
:show-zero="false"
>
<van-icon
name="bell"
size="18"
/>
</van-badge>
</button>
<div
v-if="showHoverPanel('message')"
class="hover-card compact-card"
@mouseenter="openHoverPanel('message', $event)"
@mouseleave="scheduleHoverClose()"
>
<button class="hover-menu-item" @click="goToNotificationTab('comment')">评论通知</button>
<button class="hover-menu-item" @click="goToNotificationTab('like')">点赞通知</button>
<button class="hover-menu-item" @click="goToNotificationTab('system')">系统通知</button>
</div>
</div>
<button
class="icon-action"
@click="router.push('/my/favorites')"
>
<van-icon
name="bell"
name="star-o"
size="18"
/>
</van-badge>
</button>
<button
class="icon-action"
@click="router.push('/my/favorites')"
</button>
<button
class="icon-action"
@click="router.push('/my/history')"
>
<van-icon
name="clock-o"
size="18"
/>
</button>
</template>
<template v-else>
<button
class="sticky-link auth-link"
@click="router.push('/login')"
>
登录
</button>
<button
class="sticky-link auth-link"
@click="router.push('/register')"
>
注册
</button>
</template>
<div
class="hover-card-anchor"
@mouseenter="openHoverPanel('creator', $event)"
@mouseleave="scheduleHoverClose()"
>
<van-icon
name="star-o"
size="18"
/>
</button>
<button
class="icon-action"
@click="router.push('/my/history')"
>
<van-icon
name="clock-o"
size="18"
/>
</button>
<button
class="upload-entry compact"
@click="router.push(userStore.isLoggedIn ? '/upload' : '/login')"
>
投稿
</button>
<button
class="upload-entry compact"
@click="router.push(userStore.isLoggedIn ? '/upload' : '/login')"
>
投稿
</button>
<div
v-if="showHoverPanel('creator')"
class="hover-card compact-card"
@mouseenter="openHoverPanel('creator', $event)"
@mouseleave="scheduleHoverClose()"
>
<button class="hover-menu-item" @click="router.push('/upload')">上传视频</button>
<button class="hover-menu-item" @click="router.push('/my/videos')">我的稿件</button>
<button class="hover-menu-item" @click="goToCreatorOverview">数据概览</button>
</div>
</div>
</div>
</div>
</div>
@@ -300,55 +422,57 @@
历史
</button>
<van-popover
v-model:show="showMessagePopover"
trigger="click"
placement="bottom-end"
<div
class="hover-card-anchor"
@mouseenter="openHoverPanel('message', $event)"
@mouseleave="scheduleHoverClose()"
>
<div class="popover-menu">
<button
class="popover-item"
@click="goToMessages"
<button class="top-icon">
<van-badge
:content="messageStore.totalUnreadCount || ''"
:show-zero="false"
>
<span>私信</span>
<van-badge
v-if="messageStore.unreadMessageCount"
:content="messageStore.unreadMessageCount"
<van-icon
name="bell"
size="18"
/>
</button>
<button
class="popover-item"
@click="goToNotifications"
>
<span>通知</span>
<van-badge
v-if="messageStore.unreadNotificationCount"
:content="messageStore.unreadNotificationCount"
/>
</button>
</van-badge>
</button>
<div
v-if="showHoverPanel('message')"
class="hover-card compact-card"
@mouseenter="openHoverPanel('message', $event)"
@mouseleave="scheduleHoverClose()"
>
<button class="hover-menu-item" @click="goToNotificationTab('comment')">评论通知</button>
<button class="hover-menu-item" @click="goToNotificationTab('like')">点赞通知</button>
<button class="hover-menu-item" @click="goToNotificationTab('system')">系统通知</button>
</div>
<template #reference>
<button class="top-icon">
<van-badge
:content="messageStore.totalUnreadCount || ''"
:show-zero="false"
>
<van-icon
name="bell"
size="18"
/>
</van-badge>
</button>
</template>
</van-popover>
</div>
</template>
<button
class="upload-entry compact"
@click="router.push(userStore.isLoggedIn ? '/upload' : '/login')"
<div
class="hover-card-anchor"
@mouseenter="openHoverPanel('creator', $event)"
@mouseleave="scheduleHoverClose()"
>
投稿
</button>
<button
class="upload-entry compact"
@click="router.push(userStore.isLoggedIn ? '/upload' : '/login')"
>
投稿
</button>
<div
v-if="showHoverPanel('creator')"
class="hover-card compact-card"
@mouseenter="openHoverPanel('creator', $event)"
@mouseleave="scheduleHoverClose()"
>
<button class="hover-menu-item" @click="router.push('/upload')">上传视频</button>
<button class="hover-menu-item" @click="router.push('/my/videos')">我的稿件</button>
<button class="hover-menu-item" @click="goToCreatorOverview">数据概览</button>
</div>
</div>
</div>
</div>
</div>
@@ -406,26 +530,64 @@
</template>
<template v-else>
<button
class="user-chip"
@click="goToProfileOrLogin"
<div
class="hover-card-anchor"
@mouseenter="openHoverPanel('avatar', $event)"
@mouseleave="scheduleHoverClose()"
>
<van-image
:src="displayAvatar"
round
width="36"
height="36"
/>
<div class="user-copy">
<strong>{{ displayNickname }}</strong>
<span>{{ displaySignature }}</span>
<button
class="user-chip"
@click="goToProfileOrLogin"
>
<van-image
:src="displayAvatar"
round
width="36"
height="36"
/>
<div class="user-copy">
<strong>{{ displayNickname }}</strong>
<span>{{ displaySignature }}</span>
</div>
</button>
<div
v-if="showHoverPanel('avatar')"
class="hover-card profile-card"
@mouseenter="openHoverPanel('avatar', $event)"
@mouseleave="scheduleHoverClose()"
>
<button class="hover-menu-item" @click="goToSelfProfile">个人中心</button>
<button class="hover-menu-item" @click="router.push('/my/videos')">我的作品</button>
<button class="hover-menu-item" @click="router.push('/my/history')">历史</button>
<button class="hover-menu-item" @click="router.push('/settings')">设置</button>
<button class="hover-menu-item danger" @click="handleLogout">退出登录</button>
</div>
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<Teleport to="body">
<div
v-if="activeHoverPanel && hoverPanelItems.length"
class="floating-hover-panel"
:style="hoverPanelStyle"
@mouseenter="reopenActiveHoverPanel"
@mouseleave="scheduleHoverClose()"
>
<button
v-for="item in hoverPanelItems"
:key="item.label"
class="hover-menu-item"
:class="{ danger: item.danger }"
@click="item.action()"
>
{{ item.label }}
</button>
</div>
</Teleport>
</header>
</template>
@@ -452,10 +614,12 @@ const userStore = useUserStore()
const messageStore = useMessageStore()
const searchKeyword = ref('')
const showMessagePopover = ref(false)
const bannerMotionX = ref(0)
const homeScrollY = ref(0)
const homeHeaderCompact = ref(false)
const activeHoverPanel = ref<'avatar' | 'message' | 'creator' | null>(null)
const hoverPanelPosition = ref({ top: 0, left: 0 })
let hoverCloseTimer: ReturnType<typeof setTimeout> | null = null
const defaultAvatar =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSI1MCIgZmlsbD0iI2YzZjRmNiIvPjxjaXJjbGUgY3g9IjUwIiBjeT0iMzUiIHI9IjE1IiBmaWxsPSIjYmRjM2NmIi8+PGVsbGlwc2UgY3g9IjUwIiBjeT0iNzUiIHJ4PSIyNSIgcnk9IjIwIiBmaWxsPSIjYmRjM2NmIi8+PC9zdmc+'
@@ -564,14 +728,88 @@ const handleSearch = () => {
router.push({ path: '/search', query: { keyword } })
}
const goToMessages = () => {
showMessagePopover.value = false
router.push('/messages')
const hoverPanelItems = computed(() => {
if (!userStore.isLoggedIn && activeHoverPanel.value !== 'creator') {
return []
}
if (activeHoverPanel.value === 'avatar') {
return [
{ label: '个人中心', action: goToSelfProfile },
{ label: '我的作品', action: () => router.push('/my/videos') },
{ label: '历史', action: () => router.push('/my/history') },
{ label: '设置', action: () => router.push('/settings') },
{ label: '退出登录', action: handleLogout, danger: true }
]
}
if (activeHoverPanel.value === 'message') {
return [
{ label: '评论通知', action: () => goToNotificationTab('comment') },
{ label: '点赞通知', action: () => goToNotificationTab('like') },
{ label: '系统通知', action: () => goToNotificationTab('system') }
]
}
if (activeHoverPanel.value === 'creator' && userStore.isLoggedIn) {
return [
{ label: '上传视频', action: () => router.push('/upload') },
{ label: '我的稿件', action: () => router.push('/my/videos') },
{ label: '数据概览', action: goToCreatorOverview }
]
}
return []
})
const hoverPanelStyle = computed(() => ({
top: `${hoverPanelPosition.value.top}px`,
left: `${hoverPanelPosition.value.left}px`
}))
const clearHoverCloseTimer = () => {
if (hoverCloseTimer) {
clearTimeout(hoverCloseTimer)
hoverCloseTimer = null
}
}
const goToNotifications = () => {
showMessagePopover.value = false
router.push('/notifications')
const openHoverPanel = (panel: 'avatar' | 'message' | 'creator', event?: MouseEvent) => {
if (!userStore.isLoggedIn && panel !== 'creator') return
clearHoverCloseTimer()
activeHoverPanel.value = panel
const currentTarget = event?.currentTarget as HTMLElement | null
if (currentTarget) {
const rect = currentTarget.getBoundingClientRect()
hoverPanelPosition.value = {
top: rect.bottom + 10,
left: rect.right
}
}
}
const scheduleHoverClose = () => {
clearHoverCloseTimer()
hoverCloseTimer = setTimeout(() => {
activeHoverPanel.value = null
}, 120)
}
const closeHoverPanel = () => {
clearHoverCloseTimer()
activeHoverPanel.value = null
}
const showHoverPanel = (_panel: 'avatar' | 'message' | 'creator') => false
const reopenActiveHoverPanel = () => {
clearHoverCloseTimer()
}
const goToNotificationTab = (tab: 'comment' | 'like' | 'system') => {
closeHoverPanel()
router.push({ path: '/notifications', query: { tab } })
}
const goToProfileOrLogin = () => {
@@ -583,6 +821,27 @@ const goToProfileOrLogin = () => {
router.push(`/user/${id}`)
}
const goToSelfProfile = () => {
closeHoverPanel()
goToProfileOrLogin()
}
const goToCreatorOverview = () => {
closeHoverPanel()
const id = userStore.userId || userStore.userInfo?.id || userStore.authUser?.id
if (!id) {
router.push('/login')
return
}
router.push(`/user/${id}`)
}
const handleLogout = async () => {
closeHoverPanel()
await userStore.logout()
router.push('/')
}
onMounted(async () => {
syncSearchKeyword()
updateHomeScroll()
@@ -590,6 +849,7 @@ onMounted(async () => {
if (!userStore.isLoggedIn) return
let authReady = true
try {
if (!userStore.authUser) {
await userStore.fetchAuthUser()
@@ -598,14 +858,17 @@ onMounted(async () => {
await userStore.fetchUserInfo()
}
} catch {
// keep usable
authReady = false
}
messageStore.fetchAllUnreadCount()
if (authReady && userStore.isLoggedIn) {
messageStore.fetchAllUnreadCount()
}
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', updateHomeScroll)
clearHoverCloseTimer()
})
watch(() => route.fullPath, () => {
@@ -619,11 +882,13 @@ watch(() => route.fullPath, () => {
position: fixed;
inset: 0 0 auto;
z-index: 120;
overflow: visible;
}
.home-header {
position: relative;
height: var(--home-banner-height);
overflow: hidden;
&.compact {
.home-banner {
@@ -684,11 +949,13 @@ watch(() => route.fullPath, () => {
.banner-shell {
position: relative;
z-index: 2;
z-index: 4;
height: 100%;
}
.banner-nav-row {
position: relative;
z-index: 8;
padding-top: 10px;
display: grid;
grid-template-columns: minmax(0, 1fr) 560px minmax(520px, auto);
@@ -719,6 +986,11 @@ watch(() => route.fullPath, () => {
flex-shrink: 0;
}
.auth-action {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.24);
}
.banner-search-wrap,
.sticky-search-wrap {
width: 100%;
@@ -780,7 +1052,14 @@ watch(() => route.fullPath, () => {
justify-self: end;
justify-content: flex-end;
min-width: 520px;
overflow: hidden;
overflow: visible;
}
.hover-card-anchor {
position: relative;
display: inline-flex;
align-items: center;
z-index: 12;
}
.avatar-entry {
@@ -869,6 +1148,10 @@ watch(() => route.fullPath, () => {
padding: 0;
}
.auth-link {
padding-inline: 12px;
}
.sticky-brand {
display: flex;
align-items: center;
@@ -1048,23 +1331,68 @@ watch(() => route.fullPath, () => {
color: #9499a0;
}
.popover-menu {
min-width: 180px;
padding: 8px;
.hover-card {
position: absolute;
top: calc(100% + 10px);
right: 0;
z-index: 40;
min-width: 220px;
padding: 12px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.985);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);
border: 1px solid rgba(226, 232, 240, 0.9);
backdrop-filter: blur(18px);
}
.popover-item {
.profile-card {
min-width: 236px;
}
.compact-card {
top: calc(100% + 8px);
}
.hover-menu-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 8px;
justify-content: flex-start;
padding: 12px 14px;
border-radius: 14px;
text-align: left;
color: #334155;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
}
.popover-item:hover {
background: #f6f7f8;
.hover-menu-item:hover {
background: linear-gradient(135deg, #f8fbff, #fff4f7);
color: #111827;
transform: translateX(2px);
}
.hover-menu-item + .hover-menu-item {
margin-top: 4px;
}
.hover-menu-item.danger {
color: #ef4444;
}
.floating-hover-panel {
position: fixed;
z-index: 180;
min-width: 236px;
padding: 12px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.985);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);
border: 1px solid rgba(226, 232, 240, 0.9);
backdrop-filter: blur(18px);
transform: translateX(calc(-100% + 12px));
}
@media (max-width: 1280px) {
@@ -1098,6 +1426,14 @@ watch(() => route.fullPath, () => {
}
@media (max-width: 768px) {
.hover-card {
display: none;
}
.floating-hover-panel {
display: none;
}
.home-header {
height: 132px;
}
@@ -10,7 +10,7 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import DPlayer from 'dplayer'
import { danmakuApi } from '@/api/danmaku'
import { useUserStore } from '@/stores/user'
import { showToast } from 'vant'
import { showToast } from '@/utils/toast'
type DPlayerInstance = InstanceType<typeof DPlayer>
@@ -18,6 +18,7 @@ const props = defineProps<{
src: string
poster?: string
videoId: number
initialTime?: number
}>()
const emit = defineEmits<{
@@ -28,6 +29,7 @@ const emit = defineEmits<{
const userStore = useUserStore()
const containerRef = ref<HTMLDivElement | null>(null)
let player: DPlayerInstance | null = null
let hasRestoredInitialTime = false
const toDPlayerType = (position: number) => {
if (position === 1) return 'top'
@@ -46,6 +48,7 @@ const destroyPlayer = () => {
player.destroy()
player = null
}
hasRestoredInitialTime = false
}
const syncDanmakuInputState = () => {
@@ -132,10 +135,24 @@ const initPlayer = async () => {
syncDanmakuInputState()
const restoreInitialTime = () => {
if (!player || hasRestoredInitialTime) return
const targetTime = Math.max(0, Math.floor(props.initialTime || 0))
if (targetTime <= 0) return
const duration = Number(player.video.duration || 0)
if (duration > 0 && targetTime >= duration - 3) return
player.seek(targetTime)
hasRestoredInitialTime = true
}
player.on('timeupdate', () => {
emit('timeupdate', player?.video.currentTime || 0)
})
player.on('loadedmetadata', restoreInitialTime)
player.on('canplay', restoreInitialTime)
player.on('ended', () => {
emit('ended')
})
+46 -20
View File
@@ -112,7 +112,7 @@ export const mockVideos = [
height: 1080,
format: 'mp4',
size: 156000000,
categoryId: 1,
categoryId: 13,
status: 1,
likeCount: 1256,
coinCount: 89,
@@ -128,7 +128,7 @@ export const mockVideos = [
updateTime: '2024-01-15 10:30:00',
tagIds: [1, 2, 3],
tagNames: ['Vue3', 'TypeScript', '前端开发'],
categoryName: '科技',
categoryName: '开发技术',
userNickname: '视频达人小明',
userAvatar: av1,
isLiked: false,
@@ -147,7 +147,7 @@ export const mockVideos = [
height: 1080,
format: 'mp4',
size: 234000000,
categoryId: 1,
categoryId: 13,
status: 1,
likeCount: 892,
coinCount: 67,
@@ -163,7 +163,7 @@ export const mockVideos = [
updateTime: '2024-01-14 15:20:00',
tagIds: [4, 5],
tagNames: ['Java', '微服务'],
categoryName: '科技',
categoryName: '开发技术',
userNickname: '科技爱好者',
userAvatar: av2,
isLiked: false,
@@ -182,7 +182,7 @@ export const mockVideos = [
height: 1080,
format: 'mp4',
size: 78000000,
categoryId: 2,
categoryId: 21,
status: 1,
likeCount: 2345,
coinCount: 156,
@@ -198,7 +198,7 @@ export const mockVideos = [
updateTime: '2024-01-16 12:00:00',
tagIds: [6, 7],
tagNames: ['美食', '烹饪教程'],
categoryName: '美食',
categoryName: '家常菜',
userNickname: '美食博主小美',
userAvatar: av3,
isLiked: false,
@@ -217,7 +217,7 @@ export const mockVideos = [
height: 1080,
format: 'mp4',
size: 145000000,
categoryId: 1,
categoryId: 12,
status: 1,
likeCount: 567,
coinCount: 45,
@@ -233,7 +233,7 @@ export const mockVideos = [
updateTime: '2024-01-13 09:15:00',
tagIds: [8, 9],
tagNames: ['React', '前端开发'],
categoryName: '科技',
categoryName: '软件应用',
userNickname: '视频达人小明',
userAvatar: av1,
isLiked: false,
@@ -252,7 +252,7 @@ export const mockVideos = [
height: 1080,
format: 'mp4',
size: 178000000,
categoryId: 1,
categoryId: 12,
status: 1,
likeCount: 678,
coinCount: 56,
@@ -268,7 +268,7 @@ export const mockVideos = [
updateTime: '2024-01-12 14:30:00',
tagIds: [10, 11],
tagNames: ['Docker', 'DevOps'],
categoryName: '科技',
categoryName: '软件应用',
userNickname: '科技爱好者',
userAvatar: av2,
isLiked: false,
@@ -287,7 +287,7 @@ export const mockVideos = [
height: 1080,
format: 'mp4',
size: 156000000,
categoryId: 2,
categoryId: 21,
status: 1,
likeCount: 3456,
coinCount: 234,
@@ -303,7 +303,7 @@ export const mockVideos = [
updateTime: '2024-01-17 08:00:00',
tagIds: [6, 7],
tagNames: ['美食', '烹饪教程'],
categoryName: '美食',
categoryName: '家常菜',
userNickname: '美食博主小美',
userAvatar: av3,
isLiked: false,
@@ -322,7 +322,7 @@ export const mockVideos = [
height: 1080,
format: 'mp4',
size: 132000000,
categoryId: 1,
categoryId: 13,
status: 1,
likeCount: 934,
coinCount: 66,
@@ -338,7 +338,7 @@ export const mockVideos = [
updateTime: '2024-01-18 11:20:00',
tagIds: [1, 3],
tagNames: ['Vue3', '性能优化'],
categoryName: '科技',
categoryName: '开发技术',
userNickname: '视频达人小明',
userAvatar: av1,
isLiked: false,
@@ -357,7 +357,7 @@ export const mockVideos = [
height: 1080,
format: 'mp4',
size: 166000000,
categoryId: 1,
categoryId: 13,
status: 1,
likeCount: 721,
coinCount: 49,
@@ -373,7 +373,7 @@ export const mockVideos = [
updateTime: '2024-01-19 14:00:00',
tagIds: [4, 5],
tagNames: ['Spring Cloud', '网关'],
categoryName: '科技',
categoryName: '开发技术',
userNickname: '科技爱好者',
userAvatar: av2,
isLiked: false,
@@ -392,7 +392,7 @@ export const mockVideos = [
height: 1080,
format: 'mp4',
size: 54000000,
categoryId: 2,
categoryId: 21,
status: 1,
likeCount: 1658,
coinCount: 97,
@@ -408,7 +408,7 @@ export const mockVideos = [
updateTime: '2024-01-20 18:40:00',
tagIds: [6],
tagNames: ['美食'],
categoryName: '美食',
categoryName: '家常菜',
userNickname: '美食博主小美',
userAvatar: av3,
isLiked: false,
@@ -427,7 +427,7 @@ export const mockVideos = [
height: 1080,
format: 'mp4',
size: 98000000,
categoryId: 9,
categoryId: 91,
status: 1,
likeCount: 812,
coinCount: 58,
@@ -443,7 +443,7 @@ export const mockVideos = [
updateTime: '2024-01-21 09:50:00',
tagIds: [3],
tagNames: ['论文'],
categoryName: '知识',
categoryName: '校园学习',
userNickname: '视频达人小明',
userAvatar: av1,
isLiked: false,
@@ -455,17 +455,43 @@ export const mockVideos = [
// 模拟分类数据
export const mockCategories = [
{ id: 1, name: '科技', parentId: 0, sort: 1, icon: 'tech' },
{ id: 11, name: '数码', parentId: 1, sort: 1, icon: 'digital' },
{ id: 12, name: '软件应用', parentId: 1, sort: 2, icon: 'software' },
{ id: 13, name: '开发技术', parentId: 1, sort: 3, icon: 'dev' },
{ id: 14, name: '电脑硬件', parentId: 1, sort: 4, icon: 'hardware' },
{ id: 2, name: '美食', parentId: 0, sort: 2, icon: 'food' },
{ id: 21, name: '家常菜', parentId: 2, sort: 1, icon: 'home-food' },
{ id: 22, name: '探店测评', parentId: 2, sort: 2, icon: 'store' },
{ id: 23, name: '烘焙甜品', parentId: 2, sort: 3, icon: 'dessert' },
{ id: 3, name: '游戏', parentId: 0, sort: 3, icon: 'game' },
{ id: 31, name: '手机游戏', parentId: 3, sort: 1, icon: 'mobile-game' },
{ id: 32, name: '主机游戏', parentId: 3, sort: 2, icon: 'console-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: 61, name: '日常记录', parentId: 6, sort: 1, icon: 'daily' },
{ id: 62, name: '出行见闻', parentId: 6, sort: 2, icon: 'trip' },
{ id: 7, name: '动画', parentId: 0, sort: 7, icon: 'anime' },
{ id: 71, name: 'MAD·AMV', parentId: 7, sort: 1, icon: 'amv' },
{ id: 72, name: '手书', parentId: 7, sort: 2, icon: 'draw' },
{ id: 8, name: '影视', parentId: 0, sort: 8, icon: 'movie' },
{ id: 81, name: '影视杂谈', parentId: 8, sort: 1, icon: 'talk' },
{ id: 82, name: '短片', parentId: 8, sort: 2, icon: 'short-film' },
{ id: 9, name: '知识', parentId: 0, sort: 9, icon: 'knowledge' },
{ id: 91, name: '校园学习', parentId: 9, sort: 1, icon: 'school' },
{ id: 92, name: '职场技能', parentId: 9, sort: 2, icon: 'career' },
{ id: 10, name: '运动', parentId: 0, sort: 10, icon: 'sport' }
]
export const mockCategoryTree = mockCategories
.filter((item) => item.parentId === 0)
.map((item) => ({
...item,
children: mockCategories
.filter((child) => child.parentId === item.id)
.sort((a, b) => (a.sort || 0) - (b.sort || 0))
}))
// 模拟评论数据
export const mockComments = [
{
@@ -7,6 +7,7 @@ import {
mockUsers,
mockVideos,
mockCategories,
mockCategoryTree,
mockComments,
mockFollowing,
mockFollowers,
@@ -364,7 +365,7 @@ const mockHandlers: Record<string, (url: string, options: any) => Promise<any>>
// ==================== 分类相关 ====================
'GET /content/category/tree': async () => {
await delay()
return mockCategories
return mockCategoryTree
},
'GET /content/category/list': async () => {
@@ -80,6 +80,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/user/my-videos.vue'),
meta: { title: '我的视频', requiresAuth: true }
},
{
path: 'my/videos/:id',
name: 'MyVideoDetail',
component: () => import('@/views/user/my-video-detail.vue'),
meta: { title: '视频详情', requiresAuth: true }
},
{
path: 'my/favorites',
name: 'MyFavorites',
@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Favorite } from '@/types'
import { favoriteApi } from '@/api/favorite.ts'
import { showToast } from 'vant'
import { showToast } from '@/utils/toast'
export const useFavoriteStore = defineStore('favorite', () => {
// 收藏夹列表
@@ -4,7 +4,7 @@ import { authApi } from '@/api/auth.ts'
import { userApi } from '@/api/user.ts'
import type { LoginParams, RegisterParams, UserInfo, AuthUser, CaptchaResult } from '@/types'
import { getToken, setToken, removeToken, getRefreshToken, setRefreshToken, removeRefreshToken } from '@/utils/storage.ts'
import { showToast } from 'vant'
import { showToast } from '@/utils/toast'
export const useUserStore = defineStore('user', () => {
// 状态
+17 -14
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { VideoInfo, PlayRecord } from '@/types'
import { videoApi } from '@/api/video.ts'
import { useUserStore } from '@/stores/user.ts'
export const useVideoStore = defineStore('video', () => {
// 当前播放的视频
@@ -57,25 +58,27 @@ export const useVideoStore = defineStore('video', () => {
// 记录播放
async function recordPlay(videoId: number, progress: number, duration: number) {
const userStore = useUserStore()
const playTime = Math.max(0, Math.min(Math.round(progress), Math.round(duration)))
const isFinished = duration > 0 && playTime >= duration * 0.9 ? 1 : 0
const index = playHistory.value.findIndex(item => item.videoId === videoId)
const record: PlayRecord = { videoId, playTime, isFinished }
if (index >= 0) {
playHistory.value[index] = record
} else {
playHistory.value.unshift(record)
if (playHistory.value.length > 100) {
playHistory.value = playHistory.value.slice(0, 100)
}
}
if (!userStore.isLoggedIn) {
return
}
try {
await videoApi.recordPlay({ videoId, playTime, isFinished })
// 更新本地播放历史
const index = playHistory.value.findIndex(item => item.videoId === videoId)
const record: PlayRecord = { videoId, playTime, isFinished }
if (index >= 0) {
playHistory.value[index] = record
} else {
playHistory.value.unshift(record)
// 最多保存100条记录
if (playHistory.value.length > 100) {
playHistory.value = playHistory.value.slice(0, 100)
}
}
} catch (error) {
console.error('记录播放失败:', error)
}
@@ -219,7 +219,7 @@ select {
--van-dialog-cancel-button-text-color: #61666d;
--van-toast-radius: 18px;
--van-toast-default-width: auto;
--van-toast-background: rgba(24, 25, 28, 0.88);
--van-toast-background: rgba(24, 25, 28, 0.92);
--van-toast-text-color: #fff;
--van-notify-radius: 0 0 18px 18px;
}
@@ -279,10 +279,88 @@ select {
}
.van-toast {
padding: 12px 16px;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.18);
min-width: 144px;
max-width: min(320px, calc(100vw - 32px));
padding: 14px 18px;
border-radius: 20px !important;
border: 1px solid rgba(255, 255, 255, 0.14);
backdrop-filter: blur(14px);
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.22);
font-size: 14px;
line-height: 1.5;
text-align: center;
transform: translateY(0);
animation: toast-pop-in 0.22s cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform, opacity;
.van-toast__text {
font-weight: 600;
letter-spacing: 0.01em;
}
.van-toast__icon {
margin-bottom: 10px;
font-size: 24px;
}
}
.van-toast--text {
background:
linear-gradient(180deg, rgba(35, 39, 47, 0.96), rgba(24, 25, 28, 0.92)),
rgba(24, 25, 28, 0.92);
}
.van-toast--success {
background:
linear-gradient(135deg, rgba(240, 253, 244, 0.96), rgba(220, 252, 231, 0.94)),
#f0fdf4;
border-color: rgba(34, 197, 94, 0.18);
color: #166534 !important;
box-shadow: 0 18px 42px rgba(34, 197, 94, 0.18);
.van-toast__icon {
color: #22c55e;
}
}
.van-toast--fail {
background:
linear-gradient(135deg, rgba(254, 242, 242, 0.96), rgba(255, 228, 230, 0.94)),
#fff1f2;
border-color: rgba(244, 63, 94, 0.16);
color: #be123c !important;
box-shadow: 0 18px 42px rgba(244, 63, 94, 0.16);
.van-toast__icon {
color: #f43f5e;
}
}
.van-toast--loading {
background:
linear-gradient(135deg, rgba(239, 246, 255, 0.96), rgba(224, 242, 254, 0.94)),
#eff6ff;
border-color: rgba(14, 165, 233, 0.18);
color: #0f766e !important;
box-shadow: 0 18px 42px rgba(14, 165, 233, 0.16);
}
@keyframes toast-pop-in {
0% {
opacity: 0;
transform: translateY(8px) scale(0.96);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.van-toast {
animation: none;
}
}
.van-notify {
@@ -35,6 +35,7 @@ export interface UserInfo {
followingCount: number
videoCount: number
likeCount: number
coinCount: number
level: number
vipLevel: number
vipExpireTime: string | null
@@ -125,6 +126,8 @@ export interface VideoInfo {
isLiked?: boolean
isFavorited?: boolean
isCoined?: boolean
playProgress?: number
isPlayFinished?: boolean
}
// 视频上传参数
@@ -348,6 +351,16 @@ export interface PlayRecord {
isFinished?: number
}
export interface PlayHistoryItem {
videoId: number
title: string
coverUrl: string
duration: number
playTime: number
isFinished: number
updateTime: string
}
// 推荐视频
export interface RecommendVideo {
videoId: number
@@ -1,6 +1,6 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { useUserStore } from '@/stores/user.ts'
import { showToast } from 'vant'
import { showToast } from '@/utils/toast'
import router from '@/router'
import { mockRequest, isMockEnabled } from '@/mock'
@@ -9,6 +9,25 @@ const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
const TIMEOUT = 30000
let handlingUnauthorized = false
const shouldRedirectToLogin = () => {
const currentRoute = router.currentRoute.value
return Boolean(currentRoute.meta?.requiresAuth)
}
const handleUnauthorized = (message = '登录已过期,请重新登录') => {
const userStore = useUserStore()
userStore.clearAuth()
if (!handlingUnauthorized) {
handlingUnauthorized = true
if (shouldRedirectToLogin() && router.currentRoute.value.path !== '/login') {
router.push('/login')
showToast(message)
return
}
}
}
// 创建 axios 实例
const service: AxiosInstance = axios.create({
baseURL: BASE_URL,
@@ -49,15 +68,7 @@ service.interceptors.response.use(
// Token 过期
if (data.code === 401) {
const userStore = useUserStore()
if (!handlingUnauthorized) {
handlingUnauthorized = true
userStore.clearAuth()
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
showToast('登录已过期,请重新登录')
}
handleUnauthorized()
return Promise.reject(new Error('Token expired'))
}
@@ -77,15 +88,7 @@ service.interceptors.response.use(
const status = error.response.status
switch (status) {
case 401: {
const userStore = useUserStore()
if (!handlingUnauthorized) {
handlingUnauthorized = true
userStore.clearAuth()
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
showToast('登录已过期,请重新登录')
}
handleUnauthorized()
break
}
case 403:
@@ -94,6 +97,9 @@ service.interceptors.response.use(
case 404:
showToast('请求的资源不存在')
break
case 413:
showToast('上传文件过大,请选择更小的文件')
break
case 500:
showToast('服务器错误,请稍后重试')
break
@@ -120,15 +126,7 @@ async function mockFetch<T>(method: string, url: string, data?: any, params?: an
}
if (result.code === 401) {
const userStore = useUserStore()
if (!handlingUnauthorized) {
handlingUnauthorized = true
userStore.clearAuth()
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
showToast('登录已过期,请重新登录')
}
handleUnauthorized()
throw new Error('Token expired')
}
@@ -0,0 +1,67 @@
import {
closeToast as vantCloseToast,
showFailToast as vantShowFailToast,
showLoadingToast as vantShowLoadingToast,
showSuccessToast as vantShowSuccessToast,
showToast as vantShowToast,
type ToastOptions,
type ToastWrapperInstance
} from 'vant'
import 'vant/es/toast/style'
type ToastInput = string | ToastOptions
function resolveOptions(input: ToastInput, extra: Partial<ToastOptions> = {}): ToastOptions {
if (typeof input === 'string') {
return {
message: input,
...extra
}
}
return {
...extra,
...input
}
}
function withDefaultOptions(input: ToastInput, extra: Partial<ToastOptions> = {}): ToastOptions {
return resolveOptions(input, {
position: 'middle',
duration: 1800,
forbidClick: false,
...extra
})
}
export function showToast(input: ToastInput): ToastWrapperInstance {
return vantShowToast(withDefaultOptions(input))
}
export function showSuccessToast(input: ToastInput): ToastWrapperInstance {
return vantShowSuccessToast(withDefaultOptions(input))
}
export function showFailToast(input: ToastInput): ToastWrapperInstance {
return vantShowFailToast(withDefaultOptions(input))
}
export function showLoadingToast(input: ToastInput): ToastWrapperInstance {
return vantShowLoadingToast(withDefaultOptions(input, {
duration: 0,
loadingType: 'spinner'
}))
}
export function closeToast(all?: boolean) {
return vantCloseToast(all)
}
export const toast = {
info: showToast,
success: showSuccessToast,
fail: showFailToast,
error: showFailToast,
loading: showLoadingToast,
clear: closeToast
}
@@ -11,7 +11,7 @@
</button>
<div>
<strong>{{ favoriteTitle }}</strong>
<span>{{ total }} 个视频</span>
<span>{{ displayTotal }} 个视频</span>
</div>
</div>
<div class="head-actions">
@@ -101,10 +101,12 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
import { showConfirmDialog } from 'vant'
import { showSuccessToast, showToast } from '@/utils/toast'
import VideoCard from '@/components/video/VideoCard.vue'
import { favoriteApi, type Favorite } from '@/api/favorite.ts'
import { videoApi } from '@/api/video.ts'
import { userApi } from '@/api/user.ts'
import type { VideoInfo } from '@/types'
const route = useRoute()
@@ -113,6 +115,7 @@ const favoriteId = computed(() => Number(route.params.id))
const currentFavorite = ref<Favorite | null>(null)
const favoriteTitle = computed(() => currentFavorite.value?.name || '收藏夹')
const videos = ref<VideoInfo[]>([])
const displayTotal = computed(() => Math.max(total.value, videos.value.length))
const loading = ref(false)
const finished = ref(false)
const page = ref(1)
@@ -126,6 +129,43 @@ const editForm = ref({
isPrivate: 0
})
const hydrateVideoAuthors = async (items: VideoInfo[]) => {
const missingUserIds = [...new Set(
items
.filter((item) => item.userId && (!item.userNickname || !item.userAvatar))
.map((item) => item.userId)
)]
if (!missingUserIds.length) {
return items
}
const profiles = await Promise.all(
missingUserIds.map(async (userId) => {
try {
const profile = await userApi.getPublicProfile(userId)
return [userId, profile] as const
} catch {
return [userId, null] as const
}
})
)
const profileMap = new Map(profiles)
return items.map((item) => {
const profile = profileMap.get(item.userId)
if (!profile) {
return item
}
return {
...item,
userNickname: item.userNickname || profile.nickname,
userAvatar: item.userAvatar || profile.avatar
}
})
}
const loadFavoriteMeta = async () => {
try {
const favorites = await favoriteApi.getFavoriteList()
@@ -151,7 +191,8 @@ const loadMore = async () => {
pageNum: page.value,
pageSize: 12
})
videos.value.push(...res.records)
const hydratedRecords = await hydrateVideoAuthors(res.records)
videos.value.push(...hydratedRecords)
total.value = res.total
if (videos.value.length >= res.total || res.records.length < 12) finished.value = true
page.value += 1
@@ -114,7 +114,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
import { showConfirmDialog } from 'vant'
import { showSuccessToast, showToast } from '@/utils/toast'
import { favoriteApi, type Favorite } from '@/api/favorite.ts'
const router = useRouter()
@@ -3,7 +3,8 @@
<div class="page-shell category-shell">
<div class="category-header">
<div class="header-copy">
<h1>{{ currentCategoryName || '分类' }}</h1>
<h1>{{ currentCategoryName || activeParent?.name || '分类' }}</h1>
<span>{{ activeParent?.name || '按分区查看内容列表' }}</span>
</div>
<button
class="back-button"
@@ -15,13 +16,35 @@
<div class="category-switcher">
<button
v-for="cat in categories"
:key="cat.id"
v-for="parent in categoryTree"
:key="parent.id"
class="switcher-item"
:class="{ active: activeCategoryId === cat.id }"
@click="switchCategory(cat.id)"
:class="{ active: activeParentId === parent.id }"
@click="switchParent(parent.id)"
>
{{ cat.name }}
{{ parent.name }}
</button>
</div>
<div
v-if="activeChildren.length"
class="category-sub-switcher"
>
<button
class="sub-switcher-item"
:class="{ active: selectedCategoryId === activeParentId }"
@click="switchCategory(activeParentId)"
>
全部
</button>
<button
v-for="child in activeChildren"
:key="child.id"
class="sub-switcher-item"
:class="{ active: selectedCategoryId === child.id }"
@click="switchCategory(child.id)"
>
{{ child.name }}
</button>
</div>
@@ -54,17 +77,22 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import { showToast } from '@/utils/toast'
import VideoCard from '@/components/video/VideoCard.vue'
import AppTabbar from '@/components/layout/AppTabbar.vue'
import { categoryApi, type Category } from '@/api/category.ts'
import { videoApi } from '@/api/video.ts'
import type { VideoInfo } from '@/types'
interface CategoryNode extends Category {
children: CategoryNode[]
}
const route = useRoute()
const router = useRouter()
const categories = ref<Category[]>([])
const activeCategoryId = ref<number | null>(null)
const categoryTree = ref<CategoryNode[]>([])
const allVideos = ref<VideoInfo[]>([])
const selectedCategoryId = ref<number | null>(null)
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
@@ -75,25 +103,131 @@ const routeCategoryId = computed(() => {
return Number.isFinite(value) && value > 0 ? value : null
})
const currentCategory = computed(() => categories.value.find((item) => item.id === activeCategoryId.value) || null)
const currentCategoryName = computed(() => currentCategory.value?.name || '')
const normalizeCategoryTree = (raw: Category[]) => {
const byId = new Map<number, CategoryNode>()
raw.forEach((item) => {
byId.set(item.id, {
...item,
children: []
})
})
raw.forEach((item) => {
const current = byId.get(item.id)
if (!current) return
const explicitChildren = Array.isArray(item.children) ? item.children : []
explicitChildren.forEach((child) => {
const normalizedChild = byId.get(child.id) || {
...child,
children: []
}
byId.set(normalizedChild.id, normalizedChild)
if (!current.children.some((entry) => entry.id === normalizedChild.id)) {
current.children.push(normalizedChild)
}
})
})
raw.forEach((item) => {
if (!item.parentId) return
const parent = byId.get(item.parentId)
const current = byId.get(item.id)
if (!parent || !current) return
if (!parent.children.some((entry) => entry.id === current.id)) {
parent.children.push(current)
}
})
return [...byId.values()]
.filter((item) => !item.parentId)
.sort((a, b) => (a.sort || 0) - (b.sort || 0))
.map((item) => ({
...item,
children: [...item.children].sort((a, b) => (a.sort || 0) - (b.sort || 0))
}))
}
const applyCategoryFallback = (videos: VideoInfo[], categories: CategoryNode[]) => {
const categoryMap = new Map<number, CategoryNode>()
const categoryNameMap = new Map<string, number>()
const walk = (items: CategoryNode[]) => {
items.forEach((item) => {
categoryMap.set(item.id, item)
categoryNameMap.set(item.name, item.id)
if (item.children?.length) walk(item.children)
})
}
walk(categories)
return videos.map((video) => {
const fallbackCategoryId =
video.categoryId ||
(video.categoryName ? categoryNameMap.get(video.categoryName) : undefined) ||
0
return {
...video,
categoryId: fallbackCategoryId,
categoryName: video.categoryName || categoryMap.get(fallbackCategoryId)?.name || ''
}
})
}
const categoryLookup = computed(() => {
const map = new Map<number, { self: CategoryNode; parentId: number | null }>()
const walk = (items: CategoryNode[], parentId: number | null) => {
items.forEach((item) => {
map.set(item.id, { self: item, parentId })
if (item.children?.length) {
walk(item.children, item.id)
}
})
}
walk(categoryTree.value, null)
return map
})
const activeParentId = computed(() => {
if (!selectedCategoryId.value) return categoryTree.value[0]?.id || null
const current = categoryLookup.value.get(selectedCategoryId.value)
if (!current) return categoryTree.value[0]?.id || null
return current.parentId ?? current.self.id
})
const activeParent = computed(() => categoryTree.value.find((item) => item.id === activeParentId.value) || null)
const activeChildren = computed(() => activeParent.value?.children || [])
const currentCategoryName = computed(() => categoryLookup.value.get(selectedCategoryId.value || 0)?.self.name || '')
const groupedCategoryIds = computed(() => {
if (!selectedCategoryId.value) return []
if (selectedCategoryId.value === activeParentId.value) {
return [selectedCategoryId.value, ...activeChildren.value.map((item) => item.id)]
}
return [selectedCategoryId.value]
})
const filteredVideos = computed(() =>
allVideos.value.filter((video) => groupedCategoryIds.value.includes(video.categoryId))
)
const syncCategoryFromRoute = () => {
if (!categories.value.length) return
if (!categoryTree.value.length) return
if (routeCategoryId.value && categories.value.some((item) => item.id === routeCategoryId.value)) {
activeCategoryId.value = routeCategoryId.value
if (routeCategoryId.value && categoryLookup.value.has(routeCategoryId.value)) {
selectedCategoryId.value = routeCategoryId.value
return
}
activeCategoryId.value = categories.value[0]?.id ?? null
selectedCategoryId.value = categoryTree.value[0]?.id ?? null
}
const loadCategories = async () => {
try {
categories.value = await categoryApi.getCategoryList()
const data = await categoryApi.getCategoryTree().catch(() => categoryApi.getCategoryList())
categoryTree.value = normalizeCategoryTree(data)
syncCategoryFromRoute()
if (activeCategoryId.value) {
if (selectedCategoryId.value) {
await loadVideos(true)
} else {
finished.value = true
@@ -104,7 +238,7 @@ const loadCategories = async () => {
}
const loadVideos = async (isRefresh = false) => {
if (!activeCategoryId.value) return
if (!selectedCategoryId.value) return
if (isRefresh) {
currentPage.value = 1
@@ -114,15 +248,20 @@ const loadVideos = async (isRefresh = false) => {
loading.value = true
try {
const res = await videoApi.getVideoPublicList({
pageNum: currentPage.value,
pageSize: 20,
categoryId: activeCategoryId.value
})
if (isRefresh || !allVideos.value.length) {
const res = await videoApi.getVideoPublicList({
pageNum: 1,
pageSize: 120
})
allVideos.value = applyCategoryFallback(res.records, categoryTree.value)
}
videos.value = isRefresh ? res.records : [...videos.value, ...res.records]
const pageSize = 20
const start = (currentPage.value - 1) * pageSize
const nextChunk = filteredVideos.value.slice(start, start + pageSize)
videos.value = isRefresh ? nextChunk : [...videos.value, ...nextChunk]
if (videos.value.length >= res.total || res.records.length < 20) {
if (videos.value.length >= filteredVideos.value.length || nextChunk.length < pageSize) {
finished.value = true
}
currentPage.value += 1
@@ -137,8 +276,13 @@ const loadMore = () => {
if (!finished.value) loadVideos()
}
const switchCategory = (categoryId: number) => {
if (categoryId === activeCategoryId.value) return
const switchParent = (parentId: number) => {
router.push(`/category/${parentId}`)
}
const switchCategory = (categoryId: number | null) => {
if (!categoryId) return
if (categoryId === selectedCategoryId.value) return
router.push(`/category/${categoryId}`)
}
@@ -147,7 +291,7 @@ const goToVideo = (id: number) => {
}
watch(routeCategoryId, async () => {
if (!categories.value.length) return
if (!categoryTree.value.length) return
syncCategoryFromRoute()
await loadVideos(true)
})
@@ -174,10 +318,20 @@ onMounted(loadCategories)
margin-bottom: 16px;
}
.header-copy h1 {
margin: 0;
font-size: 32px;
color: #18191c;
.header-copy {
display: grid;
gap: 6px;
h1 {
margin: 0;
font-size: 32px;
color: #18191c;
}
span {
color: #9499a0;
font-size: 14px;
}
}
.back-button {
@@ -188,23 +342,42 @@ onMounted(loadCategories)
box-shadow: var(--shadow-sm);
}
.category-switcher {
.category-switcher,
.category-sub-switcher {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.category-switcher {
margin-bottom: 12px;
}
.category-sub-switcher {
margin-bottom: 18px;
}
.switcher-item {
.switcher-item,
.sub-switcher-item {
padding: 8px 14px;
border-radius: 999px;
background: #fff;
color: #61666d;
box-shadow: var(--shadow-sm);
transition: background-color 0.18s ease, color 0.18s ease;
&.active {
background: #ffecf1;
color: #ff6699;
background: linear-gradient(135deg, #5c7cfa, #fb7299);
color: #fff;
}
}
.sub-switcher-item {
border-radius: 12px;
&.active {
background: #edf4ff;
color: #2563eb;
}
}
File diff suppressed because it is too large Load Diff
@@ -50,28 +50,6 @@
class="login-form"
@submit="handleLogin"
>
<div class="captcha-banner">
<div>
<strong>安全验证</strong>
<span>点击验证码图片可刷新,当前登录必须填写验证码。</span>
</div>
<van-image
:src="captchaImage"
fit="contain"
class="captcha-image"
@click="refreshCaptcha"
>
<template #error>
<div
class="captcha-error"
@click="refreshCaptcha"
>
点击刷新
</div>
</template>
</van-image>
</div>
<van-cell-group inset>
<van-field
v-model="form.username"
@@ -140,7 +118,7 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast, showToast } from 'vant'
import { showSuccessToast, showToast } from '@/utils/toast'
import { authApi, type CaptchaResult } from '@/api/auth.ts'
import { useUserStore } from '@/stores/user.ts'
@@ -199,8 +177,6 @@ const handleLogin = async () => {
const redirect = route.query.redirect as string
router.replace(redirect || '/')
} catch (error: any) {
const message = error?.response?.data?.message || error?.message || '登录失败'
showToast(message)
refreshCaptcha()
} finally {
loading.value = false
@@ -336,59 +312,16 @@ onMounted(() => {
line-height: 1.7;
}
.captcha-banner {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 18px;
background: #f8fafc;
strong {
display: block;
margin-bottom: 4px;
color: #18191c;
font-size: 14px;
}
span {
color: #64748b;
font-size: 12px;
line-height: 1.6;
}
}
.captcha-image,
.captcha-btn {
flex-shrink: 0;
cursor: pointer;
}
.captcha-image {
width: 120px;
height: 40px;
}
.captcha-btn {
width: 100px;
height: 32px;
}
.captcha-error {
width: 120px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: #eef2f7;
color: #64748b;
font-size: 12px;
cursor: pointer;
border-radius: 12px;
}
.login-form {
:deep(.van-cell-group--inset) {
margin: 0;
@@ -451,11 +384,6 @@ onMounted(() => {
border-radius: 24px;
}
.captcha-banner {
flex-direction: column;
align-items: flex-start;
}
.logo {
font-size: 30px;
}
@@ -47,19 +47,6 @@
</div>
<van-form @submit="handleRegister">
<div class="captcha-banner">
<div>
<strong>注册验证</strong>
<span>当前注册同样要求验证码,点击图片可以刷新。</span>
</div>
<van-image
:src="captchaImage"
fit="contain"
class="captcha-image"
@click="refreshCaptcha"
/>
</div>
<van-cell-group inset>
<van-field
v-model="form.username"
@@ -149,7 +136,7 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast, showToast } from 'vant'
import { showSuccessToast, showToast } from '@/utils/toast'
import { authApi, type CaptchaResult } from '@/api/auth.ts'
const router = useRouter()
@@ -198,7 +185,6 @@ const handleRegister = async () => {
showSuccessToast('注册成功')
router.push('/login')
} catch (error: any) {
showToast(error.message || '注册失败')
refreshCaptcha()
} finally {
loading.value = false
@@ -333,41 +319,11 @@ onMounted(() => {
line-height: 1.7;
}
.captcha-banner {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 18px;
background: #f8fafc;
strong {
display: block;
margin-bottom: 4px;
color: #18191c;
font-size: 14px;
}
span {
color: #64748b;
font-size: 12px;
line-height: 1.6;
}
}
.captcha-image,
.captcha-btn {
flex-shrink: 0;
cursor: pointer;
}
.captcha-image {
width: 120px;
height: 40px;
}
.captcha-btn {
width: 100px;
height: 32px;
@@ -433,11 +389,6 @@ onMounted(() => {
border-radius: 24px;
}
.captcha-banner {
flex-direction: column;
align-items: flex-start;
}
.logo {
font-size: 30px;
}
@@ -60,7 +60,7 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import { showToast } from '@/utils/toast'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import { formatRelativeTime } from '@/utils'
@@ -47,7 +47,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { showToast } from '@/utils/toast'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import { formatRelativeTime } from '@/utils'
@@ -74,7 +74,7 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast, showToast } from 'vant'
import { showSuccessToast, showToast } from '@/utils/toast'
import { userApi } from '@/api/user.ts'
import { formatRelativeTime } from '@/utils'
import type { Notification } from '@/types'
@@ -167,7 +167,7 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import { showToast } from '@/utils/toast'
import { userApi } from '@/api/user.ts'
import { videoApi } from '@/api/video.ts'
import type { UserInfo, VideoInfo } from '@/types'
@@ -132,7 +132,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { showToast, showSuccessToast } from '@/utils/toast'
import { videoApi } from '@/api/video.ts'
import { categoryApi, type Category } from '@/api/category.ts'
import { resolveMediaUrl } from '@/utils/media'
@@ -56,7 +56,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { showToast, showSuccessToast } from '@/utils/toast'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import { resolveAvatarUrl } from '@/utils/media'
@@ -56,7 +56,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { showToast, showSuccessToast } from '@/utils/toast'
import { userApi } from '@/api/user.ts'
import { resolveAvatarUrl } from '@/utils/media'
import type { UserFollow } from '@/types'
@@ -20,18 +20,33 @@
</section>
<section class="stats-grid">
<div class="stat-card">
<button
type="button"
class="stat-card"
:class="{ active: selectedFilter === 'all' }"
@click="toggleFilter('all')"
>
<span>历史记录</span>
<strong>{{ videos.length }}</strong>
</div>
<div class="stat-card">
</button>
<button
type="button"
class="stat-card"
:class="{ active: selectedFilter === 'finished' }"
@click="toggleFilter('finished')"
>
<span>观看完成</span>
<strong>{{ finishedCount }}</strong>
</div>
<div class="stat-card">
</button>
<button
type="button"
class="stat-card"
:class="{ active: selectedFilter === 'continue' }"
@click="toggleFilter('continue')"
>
<span>继续观看</span>
<strong>{{ continueCount }}</strong>
</div>
</button>
</section>
<section
@@ -39,13 +54,13 @@
class="history-panel"
>
<div class="panel-head">
<strong>最近观看</strong>
<span>{{ videos.length }} 条记录</span>
<strong>{{ currentFilterLabel }}</strong>
<span>{{ filteredVideos.length }} 条记录</span>
</div>
<div class="video-list">
<article
v-for="item in videos"
v-for="item in filteredVideos"
:key="item.videoId"
class="video-item"
@click="goToVideo(item.videoId)"
@@ -92,13 +107,16 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog, showToast } from 'vant'
import { showConfirmDialog } from 'vant'
import { showToast } from '@/utils/toast'
import { formatDuration } from '@/utils'
import { resolveMediaUrl } from '@/utils/media'
import { videoApi } from '@/api/video.ts'
import { useVideoStore } from '@/stores/video.ts'
import { useUserStore } from '@/stores/user.ts'
import type { PlayHistoryItem, PlayRecord } from '@/types'
interface HistoryItem {
videoId: number
@@ -106,21 +124,88 @@ interface HistoryItem {
coverUrl: string
progress: number
duration: number
updateTime?: string
}
const router = useRouter()
const videoStore = useVideoStore()
const userStore = useUserStore()
const videos = ref<HistoryItem[]>([])
const historyRecords = computed(() => [...videoStore.playHistory].reverse())
const selectedFilter = ref<'all' | 'finished' | 'continue'>('all')
const historyRecords = computed(() => [...videoStore.playHistory])
const defaultCover =
'data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 180\"%3E%3Crect width=\"320\" height=\"180\" fill=\"%23edf2f7\"/%3E%3Ctext x=\"50%25\" y=\"50%25\" dy=\".3em\" text-anchor=\"middle\" fill=\"%239499a0\"%3EHAOVP%3C/text%3E%3C/svg%3E'
const finishedCount = computed(() => videos.value.filter((item) => item.duration > 0 && item.progress >= item.duration).length)
const continueCount = computed(() => videos.value.length - finishedCount.value)
const filteredVideos = computed(() => {
if (selectedFilter.value === 'finished') {
return videos.value.filter((item) => item.duration > 0 && item.progress >= item.duration)
}
if (selectedFilter.value === 'continue') {
return videos.value.filter((item) => item.duration <= 0 || item.progress < item.duration)
}
return videos.value
})
const currentFilterLabel = computed(() => {
if (selectedFilter.value === 'finished') return '已看完记录'
if (selectedFilter.value === 'continue') return '继续观看'
return '最近观看'
})
const getCoverUrl = (coverUrl?: string) => resolveMediaUrl(coverUrl) || defaultCover
const toggleFilter = (filter: 'all' | 'finished' | 'continue') => {
if (selectedFilter.value === filter && filter !== 'all') {
selectedFilter.value = 'all'
return
}
selectedFilter.value = filter
}
const mapServerHistoryItem = (item: PlayHistoryItem): HistoryItem => ({
videoId: item.videoId,
title: item.title,
coverUrl: item.coverUrl,
progress: item.playTime,
duration: item.duration,
updateTime: item.updateTime
})
const getHistoryTimestamp = (item: HistoryItem) => {
const value = item.updateTime || ''
const time = new Date(value).getTime()
return Number.isNaN(time) ? 0 : time
}
const syncLocalHistoryFromServer = (items: PlayHistoryItem[]) => {
videoStore.playHistory = items.map<PlayRecord>((item) => ({
videoId: item.videoId,
playTime: item.playTime,
isFinished: item.isFinished
}))
}
const loadVideos = async () => {
if (userStore.isLoggedIn) {
try {
const historyPage = await videoApi.getPlayHistory({ pageNum: 1, pageSize: 30 })
const records = historyPage.records || []
syncLocalHistoryFromServer(records)
videos.value = records
.map(mapServerHistoryItem)
.sort((a, b) => getHistoryTimestamp(b) - getHistoryTimestamp(a))
return
} catch (error) {
console.error('加载服务端播放历史失败:', error)
}
}
if (!historyRecords.value.length) {
videos.value = []
return
}
try {
const details = await Promise.all(
historyRecords.value.slice(0, 30).map(async (record) => {
@@ -150,6 +235,9 @@ const clearHistory = async () => {
title: '提示',
message: '确定要清空播放历史吗?'
})
if (userStore.isLoggedIn) {
await videoApi.clearPlayHistory()
}
videoStore.playHistory = []
videos.value = []
showToast('已清空')
@@ -162,7 +250,15 @@ const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(loadVideos)
watch(historyRecords, () => {
if (!userStore.isLoggedIn) {
loadVideos()
}
}, { immediate: true, deep: true })
watch(() => userStore.isLoggedIn, () => {
loadVideos()
}, { immediate: true })
</script>
<style scoped lang="scss">
@@ -225,9 +321,15 @@ onMounted(loadVideos)
}
.stat-card {
appearance: none;
border: 0;
width: 100%;
text-align: left;
padding: 18px;
display: grid;
gap: 8px;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
span {
color: #9499a0;
@@ -239,6 +341,21 @@ onMounted(loadVideos)
font-size: 28px;
line-height: 1;
}
&:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
&.active {
background: linear-gradient(135deg, rgba(0, 161, 214, 0.12), rgba(251, 114, 153, 0.12));
box-shadow: inset 0 0 0 1px rgba(0, 161, 214, 0.22), var(--shadow-md);
span,
strong {
color: #18191c;
}
}
}
.history-panel {
@@ -0,0 +1,495 @@
<template>
<div class="creator-video-detail-page">
<div class="page-shell detail-shell">
<section class="detail-hero">
<div class="hero-copy">
<p class="hero-eyebrow">
Creator Preview
</p>
<h1>{{ video?.title || '视频详情' }}</h1>
<span>{{ statusDescription }}</span>
</div>
<div class="hero-actions">
<van-button
round
plain
@click="router.push('/my/videos')"
>
返回列表
</van-button>
<van-button
v-if="resolvedStatus === 1"
round
type="primary"
@click="goToPublicPage"
>
查看公开页
</van-button>
</div>
</section>
<section
v-if="video"
class="detail-layout"
>
<div class="main-column">
<div class="player-card">
<VideoPlayer
:src="playerVideoUrl"
:poster="playerPosterUrl"
:video-id="video.id"
/>
</div>
<div
v-if="resolvedStatus !== 1"
class="owner-notice"
>
<strong>{{ statusText }}</strong>
<span>{{ ownerNoticeText }}</span>
</div>
<div class="info-card">
<div class="title-row">
<div class="title-main">
<div class="tag-row">
<span
v-if="video.categoryName"
class="category-badge"
>
{{ video.categoryName }}
</span>
<span
class="status-badge"
:class="`status-${resolvedStatus}`"
>
{{ statusText }}
</span>
</div>
<h2>{{ video.title }}</h2>
</div>
</div>
<div class="meta-grid">
<div class="meta-item">
<span>时长</span>
<strong>{{ formatDuration(video.duration) }}</strong>
</div>
<div class="meta-item">
<span>发布时间</span>
<strong>{{ formatDateTime(video.publishTime || video.createTime) }}</strong>
</div>
<div class="meta-item">
<span>播放量</span>
<strong>{{ formatNumber(video.viewCount) }}</strong>
</div>
<div class="meta-item">
<span>点赞量</span>
<strong>{{ formatNumber(video.likeCount) }}</strong>
</div>
</div>
<div class="desc-block">
<strong>视频简介</strong>
<p>{{ video.description || '暂无简介' }}</p>
</div>
<div
v-if="video.tagNames?.length"
class="tag-list"
>
<van-tag
v-for="tag in video.tagNames"
:key="tag"
plain
>
{{ tag }}
</van-tag>
</div>
</div>
</div>
<aside class="side-column">
<div class="side-card">
<strong>当前状态</strong>
<div
class="status-panel"
:class="`status-${resolvedStatus}`"
>
<span>{{ statusText }}</span>
<p>{{ statusDescription }}</p>
</div>
</div>
<div class="side-card">
<strong>创作信息</strong>
<div class="summary-list">
<div class="summary-item">
<span>作者</span>
<strong>{{ video.userNickname || '当前用户' }}</strong>
</div>
<div class="summary-item">
<span>视频格式</span>
<strong>{{ video.format || '未知' }}</strong>
</div>
<div class="summary-item">
<span>分辨率</span>
<strong>{{ video.width }} × {{ video.height }}</strong>
</div>
<div class="summary-item">
<span>文件大小</span>
<strong>{{ fileSizeText }}</strong>
</div>
</div>
</div>
</aside>
</section>
<van-skeleton
v-else
title
:row="6"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from '@/utils/toast'
import VideoPlayer from '@/components/video/VideoPlayer.vue'
import { videoApi } from '@/api/video.ts'
import type { VideoInfo } from '@/types'
import { formatDateTime, formatDuration, formatFileSize, formatNumber } from '@/utils'
import { resolveMediaUrl } from '@/utils/media'
const route = useRoute()
const router = useRouter()
const video = ref<VideoInfo | null>(null)
const videoId = computed(() => Number(route.params.id))
const playerVideoUrl = computed(() => resolveMediaUrl(video.value?.videoUrl))
const playerPosterUrl = computed(() => resolveMediaUrl(video.value?.coverUrl))
const resolvedStatus = computed(() => {
if (typeof video.value?.status === 'number') return video.value.status
if (video.value?.publishTime) return 1
return -1
})
const statusText = computed(() => {
const map: Record<number, string> = {
0: '审核中',
1: '已发布',
2: '已下架',
3: '审核拒绝',
[-1]: '状态未知'
}
return map[resolvedStatus.value] || '未知状态'
})
const statusDescription = computed(() => {
const map: Record<number, string> = {
0: '当前视频仍在审核流程中,仅你自己可预览。',
1: '当前视频已对外公开,观众可以正常访问与互动。',
2: '当前视频处于下架状态,仅你自己可查看预览内容。',
3: '当前视频未通过审核,仅你自己可查看当前预览内容。',
[-1]: '暂时无法确认当前视频状态,请稍后刷新重试。'
}
return map[resolvedStatus.value] || '当前视频状态未知。'
})
const ownerNoticeText = computed(() => {
const map: Record<number, string> = {
0: '审核通过后会自动进入公开展示,你现在看到的是作者预览视图。',
2: '该视频当前不会在公共页面展示,你现在看到的是作者预览视图。',
3: '该视频当前不会在公共页面展示,你现在看到的是作者预览视图。'
}
return map[resolvedStatus.value] || '当前为作者预览视图。'
})
const fileSizeText = computed(() => {
if (!video.value?.size) return '未知'
return formatFileSize(video.value.size)
})
const loadVideo = async () => {
try {
video.value = await videoApi.getVideo(videoId.value)
} catch (error: any) {
showToast(error.message || '加载失败')
router.push('/my/videos')
}
}
const goToPublicPage = () => {
if (!video.value) return
router.push(`/video/${video.value.id}`)
}
onMounted(loadVideo)
watch(() => route.params.id, () => {
video.value = null
loadVideo()
})
</script>
<style scoped lang="scss">
.creator-video-detail-page {
min-height: 100vh;
background: #f4f5f7;
padding-bottom: 36px;
}
.detail-shell {
padding-top: 18px;
}
.detail-hero,
.player-card,
.info-card,
.side-card,
.owner-notice {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.detail-hero {
padding: 22px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background:
linear-gradient(135deg, rgba(0, 161, 214, 0.08), rgba(251, 114, 153, 0.08)),
#fff;
}
.hero-copy {
display: grid;
gap: 6px;
h1 {
margin: 0;
font-size: 30px;
color: #18191c;
}
span {
color: #61666d;
font-size: 14px;
}
}
.hero-eyebrow {
margin: 0;
color: var(--link-color);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.hero-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.detail-layout {
margin-top: 18px;
display: grid;
gap: 16px;
@media (min-width: 1100px) {
grid-template-columns: minmax(0, 1fr) 320px;
}
}
.main-column,
.side-column {
display: grid;
gap: 16px;
align-content: start;
}
.player-card {
overflow: hidden;
:deep(.video-player) {
border-radius: 10px;
}
}
.owner-notice {
padding: 16px 18px;
display: grid;
gap: 6px;
border-left: 4px solid #f59e0b;
strong {
font-size: 15px;
color: #18191c;
}
span {
color: #61666d;
font-size: 13px;
line-height: 1.6;
}
}
.info-card,
.side-card {
padding: 18px;
}
.title-row {
margin-bottom: 18px;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.category-badge,
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.category-badge {
background: #e5f6fd;
color: #00a1d6;
}
.status-badge,
.status-panel {
&.status-0 {
background: #fff5dd;
color: #d77a00;
}
&.status-1 {
background: #e8f8ee;
color: #18a058;
}
&.status-2 {
background: #edf7ff;
color: #007fff;
}
&.status-3 {
background: #fff0f0;
color: #d14343;
}
}
.title-main h2 {
margin: 0;
font-size: 28px;
color: #18191c;
line-height: 1.4;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.meta-item,
.summary-item {
display: grid;
gap: 6px;
padding: 12px 14px;
background: #f8fafc;
border-radius: 8px;
span {
font-size: 12px;
color: #9499a0;
}
strong {
color: #18191c;
font-size: 16px;
}
}
.desc-block {
display: grid;
gap: 10px;
strong {
font-size: 15px;
color: #18191c;
}
p {
margin: 0;
color: #61666d;
line-height: 1.7;
white-space: pre-wrap;
}
}
.tag-list {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.side-card > strong {
display: block;
margin-bottom: 14px;
font-size: 18px;
color: #18191c;
}
.status-panel {
padding: 14px;
border-radius: 8px;
display: grid;
gap: 8px;
span {
font-size: 16px;
font-weight: 700;
}
p {
margin: 0;
font-size: 13px;
line-height: 1.6;
}
}
.summary-list {
display: grid;
gap: 10px;
}
@media (max-width: 768px) {
.detail-hero {
flex-direction: column;
align-items: flex-start;
}
.hero-copy h1 {
font-size: 24px;
}
.meta-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -20,22 +20,51 @@
</section>
<section class="stats-grid">
<div class="stat-card">
<button
type="button"
class="stat-card"
:class="{ active: selectedFilter === 'all' }"
@click="toggleFilter('all')"
>
<span>全部视频</span>
<strong>{{ videos.length }}</strong>
</div>
<div class="stat-card">
</button>
<button
type="button"
class="stat-card"
:class="{ active: selectedFilter === 1 }"
@click="toggleFilter(1)"
>
<span>已发布</span>
<strong>{{ publishedCount }}</strong>
</div>
<div class="stat-card">
</button>
<button
type="button"
class="stat-card"
:class="{ active: selectedFilter === 0 }"
@click="toggleFilter(0)"
>
<span>审核中</span>
<strong>{{ reviewingCount }}</strong>
</div>
<div class="stat-card">
<span>异常状态</span>
<strong>{{ exceptionalCount }}</strong>
</div>
</button>
<button
type="button"
class="stat-card"
:class="{ active: selectedFilter === 2 }"
@click="toggleFilter(2)"
>
<span>已下架</span>
<strong>{{ offlineCount }}</strong>
</button>
<button
type="button"
class="stat-card"
:class="{ active: selectedFilter === 3 }"
@click="toggleFilter(3)"
>
<span>审核拒绝</span>
<strong>{{ rejectedCount }}</strong>
</button>
</section>
<section
@@ -43,13 +72,16 @@
class="videos-panel"
>
<div class="panel-head">
<strong>内容列表</strong>
<span>{{ videos.length }} 条记录</span>
<strong>{{ currentFilterLabel }}</strong>
<span>{{ filteredVideos.length }} 条记录</span>
</div>
<div class="video-list">
<div
v-if="filteredVideos.length"
class="video-list"
>
<article
v-for="video in videos"
v-for="video in filteredVideos"
:key="video.id"
class="video-item"
@click="goToVideo(video.id)"
@@ -84,6 +116,11 @@
</div>
</article>
</div>
<van-empty
v-else
description="当前状态下暂无视频"
/>
</section>
<van-empty
@@ -105,7 +142,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { showToast } from '@/utils/toast'
import { videoApi } from '@/api/video.ts'
import { useUserStore } from '@/stores/user.ts'
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
@@ -117,16 +154,67 @@ const userStore = useUserStore()
const videos = ref<VideoInfo[]>([])
const resolvedUserId = ref<number | null>(null)
const selectedFilter = ref<'all' | number>('all')
const defaultCover =
'data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 180\"%3E%3Crect width=\"320\" height=\"180\" fill=\"%23edf2f7\"/%3E%3Ctext x=\"50%25\" y=\"50%25\" dy=\".3em\" text-anchor=\"middle\" fill=\"%239499a0\"%3EHAOVP%3C/text%3E%3C/svg%3E'
const publishedCount = computed(() => videos.value.filter((video) => video.status === 1).length)
const reviewingCount = computed(() => videos.value.filter((video) => video.status === 0).length)
const exceptionalCount = computed(() => videos.value.filter((video) => video.status !== 0 && video.status !== 1).length)
const offlineCount = computed(() => videos.value.filter((video) => video.status === 2).length)
const rejectedCount = computed(() => videos.value.filter((video) => video.status === 3).length)
const currentFilterLabel = computed(() => {
if (selectedFilter.value === 'all') return '内容列表'
if (selectedFilter.value === 1) return '已发布内容'
if (selectedFilter.value === 0) return '审核中内容'
if (selectedFilter.value === 2) return '已下架内容'
return '审核拒绝内容'
})
const getCoverUrl = (coverUrl?: string) => resolveMediaUrl(coverUrl) || defaultCover
const getVideoTimestamp = (video: VideoInfo) => {
const value = video.publishTime || video.createTime || ''
const time = new Date(value).getTime()
return Number.isNaN(time) ? 0 : time
}
const toggleFilter = (filter: 'all' | number) => {
if (filter !== 'all' && selectedFilter.value === filter) {
selectedFilter.value = 'all'
return
}
selectedFilter.value = filter
}
const getPriorityWeight = (video: VideoInfo) => {
if (selectedFilter.value === 'all') {
if (video.status === 0) return 0
if (video.status === 1) return 1
return 2
}
return 0
}
const filteredVideos = computed(() => {
let list = [...videos.value]
if (
selectedFilter.value === 0 ||
selectedFilter.value === 1 ||
selectedFilter.value === 2 ||
selectedFilter.value === 3
) {
list = list.filter((video) => video.status === selectedFilter.value)
}
return list.sort((a, b) => {
const priorityDiff = getPriorityWeight(a) - getPriorityWeight(b)
if (priorityDiff !== 0) return priorityDiff
return getVideoTimestamp(b) - getVideoTimestamp(a)
})
})
const getStatusType = (status: number): 'warning' | 'success' | 'danger' | 'primary' => {
const types: Record<number, 'warning' | 'success' | 'danger' | 'primary'> = {
0: 'warning',
@@ -171,7 +259,7 @@ const loadVideos = async () => {
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
router.push(`/my/videos/${id}`)
}
const initPage = async () => {
@@ -258,14 +346,20 @@ onMounted(initPage)
.stats-grid {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 16px;
}
.stat-card {
appearance: none;
border: 0;
width: 100%;
text-align: left;
padding: 18px;
display: grid;
gap: 8px;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
span {
color: #9499a0;
@@ -277,6 +371,21 @@ onMounted(initPage)
font-size: 28px;
line-height: 1;
}
&:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
&.active {
background: linear-gradient(135deg, rgba(0, 161, 214, 0.12), rgba(251, 114, 153, 0.12));
box-shadow: inset 0 0 0 1px rgba(0, 161, 214, 0.22), var(--shadow-md);
span,
strong {
color: #18191c;
}
}
}
.videos-panel {
@@ -73,7 +73,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast, showToast } from 'vant'
import { showSuccessToast, showToast } from '@/utils/toast'
import { userApi } from '@/api/user.ts'
const router = useRouter()
@@ -47,7 +47,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast, showToast } from 'vant'
import { showSuccessToast, showToast } from '@/utils/toast'
import { userApi } from '@/api/user.ts'
const router = useRouter()
@@ -62,13 +62,13 @@
v-model="form.gender"
direction="horizontal"
>
<van-radio :value="0">
<van-radio :name="0">
保密
</van-radio>
<van-radio :value="1">
<van-radio :name="1">
男
</van-radio>
<van-radio :value="2">
<van-radio :name="2">
女
</van-radio>
</van-radio-group>
@@ -120,11 +120,16 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { showToast, showSuccessToast } from '@/utils/toast'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user'
import { resolveAvatarUrl } from '@/utils/media'
const MAX_AVATAR_SIZE_MB = 5
const MAX_AVATAR_SIZE_BYTES = MAX_AVATAR_SIZE_MB * 1024 * 1024
const router = useRouter()
const userStore = useUserStore()
const saving = ref(false)
const showBirthdayPicker = ref(false)
const avatarInput = ref<HTMLInputElement>()
@@ -148,7 +153,7 @@ const loadProfile = async () => {
form.avatar = profile.avatar || ''
form.nickname = profile.nickname || ''
form.signature = profile.signature || ''
form.gender = profile.gender || 0
form.gender = Number(profile.gender ?? 0)
form.birthday = profile.birthday || ''
form.region = profile.region || ''
if (form.birthday) selectedDate.value = form.birthday.split('-')
@@ -163,12 +168,24 @@ const onAvatarChange = async (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
showToast('请选择图片文件')
target.value = ''
return
}
if (file.size > MAX_AVATAR_SIZE_BYTES) {
showToast(`头像不能超过 ${MAX_AVATAR_SIZE_MB}MB`)
target.value = ''
return
}
try {
const url = await userApi.uploadAvatar(file)
const url = await userStore.uploadAvatar(file)
form.avatar = url
showSuccessToast('头像上传成功')
} catch {
showToast('头像上传失败')
} catch (error) {
console.error('头像上传失败:', error)
} finally {
target.value = ''
}
}
@@ -75,7 +75,10 @@
</div>
</div>
<div class="stats">
<div
class="stats"
:class="{ 'stats-self': isSelf }"
>
<div
class="stat-item"
@click="goToUserVideos"
@@ -117,6 +120,17 @@
获赞
</div>
</div>
<div
v-if="isSelf"
class="stat-item coin-balance"
>
<div class="value">
{{ userInfo.coinCount }}
</div>
<div class="label">
硬币
</div>
</div>
</div>
<div class="actions">
@@ -191,6 +205,13 @@
<span>账号状态</span>
<strong>{{ vipLabel || '普通用户' }}</strong>
</div>
<div
v-if="isSelf"
class="overview-item"
>
<span>硬币余额</span>
<strong>{{ userInfo.coinCount }}</strong>
</div>
<div class="overview-item">
<span>生日</span>
<strong>{{ birthdayLabel }}</strong>
@@ -310,7 +331,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { showToast, showSuccessToast } from '@/utils/toast'
import VideoCard from '@/components/video/VideoCard.vue'
import { userApi } from '@/api/user.ts'
import { videoApi } from '@/api/video.ts'
@@ -634,6 +655,10 @@ watch(activeTab, (tab) => {
border-bottom: 1px solid #f1f2f3;
}
.stats-self {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.stat-item {
text-align: center;
cursor: pointer;
@@ -647,6 +672,23 @@ watch(activeTab, (tab) => {
}
}
.coin-balance {
cursor: default;
background: linear-gradient(180deg, #fff7e6, #fff1cc);
&:hover {
background: linear-gradient(180deg, #fff7e6, #fff1cc);
}
.value {
color: #b7791f;
}
.label {
color: #9a6b16;
}
}
.value {
font-size: 18px;
font-weight: 700;
@@ -866,6 +908,10 @@ watch(activeTab, (tab) => {
gap: 8px;
}
.stats-self {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.profile-overview {
grid-template-columns: 1fr;
}
@@ -124,7 +124,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog, showToast, showSuccessToast } from 'vant'
import { showConfirmDialog } from 'vant'
import { showToast, showSuccessToast } from '@/utils/toast'
import { useUserStore } from '@/stores/user.ts'
import { useMessageStore } from '@/stores/message'
import { userApi } from '@/api/user.ts'
@@ -85,7 +85,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import { showToast } from '@/utils/toast'
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
import { resolveMediaUrl } from '@/utils/media'
import { videoApi } from '@/api/video.ts'
@@ -26,6 +26,7 @@
:src="playerVideoUrl"
:poster="playerPosterUrl"
:video-id="video.id"
:initial-time="resumePlayTime"
@timeupdate="onTimeUpdate"
@ended="onEnded"
/>
@@ -50,7 +51,7 @@
{{ video.categoryName }}
</span>
<span class="status-badge">
公开内容
{{ statusText }}
</span>
</div>
<h1 class="video-title">
@@ -80,7 +81,10 @@
<span>{{ formatDateTime(video.publishTime || video.createTime) }}</span>
</div>
<div class="video-actions">
<div
v-if="isPublicVideo"
class="video-actions"
>
<van-button
size="small"
:type="video.isLiked ? 'primary' : 'default'"
@@ -196,6 +200,7 @@
</div>
<van-tabs
v-if="isPublicVideo"
v-model:active="activeTab"
sticky
class="detail-tabs"
@@ -260,9 +265,12 @@
<div class="sidebar-card">
<div class="sidebar-title">
连续观看
{{ sidebarTitle }}
</div>
<div class="sidebar-video-list">
<div
v-if="recommendVideos.length"
class="sidebar-video-list"
>
<VideoCard
v-for="item in recommendVideos"
:key="`side-${item.id}`"
@@ -270,16 +278,116 @@
@click="goToVideo(item.id)"
/>
</div>
<div
v-else
class="sidebar-empty"
>
暂无可推荐内容
</div>
</div>
</aside>
</div>
<van-popup
v-model:show="showFavoritePopup"
round
position="bottom"
>
<div class="favorite-popup">
<div class="favorite-popup-head">
<strong>选择收藏夹</strong>
<button
class="favorite-create-link"
@click="openCreateFavoritePopup"
>
新建收藏夹
</button>
</div>
<div
v-if="favoriteFolders.length"
class="favorite-folder-list"
>
<van-radio-group v-model="selectedFavoriteId">
<van-cell-group inset>
<van-cell
v-for="folder in favoriteFolders"
:key="folder.id"
clickable
:title="folder.name"
:label="folder.description || `${folder.videoCount} 个视频`"
@click="selectedFavoriteId = folder.id"
>
<template #right-icon>
<van-radio :name="folder.id" />
</template>
</van-cell>
</van-cell-group>
</van-radio-group>
</div>
<van-empty
v-else
description="还没有收藏夹"
/>
<van-button
block
type="primary"
:loading="favoriteSubmitting"
:disabled="!favoriteFolders.length"
@click="confirmFavoriteSelection"
>
确认收藏
</van-button>
</div>
</van-popup>
<van-popup
v-model:show="showCreateFavoritePopup"
round
position="bottom"
>
<div class="favorite-popup">
<div class="favorite-popup-head">
<strong>新建收藏夹</strong>
</div>
<van-field
v-model="newFavoriteForm.name"
label="名称"
placeholder="请输入收藏夹名称"
/>
<van-field
v-model="newFavoriteForm.description"
label="简介"
placeholder="可选"
/>
<van-cell title="设为私密">
<template #right-icon>
<van-switch
v-model="newFavoriteForm.isPrivate"
:active-value="1"
:inactive-value="0"
/>
</template>
</van-cell>
<van-button
block
type="primary"
:loading="createFavoriteSubmitting"
@click="createFavoriteFolder"
>
创建并收藏
</van-button>
</div>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { showToast, showSuccessToast } from '@/utils/toast'
import VideoPlayer from '@/components/video/VideoPlayer.vue'
import CommentSection from '@/components/comment/CommentSection.vue'
import DanmakuList from '@/components/danmaku/DanmakuList.vue'
@@ -290,28 +398,65 @@ import { recommendApi } from '@/api/recommend.ts'
import { favoriteApi } from '@/api/favorite.ts'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import { useVideoStore } from '@/stores/video.ts'
import type { UserInfo, VideoInfo } from '@/types'
import type { Favorite } from '@/api/favorite.ts'
import { formatDateTime, formatNumber, formatPlayCount } from '@/utils'
import { resolveAvatarUrl, resolveMediaUrl } from '@/utils/media'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const videoStore = useVideoStore()
const videoId = computed(() => Number(route.params.id))
const video = ref<VideoInfo | null>(null)
const danmakuList = ref<Danmaku[]>([])
const recommendVideos = ref<VideoInfo[]>([])
const recommendSource = ref<'similar' | 'author' | 'hot'>('similar')
const isFollowed = ref(false)
const showFavoritePopup = ref(false)
const showCreateFavoritePopup = ref(false)
const favoriteFolders = ref<Favorite[]>([])
const selectedFavoriteId = ref<number | null>(null)
const favoriteSubmitting = ref(false)
const createFavoriteSubmitting = ref(false)
const activeTab = ref('comment')
const activeCollapse = ref<string[]>([])
const currentTime = ref(0)
const lastHistorySyncTime = ref(0)
const authorProfile = ref<UserInfo | null>(null)
const newFavoriteForm = ref({
name: '',
description: '',
isPrivate: 0
})
const playerVideoUrl = computed(() => resolveMediaUrl(video.value?.videoUrl))
const playerPosterUrl = computed(() => resolveMediaUrl(video.value?.coverUrl))
const resumePlayTime = computed(() => {
if (!video.value) return 0
if (video.value.isPlayFinished) return 0
const serverProgress = video.value.playProgress ?? 0
if (serverProgress > 0) {
return serverProgress
}
return videoStore.getPlayProgress(video.value.id)
})
const notificationBackVisible = computed(() => route.query.from === 'notifications')
const authorName = computed(() => authorProfile.value?.nickname || video.value?.userNickname || '未知用户')
const authorAvatar = computed(() => resolveAvatarUrl(authorProfile.value?.avatar || video.value?.userAvatar, defaultAvatar))
const isPublicVideo = computed(() => video.value?.status === 1)
const statusText = computed(() => {
const map: Record<number, string> = {
0: '审核中',
1: '公开内容',
2: '已下架',
3: '审核拒绝'
}
return map[video.value?.status ?? 1] || '内容详情'
})
const authorFollowerText = computed(() => {
if (authorProfile.value) {
return `${formatNumber(authorProfile.value.followerCount)} 粉丝`
@@ -321,13 +466,46 @@ const authorFollowerText = computed(() => {
}
return '作者信息暂不可用'
})
const sidebarTitle = computed(() => {
const titleMap = {
similar: '连续观看',
author: 'UP 主的其他视频',
hot: '热门推荐'
}
return titleMap[recommendSource.value]
})
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 dedupeVideos = (videos: VideoInfo[]) => {
const seen = new Set<number>()
return videos.filter((item) => {
if (!item?.id || item.id === videoId.value || seen.has(item.id)) return false
seen.add(item.id)
return true
})
}
const resetNewFavoriteForm = () => {
newFavoriteForm.value = {
name: '',
description: '',
isPrivate: 0
}
}
const loadFavoriteFolders = async () => {
favoriteFolders.value = await favoriteApi.getFavoriteList()
selectedFavoriteId.value = favoriteFolders.value[0]?.id ?? null
}
// 加载视频详情
const loadVideo = async () => {
try {
video.value = await videoApi.getVideoPublic(videoId.value)
video.value = userStore.isLoggedIn
? await videoApi.getVideo(videoId.value)
: await videoApi.getVideoPublic(videoId.value)
// 播放记录接口当前要求登录,匿名态不应触发 401 跳转。
if (userStore.isLoggedIn) {
try {
@@ -381,10 +559,37 @@ const loadAuthorProfile = async () => {
// 加载推荐
const loadRecommend = async () => {
try {
const res = await recommendApi.getSimilarVideos(videoId.value, 10)
recommendVideos.value = res
const similarVideos = dedupeVideos(await recommendApi.getSimilarVideos(videoId.value, 10))
if (similarVideos.length) {
recommendSource.value = 'similar'
recommendVideos.value = similarVideos
return
}
if (video.value?.userId) {
const authorVideos = dedupeVideos(
(
await videoApi.getVideoPublicList({
userId: video.value.userId,
pageNum: 1,
pageSize: 10,
status: 1
})
).records || []
)
if (authorVideos.length) {
recommendSource.value = 'author'
recommendVideos.value = authorVideos
return
}
}
recommendSource.value = 'hot'
recommendVideos.value = dedupeVideos(await recommendApi.getHotRecommend(1, 10))
} catch (error) {
console.error('加载推荐失败:', error)
recommendSource.value = 'hot'
recommendVideos.value = []
}
}
@@ -428,10 +633,29 @@ const toggleCoin = async () => {
return
}
if (!video.value) return
if (video.value.isCoined) {
showToast('已经投币过了')
return
}
try {
await userStore.fetchUserInfo()
} catch (error) {
console.error('刷新用户硬币余额失败:', error)
}
if ((userStore.userInfo?.coinCount ?? 0) < 1) {
showToast('硬币余额不足')
return
}
try {
await videoApi.coinVideo(video.value.id, 1)
video.value.coinCount++
video.value.isCoined = true
if (userStore.userInfo) {
userStore.userInfo.coinCount = Math.max(0, userStore.userInfo.coinCount - 1)
}
showSuccessToast('投币成功')
} catch (error: any) {
showToast(error.message || '投币失败')
@@ -451,11 +675,10 @@ const toggleFavorite = async () => {
await videoApi.unfavoriteVideo(video.value.id)
video.value.favoriteCount--
video.value.isFavorited = false
showSuccessToast('已取消收藏')
} else {
const favoriteId = await ensureFavoriteId()
await videoApi.favoriteVideo(video.value.id, favoriteId)
video.value.favoriteCount++
video.value.isFavorited = true
await loadFavoriteFolders()
showFavoritePopup.value = true
}
} catch (error: any) {
showToast(error.message || '操作失败')
@@ -474,18 +697,56 @@ const shareVideo = async () => {
}
}
const ensureFavoriteId = async () => {
const favorites = await favoriteApi.getFavoriteList()
if (favorites.length > 0) {
return favorites[0].id
const confirmFavoriteSelection = async () => {
if (!video.value) return
if (!selectedFavoriteId.value) {
showToast('请选择收藏夹')
return
}
const favorite = await favoriteApi.createFavorite({
name: '默认收藏夹',
description: '自动创建的默认收藏夹',
isPrivate: 0
})
return favorite.id
favoriteSubmitting.value = true
try {
await videoApi.favoriteVideo(video.value.id, selectedFavoriteId.value)
video.value.favoriteCount++
video.value.isFavorited = true
showFavoritePopup.value = false
showSuccessToast('收藏成功')
} catch (error: any) {
showToast(error.message || '收藏失败')
} finally {
favoriteSubmitting.value = false
}
}
const openCreateFavoritePopup = () => {
showFavoritePopup.value = false
showCreateFavoritePopup.value = true
}
const createFavoriteFolder = async () => {
if (!newFavoriteForm.value.name.trim()) {
showToast('请输入收藏夹名称')
return
}
if (!video.value) return
createFavoriteSubmitting.value = true
try {
const favorite = await favoriteApi.createFavorite({
name: newFavoriteForm.value.name.trim(),
description: newFavoriteForm.value.description.trim(),
isPrivate: newFavoriteForm.value.isPrivate
})
favoriteFolders.value.unshift(favorite)
selectedFavoriteId.value = favorite.id
showCreateFavoritePopup.value = false
resetNewFavoriteForm()
await confirmFavoriteSelection()
} catch (error: any) {
showToast(error.message || '创建收藏夹失败')
} finally {
createFavoriteSubmitting.value = false
}
}
// 关注
@@ -531,25 +792,52 @@ const goBackToNotifications = () => {
// 播放时间更新
const onTimeUpdate = (time: number) => {
currentTime.value = time
if (!video.value?.id) return
if (time - lastHistorySyncTime.value < 5) return
lastHistorySyncTime.value = time
videoStore.recordPlay(video.value.id, time, video.value.duration || 0)
}
// 播放结束
const onEnded = () => {
// 播放完成逻辑
if (!video.value?.id) return
const duration = video.value.duration || currentTime.value
lastHistorySyncTime.value = duration
videoStore.recordPlay(video.value.id, duration, duration)
}
onMounted(() => {
loadVideo()
})
onBeforeUnmount(() => {
if (!video.value?.id || currentTime.value <= 0) return
videoStore.recordPlay(video.value.id, currentTime.value, video.value.duration || 0)
})
watch(() => route.params.id, () => {
video.value = null
danmakuList.value = []
recommendVideos.value = []
isFollowed.value = false
authorProfile.value = null
currentTime.value = 0
lastHistorySyncTime.value = 0
loadVideo()
})
watch(showFavoritePopup, (value) => {
if (!value) {
favoriteSubmitting.value = false
}
})
watch(showCreateFavoritePopup, (value) => {
if (!value) {
resetNewFavoriteForm()
}
})
</script>
<style scoped lang="scss">
@@ -930,6 +1218,42 @@ watch(() => route.params.id, () => {
gap: 16px;
}
.sidebar-empty {
padding: 18px 8px 6px;
color: #94a3b8;
font-size: 13px;
text-align: center;
}
.favorite-popup {
padding: 20px 16px 28px;
display: grid;
gap: 14px;
}
.favorite-popup-head {
display: flex;
align-items: center;
justify-content: space-between;
strong {
font-size: 18px;
color: #18191c;
}
}
.favorite-create-link {
border: none;
background: transparent;
color: var(--link-color);
font-size: 14px;
}
.favorite-folder-list {
max-height: 50vh;
overflow-y: auto;
}
.mobile-recommend {
@media (min-width: 1200px) {
display: none;