refactor(frontend): Rework home layout and user interactions

This commit is contained in:
2026-04-22 16:45:00 +08:00
parent 79f255bf6c
commit 59e87faba9
84 changed files with 8299 additions and 2181 deletions
+2
View File
@@ -1,3 +1,5 @@
# 应用配置
VITE_APP_TITLE=HAOVP视频平台
VITE_API_BASE_URL=/api
VITE_PROXY_TARGET=http://192.168.1.101:9000
VITE_MEDIA_BASE_URL=http://192.168.1.101:9100/video-platform
+2
View File
@@ -3,4 +3,6 @@
VITE_APP_TITLE=HAOVP视频平台
VITE_API_BASE_URL=/api
VITE_PROXY_TARGET=http://192.168.1.101:9000
VITE_MEDIA_BASE_URL=http://192.168.1.101:9100/video-platform
VITE_MOCK=true
@@ -0,0 +1,4 @@
dist
node_modules
src/auto-imports.d.ts
src/components.d.ts
+29
View File
@@ -0,0 +1,29 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended'
],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 'latest',
sourceType: 'module',
extraFileExtensions: ['.vue']
},
rules: {
'no-console': 'off',
'no-empty': ['error', { allowEmptyCatch: true }],
'vue/multi-word-component-names': 'off',
'vue/no-template-shadow': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }]
}
}
+35
View File
@@ -14,6 +14,7 @@
"artplayer-plugin-danmuku": "^5.1.0",
"axios": "^1.6.8",
"dayjs": "^1.11.10",
"dplayer": "^1.27.1",
"nprogress": "^0.2.0",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
@@ -2617,6 +2618,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/balloon-css": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/balloon-css/-/balloon-css-1.2.0.tgz",
"integrity": "sha512-urXwkHgwp6GsXVF+it01485Z2Cj4pnW02ICnM0TemOlkKmCNnDLmyy+ZZiRXBpwldUXO+aRNr7Hdia4CBvXJ5A==",
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.8",
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
@@ -3040,6 +3047,28 @@
"node": ">=6.0.0"
}
},
"node_modules/dplayer": {
"version": "1.27.1",
"resolved": "https://registry.npmmirror.com/dplayer/-/dplayer-1.27.1.tgz",
"integrity": "sha512-2laBMXs5V1B9zPwJ7eAIw/OBo+Xjvy03i4GHTk3Cg+IWbrq8rKMFO0fFr6ClAYotYOCcFGOvaJDkOZcgKllsCA==",
"license": "MIT",
"dependencies": {
"axios": "1.2.3",
"balloon-css": "^1.0.3",
"promise-polyfill": "8.3.0"
}
},
"node_modules/dplayer/node_modules/axios": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.2.3.tgz",
"integrity": "sha512-pdDkMYJeuXLZ6Xj/Q5J3Phpe+jbGdsSzlQaFVkMQzRUL05+6+tetX8TV3p4HrU4kzuO9bt+io/yGQxuyxA/xcw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4886,6 +4915,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/promise-polyfill": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+21 -20
View File
@@ -8,39 +8,40 @@
"dev:mock": "vite --mode mock",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"lint": "eslint --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix src",
"format": "prettier --write src"
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"axios": "^1.6.8",
"vant": "^4.8.4",
"@iconify/vue": "^4.1.1",
"@vant/use": "^1.6.0",
"artplayer": "^5.1.7",
"artplayer-plugin-danmuku": "^5.1.0",
"axios": "^1.6.8",
"dayjs": "^1.11.10",
"dplayer": "^1.27.1",
"nprogress": "^0.2.0",
"@iconify/vue": "^4.1.1"
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"vant": "^4.8.4",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"@vue/tsconfig": "^0.5.1",
"vite": "^5.2.0",
"vite-plugin-vue-devtools": "^7.0.25",
"typescript": "~5.4.0",
"vue-tsc": "^2.0.6",
"sass": "^1.72.0",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"@types/node": "^20.11.30",
"@types/nprogress": "^0.2.3",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.24.0",
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
"prettier": "^3.2.5"
"@vitejs/plugin-vue": "^5.0.4",
"@vue/tsconfig": "^0.5.1",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.24.0",
"prettier": "^3.2.5",
"sass": "^1.72.0",
"typescript": "~5.4.0",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.2.0",
"vite-plugin-vue-devtools": "^7.0.25",
"vue-tsc": "^2.0.6"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -8,7 +8,7 @@ export const commentApi = {
/**
* 发表评论
*/
addComment(params: CommentSendParams): Promise<Comment> {
addComment(params: CommentSendParams): Promise<number> {
return request.post('/comment', params)
},
@@ -50,15 +50,15 @@ export const commentApi = {
/**
* 置顶评论
*/
topComment(id: number): Promise<void> {
return request.post(`/comment/top/${id}`)
topComment(id: number, videoId: number): Promise<void> {
return request.post(`/comment/top/${id}?videoId=${videoId}`)
},
/**
* 取消置顶评论
*/
untopComment(id: number): Promise<void> {
return request.delete(`/comment/top/${id}`)
untopComment(id: number, videoId: number): Promise<void> {
return request.delete(`/comment/top/${id}`, { videoId })
},
/**
@@ -73,7 +73,7 @@ export const commentApi = {
/**
* 添加回复
*/
addReply(params: { commentId: number; content: string; replyId?: number }): Promise<Comment> {
addReply(params: { commentId: number; videoId: number; content: string; replyToId?: number }): Promise<number> {
return request.post('/comment/reply', params)
},
+6 -6
View File
@@ -73,14 +73,14 @@ export const userApi = {
/**
* 获取关注列表
*/
getFollowing(userId: number, params?: PageParams): Promise<UserFollow[]> {
getFollowing(userId: number, params?: PageParams): Promise<PageResult<UserFollow>> {
return request.get(`/user/follow/following/${userId}`, params)
},
/**
* 获取粉丝列表
*/
getFollowers(userId: number, params?: PageParams): Promise<UserFollow[]> {
getFollowers(userId: number, params?: PageParams): Promise<PageResult<UserFollow>> {
return request.get(`/user/follow/followers/${userId}`, params)
},
@@ -105,21 +105,21 @@ export const userApi = {
/**
* 发送私信
*/
sendMessage(params: MessageSendParams): Promise<Message> {
sendMessage(params: MessageSendParams): Promise<void> {
return request.post('/user/message', params)
},
/**
* 获取私信会话列表
*/
getMessageList(params?: PageParams): Promise<Message[]> {
getMessageList(params?: PageParams): Promise<PageResult<Message>> {
return request.get('/user/message/list', params)
},
/**
* 获取与指定用户的私信记录
*/
getMessageDetail(targetId: number, params?: PageParams): Promise<Message[]> {
getMessageDetail(targetId: number, params?: PageParams): Promise<PageResult<Message>> {
return request.get(`/user/message/detail/${targetId}`, params)
},
@@ -142,7 +142,7 @@ export const userApi = {
/**
* 获取通知列表
*/
getNotifications(type?: number, params?: PageParams): Promise<Notification[]> {
getNotifications(type?: number, params?: PageParams): Promise<PageResult<Notification>> {
return request.get('/user/notification/list', { type, ...params })
},
+7 -6
View File
@@ -5,6 +5,7 @@ import type {
VideoUpdateParams,
VideoQueryParams,
VideoUploadParams,
VideoUploadResult,
PlayRecord,
PageResult
} from '@/types'
@@ -18,14 +19,14 @@ export const videoApi = {
/**
* 上传视频文件
*/
uploadVideo(params: VideoUploadParams): Promise<{ videoUrl: string; coverUrl: string }> {
uploadVideo(params: VideoUploadParams): Promise<VideoUploadResult> {
return request.upload('/content/video/upload', params.file, params.onProgress)
},
/**
* 创建视频记录
*/
createVideo(params: VideoCreateParams): Promise<VideoInfo> {
createVideo(params: VideoCreateParams): Promise<number> {
return request.post('/content/video', params)
},
@@ -101,8 +102,8 @@ export const videoApi = {
/**
* 投币
*/
coinVideo(videoId: number): Promise<void> {
return request.post(`/content/video/coin/${videoId}`)
coinVideo(videoId: number, amount = 1): Promise<void> {
return request.post(`/content/video/coin/${videoId}?amount=${amount}`)
},
/**
@@ -122,8 +123,8 @@ export const videoApi = {
/**
* 分享视频
*/
shareVideo(videoId: number): Promise<void> {
return request.post(`/content/video/share/${videoId}`)
shareVideo(videoId: number, platform = 'web'): Promise<void> {
return request.post(`/content/video/share/${videoId}?platform=${platform}`)
},
/**
+2 -4
View File
@@ -34,14 +34,12 @@ declare module 'vue' {
VanPopover: typeof import('vant/es')['Popover']
VanPopup: typeof import('vant/es')['Popup']
VanProgress: typeof import('vant/es')['Progress']
VanPullRefresh: typeof import('vant/es')['PullRefresh']
VanRadio: typeof import('vant/es')['Radio']
VanRadioGroup: typeof import('vant/es')['RadioGroup']
VanSearch: typeof import('vant/es')['Search']
VanSidebar: typeof import('vant/es')['Sidebar']
VanSidebarItem: typeof import('vant/es')['SidebarItem']
VanSkeleton: typeof import('vant/es')['Skeleton']
VanSlider: typeof import('vant/es')['Slider']
VanSwipe: typeof import('vant/es')['Swipe']
VanSwipeItem: typeof import('vant/es')['SwipeItem']
VanSwitch: typeof import('vant/es')['Switch']
VanTab: typeof import('vant/es')['Tab']
VanTabbar: typeof import('vant/es')['Tabbar']
@@ -5,24 +5,46 @@
<van-field
v-model="content"
type="textarea"
placeholder="发一条友善的评论吧"
:placeholder="replyTarget ? `回复 @${replyTarget.userNickname}` : '发一条友善的评论吧'"
rows="2"
autosize
maxlength="500"
show-word-limit
/>
<div class="input-actions">
<van-button type="primary" size="small" :loading="submitting" @click="submitComment">
发表评论
<van-button
v-if="replyTarget"
plain
size="small"
@click="cancelReply"
>
取消回复
</van-button>
<van-button
type="primary"
size="small"
:loading="submitting"
@click="submitComment"
>
{{ replyTarget ? '发送回复' : '发表评论' }}
</van-button>
</div>
</div>
<!-- 评论列表 -->
<div class="comment-list">
<van-tabs v-model:active="sortBy" @change="loadComments">
<van-tab title="最热" name="hot" />
<van-tab title="最新" name="time" />
<van-tabs
v-model:active="sortBy"
@change="loadComments"
>
<van-tab
title="最热"
name="hot"
/>
<van-tab
title="最新"
name="time"
/>
</van-tabs>
<van-list
@@ -31,16 +53,26 @@
finished-text="没有更多评论了"
@load="loadMore"
>
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div
v-for="comment in comments"
:key="comment.id"
class="comment-item"
>
<div class="comment-avatar">
<van-image :src="comment.userAvatar || defaultAvatar" round fit="cover" />
<van-image
:src="comment.userAvatar || defaultAvatar"
round
fit="cover"
/>
</div>
<div class="comment-content">
<div class="comment-header">
<span class="username">{{ comment.userNickname }}</span>
<span class="time">{{ formatRelativeTime(comment.createTime) }}</span>
</div>
<div class="comment-text">{{ comment.content }}</div>
<div class="comment-text">
{{ comment.content }}
</div>
<div class="comment-actions">
<span @click="toggleLike(comment)">
<van-icon :name="comment.isLiked ? 'good-job' : 'good-job-o'" />
@@ -51,6 +83,27 @@
回复
</span>
</div>
<div
v-if="comment.hotReplies?.length"
class="reply-list"
>
<div
v-for="reply in comment.hotReplies"
:key="reply.id"
class="reply-item"
>
<span class="reply-name">{{ reply.userNickname }}</span>
<span
v-if="reply.replyToNickname"
class="reply-to"
>回复 {{ reply.replyToNickname }}</span>
<span class="reply-text">:{{ reply.content }}</span>
<span
class="reply-action"
@click="replyTo(comment, reply)"
>回复</span>
</div>
</div>
</div>
</div>
</van-list>
@@ -79,6 +132,7 @@ const loading = ref(false)
const finished = ref(false)
const submitting = ref(false)
const currentPage = ref(1)
const replyTarget = ref<Comment | null>(null)
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'
@@ -133,12 +187,22 @@ const submitComment = async () => {
submitting.value = true
try {
if (replyTarget.value) {
await commentApi.addReply({
commentId: replyTarget.value.id,
videoId: props.videoId,
content: content.value,
replyToId: replyTarget.value.userId
})
} else {
await commentApi.addComment({
videoId: props.videoId,
content: content.value
})
}
content.value = ''
showSuccessToast('评论成功')
replyTarget.value = null
showSuccessToast('发送成功')
loadComments(true)
} catch (error: any) {
showToast(error.message || '评论失败')
@@ -166,8 +230,14 @@ const toggleLike = async (comment: Comment) => {
}
}
const replyTo = (comment: Comment) => {
content.value = `@${comment.userNickname} `
const replyTo = (comment: Comment, reply?: Comment) => {
replyTarget.value = reply ?? comment
content.value = ''
}
const cancelReply = () => {
replyTarget.value = null
content.value = ''
}
onMounted(() => {
@@ -255,6 +325,31 @@ onMounted(() => {
}
}
}
.reply-list {
margin-top: 10px;
display: grid;
gap: 6px;
padding: 10px 12px;
background: #f8fafc;
border-radius: 12px;
}
.reply-item {
font-size: 12px;
line-height: 1.6;
color: #64748b;
}
.reply-name,
.reply-to {
color: #0f766e;
}
.reply-action {
margin-left: 8px;
color: #00a1d6;
}
}
}
}
@@ -1,35 +1,64 @@
<template>
<div class="danmaku-list">
<van-tabs v-model:active="activeTab">
<van-tab title="弹幕列表" name="list">
<van-tab
title="弹幕列表"
name="list"
>
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多弹幕了"
@load="loadMore"
>
<div v-for="item in danmakuList" :key="item.id" class="danmaku-item">
<div class="danmaku-time">{{ formatDanmakuTime(item.time) }}</div>
<div class="danmaku-content" :style="{ color: item.color }">
<div
v-for="item in danmakuList"
:key="item.id"
class="danmaku-item"
>
<div class="danmaku-time">
{{ formatDanmakuTime(item.time) }}
</div>
<div
class="danmaku-content"
:style="{ color: item.color }"
>
{{ item.content }}
</div>
<div class="danmaku-user">{{ item.userId }}</div>
<div class="danmaku-user">
{{ item.userId }}
</div>
</div>
</van-list>
</van-tab>
<van-tab title="弹幕设置" name="settings">
<van-tab
title="弹幕设置"
name="settings"
>
<van-cell-group inset>
<van-cell title="弹幕显示">
<van-switch v-model="settings.showDanmaku" />
</van-cell>
<van-cell title="弹幕透明度">
<van-slider v-model="settings.opacity" :min="0" :max="100" />
<van-slider
v-model="settings.opacity"
:min="0"
:max="100"
/>
</van-cell>
<van-cell title="弹幕大小">
<van-slider v-model="settings.fontSize" :min="12" :max="36" />
<van-slider
v-model="settings.fontSize"
:min="12"
:max="36"
/>
</van-cell>
<van-cell title="弹幕速度">
<van-slider v-model="settings.speed" :min="1" :max="10" />
<van-slider
v-model="settings.speed"
:min="1"
:max="10"
/>
</van-cell>
</van-cell-group>
</van-tab>
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,38 @@
<template>
<van-tabbar v-model="active" route fixed active-color="#fb7299" inactive-color="#9499a0" class="app-tabbar">
<van-tabbar-item to="/" icon="home-o">首页</van-tabbar-item>
<van-tabbar-item to="/category" icon="apps-o">分类</van-tabbar-item>
<van-tabbar-item v-if="userStore.isLoggedIn" to="/my/videos" icon="video-o">我的</van-tabbar-item>
<van-tabbar-item v-else to="/login" icon="user-o">我的</van-tabbar-item>
<van-tabbar
v-model="active"
route
fixed
active-color="#fb7299"
inactive-color="#9499a0"
class="app-tabbar"
>
<van-tabbar-item
to="/"
icon="home-o"
>
首页
</van-tabbar-item>
<van-tabbar-item
to="/category"
icon="apps-o"
>
分类
</van-tabbar-item>
<van-tabbar-item
v-if="userStore.isLoggedIn"
to="/my/videos"
icon="video-o"
>
我的
</van-tabbar-item>
<van-tabbar-item
v-else
to="/login"
icon="user-o"
>
我的
</van-tabbar-item>
</van-tabbar>
</template>
@@ -1,47 +1,53 @@
<template>
<div class="video-card" @click="handleClick">
<!-- 封面 -->
<article
class="video-card"
@click="handleClick"
>
<div class="video-cover">
<van-image
:src="video.coverUrl || defaultCover"
:src="getCoverUrl(video.coverUrl)"
fit="cover"
class="cover-image"
>
<template v-slot:loading>
<van-loading type="spinner" size="20" />
<template #loading>
<van-loading
type="spinner"
size="18"
/>
</template>
</van-image>
<!-- 时长 -->
<div class="cover-mask">
<div class="cover-metrics">
<span><van-icon name="play-circle-o" /> {{ formatPlayCount(video.viewCount) }}</span>
<span><van-icon name="comment-circle-o" /> {{ formatNumber(video.danmakuCount || video.commentCount) }}</span>
</div>
<span class="duration">{{ formatDuration(video.duration) }}</span>
<!-- 播放量 -->
<span class="view-count">{{ formatPlayCount(video.viewCount) }}播放</span>
</div>
</div>
<!-- 视频信息 -->
<div class="video-info">
<h3 class="video-title">{{ video.title }}</h3>
<div class="video-body">
<h3 class="video-title">
{{ video.title }}
</h3>
<div class="video-meta">
<span class="author">
<van-image
:src="video.userAvatar || defaultAvatar"
round
class="avatar"
fit="cover"
/>
{{ video.userNickname || '匿名用户' }}
</span>
<span>{{ video.userNickname || '匿名用户' }}</span>
<span>{{ video.categoryName || '综合' }}</span>
<span>{{ publishText }}</span>
</div>
<div class="video-stats">
<span><van-icon name="good-job-o" /> {{ formatNumber(video.likeCount) }}</span>
<span><van-icon name="chat-o" /> {{ formatNumber(video.commentCount) }}</span>
</div>
<span>点赞 {{ formatNumber(video.likeCount) }}</span>
<span>评论 {{ formatNumber(video.commentCount) }}</span>
</div>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VideoInfo } from '@/types'
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
import { resolveMediaUrl } from '@/utils/media'
const props = defineProps<{
video: VideoInfo
@@ -51,8 +57,21 @@ const emit = defineEmits<{
click: [id: number]
}>()
const defaultCover = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120"%3E%3Crect fill="%23f0f0f0" width="200" height="120"/%3E%3Ctext x="50%25" y="50%25" fill="%23ccc" text-anchor="middle" dy=".3em"%3E%E6%97%A0%E5%B0%81%E9%9D%A2%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 defaultCover =
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 200"%3E%3Crect fill="%23edf2f7" width="320" height="200"/%3E%3Ctext x="50%25" y="50%25" fill="%239499a0" text-anchor="middle" dy=".3em"%3EHAOVP%3C/text%3E%3C/svg%3E'
const getCoverUrl = (coverUrl?: string) => resolveMediaUrl(coverUrl) || defaultCover
const publishText = computed(() => {
const value = props.video.publishTime || props.video.createTime
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'
}).format(date)
})
const handleClick = () => {
emit('click', props.video.id)
@@ -61,115 +80,95 @@ const handleClick = () => {
<style scoped lang="scss">
.video-card {
background: transparent;
border-radius: 14px;
overflow: visible;
cursor: pointer;
transition: transform 0.22s ease;
&:hover {
transform: translateY(-4px);
.cover-image {
transform: scale(1.04);
}
.video-title {
color: #fb7299;
}
}
}
.video-cover {
position: relative;
width: 100%;
padding-top: 56.25%; /* 16:9 */
overflow: hidden;
border-radius: 14px;
background: #e5e7eb;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
border-radius: 6px;
background: #eef1f4;
aspect-ratio: 16 / 10;
.cover-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transition: transform 0.28s ease;
transition: transform 0.24s ease;
}
&:hover .cover-image {
transform: scale(1.04);
}
}
.cover-mask {
position: absolute;
inset: auto 0 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 28px 10px 8px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.76));
}
.cover-metrics {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
span {
display: inline-flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.94);
font-size: 12px;
}
}
.duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(15, 23, 42, 0.75);
color: #fff;
color: rgba(255, 255, 255, 0.94);
font-size: 12px;
padding: 4px 8px;
border-radius: 999px;
font-weight: 600;
flex-shrink: 0;
}
.view-count {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(15, 23, 42, 0.54);
color: #fff;
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
}
}
.video-info {
.video-body {
padding: 10px 2px 0;
}
.video-title {
font-size: 15px;
font-weight: 600;
color: #18191c;
margin: 0 0 9px;
line-height: 1.4;
margin: 0;
min-height: 42px;
color: #212121;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
min-height: 40px;
transition: color 0.2s ease;
.video-card:hover & {
color: var(--link-color);
}
}
.video-meta,
.video-stats {
display: flex;
flex-wrap: wrap;
gap: 8px 10px;
font-size: 12px;
color: #999;
}
.video-meta {
display: flex;
align-items: center;
font-size: 13px;
color: #61666d;
margin-bottom: 6px;
.author {
display: flex;
align-items: center;
gap: 6px;
}
.avatar {
width: 22px;
height: 22px;
border: 1px solid rgba(148, 163, 184, 0.18);
}
margin-top: 6px;
}
.video-stats {
display: flex;
gap: 14px;
font-size: 12px;
color: #9499a0;
span {
display: flex;
align-items: center;
gap: 4px;
}
}
margin-top: 4px;
}
</style>
@@ -1,25 +1,23 @@
<template>
<div class="video-player">
<video
ref="playerRef"
class="native-player"
:src="src"
:poster="poster"
controls
playsinline
preload="metadata"
@timeupdate="handleTimeUpdate"
@ended="emit('ended')"
<div
ref="containerRef"
class="video-player"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import DPlayer from 'dplayer'
import { danmakuApi } from '@/api/danmaku'
import { useUserStore } from '@/stores/user'
import { showToast } from 'vant'
type DPlayerInstance = InstanceType<typeof DPlayer>
const props = defineProps<{
src: string
poster?: string
videoId: number
}>()
const emit = defineEmits<{
@@ -27,15 +25,132 @@ const emit = defineEmits<{
ended: []
}>()
const playerRef = ref<HTMLVideoElement>()
const userStore = useUserStore()
const containerRef = ref<HTMLDivElement | null>(null)
let player: DPlayerInstance | null = null
const handleTimeUpdate = () => {
emit('timeupdate', playerRef.value?.currentTime || 0)
const toDPlayerType = (position: number) => {
if (position === 1) return 'top'
if (position === 2) return 'bottom'
return 'right'
}
watch(() => props.src, () => {
if (!playerRef.value) return
playerRef.value.load()
const fromDPlayerType = (type?: string) => {
if (type === 'top') return 1
if (type === 'bottom') return 2
return 0
}
const destroyPlayer = () => {
if (player) {
player.destroy()
player = null
}
}
const syncDanmakuInputState = () => {
if (!containerRef.value) return
const input = containerRef.value.querySelector<HTMLInputElement>('.dplayer-comment-input')
if (!input) return
input.placeholder = userStore.isLoggedIn ? '发个友善的弹幕见证一下' : '登录后即可发送弹幕'
input.disabled = !userStore.isLoggedIn
}
const initPlayer = async () => {
if (!containerRef.value || !props.src) return
destroyPlayer()
await nextTick()
player = new DPlayer({
container: containerRef.value,
theme: '#00AEEC',
autoplay: false,
screenshot: true,
hotkey: true,
loop: false,
volume: 0.7,
mutex: true,
airplay: true,
playbackSpeed: [0.75, 1, 1.25, 1.5, 2],
contextmenu: [
{
text: 'HAOVP 视频平台',
link: window.location.origin
}
],
video: {
url: props.src,
pic: props.poster,
type: 'auto'
},
danmaku: {
id: String(props.videoId),
api: '/danmaku',
user: userStore.username || '游客'
},
apiBackend: {
read: async (options: any) => {
try {
const list = await danmakuApi.getAllDanmaku(props.videoId)
options.success(list.map((item) => ({
author: String(item.userId),
time: item.time,
type: toDPlayerType(item.position),
text: item.content,
color: item.color
})))
} catch (error) {
console.error('读取弹幕失败:', error)
options.success([])
}
},
send: async (options: any) => {
if (!userStore.isLoggedIn) {
showToast('请先登录后再发送弹幕')
options.error(new Error('login required'))
return
}
try {
await danmakuApi.sendDanmaku({
videoId: props.videoId,
content: options.data.text,
color: options.data.color,
position: fromDPlayerType(options.data.type),
time: Number(options.data.time ?? player?.video.currentTime ?? 0),
fontSize: 25
})
options.success(options.data)
} catch (error) {
console.error('发送弹幕失败:', error)
options.error(error)
}
}
}
})
syncDanmakuInputState()
player.on('timeupdate', () => {
emit('timeupdate', player?.video.currentTime || 0)
})
player.on('ended', () => {
emit('ended')
})
}
onMounted(() => {
initPlayer()
})
onBeforeUnmount(() => {
destroyPlayer()
})
watch(() => [props.src, props.poster, props.videoId], () => {
initPlayer()
})
</script>
@@ -46,11 +161,39 @@ watch(() => props.src, () => {
background: #000;
}
.native-player {
.video-player:deep(.dplayer) {
width: 100%;
height: 100%;
aspect-ratio: 16 / 9;
display: block;
border-radius: 24px;
overflow: hidden;
background: #000;
}
.video-player:deep(.dplayer-controller) {
padding-bottom: 4px;
}
.video-player:deep(.dplayer-danmaku-bar) {
background: rgba(24, 25, 28, 0.78);
border-radius: 999px;
margin: 0 10px 10px;
}
.video-player:deep(.dplayer-comment-input) {
color: #fff;
}
.video-player:deep(.dplayer-comment-input::placeholder) {
color: rgba(255, 255, 255, 0.6);
}
.video-player:deep(.dplayer-menu),
.video-player:deep(.dplayer-setting-box) {
border-radius: 12px;
overflow: hidden;
}
.video-player:deep(.dplayer-controller .dplayer-icons .dplayer-icon:hover) {
color: #00aeec;
}
</style>
@@ -376,7 +376,7 @@ const mockHandlers: Record<string, (url: string, options: any) => Promise<any>>
'GET /comment/public/list': async (_url: string, options: any) => {
await delay()
const params = options.params || {}
let comments = mockComments.filter(c => c.videoId === params.videoId)
const comments = mockComments.filter(c => c.videoId === params.videoId)
if (params.sortBy === 'hot') {
comments.sort((a, b) => b.likeCount - a.likeCount)
} else {
@@ -526,7 +526,7 @@ const mockHandlers: Record<string, (url: string, options: any) => Promise<any>>
// 匹配 Mock 处理器
function matchHandler(url: string, method: string): ((url: string, options: any) => Promise<any>) | null {
// 标准化 URL(移除查询参数和 /api 前缀)
let normalizedUrl = url.replace(/^\/api/, '').split('?')[0]
const normalizedUrl = url.replace(/^\/api/, '').split('?')[0]
// 构建匹配模式
const patterns = Object.keys(mockHandlers)
@@ -30,7 +30,7 @@ export const useFavoriteStore = defineStore('favorite', () => {
}
// 创建收藏夹
async function createFavorite(params: { name: string; description?: string; visibility: number }) {
async function createFavorite(params: { name: string; description?: string; isPrivate: number }) {
try {
const result = await favoriteApi.createFavorite(params)
favorites.value.unshift(result)
@@ -70,7 +70,11 @@ export const useUserStore = defineStore('user', () => {
} catch (error) {
console.error('登出请求失败:', error)
} finally {
// 清除本地状态
clearAuth()
}
}
function clearAuth() {
token.value = null
refreshToken.value = null
userInfo.value = null
@@ -78,12 +82,11 @@ export const useUserStore = defineStore('user', () => {
removeToken()
removeRefreshToken()
}
}
// 刷新 Token
async function refreshAccessToken() {
if (!refreshToken.value) {
logout()
clearAuth()
return false
}
@@ -96,7 +99,7 @@ export const useUserStore = defineStore('user', () => {
return true
} catch (error) {
console.error('刷新Token失败:', error)
logout()
clearAuth()
return false
}
}
@@ -194,6 +197,7 @@ export const useUserStore = defineStore('user', () => {
login,
register,
logout,
clearAuth,
refreshAccessToken,
fetchAuthUser,
fetchUserInfo,
@@ -57,12 +57,15 @@ export const useVideoStore = defineStore('video', () => {
// 记录播放
async function recordPlay(videoId: number, progress: number, duration: number) {
const playTime = Math.max(0, Math.min(Math.round(progress), Math.round(duration)))
const isFinished = duration > 0 && playTime >= duration * 0.9 ? 1 : 0
try {
await videoApi.recordPlay({ videoId, progress, duration })
await videoApi.recordPlay({ videoId, playTime, isFinished })
// 更新本地播放历史
const index = playHistory.value.findIndex(item => item.videoId === videoId)
const record: PlayRecord = { videoId, progress, duration }
const record: PlayRecord = { videoId, playTime, isFinished }
if (index >= 0) {
playHistory.value[index] = record
@@ -81,7 +84,7 @@ export const useVideoStore = defineStore('video', () => {
// 获取播放进度
function getPlayProgress(videoId: number): number {
const record = playHistory.value.find(item => item.videoId === videoId)
return record?.progress || 0
return record?.playTime || 0
}
// 清除当前视频
+119 -33
View File
@@ -11,17 +11,19 @@ html,
body {
width: 100%;
min-height: 100vh;
font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
font-family: 'Microsoft YaHei', 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
line-height: 1.5;
color: #18191c;
background-color: #f6f7fb;
background-color: #f4f5f7;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
min-width: 320px;
}
a {
color: inherit;
text-decoration: none;
@@ -85,56 +87,54 @@ select {
// 主题变量
:root {
// 主色调
--primary-color: #fb7299;
--primary-hover-color: #ff85ad;
// 辅助色
--link-color: #00a1d6;
--link-hover-color: #33b4de;
--success-color: #52c41a;
--warning-color: #faad14;
--error-color: #ff4d4f;
--info-color: #1890ff;
// 粉色(B站风格)
--pink-color: #fb7299;
--pink-hover-color: #ff85ad;
// 文字颜色
--text-primary: #18191c;
--text-secondary: #61666d;
--text-tertiary: #9499a0;
--text-white: #fff;
// 背景颜色
--bg-primary: #f6f7fb;
--bg-primary: #f4f5f7;
--bg-secondary: #fff;
--bg-tertiary: #f1f2f3;
// 边框颜色
--bg-tertiary: #f6f7f8;
--bg-soft-blue: #edf7ff;
--bg-soft-pink: #fff2f6;
--border-color: #e3e5e7;
--border-light: #f1f2f3;
// 阴影
--shadow-sm: 0 4px 16px rgba(15, 23, 42, 0.04);
--shadow-md: 0 10px 28px rgba(15, 23, 42, 0.08);
--shadow-lg: 0 18px 48px rgba(15, 23, 42, 0.12);
// 圆角
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
// 间距
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 18px 44px rgba(0, 0, 0, 0.08);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
// 布局
--header-height: 60px;
--header-top-height: 42px;
--header-main-height: 74px;
--header-height: 74px;
--header-total-height: 116px;
--home-banner-height: 190px;
--sidebar-width: 200px;
--max-content-width: 1400px;
--max-content-width: 1380px;
}
@media (max-width: 768px) {
:root {
--header-main-height: 56px;
--header-height: 56px;
--header-total-height: 56px;
--home-banner-height: 132px;
}
}
// 暗色主题
@@ -203,6 +203,92 @@ select {
box-shadow: var(--shadow-sm);
}
.page-shell {
width: min(var(--max-content-width), calc(100% - 24px));
margin: 0 auto;
}
// Vant feedback layer overrides
:root {
--van-dialog-width: 420px;
--van-dialog-radius: 20px;
--van-dialog-background: rgba(255, 255, 255, 0.98);
--van-dialog-has-title-message-text-color: #61666d;
--van-dialog-message-font-size: 14px;
--van-dialog-confirm-button-text-color: #fb7299;
--van-dialog-cancel-button-text-color: #61666d;
--van-toast-radius: 18px;
--van-toast-default-width: auto;
--van-toast-background: rgba(24, 25, 28, 0.88);
--van-toast-text-color: #fff;
--van-notify-radius: 0 0 18px 18px;
}
.van-overlay {
backdrop-filter: blur(4px);
}
.van-dialog {
border-radius: 20px !important;
overflow: hidden;
box-shadow: 0 20px 44px rgba(15, 23, 42, 0.18);
.van-dialog__header {
padding: 22px 24px 6px;
font-size: 20px;
font-weight: 700;
color: #18191c;
text-align: left;
}
.van-dialog__message {
padding: 0 24px 18px;
line-height: 1.7;
text-align: left;
}
.van-dialog__footer {
padding: 0 16px 16px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
&::after {
display: none;
}
}
.van-dialog__confirm,
.van-dialog__cancel {
height: 42px;
margin: 0;
border-radius: 14px;
border: 1px solid #e9edf2;
font-size: 15px;
font-weight: 600;
}
.van-dialog__cancel {
background: #f6f7f8;
}
.van-dialog__confirm {
background: #fff2f6;
border-color: #ffd5e2;
}
}
.van-toast {
padding: 12px 16px;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.18);
font-size: 14px;
line-height: 1.5;
}
.van-notify {
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
}
// 骨架屏动画
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+1
View File
@@ -0,0 +1 @@
declare module 'dplayer';
+22 -6
View File
@@ -7,6 +7,8 @@ export interface ApiResponse<T = any> {
// 分页请求参数
export interface PageParams {
page?: number
size?: number
pageNum?: number
pageSize?: number
}
@@ -146,6 +148,17 @@ export interface VideoCreateParams {
tagIds?: number[]
}
export interface VideoUploadResult {
videoId?: number
videoUrl: string
coverUrl: string
duration: number
width: number
height: number
format: string
size: number
}
// 视频更新参数
export interface VideoUpdateParams {
id: number
@@ -222,6 +235,11 @@ export interface Comment {
userNickname?: string
userAvatar?: string
isLiked?: boolean
topStatus?: number
replyToId?: number
replyToNickname?: string
replyToAvatar?: string
hotReplies?: Comment[]
replies?: Comment[]
}
@@ -246,8 +264,7 @@ export interface Favorite {
userId: number
name: string
description: string
coverUrl: string
visibility: number
isPrivate: number
videoCount: number
createTime: string
}
@@ -256,8 +273,7 @@ export interface Favorite {
export interface FavoriteCreateParams {
name: string
description?: string
coverUrl?: string
visibility: number
isPrivate: number
}
// 收藏视频参数
@@ -328,8 +344,8 @@ export interface UserProfileUpdateParams {
// 播放记录
export interface PlayRecord {
videoId: number
progress: number
duration: number
playTime: number
isFinished?: number
}
// 推荐视频
@@ -0,0 +1,13 @@
const MEDIA_BASE_URL = (import.meta.env.VITE_MEDIA_BASE_URL || '').replace(/\/$/, '')
export function resolveMediaUrl(url?: string | null) {
if (!url) return ''
if (/^default\//i.test(url)) return ''
if (/^(data:|blob:|https?:\/\/)/i.test(url)) return url
if (!MEDIA_BASE_URL) return url
return `${MEDIA_BASE_URL}/${url.replace(/^\/+/, '')}`
}
export function resolveAvatarUrl(url?: string | null, fallback = '') {
return resolveMediaUrl(url) || fallback
}
@@ -7,6 +7,7 @@ import { mockRequest, isMockEnabled } from '@/mock'
// API 基础配置
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
const TIMEOUT = 30000
let handlingUnauthorized = false
// 创建 axios 实例
const service: AxiosInstance = axios.create({
@@ -42,15 +43,21 @@ service.interceptors.response.use(
// 业务状态码判断
if (data.code === 200) {
handlingUnauthorized = false
return data.data
}
// Token 过期
if (data.code === 401) {
const userStore = useUserStore()
userStore.logout()
if (!handlingUnauthorized) {
handlingUnauthorized = true
userStore.clearAuth()
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
showToast('登录已过期,请重新登录')
}
return Promise.reject(new Error('Token expired'))
}
@@ -69,12 +76,18 @@ service.interceptors.response.use(
} else if (error.response) {
const status = error.response.status
switch (status) {
case 401:
case 401: {
const userStore = useUserStore()
userStore.logout()
if (!handlingUnauthorized) {
handlingUnauthorized = true
userStore.clearAuth()
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
showToast('登录已过期,请重新登录')
}
break
}
case 403:
showToast('没有权限访问')
break
@@ -102,14 +115,20 @@ async function mockFetch<T>(method: string, url: string, data?: any, params?: an
})
if (result.code === 200) {
handlingUnauthorized = false
return result.data
}
if (result.code === 401) {
const userStore = useUserStore()
userStore.logout()
if (!handlingUnauthorized) {
handlingUnauthorized = true
userStore.clearAuth()
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
showToast('登录已过期,请重新登录')
}
throw new Error('Token expired')
}
@@ -3,7 +3,12 @@
<div class="content">
<h1>404</h1>
<p>页面未找到</p>
<van-button type="primary" to="/">返回首页</van-button>
<van-button
type="primary"
to="/"
>
返回首页
</van-button>
</div>
</div>
</template>
@@ -1,49 +1,147 @@
<template>
<div class="favorite-detail-page">
<van-nav-bar :title="favoriteTitle" fixed placeholder left-arrow @click-left="router.back()" />
<section class="favorite-hero">
<div class="page-shell detail-shell">
<div class="page-head">
<div class="head-main">
<button
class="back-link"
@click="router.back()"
>
返回
</button>
<div>
<h2>{{ favoriteTitle }}</h2>
<p>{{ description }}</p>
<strong>{{ favoriteTitle }}</strong>
<span>{{ total }} 个视频</span>
</div>
</div>
<div class="head-actions">
<span class="privacy-tag">{{ currentFavorite?.isPrivate ? '私密' : '公开' }}</span>
<button
class="edit-link"
@click="openEditPopup"
>
编辑
</button>
</div>
</div>
<div class="count">{{ total }} 个视频</div>
</section>
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多内容了"
@load="loadMore"
class="favorite-list"
>
<div class="video-grid">
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
<div
v-for="video in videos"
:key="video.id"
class="favorite-video-item"
>
<VideoCard
:video="video"
@click="goToVideo(video.id)"
/>
<button
class="remove-link"
@click.stop="removeVideo(video.id)"
>
移出收藏夹
</button>
</div>
</div>
<van-empty
v-if="!loading && !videos.length"
description="收藏夹里还没有视频"
/>
</van-list>
</div>
<van-popup
v-model:show="showEditPopup"
round
position="bottom"
>
<div class="favorite-popup">
<div class="popup-title">
编辑收藏夹
</div>
<van-field
v-model="editForm.name"
label="名称"
placeholder="请输入收藏夹名称"
/>
<van-field
v-model="editForm.description"
label="简介"
placeholder="可选"
/>
<van-cell title="设为私密">
<template #right-icon>
<van-switch
v-model="editForm.isPrivate"
:active-value="1"
:inactive-value="0"
/>
</template>
</van-cell>
<van-button
block
type="primary"
:loading="submitting"
@click="saveFavorite"
>
保存
</van-button>
</div>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import { favoriteApi } from '@/api/favorite.ts'
import { favoriteApi, type Favorite } from '@/api/favorite.ts'
import { videoApi } from '@/api/video.ts'
import type { VideoInfo } from '@/types'
const route = useRoute()
const router = useRouter()
const favoriteId = computed(() => Number(route.params.id))
const favoriteTitle = computed(() => favoriteId.value === 1 ? '默认收藏夹' : '学习资料')
const description = computed(() => favoriteId.value === 1 ? '最近常看的优质内容' : '项目开发与知识学习整理')
const currentFavorite = ref<Favorite | null>(null)
const favoriteTitle = computed(() => currentFavorite.value?.name || '收藏夹')
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const page = ref(1)
const total = ref(0)
const showEditPopup = ref(false)
const submitting = ref(false)
const editForm = ref({
id: 0,
name: '',
description: '',
isPrivate: 0
})
const loadFavoriteMeta = async () => {
try {
const favorites = await favoriteApi.getFavoriteList()
currentFavorite.value = favorites.find((item) => item.id === favoriteId.value) || null
if (currentFavorite.value) {
editForm.value = {
id: currentFavorite.value.id,
name: currentFavorite.value.name,
description: currentFavorite.value.description || '',
isPrivate: currentFavorite.value.isPrivate || 0
}
}
} catch {
showToast('加载收藏夹信息失败')
}
}
const loadMore = async () => {
if (finished.value) return
@@ -56,7 +154,7 @@ const loadMore = async () => {
videos.value.push(...res.records)
total.value = res.total
if (videos.value.length >= res.total || res.records.length < 12) finished.value = true
page.value++
page.value += 1
} catch {
finished.value = true
showToast('加载收藏夹失败')
@@ -69,7 +167,51 @@ const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
const openEditPopup = () => {
if (!currentFavorite.value) return
showEditPopup.value = true
}
const saveFavorite = async () => {
if (!editForm.value.name.trim()) {
showToast('请输入收藏夹名称')
return
}
submitting.value = true
try {
await favoriteApi.updateFavorite({
id: editForm.value.id,
name: editForm.value.name.trim(),
description: editForm.value.description.trim(),
isPrivate: editForm.value.isPrivate
})
showSuccessToast('已保存')
showEditPopup.value = false
await loadFavoriteMeta()
} catch (error: any) {
showToast(error?.message || '保存失败')
} finally {
submitting.value = false
}
}
const removeVideo = async (videoId: number) => {
try {
await showConfirmDialog({
title: '移除视频',
message: '确定从收藏中移除该视频吗?'
})
await videoApi.unfavoriteVideo(videoId)
videos.value = videos.value.filter((item) => item.id !== videoId)
total.value = Math.max(0, total.value - 1)
showSuccessToast('已移除')
} catch (error: any) {
if (error !== 'cancel' && error?.message) showToast(error.message)
}
}
onMounted(() => {
loadFavoriteMeta()
loadMore()
})
</script>
@@ -77,42 +219,60 @@ onMounted(() => {
<style scoped lang="scss">
.favorite-detail-page {
min-height: 100vh;
background: #f6f7fb;
background: #f4f5f7;
padding-bottom: 36px;
}
.favorite-hero {
margin: 18px auto 12px;
max-width: 1400px;
padding: 24px 22px;
border-radius: 24px;
color: #fff;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
.detail-shell {
padding-top: 18px;
}
.page-head {
margin-bottom: 16px;
padding: 16px 18px;
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
h2 {
margin: 0 0 8px;
font-size: 28px;
gap: 12px;
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.84);
.head-main {
display: flex;
align-items: center;
gap: 16px;
strong {
display: block;
font-size: 24px;
}
.count {
padding: 10px 16px;
span {
color: #9499a0;
font-size: 13px;
}
}
.back-link,
.edit-link {
color: var(--link-color);
font-size: 13px;
}
.head-actions {
display: flex;
align-items: center;
gap: 12px;
}
.privacy-tag {
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
}
}
.favorite-list {
max-width: 1400px;
margin: 0 auto;
padding: 8px 12px 24px;
background: #f6f7f8;
color: #61666d;
font-size: 12px;
}
.video-grid {
@@ -120,4 +280,27 @@ onMounted(() => {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px 16px;
}
.favorite-video-item {
display: grid;
gap: 8px;
}
.remove-link {
justify-self: flex-end;
color: #fb7299;
font-size: 13px;
}
.favorite-popup {
padding: 20px 16px 28px;
display: grid;
gap: 12px;
}
.popup-title {
text-align: center;
font-size: 18px;
font-weight: 700;
}
</style>
@@ -1,64 +1,143 @@
<template>
<div class="favorite-page">
<div class="favorite-shell">
<section class="favorite-header">
<h2>我的收藏</h2>
<p>整理日常浏览中想反复观看的内容与专题合集。</p>
</section>
<div class="page-shell favorite-shell">
<div class="page-head">
<div class="head-title">
<strong>我的收藏</strong>
<span>{{ favorites.length }} 个收藏夹</span>
</div>
<van-button
type="primary"
round
size="small"
@click="showCreatePopup = true"
>
新建收藏夹
</van-button>
</div>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadMore">
<van-cell-group v-if="favorites.length" inset>
<van-cell
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<div
v-if="favorites.length"
class="favorite-grid"
>
<div
v-for="fav in favorites"
:key="fav.id"
:title="fav.name"
:label="`${fav.videoCount}个视频`"
is-link
class="favorite-card"
>
<button
class="favorite-main"
@click="goToFavorite(fav.id)"
>
<template #icon>
<van-icon name="star" class="fav-icon" />
<div class="favorite-top">
<strong>{{ fav.name }}</strong>
<span class="privacy-tag">{{ fav.isPrivate ? '私密' : '公开' }}</span>
</div>
<div class="favorite-count">
{{ fav.videoCount }} 个视频
</div>
<p>{{ fav.description || '未填写简介' }}</p>
</button>
<button
v-if="fav.name !== '默认收藏夹'"
class="danger-link"
@click="removeFavorite(fav)"
>
删除
</button>
</div>
</div>
<van-empty
v-else
description="暂无收藏夹"
>
<van-button
round
type="primary"
@click="showCreatePopup = true"
>
新建收藏夹
</van-button>
</van-empty>
</van-list>
</div>
<van-popup
v-model:show="showCreatePopup"
round
position="bottom"
>
<div class="favorite-popup">
<div class="popup-title">
新建收藏夹
</div>
<van-field
v-model="createForm.name"
label="名称"
placeholder="请输入收藏夹名称"
/>
<van-field
v-model="createForm.description"
label="简介"
placeholder="可选"
/>
<van-cell title="设为私密">
<template #right-icon>
<van-switch
v-model="createForm.isPrivate"
:active-value="1"
:inactive-value="0"
/>
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无收藏夹" />
</van-list>
</van-pull-refresh>
<van-button
block
type="primary"
:loading="submitting"
@click="createFavorite"
>
创建
</van-button>
</div>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
import { favoriteApi, type Favorite } from '@/api/favorite.ts'
const router = useRouter()
const favorites = ref<Favorite[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const loadFavorites = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
favorites.value = []
finished.value = false
}
const showCreatePopup = ref(false)
const submitting = ref(false)
const createForm = ref({
name: '',
description: '',
isPrivate: 0
})
const loadFavorites = async () => {
loading.value = true
try {
const data = await favoriteApi.getFavoriteList()
favorites.value = data
favorites.value = await favoriteApi.getFavoriteList()
finished.value = true
} catch (error) {
} catch {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
@@ -66,60 +145,155 @@ const loadMore = () => {
if (!finished.value) loadFavorites()
}
const onRefresh = () => {
loadFavorites(true)
const createFavorite = async () => {
if (!createForm.value.name.trim()) {
showToast('请输入收藏夹名称')
return
}
submitting.value = true
try {
await favoriteApi.createFavorite({
name: createForm.value.name.trim(),
description: createForm.value.description.trim(),
isPrivate: createForm.value.isPrivate
})
showCreatePopup.value = false
createForm.value = { name: '', description: '', isPrivate: 0 }
showSuccessToast('创建成功')
await loadFavorites()
} catch (error: any) {
showToast(error?.message || '创建失败')
} finally {
submitting.value = false
}
}
const removeFavorite = async (favorite: Favorite) => {
try {
await showConfirmDialog({
title: '删除收藏夹',
message: `确定删除“${favorite.name}”吗?`
})
await favoriteApi.deleteFavorite(favorite.id)
showSuccessToast('删除成功')
await loadFavorites()
} catch (error: any) {
if (error !== 'cancel' && error?.message) showToast(error.message)
}
}
const goToFavorite = (id: number) => {
router.push(`/my/favorites/${id}`)
}
onMounted(() => {
loadFavorites()
})
onMounted(loadFavorites)
</script>
<style scoped lang="scss">
.favorite-page {
min-height: 100vh;
background: #f6f7fb;
padding-bottom: 60px;
background: #f4f5f7;
padding-bottom: 36px;
}
.favorite-shell {
max-width: 980px;
margin: 0 auto;
padding: 20px 12px 28px;
padding-top: 18px;
}
.favorite-header {
padding: 24px 26px;
border-radius: 28px;
background: linear-gradient(135deg, #fb7185, #8b5cf6);
color: #fff;
margin-bottom: 18px;
h2 {
margin: 0 0 8px;
font-size: 28px;
.page-head,
.favorite-card {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.82);
.page-head {
padding: 16px 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.head-title {
display: grid;
gap: 4px;
strong {
font-size: 24px;
}
span {
color: #9499a0;
font-size: 13px;
}
}
.favorite-shell :deep(.van-cell-group) {
margin: 0;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
.favorite-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.fav-icon {
color: #f7ba2a;
margin-right: 8px;
font-size: 20px;
.favorite-card {
padding: 18px;
display: grid;
gap: 14px;
}
.favorite-main {
text-align: left;
}
.favorite-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
strong {
font-size: 18px;
color: #18191c;
}
}
.privacy-tag {
padding: 4px 8px;
border-radius: 999px;
background: #f6f7f8;
color: #61666d;
font-size: 12px;
}
.favorite-count {
margin-top: 10px;
color: var(--link-color);
font-size: 13px;
}
.favorite-card p {
margin: 10px 0 0;
color: #61666d;
font-size: 13px;
line-height: 1.6;
}
.danger-link {
justify-self: flex-end;
color: #fb7299;
font-size: 13px;
}
.favorite-popup {
padding: 20px 16px 28px;
display: grid;
gap: 12px;
}
.popup-title {
text-align: center;
font-size: 18px;
font-weight: 700;
}
</style>
@@ -1,18 +1,30 @@
<template>
<div class="category-page">
<van-nav-bar title="分类" fixed placeholder />
<div class="page-shell category-shell">
<div class="category-header">
<div class="header-copy">
<h1>{{ currentCategoryName || '分类' }}</h1>
</div>
<button
class="back-button"
@click="router.push('/')"
>
返回首页
</button>
</div>
<div class="category-content">
<van-sidebar v-model="activeCategory">
<van-sidebar-item
<div class="category-switcher">
<button
v-for="cat in categories"
:key="cat.id"
:title="cat.name"
/>
</van-sidebar>
class="switcher-item"
:class="{ active: activeCategoryId === cat.id }"
@click="switchCategory(cat.id)"
>
{{ cat.name }}
</button>
</div>
<div class="video-list">
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
@@ -27,9 +39,12 @@
@click="goToVideo(video.id)"
/>
</div>
<van-empty
v-if="!loading && !videos.length"
description="该分类下暂无视频"
/>
</van-list>
</van-pull-refresh>
</div>
</div>
<AppTabbar />
@@ -37,8 +52,8 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import AppTabbar from '@/components/layout/AppTabbar.vue'
@@ -46,56 +61,75 @@ import { categoryApi, type Category } from '@/api/category.ts'
import { videoApi } from '@/api/video.ts'
import type { VideoInfo } from '@/types'
const route = useRoute()
const router = useRouter()
const categories = ref<Category[]>([])
const activeCategory = ref(0)
const activeCategoryId = ref<number | null>(null)
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const routeCategoryId = computed(() => {
const value = Number(route.params.id)
return Number.isFinite(value) && value > 0 ? value : null
})
const currentCategory = computed(() => categories.value.find((item) => item.id === activeCategoryId.value) || null)
const currentCategoryName = computed(() => currentCategory.value?.name || '')
const syncCategoryFromRoute = () => {
if (!categories.value.length) return
if (routeCategoryId.value && categories.value.some((item) => item.id === routeCategoryId.value)) {
activeCategoryId.value = routeCategoryId.value
return
}
activeCategoryId.value = categories.value[0]?.id ?? null
}
const loadCategories = async () => {
try {
categories.value = await categoryApi.getCategoryList()
} catch (error) {
syncCategoryFromRoute()
if (activeCategoryId.value) {
await loadVideos(true)
} else {
finished.value = true
}
} catch {
showToast('加载分类失败')
}
}
const loadVideos = async (isRefresh = false) => {
if (!activeCategoryId.value) return
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
const category = categories.value[activeCategory.value]
if (!category) return
loading.value = true
try {
const res = await videoApi.getVideoPublicList({
pageNum: currentPage.value,
pageSize: 20,
categoryId: category.id
categoryId: activeCategoryId.value
})
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
}
videos.value = isRefresh ? res.records : [...videos.value, ...res.records]
if (videos.value.length >= res.total || res.records.length < 20) {
finished.value = true
}
currentPage.value++
} catch (error) {
currentPage.value += 1
} catch {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
@@ -103,44 +137,92 @@ const loadMore = () => {
if (!finished.value) loadVideos()
}
const onRefresh = () => {
loadVideos(true)
const switchCategory = (categoryId: number) => {
if (categoryId === activeCategoryId.value) return
router.push(`/category/${categoryId}`)
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
watch(activeCategory, () => {
loadVideos(true)
watch(routeCategoryId, async () => {
if (!categories.value.length) return
syncCategoryFromRoute()
await loadVideos(true)
})
onMounted(() => {
loadCategories()
})
onMounted(loadCategories)
</script>
<style scoped lang="scss">
.category-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
background: #f4f5f7;
padding: 18px 0 60px;
}
.category-content {
display: flex;
height: calc(100vh - 46px);
.category-shell {
max-width: 1400px;
}
.video-list {
flex: 1;
overflow-y: auto;
padding: 12px;
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.header-copy h1 {
margin: 0;
font-size: 32px;
color: #18191c;
}
.back-button {
padding: 8px 14px;
border-radius: 999px;
background: #fff;
color: #18191c;
box-shadow: var(--shadow-sm);
}
.category-switcher {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 18px;
}
.switcher-item {
padding: 8px 14px;
border-radius: 999px;
background: #fff;
color: #61666d;
box-shadow: var(--shadow-sm);
&.active {
background: #ffecf1;
color: #ff6699;
}
}
.video-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px 16px;
margin-bottom: 20px;
}
@media (max-width: 768px) {
.category-header {
flex-direction: column;
align-items: flex-start;
}
.video-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,276 +1,673 @@
<template>
<div class="home-page">
<section class="hero-panel">
<div class="hero-copy">
<span class="hero-tag">热门精选</span>
<h1>发现值得反复观看的优质内容</h1>
<p>围绕技术分享、知识学习与生活记录构建内容分发首页,提供推荐、热门与分类浏览体验。</p>
<section class="channel-panel page-shell">
<div
class="channel-main"
@mouseenter="categoryExpanded = true"
@mouseleave="categoryExpanded = false"
>
<div class="quick-entry-group">
<button
v-for="entry in quickEntries"
:key="entry.label"
class="quick-entry"
@click="entry.action()"
>
<span class="quick-icon">{{ entry.short }}</span>
<span>{{ entry.label }}</span>
</button>
</div>
<div class="hero-metrics">
<div class="metric"><strong>{{ videos.length || 12 }}</strong><span>精选视频</span></div>
<div class="metric"><strong>{{ categories.length || 5 }}</strong><span>内容分区</span></div>
<div class="metric"><strong>24h</strong><span>热度更新</span></div>
<div class="channel-grid">
<button
v-for="entry in visibleCategoryEntries"
:key="entry.label"
class="channel-pill"
@click="entry.action()"
>
{{ entry.label }}
</button>
<button
v-if="hiddenCategoryEntries.length"
class="channel-pill more-pill"
@mouseenter="categoryExpanded = true"
>
更多
</button>
</div>
<transition name="expand-fade">
<div
v-if="categoryExpanded && hiddenCategoryEntries.length"
class="channel-expand"
>
<button
v-for="entry in hiddenCategoryEntries"
:key="`extra-${entry.label}`"
class="channel-pill"
@click="entry.action()"
>
{{ entry.label }}
</button>
</div>
</transition>
</div>
</section>
<section class="top-feed page-shell">
<div class="top-feed-main">
<van-swipe
class="banner-swipe"
:autoplay="4000"
indicator-color="#ffffff"
>
<van-swipe-item
v-for="item in bannerVideos"
:key="item.id"
>
<button
class="banner-card"
@click="goToVideo(item.id)"
>
<van-image
:src="getCoverUrl(item.coverUrl)"
fit="cover"
class="banner-image"
/>
<div class="banner-overlay">
<strong>{{ item.title }}</strong>
<span>{{ item.userNickname || '匿名用户' }}</span>
</div>
</button>
</van-swipe-item>
</van-swipe>
</div>
<div class="top-feed-side">
<button
v-for="item in topSideVideos"
:key="item.id"
class="top-video-card"
@click="goToVideo(item.id)"
>
<div class="top-video-cover">
<van-image
:src="getCoverUrl(item.coverUrl)"
fit="cover"
class="top-video-image"
/>
<div class="top-video-mask">
<span>{{ formatPlayCount(item.viewCount) }}</span>
<span>{{ formatDuration(item.duration) }}</span>
</div>
</div>
<div class="top-video-title">
{{ item.title }}
</div>
</button>
</div>
</section>
<!-- 分类导航 -->
<div class="category-nav">
<van-tabs v-model:active="activeCategory" sticky shrink @change="onCategoryChange">
<van-tab title="推荐" name="recommend" />
<van-tab title="热门" name="hot" />
<van-tab v-for="cat in categories" :key="cat.id" :title="cat.name" :name="cat.id" />
</van-tabs>
<section class="content-section page-shell">
<div class="content-main">
<div
v-for="row in categoryRows"
:key="row.id"
class="content-row"
>
<div class="row-head">
<div class="row-title">
<span class="row-dot" />
<strong>{{ row.name }}</strong>
</div>
<button
class="row-more"
@click="router.push(`/category/${row.id}`)"
>
更多
</button>
</div>
<!-- 视频列表 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
class="video-list"
>
<div class="video-grid">
<div class="row-grid">
<VideoCard
v-for="video in videos"
:key="video.id"
v-for="video in row.videos"
:key="`${row.id}-${video.id}`"
:video="video"
@click="goToVideo(video.id)"
/>
</div>
</van-list>
</van-pull-refresh>
</div>
<!-- 底部导航 -->
<AppTabbar />
<div
v-if="!categoryRows.length"
class="content-row"
>
<div class="row-head">
<div class="row-title">
<span class="row-dot" />
<strong>最新发布</strong>
</div>
</div>
<div class="row-grid">
<VideoCard
v-for="video in fallbackContentVideos"
:key="`fallback-${video.id}`"
:video="video"
@click="goToVideo(video.id)"
/>
</div>
</div>
</div>
<aside class="content-side">
<div class="side-card">
<div class="side-head">
<strong>热门排行</strong>
<button
class="side-link"
@click="router.push('/search?keyword=热门')"
>
更多
</button>
</div>
<button
v-for="(video, index) in hotVideos.slice(0, 10)"
:key="video.id"
class="rank-item"
@click="goToVideo(video.id)"
>
<span class="rank-index">{{ index + 1 }}</span>
<div class="rank-copy">
<strong>{{ video.title }}</strong>
<span>{{ formatPlayCount(video.viewCount) }} 播放</span>
</div>
</button>
</div>
</aside>
</section>
</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 VideoCard from '@/components/video/VideoCard.vue'
import AppTabbar from '@/components/layout/AppTabbar.vue'
import { videoApi } from '@/api/video.ts'
import { categoryApi, type Category } from '@/api/category.ts'
import { recommendApi } from '@/api/recommend.ts'
import { videoApi } from '@/api/video.ts'
import type { VideoInfo } from '@/types'
import { formatDuration, formatPlayCount } from '@/utils'
import { resolveMediaUrl } from '@/utils/media'
import VideoCard from '@/components/video/VideoCard.vue'
interface CategoryRow {
id: number
name: string
videos: VideoInfo[]
}
const router = useRouter()
// 分类
const categories = ref<Category[]>([])
const activeCategory = ref<string | number>('recommend')
const featuredVideos = ref<VideoInfo[]>([])
const hotVideos = ref<VideoInfo[]>([])
const categoryRows = ref<CategoryRow[]>([])
const fallbackVideos = ref<VideoInfo[]>([])
const categoryExpanded = ref(false)
// 视频列表
const videos = ref<VideoInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const pageSize = 20
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 loadCategories = async () => {
try {
const res = await categoryApi.getCategoryList()
categories.value = res
} catch (error) {
console.error('加载分类失败:', error)
const getCoverUrl = (coverUrl?: string) => resolveMediaUrl(coverUrl) || defaultCover
const quickEntries = computed(() => [{ label: '热门', short: '热', action: () => router.push('/search?keyword=热门') }])
const categoryEntries = computed(() =>
categories.value.map((category) => ({
label: category.name,
action: () => router.push(`/category/${category.id}`)
}))
)
const visibleCategoryEntries = computed(() => categoryEntries.value.slice(0, 16))
const hiddenCategoryEntries = computed(() => categoryEntries.value.slice(16))
const allTopVideos = computed(() => {
const merged = [...featuredVideos.value, ...hotVideos.value, ...fallbackVideos.value]
const deduplicated: VideoInfo[] = []
const seen = new Set<number>()
for (const item of merged) {
if (!item?.id || seen.has(item.id)) continue
seen.add(item.id)
deduplicated.push(item)
}
}
// 加载视频列表
const loadVideos = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
}
loading.value = true
try {
let res
if (activeCategory.value === 'recommend') {
// 首页推荐返回列表
const list = await recommendApi.getHomeRecommend(currentPage.value, pageSize)
res = { records: list, total: list.length }
// 推荐接口数据较少时标记完成
if (list.length < pageSize) {
finished.value = true
}
} else if (activeCategory.value === 'hot') {
// 热门推荐返回列表,periodType: 1-日榜
const list = await recommendApi.getHotRecommend(1, pageSize)
res = { records: list, total: list.length }
finished.value = true // 热门榜只加载一次
} else {
// 分类视频返回分页对象,参数名为 pageNum 和 pageSize
res = await videoApi.getVideoPublicList({
pageNum: currentPage.value,
pageSize: pageSize,
categoryId: activeCategory.value as number
return deduplicated
})
const bannerVideos = computed(() => allTopVideos.value.slice(0, 4))
const topSideVideos = computed(() => {
return allTopVideos.value.slice(4, 10)
})
const fallbackContentVideos = computed(() => allTopVideos.value.slice(0, 8))
const loadHomeData = async () => {
const [categoryList, recommendList, hotList, publicList] = await Promise.all([
categoryApi.getCategoryList(),
recommendApi.getHomeRecommend(1, 12).catch(() => []),
recommendApi.getHotRecommend(1, 12).catch(() => []),
videoApi.getVideoPublicList({ pageNum: 1, pageSize: 80 }).catch(() => ({
records: [],
total: 0,
size: 80,
current: 1,
pages: 0
}))
])
categories.value = categoryList
hotVideos.value = hotList
featuredVideos.value = recommendList
fallbackVideos.value = publicList.records
if (!featuredVideos.value.length) {
featuredVideos.value = publicList.records.slice(0, 12)
}
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
if (!hotVideos.value.length) {
hotVideos.value = publicList.records.slice(0, 12)
}
// 判断是否还有更多
if (videos.value.length >= res.total || res.records.length < pageSize) {
finished.value = true
}
currentPage.value++
} catch (error: any) {
showToast(error.message || '加载失败')
finished.value = true
} finally {
loading.value = false
refreshing.value = false
const categoryMap = new Map(categoryList.map((category) => [category.id, category]))
const groupedRows = new Map<number, VideoInfo[]>()
for (const video of publicList.records) {
if (!video.categoryId || !categoryMap.has(video.categoryId)) continue
const current = groupedRows.get(video.categoryId) || []
if (current.length < 8) {
current.push(video)
groupedRows.set(video.categoryId, current)
}
}
// 加载更多
const loadMore = () => {
if (!finished.value) {
loadVideos()
const groupedResult = [...groupedRows.entries()]
.map(([categoryId, videos]) => ({
id: categoryId,
name: categoryMap.get(categoryId)?.name || '分类',
videos
}))
.filter((row) => row.videos.length > 0)
.slice(0, 6)
// If category distribution is too sparse, fall back to a few curated sections
// so the homepage still looks populated with current data.
if (groupedResult.length >= 2) {
categoryRows.value = groupedResult
return
}
const fallbackRows: CategoryRow[] = []
const appendRow = (id: number, name: string, videos: VideoInfo[]) => {
const normalized = videos.filter((video, index, arr) => video?.id && arr.findIndex((item) => item.id === video.id) === index).slice(0, 8)
if (normalized.length) {
fallbackRows.push({ id, name, videos: normalized })
}
}
// 下拉刷新
const onRefresh = () => {
loadVideos(true)
appendRow(-1, '推荐内容', featuredVideos.value)
appendRow(-2, '热门内容', hotVideos.value)
appendRow(-3, '最新发布', publicList.records)
categoryRows.value = fallbackRows
}
// 分类切换
const onCategoryChange = () => {
loadVideos(true)
}
// 跳转视频详情
const goToVideo = (id: number) => {
if (!id) return
router.push(`/video/${id}`)
}
onMounted(() => {
loadCategories()
loadVideos()
onMounted(async () => {
try {
await loadHomeData()
} catch (error) {
console.error('首页加载失败:', error)
}
})
</script>
<style scoped lang="scss">
.home-page {
min-height: 100vh;
background: #f6f7fb;
padding-bottom: 60px;
@media (min-width: 768px) {
padding-bottom: 0;
}
background: #f4f5f7;
padding: 16px 0 36px;
}
.hero-panel {
margin: 20px auto 12px;
padding: 30px 28px;
border-radius: 24px;
color: #fff;
max-width: 1380px;
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.28), transparent 24%),
linear-gradient(135deg, #fb7299 0%, #ff85ad 32%, #3da5ff 100%);
box-shadow: 0 24px 52px rgba(251, 114, 153, 0.18);
.hero-copy h1 {
margin: 10px 0;
font-size: 28px;
line-height: 1.25;
.channel-panel,
.content-row,
.side-card {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.hero-copy p {
margin: 0;
max-width: 760px;
color: rgba(255, 255, 255, 0.84);
line-height: 1.7;
.channel-panel {
padding: 14px 16px;
}
.hero-tag {
display: inline-flex;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
font-size: 12px;
letter-spacing: 0.1em;
}
.channel-main {
position: relative;
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.hero-metrics {
.quick-entry-group {
display: flex;
gap: 12px;
margin-top: 18px;
flex-wrap: wrap;
.metric {
min-width: 92px;
padding: 12px 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(10px);
gap: 10px;
}
.quick-entry {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 82px;
padding: 8px 0;
flex: 0 0 82px;
}
.quick-icon {
width: 52px;
height: 52px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #4cb4e7, #fb7299);
}
.channel-grid {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 10px;
width: 100%;
}
.channel-expand {
position: absolute;
left: 98px;
right: 0;
top: calc(100% + 12px);
padding: 14px;
border-radius: 12px;
background: #fff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 10px;
z-index: 6;
}
.channel-pill {
width: 100%;
min-height: 38px;
padding: 0 8px;
border-radius: 8px;
background: #f6f7f8;
color: #505965;
font-size: 14px;
white-space: nowrap;
}
.more-pill {
color: var(--link-color);
font-weight: 600;
}
.top-feed {
margin-top: 14px;
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr);
gap: 16px;
}
.banner-swipe,
.banner-card {
width: 100%;
height: 100%;
}
.banner-card {
position: relative;
overflow: hidden;
border-radius: 10px;
aspect-ratio: 16 / 9;
text-align: left;
}
.banner-image {
width: 100%;
height: 100%;
}
.banner-overlay {
position: absolute;
inset: auto 0 0;
padding: 54px 18px 18px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.82));
strong {
display: block;
font-size: 22px;
margin-bottom: 4px;
color: #fff;
font-size: 18px;
line-height: 1.4;
}
span {
display: inline-block;
margin-top: 6px;
color: rgba(255, 255, 255, 0.78);
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
}
}
.category-nav {
background: rgba(255, 255, 255, 0.88);
position: sticky;
top: var(--header-height);
z-index: 100;
backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
margin-bottom: 6px;
:deep(.van-tabs__nav) {
padding: 0 12px;
max-width: 1400px;
margin: 0 auto;
}
:deep(.van-tab) {
padding: 0 16px;
font-size: 15px;
color: #61666d;
}
:deep(.van-tabs__line) {
background: #fb7299;
}
}
.video-list {
max-width: 1400px;
margin: 0 auto;
padding: 12px 12px 20px;
}
.video-grid {
.top-feed-side {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px 16px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.top-video-card {
width: 100%;
text-align: left;
}
.top-video-cover {
position: relative;
overflow: hidden;
border-radius: 8px;
aspect-ratio: 16 / 10;
background: #edf2f7;
}
.top-video-image {
width: 100%;
height: 100%;
}
.top-video-mask {
position: absolute;
inset: auto 0 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 8px 6px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.78));
span {
color: #fff;
font-size: 12px;
}
}
.top-video-title {
margin-top: 8px;
color: #18191c;
font-size: 14px;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.content-section {
margin-top: 18px;
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
}
.content-main {
display: grid;
gap: 18px;
}
.content-row {
padding: 18px;
}
.row-head,
.side-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.row-title {
display: flex;
align-items: center;
gap: 10px;
strong {
font-size: 20px;
}
}
.row-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--link-color);
}
.row-more,
.side-link {
color: var(--link-color);
font-size: 13px;
}
.row-grid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.side-card {
padding: 18px;
}
.rank-item {
width: 100%;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 0;
text-align: left;
& + .rank-item {
border-top: 1px solid #f1f2f3;
}
}
.rank-index {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 4px;
background: #00a1d6;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.rank-copy {
min-width: 0;
display: grid;
gap: 4px;
strong {
color: #18191c;
font-size: 13px;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
span {
color: #999;
font-size: 12px;
}
}
@media (max-width: 1200px) {
.content-section,
.top-feed {
grid-template-columns: 1fr;
}
.row-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.channel-main,
.channel-grid,
.top-feed-side,
.row-grid,
.channel-expand {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.quick-entry-group {
grid-column: 1 / -1;
}
.channel-expand {
left: 0;
right: auto;
}
}
.expand-fade-enter-active,
.expand-fade-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.expand-fade-enter-from,
.expand-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
@@ -3,9 +3,15 @@
<!-- 顶部导航 -->
<AppHeader />
<!-- 主内容区 -->
<main class="main-content">
<main
class="main-content"
:class="{ 'is-home': isHome }"
>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<transition
name="fade"
mode="out-in"
>
<component :is="Component" />
</transition>
</router-view>
@@ -19,9 +25,12 @@
import AppHeader from '@/components/layout/AppHeader.vue'
import AppTabbar from '@/components/layout/AppTabbar.vue'
import { useAppStore } from '@/stores'
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const appStore = useAppStore()
const route = useRoute()
const isHome = computed(() => route.path === '/')
onMounted(() => {
appStore.initApp()
@@ -38,9 +47,13 @@ onMounted(() => {
.main-content {
flex: 1;
padding-top: var(--header-height);
padding-top: var(--header-total-height);
padding-bottom: 50px; // 移动端 tabbar 高度
&.is-home {
padding-top: var(--home-banner-height);
}
@media (min-width: 768px) {
padding-bottom: 0;
}
@@ -1,24 +1,73 @@
<template>
<div class="login-page">
<div class="login-container">
<!-- Logo -->
<div class="login-header">
<h1 class="logo">HAOVP</h1>
<p class="slogan">登录后即可继续观看、发布与互动</p>
<div class="auth-page">
<div class="auth-shell">
<section class="auth-showcase">
<div class="showcase-copy">
<p class="eyebrow">
Member Access
</p>
<h1>登录后继续观看、互动与创作</h1>
<p class="lead">
你的播放记录、收藏夹、私信和投稿入口都会在登录后同步到当前账号,适合持续使用整套前后端联调能力。
</p>
</div>
<!-- 登录表单 -->
<van-form @submit="handleLogin" class="login-form">
<!-- 验证码 -->
<div class="captcha-row">
<div class="showcase-board">
<div class="board-card">
<strong>观看</strong>
<span>保存播放记录,继续浏览推荐流和分区内容。</span>
</div>
<div class="board-card">
<strong>互动</strong>
<span>评论、回复、通知、私信都需要登录态支撑。</span>
</div>
<div class="board-card">
<strong>创作</strong>
<span>上传视频、进入创作中心和个人空间管理。</span>
</div>
</div>
</section>
<section class="auth-panel">
<div class="panel-top">
<button
class="back-home"
@click="router.push('/')"
>
返回首页
</button>
<div class="brand-lockup">
<h1 class="logo">
HAOVP
</h1>
<p class="slogan">
登录后即可继续观看、发布与互动
</p>
</div>
</div>
<van-form
class="login-form"
@submit="handleLogin"
>
<div class="captcha-banner">
<div>
<strong>安全验证</strong>
<span>点击验证码图片可刷新,当前登录必须填写验证码。</span>
</div>
<van-image
:src="captchaImage"
fit="contain"
class="captcha-image"
@click="refreshCaptcha"
>
<template v-slot:error>
<div class="captcha-error" @click="refreshCaptcha">点击刷新</div>
<template #error>
<div
class="captcha-error"
@click="refreshCaptcha"
>
点击刷新
</div>
</template>
</van-image>
</div>
@@ -57,6 +106,10 @@
</van-field>
</van-cell-group>
<div class="tips-row">
<span>登录成功后会优先回到你访问前的页面。</span>
</div>
<div class="login-actions">
<van-button
round
@@ -64,25 +117,30 @@
type="primary"
native-type="submit"
:loading="loading"
color="#00a1d6"
class="submit-btn"
>
登录
</van-button>
</div>
</van-form>
<!-- 其他登录方式 -->
<div class="login-footer">
<router-link to="/register" class="link">注册账号</router-link>
<router-link
to="/register"
class="link"
>
注册账号
</router-link>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast, showToast } from 'vant'
import { authApi, type CaptchaResult } from '@/api/auth.ts'
import { useUserStore } from '@/stores/user.ts'
@@ -100,7 +158,6 @@ const form = reactive({
captcha: ''
})
// 获取验证码
const refreshCaptcha = async () => {
try {
const res: CaptchaResult = await authApi.getCaptcha()
@@ -111,7 +168,6 @@ const refreshCaptcha = async () => {
}
}
// 登录
const handleLogin = async () => {
if (!form.username || !form.password || !form.captcha) {
showToast('请填写完整信息')
@@ -127,27 +183,22 @@ const handleLogin = async () => {
captchaKey: captchaKey.value
})
// 保存登录信息
userStore.setToken(result.accessToken, result.refreshToken)
userStore.setUserInfo({
id: result.userId,
username: result.username
})
// 获取用户资料
try {
await userStore.fetchUserInfo()
} catch (e) {
// 忽略获取用户资料错误
} catch {
// 忽略获取资料错误,保持登录结果优先
}
showSuccessToast('登录成功')
// 跳转到之前的页面或首页
const redirect = route.query.redirect as string
router.replace(redirect || '/')
} catch (error: any) {
// error 可能是 Error 对象或 axios 错误响应
const message = error?.response?.data?.message || error?.message || '登录失败'
showToast(message)
refreshCaptcha()
@@ -162,102 +213,251 @@ onMounted(() => {
</script>
<style scoped lang="scss">
.login-page {
.auth-page {
min-height: 100vh;
background:
radial-gradient(circle at 20% 20%, rgba(14, 165, 233, 0.26), transparent 26%),
radial-gradient(circle at 85% 80%, rgba(249, 115, 22, 0.24), transparent 24%),
linear-gradient(135deg, #081222 0%, #12345b 52%, #0f766e 100%);
radial-gradient(circle at 18% 18%, rgba(56, 189, 248, 0.18), transparent 24%),
radial-gradient(circle at 82% 82%, rgba(251, 113, 133, 0.18), transparent 22%),
linear-gradient(135deg, #09111f 0%, #102b4c 55%, #0f766e 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
padding: 28px 20px;
}
.login-container {
width: 100%;
max-width: 430px;
background: rgba(255, 255, 255, 0.96);
border-radius: 28px;
padding: 42px 28px 28px;
.auth-shell {
width: min(1180px, 100%);
display: grid;
grid-template-columns: minmax(0, 1.15fr) 430px;
gap: 20px;
}
.auth-showcase,
.auth-panel {
border-radius: 30px;
overflow: hidden;
}
.auth-showcase {
padding: 34px 32px;
color: #fff;
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.2), transparent 28%),
linear-gradient(135deg, rgba(15, 23, 42, 0.92), rgba(29, 78, 216, 0.88));
box-shadow: 0 24px 80px rgba(4, 16, 33, 0.34);
backdrop-filter: blur(22px);
display: grid;
align-content: space-between;
}
.login-header {
text-align: center;
margin-bottom: 32px;
.eyebrow {
margin: 0 0 12px;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.logo {
font-size: 36px;
font-weight: bold;
color: #00a1d6;
.showcase-copy {
h1 {
margin: 0 0 12px;
font-size: 40px;
line-height: 1.18;
}
.lead {
margin: 0;
max-width: 620px;
color: rgba(255, 255, 255, 0.82);
line-height: 1.8;
}
}
.slogan {
margin-top: 10px;
color: #64748b;
.showcase-board {
display: grid;
gap: 12px;
margin-top: 28px;
}
.board-card {
padding: 18px 20px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px);
strong {
display: block;
margin-bottom: 6px;
font-size: 16px;
}
span {
color: rgba(255, 255, 255, 0.76);
font-size: 13px;
line-height: 1.7;
}
}
.captcha-row {
display: flex;
justify-content: center;
margin-bottom: 16px;
.auth-panel {
background: rgba(255, 255, 255, 0.96);
padding: 28px 26px 26px;
box-shadow: 0 24px 80px rgba(4, 16, 33, 0.34);
backdrop-filter: blur(22px);
}
.captcha-image {
height: 40px;
.panel-top {
margin-bottom: 22px;
}
.back-home {
border: none;
background: #f8fafc;
color: #475569;
padding: 8px 12px;
border-radius: 999px;
font-size: 12px;
cursor: pointer;
}
.captcha-error {
.brand-lockup {
margin-top: 16px;
}
.logo {
margin: 0;
font-size: 34px;
font-weight: 800;
color: #00a1d6;
}
.slogan {
margin: 10px 0 0;
color: #64748b;
font-size: 13px;
line-height: 1.7;
}
.captcha-banner {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 18px;
background: #f8fafc;
strong {
display: block;
margin-bottom: 4px;
color: #18191c;
font-size: 14px;
}
span {
color: #64748b;
font-size: 12px;
line-height: 1.6;
}
}
.captcha-image,
.captcha-btn {
flex-shrink: 0;
cursor: pointer;
}
.captcha-image {
width: 120px;
height: 40px;
}
.captcha-btn {
width: 100px;
height: 32px;
}
.captcha-error {
width: 120px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
color: #999;
background: #eef2f7;
color: #64748b;
font-size: 12px;
cursor: pointer;
}
border-radius: 12px;
}
.login-form {
:deep(.van-cell-group--inset) {
margin: 0;
border-radius: 20px;
overflow: hidden;
box-shadow: inset 0 0 0 1px #eef2f7;
}
:deep(.van-field) {
padding: 12px 16px;
padding: 14px 16px;
}
}
.captcha-btn {
height: 32px;
cursor: pointer;
}
.tips-row {
margin-top: 12px;
color: #64748b;
font-size: 12px;
}
.login-actions {
margin-top: 24px;
margin-top: 22px;
}
.submit-btn {
:deep(.van-button) {
background: linear-gradient(135deg, #fb7299, #ff8fab);
border: none;
box-shadow: 0 14px 28px rgba(251, 114, 153, 0.24);
}
}
.login-footer {
text-align: center;
margin-top: 24px;
margin-top: 22px;
}
.link {
color: #00a1d6;
font-size: 14px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
@media (max-width: 960px) {
.auth-shell {
grid-template-columns: 1fr;
}
.auth-showcase {
display: none;
}
}
@media (max-width: 768px) {
.auth-page {
padding: 16px 12px;
}
.auth-panel {
padding: 22px 18px 20px;
border-radius: 24px;
}
.captcha-banner {
flex-direction: column;
align-items: flex-start;
}
.logo {
font-size: 30px;
}
}
</style>
@@ -1,12 +1,65 @@
<template>
<div class="register-page">
<div class="register-container">
<div class="register-header">
<h1 class="logo">HAOVP</h1>
<p class="slogan">创建账号,保存你的观看与创作记录</p>
<div class="auth-page">
<div class="auth-shell">
<section class="auth-showcase">
<div class="showcase-copy">
<p class="eyebrow">
New Account
</p>
<h1>创建账号,进入你的创作与互动空间</h1>
<p class="lead">
注册成功后可以保存观看记录、创建收藏夹、进入消息中心,并继续使用上传、审核与播放联调后的完整链路。
</p>
</div>
<div class="showcase-board">
<div class="board-card">
<strong>保存记录</strong>
<span>同步个人资料、播放历史、收藏夹和消息通知。</span>
</div>
<div class="board-card">
<strong>参与互动</strong>
<span>评论、回复、关注、私信和通知都依赖注册后的个人身份。</span>
</div>
<div class="board-card">
<strong>开始创作</strong>
<span>上传视频并进入个人主页,逐步搭建自己的创作者空间。</span>
</div>
</div>
</section>
<section class="auth-panel">
<div class="panel-top">
<button
class="back-home"
@click="router.push('/')"
>
返回首页
</button>
<div class="brand-lockup">
<h1 class="logo">
HAOVP
</h1>
<p class="slogan">
创建账号,保存你的观看与创作记录
</p>
</div>
</div>
<van-form @submit="handleRegister">
<div class="captcha-banner">
<div>
<strong>注册验证</strong>
<span>当前注册同样要求验证码,点击图片可以刷新。</span>
</div>
<van-image
:src="captchaImage"
fit="contain"
class="captcha-image"
@click="refreshCaptcha"
/>
</div>
<van-cell-group inset>
<van-field
v-model="form.username"
@@ -52,29 +105,51 @@
:rules="[{ required: true, message: '请输入验证码' }]"
>
<template #button>
<van-image :src="captchaImage" fit="contain" class="captcha-btn" @click="refreshCaptcha" />
<van-image
:src="captchaImage"
fit="contain"
class="captcha-btn"
@click="refreshCaptcha"
/>
</template>
</van-field>
</van-cell-group>
<div class="tips-row">
<span>注册成功后将跳转到登录页,请使用刚创建的账号继续登录。</span>
</div>
<div class="register-actions">
<van-button round block type="primary" native-type="submit" :loading="loading" color="#00a1d6">
<van-button
round
block
type="primary"
native-type="submit"
:loading="loading"
class="submit-btn"
>
注册
</van-button>
</div>
</van-form>
<div class="register-footer">
<router-link to="/login" class="link">已有账号?去登录</router-link>
<router-link
to="/login"
class="link"
>
已有账号?去登录
</router-link>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { showSuccessToast, showToast } from 'vant'
import { authApi, type CaptchaResult } from '@/api/auth.ts'
const router = useRouter()
@@ -91,9 +166,7 @@ const form = reactive({
captcha: ''
})
const validatePassword = () => {
return form.password === form.confirmPassword
}
const validatePassword = () => form.password === form.confirmPassword
const refreshCaptcha = async () => {
try {
@@ -138,63 +211,235 @@ onMounted(() => {
</script>
<style scoped lang="scss">
.register-page {
.auth-page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(34, 197, 94, 0.22), transparent 28%),
radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.24), transparent 26%),
linear-gradient(135deg, #0b1323 0%, #1d3557 55%, #0f766e 100%);
radial-gradient(circle at 18% 18%, rgba(34, 197, 94, 0.16), transparent 24%),
radial-gradient(circle at 82% 82%, rgba(59, 130, 246, 0.2), transparent 22%),
linear-gradient(135deg, #08111f 0%, #12345b 52%, #0f766e 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
padding: 28px 20px;
}
.register-container {
width: 100%;
max-width: 430px;
background: rgba(255, 255, 255, 0.96);
border-radius: 28px;
padding: 34px 28px 28px;
.auth-shell {
width: min(1180px, 100%);
display: grid;
grid-template-columns: minmax(0, 1.15fr) 460px;
gap: 20px;
}
.auth-showcase,
.auth-panel {
border-radius: 30px;
overflow: hidden;
}
.auth-showcase {
padding: 34px 32px;
color: #fff;
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.2), transparent 28%),
linear-gradient(135deg, rgba(15, 23, 42, 0.92), rgba(15, 118, 110, 0.92));
box-shadow: 0 24px 80px rgba(4, 16, 33, 0.34);
display: grid;
align-content: space-between;
}
.register-header {
text-align: center;
margin-bottom: 24px;
.eyebrow {
margin: 0 0 12px;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.68);
}
.logo {
font-size: 32px;
font-weight: bold;
color: #00a1d6;
.showcase-copy {
h1 {
margin: 0 0 12px;
font-size: 40px;
line-height: 1.18;
}
.lead {
margin: 0;
max-width: 620px;
color: rgba(255, 255, 255, 0.82);
line-height: 1.8;
}
}
.slogan {
margin-top: 10px;
color: #64748b;
.showcase-board {
display: grid;
gap: 12px;
margin-top: 28px;
}
.board-card {
padding: 18px 20px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px);
strong {
display: block;
margin-bottom: 6px;
font-size: 16px;
}
span {
color: rgba(255, 255, 255, 0.76);
font-size: 13px;
line-height: 1.7;
}
}
.auth-panel {
background: rgba(255, 255, 255, 0.96);
padding: 28px 26px 26px;
box-shadow: 0 24px 80px rgba(4, 16, 33, 0.34);
}
.panel-top {
margin-bottom: 22px;
}
.back-home {
border: none;
background: #f8fafc;
color: #475569;
padding: 8px 12px;
border-radius: 999px;
font-size: 12px;
cursor: pointer;
}
.brand-lockup {
margin-top: 16px;
}
.logo {
margin: 0;
font-size: 34px;
font-weight: 800;
color: #00a1d6;
}
.slogan {
margin: 10px 0 0;
color: #64748b;
font-size: 13px;
line-height: 1.7;
}
.captcha-banner {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 18px;
background: #f8fafc;
strong {
display: block;
margin-bottom: 4px;
color: #18191c;
font-size: 14px;
}
span {
color: #64748b;
font-size: 12px;
line-height: 1.6;
}
}
.captcha-image,
.captcha-btn {
flex-shrink: 0;
cursor: pointer;
}
.captcha-image {
width: 120px;
height: 40px;
}
.captcha-btn {
width: 100px;
height: 32px;
}
:deep(.van-cell-group--inset) {
margin: 0;
border-radius: 20px;
overflow: hidden;
box-shadow: inset 0 0 0 1px #eef2f7;
}
:deep(.van-field) {
padding: 14px 16px;
}
.tips-row {
margin-top: 12px;
color: #64748b;
font-size: 12px;
}
.register-actions {
margin-top: 20px;
margin-top: 22px;
}
.submit-btn {
:deep(.van-button) {
background: linear-gradient(135deg, #fb7299, #ff8fab);
border: none;
box-shadow: 0 14px 28px rgba(251, 114, 153, 0.24);
}
}
.register-footer {
text-align: center;
margin-top: 20px;
margin-top: 22px;
}
.link {
color: #00a1d6;
font-size: 14px;
text-decoration: none;
}
@media (max-width: 960px) {
.auth-shell {
grid-template-columns: 1fr;
}
.captcha-btn {
height: 32px;
cursor: pointer;
.auth-showcase {
display: none;
}
}
@media (max-width: 768px) {
.auth-page {
padding: 16px 12px;
}
.auth-panel {
padding: 22px 18px 20px;
border-radius: 24px;
}
.captcha-banner {
flex-direction: column;
align-items: flex-start;
}
.logo {
font-size: 30px;
}
}
</style>
@@ -1,48 +1,60 @@
<template>
<div class="message-detail-page">
<van-nav-bar title="聊天详情" fixed placeholder left-arrow @click-left="router.back()" />
<div class="page-shell chat-shell">
<div class="chat-head">
<button
class="back-link"
@click="router.back()"
>
返回
</button>
<strong>聊天详情</strong>
</div>
<div class="message-list" ref="listRef">
<div
ref="listRef"
class="message-list"
>
<div
v-for="msg in messages"
:key="msg.id"
:class="['message-item', { 'is-self': msg.senderId === userStore.userId }]"
>
<van-image
v-if="msg.senderId !== userStore.userId"
:src="msg.senderAvatar || defaultAvatar"
:src="getAvatarUrl(msg.senderId === userStore.userId ? userStore.userInfo?.avatar : msg.senderAvatar)"
round
width="36"
height="36"
width="38"
height="38"
class="avatar"
/>
<div class="message-content">
<div class="bubble">{{ msg.content }}</div>
<div class="time">{{ formatRelativeTime(msg.createTime) }}</div>
<div class="bubble">
{{ msg.content }}
</div>
<div class="time">
{{ formatRelativeTime(msg.createTime) }}
</div>
</div>
<van-image
v-if="msg.senderId === userStore.userId"
:src="userStore.userInfo?.avatar || defaultAvatar"
round
width="36"
height="36"
class="avatar"
/>
</div>
</div>
<div class="message-input">
<van-field
v-model="content"
placeholder="输入消息..."
placeholder="输入消息"
:border="false"
@keyup.enter="sendMessage"
/>
<van-button type="primary" size="small" :loading="sending" @click="sendMessage">
<van-button
type="primary"
:loading="sending"
@click="sendMessage"
>
发送
</van-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
@@ -52,50 +64,21 @@ import { showToast } from 'vant'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import { formatRelativeTime } from '@/utils'
import { resolveAvatarUrl } from '@/utils/media'
import type { Message } from '@/types'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const targetId = Number(route.params.id)
const messages = ref<Message[]>([])
const content = ref('')
const sending = ref(false)
const listRef = ref<HTMLElement>()
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 loadMessages = async () => {
try {
const data = await userApi.getMessageDetail(targetId)
messages.value = data
await nextTick()
scrollToBottom()
} catch (error) {
showToast('加载失败')
}
}
const sendMessage = async () => {
if (!content.value.trim()) return
sending.value = true
try {
const msg = await userApi.sendMessage({
receiverId: targetId,
content: content.value
})
messages.value.push(msg)
content.value = ''
await nextTick()
scrollToBottom()
} catch (error) {
showToast('发送失败')
} finally {
sending.value = false
}
}
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 scrollToBottom = () => {
if (listRef.value) {
@@ -103,28 +86,86 @@ const scrollToBottom = () => {
}
}
onMounted(() => {
loadMessages()
const loadMessages = async () => {
try {
const data = await userApi.getMessageDetail(targetId, { page: 1, size: 50 })
messages.value = data.records
await userApi.markMessageRead(targetId)
await nextTick()
scrollToBottom()
} catch {
showToast('加载失败')
}
}
const sendMessage = async () => {
if (!content.value.trim()) return
sending.value = true
try {
await userApi.sendMessage({
receiverId: targetId,
content: content.value
})
content.value = ''
await loadMessages()
} catch {
showToast('发送失败')
} finally {
sending.value = false
}
}
onMounted(loadMessages)
</script>
<style scoped lang="scss">
.message-detail-page {
min-height: 100vh;
background: #f5f5f5;
background: #f4f5f7;
padding: 18px 0 36px;
}
.chat-shell {
height: calc(100vh - var(--header-total-height) - 54px);
display: grid;
grid-template-rows: auto 1fr auto;
gap: 12px;
}
.chat-head,
.message-list,
.message-input {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.chat-head {
padding: 14px 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
strong {
font-size: 18px;
}
}
.back-link {
color: var(--link-color);
font-size: 13px;
}
.message-list {
flex: 1;
overflow-y: auto;
padding: 12px;
padding: 18px;
}
.message-item {
display: flex;
gap: 8px;
margin-bottom: 12px;
gap: 10px;
margin-bottom: 14px;
align-items: flex-start;
&.is-self {
flex-direction: row-reverse;
@@ -134,45 +175,41 @@ onMounted(() => {
}
.bubble {
background: #00a1d6;
color: #fff;
background: #e5f6fd;
color: #18191c;
}
}
.avatar {
flex-shrink: 0;
}
.message-content {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 70%;
display: grid;
gap: 6px;
}
.bubble {
padding: 10px 14px;
background: #fff;
border-radius: 8px;
word-break: break-all;
padding: 11px 14px;
border-radius: 10px;
background: #f6f7f8;
line-height: 1.6;
word-break: break-word;
}
.time {
color: #9499a0;
font-size: 12px;
color: #999;
}
}
}
}
.message-input {
display: flex;
gap: 8px;
padding: 8px 12px;
background: #fff;
border-top: 1px solid #eee;
.van-field {
flex: 1;
padding: 10px 12px;
align-items: center;
}
.message-input :deep(.van-field) {
flex: 1;
background: #f6f7f8;
border-radius: 8px;
}
</style>
@@ -1,37 +1,45 @@
<template>
<div class="message-page">
<div class="message-shell">
<section class="message-header">
<div>
<h2>私信</h2>
<p>查看最近会话、沟通合作与内容互动消息。</p>
<div class="page-shell message-shell">
<div class="page-head">
<strong>私信列表</strong>
<span>{{ conversations.length }} 个会话</span>
</div>
</section>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="loadMore">
<van-cell-group v-if="conversations.length" inset>
<van-cell
<div
v-if="conversations.length"
class="conversation-list"
>
<button
v-for="conv in conversations"
:key="conv.targetId"
:title="conv.targetNickname"
:label="conv.lastMessage"
is-link
class="conversation-item"
@click="goToDetail(conv.targetId)"
>
<template #icon>
<van-image :src="conv.targetAvatar || defaultAvatar" round width="48" height="48" />
</template>
<template #value>
<van-badge :content="conv.unreadCount" :show-zero="false">
<span class="time">{{ formatRelativeTime(conv.lastTime) }}</span>
</van-badge>
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无私信" />
</van-list>
</van-pull-refresh>
<van-image
:src="getAvatarUrl(conv.targetAvatar)"
round
width="48"
height="48"
/>
<div class="conversation-copy">
<div class="conversation-top">
<strong>{{ conv.targetNickname }}</strong>
<span>{{ formatRelativeTime(conv.lastTime) }}</span>
</div>
<p>{{ conv.lastMessage }}</p>
</div>
<van-badge
:content="conv.unreadCount"
:show-zero="false"
/>
</button>
</div>
<van-empty
v-else
description="暂无私信"
/>
</div>
</div>
</template>
@@ -41,7 +49,9 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import { formatRelativeTime } from '@/utils'
import { resolveAvatarUrl } from '@/utils/media'
interface Conversation {
targetId: number
@@ -53,96 +63,131 @@ interface Conversation {
}
const router = useRouter()
const userStore = useUserStore()
const conversations = ref<Conversation[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
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 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 loadConversations = async (isRefresh = false) => {
if (isRefresh) {
conversations.value = []
finished.value = false
}
loading.value = true
const loadConversations = async () => {
try {
const data = await userApi.getMessageList()
conversations.value = data.map((item) => ({
targetId: item.senderId,
targetNickname: item.senderNickname,
targetAvatar: item.senderAvatar,
const data = await userApi.getMessageList({ page: 1, size: 20 })
const conversationMap = new Map<number, Conversation>()
data.records.forEach((item) => {
const isSelfSender = item.senderId === userStore.userId
const targetId = isSelfSender ? item.receiverId : item.senderId
const existing = conversationMap.get(targetId)
const unreadCount = item.isRead ? 0 : 1
const conversation = {
targetId,
targetNickname: isSelfSender ? item.receiverNickname : item.senderNickname,
targetAvatar: isSelfSender ? item.receiverAvatar : item.senderAvatar,
lastMessage: item.content,
lastTime: item.createTime,
unreadCount: item.isRead ? 0 : 1
}))
finished.value = true
} catch (error) {
unreadCount: (existing?.unreadCount || 0) + unreadCount
}
if (!existing || new Date(item.createTime).getTime() > new Date(existing.lastTime).getTime()) {
conversationMap.set(targetId, conversation)
} else {
conversationMap.set(targetId, {
...existing,
unreadCount: existing.unreadCount + unreadCount
})
}
})
conversations.value = [...conversationMap.values()].sort((a, b) => new Date(b.lastTime).getTime() - new Date(a.lastTime).getTime())
} catch {
showToast('加载失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadConversations()
}
const onRefresh = () => {
loadConversations(true)
}
const goToDetail = (targetId: number) => {
router.push(`/messages/${targetId}`)
}
onMounted(() => {
loadConversations()
})
onMounted(loadConversations)
</script>
<style scoped lang="scss">
.message-page {
min-height: 100vh;
background: #f6f7fb;
padding-bottom: 60px;
background: #f4f5f7;
padding-bottom: 36px;
}
.message-shell {
max-width: 980px;
margin: 0 auto;
padding: 20px 12px 28px;
padding-top: 18px;
}
.message-header {
padding: 24px 26px;
border-radius: 28px;
background: linear-gradient(135deg, #1d4ed8, #0f766e);
color: #fff;
margin-bottom: 18px;
h2 {
margin: 0 0 8px;
font-size: 28px;
.page-head,
.conversation-list {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.page-head {
padding: 16px 18px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
strong {
font-size: 24px;
}
span {
font-size: 13px;
color: #9499a0;
}
}
.conversation-list {
padding: 0 18px;
}
.conversation-item {
width: 100%;
display: flex;
align-items: center;
gap: 14px;
padding: 16px 0;
text-align: left;
& + .conversation-item {
border-top: 1px solid #f1f2f3;
}
}
.conversation-copy {
flex: 1;
min-width: 0;
p {
margin: 0;
color: rgba(255, 255, 255, 0.82);
}
}
.message-shell :deep(.van-cell-group) {
margin: 0;
border-radius: 24px;
margin: 6px 0 0;
color: #61666d;
font-size: 13px;
overflow: hidden;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
text-overflow: ellipsis;
white-space: nowrap;
}
}
.time {
.conversation-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
strong {
font-size: 16px;
color: #18191c;
}
span {
color: #9499a0;
font-size: 12px;
color: #999;
}
}
</style>
@@ -1,84 +1,96 @@
<template>
<div class="notifications-page">
<van-nav-bar title="通知" fixed placeholder left-arrow @click-left="router.back()" />
<van-tabs v-model:active="activeTab">
<van-tab title="全部" name="all" />
<van-tab title="点赞" name="like" />
<van-tab title="评论" name="comment" />
<van-tab title="关注" name="follow" />
<van-tab title="系统" name="system" />
</van-tabs>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
<div class="page-shell notifications-shell">
<div class="page-head">
<strong>通知中心</strong>
<button
class="read-all"
@click="markAllRead"
>
<van-cell-group v-if="notifications.length" inset>
<van-cell
全部已读
</button>
</div>
<div class="tabs-wrap">
<van-tabs v-model:active="activeTab">
<van-tab
title="全部"
name="all"
/>
<van-tab
title="点赞"
name="like"
/>
<van-tab
title="评论"
name="comment"
/>
<van-tab
title="关注"
name="follow"
/>
<van-tab
title="系统"
name="system"
/>
</van-tabs>
</div>
<div
v-if="notifications.length"
class="notification-list"
>
<button
v-for="notif in notifications"
:key="notif.id"
:title="notif.content"
:label="formatRelativeTime(notif.createTime)"
class="notification-item"
@click="openNotification(notif)"
>
<template #icon>
<van-icon :name="getIcon(notif.type)" :color="getColor(notif.type)" class="notif-icon" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无通知" />
</van-list>
</van-pull-refresh>
<div
class="notif-icon"
:style="{ backgroundColor: getColor(notif.type) }"
>
<van-icon :name="getIcon(notif.type)" />
</div>
<div class="notif-copy">
<strong>{{ notif.content }}</strong>
<span>{{ formatRelativeTime(notif.createTime) }}</span>
</div>
<span
v-if="!notif.isRead && !pendingReadIds.includes(notif.id)"
class="unread-dot"
/>
</button>
</div>
<van-empty
v-else
description="暂无通知"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showSuccessToast, showToast } from 'vant'
import { userApi } from '@/api/user.ts'
import { formatRelativeTime } from '@/utils'
import type { Notification } from '@/types'
import { useMessageStore } from '@/stores/message'
const router = useRouter()
const route = useRoute()
const messageStore = useMessageStore()
const activeTab = ref('all')
const notifications = ref<Notification[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const pendingReadIds = ref<number[]>([])
const getIcon = (type: number) => {
const icons: Record<number, string> = {
1: 'good-job',
2: 'chat-o',
3: 'user-o',
4: 'bell'
}
return icons[type] || 'info-o'
}
const getIcon = (type: number) => ({ 1: 'good-job', 2: 'chat-o', 3: 'friends-o', 4: 'bell' }[type] || 'info-o')
const getColor = (type: number) => ({ 1: '#fb7299', 2: '#ffb347', 3: '#00a1d6', 4: '#94a3b8' }[type] || '#94a3b8')
const getColor = (type: number) => {
const colors: Record<number, string> = {
1: '#f56c6c',
2: '#e6a23c',
3: '#409eff',
4: '#909399'
}
return colors[type] || '#909399'
}
const loadNotifications = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
notifications.value = []
finished.value = false
}
loading.value = true
const loadNotifications = async () => {
try {
const typeMap: Record<string, number | undefined> = {
all: undefined,
@@ -87,39 +99,160 @@ const loadNotifications = async (isRefresh = false) => {
follow: 3,
system: 4
}
const data = await userApi.getNotifications(typeMap[activeTab.value])
notifications.value = data
finished.value = true
} catch (error) {
const data = await userApi.getNotifications(typeMap[activeTab.value], { page: 1, size: 50 })
notifications.value = data.records
} catch {
showToast('加载失败')
}
}
const markAllRead = async () => {
try {
await userApi.markAllNotificationsRead()
notifications.value = notifications.value.map((item) => ({ ...item, isRead: 1 }))
pendingReadIds.value = []
messageStore.clearNotificationUnread()
showSuccessToast('已全部标记为已读')
} catch {
showToast('操作失败')
}
}
const openNotification = async (notification: Notification) => {
try {
if (!notification.isRead) {
pendingReadIds.value.push(notification.id)
await userApi.markNotificationRead(notification.id)
notification.isRead = 1
messageStore.decrementNotificationUnread()
}
} finally {
loading.value = false
refreshing.value = false
}
pendingReadIds.value = pendingReadIds.value.filter((id) => id !== notification.id)
}
const loadMore = () => {
if (!finished.value) loadNotifications()
if ((notification.type === 1 || notification.type === 2) && notification.relatedId) {
router.push({ path: `/video/${notification.relatedId}`, query: { from: 'notifications', tab: activeTab.value } })
return
}
const onRefresh = () => {
loadNotifications(true)
if (notification.type === 3 && notification.relatedId) {
router.push({ path: `/user/${notification.relatedId}`, query: { from: 'notifications', tab: activeTab.value } })
return
}
showToast('当前通知没有可跳转的目标')
}
onMounted(() => {
const tab = typeof route.query.tab === 'string' ? route.query.tab : 'all'
if (['all', 'like', 'comment', 'follow', 'system'].includes(tab)) {
activeTab.value = tab
}
loadNotifications()
})
watch(activeTab, loadNotifications)
</script>
<style scoped lang="scss">
.notifications-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 60px;
background: #f4f5f7;
padding-bottom: 36px;
}
.notifications-shell {
padding-top: 18px;
}
.page-head,
.tabs-wrap,
.notification-list {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.page-head {
padding: 16px 18px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
strong {
font-size: 24px;
}
}
.read-all {
color: var(--link-color);
font-size: 13px;
}
.tabs-wrap {
margin-bottom: 12px;
overflow: hidden;
:deep(.van-tab--active) {
color: var(--link-color);
}
:deep(.van-tabs__line) {
background: var(--link-color);
}
}
.notification-list {
padding: 0 18px;
}
.notification-item {
width: 100%;
display: flex;
align-items: center;
gap: 14px;
padding: 16px 0;
text-align: left;
& + .notification-item {
border-top: 1px solid #f1f2f3;
}
}
.notif-icon {
font-size: 20px;
margin-right: 8px;
width: 38px;
height: 38px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.notif-copy {
flex: 1;
min-width: 0;
display: grid;
gap: 6px;
strong {
color: #18191c;
font-size: 14px;
line-height: 1.5;
}
span {
color: #9499a0;
font-size: 12px;
}
}
.unread-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #fb7299;
}
</style>
@@ -1,56 +1,207 @@
<template>
<div class="search-page">
<van-nav-bar title="搜索" fixed placeholder>
<template #right>
<van-icon name="search" size="22" @click="handleSearch" />
</template>
</van-nav-bar>
<div class="page-shell search-shell">
<div class="search-summary">
<div class="summary-left">
<strong>搜索结果</strong>
<span v-if="keyword.trim()">关键词:{{ keyword }}</span>
</div>
<div class="summary-right">
<button
v-for="entry in hotKeywords"
:key="entry"
class="keyword-chip"
@click="useKeyword(entry)"
>
{{ entry }}
</button>
</div>
</div>
<div class="search-content">
<van-search
v-model="keyword"
placeholder="搜索视频、用户"
shape="round"
@search="handleSearch"
<div class="search-layout">
<main class="result-panel">
<div class="result-tabs">
<button class="result-tab active">
综合
</button>
<button class="result-tab">
视频 {{ videos.length }}
</button>
<button class="result-tab">
用户 {{ users.length }}
</button>
</div>
<section
v-if="users.length"
class="user-panel"
>
<div class="panel-title">
相关用户
</div>
<div class="user-grid">
<button
v-for="user in users"
:key="user.id"
class="user-card"
@click="goToUser(user.id)"
>
<van-image
:src="getAvatarUrl(user.avatar)"
round
width="48"
height="48"
/>
<div class="user-copy">
<strong>{{ user.nickname }}</strong>
<span>{{ user.signature || '这个人很懒,什么都没写' }}</span>
</div>
</button>
</div>
</section>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
finished-text="没有更多结果了"
@load="loadMore"
>
<div class="video-grid">
<VideoCard
<div
v-if="videos.length"
class="video-list"
>
<button
v-for="video in videos"
:key="video.id"
:video="video"
class="video-row"
@click="goToVideo(video.id)"
>
<div class="video-cover">
<van-image
:src="getCoverUrl(video.coverUrl)"
fit="cover"
class="cover-image"
/>
<div class="video-cover-mask">
<span>{{ formatPlayCount(video.viewCount) }} 播放</span>
<span>{{ formatDuration(video.duration) }}</span>
</div>
</div>
<div class="video-copy">
<h3>{{ video.title }}</h3>
<p>{{ video.description || '这个视频暂时没有简介。' }}</p>
<div class="video-meta">
<span>{{ video.userNickname || '匿名用户' }}</span>
<span>{{ video.categoryName || '综合' }}</span>
<span>{{ formatNumber(video.commentCount) }} 评论</span>
<span>{{ formatPublishTime(video.publishTime || video.createTime) }}</span>
</div>
</div>
</button>
</div>
<div
v-else-if="!loading && keyword.trim()"
class="empty-panel"
>
<div class="empty-illustration">
<div class="empty-card large" />
<div class="empty-card small" />
</div>
<strong>没有搜索到相关内容</strong>
<span>可以试试更短的关键词,或者从热门词里继续浏览。</span>
<div class="empty-actions">
<button
v-for="entry in hotKeywords"
:key="`empty-${entry}`"
class="keyword-chip"
@click="useKeyword(entry)"
>
{{ entry }}
</button>
</div>
</div>
</van-list>
</van-pull-refresh>
</main>
<aside class="aside-panel">
<div class="aside-card">
<div class="panel-title">
搜索建议
</div>
<div class="suggest-list">
<button
v-for="entry in hotKeywords"
:key="`suggest-${entry}`"
class="suggest-item"
@click="useKeyword(entry)"
>
{{ entry }}
</button>
</div>
</div>
<div class="aside-card rank-card">
<div class="panel-title">
热门搜索
</div>
<div class="rank-list">
<button
v-for="(entry, index) in hotKeywords"
:key="`rank-${entry}`"
class="rank-item"
@click="useKeyword(entry)"
>
<span class="rank-index">{{ index + 1 }}</span>
<span>{{ entry }}</span>
</button>
</div>
</div>
</aside>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import VideoCard from '@/components/video/VideoCard.vue'
import { userApi } from '@/api/user.ts'
import { videoApi } from '@/api/video.ts'
import type { VideoInfo } from '@/types'
import type { UserInfo, VideoInfo } from '@/types'
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
import { resolveAvatarUrl, resolveMediaUrl } from '@/utils/media'
const router = useRouter()
const route = useRoute()
const keyword = ref('')
const videos = ref<VideoInfo[]>([])
const users = ref<UserInfo[]>([])
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const currentPage = ref(1)
const hotKeywords = ['投稿', '高可用', '热门', '审核流程', '系统设计']
const defaultCover =
'data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 280 160\"%3E%3Crect width=\"280\" height=\"160\" 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 getAvatarUrl = (avatar?: string) => resolveAvatarUrl(avatar, '')
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 loadVideos = async (isRefresh = false) => {
if (!keyword.value.trim()) return
@@ -58,75 +209,398 @@ const loadVideos = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1
videos.value = []
users.value = []
finished.value = false
}
loading.value = true
try {
const res = await videoApi.getVideoPublicList({
pageNum: currentPage.value,
pageSize: 20,
keyword: keyword.value
})
if (isRefresh) {
videos.value = res.records
} else {
videos.value.push(...res.records)
try {
const [videoRes, userRes] = await Promise.all([
videoApi.getVideoPublicList({
pageNum: currentPage.value,
pageSize: 12,
keyword: keyword.value.trim()
}),
currentPage.value === 1
? userApi.searchUsers(keyword.value.trim(), { page: 1, size: 8 })
: Promise.resolve({ records: [], total: 0, size: 0, current: 1, pages: 0 })
])
videos.value = isRefresh ? videoRes.records : [...videos.value, ...videoRes.records]
if (currentPage.value === 1) {
users.value = userRes.records
}
if (videos.value.length >= res.total || res.records.length < 20) {
if (videos.value.length >= videoRes.total || videoRes.records.length < 12) {
finished.value = true
}
currentPage.value++
} catch (error) {
currentPage.value += 1
} catch {
showToast('搜索失败')
} finally {
loading.value = false
refreshing.value = false
}
}
const loadMore = () => {
if (!finished.value) loadVideos()
if (!finished.value) {
loadVideos()
}
}
const onRefresh = () => {
loadVideos(true)
}
const handleSearch = () => {
loadVideos(true)
const useKeyword = (value: string) => {
router.replace({ path: '/search', query: { keyword: value } })
}
const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
const goToUser = (id: number) => {
router.push(`/user/${id}`)
}
onMounted(() => {
if (route.query.keyword) {
keyword.value = route.query.keyword as string
if (typeof route.query.keyword === 'string') {
keyword.value = route.query.keyword
loadVideos(true)
}
})
watch(
() => route.query.keyword,
(nextKeyword) => {
keyword.value = typeof nextKeyword === 'string' ? nextKeyword : ''
if (keyword.value.trim()) {
loadVideos(true)
} else {
videos.value = []
users.value = []
finished.value = true
}
}
)
</script>
<style scoped lang="scss">
.search-page {
min-height: 100vh;
background: #f6f7fb;
background: #f4f5f7;
padding: 18px 0 36px;
}
.search-content {
max-width: 1400px;
margin: 0 auto;
padding: 12px;
.search-summary,
.result-panel,
.aside-card,
.empty-panel {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.video-grid {
.search-summary {
padding: 16px 18px;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.summary-left {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px 16px;
gap: 4px;
strong {
font-size: 22px;
color: #18191c;
}
span {
color: #9499a0;
font-size: 13px;
}
}
.summary-right,
.empty-actions,
.suggest-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.keyword-chip,
.suggest-item {
padding: 7px 12px;
border-radius: 999px;
background: #f6f7f8;
color: #61666d;
font-size: 12px;
}
.search-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 16px;
align-items: start;
}
.result-panel {
padding: 18px 22px 22px;
}
.result-tabs {
display: flex;
gap: 24px;
padding-bottom: 12px;
border-bottom: 1px solid #f1f2f3;
}
.result-tab {
position: relative;
color: #61666d;
font-size: 14px;
&.active {
color: var(--link-color);
font-weight: 700;
}
}
.panel-title {
font-size: 16px;
font-weight: 700;
color: #18191c;
}
.user-panel {
margin-top: 18px;
}
.user-grid {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.user-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 10px;
background: #f8fafc;
text-align: left;
}
.user-copy {
min-width: 0;
display: grid;
gap: 4px;
strong {
font-size: 15px;
color: #18191c;
}
span {
color: #61666d;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.video-list {
margin-top: 18px;
display: grid;
gap: 18px;
}
.video-row {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 16px;
text-align: left;
}
.video-cover {
position: relative;
overflow: hidden;
border-radius: 8px;
aspect-ratio: 16 / 10;
background: #edf2f7;
}
.cover-image {
width: 100%;
height: 100%;
}
.video-cover-mask {
position: absolute;
inset: auto 0 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 8px 6px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.78));
span {
color: #fff;
font-size: 12px;
}
}
.video-copy {
min-width: 0;
display: grid;
gap: 10px;
align-content: center;
h3 {
margin: 0;
font-size: 20px;
color: #18191c;
line-height: 1.35;
}
p {
margin: 0;
color: #61666d;
font-size: 13px;
line-height: 1.7;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
}
.video-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
color: #999;
font-size: 12px;
}
.aside-panel {
display: grid;
gap: 16px;
}
.aside-card {
padding: 18px;
}
.rank-list {
margin-top: 12px;
display: grid;
gap: 10px;
}
.rank-item {
display: flex;
align-items: center;
gap: 10px;
text-align: left;
color: #18191c;
}
.rank-index {
width: 22px;
height: 22px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #edf7ff;
color: var(--link-color);
font-size: 12px;
font-weight: 700;
}
.empty-panel {
margin-top: 18px;
padding: 42px 24px;
display: grid;
justify-items: center;
gap: 14px;
text-align: center;
strong {
font-size: 22px;
color: #18191c;
}
span {
color: #61666d;
font-size: 14px;
}
}
.empty-illustration {
width: 120px;
height: 96px;
position: relative;
}
.empty-card {
position: absolute;
border-radius: 14px;
background: linear-gradient(180deg, #edf2f7, #f8fafc);
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.06);
&.large {
inset: 14px 18px 0 18px;
}
&.small {
width: 52px;
height: 52px;
right: 8px;
top: 0;
}
}
@media (max-width: 1024px) {
.search-layout {
grid-template-columns: 1fr;
}
.aside-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.search-summary,
.result-panel,
.aside-card,
.empty-panel {
padding: 14px;
}
.search-summary,
.video-row {
grid-template-columns: 1fr;
}
.user-grid,
.aside-panel {
grid-template-columns: 1fr;
}
.video-copy h3,
.empty-panel strong {
font-size: 18px;
}
}
</style>
@@ -1,13 +1,21 @@
<template>
<div class="upload-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()"
/>
<section class="upload-hero">
<div>
<h2>发布你的新作品</h2>
<p>上传视频、完善标题与分类信息,完成内容发布前的基础编辑。</p>
</div>
<div class="hero-badge">高清视频上传</div>
<div class="hero-badge">
高清视频上传
</div>
</section>
<van-form @submit="handleSubmit">
@@ -17,7 +25,10 @@
name="title"
label="标题"
placeholder="请输入视频标题"
:rules="[{ required: true, message: '请输入标题' }]"
:rules="[
{ required: true, message: '请输入标题' },
{ pattern: /^.{5,100}$/, message: '标题长度需在5到100字之间' }
]"
/>
<van-field
v-model="form.description"
@@ -37,14 +48,25 @@
placeholder="请选择分类"
@click="showCategoryPicker = true"
/>
<van-field name="cover" label="封面">
<van-field
name="cover"
label="封面"
>
<template #input>
<van-uploader
v-model="coverFile"
max-count="1"
accept="image/*"
:after-read="afterReadCover"
<div class="cover-panel">
<van-image
v-if="coverUrl"
:src="resolveMediaUrl(coverUrl)"
fit="cover"
class="cover-preview"
/>
<div
v-else
class="cover-placeholder"
>
上传视频后自动生成封面
</div>
</div>
</template>
</van-field>
</van-cell-group>
@@ -56,22 +78,48 @@
accept="video/*"
:after-read="afterReadVideo"
>
<van-button type="primary" block>选择视频文件</van-button>
<van-button
type="primary"
block
>
选择视频文件
</van-button>
</van-uploader>
<div v-if="uploadProgress > 0" class="progress">
<div
v-if="uploadProgress > 0"
class="progress"
>
<van-progress :percentage="uploadProgress" />
<span>上传中 {{ uploadProgress }}%</span>
</div>
<div
v-if="videoInfo"
class="upload-result"
>
<div><strong>时长:</strong>{{ videoInfo.duration }} 秒</div>
<div><strong>分辨率:</strong>{{ videoInfo.width }} x {{ videoInfo.height }}</div>
<div><strong>格式:</strong>{{ videoInfo.format }}</div>
<div><strong>大小:</strong>{{ formatSize(videoInfo.size) }}</div>
</div>
</div>
<div class="submit-btn">
<van-button type="primary" block native-type="submit" :loading="submitting">
<van-button
type="primary"
block
native-type="submit"
:loading="submitting"
>
发布视频
</van-button>
</div>
</van-form>
<van-popup v-model:show="showCategoryPicker" position="bottom" round>
<van-popup
v-model:show="showCategoryPicker"
position="bottom"
round
>
<van-picker
:columns="categories"
@confirm="onCategoryConfirm"
@@ -87,6 +135,7 @@ import { useRouter } from 'vue-router'
import { showToast, showSuccessToast } from 'vant'
import { videoApi } from '@/api/video.ts'
import { categoryApi, type Category } from '@/api/category.ts'
import { resolveMediaUrl } from '@/utils/media'
const router = useRouter()
@@ -96,7 +145,6 @@ const form = reactive({
categoryId: 0
})
const categoryName = ref('')
const coverFile = ref<any[]>([])
const videoFile = ref<any[]>([])
const uploadProgress = ref(0)
const submitting = ref(false)
@@ -126,31 +174,42 @@ const onCategoryConfirm = ({ selectedOptions }: any) => {
showCategoryPicker.value = false
}
const afterReadCover = async (_file: any) => {
// TODO: 上传封面
}
const afterReadVideo = async (file: any) => {
if (!file.file) return
uploadProgress.value = 0
// TODO: 实际上传逻辑
try {
const result = await videoApi.uploadVideo({ file: file.file, onProgress: (percent) => {
const result = await videoApi.uploadVideo({
file: file.file,
onProgress: (percent) => {
uploadProgress.value = percent
}})
}
})
videoUrl.value = result.videoUrl
coverUrl.value = result.coverUrl
videoInfo.value = result
showSuccessToast('视频上传成功')
} catch (error) {
showToast('上传失败')
}
}
const formatSize = (size: number) => {
if (!size) return '0 B'
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
const handleSubmit = async () => {
if (!videoUrl.value) {
showToast('请先上传视频')
return
}
if (!form.title || form.title.trim().length < 5) {
showToast('标题长度需至少5个字')
return
}
if (!form.categoryId) {
showToast('请选择分类')
return
@@ -243,6 +302,44 @@ onMounted(() => {
padding: 16px;
}
.cover-panel {
width: 120px;
height: 68px;
border-radius: 12px;
overflow: hidden;
border: 1px dashed #cbd5e1;
background: #f8fafc;
}
.cover-preview {
width: 100%;
height: 100%;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
text-align: center;
color: #64748b;
font-size: 12px;
line-height: 1.4;
}
.upload-result {
margin-top: 14px;
display: grid;
gap: 6px;
padding: 12px 14px;
border-radius: 14px;
background: #f8fafc;
color: #334155;
font-size: 13px;
}
:deep(.van-cell-group) {
margin: 12px;
border-radius: 20px;
@@ -1,30 +1,273 @@
<template>
<div class="about-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()"
/>
<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">
<h2>HAOVP 在线视频平台</h2>
<p>一个面向内容上传、播放互动与平台治理场景构建的在线视频系统。</p>
<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="1.0.0" />
<van-cell title="主要能力" value="上传、播放、评论、推荐、后台治理" />
<van-cell title="技术栈" value="Vue 3 + Vite + Vant" />
<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>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="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="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>
<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"
>
<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>
<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>
</template>
</van-cell>
</van-cell-group>
<van-empty v-else description="暂无播放记录" />
</van-list>
</van-pull-refresh>
</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
}
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 finishedCount = computed(() => videos.value.filter((item) => item.duration > 0 && item.progress >= item.duration).length)
const continueCount = computed(() => videos.value.length - finishedCount.value)
const loadMore = () => {
if (!finished.value) loadVideos()
}
const getCoverUrl = (coverUrl?: string) => resolveMediaUrl(coverUrl) || defaultCover
const onRefresh = () => {
loadVideos(true)
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('加载失败')
}
}
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;
.video-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: #f4f5f7;
padding-bottom: 36px;
}
.video-progress {
.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;
}
span {
color: #61666d;
font-size: 14px;
}
}
.hero-eyebrow {
margin: 0;
color: var(--link-color);
font-size: 12px;
color: #999;
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>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="loadMore"
>
<van-cell-group v-if="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="page-shell videos-shell">
<section class="videos-hero">
<div class="hero-copy">
<p class="hero-eyebrow">
Creator Center
</p>
<h1>我的视频</h1>
<span>管理已上传内容与当前审核状态</span>
</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-button
type="primary"
round
class="upload-button"
@click="router.push('/upload')"
>
上传视频
</van-button>
</section>
<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"
>
<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>
<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>
</van-list>
</van-pull-refresh>
</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
if (isRefresh) {
currentPage.value = 1
videos.value = []
finished.value = false
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)
}
loading.value = true
const loadVideos = async () => {
if (!resolvedUserId.value) return
try {
const res = await videoApi.getUserVideos(userStore.userId, { pageNum: currentPage.value, pageSize: 20 })
if (isRefresh) {
const res = await videoApi.getUserVideos(resolvedUserId.value, { pageNum: 1, pageSize: 50 })
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) {
} 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;
.video-title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: #f4f5f7;
padding-bottom: 36px;
}
.video-stats {
.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;
gap: 12px;
align-items: center;
justify-content: space-between;
gap: 16px;
background:
linear-gradient(135deg, rgba(0, 161, 214, 0.08), rgba(251, 114, 153, 0.08)),
#fff;
}
.hero-copy {
display: grid;
gap: 6px;
h1 {
margin: 0;
font-size: 30px;
color: #18191c;
}
span {
color: #61666d;
font-size: 14px;
}
}
.hero-eyebrow {
margin: 0;
color: var(--link-color);
font-size: 12px;
color: #999;
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()" />
<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>
<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>
<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>
<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>
<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>
<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 () => {
saving.value = true
try {
await userApi.updateSettings(form)
showSuccessToast('已保存')
showSuccessToast('通知设置已保存')
} catch (error: any) {
showToast(error?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(async () => {
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()" />
<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-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>
<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 () => {
saving.value = true
try {
await userApi.updateSettings({ privacyLevel: form.privacyLevel })
showSuccessToast('已保存')
showSuccessToast('隐私设置已保存')
} catch (error: any) {
showToast(error?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(async () => {
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>
<div class="panel-card">
<van-form @submit="handleSave">
<van-cell-group inset>
<van-cell title="头像" is-link @click="chooseAvatar">
<van-cell
title="头像"
is-link
@click="chooseAvatar"
>
<template #value>
<van-image :src="form.avatar || defaultAvatar" round width="48" height="48" fit="cover" />
<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 placeholder="介绍一下自己" maxlength="100" show-word-limit />
<van-field name="gender" label="性别">
<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
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-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;
.van-cell-group {
margin: 12px;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
background: #f4f5f7;
padding-bottom: 36px;
}
.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="stat-item" @click="goToFollowers">
<div class="value">{{ userInfo.followerCount }}</div>
<div class="label">粉丝</div>
<div class="label">
视频
</div>
</div>
<div
class="stat-item"
@click="goToFollowers"
>
<div class="value">
{{ userInfo.followerCount }}
</div>
<div class="label">
粉丝
</div>
</div>
<div
class="stat-item"
@click="goToFollowing"
>
<div class="value">
{{ userInfo.followingCount }}
</div>
<div class="label">
关注
</div>
<div 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="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="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="logout-btn">
<van-button type="danger" block @click="handleLogout">退出登录</van-button>
<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="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;
h2 {
margin: 0 0 8px;
font-size: 28px;
.page-head,
.panel-card {
background: #fff;
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.82);
.page-head {
padding: 16px 18px;
margin-bottom: 16px;
}
.head-main {
display: grid;
gap: 4px;
strong {
font-size: 24px;
}
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>
</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>
</van-list>
</van-pull-refresh>
</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)))
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)
}
loading.value = true
const loadPage = async () => {
try {
const res = await videoApi.getUserVideos(userId, { pageNum: currentPage.value, pageSize: 20 })
if (isRefresh) {
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
} else {
videos.value.push(...res.records)
}
if (videos.value.length >= res.total || res.records.length < 20) {
finished.value = true
}
currentPage.value++
} catch (error) {
} 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>
@@ -2,88 +2,273 @@
<div class="video-detail-page">
<div class="detail-shell">
<section class="detail-main">
<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="player-container">
<VideoPlayer
v-if="video"
:src="video.videoUrl"
:poster="video.coverUrl"
:danmaku-list="danmakuList"
:src="playerVideoUrl"
:poster="playerPosterUrl"
:video-id="video.id"
@timeupdate="onTimeUpdate"
@ended="onEnded"
/>
<van-skeleton v-else title :row="3" />
<van-skeleton
v-else
title
:row="3"
/>
</div>
<div class="video-info" v-if="video">
<h1 class="video-title">{{ video.title }}</h1>
<div
v-if="video"
class="video-info"
>
<div class="title-row">
<div class="title-main">
<div class="title-tags">
<span
v-if="video.categoryName"
class="category-badge"
>
{{ video.categoryName }}
</span>
<span class="status-badge">
公开内容
</span>
</div>
<h1 class="video-title">
{{ video.title }}
</h1>
</div>
<button
class="author-entry"
@click.stop="goToUser(video.userId)"
>
<van-image
:src="authorAvatar"
round
class="author-entry-avatar"
/>
<div class="author-entry-copy">
<strong>{{ authorName }}</strong>
<span>{{ authorFollowerText }}</span>
</div>
</button>
</div>
<div class="video-stats">
<span>{{ formatPlayCount(video.viewCount) }}播放</span>
<span>{{ formatNumber(video.danmakuCount) }}弹幕</span>
<span>{{ formatNumber(video.likeCount) }}点赞</span>
<span>{{ formatDateTime(video.publishTime || video.createTime) }}</span>
</div>
<div class="video-actions">
<van-button size="small" :type="video.isLiked ? 'primary' : 'default'" @click="toggleLike">
<van-button
size="small"
:type="video.isLiked ? 'primary' : 'default'"
@click="toggleLike"
>
<van-icon :name="video.isLiked ? 'good-job' : 'good-job-o'" />
{{ formatNumber(video.likeCount) }}
</van-button>
<van-button size="small" :type="video.isCoined ? 'primary' : 'default'" @click="toggleCoin">
<van-button
size="small"
:type="video.isCoined ? 'primary' : 'default'"
@click="toggleCoin"
>
<van-icon name="gold-coin-o" />
{{ formatNumber(video.coinCount) }}
</van-button>
<van-button size="small" :type="video.isFavorited ? 'primary' : 'default'" @click="toggleFavorite">
<van-button
size="small"
:type="video.isFavorited ? 'primary' : 'default'"
@click="toggleFavorite"
>
<van-icon :name="video.isFavorited ? 'star' : 'star-o'" />
{{ formatNumber(video.favoriteCount) }}
</van-button>
<van-button size="small" @click="shareVideo">
<van-button
size="small"
@click="shareVideo"
>
<van-icon name="share-o" />
分享
</van-button>
</div>
<div class="author-info" @click="goToUser(video.userId)">
<van-image :src="video.userAvatar || defaultAvatar" round class="author-avatar" />
<div class="author-detail">
<div class="author-name">{{ video.userNickname }}</div>
<div class="author-fans">{{ formatNumber(video.likeCount) }}粉丝</div>
<div class="video-toolbar">
<div class="toolbar-item">
<strong>{{ formatNumber(video.viewCount) }}</strong>
<span>总播放</span>
</div>
<van-button size="small" :type="isFollowed ? 'default' : 'primary'" @click.stop="toggleFollow">
<div class="toolbar-item">
<strong>{{ formatNumber(video.commentCount) }}</strong>
<span>评论</span>
</div>
<div class="toolbar-item">
<strong>{{ formatNumber(video.favoriteCount) }}</strong>
<span>收藏</span>
</div>
</div>
<div class="up-panel">
<div
class="author-info"
@click="goToUser(video.userId)"
>
<van-image
:src="authorAvatar"
round
class="author-avatar"
/>
<div class="author-detail">
<div class="author-name">
{{ authorName }}
</div>
<div class="author-fans">
{{ authorFollowerText }}
</div>
<div class="author-extra">
{{ authorProfile?.signature || '这个作者还没有填写个性签名。' }}
</div>
</div>
</div>
<div class="up-actions">
<van-button
size="small"
:type="isFollowed ? 'default' : 'primary'"
@click.stop="toggleFollow"
>
{{ isFollowed ? '已关注' : '关注' }}
</van-button>
<van-button
size="small"
plain
@click.stop="goToUser(video.userId)"
>
查看主页
</van-button>
</div>
</div>
<div class="video-desc">
<van-collapse v-model="activeCollapse">
<van-collapse-item title="简介" name="desc">
<div class="desc-content">{{ video.description || '暂无简介' }}</div>
<div class="video-tags" v-if="video.tagNames?.length">
<van-tag v-for="tag in video.tagNames" :key="tag" plain>{{ tag }}</van-tag>
<van-collapse-item
title="简介"
name="desc"
>
<div class="desc-content">
{{ video.description || '暂无简介' }}
</div>
<div
v-if="video.tagNames?.length"
class="video-tags"
>
<van-tag
v-for="tag in video.tagNames"
:key="tag"
plain
>
{{ tag }}
</van-tag>
</div>
</van-collapse-item>
</van-collapse>
</div>
</div>
<van-tabs v-model:active="activeTab" sticky class="detail-tabs">
<van-tab title="评论" name="comment">
<CommentSection v-if="video" :video-id="video.id" />
<van-tabs
v-model:active="activeTab"
sticky
class="detail-tabs"
>
<van-tab
title="评论"
name="comment"
>
<CommentSection
v-if="video"
:video-id="video.id"
/>
</van-tab>
<van-tab title="弹幕" name="danmaku">
<DanmakuList v-if="video" :video-id="video.id" />
<van-tab
title="弹幕"
name="danmaku"
>
<DanmakuList
v-if="video"
:video-id="video.id"
/>
</van-tab>
<van-tab title="推荐" name="recommend">
<van-tab
title="推荐"
name="recommend"
>
<div class="recommend-list mobile-recommend">
<VideoCard v-for="item in recommendVideos" :key="item.id" :video="item" @click="goToVideo(item.id)" />
<VideoCard
v-for="item in recommendVideos"
:key="item.id"
:video="item"
@click="goToVideo(item.id)"
/>
</div>
</van-tab>
</van-tabs>
</section>
<aside class="detail-sidebar">
<div class="sidebar-card author-card">
<div class="sidebar-title">
UP 主信息
</div>
<button
class="sidebar-author"
@click="goToUser(video?.userId || 0)"
>
<van-image
:src="authorAvatar"
round
class="sidebar-author-avatar"
/>
<div class="sidebar-author-copy">
<strong>{{ authorName }}</strong>
<span>{{ authorFollowerText }}</span>
</div>
</button>
<p class="sidebar-author-signature">
{{ authorProfile?.signature || '这个作者还没有填写个性签名。' }}
</p>
</div>
<div class="sidebar-card">
<div class="sidebar-title">相关推荐</div>
<div class="sidebar-title">
连续观看
</div>
<div class="sidebar-video-list">
<VideoCard v-for="item in recommendVideos" :key="`side-${item.id}`" :video="item" @click="goToVideo(item.id)" />
<VideoCard
v-for="item in recommendVideos"
:key="`side-${item.id}`"
:video="item"
@click="goToVideo(item.id)"
/>
</div>
</div>
</aside>
@@ -102,10 +287,12 @@ import VideoCard from '@/components/video/VideoCard.vue'
import { videoApi } from '@/api/video.ts'
import { danmakuApi, type Danmaku } from '@/api/danmaku.ts'
import { recommendApi } from '@/api/recommend.ts'
import { favoriteApi } from '@/api/favorite.ts'
import { userApi } from '@/api/user.ts'
import { useUserStore } from '@/stores/user.ts'
import type { VideoInfo } from '@/types'
import type { UserInfo, VideoInfo } from '@/types'
import { formatDateTime, formatNumber, formatPlayCount } from '@/utils'
import { resolveAvatarUrl, resolveMediaUrl } from '@/utils/media'
const route = useRoute()
const router = useRouter()
@@ -119,6 +306,21 @@ const isFollowed = ref(false)
const activeTab = ref('comment')
const activeCollapse = ref<string[]>([])
const currentTime = ref(0)
const authorProfile = ref<UserInfo | null>(null)
const playerVideoUrl = computed(() => resolveMediaUrl(video.value?.videoUrl))
const playerPosterUrl = computed(() => resolveMediaUrl(video.value?.coverUrl))
const notificationBackVisible = computed(() => route.query.from === 'notifications')
const authorName = computed(() => authorProfile.value?.nickname || video.value?.userNickname || '未知用户')
const authorAvatar = computed(() => resolveAvatarUrl(authorProfile.value?.avatar || video.value?.userAvatar, defaultAvatar))
const authorFollowerText = computed(() => {
if (authorProfile.value) {
return `${formatNumber(authorProfile.value.followerCount)} 粉丝`
}
if (video.value?.userId) {
return '作者信息加载中'
}
return '作者信息暂不可用'
})
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'
@@ -126,20 +328,24 @@ const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/s
const loadVideo = async () => {
try {
video.value = await videoApi.getVideoPublic(videoId.value)
// 记录播放
// 播放记录接口当前要求登录,匿名态不应触发 401 跳转。
if (userStore.isLoggedIn) {
try {
videoApi.recordPlay({
await videoApi.recordPlay({
videoId: videoId.value,
progress: 0,
duration: video.value.duration
playTime: 0,
isFinished: 0
})
} catch (e) {
// 忽略播放记录错误
}
}
// 加载弹幕
loadDanmaku()
// 加载推荐
loadRecommend()
// 加载作者信息
loadAuthorProfile()
// 检查关注状态
if (userStore.isLoggedIn) {
checkFollowStatus()
@@ -158,6 +364,20 @@ const loadDanmaku = async () => {
}
}
// 加载作者信息
const loadAuthorProfile = async () => {
if (!video.value?.userId) {
authorProfile.value = null
return
}
try {
authorProfile.value = await userApi.getPublicProfile(video.value.userId)
} catch (error) {
authorProfile.value = null
console.error('加载作者信息失败:', error)
}
}
// 加载推荐
const loadRecommend = async () => {
try {
@@ -210,7 +430,7 @@ const toggleCoin = async () => {
if (!video.value) return
try {
await videoApi.coinVideo(video.value.id)
await videoApi.coinVideo(video.value.id, 1)
video.value.coinCount++
showSuccessToast('投币成功')
} catch (error: any) {
@@ -232,8 +452,8 @@ const toggleFavorite = async () => {
video.value.favoriteCount--
video.value.isFavorited = false
} else {
// TODO: 选择收藏夹
await videoApi.favoriteVideo(video.value.id, 0)
const favoriteId = await ensureFavoriteId()
await videoApi.favoriteVideo(video.value.id, favoriteId)
video.value.favoriteCount++
video.value.isFavorited = true
}
@@ -248,12 +468,26 @@ const shareVideo = async () => {
try {
await navigator.clipboard.writeText(url)
showSuccessToast('链接已复制')
videoApi.shareVideo(videoId.value)
await videoApi.shareVideo(videoId.value, 'web')
} catch {
showToast('复制失败')
}
}
const ensureFavoriteId = async () => {
const favorites = await favoriteApi.getFavoriteList()
if (favorites.length > 0) {
return favorites[0].id
}
const favorite = await favoriteApi.createFavorite({
name: '默认收藏夹',
description: '自动创建的默认收藏夹',
isPrivate: 0
})
return favorite.id
}
// 关注
const toggleFollow = async () => {
if (!userStore.isLoggedIn) {
@@ -287,6 +521,13 @@ const goToVideo = (id: number) => {
router.push(`/video/${id}`)
}
const goBackToNotifications = () => {
router.push({
path: '/notifications',
query: route.query.tab ? { tab: String(route.query.tab) } : undefined
})
}
// 播放时间更新
const onTimeUpdate = (time: number) => {
currentTime.value = time
@@ -306,6 +547,7 @@ watch(() => route.params.id, () => {
danmakuList.value = []
recommendVideos.value = []
isFollowed.value = false
authorProfile.value = null
loadVideo()
})
</script>
@@ -313,20 +555,42 @@ watch(() => route.params.id, () => {
<style scoped lang="scss">
.video-detail-page {
min-height: 100vh;
background:
linear-gradient(180deg, rgba(14, 165, 233, 0.06), transparent 200px),
#f4f7fb;
background: #f4f5f7;
}
.detail-shell {
max-width: 1440px;
max-width: 1380px;
margin: 0 auto;
padding: 18px 12px 28px;
padding: 18px 12px 32px;
display: grid;
gap: 20px;
gap: 16px;
@media (min-width: 1200px) {
grid-template-columns: minmax(0, 1fr) 360px;
grid-template-columns: minmax(0, 1fr) 320px;
}
}
.return-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-radius: 10px;
background: #fff;
box-shadow: var(--shadow-sm);
margin-bottom: 14px;
strong {
display: block;
margin-bottom: 4px;
font-size: 14px;
color: #18191c;
}
span {
color: #61666d;
font-size: 13px;
}
}
@@ -334,31 +598,106 @@ watch(() => route.params.id, () => {
width: 100%;
background: #000;
aspect-ratio: 16 / 9;
border-radius: 24px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 22px 48px rgba(15, 23, 42, 0.14);
box-shadow: var(--shadow-sm);
}
.video-info {
background: rgba(255, 255, 255, 0.96);
padding: 18px 18px 14px;
border-radius: 24px;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.06);
margin-top: 16px;
background: #fff;
padding: 18px 20px 14px;
border-radius: 10px;
box-shadow: var(--shadow-sm);
margin-top: 12px;
}
.title-row {
display: flex;
justify-content: space-between;
align-items: start;
gap: 18px;
margin-bottom: 10px;
}
.title-main {
min-width: 0;
}
.title-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.category-badge,
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.category-badge {
background: #e5f6fd;
color: #00a1d6;
}
.status-badge {
background: #f6f7f8;
color: #9499a0;
}
.video-title {
font-size: 24px;
font-size: 26px;
font-weight: 600;
color: #333;
margin: 0 0 8px;
color: #18191c;
margin: 0;
line-height: 1.4;
}
.author-entry {
border: none;
background: #f6f7f8;
border-radius: 8px;
padding: 10px 12px;
min-width: 180px;
display: flex;
align-items: center;
gap: 10px;
text-align: left;
cursor: pointer;
}
.author-entry-avatar {
width: 42px;
height: 42px;
flex-shrink: 0;
}
.author-entry-copy {
display: grid;
gap: 4px;
strong {
font-size: 14px;
color: #18191c;
}
span {
font-size: 12px;
color: #9499a0;
}
}
.video-stats {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
color: #999;
color: #9499a0;
margin-bottom: 12px;
}
@@ -371,45 +710,113 @@ watch(() => route.params.id, () => {
.van-button {
flex: 1 1 120px;
}
:deep(.van-button) {
border-radius: 6px;
}
:deep(.van-button--primary) {
background: var(--pink-color);
border-color: var(--pink-color);
}
}
.video-toolbar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.toolbar-item {
background: #f6f7f8;
border-radius: 8px;
padding: 12px 14px;
display: grid;
gap: 4px;
strong {
font-size: 18px;
color: #18191c;
}
span {
font-size: 12px;
color: #9499a0;
}
}
.up-panel {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-top: 1px solid #f1f2f3;
border-bottom: 1px solid #f1f2f3;
}
.author-info {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
cursor: pointer;
min-width: 0;
flex: 1;
}
.author-avatar {
width: 40px;
height: 40px;
width: 52px;
height: 52px;
flex-shrink: 0;
}
.author-detail {
flex: 1;
display: grid;
gap: 4px;
min-width: 0;
}
.author-name {
font-size: 15px;
font-weight: 500;
font-size: 16px;
font-weight: 600;
color: #333;
}
.author-fans {
font-size: 12px;
color: #999;
margin-top: 2px;
}
.author-extra {
font-size: 13px;
color: #61666d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.up-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.video-desc {
margin-top: 12px;
:deep(.van-cell) {
padding-left: 0;
padding-right: 0;
}
:deep(.van-collapse-item__content) {
padding: 0 0 12px;
}
.desc-content {
font-size: 14px;
color: #666;
color: #61666d;
line-height: 1.6;
white-space: pre-wrap;
}
@@ -421,13 +828,25 @@ watch(() => route.params.id, () => {
margin-top: 12px;
}
}
}
.detail-tabs {
background: rgba(255, 255, 255, 0.96);
background: #fff;
margin-top: 12px;
border-radius: 24px;
border-radius: 10px;
overflow: hidden;
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);
}
}
.recommend-list {
@@ -447,11 +866,11 @@ watch(() => route.params.id, () => {
.sidebar-card {
position: sticky;
top: 78px;
background: rgba(255, 255, 255, 0.96);
border-radius: 24px;
top: calc(var(--header-total-height) + 16px);
background: #fff;
border-radius: 10px;
padding: 18px;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.06);
box-shadow: var(--shadow-sm);
}
.sidebar-title {
@@ -461,6 +880,51 @@ watch(() => route.params.id, () => {
margin-bottom: 14px;
}
.author-card {
margin-bottom: 16px;
}
.sidebar-author {
width: 100%;
border: none;
background: #f6f7f8;
border-radius: 8px;
padding: 14px;
display: flex;
align-items: center;
gap: 12px;
text-align: left;
cursor: pointer;
}
.sidebar-author-avatar {
width: 52px;
height: 52px;
flex-shrink: 0;
}
.sidebar-author-copy {
display: grid;
gap: 4px;
strong {
color: #18191c;
font-size: 15px;
}
span {
font-size: 12px;
color: #9499a0;
}
}
.sidebar-author-signature {
margin: 12px 4px 0;
font-size: 13px;
line-height: 1.6;
color: #61666d;
}
.sidebar-video-list {
display: grid;
gap: 16px;
@@ -471,4 +935,26 @@ watch(() => route.params.id, () => {
display: none;
}
}
@media (max-width: 768px) {
.return-banner {
flex-direction: column;
align-items: flex-start;
}
.title-row,
.up-panel,
.up-actions {
flex-direction: column;
align-items: flex-start;
}
.video-toolbar {
grid-template-columns: 1fr;
}
.author-entry {
width: 100%;
}
}
</style>
+2 -1
View File
@@ -2,13 +2,14 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
const component: DefineComponent<Record<string, never>, Record<string, never>, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_API_BASE_URL: string
readonly VITE_MEDIA_BASE_URL: string
}
interface ImportMeta {
+10 -5
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite'
@@ -7,14 +7,18 @@ import { VantResolver } from 'unplugin-vue-components/resolvers'
import VueDevTools from 'vite-plugin-vue-devtools'
// https://vitejs.dev/config/
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, '')
const proxyTarget = env.VITE_PROXY_TARGET || 'http://192.168.1.101:9000'
return {
plugins: [
vue(),
VueDevTools(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
dts: 'src/auto-imports.d.ts',
dirs: ['src/composables'], // 只扫描 composables,stores 使用显式导入
dirs: ['src/composables'],
vueTemplate: true
}),
Components({
@@ -33,7 +37,7 @@ export default defineConfig({
open: true,
proxy: {
'/api': {
target: 'http://192.168.1.101:9000',
target: proxyTarget,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
@@ -42,7 +46,7 @@ export default defineConfig({
css: {
preprocessorOptions: {
scss: {
silenceDeprecations: ['legacy-js-api'] // 静默 Sass legacy API 弃用警告
silenceDeprecations: ['legacy-js-api']
}
}
},
@@ -61,4 +65,5 @@ export default defineConfig({
}
}
}
}
})