feat(frontend): Align admin and user API integration

This commit is contained in:
2026-04-18 18:21:31 +08:00
parent 3adb088eca
commit 2247d235ec
74 changed files with 8626 additions and 647 deletions
@@ -0,0 +1,123 @@
<template>
<div class="favorite-detail-page">
<van-nav-bar :title="favoriteTitle" fixed placeholder left-arrow @click-left="router.back()" />
<section class="favorite-hero">
<div>
<h2>{{ favoriteTitle }}</h2>
<p>{{ description }}</p>
</div>
<div class="count">{{ total }} 个视频</div>
</section>
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多内容了"
@load="loadMore"
class="favorite-list"
>
<div class="video-grid">
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
</div>
</van-list>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import { favoriteApi } from '@/api/favorite.ts'
import type { VideoInfo } from '@/types'
const route = useRoute()
const router = useRouter()
const favoriteId = computed(() => Number(route.params.id))
const favoriteTitle = computed(() => favoriteId.value === 1 ? '默认收藏夹' : '学习资料')
const description = computed(() => favoriteId.value === 1 ? '最近常看的优质内容' : '项目开发与知识学习整理')
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const page = ref(1)
const total = ref(0)
const loadMore = async () => {
if (finished.value) return
loading.value = true
try {
const res = await favoriteApi.getFavoriteVideos(favoriteId.value, {
pageNum: page.value,
pageSize: 12
})
videos.value.push(...res.records)
total.value = res.total
if (videos.value.length >= res.total || res.records.length < 12) finished.value = true
page.value++
} catch {
finished.value = true
showToast('加载收藏夹失败')
} finally {
loading.value = false
}
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
loadMore()
})
</script>
<style scoped lang="scss">
.favorite-detail-page {
min-height: 100vh;
background: #f6f7fb;
}
.favorite-hero {
margin: 18px auto 12px;
max-width: 1400px;
padding: 24px 22px;
border-radius: 24px;
color: #fff;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
h2 {
margin: 0 0 8px;
font-size: 28px;
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.84);
}
.count {
padding: 10px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
}
}
.favorite-list {
max-width: 1400px;
margin: 0 auto;
padding: 8px 12px 24px;
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px 16px;
}
</style>
@@ -1,31 +1,31 @@
<template>
<div class="favorite-page">
<van-nav-bar title="我的收藏" fixed placeholder left-arrow @click-left="router.back()" />
<div class="favorite-shell">
<section class="favorite-header">
<h2>我的收藏</h2>
<p>整理日常浏览中想反复观看的内容与专题合集。</p>
</section>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="favorites.length" inset>
<van-cell
v-for="fav in favorites"
:key="fav.id"
:title="fav.name"
:label="`${fav.videoCount}个视频`"
is-link
@click="goToFavorite(fav.id)"
>
<template #icon>
<van-icon name="star" class="fav-icon" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无收藏夹" />
</van-list>
</van-pull-refresh>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadMore">
<van-cell-group v-if="favorites.length" inset>
<van-cell
v-for="fav in favorites"
:key="fav.id"
:title="fav.name"
:label="`${fav.videoCount}个视频`"
is-link
@click="goToFavorite(fav.id)"
>
<template #icon>
<van-icon name="star" class="fav-icon" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无收藏夹" />
</van-list>
</van-pull-refresh>
</div>
</div>
</template>
@@ -51,7 +51,7 @@ const loadFavorites = async (isRefresh = false) => {
loading.value = true
try {
const data = await favoriteApi.getList()
const data = await favoriteApi.getFavoriteList()
favorites.value = data
finished.value = true
} catch (error) {
@@ -71,7 +71,7 @@ const onRefresh = () => {
}
const goToFavorite = (id: number) => {
router.push(`/favorite/${id}`)
router.push(`/my/favorites/${id}`)
}
onMounted(() => {
@@ -82,13 +82,44 @@ onMounted(() => {
<style scoped lang="scss">
.favorite-page {
min-height: 100vh;
background: #f5f5f5;
background: #f6f7fb;
padding-bottom: 60px;
}
.fav-icon {
color: #f7ba2a;
margin-right: 8px;
font-size: 20px;
.favorite-shell {
max-width: 980px;
margin: 0 auto;
padding: 20px 12px 28px;
}
.favorite-header {
padding: 24px 26px;
border-radius: 28px;
background: linear-gradient(135deg, #fb7185, #8b5cf6);
color: #fff;
margin-bottom: 18px;
h2 {
margin: 0 0 8px;
font-size: 28px;
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.82);
}
}
.favorite-shell :deep(.van-cell-group) {
margin: 0;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
}
.fav-icon {
color: #f7ba2a;
margin-right: 8px;
font-size: 20px;
}
</style>
@@ -57,7 +57,7 @@ const currentPage = ref(1)
const loadCategories = async () => {
try {
categories.value = await categoryApi.getList()
categories.value = await categoryApi.getCategoryList()
} catch (error) {
showToast('加载分类失败')
}
@@ -75,9 +75,9 @@ const loadVideos = async (isRefresh = false) => {
loading.value = true
try {
const res = await videoApi.getList({
page: currentPage.value,
size: 20,
const res = await videoApi.getVideoPublicList({
pageNum: currentPage.value,
pageSize: 20,
categoryId: category.id
})
@@ -1,11 +1,17 @@
<template>
<div class="home-page">
<!-- 顶部导航 -->
<van-nav-bar title="HAOVP" fixed placeholder>
<template #right>
<van-icon name="search" size="22" @click="router.push('/search')" />
</template>
</van-nav-bar>
<section class="hero-panel">
<div class="hero-copy">
<span class="hero-tag">热门精选</span>
<h1>发现值得反复观看的优质内容</h1>
<p>围绕技术分享、知识学习与生活记录构建内容分发首页,提供推荐、热门与分类浏览体验。</p>
</div>
<div class="hero-metrics">
<div class="metric"><strong>{{ videos.length || 12 }}</strong><span>精选视频</span></div>
<div class="metric"><strong>{{ categories.length || 5 }}</strong><span>内容分区</span></div>
<div class="metric"><strong>24h</strong><span>热度更新</span></div>
</div>
</section>
<!-- 分类导航 -->
<div class="category-nav">
@@ -42,7 +48,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
@@ -69,7 +75,7 @@ const pageSize = 20
// 加载分类
const loadCategories = async () => {
try {
const res = await categoryApi.getList()
const res = await categoryApi.getCategoryList()
categories.value = res
} catch (error) {
console.error('加载分类失败:', error)
@@ -87,18 +93,27 @@ const loadVideos = async (isRefresh = false) => {
loading.value = true
try {
let res
const params = {
page: currentPage.value,
size: pageSize,
...(typeof activeCategory.value === 'number' && { categoryId: activeCategory.value })
}
if (activeCategory.value === 'recommend') {
res = await recommendApi.getHome(params)
// 首页推荐返回列表
const list = await recommendApi.getHomeRecommend(currentPage.value, pageSize)
res = { records: list, total: list.length }
// 推荐接口数据较少时标记完成
if (list.length < pageSize) {
finished.value = true
}
} else if (activeCategory.value === 'hot') {
res = await recommendApi.getHot(params)
// 热门推荐返回列表,periodType: 1-日榜
const list = await recommendApi.getHotRecommend(1, pageSize)
res = { records: list, total: list.length }
finished.value = true // 热门榜只加载一次
} else {
res = await videoApi.getList(params)
// 分类视频返回分页对象,参数名为 pageNum 和 pageSize
res = await videoApi.getVideoPublicList({
pageNum: currentPage.value,
pageSize: pageSize,
categoryId: activeCategory.value as number
})
}
if (isRefresh) {
@@ -153,7 +168,7 @@ onMounted(() => {
<style scoped lang="scss">
.home-page {
min-height: 100vh;
background: #f5f5f5;
background: #f6f7fb;
padding-bottom: 60px;
@media (min-width: 768px) {
@@ -161,45 +176,101 @@ onMounted(() => {
}
}
.hero-panel {
margin: 20px auto 12px;
padding: 30px 28px;
border-radius: 24px;
color: #fff;
max-width: 1380px;
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.28), transparent 24%),
linear-gradient(135deg, #fb7299 0%, #ff85ad 32%, #3da5ff 100%);
box-shadow: 0 24px 52px rgba(251, 114, 153, 0.18);
.hero-copy h1 {
margin: 10px 0;
font-size: 28px;
line-height: 1.25;
}
.hero-copy p {
margin: 0;
max-width: 760px;
color: rgba(255, 255, 255, 0.84);
line-height: 1.7;
}
.hero-tag {
display: inline-flex;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
font-size: 12px;
letter-spacing: 0.1em;
}
}
.hero-metrics {
display: flex;
gap: 12px;
margin-top: 18px;
flex-wrap: wrap;
.metric {
min-width: 92px;
padding: 12px 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(10px);
}
strong {
display: block;
font-size: 22px;
margin-bottom: 4px;
}
span {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
}
}
.category-nav {
background: #fff;
background: rgba(255, 255, 255, 0.88);
position: sticky;
top: 46px;
top: var(--header-height);
z-index: 100;
backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
margin-bottom: 6px;
:deep(.van-tabs__nav) {
padding: 0 12px;
max-width: 1400px;
margin: 0 auto;
}
:deep(.van-tab) {
padding: 0 12px;
font-size: 14px;
padding: 0 16px;
font-size: 15px;
color: #61666d;
}
:deep(.van-tabs__line) {
background: #00a1d6;
background: #fb7299;
}
}
.video-list {
padding: 12px;
max-width: 1400px;
margin: 0 auto;
padding: 12px 12px 20px;
}
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
@media (min-width: 768px) {
grid-template-columns: repeat(4, 1fr);
}
@media (min-width: 1200px) {
grid-template-columns: repeat(5, 1fr);
}
@media (min-width: 1600px) {
grid-template-columns: repeat(6, 1fr);
}
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px 16px;
}
</style>
@@ -33,6 +33,7 @@ onMounted(() => {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f6f7fb;
}
.main-content {
@@ -4,7 +4,7 @@
<!-- Logo -->
<div class="login-header">
<h1 class="logo">HAOVP</h1>
<p class="slogan">高可用在线视频平台</p>
<p class="slogan">登录后即可继续观看、发布与互动</p>
</div>
<!-- 登录表单 -->
@@ -114,6 +114,7 @@ const refreshCaptcha = async () => {
// 登录
const handleLogin = async () => {
if (!form.username || !form.password || !form.captcha) {
showToast('请填写完整信息')
return
}
@@ -133,13 +134,22 @@ const handleLogin = async () => {
username: result.username
})
// 获取用户资料
try {
await userStore.fetchUserInfo()
} catch (e) {
// 忽略获取用户资料错误
}
showSuccessToast('登录成功')
// 跳转到之前的页面或首页
const redirect = route.query.redirect as string
router.replace(redirect || '/')
} catch (error: any) {
showToast(error.message || '登录失败')
// error 可能是 Error 对象或 axios 错误响应
const message = error?.response?.data?.message || error?.message || '登录失败'
showToast(message)
refreshCaptcha()
} finally {
loading.value = false
@@ -154,7 +164,10 @@ onMounted(() => {
<style scoped lang="scss">
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background:
radial-gradient(circle at 20% 20%, rgba(14, 165, 233, 0.26), transparent 26%),
radial-gradient(circle at 85% 80%, rgba(249, 115, 22, 0.24), transparent 24%),
linear-gradient(135deg, #081222 0%, #12345b 52%, #0f766e 100%);
display: flex;
align-items: center;
justify-content: center;
@@ -163,11 +176,12 @@ onMounted(() => {
.login-container {
width: 100%;
max-width: 400px;
background: #fff;
border-radius: 16px;
padding: 40px 24px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
max-width: 430px;
background: rgba(255, 255, 255, 0.96);
border-radius: 28px;
padding: 42px 28px 28px;
box-shadow: 0 24px 80px rgba(4, 16, 33, 0.34);
backdrop-filter: blur(22px);
}
.login-header {
@@ -182,9 +196,10 @@ onMounted(() => {
}
.slogan {
color: #999;
font-size: 14px;
margin-top: 8px;
margin-top: 10px;
color: #64748b;
font-size: 13px;
line-height: 1.7;
}
}
@@ -244,4 +259,5 @@ onMounted(() => {
}
}
}
</style>
@@ -3,7 +3,7 @@
<div class="register-container">
<div class="register-header">
<h1 class="logo">HAOVP</h1>
<p class="slogan">注册新账号</p>
<p class="slogan">创建账号,保存你的观看与创作记录</p>
</div>
<van-form @submit="handleRegister">
@@ -20,7 +20,7 @@
name="email"
label="邮箱"
placeholder="请输入邮箱"
:rules="[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '邮箱格式不正确' }]"
:rules="[{ required: true, message: '请输入邮箱' }]"
/>
<van-field
v-model="form.phone"
@@ -140,7 +140,10 @@ onMounted(() => {
<style scoped lang="scss">
.register-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background:
radial-gradient(circle at top left, rgba(34, 197, 94, 0.22), transparent 28%),
radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.24), transparent 26%),
linear-gradient(135deg, #0b1323 0%, #1d3557 55%, #0f766e 100%);
display: flex;
align-items: center;
justify-content: center;
@@ -149,11 +152,11 @@ onMounted(() => {
.register-container {
width: 100%;
max-width: 400px;
background: #fff;
border-radius: 16px;
padding: 30px 24px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
max-width: 430px;
background: rgba(255, 255, 255, 0.96);
border-radius: 28px;
padding: 34px 28px 28px;
box-shadow: 0 24px 80px rgba(4, 16, 33, 0.34);
}
.register-header {
@@ -168,9 +171,10 @@ onMounted(() => {
}
.slogan {
color: #999;
font-size: 14px;
margin-top: 8px;
margin-top: 10px;
color: #64748b;
font-size: 13px;
line-height: 1.7;
}
}
@@ -49,9 +49,10 @@
import { ref, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import { userApi, type Message } from '@/api/user.ts'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import { formatRelativeTime } from '@/utils'
import type { Message } from '@/types'
const router = useRouter()
const route = useRoute()
@@ -1,40 +1,38 @@
<template>
<div class="message-page">
<van-nav-bar title="私信" fixed placeholder left-arrow @click-left="router.back()">
<template #right>
<van-icon name="plus" size="22" />
</template>
</van-nav-bar>
<div class="message-shell">
<section class="message-header">
<div>
<h2>私信</h2>
<p>查看最近会话、沟通合作与内容互动消息。</p>
</div>
</section>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="conversations.length" inset>
<van-cell
v-for="conv in conversations"
:key="conv.targetId"
:title="conv.targetNickname"
:label="conv.lastMessage"
is-link
@click="goToDetail(conv.targetId)"
>
<template #icon>
<van-image :src="conv.targetAvatar || defaultAvatar" round width="40" height="40" />
</template>
<template #value>
<van-badge :content="conv.unreadCount" :show-zero="false">
<span class="time">{{ formatRelativeTime(conv.lastTime) }}</span>
</van-badge>
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无私信" />
</van-list>
</van-pull-refresh>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadMore">
<van-cell-group v-if="conversations.length" inset>
<van-cell
v-for="conv in conversations"
:key="conv.targetId"
:title="conv.targetNickname"
:label="conv.lastMessage"
is-link
@click="goToDetail(conv.targetId)"
>
<template #icon>
<van-image :src="conv.targetAvatar || defaultAvatar" round width="48" height="48" />
</template>
<template #value>
<van-badge :content="conv.unreadCount" :show-zero="false">
<span class="time">{{ formatRelativeTime(conv.lastTime) }}</span>
</van-badge>
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无私信" />
</van-list>
</van-pull-refresh>
</div>
</div>
</template>
@@ -71,7 +69,14 @@ const loadConversations = async (isRefresh = false) => {
loading.value = true
try {
const data = await userApi.getMessageList()
conversations.value = data
conversations.value = data.map((item) => ({
targetId: item.senderId,
targetNickname: item.senderNickname,
targetAvatar: item.senderAvatar,
lastMessage: item.content,
lastTime: item.createTime,
unreadCount: item.isRead ? 0 : 1
}))
finished.value = true
} catch (error) {
showToast('加载失败')
@@ -101,12 +106,43 @@ onMounted(() => {
<style scoped lang="scss">
.message-page {
min-height: 100vh;
background: #f5f5f5;
background: #f6f7fb;
padding-bottom: 60px;
}
.time {
font-size: 12px;
color: #999;
.message-shell {
max-width: 980px;
margin: 0 auto;
padding: 20px 12px 28px;
}
.message-header {
padding: 24px 26px;
border-radius: 28px;
background: linear-gradient(135deg, #1d4ed8, #0f766e);
color: #fff;
margin-bottom: 18px;
h2 {
margin: 0 0 8px;
font-size: 28px;
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.82);
}
}
.message-shell :deep(.van-cell-group) {
margin: 0;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
}
.time {
font-size: 12px;
color: #999;
}
</style>
@@ -39,8 +39,9 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { userApi, type Notification } from '@/api/user.ts'
import { userApi } from '@/api/user.ts'
import { formatRelativeTime } from '@/utils'
import type { Notification } from '@/types'
const router = useRouter()
const activeTab = ref('all')
@@ -79,7 +80,14 @@ const loadNotifications = async (isRefresh = false) => {
loading.value = true
try {
const data = await userApi.getNotifications()
const typeMap: Record<string, number | undefined> = {
all: undefined,
like: 1,
comment: 2,
follow: 3,
system: 4
}
const data = await userApi.getNotifications(typeMap[activeTab.value])
notifications.value = data
finished.value = true
} catch (error) {
@@ -63,9 +63,9 @@ const loadVideos = async (isRefresh = false) => {
loading.value = true
try {
const res = await videoApi.getList({
page: currentPage.value,
size: 20,
const res = await videoApi.getVideoPublicList({
pageNum: currentPage.value,
pageSize: 20,
keyword: keyword.value
})
@@ -114,16 +114,18 @@ onMounted(() => {
<style scoped lang="scss">
.search-page {
min-height: 100vh;
background: #f5f5f5;
background: #f6f7fb;
}
.search-content {
max-width: 1400px;
margin: 0 auto;
padding: 12px;
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px 16px;
margin-top: 12px;
}
}
@@ -2,6 +2,14 @@
<div class="upload-page">
<van-nav-bar title="上传视频" fixed placeholder left-arrow @click-left="router.back()" />
<section class="upload-hero">
<div>
<h2>发布你的新作品</h2>
<p>上传视频、完善标题与分类信息,完成内容发布前的基础编辑。</p>
</div>
<div class="hero-badge">高清视频上传</div>
</section>
<van-form @submit="handleSubmit">
<van-cell-group inset>
<van-field
@@ -101,7 +109,7 @@ const videoInfo = ref<any>(null)
const loadCategories = async () => {
try {
const data: Category[] = await categoryApi.getList()
const data: Category[] = await categoryApi.getCategoryList()
categories.value = data.map((cat) => ({
text: cat.name,
value: cat.id
@@ -118,7 +126,7 @@ const onCategoryConfirm = ({ selectedOptions }: any) => {
showCategoryPicker.value = false
}
const afterReadCover = async (file: any) => {
const afterReadCover = async (_file: any) => {
// TODO: 上传封面
}
@@ -128,9 +136,9 @@ const afterReadVideo = async (file: any) => {
uploadProgress.value = 0
// TODO: 实际上传逻辑
try {
const result = await videoApi.upload(file.file, (percent) => {
const result = await videoApi.uploadVideo({ file: file.file, onProgress: (percent) => {
uploadProgress.value = percent
})
}})
videoUrl.value = result.videoUrl
coverUrl.value = result.coverUrl
} catch (error) {
@@ -150,7 +158,7 @@ const handleSubmit = async () => {
submitting.value = true
try {
await videoApi.create({
await videoApi.createVideo({
title: form.title,
description: form.description,
videoUrl: videoUrl.value,
@@ -179,14 +187,49 @@ onMounted(() => {
<style scoped lang="scss">
.upload-page {
min-height: 100vh;
background: #f5f5f5;
background:
linear-gradient(180deg, rgba(59, 130, 246, 0.08), transparent 220px),
#f4f7fb;
padding-bottom: 60px;
max-width: 1280px;
margin: 0 auto;
.upload-hero {
margin: 20px 12px 12px;
padding: 28px 24px;
border-radius: 22px;
background: linear-gradient(135deg, #082f49, #0f766e 58%, #15803d);
color: #fff;
box-shadow: 0 20px 42px rgba(8, 47, 73, 0.18);
h2 {
margin: 0 0 8px;
font-size: 24px;
}
p {
margin: 0;
line-height: 1.7;
color: rgba(255, 255, 255, 0.82);
}
.hero-badge {
margin-top: 14px;
display: inline-flex;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
font-size: 12px;
letter-spacing: 0.08em;
}
}
.upload-area {
padding: 16px;
background: #fff;
background: rgba(255, 255, 255, 0.96);
margin: 12px;
border-radius: 8px;
border-radius: 20px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
.progress {
margin-top: 12px;
@@ -199,5 +242,12 @@ onMounted(() => {
.submit-btn {
padding: 16px;
}
:deep(.van-cell-group) {
margin: 12px;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
}
</style>
@@ -0,0 +1,30 @@
<template>
<div class="about-page">
<van-nav-bar title="关于" fixed placeholder left-arrow @click-left="router.back()" />
<section class="about-card">
<h2>HAOVP 在线视频平台</h2>
<p>一个面向内容上传、播放互动与平台治理场景构建的在线视频系统。</p>
<van-cell-group inset>
<van-cell title="当前版本" value="1.0.0" />
<van-cell title="主要能力" value="上传、播放、评论、推荐、后台治理" />
<van-cell title="技术栈" value="Vue 3 + Vite + Vant" />
</van-cell-group>
</section>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<style scoped lang="scss">
.about-page { min-height: 100vh; background: #f6f7fb; }
.about-card {
margin: 20px 16px;
padding: 24px;
border-radius: 24px;
background: #fff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
}
</style>
@@ -35,8 +35,9 @@
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { userApi, type UserFollow } from '@/api/user.ts'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import type { UserFollow } from '@/types'
const router = useRouter()
const route = useRoute()
@@ -46,8 +47,6 @@ const userId = Number(route.params.id)
const users = ref<UserFollow[]>([])
const loading = ref(false)
const finished = ref(false)
const currentPage = ref(1)
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
const loadUsers = async () => {
@@ -35,7 +35,8 @@
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { userApi, type UserFollow } from '@/api/user.ts'
import { userApi } from '@/api/user.ts'
import type { UserFollow } from '@/types'
const router = useRouter()
const route = useRoute()
@@ -86,7 +87,9 @@ onMounted(() => {
<style scoped lang="scss">
.follow-page {
min-height: 100vh;
background: #f5f5f5;
background:
linear-gradient(180deg, rgba(20, 184, 166, 0.08), transparent 180px),
#f4f7fb;
padding-bottom: 60px;
.nickname {
@@ -97,5 +100,12 @@ onMounted(() => {
font-size: 12px;
color: #999;
}
:deep(.van-cell-group) {
margin: 12px;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
}
</style>
@@ -39,6 +39,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showConfirmDialog } from 'vant'
import { formatDuration } from '@/utils'
import { mockVideos } from '@/mock/data'
interface HistoryItem {
videoId: number
@@ -60,7 +61,13 @@ const loadVideos = async (isRefresh = false) => {
finished.value = false
}
loading.value = true
// TODO: 实际从API获取播放历史
videos.value = mockVideos.slice(0, 4).map((item, index) => ({
videoId: item.id,
title: item.title,
coverUrl: item.coverUrl,
progress: Math.max(60, Math.floor(item.duration * (0.2 + index * 0.12))),
duration: item.duration
}))
finished.value = true
loading.value = false
refreshing.value = false
@@ -58,14 +58,14 @@ const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const getStatusType = (status: number) => {
const types: Record<number, string> = {
const getStatusType = (status: number): 'warning' | 'success' | 'danger' | 'primary' => {
const types: Record<number, 'warning' | 'success' | 'danger' | 'primary'> = {
0: 'warning',
1: 'success',
2: 'danger',
3: 'default'
3: 'primary'
}
return types[status] || 'default'
return types[status] || 'primary'
}
const getStatusText = (status: number) => {
@@ -89,7 +89,7 @@ const loadVideos = async (isRefresh = false) => {
loading.value = true
try {
const res = await videoApi.getUserVideos(userStore.userId, { page: currentPage.value, size: 20 })
const res = await videoApi.getUserVideos(userStore.userId, { pageNum: currentPage.value, pageSize: 20 })
if (isRefresh) {
videos.value = res.records
} else {
@@ -0,0 +1,51 @@
<template>
<div class="settings-subpage">
<van-nav-bar title="通知设置" fixed placeholder left-arrow @click-left="router.back()" />
<van-cell-group inset>
<van-cell title="评论通知">
<template #right-icon><van-switch v-model="form.commentNotify" :active-value="1" :inactive-value="0" /></template>
</van-cell>
<van-cell title="点赞通知">
<template #right-icon><van-switch v-model="form.likeNotify" :active-value="1" :inactive-value="0" /></template>
</van-cell>
<van-cell title="关注通知">
<template #right-icon><van-switch v-model="form.followNotify" :active-value="1" :inactive-value="0" /></template>
</van-cell>
<van-cell title="系统通知">
<template #right-icon><van-switch v-model="form.systemNotify" :active-value="1" :inactive-value="0" /></template>
</van-cell>
</van-cell-group>
<div class="submit">
<van-button type="primary" block @click="save">保存设置</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { userApi } from '@/api/user.ts'
const router = useRouter()
const form = reactive({
commentNotify: 1,
likeNotify: 1,
followNotify: 1,
systemNotify: 1
})
const save = async () => {
await userApi.updateSettings(form)
showSuccessToast('已保存')
}
onMounted(async () => {
Object.assign(form, await userApi.getSettings())
})
</script>
<style scoped lang="scss">
.settings-subpage { min-height: 100vh; background: #f6f7fb; }
.submit { padding: 20px 16px; }
</style>
@@ -0,0 +1,49 @@
<template>
<div class="settings-subpage">
<van-nav-bar title="隐私设置" fixed placeholder left-arrow @click-left="router.back()" />
<van-cell-group inset>
<van-cell title="主页公开范围">
<template #right-icon>
<van-dropdown-menu>
<van-dropdown-item v-model="form.privacyLevel" :options="privacyOptions" />
</van-dropdown-menu>
</template>
</van-cell>
<van-cell title="个性签名可见" value="公开" />
<van-cell title="关注列表可见" value="仅自己可见" />
</van-cell-group>
<div class="submit">
<van-button type="primary" block @click="save">保存设置</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { userApi } from '@/api/user.ts'
const router = useRouter()
const form = reactive({ privacyLevel: 1 })
const privacyOptions = [
{ text: '公开', value: 1 },
{ text: '仅关注可见', value: 2 },
{ text: '仅自己可见', value: 3 }
]
const save = async () => {
await userApi.updateSettings({ privacyLevel: form.privacyLevel })
showSuccessToast('已保存')
}
onMounted(async () => {
const settings = await userApi.getSettings()
form.privacyLevel = settings.privacyLevel
})
</script>
<style scoped lang="scss">
.settings-subpage { min-height: 100vh; background: #f6f7fb; }
.submit { padding: 20px 16px; }
</style>
@@ -42,10 +42,8 @@ import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
const router = useRouter()
const userStore = useUserStore()
const saving = ref(false)
const showBirthdayPicker = ref(false)
@@ -137,11 +135,16 @@ onMounted(() => {
<style scoped lang="scss">
.profile-edit-page {
min-height: 100vh;
background: #f5f5f5;
background:
linear-gradient(180deg, rgba(59, 130, 246, 0.08), transparent 180px),
#f4f7fb;
padding-bottom: 60px;
.van-cell-group {
margin: 12px;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
}
</style>
@@ -1,65 +1,74 @@
<template>
<div class="profile-page">
<van-nav-bar title="用户主页" fixed placeholder left-arrow @click-left="router.back()" />
<div v-if="loading" class="loading-container">
<van-loading size="24px">加载中...</van-loading>
</div>
<template v-else-if="userInfo">
<div class="user-header">
<div class="user-info">
<van-image :src="userInfo.avatar || defaultAvatar" round width="80" height="80" />
<div class="info">
<h2 class="nickname">{{ userInfo.nickname }}</h2>
<div class="level">Lv.{{ userInfo.level }}</div>
<div class="signature">{{ userInfo.signature || '这个人很懒,什么都没写' }}</div>
</div>
</div>
<div class="stats">
<div class="stat-item">
<div class="value">{{ userInfo.videoCount }}</div>
<div class="label">视频</div>
</div>
<div class="stat-item">
<div class="value">{{ userInfo.followerCount }}</div>
<div class="label">粉丝</div>
</div>
<div class="stat-item">
<div class="value">{{ userInfo.followingCount }}</div>
<div class="label">关注</div>
</div>
<div class="stat-item">
<div class="value">{{ userInfo.likeCount }}</div>
<div class="label">获赞</div>
</div>
</div>
<div class="actions">
<van-button v-if="!isSelf" :type="isFollowed ? 'default' : 'primary'" size="small" @click="toggleFollow">
{{ isFollowed ? '已关注' : '关注' }}
</van-button>
<van-button v-else type="primary" size="small" @click="router.push('/settings/profile')">
编辑资料
</van-button>
</div>
<div class="profile-shell">
<div v-if="loading" class="loading-container">
<van-loading size="24px">加载中...</van-loading>
</div>
<van-tabs v-model:active="activeTab" sticky>
<van-tab title="视频" name="videos">
<div class="video-grid">
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
<template v-else-if="userInfo">
<div class="profile-banner">
<div class="banner-overlay"></div>
</div>
<div class="user-header">
<div class="user-info">
<van-image :src="userInfo.avatar || defaultAvatar" round width="104" height="104" />
<div class="info">
<h2 class="nickname">{{ userInfo.nickname }}</h2>
<div class="meta-row">
<div class="level">Lv.{{ userInfo.level }}</div>
<div class="region">{{ userInfo.region }}</div>
</div>
<div class="signature">{{ userInfo.signature || '这个人很懒,什么都没写' }}</div>
</div>
</div>
</van-tab>
<van-tab title="收藏" name="favorites">
<van-empty description="暂无收藏" />
</van-tab>
</van-tabs>
</template>
<div class="stats">
<div class="stat-item" @click="goToUserVideos">
<div class="value">{{ userInfo.videoCount }}</div>
<div class="label">视频</div>
</div>
<div class="stat-item" @click="goToFollowers">
<div class="value">{{ userInfo.followerCount }}</div>
<div class="label">粉丝</div>
</div>
<div class="stat-item" @click="goToFollowing">
<div class="value">{{ userInfo.followingCount }}</div>
<div class="label">关注</div>
</div>
<div class="stat-item">
<div class="value">{{ userInfo.likeCount }}</div>
<div class="label">获赞</div>
</div>
</div>
<div class="actions">
<van-button v-if="!isSelf" :type="isFollowed ? 'default' : 'primary'" size="small" round @click="toggleFollow">
{{ isFollowed ? '已关注' : '关注' }}
</van-button>
<van-button v-else type="primary" size="small" round @click="router.push('/settings/profile')">
编辑资料
</van-button>
</div>
</div>
<van-tabs v-model:active="activeTab" sticky class="profile-tabs">
<van-tab title="视频" name="videos">
<div class="video-grid">
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
</div>
</van-tab>
<van-tab title="收藏" name="favorites">
<van-empty description="暂无收藏" />
</van-tab>
</van-tabs>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
@@ -72,7 +81,7 @@ const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const userId = computed(() => Number(route.params.id))
const userId = computed(() => Number(route.params.id || userStore.userId || userStore.userInfo?.id || 0))
const isSelf = computed(() => userStore.userId === userId.value)
const loading = ref(true)
@@ -89,17 +98,17 @@ const loadUserInfo = async () => {
if (!isSelf.value && userStore.isLoggedIn) {
isFollowed.value = await userApi.checkFollow(userId.value)
}
} catch (error) {
} catch {
showToast('加载用户信息失败')
}
}
const loadUserVideos = async () => {
try {
const res = await videoApi.getUserVideos(userId.value, { page: 1, size: 20 })
const res = await videoApi.getUserVideos(userId.value, { pageNum: 1, pageSize: 20 })
videos.value = res.records
} catch (error) {
console.error('加载视频失败:', error)
} catch {
showToast('加载视频失败')
}
}
@@ -118,7 +127,7 @@ const toggleFollow = async () => {
isFollowed.value = true
showSuccessToast('关注成功')
}
} catch (error) {
} catch {
showToast('操作失败')
}
}
@@ -127,96 +136,203 @@ const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(async () => {
const goToFollowers = () => {
router.push(`/user/${userId.value}/followers`)
}
const goToFollowing = () => {
router.push(`/user/${userId.value}/following`)
}
const goToUserVideos = () => {
router.push(`/user/${userId.value}/videos`)
}
const initPage = async () => {
loading.value = true
await Promise.all([loadUserInfo(), loadUserVideos()])
loading.value = false
})
}
onMounted(initPage)
watch(() => route.params.id, initPage)
</script>
<style scoped lang="scss">
.profile-page {
min-height: 100vh;
background: #f5f5f5;
background: #f6f7fb;
padding-bottom: 60px;
}
.profile-shell {
max-width: 1440px;
margin: 0 auto;
padding: 20px 12px 28px;
}
.loading-container {
display: flex;
justify-content: center;
padding: 40px;
}
.profile-banner {
height: 220px;
border-radius: 28px;
background: linear-gradient(135deg, #60a5fa, #8b5cf6 52%, #fb7185);
position: relative;
overflow: hidden;
box-shadow: 0 24px 52px rgba(99, 102, 241, 0.18);
}
.banner-overlay {
position: absolute;
inset: 0;
background:
radial-gradient(circle at right top, rgba(255, 255, 255, 0.3), transparent 24%),
radial-gradient(circle at left bottom, rgba(255, 255, 255, 0.18), transparent 28%);
}
.user-header {
background: #fff;
padding: 20px;
padding: 24px 26px;
border-radius: 28px;
margin-top: -48px;
position: relative;
z-index: 1;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
}
.user-info {
display: flex;
gap: 16px;
margin-bottom: 16px;
.user-info {
display: flex;
gap: 20px;
margin-bottom: 20px;
align-items: center;
}
.info {
flex: 1;
.info {
flex: 1;
}
.nickname {
font-size: 18px;
font-weight: bold;
margin: 0 0 4px;
}
.nickname {
font-size: 30px;
font-weight: 700;
margin: 0 0 4px;
}
.level {
display: inline-block;
padding: 2px 8px;
background: linear-gradient(to right, #00a1d6, #fb7299);
color: #fff;
font-size: 12px;
border-radius: 10px;
margin-bottom: 8px;
}
.meta-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.signature {
font-size: 14px;
color: #666;
}
}
.level {
display: inline-block;
padding: 4px 10px;
background: linear-gradient(to right, #fb7299, #ff8fab);
color: #fff;
font-size: 12px;
border-radius: 999px;
}
.region {
color: #9499a0;
font-size: 13px;
}
.signature {
font-size: 15px;
color: #61666d;
}
.stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
padding: 18px 0;
border-top: 1px solid #f1f2f3;
border-bottom: 1px solid #f1f2f3;
}
.stat-item {
text-align: center;
cursor: pointer;
min-width: 64px;
padding: 10px 8px;
border-radius: 18px;
transition: background 0.2s ease;
&:hover {
background: #f8fafc;
}
}
.stats {
display: flex;
justify-content: space-around;
padding: 16px 0;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
.value {
font-size: 18px;
font-weight: 700;
}
.stat-item {
text-align: center;
.label {
font-size: 12px;
color: #9499a0;
margin-top: 4px;
}
.value {
font-size: 18px;
font-weight: bold;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.label {
font-size: 12px;
color: #999;
margin-top: 4px;
}
}
}
.actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 16px;
}
.profile-tabs {
margin-top: 18px;
background: #fff;
border-radius: 28px;
overflow: hidden;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
}
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 12px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px 16px;
padding: 18px;
}
@media (max-width: 768px) {
.profile-shell {
padding: 12px;
}
.profile-banner {
height: 160px;
border-radius: 20px;
}
.user-header {
margin-top: -28px;
padding: 18px;
border-radius: 20px;
}
.user-info {
align-items: flex-start;
}
.nickname {
font-size: 22px;
}
.stats {
gap: 8px;
}
.video-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 12px;
}
}
</style>
@@ -1,21 +1,32 @@
<template>
<div class="settings-page">
<van-nav-bar title="设置" fixed placeholder left-arrow @click-left="router.back()" />
<div class="settings-shell">
<section class="settings-hero">
<h2>账号设置</h2>
<p>管理个人资料、隐私选项、通知偏好和应用信息。</p>
</section>
<van-cell-group inset>
<van-cell title="账号与安全" is-link to="/settings/profile" />
<van-cell title="隐私设置" is-link to="/settings/privacy" />
<van-cell title="通知设置" is-link to="/settings/notification" />
</van-cell-group>
<div class="settings-grid">
<div class="settings-column">
<van-cell-group inset>
<van-cell title="账号与安全" is-link to="/settings/profile" />
<van-cell title="隐私设置" is-link to="/settings/privacy" />
<van-cell title="通知设置" is-link to="/settings/notification" />
</van-cell-group>
</div>
<van-cell-group inset>
<van-cell title="清除缓存" :value="cacheSize" @click="clearCache" />
<van-cell title="关于我们" is-link to="/about" />
<van-cell title="版本号" value="1.0.0" />
</van-cell-group>
<div class="settings-column">
<van-cell-group inset>
<van-cell title="清除缓存" :value="cacheSize" @click="clearCache" />
<van-cell title="关于我们" is-link to="/about" />
<van-cell title="版本号" value="1.0.0" />
</van-cell-group>
<div class="logout-btn">
<van-button type="danger" block @click="handleLogout">退出登录</van-button>
<div class="logout-btn">
<van-button type="danger" block @click="handleLogout">退出登录</van-button>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -54,15 +65,54 @@ const handleLogout = async () => {
<style scoped lang="scss">
.settings-page {
min-height: 100vh;
background: #f5f5f5;
background: #f6f7fb;
padding-bottom: 60px;
}
.van-cell-group {
margin: 12px;
.settings-shell {
max-width: 1280px;
margin: 0 auto;
padding: 20px 12px 28px;
}
.settings-hero {
padding: 24px 26px;
border-radius: 28px;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: #fff;
margin-bottom: 18px;
h2 {
margin: 0 0 8px;
font-size: 28px;
}
.logout-btn {
padding: 20px 16px;
p {
margin: 0;
color: rgba(255, 255, 255, 0.82);
}
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.settings-column :deep(.van-cell-group) {
margin: 0;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
}
.logout-btn {
margin-top: 18px;
}
@media (max-width: 768px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -44,7 +44,7 @@ const loadVideos = async (isRefresh = false) => {
loading.value = true
try {
const res = await videoApi.getUserVideos(userId, { page: currentPage.value, size: 20 })
const res = await videoApi.getUserVideos(userId, { pageNum: currentPage.value, pageSize: 20 })
if (isRefresh) {
videos.value = res.records
} else {
@@ -82,14 +82,16 @@ onMounted(() => {
<style scoped lang="scss">
.user-videos-page {
min-height: 100vh;
background: #f5f5f5;
background: #f6f7fb;
padding-bottom: 60px;
}
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 12px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px 16px;
max-width: 1400px;
margin: 0 auto;
padding: 16px 12px 24px;
}
</style>
@@ -1,109 +1,100 @@
<template>
<div class="video-detail-page">
<!-- 视频播放器 -->
<div class="player-container">
<VideoPlayer
v-if="video"
:src="video.videoUrl"
:poster="video.coverUrl"
:danmaku-list="danmakuList"
@timeupdate="onTimeUpdate"
@ended="onEnded"
/>
<van-skeleton v-else title :row="3" />
</div>
<!-- 视频信息 -->
<div class="video-info" v-if="video">
<h1 class="video-title">{{ video.title }}</h1>
<!-- 统计数据 -->
<div class="video-stats">
<span>{{ formatPlayCount(video.viewCount) }}播放</span>
<span>{{ formatNumber(video.danmakuCount) }}弹幕</span>
<span>{{ formatDateTime(video.publishTime || video.createTime) }}</span>
</div>
<!-- 操作按钮 -->
<div class="video-actions">
<van-button size="small" :type="video.isLiked ? 'primary' : 'default'" @click="toggleLike">
<van-icon :name="video.isLiked ? 'good-job' : 'good-job-o'" />
{{ formatNumber(video.likeCount) }}
</van-button>
<van-button size="small" :type="video.isCoined ? 'primary' : 'default'" @click="toggleCoin">
<van-icon name="coin" />
{{ formatNumber(video.coinCount) }}
</van-button>
<van-button size="small" :type="video.isFavorited ? 'primary' : 'default'" @click="toggleFavorite">
<van-icon :name="video.isFavorited ? 'star' : 'star-o'" />
{{ formatNumber(video.favoriteCount) }}
</van-button>
<van-button size="small" @click="shareVideo">
<van-icon name="share-o" />
分享
</van-button>
</div>
<!-- 作者信息 -->
<div class="author-info" @click="goToUser(video.userId)">
<van-image :src="video.userAvatar || defaultAvatar" round class="author-avatar" />
<div class="author-detail">
<div class="author-name">{{ video.userNickname }}</div>
<div class="author-fans">{{ formatNumber(video.likeCount) }}粉丝</div>
</div>
<van-button
size="small"
:type="isFollowed ? 'default' : 'primary'"
@click.stop="toggleFollow"
>
{{ isFollowed ? '已关注' : '关注' }}
</van-button>
</div>
<!-- 简介 -->
<div class="video-desc">
<van-collapse v-model="activeCollapse">
<van-collapse-item title="简介" name="desc">
<div class="desc-content">{{ video.description || '暂无简介' }}</div>
<div class="video-tags" v-if="video.tagNames?.length">
<van-tag v-for="tag in video.tagNames" :key="tag" plain>{{ tag }}</van-tag>
</div>
</van-collapse-item>
</van-collapse>
</div>
</div>
<!-- 标签页 -->
<van-tabs v-model:active="activeTab" sticky class="detail-tabs">
<!-- 评论 -->
<van-tab title="评论" name="comment">
<CommentSection v-if="video" :video-id="video.id" />
</van-tab>
<!-- 弹幕 -->
<van-tab title="弹幕" name="danmaku">
<DanmakuList v-if="video" :video-id="video.id" />
</van-tab>
<!-- 相关推荐 -->
<van-tab title="推荐" name="recommend">
<div class="recommend-list">
<VideoCard
v-for="item in recommendVideos"
:key="item.id"
:video="item"
@click="goToVideo(item.id)"
<div class="detail-shell">
<section class="detail-main">
<div class="player-container">
<VideoPlayer
v-if="video"
:src="video.videoUrl"
:poster="video.coverUrl"
:danmaku-list="danmakuList"
@timeupdate="onTimeUpdate"
@ended="onEnded"
/>
<van-skeleton v-else title :row="3" />
</div>
</van-tab>
</van-tabs>
<div class="video-info" v-if="video">
<h1 class="video-title">{{ video.title }}</h1>
<div class="video-stats">
<span>{{ formatPlayCount(video.viewCount) }}播放</span>
<span>{{ formatNumber(video.danmakuCount) }}弹幕</span>
<span>{{ formatDateTime(video.publishTime || video.createTime) }}</span>
</div>
<div class="video-actions">
<van-button size="small" :type="video.isLiked ? 'primary' : 'default'" @click="toggleLike">
<van-icon :name="video.isLiked ? 'good-job' : 'good-job-o'" />
{{ formatNumber(video.likeCount) }}
</van-button>
<van-button size="small" :type="video.isCoined ? 'primary' : 'default'" @click="toggleCoin">
<van-icon name="gold-coin-o" />
{{ formatNumber(video.coinCount) }}
</van-button>
<van-button size="small" :type="video.isFavorited ? 'primary' : 'default'" @click="toggleFavorite">
<van-icon :name="video.isFavorited ? 'star' : 'star-o'" />
{{ formatNumber(video.favoriteCount) }}
</van-button>
<van-button size="small" @click="shareVideo">
<van-icon name="share-o" />
分享
</van-button>
</div>
<div class="author-info" @click="goToUser(video.userId)">
<van-image :src="video.userAvatar || defaultAvatar" round class="author-avatar" />
<div class="author-detail">
<div class="author-name">{{ video.userNickname }}</div>
<div class="author-fans">{{ formatNumber(video.likeCount) }}粉丝</div>
</div>
<van-button size="small" :type="isFollowed ? 'default' : 'primary'" @click.stop="toggleFollow">
{{ isFollowed ? '已关注' : '关注' }}
</van-button>
</div>
<div class="video-desc">
<van-collapse v-model="activeCollapse">
<van-collapse-item title="简介" name="desc">
<div class="desc-content">{{ video.description || '暂无简介' }}</div>
<div class="video-tags" v-if="video.tagNames?.length">
<van-tag v-for="tag in video.tagNames" :key="tag" plain>{{ tag }}</van-tag>
</div>
</van-collapse-item>
</van-collapse>
</div>
</div>
<van-tabs v-model:active="activeTab" sticky class="detail-tabs">
<van-tab title="评论" name="comment">
<CommentSection v-if="video" :video-id="video.id" />
</van-tab>
<van-tab title="弹幕" name="danmaku">
<DanmakuList v-if="video" :video-id="video.id" />
</van-tab>
<van-tab title="推荐" name="recommend">
<div class="recommend-list mobile-recommend">
<VideoCard v-for="item in recommendVideos" :key="item.id" :video="item" @click="goToVideo(item.id)" />
</div>
</van-tab>
</van-tabs>
</section>
<aside class="detail-sidebar">
<div class="sidebar-card">
<div class="sidebar-title">相关推荐</div>
<div class="sidebar-video-list">
<VideoCard v-for="item in recommendVideos" :key="`side-${item.id}`" :video="item" @click="goToVideo(item.id)" />
</div>
</div>
</aside>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showSuccessToast, showConfirmDialog } from 'vant'
import { showToast, showSuccessToast } from 'vant'
import VideoPlayer from '@/components/video/VideoPlayer.vue'
import CommentSection from '@/components/comment/CommentSection.vue'
import DanmakuList from '@/components/danmaku/DanmakuList.vue'
@@ -134,13 +125,17 @@ const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/s
// 加载视频详情
const loadVideo = async () => {
try {
video.value = await videoApi.getDetail(videoId.value)
video.value = await videoApi.getVideoPublic(videoId.value)
// 记录播放
videoApi.recordPlay({
videoId: videoId.value,
progress: 0,
duration: video.value.duration
})
try {
videoApi.recordPlay({
videoId: videoId.value,
progress: 0,
duration: video.value.duration
})
} catch (e) {
// 忽略播放记录错误
}
// 加载弹幕
loadDanmaku()
// 加载推荐
@@ -157,7 +152,7 @@ const loadVideo = async () => {
// 加载弹幕
const loadDanmaku = async () => {
try {
danmakuList.value = await danmakuApi.getAll(videoId.value)
danmakuList.value = await danmakuApi.getAllDanmaku(videoId.value)
} catch (error) {
console.error('加载弹幕失败:', error)
}
@@ -166,8 +161,8 @@ const loadDanmaku = async () => {
// 加载推荐
const loadRecommend = async () => {
try {
const res = await recommendApi.getSimilar(videoId.value, { page: 1, size: 10 })
recommendVideos.value = res.records
const res = await recommendApi.getSimilarVideos(videoId.value, 10)
recommendVideos.value = res
} catch (error) {
console.error('加载推荐失败:', error)
}
@@ -193,11 +188,11 @@ const toggleLike = async () => {
try {
if (video.value.isLiked) {
await videoApi.unlike(video.value.id)
await videoApi.unlikeVideo(video.value.id)
video.value.likeCount--
video.value.isLiked = false
} else {
await videoApi.like(video.value.id)
await videoApi.likeVideo(video.value.id)
video.value.likeCount++
video.value.isLiked = true
}
@@ -215,7 +210,7 @@ const toggleCoin = async () => {
if (!video.value) return
try {
await videoApi.coin(video.value.id)
await videoApi.coinVideo(video.value.id)
video.value.coinCount++
showSuccessToast('投币成功')
} catch (error: any) {
@@ -233,12 +228,12 @@ const toggleFavorite = async () => {
try {
if (video.value.isFavorited) {
await videoApi.unfavorite(video.value.id)
await videoApi.unfavoriteVideo(video.value.id)
video.value.favoriteCount--
video.value.isFavorited = false
} else {
// TODO: 选择收藏夹
await videoApi.favorite({ videoId: video.value.id, favoriteId: 0 })
await videoApi.favoriteVideo(video.value.id, 0)
video.value.favoriteCount++
video.value.isFavorited = true
}
@@ -253,7 +248,7 @@ const shareVideo = async () => {
try {
await navigator.clipboard.writeText(url)
showSuccessToast('链接已复制')
videoApi.share(videoId.value)
videoApi.shareVideo(videoId.value)
} catch {
showToast('复制失败')
}
@@ -305,33 +300,54 @@ const onEnded = () => {
onMounted(() => {
loadVideo()
})
watch(() => route.params.id, () => {
video.value = null
danmakuList.value = []
recommendVideos.value = []
isFollowed.value = false
loadVideo()
})
</script>
<style scoped lang="scss">
.video-detail-page {
min-height: 100vh;
background: #f5f5f5;
background:
linear-gradient(180deg, rgba(14, 165, 233, 0.06), transparent 200px),
#f4f7fb;
}
.detail-shell {
max-width: 1440px;
margin: 0 auto;
padding: 18px 12px 28px;
display: grid;
gap: 20px;
@media (min-width: 1200px) {
grid-template-columns: minmax(0, 1fr) 360px;
}
}
.player-container {
width: 100%;
background: #000;
aspect-ratio: 16 / 9;
position: sticky;
top: 0;
z-index: 100;
@media (min-width: 768px) {
max-height: 70vh;
}
border-radius: 24px;
overflow: hidden;
box-shadow: 0 22px 48px rgba(15, 23, 42, 0.14);
}
.video-info {
background: #fff;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.96);
padding: 18px 18px 14px;
border-radius: 24px;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.06);
margin-top: 16px;
.video-title {
font-size: 18px;
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0 0 8px;
@@ -350,9 +366,10 @@ onMounted(() => {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
.van-button {
flex: 1;
flex: 1 1 120px;
}
}
@@ -407,18 +424,51 @@ onMounted(() => {
}
.detail-tabs {
background: #fff;
background: rgba(255, 255, 255, 0.96);
margin-top: 12px;
border-radius: 24px;
overflow: hidden;
}
.recommend-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px 16px;
padding: 12px;
}
@media (min-width: 768px) {
grid-template-columns: repeat(4, 1fr);
.detail-sidebar {
display: none;
@media (min-width: 1200px) {
display: block;
}
}
.sidebar-card {
position: sticky;
top: 78px;
background: rgba(255, 255, 255, 0.96);
border-radius: 24px;
padding: 18px;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.06);
}
.sidebar-title {
font-size: 18px;
font-weight: 700;
color: #18191c;
margin-bottom: 14px;
}
.sidebar-video-list {
display: grid;
gap: 16px;
}
.mobile-recommend {
@media (min-width: 1200px) {
display: none;
}
}
</style>