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