refactor(frontend): Rework home layout and user interactions
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user