refactor(frontend): Rework home layout and user interactions

This commit is contained in:
2026-04-22 16:45:00 +08:00
parent 79f255bf6c
commit 59e87faba9
84 changed files with 8299 additions and 2181 deletions
@@ -1,30 +1,273 @@
<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>
<van-nav-bar
title="关于"
fixed
placeholder
left-arrow
@click-left="router.back()"
/>
<div class="about-shell">
<section class="about-hero">
<div>
<p class="eyebrow">
HAOVP
</p>
<h2>高可用在线视频平台前端</h2>
<p class="hero-copy">
面向上传、播放、互动与后台治理链路的用户端界面,当前环境已经对齐单机与高可用后端联调。
</p>
</div>
<div class="hero-badges">
<span>版本 {{ appVersion }}</span>
<span>{{ environmentLabel }}</span>
</div>
</section>
<div class="about-grid">
<section class="about-card">
<div class="card-title">
当前环境
</div>
<div class="env-grid">
<div class="env-item">
<span>API 代理</span>
<strong>{{ apiTarget }}</strong>
</div>
<div class="env-item">
<span>媒体访问</span>
<strong>{{ mediaBaseUrl }}</strong>
</div>
<div class="env-item">
<span>当前账号</span>
<strong>{{ currentAccount }}</strong>
</div>
<div class="env-item">
<span>登录状态</span>
<strong>{{ loginStatus }}</strong>
</div>
</div>
</section>
<section class="about-card">
<div class="card-title">
主要能力
</div>
<div class="capability-list">
<div
v-for="item in capabilities"
:key="item.title"
class="capability-item"
>
<strong>{{ item.title }}</strong>
<span>{{ item.description }}</span>
</div>
</div>
</section>
</div>
<section class="about-card">
<div class="card-title">
技术栈与说明
</div>
<van-cell-group inset>
<van-cell
title="用户端技术栈"
value="Vue 3 + Vite + Vant + Pinia"
/>
<van-cell
title="联调链路"
value="注册、登录、上传、审核、公开播放、评论互动"
/>
<van-cell
title="部署目标"
value="单机 Compose 与三节点高可用后端"
/>
<van-cell
title="项目定位"
value="毕业设计实验平台"
/>
</van-cell-group>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const appVersion = '1.0.0'
const apiTarget = import.meta.env.VITE_PROXY_TARGET || window.location.origin
const mediaBaseUrl = import.meta.env.VITE_MEDIA_BASE_URL || '未配置'
const environmentLabel = import.meta.env.DEV ? '开发环境' : '生产环境'
const currentAccount = computed(() => userStore.authUser?.username || userStore.userInfo?.nickname || '未登录')
const loginStatus = computed(() => (userStore.isLoggedIn ? '已登录' : '未登录'))
const capabilities = [
{
title: '内容上传与播放',
description: '支持视频上传、审核后公开播放、封面与媒体资源分离访问。'
},
{
title: '互动社区能力',
description: '支持评论、回复、点赞、收藏、私信和通知等常用互动链路。'
},
{
title: '平台治理能力',
description: '支持后台审核、用户管理、评论管理与统计类能力的联调展示。'
},
{
title: '高可用部署演进',
description: '前端已按网关与对象存储入口适配单机和多节点后端部署。'
}
]
</script>
<style scoped lang="scss">
.about-page { min-height: 100vh; background: #f6f7fb; }
.about-page {
min-height: 100vh;
background:
radial-gradient(circle at top right, rgba(59, 130, 246, 0.12), transparent 28%),
#f6f7fb;
padding-bottom: 60px;
}
.about-shell {
max-width: 1280px;
margin: 0 auto;
padding: 20px 12px 28px;
}
.about-hero,
.about-card {
margin: 20px 16px;
padding: 24px;
border-radius: 24px;
background: #fff;
border-radius: 28px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
}
.about-hero {
padding: 28px;
display: flex;
justify-content: space-between;
gap: 24px;
margin-bottom: 18px;
background: linear-gradient(135deg, #0f172a, #1d4ed8 58%, #38bdf8);
color: #fff;
}
.eyebrow {
margin: 0 0 12px;
font-size: 12px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.72);
}
.about-hero h2 {
margin: 0 0 10px;
font-size: 32px;
}
.hero-copy {
margin: 0;
max-width: 720px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.86);
}
.hero-badges {
display: flex;
flex-direction: column;
gap: 10px;
span {
padding: 12px 14px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.12);
white-space: nowrap;
}
}
.about-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
margin-bottom: 18px;
}
.about-card {
padding: 24px;
}
.card-title {
margin-bottom: 16px;
font-size: 18px;
font-weight: 700;
}
.env-grid,
.capability-list {
display: grid;
gap: 12px;
}
.env-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.env-item,
.capability-item {
background: #f8fafc;
border-radius: 18px;
padding: 16px;
}
.env-item {
display: grid;
gap: 8px;
span {
font-size: 12px;
color: #9499a0;
}
strong {
font-size: 14px;
line-height: 1.5;
word-break: break-all;
}
}
.capability-item {
display: grid;
gap: 8px;
strong {
color: #18191c;
}
span {
color: #61666d;
line-height: 1.6;
}
}
@media (max-width: 768px) {
.about-hero,
.about-grid {
grid-template-columns: 1fr;
}
.about-hero {
flex-direction: column;
}
.env-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,6 +1,12 @@
<template>
<div class="follow-page">
<van-nav-bar title="粉丝" fixed placeholder left-arrow @click-left="router.back()" />
<van-nav-bar
title="粉丝"
fixed
placeholder
left-arrow
@click-left="router.back()"
/>
<van-list
v-model:loading="loading"
@@ -9,9 +15,18 @@
@load="loadMore"
>
<van-cell-group inset>
<van-cell v-for="user in users" :key="user.id" @click="goToProfile(user.id)">
<van-cell
v-for="user in users"
:key="user.id"
@click="goToProfile(user.id)"
>
<template #icon>
<van-image :src="user.avatar || defaultAvatar" round width="40" height="40" />
<van-image
:src="getAvatarUrl(user.avatar)"
round
width="40"
height="40"
/>
</template>
<template #title>
<span class="nickname">{{ user.nickname }}</span>
@@ -20,13 +35,20 @@
<span class="signature">{{ user.signature || '这个人很懒' }}</span>
</template>
<template #value>
<van-button size="small" :type="user.isFollowed ? 'default' : 'primary'" @click.stop="toggleFollow(user)">
<van-button
size="small"
:type="user.isFollowed ? 'default' : 'primary'"
@click.stop="toggleFollow(user)"
>
{{ user.isFollowed ? '已关注' : '关注' }}
</van-button>
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="!loading && !users.length" description="暂无粉丝" />
<van-empty
v-if="!loading && !users.length"
description="暂无粉丝"
/>
</van-list>
</div>
</template>
@@ -37,6 +59,7 @@ import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import { resolveAvatarUrl } from '@/utils/media'
import type { UserFollow } from '@/types'
const router = useRouter()
@@ -47,14 +70,22 @@ const userId = Number(route.params.id)
const users = ref<UserFollow[]>([])
const loading = ref(false)
const finished = ref(false)
const page = 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 getAvatarUrl = (avatar?: string) => resolveAvatarUrl(avatar, defaultAvatar)
const loadUsers = async () => {
const loadUsers = async (isRefresh = false) => {
if (isRefresh) {
users.value = []
page.value = 1
finished.value = false
}
loading.value = true
try {
const data = await userApi.getFollowers(userId)
users.value = data
finished.value = true
const data = await userApi.getFollowers(userId, { page: page.value, size: 10 })
users.value.push(...data.records)
finished.value = users.value.length >= data.total || data.records.length < 10
page.value++
} catch (error) {
showToast('加载失败')
} finally {
@@ -91,7 +122,7 @@ const goToProfile = (id: number) => {
}
onMounted(() => {
loadUsers()
loadUsers(true)
})
</script>
@@ -1,6 +1,12 @@
<template>
<div class="follow-page">
<van-nav-bar title="关注" fixed placeholder left-arrow @click-left="router.back()" />
<van-nav-bar
title="关注"
fixed
placeholder
left-arrow
@click-left="router.back()"
/>
<van-list
v-model:loading="loading"
@@ -9,9 +15,18 @@
@load="loadMore"
>
<van-cell-group inset>
<van-cell v-for="user in users" :key="user.id" @click="goToProfile(user.id)">
<van-cell
v-for="user in users"
:key="user.id"
@click="goToProfile(user.id)"
>
<template #icon>
<van-image :src="user.avatar || defaultAvatar" round width="40" height="40" />
<van-image
:src="getAvatarUrl(user.avatar)"
round
width="40"
height="40"
/>
</template>
<template #title>
<span class="nickname">{{ user.nickname }}</span>
@@ -20,13 +35,20 @@
<span class="signature">{{ user.signature || '这个人很懒' }}</span>
</template>
<template #value>
<van-button size="small" type="default" @click.stop="unfollow(user)">
<van-button
size="small"
type="default"
@click.stop="unfollow(user)"
>
已关注
</van-button>
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="!loading && !users.length" description="暂无关注" />
<van-empty
v-if="!loading && !users.length"
description="暂无关注"
/>
</van-list>
</div>
</template>
@@ -36,6 +58,7 @@ import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { userApi } from '@/api/user.ts'
import { resolveAvatarUrl } from '@/utils/media'
import type { UserFollow } from '@/types'
const router = useRouter()
@@ -45,15 +68,23 @@ const userId = Number(route.params.id)
const users = ref<UserFollow[]>([])
const loading = ref(false)
const finished = ref(false)
const page = 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 getAvatarUrl = (avatar?: string) => resolveAvatarUrl(avatar, defaultAvatar)
const loadUsers = async () => {
const loadUsers = async (isRefresh = false) => {
if (isRefresh) {
users.value = []
page.value = 1
finished.value = false
}
loading.value = true
try {
const data = await userApi.getFollowing(userId)
users.value = data
finished.value = true
const data = await userApi.getFollowing(userId, { page: page.value, size: 10 })
users.value.push(...data.records)
finished.value = users.value.length >= data.total || data.records.length < 10
page.value++
} catch (error) {
showToast('加载失败')
} finally {
@@ -80,7 +111,7 @@ const goToProfile = (id: number) => {
}
onMounted(() => {
loadUsers()
loadUsers(true)
})
</script>
@@ -1,45 +1,104 @@
<template>
<div class="history-page">
<van-nav-bar title="播放历史" fixed placeholder left-arrow @click-left="router.back()">
<template #right>
<van-icon name="delete-o" size="22" @click="clearHistory" />
</template>
</van-nav-bar>
<div class="page-shell history-shell">
<section class="history-hero">
<div class="hero-copy">
<p class="hero-eyebrow">
Watch History
</p>
<h1>播放历史</h1>
<span>继续观看你最近浏览过的内容</span>
</div>
<van-button
plain
round
class="clear-button"
@click="clearHistory"
>
清空历史
</van-button>
</section>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
<section class="stats-grid">
<div class="stat-card">
<span>历史记录</span>
<strong>{{ videos.length }}</strong>
</div>
<div class="stat-card">
<span>观看完成</span>
<strong>{{ finishedCount }}</strong>
</div>
<div class="stat-card">
<span>继续观看</span>
<strong>{{ continueCount }}</strong>
</div>
</section>
<section
v-if="videos.length"
class="history-panel"
>
<van-cell-group v-if="videos.length" inset>
<van-cell v-for="item in videos" :key="item.videoId" @click="goToVideo(item.videoId)">
<template #icon>
<van-image :src="item.coverUrl" width="80" height="45" fit="cover" radius="4" />
</template>
<template #title>
<div class="video-title">{{ item.title }}</div>
</template>
<template #label>
<div class="video-progress">
观看至 {{ formatDuration(item.progress) }} / {{ formatDuration(item.duration) }}
<div class="panel-head">
<strong>最近观看</strong>
<span>{{ videos.length }} 条记录</span>
</div>
<div class="video-list">
<article
v-for="item in videos"
:key="item.videoId"
class="video-item"
@click="goToVideo(item.videoId)"
>
<div class="video-cover">
<van-image
:src="getCoverUrl(item.coverUrl)"
fit="cover"
class="cover-image"
/>
<span class="duration">{{ formatDuration(item.duration) }}</span>
</div>
<div class="video-body">
<div class="video-title-row">
<h3>{{ item.title }}</h3>
<van-tag :type="item.progress >= item.duration && item.duration > 0 ? 'success' : 'primary'">
{{ item.progress >= item.duration && item.duration > 0 ? '已看完' : '观看中' }}
</van-tag>
</div>
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无播放记录" />
</van-list>
</van-pull-refresh>
<div class="video-meta">
<span>观看至 {{ formatDuration(item.progress) }}</span>
<span>总时长 {{ formatDuration(item.duration) }}</span>
</div>
<div class="progress-track">
<div
class="progress-bar"
:style="{ width: `${item.duration ? Math.min((item.progress / item.duration) * 100, 100) : 0}%` }"
/>
</div>
</div>
</article>
</div>
</section>
<van-empty
v-else
description="暂无播放记录"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showConfirmDialog } from 'vant'
import { showConfirmDialog, showToast } from 'vant'
import { formatDuration } from '@/utils'
import { mockVideos } from '@/mock/data'
import { resolveMediaUrl } from '@/utils/media'
import { videoApi } from '@/api/video.ts'
import { useVideoStore } from '@/stores/video.ts'
interface HistoryItem {
videoId: number
@@ -50,35 +109,39 @@ interface HistoryItem {
}
const router = useRouter()
const videoStore = useVideoStore()
const videos = ref<HistoryItem[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const historyRecords = computed(() => [...videoStore.playHistory].reverse())
const defaultCover =
'data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 180\"%3E%3Crect width=\"320\" height=\"180\" fill=\"%23edf2f7\"/%3E%3Ctext x=\"50%25\" y=\"50%25\" dy=\".3em\" text-anchor=\"middle\" fill=\"%239499a0\"%3EHAOVP%3C/text%3E%3C/svg%3E'
const loadVideos = async (isRefresh = false) => {
if (isRefresh) {
videos.value = []
finished.value = false
const finishedCount = computed(() => videos.value.filter((item) => item.duration > 0 && item.progress >= item.duration).length)
const continueCount = computed(() => videos.value.length - finishedCount.value)
const getCoverUrl = (coverUrl?: string) => resolveMediaUrl(coverUrl) || defaultCover
const loadVideos = async () => {
try {
const details = await Promise.all(
historyRecords.value.slice(0, 30).map(async (record) => {
try {
const video = await videoApi.getVideoPublic(record.videoId)
return {
videoId: record.videoId,
title: video.title,
coverUrl: video.coverUrl,
progress: record.playTime,
duration: video.duration
}
} catch {
return null
}
})
)
videos.value = details.filter(Boolean) as HistoryItem[]
} catch {
showToast('加载失败')
}
loading.value = true
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
}
const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
}
const clearHistory = async () => {
@@ -87,10 +150,11 @@ const clearHistory = async () => {
title: '提示',
message: '确定要清空播放历史吗?'
})
videoStore.playHistory = []
videos.value = []
showToast('已清空')
} catch {
// 取消
// cancel
}
}
@@ -98,27 +162,220 @@ const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
loadVideos(true)
})
onMounted(loadVideos)
</script>
<style scoped lang="scss">
.history-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
background: #f4f5f7;
padding-bottom: 36px;
}
.video-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.history-shell {
padding-top: 18px;
}
.history-hero,
.history-panel,
.stat-card {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.history-hero {
padding: 22px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.hero-copy {
display: grid;
gap: 6px;
h1 {
margin: 0;
font-size: 30px;
color: #18191c;
}
.video-progress {
font-size: 12px;
color: #999;
span {
color: #61666d;
font-size: 14px;
}
}
.hero-eyebrow {
margin: 0;
color: var(--link-color);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.stats-grid {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.stat-card {
padding: 18px;
display: grid;
gap: 8px;
span {
color: #9499a0;
font-size: 13px;
}
strong {
color: #18191c;
font-size: 28px;
line-height: 1;
}
}
.history-panel {
margin-top: 18px;
padding: 18px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
strong {
font-size: 20px;
}
span {
color: #9499a0;
font-size: 13px;
}
}
.video-list {
display: grid;
gap: 16px;
}
.video-item {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 18px;
padding: 14px;
border-radius: 10px;
background: #f8fafc;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
&:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
}
.video-cover {
position: relative;
overflow: hidden;
border-radius: 8px;
aspect-ratio: 16 / 9;
background: #edf2f7;
}
.cover-image {
width: 100%;
height: 100%;
}
.duration {
position: absolute;
right: 8px;
bottom: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.74);
color: #fff;
font-size: 12px;
}
.video-body {
min-width: 0;
display: grid;
gap: 12px;
align-content: center;
}
.video-title-row {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
h3 {
margin: 0;
color: #18191c;
font-size: 20px;
line-height: 1.4;
}
}
.video-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
color: #9499a0;
font-size: 13px;
}
.progress-track {
height: 8px;
border-radius: 999px;
background: #e6edf5;
overflow: hidden;
}
.progress-bar {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #00a1d6, #fb7299);
}
@media (max-width: 960px) {
.stats-grid {
grid-template-columns: 1fr;
}
.video-item {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.history-hero {
flex-direction: column;
align-items: flex-start;
}
.hero-copy h1 {
font-size: 24px;
}
.history-panel {
padding: 14px;
}
.video-item {
padding: 10px;
}
}
</style>
@@ -1,69 +1,138 @@
<template>
<div class="my-videos-page">
<van-nav-bar title="我的视频" fixed placeholder left-arrow @click-left="router.back()">
<template #right>
<van-icon name="plus" size="22" @click="router.push('/upload')" />
</template>
</van-nav-bar>
<div class="page-shell videos-shell">
<section class="videos-hero">
<div class="hero-copy">
<p class="hero-eyebrow">
Creator Center
</p>
<h1>我的视频</h1>
<span>管理已上传内容与当前审核状态</span>
</div>
<van-button
type="primary"
round
class="upload-button"
@click="router.push('/upload')"
>
上传视频
</van-button>
</section>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
<section class="stats-grid">
<div class="stat-card">
<span>全部视频</span>
<strong>{{ videos.length }}</strong>
</div>
<div class="stat-card">
<span>已发布</span>
<strong>{{ publishedCount }}</strong>
</div>
<div class="stat-card">
<span>审核中</span>
<strong>{{ reviewingCount }}</strong>
</div>
<div class="stat-card">
<span>异常状态</span>
<strong>{{ exceptionalCount }}</strong>
</div>
</section>
<section
v-if="videos.length"
class="videos-panel"
>
<van-cell-group v-if="videos.length" inset>
<van-cell v-for="video in videos" :key="video.id" @click="goToVideo(video.id)">
<template #icon>
<van-image :src="video.coverUrl" width="80" height="45" fit="cover" radius="4" />
</template>
<template #title>
<div class="video-title">{{ video.title }}</div>
</template>
<template #label>
<div class="video-stats">
<span>{{ formatNumber(video.viewCount) }}播放</span>
<span>{{ formatNumber(video.likeCount) }}赞</span>
<div class="panel-head">
<strong>内容列表</strong>
<span>{{ videos.length }} 条记录</span>
</div>
<div class="video-list">
<article
v-for="video in videos"
:key="video.id"
class="video-item"
@click="goToVideo(video.id)"
>
<div class="video-cover">
<van-image
:src="getCoverUrl(video.coverUrl)"
fit="cover"
class="cover-image"
/>
<span class="duration">{{ formatDuration(video.duration) }}</span>
</div>
<div class="video-body">
<div class="video-title-row">
<h3>{{ video.title }}</h3>
<van-tag :type="getStatusType(video.status)">
{{ getStatusText(video.status) }}
</van-tag>
</div>
</template>
<template #value>
<van-tag :type="getStatusType(video.status)">{{ getStatusText(video.status) }}</van-tag>
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无视频">
<van-button type="primary" round @click="router.push('/upload')">去上传</van-button>
</van-empty>
</van-list>
</van-pull-refresh>
<p class="video-desc">
{{ video.description || '暂无简介' }}
</p>
<div class="video-meta">
<span>{{ video.categoryName || '未分类' }}</span>
<span>{{ formatPlayCount(video.viewCount) }} 播放</span>
<span>{{ formatNumber(video.likeCount) }} 点赞</span>
<span>{{ formatPublishTime(video.publishTime || video.createTime) }}</span>
</div>
</div>
</article>
</div>
</section>
<van-empty
v-else
description="暂无视频"
>
<van-button
type="primary"
round
@click="router.push('/upload')"
>
去上传
</van-button>
</van-empty>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { videoApi } from '@/api/video.ts'
import { useUserStore } from '@/stores/user.ts'
import { formatNumber } from '@/utils'
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
import { resolveMediaUrl } from '@/utils/media'
import type { VideoInfo } from '@/types'
const router = useRouter()
const userStore = useUserStore()
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const resolvedUserId = ref<number | null>(null)
const defaultCover =
'data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 180\"%3E%3Crect width=\"320\" height=\"180\" fill=\"%23edf2f7\"/%3E%3Ctext x=\"50%25\" y=\"50%25\" dy=\".3em\" text-anchor=\"middle\" fill=\"%239499a0\"%3EHAOVP%3C/text%3E%3C/svg%3E'
const publishedCount = computed(() => videos.value.filter((video) => video.status === 1).length)
const reviewingCount = computed(() => videos.value.filter((video) => video.status === 0).length)
const exceptionalCount = computed(() => videos.value.filter((video) => video.status !== 0 && video.status !== 1).length)
const getCoverUrl = (coverUrl?: string) => resolveMediaUrl(coverUrl) || defaultCover
const getStatusType = (status: number): 'warning' | 'success' | 'danger' | 'primary' => {
const types: Record<number, 'warning' | 'success' | 'danger' | 'primary'> = {
0: 'warning',
1: 'success',
2: 'danger',
3: 'primary'
2: 'primary',
3: 'danger'
}
return types[status] || 'primary'
}
@@ -72,76 +141,305 @@ const getStatusText = (status: number) => {
const texts: Record<number, string> = {
0: '审核中',
1: '已发布',
2: '审核拒绝',
3: '已下架'
2: '已下架',
3: '审核拒绝'
}
return texts[status] || '未知'
}
const loadVideos = async (isRefresh = false) => {
if (!userStore.userId) return
const formatPublishTime = (value?: string) => {
if (!value) return '未发布'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date)
}
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
const loadVideos = async () => {
if (!resolvedUserId.value) return
loading.value = true
try {
const res = await videoApi.getUserVideos(userStore.userId, { pageNum: currentPage.value, pageSize: 20 })
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
}
if (videos.value.length >= res.total || res.records.length < 20) {
finished.value = true
}
currentPage.value++
} catch (error) {
const res = await videoApi.getUserVideos(resolvedUserId.value, { pageNum: 1, pageSize: 50 })
videos.value = res.records
} catch {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
loadVideos(true)
})
const initPage = async () => {
try {
if (!userStore.authUser) {
await userStore.fetchAuthUser()
}
if (!userStore.userInfo) {
await userStore.fetchUserInfo()
}
resolvedUserId.value = userStore.userId || null
if (!resolvedUserId.value) {
showToast('未获取到当前用户信息')
return
}
await loadVideos()
} catch {
showToast('加载失败')
}
}
onMounted(initPage)
</script>
<style scoped lang="scss">
.my-videos-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
background: #f4f5f7;
padding-bottom: 36px;
}
.video-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.videos-shell {
padding-top: 18px;
}
.videos-hero,
.videos-panel,
.stat-card {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.videos-hero {
padding: 22px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background:
linear-gradient(135deg, rgba(0, 161, 214, 0.08), rgba(251, 114, 153, 0.08)),
#fff;
}
.hero-copy {
display: grid;
gap: 6px;
h1 {
margin: 0;
font-size: 30px;
color: #18191c;
}
.video-stats {
display: flex;
gap: 12px;
font-size: 12px;
color: #999;
span {
color: #61666d;
font-size: 14px;
}
}
.hero-eyebrow {
margin: 0;
color: var(--link-color);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.upload-button {
flex-shrink: 0;
}
.stats-grid {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.stat-card {
padding: 18px;
display: grid;
gap: 8px;
span {
color: #9499a0;
font-size: 13px;
}
strong {
color: #18191c;
font-size: 28px;
line-height: 1;
}
}
.videos-panel {
margin-top: 18px;
padding: 18px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
strong {
font-size: 20px;
}
span {
color: #9499a0;
font-size: 13px;
}
}
.video-list {
display: grid;
gap: 16px;
}
.video-item {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 18px;
padding: 14px;
border-radius: 10px;
background: #f8fafc;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
&:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
}
.video-cover {
position: relative;
overflow: hidden;
border-radius: 8px;
aspect-ratio: 16 / 9;
background: #edf2f7;
}
.cover-image {
width: 100%;
height: 100%;
}
.duration {
position: absolute;
right: 8px;
bottom: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.74);
color: #fff;
font-size: 12px;
}
.video-body {
min-width: 0;
display: grid;
gap: 12px;
align-content: center;
}
.video-title-row {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
h3 {
margin: 0;
color: #18191c;
font-size: 20px;
line-height: 1.4;
}
}
.video-title-row :deep(.van-tag--success) {
background: #e8f8ee;
color: #18a058;
border-color: #e8f8ee;
}
.video-title-row :deep(.van-tag--warning) {
background: #fff5dd;
color: #d77a00;
border-color: #fff5dd;
}
.video-title-row :deep(.van-tag--danger) {
background: #fff0f0;
color: #d14343;
border-color: #fff0f0;
}
.video-title-row :deep(.van-tag--primary) {
background: #edf7ff;
color: #007fff;
border-color: #edf7ff;
}
.video-desc {
margin: 0;
color: #61666d;
font-size: 14px;
line-height: 1.7;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.video-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
color: #9499a0;
font-size: 13px;
}
@media (max-width: 960px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.video-item {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.videos-hero {
flex-direction: column;
align-items: flex-start;
}
.hero-copy h1 {
font-size: 24px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.videos-panel {
padding: 14px;
}
.video-item {
padding: 10px;
}
}
</style>
@@ -1,30 +1,79 @@
<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 class="page-shell subpage-shell">
<div class="subpage-head">
<button
class="back-link"
@click="router.back()"
>
返回
</button>
<strong>通知设置</strong>
</div>
<div class="panel-card">
<div class="status-row">
<span>当前开启</span>
<strong>{{ enabledSummary }}</strong>
</div>
<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>
<van-button
type="primary"
block
class="save-btn"
:loading="saving"
@click="save"
>
保存设置
</van-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { showSuccessToast, showToast } from 'vant'
import { userApi } from '@/api/user.ts'
const router = useRouter()
@@ -34,18 +83,94 @@ const form = reactive({
followNotify: 1,
systemNotify: 1
})
const saving = ref(false)
const enabledSummary = computed(() => {
const enabled: string[] = []
if (form.commentNotify) enabled.push('评论')
if (form.likeNotify) enabled.push('点赞')
if (form.followNotify) enabled.push('关注')
if (form.systemNotify) enabled.push('系统')
return enabled.length ? enabled.join(' / ') : '全部关闭'
})
const save = async () => {
await userApi.updateSettings(form)
showSuccessToast('已保存')
saving.value = true
try {
await userApi.updateSettings(form)
showSuccessToast('通知设置已保存')
} catch (error: any) {
showToast(error?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(async () => {
Object.assign(form, await userApi.getSettings())
try {
Object.assign(form, await userApi.getSettings())
} catch (error: any) {
showToast(error?.message || '加载失败')
}
})
</script>
<style scoped lang="scss">
.settings-subpage { min-height: 100vh; background: #f6f7fb; }
.submit { padding: 20px 16px; }
.settings-subpage {
min-height: 100vh;
background: #f4f5f7;
padding-bottom: 36px;
}
.subpage-shell {
padding-top: 18px;
}
.subpage-head,
.panel-card {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.subpage-head {
padding: 14px 18px;
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
strong {
font-size: 20px;
}
}
.back-link {
color: var(--link-color);
font-size: 13px;
}
.panel-card {
padding: 18px;
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
span {
color: #9499a0;
font-size: 13px;
}
strong {
color: #18191c;
}
}
.save-btn {
margin-top: 16px;
}
</style>
@@ -1,49 +1,144 @@
<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 class="page-shell subpage-shell">
<div class="subpage-head">
<button
class="back-link"
@click="router.back()"
>
返回
</button>
<strong>隐私设置</strong>
</div>
<div class="panel-card">
<div class="status-row">
<span>当前可见范围</span>
<strong>{{ currentPrivacyLabel }}</strong>
</div>
<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-group>
<van-button
type="primary"
block
class="save-btn"
:loading="saving"
@click="save"
>
保存设置
</van-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showSuccessToast } from 'vant'
import { showSuccessToast, showToast } from 'vant'
import { userApi } from '@/api/user.ts'
const router = useRouter()
const form = reactive({ privacyLevel: 1 })
const saving = ref(false)
const privacyOptions = [
{ text: '公开', value: 1 },
{ text: '仅关注可见', value: 2 },
{ text: '仅自己可见', value: 3 }
]
const currentPrivacyLabel = computed(() => privacyOptions.find((item) => item.value === form.privacyLevel)?.text || '未设置')
const save = async () => {
await userApi.updateSettings({ privacyLevel: form.privacyLevel })
showSuccessToast('已保存')
saving.value = true
try {
await userApi.updateSettings({ privacyLevel: form.privacyLevel })
showSuccessToast('隐私设置已保存')
} catch (error: any) {
showToast(error?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(async () => {
const settings = await userApi.getSettings()
form.privacyLevel = settings.privacyLevel
try {
const settings = await userApi.getSettings()
form.privacyLevel = settings.privacyLevel
} catch (error: any) {
showToast(error?.message || '加载失败')
}
})
</script>
<style scoped lang="scss">
.settings-subpage { min-height: 100vh; background: #f6f7fb; }
.submit { padding: 20px 16px; }
.settings-subpage {
min-height: 100vh;
background: #f4f5f7;
padding-bottom: 36px;
}
.subpage-shell {
padding-top: 18px;
}
.subpage-head,
.panel-card {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.subpage-head {
padding: 14px 18px;
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
strong {
font-size: 20px;
}
}
.back-link {
color: var(--link-color);
font-size: 13px;
}
.panel-card {
padding: 18px;
}
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
span {
color: #9499a0;
font-size: 13px;
}
strong {
color: #18191c;
}
}
.save-btn {
margin-top: 16px;
}
</style>
@@ -1,39 +1,119 @@
<template>
<div class="profile-edit-page">
<van-nav-bar title="编辑资料" fixed placeholder left-arrow @click-left="router.back()">
<template #right>
<van-button type="primary" size="small" :loading="saving" @click="handleSave">保存</van-button>
</template>
</van-nav-bar>
<div class="page-shell edit-shell">
<div class="page-head">
<button
class="back-link"
@click="router.back()"
>
返回
</button>
<strong>编辑资料</strong>
<van-button
type="primary"
size="small"
:loading="saving"
@click="handleSave"
>
保存
</van-button>
</div>
<van-form @submit="handleSave">
<van-cell-group inset>
<van-cell title="头像" is-link @click="chooseAvatar">
<template #value>
<van-image :src="form.avatar || defaultAvatar" round width="48" height="48" fit="cover" />
</template>
</van-cell>
<van-field v-model="form.nickname" name="nickname" label="昵称" placeholder="请输入昵称" />
<van-field v-model="form.signature" name="signature" label="签名" type="textarea" rows="2" autosize placeholder="介绍一下自己" maxlength="100" show-word-limit />
<van-field name="gender" label="性别">
<template #input>
<van-radio-group v-model="form.gender" direction="horizontal">
<van-radio :value="0">保密</van-radio>
<van-radio :value="1">男</van-radio>
<van-radio :value="2">女</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field v-model="form.birthday" name="birthday" label="生日" is-link readonly placeholder="选择生日" @click="showBirthdayPicker = true" />
<van-field v-model="form.region" name="region" label="地区" placeholder="请输入地区" />
</van-cell-group>
</van-form>
<div class="panel-card">
<van-form @submit="handleSave">
<van-cell-group inset>
<van-cell
title="头像"
is-link
@click="chooseAvatar"
>
<template #value>
<van-image
:src="getAvatarUrl(form.avatar)"
round
width="48"
height="48"
fit="cover"
/>
</template>
</van-cell>
<van-field
v-model="form.nickname"
name="nickname"
label="昵称"
placeholder="请输入昵称"
/>
<van-field
v-model="form.signature"
name="signature"
label="签名"
type="textarea"
rows="2"
autosize
maxlength="100"
show-word-limit
/>
<van-field
name="gender"
label="性别"
>
<template #input>
<van-radio-group
v-model="form.gender"
direction="horizontal"
>
<van-radio :value="0">
保密
</van-radio>
<van-radio :value="1">
男
</van-radio>
<van-radio :value="2">
女
</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field
v-model="form.birthday"
name="birthday"
label="生日"
is-link
readonly
placeholder="选择生日"
@click="showBirthdayPicker = true"
/>
<van-field
v-model="form.region"
name="region"
label="地区"
placeholder="请输入地区"
/>
</van-cell-group>
</van-form>
</div>
</div>
<van-popup v-model:show="showBirthdayPicker" position="bottom" round>
<van-date-picker v-model="selectedDate" title="选择生日" @confirm="onBirthdayConfirm" @cancel="showBirthdayPicker = false" />
<van-popup
v-model:show="showBirthdayPicker"
position="bottom"
round
>
<van-date-picker
v-model="selectedDate"
title="选择生日"
@confirm="onBirthdayConfirm"
@cancel="showBirthdayPicker = false"
/>
</van-popup>
<input ref="avatarInput" type="file" accept="image/*" style="display: none" @change="onAvatarChange" />
<input
ref="avatarInput"
type="file"
accept="image/*"
style="display: none"
@change="onAvatarChange"
>
</div>
</template>
@@ -42,15 +122,16 @@ import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { userApi } from '@/api/user.ts'
import { resolveAvatarUrl } from '@/utils/media'
const router = useRouter()
const saving = ref(false)
const showBirthdayPicker = ref(false)
const avatarInput = ref<HTMLInputElement>()
const selectedDate = ref(['2000', '01', '01'])
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
const defaultAvatar =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
const getAvatarUrl = (avatar?: string) => resolveAvatarUrl(avatar, defaultAvatar)
const form = reactive({
avatar: '',
@@ -70,17 +151,13 @@ const loadProfile = async () => {
form.gender = profile.gender || 0
form.birthday = profile.birthday || ''
form.region = profile.region || ''
if (form.birthday) {
selectedDate.value = form.birthday.split('-')
}
} catch (error) {
if (form.birthday) selectedDate.value = form.birthday.split('-')
} catch {
showToast('加载资料失败')
}
}
const chooseAvatar = () => {
avatarInput.value?.click()
}
const chooseAvatar = () => avatarInput.value?.click()
const onAvatarChange = async (e: Event) => {
const target = e.target as HTMLInputElement
@@ -90,7 +167,7 @@ const onAvatarChange = async (e: Event) => {
const url = await userApi.uploadAvatar(file)
form.avatar = url
showSuccessToast('头像上传成功')
} catch (error) {
} catch {
showToast('头像上传失败')
}
}
@@ -105,7 +182,6 @@ const handleSave = async () => {
showToast('请输入昵称')
return
}
saving.value = true
try {
await userApi.updateProfile({
@@ -115,36 +191,55 @@ const handleSave = async () => {
birthday: form.birthday,
region: form.region
})
if (form.avatar) {
// 头像已单独上传
}
showSuccessToast('保存成功')
router.back()
} catch (error) {
} catch {
showToast('保存失败')
} finally {
saving.value = false
}
}
onMounted(() => {
loadProfile()
})
onMounted(loadProfile)
</script>
<style scoped lang="scss">
.profile-edit-page {
min-height: 100vh;
background:
linear-gradient(180deg, rgba(59, 130, 246, 0.08), transparent 180px),
#f4f7fb;
padding-bottom: 60px;
background: #f4f5f7;
padding-bottom: 36px;
}
.van-cell-group {
margin: 12px;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
.edit-shell {
padding-top: 18px;
}
.page-head,
.panel-card {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.page-head {
padding: 14px 18px;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
margin-bottom: 16px;
strong {
font-size: 20px;
}
}
.back-link {
color: var(--link-color);
font-size: 13px;
}
.panel-card {
padding: 18px;
}
</style>
@@ -1,65 +1,305 @@
<template>
<div class="profile-page">
<div class="profile-shell">
<div v-if="loading" class="loading-container">
<van-loading size="24px">加载中...</van-loading>
<div
v-if="loading"
class="loading-container"
>
<van-loading size="24px">
加载中...
</van-loading>
</div>
<template v-else-if="userInfo">
<div
v-if="notificationBackVisible"
class="return-banner"
>
<div>
<strong>来自通知跳转</strong>
<span>这个用户和一条通知相关,你可以返回通知列表继续处理。</span>
</div>
<van-button
size="small"
round
plain
@click="goBackToNotifications"
>
返回通知
</van-button>
</div>
<div class="profile-banner">
<div class="banner-overlay"></div>
<div class="banner-overlay" />
<div class="banner-content">
<p class="eyebrow">
Creator Space
</p>
<strong>{{ userInfo.nickname }}</strong>
<span>{{ isSelf ? '管理你的公开形象与内容输出' : '浏览这个创作者的公开空间' }}</span>
</div>
</div>
<div class="user-header">
<div class="user-info">
<van-image :src="userInfo.avatar || defaultAvatar" round width="104" height="104" />
<van-image
:src="getAvatarUrl(userInfo.avatar)"
round
width="104"
height="104"
/>
<div class="info">
<h2 class="nickname">{{ userInfo.nickname }}</h2>
<h2 class="nickname">
{{ userInfo.nickname }}
</h2>
<div class="meta-row">
<div class="level">Lv.{{ userInfo.level }}</div>
<div class="region">{{ userInfo.region }}</div>
<div class="level">
Lv.{{ userInfo.level }}
</div>
<div
v-if="vipLabel"
class="vip"
>
{{ vipLabel }}
</div>
<div class="gender">
{{ genderLabel }}
</div>
<div class="region">
{{ userInfo.region || '未设置地区' }}
</div>
</div>
<div class="signature">
{{ userInfo.signature || '这个人很懒,什么都没写' }}
</div>
<div class="signature">{{ userInfo.signature || '这个人很懒,什么都没写' }}</div>
</div>
</div>
<div class="stats">
<div class="stat-item" @click="goToUserVideos">
<div class="value">{{ userInfo.videoCount }}</div>
<div class="label">视频</div>
<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
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
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 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">
<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
v-if="!isSelf"
plain
size="small"
round
@click="startConversation"
>
发私信
</van-button>
<van-button
v-else
type="primary"
size="small"
round
@click="router.push('/settings/profile')"
>
编辑资料
</van-button>
<van-button
v-if="isSelf"
plain
size="small"
round
@click="router.push('/settings')"
>
账号设置
</van-button>
<van-button
v-if="isSelf"
plain
size="small"
round
@click="router.push('/about')"
>
关于项目
</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 class="profile-overview">
<section class="overview-card">
<div class="overview-head">
<h3>资料概览</h3>
<span>{{ isSelf ? '当前账号' : '公开资料' }}</span>
</div>
<div class="overview-grid">
<div class="overview-item">
<span>注册时间</span>
<strong>{{ joinDate }}</strong>
</div>
<div class="overview-item">
<span>地区</span>
<strong>{{ userInfo.region || '未设置' }}</strong>
</div>
<div class="overview-item">
<span>性别</span>
<strong>{{ genderLabel }}</strong>
</div>
<div class="overview-item">
<span>账号状态</span>
<strong>{{ vipLabel || '普通用户' }}</strong>
</div>
<div class="overview-item">
<span>生日</span>
<strong>{{ birthdayLabel }}</strong>
</div>
<div class="overview-item">
<span>互动状态</span>
<strong>{{ interactionLabel }}</strong>
</div>
</div>
</section>
<section class="overview-card">
<div class="overview-head">
<h3>{{ isSelf ? '主页快捷入口' : '互动操作' }}</h3>
<span>{{ isSelf ? '常用页面直达' : '继续浏览这个用户' }}</span>
</div>
<div class="quick-action-grid">
<button
class="quick-action"
@click="goToUserVideos"
>
<strong>{{ userInfo.videoCount }}</strong>
<span>查看视频</span>
</button>
<button
class="quick-action"
@click="goToFollowers"
>
<strong>{{ userInfo.followerCount }}</strong>
<span>查看粉丝</span>
</button>
<button
class="quick-action"
@click="goToFollowing"
>
<strong>{{ userInfo.followingCount }}</strong>
<span>查看关注</span>
</button>
<button
class="quick-action"
@click="activeTab = 'videos'"
>
<strong>{{ videos.length }}</strong>
<span>当前页视频</span>
</button>
</div>
</section>
</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-empty
v-if="!videos.length"
description="还没有公开视频"
/>
</van-tab>
<van-tab title="收藏" name="favorites">
<van-empty description="暂无收藏" />
<van-tab
title="收藏"
name="favorites"
>
<div
v-if="isSelf && favorites.length"
class="favorite-grid"
>
<button
v-for="favorite in favorites"
:key="favorite.id"
class="favorite-card"
@click="goToFavorite(favorite.id)"
>
<div class="favorite-top">
<h4>{{ favorite.name }}</h4>
<van-tag
plain
type="primary"
>
{{ favorite.isPrivate ? '私密' : '公开' }}
</van-tag>
</div>
<p>{{ favorite.description || '这个收藏夹还没有补充说明。' }}</p>
<div class="favorite-meta">
<span>{{ favorite.videoCount }} 个视频</span>
<span>{{ formatDate(favorite.createTime) }}</span>
</div>
</button>
</div>
<van-empty
v-else-if="isSelf"
description="还没有收藏夹"
/>
<van-empty
v-else
description="仅自己可查看收藏夹"
/>
</van-tab>
</van-tabs>
</template>
@@ -74,7 +314,9 @@ import { showToast, showSuccessToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import { userApi } from '@/api/user.ts'
import { videoApi } from '@/api/video.ts'
import { favoriteApi, type Favorite } from '@/api/favorite.ts'
import { useUserStore } from '@/stores/user.ts'
import { resolveAvatarUrl } from '@/utils/media'
import type { UserInfo, VideoInfo } from '@/types'
const router = useRouter()
@@ -89,8 +331,34 @@ const userInfo = ref<UserInfo | null>(null)
const isFollowed = ref(false)
const activeTab = ref('videos')
const videos = ref<VideoInfo[]>([])
const favorites = ref<Favorite[]>([])
const notificationBackVisible = computed(() => route.query.from === 'notifications')
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 getAvatarUrl = (avatar?: string) => resolveAvatarUrl(avatar, defaultAvatar)
const genderLabel = computed(() => {
if (userInfo.value?.gender === 1) return '男'
if (userInfo.value?.gender === 2) return '女'
return '保密'
})
const vipLabel = computed(() => (userInfo.value?.vipLevel ? `VIP ${userInfo.value.vipLevel}` : ''))
const joinDate = computed(() => formatDate(userInfo.value?.createTime))
const birthdayLabel = computed(() => formatDate(userInfo.value?.birthday))
const interactionLabel = computed(() => {
if (isSelf.value) return '当前登录账号'
return isFollowed.value ? '已关注,可继续私信交流' : '尚未关注'
})
const formatDate = (value?: string | null) => {
if (!value) return '未记录'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(date)
}
const loadUserInfo = async () => {
try {
@@ -105,13 +373,29 @@ const loadUserInfo = async () => {
const loadUserVideos = async () => {
try {
const res = await videoApi.getUserVideos(userId.value, { pageNum: 1, pageSize: 20 })
const res = await videoApi.getVideoPublicList({
pageNum: 1,
pageSize: 20,
userId: userId.value
})
videos.value = res.records
} catch {
showToast('加载视频失败')
}
}
const loadFavorites = async () => {
if (!isSelf.value) {
favorites.value = []
return
}
try {
favorites.value = await favoriteApi.getFavoriteList()
} catch {
showToast('加载收藏夹失败')
}
}
const toggleFollow = async () => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
@@ -121,10 +405,16 @@ const toggleFollow = async () => {
if (isFollowed.value) {
await userApi.unfollow(userId.value)
isFollowed.value = false
if (userInfo.value) {
userInfo.value.followerCount = Math.max(0, userInfo.value.followerCount - 1)
}
showSuccessToast('已取消关注')
} else {
await userApi.follow(userId.value)
isFollowed.value = true
if (userInfo.value) {
userInfo.value.followerCount += 1
}
showSuccessToast('关注成功')
}
} catch {
@@ -148,27 +438,52 @@ const goToUserVideos = () => {
router.push(`/user/${userId.value}/videos`)
}
const goToFavorite = (id: number) => {
router.push(`/my/favorites/${id}`)
}
const startConversation = () => {
if (!userStore.isLoggedIn) {
showToast('请先登录')
return
}
router.push(`/messages/${userId.value}`)
}
const goBackToNotifications = () => {
router.push({
path: '/notifications',
query: route.query.tab ? { tab: String(route.query.tab) } : undefined
})
}
const initPage = async () => {
loading.value = true
await Promise.all([loadUserInfo(), loadUserVideos()])
favorites.value = []
await Promise.all([loadUserInfo(), loadUserVideos(), activeTab.value === 'favorites' ? loadFavorites() : Promise.resolve()])
loading.value = false
}
onMounted(initPage)
watch(() => route.params.id, initPage)
watch(activeTab, (tab) => {
if (tab === 'favorites' && isSelf.value && !favorites.value.length) {
loadFavorites()
}
})
</script>
<style scoped lang="scss">
.profile-page {
min-height: 100vh;
background: #f6f7fb;
background: #f4f5f7;
padding-bottom: 60px;
}
.profile-shell {
max-width: 1440px;
max-width: 1380px;
margin: 0 auto;
padding: 20px 12px 28px;
padding: 18px 12px 28px;
}
.loading-container {
@@ -177,13 +492,38 @@ watch(() => route.params.id, initPage)
padding: 40px;
}
.return-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: #fff;
padding: 14px 16px;
border-radius: 10px;
box-shadow: var(--shadow-sm);
margin-bottom: 16px;
strong {
display: block;
margin-bottom: 4px;
color: #18191c;
}
span {
color: #61666d;
font-size: 13px;
}
}
.profile-banner {
height: 220px;
border-radius: 28px;
background: linear-gradient(135deg, #60a5fa, #8b5cf6 52%, #fb7185);
height: 200px;
border-radius: 10px;
background:
radial-gradient(circle at right top, rgba(255, 255, 255, 0.24), transparent 22%),
linear-gradient(120deg, #52b6e8, #79d0f6 45%, #fb9dbe);
position: relative;
overflow: hidden;
box-shadow: 0 24px 52px rgba(99, 102, 241, 0.18);
box-shadow: var(--shadow-sm);
}
.banner-overlay {
@@ -194,14 +534,41 @@ watch(() => route.params.id, initPage)
radial-gradient(circle at left bottom, rgba(255, 255, 255, 0.18), transparent 28%);
}
.banner-content {
position: absolute;
left: 28px;
bottom: 24px;
z-index: 1;
color: #fff;
display: grid;
gap: 6px;
strong {
font-size: 28px;
}
span {
font-size: 13px;
color: rgba(255, 255, 255, 0.76);
}
}
.eyebrow {
margin: 0;
font-size: 12px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.7);
}
.user-header {
background: #fff;
padding: 24px 26px;
border-radius: 28px;
margin-top: -48px;
padding: 22px 24px;
border-radius: 10px;
margin-top: -42px;
position: relative;
z-index: 1;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
box-shadow: var(--shadow-sm);
}
.user-info {
@@ -216,7 +583,7 @@ watch(() => route.params.id, initPage)
}
.nickname {
font-size: 30px;
font-size: 28px;
font-weight: 700;
margin: 0 0 4px;
}
@@ -224,6 +591,7 @@ watch(() => route.params.id, initPage)
.meta-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
@@ -231,12 +599,22 @@ watch(() => route.params.id, initPage)
.level {
display: inline-block;
padding: 4px 10px;
background: linear-gradient(to right, #fb7299, #ff8fab);
background: linear-gradient(to right, #fb7299, #ff9abb);
color: #fff;
font-size: 12px;
border-radius: 999px;
}
.vip {
display: inline-block;
padding: 4px 10px;
background: #e5f6fd;
color: #00a1d6;
font-size: 12px;
border-radius: 999px;
}
.gender,
.region {
color: #9499a0;
font-size: 13px;
@@ -261,11 +639,11 @@ watch(() => route.params.id, initPage)
cursor: pointer;
min-width: 64px;
padding: 10px 8px;
border-radius: 18px;
border-radius: 8px;
transition: background 0.2s ease;
&:hover {
background: #f8fafc;
background: #f6f7f8;
}
}
@@ -282,17 +660,115 @@ watch(() => route.params.id, initPage)
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.profile-overview {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
margin-top: 18px;
}
.overview-card {
background: #fff;
border-radius: 10px;
padding: 22px 24px;
box-shadow: var(--shadow-sm);
}
.overview-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 16px;
h3 {
margin: 0;
font-size: 18px;
}
span {
font-size: 12px;
color: #9499a0;
}
}
.overview-grid,
.quick-action-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.overview-item,
.quick-action {
border-radius: 8px;
padding: 16px;
background: #f6f7f8;
}
.overview-item {
display: grid;
gap: 8px;
span {
font-size: 12px;
color: #9499a0;
}
strong {
font-size: 15px;
color: #18191c;
}
}
.quick-action {
border: none;
text-align: left;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
strong {
display: block;
font-size: 24px;
margin-bottom: 4px;
color: #18191c;
}
span {
color: #61666d;
font-size: 13px;
}
}
.profile-tabs {
margin-top: 18px;
background: #fff;
border-radius: 28px;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
box-shadow: var(--shadow-sm);
:deep(.van-tabs__wrap) {
border-bottom: 1px solid #f1f2f3;
}
:deep(.van-tab--active) {
color: var(--link-color);
}
:deep(.van-tabs__line) {
background: var(--link-color);
}
}
.video-grid {
@@ -302,20 +778,80 @@ watch(() => route.params.id, initPage)
padding: 18px;
}
.favorite-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
padding: 18px;
}
.favorite-card {
border: none;
background: #f6f7f8;
border-radius: 8px;
padding: 18px;
text-align: left;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
h4 {
margin: 0;
font-size: 16px;
}
p {
min-height: 40px;
margin: 12px 0 14px;
color: #61666d;
line-height: 1.5;
}
}
.favorite-top,
.favorite-meta {
display: flex;
justify-content: space-between;
gap: 12px;
}
.favorite-meta {
font-size: 12px;
color: #9499a0;
}
@media (max-width: 768px) {
.profile-shell {
padding: 12px;
}
.return-banner {
flex-direction: column;
align-items: flex-start;
}
.profile-banner {
height: 160px;
border-radius: 20px;
border-radius: 10px;
}
.banner-content {
left: 18px;
bottom: 18px;
strong {
font-size: 22px;
}
}
.user-header {
margin-top: -28px;
padding: 18px;
border-radius: 20px;
border-radius: 10px;
}
.user-info {
@@ -330,9 +866,23 @@ watch(() => route.params.id, initPage)
gap: 8px;
}
.profile-overview {
grid-template-columns: 1fr;
}
.overview-grid,
.quick-action-grid {
grid-template-columns: 1fr;
}
.video-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 12px;
}
.favorite-grid {
grid-template-columns: 1fr;
padding: 12px;
}
}
</style>
@@ -1,50 +1,203 @@
<template>
<div class="settings-page">
<div class="settings-shell">
<section class="settings-hero">
<h2>账号设置</h2>
<p>管理个人资料、隐私选项、通知偏好和应用信息。</p>
</section>
<div class="page-shell settings-shell">
<div class="page-head">
<div class="head-main">
<strong>账号设置</strong>
<span>{{ accountInfo.username }}</span>
</div>
</div>
<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 class="panel-card profile-card">
<div class="profile-top">
<div class="profile-copy">
<strong>{{ accountInfo.nickname }}</strong>
<span>{{ accountInfo.region }}</span>
</div>
<div class="profile-tags">
<span>Lv.{{ accountInfo.level }}</span>
<span>{{ accountInfo.vipLabel }}</span>
</div>
</div>
<div class="status-list">
<div class="status-item">
<span>公开范围</span>
<strong>{{ privacyLabel }}</strong>
</div>
<div class="status-item">
<span>通知偏好</span>
<strong>{{ notificationSummary }}</strong>
</div>
<div class="status-item">
<span>未读消息</span>
<strong>{{ unreadSummary }}</strong>
</div>
<div class="status-item">
<span>资料完整度</span>
<strong>{{ profileCompleteness }}</strong>
</div>
</div>
</div>
<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="panel-card menu-card">
<button
class="menu-item"
@click="router.push('/settings/profile')"
>
<span>编辑资料</span>
<small>头像、昵称、签名</small>
</button>
<button
class="menu-item"
@click="router.push('/settings/privacy')"
>
<span>隐私设置</span>
<small>{{ privacyLabel }}</small>
</button>
<button
class="menu-item"
@click="router.push('/settings/notification')"
>
<span>通知设置</span>
<small>{{ notificationSummary }}</small>
</button>
<button
class="menu-item"
@click="router.push('/about')"
>
<span>关于项目</span>
<small>查看项目信息</small>
</button>
</div>
<div class="logout-btn">
<van-button type="danger" block @click="handleLogout">退出登录</van-button>
<div class="panel-card runtime-card">
<div class="status-list">
<div class="status-item">
<span>最近登录</span>
<strong>{{ accountInfo.lastLoginTime }}</strong>
</div>
<div class="status-item">
<span>登录 IP</span>
<strong>{{ accountInfo.lastLoginIp }}</strong>
</div>
<div class="status-item">
<span>缓存占用</span>
<strong>{{ cacheSize }}</strong>
</div>
<div class="status-item">
<span>API 地址</span>
<strong>{{ apiTarget }}</strong>
</div>
</div>
<div class="runtime-actions">
<button
class="text-link"
@click="refreshStats"
>
刷新状态
</button>
<button
class="text-link"
@click="clearCache"
>
清除缓存
</button>
</div>
</div>
</div>
<van-button
type="danger"
block
class="logout-btn"
@click="handleLogout"
>
退出登录
</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog, showToast, showSuccessToast } from 'vant'
import { useUserStore } from '@/stores/user.ts'
import { useMessageStore } from '@/stores/message'
import { userApi } from '@/api/user.ts'
const router = useRouter()
const userStore = useUserStore()
const messageStore = useMessageStore()
const cacheSize = ref('0KB')
const settings = ref({
privacyLevel: 1,
commentNotify: 1,
likeNotify: 1,
followNotify: 1,
systemNotify: 1
})
const apiTarget = import.meta.env.VITE_PROXY_TARGET || window.location.origin
const accountInfo = computed(() => ({
username: userStore.authUser?.username || '-',
nickname: userStore.userInfo?.nickname || '-',
region: userStore.userInfo?.region || '未设置地区',
level: userStore.userInfo?.level || 1,
vipLabel: userStore.userInfo?.vipLevel ? `VIP ${userStore.userInfo.vipLevel}` : '普通用户',
lastLoginTime: userStore.authUser?.lastLoginTime || '未记录',
lastLoginIp: userStore.authUser?.lastLoginIp || '未记录'
}))
const privacyLabel = computed(() => ({ 1: '公开', 2: '仅关注可见', 3: '仅自己可见' }[settings.value.privacyLevel] || '未设置'))
const notificationSummary = computed(() => {
const enabled: string[] = []
if (settings.value.commentNotify) enabled.push('评论')
if (settings.value.likeNotify) enabled.push('点赞')
if (settings.value.followNotify) enabled.push('关注')
if (settings.value.systemNotify) enabled.push('系统')
return enabled.length ? enabled.join(' / ') : '全部关闭'
})
const unreadSummary = computed(() => `私信 ${messageStore.unreadMessageCount} · 通知 ${messageStore.unreadNotificationCount}`)
const profileCompleteness = computed(() => {
const fields = [
!!userStore.userInfo?.nickname,
!!userStore.userInfo?.avatar,
!!userStore.userInfo?.signature,
!!userStore.userInfo?.region,
!!userStore.userInfo?.birthday
]
return `${Math.round((fields.filter(Boolean).length / fields.length) * 100)}%`
})
const calcStorageSize = () => {
const total = [localStorage, sessionStorage].reduce((sum, storage) => {
return sum + Object.keys(storage).reduce((inner, key) => inner + key.length + (storage.getItem(key)?.length || 0), 0)
}, 0)
if (total < 1024) return `${total} B`
if (total < 1024 * 1024) return `${(total / 1024).toFixed(1)} KB`
return `${(total / (1024 * 1024)).toFixed(1)} MB`
}
const refreshStats = async () => {
try {
if (!userStore.authUser) await userStore.fetchAuthUser()
await userStore.fetchUserInfo()
settings.value = await userApi.getSettings()
await messageStore.fetchAllUnreadCount()
cacheSize.value = calcStorageSize()
} catch {
// keep current data
}
}
const clearCache = () => {
showToast('缓存已清除')
cacheSize.value = '0KB'
sessionStorage.clear()
cacheSize.value = calcStorageSize()
showToast('会话缓存已清除')
}
const handleLogout = async () => {
@@ -57,60 +210,159 @@ const handleLogout = async () => {
showSuccessToast('已退出登录')
router.push('/')
} catch {
// 取消
// cancel
}
}
onMounted(refreshStats)
</script>
<style scoped lang="scss">
.settings-page {
min-height: 100vh;
background: #f6f7fb;
padding-bottom: 60px;
background: #f4f5f7;
padding-bottom: 36px;
}
.settings-shell {
max-width: 1280px;
margin: 0 auto;
padding: 20px 12px 28px;
padding-top: 18px;
}
.settings-hero {
padding: 24px 26px;
border-radius: 28px;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: #fff;
margin-bottom: 18px;
.page-head,
.panel-card {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
h2 {
margin: 0 0 8px;
font-size: 28px;
.page-head {
padding: 16px 18px;
margin-bottom: 16px;
}
.head-main {
display: grid;
gap: 4px;
strong {
font-size: 24px;
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.82);
span {
color: #9499a0;
font-size: 13px;
}
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.settings-column :deep(.van-cell-group) {
margin: 0;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
.panel-card {
padding: 18px;
}
.profile-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.profile-copy {
display: grid;
gap: 4px;
strong {
font-size: 20px;
}
span {
color: #61666d;
font-size: 13px;
}
}
.profile-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
span {
padding: 4px 8px;
border-radius: 999px;
background: #f6f7f8;
font-size: 12px;
color: #61666d;
}
}
.status-list {
display: grid;
gap: 10px;
}
.status-item {
display: grid;
gap: 6px;
padding: 12px 14px;
border-radius: 8px;
background: #f6f7f8;
span {
font-size: 12px;
color: #9499a0;
}
strong {
color: #18191c;
word-break: break-all;
}
}
.menu-card {
display: grid;
gap: 10px;
}
.menu-item {
padding: 14px;
border-radius: 8px;
background: #f6f7f8;
text-align: left;
display: grid;
gap: 4px;
span {
font-size: 15px;
color: #18191c;
}
small {
color: #9499a0;
font-size: 12px;
}
}
.runtime-actions {
margin-top: 14px;
display: flex;
gap: 16px;
}
.text-link {
color: var(--link-color);
font-size: 13px;
}
.logout-btn {
margin-top: 18px;
}
@media (max-width: 768px) {
@media (max-width: 1100px) {
.settings-grid {
grid-template-columns: 1fr;
}
@@ -1,97 +1,351 @@
<template>
<div class="user-videos-page">
<van-nav-bar title="用户视频" fixed placeholder left-arrow @click-left="router.back()" />
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<div class="video-grid">
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
<div class="page-shell videos-shell">
<section class="videos-hero">
<div class="hero-copy">
<p class="hero-eyebrow">
Public Videos
</p>
<h1>{{ pageTitle }}</h1>
<span>{{ profileSummary }}</span>
</div>
</van-list>
</van-pull-refresh>
</section>
<section class="stats-grid">
<div class="stat-card">
<span>公开视频</span>
<strong>{{ videos.length }}</strong>
</div>
<div class="stat-card">
<span>总播放</span>
<strong>{{ totalViews }}</strong>
</div>
<div class="stat-card">
<span>总点赞</span>
<strong>{{ totalLikes }}</strong>
</div>
</section>
<section
v-if="videos.length"
class="videos-panel"
>
<div class="panel-head">
<strong>内容列表</strong>
<span>{{ videos.length }} 条记录</span>
</div>
<div class="video-list">
<article
v-for="video in videos"
:key="video.id"
class="video-item"
@click="goToVideo(video.id)"
>
<div class="video-cover">
<van-image
:src="getCoverUrl(video.coverUrl)"
fit="cover"
class="cover-image"
/>
<span class="duration">{{ formatDuration(video.duration) }}</span>
</div>
<div class="video-body">
<div class="video-title-row">
<h3>{{ video.title }}</h3>
<van-tag type="success">
已公开
</van-tag>
</div>
<p class="video-desc">
{{ video.description || '暂无简介' }}
</p>
<div class="video-meta">
<span>{{ video.categoryName || '未分类' }}</span>
<span>{{ formatPlayCount(video.viewCount) }} 播放</span>
<span>{{ formatNumber(video.likeCount) }} 点赞</span>
<span>{{ formatPublishTime(video.publishTime || video.createTime) }}</span>
</div>
</div>
</article>
</div>
</section>
<van-empty
v-else
description="该用户暂未发布公开视频"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
import { resolveMediaUrl } from '@/utils/media'
import { videoApi } from '@/api/video.ts'
import type { VideoInfo } from '@/types'
import { userApi } from '@/api/user.ts'
import type { UserInfo, VideoInfo } from '@/types'
const router = useRouter()
const route = useRoute()
const userId = Number(route.params.id)
const userProfile = ref<UserInfo | null>(null)
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const defaultCover =
'data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 180\"%3E%3Crect width=\"320\" height=\"180\" fill=\"%23edf2f7\"/%3E%3Ctext x=\"50%25\" y=\"50%25\" dy=\".3em\" text-anchor=\"middle\" fill=\"%239499a0\"%3EHAOVP%3C/text%3E%3C/svg%3E'
const loadVideos = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
const pageTitle = computed(() => userProfile.value?.nickname ? `${userProfile.value.nickname} 的视频` : '用户视频')
const profileSummary = computed(() => userProfile.value?.signature || '查看该用户公开发布的视频内容')
const totalViews = computed(() => formatNumber(videos.value.reduce((sum, video) => sum + (video.viewCount || 0), 0)))
const totalLikes = computed(() => formatNumber(videos.value.reduce((sum, video) => sum + (video.likeCount || 0), 0)))
loading.value = true
const getCoverUrl = (coverUrl?: string) => resolveMediaUrl(coverUrl) || defaultCover
const formatPublishTime = (value?: string) => {
if (!value) return '未发布'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date)
}
const loadPage = async () => {
try {
const res = await videoApi.getUserVideos(userId, { pageNum: currentPage.value, pageSize: 20 })
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
}
if (videos.value.length >= res.total || res.records.length < 20) {
finished.value = true
}
currentPage.value++
} catch (error) {
const [profile, res] = await Promise.all([
userApi.getPublicProfile(userId).catch(() => null),
videoApi.getVideoPublicList({
pageNum: 1,
pageSize: 50,
userId
})
])
userProfile.value = profile
videos.value = res.records
} catch {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
onMounted(() => {
loadVideos(true)
})
onMounted(loadPage)
</script>
<style scoped lang="scss">
.user-videos-page {
min-height: 100vh;
background: #f6f7fb;
padding-bottom: 60px;
background: #f4f5f7;
padding-bottom: 36px;
}
.video-grid {
.videos-shell {
padding-top: 18px;
}
.videos-hero,
.videos-panel,
.stat-card {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.videos-hero {
padding: 22px 24px;
background:
linear-gradient(135deg, rgba(0, 161, 214, 0.08), rgba(251, 114, 153, 0.08)),
#fff;
}
.hero-copy {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px 16px;
max-width: 1400px;
margin: 0 auto;
padding: 16px 12px 24px;
gap: 6px;
h1 {
margin: 0;
font-size: 30px;
color: #18191c;
}
span {
color: #61666d;
font-size: 14px;
}
}
.hero-eyebrow {
margin: 0;
color: var(--link-color);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.stats-grid {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.stat-card {
padding: 18px;
display: grid;
gap: 8px;
span {
color: #9499a0;
font-size: 13px;
}
strong {
color: #18191c;
font-size: 28px;
line-height: 1;
}
}
.videos-panel {
margin-top: 18px;
padding: 18px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
strong {
font-size: 20px;
}
span {
color: #9499a0;
font-size: 13px;
}
}
.video-list {
display: grid;
gap: 16px;
}
.video-item {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 18px;
padding: 14px;
border-radius: 10px;
background: #f8fafc;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
&:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
}
.video-cover {
position: relative;
overflow: hidden;
border-radius: 8px;
aspect-ratio: 16 / 9;
background: #edf2f7;
}
.cover-image {
width: 100%;
height: 100%;
}
.duration {
position: absolute;
right: 8px;
bottom: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.74);
color: #fff;
font-size: 12px;
}
.video-body {
min-width: 0;
display: grid;
gap: 12px;
align-content: center;
}
.video-title-row {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
h3 {
margin: 0;
color: #18191c;
font-size: 20px;
line-height: 1.4;
}
}
.video-desc {
margin: 0;
color: #61666d;
font-size: 14px;
line-height: 1.7;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.video-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
color: #9499a0;
font-size: 13px;
}
@media (max-width: 960px) {
.stats-grid {
grid-template-columns: 1fr;
}
.video-item {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.hero-copy h1 {
font-size: 24px;
}
.videos-panel {
padding: 14px;
}
.video-item {
padding: 10px;
}
}
</style>