refactor(frontend): Rework home and video detail interactions
This commit is contained in:
@@ -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')
|
||||
},
|
||||
|
||||
/**
|
||||
* 增加评论数量(内部接口)
|
||||
*/
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
// 状态
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user