refactor(frontend): Rework home and video detail interactions
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
|||||||
VideoUploadParams,
|
VideoUploadParams,
|
||||||
VideoUploadResult,
|
VideoUploadResult,
|
||||||
PlayRecord,
|
PlayRecord,
|
||||||
|
PlayHistoryItem,
|
||||||
PageResult
|
PageResult
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
@@ -134,6 +135,20 @@ export const videoApi = {
|
|||||||
return request.post('/content/video/play', params)
|
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']
|
VanLoading: typeof import('vant/es')['Loading']
|
||||||
VanNavBar: typeof import('vant/es')['NavBar']
|
VanNavBar: typeof import('vant/es')['NavBar']
|
||||||
VanPicker: typeof import('vant/es')['Picker']
|
VanPicker: typeof import('vant/es')['Picker']
|
||||||
VanPopover: typeof import('vant/es')['Popover']
|
|
||||||
VanPopup: typeof import('vant/es')['Popup']
|
VanPopup: typeof import('vant/es')['Popup']
|
||||||
VanProgress: typeof import('vant/es')['Progress']
|
VanProgress: typeof import('vant/es')['Progress']
|
||||||
VanRadio: typeof import('vant/es')['Radio']
|
VanRadio: typeof import('vant/es')['Radio']
|
||||||
|
|||||||
@@ -113,7 +113,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { showToast, showSuccessToast } from 'vant'
|
import { showToast, showSuccessToast } from '@/utils/toast'
|
||||||
import { commentApi } from '@/api/comment.ts'
|
import { commentApi } from '@/api/comment.ts'
|
||||||
import { useUserStore } from '@/stores/user.ts'
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
import { formatRelativeTime } from '@/utils'
|
import { formatRelativeTime } from '@/utils'
|
||||||
|
|||||||
@@ -74,6 +74,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="banner-actions">
|
<div class="banner-actions">
|
||||||
|
<template v-if="userStore.isLoggedIn">
|
||||||
|
<div
|
||||||
|
class="hover-card-anchor"
|
||||||
|
@mouseenter="openHoverPanel('avatar', $event)"
|
||||||
|
@mouseleave="scheduleHoverClose()"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="avatar-entry"
|
class="avatar-entry"
|
||||||
@click="goToProfileOrLogin"
|
@click="goToProfileOrLogin"
|
||||||
@@ -85,6 +91,24 @@
|
|||||||
height="36"
|
height="36"
|
||||||
/>
|
/>
|
||||||
</button>
|
</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
|
<button
|
||||||
class="banner-action"
|
class="banner-action"
|
||||||
@click="router.push('/notifications')"
|
@click="router.push('/notifications')"
|
||||||
@@ -100,6 +124,17 @@
|
|||||||
</van-badge>
|
</van-badge>
|
||||||
<span>消息</span>
|
<span>消息</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
class="banner-action"
|
class="banner-action"
|
||||||
@click="router.push('/my/favorites')"
|
@click="router.push('/my/favorites')"
|
||||||
@@ -120,16 +155,26 @@
|
|||||||
/>
|
/>
|
||||||
<span>历史</span>
|
<span>历史</span>
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<button
|
<button
|
||||||
class="banner-action"
|
class="banner-action auth-action"
|
||||||
@click="router.push(userStore.isLoggedIn ? '/my/videos' : '/login')"
|
@click="router.push('/login')"
|
||||||
>
|
>
|
||||||
<van-icon
|
<span>登录</span>
|
||||||
name="video-o"
|
|
||||||
size="18"
|
|
||||||
/>
|
|
||||||
<span>创作中心</span>
|
|
||||||
</button>
|
</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()"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="upload-entry"
|
class="upload-entry"
|
||||||
@click="router.push(userStore.isLoggedIn ? '/upload' : '/login')"
|
@click="router.push(userStore.isLoggedIn ? '/upload' : '/login')"
|
||||||
@@ -140,6 +185,17 @@
|
|||||||
/>
|
/>
|
||||||
<span>投稿</span>
|
<span>投稿</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,6 +256,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sticky-actions">
|
<div class="sticky-actions">
|
||||||
|
<template v-if="userStore.isLoggedIn">
|
||||||
|
<div
|
||||||
|
class="hover-card-anchor"
|
||||||
|
@mouseenter="openHoverPanel('avatar', $event)"
|
||||||
|
@mouseleave="scheduleHoverClose()"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="avatar-entry compact"
|
class="avatar-entry compact"
|
||||||
@click="goToProfileOrLogin"
|
@click="goToProfileOrLogin"
|
||||||
@@ -211,6 +273,24 @@
|
|||||||
height="32"
|
height="32"
|
||||||
/>
|
/>
|
||||||
</button>
|
</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
|
<button
|
||||||
class="icon-action"
|
class="icon-action"
|
||||||
@click="router.push('/notifications')"
|
@click="router.push('/notifications')"
|
||||||
@@ -225,6 +305,17 @@
|
|||||||
/>
|
/>
|
||||||
</van-badge>
|
</van-badge>
|
||||||
</button>
|
</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
|
<button
|
||||||
class="icon-action"
|
class="icon-action"
|
||||||
@click="router.push('/my/favorites')"
|
@click="router.push('/my/favorites')"
|
||||||
@@ -243,12 +334,43 @@
|
|||||||
size="18"
|
size="18"
|
||||||
/>
|
/>
|
||||||
</button>
|
</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()"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="upload-entry compact"
|
class="upload-entry compact"
|
||||||
@click="router.push(userStore.isLoggedIn ? '/upload' : '/login')"
|
@click="router.push(userStore.isLoggedIn ? '/upload' : '/login')"
|
||||||
>
|
>
|
||||||
投稿
|
投稿
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,34 +422,11 @@
|
|||||||
历史
|
历史
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<van-popover
|
<div
|
||||||
v-model:show="showMessagePopover"
|
class="hover-card-anchor"
|
||||||
trigger="click"
|
@mouseenter="openHoverPanel('message', $event)"
|
||||||
placement="bottom-end"
|
@mouseleave="scheduleHoverClose()"
|
||||||
>
|
>
|
||||||
<div class="popover-menu">
|
|
||||||
<button
|
|
||||||
class="popover-item"
|
|
||||||
@click="goToMessages"
|
|
||||||
>
|
|
||||||
<span>私信</span>
|
|
||||||
<van-badge
|
|
||||||
v-if="messageStore.unreadMessageCount"
|
|
||||||
:content="messageStore.unreadMessageCount"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="popover-item"
|
|
||||||
@click="goToNotifications"
|
|
||||||
>
|
|
||||||
<span>通知</span>
|
|
||||||
<van-badge
|
|
||||||
v-if="messageStore.unreadNotificationCount"
|
|
||||||
:content="messageStore.unreadNotificationCount"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<template #reference>
|
|
||||||
<button class="top-icon">
|
<button class="top-icon">
|
||||||
<van-badge
|
<van-badge
|
||||||
:content="messageStore.totalUnreadCount || ''"
|
:content="messageStore.totalUnreadCount || ''"
|
||||||
@@ -339,16 +438,41 @@
|
|||||||
/>
|
/>
|
||||||
</van-badge>
|
</van-badge>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
<div
|
||||||
</van-popover>
|
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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="hover-card-anchor"
|
||||||
|
@mouseenter="openHoverPanel('creator', $event)"
|
||||||
|
@mouseleave="scheduleHoverClose()"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="upload-entry compact"
|
class="upload-entry compact"
|
||||||
@click="router.push(userStore.isLoggedIn ? '/upload' : '/login')"
|
@click="router.push(userStore.isLoggedIn ? '/upload' : '/login')"
|
||||||
>
|
>
|
||||||
投稿
|
投稿
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -406,6 +530,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<div
|
||||||
|
class="hover-card-anchor"
|
||||||
|
@mouseenter="openHoverPanel('avatar', $event)"
|
||||||
|
@mouseleave="scheduleHoverClose()"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="user-chip"
|
class="user-chip"
|
||||||
@click="goToProfileOrLogin"
|
@click="goToProfileOrLogin"
|
||||||
@@ -421,11 +550,44 @@
|
|||||||
<span>{{ displaySignature }}</span>
|
<span>{{ displaySignature }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -452,10 +614,12 @@ const userStore = useUserStore()
|
|||||||
const messageStore = useMessageStore()
|
const messageStore = useMessageStore()
|
||||||
|
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const showMessagePopover = ref(false)
|
|
||||||
const bannerMotionX = ref(0)
|
const bannerMotionX = ref(0)
|
||||||
const homeScrollY = ref(0)
|
const homeScrollY = ref(0)
|
||||||
const homeHeaderCompact = ref(false)
|
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 =
|
const defaultAvatar =
|
||||||
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSI1MCIgZmlsbD0iI2YzZjRmNiIvPjxjaXJjbGUgY3g9IjUwIiBjeT0iMzUiIHI9IjE1IiBmaWxsPSIjYmRjM2NmIi8+PGVsbGlwc2UgY3g9IjUwIiBjeT0iNzUiIHJ4PSIyNSIgcnk9IjIwIiBmaWxsPSIjYmRjM2NmIi8+PC9zdmc+'
|
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSI1MCIgZmlsbD0iI2YzZjRmNiIvPjxjaXJjbGUgY3g9IjUwIiBjeT0iMzUiIHI9IjE1IiBmaWxsPSIjYmRjM2NmIi8+PGVsbGlwc2UgY3g9IjUwIiBjeT0iNzUiIHJ4PSIyNSIgcnk9IjIwIiBmaWxsPSIjYmRjM2NmIi8+PC9zdmc+'
|
||||||
|
|
||||||
@@ -564,14 +728,88 @@ const handleSearch = () => {
|
|||||||
router.push({ path: '/search', query: { keyword } })
|
router.push({ path: '/search', query: { keyword } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToMessages = () => {
|
const hoverPanelItems = computed(() => {
|
||||||
showMessagePopover.value = false
|
if (!userStore.isLoggedIn && activeHoverPanel.value !== 'creator') {
|
||||||
router.push('/messages')
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToNotifications = () => {
|
if (activeHoverPanel.value === 'avatar') {
|
||||||
showMessagePopover.value = false
|
return [
|
||||||
router.push('/notifications')
|
{ 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 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 = () => {
|
const goToProfileOrLogin = () => {
|
||||||
@@ -583,6 +821,27 @@ const goToProfileOrLogin = () => {
|
|||||||
router.push(`/user/${id}`)
|
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 () => {
|
onMounted(async () => {
|
||||||
syncSearchKeyword()
|
syncSearchKeyword()
|
||||||
updateHomeScroll()
|
updateHomeScroll()
|
||||||
@@ -590,6 +849,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
if (!userStore.isLoggedIn) return
|
if (!userStore.isLoggedIn) return
|
||||||
|
|
||||||
|
let authReady = true
|
||||||
try {
|
try {
|
||||||
if (!userStore.authUser) {
|
if (!userStore.authUser) {
|
||||||
await userStore.fetchAuthUser()
|
await userStore.fetchAuthUser()
|
||||||
@@ -598,14 +858,17 @@ onMounted(async () => {
|
|||||||
await userStore.fetchUserInfo()
|
await userStore.fetchUserInfo()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// keep usable
|
authReady = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authReady && userStore.isLoggedIn) {
|
||||||
messageStore.fetchAllUnreadCount()
|
messageStore.fetchAllUnreadCount()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('scroll', updateHomeScroll)
|
window.removeEventListener('scroll', updateHomeScroll)
|
||||||
|
clearHoverCloseTimer()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => route.fullPath, () => {
|
watch(() => route.fullPath, () => {
|
||||||
@@ -619,11 +882,13 @@ watch(() => route.fullPath, () => {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 0 auto;
|
inset: 0 0 auto;
|
||||||
z-index: 120;
|
z-index: 120;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-header {
|
.home-header {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: var(--home-banner-height);
|
height: var(--home-banner-height);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&.compact {
|
&.compact {
|
||||||
.home-banner {
|
.home-banner {
|
||||||
@@ -684,11 +949,13 @@ watch(() => route.fullPath, () => {
|
|||||||
|
|
||||||
.banner-shell {
|
.banner-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 4;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-nav-row {
|
.banner-nav-row {
|
||||||
|
position: relative;
|
||||||
|
z-index: 8;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 560px minmax(520px, auto);
|
grid-template-columns: minmax(0, 1fr) 560px minmax(520px, auto);
|
||||||
@@ -719,6 +986,11 @@ watch(() => route.fullPath, () => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-action {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
.banner-search-wrap,
|
.banner-search-wrap,
|
||||||
.sticky-search-wrap {
|
.sticky-search-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -780,7 +1052,14 @@ watch(() => route.fullPath, () => {
|
|||||||
justify-self: end;
|
justify-self: end;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
min-width: 520px;
|
min-width: 520px;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-card-anchor {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-entry {
|
.avatar-entry {
|
||||||
@@ -869,6 +1148,10 @@ watch(() => route.fullPath, () => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.sticky-brand {
|
.sticky-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1048,23 +1331,68 @@ watch(() => route.fullPath, () => {
|
|||||||
color: #9499a0;
|
color: #9499a0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover-menu {
|
.hover-card {
|
||||||
min-width: 180px;
|
position: absolute;
|
||||||
padding: 8px;
|
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%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
padding: 10px 12px;
|
padding: 12px 14px;
|
||||||
border-radius: 8px;
|
border-radius: 14px;
|
||||||
text-align: left;
|
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 {
|
.hover-menu-item:hover {
|
||||||
background: #f6f7f8;
|
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) {
|
@media (max-width: 1280px) {
|
||||||
@@ -1098,6 +1426,14 @@ watch(() => route.fullPath, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.hover-card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-hover-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.home-header {
|
.home-header {
|
||||||
height: 132px;
|
height: 132px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|||||||
import DPlayer from 'dplayer'
|
import DPlayer from 'dplayer'
|
||||||
import { danmakuApi } from '@/api/danmaku'
|
import { danmakuApi } from '@/api/danmaku'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
type DPlayerInstance = InstanceType<typeof DPlayer>
|
type DPlayerInstance = InstanceType<typeof DPlayer>
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ const props = defineProps<{
|
|||||||
src: string
|
src: string
|
||||||
poster?: string
|
poster?: string
|
||||||
videoId: number
|
videoId: number
|
||||||
|
initialTime?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -28,6 +29,7 @@ const emit = defineEmits<{
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const containerRef = ref<HTMLDivElement | null>(null)
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
let player: DPlayerInstance | null = null
|
let player: DPlayerInstance | null = null
|
||||||
|
let hasRestoredInitialTime = false
|
||||||
|
|
||||||
const toDPlayerType = (position: number) => {
|
const toDPlayerType = (position: number) => {
|
||||||
if (position === 1) return 'top'
|
if (position === 1) return 'top'
|
||||||
@@ -46,6 +48,7 @@ const destroyPlayer = () => {
|
|||||||
player.destroy()
|
player.destroy()
|
||||||
player = null
|
player = null
|
||||||
}
|
}
|
||||||
|
hasRestoredInitialTime = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncDanmakuInputState = () => {
|
const syncDanmakuInputState = () => {
|
||||||
@@ -132,10 +135,24 @@ const initPlayer = async () => {
|
|||||||
|
|
||||||
syncDanmakuInputState()
|
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', () => {
|
player.on('timeupdate', () => {
|
||||||
emit('timeupdate', player?.video.currentTime || 0)
|
emit('timeupdate', player?.video.currentTime || 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
player.on('loadedmetadata', restoreInitialTime)
|
||||||
|
player.on('canplay', restoreInitialTime)
|
||||||
|
|
||||||
player.on('ended', () => {
|
player.on('ended', () => {
|
||||||
emit('ended')
|
emit('ended')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export const mockVideos = [
|
|||||||
height: 1080,
|
height: 1080,
|
||||||
format: 'mp4',
|
format: 'mp4',
|
||||||
size: 156000000,
|
size: 156000000,
|
||||||
categoryId: 1,
|
categoryId: 13,
|
||||||
status: 1,
|
status: 1,
|
||||||
likeCount: 1256,
|
likeCount: 1256,
|
||||||
coinCount: 89,
|
coinCount: 89,
|
||||||
@@ -128,7 +128,7 @@ export const mockVideos = [
|
|||||||
updateTime: '2024-01-15 10:30:00',
|
updateTime: '2024-01-15 10:30:00',
|
||||||
tagIds: [1, 2, 3],
|
tagIds: [1, 2, 3],
|
||||||
tagNames: ['Vue3', 'TypeScript', '前端开发'],
|
tagNames: ['Vue3', 'TypeScript', '前端开发'],
|
||||||
categoryName: '科技',
|
categoryName: '开发技术',
|
||||||
userNickname: '视频达人小明',
|
userNickname: '视频达人小明',
|
||||||
userAvatar: av1,
|
userAvatar: av1,
|
||||||
isLiked: false,
|
isLiked: false,
|
||||||
@@ -147,7 +147,7 @@ export const mockVideos = [
|
|||||||
height: 1080,
|
height: 1080,
|
||||||
format: 'mp4',
|
format: 'mp4',
|
||||||
size: 234000000,
|
size: 234000000,
|
||||||
categoryId: 1,
|
categoryId: 13,
|
||||||
status: 1,
|
status: 1,
|
||||||
likeCount: 892,
|
likeCount: 892,
|
||||||
coinCount: 67,
|
coinCount: 67,
|
||||||
@@ -163,7 +163,7 @@ export const mockVideos = [
|
|||||||
updateTime: '2024-01-14 15:20:00',
|
updateTime: '2024-01-14 15:20:00',
|
||||||
tagIds: [4, 5],
|
tagIds: [4, 5],
|
||||||
tagNames: ['Java', '微服务'],
|
tagNames: ['Java', '微服务'],
|
||||||
categoryName: '科技',
|
categoryName: '开发技术',
|
||||||
userNickname: '科技爱好者',
|
userNickname: '科技爱好者',
|
||||||
userAvatar: av2,
|
userAvatar: av2,
|
||||||
isLiked: false,
|
isLiked: false,
|
||||||
@@ -182,7 +182,7 @@ export const mockVideos = [
|
|||||||
height: 1080,
|
height: 1080,
|
||||||
format: 'mp4',
|
format: 'mp4',
|
||||||
size: 78000000,
|
size: 78000000,
|
||||||
categoryId: 2,
|
categoryId: 21,
|
||||||
status: 1,
|
status: 1,
|
||||||
likeCount: 2345,
|
likeCount: 2345,
|
||||||
coinCount: 156,
|
coinCount: 156,
|
||||||
@@ -198,7 +198,7 @@ export const mockVideos = [
|
|||||||
updateTime: '2024-01-16 12:00:00',
|
updateTime: '2024-01-16 12:00:00',
|
||||||
tagIds: [6, 7],
|
tagIds: [6, 7],
|
||||||
tagNames: ['美食', '烹饪教程'],
|
tagNames: ['美食', '烹饪教程'],
|
||||||
categoryName: '美食',
|
categoryName: '家常菜',
|
||||||
userNickname: '美食博主小美',
|
userNickname: '美食博主小美',
|
||||||
userAvatar: av3,
|
userAvatar: av3,
|
||||||
isLiked: false,
|
isLiked: false,
|
||||||
@@ -217,7 +217,7 @@ export const mockVideos = [
|
|||||||
height: 1080,
|
height: 1080,
|
||||||
format: 'mp4',
|
format: 'mp4',
|
||||||
size: 145000000,
|
size: 145000000,
|
||||||
categoryId: 1,
|
categoryId: 12,
|
||||||
status: 1,
|
status: 1,
|
||||||
likeCount: 567,
|
likeCount: 567,
|
||||||
coinCount: 45,
|
coinCount: 45,
|
||||||
@@ -233,7 +233,7 @@ export const mockVideos = [
|
|||||||
updateTime: '2024-01-13 09:15:00',
|
updateTime: '2024-01-13 09:15:00',
|
||||||
tagIds: [8, 9],
|
tagIds: [8, 9],
|
||||||
tagNames: ['React', '前端开发'],
|
tagNames: ['React', '前端开发'],
|
||||||
categoryName: '科技',
|
categoryName: '软件应用',
|
||||||
userNickname: '视频达人小明',
|
userNickname: '视频达人小明',
|
||||||
userAvatar: av1,
|
userAvatar: av1,
|
||||||
isLiked: false,
|
isLiked: false,
|
||||||
@@ -252,7 +252,7 @@ export const mockVideos = [
|
|||||||
height: 1080,
|
height: 1080,
|
||||||
format: 'mp4',
|
format: 'mp4',
|
||||||
size: 178000000,
|
size: 178000000,
|
||||||
categoryId: 1,
|
categoryId: 12,
|
||||||
status: 1,
|
status: 1,
|
||||||
likeCount: 678,
|
likeCount: 678,
|
||||||
coinCount: 56,
|
coinCount: 56,
|
||||||
@@ -268,7 +268,7 @@ export const mockVideos = [
|
|||||||
updateTime: '2024-01-12 14:30:00',
|
updateTime: '2024-01-12 14:30:00',
|
||||||
tagIds: [10, 11],
|
tagIds: [10, 11],
|
||||||
tagNames: ['Docker', 'DevOps'],
|
tagNames: ['Docker', 'DevOps'],
|
||||||
categoryName: '科技',
|
categoryName: '软件应用',
|
||||||
userNickname: '科技爱好者',
|
userNickname: '科技爱好者',
|
||||||
userAvatar: av2,
|
userAvatar: av2,
|
||||||
isLiked: false,
|
isLiked: false,
|
||||||
@@ -287,7 +287,7 @@ export const mockVideos = [
|
|||||||
height: 1080,
|
height: 1080,
|
||||||
format: 'mp4',
|
format: 'mp4',
|
||||||
size: 156000000,
|
size: 156000000,
|
||||||
categoryId: 2,
|
categoryId: 21,
|
||||||
status: 1,
|
status: 1,
|
||||||
likeCount: 3456,
|
likeCount: 3456,
|
||||||
coinCount: 234,
|
coinCount: 234,
|
||||||
@@ -303,7 +303,7 @@ export const mockVideos = [
|
|||||||
updateTime: '2024-01-17 08:00:00',
|
updateTime: '2024-01-17 08:00:00',
|
||||||
tagIds: [6, 7],
|
tagIds: [6, 7],
|
||||||
tagNames: ['美食', '烹饪教程'],
|
tagNames: ['美食', '烹饪教程'],
|
||||||
categoryName: '美食',
|
categoryName: '家常菜',
|
||||||
userNickname: '美食博主小美',
|
userNickname: '美食博主小美',
|
||||||
userAvatar: av3,
|
userAvatar: av3,
|
||||||
isLiked: false,
|
isLiked: false,
|
||||||
@@ -322,7 +322,7 @@ export const mockVideos = [
|
|||||||
height: 1080,
|
height: 1080,
|
||||||
format: 'mp4',
|
format: 'mp4',
|
||||||
size: 132000000,
|
size: 132000000,
|
||||||
categoryId: 1,
|
categoryId: 13,
|
||||||
status: 1,
|
status: 1,
|
||||||
likeCount: 934,
|
likeCount: 934,
|
||||||
coinCount: 66,
|
coinCount: 66,
|
||||||
@@ -338,7 +338,7 @@ export const mockVideos = [
|
|||||||
updateTime: '2024-01-18 11:20:00',
|
updateTime: '2024-01-18 11:20:00',
|
||||||
tagIds: [1, 3],
|
tagIds: [1, 3],
|
||||||
tagNames: ['Vue3', '性能优化'],
|
tagNames: ['Vue3', '性能优化'],
|
||||||
categoryName: '科技',
|
categoryName: '开发技术',
|
||||||
userNickname: '视频达人小明',
|
userNickname: '视频达人小明',
|
||||||
userAvatar: av1,
|
userAvatar: av1,
|
||||||
isLiked: false,
|
isLiked: false,
|
||||||
@@ -357,7 +357,7 @@ export const mockVideos = [
|
|||||||
height: 1080,
|
height: 1080,
|
||||||
format: 'mp4',
|
format: 'mp4',
|
||||||
size: 166000000,
|
size: 166000000,
|
||||||
categoryId: 1,
|
categoryId: 13,
|
||||||
status: 1,
|
status: 1,
|
||||||
likeCount: 721,
|
likeCount: 721,
|
||||||
coinCount: 49,
|
coinCount: 49,
|
||||||
@@ -373,7 +373,7 @@ export const mockVideos = [
|
|||||||
updateTime: '2024-01-19 14:00:00',
|
updateTime: '2024-01-19 14:00:00',
|
||||||
tagIds: [4, 5],
|
tagIds: [4, 5],
|
||||||
tagNames: ['Spring Cloud', '网关'],
|
tagNames: ['Spring Cloud', '网关'],
|
||||||
categoryName: '科技',
|
categoryName: '开发技术',
|
||||||
userNickname: '科技爱好者',
|
userNickname: '科技爱好者',
|
||||||
userAvatar: av2,
|
userAvatar: av2,
|
||||||
isLiked: false,
|
isLiked: false,
|
||||||
@@ -392,7 +392,7 @@ export const mockVideos = [
|
|||||||
height: 1080,
|
height: 1080,
|
||||||
format: 'mp4',
|
format: 'mp4',
|
||||||
size: 54000000,
|
size: 54000000,
|
||||||
categoryId: 2,
|
categoryId: 21,
|
||||||
status: 1,
|
status: 1,
|
||||||
likeCount: 1658,
|
likeCount: 1658,
|
||||||
coinCount: 97,
|
coinCount: 97,
|
||||||
@@ -408,7 +408,7 @@ export const mockVideos = [
|
|||||||
updateTime: '2024-01-20 18:40:00',
|
updateTime: '2024-01-20 18:40:00',
|
||||||
tagIds: [6],
|
tagIds: [6],
|
||||||
tagNames: ['美食'],
|
tagNames: ['美食'],
|
||||||
categoryName: '美食',
|
categoryName: '家常菜',
|
||||||
userNickname: '美食博主小美',
|
userNickname: '美食博主小美',
|
||||||
userAvatar: av3,
|
userAvatar: av3,
|
||||||
isLiked: false,
|
isLiked: false,
|
||||||
@@ -427,7 +427,7 @@ export const mockVideos = [
|
|||||||
height: 1080,
|
height: 1080,
|
||||||
format: 'mp4',
|
format: 'mp4',
|
||||||
size: 98000000,
|
size: 98000000,
|
||||||
categoryId: 9,
|
categoryId: 91,
|
||||||
status: 1,
|
status: 1,
|
||||||
likeCount: 812,
|
likeCount: 812,
|
||||||
coinCount: 58,
|
coinCount: 58,
|
||||||
@@ -443,7 +443,7 @@ export const mockVideos = [
|
|||||||
updateTime: '2024-01-21 09:50:00',
|
updateTime: '2024-01-21 09:50:00',
|
||||||
tagIds: [3],
|
tagIds: [3],
|
||||||
tagNames: ['论文'],
|
tagNames: ['论文'],
|
||||||
categoryName: '知识',
|
categoryName: '校园学习',
|
||||||
userNickname: '视频达人小明',
|
userNickname: '视频达人小明',
|
||||||
userAvatar: av1,
|
userAvatar: av1,
|
||||||
isLiked: false,
|
isLiked: false,
|
||||||
@@ -455,17 +455,43 @@ export const mockVideos = [
|
|||||||
// 模拟分类数据
|
// 模拟分类数据
|
||||||
export const mockCategories = [
|
export const mockCategories = [
|
||||||
{ id: 1, name: '科技', parentId: 0, sort: 1, icon: 'tech' },
|
{ 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: 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: 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: 4, name: '音乐', parentId: 0, sort: 4, icon: 'music' },
|
||||||
{ id: 5, name: '舞蹈', parentId: 0, sort: 5, icon: 'dance' },
|
{ id: 5, name: '舞蹈', parentId: 0, sort: 5, icon: 'dance' },
|
||||||
{ id: 6, name: '生活', parentId: 0, sort: 6, icon: 'life' },
|
{ 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: 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: 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: 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' }
|
{ 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 = [
|
export const mockComments = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
mockUsers,
|
mockUsers,
|
||||||
mockVideos,
|
mockVideos,
|
||||||
mockCategories,
|
mockCategories,
|
||||||
|
mockCategoryTree,
|
||||||
mockComments,
|
mockComments,
|
||||||
mockFollowing,
|
mockFollowing,
|
||||||
mockFollowers,
|
mockFollowers,
|
||||||
@@ -364,7 +365,7 @@ const mockHandlers: Record<string, (url: string, options: any) => Promise<any>>
|
|||||||
// ==================== 分类相关 ====================
|
// ==================== 分类相关 ====================
|
||||||
'GET /content/category/tree': async () => {
|
'GET /content/category/tree': async () => {
|
||||||
await delay()
|
await delay()
|
||||||
return mockCategories
|
return mockCategoryTree
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /content/category/list': async () => {
|
'GET /content/category/list': async () => {
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/user/my-videos.vue'),
|
component: () => import('@/views/user/my-videos.vue'),
|
||||||
meta: { title: '我的视频', requiresAuth: true }
|
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',
|
path: 'my/favorites',
|
||||||
name: 'MyFavorites',
|
name: 'MyFavorites',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { Favorite } from '@/types'
|
import type { Favorite } from '@/types'
|
||||||
import { favoriteApi } from '@/api/favorite.ts'
|
import { favoriteApi } from '@/api/favorite.ts'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
export const useFavoriteStore = defineStore('favorite', () => {
|
export const useFavoriteStore = defineStore('favorite', () => {
|
||||||
// 收藏夹列表
|
// 收藏夹列表
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { authApi } from '@/api/auth.ts'
|
|||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import type { LoginParams, RegisterParams, UserInfo, AuthUser, CaptchaResult } from '@/types'
|
import type { LoginParams, RegisterParams, UserInfo, AuthUser, CaptchaResult } from '@/types'
|
||||||
import { getToken, setToken, removeToken, getRefreshToken, setRefreshToken, removeRefreshToken } from '@/utils/storage.ts'
|
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', () => {
|
export const useUserStore = defineStore('user', () => {
|
||||||
// 状态
|
// 状态
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { VideoInfo, PlayRecord } from '@/types'
|
import type { VideoInfo, PlayRecord } from '@/types'
|
||||||
import { videoApi } from '@/api/video.ts'
|
import { videoApi } from '@/api/video.ts'
|
||||||
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
|
|
||||||
export const useVideoStore = defineStore('video', () => {
|
export const useVideoStore = defineStore('video', () => {
|
||||||
// 当前播放的视频
|
// 当前播放的视频
|
||||||
@@ -57,13 +58,9 @@ export const useVideoStore = defineStore('video', () => {
|
|||||||
|
|
||||||
// 记录播放
|
// 记录播放
|
||||||
async function recordPlay(videoId: number, progress: number, duration: number) {
|
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 playTime = Math.max(0, Math.min(Math.round(progress), Math.round(duration)))
|
||||||
const isFinished = duration > 0 && playTime >= duration * 0.9 ? 1 : 0
|
const isFinished = duration > 0 && playTime >= duration * 0.9 ? 1 : 0
|
||||||
|
|
||||||
try {
|
|
||||||
await videoApi.recordPlay({ videoId, playTime, isFinished })
|
|
||||||
|
|
||||||
// 更新本地播放历史
|
|
||||||
const index = playHistory.value.findIndex(item => item.videoId === videoId)
|
const index = playHistory.value.findIndex(item => item.videoId === videoId)
|
||||||
const record: PlayRecord = { videoId, playTime, isFinished }
|
const record: PlayRecord = { videoId, playTime, isFinished }
|
||||||
|
|
||||||
@@ -71,11 +68,17 @@ export const useVideoStore = defineStore('video', () => {
|
|||||||
playHistory.value[index] = record
|
playHistory.value[index] = record
|
||||||
} else {
|
} else {
|
||||||
playHistory.value.unshift(record)
|
playHistory.value.unshift(record)
|
||||||
// 最多保存100条记录
|
|
||||||
if (playHistory.value.length > 100) {
|
if (playHistory.value.length > 100) {
|
||||||
playHistory.value = playHistory.value.slice(0, 100)
|
playHistory.value = playHistory.value.slice(0, 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!userStore.isLoggedIn) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await videoApi.recordPlay({ videoId, playTime, isFinished })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('记录播放失败:', error)
|
console.error('记录播放失败:', error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ select {
|
|||||||
--van-dialog-cancel-button-text-color: #61666d;
|
--van-dialog-cancel-button-text-color: #61666d;
|
||||||
--van-toast-radius: 18px;
|
--van-toast-radius: 18px;
|
||||||
--van-toast-default-width: auto;
|
--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-toast-text-color: #fff;
|
||||||
--van-notify-radius: 0 0 18px 18px;
|
--van-notify-radius: 0 0 18px 18px;
|
||||||
}
|
}
|
||||||
@@ -279,10 +279,88 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.van-toast {
|
.van-toast {
|
||||||
padding: 12px 16px;
|
min-width: 144px;
|
||||||
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.18);
|
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;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
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 {
|
.van-notify {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface UserInfo {
|
|||||||
followingCount: number
|
followingCount: number
|
||||||
videoCount: number
|
videoCount: number
|
||||||
likeCount: number
|
likeCount: number
|
||||||
|
coinCount: number
|
||||||
level: number
|
level: number
|
||||||
vipLevel: number
|
vipLevel: number
|
||||||
vipExpireTime: string | null
|
vipExpireTime: string | null
|
||||||
@@ -125,6 +126,8 @@ export interface VideoInfo {
|
|||||||
isLiked?: boolean
|
isLiked?: boolean
|
||||||
isFavorited?: boolean
|
isFavorited?: boolean
|
||||||
isCoined?: boolean
|
isCoined?: boolean
|
||||||
|
playProgress?: number
|
||||||
|
isPlayFinished?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频上传参数
|
// 视频上传参数
|
||||||
@@ -348,6 +351,16 @@ export interface PlayRecord {
|
|||||||
isFinished?: number
|
isFinished?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlayHistoryItem {
|
||||||
|
videoId: number
|
||||||
|
title: string
|
||||||
|
coverUrl: string
|
||||||
|
duration: number
|
||||||
|
playTime: number
|
||||||
|
isFinished: number
|
||||||
|
updateTime: string
|
||||||
|
}
|
||||||
|
|
||||||
// 推荐视频
|
// 推荐视频
|
||||||
export interface RecommendVideo {
|
export interface RecommendVideo {
|
||||||
videoId: number
|
videoId: number
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||||
import { useUserStore } from '@/stores/user.ts'
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from '@/utils/toast'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { mockRequest, isMockEnabled } from '@/mock'
|
import { mockRequest, isMockEnabled } from '@/mock'
|
||||||
|
|
||||||
@@ -9,6 +9,25 @@ const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
|
|||||||
const TIMEOUT = 30000
|
const TIMEOUT = 30000
|
||||||
let handlingUnauthorized = false
|
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 实例
|
// 创建 axios 实例
|
||||||
const service: AxiosInstance = axios.create({
|
const service: AxiosInstance = axios.create({
|
||||||
baseURL: BASE_URL,
|
baseURL: BASE_URL,
|
||||||
@@ -49,15 +68,7 @@ service.interceptors.response.use(
|
|||||||
|
|
||||||
// Token 过期
|
// Token 过期
|
||||||
if (data.code === 401) {
|
if (data.code === 401) {
|
||||||
const userStore = useUserStore()
|
handleUnauthorized()
|
||||||
if (!handlingUnauthorized) {
|
|
||||||
handlingUnauthorized = true
|
|
||||||
userStore.clearAuth()
|
|
||||||
if (router.currentRoute.value.path !== '/login') {
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
showToast('登录已过期,请重新登录')
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error('Token expired'))
|
return Promise.reject(new Error('Token expired'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,15 +88,7 @@ service.interceptors.response.use(
|
|||||||
const status = error.response.status
|
const status = error.response.status
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 401: {
|
case 401: {
|
||||||
const userStore = useUserStore()
|
handleUnauthorized()
|
||||||
if (!handlingUnauthorized) {
|
|
||||||
handlingUnauthorized = true
|
|
||||||
userStore.clearAuth()
|
|
||||||
if (router.currentRoute.value.path !== '/login') {
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
showToast('登录已过期,请重新登录')
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 403:
|
case 403:
|
||||||
@@ -94,6 +97,9 @@ service.interceptors.response.use(
|
|||||||
case 404:
|
case 404:
|
||||||
showToast('请求的资源不存在')
|
showToast('请求的资源不存在')
|
||||||
break
|
break
|
||||||
|
case 413:
|
||||||
|
showToast('上传文件过大,请选择更小的文件')
|
||||||
|
break
|
||||||
case 500:
|
case 500:
|
||||||
showToast('服务器错误,请稍后重试')
|
showToast('服务器错误,请稍后重试')
|
||||||
break
|
break
|
||||||
@@ -120,15 +126,7 @@ async function mockFetch<T>(method: string, url: string, data?: any, params?: an
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.code === 401) {
|
if (result.code === 401) {
|
||||||
const userStore = useUserStore()
|
handleUnauthorized()
|
||||||
if (!handlingUnauthorized) {
|
|
||||||
handlingUnauthorized = true
|
|
||||||
userStore.clearAuth()
|
|
||||||
if (router.currentRoute.value.path !== '/login') {
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
showToast('登录已过期,请重新登录')
|
|
||||||
}
|
|
||||||
throw new Error('Token expired')
|
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>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ favoriteTitle }}</strong>
|
<strong>{{ favoriteTitle }}</strong>
|
||||||
<span>{{ total }} 个视频</span>
|
<span>{{ displayTotal }} 个视频</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
@@ -101,10 +101,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 VideoCard from '@/components/video/VideoCard.vue'
|
||||||
import { favoriteApi, type Favorite } from '@/api/favorite.ts'
|
import { favoriteApi, type Favorite } from '@/api/favorite.ts'
|
||||||
import { videoApi } from '@/api/video.ts'
|
import { videoApi } from '@/api/video.ts'
|
||||||
|
import { userApi } from '@/api/user.ts'
|
||||||
import type { VideoInfo } from '@/types'
|
import type { VideoInfo } from '@/types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -113,6 +115,7 @@ const favoriteId = computed(() => Number(route.params.id))
|
|||||||
const currentFavorite = ref<Favorite | null>(null)
|
const currentFavorite = ref<Favorite | null>(null)
|
||||||
const favoriteTitle = computed(() => currentFavorite.value?.name || '收藏夹')
|
const favoriteTitle = computed(() => currentFavorite.value?.name || '收藏夹')
|
||||||
const videos = ref<VideoInfo[]>([])
|
const videos = ref<VideoInfo[]>([])
|
||||||
|
const displayTotal = computed(() => Math.max(total.value, videos.value.length))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const finished = ref(false)
|
const finished = ref(false)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
@@ -126,6 +129,43 @@ const editForm = ref({
|
|||||||
isPrivate: 0
|
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 () => {
|
const loadFavoriteMeta = async () => {
|
||||||
try {
|
try {
|
||||||
const favorites = await favoriteApi.getFavoriteList()
|
const favorites = await favoriteApi.getFavoriteList()
|
||||||
@@ -151,7 +191,8 @@ const loadMore = async () => {
|
|||||||
pageNum: page.value,
|
pageNum: page.value,
|
||||||
pageSize: 12
|
pageSize: 12
|
||||||
})
|
})
|
||||||
videos.value.push(...res.records)
|
const hydratedRecords = await hydrateVideoAuthors(res.records)
|
||||||
|
videos.value.push(...hydratedRecords)
|
||||||
total.value = res.total
|
total.value = res.total
|
||||||
if (videos.value.length >= res.total || res.records.length < 12) finished.value = true
|
if (videos.value.length >= res.total || res.records.length < 12) finished.value = true
|
||||||
page.value += 1
|
page.value += 1
|
||||||
|
|||||||
@@ -114,7 +114,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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'
|
import { favoriteApi, type Favorite } from '@/api/favorite.ts'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
<div class="page-shell category-shell">
|
<div class="page-shell category-shell">
|
||||||
<div class="category-header">
|
<div class="category-header">
|
||||||
<div class="header-copy">
|
<div class="header-copy">
|
||||||
<h1>{{ currentCategoryName || '分类' }}</h1>
|
<h1>{{ currentCategoryName || activeParent?.name || '分类' }}</h1>
|
||||||
|
<span>{{ activeParent?.name || '按分区查看内容列表' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="back-button"
|
class="back-button"
|
||||||
@@ -15,13 +16,35 @@
|
|||||||
|
|
||||||
<div class="category-switcher">
|
<div class="category-switcher">
|
||||||
<button
|
<button
|
||||||
v-for="cat in categories"
|
v-for="parent in categoryTree"
|
||||||
:key="cat.id"
|
:key="parent.id"
|
||||||
class="switcher-item"
|
class="switcher-item"
|
||||||
:class="{ active: activeCategoryId === cat.id }"
|
:class="{ active: activeParentId === parent.id }"
|
||||||
@click="switchCategory(cat.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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,17 +77,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from '@/utils/toast'
|
||||||
import VideoCard from '@/components/video/VideoCard.vue'
|
import VideoCard from '@/components/video/VideoCard.vue'
|
||||||
import AppTabbar from '@/components/layout/AppTabbar.vue'
|
import AppTabbar from '@/components/layout/AppTabbar.vue'
|
||||||
import { categoryApi, type Category } from '@/api/category.ts'
|
import { categoryApi, type Category } from '@/api/category.ts'
|
||||||
import { videoApi } from '@/api/video.ts'
|
import { videoApi } from '@/api/video.ts'
|
||||||
import type { VideoInfo } from '@/types'
|
import type { VideoInfo } from '@/types'
|
||||||
|
|
||||||
|
interface CategoryNode extends Category {
|
||||||
|
children: CategoryNode[]
|
||||||
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const categories = ref<Category[]>([])
|
const categoryTree = ref<CategoryNode[]>([])
|
||||||
const activeCategoryId = ref<number | null>(null)
|
const allVideos = ref<VideoInfo[]>([])
|
||||||
|
const selectedCategoryId = ref<number | null>(null)
|
||||||
const videos = ref<VideoInfo[]>([])
|
const videos = ref<VideoInfo[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const finished = ref(false)
|
const finished = ref(false)
|
||||||
@@ -75,25 +103,131 @@ const routeCategoryId = computed(() => {
|
|||||||
return Number.isFinite(value) && value > 0 ? value : null
|
return Number.isFinite(value) && value > 0 ? value : null
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentCategory = computed(() => categories.value.find((item) => item.id === activeCategoryId.value) || null)
|
const normalizeCategoryTree = (raw: Category[]) => {
|
||||||
const currentCategoryName = computed(() => currentCategory.value?.name || '')
|
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 = () => {
|
const syncCategoryFromRoute = () => {
|
||||||
if (!categories.value.length) return
|
if (!categoryTree.value.length) return
|
||||||
|
|
||||||
if (routeCategoryId.value && categories.value.some((item) => item.id === routeCategoryId.value)) {
|
if (routeCategoryId.value && categoryLookup.value.has(routeCategoryId.value)) {
|
||||||
activeCategoryId.value = routeCategoryId.value
|
selectedCategoryId.value = routeCategoryId.value
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
activeCategoryId.value = categories.value[0]?.id ?? null
|
selectedCategoryId.value = categoryTree.value[0]?.id ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadCategories = async () => {
|
const loadCategories = async () => {
|
||||||
try {
|
try {
|
||||||
categories.value = await categoryApi.getCategoryList()
|
const data = await categoryApi.getCategoryTree().catch(() => categoryApi.getCategoryList())
|
||||||
|
categoryTree.value = normalizeCategoryTree(data)
|
||||||
syncCategoryFromRoute()
|
syncCategoryFromRoute()
|
||||||
if (activeCategoryId.value) {
|
if (selectedCategoryId.value) {
|
||||||
await loadVideos(true)
|
await loadVideos(true)
|
||||||
} else {
|
} else {
|
||||||
finished.value = true
|
finished.value = true
|
||||||
@@ -104,7 +238,7 @@ const loadCategories = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadVideos = async (isRefresh = false) => {
|
const loadVideos = async (isRefresh = false) => {
|
||||||
if (!activeCategoryId.value) return
|
if (!selectedCategoryId.value) return
|
||||||
|
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
@@ -114,15 +248,20 @@ const loadVideos = async (isRefresh = false) => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
if (isRefresh || !allVideos.value.length) {
|
||||||
const res = await videoApi.getVideoPublicList({
|
const res = await videoApi.getVideoPublicList({
|
||||||
pageNum: currentPage.value,
|
pageNum: 1,
|
||||||
pageSize: 20,
|
pageSize: 120
|
||||||
categoryId: activeCategoryId.value
|
|
||||||
})
|
})
|
||||||
|
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
|
finished.value = true
|
||||||
}
|
}
|
||||||
currentPage.value += 1
|
currentPage.value += 1
|
||||||
@@ -137,8 +276,13 @@ const loadMore = () => {
|
|||||||
if (!finished.value) loadVideos()
|
if (!finished.value) loadVideos()
|
||||||
}
|
}
|
||||||
|
|
||||||
const switchCategory = (categoryId: number) => {
|
const switchParent = (parentId: number) => {
|
||||||
if (categoryId === activeCategoryId.value) return
|
router.push(`/category/${parentId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchCategory = (categoryId: number | null) => {
|
||||||
|
if (!categoryId) return
|
||||||
|
if (categoryId === selectedCategoryId.value) return
|
||||||
router.push(`/category/${categoryId}`)
|
router.push(`/category/${categoryId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +291,7 @@ const goToVideo = (id: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(routeCategoryId, async () => {
|
watch(routeCategoryId, async () => {
|
||||||
if (!categories.value.length) return
|
if (!categoryTree.value.length) return
|
||||||
syncCategoryFromRoute()
|
syncCategoryFromRoute()
|
||||||
await loadVideos(true)
|
await loadVideos(true)
|
||||||
})
|
})
|
||||||
@@ -174,12 +318,22 @@ onMounted(loadCategories)
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-copy h1 {
|
.header-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
color: #18191c;
|
color: #18191c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #9499a0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.back-button {
|
.back-button {
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -188,23 +342,42 @@ onMounted(loadCategories)
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-switcher {
|
.category-switcher,
|
||||||
|
.category-sub-switcher {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-switcher {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-sub-switcher {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.switcher-item {
|
.switcher-item,
|
||||||
|
.sub-switcher-item {
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #61666d;
|
color: #61666d;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: background-color 0.18s ease, color 0.18s ease;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #ffecf1;
|
background: linear-gradient(135deg, #5c7cfa, #fb7299);
|
||||||
color: #ff6699;
|
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"
|
class="login-form"
|
||||||
@submit="handleLogin"
|
@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-cell-group inset>
|
||||||
<van-field
|
<van-field
|
||||||
v-model="form.username"
|
v-model="form.username"
|
||||||
@@ -140,7 +118,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 { authApi, type CaptchaResult } from '@/api/auth.ts'
|
||||||
import { useUserStore } from '@/stores/user.ts'
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
|
|
||||||
@@ -199,8 +177,6 @@ const handleLogin = async () => {
|
|||||||
const redirect = route.query.redirect as string
|
const redirect = route.query.redirect as string
|
||||||
router.replace(redirect || '/')
|
router.replace(redirect || '/')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error?.response?.data?.message || error?.message || '登录失败'
|
|
||||||
showToast(message)
|
|
||||||
refreshCaptcha()
|
refreshCaptcha()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -336,59 +312,16 @@ onMounted(() => {
|
|||||||
line-height: 1.7;
|
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 {
|
.captcha-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.captcha-image {
|
|
||||||
width: 120px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.captcha-btn {
|
.captcha-btn {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 32px;
|
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 {
|
.login-form {
|
||||||
:deep(.van-cell-group--inset) {
|
:deep(.van-cell-group--inset) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -451,11 +384,6 @@ onMounted(() => {
|
|||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.captcha-banner {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,19 +47,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<van-form @submit="handleRegister">
|
<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-cell-group inset>
|
||||||
<van-field
|
<van-field
|
||||||
v-model="form.username"
|
v-model="form.username"
|
||||||
@@ -149,7 +136,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showSuccessToast, showToast } from 'vant'
|
import { showSuccessToast, showToast } from '@/utils/toast'
|
||||||
import { authApi, type CaptchaResult } from '@/api/auth.ts'
|
import { authApi, type CaptchaResult } from '@/api/auth.ts'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -198,7 +185,6 @@ const handleRegister = async () => {
|
|||||||
showSuccessToast('注册成功')
|
showSuccessToast('注册成功')
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
showToast(error.message || '注册失败')
|
|
||||||
refreshCaptcha()
|
refreshCaptcha()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -333,41 +319,11 @@ onMounted(() => {
|
|||||||
line-height: 1.7;
|
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 {
|
.captcha-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.captcha-image {
|
|
||||||
width: 120px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.captcha-btn {
|
.captcha-btn {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -433,11 +389,6 @@ onMounted(() => {
|
|||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.captcha-banner {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, nextTick } from 'vue'
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from '@/utils/toast'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import { useUserStore } from '@/stores/user.ts'
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
import { formatRelativeTime } from '@/utils'
|
import { formatRelativeTime } from '@/utils'
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from '@/utils/toast'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import { useUserStore } from '@/stores/user.ts'
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
import { formatRelativeTime } from '@/utils'
|
import { formatRelativeTime } from '@/utils'
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { showSuccessToast, showToast } from 'vant'
|
import { showSuccessToast, showToast } from '@/utils/toast'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import { formatRelativeTime } from '@/utils'
|
import { formatRelativeTime } from '@/utils'
|
||||||
import type { Notification } from '@/types'
|
import type { Notification } from '@/types'
|
||||||
|
|||||||
@@ -167,7 +167,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from '@/utils/toast'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import { videoApi } from '@/api/video.ts'
|
import { videoApi } from '@/api/video.ts'
|
||||||
import type { UserInfo, VideoInfo } from '@/types'
|
import type { UserInfo, VideoInfo } from '@/types'
|
||||||
|
|||||||
@@ -132,7 +132,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast, showSuccessToast } from 'vant'
|
import { showToast, showSuccessToast } from '@/utils/toast'
|
||||||
import { videoApi } from '@/api/video.ts'
|
import { videoApi } from '@/api/video.ts'
|
||||||
import { categoryApi, type Category } from '@/api/category.ts'
|
import { categoryApi, type Category } from '@/api/category.ts'
|
||||||
import { resolveMediaUrl } from '@/utils/media'
|
import { resolveMediaUrl } from '@/utils/media'
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { showToast, showSuccessToast } from 'vant'
|
import { showToast, showSuccessToast } from '@/utils/toast'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import { useUserStore } from '@/stores/user.ts'
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
import { resolveAvatarUrl } from '@/utils/media'
|
import { resolveAvatarUrl } from '@/utils/media'
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { showToast, showSuccessToast } from 'vant'
|
import { showToast, showSuccessToast } from '@/utils/toast'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import { resolveAvatarUrl } from '@/utils/media'
|
import { resolveAvatarUrl } from '@/utils/media'
|
||||||
import type { UserFollow } from '@/types'
|
import type { UserFollow } from '@/types'
|
||||||
|
|||||||
@@ -20,18 +20,33 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="stats-grid">
|
<section class="stats-grid">
|
||||||
<div class="stat-card">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stat-card"
|
||||||
|
:class="{ active: selectedFilter === 'all' }"
|
||||||
|
@click="toggleFilter('all')"
|
||||||
|
>
|
||||||
<span>历史记录</span>
|
<span>历史记录</span>
|
||||||
<strong>{{ videos.length }}</strong>
|
<strong>{{ videos.length }}</strong>
|
||||||
</div>
|
</button>
|
||||||
<div class="stat-card">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stat-card"
|
||||||
|
:class="{ active: selectedFilter === 'finished' }"
|
||||||
|
@click="toggleFilter('finished')"
|
||||||
|
>
|
||||||
<span>观看完成</span>
|
<span>观看完成</span>
|
||||||
<strong>{{ finishedCount }}</strong>
|
<strong>{{ finishedCount }}</strong>
|
||||||
</div>
|
</button>
|
||||||
<div class="stat-card">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stat-card"
|
||||||
|
:class="{ active: selectedFilter === 'continue' }"
|
||||||
|
@click="toggleFilter('continue')"
|
||||||
|
>
|
||||||
<span>继续观看</span>
|
<span>继续观看</span>
|
||||||
<strong>{{ continueCount }}</strong>
|
<strong>{{ continueCount }}</strong>
|
||||||
</div>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -39,13 +54,13 @@
|
|||||||
class="history-panel"
|
class="history-panel"
|
||||||
>
|
>
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<strong>最近观看</strong>
|
<strong>{{ currentFilterLabel }}</strong>
|
||||||
<span>{{ videos.length }} 条记录</span>
|
<span>{{ filteredVideos.length }} 条记录</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="video-list">
|
<div class="video-list">
|
||||||
<article
|
<article
|
||||||
v-for="item in videos"
|
v-for="item in filteredVideos"
|
||||||
:key="item.videoId"
|
:key="item.videoId"
|
||||||
class="video-item"
|
class="video-item"
|
||||||
@click="goToVideo(item.videoId)"
|
@click="goToVideo(item.videoId)"
|
||||||
@@ -92,13 +107,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showConfirmDialog, showToast } from 'vant'
|
import { showConfirmDialog } from 'vant'
|
||||||
|
import { showToast } from '@/utils/toast'
|
||||||
import { formatDuration } from '@/utils'
|
import { formatDuration } from '@/utils'
|
||||||
import { resolveMediaUrl } from '@/utils/media'
|
import { resolveMediaUrl } from '@/utils/media'
|
||||||
import { videoApi } from '@/api/video.ts'
|
import { videoApi } from '@/api/video.ts'
|
||||||
import { useVideoStore } from '@/stores/video.ts'
|
import { useVideoStore } from '@/stores/video.ts'
|
||||||
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
|
import type { PlayHistoryItem, PlayRecord } from '@/types'
|
||||||
|
|
||||||
interface HistoryItem {
|
interface HistoryItem {
|
||||||
videoId: number
|
videoId: number
|
||||||
@@ -106,21 +124,88 @@ interface HistoryItem {
|
|||||||
coverUrl: string
|
coverUrl: string
|
||||||
progress: number
|
progress: number
|
||||||
duration: number
|
duration: number
|
||||||
|
updateTime?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const videoStore = useVideoStore()
|
const videoStore = useVideoStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
const videos = ref<HistoryItem[]>([])
|
const videos = ref<HistoryItem[]>([])
|
||||||
const historyRecords = computed(() => [...videoStore.playHistory].reverse())
|
const selectedFilter = ref<'all' | 'finished' | 'continue'>('all')
|
||||||
|
const historyRecords = computed(() => [...videoStore.playHistory])
|
||||||
const defaultCover =
|
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'
|
'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 finishedCount = computed(() => videos.value.filter((item) => item.duration > 0 && item.progress >= item.duration).length)
|
||||||
const continueCount = computed(() => videos.value.length - finishedCount.value)
|
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 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 () => {
|
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 {
|
try {
|
||||||
const details = await Promise.all(
|
const details = await Promise.all(
|
||||||
historyRecords.value.slice(0, 30).map(async (record) => {
|
historyRecords.value.slice(0, 30).map(async (record) => {
|
||||||
@@ -150,6 +235,9 @@ const clearHistory = async () => {
|
|||||||
title: '提示',
|
title: '提示',
|
||||||
message: '确定要清空播放历史吗?'
|
message: '确定要清空播放历史吗?'
|
||||||
})
|
})
|
||||||
|
if (userStore.isLoggedIn) {
|
||||||
|
await videoApi.clearPlayHistory()
|
||||||
|
}
|
||||||
videoStore.playHistory = []
|
videoStore.playHistory = []
|
||||||
videos.value = []
|
videos.value = []
|
||||||
showToast('已清空')
|
showToast('已清空')
|
||||||
@@ -162,7 +250,15 @@ const goToVideo = (id: number) => {
|
|||||||
router.push(`/video/${id}`)
|
router.push(`/video/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadVideos)
|
watch(historyRecords, () => {
|
||||||
|
if (!userStore.isLoggedIn) {
|
||||||
|
loadVideos()
|
||||||
|
}
|
||||||
|
}, { immediate: true, deep: true })
|
||||||
|
|
||||||
|
watch(() => userStore.isLoggedIn, () => {
|
||||||
|
loadVideos()
|
||||||
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -225,9 +321,15 @@ onMounted(loadVideos)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
color: #9499a0;
|
color: #9499a0;
|
||||||
@@ -239,6 +341,21 @@ onMounted(loadVideos)
|
|||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
line-height: 1;
|
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 {
|
.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>
|
||||||
|
|
||||||
<section class="stats-grid">
|
<section class="stats-grid">
|
||||||
<div class="stat-card">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stat-card"
|
||||||
|
:class="{ active: selectedFilter === 'all' }"
|
||||||
|
@click="toggleFilter('all')"
|
||||||
|
>
|
||||||
<span>全部视频</span>
|
<span>全部视频</span>
|
||||||
<strong>{{ videos.length }}</strong>
|
<strong>{{ videos.length }}</strong>
|
||||||
</div>
|
</button>
|
||||||
<div class="stat-card">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stat-card"
|
||||||
|
:class="{ active: selectedFilter === 1 }"
|
||||||
|
@click="toggleFilter(1)"
|
||||||
|
>
|
||||||
<span>已发布</span>
|
<span>已发布</span>
|
||||||
<strong>{{ publishedCount }}</strong>
|
<strong>{{ publishedCount }}</strong>
|
||||||
</div>
|
</button>
|
||||||
<div class="stat-card">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stat-card"
|
||||||
|
:class="{ active: selectedFilter === 0 }"
|
||||||
|
@click="toggleFilter(0)"
|
||||||
|
>
|
||||||
<span>审核中</span>
|
<span>审核中</span>
|
||||||
<strong>{{ reviewingCount }}</strong>
|
<strong>{{ reviewingCount }}</strong>
|
||||||
</div>
|
</button>
|
||||||
<div class="stat-card">
|
<button
|
||||||
<span>异常状态</span>
|
type="button"
|
||||||
<strong>{{ exceptionalCount }}</strong>
|
class="stat-card"
|
||||||
</div>
|
: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>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -43,13 +72,16 @@
|
|||||||
class="videos-panel"
|
class="videos-panel"
|
||||||
>
|
>
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<strong>内容列表</strong>
|
<strong>{{ currentFilterLabel }}</strong>
|
||||||
<span>{{ videos.length }} 条记录</span>
|
<span>{{ filteredVideos.length }} 条记录</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="video-list">
|
<div
|
||||||
|
v-if="filteredVideos.length"
|
||||||
|
class="video-list"
|
||||||
|
>
|
||||||
<article
|
<article
|
||||||
v-for="video in videos"
|
v-for="video in filteredVideos"
|
||||||
:key="video.id"
|
:key="video.id"
|
||||||
class="video-item"
|
class="video-item"
|
||||||
@click="goToVideo(video.id)"
|
@click="goToVideo(video.id)"
|
||||||
@@ -84,6 +116,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<van-empty
|
||||||
|
v-else
|
||||||
|
description="当前状态下暂无视频"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<van-empty
|
<van-empty
|
||||||
@@ -105,7 +142,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from '@/utils/toast'
|
||||||
import { videoApi } from '@/api/video.ts'
|
import { videoApi } from '@/api/video.ts'
|
||||||
import { useUserStore } from '@/stores/user.ts'
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
|
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
|
||||||
@@ -117,16 +154,67 @@ const userStore = useUserStore()
|
|||||||
|
|
||||||
const videos = ref<VideoInfo[]>([])
|
const videos = ref<VideoInfo[]>([])
|
||||||
const resolvedUserId = ref<number | null>(null)
|
const resolvedUserId = ref<number | null>(null)
|
||||||
|
const selectedFilter = ref<'all' | number>('all')
|
||||||
|
|
||||||
const defaultCover =
|
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'
|
'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 publishedCount = computed(() => videos.value.filter((video) => video.status === 1).length)
|
||||||
const reviewingCount = computed(() => videos.value.filter((video) => video.status === 0).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 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 getStatusType = (status: number): 'warning' | 'success' | 'danger' | 'primary' => {
|
||||||
const types: Record<number, 'warning' | 'success' | 'danger' | 'primary'> = {
|
const types: Record<number, 'warning' | 'success' | 'danger' | 'primary'> = {
|
||||||
0: 'warning',
|
0: 'warning',
|
||||||
@@ -171,7 +259,7 @@ const loadVideos = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goToVideo = (id: number) => {
|
const goToVideo = (id: number) => {
|
||||||
router.push(`/video/${id}`)
|
router.push(`/my/videos/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const initPage = async () => {
|
const initPage = async () => {
|
||||||
@@ -258,14 +346,20 @@ onMounted(initPage)
|
|||||||
.stats-grid {
|
.stats-grid {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
color: #9499a0;
|
color: #9499a0;
|
||||||
@@ -277,6 +371,21 @@ onMounted(initPage)
|
|||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
line-height: 1;
|
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 {
|
.videos-panel {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showSuccessToast, showToast } from 'vant'
|
import { showSuccessToast, showToast } from '@/utils/toast'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showSuccessToast, showToast } from 'vant'
|
import { showSuccessToast, showToast } from '@/utils/toast'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -62,13 +62,13 @@
|
|||||||
v-model="form.gender"
|
v-model="form.gender"
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
>
|
>
|
||||||
<van-radio :value="0">
|
<van-radio :name="0">
|
||||||
保密
|
保密
|
||||||
</van-radio>
|
</van-radio>
|
||||||
<van-radio :value="1">
|
<van-radio :name="1">
|
||||||
男
|
男
|
||||||
</van-radio>
|
</van-radio>
|
||||||
<van-radio :value="2">
|
<van-radio :name="2">
|
||||||
女
|
女
|
||||||
</van-radio>
|
</van-radio>
|
||||||
</van-radio-group>
|
</van-radio-group>
|
||||||
@@ -120,11 +120,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { showToast, showSuccessToast } from 'vant'
|
import { showToast, showSuccessToast } from '@/utils/toast'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
import { resolveAvatarUrl } from '@/utils/media'
|
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 router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const showBirthdayPicker = ref(false)
|
const showBirthdayPicker = ref(false)
|
||||||
const avatarInput = ref<HTMLInputElement>()
|
const avatarInput = ref<HTMLInputElement>()
|
||||||
@@ -148,7 +153,7 @@ const loadProfile = async () => {
|
|||||||
form.avatar = profile.avatar || ''
|
form.avatar = profile.avatar || ''
|
||||||
form.nickname = profile.nickname || ''
|
form.nickname = profile.nickname || ''
|
||||||
form.signature = profile.signature || ''
|
form.signature = profile.signature || ''
|
||||||
form.gender = profile.gender || 0
|
form.gender = Number(profile.gender ?? 0)
|
||||||
form.birthday = profile.birthday || ''
|
form.birthday = profile.birthday || ''
|
||||||
form.region = profile.region || ''
|
form.region = profile.region || ''
|
||||||
if (form.birthday) selectedDate.value = form.birthday.split('-')
|
if (form.birthday) selectedDate.value = form.birthday.split('-')
|
||||||
@@ -163,12 +168,24 @@ const onAvatarChange = async (e: Event) => {
|
|||||||
const target = e.target as HTMLInputElement
|
const target = e.target as HTMLInputElement
|
||||||
const file = target.files?.[0]
|
const file = target.files?.[0]
|
||||||
if (!file) return
|
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 {
|
try {
|
||||||
const url = await userApi.uploadAvatar(file)
|
const url = await userStore.uploadAvatar(file)
|
||||||
form.avatar = url
|
form.avatar = url
|
||||||
showSuccessToast('头像上传成功')
|
showSuccessToast('头像上传成功')
|
||||||
} catch {
|
} catch (error) {
|
||||||
showToast('头像上传失败')
|
console.error('头像上传失败:', error)
|
||||||
|
} finally {
|
||||||
|
target.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats">
|
<div
|
||||||
|
class="stats"
|
||||||
|
:class="{ 'stats-self': isSelf }"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="stat-item"
|
class="stat-item"
|
||||||
@click="goToUserVideos"
|
@click="goToUserVideos"
|
||||||
@@ -117,6 +120,17 @@
|
|||||||
获赞
|
获赞
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="isSelf"
|
||||||
|
class="stat-item coin-balance"
|
||||||
|
>
|
||||||
|
<div class="value">
|
||||||
|
{{ userInfo.coinCount }}
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
硬币
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -191,6 +205,13 @@
|
|||||||
<span>账号状态</span>
|
<span>账号状态</span>
|
||||||
<strong>{{ vipLabel || '普通用户' }}</strong>
|
<strong>{{ vipLabel || '普通用户' }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="isSelf"
|
||||||
|
class="overview-item"
|
||||||
|
>
|
||||||
|
<span>硬币余额</span>
|
||||||
|
<strong>{{ userInfo.coinCount }}</strong>
|
||||||
|
</div>
|
||||||
<div class="overview-item">
|
<div class="overview-item">
|
||||||
<span>生日</span>
|
<span>生日</span>
|
||||||
<strong>{{ birthdayLabel }}</strong>
|
<strong>{{ birthdayLabel }}</strong>
|
||||||
@@ -310,7 +331,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { showToast, showSuccessToast } from 'vant'
|
import { showToast, showSuccessToast } from '@/utils/toast'
|
||||||
import VideoCard from '@/components/video/VideoCard.vue'
|
import VideoCard from '@/components/video/VideoCard.vue'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import { videoApi } from '@/api/video.ts'
|
import { videoApi } from '@/api/video.ts'
|
||||||
@@ -634,6 +655,10 @@ watch(activeTab, (tab) => {
|
|||||||
border-bottom: 1px solid #f1f2f3;
|
border-bottom: 1px solid #f1f2f3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-self {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.stat-item {
|
.stat-item {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
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 {
|
.value {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -866,6 +908,10 @@ watch(activeTab, (tab) => {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-self {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.profile-overview {
|
.profile-overview {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 { useUserStore } from '@/stores/user.ts'
|
||||||
import { useMessageStore } from '@/stores/message'
|
import { useMessageStore } from '@/stores/message'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from '@/utils/toast'
|
||||||
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
|
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
|
||||||
import { resolveMediaUrl } from '@/utils/media'
|
import { resolveMediaUrl } from '@/utils/media'
|
||||||
import { videoApi } from '@/api/video.ts'
|
import { videoApi } from '@/api/video.ts'
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
:src="playerVideoUrl"
|
:src="playerVideoUrl"
|
||||||
:poster="playerPosterUrl"
|
:poster="playerPosterUrl"
|
||||||
:video-id="video.id"
|
:video-id="video.id"
|
||||||
|
:initial-time="resumePlayTime"
|
||||||
@timeupdate="onTimeUpdate"
|
@timeupdate="onTimeUpdate"
|
||||||
@ended="onEnded"
|
@ended="onEnded"
|
||||||
/>
|
/>
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
{{ video.categoryName }}
|
{{ video.categoryName }}
|
||||||
</span>
|
</span>
|
||||||
<span class="status-badge">
|
<span class="status-badge">
|
||||||
公开内容
|
{{ statusText }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="video-title">
|
<h1 class="video-title">
|
||||||
@@ -80,7 +81,10 @@
|
|||||||
<span>{{ formatDateTime(video.publishTime || video.createTime) }}</span>
|
<span>{{ formatDateTime(video.publishTime || video.createTime) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="video-actions">
|
<div
|
||||||
|
v-if="isPublicVideo"
|
||||||
|
class="video-actions"
|
||||||
|
>
|
||||||
<van-button
|
<van-button
|
||||||
size="small"
|
size="small"
|
||||||
:type="video.isLiked ? 'primary' : 'default'"
|
:type="video.isLiked ? 'primary' : 'default'"
|
||||||
@@ -196,6 +200,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<van-tabs
|
<van-tabs
|
||||||
|
v-if="isPublicVideo"
|
||||||
v-model:active="activeTab"
|
v-model:active="activeTab"
|
||||||
sticky
|
sticky
|
||||||
class="detail-tabs"
|
class="detail-tabs"
|
||||||
@@ -260,9 +265,12 @@
|
|||||||
|
|
||||||
<div class="sidebar-card">
|
<div class="sidebar-card">
|
||||||
<div class="sidebar-title">
|
<div class="sidebar-title">
|
||||||
连续观看
|
{{ sidebarTitle }}
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-video-list">
|
<div
|
||||||
|
v-if="recommendVideos.length"
|
||||||
|
class="sidebar-video-list"
|
||||||
|
>
|
||||||
<VideoCard
|
<VideoCard
|
||||||
v-for="item in recommendVideos"
|
v-for="item in recommendVideos"
|
||||||
:key="`side-${item.id}`"
|
:key="`side-${item.id}`"
|
||||||
@@ -270,16 +278,116 @@
|
|||||||
@click="goToVideo(item.id)"
|
@click="goToVideo(item.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="sidebar-empty"
|
||||||
|
>
|
||||||
|
暂无可推荐内容
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { showToast, showSuccessToast } from 'vant'
|
import { showToast, showSuccessToast } from '@/utils/toast'
|
||||||
import VideoPlayer from '@/components/video/VideoPlayer.vue'
|
import VideoPlayer from '@/components/video/VideoPlayer.vue'
|
||||||
import CommentSection from '@/components/comment/CommentSection.vue'
|
import CommentSection from '@/components/comment/CommentSection.vue'
|
||||||
import DanmakuList from '@/components/danmaku/DanmakuList.vue'
|
import DanmakuList from '@/components/danmaku/DanmakuList.vue'
|
||||||
@@ -290,28 +398,65 @@ import { recommendApi } from '@/api/recommend.ts'
|
|||||||
import { favoriteApi } from '@/api/favorite.ts'
|
import { favoriteApi } from '@/api/favorite.ts'
|
||||||
import { userApi } from '@/api/user.ts'
|
import { userApi } from '@/api/user.ts'
|
||||||
import { useUserStore } from '@/stores/user.ts'
|
import { useUserStore } from '@/stores/user.ts'
|
||||||
|
import { useVideoStore } from '@/stores/video.ts'
|
||||||
import type { UserInfo, VideoInfo } from '@/types'
|
import type { UserInfo, VideoInfo } from '@/types'
|
||||||
|
import type { Favorite } from '@/api/favorite.ts'
|
||||||
import { formatDateTime, formatNumber, formatPlayCount } from '@/utils'
|
import { formatDateTime, formatNumber, formatPlayCount } from '@/utils'
|
||||||
import { resolveAvatarUrl, resolveMediaUrl } from '@/utils/media'
|
import { resolveAvatarUrl, resolveMediaUrl } from '@/utils/media'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const videoStore = useVideoStore()
|
||||||
|
|
||||||
const videoId = computed(() => Number(route.params.id))
|
const videoId = computed(() => Number(route.params.id))
|
||||||
const video = ref<VideoInfo | null>(null)
|
const video = ref<VideoInfo | null>(null)
|
||||||
const danmakuList = ref<Danmaku[]>([])
|
const danmakuList = ref<Danmaku[]>([])
|
||||||
const recommendVideos = ref<VideoInfo[]>([])
|
const recommendVideos = ref<VideoInfo[]>([])
|
||||||
|
const recommendSource = ref<'similar' | 'author' | 'hot'>('similar')
|
||||||
const isFollowed = ref(false)
|
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 activeTab = ref('comment')
|
||||||
const activeCollapse = ref<string[]>([])
|
const activeCollapse = ref<string[]>([])
|
||||||
const currentTime = ref(0)
|
const currentTime = ref(0)
|
||||||
|
const lastHistorySyncTime = ref(0)
|
||||||
const authorProfile = ref<UserInfo | null>(null)
|
const authorProfile = ref<UserInfo | null>(null)
|
||||||
|
const newFavoriteForm = ref({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
isPrivate: 0
|
||||||
|
})
|
||||||
const playerVideoUrl = computed(() => resolveMediaUrl(video.value?.videoUrl))
|
const playerVideoUrl = computed(() => resolveMediaUrl(video.value?.videoUrl))
|
||||||
const playerPosterUrl = computed(() => resolveMediaUrl(video.value?.coverUrl))
|
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 notificationBackVisible = computed(() => route.query.from === 'notifications')
|
||||||
const authorName = computed(() => authorProfile.value?.nickname || video.value?.userNickname || '未知用户')
|
const authorName = computed(() => authorProfile.value?.nickname || video.value?.userNickname || '未知用户')
|
||||||
const authorAvatar = computed(() => resolveAvatarUrl(authorProfile.value?.avatar || video.value?.userAvatar, defaultAvatar))
|
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(() => {
|
const authorFollowerText = computed(() => {
|
||||||
if (authorProfile.value) {
|
if (authorProfile.value) {
|
||||||
return `${formatNumber(authorProfile.value.followerCount)} 粉丝`
|
return `${formatNumber(authorProfile.value.followerCount)} 粉丝`
|
||||||
@@ -321,13 +466,46 @@ const authorFollowerText = computed(() => {
|
|||||||
}
|
}
|
||||||
return '作者信息暂不可用'
|
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 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 () => {
|
const loadVideo = async () => {
|
||||||
try {
|
try {
|
||||||
video.value = await videoApi.getVideoPublic(videoId.value)
|
video.value = userStore.isLoggedIn
|
||||||
|
? await videoApi.getVideo(videoId.value)
|
||||||
|
: await videoApi.getVideoPublic(videoId.value)
|
||||||
// 播放记录接口当前要求登录,匿名态不应触发 401 跳转。
|
// 播放记录接口当前要求登录,匿名态不应触发 401 跳转。
|
||||||
if (userStore.isLoggedIn) {
|
if (userStore.isLoggedIn) {
|
||||||
try {
|
try {
|
||||||
@@ -381,10 +559,37 @@ const loadAuthorProfile = async () => {
|
|||||||
// 加载推荐
|
// 加载推荐
|
||||||
const loadRecommend = async () => {
|
const loadRecommend = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await recommendApi.getSimilarVideos(videoId.value, 10)
|
const similarVideos = dedupeVideos(await recommendApi.getSimilarVideos(videoId.value, 10))
|
||||||
recommendVideos.value = res
|
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) {
|
} catch (error) {
|
||||||
console.error('加载推荐失败:', error)
|
console.error('加载推荐失败:', error)
|
||||||
|
recommendSource.value = 'hot'
|
||||||
|
recommendVideos.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,10 +633,29 @@ const toggleCoin = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!video.value) 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 {
|
try {
|
||||||
await videoApi.coinVideo(video.value.id, 1)
|
await videoApi.coinVideo(video.value.id, 1)
|
||||||
video.value.coinCount++
|
video.value.coinCount++
|
||||||
|
video.value.isCoined = true
|
||||||
|
if (userStore.userInfo) {
|
||||||
|
userStore.userInfo.coinCount = Math.max(0, userStore.userInfo.coinCount - 1)
|
||||||
|
}
|
||||||
showSuccessToast('投币成功')
|
showSuccessToast('投币成功')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
showToast(error.message || '投币失败')
|
showToast(error.message || '投币失败')
|
||||||
@@ -451,11 +675,10 @@ const toggleFavorite = async () => {
|
|||||||
await videoApi.unfavoriteVideo(video.value.id)
|
await videoApi.unfavoriteVideo(video.value.id)
|
||||||
video.value.favoriteCount--
|
video.value.favoriteCount--
|
||||||
video.value.isFavorited = false
|
video.value.isFavorited = false
|
||||||
|
showSuccessToast('已取消收藏')
|
||||||
} else {
|
} else {
|
||||||
const favoriteId = await ensureFavoriteId()
|
await loadFavoriteFolders()
|
||||||
await videoApi.favoriteVideo(video.value.id, favoriteId)
|
showFavoritePopup.value = true
|
||||||
video.value.favoriteCount++
|
|
||||||
video.value.isFavorited = true
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
showToast(error.message || '操作失败')
|
showToast(error.message || '操作失败')
|
||||||
@@ -474,18 +697,56 @@ const shareVideo = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureFavoriteId = async () => {
|
const confirmFavoriteSelection = async () => {
|
||||||
const favorites = await favoriteApi.getFavoriteList()
|
if (!video.value) return
|
||||||
if (favorites.length > 0) {
|
if (!selectedFavoriteId.value) {
|
||||||
return favorites[0].id
|
showToast('请选择收藏夹')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
const favorite = await favoriteApi.createFavorite({
|
||||||
name: '默认收藏夹',
|
name: newFavoriteForm.value.name.trim(),
|
||||||
description: '自动创建的默认收藏夹',
|
description: newFavoriteForm.value.description.trim(),
|
||||||
isPrivate: 0
|
isPrivate: newFavoriteForm.value.isPrivate
|
||||||
})
|
})
|
||||||
return favorite.id
|
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) => {
|
const onTimeUpdate = (time: number) => {
|
||||||
currentTime.value = time
|
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 = () => {
|
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(() => {
|
onMounted(() => {
|
||||||
loadVideo()
|
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, () => {
|
watch(() => route.params.id, () => {
|
||||||
video.value = null
|
video.value = null
|
||||||
danmakuList.value = []
|
danmakuList.value = []
|
||||||
recommendVideos.value = []
|
recommendVideos.value = []
|
||||||
isFollowed.value = false
|
isFollowed.value = false
|
||||||
authorProfile.value = null
|
authorProfile.value = null
|
||||||
|
currentTime.value = 0
|
||||||
|
lastHistorySyncTime.value = 0
|
||||||
loadVideo()
|
loadVideo()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(showFavoritePopup, (value) => {
|
||||||
|
if (!value) {
|
||||||
|
favoriteSubmitting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showCreateFavoritePopup, (value) => {
|
||||||
|
if (!value) {
|
||||||
|
resetNewFavoriteForm()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -930,6 +1218,42 @@ watch(() => route.params.id, () => {
|
|||||||
gap: 16px;
|
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 {
|
.mobile-recommend {
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1200px) {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user