refactor(frontend): Rework home layout and user interactions
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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: '^_' }]
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 547 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
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)
|
||||
},
|
||||
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
await commentApi.addComment({
|
||||
videoId: props.videoId,
|
||||
content: content.value
|
||||
})
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
<!-- 时长 -->
|
||||
<span class="duration">{{ formatDuration(video.duration) }}</span>
|
||||
<!-- 播放量 -->
|
||||
<span class="view-count">{{ formatPlayCount(video.viewCount) }}播放</span>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
<span>点赞 {{ formatNumber(video.likeCount) }}</span>
|
||||
<span>评论 {{ formatNumber(video.commentCount) }}</span>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
|
||||
.duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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;
|
||||
&:hover .cover-image {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
.video-info {
|
||||
padding: 10px 2px 0;
|
||||
.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));
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #18191c;
|
||||
margin: 0 0 9px;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-height: 40px;
|
||||
}
|
||||
.cover-metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
|
||||
.video-meta {
|
||||
display: flex;
|
||||
span {
|
||||
display: inline-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);
|
||||
}
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
gap: 4px;
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
font-size: 12px;
|
||||
color: #9499a0;
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.video-body {
|
||||
padding: 10px 2px 0;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
margin: 0;
|
||||
min-height: 42px;
|
||||
color: #212121;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
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 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
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>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="video-player"
|
||||
/>
|
||||
</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,20 +70,23 @@ export const useUserStore = defineStore('user', () => {
|
||||
} catch (error) {
|
||||
console.error('登出请求失败:', error)
|
||||
} finally {
|
||||
// 清除本地状态
|
||||
token.value = null
|
||||
refreshToken.value = null
|
||||
userInfo.value = null
|
||||
authUser.value = null
|
||||
removeToken()
|
||||
removeRefreshToken()
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
token.value = null
|
||||
refreshToken.value = null
|
||||
userInfo.value = null
|
||||
authUser.value = null
|
||||
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
|
||||
}
|
||||
|
||||
// 清除当前视频
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
declare module 'dplayer';
|
||||
@@ -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()
|
||||
router.push('/login')
|
||||
showToast('登录已过期,请重新登录')
|
||||
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()
|
||||
router.push('/login')
|
||||
showToast('登录已过期,请重新登录')
|
||||
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()
|
||||
router.push('/login')
|
||||
showToast('登录已过期,请重新登录')
|
||||
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>
|
||||
<h2>{{ favoriteTitle }}</h2>
|
||||
<p>{{ description }}</p>
|
||||
<div class="page-shell detail-shell">
|
||||
<div class="page-head">
|
||||
<div class="head-main">
|
||||
<button
|
||||
class="back-link"
|
||||
@click="router.back()"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<div>
|
||||
<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"
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多内容了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<div class="video-grid">
|
||||
<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="video-grid">
|
||||
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
|
||||
<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-list>
|
||||
</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;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
.head-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
|
||||
.count {
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
span {
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-list {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 12px 24px;
|
||||
.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: #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
|
||||
v-for="fav in favorites"
|
||||
:key="fav.id"
|
||||
:title="fav.name"
|
||||
:label="`${fav.videoCount}个视频`"
|
||||
is-link
|
||||
<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"
|
||||
class="favorite-card"
|
||||
>
|
||||
<button
|
||||
class="favorite-main"
|
||||
@click="goToFavorite(fav.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<van-icon name="star" class="fav-icon" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<van-empty v-else description="暂无收藏夹" />
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
<div 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-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;
|
||||
.page-head,
|
||||
.favorite-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
.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;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
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,35 +1,50 @@
|
||||
<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>
|
||||
|
||||
<div class="video-list">
|
||||
<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>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
class="switcher-item"
|
||||
:class="{ active: activeCategoryId === cat.id }"
|
||||
@click="switchCategory(cat.id)"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<van-empty
|
||||
v-if="!loading && !videos.length"
|
||||
description="该分类下暂无视频"
|
||||
/>
|
||||
</van-list>
|
||||
</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(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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
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="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>
|
||||
<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>
|
||||
|
||||
</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>
|
||||
</div>
|
||||
<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">
|
||||
<VideoCard
|
||||
v-for="video in videos"
|
||||
:key="video.id"
|
||||
:video="video"
|
||||
@click="goToVideo(video.id)"
|
||||
/>
|
||||
<div class="row-grid">
|
||||
<VideoCard
|
||||
v-for="video in row.videos"
|
||||
:key="`${row.id}-${video.id}`"
|
||||
:video="video"
|
||||
@click="goToVideo(video.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<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)
|
||||
}
|
||||
}
|
||||
return deduplicated
|
||||
})
|
||||
|
||||
// 加载视频列表
|
||||
const loadVideos = async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
currentPage.value = 1
|
||||
videos.value = []
|
||||
finished.value = false
|
||||
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)
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
let res
|
||||
if (!hotVideos.value.length) {
|
||||
hotVideos.value = publicList.records.slice(0, 12)
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
if (isRefresh) {
|
||||
videos.value = res.records
|
||||
} else {
|
||||
videos.value.push(...res.records)
|
||||
}
|
||||
|
||||
// 判断是否还有更多
|
||||
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 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 })
|
||||
}
|
||||
}
|
||||
|
||||
appendRow(-1, '推荐内容', featuredVideos.value)
|
||||
appendRow(-2, '热门内容', hotVideos.value)
|
||||
appendRow(-3, '最新发布', publicList.records)
|
||||
|
||||
categoryRows.value = fallbackRows
|
||||
}
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = () => {
|
||||
loadVideos(true)
|
||||
}
|
||||
|
||||
// 分类切换
|
||||
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;
|
||||
}
|
||||
|
||||
.hero-copy p {
|
||||
margin: 0;
|
||||
max-width: 760px;
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hero-tag {
|
||||
display: inline-flex;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.channel-panel,
|
||||
.content-row,
|
||||
.side-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.hero-metrics {
|
||||
.channel-panel {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.channel-main {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 82px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.quick-entry-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-width: 92px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(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,88 +1,146 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<!-- Logo -->
|
||||
<div class="login-header">
|
||||
<h1 class="logo">HAOVP</h1>
|
||||
<p class="slogan">登录后即可继续观看、发布与互动</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<van-form @submit="handleLogin" class="login-form">
|
||||
<!-- 验证码 -->
|
||||
<div class="captcha-row">
|
||||
<van-image
|
||||
:src="captchaImage"
|
||||
fit="contain"
|
||||
class="captcha-image"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
<template v-slot:error>
|
||||
<div class="captcha-error" @click="refreshCaptcha">点击刷新</div>
|
||||
</template>
|
||||
</van-image>
|
||||
<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-cell-group inset>
|
||||
<van-field
|
||||
v-model="form.username"
|
||||
name="username"
|
||||
placeholder="用户名/邮箱/手机号"
|
||||
:rules="[{ required: true, message: '请输入用户名' }]"
|
||||
left-icon="user-o"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="密码"
|
||||
:rules="[{ required: true, message: '请输入密码' }]"
|
||||
left-icon="lock"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.captcha"
|
||||
name="captcha"
|
||||
placeholder="验证码"
|
||||
:rules="[{ required: true, message: '请输入验证码' }]"
|
||||
left-icon="shield-o"
|
||||
>
|
||||
<template #button>
|
||||
<van-image
|
||||
:src="captchaImage"
|
||||
fit="contain"
|
||||
class="captcha-btn"
|
||||
@click="refreshCaptcha"
|
||||
/>
|
||||
</template>
|
||||
</van-field>
|
||||
</van-cell-group>
|
||||
|
||||
<div class="login-actions">
|
||||
<van-button
|
||||
round
|
||||
block
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="loading"
|
||||
color="#00a1d6"
|
||||
>
|
||||
登录
|
||||
</van-button>
|
||||
<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>
|
||||
</van-form>
|
||||
</section>
|
||||
|
||||
<!-- 其他登录方式 -->
|
||||
<div class="login-footer">
|
||||
<router-link to="/register" class="link">注册账号</router-link>
|
||||
</div>
|
||||
<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 #error>
|
||||
<div
|
||||
class="captcha-error"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
点击刷新
|
||||
</div>
|
||||
</template>
|
||||
</van-image>
|
||||
</div>
|
||||
|
||||
<van-cell-group inset>
|
||||
<van-field
|
||||
v-model="form.username"
|
||||
name="username"
|
||||
placeholder="用户名/邮箱/手机号"
|
||||
:rules="[{ required: true, message: '请输入用户名' }]"
|
||||
left-icon="user-o"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="密码"
|
||||
:rules="[{ required: true, message: '请输入密码' }]"
|
||||
left-icon="lock"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.captcha"
|
||||
name="captcha"
|
||||
placeholder="验证码"
|
||||
:rules="[{ required: true, message: '请输入验证码' }]"
|
||||
left-icon="shield-o"
|
||||
>
|
||||
<template #button>
|
||||
<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="login-actions">
|
||||
<van-button
|
||||
round
|
||||
block
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="loading"
|
||||
class="submit-btn"
|
||||
>
|
||||
登录
|
||||
</van-button>
|
||||
</div>
|
||||
</van-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<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;
|
||||
margin: 0;
|
||||
.showcase-copy {
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 40px;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.slogan {
|
||||
margin-top: 10px;
|
||||
color: #64748b;
|
||||
.lead {
|
||||
margin: 0;
|
||||
max-width: 620px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
.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);
|
||||
}
|
||||
|
||||
.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: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: #f8fafc;
|
||||
|
||||
.captcha-image {
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #18191c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.captcha-error {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
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: #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;
|
||||
}
|
||||
|
||||
.captcha-btn {
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
.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,80 +1,155 @@
|
||||
<template>
|
||||
<div class="register-page">
|
||||
<div class="register-container">
|
||||
<div class="register-header">
|
||||
<h1 class="logo">HAOVP</h1>
|
||||
<p class="slogan">创建账号,保存你的观看与创作记录</p>
|
||||
</div>
|
||||
|
||||
<van-form @submit="handleRegister">
|
||||
<van-cell-group inset>
|
||||
<van-field
|
||||
v-model="form.username"
|
||||
name="username"
|
||||
label="用户名"
|
||||
placeholder="请输入用户名"
|
||||
:rules="[{ required: true, message: '请输入用户名' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.email"
|
||||
name="email"
|
||||
label="邮箱"
|
||||
placeholder="请输入邮箱"
|
||||
:rules="[{ required: true, message: '请输入邮箱' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.phone"
|
||||
name="phone"
|
||||
label="手机号"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
name="password"
|
||||
label="密码"
|
||||
placeholder="请输入密码"
|
||||
:rules="[{ required: true, message: '请输入密码' }, { pattern: /^.{6,}$/, message: '密码至少6位' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
label="确认密码"
|
||||
placeholder="请再次输入密码"
|
||||
:rules="[{ required: true, message: '请确认密码' }, { validator: validatePassword, message: '两次密码不一致' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.captcha"
|
||||
name="captcha"
|
||||
label="验证码"
|
||||
placeholder="请输入验证码"
|
||||
:rules="[{ required: true, message: '请输入验证码' }]"
|
||||
>
|
||||
<template #button>
|
||||
<van-image :src="captchaImage" fit="contain" class="captcha-btn" @click="refreshCaptcha" />
|
||||
</template>
|
||||
</van-field>
|
||||
</van-cell-group>
|
||||
|
||||
<div class="register-actions">
|
||||
<van-button round block type="primary" native-type="submit" :loading="loading" color="#00a1d6">
|
||||
注册
|
||||
</van-button>
|
||||
<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>
|
||||
</van-form>
|
||||
|
||||
<div class="register-footer">
|
||||
<router-link to="/login" class="link">已有账号?去登录</router-link>
|
||||
</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"
|
||||
name="username"
|
||||
label="用户名"
|
||||
placeholder="请输入用户名"
|
||||
:rules="[{ required: true, message: '请输入用户名' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.email"
|
||||
name="email"
|
||||
label="邮箱"
|
||||
placeholder="请输入邮箱"
|
||||
:rules="[{ required: true, message: '请输入邮箱' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.phone"
|
||||
name="phone"
|
||||
label="手机号"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
name="password"
|
||||
label="密码"
|
||||
placeholder="请输入密码"
|
||||
:rules="[{ required: true, message: '请输入密码' }, { pattern: /^.{6,}$/, message: '密码至少6位' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
label="确认密码"
|
||||
placeholder="请再次输入密码"
|
||||
:rules="[{ required: true, message: '请确认密码' }, { validator: validatePassword, message: '两次密码不一致' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.captcha"
|
||||
name="captcha"
|
||||
label="验证码"
|
||||
placeholder="请输入验证码"
|
||||
:rules="[{ required: true, message: '请输入验证码' }]"
|
||||
>
|
||||
<template #button>
|
||||
<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"
|
||||
class="submit-btn"
|
||||
>
|
||||
注册
|
||||
</van-button>
|
||||
</div>
|
||||
</van-form>
|
||||
|
||||
<div class="register-footer">
|
||||
<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;
|
||||
margin: 0;
|
||||
.showcase-copy {
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 40px;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.slogan {
|
||||
margin-top: 10px;
|
||||
color: #64748b;
|
||||
.lead {
|
||||
margin: 0;
|
||||
max-width: 620px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
.link {
|
||||
color: #00a1d6;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.auth-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-showcase {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-btn {
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
@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,46 +1,58 @@
|
||||
<template>
|
||||
<div class="message-detail-page">
|
||||
<van-nav-bar title="聊天详情" fixed placeholder left-arrow @click-left="router.back()" />
|
||||
|
||||
<div class="message-list" ref="listRef">
|
||||
<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"
|
||||
round
|
||||
width="36"
|
||||
height="36"
|
||||
class="avatar"
|
||||
/>
|
||||
<div class="message-content">
|
||||
<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 class="page-shell chat-shell">
|
||||
<div class="chat-head">
|
||||
<button
|
||||
class="back-link"
|
||||
@click="router.back()"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<strong>聊天详情</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-input">
|
||||
<van-field
|
||||
v-model="content"
|
||||
placeholder="输入消息..."
|
||||
:border="false"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<van-button type="primary" size="small" :loading="sending" @click="sendMessage">
|
||||
发送
|
||||
</van-button>
|
||||
<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
|
||||
:src="getAvatarUrl(msg.senderId === userStore.userId ? userStore.userInfo?.avatar : msg.senderAvatar)"
|
||||
round
|
||||
width="38"
|
||||
height="38"
|
||||
class="avatar"
|
||||
/>
|
||||
<div class="message-content">
|
||||
<div class="bubble">
|
||||
{{ msg.content }}
|
||||
</div>
|
||||
<div class="time">
|
||||
{{ formatRelativeTime(msg.createTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-input">
|
||||
<van-field
|
||||
v-model="content"
|
||||
placeholder="输入消息"
|
||||
:border="false"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<van-button
|
||||
type="primary"
|
||||
:loading="sending"
|
||||
@click="sendMessage"
|
||||
>
|
||||
发送
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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,76 +86,130 @@ 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;
|
||||
.message-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
align-items: flex-start;
|
||||
|
||||
&.is-self {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background: #00a1d6;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
&.is-self {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 70%;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.bubble {
|
||||
background: #e5f6fd;
|
||||
color: #18191c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 70%;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 11px 14px;
|
||||
border-radius: 10px;
|
||||
background: #f6f7f8;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #9499a0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #eee;
|
||||
padding: 10px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.van-field {
|
||||
flex: 1;
|
||||
}
|
||||
.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>
|
||||
</section>
|
||||
<div class="page-shell message-shell">
|
||||
<div class="page-head">
|
||||
<strong>私信列表</strong>
|
||||
<span>{{ conversations.length }} 个会话</span>
|
||||
</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="conversations.length" inset>
|
||||
<van-cell
|
||||
v-for="conv in conversations"
|
||||
:key="conv.targetId"
|
||||
:title="conv.targetNickname"
|
||||
:label="conv.lastMessage"
|
||||
is-link
|
||||
@click="goToDetail(conv.targetId)"
|
||||
>
|
||||
<template #icon>
|
||||
<van-image :src="conv.targetAvatar || defaultAvatar" round width="48" height="48" />
|
||||
</template>
|
||||
<template #value>
|
||||
<van-badge :content="conv.unreadCount" :show-zero="false">
|
||||
<span class="time">{{ formatRelativeTime(conv.lastTime) }}</span>
|
||||
</van-badge>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<van-empty v-else description="暂无私信" />
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
<div
|
||||
v-if="conversations.length"
|
||||
class="conversation-list"
|
||||
>
|
||||
<button
|
||||
v-for="conv in conversations"
|
||||
:key="conv.targetId"
|
||||
class="conversation-item"
|
||||
@click="goToDetail(conv.targetId)"
|
||||
>
|
||||
<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,
|
||||
lastMessage: item.content,
|
||||
lastTime: item.createTime,
|
||||
unreadCount: item.isRead ? 0 : 1
|
||||
}))
|
||||
finished.value = true
|
||||
} catch (error) {
|
||||
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: (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;
|
||||
.page-head,
|
||||
.conversation-list {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
.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);
|
||||
margin: 6px 0 0;
|
||||
color: #61666d;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.message-shell :deep(.van-cell-group) {
|
||||
margin: 0;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.conversation-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
strong {
|
||||
font-size: 16px;
|
||||
color: #18191c;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #9499a0;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,84 +1,96 @@
|
||||
<template>
|
||||
<div class="notifications-page">
|
||||
<van-nav-bar title="通知" fixed placeholder left-arrow @click-left="router.back()" />
|
||||
<div class="page-shell notifications-shell">
|
||||
<div class="page-head">
|
||||
<strong>通知中心</strong>
|
||||
<button
|
||||
class="read-all"
|
||||
@click="markAllRead"
|
||||
>
|
||||
全部已读
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
<div
|
||||
v-if="notifications.length"
|
||||
class="notification-list"
|
||||
>
|
||||
<van-cell-group v-if="notifications.length" inset>
|
||||
<van-cell
|
||||
v-for="notif in notifications"
|
||||
:key="notif.id"
|
||||
:title="notif.content"
|
||||
:label="formatRelativeTime(notif.createTime)"
|
||||
<button
|
||||
v-for="notif in notifications"
|
||||
:key="notif.id"
|
||||
class="notification-item"
|
||||
@click="openNotification(notif)"
|
||||
>
|
||||
<div
|
||||
class="notif-icon"
|
||||
:style="{ backgroundColor: getColor(notif.type) }"
|
||||
>
|
||||
<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>
|
||||
<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('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!finished.value) loadNotifications()
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await userApi.markAllNotificationsRead()
|
||||
notifications.value = notifications.value.map((item) => ({ ...item, isRead: 1 }))
|
||||
pendingReadIds.value = []
|
||||
messageStore.clearNotificationUnread()
|
||||
showSuccessToast('已全部标记为已读')
|
||||
} catch {
|
||||
showToast('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
loadNotifications(true)
|
||||
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 {
|
||||
pendingReadIds.value = pendingReadIds.value.filter((id) => id !== notification.id)
|
||||
}
|
||||
|
||||
if ((notification.type === 1 || notification.type === 2) && notification.relatedId) {
|
||||
router.push({ path: `/video/${notification.relatedId}`, query: { from: 'notifications', tab: activeTab.value } })
|
||||
return
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.notif-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 8px;
|
||||
.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 {
|
||||
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"
|
||||
/>
|
||||
|
||||
<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="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>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
|
||||
<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-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多结果了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<div
|
||||
v-if="videos.length"
|
||||
class="video-list"
|
||||
>
|
||||
<button
|
||||
v-for="video in videos"
|
||||
:key="video.id"
|
||||
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>
|
||||
</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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 18px 16px;
|
||||
margin-top: 12px;
|
||||
.search-summary {
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-left {
|
||||
display: grid;
|
||||
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) => {
|
||||
uploadProgress.value = 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()" />
|
||||
<section class="about-card">
|
||||
<h2>HAOVP 在线视频平台</h2>
|
||||
<p>一个面向内容上传、播放互动与平台治理场景构建的在线视频系统。</p>
|
||||
<van-cell-group inset>
|
||||
<van-cell title="当前版本" value="1.0.0" />
|
||||
<van-cell title="主要能力" value="上传、播放、评论、推荐、后台治理" />
|
||||
<van-cell title="技术栈" value="Vue 3 + Vite + Vant" />
|
||||
</van-cell-group>
|
||||
</section>
|
||||
<van-nav-bar
|
||||
title="关于"
|
||||
fixed
|
||||
placeholder
|
||||
left-arrow
|
||||
@click-left="router.back()"
|
||||
/>
|
||||
<div class="about-shell">
|
||||
<section class="about-hero">
|
||||
<div>
|
||||
<p class="eyebrow">
|
||||
HAOVP
|
||||
</p>
|
||||
<h2>高可用在线视频平台前端</h2>
|
||||
<p class="hero-copy">
|
||||
面向上传、播放、互动与后台治理链路的用户端界面,当前环境已经对齐单机与高可用后端联调。
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-badges">
|
||||
<span>版本 {{ appVersion }}</span>
|
||||
<span>{{ environmentLabel }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="about-grid">
|
||||
<section class="about-card">
|
||||
<div class="card-title">
|
||||
当前环境
|
||||
</div>
|
||||
<div class="env-grid">
|
||||
<div class="env-item">
|
||||
<span>API 代理</span>
|
||||
<strong>{{ apiTarget }}</strong>
|
||||
</div>
|
||||
<div class="env-item">
|
||||
<span>媒体访问</span>
|
||||
<strong>{{ mediaBaseUrl }}</strong>
|
||||
</div>
|
||||
<div class="env-item">
|
||||
<span>当前账号</span>
|
||||
<strong>{{ currentAccount }}</strong>
|
||||
</div>
|
||||
<div class="env-item">
|
||||
<span>登录状态</span>
|
||||
<strong>{{ loginStatus }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about-card">
|
||||
<div class="card-title">
|
||||
主要能力
|
||||
</div>
|
||||
<div class="capability-list">
|
||||
<div
|
||||
v-for="item in capabilities"
|
||||
:key="item.title"
|
||||
class="capability-item"
|
||||
>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="about-card">
|
||||
<div class="card-title">
|
||||
技术栈与说明
|
||||
</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
title="用户端技术栈"
|
||||
value="Vue 3 + Vite + Vant + Pinia"
|
||||
/>
|
||||
<van-cell
|
||||
title="联调链路"
|
||||
value="注册、登录、上传、审核、公开播放、评论互动"
|
||||
/>
|
||||
<van-cell
|
||||
title="部署目标"
|
||||
value="单机 Compose 与三节点高可用后端"
|
||||
/>
|
||||
<van-cell
|
||||
title="项目定位"
|
||||
value="毕业设计实验平台"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const appVersion = '1.0.0'
|
||||
const apiTarget = import.meta.env.VITE_PROXY_TARGET || window.location.origin
|
||||
const mediaBaseUrl = import.meta.env.VITE_MEDIA_BASE_URL || '未配置'
|
||||
const environmentLabel = import.meta.env.DEV ? '开发环境' : '生产环境'
|
||||
const currentAccount = computed(() => userStore.authUser?.username || userStore.userInfo?.nickname || '未登录')
|
||||
const loginStatus = computed(() => (userStore.isLoggedIn ? '已登录' : '未登录'))
|
||||
const capabilities = [
|
||||
{
|
||||
title: '内容上传与播放',
|
||||
description: '支持视频上传、审核后公开播放、封面与媒体资源分离访问。'
|
||||
},
|
||||
{
|
||||
title: '互动社区能力',
|
||||
description: '支持评论、回复、点赞、收藏、私信和通知等常用互动链路。'
|
||||
},
|
||||
{
|
||||
title: '平台治理能力',
|
||||
description: '支持后台审核、用户管理、评论管理与统计类能力的联调展示。'
|
||||
},
|
||||
{
|
||||
title: '高可用部署演进',
|
||||
description: '前端已按网关与对象存储入口适配单机和多节点后端部署。'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.about-page { min-height: 100vh; background: #f6f7fb; }
|
||||
.about-page {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.12), transparent 28%),
|
||||
#f6f7fb;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.about-shell {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 12px 28px;
|
||||
}
|
||||
|
||||
.about-hero,
|
||||
.about-card {
|
||||
margin: 20px 16px;
|
||||
padding: 24px;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.about-hero {
|
||||
padding: 28px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 18px;
|
||||
background: linear-gradient(135deg, #0f172a, #1d4ed8 58%, #38bdf8);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.about-hero h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
margin: 0;
|
||||
max-width: 720px;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.hero-badges {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
span {
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.about-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.about-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin-bottom: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.env-grid,
|
||||
.capability-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.env-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.env-item,
|
||||
.capability-item {
|
||||
background: #f8fafc;
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.env-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #9499a0;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.capability-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
strong {
|
||||
color: #18191c;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #61666d;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.about-hero,
|
||||
.about-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.about-hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.env-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<div class="follow-page">
|
||||
<van-nav-bar title="粉丝" fixed placeholder left-arrow @click-left="router.back()" />
|
||||
<van-nav-bar
|
||||
title="粉丝"
|
||||
fixed
|
||||
placeholder
|
||||
left-arrow
|
||||
@click-left="router.back()"
|
||||
/>
|
||||
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
@@ -9,9 +15,18 @@
|
||||
@load="loadMore"
|
||||
>
|
||||
<van-cell-group inset>
|
||||
<van-cell v-for="user in users" :key="user.id" @click="goToProfile(user.id)">
|
||||
<van-cell
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
@click="goToProfile(user.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<van-image :src="user.avatar || defaultAvatar" round width="40" height="40" />
|
||||
<van-image
|
||||
:src="getAvatarUrl(user.avatar)"
|
||||
round
|
||||
width="40"
|
||||
height="40"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="nickname">{{ user.nickname }}</span>
|
||||
@@ -20,13 +35,20 @@
|
||||
<span class="signature">{{ user.signature || '这个人很懒' }}</span>
|
||||
</template>
|
||||
<template #value>
|
||||
<van-button size="small" :type="user.isFollowed ? 'default' : 'primary'" @click.stop="toggleFollow(user)">
|
||||
<van-button
|
||||
size="small"
|
||||
:type="user.isFollowed ? 'default' : 'primary'"
|
||||
@click.stop="toggleFollow(user)"
|
||||
>
|
||||
{{ user.isFollowed ? '已关注' : '关注' }}
|
||||
</van-button>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<van-empty v-if="!loading && !users.length" description="暂无粉丝" />
|
||||
<van-empty
|
||||
v-if="!loading && !users.length"
|
||||
description="暂无粉丝"
|
||||
/>
|
||||
</van-list>
|
||||
</div>
|
||||
</template>
|
||||
@@ -37,6 +59,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { showToast, showSuccessToast } from 'vant'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import { resolveAvatarUrl } from '@/utils/media'
|
||||
import type { UserFollow } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -47,14 +70,22 @@ const userId = Number(route.params.id)
|
||||
const users = ref<UserFollow[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const page = ref(1)
|
||||
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
|
||||
const getAvatarUrl = (avatar?: string) => resolveAvatarUrl(avatar, defaultAvatar)
|
||||
|
||||
const loadUsers = async () => {
|
||||
const loadUsers = async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
users.value = []
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await userApi.getFollowers(userId)
|
||||
users.value = data
|
||||
finished.value = true
|
||||
const data = await userApi.getFollowers(userId, { page: page.value, size: 10 })
|
||||
users.value.push(...data.records)
|
||||
finished.value = users.value.length >= data.total || data.records.length < 10
|
||||
page.value++
|
||||
} catch (error) {
|
||||
showToast('加载失败')
|
||||
} finally {
|
||||
@@ -91,7 +122,7 @@ const goToProfile = (id: number) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
loadUsers(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<div class="follow-page">
|
||||
<van-nav-bar title="关注" fixed placeholder left-arrow @click-left="router.back()" />
|
||||
<van-nav-bar
|
||||
title="关注"
|
||||
fixed
|
||||
placeholder
|
||||
left-arrow
|
||||
@click-left="router.back()"
|
||||
/>
|
||||
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
@@ -9,9 +15,18 @@
|
||||
@load="loadMore"
|
||||
>
|
||||
<van-cell-group inset>
|
||||
<van-cell v-for="user in users" :key="user.id" @click="goToProfile(user.id)">
|
||||
<van-cell
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
@click="goToProfile(user.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<van-image :src="user.avatar || defaultAvatar" round width="40" height="40" />
|
||||
<van-image
|
||||
:src="getAvatarUrl(user.avatar)"
|
||||
round
|
||||
width="40"
|
||||
height="40"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="nickname">{{ user.nickname }}</span>
|
||||
@@ -20,13 +35,20 @@
|
||||
<span class="signature">{{ user.signature || '这个人很懒' }}</span>
|
||||
</template>
|
||||
<template #value>
|
||||
<van-button size="small" type="default" @click.stop="unfollow(user)">
|
||||
<van-button
|
||||
size="small"
|
||||
type="default"
|
||||
@click.stop="unfollow(user)"
|
||||
>
|
||||
已关注
|
||||
</van-button>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<van-empty v-if="!loading && !users.length" description="暂无关注" />
|
||||
<van-empty
|
||||
v-if="!loading && !users.length"
|
||||
description="暂无关注"
|
||||
/>
|
||||
</van-list>
|
||||
</div>
|
||||
</template>
|
||||
@@ -36,6 +58,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { showToast, showSuccessToast } from 'vant'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
import { resolveAvatarUrl } from '@/utils/media'
|
||||
import type { UserFollow } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -45,15 +68,23 @@ const userId = Number(route.params.id)
|
||||
const users = ref<UserFollow[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const page = ref(1)
|
||||
|
||||
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
|
||||
const getAvatarUrl = (avatar?: string) => resolveAvatarUrl(avatar, defaultAvatar)
|
||||
|
||||
const loadUsers = async () => {
|
||||
const loadUsers = async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
users.value = []
|
||||
page.value = 1
|
||||
finished.value = false
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await userApi.getFollowing(userId)
|
||||
users.value = data
|
||||
finished.value = true
|
||||
const data = await userApi.getFollowing(userId, { page: page.value, size: 10 })
|
||||
users.value.push(...data.records)
|
||||
finished.value = users.value.length >= data.total || data.records.length < 10
|
||||
page.value++
|
||||
} catch (error) {
|
||||
showToast('加载失败')
|
||||
} finally {
|
||||
@@ -80,7 +111,7 @@ const goToProfile = (id: number) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
loadUsers(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,45 +1,104 @@
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<van-nav-bar title="播放历史" fixed placeholder left-arrow @click-left="router.back()">
|
||||
<template #right>
|
||||
<van-icon name="delete-o" size="22" @click="clearHistory" />
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
<div class="page-shell history-shell">
|
||||
<section class="history-hero">
|
||||
<div class="hero-copy">
|
||||
<p class="hero-eyebrow">
|
||||
Watch History
|
||||
</p>
|
||||
<h1>播放历史</h1>
|
||||
<span>继续观看你最近浏览过的内容</span>
|
||||
</div>
|
||||
<van-button
|
||||
plain
|
||||
round
|
||||
class="clear-button"
|
||||
@click="clearHistory"
|
||||
>
|
||||
清空历史
|
||||
</van-button>
|
||||
</section>
|
||||
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
<section class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span>历史记录</span>
|
||||
<strong>{{ videos.length }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>观看完成</span>
|
||||
<strong>{{ finishedCount }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>继续观看</span>
|
||||
<strong>{{ continueCount }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="videos.length"
|
||||
class="history-panel"
|
||||
>
|
||||
<van-cell-group v-if="videos.length" inset>
|
||||
<van-cell v-for="item in videos" :key="item.videoId" @click="goToVideo(item.videoId)">
|
||||
<template #icon>
|
||||
<van-image :src="item.coverUrl" width="80" height="45" fit="cover" radius="4" />
|
||||
</template>
|
||||
<template #title>
|
||||
<div class="video-title">{{ item.title }}</div>
|
||||
</template>
|
||||
<template #label>
|
||||
<div class="video-progress">
|
||||
观看至 {{ formatDuration(item.progress) }} / {{ formatDuration(item.duration) }}
|
||||
<div class="panel-head">
|
||||
<strong>最近观看</strong>
|
||||
<span>{{ videos.length }} 条记录</span>
|
||||
</div>
|
||||
|
||||
<div class="video-list">
|
||||
<article
|
||||
v-for="item in videos"
|
||||
:key="item.videoId"
|
||||
class="video-item"
|
||||
@click="goToVideo(item.videoId)"
|
||||
>
|
||||
<div class="video-cover">
|
||||
<van-image
|
||||
:src="getCoverUrl(item.coverUrl)"
|
||||
fit="cover"
|
||||
class="cover-image"
|
||||
/>
|
||||
<span class="duration">{{ formatDuration(item.duration) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="video-body">
|
||||
<div class="video-title-row">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<van-tag :type="item.progress >= item.duration && item.duration > 0 ? 'success' : 'primary'">
|
||||
{{ item.progress >= item.duration && item.duration > 0 ? '已看完' : '观看中' }}
|
||||
</van-tag>
|
||||
</div>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<van-empty v-else description="暂无播放记录" />
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
|
||||
<div class="video-meta">
|
||||
<span>观看至 {{ formatDuration(item.progress) }}</span>
|
||||
<span>总时长 {{ formatDuration(item.duration) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="progress-track">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:style="{ width: `${item.duration ? Math.min((item.progress / item.duration) * 100, 100) : 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<van-empty
|
||||
v-else
|
||||
description="暂无播放记录"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showConfirmDialog } from 'vant'
|
||||
import { showConfirmDialog, showToast } from 'vant'
|
||||
import { formatDuration } from '@/utils'
|
||||
import { mockVideos } from '@/mock/data'
|
||||
import { resolveMediaUrl } from '@/utils/media'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
import { useVideoStore } from '@/stores/video.ts'
|
||||
|
||||
interface HistoryItem {
|
||||
videoId: number
|
||||
@@ -50,35 +109,39 @@ interface HistoryItem {
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const videoStore = useVideoStore()
|
||||
const videos = ref<HistoryItem[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const historyRecords = computed(() => [...videoStore.playHistory].reverse())
|
||||
const defaultCover =
|
||||
'data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 180\"%3E%3Crect width=\"320\" height=\"180\" fill=\"%23edf2f7\"/%3E%3Ctext x=\"50%25\" y=\"50%25\" dy=\".3em\" text-anchor=\"middle\" fill=\"%239499a0\"%3EHAOVP%3C/text%3E%3C/svg%3E'
|
||||
|
||||
const loadVideos = async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
videos.value = []
|
||||
finished.value = false
|
||||
const finishedCount = computed(() => videos.value.filter((item) => item.duration > 0 && item.progress >= item.duration).length)
|
||||
const continueCount = computed(() => videos.value.length - finishedCount.value)
|
||||
|
||||
const getCoverUrl = (coverUrl?: string) => resolveMediaUrl(coverUrl) || defaultCover
|
||||
|
||||
const loadVideos = async () => {
|
||||
try {
|
||||
const details = await Promise.all(
|
||||
historyRecords.value.slice(0, 30).map(async (record) => {
|
||||
try {
|
||||
const video = await videoApi.getVideoPublic(record.videoId)
|
||||
return {
|
||||
videoId: record.videoId,
|
||||
title: video.title,
|
||||
coverUrl: video.coverUrl,
|
||||
progress: record.playTime,
|
||||
duration: video.duration
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
videos.value = details.filter(Boolean) as HistoryItem[]
|
||||
} catch {
|
||||
showToast('加载失败')
|
||||
}
|
||||
loading.value = true
|
||||
videos.value = mockVideos.slice(0, 4).map((item, index) => ({
|
||||
videoId: item.id,
|
||||
title: item.title,
|
||||
coverUrl: item.coverUrl,
|
||||
progress: Math.max(60, Math.floor(item.duration * (0.2 + index * 0.12))),
|
||||
duration: item.duration
|
||||
}))
|
||||
finished.value = true
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!finished.value) loadVideos()
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
loadVideos(true)
|
||||
}
|
||||
|
||||
const clearHistory = async () => {
|
||||
@@ -87,10 +150,11 @@ const clearHistory = async () => {
|
||||
title: '提示',
|
||||
message: '确定要清空播放历史吗?'
|
||||
})
|
||||
videoStore.playHistory = []
|
||||
videos.value = []
|
||||
showToast('已清空')
|
||||
} catch {
|
||||
// 取消
|
||||
// cancel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,27 +162,220 @@ const goToVideo = (id: number) => {
|
||||
router.push(`/video/${id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVideos(true)
|
||||
})
|
||||
onMounted(loadVideos)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.history-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
background: #f4f5f7;
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.history-shell {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.history-hero,
|
||||
.history-panel,
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.history-hero {
|
||||
padding: 22px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
color: #18191c;
|
||||
}
|
||||
|
||||
.video-progress {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
span {
|
||||
color: #61666d;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-eyebrow {
|
||||
margin: 0;
|
||||
color: var(--link-color);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #18191c;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.history-panel {
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-list {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
}
|
||||
|
||||
.video-cover {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #edf2f7;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.duration {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.74);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.video-body {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.video-title-row {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #18191c;
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #e6edf5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #00a1d6, #fb7299);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.history-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.history-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,69 +1,138 @@
|
||||
<template>
|
||||
<div class="my-videos-page">
|
||||
<van-nav-bar title="我的视频" fixed placeholder left-arrow @click-left="router.back()">
|
||||
<template #right>
|
||||
<van-icon name="plus" size="22" @click="router.push('/upload')" />
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
<div class="page-shell videos-shell">
|
||||
<section class="videos-hero">
|
||||
<div class="hero-copy">
|
||||
<p class="hero-eyebrow">
|
||||
Creator Center
|
||||
</p>
|
||||
<h1>我的视频</h1>
|
||||
<span>管理已上传内容与当前审核状态</span>
|
||||
</div>
|
||||
<van-button
|
||||
type="primary"
|
||||
round
|
||||
class="upload-button"
|
||||
@click="router.push('/upload')"
|
||||
>
|
||||
上传视频
|
||||
</van-button>
|
||||
</section>
|
||||
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
<section class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span>全部视频</span>
|
||||
<strong>{{ videos.length }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>已发布</span>
|
||||
<strong>{{ publishedCount }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>审核中</span>
|
||||
<strong>{{ reviewingCount }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>异常状态</span>
|
||||
<strong>{{ exceptionalCount }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="videos.length"
|
||||
class="videos-panel"
|
||||
>
|
||||
<van-cell-group v-if="videos.length" inset>
|
||||
<van-cell v-for="video in videos" :key="video.id" @click="goToVideo(video.id)">
|
||||
<template #icon>
|
||||
<van-image :src="video.coverUrl" width="80" height="45" fit="cover" radius="4" />
|
||||
</template>
|
||||
<template #title>
|
||||
<div class="video-title">{{ video.title }}</div>
|
||||
</template>
|
||||
<template #label>
|
||||
<div class="video-stats">
|
||||
<span>{{ formatNumber(video.viewCount) }}播放</span>
|
||||
<span>{{ formatNumber(video.likeCount) }}赞</span>
|
||||
<div class="panel-head">
|
||||
<strong>内容列表</strong>
|
||||
<span>{{ videos.length }} 条记录</span>
|
||||
</div>
|
||||
|
||||
<div class="video-list">
|
||||
<article
|
||||
v-for="video in videos"
|
||||
:key="video.id"
|
||||
class="video-item"
|
||||
@click="goToVideo(video.id)"
|
||||
>
|
||||
<div class="video-cover">
|
||||
<van-image
|
||||
:src="getCoverUrl(video.coverUrl)"
|
||||
fit="cover"
|
||||
class="cover-image"
|
||||
/>
|
||||
<span class="duration">{{ formatDuration(video.duration) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="video-body">
|
||||
<div class="video-title-row">
|
||||
<h3>{{ video.title }}</h3>
|
||||
<van-tag :type="getStatusType(video.status)">
|
||||
{{ getStatusText(video.status) }}
|
||||
</van-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #value>
|
||||
<van-tag :type="getStatusType(video.status)">{{ getStatusText(video.status) }}</van-tag>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<van-empty v-else description="暂无视频">
|
||||
<van-button type="primary" round @click="router.push('/upload')">去上传</van-button>
|
||||
</van-empty>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
|
||||
<p class="video-desc">
|
||||
{{ video.description || '暂无简介' }}
|
||||
</p>
|
||||
|
||||
<div class="video-meta">
|
||||
<span>{{ video.categoryName || '未分类' }}</span>
|
||||
<span>{{ formatPlayCount(video.viewCount) }} 播放</span>
|
||||
<span>{{ formatNumber(video.likeCount) }} 点赞</span>
|
||||
<span>{{ formatPublishTime(video.publishTime || video.createTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<van-empty
|
||||
v-else
|
||||
description="暂无视频"
|
||||
>
|
||||
<van-button
|
||||
type="primary"
|
||||
round
|
||||
@click="router.push('/upload')"
|
||||
>
|
||||
去上传
|
||||
</van-button>
|
||||
</van-empty>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import { formatNumber } from '@/utils'
|
||||
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
|
||||
import { resolveMediaUrl } from '@/utils/media'
|
||||
import type { VideoInfo } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const videos = ref<VideoInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const resolvedUserId = ref<number | null>(null)
|
||||
|
||||
const defaultCover =
|
||||
'data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 180\"%3E%3Crect width=\"320\" height=\"180\" fill=\"%23edf2f7\"/%3E%3Ctext x=\"50%25\" y=\"50%25\" dy=\".3em\" text-anchor=\"middle\" fill=\"%239499a0\"%3EHAOVP%3C/text%3E%3C/svg%3E'
|
||||
|
||||
const publishedCount = computed(() => videos.value.filter((video) => video.status === 1).length)
|
||||
const reviewingCount = computed(() => videos.value.filter((video) => video.status === 0).length)
|
||||
const exceptionalCount = computed(() => videos.value.filter((video) => video.status !== 0 && video.status !== 1).length)
|
||||
|
||||
const getCoverUrl = (coverUrl?: string) => resolveMediaUrl(coverUrl) || defaultCover
|
||||
|
||||
const getStatusType = (status: number): 'warning' | 'success' | 'danger' | 'primary' => {
|
||||
const types: Record<number, 'warning' | 'success' | 'danger' | 'primary'> = {
|
||||
0: 'warning',
|
||||
1: 'success',
|
||||
2: 'danger',
|
||||
3: 'primary'
|
||||
2: 'primary',
|
||||
3: 'danger'
|
||||
}
|
||||
return types[status] || 'primary'
|
||||
}
|
||||
@@ -72,76 +141,305 @@ const getStatusText = (status: number) => {
|
||||
const texts: Record<number, string> = {
|
||||
0: '审核中',
|
||||
1: '已发布',
|
||||
2: '审核拒绝',
|
||||
3: '已下架'
|
||||
2: '已下架',
|
||||
3: '审核拒绝'
|
||||
}
|
||||
return texts[status] || '未知'
|
||||
}
|
||||
|
||||
const loadVideos = async (isRefresh = false) => {
|
||||
if (!userStore.userId) return
|
||||
const formatPublishTime = (value?: string) => {
|
||||
if (!value) return '未发布'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
if (isRefresh) {
|
||||
currentPage.value = 1
|
||||
videos.value = []
|
||||
finished.value = false
|
||||
}
|
||||
const loadVideos = async () => {
|
||||
if (!resolvedUserId.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await videoApi.getUserVideos(userStore.userId, { pageNum: currentPage.value, pageSize: 20 })
|
||||
if (isRefresh) {
|
||||
videos.value = res.records
|
||||
} else {
|
||||
videos.value.push(...res.records)
|
||||
}
|
||||
if (videos.value.length >= res.total || res.records.length < 20) {
|
||||
finished.value = true
|
||||
}
|
||||
currentPage.value++
|
||||
} catch (error) {
|
||||
const res = await videoApi.getUserVideos(resolvedUserId.value, { pageNum: 1, pageSize: 50 })
|
||||
videos.value = res.records
|
||||
} catch {
|
||||
showToast('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!finished.value) loadVideos()
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
loadVideos(true)
|
||||
}
|
||||
|
||||
const goToVideo = (id: number) => {
|
||||
router.push(`/video/${id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVideos(true)
|
||||
})
|
||||
const initPage = async () => {
|
||||
try {
|
||||
if (!userStore.authUser) {
|
||||
await userStore.fetchAuthUser()
|
||||
}
|
||||
if (!userStore.userInfo) {
|
||||
await userStore.fetchUserInfo()
|
||||
}
|
||||
resolvedUserId.value = userStore.userId || null
|
||||
if (!resolvedUserId.value) {
|
||||
showToast('未获取到当前用户信息')
|
||||
return
|
||||
}
|
||||
await loadVideos()
|
||||
} catch {
|
||||
showToast('加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(initPage)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.my-videos-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
background: #f4f5f7;
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.videos-shell {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.videos-hero,
|
||||
.videos-panel,
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.videos-hero {
|
||||
padding: 22px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 161, 214, 0.08), rgba(251, 114, 153, 0.08)),
|
||||
#fff;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
color: #18191c;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
span {
|
||||
color: #61666d;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-eyebrow {
|
||||
margin: 0;
|
||||
color: var(--link-color);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #18191c;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.videos-panel {
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-list {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
}
|
||||
|
||||
.video-cover {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #edf2f7;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.duration {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.74);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.video-body {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.video-title-row {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #18191c;
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.video-title-row :deep(.van-tag--success) {
|
||||
background: #e8f8ee;
|
||||
color: #18a058;
|
||||
border-color: #e8f8ee;
|
||||
}
|
||||
|
||||
.video-title-row :deep(.van-tag--warning) {
|
||||
background: #fff5dd;
|
||||
color: #d77a00;
|
||||
border-color: #fff5dd;
|
||||
}
|
||||
|
||||
.video-title-row :deep(.van-tag--danger) {
|
||||
background: #fff0f0;
|
||||
color: #d14343;
|
||||
border-color: #fff0f0;
|
||||
}
|
||||
|
||||
.video-title-row :deep(.van-tag--primary) {
|
||||
background: #edf7ff;
|
||||
color: #007fff;
|
||||
border-color: #edf7ff;
|
||||
}
|
||||
|
||||
.video-desc {
|
||||
margin: 0;
|
||||
color: #61666d;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.video-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.videos-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.videos-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,30 +1,79 @@
|
||||
<template>
|
||||
<div class="settings-subpage">
|
||||
<van-nav-bar title="通知设置" fixed placeholder left-arrow @click-left="router.back()" />
|
||||
<van-cell-group inset>
|
||||
<van-cell title="评论通知">
|
||||
<template #right-icon><van-switch v-model="form.commentNotify" :active-value="1" :inactive-value="0" /></template>
|
||||
</van-cell>
|
||||
<van-cell title="点赞通知">
|
||||
<template #right-icon><van-switch v-model="form.likeNotify" :active-value="1" :inactive-value="0" /></template>
|
||||
</van-cell>
|
||||
<van-cell title="关注通知">
|
||||
<template #right-icon><van-switch v-model="form.followNotify" :active-value="1" :inactive-value="0" /></template>
|
||||
</van-cell>
|
||||
<van-cell title="系统通知">
|
||||
<template #right-icon><van-switch v-model="form.systemNotify" :active-value="1" :inactive-value="0" /></template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<div class="submit">
|
||||
<van-button type="primary" block @click="save">保存设置</van-button>
|
||||
<div class="page-shell subpage-shell">
|
||||
<div class="subpage-head">
|
||||
<button
|
||||
class="back-link"
|
||||
@click="router.back()"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<strong>通知设置</strong>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="status-row">
|
||||
<span>当前开启</span>
|
||||
<strong>{{ enabledSummary }}</strong>
|
||||
</div>
|
||||
|
||||
<van-cell-group inset>
|
||||
<van-cell title="评论通知">
|
||||
<template #right-icon>
|
||||
<van-switch
|
||||
v-model="form.commentNotify"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="点赞通知">
|
||||
<template #right-icon>
|
||||
<van-switch
|
||||
v-model="form.likeNotify"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="关注通知">
|
||||
<template #right-icon>
|
||||
<van-switch
|
||||
v-model="form.followNotify"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="系统通知">
|
||||
<template #right-icon>
|
||||
<van-switch
|
||||
v-model="form.systemNotify"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
class="save-btn"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>
|
||||
保存设置
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showSuccessToast } from 'vant'
|
||||
import { showSuccessToast, showToast } from 'vant'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -34,18 +83,94 @@ const form = reactive({
|
||||
followNotify: 1,
|
||||
systemNotify: 1
|
||||
})
|
||||
const saving = ref(false)
|
||||
const enabledSummary = computed(() => {
|
||||
const enabled: string[] = []
|
||||
if (form.commentNotify) enabled.push('评论')
|
||||
if (form.likeNotify) enabled.push('点赞')
|
||||
if (form.followNotify) enabled.push('关注')
|
||||
if (form.systemNotify) enabled.push('系统')
|
||||
return enabled.length ? enabled.join(' / ') : '全部关闭'
|
||||
})
|
||||
|
||||
const save = async () => {
|
||||
await userApi.updateSettings(form)
|
||||
showSuccessToast('已保存')
|
||||
saving.value = true
|
||||
try {
|
||||
await userApi.updateSettings(form)
|
||||
showSuccessToast('通知设置已保存')
|
||||
} catch (error: any) {
|
||||
showToast(error?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
Object.assign(form, await userApi.getSettings())
|
||||
try {
|
||||
Object.assign(form, await userApi.getSettings())
|
||||
} catch (error: any) {
|
||||
showToast(error?.message || '加载失败')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-subpage { min-height: 100vh; background: #f6f7fb; }
|
||||
.submit { padding: 20px 16px; }
|
||||
.settings-subpage {
|
||||
min-height: 100vh;
|
||||
background: #f4f5f7;
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.subpage-shell {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.subpage-head,
|
||||
.panel-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.subpage-head {
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--link-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
span {
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #18191c;
|
||||
}
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,49 +1,144 @@
|
||||
<template>
|
||||
<div class="settings-subpage">
|
||||
<van-nav-bar title="隐私设置" fixed placeholder left-arrow @click-left="router.back()" />
|
||||
<van-cell-group inset>
|
||||
<van-cell title="主页公开范围">
|
||||
<template #right-icon>
|
||||
<van-dropdown-menu>
|
||||
<van-dropdown-item v-model="form.privacyLevel" :options="privacyOptions" />
|
||||
</van-dropdown-menu>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="个性签名可见" value="公开" />
|
||||
<van-cell title="关注列表可见" value="仅自己可见" />
|
||||
</van-cell-group>
|
||||
<div class="submit">
|
||||
<van-button type="primary" block @click="save">保存设置</van-button>
|
||||
<div class="page-shell subpage-shell">
|
||||
<div class="subpage-head">
|
||||
<button
|
||||
class="back-link"
|
||||
@click="router.back()"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<strong>隐私设置</strong>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="status-row">
|
||||
<span>当前可见范围</span>
|
||||
<strong>{{ currentPrivacyLabel }}</strong>
|
||||
</div>
|
||||
|
||||
<van-cell-group inset>
|
||||
<van-cell title="主页公开范围">
|
||||
<template #right-icon>
|
||||
<van-dropdown-menu>
|
||||
<van-dropdown-item
|
||||
v-model="form.privacyLevel"
|
||||
:options="privacyOptions"
|
||||
/>
|
||||
</van-dropdown-menu>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
class="save-btn"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>
|
||||
保存设置
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showSuccessToast } from 'vant'
|
||||
import { showSuccessToast, showToast } from 'vant'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
|
||||
const router = useRouter()
|
||||
const form = reactive({ privacyLevel: 1 })
|
||||
const saving = ref(false)
|
||||
const privacyOptions = [
|
||||
{ text: '公开', value: 1 },
|
||||
{ text: '仅关注可见', value: 2 },
|
||||
{ text: '仅自己可见', value: 3 }
|
||||
]
|
||||
const currentPrivacyLabel = computed(() => privacyOptions.find((item) => item.value === form.privacyLevel)?.text || '未设置')
|
||||
|
||||
const save = async () => {
|
||||
await userApi.updateSettings({ privacyLevel: form.privacyLevel })
|
||||
showSuccessToast('已保存')
|
||||
saving.value = true
|
||||
try {
|
||||
await userApi.updateSettings({ privacyLevel: form.privacyLevel })
|
||||
showSuccessToast('隐私设置已保存')
|
||||
} catch (error: any) {
|
||||
showToast(error?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const settings = await userApi.getSettings()
|
||||
form.privacyLevel = settings.privacyLevel
|
||||
try {
|
||||
const settings = await userApi.getSettings()
|
||||
form.privacyLevel = settings.privacyLevel
|
||||
} catch (error: any) {
|
||||
showToast(error?.message || '加载失败')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-subpage { min-height: 100vh; background: #f6f7fb; }
|
||||
.submit { padding: 20px 16px; }
|
||||
.settings-subpage {
|
||||
min-height: 100vh;
|
||||
background: #f4f5f7;
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.subpage-shell {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.subpage-head,
|
||||
.panel-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.subpage-head {
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--link-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
span {
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #18191c;
|
||||
}
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,39 +1,119 @@
|
||||
<template>
|
||||
<div class="profile-edit-page">
|
||||
<van-nav-bar title="编辑资料" fixed placeholder left-arrow @click-left="router.back()">
|
||||
<template #right>
|
||||
<van-button type="primary" size="small" :loading="saving" @click="handleSave">保存</van-button>
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
<div class="page-shell edit-shell">
|
||||
<div class="page-head">
|
||||
<button
|
||||
class="back-link"
|
||||
@click="router.back()"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<strong>编辑资料</strong>
|
||||
<van-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
保存
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<van-form @submit="handleSave">
|
||||
<van-cell-group inset>
|
||||
<van-cell title="头像" is-link @click="chooseAvatar">
|
||||
<template #value>
|
||||
<van-image :src="form.avatar || defaultAvatar" round width="48" height="48" fit="cover" />
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-field v-model="form.nickname" name="nickname" label="昵称" placeholder="请输入昵称" />
|
||||
<van-field v-model="form.signature" name="signature" label="签名" type="textarea" rows="2" autosize placeholder="介绍一下自己" maxlength="100" show-word-limit />
|
||||
<van-field name="gender" label="性别">
|
||||
<template #input>
|
||||
<van-radio-group v-model="form.gender" direction="horizontal">
|
||||
<van-radio :value="0">保密</van-radio>
|
||||
<van-radio :value="1">男</van-radio>
|
||||
<van-radio :value="2">女</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field v-model="form.birthday" name="birthday" label="生日" is-link readonly placeholder="选择生日" @click="showBirthdayPicker = true" />
|
||||
<van-field v-model="form.region" name="region" label="地区" placeholder="请输入地区" />
|
||||
</van-cell-group>
|
||||
</van-form>
|
||||
<div class="panel-card">
|
||||
<van-form @submit="handleSave">
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
title="头像"
|
||||
is-link
|
||||
@click="chooseAvatar"
|
||||
>
|
||||
<template #value>
|
||||
<van-image
|
||||
:src="getAvatarUrl(form.avatar)"
|
||||
round
|
||||
width="48"
|
||||
height="48"
|
||||
fit="cover"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-field
|
||||
v-model="form.nickname"
|
||||
name="nickname"
|
||||
label="昵称"
|
||||
placeholder="请输入昵称"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.signature"
|
||||
name="signature"
|
||||
label="签名"
|
||||
type="textarea"
|
||||
rows="2"
|
||||
autosize
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
<van-field
|
||||
name="gender"
|
||||
label="性别"
|
||||
>
|
||||
<template #input>
|
||||
<van-radio-group
|
||||
v-model="form.gender"
|
||||
direction="horizontal"
|
||||
>
|
||||
<van-radio :value="0">
|
||||
保密
|
||||
</van-radio>
|
||||
<van-radio :value="1">
|
||||
男
|
||||
</van-radio>
|
||||
<van-radio :value="2">
|
||||
女
|
||||
</van-radio>
|
||||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-model="form.birthday"
|
||||
name="birthday"
|
||||
label="生日"
|
||||
is-link
|
||||
readonly
|
||||
placeholder="选择生日"
|
||||
@click="showBirthdayPicker = true"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.region"
|
||||
name="region"
|
||||
label="地区"
|
||||
placeholder="请输入地区"
|
||||
/>
|
||||
</van-cell-group>
|
||||
</van-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-popup v-model:show="showBirthdayPicker" position="bottom" round>
|
||||
<van-date-picker v-model="selectedDate" title="选择生日" @confirm="onBirthdayConfirm" @cancel="showBirthdayPicker = false" />
|
||||
<van-popup
|
||||
v-model:show="showBirthdayPicker"
|
||||
position="bottom"
|
||||
round
|
||||
>
|
||||
<van-date-picker
|
||||
v-model="selectedDate"
|
||||
title="选择生日"
|
||||
@confirm="onBirthdayConfirm"
|
||||
@cancel="showBirthdayPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
|
||||
<input ref="avatarInput" type="file" accept="image/*" style="display: none" @change="onAvatarChange" />
|
||||
<input
|
||||
ref="avatarInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="onAvatarChange"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -42,15 +122,16 @@ import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showSuccessToast } from 'vant'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
import { resolveAvatarUrl } from '@/utils/media'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const saving = ref(false)
|
||||
const showBirthdayPicker = ref(false)
|
||||
const avatarInput = ref<HTMLInputElement>()
|
||||
const selectedDate = ref(['2000', '01', '01'])
|
||||
|
||||
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
|
||||
const defaultAvatar =
|
||||
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
|
||||
const getAvatarUrl = (avatar?: string) => resolveAvatarUrl(avatar, defaultAvatar)
|
||||
|
||||
const form = reactive({
|
||||
avatar: '',
|
||||
@@ -70,17 +151,13 @@ const loadProfile = async () => {
|
||||
form.gender = profile.gender || 0
|
||||
form.birthday = profile.birthday || ''
|
||||
form.region = profile.region || ''
|
||||
if (form.birthday) {
|
||||
selectedDate.value = form.birthday.split('-')
|
||||
}
|
||||
} catch (error) {
|
||||
if (form.birthday) selectedDate.value = form.birthday.split('-')
|
||||
} catch {
|
||||
showToast('加载资料失败')
|
||||
}
|
||||
}
|
||||
|
||||
const chooseAvatar = () => {
|
||||
avatarInput.value?.click()
|
||||
}
|
||||
const chooseAvatar = () => avatarInput.value?.click()
|
||||
|
||||
const onAvatarChange = async (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
@@ -90,7 +167,7 @@ const onAvatarChange = async (e: Event) => {
|
||||
const url = await userApi.uploadAvatar(file)
|
||||
form.avatar = url
|
||||
showSuccessToast('头像上传成功')
|
||||
} catch (error) {
|
||||
} catch {
|
||||
showToast('头像上传失败')
|
||||
}
|
||||
}
|
||||
@@ -105,7 +182,6 @@ const handleSave = async () => {
|
||||
showToast('请输入昵称')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await userApi.updateProfile({
|
||||
@@ -115,36 +191,55 @@ const handleSave = async () => {
|
||||
birthday: form.birthday,
|
||||
region: form.region
|
||||
})
|
||||
if (form.avatar) {
|
||||
// 头像已单独上传
|
||||
}
|
||||
showSuccessToast('保存成功')
|
||||
router.back()
|
||||
} catch (error) {
|
||||
} catch {
|
||||
showToast('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile()
|
||||
})
|
||||
onMounted(loadProfile)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.profile-edit-page {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(59, 130, 246, 0.08), transparent 180px),
|
||||
#f4f7fb;
|
||||
padding-bottom: 60px;
|
||||
background: #f4f5f7;
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.van-cell-group {
|
||||
margin: 12px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
|
||||
.edit-shell {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.page-head,
|
||||
.panel-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.page-head {
|
||||
padding: 14px 18px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--link-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
padding: 18px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,65 +1,305 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<div class="profile-shell">
|
||||
<div v-if="loading" class="loading-container">
|
||||
<van-loading size="24px">加载中...</van-loading>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="loading-container"
|
||||
>
|
||||
<van-loading size="24px">
|
||||
加载中...
|
||||
</van-loading>
|
||||
</div>
|
||||
|
||||
<template v-else-if="userInfo">
|
||||
<div
|
||||
v-if="notificationBackVisible"
|
||||
class="return-banner"
|
||||
>
|
||||
<div>
|
||||
<strong>来自通知跳转</strong>
|
||||
<span>这个用户和一条通知相关,你可以返回通知列表继续处理。</span>
|
||||
</div>
|
||||
<van-button
|
||||
size="small"
|
||||
round
|
||||
plain
|
||||
@click="goBackToNotifications"
|
||||
>
|
||||
返回通知
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<div class="profile-banner">
|
||||
<div class="banner-overlay"></div>
|
||||
<div class="banner-overlay" />
|
||||
<div class="banner-content">
|
||||
<p class="eyebrow">
|
||||
Creator Space
|
||||
</p>
|
||||
<strong>{{ userInfo.nickname }}</strong>
|
||||
<span>{{ isSelf ? '管理你的公开形象与内容输出' : '浏览这个创作者的公开空间' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-header">
|
||||
<div class="user-info">
|
||||
<van-image :src="userInfo.avatar || defaultAvatar" round width="104" height="104" />
|
||||
<van-image
|
||||
:src="getAvatarUrl(userInfo.avatar)"
|
||||
round
|
||||
width="104"
|
||||
height="104"
|
||||
/>
|
||||
<div class="info">
|
||||
<h2 class="nickname">{{ userInfo.nickname }}</h2>
|
||||
<h2 class="nickname">
|
||||
{{ userInfo.nickname }}
|
||||
</h2>
|
||||
<div class="meta-row">
|
||||
<div class="level">Lv.{{ userInfo.level }}</div>
|
||||
<div class="region">{{ userInfo.region }}</div>
|
||||
<div class="level">
|
||||
Lv.{{ userInfo.level }}
|
||||
</div>
|
||||
<div
|
||||
v-if="vipLabel"
|
||||
class="vip"
|
||||
>
|
||||
{{ vipLabel }}
|
||||
</div>
|
||||
<div class="gender">
|
||||
{{ genderLabel }}
|
||||
</div>
|
||||
<div class="region">
|
||||
{{ userInfo.region || '未设置地区' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="signature">
|
||||
{{ userInfo.signature || '这个人很懒,什么都没写' }}
|
||||
</div>
|
||||
<div class="signature">{{ userInfo.signature || '这个人很懒,什么都没写' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item" @click="goToUserVideos">
|
||||
<div class="value">{{ userInfo.videoCount }}</div>
|
||||
<div class="label">视频</div>
|
||||
<div
|
||||
class="stat-item"
|
||||
@click="goToUserVideos"
|
||||
>
|
||||
<div class="value">
|
||||
{{ userInfo.videoCount }}
|
||||
</div>
|
||||
<div class="label">
|
||||
视频
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item" @click="goToFollowers">
|
||||
<div class="value">{{ userInfo.followerCount }}</div>
|
||||
<div class="label">粉丝</div>
|
||||
<div
|
||||
class="stat-item"
|
||||
@click="goToFollowers"
|
||||
>
|
||||
<div class="value">
|
||||
{{ userInfo.followerCount }}
|
||||
</div>
|
||||
<div class="label">
|
||||
粉丝
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item" @click="goToFollowing">
|
||||
<div class="value">{{ userInfo.followingCount }}</div>
|
||||
<div class="label">关注</div>
|
||||
<div
|
||||
class="stat-item"
|
||||
@click="goToFollowing"
|
||||
>
|
||||
<div class="value">
|
||||
{{ userInfo.followingCount }}
|
||||
</div>
|
||||
<div class="label">
|
||||
关注
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="value">{{ userInfo.likeCount }}</div>
|
||||
<div class="label">获赞</div>
|
||||
<div class="value">
|
||||
{{ userInfo.likeCount }}
|
||||
</div>
|
||||
<div class="label">
|
||||
获赞
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<van-button v-if="!isSelf" :type="isFollowed ? 'default' : 'primary'" size="small" round @click="toggleFollow">
|
||||
<van-button
|
||||
v-if="!isSelf"
|
||||
:type="isFollowed ? 'default' : 'primary'"
|
||||
size="small"
|
||||
round
|
||||
@click="toggleFollow"
|
||||
>
|
||||
{{ isFollowed ? '已关注' : '关注' }}
|
||||
</van-button>
|
||||
<van-button v-else type="primary" size="small" round @click="router.push('/settings/profile')">
|
||||
<van-button
|
||||
v-if="!isSelf"
|
||||
plain
|
||||
size="small"
|
||||
round
|
||||
@click="startConversation"
|
||||
>
|
||||
发私信
|
||||
</van-button>
|
||||
<van-button
|
||||
v-else
|
||||
type="primary"
|
||||
size="small"
|
||||
round
|
||||
@click="router.push('/settings/profile')"
|
||||
>
|
||||
编辑资料
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="isSelf"
|
||||
plain
|
||||
size="small"
|
||||
round
|
||||
@click="router.push('/settings')"
|
||||
>
|
||||
账号设置
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="isSelf"
|
||||
plain
|
||||
size="small"
|
||||
round
|
||||
@click="router.push('/about')"
|
||||
>
|
||||
关于项目
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-tabs v-model:active="activeTab" sticky class="profile-tabs">
|
||||
<van-tab title="视频" name="videos">
|
||||
<div class="video-grid">
|
||||
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
|
||||
<div class="profile-overview">
|
||||
<section class="overview-card">
|
||||
<div class="overview-head">
|
||||
<h3>资料概览</h3>
|
||||
<span>{{ isSelf ? '当前账号' : '公开资料' }}</span>
|
||||
</div>
|
||||
<div class="overview-grid">
|
||||
<div class="overview-item">
|
||||
<span>注册时间</span>
|
||||
<strong>{{ joinDate }}</strong>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<span>地区</span>
|
||||
<strong>{{ userInfo.region || '未设置' }}</strong>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<span>性别</span>
|
||||
<strong>{{ genderLabel }}</strong>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<span>账号状态</span>
|
||||
<strong>{{ vipLabel || '普通用户' }}</strong>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<span>生日</span>
|
||||
<strong>{{ birthdayLabel }}</strong>
|
||||
</div>
|
||||
<div class="overview-item">
|
||||
<span>互动状态</span>
|
||||
<strong>{{ interactionLabel }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview-card">
|
||||
<div class="overview-head">
|
||||
<h3>{{ isSelf ? '主页快捷入口' : '互动操作' }}</h3>
|
||||
<span>{{ isSelf ? '常用页面直达' : '继续浏览这个用户' }}</span>
|
||||
</div>
|
||||
<div class="quick-action-grid">
|
||||
<button
|
||||
class="quick-action"
|
||||
@click="goToUserVideos"
|
||||
>
|
||||
<strong>{{ userInfo.videoCount }}</strong>
|
||||
<span>查看视频</span>
|
||||
</button>
|
||||
<button
|
||||
class="quick-action"
|
||||
@click="goToFollowers"
|
||||
>
|
||||
<strong>{{ userInfo.followerCount }}</strong>
|
||||
<span>查看粉丝</span>
|
||||
</button>
|
||||
<button
|
||||
class="quick-action"
|
||||
@click="goToFollowing"
|
||||
>
|
||||
<strong>{{ userInfo.followingCount }}</strong>
|
||||
<span>查看关注</span>
|
||||
</button>
|
||||
<button
|
||||
class="quick-action"
|
||||
@click="activeTab = 'videos'"
|
||||
>
|
||||
<strong>{{ videos.length }}</strong>
|
||||
<span>当前页视频</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<van-tabs
|
||||
v-model:active="activeTab"
|
||||
sticky
|
||||
class="profile-tabs"
|
||||
>
|
||||
<van-tab
|
||||
title="视频"
|
||||
name="videos"
|
||||
>
|
||||
<div class="video-grid">
|
||||
<VideoCard
|
||||
v-for="video in videos"
|
||||
:key="video.id"
|
||||
:video="video"
|
||||
@click="goToVideo(video.id)"
|
||||
/>
|
||||
</div>
|
||||
<van-empty
|
||||
v-if="!videos.length"
|
||||
description="还没有公开视频"
|
||||
/>
|
||||
</van-tab>
|
||||
<van-tab title="收藏" name="favorites">
|
||||
<van-empty description="暂无收藏" />
|
||||
<van-tab
|
||||
title="收藏"
|
||||
name="favorites"
|
||||
>
|
||||
<div
|
||||
v-if="isSelf && favorites.length"
|
||||
class="favorite-grid"
|
||||
>
|
||||
<button
|
||||
v-for="favorite in favorites"
|
||||
:key="favorite.id"
|
||||
class="favorite-card"
|
||||
@click="goToFavorite(favorite.id)"
|
||||
>
|
||||
<div class="favorite-top">
|
||||
<h4>{{ favorite.name }}</h4>
|
||||
<van-tag
|
||||
plain
|
||||
type="primary"
|
||||
>
|
||||
{{ favorite.isPrivate ? '私密' : '公开' }}
|
||||
</van-tag>
|
||||
</div>
|
||||
<p>{{ favorite.description || '这个收藏夹还没有补充说明。' }}</p>
|
||||
<div class="favorite-meta">
|
||||
<span>{{ favorite.videoCount }} 个视频</span>
|
||||
<span>{{ formatDate(favorite.createTime) }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<van-empty
|
||||
v-else-if="isSelf"
|
||||
description="还没有收藏夹"
|
||||
/>
|
||||
<van-empty
|
||||
v-else
|
||||
description="仅自己可查看收藏夹"
|
||||
/>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</template>
|
||||
@@ -74,7 +314,9 @@ import { showToast, showSuccessToast } from 'vant'
|
||||
import VideoCard from '@/components/video/VideoCard.vue'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
import { favoriteApi, type Favorite } from '@/api/favorite.ts'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import { resolveAvatarUrl } from '@/utils/media'
|
||||
import type { UserInfo, VideoInfo } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -89,8 +331,34 @@ const userInfo = ref<UserInfo | null>(null)
|
||||
const isFollowed = ref(false)
|
||||
const activeTab = ref('videos')
|
||||
const videos = ref<VideoInfo[]>([])
|
||||
const favorites = ref<Favorite[]>([])
|
||||
const notificationBackVisible = computed(() => route.query.from === 'notifications')
|
||||
|
||||
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
|
||||
const getAvatarUrl = (avatar?: string) => resolveAvatarUrl(avatar, defaultAvatar)
|
||||
const genderLabel = computed(() => {
|
||||
if (userInfo.value?.gender === 1) return '男'
|
||||
if (userInfo.value?.gender === 2) return '女'
|
||||
return '保密'
|
||||
})
|
||||
const vipLabel = computed(() => (userInfo.value?.vipLevel ? `VIP ${userInfo.value.vipLevel}` : ''))
|
||||
const joinDate = computed(() => formatDate(userInfo.value?.createTime))
|
||||
const birthdayLabel = computed(() => formatDate(userInfo.value?.birthday))
|
||||
const interactionLabel = computed(() => {
|
||||
if (isSelf.value) return '当前登录账号'
|
||||
return isFollowed.value ? '已关注,可继续私信交流' : '尚未关注'
|
||||
})
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return '未记录'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const loadUserInfo = async () => {
|
||||
try {
|
||||
@@ -105,13 +373,29 @@ const loadUserInfo = async () => {
|
||||
|
||||
const loadUserVideos = async () => {
|
||||
try {
|
||||
const res = await videoApi.getUserVideos(userId.value, { pageNum: 1, pageSize: 20 })
|
||||
const res = await videoApi.getVideoPublicList({
|
||||
pageNum: 1,
|
||||
pageSize: 20,
|
||||
userId: userId.value
|
||||
})
|
||||
videos.value = res.records
|
||||
} catch {
|
||||
showToast('加载视频失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadFavorites = async () => {
|
||||
if (!isSelf.value) {
|
||||
favorites.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
favorites.value = await favoriteApi.getFavoriteList()
|
||||
} catch {
|
||||
showToast('加载收藏夹失败')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFollow = async () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
showToast('请先登录')
|
||||
@@ -121,10 +405,16 @@ const toggleFollow = async () => {
|
||||
if (isFollowed.value) {
|
||||
await userApi.unfollow(userId.value)
|
||||
isFollowed.value = false
|
||||
if (userInfo.value) {
|
||||
userInfo.value.followerCount = Math.max(0, userInfo.value.followerCount - 1)
|
||||
}
|
||||
showSuccessToast('已取消关注')
|
||||
} else {
|
||||
await userApi.follow(userId.value)
|
||||
isFollowed.value = true
|
||||
if (userInfo.value) {
|
||||
userInfo.value.followerCount += 1
|
||||
}
|
||||
showSuccessToast('关注成功')
|
||||
}
|
||||
} catch {
|
||||
@@ -148,27 +438,52 @@ const goToUserVideos = () => {
|
||||
router.push(`/user/${userId.value}/videos`)
|
||||
}
|
||||
|
||||
const goToFavorite = (id: number) => {
|
||||
router.push(`/my/favorites/${id}`)
|
||||
}
|
||||
|
||||
const startConversation = () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
showToast('请先登录')
|
||||
return
|
||||
}
|
||||
router.push(`/messages/${userId.value}`)
|
||||
}
|
||||
|
||||
const goBackToNotifications = () => {
|
||||
router.push({
|
||||
path: '/notifications',
|
||||
query: route.query.tab ? { tab: String(route.query.tab) } : undefined
|
||||
})
|
||||
}
|
||||
|
||||
const initPage = async () => {
|
||||
loading.value = true
|
||||
await Promise.all([loadUserInfo(), loadUserVideos()])
|
||||
favorites.value = []
|
||||
await Promise.all([loadUserInfo(), loadUserVideos(), activeTab.value === 'favorites' ? loadFavorites() : Promise.resolve()])
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(initPage)
|
||||
watch(() => route.params.id, initPage)
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'favorites' && isSelf.value && !favorites.value.length) {
|
||||
loadFavorites()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.profile-page {
|
||||
min-height: 100vh;
|
||||
background: #f6f7fb;
|
||||
background: #f4f5f7;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.profile-shell {
|
||||
max-width: 1440px;
|
||||
max-width: 1380px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 12px 28px;
|
||||
padding: 18px 12px 28px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
@@ -177,13 +492,38 @@ watch(() => route.params.id, initPage)
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.return-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
background: #fff;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 16px;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #18191c;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #61666d;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-banner {
|
||||
height: 220px;
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(135deg, #60a5fa, #8b5cf6 52%, #fb7185);
|
||||
height: 200px;
|
||||
border-radius: 10px;
|
||||
background:
|
||||
radial-gradient(circle at right top, rgba(255, 255, 255, 0.24), transparent 22%),
|
||||
linear-gradient(120deg, #52b6e8, #79d0f6 45%, #fb9dbe);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 52px rgba(99, 102, 241, 0.18);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.banner-overlay {
|
||||
@@ -194,14 +534,41 @@ watch(() => route.params.id, initPage)
|
||||
radial-gradient(circle at left bottom, rgba(255, 255, 255, 0.18), transparent 28%);
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
position: absolute;
|
||||
left: 28px;
|
||||
bottom: 24px;
|
||||
z-index: 1;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
|
||||
strong {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.user-header {
|
||||
background: #fff;
|
||||
padding: 24px 26px;
|
||||
border-radius: 28px;
|
||||
margin-top: -48px;
|
||||
padding: 22px 24px;
|
||||
border-radius: 10px;
|
||||
margin-top: -42px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
@@ -216,7 +583,7 @@ watch(() => route.params.id, initPage)
|
||||
}
|
||||
|
||||
.nickname {
|
||||
font-size: 30px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
@@ -224,6 +591,7 @@ watch(() => route.params.id, initPage)
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -231,12 +599,22 @@ watch(() => route.params.id, initPage)
|
||||
.level {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: linear-gradient(to right, #fb7299, #ff8fab);
|
||||
background: linear-gradient(to right, #fb7299, #ff9abb);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.vip {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: #e5f6fd;
|
||||
color: #00a1d6;
|
||||
font-size: 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.gender,
|
||||
.region {
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
@@ -261,11 +639,11 @@ watch(() => route.params.id, initPage)
|
||||
cursor: pointer;
|
||||
min-width: 64px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 18px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f8fafc;
|
||||
background: #f6f7f8;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,17 +660,115 @@ watch(() => route.params.id, initPage)
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.profile-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 22px 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.overview-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #9499a0;
|
||||
}
|
||||
}
|
||||
|
||||
.overview-grid,
|
||||
.quick-action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overview-item,
|
||||
.quick-action {
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: #f6f7f8;
|
||||
}
|
||||
|
||||
.overview-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #9499a0;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 15px;
|
||||
color: #18191c;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-action {
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
margin-bottom: 4px;
|
||||
color: #18191c;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #61666d;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
margin-top: 18px;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
:deep(.van-tabs__wrap) {
|
||||
border-bottom: 1px solid #f1f2f3;
|
||||
}
|
||||
|
||||
:deep(.van-tab--active) {
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
:deep(.van-tabs__line) {
|
||||
background: var(--link-color);
|
||||
}
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
@@ -302,20 +778,80 @@ watch(() => route.params.id, initPage)
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.favorite-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.favorite-card {
|
||||
border: none;
|
||||
background: #f6f7f8;
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
min-height: 40px;
|
||||
margin: 12px 0 14px;
|
||||
color: #61666d;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-top,
|
||||
.favorite-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.favorite-meta {
|
||||
font-size: 12px;
|
||||
color: #9499a0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-shell {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.return-banner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.profile-banner {
|
||||
height: 160px;
|
||||
border-radius: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
left: 18px;
|
||||
bottom: 18px;
|
||||
|
||||
strong {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-header {
|
||||
margin-top: -28px;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
@@ -330,9 +866,23 @@ watch(() => route.params.id, initPage)
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overview-grid,
|
||||
.quick-action-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.favorite-grid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,50 +1,203 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="settings-shell">
|
||||
<section class="settings-hero">
|
||||
<h2>账号设置</h2>
|
||||
<p>管理个人资料、隐私选项、通知偏好和应用信息。</p>
|
||||
</section>
|
||||
<div class="page-shell settings-shell">
|
||||
<div class="page-head">
|
||||
<div class="head-main">
|
||||
<strong>账号设置</strong>
|
||||
<span>{{ accountInfo.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<div class="settings-column">
|
||||
<van-cell-group inset>
|
||||
<van-cell title="账号与安全" is-link to="/settings/profile" />
|
||||
<van-cell title="隐私设置" is-link to="/settings/privacy" />
|
||||
<van-cell title="通知设置" is-link to="/settings/notification" />
|
||||
</van-cell-group>
|
||||
<div class="panel-card profile-card">
|
||||
<div class="profile-top">
|
||||
<div class="profile-copy">
|
||||
<strong>{{ accountInfo.nickname }}</strong>
|
||||
<span>{{ accountInfo.region }}</span>
|
||||
</div>
|
||||
<div class="profile-tags">
|
||||
<span>Lv.{{ accountInfo.level }}</span>
|
||||
<span>{{ accountInfo.vipLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-list">
|
||||
<div class="status-item">
|
||||
<span>公开范围</span>
|
||||
<strong>{{ privacyLabel }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>通知偏好</span>
|
||||
<strong>{{ notificationSummary }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>未读消息</span>
|
||||
<strong>{{ unreadSummary }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>资料完整度</span>
|
||||
<strong>{{ profileCompleteness }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-column">
|
||||
<van-cell-group inset>
|
||||
<van-cell title="清除缓存" :value="cacheSize" @click="clearCache" />
|
||||
<van-cell title="关于我们" is-link to="/about" />
|
||||
<van-cell title="版本号" value="1.0.0" />
|
||||
</van-cell-group>
|
||||
<div class="panel-card menu-card">
|
||||
<button
|
||||
class="menu-item"
|
||||
@click="router.push('/settings/profile')"
|
||||
>
|
||||
<span>编辑资料</span>
|
||||
<small>头像、昵称、签名</small>
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
@click="router.push('/settings/privacy')"
|
||||
>
|
||||
<span>隐私设置</span>
|
||||
<small>{{ privacyLabel }}</small>
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
@click="router.push('/settings/notification')"
|
||||
>
|
||||
<span>通知设置</span>
|
||||
<small>{{ notificationSummary }}</small>
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
@click="router.push('/about')"
|
||||
>
|
||||
<span>关于项目</span>
|
||||
<small>查看项目信息</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="logout-btn">
|
||||
<van-button type="danger" block @click="handleLogout">退出登录</van-button>
|
||||
<div class="panel-card runtime-card">
|
||||
<div class="status-list">
|
||||
<div class="status-item">
|
||||
<span>最近登录</span>
|
||||
<strong>{{ accountInfo.lastLoginTime }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>登录 IP</span>
|
||||
<strong>{{ accountInfo.lastLoginIp }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>缓存占用</span>
|
||||
<strong>{{ cacheSize }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>API 地址</span>
|
||||
<strong>{{ apiTarget }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="runtime-actions">
|
||||
<button
|
||||
class="text-link"
|
||||
@click="refreshStats"
|
||||
>
|
||||
刷新状态
|
||||
</button>
|
||||
<button
|
||||
class="text-link"
|
||||
@click="clearCache"
|
||||
>
|
||||
清除缓存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-button
|
||||
type="danger"
|
||||
block
|
||||
class="logout-btn"
|
||||
@click="handleLogout"
|
||||
>
|
||||
退出登录
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showConfirmDialog, showToast, showSuccessToast } from 'vant'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import { useMessageStore } from '@/stores/message'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const messageStore = useMessageStore()
|
||||
const cacheSize = ref('0KB')
|
||||
const settings = ref({
|
||||
privacyLevel: 1,
|
||||
commentNotify: 1,
|
||||
likeNotify: 1,
|
||||
followNotify: 1,
|
||||
systemNotify: 1
|
||||
})
|
||||
const apiTarget = import.meta.env.VITE_PROXY_TARGET || window.location.origin
|
||||
|
||||
const accountInfo = computed(() => ({
|
||||
username: userStore.authUser?.username || '-',
|
||||
nickname: userStore.userInfo?.nickname || '-',
|
||||
region: userStore.userInfo?.region || '未设置地区',
|
||||
level: userStore.userInfo?.level || 1,
|
||||
vipLabel: userStore.userInfo?.vipLevel ? `VIP ${userStore.userInfo.vipLevel}` : '普通用户',
|
||||
lastLoginTime: userStore.authUser?.lastLoginTime || '未记录',
|
||||
lastLoginIp: userStore.authUser?.lastLoginIp || '未记录'
|
||||
}))
|
||||
|
||||
const privacyLabel = computed(() => ({ 1: '公开', 2: '仅关注可见', 3: '仅自己可见' }[settings.value.privacyLevel] || '未设置'))
|
||||
const notificationSummary = computed(() => {
|
||||
const enabled: string[] = []
|
||||
if (settings.value.commentNotify) enabled.push('评论')
|
||||
if (settings.value.likeNotify) enabled.push('点赞')
|
||||
if (settings.value.followNotify) enabled.push('关注')
|
||||
if (settings.value.systemNotify) enabled.push('系统')
|
||||
return enabled.length ? enabled.join(' / ') : '全部关闭'
|
||||
})
|
||||
const unreadSummary = computed(() => `私信 ${messageStore.unreadMessageCount} · 通知 ${messageStore.unreadNotificationCount}`)
|
||||
const profileCompleteness = computed(() => {
|
||||
const fields = [
|
||||
!!userStore.userInfo?.nickname,
|
||||
!!userStore.userInfo?.avatar,
|
||||
!!userStore.userInfo?.signature,
|
||||
!!userStore.userInfo?.region,
|
||||
!!userStore.userInfo?.birthday
|
||||
]
|
||||
return `${Math.round((fields.filter(Boolean).length / fields.length) * 100)}%`
|
||||
})
|
||||
|
||||
const calcStorageSize = () => {
|
||||
const total = [localStorage, sessionStorage].reduce((sum, storage) => {
|
||||
return sum + Object.keys(storage).reduce((inner, key) => inner + key.length + (storage.getItem(key)?.length || 0), 0)
|
||||
}, 0)
|
||||
if (total < 1024) return `${total} B`
|
||||
if (total < 1024 * 1024) return `${(total / 1024).toFixed(1)} KB`
|
||||
return `${(total / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const refreshStats = async () => {
|
||||
try {
|
||||
if (!userStore.authUser) await userStore.fetchAuthUser()
|
||||
await userStore.fetchUserInfo()
|
||||
settings.value = await userApi.getSettings()
|
||||
await messageStore.fetchAllUnreadCount()
|
||||
cacheSize.value = calcStorageSize()
|
||||
} catch {
|
||||
// keep current data
|
||||
}
|
||||
}
|
||||
|
||||
const clearCache = () => {
|
||||
showToast('缓存已清除')
|
||||
cacheSize.value = '0KB'
|
||||
sessionStorage.clear()
|
||||
cacheSize.value = calcStorageSize()
|
||||
showToast('会话缓存已清除')
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
@@ -57,60 +210,159 @@ const handleLogout = async () => {
|
||||
showSuccessToast('已退出登录')
|
||||
router.push('/')
|
||||
} catch {
|
||||
// 取消
|
||||
// cancel
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshStats)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
min-height: 100vh;
|
||||
background: #f6f7fb;
|
||||
padding-bottom: 60px;
|
||||
background: #f4f5f7;
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.settings-shell {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 12px 28px;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.settings-hero {
|
||||
padding: 24px 26px;
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
color: #fff;
|
||||
margin-bottom: 18px;
|
||||
.page-head,
|
||||
.panel-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
.page-head {
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.head-main {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
span {
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-column :deep(.van-cell-group) {
|
||||
margin: 0;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
|
||||
.panel-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.profile-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.profile-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #61666d;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f6f7f8;
|
||||
font-size: 12px;
|
||||
color: #61666d;
|
||||
}
|
||||
}
|
||||
|
||||
.status-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
background: #f6f7f8;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #9499a0;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #18191c;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: #f6f7f8;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
span {
|
||||
font-size: 15px;
|
||||
color: #18191c;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #9499a0;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.runtime-actions {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: var(--link-color);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 1100px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -1,97 +1,351 @@
|
||||
<template>
|
||||
<div class="user-videos-page">
|
||||
<van-nav-bar title="用户视频" fixed placeholder left-arrow @click-left="router.back()" />
|
||||
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<div class="video-grid">
|
||||
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
|
||||
<div class="page-shell videos-shell">
|
||||
<section class="videos-hero">
|
||||
<div class="hero-copy">
|
||||
<p class="hero-eyebrow">
|
||||
Public Videos
|
||||
</p>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
<span>{{ profileSummary }}</span>
|
||||
</div>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</section>
|
||||
|
||||
<section class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span>公开视频</span>
|
||||
<strong>{{ videos.length }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>总播放</span>
|
||||
<strong>{{ totalViews }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>总点赞</span>
|
||||
<strong>{{ totalLikes }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="videos.length"
|
||||
class="videos-panel"
|
||||
>
|
||||
<div class="panel-head">
|
||||
<strong>内容列表</strong>
|
||||
<span>{{ videos.length }} 条记录</span>
|
||||
</div>
|
||||
|
||||
<div class="video-list">
|
||||
<article
|
||||
v-for="video in videos"
|
||||
:key="video.id"
|
||||
class="video-item"
|
||||
@click="goToVideo(video.id)"
|
||||
>
|
||||
<div class="video-cover">
|
||||
<van-image
|
||||
:src="getCoverUrl(video.coverUrl)"
|
||||
fit="cover"
|
||||
class="cover-image"
|
||||
/>
|
||||
<span class="duration">{{ formatDuration(video.duration) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="video-body">
|
||||
<div class="video-title-row">
|
||||
<h3>{{ video.title }}</h3>
|
||||
<van-tag type="success">
|
||||
已公开
|
||||
</van-tag>
|
||||
</div>
|
||||
|
||||
<p class="video-desc">
|
||||
{{ video.description || '暂无简介' }}
|
||||
</p>
|
||||
|
||||
<div class="video-meta">
|
||||
<span>{{ video.categoryName || '未分类' }}</span>
|
||||
<span>{{ formatPlayCount(video.viewCount) }} 播放</span>
|
||||
<span>{{ formatNumber(video.likeCount) }} 点赞</span>
|
||||
<span>{{ formatPublishTime(video.publishTime || video.createTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<van-empty
|
||||
v-else
|
||||
description="该用户暂未发布公开视频"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import VideoCard from '@/components/video/VideoCard.vue'
|
||||
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
|
||||
import { resolveMediaUrl } from '@/utils/media'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
import type { VideoInfo } from '@/types'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
import type { UserInfo, VideoInfo } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userId = Number(route.params.id)
|
||||
|
||||
const userProfile = ref<UserInfo | null>(null)
|
||||
const videos = ref<VideoInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const defaultCover =
|
||||
'data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 180\"%3E%3Crect width=\"320\" height=\"180\" fill=\"%23edf2f7\"/%3E%3Ctext x=\"50%25\" y=\"50%25\" dy=\".3em\" text-anchor=\"middle\" fill=\"%239499a0\"%3EHAOVP%3C/text%3E%3C/svg%3E'
|
||||
|
||||
const loadVideos = async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
currentPage.value = 1
|
||||
videos.value = []
|
||||
finished.value = false
|
||||
}
|
||||
const pageTitle = computed(() => userProfile.value?.nickname ? `${userProfile.value.nickname} 的视频` : '用户视频')
|
||||
const profileSummary = computed(() => userProfile.value?.signature || '查看该用户公开发布的视频内容')
|
||||
const totalViews = computed(() => formatNumber(videos.value.reduce((sum, video) => sum + (video.viewCount || 0), 0)))
|
||||
const totalLikes = computed(() => formatNumber(videos.value.reduce((sum, video) => sum + (video.likeCount || 0), 0)))
|
||||
|
||||
loading.value = true
|
||||
const getCoverUrl = (coverUrl?: string) => resolveMediaUrl(coverUrl) || defaultCover
|
||||
|
||||
const formatPublishTime = (value?: string) => {
|
||||
if (!value) return '未发布'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const loadPage = async () => {
|
||||
try {
|
||||
const res = await videoApi.getUserVideos(userId, { pageNum: currentPage.value, pageSize: 20 })
|
||||
if (isRefresh) {
|
||||
videos.value = res.records
|
||||
} else {
|
||||
videos.value.push(...res.records)
|
||||
}
|
||||
if (videos.value.length >= res.total || res.records.length < 20) {
|
||||
finished.value = true
|
||||
}
|
||||
currentPage.value++
|
||||
} catch (error) {
|
||||
const [profile, res] = await Promise.all([
|
||||
userApi.getPublicProfile(userId).catch(() => null),
|
||||
videoApi.getVideoPublicList({
|
||||
pageNum: 1,
|
||||
pageSize: 50,
|
||||
userId
|
||||
})
|
||||
])
|
||||
userProfile.value = profile
|
||||
videos.value = res.records
|
||||
} catch {
|
||||
showToast('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!finished.value) loadVideos()
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
loadVideos(true)
|
||||
}
|
||||
|
||||
const goToVideo = (id: number) => {
|
||||
router.push(`/video/${id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVideos(true)
|
||||
})
|
||||
onMounted(loadPage)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-videos-page {
|
||||
min-height: 100vh;
|
||||
background: #f6f7fb;
|
||||
padding-bottom: 60px;
|
||||
background: #f4f5f7;
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
.videos-shell {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.videos-hero,
|
||||
.videos-panel,
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.videos-hero {
|
||||
padding: 22px 24px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 161, 214, 0.08), rgba(251, 114, 153, 0.08)),
|
||||
#fff;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 18px 16px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 12px 24px;
|
||||
gap: 6px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
color: #18191c;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #61666d;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-eyebrow {
|
||||
margin: 0;
|
||||
color: var(--link-color);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #18191c;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.videos-panel {
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-list {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
}
|
||||
|
||||
.video-cover {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #edf2f7;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.duration {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.74);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.video-body {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.video-title-row {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #18191c;
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.video-desc {
|
||||
margin: 0;
|
||||
color: #61666d;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
color: #9499a0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-copy h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.videos-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<van-button size="small" :type="isFollowed ? 'default' : 'primary'" @click.stop="toggleFollow">
|
||||
{{ isFollowed ? '已关注' : '关注' }}
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<div class="video-desc">
|
||||
<van-collapse v-model="activeCollapse">
|
||||
<van-collapse-item title="简介" name="desc">
|
||||
<div class="desc-content">{{ video.description || '暂无简介' }}</div>
|
||||
<div class="video-tags" v-if="video.tagNames?.length">
|
||||
<van-tag v-for="tag in video.tagNames" :key="tag" plain>{{ tag }}</van-tag>
|
||||
<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)
|
||||
// 记录播放
|
||||
try {
|
||||
videoApi.recordPlay({
|
||||
videoId: videoId.value,
|
||||
progress: 0,
|
||||
duration: video.value.duration
|
||||
})
|
||||
} catch (e) {
|
||||
// 忽略播放记录错误
|
||||
// 播放记录接口当前要求登录,匿名态不应触发 401 跳转。
|
||||
if (userStore.isLoggedIn) {
|
||||
try {
|
||||
await videoApi.recordPlay({
|
||||
videoId: videoId.value,
|
||||
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,100 +598,255 @@ 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;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.4;
|
||||
.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: 26px;
|
||||
font-weight: 600;
|
||||
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;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-bottom: 12px;
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #9499a0;
|
||||
}
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: #9499a0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.van-button {
|
||||
flex: 1 1 120px;
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
: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;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.author-avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.author-detail {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.author-fans {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.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: #61666d;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.video-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.van-button {
|
||||
flex: 1 1 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.author-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid #eee;
|
||||
border-bottom: 1px solid #eee;
|
||||
cursor: pointer;
|
||||
|
||||
.author-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.author-detail {
|
||||
flex: 1;
|
||||
|
||||
.author-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.author-fans {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-desc {
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
|
||||
.desc-content {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.video-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
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,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 {
|
||||
|
||||
@@ -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,56 +7,61 @@ import { VantResolver } from 'unplugin-vue-components/resolvers'
|
||||
import VueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VueDevTools(),
|
||||
AutoImport({
|
||||
imports: ['vue', 'vue-router', 'pinia'],
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
dirs: ['src/composables'], // 只扫描 composables,stores 使用显式导入
|
||||
vueTemplate: true
|
||||
}),
|
||||
Components({
|
||||
resolvers: [VantResolver()],
|
||||
dts: 'src/components.d.ts'
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
open: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://192.168.1.101:9000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
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'],
|
||||
vueTemplate: true
|
||||
}),
|
||||
Components({
|
||||
resolvers: [VantResolver()],
|
||||
dts: 'src/components.d.ts'
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
}
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
silenceDeprecations: ['legacy-js-api'] // 静默 Sass legacy API 弃用警告
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
open: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: 'es2015',
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 2000,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ['vue', 'vue-router', 'pinia'],
|
||||
vant: ['vant', '@vant/use'],
|
||||
artplayer: ['artplayer', 'artplayer-plugin-danmuku']
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
silenceDeprecations: ['legacy-js-api']
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: 'es2015',
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 2000,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ['vue', 'vue-router', 'pinia'],
|
||||
vant: ['vant', '@vant/use'],
|
||||
artplayer: ['artplayer', 'artplayer-plugin-danmuku']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||