refactor(project): Reorganize frontend and backend directories
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HAOVP 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "video-platform-admin",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
"format": "prettier --write src"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.8",
|
||||
"element-plus": "^2.6.3",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"echarts": "^5.5.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"nprogress": "^0.2.0",
|
||||
"sortablejs": "^1.15.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.30",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"sass": "^1.72.0",
|
||||
"typescript": "~5.4.3",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.2.6",
|
||||
"vue-tsc": "^2.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
||||
"@typescript-eslint/parser": "^7.3.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.23.0",
|
||||
"prettier": "^3.2.5"
|
||||
}
|
||||
}
|
||||
+3009
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Admin App 根组件
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type { AdminUser, PageResult, PageParams } from '@/types'
|
||||
|
||||
/**
|
||||
* 管理员管理 API
|
||||
*/
|
||||
export const adminApi = {
|
||||
/**
|
||||
* 获取管理员列表
|
||||
*/
|
||||
getList(params: PageParams & { keyword?: string }): Promise<PageResult<AdminUser>> {
|
||||
return request.get('/user/list', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取管理员详情
|
||||
*/
|
||||
getDetail(id: number): Promise<AdminUser> {
|
||||
return request.get(`/user/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加管理员
|
||||
*/
|
||||
create(data: {
|
||||
username: string
|
||||
password: string
|
||||
nickname: string
|
||||
email?: string
|
||||
phone?: string
|
||||
role?: string
|
||||
}): Promise<void> {
|
||||
return request.post('/user', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新管理员
|
||||
*/
|
||||
update(data: {
|
||||
id: number
|
||||
nickname?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
role?: string
|
||||
}): Promise<void> {
|
||||
return request.put('/user', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除管理员
|
||||
*/
|
||||
delete(id: number): Promise<void> {
|
||||
return request.delete(`/user/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 启用/禁用管理员
|
||||
*/
|
||||
updateStatus(id: number, status: number): Promise<void> {
|
||||
return request.put(`/user/${id}/status/${status}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
resetPassword(id: number): Promise<void> {
|
||||
return request.put(`/user/${id}/reset-password`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type {
|
||||
LoginParams,
|
||||
LoginResult,
|
||||
CaptchaResult,
|
||||
AdminUser
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* 管理员认证 API
|
||||
*/
|
||||
export const authApi = {
|
||||
/**
|
||||
* 获取验证码
|
||||
*/
|
||||
getCaptcha(): Promise<CaptchaResult> {
|
||||
return request.get('/auth/captcha')
|
||||
},
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
login(params: LoginParams): Promise<LoginResult> {
|
||||
return request.post('/auth/login', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
logout(): Promise<void> {
|
||||
return request.post('/auth/logout')
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前管理员信息
|
||||
*/
|
||||
getInfo(): Promise<AdminUser> {
|
||||
return request.get('/auth/info')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type { Category, CategoryParams, Tag, TagQueryParams, PageResult } from '@/types'
|
||||
|
||||
/**
|
||||
* 分类管理 API
|
||||
*/
|
||||
export const categoryApi = {
|
||||
/**
|
||||
* 获取分类列表
|
||||
*/
|
||||
getList(): Promise<Category[]> {
|
||||
return request.get('/platform/content/category/list')
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加分类
|
||||
*/
|
||||
create(params: CategoryParams): Promise<void> {
|
||||
return request.post('/platform/content/category', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新分类
|
||||
*/
|
||||
update(params: CategoryParams): Promise<void> {
|
||||
return request.put('/platform/content/category', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除分类
|
||||
*/
|
||||
delete(categoryId: number): Promise<void> {
|
||||
return request.delete(`/platform/content/category/${categoryId}`)
|
||||
},
|
||||
|
||||
// ==================== 标签管理 ====================
|
||||
|
||||
/**
|
||||
* 获取标签列表
|
||||
*/
|
||||
getTags(params?: TagQueryParams): Promise<PageResult<Tag>> {
|
||||
return request.get('/content/tag/page', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加标签
|
||||
*/
|
||||
createTag(params: { name: string }): Promise<void> {
|
||||
return request.post('/content/tag', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新标签
|
||||
*/
|
||||
updateTag(params: { id: number; name: string }): Promise<void> {
|
||||
return request.put('/content/tag', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除标签
|
||||
*/
|
||||
deleteTag(tagId: number): Promise<void> {
|
||||
return request.delete(`/content/tag/${tagId}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type { PageResult, PlatformComment, CommentQueryParams } from '@/types'
|
||||
|
||||
/**
|
||||
* 评论管理 API
|
||||
*/
|
||||
export const commentApi = {
|
||||
/**
|
||||
* 获取评论列表
|
||||
*/
|
||||
getList(params: CommentQueryParams): Promise<PageResult<PlatformComment>> {
|
||||
return request.get('/admin/list', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取评论详情
|
||||
*/
|
||||
getDetail(commentId: number): Promise<PlatformComment> {
|
||||
return request.get(`/admin/${commentId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除评论
|
||||
*/
|
||||
delete(commentId: number): Promise<void> {
|
||||
return request.delete(`/admin/${commentId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取评论统计数据
|
||||
*/
|
||||
getStats(): Promise<{
|
||||
totalComments: number
|
||||
commentsToday: number
|
||||
commentGrowthRate: number
|
||||
}> {
|
||||
return request.get('/admin/stats')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// 导出所有 API 模块
|
||||
export * from './auth.ts'
|
||||
export * from './user.ts'
|
||||
export * from './video.ts'
|
||||
export * from './category.ts'
|
||||
export * from './comment.ts'
|
||||
export * from './admin.ts'
|
||||
export * from './statistics.ts'
|
||||
@@ -0,0 +1,42 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type { DashboardData, StatisticsData, TrendData } from '@/types'
|
||||
|
||||
/**
|
||||
* 统计数据 API
|
||||
*/
|
||||
export const statisticsApi = {
|
||||
/**
|
||||
* 获取仪表盘数据
|
||||
*/
|
||||
getDashboard(): Promise<DashboardData> {
|
||||
return request.get('/statistics/dashboard')
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取统计数据
|
||||
*/
|
||||
getStatistics(): Promise<StatisticsData> {
|
||||
return request.get('/statistics/overview')
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户趋势
|
||||
*/
|
||||
getUserTrend(days: number = 7): Promise<TrendData[]> {
|
||||
return request.get('/statistics/user/trend', { days })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取视频趋势
|
||||
*/
|
||||
getVideoTrend(days: number = 7): Promise<TrendData[]> {
|
||||
return request.get('/statistics/video/trend', { days })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取播放趋势
|
||||
*/
|
||||
getViewTrend(days: number = 7): Promise<TrendData[]> {
|
||||
return request.get('/statistics/view/trend', { days })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type { PageResult, PlatformUser, UserQueryParams } from '@/types'
|
||||
|
||||
/**
|
||||
* 用户管理 API
|
||||
*/
|
||||
export const userApi = {
|
||||
/**
|
||||
* 获取用户列表
|
||||
*/
|
||||
getList(params: UserQueryParams): Promise<PageResult<PlatformUser>> {
|
||||
return request.get('/platform/user/list', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
*/
|
||||
getDetail(userId: number): Promise<PlatformUser> {
|
||||
return request.get(`/platform/user/${userId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 禁用/启用用户
|
||||
*/
|
||||
updateStatus(userId: number, status: number): Promise<void> {
|
||||
return request.put(`/platform/user/${userId}/status/${status}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 重置用户密码
|
||||
*/
|
||||
resetPassword(userId: number): Promise<void> {
|
||||
return request.put(`/platform/user/${userId}/reset-password`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户统计数据
|
||||
*/
|
||||
getStats(): Promise<{
|
||||
totalUsers: number
|
||||
newUsersToday: number
|
||||
activeUsersToday: number
|
||||
userGrowthRate: number
|
||||
}> {
|
||||
return request.get('/platform/user/stats')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type { PageResult, PlatformVideo, VideoQueryParams, VideoReviewParams } from '@/types'
|
||||
|
||||
/**
|
||||
* 视频管理 API
|
||||
*/
|
||||
export const videoApi = {
|
||||
/**
|
||||
* 获取视频列表
|
||||
*/
|
||||
getList(params: VideoQueryParams): Promise<PageResult<PlatformVideo>> {
|
||||
return request.get('/platform/content/video/list', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取视频详情
|
||||
*/
|
||||
getDetail(videoId: number): Promise<PlatformVideo> {
|
||||
return request.get(`/platform/content/video/${videoId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 审核视频
|
||||
*/
|
||||
review(params: VideoReviewParams): Promise<void> {
|
||||
return request.post(`/platform/content/video/review/${params.videoId}`, {
|
||||
status: params.status,
|
||||
reason: params.reason
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 下架视频
|
||||
*/
|
||||
disable(videoId: number): Promise<void> {
|
||||
return request.put(`/platform/content/video/disable/${videoId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取视频统计数据
|
||||
*/
|
||||
getStats(): Promise<{
|
||||
totalVideos: number
|
||||
newVideosToday: number
|
||||
pendingReview: number
|
||||
videoGrowthRate: number
|
||||
}> {
|
||||
return request.get('/platform/content/video/stats')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Breadcrumb: typeof import('./components/Breadcrumb.vue')['default']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
TagsView: typeof import('./components/TagsView.vue')['default']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<el-breadcrumb class="breadcrumb" separator="/">
|
||||
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path">
|
||||
<span v-if="index === breadcrumbs.length - 1" class="no-redirect">
|
||||
{{ item.meta?.title }}
|
||||
</span>
|
||||
<a v-else @click.prevent="handleLink(item)">
|
||||
{{ item.meta?.title }}
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter, type RouteLocationMatched } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const breadcrumbs = ref<RouteLocationMatched[]>([])
|
||||
|
||||
const getBreadcrumbs = () => {
|
||||
const matched = route.matched.filter(
|
||||
(item) => item.meta && item.meta.title && !item.meta.hidden
|
||||
)
|
||||
breadcrumbs.value = matched
|
||||
}
|
||||
|
||||
const handleLink = (item: RouteLocationMatched) => {
|
||||
const { path, redirect } = item
|
||||
if (redirect) {
|
||||
router.push(redirect as string)
|
||||
return
|
||||
}
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
getBreadcrumbs()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.breadcrumb {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
line-height: 50px;
|
||||
|
||||
.no-redirect {
|
||||
color: #97a8be;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #303133;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div class="tags-view-container">
|
||||
<el-scrollbar class="tags-view-wrapper">
|
||||
<router-link
|
||||
v-for="tag in visitedViews"
|
||||
:key="tag.path"
|
||||
:to="{ path: tag.path, query: tag.query }"
|
||||
class="tags-view-item"
|
||||
:class="{ active: isActive(tag) }"
|
||||
@contextmenu.prevent="openMenu(tag, $event)"
|
||||
>
|
||||
{{ tag.title }}
|
||||
<el-icon
|
||||
v-if="!tag.affix"
|
||||
class="close-icon"
|
||||
@click.prevent.stop="closeSelectedView(tag)"
|
||||
>
|
||||
<Close />
|
||||
</el-icon>
|
||||
</router-link>
|
||||
</el-scrollbar>
|
||||
|
||||
<ul v-show="visible" class="context-menu" :style="{ left: left + 'px', top: top + 'px' }">
|
||||
<li @click="refreshSelectedView(selectedTag!)">刷新</li>
|
||||
<li v-if="!selectedTag?.affix" @click="closeSelectedView(selectedTag!)">关闭</li>
|
||||
<li @click="closeOthersViews">关闭其他</li>
|
||||
<li @click="closeLeftViews">关闭左侧</li>
|
||||
<li @click="closeRightViews">关闭右侧</li>
|
||||
<li @click="closeAllViews">关闭所有</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTagsViewStore, type TagView } from '@/stores/tagsView.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
const visitedViews = computed(() => tagsViewStore.visitedViews)
|
||||
|
||||
// 右键菜单
|
||||
const visible = ref(false)
|
||||
const top = ref(0)
|
||||
const left = ref(0)
|
||||
const selectedTag = ref<TagView | null>(null)
|
||||
|
||||
// 判断是否当前路由
|
||||
const isActive = (tag: TagView) => {
|
||||
return tag.path === route.path
|
||||
}
|
||||
|
||||
// 关闭选中的标签
|
||||
const closeSelectedView = (view: TagView) => {
|
||||
tagsViewStore.delView(view)
|
||||
if (isActive(view)) {
|
||||
toLastView()
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新选中的标签
|
||||
const refreshSelectedView = (view: TagView) => {
|
||||
router.replace({ path: '/redirect' + view.path })
|
||||
}
|
||||
|
||||
// 关闭其他标签
|
||||
const closeOthersViews = () => {
|
||||
if (selectedTag.value) {
|
||||
tagsViewStore.delOthersViews(selectedTag.value)
|
||||
router.push(selectedTag.value.path)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭左侧标签
|
||||
const closeLeftViews = () => {
|
||||
if (selectedTag.value) {
|
||||
tagsViewStore.delLeftViews(selectedTag.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭右侧标签
|
||||
const closeRightViews = () => {
|
||||
if (selectedTag.value) {
|
||||
tagsViewStore.delRightViews(selectedTag.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭所有标签
|
||||
const closeAllViews = () => {
|
||||
tagsViewStore.delAllViews()
|
||||
toLastView()
|
||||
}
|
||||
|
||||
// 跳转到最后一个标签
|
||||
const toLastView = () => {
|
||||
const latestView = visitedViews.value.slice(-1)[0]
|
||||
if (latestView) {
|
||||
router.push(latestView.fullPath)
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
// 打开右键菜单
|
||||
const openMenu = (tag: TagView, e: MouseEvent) => {
|
||||
const menuMinWidth = 105
|
||||
const offsetLeft = document.body.getBoundingClientRect().left
|
||||
const offsetWidth = document.body.offsetWidth
|
||||
const maxLeft = offsetWidth - menuMinWidth
|
||||
const l = e.clientX - offsetLeft + 15
|
||||
|
||||
left.value = l > maxLeft ? maxLeft : l
|
||||
top.value = e.clientY
|
||||
visible.value = true
|
||||
selectedTag.value = tag
|
||||
}
|
||||
|
||||
// 关闭右键菜单
|
||||
const closeMenu = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
tagsViewStore.addView(route)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 点击其他地方关闭菜单
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeMenu)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tags-view-container {
|
||||
height: 34px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #d8dce5;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 0 3px 0 rgba(0, 0, 0, 0.04);
|
||||
|
||||
.tags-view-wrapper {
|
||||
.tags-view-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
border: 1px solid #d8dce5;
|
||||
color: #495060;
|
||||
background: #fff;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
margin-top: 4px;
|
||||
border-radius: 2px;
|
||||
text-decoration: none;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #42b983;
|
||||
color: #fff;
|
||||
border-color: #42b983;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
background: #fff;
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: #495060;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
margin-left: 5px;
|
||||
font-size: 12px;
|
||||
border-radius: 50%;
|
||||
|
||||
&:hover {
|
||||
background-color: #b4bccc;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
z-index: 3000;
|
||||
position: absolute;
|
||||
list-style-type: none;
|
||||
padding: 5px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 7px 16px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/index.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册所有 Element Plus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,206 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
|
||||
// 配置 NProgress
|
||||
NProgress.configure({ showSpinner: false })
|
||||
|
||||
// 布局组件
|
||||
const Layout = () => import('@/views/layout/index.vue')
|
||||
|
||||
// 静态路由
|
||||
export const constantRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { title: '登录', hidden: true }
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/error/404.vue'),
|
||||
meta: { title: '页面未找到', hidden: true }
|
||||
}
|
||||
]
|
||||
|
||||
// 动态路由(需要登录)
|
||||
export const asyncRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: { title: '仪表盘', icon: 'Odometer', affix: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
component: Layout,
|
||||
redirect: '/user/list',
|
||||
meta: { title: '用户管理', icon: 'User' },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'UserList',
|
||||
component: () => import('@/views/user/list.vue'),
|
||||
meta: { title: '用户列表', icon: 'List' }
|
||||
},
|
||||
{
|
||||
path: 'detail/:id',
|
||||
name: 'UserDetail',
|
||||
component: () => import('@/views/user/detail.vue'),
|
||||
meta: { title: '用户详情', hidden: true, activeMenu: '/user/list' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/video',
|
||||
component: Layout,
|
||||
redirect: '/video/list',
|
||||
meta: { title: '视频管理', icon: 'VideoCamera' },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'VideoList',
|
||||
component: () => import('@/views/video/list.vue'),
|
||||
meta: { title: '视频列表', icon: 'List' }
|
||||
},
|
||||
{
|
||||
path: 'review',
|
||||
name: 'VideoReview',
|
||||
component: () => import('@/views/video/review.vue'),
|
||||
meta: { title: '视频审核', icon: 'Check' }
|
||||
},
|
||||
{
|
||||
path: 'detail/:id',
|
||||
name: 'VideoDetail',
|
||||
component: () => import('@/views/video/detail.vue'),
|
||||
meta: { title: '视频详情', hidden: true, activeMenu: '/video/list' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/content',
|
||||
component: Layout,
|
||||
redirect: '/content/category',
|
||||
meta: { title: '内容管理', icon: 'Files' },
|
||||
children: [
|
||||
{
|
||||
path: 'category',
|
||||
name: 'Category',
|
||||
component: () => import('@/views/content/category.vue'),
|
||||
meta: { title: '分类管理', icon: 'Grid' }
|
||||
},
|
||||
{
|
||||
path: 'tag',
|
||||
name: 'Tag',
|
||||
component: () => import('@/views/content/tag.vue'),
|
||||
meta: { title: '标签管理', icon: 'PriceTag' }
|
||||
},
|
||||
{
|
||||
path: 'comment',
|
||||
name: 'Comment',
|
||||
component: () => import('@/views/content/comment.vue'),
|
||||
meta: { title: '评论管理', icon: 'ChatDotSquare' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/system',
|
||||
component: Layout,
|
||||
redirect: '/system/admin',
|
||||
meta: { title: '系统管理', icon: 'Setting' },
|
||||
children: [
|
||||
{
|
||||
path: 'admin',
|
||||
name: 'AdminUser',
|
||||
component: () => import('@/views/system/admin.vue'),
|
||||
meta: { title: '管理员管理', icon: 'UserFilled' }
|
||||
}
|
||||
]
|
||||
},
|
||||
// 404 页面必须放在最后
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404',
|
||||
meta: { hidden: true }
|
||||
}
|
||||
]
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [...constantRoutes, ...asyncRoutes],
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
return { top: 0 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 路由白名单
|
||||
const whiteList = ['/login', '/404']
|
||||
|
||||
// 路由前置守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 开始进度条
|
||||
NProgress.start()
|
||||
|
||||
// 设置页面标题
|
||||
const title = to.meta?.title as string
|
||||
document.title = title ? `${title} - HAOVP管理后台` : 'HAOVP管理后台'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const hasToken = userStore.token
|
||||
|
||||
if (hasToken) {
|
||||
if (to.path === '/login') {
|
||||
// 已登录,跳转到首页
|
||||
next({ path: '/' })
|
||||
NProgress.done()
|
||||
} else {
|
||||
// 检查是否已获取用户信息
|
||||
if (userStore.userInfo) {
|
||||
next()
|
||||
} else {
|
||||
try {
|
||||
// 获取用户信息
|
||||
await userStore.fetchUserInfo()
|
||||
next()
|
||||
} catch (error) {
|
||||
// 获取用户信息失败,清除登录状态
|
||||
userStore.clearAuth()
|
||||
next(`/login?redirect=${to.path}`)
|
||||
NProgress.done()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 未登录
|
||||
if (whiteList.includes(to.path)) {
|
||||
// 在白名单中,直接进入
|
||||
next()
|
||||
} else {
|
||||
// 不在白名单中,跳转到登录页
|
||||
next(`/login?redirect=${to.path}`)
|
||||
NProgress.done()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 路由后置守卫
|
||||
router.afterEach(() => {
|
||||
// 结束进度条
|
||||
NProgress.done()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useAppStore = defineStore('admin-app', () => {
|
||||
// 侧边栏折叠状态
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
// 设备类型
|
||||
const device = ref<'desktop' | 'mobile'>('desktop')
|
||||
|
||||
// 切换侧边栏
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
// 设置设备类型
|
||||
function setDevice(d: 'desktop' | 'mobile') {
|
||||
device.value = d
|
||||
if (d === 'mobile') {
|
||||
sidebarCollapsed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
device,
|
||||
toggleSidebar,
|
||||
setDevice
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useUserStore } from './user.ts'
|
||||
export { useAppStore } from './app.ts'
|
||||
export { useTagsViewStore } from './tagsView.ts'
|
||||
@@ -0,0 +1,111 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
export interface TagView {
|
||||
path: string
|
||||
name: string
|
||||
title: string
|
||||
fullPath: string
|
||||
query?: Record<string, any>
|
||||
affix?: boolean
|
||||
}
|
||||
|
||||
export const useTagsViewStore = defineStore('admin-tagsView', () => {
|
||||
// 访问过的页面
|
||||
const visitedViews = ref<TagView[]>([])
|
||||
|
||||
// 缓存的页面名称
|
||||
const cachedViews = ref<string[]>([])
|
||||
|
||||
// 添加访问过的页面
|
||||
function addView(view: RouteLocationNormalized) {
|
||||
// 检查是否已存在
|
||||
const exists = visitedViews.value.some((v) => v.path === view.path)
|
||||
if (exists) return
|
||||
|
||||
// 添加新页面
|
||||
visitedViews.value.push({
|
||||
path: view.path,
|
||||
name: view.name as string,
|
||||
title: (view.meta?.title as string) || 'no-name',
|
||||
fullPath: view.fullPath,
|
||||
query: view.query as Record<string, any>,
|
||||
affix: view.meta?.affix as boolean
|
||||
})
|
||||
|
||||
// 添加缓存
|
||||
if (view.name && view.meta?.noCache !== true) {
|
||||
addCachedView(view.name as string)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加缓存页面
|
||||
function addCachedView(name: string) {
|
||||
if (cachedViews.value.includes(name)) return
|
||||
cachedViews.value.push(name)
|
||||
}
|
||||
|
||||
// 删除访问过的页面
|
||||
function delView(view: TagView) {
|
||||
// 删除访问记录
|
||||
const index = visitedViews.value.findIndex((v) => v.path === view.path)
|
||||
if (index > -1) {
|
||||
visitedViews.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 删除缓存
|
||||
if (view.name) {
|
||||
delCachedView(view.name)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除缓存页面
|
||||
function delCachedView(name: string) {
|
||||
const index = cachedViews.value.indexOf(name)
|
||||
if (index > -1) {
|
||||
cachedViews.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除其他页面
|
||||
function delOthersViews(view: TagView) {
|
||||
visitedViews.value = visitedViews.value.filter((v) => v.affix || v.path === view.path)
|
||||
cachedViews.value = cachedViews.value.filter((name) => name === view.name)
|
||||
}
|
||||
|
||||
// 删除所有页面
|
||||
function delAllViews() {
|
||||
visitedViews.value = visitedViews.value.filter((v) => v.affix)
|
||||
cachedViews.value = []
|
||||
}
|
||||
|
||||
// 删除左侧页面
|
||||
function delLeftViews(view: TagView) {
|
||||
const index = visitedViews.value.findIndex((v) => v.path === view.path)
|
||||
if (index > -1) {
|
||||
visitedViews.value = visitedViews.value.filter((v, i) => v.affix || i >= index)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除右侧页面
|
||||
function delRightViews(view: TagView) {
|
||||
const index = visitedViews.value.findIndex((v) => v.path === view.path)
|
||||
if (index > -1) {
|
||||
visitedViews.value = visitedViews.value.filter((v, i) => v.affix || i <= index)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
visitedViews,
|
||||
cachedViews,
|
||||
addView,
|
||||
addCachedView,
|
||||
delView,
|
||||
delCachedView,
|
||||
delOthersViews,
|
||||
delAllViews,
|
||||
delLeftViews,
|
||||
delRightViews
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { authApi, type LoginParams, type AdminUser } from '@/api/auth.ts'
|
||||
import router from '@/router'
|
||||
|
||||
const TOKEN_KEY = 'admin_token'
|
||||
const REFRESH_TOKEN_KEY = 'admin_refresh_token'
|
||||
const USER_INFO_KEY = 'admin_user_info'
|
||||
|
||||
export const useUserStore = defineStore('admin-user', () => {
|
||||
// State
|
||||
const token = ref<string>(localStorage.getItem(TOKEN_KEY) || '')
|
||||
const refreshToken = ref<string>(localStorage.getItem(REFRESH_TOKEN_KEY) || '')
|
||||
const userInfo = ref<AdminUser | null>(
|
||||
JSON.parse(localStorage.getItem(USER_INFO_KEY) || 'null')
|
||||
)
|
||||
|
||||
// Getters
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const userId = computed(() => userInfo.value?.id)
|
||||
const username = computed(() => userInfo.value?.username)
|
||||
const nickname = computed(() => userInfo.value?.nickname)
|
||||
const avatar = computed(() => userInfo.value?.avatar)
|
||||
const role = computed(() => userInfo.value?.role)
|
||||
|
||||
// Actions
|
||||
function setToken(accessToken: string, refresh?: string) {
|
||||
token.value = accessToken
|
||||
localStorage.setItem(TOKEN_KEY, accessToken)
|
||||
if (refresh) {
|
||||
refreshToken.value = refresh
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refresh)
|
||||
}
|
||||
}
|
||||
|
||||
function setUserInfo(info: AdminUser) {
|
||||
userInfo.value = info
|
||||
localStorage.setItem(USER_INFO_KEY, JSON.stringify(info))
|
||||
}
|
||||
|
||||
async function login(params: LoginParams) {
|
||||
const result = await authApi.login(params)
|
||||
setToken(result.accessToken, result.refreshToken)
|
||||
return result
|
||||
}
|
||||
|
||||
async function fetchUserInfo() {
|
||||
const info = await authApi.getInfo()
|
||||
setUserInfo(info)
|
||||
return info
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
} finally {
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
token.value = ''
|
||||
refreshToken.value = ''
|
||||
userInfo.value = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
localStorage.removeItem(USER_INFO_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
token,
|
||||
refreshToken,
|
||||
userInfo,
|
||||
// Getters
|
||||
isLoggedIn,
|
||||
userId,
|
||||
username,
|
||||
nickname,
|
||||
avatar,
|
||||
role,
|
||||
// Actions
|
||||
setToken,
|
||||
setUserInfo,
|
||||
login,
|
||||
fetchUserInfo,
|
||||
logout,
|
||||
clearAuth
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
// 全局样式
|
||||
@use 'variables' as *;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -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-size: 14px;
|
||||
color: $text-primary;
|
||||
background-color: $bg-secondary;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $primary-color;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $primary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动条样式
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: $bg-tertiary;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
}
|
||||
|
||||
// Element Plus 覆盖
|
||||
.el-card {
|
||||
border-radius: $radius-md;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.el-table {
|
||||
.el-table__header {
|
||||
th {
|
||||
background: $bg-tertiary !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.el-message-box {
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
// 工具类
|
||||
.text-primary {
|
||||
color: $primary-color !important;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: $success-color !important;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: $warning-color !important;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: $danger-color !important;
|
||||
}
|
||||
|
||||
.text-info {
|
||||
color: $info-color !important;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mt-sm {
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.mt-md {
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
|
||||
.mt-lg {
|
||||
margin-top: $spacing-lg;
|
||||
}
|
||||
|
||||
.mb-sm {
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.mb-md {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.mb-lg {
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.p-sm {
|
||||
padding: $spacing-sm;
|
||||
}
|
||||
|
||||
.p-md {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.p-lg {
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// 颜色变量
|
||||
$primary-color: #409eff;
|
||||
$primary-hover: #66b1ff;
|
||||
$success-color: #67c23a;
|
||||
$warning-color: #e6a23c;
|
||||
$danger-color: #f56c6c;
|
||||
$info-color: #909399;
|
||||
|
||||
// 文本颜色
|
||||
$text-primary: #303133;
|
||||
$text-regular: #606266;
|
||||
$text-secondary: #909399;
|
||||
$text-placeholder: #c0c4cc;
|
||||
|
||||
// 背景颜色
|
||||
$bg-primary: #ffffff;
|
||||
$bg-secondary: #f0f2f5;
|
||||
$bg-tertiary: #f5f7fa;
|
||||
|
||||
// 边框颜色
|
||||
$border-light: #e4e7ed;
|
||||
$border-base: #dcdfe6;
|
||||
$border-dark: #d4d7de;
|
||||
|
||||
// 间距
|
||||
$spacing-xs: 4px;
|
||||
$spacing-sm: 8px;
|
||||
$spacing-md: 16px;
|
||||
$spacing-lg: 24px;
|
||||
$spacing-xl: 32px;
|
||||
|
||||
// 圆角
|
||||
$radius-sm: 4px;
|
||||
$radius-md: 8px;
|
||||
$radius-lg: 12px;
|
||||
|
||||
// 阴影
|
||||
$shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
$shadow-md: 0 4px 8px rgba(0, 0, 0, 0.08);
|
||||
$shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.12);
|
||||
|
||||
// 布局
|
||||
$header-height: 50px;
|
||||
$sidebar-width: 210px;
|
||||
$sidebar-collapsed-width: 64px;
|
||||
@@ -0,0 +1,245 @@
|
||||
// API 响应通用结构
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 分页请求参数
|
||||
export interface PageParams {
|
||||
page?: number
|
||||
size?: number
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
// 分页响应数据
|
||||
export interface PageResult<T> {
|
||||
records: T[]
|
||||
total: number
|
||||
size: number
|
||||
current: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
// 管理员信息
|
||||
export interface AdminUser {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
email: string
|
||||
phone: string
|
||||
role: string
|
||||
status: number
|
||||
lastLoginTime: string
|
||||
lastLoginIp: string
|
||||
createTime: string
|
||||
}
|
||||
|
||||
// 登录响应
|
||||
export interface LoginResult {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
tokenType: string
|
||||
expiresIn: number
|
||||
adminId: number
|
||||
username: string
|
||||
}
|
||||
|
||||
// 验证码响应
|
||||
export interface CaptchaResult {
|
||||
captchaKey: string
|
||||
captchaImage: string
|
||||
}
|
||||
|
||||
// 登录请求
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
captcha: string
|
||||
captchaKey: string
|
||||
}
|
||||
|
||||
// 平台用户信息(管理端查看)
|
||||
export interface PlatformUser {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
email: string
|
||||
phone: string
|
||||
gender: number
|
||||
birthday: string
|
||||
signature: string
|
||||
status: number
|
||||
followerCount: number
|
||||
followingCount: number
|
||||
videoCount: number
|
||||
likeCount: number
|
||||
level: number
|
||||
vipLevel: number
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
// 用户查询参数
|
||||
export interface UserQueryParams extends PageParams {
|
||||
keyword?: string
|
||||
status?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
// 平台视频信息(管理端查看)
|
||||
export interface PlatformVideo {
|
||||
id: number
|
||||
userId: number
|
||||
username: string
|
||||
userNickname: string
|
||||
userAvatar: string
|
||||
title: string
|
||||
description: string
|
||||
coverUrl: string
|
||||
videoUrl: string
|
||||
duration: number
|
||||
width: number
|
||||
height: number
|
||||
format: string
|
||||
size: number
|
||||
categoryId: number
|
||||
categoryName: string
|
||||
status: number // 0:待审核,1:已发布,2:审核拒绝,3:已下架
|
||||
auditStatus: number
|
||||
auditReason: string
|
||||
likeCount: number
|
||||
coinCount: number
|
||||
favoriteCount: number
|
||||
commentCount: number
|
||||
viewCount: number
|
||||
shareCount: number
|
||||
danmakuCount: number
|
||||
isRecommend: number
|
||||
isTop: number
|
||||
publishTime: string
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
// 视频查询参数
|
||||
export interface VideoQueryParams extends PageParams {
|
||||
keyword?: string
|
||||
userId?: number
|
||||
categoryId?: number
|
||||
status?: number
|
||||
auditStatus?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
// 视频审核参数
|
||||
export interface VideoReviewParams {
|
||||
videoId: number
|
||||
status: number // 1:通过, 2:拒绝
|
||||
reason?: string
|
||||
}
|
||||
|
||||
// 分类信息
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
parentId: number
|
||||
sort: number
|
||||
icon: string
|
||||
children?: Category[]
|
||||
}
|
||||
|
||||
// 分类创建/更新参数
|
||||
export interface CategoryParams {
|
||||
id?: number
|
||||
name: string
|
||||
parentId?: number
|
||||
sort?: number
|
||||
icon?: string
|
||||
}
|
||||
|
||||
// 标签信息
|
||||
export interface Tag {
|
||||
id: number
|
||||
name: string
|
||||
useCount: number
|
||||
createTime: string
|
||||
}
|
||||
|
||||
// 标签查询参数
|
||||
export interface TagQueryParams extends PageParams {
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
// 评论信息(管理端)
|
||||
export interface PlatformComment {
|
||||
id: number
|
||||
videoId: number
|
||||
videoTitle: string
|
||||
userId: number
|
||||
userNickname: string
|
||||
userAvatar: string
|
||||
parentId: number
|
||||
rootId: number
|
||||
content: string
|
||||
likeCount: number
|
||||
replyCount: number
|
||||
isTop: number
|
||||
status: number
|
||||
createTime: string
|
||||
}
|
||||
|
||||
// 评论查询参数
|
||||
export interface CommentQueryParams extends PageParams {
|
||||
keyword?: string
|
||||
videoId?: number
|
||||
userId?: number
|
||||
status?: number
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
export interface StatisticsData {
|
||||
// 用户统计
|
||||
totalUsers: number
|
||||
newUsersToday: number
|
||||
activeUsersToday: number
|
||||
userGrowthRate: number
|
||||
// 视频统计
|
||||
totalVideos: number
|
||||
newVideosToday: number
|
||||
pendingReview: number
|
||||
videoGrowthRate: number
|
||||
// 播放统计
|
||||
totalViews: number
|
||||
viewsToday: number
|
||||
viewGrowthRate: number
|
||||
// 互动统计
|
||||
totalLikes: number
|
||||
totalComments: number
|
||||
totalDanmakus: number
|
||||
// 趋势数据
|
||||
userTrend: TrendData[]
|
||||
videoTrend: TrendData[]
|
||||
viewTrend: TrendData[]
|
||||
}
|
||||
|
||||
// 趋势数据
|
||||
export interface TrendData {
|
||||
date: string
|
||||
value: number
|
||||
}
|
||||
|
||||
// 仪表盘数据
|
||||
export interface DashboardData {
|
||||
statistics: StatisticsData
|
||||
hotVideos: PlatformVideo[]
|
||||
recentComments: PlatformComment[]
|
||||
recentUsers: PlatformUser[]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
export function formatDateTime(date: string | Date | undefined | null, format = 'YYYY-MM-DD HH:mm:ss'): string {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).format(format)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
export function formatDate(date: string | Date | undefined | null): string {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
export function formatTime(date: string | Date | undefined | null): string {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).format('HH:mm:ss')
|
||||
}
|
||||
|
||||
/**
|
||||
* 相对时间
|
||||
*/
|
||||
export function formatRelativeTime(date: string | Date | undefined | null): string {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).fromNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化视频时长
|
||||
*/
|
||||
export function formatDuration(seconds: number | undefined | null): string {
|
||||
if (!seconds || seconds < 0) return '00:00'
|
||||
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
|
||||
if (hours > 0) {
|
||||
return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`
|
||||
}
|
||||
return `${pad(minutes)}:${pad(secs)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes: number | undefined | null): string {
|
||||
if (!bytes || bytes === 0) return '0 B'
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const k = 1024
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + units[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字
|
||||
*/
|
||||
export function formatNumber(num: number | undefined | null): string {
|
||||
if (!num && num !== 0) return '0'
|
||||
if (num < 1000) {
|
||||
return num.toString()
|
||||
} else if (num < 10000) {
|
||||
return (num / 1000).toFixed(1) + '千'
|
||||
} else if (num < 100000000) {
|
||||
return (num / 10000).toFixed(1) + '万'
|
||||
} else {
|
||||
return (num / 100000000).toFixed(1) + '亿'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化播放量
|
||||
*/
|
||||
export function formatPlayCount(count: number | undefined | null): string {
|
||||
return formatNumber(count)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import router from '@/router'
|
||||
|
||||
// 配置 NProgress
|
||||
NProgress.configure({ showSpinner: false })
|
||||
|
||||
// 创建 axios 实例
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL: '/api/admin',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
NProgress.start()
|
||||
|
||||
// 添加 Token
|
||||
const userStore = useUserStore()
|
||||
const token = userStore.token
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
NProgress.done()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
NProgress.done()
|
||||
|
||||
const res = response.data
|
||||
|
||||
// 如果是文件下载,直接返回
|
||||
if (response.config.responseType === 'blob') {
|
||||
return response
|
||||
}
|
||||
|
||||
// 业务状态码判断
|
||||
if (res.code !== 200 && res.code !== 0) {
|
||||
ElMessage.error(res.message || '请求失败')
|
||||
|
||||
// Token 过期
|
||||
if (res.code === 401) {
|
||||
ElMessageBox.confirm('登录状态已过期,请重新登录', '提示', {
|
||||
confirmButtonText: '重新登录',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
}
|
||||
|
||||
return res.data
|
||||
},
|
||||
(error) => {
|
||||
NProgress.done()
|
||||
|
||||
let message = '请求失败'
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
case 400:
|
||||
message = '请求参数错误'
|
||||
break
|
||||
case 401:
|
||||
message = '未授权,请登录'
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
break
|
||||
case 403:
|
||||
message = '拒绝访问'
|
||||
break
|
||||
case 404:
|
||||
message = '请求地址不存在'
|
||||
break
|
||||
case 500:
|
||||
message = '服务器内部错误'
|
||||
break
|
||||
default:
|
||||
message = error.response.data?.message || '请求失败'
|
||||
}
|
||||
} else if (error.message.includes('timeout')) {
|
||||
message = '请求超时'
|
||||
} else if (error.message.includes('Network Error')) {
|
||||
message = '网络错误'
|
||||
}
|
||||
|
||||
ElMessage.error(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 封装请求方法
|
||||
const request = {
|
||||
get<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.get(url, { params, ...config })
|
||||
},
|
||||
|
||||
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.post(url, data, config)
|
||||
},
|
||||
|
||||
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.put(url, data, config)
|
||||
},
|
||||
|
||||
delete<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.delete(url, { params, ...config })
|
||||
},
|
||||
|
||||
upload<T = any>(url: string, file: File, onProgress?: (percent: number) => void): Promise<T> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return service.post(url, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total && onProgress) {
|
||||
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
onProgress(percent)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
download(url: string, params?: any, filename?: string): Promise<void> {
|
||||
return service
|
||||
.get(url, {
|
||||
params,
|
||||
responseType: 'blob'
|
||||
})
|
||||
.then((response: any) => {
|
||||
const blob = new Blob([response.data])
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = filename || 'download'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default request
|
||||
export { service as axiosInstance }
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="category-page">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>分类管理</span>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>添加分类
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="categories" row-key="id" default-expand-all>
|
||||
<el-table-column prop="name" label="分类名称" />
|
||||
<el-table-column prop="sort" label="排序" width="100" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="primary" text @click="handleAddChild(row)">添加子分类</el-button>
|
||||
<el-button type="danger" text @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="400px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="分类名称" required>
|
||||
<el-input v-model="form.name" placeholder="请输入分类名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父级分类">
|
||||
<el-tree-select
|
||||
v-model="form.parentId"
|
||||
:data="categoryTree"
|
||||
:props="{ label: 'name', value: 'id' }"
|
||||
check-strictly
|
||||
clearable
|
||||
placeholder="选择父级分类(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标">
|
||||
<el-input v-model="form.icon" placeholder="图标URL" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { categoryApi, type Category } from '@/api/category.ts'
|
||||
|
||||
const loading = ref(false)
|
||||
const categories = ref<Category[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('添加分类')
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
id: undefined as number | undefined,
|
||||
name: '',
|
||||
parentId: 0,
|
||||
sort: 0,
|
||||
icon: ''
|
||||
})
|
||||
|
||||
const categoryTree = computed(() => {
|
||||
const buildTree = (list: Category[], parentId: number = 0): Category[] => {
|
||||
return list.filter(item => item.parentId === parentId).map(item => ({
|
||||
...item,
|
||||
children: buildTree(list, item.id)
|
||||
}))
|
||||
}
|
||||
return buildTree(categories.value)
|
||||
})
|
||||
|
||||
const loadCategories = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
categories.value = await categoryApi.getList()
|
||||
} catch (error) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = undefined
|
||||
form.name = ''
|
||||
form.parentId = 0
|
||||
form.sort = 0
|
||||
form.icon = ''
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
resetForm()
|
||||
dialogTitle.value = '添加分类'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleAddChild = (row: Category) => {
|
||||
resetForm()
|
||||
form.parentId = row.id
|
||||
dialogTitle.value = '添加子分类'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: Category) => {
|
||||
form.id = row.id
|
||||
form.name = row.name
|
||||
form.parentId = row.parentId
|
||||
form.sort = row.sort
|
||||
form.icon = row.icon || ''
|
||||
dialogTitle.value = '编辑分类'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row: Category) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该分类吗?', '提示', { type: 'warning' })
|
||||
await categoryApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadCategories()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
ElMessage.warning('请输入分类名称')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (form.id) {
|
||||
await categoryApi.update({ id: form.id, name: form.name, parentId: form.parentId, sort: form.sort, icon: form.icon })
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await categoryApi.create({ name: form.name, parentId: form.parentId, sort: form.sort, icon: form.icon })
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadCategories()
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadCategories())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.category-page {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="comment-page">
|
||||
<el-card shadow="never" class="search-card">
|
||||
<el-form :model="queryParams" inline>
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="queryParams.keyword" placeholder="评论内容" clearable @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="全部" clearable>
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="已删除" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-table v-loading="loading" :data="comments">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="评论内容" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="comment-content">{{ row.content }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="视频" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="video-title">{{ row.videoTitle }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户" width="120">
|
||||
<template #default="{ row }">
|
||||
<div class="user-info">
|
||||
<el-avatar :src="row.userAvatar" :size="24">{{ row.userNickname?.charAt(0) }}</el-avatar>
|
||||
<span>{{ row.userNickname }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="likeCount" label="点赞" width="80" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
|
||||
{{ row.status === 1 ? '正常' : '已删除' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" text @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.size"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { commentApi } from '@/api/comment.ts'
|
||||
import { formatDateTime } from '@/utils/format.ts'
|
||||
import type { PlatformComment, CommentQueryParams } from '@/types'
|
||||
|
||||
const queryParams = reactive<CommentQueryParams>({ page: 1, size: 10, keyword: '', status: undefined })
|
||||
const loading = ref(false)
|
||||
const comments = ref<PlatformComment[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const loadComments = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await commentApi.getList(queryParams)
|
||||
comments.value = res.records
|
||||
total.value = res.total
|
||||
} catch (error) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => { queryParams.page = 1; loadComments() }
|
||||
const handleReset = () => { queryParams.keyword = ''; queryParams.status = undefined; handleSearch() }
|
||||
const handleSizeChange = (size: number) => { queryParams.size = size; loadComments() }
|
||||
const handlePageChange = (page: number) => { queryParams.page = page; loadComments() }
|
||||
|
||||
const handleDelete = async (row: PlatformComment) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该评论吗?', '提示', { type: 'warning' })
|
||||
await commentApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadComments()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(() => loadComments())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.comment-page {
|
||||
.search-card { margin-bottom: 20px; }
|
||||
.comment-content { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.video-title { color: #666; font-size: 12px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.pagination { margin-top: 20px; display: flex; justify-content: flex-end; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="tag-page">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-form :inline="true" :model="queryParams">
|
||||
<el-form-item>
|
||||
<el-input v-model="queryParams.keyword" placeholder="标签名称" clearable @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>添加标签
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="tags">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="标签名称" />
|
||||
<el-table-column prop="useCount" label="使用次数" width="120" />
|
||||
<el-table-column prop="createTime" label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" text @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.size"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="400px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="标签名称" required>
|
||||
<el-input v-model="form.name" placeholder="请输入标签名称" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { categoryApi, type Tag } from '@/api/category.ts'
|
||||
import { formatDateTime } from '@/utils/format.ts'
|
||||
|
||||
const loading = ref(false)
|
||||
const tags = ref<Tag[]>([])
|
||||
const total = ref(0)
|
||||
const queryParams = reactive({ page: 1, size: 10, keyword: '' })
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('添加标签')
|
||||
const submitting = ref(false)
|
||||
const form = reactive({ id: undefined as number | undefined, name: '' })
|
||||
|
||||
const loadTags = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await categoryApi.getTags(queryParams)
|
||||
tags.value = res.records || res as any
|
||||
total.value = (res as any).total || tags.value.length
|
||||
} catch (error) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => { queryParams.page = 1; loadTags() }
|
||||
const handlePageChange = (page: number) => { queryParams.page = page; loadTags() }
|
||||
|
||||
const handleAdd = () => {
|
||||
form.id = undefined
|
||||
form.name = ''
|
||||
dialogTitle.value = '添加标签'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: Tag) => {
|
||||
form.id = row.id
|
||||
form.name = row.name
|
||||
dialogTitle.value = '编辑标签'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row: Tag) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该标签吗?', '提示', { type: 'warning' })
|
||||
await categoryApi.deleteTag(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadTags()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim()) { ElMessage.warning('请输入标签名称'); return }
|
||||
submitting.value = true
|
||||
try {
|
||||
if (form.id) {
|
||||
await categoryApi.updateTag({ id: form.id, name: form.name })
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await categoryApi.createTag({ name: form.name })
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadTags()
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadTags())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tag-page {
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.pagination { margin-top: 20px; display: flex; justify-content: flex-end; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,424 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="20" class="stat-cards">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-icon" style="background: linear-gradient(135deg, #667eea, #764ba2)">
|
||||
<el-icon :size="28"><User /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ statistics.totalUsers.toLocaleString() }}</div>
|
||||
<div class="stat-label">总用户数</div>
|
||||
<div class="stat-footer">
|
||||
<span>今日新增 {{ statistics.newUsersToday }}</span>
|
||||
<span :class="statistics.userGrowthRate >= 0 ? 'up' : 'down'">
|
||||
{{ statistics.userGrowthRate >= 0 ? '+' : '' }}{{ statistics.userGrowthRate }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-icon" style="background: linear-gradient(135deg, #f093fb, #f5576c)">
|
||||
<el-icon :size="28"><VideoCamera /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ statistics.totalVideos.toLocaleString() }}</div>
|
||||
<div class="stat-label">总视频数</div>
|
||||
<div class="stat-footer">
|
||||
<span>今日上传 {{ statistics.newVideosToday }}</span>
|
||||
<span :class="statistics.videoGrowthRate >= 0 ? 'up' : 'down'">
|
||||
{{ statistics.videoGrowthRate >= 0 ? '+' : '' }}{{ statistics.videoGrowthRate }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-icon" style="background: linear-gradient(135deg, #4facfe, #00f2fe)">
|
||||
<el-icon :size="28"><View /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ formatNumber(statistics.totalViews) }}</div>
|
||||
<div class="stat-label">总播放量</div>
|
||||
<div class="stat-footer">
|
||||
<span>今日播放 {{ formatNumber(statistics.viewsToday) }}</span>
|
||||
<span :class="statistics.viewGrowthRate >= 0 ? 'up' : 'down'">
|
||||
{{ statistics.viewGrowthRate >= 0 ? '+' : '' }}{{ statistics.viewGrowthRate }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-icon" style="background: linear-gradient(135deg, #fa709a, #fee140)">
|
||||
<el-icon :size="28"><DocumentChecked /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value text-warning">{{ statistics.pendingReview }}</div>
|
||||
<div class="stat-label">待审核视频</div>
|
||||
<div class="stat-footer">
|
||||
<el-button type="primary" size="small" text @click="goToReview">
|
||||
去审核
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<el-row :gutter="20" class="chart-row">
|
||||
<el-col :xs="24" :lg="16">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>数据趋势</span>
|
||||
<el-radio-group v-model="chartType" size="small">
|
||||
<el-radio-button label="user">用户</el-radio-button>
|
||||
<el-radio-button label="video">视频</el-radio-button>
|
||||
<el-radio-button label="view">播放</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="trendChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="8">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span>互动数据</span>
|
||||
</template>
|
||||
<div ref="pieChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 列表区域 -->
|
||||
<el-row :gutter="20" class="list-row">
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>热门视频</span>
|
||||
<el-button type="primary" text @click="goToVideoList">查看更多</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="hotVideos" style="width: 100%">
|
||||
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="userNickname" label="作者" width="100" />
|
||||
<el-table-column prop="viewCount" label="播放" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ formatNumber(row.viewCount) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="likeCount" label="点赞" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ formatNumber(row.likeCount) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>最新评论</span>
|
||||
<el-button type="primary" text @click="goToCommentList">查看更多</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="recentComments" style="width: 100%">
|
||||
<el-table-column prop="content" label="内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="userNickname" label="用户" width="100" />
|
||||
<el-table-column prop="createTime" label="时间" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.createTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as echarts from 'echarts'
|
||||
import { statisticsApi } from '@/api/statistics.ts'
|
||||
import { formatNumber, formatTime } from '@/utils/format.ts'
|
||||
import type { StatisticsData, PlatformVideo, PlatformComment, TrendData } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 统计数据
|
||||
const statistics = ref<StatisticsData>({
|
||||
totalUsers: 0,
|
||||
newUsersToday: 0,
|
||||
activeUsersToday: 0,
|
||||
userGrowthRate: 0,
|
||||
totalVideos: 0,
|
||||
newVideosToday: 0,
|
||||
pendingReview: 0,
|
||||
videoGrowthRate: 0,
|
||||
totalViews: 0,
|
||||
viewsToday: 0,
|
||||
viewGrowthRate: 0,
|
||||
totalLikes: 0,
|
||||
totalComments: 0,
|
||||
totalDanmakus: 0,
|
||||
userTrend: [],
|
||||
videoTrend: [],
|
||||
viewTrend: []
|
||||
})
|
||||
|
||||
// 列表数据
|
||||
const hotVideos = ref<PlatformVideo[]>([])
|
||||
const recentComments = ref<PlatformComment[]>([])
|
||||
|
||||
// 图表类型
|
||||
const chartType = ref<'user' | 'video' | 'view'>('user')
|
||||
|
||||
// 图表实例
|
||||
const trendChartRef = ref<HTMLElement>()
|
||||
const pieChartRef = ref<HTMLElement>()
|
||||
let trendChart: echarts.ECharts | null = null
|
||||
let pieChart: echarts.ECharts | null = null
|
||||
|
||||
// 加载数据
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
const data = await statisticsApi.getDashboard()
|
||||
statistics.value = data.statistics
|
||||
hotVideos.value = data.hotVideos
|
||||
recentComments.value = data.recentComments
|
||||
|
||||
// 初始化图表
|
||||
await nextTick()
|
||||
initCharts()
|
||||
} catch (error) {
|
||||
console.error('加载仪表盘数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化图表
|
||||
const initCharts = () => {
|
||||
// 趋势图表
|
||||
if (trendChartRef.value) {
|
||||
trendChart = echarts.init(trendChartRef.value)
|
||||
updateTrendChart()
|
||||
}
|
||||
|
||||
// 饼图
|
||||
if (pieChartRef.value) {
|
||||
pieChart = echarts.init(pieChartRef.value)
|
||||
pieChart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
legend: {
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}: {c}'
|
||||
},
|
||||
data: [
|
||||
{ value: statistics.value.totalLikes, name: '点赞', itemStyle: { color: '#f56c6c' } },
|
||||
{ value: statistics.value.totalComments, name: '评论', itemStyle: { color: '#e6a23c' } },
|
||||
{ value: statistics.value.totalDanmakus, name: '弹幕', itemStyle: { color: '#409eff' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 更新趋势图表
|
||||
const updateTrendChart = () => {
|
||||
if (!trendChart) return
|
||||
|
||||
let data: TrendData[] = []
|
||||
let title = ''
|
||||
|
||||
switch (chartType.value) {
|
||||
case 'user':
|
||||
data = statistics.value.userTrend
|
||||
title = '用户增长趋势'
|
||||
break
|
||||
case 'video':
|
||||
data = statistics.value.videoTrend
|
||||
title = '视频上传趋势'
|
||||
break
|
||||
case 'view':
|
||||
data = statistics.value.viewTrend
|
||||
title = '播放量趋势'
|
||||
break
|
||||
}
|
||||
|
||||
trendChart.setOption({
|
||||
title: {
|
||||
text: title,
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 14
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: data.map((item) => item.date)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: title,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(64, 158, 255, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(64, 158, 255, 0.1)' }
|
||||
])
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#409eff'
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#409eff'
|
||||
},
|
||||
data: data.map((item) => item.value)
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// 监听图表类型变化
|
||||
watch(chartType, () => {
|
||||
updateTrendChart()
|
||||
})
|
||||
|
||||
// 跳转方法
|
||||
const goToReview = () => router.push('/video/review')
|
||||
const goToVideoList = () => router.push('/video/list')
|
||||
const goToCommentList = () => router.push('/content/comment')
|
||||
|
||||
// 窗口大小变化时重新渲染图表
|
||||
const handleResize = () => {
|
||||
trendChart?.resize()
|
||||
pieChart?.resize()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDashboardData()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
trendChart?.dispose()
|
||||
pieChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard-page {
|
||||
.stat-cards {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
:deep(.el-card__body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
|
||||
.up {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.down {
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.list-row {
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="error-page">
|
||||
<div class="error-content">
|
||||
<h1 class="error-code">404</h1>
|
||||
<h2 class="error-message">页面未找到</h2>
|
||||
<p class="error-description">抱歉,您访问的页面不存在</p>
|
||||
<el-button type="primary" @click="goHome">返回首页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.error-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.error-content {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
|
||||
.error-code {
|
||||
font-size: 120px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 32px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 16px;
|
||||
margin-bottom: 30px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside :width="sidebarWidth" class="sidebar">
|
||||
<div class="logo">
|
||||
<span v-if="!appStore.sidebarCollapsed">HAOVP</span>
|
||||
<span v-else>H</span>
|
||||
</div>
|
||||
<el-scrollbar>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="appStore.sidebarCollapsed"
|
||||
:unique-opened="true"
|
||||
:collapse-transition="false"
|
||||
router
|
||||
class="sidebar-menu"
|
||||
>
|
||||
<template v-for="route in menuRoutes" :key="route.path">
|
||||
<!-- 有子菜单 -->
|
||||
<el-sub-menu
|
||||
v-if="route.children && route.children.length > 1"
|
||||
:index="route.path"
|
||||
>
|
||||
<template #title>
|
||||
<el-icon>
|
||||
<component :is="route.meta?.icon" />
|
||||
</el-icon>
|
||||
<span>{{ route.meta?.title }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
v-for="child in route.children.filter((c: any) => !c.meta?.hidden)"
|
||||
:key="child.path"
|
||||
:index="`${route.path}/${child.path}`"
|
||||
>
|
||||
<el-icon>
|
||||
<component :is="child.meta?.icon" />
|
||||
</el-icon>
|
||||
<span>{{ child.meta?.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<!-- 单个菜单项 -->
|
||||
<el-menu-item
|
||||
v-else-if="route.children && route.children.length === 1"
|
||||
:index="`${route.path}/${route.children[0].path}`"
|
||||
>
|
||||
<el-icon>
|
||||
<component :is="route.children[0].meta?.icon" />
|
||||
</el-icon>
|
||||
<span>{{ route.children[0].meta?.title }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</el-aside>
|
||||
|
||||
<el-container class="main-container">
|
||||
<!-- 顶部导航 -->
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-icon class="collapse-btn" @click="toggleSidebar">
|
||||
<component :is="appStore.sidebarCollapsed ? 'Expand' : 'Fold'" />
|
||||
</el-icon>
|
||||
<Breadcrumb />
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<div class="user-info">
|
||||
<el-avatar :src="userStore.avatar" :size="32">
|
||||
{{ userStore.nickname?.charAt(0) }}
|
||||
</el-avatar>
|
||||
<span class="nickname">{{ userStore.nickname }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<el-icon><User /></el-icon>
|
||||
个人中心
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 标签页导航 -->
|
||||
<TagsView />
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-main class="main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-transform" mode="out-in">
|
||||
<keep-alive :include="cachedViews">
|
||||
<component :is="Component" :key="key" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { useUserStore, useAppStore, useTagsViewStore } from '@/stores'
|
||||
import Breadcrumb from '@/components/Breadcrumb.vue'
|
||||
import TagsView from '@/components/TagsView.vue'
|
||||
import { asyncRoutes } from '@/router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const appStore = useAppStore()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
// 侧边栏宽度
|
||||
const sidebarWidth = computed(() => (appStore.sidebarCollapsed ? '64px' : '210px'))
|
||||
|
||||
// 当前激活菜单
|
||||
const activeMenu = computed(() => route.path)
|
||||
|
||||
// 菜单路由
|
||||
const menuRoutes = computed(() => {
|
||||
return asyncRoutes.filter((route) => !route.meta?.hidden && route.children?.length)
|
||||
})
|
||||
|
||||
// 缓存的页面
|
||||
const cachedViews = computed(() => tagsViewStore.cachedViews)
|
||||
|
||||
// 路由key
|
||||
const key = computed(() => route.fullPath)
|
||||
|
||||
// 切换侧边栏
|
||||
const toggleSidebar = () => {
|
||||
appStore.toggleSidebar()
|
||||
}
|
||||
|
||||
// 下拉菜单命令
|
||||
const handleCommand = async (command: string) => {
|
||||
switch (command) {
|
||||
case 'profile':
|
||||
router.push('/profile')
|
||||
break
|
||||
case 'logout':
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式布局
|
||||
const handleResize = () => {
|
||||
const width = window.innerWidth
|
||||
if (width < 768) {
|
||||
appStore.setDevice('mobile')
|
||||
} else {
|
||||
appStore.setDevice('desktop')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout-container {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #304156;
|
||||
transition: width 0.3s;
|
||||
overflow: hidden;
|
||||
|
||||
.logo {
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
background: #263445;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
|
||||
:deep(.el-menu-item),
|
||||
:deep(.el-sub-menu__title) {
|
||||
color: #bfcbd9;
|
||||
|
||||
&:hover {
|
||||
background: #263445;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
color: #409eff;
|
||||
background: #263445;
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu .el-menu-item) {
|
||||
background: #1f2d3d;
|
||||
|
||||
&:hover {
|
||||
background: #001528;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
z-index: 10;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
|
||||
&:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
.nickname {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
background: #f0f2f5;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 过渡动画
|
||||
.fade-transform-enter-active,
|
||||
.fade-transform-leave-active {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.fade-transform-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.fade-transform-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<div class="login-header">
|
||||
<h1 class="logo">HAOVP</h1>
|
||||
<p class="title">管理后台</p>
|
||||
</div>
|
||||
|
||||
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="login-form">
|
||||
<!-- 验证码 -->
|
||||
<div class="captcha-wrapper">
|
||||
<el-image
|
||||
:src="captchaImage"
|
||||
fit="contain"
|
||||
class="captcha-image"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
<template #error>
|
||||
<div class="captcha-error" @click="refreshCaptcha">点击刷新</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</div>
|
||||
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="用户名"
|
||||
prefix-icon="User"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
prefix-icon="Lock"
|
||||
size="large"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="captcha">
|
||||
<el-input
|
||||
v-model="loginForm.captcha"
|
||||
placeholder="验证码"
|
||||
prefix-icon="Key"
|
||||
size="large"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<template #append>
|
||||
<el-image
|
||||
:src="captchaImage"
|
||||
fit="contain"
|
||||
class="captcha-btn"
|
||||
@click="refreshCaptcha"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
class="login-btn"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { authApi, type CaptchaResult } from '@/api/auth.ts'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loginFormRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const captchaKey = ref('')
|
||||
const captchaImage = ref('')
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha: ''
|
||||
})
|
||||
|
||||
const loginRules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
|
||||
],
|
||||
captcha: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 获取验证码
|
||||
const refreshCaptcha = async () => {
|
||||
try {
|
||||
const res: CaptchaResult = await authApi.getCaptcha()
|
||||
captchaKey.value = res.captchaKey
|
||||
captchaImage.value = res.captchaImage
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return
|
||||
|
||||
await loginFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login({
|
||||
username: loginForm.username,
|
||||
password: loginForm.password,
|
||||
captcha: loginForm.captcha,
|
||||
captchaKey: captchaKey.value
|
||||
})
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
|
||||
// 跳转到之前的页面或首页
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
router.replace(redirect)
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '登录失败')
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshCaptcha()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.logo {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
background: linear-gradient(to right, #00a1d6, #fb7299);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.captcha-image {
|
||||
height: 50px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.captcha-error {
|
||||
width: 150px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
.captcha-btn {
|
||||
width: 100px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="admin-page">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-form :inline="true" :model="queryParams">
|
||||
<el-form-item>
|
||||
<el-input v-model="queryParams.keyword" placeholder="用户名/昵称" clearable @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>添加管理员
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="adminList">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="管理员信息" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="admin-info">
|
||||
<el-avatar :src="row.avatar" :size="40">{{ row.nickname?.charAt(0) }}</el-avatar>
|
||||
<div class="info">
|
||||
<div class="nickname">{{ row.nickname }}</div>
|
||||
<div class="username">@{{ row.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="email" label="邮箱" width="180" />
|
||||
<el-table-column prop="phone" label="手机号" width="120" />
|
||||
<el-table-column label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag>{{ row.role === 'admin' ? '超级管理员' : '管理员' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
|
||||
{{ row.status === 1 ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastLoginTime" label="最后登录" width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.lastLoginTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button :type="row.status === 1 ? 'warning' : 'success'" text @click="handleToggleStatus(row)">
|
||||
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button type="warning" text @click="handleResetPassword(row)">重置密码</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.size"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
|
||||
<el-form-item label="用户名" prop="username" v-if="!form.id">
|
||||
<el-input v-model="form.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="form.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="role">
|
||||
<el-select v-model="form.role" placeholder="请选择角色">
|
||||
<el-option label="管理员" value="manager" />
|
||||
<el-option label="超级管理员" value="admin" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password" v-if="!form.id">
|
||||
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { adminApi, type AdminUser } from '@/api/admin.ts'
|
||||
import { formatDateTime } from '@/utils/format.ts'
|
||||
|
||||
const queryParams = reactive({ page: 1, size: 10, keyword: '' })
|
||||
const loading = ref(false)
|
||||
const adminList = ref<AdminUser[]>([])
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('添加管理员')
|
||||
const submitting = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive({
|
||||
id: undefined as number | undefined,
|
||||
username: '',
|
||||
nickname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role: 'manager',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const loadAdminList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminApi.getList(queryParams)
|
||||
adminList.value = res.records
|
||||
total.value = res.total
|
||||
} catch (error) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => { queryParams.page = 1; loadAdminList() }
|
||||
const handlePageChange = (page: number) => { queryParams.page = page; loadAdminList() }
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = undefined
|
||||
form.username = ''
|
||||
form.nickname = ''
|
||||
form.email = ''
|
||||
form.phone = ''
|
||||
form.role = 'manager'
|
||||
form.password = ''
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
resetForm()
|
||||
dialogTitle.value = '添加管理员'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: AdminUser) => {
|
||||
form.id = row.id
|
||||
form.username = row.username
|
||||
form.nickname = row.nickname
|
||||
form.email = row.email || ''
|
||||
form.phone = row.phone || ''
|
||||
form.role = row.role
|
||||
form.password = ''
|
||||
dialogTitle.value = '编辑管理员'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (row: AdminUser) => {
|
||||
const newStatus = row.status === 1 ? 0 : 1
|
||||
const action = newStatus === 0 ? '禁用' : '启用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要${action}该管理员吗?`, '提示', { type: 'warning' })
|
||||
await adminApi.updateStatus(row.id, newStatus)
|
||||
ElMessage.success(`${action}成功`)
|
||||
loadAdminList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleResetPassword = async (row: AdminUser) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要重置该管理员的密码吗?', '提示', { type: 'warning' })
|
||||
await adminApi.resetPassword(row.id)
|
||||
ElMessage.success('密码重置成功')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitting.value = true
|
||||
try {
|
||||
if (form.id) {
|
||||
await adminApi.update({ id: form.id, nickname: form.nickname, email: form.email, phone: form.phone, role: form.role })
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await adminApi.create({ username: form.username, password: form.password, nickname: form.nickname, email: form.email, phone: form.phone, role: form.role })
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadAdminList()
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => loadAdminList())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-page {
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.admin-info { display: flex; align-items: center; gap: 12px; .info { .nickname { font-weight: 500; } .username { font-size: 12px; color: #999; } } }
|
||||
.pagination { margin-top: 20px; display: flex; justify-content: flex-end; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="user-detail-page">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-page-header @back="goBack">
|
||||
<template #content>
|
||||
<span class="page-title">用户详情</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-skeleton :loading="loading" animated>
|
||||
<template #default>
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="用户ID">{{ user?.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ user?.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="昵称">{{ user?.nickname }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ user?.email }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ user?.phone }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">
|
||||
{{ user?.gender === 1 ? '男' : user?.gender === 2 ? '女' : '未知' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="等级">Lv.{{ user?.level }}</el-descriptions-item>
|
||||
<el-descriptions-item label="VIP等级">
|
||||
{{ user?.vipLevel > 0 ? `VIP${user?.vipLevel}` : '普通用户' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="user?.status === 1 ? 'success' : 'danger'">
|
||||
{{ user?.status === 1 ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="粉丝数">{{ user?.followerCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="关注数">{{ user?.followingCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="视频数">{{ user?.videoCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="获赞数">{{ user?.likeCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ formatDateTime(user?.createTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDateTime(user?.updateTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="个人签名" :span="3">
|
||||
{{ user?.signature || '暂无' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</el-skeleton>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
import { formatDateTime } from '@/utils/format.ts'
|
||||
import type { PlatformUser } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const user = ref<PlatformUser | null>(null)
|
||||
|
||||
const loadUser = async () => {
|
||||
const userId = route.params.id
|
||||
if (!userId) {
|
||||
ElMessage.error('用户ID不存在')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
user.value = await userApi.getDetail(Number(userId))
|
||||
} catch (error) {
|
||||
ElMessage.error('获取用户信息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUser()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-detail-page {
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<div class="user-list-page">
|
||||
<!-- 搜索表单 -->
|
||||
<el-card shadow="never" class="search-card">
|
||||
<el-form :model="queryParams" inline>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="用户名/昵称/手机号"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="全部" clearable>
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="注册时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-card shadow="never" class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>用户列表</span>
|
||||
<el-button type="primary" @click="handleExport">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="userList" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="用户信息" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="user-info">
|
||||
<el-avatar :src="row.avatar" :size="40">
|
||||
{{ row.nickname?.charAt(0) }}
|
||||
</el-avatar>
|
||||
<div class="info">
|
||||
<div class="nickname">{{ row.nickname }}</div>
|
||||
<div class="username">@{{ row.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="email" label="邮箱" width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="phone" label="手机号" width="120" />
|
||||
<el-table-column label="等级" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="warning">Lv.{{ row.level }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数据" width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="stats">
|
||||
<span>视频 {{ row.videoCount }}</span>
|
||||
<span>粉丝 {{ formatNumber(row.followerCount) }}</span>
|
||||
<span>获赞 {{ formatNumber(row.likeCount) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
|
||||
{{ row.status === 1 ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="注册时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.createTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text @click="handleDetail(row)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="row.status === 1 ? 'danger' : 'success'"
|
||||
text
|
||||
@click="handleStatus(row)"
|
||||
>
|
||||
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button type="warning" text @click="handleResetPassword(row)">
|
||||
重置密码
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 用户详情对话框 -->
|
||||
<el-dialog v-model="detailVisible" title="用户详情" width="600px">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户ID">{{ currentUser?.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ currentUser?.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="昵称">{{ currentUser?.nickname }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ currentUser?.email }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ currentUser?.phone }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">
|
||||
{{ currentUser?.gender === 1 ? '男' : currentUser?.gender === 2 ? '女' : '未知' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="等级">Lv.{{ currentUser?.level }}</el-descriptions-item>
|
||||
<el-descriptions-item label="VIP等级">
|
||||
{{ currentUser?.vipLevel > 0 ? `VIP${currentUser?.vipLevel}` : '普通用户' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="粉丝数">{{ currentUser?.followerCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="关注数">{{ currentUser?.followingCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="视频数">{{ currentUser?.videoCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="获赞数">{{ currentUser?.likeCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="currentUser?.status === 1 ? 'success' : 'danger'">
|
||||
{{ currentUser?.status === 1 ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ formatDateTime(currentUser?.createTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDateTime(currentUser?.updateTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="个人签名" :span="2">
|
||||
{{ currentUser?.signature || '暂无' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
import { formatDateTime, formatNumber } from '@/utils/format.ts'
|
||||
import type { PlatformUser, UserQueryParams } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive<UserQueryParams>({
|
||||
page: 1,
|
||||
size: 10,
|
||||
keyword: '',
|
||||
status: undefined,
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
})
|
||||
|
||||
// 日期范围
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const userList = ref<PlatformUser[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
// 详情
|
||||
const detailVisible = ref(false)
|
||||
const currentUser = ref<PlatformUser | null>(null)
|
||||
|
||||
// 加载用户列表
|
||||
const loadUserList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await userApi.getList(queryParams)
|
||||
userList.value = res.records
|
||||
total.value = res.total
|
||||
} catch (error) {
|
||||
console.error('加载用户列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
loadUserList()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
queryParams.keyword = ''
|
||||
queryParams.status = undefined
|
||||
queryParams.startDate = ''
|
||||
queryParams.endDate = ''
|
||||
dateRange.value = null
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// 日期变化
|
||||
const handleDateChange = (value: [string, string] | null) => {
|
||||
if (value) {
|
||||
queryParams.startDate = value[0]
|
||||
queryParams.endDate = value[1]
|
||||
} else {
|
||||
queryParams.startDate = ''
|
||||
queryParams.endDate = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.size = size
|
||||
loadUserList()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
queryParams.page = page
|
||||
loadUserList()
|
||||
}
|
||||
|
||||
// 详情
|
||||
const handleDetail = async (row: PlatformUser) => {
|
||||
try {
|
||||
currentUser.value = await userApi.getDetail(row.id)
|
||||
detailVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error('获取用户详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换状态
|
||||
const handleStatus = async (row: PlatformUser) => {
|
||||
const newStatus = row.status === 1 ? 0 : 1
|
||||
const action = newStatus === 0 ? '禁用' : '启用'
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要${action}该用户吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await userApi.updateStatus(row.id, newStatus)
|
||||
ElMessage.success(`${action}成功`)
|
||||
loadUserList()
|
||||
} catch (error) {
|
||||
// 取消或失败
|
||||
}
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
const handleResetPassword = async (row: PlatformUser) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要重置该用户的密码吗?密码将重置为默认密码。', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await userApi.resetPassword(row.id)
|
||||
ElMessage.success('密码重置成功')
|
||||
} catch (error) {
|
||||
// 取消或失败
|
||||
}
|
||||
}
|
||||
|
||||
// 导出
|
||||
const handleExport = () => {
|
||||
ElMessage.info('导出功能开发中')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUserList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-list-page {
|
||||
.search-card {
|
||||
margin-bottom: 20px;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.table-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.info {
|
||||
.nickname {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="video-detail-page">
|
||||
<el-page-header @back="goBack">
|
||||
<template #content><span class="page-title">视频详情</span></template>
|
||||
</el-page-header>
|
||||
|
||||
<el-skeleton :loading="loading" animated class="mt-20">
|
||||
<template #default>
|
||||
<el-row :gutter="20" v-if="video">
|
||||
<el-col :span="16">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>基本信息</span></template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="视频ID">{{ video.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="getStatusType(video.status)">{{ getStatusText(video.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="标题" :span="2">{{ video.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="简介" :span="2">{{ video.description || '暂无' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="作者">{{ video.userNickname }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分类">{{ video.categoryName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="时长">{{ formatDuration(video.duration) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="大小">{{ formatFileSize(video.size) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="格式">{{ video.format }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分辨率">{{ video.width }} x {{ video.height }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(video.createTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发布时间">{{ formatDateTime(video.publishTime) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-20">
|
||||
<template #header><span>视频预览</span></template>
|
||||
<video :src="video.videoUrl" controls class="video-player" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>数据统计</span></template>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="播放量">{{ formatNumber(video.viewCount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="点赞数">{{ formatNumber(video.likeCount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="投币数">{{ formatNumber(video.coinCount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收藏数">{{ formatNumber(video.favoriteCount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="评论数">{{ formatNumber(video.commentCount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="弹幕数">{{ formatNumber(video.danmakuCount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分享数">{{ formatNumber(video.shareCount) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-20">
|
||||
<template #header><span>操作</span></template>
|
||||
<div class="action-buttons">
|
||||
<el-button v-if="video.status === 0" type="success" @click="handleReview(1)">通过审核</el-button>
|
||||
<el-button v-if="video.status === 0" type="danger" @click="handleReview(2)">拒绝</el-button>
|
||||
<el-button v-if="video.status === 1" type="warning" @click="handleDisable">下架视频</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
</el-skeleton>
|
||||
|
||||
<el-dialog v-model="rejectVisible" title="拒绝原因" width="400px">
|
||||
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="请输入拒绝原因" />
|
||||
<template #footer>
|
||||
<el-button @click="rejectVisible = false">取消</el-button>
|
||||
<el-button type="danger" @click="confirmReject">确认拒绝</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
import { formatDateTime, formatDuration, formatFileSize, formatNumber } from '@/utils/format.ts'
|
||||
import type { PlatformVideo } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const loading = ref(false)
|
||||
const video = ref<PlatformVideo | null>(null)
|
||||
const rejectVisible = ref(false)
|
||||
const rejectReason = ref('')
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
const loadVideo = async () => {
|
||||
const videoId = route.params.id
|
||||
if (!videoId) return
|
||||
loading.value = true
|
||||
try {
|
||||
video.value = await videoApi.getDetail(Number(videoId))
|
||||
} catch (error) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleReview = async (status: number) => {
|
||||
if (status === 2) {
|
||||
rejectVisible.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要通过该视频的审核吗?', '提示', { type: 'success' })
|
||||
await videoApi.review({ videoId: video.value!.id, status: 1 })
|
||||
ElMessage.success('审核通过')
|
||||
loadVideo()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const confirmReject = async () => {
|
||||
if (!rejectReason.value.trim()) {
|
||||
ElMessage.warning('请输入拒绝原因')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await videoApi.review({ videoId: video.value!.id, status: 2, reason: rejectReason.value })
|
||||
ElMessage.success('已拒绝')
|
||||
rejectVisible.value = false
|
||||
loadVideo()
|
||||
} catch {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisable = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要下架该视频吗?', '提示', { type: 'warning' })
|
||||
await videoApi.disable(video.value!.id)
|
||||
ElMessage.success('下架成功')
|
||||
loadVideo()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const getStatusType = (status: number) => ({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' }[status] || 'info')
|
||||
const getStatusText = (status: number) => ({ 0: '待审核', 1: '已发布', 2: '已拒绝', 3: '已下架' }[status] || '未知')
|
||||
|
||||
onMounted(() => loadVideo())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.video-detail-page {
|
||||
.page-title { font-size: 18px; font-weight: 500; }
|
||||
.mt-20 { margin-top: 20px; }
|
||||
.video-player { width: 100%; max-height: 400px; }
|
||||
.action-buttons { display: flex; flex-direction: column; gap: 10px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="video-list-page">
|
||||
<!-- 搜索表单 -->
|
||||
<el-card shadow="never" class="search-card">
|
||||
<el-form :model="queryParams" inline>
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="queryParams.keyword" placeholder="视频标题" clearable @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="全部" clearable>
|
||||
<el-option label="待审核" :value="0" />
|
||||
<el-option label="已发布" :value="1" />
|
||||
<el-option label="已下架" :value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-table v-loading="loading" :data="videoList" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="视频" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="video-info">
|
||||
<el-image :src="row.coverUrl" fit="cover" class="cover">
|
||||
<template #error>
|
||||
<div class="cover-error"><el-icon><VideoCamera /></el-icon></div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="info">
|
||||
<div class="title">{{ row.title }}</div>
|
||||
<div class="meta">
|
||||
<span>{{ formatDuration(row.duration) }}</span>
|
||||
<span>{{ formatFileSize(row.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="作者" width="120">
|
||||
<template #default="{ row }">
|
||||
<div class="author">
|
||||
<el-avatar :src="row.userAvatar" :size="24">{{ row.userNickname?.charAt(0) }}</el-avatar>
|
||||
<span>{{ row.userNickname }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="categoryName" label="分类" width="100" />
|
||||
<el-table-column label="数据" width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="stats">
|
||||
<span>播放 {{ formatNumber(row.viewCount) }}</span>
|
||||
<span>点赞 {{ formatNumber(row.likeCount) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text @click="handleDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.status === 1" type="danger" text @click="handleDisable(row)">下架</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
import { formatDateTime, formatDuration, formatFileSize, formatNumber } from '@/utils/format.ts'
|
||||
import type { PlatformVideo, VideoQueryParams } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const queryParams = reactive<VideoQueryParams>({
|
||||
page: 1,
|
||||
size: 10,
|
||||
keyword: '',
|
||||
status: undefined
|
||||
})
|
||||
const loading = ref(false)
|
||||
const videoList = ref<PlatformVideo[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const loadVideoList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await videoApi.getList(queryParams)
|
||||
videoList.value = res.records
|
||||
total.value = res.total
|
||||
} catch (error) {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
loadVideoList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.keyword = ''
|
||||
queryParams.status = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.size = size
|
||||
loadVideoList()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
queryParams.page = page
|
||||
loadVideoList()
|
||||
}
|
||||
|
||||
const handleDetail = (row: PlatformVideo) => {
|
||||
router.push(`/video/detail/${row.id}`)
|
||||
}
|
||||
|
||||
const handleDisable = async (row: PlatformVideo) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要下架该视频吗?', '提示', { type: 'warning' })
|
||||
await videoApi.disable(row.id)
|
||||
ElMessage.success('下架成功')
|
||||
loadVideoList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const getStatusType = (status: number) => ({ 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' }[status] || 'info')
|
||||
const getStatusText = (status: number) => ({ 0: '待审核', 1: '已发布', 2: '已拒绝', 3: '已下架' }[status] || '未知')
|
||||
|
||||
onMounted(() => loadVideoList())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.video-list-page {
|
||||
.search-card { margin-bottom: 20px; }
|
||||
.video-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
.cover { width: 120px; height: 68px; border-radius: 4px; background: #f5f5f5; }
|
||||
.cover-error { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #ccc; }
|
||||
.info { flex: 1; .title { font-weight: 500; margin-bottom: 4px; } .meta { font-size: 12px; color: #999; display: flex; gap: 12px; } }
|
||||
}
|
||||
.author { display: flex; align-items: center; gap: 8px; }
|
||||
.stats { font-size: 12px; color: #666; display: flex; gap: 12px; }
|
||||
.pagination { margin-top: 20px; display: flex; justify-content: flex-end; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<div class="video-review-page">
|
||||
<!-- 搜索表单 -->
|
||||
<el-card shadow="never" class="search-card">
|
||||
<el-form :model="queryParams" inline>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="视频标题"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核状态">
|
||||
<el-select v-model="queryParams.auditStatus" placeholder="全部" clearable>
|
||||
<el-option label="待审核" :value="0" />
|
||||
<el-option label="已通过" :value="1" />
|
||||
<el-option label="已拒绝" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="queryParams.categoryId" placeholder="全部" clearable>
|
||||
<el-option
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
:label="cat.name"
|
||||
:value="cat.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-card shadow="never" class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>待审核视频 ({{ total }})</span>
|
||||
<el-tag type="danger" v-if="pendingCount > 0">
|
||||
有 {{ pendingCount }} 个视频待审核
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="videoList" style="width: 100%">
|
||||
<el-table-column label="视频" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="video-info">
|
||||
<el-image
|
||||
:src="row.coverUrl"
|
||||
fit="cover"
|
||||
class="cover"
|
||||
>
|
||||
<template #error>
|
||||
<div class="cover-error">
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="info">
|
||||
<div class="title">{{ row.title }}</div>
|
||||
<div class="meta">
|
||||
<span>{{ formatDuration(row.duration) }}</span>
|
||||
<span>{{ formatFileSize(row.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="作者" width="120">
|
||||
<template #default="{ row }">
|
||||
<div class="author">
|
||||
<el-avatar :src="row.userAvatar" :size="24">
|
||||
{{ row.userNickname?.charAt(0) }}
|
||||
</el-avatar>
|
||||
<span>{{ row.userNickname }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="categoryName" label="分类" width="100" />
|
||||
<el-table-column label="审核状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getAuditStatusType(row.auditStatus)">
|
||||
{{ getAuditStatusText(row.auditStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="提交时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.createTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" text @click="handlePreview(row)">
|
||||
预览
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.auditStatus === 0"
|
||||
type="success"
|
||||
text
|
||||
@click="handleReview(row, 1)"
|
||||
>
|
||||
通过
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.auditStatus === 0"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleReview(row, 2)"
|
||||
>
|
||||
拒绝
|
||||
</el-button>
|
||||
<el-button type="info" text @click="handleDetail(row)">
|
||||
详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 视频预览对话框 -->
|
||||
<el-dialog v-model="previewVisible" title="视频预览" width="800px" destroy-on-close>
|
||||
<div class="preview-container">
|
||||
<video
|
||||
v-if="previewVideo"
|
||||
:src="previewVideo.videoUrl"
|
||||
controls
|
||||
class="video-player"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="success" @click="handleReview(previewVideo!, 1)">通过</el-button>
|
||||
<el-button type="danger" @click="handleReview(previewVideo!, 2)">拒绝</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 拒绝原因对话框 -->
|
||||
<el-dialog v-model="rejectVisible" title="拒绝原因" width="400px">
|
||||
<el-form>
|
||||
<el-form-item label="拒绝原因">
|
||||
<el-input
|
||||
v-model="rejectReason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入拒绝原因"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rejectVisible = false">取消</el-button>
|
||||
<el-button type="danger" @click="confirmReject">确认拒绝</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
import { categoryApi } from '@/api/category.ts'
|
||||
import { formatDateTime, formatDuration, formatFileSize } from '@/utils/format.ts'
|
||||
import type { PlatformVideo, VideoQueryParams, Category } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive<VideoQueryParams>({
|
||||
page: 1,
|
||||
size: 10,
|
||||
keyword: '',
|
||||
categoryId: undefined,
|
||||
auditStatus: 0 // 默认待审核
|
||||
})
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const videoList = ref<PlatformVideo[]>([])
|
||||
const total = ref(0)
|
||||
const pendingCount = ref(0)
|
||||
const categories = ref<Category[]>([])
|
||||
|
||||
// 预览
|
||||
const previewVisible = ref(false)
|
||||
const previewVideo = ref<PlatformVideo | null>(null)
|
||||
|
||||
// 拒绝
|
||||
const rejectVisible = ref(false)
|
||||
const rejectReason = ref('')
|
||||
const rejectVideo = ref<PlatformVideo | null>(null)
|
||||
|
||||
// 加载分类
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
categories.value = await categoryApi.getList()
|
||||
} catch (error) {
|
||||
console.error('加载分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载视频列表
|
||||
const loadVideoList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await videoApi.getList(queryParams)
|
||||
videoList.value = res.records
|
||||
total.value = res.total
|
||||
} catch (error) {
|
||||
console.error('加载视频列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载待审核数量
|
||||
const loadPendingCount = async () => {
|
||||
try {
|
||||
const stats = await videoApi.getStats()
|
||||
pendingCount.value = stats.pendingReview
|
||||
} catch (error) {
|
||||
console.error('加载统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
loadVideoList()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
queryParams.keyword = ''
|
||||
queryParams.categoryId = undefined
|
||||
queryParams.auditStatus = 0
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.size = size
|
||||
loadVideoList()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
queryParams.page = page
|
||||
loadVideoList()
|
||||
}
|
||||
|
||||
// 预览
|
||||
const handlePreview = (row: PlatformVideo) => {
|
||||
previewVideo.value = row
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
// 详情
|
||||
const handleDetail = (row: PlatformVideo) => {
|
||||
router.push(`/video/detail/${row.id}`)
|
||||
}
|
||||
|
||||
// 审核
|
||||
const handleReview = async (row: PlatformVideo, status: number) => {
|
||||
if (status === 2) {
|
||||
// 拒绝需要填写原因
|
||||
rejectVideo.value = row
|
||||
rejectReason.value = ''
|
||||
rejectVisible.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要通过该视频的审核吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'success'
|
||||
})
|
||||
|
||||
await videoApi.review({
|
||||
videoId: row.id,
|
||||
status: 1
|
||||
})
|
||||
|
||||
ElMessage.success('审核通过')
|
||||
loadVideoList()
|
||||
loadPendingCount()
|
||||
} catch (error) {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
|
||||
// 确认拒绝
|
||||
const confirmReject = async () => {
|
||||
if (!rejectVideo.value) return
|
||||
|
||||
if (!rejectReason.value.trim()) {
|
||||
ElMessage.warning('请输入拒绝原因')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await videoApi.review({
|
||||
videoId: rejectVideo.value.id,
|
||||
status: 2,
|
||||
reason: rejectReason.value
|
||||
})
|
||||
|
||||
ElMessage.success('已拒绝')
|
||||
rejectVisible.value = false
|
||||
loadVideoList()
|
||||
loadPendingCount()
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 审核状态文本
|
||||
const getAuditStatusText = (status: number) => {
|
||||
const map: Record<number, string> = {
|
||||
0: '待审核',
|
||||
1: '已通过',
|
||||
2: '已拒绝'
|
||||
}
|
||||
return map[status] || '未知'
|
||||
}
|
||||
|
||||
// 审核状态类型
|
||||
const getAuditStatusType = (status: number) => {
|
||||
const map: Record<number, string> = {
|
||||
0: 'warning',
|
||||
1: 'success',
|
||||
2: 'danger'
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
loadVideoList()
|
||||
loadPendingCount()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.video-review-page {
|
||||
.search-card {
|
||||
margin-bottom: 20px;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.table-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.cover {
|
||||
width: 120px;
|
||||
height: 68px;
|
||||
border-radius: 4px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.cover-error {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
max-height: 450px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["vite/client", "element-plus/global"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json"}]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
imports: ['vue', 'vue-router', 'pinia'],
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/auto-imports.d.ts'
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/components.d.ts'
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3100,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:9000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'element-plus': ['element-plus'],
|
||||
'echarts': ['echarts'],
|
||||
'vendor': ['vue', 'vue-router', 'pinia']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
# 应用配置
|
||||
VITE_APP_TITLE=HAOVP视频平台
|
||||
VITE_API_BASE_URL=/api
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="description" content="高可用在线视频平台 - 用户端" />
|
||||
<title>HAOVP - 在线视频平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+6105
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "video-platform-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"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",
|
||||
"@vant/use": "^1.6.0",
|
||||
"artplayer": "^5.1.7",
|
||||
"artplayer-plugin-danmuku": "^5.1.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"nprogress": "^0.2.0",
|
||||
"@iconify/vue": "^4.1.1"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+3837
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#00a1d6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fb7299;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="45" fill="url(#gradient)"/>
|
||||
<polygon points="40,30 40,70 75,50" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 432 B |
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// App 根组件
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type {
|
||||
LoginParams,
|
||||
LoginResult,
|
||||
RegisterParams,
|
||||
CaptchaResult,
|
||||
AuthUser
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
export const authApi = {
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
login(params: LoginParams): Promise<LoginResult> {
|
||||
return request.post('/auth/login', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
register(params: RegisterParams): Promise<string> {
|
||||
return request.post('/auth/register', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
*/
|
||||
getCaptcha(): Promise<CaptchaResult> {
|
||||
return request.get('/auth/captcha')
|
||||
},
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
*/
|
||||
refreshToken(refreshToken: string): Promise<LoginResult> {
|
||||
return request.post('/auth/refresh', { refreshToken })
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
logout(): Promise<void> {
|
||||
return request.post('/auth/logout')
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
getCurrentUser(): Promise<AuthUser> {
|
||||
return request.get('/auth/current')
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据用户ID获取用户名
|
||||
*/
|
||||
getUsername(userId: number): Promise<string> {
|
||||
return request.get(`/auth/username/${userId}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type { Category, Tag, PageResult, PageParams } from '@/types'
|
||||
|
||||
/**
|
||||
* 分类相关 API
|
||||
*/
|
||||
export const categoryApi = {
|
||||
/**
|
||||
* 获取分类树
|
||||
*/
|
||||
getCategoryTree(): Promise<Category[]> {
|
||||
return request.get('/content/category/tree')
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取分类列表
|
||||
*/
|
||||
getCategoryList(): Promise<Category[]> {
|
||||
return request.get('/content/category/list')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签相关 API
|
||||
*/
|
||||
export const tagApi = {
|
||||
/**
|
||||
* 获取标签列表
|
||||
*/
|
||||
getTagList(): Promise<Tag[]> {
|
||||
return request.get('/content/tag/list')
|
||||
},
|
||||
|
||||
/**
|
||||
* 分页获取标签
|
||||
*/
|
||||
getTagPage(params?: PageParams): Promise<PageResult<Tag>> {
|
||||
return request.get('/content/tag/page', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取视频标签
|
||||
*/
|
||||
getVideoTags(videoId: number): Promise<Tag[]> {
|
||||
return request.get(`/content/tag/video/${videoId}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type { Comment, CommentSendParams, CommentQueryParams, PageResult } from '@/types'
|
||||
|
||||
/**
|
||||
* 评论相关 API
|
||||
*/
|
||||
export const commentApi = {
|
||||
/**
|
||||
* 发表评论
|
||||
*/
|
||||
addComment(params: CommentSendParams): Promise<Comment> {
|
||||
return request.post('/comment', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取评论列表(公开接口)
|
||||
*/
|
||||
getCommentList(params: CommentQueryParams): Promise<PageResult<Comment>> {
|
||||
return request.get('/comment/public/list', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取评论详情(公开接口)
|
||||
*/
|
||||
getCommentDetail(id: number): Promise<Comment> {
|
||||
return request.get(`/comment/public/detail/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除评论
|
||||
*/
|
||||
deleteComment(id: number): Promise<void> {
|
||||
return request.delete(`/comment/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 点赞评论
|
||||
*/
|
||||
likeComment(id: number): Promise<void> {
|
||||
return request.post(`/comment/like/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 取消点赞评论
|
||||
*/
|
||||
unlikeComment(id: number): Promise<void> {
|
||||
return request.delete(`/comment/like/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 置顶评论
|
||||
*/
|
||||
topComment(id: number): Promise<void> {
|
||||
return request.post(`/comment/top/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 取消置顶评论
|
||||
*/
|
||||
untopComment(id: number): Promise<void> {
|
||||
return request.delete(`/comment/top/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取评论统计信息(公开接口)
|
||||
*/
|
||||
getCommentStat(videoId: number): Promise<{ total: number; hotCount: number }> {
|
||||
return request.get(`/comment/public/stat/${videoId}`)
|
||||
},
|
||||
|
||||
// ==================== 回复相关 ====================
|
||||
|
||||
/**
|
||||
* 添加回复
|
||||
*/
|
||||
addReply(params: { commentId: number; content: string; replyId?: number }): Promise<Comment> {
|
||||
return request.post('/comment/reply', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取评论的回复列表(公开接口)
|
||||
*/
|
||||
getReplyList(commentId: number, params?: { page?: number; size?: number }): Promise<PageResult<Comment>> {
|
||||
return request.get(`/comment/reply/public/list/${commentId}`, params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取评论的热门回复(公开接口)
|
||||
*/
|
||||
getHotReplies(commentId: number, limit?: number): Promise<Comment[]> {
|
||||
return request.get(`/comment/reply/public/hot/${commentId}`, { limit })
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除回复
|
||||
*/
|
||||
deleteReply(id: number): Promise<void> {
|
||||
return request.delete(`/comment/reply/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 点赞回复
|
||||
*/
|
||||
likeReply(id: number): Promise<void> {
|
||||
return request.post(`/comment/reply/like/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 取消点赞回复
|
||||
*/
|
||||
unlikeReply(id: number): Promise<void> {
|
||||
return request.delete(`/comment/reply/like/${id}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type { Danmaku, DanmakuSendParams } from '@/types'
|
||||
|
||||
/**
|
||||
* 弹幕相关 API
|
||||
*/
|
||||
export const danmakuApi = {
|
||||
/**
|
||||
* 发送弹幕
|
||||
*/
|
||||
sendDanmaku(params: DanmakuSendParams): Promise<Danmaku> {
|
||||
return request.post('/content/danmaku', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取视频指定时间段的弹幕
|
||||
*/
|
||||
getDanmakuByTime(videoId: number, startTime: number, endTime: number): Promise<Danmaku[]> {
|
||||
return request.get('/content/danmaku/time', { videoId, startTime, endTime })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取视频所有弹幕
|
||||
*/
|
||||
getAllDanmaku(videoId: number): Promise<Danmaku[]> {
|
||||
return request.get(`/content/danmaku/all/${videoId}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type { Favorite, FavoriteCreateParams, FavoriteVideoParams, VideoInfo, PageResult, PageParams } from '@/types'
|
||||
|
||||
/**
|
||||
* 收藏夹相关 API
|
||||
*/
|
||||
export const favoriteApi = {
|
||||
/**
|
||||
* 获取用户收藏夹列表
|
||||
*/
|
||||
getFavoriteList(): Promise<Favorite[]> {
|
||||
return request.get('/content/favorite/list')
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建收藏夹
|
||||
*/
|
||||
createFavorite(params: FavoriteCreateParams): Promise<Favorite> {
|
||||
return request.post('/content/favorite', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新收藏夹
|
||||
*/
|
||||
updateFavorite(params: Partial<Favorite> & { id: number }): Promise<void> {
|
||||
return request.put('/content/favorite', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除收藏夹
|
||||
*/
|
||||
deleteFavorite(id: number): Promise<void> {
|
||||
return request.delete(`/content/favorite/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取收藏夹中的视频
|
||||
*/
|
||||
getFavoriteVideos(favoriteId: number, params?: PageParams): Promise<PageResult<VideoInfo>> {
|
||||
return request.get(`/content/favorite/${favoriteId}/videos`, params)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import request from '@/utils/request.ts'
|
||||
|
||||
/**
|
||||
* 文件上传 API
|
||||
*/
|
||||
export const uploadApi = {
|
||||
/**
|
||||
* 上传图片
|
||||
*/
|
||||
uploadImage(file: File, onProgress?: (percent: number) => void): Promise<string> {
|
||||
return request.upload('/user/profile/avatar', file, onProgress)
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传视频
|
||||
*/
|
||||
uploadVideo(file: File, onProgress?: (percent: number) => void): Promise<{ videoUrl: string; coverUrl: string }> {
|
||||
return request.upload('/content/video/upload', file, onProgress)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出所有 API
|
||||
export * from './auth.ts'
|
||||
export * from './user.ts'
|
||||
export * from './video.ts'
|
||||
export * from './comment.ts'
|
||||
export * from './category.ts'
|
||||
export * from './danmaku.ts'
|
||||
export * from './favorite.ts'
|
||||
export * from './recommend.ts'
|
||||
@@ -0,0 +1,52 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type { VideoInfo, RecommendVideo } from '@/types'
|
||||
|
||||
/**
|
||||
* 推荐相关 API
|
||||
*/
|
||||
export const recommendApi = {
|
||||
/**
|
||||
* 获取首页推荐
|
||||
*/
|
||||
getHomeRecommend(limit?: number): Promise<VideoInfo[]> {
|
||||
return request.get('/recommend/home', { limit })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取热门推荐
|
||||
*/
|
||||
getHotRecommend(params?: { page?: number; size?: number }): Promise<VideoInfo[]> {
|
||||
return request.get('/recommend/hot', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取相似视频推荐
|
||||
*/
|
||||
getSimilarVideos(videoId: number, limit?: number): Promise<VideoInfo[]> {
|
||||
return request.get(`/recommend/similar/${videoId}`, { limit })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取标签视频推荐
|
||||
*/
|
||||
getTagVideos(tagId: number, params?: { page?: number; size?: number }): Promise<VideoInfo[]> {
|
||||
return request.get(`/recommend/tag/${tagId}`, params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 记录用户行为
|
||||
*/
|
||||
recordBehavior(params: {
|
||||
videoId: number
|
||||
actionType: 'view' | 'like' | 'coin' | 'favorite' | 'share' | 'comment'
|
||||
}): Promise<void> {
|
||||
return request.post('/recommend/behavior', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新用户兴趣
|
||||
*/
|
||||
updateInterest(params: { tagIds: number[] }): Promise<void> {
|
||||
return request.post('/recommend/interest/update', params)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type {
|
||||
UserInfo,
|
||||
UserProfileUpdateParams,
|
||||
UserFollow,
|
||||
UserSetting,
|
||||
Message,
|
||||
MessageSendParams,
|
||||
Notification,
|
||||
PageParams,
|
||||
PageResult
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* 用户相关 API
|
||||
*/
|
||||
export const userApi = {
|
||||
// ==================== 用户资料 ====================
|
||||
|
||||
/**
|
||||
* 获取当前用户资料
|
||||
*/
|
||||
getCurrentProfile(): Promise<UserInfo> {
|
||||
return request.get('/user/profile/current')
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取指定用户资料
|
||||
*/
|
||||
getPublicProfile(userId: number): Promise<UserInfo> {
|
||||
return request.get(`/user/profile/public/${userId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新用户资料
|
||||
*/
|
||||
updateProfile(params: UserProfileUpdateParams): Promise<void> {
|
||||
return request.put('/user/profile', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传头像
|
||||
*/
|
||||
uploadAvatar(file: File, onProgress?: (percent: number) => void): Promise<string> {
|
||||
return request.upload('/user/profile/avatar', file, onProgress)
|
||||
},
|
||||
|
||||
/**
|
||||
* 搜索用户
|
||||
*/
|
||||
searchUsers(keyword: string, params?: PageParams): Promise<PageResult<UserInfo>> {
|
||||
return request.get('/user/profile/search', { keyword, ...params })
|
||||
},
|
||||
|
||||
// ==================== 用户关注 ====================
|
||||
|
||||
/**
|
||||
* 关注用户
|
||||
*/
|
||||
follow(followId: number): Promise<void> {
|
||||
return request.post(`/user/follow/${followId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 取消关注
|
||||
*/
|
||||
unfollow(followId: number): Promise<void> {
|
||||
return request.delete(`/user/follow/${followId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取关注列表
|
||||
*/
|
||||
getFollowing(userId: number, params?: PageParams): Promise<UserFollow[]> {
|
||||
return request.get(`/user/follow/following/${userId}`, params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取粉丝列表
|
||||
*/
|
||||
getFollowers(userId: number, params?: PageParams): Promise<UserFollow[]> {
|
||||
return request.get(`/user/follow/followers/${userId}`, params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查是否已关注
|
||||
*/
|
||||
checkFollow(followId: number): Promise<boolean> {
|
||||
return request.get(`/user/follow/check/${followId}`)
|
||||
},
|
||||
|
||||
// 兼容旧名称
|
||||
followUser: function(followId: number): Promise<void> {
|
||||
return this.follow(followId)
|
||||
},
|
||||
|
||||
unfollowUser: function(followId: number): Promise<void> {
|
||||
return this.unfollow(followId)
|
||||
},
|
||||
|
||||
// ==================== 用户私信 ====================
|
||||
|
||||
/**
|
||||
* 发送私信
|
||||
*/
|
||||
sendMessage(params: MessageSendParams): Promise<Message> {
|
||||
return request.post('/user/message', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取私信会话列表
|
||||
*/
|
||||
getMessageList(params?: PageParams): Promise<Message[]> {
|
||||
return request.get('/user/message/list', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取与指定用户的私信记录
|
||||
*/
|
||||
getMessageDetail(targetId: number, params?: PageParams): Promise<Message[]> {
|
||||
return request.get(`/user/message/detail/${targetId}`, params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 标记私信为已读
|
||||
*/
|
||||
markMessageRead(senderId: number): Promise<void> {
|
||||
return request.put(`/user/message/read/${senderId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取未读私信数量
|
||||
*/
|
||||
getUnreadMessageCount(): Promise<number> {
|
||||
return request.get('/user/message/unread/count')
|
||||
},
|
||||
|
||||
// ==================== 用户通知 ====================
|
||||
|
||||
/**
|
||||
* 获取通知列表
|
||||
*/
|
||||
getNotifications(type?: number, params?: PageParams): Promise<Notification[]> {
|
||||
return request.get('/user/notification/list', { type, ...params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 标记所有通知为已读
|
||||
*/
|
||||
markAllNotificationsRead(): Promise<void> {
|
||||
return request.put('/user/notification/read')
|
||||
},
|
||||
|
||||
/**
|
||||
* 标记指定通知为已读
|
||||
*/
|
||||
markNotificationRead(notificationId: number): Promise<void> {
|
||||
return request.put(`/user/notification/read/${notificationId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取未读通知数量
|
||||
*/
|
||||
getUnreadNotificationCount(): Promise<number> {
|
||||
return request.get('/user/notification/unread/count')
|
||||
},
|
||||
|
||||
// ==================== 用户设置 ====================
|
||||
|
||||
/**
|
||||
* 获取用户设置
|
||||
*/
|
||||
getSettings(): Promise<UserSetting> {
|
||||
return request.get('/user/setting')
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新用户设置
|
||||
*/
|
||||
updateSettings(params: Partial<UserSetting>): Promise<void> {
|
||||
return request.put('/user/setting', params)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import request from '@/utils/request.ts'
|
||||
import type {
|
||||
VideoInfo,
|
||||
VideoCreateParams,
|
||||
VideoUpdateParams,
|
||||
VideoQueryParams,
|
||||
VideoUploadParams,
|
||||
PlayRecord,
|
||||
PageResult
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* 视频相关 API
|
||||
*/
|
||||
export const videoApi = {
|
||||
// ==================== 视频上传 ====================
|
||||
|
||||
/**
|
||||
* 上传视频文件
|
||||
*/
|
||||
uploadVideo(params: VideoUploadParams): Promise<{ videoUrl: string; coverUrl: string }> {
|
||||
return request.upload('/content/video/upload', params.file, params.onProgress)
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建视频记录
|
||||
*/
|
||||
createVideo(params: VideoCreateParams): Promise<VideoInfo> {
|
||||
return request.post('/content/video', params)
|
||||
},
|
||||
|
||||
// ==================== 视频查询 ====================
|
||||
|
||||
/**
|
||||
* 获取视频详情(公开接口)
|
||||
*/
|
||||
getVideoPublic(id: number): Promise<VideoInfo> {
|
||||
return request.get(`/content/video/public/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取视频列表(公开接口)
|
||||
*/
|
||||
getVideoPublicList(params?: VideoQueryParams): Promise<PageResult<VideoInfo>> {
|
||||
return request.get('/content/video/public/list', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取视频详情(需登录)
|
||||
*/
|
||||
getVideo(id: number): Promise<VideoInfo> {
|
||||
return request.get(`/content/video/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取视频列表(需登录)
|
||||
*/
|
||||
getVideoList(params?: VideoQueryParams): Promise<PageResult<VideoInfo>> {
|
||||
return request.get('/content/video/list', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户视频列表
|
||||
*/
|
||||
getUserVideos(userId: number, params?: VideoQueryParams): Promise<PageResult<VideoInfo>> {
|
||||
return request.get(`/content/video/user/${userId}`, params)
|
||||
},
|
||||
|
||||
// ==================== 视频管理 ====================
|
||||
|
||||
/**
|
||||
* 更新视频信息
|
||||
*/
|
||||
updateVideo(params: VideoUpdateParams): Promise<void> {
|
||||
return request.put('/content/video', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除视频
|
||||
*/
|
||||
deleteVideo(id: number): Promise<void> {
|
||||
return request.delete(`/content/video/${id}`)
|
||||
},
|
||||
|
||||
// ==================== 视频互动 ====================
|
||||
|
||||
/**
|
||||
* 点赞视频
|
||||
*/
|
||||
likeVideo(videoId: number): Promise<void> {
|
||||
return request.post(`/content/video/like/${videoId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 取消点赞
|
||||
*/
|
||||
unlikeVideo(videoId: number): Promise<void> {
|
||||
return request.delete(`/content/video/like/${videoId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 投币
|
||||
*/
|
||||
coinVideo(videoId: number): Promise<void> {
|
||||
return request.post(`/content/video/coin/${videoId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 收藏视频
|
||||
*/
|
||||
favoriteVideo(videoId: number, favoriteId: number): Promise<void> {
|
||||
return request.post('/content/video/favorite', { videoId, favoriteId })
|
||||
},
|
||||
|
||||
/**
|
||||
* 取消收藏
|
||||
*/
|
||||
unfavoriteVideo(videoId: number): Promise<void> {
|
||||
return request.delete(`/content/video/favorite/${videoId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 分享视频
|
||||
*/
|
||||
shareVideo(videoId: number): Promise<void> {
|
||||
return request.post(`/content/video/share/${videoId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 记录播放
|
||||
*/
|
||||
recordPlay(params: PlayRecord): Promise<void> {
|
||||
return request.post('/content/video/play', params)
|
||||
},
|
||||
|
||||
/**
|
||||
* 增加评论数量(内部接口)
|
||||
*/
|
||||
incrementCommentCount(videoId: number, count: number): Promise<void> {
|
||||
return request.post(`/content/video/count/comment/${videoId}/${count}`)
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAppStore: typeof import('./stores')['useAppStore']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useFavoriteStore: typeof import('./stores')['useFavoriteStore']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useMessageStore: typeof import('./stores/message.ts')['useMessageStore']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const useUserStore: typeof import('./stores/user.ts')['useUserStore']
|
||||
const useVideoStore: typeof import('./stores/video.ts')['useVideoStore']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
// for vue template auto import
|
||||
import { UnwrapRef } from 'vue'
|
||||
declare module 'vue' {
|
||||
interface GlobalComponents {}
|
||||
interface ComponentCustomProperties {
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
|
||||
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
||||
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
|
||||
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
||||
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
|
||||
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
|
||||
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
|
||||
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
|
||||
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
|
||||
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
|
||||
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
||||
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
||||
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
|
||||
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
|
||||
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
|
||||
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
|
||||
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
|
||||
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
|
||||
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
|
||||
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
|
||||
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
|
||||
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
|
||||
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
|
||||
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
|
||||
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
||||
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
||||
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
|
||||
readonly ref: UnwrapRef<typeof import('vue')['ref']>
|
||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
|
||||
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
|
||||
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
|
||||
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
|
||||
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
|
||||
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
|
||||
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
||||
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||
readonly unref: UnwrapRef<typeof import('vue')['unref']>
|
||||
readonly useAppStore: UnwrapRef<typeof import('./stores')['useAppStore']>
|
||||
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
||||
readonly useFavoriteStore: UnwrapRef<typeof import('./stores')['useFavoriteStore']>
|
||||
readonly useId: UnwrapRef<typeof import('vue')['useId']>
|
||||
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
|
||||
readonly useMessageStore: UnwrapRef<typeof import('./stores/message.ts')['useMessageStore']>
|
||||
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
|
||||
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
|
||||
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
|
||||
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
|
||||
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
|
||||
readonly useUserStore: UnwrapRef<typeof import('./stores/user.ts')['useUserStore']>
|
||||
readonly useVideoStore: UnwrapRef<typeof import('./stores/video.ts')['useVideoStore']>
|
||||
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
||||
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
|
||||
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
|
||||
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AppHeader: typeof import('./components/layout/AppHeader.vue')['default']
|
||||
AppTabbar: typeof import('./components/layout/AppTabbar.vue')['default']
|
||||
CommentSection: typeof import('./components/comment/CommentSection.vue')['default']
|
||||
DanmakuList: typeof import('./components/danmaku/DanmakuList.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
VanBadge: typeof import('vant/es')['Badge']
|
||||
VanButton: typeof import('vant/es')['Button']
|
||||
VanCell: typeof import('vant/es')['Cell']
|
||||
VanCellGroup: typeof import('vant/es')['CellGroup']
|
||||
VanField: typeof import('vant/es')['Field']
|
||||
VanForm: typeof import('vant/es')['Form']
|
||||
VanIcon: typeof import('vant/es')['Icon']
|
||||
VanImage: typeof import('vant/es')['Image']
|
||||
VanList: typeof import('vant/es')['List']
|
||||
VanLoading: typeof import('vant/es')['Loading']
|
||||
VanNavBar: typeof import('vant/es')['NavBar']
|
||||
VanPopover: typeof import('vant/es')['Popover']
|
||||
VanPullRefresh: typeof import('vant/es')['PullRefresh']
|
||||
VanSearch: typeof import('vant/es')['Search']
|
||||
VanTab: typeof import('vant/es')['Tab']
|
||||
VanTabbar: typeof import('vant/es')['Tabbar']
|
||||
VanTabbarItem: typeof import('vant/es')['TabbarItem']
|
||||
VanTabs: typeof import('vant/es')['Tabs']
|
||||
VideoCard: typeof import('./components/video/VideoCard.vue')['default']
|
||||
VideoPlayer: typeof import('./components/video/VideoPlayer.vue')['default']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div class="comment-section">
|
||||
<!-- 评论输入 -->
|
||||
<div class="comment-input">
|
||||
<van-field
|
||||
v-model="content"
|
||||
type="textarea"
|
||||
placeholder="发一条友善的评论吧"
|
||||
rows="2"
|
||||
autosize
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<van-button type="primary" size="small" :loading="submitting" @click="submitComment">
|
||||
发表评论
|
||||
</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>
|
||||
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多评论了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<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" />
|
||||
</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-actions">
|
||||
<span @click="toggleLike(comment)">
|
||||
<van-icon :name="comment.isLiked ? 'good-job' : 'good-job-o'" />
|
||||
{{ comment.likeCount || '' }}
|
||||
</span>
|
||||
<span @click="replyTo(comment)">
|
||||
<van-icon name="chat-o" />
|
||||
回复
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-list>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { showToast, showSuccessToast } from 'vant'
|
||||
import { commentApi } from '@/api/comment.ts'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import { formatRelativeTime } from '@/utils'
|
||||
import type { Comment } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
videoId: number
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const content = ref('')
|
||||
const sortBy = ref<'hot' | 'time'>('hot')
|
||||
const comments = ref<Comment[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const submitting = ref(false)
|
||||
const currentPage = ref(1)
|
||||
|
||||
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
|
||||
|
||||
const loadComments = async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
currentPage.value = 1
|
||||
comments.value = []
|
||||
finished.value = false
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await commentApi.getList({
|
||||
videoId: props.videoId,
|
||||
page: currentPage.value,
|
||||
size: 20,
|
||||
sortBy: sortBy.value
|
||||
})
|
||||
|
||||
if (isRefresh) {
|
||||
comments.value = res.records
|
||||
} else {
|
||||
comments.value.push(...res.records)
|
||||
}
|
||||
|
||||
if (comments.value.length >= res.total || res.records.length < 20) {
|
||||
finished.value = true
|
||||
}
|
||||
currentPage.value++
|
||||
} catch (error) {
|
||||
showToast('加载评论失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!finished.value) {
|
||||
loadComments()
|
||||
}
|
||||
}
|
||||
|
||||
const submitComment = async () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
showToast('请先登录')
|
||||
return
|
||||
}
|
||||
if (!content.value.trim()) {
|
||||
showToast('请输入评论内容')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await commentApi.create({
|
||||
videoId: props.videoId,
|
||||
content: content.value
|
||||
})
|
||||
content.value = ''
|
||||
showSuccessToast('评论成功')
|
||||
loadComments(true)
|
||||
} catch (error: any) {
|
||||
showToast(error.message || '评论失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleLike = async (comment: Comment) => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
showToast('请先登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (comment.isLiked) {
|
||||
await commentApi.unlike(comment.id)
|
||||
comment.likeCount--
|
||||
} else {
|
||||
await commentApi.like(comment.id)
|
||||
comment.likeCount++
|
||||
}
|
||||
comment.isLiked = !comment.isLiked
|
||||
} catch (error) {
|
||||
showToast('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const replyTo = (comment: Comment) => {
|
||||
content.value = `@${comment.userNickname} `
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadComments()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.comment-section {
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.comment-input {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
.comment-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #00a1d6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="danmaku-list">
|
||||
<van-tabs v-model:active="activeTab">
|
||||
<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 }">
|
||||
{{ item.content }}
|
||||
</div>
|
||||
<div class="danmaku-user">{{ item.userId }}</div>
|
||||
</div>
|
||||
</van-list>
|
||||
</van-tab>
|
||||
<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-cell>
|
||||
<van-cell title="弹幕大小">
|
||||
<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-cell>
|
||||
</van-cell-group>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { danmakuApi, type Danmaku } from '@/api/danmaku.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
videoId: number
|
||||
}>()
|
||||
|
||||
const activeTab = ref('list')
|
||||
const danmakuList = ref<Danmaku[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
|
||||
const settings = reactive({
|
||||
showDanmaku: true,
|
||||
opacity: 80,
|
||||
fontSize: 24,
|
||||
speed: 5
|
||||
})
|
||||
|
||||
const formatDanmakuTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (finished.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await danmakuApi.getAll(props.videoId)
|
||||
danmakuList.value = data
|
||||
finished.value = true
|
||||
} catch (error) {
|
||||
finished.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMore()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.danmaku-list {
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
|
||||
.danmaku-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
font-size: 14px;
|
||||
|
||||
.danmaku-time {
|
||||
width: 50px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.danmaku-content {
|
||||
flex: 1;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.danmaku-user {
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<!-- Logo -->
|
||||
<div class="logo" @click="router.push('/')">
|
||||
<svg viewBox="0 0 100 100" class="logo-icon">
|
||||
<defs>
|
||||
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#00a1d6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fb7299;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="45" fill="url(#logoGradient)" />
|
||||
<polygon points="40,30 40,70 75,50" fill="white" />
|
||||
</svg>
|
||||
<span class="logo-text">HAOVP</span>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-box">
|
||||
<van-search
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索视频、用户"
|
||||
shape="round"
|
||||
@search="handleSearch"
|
||||
>
|
||||
<template #right-icon>
|
||||
<van-icon name="search" @click="handleSearch" />
|
||||
</template>
|
||||
</van-search>
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作区 -->
|
||||
<div class="header-actions">
|
||||
<!-- 投稿按钮 -->
|
||||
<van-button
|
||||
v-if="userStore.isLoggedIn"
|
||||
type="primary"
|
||||
size="small"
|
||||
round
|
||||
@click="router.push('/upload')"
|
||||
>
|
||||
<van-icon name="plus" />
|
||||
投稿
|
||||
</van-button>
|
||||
|
||||
<!-- 未登录 -->
|
||||
<template v-if="!userStore.isLoggedIn">
|
||||
<van-button type="primary" size="small" round @click="router.push('/login')">
|
||||
登录
|
||||
</van-button>
|
||||
</template>
|
||||
|
||||
<!-- 已登录 -->
|
||||
<template v-else>
|
||||
<!-- 消息 -->
|
||||
<van-popover v-model:show="showMessagePopover" trigger="click" placement="bottom-end">
|
||||
<div class="message-popover">
|
||||
<div class="message-item" @click="goToMessages">
|
||||
<van-icon name="chat-o" />
|
||||
<span>私信</span>
|
||||
<van-badge v-if="messageStore.unreadMessageCount" :content="messageStore.unreadMessageCount" />
|
||||
</div>
|
||||
<div class="message-item" @click="goToNotifications">
|
||||
<van-icon name="bell" />
|
||||
<span>通知</span>
|
||||
<van-badge v-if="messageStore.unreadNotificationCount" :content="messageStore.unreadNotificationCount" />
|
||||
</div>
|
||||
</div>
|
||||
<template #reference>
|
||||
<van-badge :content="messageStore.totalUnreadCount || ''" :show-zero="false">
|
||||
<van-icon name="bell" size="24" class="action-icon" />
|
||||
</van-badge>
|
||||
</template>
|
||||
</van-popover>
|
||||
|
||||
<!-- 用户头像 -->
|
||||
<van-popover v-model:show="showUserPopover" trigger="click" placement="bottom-end">
|
||||
<div class="user-popover">
|
||||
<div class="user-info">
|
||||
<van-image
|
||||
:src="userStore.userInfo?.avatar || defaultAvatar"
|
||||
round
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
<div class="user-detail">
|
||||
<div class="nickname">{{ userStore.userInfo?.nickname }}</div>
|
||||
<div class="level">Lv.{{ userStore.userInfo?.level || 1 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell title="个人主页" icon="user-o" @click="goToProfile" />
|
||||
<van-cell title="我的视频" icon="video-o" @click="router.push('/my/videos')" />
|
||||
<van-cell title="我的收藏" icon="star-o" @click="router.push('/my/favorites')" />
|
||||
<van-cell title="播放历史" icon="clock-o" @click="router.push('/my/history')" />
|
||||
<van-cell title="设置" icon="setting-o" @click="router.push('/settings')" />
|
||||
<van-cell title="退出登录" icon="revoke" @click="handleLogout" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
<template #reference>
|
||||
<van-image
|
||||
:src="userStore.userInfo?.avatar || defaultAvatar"
|
||||
round
|
||||
width="32"
|
||||
height="32"
|
||||
class="avatar"
|
||||
/>
|
||||
</template>
|
||||
</van-popover>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore, useMessageStore } from '@/stores'
|
||||
import { showConfirmDialog, showToast } from 'vant'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const messageStore = useMessageStore()
|
||||
|
||||
const searchKeyword = ref('')
|
||||
const showMessagePopover = ref(false)
|
||||
const showUserPopover = ref(false)
|
||||
const defaultAvatar = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1MCIgY3k9IjUwIiByPSI1MCIgZmlsbD0iI2NjYyIvPjxjaXJjbGUgY3g9IjUwIiBjeT0iMzUiIHI9IjE1IiBmaWxsPSIjOTk5Ii8+PGVsbGlwc2UgY3g9IjUwIiBjeT0iNzUiIHJ4PSIyNSIgcnk9IjIwIiBmaWxsPSIjOTk5Ii8+PC9zdmc+'
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
if (searchKeyword.value.trim()) {
|
||||
router.push({ path: '/search', query: { keyword: searchKeyword.value.trim() } })
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到私信
|
||||
const goToMessages = () => {
|
||||
showMessagePopover.value = false
|
||||
router.push('/messages')
|
||||
}
|
||||
|
||||
// 跳转到通知
|
||||
const goToNotifications = () => {
|
||||
showMessagePopover.value = false
|
||||
router.push('/notifications')
|
||||
}
|
||||
|
||||
// 跳转到个人主页
|
||||
const goToProfile = () => {
|
||||
showUserPopover.value = false
|
||||
if (userStore.userId) {
|
||||
router.push(`/user/${userStore.userId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await showConfirmDialog({
|
||||
title: '提示',
|
||||
message: '确定要退出登录吗?'
|
||||
})
|
||||
await userStore.logout()
|
||||
showToast('已退出登录')
|
||||
router.push('/')
|
||||
} catch {
|
||||
// 取消操作
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
if (userStore.isLoggedIn) {
|
||||
messageStore.fetchAllUnreadCount()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--header-height);
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
z-index: 100;
|
||||
|
||||
.header-content {
|
||||
max-width: var(--max-content-width);
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
|
||||
.logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
background: linear-gradient(to right, var(--primary-color), var(--pink-color));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
|
||||
:deep(.van-search) {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
|
||||
.van-search__content {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
|
||||
.action-icon {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-popover,
|
||||
.user-popover {
|
||||
padding: var(--spacing-sm);
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.message-popover {
|
||||
.message-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-popover {
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
|
||||
.user-detail {
|
||||
.nickname {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.level {
|
||||
font-size: 12px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.van-cell-group) {
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-box {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<van-tabbar v-model="active" route fixed active-color="#00a1d6" 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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const active = ref(0)
|
||||
|
||||
// 根据路由设置激活项
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
if (path === '/' || path.startsWith('/video')) {
|
||||
active.value = 0
|
||||
} else if (path.startsWith('/category')) {
|
||||
active.value = 1
|
||||
} else if (path.startsWith('/my') || path.startsWith('/user')) {
|
||||
active.value = 2
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-tabbar {
|
||||
@media (min-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="video-card" @click="handleClick">
|
||||
<!-- 封面 -->
|
||||
<div class="video-cover">
|
||||
<van-image
|
||||
:src="video.coverUrl || defaultCover"
|
||||
fit="cover"
|
||||
class="cover-image"
|
||||
lazy-load
|
||||
>
|
||||
<template v-slot:loading>
|
||||
<van-loading type="spinner" size="20" />
|
||||
</template>
|
||||
</van-image>
|
||||
<!-- 时长 -->
|
||||
<span class="duration">{{ formatDuration(video.duration) }}</span>
|
||||
<!-- 播放量 -->
|
||||
<span class="view-count">{{ formatPlayCount(video.viewCount) }}播放</span>
|
||||
</div>
|
||||
|
||||
<!-- 视频信息 -->
|
||||
<div class="video-info">
|
||||
<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>
|
||||
</div>
|
||||
<div class="video-stats">
|
||||
<span><van-icon name="good-job-o" /> {{ formatNumber(video.likeCount) }}</span>
|
||||
<span><van-icon name="chat-o" /> {{ formatNumber(video.commentCount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { VideoInfo } from '@/types'
|
||||
import { formatDuration, formatNumber, formatPlayCount } from '@/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
video: VideoInfo
|
||||
}>()
|
||||
|
||||
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 handleClick = () => {
|
||||
emit('click', props.video.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.video-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.video-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 56.25%; /* 16:9 */
|
||||
|
||||
.cover-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.view-count {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-info {
|
||||
padding: 10px;
|
||||
|
||||
.video-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="video-player">
|
||||
<div ref="playerRef" class="player-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import Artplayer from 'artplayer'
|
||||
import type { Danmaku } from '@/api/danmaku.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
poster?: string
|
||||
danmakuList?: Danmaku[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
timeupdate: [time: number]
|
||||
ended: []
|
||||
}>()
|
||||
|
||||
const playerRef = ref<HTMLElement>()
|
||||
let instance: Artplayer | null = null
|
||||
|
||||
const initPlayer = () => {
|
||||
if (!playerRef.value) return
|
||||
|
||||
instance = new Artplayer({
|
||||
container: playerRef.value,
|
||||
url: props.src,
|
||||
poster: props.poster,
|
||||
autoplay: false,
|
||||
pip: true,
|
||||
autoSize: false,
|
||||
autoMini: true,
|
||||
screenshot: true,
|
||||
setting: true,
|
||||
loop: false,
|
||||
flip: true,
|
||||
playbackRate: true,
|
||||
aspectRatio: true,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
subtitleOffset: true,
|
||||
miniProgressBar: true,
|
||||
mutex: true,
|
||||
backdrop: true,
|
||||
playsInline: true,
|
||||
autoPlayback: true,
|
||||
airplay: true,
|
||||
theme: '#00a1d6',
|
||||
lang: navigator.language.toLowerCase(),
|
||||
moreVideoAttr: {
|
||||
crossOrigin: 'anonymous'
|
||||
},
|
||||
customType: {
|
||||
danmaku: (video, url) => {
|
||||
// 弹幕插件集成
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
instance.on('video:timeupdate', () => {
|
||||
emit('timeupdate', instance?.video?.currentTime || 0)
|
||||
})
|
||||
|
||||
instance.on('video:ended', () => {
|
||||
emit('ended')
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.src, () => {
|
||||
if (instance) {
|
||||
instance.switchUrl(props.src, props.poster)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initPlayer()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (instance) {
|
||||
instance.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.video-player {
|
||||
width: 100%;
|
||||
background: #000;
|
||||
|
||||
.player-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// Vant 组件按需引入由 unplugin-vue-components 自动完成
|
||||
// 样式已自动引入
|
||||
|
||||
// 全局样式
|
||||
import '@/styles/index.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Pinia 状态管理
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
app.use(pinia)
|
||||
|
||||
// 路由
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,189 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
|
||||
// 配置 NProgress
|
||||
NProgress.configure({ showSpinner: false })
|
||||
|
||||
// 路由配置
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/views/layout/MainLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/home/index.vue'),
|
||||
meta: { title: '首页' }
|
||||
},
|
||||
{
|
||||
path: 'video/:id',
|
||||
name: 'Video',
|
||||
component: () => import('@/views/video/detail.vue'),
|
||||
meta: { title: '视频详情' }
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
name: 'Search',
|
||||
component: () => import('@/views/search/index.vue'),
|
||||
meta: { title: '搜索' }
|
||||
},
|
||||
{
|
||||
path: 'category/:id?',
|
||||
name: 'Category',
|
||||
component: () => import('@/views/home/category.vue'),
|
||||
meta: { title: '分类' }
|
||||
},
|
||||
{
|
||||
path: 'user/:id',
|
||||
name: 'UserProfile',
|
||||
component: () => import('@/views/user/profile.vue'),
|
||||
meta: { title: '用户主页' }
|
||||
},
|
||||
{
|
||||
path: 'user/:id/videos',
|
||||
name: 'UserVideos',
|
||||
component: () => import('@/views/user/videos.vue'),
|
||||
meta: { title: '用户视频' }
|
||||
},
|
||||
{
|
||||
path: 'user/:id/followers',
|
||||
name: 'UserFollowers',
|
||||
component: () => import('@/views/user/followers.vue'),
|
||||
meta: { title: '粉丝' }
|
||||
},
|
||||
{
|
||||
path: 'user/:id/following',
|
||||
name: 'UserFollowing',
|
||||
component: () => import('@/views/user/following.vue'),
|
||||
meta: { title: '关注' }
|
||||
}
|
||||
]
|
||||
},
|
||||
// 需要登录的路由
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/views/layout/MainLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: 'upload',
|
||||
name: 'Upload',
|
||||
component: () => import('@/views/upload/index.vue'),
|
||||
meta: { title: '上传视频', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'my/videos',
|
||||
name: 'MyVideos',
|
||||
component: () => import('@/views/user/my-videos.vue'),
|
||||
meta: { title: '我的视频', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'my/favorites',
|
||||
name: 'MyFavorites',
|
||||
component: () => import('@/views/favorite/index.vue'),
|
||||
meta: { title: '我的收藏', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'my/history',
|
||||
name: 'PlayHistory',
|
||||
component: () => import('@/views/user/history.vue'),
|
||||
meta: { title: '播放历史', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/user/settings.vue'),
|
||||
meta: { title: '设置', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/profile',
|
||||
name: 'ProfileEdit',
|
||||
component: () => import('@/views/user/profile-edit.vue'),
|
||||
meta: { title: '编辑资料', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'messages',
|
||||
name: 'Messages',
|
||||
component: () => import('@/views/message/index.vue'),
|
||||
meta: { title: '私信', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'messages/:id',
|
||||
name: 'MessageDetail',
|
||||
component: () => import('@/views/message/detail.vue'),
|
||||
meta: { title: '私信详情', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'Notifications',
|
||||
component: () => import('@/views/message/notifications.vue'),
|
||||
meta: { title: '通知', requiresAuth: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
// 登录注册
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { title: '登录' }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/login/register.vue'),
|
||||
meta: { title: '注册' }
|
||||
},
|
||||
// 404
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/error/404.vue'),
|
||||
meta: { title: '页面未找到' }
|
||||
}
|
||||
]
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
} else {
|
||||
return { top: 0 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 路由前置守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 开始进度条
|
||||
NProgress.start()
|
||||
|
||||
// 设置页面标题
|
||||
const title = to.meta.title as string
|
||||
document.title = title ? `${title} - HAOVP` : 'HAOVP - 在线视频平台'
|
||||
|
||||
// 检查登录状态
|
||||
const userStore = useUserStore()
|
||||
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
})
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
// 路由后置守卫
|
||||
router.afterEach(() => {
|
||||
// 结束进度条
|
||||
NProgress.done()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,102 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
// 主题 (light, dark, auto)
|
||||
const theme = ref<'light' | 'dark' | 'auto'>('auto')
|
||||
|
||||
// 语言
|
||||
const locale = ref('zh-CN')
|
||||
|
||||
// 侧边栏是否展开
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
// 是否显示弹幕
|
||||
const danmakuVisible = ref(true)
|
||||
|
||||
// 弹幕设置
|
||||
const danmakuSettings = ref({
|
||||
fontSize: 24,
|
||||
opacity: 1,
|
||||
speed: 1,
|
||||
showTop: true,
|
||||
showBottom: true,
|
||||
showScroll: true
|
||||
})
|
||||
|
||||
// 播放器设置
|
||||
const playerSettings = ref({
|
||||
autoplay: false,
|
||||
defaultQuality: 'auto',
|
||||
defaultPlaybackRate: 1
|
||||
})
|
||||
|
||||
// 设置主题
|
||||
function setTheme(newTheme: 'light' | 'dark' | 'auto') {
|
||||
theme.value = newTheme
|
||||
applyTheme(newTheme)
|
||||
}
|
||||
|
||||
// 应用主题
|
||||
function applyTheme(theme: 'light' | 'dark' | 'auto') {
|
||||
const root = document.documentElement
|
||||
|
||||
if (theme === 'auto') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
root.classList.toggle('dark', prefersDark)
|
||||
} else {
|
||||
root.classList.toggle('dark', theme === 'dark')
|
||||
}
|
||||
}
|
||||
|
||||
// 设置语言
|
||||
function setLocale(newLocale: string) {
|
||||
locale.value = newLocale
|
||||
// TODO: 实现 i18n
|
||||
}
|
||||
|
||||
// 切换侧边栏
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
// 设置弹幕可见性
|
||||
function setDanmakuVisible(visible: boolean) {
|
||||
danmakuVisible.value = visible
|
||||
}
|
||||
|
||||
// 更新弹幕设置
|
||||
function updateDanmakuSettings(settings: Partial<typeof danmakuSettings.value>) {
|
||||
danmakuSettings.value = { ...danmakuSettings.value, ...settings }
|
||||
}
|
||||
|
||||
// 更新播放器设置
|
||||
function updatePlayerSettings(settings: Partial<typeof playerSettings.value>) {
|
||||
playerSettings.value = { ...playerSettings.value, ...settings }
|
||||
}
|
||||
|
||||
// 初始化应用
|
||||
function initApp() {
|
||||
applyTheme(theme.value)
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
theme,
|
||||
locale,
|
||||
sidebarCollapsed,
|
||||
danmakuVisible,
|
||||
danmakuSettings,
|
||||
playerSettings,
|
||||
// 方法
|
||||
setTheme,
|
||||
setLocale,
|
||||
toggleSidebar,
|
||||
setDanmakuVisible,
|
||||
updateDanmakuSettings,
|
||||
updatePlayerSettings,
|
||||
initApp
|
||||
}
|
||||
}, {
|
||||
persist: true
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Favorite, VideoInfo } from '@/types'
|
||||
import { favoriteApi } from '@/api/favorite.ts'
|
||||
import { showToast } from 'vant'
|
||||
|
||||
export const useFavoriteStore = defineStore('favorite', () => {
|
||||
// 收藏夹列表
|
||||
const favorites = ref<Favorite[]>([])
|
||||
|
||||
// 当前选中的收藏夹
|
||||
const currentFavorite = ref<Favorite | null>(null)
|
||||
|
||||
// 是否加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 获取收藏夹列表
|
||||
async function fetchFavorites() {
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await favoriteApi.getFavoriteList()
|
||||
favorites.value = result
|
||||
} catch (error) {
|
||||
console.error('获取收藏夹列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建收藏夹
|
||||
async function createFavorite(params: { name: string; description?: string; visibility: number }) {
|
||||
try {
|
||||
const result = await favoriteApi.createFavorite(params)
|
||||
favorites.value.unshift(result)
|
||||
showToast('创建成功')
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('创建收藏夹失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 更新收藏夹
|
||||
async function updateFavorite(params: Partial<Favorite> & { id: number }) {
|
||||
try {
|
||||
await favoriteApi.updateFavorite(params)
|
||||
const index = favorites.value.findIndex(f => f.id === params.id)
|
||||
if (index >= 0) {
|
||||
favorites.value[index] = { ...favorites.value[index], ...params }
|
||||
}
|
||||
showToast('更新成功')
|
||||
} catch (error) {
|
||||
console.error('更新收藏夹失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 删除收藏夹
|
||||
async function deleteFavorite(id: number) {
|
||||
try {
|
||||
await favoriteApi.deleteFavorite(id)
|
||||
favorites.value = favorites.value.filter(f => f.id !== id)
|
||||
showToast('删除成功')
|
||||
} catch (error) {
|
||||
console.error('删除收藏夹失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 设置当前收藏夹
|
||||
function setCurrentFavorite(favorite: Favorite | null) {
|
||||
currentFavorite.value = favorite
|
||||
}
|
||||
|
||||
// 更新收藏夹中的视频数量
|
||||
function updateVideoCount(favoriteId: number, delta: number) {
|
||||
const favorite = favorites.value.find(f => f.id === favoriteId)
|
||||
if (favorite) {
|
||||
favorite.videoCount += delta
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
favorites,
|
||||
currentFavorite,
|
||||
loading,
|
||||
// 方法
|
||||
fetchFavorites,
|
||||
createFavorite,
|
||||
updateFavorite,
|
||||
deleteFavorite,
|
||||
setCurrentFavorite,
|
||||
updateVideoCount
|
||||
}
|
||||
}, {
|
||||
persist: {
|
||||
paths: ['favorites']
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
export { useUserStore } from './user.ts'
|
||||
export { useVideoStore } from './video.ts'
|
||||
export { useFavoriteStore } from './favorite.ts'
|
||||
export { useMessageStore } from './message.ts'
|
||||
export { useAppStore } from './app.ts'
|
||||
@@ -0,0 +1,89 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
import type { Message, Notification } from '@/types'
|
||||
|
||||
export const useMessageStore = defineStore('message', () => {
|
||||
// 未读私信数量
|
||||
const unreadMessageCount = ref(0)
|
||||
|
||||
// 未读通知数量
|
||||
const unreadNotificationCount = ref(0)
|
||||
|
||||
// 通知列表
|
||||
const notifications = ref<Notification[]>([])
|
||||
|
||||
// 计算总未读数
|
||||
const totalUnreadCount = computed(() => {
|
||||
return unreadMessageCount.value + unreadNotificationCount.value
|
||||
})
|
||||
|
||||
// 获取未读私信数量
|
||||
async function fetchUnreadMessageCount() {
|
||||
try {
|
||||
const count = await userApi.getUnreadMessageCount()
|
||||
unreadMessageCount.value = count
|
||||
} catch (error) {
|
||||
console.error('获取未读私信数量失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取未读通知数量
|
||||
async function fetchUnreadNotificationCount() {
|
||||
try {
|
||||
const count = await userApi.getUnreadNotificationCount()
|
||||
unreadNotificationCount.value = count
|
||||
} catch (error) {
|
||||
console.error('获取未读通知数量失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有未读数量
|
||||
async function fetchAllUnreadCount() {
|
||||
await Promise.all([
|
||||
fetchUnreadMessageCount(),
|
||||
fetchUnreadNotificationCount()
|
||||
])
|
||||
}
|
||||
|
||||
// 标记私信为已读
|
||||
function decrementMessageUnread(count = 1) {
|
||||
unreadMessageCount.value = Math.max(0, unreadMessageCount.value - count)
|
||||
}
|
||||
|
||||
// 标记通知为已读
|
||||
function decrementNotificationUnread(count = 1) {
|
||||
unreadNotificationCount.value = Math.max(0, unreadNotificationCount.value - count)
|
||||
}
|
||||
|
||||
// 清空所有未读私信
|
||||
function clearMessageUnread() {
|
||||
unreadMessageCount.value = 0
|
||||
}
|
||||
|
||||
// 清空所有未读通知
|
||||
function clearNotificationUnread() {
|
||||
unreadNotificationCount.value = 0
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
unreadMessageCount,
|
||||
unreadNotificationCount,
|
||||
notifications,
|
||||
// 计算属性
|
||||
totalUnreadCount,
|
||||
// 方法
|
||||
fetchUnreadMessageCount,
|
||||
fetchUnreadNotificationCount,
|
||||
fetchAllUnreadCount,
|
||||
decrementMessageUnread,
|
||||
decrementNotificationUnread,
|
||||
clearMessageUnread,
|
||||
clearNotificationUnread
|
||||
}
|
||||
}, {
|
||||
persist: {
|
||||
paths: ['unreadMessageCount', 'unreadNotificationCount']
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,184 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { authApi } from '@/api/auth.ts'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
import type { LoginParams, RegisterParams, UserInfo, AuthUser, CaptchaResult } from '@/types'
|
||||
import { getToken, setToken, removeToken, getRefreshToken, setRefreshToken, removeRefreshToken } from '@/utils/storage.ts'
|
||||
import { showToast } from 'vant'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 状态
|
||||
const token = ref<string | null>(getToken())
|
||||
const refreshToken = ref<string | null>(getRefreshToken())
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const authUser = ref<AuthUser | null>(null)
|
||||
const captcha = ref<CaptchaResult | null>(null)
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const userId = computed(() => authUser.value?.id || userInfo.value?.id)
|
||||
const username = computed(() => authUser.value?.username || userInfo.value?.nickname)
|
||||
|
||||
// 获取验证码
|
||||
async function getCaptcha() {
|
||||
try {
|
||||
const result = await authApi.getCaptcha()
|
||||
captcha.value = result
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('获取验证码失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
async function login(params: LoginParams) {
|
||||
try {
|
||||
const result = await authApi.login(params)
|
||||
token.value = result.accessToken
|
||||
refreshToken.value = result.refreshToken
|
||||
setToken(result.accessToken)
|
||||
setRefreshToken(result.refreshToken)
|
||||
|
||||
// 获取用户信息
|
||||
await fetchAuthUser()
|
||||
await fetchUserInfo()
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 注册
|
||||
async function register(params: RegisterParams) {
|
||||
try {
|
||||
const result = await authApi.register(params)
|
||||
showToast('注册成功')
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('注册失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 登出
|
||||
async function logout() {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch (error) {
|
||||
console.error('登出请求失败:', error)
|
||||
} finally {
|
||||
// 清除本地状态
|
||||
token.value = null
|
||||
refreshToken.value = null
|
||||
userInfo.value = null
|
||||
authUser.value = null
|
||||
removeToken()
|
||||
removeRefreshToken()
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新 Token
|
||||
async function refreshAccessToken() {
|
||||
if (!refreshToken.value) {
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authApi.refreshToken(refreshToken.value)
|
||||
token.value = result.accessToken
|
||||
refreshToken.value = result.refreshToken
|
||||
setToken(result.accessToken)
|
||||
setRefreshToken(result.refreshToken)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('刷新Token失败:', error)
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取认证用户信息
|
||||
async function fetchAuthUser() {
|
||||
if (!token.value) return null
|
||||
|
||||
try {
|
||||
const result = await authApi.getCurrentUser()
|
||||
authUser.value = result
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('获取认证用户信息失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户资料
|
||||
async function fetchUserInfo() {
|
||||
if (!token.value) return null
|
||||
|
||||
try {
|
||||
const result = await userApi.getCurrentProfile()
|
||||
userInfo.value = result
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('获取用户资料失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户资料
|
||||
async function updateProfile(params: Partial<UserInfo>) {
|
||||
try {
|
||||
await userApi.updateProfile(params)
|
||||
// 重新获取用户资料
|
||||
await fetchUserInfo()
|
||||
} catch (error) {
|
||||
console.error('更新用户资料失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 上传头像
|
||||
async function uploadAvatar(file: File, onProgress?: (percent: number) => void) {
|
||||
try {
|
||||
const avatarUrl = await userApi.uploadAvatar(file, onProgress)
|
||||
if (userInfo.value) {
|
||||
userInfo.value.avatar = avatarUrl
|
||||
}
|
||||
return avatarUrl
|
||||
} catch (error) {
|
||||
console.error('上传头像失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
token,
|
||||
refreshToken,
|
||||
userInfo,
|
||||
authUser,
|
||||
captcha,
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
userId,
|
||||
username,
|
||||
// 方法
|
||||
getCaptcha,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshAccessToken,
|
||||
fetchAuthUser,
|
||||
fetchUserInfo,
|
||||
updateProfile,
|
||||
uploadAvatar
|
||||
}
|
||||
}, {
|
||||
persist: {
|
||||
paths: ['token', 'refreshToken', 'userInfo', 'authUser']
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { VideoInfo, PlayRecord } from '@/types'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
|
||||
export const useVideoStore = defineStore('video', () => {
|
||||
// 当前播放的视频
|
||||
const currentVideo = ref<VideoInfo | null>(null)
|
||||
|
||||
// 播放进度
|
||||
const playProgress = ref<number>(0)
|
||||
|
||||
// 播放历史(本地存储)
|
||||
const playHistory = ref<PlayRecord[]>([])
|
||||
|
||||
// 是否正在播放
|
||||
const isPlaying = ref(false)
|
||||
|
||||
// 弹幕开关
|
||||
const danmakuEnabled = ref(true)
|
||||
|
||||
// 音量
|
||||
const volume = ref(0.8)
|
||||
|
||||
// 播放速度
|
||||
const playbackRate = ref(1)
|
||||
|
||||
// 设置当前视频
|
||||
function setCurrentVideo(video: VideoInfo | null) {
|
||||
currentVideo.value = video
|
||||
}
|
||||
|
||||
// 更新播放进度
|
||||
function updateProgress(progress: number) {
|
||||
playProgress.value = progress
|
||||
}
|
||||
|
||||
// 切换播放状态
|
||||
function togglePlaying(playing: boolean) {
|
||||
isPlaying.value = playing
|
||||
}
|
||||
|
||||
// 切换弹幕
|
||||
function toggleDanmaku(enabled: boolean) {
|
||||
danmakuEnabled.value = enabled
|
||||
}
|
||||
|
||||
// 设置音量
|
||||
function setVolume(vol: number) {
|
||||
volume.value = vol
|
||||
}
|
||||
|
||||
// 设置播放速度
|
||||
function setPlaybackRate(rate: number) {
|
||||
playbackRate.value = rate
|
||||
}
|
||||
|
||||
// 记录播放
|
||||
async function recordPlay(videoId: number, progress: number, duration: number) {
|
||||
try {
|
||||
await videoApi.recordPlay({ videoId, progress, duration })
|
||||
|
||||
// 更新本地播放历史
|
||||
const index = playHistory.value.findIndex(item => item.videoId === videoId)
|
||||
const record: PlayRecord = { videoId, progress, duration }
|
||||
|
||||
if (index >= 0) {
|
||||
playHistory.value[index] = record
|
||||
} else {
|
||||
playHistory.value.unshift(record)
|
||||
// 最多保存100条记录
|
||||
if (playHistory.value.length > 100) {
|
||||
playHistory.value = playHistory.value.slice(0, 100)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('记录播放失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取播放进度
|
||||
function getPlayProgress(videoId: number): number {
|
||||
const record = playHistory.value.find(item => item.videoId === videoId)
|
||||
return record?.progress || 0
|
||||
}
|
||||
|
||||
// 清除当前视频
|
||||
function clearCurrentVideo() {
|
||||
currentVideo.value = null
|
||||
playProgress.value = 0
|
||||
isPlaying.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
currentVideo,
|
||||
playProgress,
|
||||
playHistory,
|
||||
isPlaying,
|
||||
danmakuEnabled,
|
||||
volume,
|
||||
playbackRate,
|
||||
// 方法
|
||||
setCurrentVideo,
|
||||
updateProgress,
|
||||
togglePlaying,
|
||||
toggleDanmaku,
|
||||
setVolume,
|
||||
setPlaybackRate,
|
||||
recordPlay,
|
||||
getPlayProgress,
|
||||
clearCurrentVideo
|
||||
}
|
||||
}, {
|
||||
persist: {
|
||||
paths: ['playHistory', 'danmakuEnabled', 'volume', 'playbackRate']
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,254 @@
|
||||
// 全局样式入口
|
||||
|
||||
// 重置样式
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
font-family: -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-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
background-color: #f4f4f4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
// 滚动条样式
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
}
|
||||
|
||||
// NProgress 样式覆盖
|
||||
#nprogress {
|
||||
.bar {
|
||||
background: linear-gradient(to right, #00a1d6, #fb7299) !important;
|
||||
height: 3px !important;
|
||||
}
|
||||
|
||||
.peg {
|
||||
box-shadow: 0 0 10px #00a1d6, 0 0 5px #00a1d6 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 主题变量
|
||||
:root {
|
||||
// 主色调
|
||||
--primary-color: #00a1d6;
|
||||
--primary-hover-color: #00b5e2;
|
||||
|
||||
// 辅助色
|
||||
--success-color: #52c41a;
|
||||
--warning-color: #faad14;
|
||||
--error-color: #ff4d4f;
|
||||
--info-color: #1890ff;
|
||||
|
||||
// 粉色(B站风格)
|
||||
--pink-color: #fb7299;
|
||||
--pink-hover-color: #fc8bab;
|
||||
|
||||
// 文字颜色
|
||||
--text-primary: #333;
|
||||
--text-secondary: #666;
|
||||
--text-tertiary: #999;
|
||||
--text-white: #fff;
|
||||
|
||||
// 背景颜色
|
||||
--bg-primary: #f4f4f4;
|
||||
--bg-secondary: #fff;
|
||||
--bg-tertiary: #f9f9f9;
|
||||
|
||||
// 边框颜色
|
||||
--border-color: #e5e5e5;
|
||||
--border-light: #f0f0f0;
|
||||
|
||||
// 阴影
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
|
||||
// 圆角
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
// 间距
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
|
||||
// 布局
|
||||
--header-height: 60px;
|
||||
--sidebar-width: 200px;
|
||||
--max-content-width: 1400px;
|
||||
}
|
||||
|
||||
// 暗色主题
|
||||
.dark {
|
||||
--text-primary: #e5e5e5;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-tertiary: #666;
|
||||
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #252525;
|
||||
--bg-tertiary: #2a2a2a;
|
||||
|
||||
--border-color: #333;
|
||||
--border-light: #2a2a2a;
|
||||
}
|
||||
|
||||
// 常用类
|
||||
.text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text-ellipsis-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.text-ellipsis-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
max-width: var(--max-content-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
// 骨架屏动画
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 淡入动画
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// 滑入动画
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
// 缩放动画
|
||||
.scale-enter-active,
|
||||
.scale-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.scale-enter-from,
|
||||
.scale-leave-to {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// API 响应通用结构
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 分页请求参数
|
||||
export interface PageParams {
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
// 分页响应数据
|
||||
export interface PageResult<T> {
|
||||
records: T[]
|
||||
total: number
|
||||
size: number
|
||||
current: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
// 用户信息
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
nickname: string
|
||||
avatar: string
|
||||
gender: number
|
||||
birthday: string
|
||||
signature: string
|
||||
region: string
|
||||
followerCount: number
|
||||
followingCount: number
|
||||
videoCount: number
|
||||
likeCount: number
|
||||
level: number
|
||||
vipLevel: number
|
||||
vipExpireTime: string | null
|
||||
isFollowed?: boolean
|
||||
createTime: string
|
||||
}
|
||||
|
||||
// 认证用户
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
phone: string
|
||||
status: number
|
||||
lastLoginTime: string
|
||||
lastLoginIp: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// 登录响应
|
||||
export interface LoginResult {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
tokenType: string
|
||||
expiresIn: number
|
||||
userId: number
|
||||
username: string
|
||||
}
|
||||
|
||||
// 验证码响应
|
||||
export interface CaptchaResult {
|
||||
captchaKey: string
|
||||
captchaImage: string
|
||||
}
|
||||
|
||||
// 登录请求
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
captcha: string
|
||||
captchaKey: string
|
||||
}
|
||||
|
||||
// 注册请求
|
||||
export interface RegisterParams {
|
||||
username: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
email: string
|
||||
phone: string
|
||||
captcha: string
|
||||
captchaKey: string
|
||||
}
|
||||
|
||||
// 视频信息
|
||||
export interface VideoInfo {
|
||||
id: number
|
||||
userId: number
|
||||
title: string
|
||||
description: string
|
||||
coverUrl: string
|
||||
videoUrl: string
|
||||
duration: number
|
||||
width: number
|
||||
height: number
|
||||
format: string
|
||||
size: number
|
||||
categoryId: number
|
||||
status: number
|
||||
likeCount: number
|
||||
coinCount: number
|
||||
favoriteCount: number
|
||||
commentCount: number
|
||||
viewCount: number
|
||||
shareCount: number
|
||||
danmakuCount: number
|
||||
isRecommend: number
|
||||
isTop: number
|
||||
publishTime: string
|
||||
createTime: string
|
||||
updateTime: string
|
||||
tagIds?: number[]
|
||||
tagNames?: string[]
|
||||
categoryName?: string
|
||||
userNickname?: string
|
||||
userAvatar?: string
|
||||
isLiked?: boolean
|
||||
isFavorited?: boolean
|
||||
isCoined?: boolean
|
||||
}
|
||||
|
||||
// 视频上传参数
|
||||
export interface VideoUploadParams {
|
||||
file: File
|
||||
onProgress?: (percent: number) => void
|
||||
}
|
||||
|
||||
// 视频创建参数
|
||||
export interface VideoCreateParams {
|
||||
title: string
|
||||
description: string
|
||||
videoUrl: string
|
||||
coverUrl: string
|
||||
duration: number
|
||||
width: number
|
||||
height: number
|
||||
format: string
|
||||
size: number
|
||||
categoryId: number
|
||||
tagIds?: number[]
|
||||
}
|
||||
|
||||
// 视频更新参数
|
||||
export interface VideoUpdateParams {
|
||||
id: number
|
||||
title?: string
|
||||
description?: string
|
||||
coverUrl?: string
|
||||
categoryId?: number
|
||||
tagIds?: number[]
|
||||
}
|
||||
|
||||
// 视频查询参数
|
||||
export interface VideoQueryParams extends PageParams {
|
||||
keyword?: string
|
||||
categoryId?: number
|
||||
tagId?: number
|
||||
userId?: number
|
||||
status?: number
|
||||
sortBy?: string
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// 分类信息
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
parentId: number
|
||||
sort: number
|
||||
icon: string
|
||||
children?: Category[]
|
||||
}
|
||||
|
||||
// 标签信息
|
||||
export interface Tag {
|
||||
id: number
|
||||
name: string
|
||||
useCount: number
|
||||
}
|
||||
|
||||
// 弹幕信息
|
||||
export interface Danmaku {
|
||||
id: number
|
||||
videoId: number
|
||||
userId: number
|
||||
content: string
|
||||
color: string
|
||||
position: number
|
||||
time: number
|
||||
fontSize: number
|
||||
createTime: string
|
||||
}
|
||||
|
||||
// 弹幕发送参数
|
||||
export interface DanmakuSendParams {
|
||||
videoId: number
|
||||
content: string
|
||||
color: string
|
||||
position: number
|
||||
time: number
|
||||
fontSize: number
|
||||
}
|
||||
|
||||
// 评论信息
|
||||
export interface Comment {
|
||||
id: number
|
||||
videoId: number
|
||||
userId: number
|
||||
parentId: number
|
||||
rootId: number
|
||||
content: string
|
||||
likeCount: number
|
||||
replyCount: number
|
||||
isTop: number
|
||||
createTime: string
|
||||
userNickname?: string
|
||||
userAvatar?: string
|
||||
isLiked?: boolean
|
||||
replies?: Comment[]
|
||||
}
|
||||
|
||||
// 评论发送参数
|
||||
export interface CommentSendParams {
|
||||
videoId: number
|
||||
parentId?: number
|
||||
rootId?: number
|
||||
content: string
|
||||
}
|
||||
|
||||
// 评论查询参数
|
||||
export interface CommentQueryParams extends PageParams {
|
||||
videoId: number
|
||||
rootId?: number
|
||||
sortBy?: 'hot' | 'time'
|
||||
}
|
||||
|
||||
// 收藏夹信息
|
||||
export interface Favorite {
|
||||
id: number
|
||||
userId: number
|
||||
name: string
|
||||
description: string
|
||||
coverUrl: string
|
||||
visibility: number
|
||||
videoCount: number
|
||||
createTime: string
|
||||
}
|
||||
|
||||
// 收藏夹创建参数
|
||||
export interface FavoriteCreateParams {
|
||||
name: string
|
||||
description?: string
|
||||
coverUrl?: string
|
||||
visibility: number
|
||||
}
|
||||
|
||||
// 收藏视频参数
|
||||
export interface FavoriteVideoParams {
|
||||
videoId: number
|
||||
favoriteId: number
|
||||
}
|
||||
|
||||
// 用户关注
|
||||
export interface UserFollow {
|
||||
id: number
|
||||
nickname: string
|
||||
avatar: string
|
||||
signature: string
|
||||
followerCount: number
|
||||
followingCount: number
|
||||
isFollowed?: boolean
|
||||
}
|
||||
|
||||
// 私信信息
|
||||
export interface Message {
|
||||
id: number
|
||||
senderId: number
|
||||
senderNickname: string
|
||||
senderAvatar: string
|
||||
receiverId: number
|
||||
receiverNickname: string
|
||||
receiverAvatar: string
|
||||
content: string
|
||||
isRead: number
|
||||
createTime: string
|
||||
}
|
||||
|
||||
// 私信发送参数
|
||||
export interface MessageSendParams {
|
||||
receiverId: number
|
||||
content: string
|
||||
}
|
||||
|
||||
// 通知信息
|
||||
export interface Notification {
|
||||
id: number
|
||||
type: number // 1:点赞,2:评论,3:关注,4:系统通知
|
||||
content: string
|
||||
relatedId: number
|
||||
isRead: number
|
||||
createTime: string
|
||||
}
|
||||
|
||||
// 用户设置
|
||||
export interface UserSetting {
|
||||
privacyLevel: number // 1:公开,2:仅关注,3:私密
|
||||
commentNotify: number
|
||||
likeNotify: number
|
||||
followNotify: number
|
||||
systemNotify: number
|
||||
}
|
||||
|
||||
// 用户资料更新参数
|
||||
export interface UserProfileUpdateParams {
|
||||
nickname?: string
|
||||
gender?: number
|
||||
birthday?: string
|
||||
signature?: string
|
||||
region?: string
|
||||
}
|
||||
|
||||
// 播放记录
|
||||
export interface PlayRecord {
|
||||
videoId: number
|
||||
progress: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
// 推荐视频
|
||||
export interface RecommendVideo {
|
||||
videoId: number
|
||||
score: number
|
||||
reason: string
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
|
||||
// 配置 dayjs
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
export function formatDateTime(date: string | Date, format = 'YYYY-MM-DD HH:mm:ss'): string {
|
||||
return dayjs(date).format(format)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
export function formatDate(date: string | Date): string {
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
export function formatTime(date: string | Date): string {
|
||||
return dayjs(date).format('HH:mm:ss')
|
||||
}
|
||||
|
||||
/**
|
||||
* 相对时间(如:3 天前)
|
||||
*/
|
||||
export function formatRelativeTime(date: string | Date): string {
|
||||
return dayjs(date).fromNow()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化视频时长(秒转为 mm:ss 或 hh:mm:ss)
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!seconds || seconds < 0) return '00:00'
|
||||
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
|
||||
if (hours > 0) {
|
||||
return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`
|
||||
}
|
||||
return `${pad(minutes)}:${pad(secs)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const k = 1024
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + units[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(如:1000 -> 1千,10000 -> 1万)
|
||||
*/
|
||||
export function formatNumber(num: number): string {
|
||||
if (num < 1000) {
|
||||
return num.toString()
|
||||
} else if (num < 10000) {
|
||||
return (num / 1000).toFixed(1) + '千'
|
||||
} else if (num < 100000000) {
|
||||
return (num / 10000).toFixed(1) + '万'
|
||||
} else {
|
||||
return (num / 100000000).toFixed(1) + '亿'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化播放量
|
||||
*/
|
||||
export function formatPlayCount(count: number): string {
|
||||
return formatNumber(count)
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay = 300
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
fn.apply(this, args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
*/
|
||||
export function throttle<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay = 300
|
||||
): (...args: Parameters<T>) => void {
|
||||
let lastTime = 0
|
||||
|
||||
return function (this: any, ...args: Parameters<T>) {
|
||||
const now = Date.now()
|
||||
if (now - lastTime >= delay) {
|
||||
fn.apply(this, args)
|
||||
lastTime = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝
|
||||
*/
|
||||
export function deepClone<T>(obj: T): T {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => deepClone(item)) as any
|
||||
}
|
||||
|
||||
const cloned = {} as T
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
cloned[key] = deepClone(obj[key])
|
||||
}
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串
|
||||
*/
|
||||
export function randomString(length = 16): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
const result = document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 URL 参数
|
||||
*/
|
||||
export function getQueryParam(name: string): string | null {
|
||||
const url = new URL(window.location.href)
|
||||
return url.searchParams.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 URL 参数
|
||||
*/
|
||||
export function buildQueryString(params: Record<string, any>): string {
|
||||
const searchParams = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
searchParams.append(key, String(value))
|
||||
}
|
||||
})
|
||||
return searchParams.toString()
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import { showToast } from 'vant'
|
||||
import router from '@/router'
|
||||
|
||||
// API 基础配置
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
const TIMEOUT = 30000
|
||||
|
||||
// 创建 axios 实例
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: TIMEOUT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 添加 Token
|
||||
if (userStore.token) {
|
||||
config.headers.Authorization = `Bearer ${userStore.token}`
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
console.error('请求错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
const { data } = response
|
||||
|
||||
// 业务状态码判断
|
||||
if (data.code === 200) {
|
||||
return data.data
|
||||
}
|
||||
|
||||
// Token 过期
|
||||
if (data.code === 401) {
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
showToast('登录已过期,请重新登录')
|
||||
return Promise.reject(new Error('Token expired'))
|
||||
}
|
||||
|
||||
// 其他业务错误
|
||||
showToast(data.message || '请求失败')
|
||||
return Promise.reject(new Error(data.message || '请求失败'))
|
||||
},
|
||||
(error) => {
|
||||
console.error('响应错误:', error)
|
||||
|
||||
// 网络错误
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
showToast('请求超时,请稍后重试')
|
||||
} else if (error.message.includes('Network Error')) {
|
||||
showToast('网络错误,请检查网络连接')
|
||||
} else if (error.response) {
|
||||
const status = error.response.status
|
||||
switch (status) {
|
||||
case 401:
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
showToast('登录已过期,请重新登录')
|
||||
break
|
||||
case 403:
|
||||
showToast('没有权限访问')
|
||||
break
|
||||
case 404:
|
||||
showToast('请求的资源不存在')
|
||||
break
|
||||
case 500:
|
||||
showToast('服务器错误,请稍后重试')
|
||||
break
|
||||
default:
|
||||
showToast(`请求失败: ${status}`)
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 封装请求方法
|
||||
const request = {
|
||||
get<T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.get(url, { params, ...config })
|
||||
},
|
||||
|
||||
post<T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.post(url, data, config)
|
||||
},
|
||||
|
||||
put<T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.put(url, data, config)
|
||||
},
|
||||
|
||||
delete<T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.delete(url, { params, ...config })
|
||||
},
|
||||
|
||||
upload<T = any>(url: string, file: File, onProgress?: (percent: number) => void): Promise<T> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return service.post(url, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total && onProgress) {
|
||||
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
onProgress(percent)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default request
|
||||
export { service as axiosInstance }
|
||||
@@ -0,0 +1,151 @@
|
||||
// 本地存储工具
|
||||
|
||||
const TOKEN_KEY = 'haovp_token'
|
||||
const REFRESH_TOKEN_KEY = 'haovp_refresh_token'
|
||||
const USER_INFO_KEY = 'haovp_user_info'
|
||||
|
||||
/**
|
||||
* 获取 Token
|
||||
*/
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Token
|
||||
*/
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 Token
|
||||
*/
|
||||
export function removeToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取刷新 Token
|
||||
*/
|
||||
export function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置刷新 Token
|
||||
*/
|
||||
export function setRefreshToken(token: string): void {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除刷新 Token
|
||||
*/
|
||||
export function removeRefreshToken(): void {
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
export function getUserInfo(): any {
|
||||
const info = localStorage.getItem(USER_INFO_KEY)
|
||||
return info ? JSON.parse(info) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户信息
|
||||
*/
|
||||
export function setUserInfo(info: object): void {
|
||||
localStorage.setItem(USER_INFO_KEY, JSON.stringify(info))
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除用户信息
|
||||
*/
|
||||
export function removeUserInfo(): void {
|
||||
localStorage.removeItem(USER_INFO_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有认证信息
|
||||
*/
|
||||
export function clearAuth(): void {
|
||||
removeToken()
|
||||
removeRefreshToken()
|
||||
removeUserInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否登录
|
||||
*/
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用本地存储
|
||||
*/
|
||||
export const storage = {
|
||||
get<T = any>(key: string): T | null {
|
||||
const value = localStorage.getItem(key)
|
||||
if (value) {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return value as any
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
set(key: string, value: any): void {
|
||||
if (typeof value === 'string') {
|
||||
localStorage.setItem(key, value)
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
},
|
||||
|
||||
remove(key: string): void {
|
||||
localStorage.removeItem(key)
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
localStorage.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session 存储
|
||||
*/
|
||||
export const session = {
|
||||
get<T = any>(key: string): T | null {
|
||||
const value = sessionStorage.getItem(key)
|
||||
if (value) {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return value as any
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
set(key: string, value: any): void {
|
||||
if (typeof value === 'string') {
|
||||
sessionStorage.setItem(key, value)
|
||||
} else {
|
||||
sessionStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
},
|
||||
|
||||
remove(key: string): void {
|
||||
sessionStorage.removeItem(key)
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
sessionStorage.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<div class="content">
|
||||
<h1>404</h1>
|
||||
<p>页面未找到</p>
|
||||
<van-button type="primary" to="/">返回首页</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.not-found {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
|
||||
.content {
|
||||
text-align: center;
|
||||
|
||||
h1 {
|
||||
font-size: 120px;
|
||||
color: #00a1d6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 24px;
|
||||
color: #666;
|
||||
margin: 20px 0 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="favorite-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"
|
||||
>
|
||||
<van-cell-group v-if="favorites.length" inset>
|
||||
<van-cell
|
||||
v-for="fav in favorites"
|
||||
:key="fav.id"
|
||||
:title="fav.name"
|
||||
:label="`${fav.videoCount}个视频`"
|
||||
is-link
|
||||
@click="goToFavorite(fav.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<van-icon name="star" class="fav-icon" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<van-empty v-else description="暂无收藏夹" />
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { 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
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await favoriteApi.getList()
|
||||
favorites.value = data
|
||||
finished.value = true
|
||||
} catch (error) {
|
||||
showToast('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!finished.value) loadFavorites()
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
loadFavorites(true)
|
||||
}
|
||||
|
||||
const goToFavorite = (id: number) => {
|
||||
router.push(`/favorite/${id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFavorites()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.favorite-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
|
||||
.fav-icon {
|
||||
color: #f7ba2a;
|
||||
margin-right: 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="category-page">
|
||||
<van-nav-bar title="分类" fixed placeholder />
|
||||
|
||||
<div class="category-content">
|
||||
<van-sidebar v-model="activeCategory">
|
||||
<van-sidebar-item
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppTabbar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } 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 { categoryApi, type Category } from '@/api/category.ts'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
import type { VideoInfo } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const categories = ref<Category[]>([])
|
||||
const activeCategory = ref(0)
|
||||
const videos = ref<VideoInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const currentPage = ref(1)
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
categories.value = await categoryApi.getList()
|
||||
} catch (error) {
|
||||
showToast('加载分类失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadVideos = async (isRefresh = false) => {
|
||||
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.getList({
|
||||
page: currentPage.value,
|
||||
size: 20,
|
||||
categoryId: category.id
|
||||
})
|
||||
|
||||
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) {
|
||||
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}`)
|
||||
}
|
||||
|
||||
watch(activeCategory, () => {
|
||||
loadVideos(true)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.category-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.category-content {
|
||||
display: flex;
|
||||
height: calc(100vh - 46px);
|
||||
|
||||
.video-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<!-- 顶部导航 -->
|
||||
<van-nav-bar title="HAOVP" fixed placeholder>
|
||||
<template #right>
|
||||
<van-icon name="search" size="22" @click="router.push('/search')" />
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
|
||||
<!-- 分类导航 -->
|
||||
<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>
|
||||
|
||||
<!-- 视频列表 -->
|
||||
<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>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
|
||||
<!-- 底部导航 -->
|
||||
<AppTabbar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } 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 type { VideoInfo } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 分类
|
||||
const categories = ref<Category[]>([])
|
||||
const activeCategory = ref<string | number>('recommend')
|
||||
|
||||
// 视频列表
|
||||
const videos = ref<VideoInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
// 加载分类
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const res = await categoryApi.getList()
|
||||
categories.value = res
|
||||
} catch (error) {
|
||||
console.error('加载分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载视频列表
|
||||
const loadVideos = async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
currentPage.value = 1
|
||||
videos.value = []
|
||||
finished.value = false
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
let res
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
size: pageSize,
|
||||
...(typeof activeCategory.value === 'number' && { categoryId: activeCategory.value })
|
||||
}
|
||||
|
||||
if (activeCategory.value === 'recommend') {
|
||||
res = await recommendApi.getHome(params)
|
||||
} else if (activeCategory.value === 'hot') {
|
||||
res = await recommendApi.getHot(params)
|
||||
} else {
|
||||
res = await videoApi.getList(params)
|
||||
}
|
||||
|
||||
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 onRefresh = () => {
|
||||
loadVideos(true)
|
||||
}
|
||||
|
||||
// 分类切换
|
||||
const onCategoryChange = () => {
|
||||
loadVideos(true)
|
||||
}
|
||||
|
||||
// 跳转视频详情
|
||||
const goToVideo = (id: number) => {
|
||||
router.push(`/video/${id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
loadVideos()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.category-nav {
|
||||
background: #fff;
|
||||
position: sticky;
|
||||
top: 46px;
|
||||
z-index: 100;
|
||||
|
||||
:deep(.van-tabs__nav) {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
:deep(.van-tab) {
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.van-tabs__line) {
|
||||
background: #00a1d6;
|
||||
}
|
||||
}
|
||||
|
||||
.video-list {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="main-layout">
|
||||
<!-- 顶部导航 -->
|
||||
<AppHeader />
|
||||
<!-- 主内容区 -->
|
||||
<main class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
<!-- 底部导航(移动端) -->
|
||||
<AppTabbar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import AppTabbar from '@/components/layout/AppTabbar.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
onMounted(() => {
|
||||
appStore.initApp()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.main-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding-top: var(--header-height);
|
||||
padding-bottom: 50px; // 移动端 tabbar 高度
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</van-form>
|
||||
|
||||
<!-- 其他登录方式 -->
|
||||
<div class="login-footer">
|
||||
<router-link to="/register" class="link">注册账号</router-link>
|
||||
</div>
|
||||
</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 { authApi, type CaptchaResult } from '@/api/auth.ts'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const captchaKey = ref('')
|
||||
const captchaImage = ref('')
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha: ''
|
||||
})
|
||||
|
||||
// 获取验证码
|
||||
const refreshCaptcha = async () => {
|
||||
try {
|
||||
const res: CaptchaResult = await authApi.getCaptcha()
|
||||
captchaKey.value = res.captchaKey
|
||||
captchaImage.value = res.captchaImage
|
||||
} catch (error) {
|
||||
showToast('获取验证码失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
const handleLogin = async () => {
|
||||
if (!form.username || !form.password || !form.captcha) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await authApi.login({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
captcha: form.captcha,
|
||||
captchaKey: captchaKey.value
|
||||
})
|
||||
|
||||
// 保存登录信息
|
||||
userStore.setToken(result.accessToken, result.refreshToken)
|
||||
userStore.setUserInfo({
|
||||
id: result.userId,
|
||||
username: result.username
|
||||
})
|
||||
|
||||
showSuccessToast('登录成功')
|
||||
|
||||
// 跳转到之前的页面或首页
|
||||
const redirect = route.query.redirect as string
|
||||
router.replace(redirect || '/')
|
||||
} catch (error: any) {
|
||||
showToast(error.message || '登录失败')
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshCaptcha()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 40px 24px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.logo {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #00a1d6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.slogan {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.captcha-image {
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.captcha-error {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
:deep(.van-cell-group--inset) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.van-field) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.captcha-btn {
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
|
||||
.link {
|
||||
color: #00a1d6;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<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: '请输入邮箱' }, { type: 'email', 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>
|
||||
</van-form>
|
||||
|
||||
<div class="register-footer">
|
||||
<router-link to="/login" class="link">已有账号?去登录</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showSuccessToast } from 'vant'
|
||||
import { authApi, type CaptchaResult } from '@/api/auth.ts'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const captchaKey = ref('')
|
||||
const captchaImage = ref('')
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
captcha: ''
|
||||
})
|
||||
|
||||
const validatePassword = () => {
|
||||
return form.password === form.confirmPassword
|
||||
}
|
||||
|
||||
const refreshCaptcha = async () => {
|
||||
try {
|
||||
const res: CaptchaResult = await authApi.getCaptcha()
|
||||
captchaKey.value = res.captchaKey
|
||||
captchaImage.value = res.captchaImage
|
||||
} catch (error) {
|
||||
showToast('获取验证码失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!validatePassword()) {
|
||||
showToast('两次密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await authApi.register({
|
||||
username: form.username,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
password: form.password,
|
||||
confirmPassword: form.confirmPassword,
|
||||
captcha: form.captcha,
|
||||
captchaKey: captchaKey.value
|
||||
})
|
||||
showSuccessToast('注册成功')
|
||||
router.push('/login')
|
||||
} catch (error: any) {
|
||||
showToast(error.message || '注册失败')
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshCaptcha()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.register-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 30px 24px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #00a1d6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.slogan {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.register-actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.register-footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
|
||||
.link {
|
||||
color: #00a1d6;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-btn {
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { userApi, type Message } from '@/api/user.ts'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
import { formatRelativeTime } from '@/utils'
|
||||
|
||||
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 scrollToBottom = () => {
|
||||
if (listRef.value) {
|
||||
listRef.value.scrollTop = listRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMessages()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-detail-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&.is-self {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
background: #00a1d6;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 70%;
|
||||
|
||||
.bubble {
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #eee;
|
||||
|
||||
.van-field {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="message-page">
|
||||
<van-nav-bar title="私信" fixed placeholder left-arrow @click-left="router.back()">
|
||||
<template #right>
|
||||
<van-icon name="plus" size="22" />
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<van-cell-group v-if="conversations.length" inset>
|
||||
<van-cell
|
||||
v-for="conv in conversations"
|
||||
:key="conv.targetId"
|
||||
:title="conv.targetNickname"
|
||||
:label="conv.lastMessage"
|
||||
is-link
|
||||
@click="goToDetail(conv.targetId)"
|
||||
>
|
||||
<template #icon>
|
||||
<van-image :src="conv.targetAvatar || defaultAvatar" round width="40" height="40" />
|
||||
</template>
|
||||
<template #value>
|
||||
<van-badge :content="conv.unreadCount" :show-zero="false">
|
||||
<span class="time">{{ formatRelativeTime(conv.lastTime) }}</span>
|
||||
</van-badge>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<van-empty v-else description="暂无私信" />
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
import { formatRelativeTime } from '@/utils'
|
||||
|
||||
interface Conversation {
|
||||
targetId: number
|
||||
targetNickname: string
|
||||
targetAvatar: string
|
||||
lastMessage: string
|
||||
lastTime: string
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
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 loadConversations = async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
conversations.value = []
|
||||
finished.value = false
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await userApi.getMessageList()
|
||||
conversations.value = data
|
||||
finished.value = true
|
||||
} catch (error) {
|
||||
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()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="notifications-page">
|
||||
<van-nav-bar title="通知" fixed placeholder left-arrow @click-left="router.back()" />
|
||||
|
||||
<van-tabs v-model:active="activeTab">
|
||||
<van-tab title="全部" name="all" />
|
||||
<van-tab title="点赞" name="like" />
|
||||
<van-tab title="评论" name="comment" />
|
||||
<van-tab title="关注" name="follow" />
|
||||
<van-tab title="系统" name="system" />
|
||||
</van-tabs>
|
||||
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<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)"
|
||||
>
|
||||
<template #icon>
|
||||
<van-icon :name="getIcon(notif.type)" :color="getColor(notif.type)" class="notif-icon" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<van-empty v-else description="暂无通知" />
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { userApi, type Notification } from '@/api/user.ts'
|
||||
import { formatRelativeTime } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
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 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 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
|
||||
try {
|
||||
const data = await userApi.getNotifications()
|
||||
notifications.value = data
|
||||
finished.value = true
|
||||
} catch (error) {
|
||||
showToast('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!finished.value) loadNotifications()
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
loadNotifications(true)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNotifications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.notifications-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
|
||||
.notif-icon {
|
||||
font-size: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<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="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>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import VideoCard from '@/components/video/VideoCard.vue'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
import type { VideoInfo } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const keyword = ref('')
|
||||
const videos = ref<VideoInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const currentPage = ref(1)
|
||||
|
||||
const loadVideos = async (isRefresh = false) => {
|
||||
if (!keyword.value.trim()) return
|
||||
|
||||
if (isRefresh) {
|
||||
currentPage.value = 1
|
||||
videos.value = []
|
||||
finished.value = false
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await videoApi.getList({
|
||||
page: currentPage.value,
|
||||
size: 20,
|
||||
keyword: keyword.value
|
||||
})
|
||||
|
||||
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) {
|
||||
showToast('搜索失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!finished.value) loadVideos()
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
loadVideos(true)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
loadVideos(true)
|
||||
}
|
||||
|
||||
const goToVideo = (id: number) => {
|
||||
router.push(`/video/${id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.keyword) {
|
||||
keyword.value = route.query.keyword as string
|
||||
loadVideos(true)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.search-content {
|
||||
padding: 12px;
|
||||
|
||||
.video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="upload-page">
|
||||
<van-nav-bar title="上传视频" fixed placeholder left-arrow @click-left="router.back()" />
|
||||
|
||||
<van-form @submit="handleSubmit">
|
||||
<van-cell-group inset>
|
||||
<van-field
|
||||
v-model="form.title"
|
||||
name="title"
|
||||
label="标题"
|
||||
placeholder="请输入视频标题"
|
||||
:rules="[{ required: true, message: '请输入标题' }]"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.description"
|
||||
name="description"
|
||||
label="简介"
|
||||
type="textarea"
|
||||
rows="3"
|
||||
autosize
|
||||
placeholder="请输入视频简介"
|
||||
/>
|
||||
<van-field
|
||||
v-model="categoryName"
|
||||
is-link
|
||||
readonly
|
||||
name="category"
|
||||
label="分类"
|
||||
placeholder="请选择分类"
|
||||
@click="showCategoryPicker = true"
|
||||
/>
|
||||
<van-field name="cover" label="封面">
|
||||
<template #input>
|
||||
<van-uploader
|
||||
v-model="coverFile"
|
||||
max-count="1"
|
||||
accept="image/*"
|
||||
:after-read="afterReadCover"
|
||||
/>
|
||||
</template>
|
||||
</van-field>
|
||||
</van-cell-group>
|
||||
|
||||
<div class="upload-area">
|
||||
<van-uploader
|
||||
v-model="videoFile"
|
||||
max-count="1"
|
||||
accept="video/*"
|
||||
:after-read="afterReadVideo"
|
||||
>
|
||||
<van-button type="primary" block>选择视频文件</van-button>
|
||||
</van-uploader>
|
||||
<div v-if="uploadProgress > 0" class="progress">
|
||||
<van-progress :percentage="uploadProgress" />
|
||||
<span>上传中 {{ uploadProgress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="submit-btn">
|
||||
<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-picker
|
||||
:columns="categories"
|
||||
@confirm="onCategoryConfirm"
|
||||
@cancel="showCategoryPicker = false"
|
||||
/>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showSuccessToast } from 'vant'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
import { categoryApi, type Category } from '@/api/category.ts'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
categoryId: 0
|
||||
})
|
||||
const categoryName = ref('')
|
||||
const coverFile = ref<any[]>([])
|
||||
const videoFile = ref<any[]>([])
|
||||
const uploadProgress = ref(0)
|
||||
const submitting = ref(false)
|
||||
const showCategoryPicker = ref(false)
|
||||
const categories = ref<{ text: string; value: number }[]>([])
|
||||
|
||||
const videoUrl = ref('')
|
||||
const coverUrl = ref('')
|
||||
const videoInfo = ref<any>(null)
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const data: Category[] = await categoryApi.getList()
|
||||
categories.value = data.map((cat) => ({
|
||||
text: cat.name,
|
||||
value: cat.id
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('加载分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onCategoryConfirm = ({ selectedOptions }: any) => {
|
||||
const option = selectedOptions[0]
|
||||
form.categoryId = option.value
|
||||
categoryName.value = option.text
|
||||
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.upload(file.file, (percent) => {
|
||||
uploadProgress.value = percent
|
||||
})
|
||||
videoUrl.value = result.videoUrl
|
||||
coverUrl.value = result.coverUrl
|
||||
} catch (error) {
|
||||
showToast('上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!videoUrl.value) {
|
||||
showToast('请先上传视频')
|
||||
return
|
||||
}
|
||||
if (!form.categoryId) {
|
||||
showToast('请选择分类')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await videoApi.create({
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
videoUrl: videoUrl.value,
|
||||
coverUrl: coverUrl.value,
|
||||
duration: videoInfo.value?.duration || 0,
|
||||
width: videoInfo.value?.width || 0,
|
||||
height: videoInfo.value?.height || 0,
|
||||
format: videoInfo.value?.format || '',
|
||||
size: videoInfo.value?.size || 0,
|
||||
categoryId: form.categoryId
|
||||
})
|
||||
showSuccessToast('发布成功')
|
||||
router.push('/my/videos')
|
||||
} catch (error: any) {
|
||||
showToast(error.message || '发布失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.upload-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
|
||||
.upload-area {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
margin: 12px;
|
||||
border-radius: 8px;
|
||||
|
||||
.progress {
|
||||
margin-top: 12px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="follow-page">
|
||||
<van-nav-bar title="粉丝" fixed placeholder left-arrow @click-left="router.back()" />
|
||||
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<van-cell-group inset>
|
||||
<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" />
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="nickname">{{ user.nickname }}</span>
|
||||
</template>
|
||||
<template #label>
|
||||
<span class="signature">{{ user.signature || '这个人很懒' }}</span>
|
||||
</template>
|
||||
<template #value>
|
||||
<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-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { showToast, showSuccessToast } from 'vant'
|
||||
import { userApi, type UserFollow } from '@/api/user.ts'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const userId = Number(route.params.id)
|
||||
|
||||
const users = ref<UserFollow[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const currentPage = ref(1)
|
||||
|
||||
const defaultAvatar = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"%3E%3Ccircle cx="50" cy="50" r="50" fill="%23e0e0e0"/%3E%3Ctext x="50%25" y="50%25" fill="%23999" text-anchor="middle" dy=".3em"%3EU%3C/text%3E%3C/svg%3E'
|
||||
|
||||
const loadUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await userApi.getFollowers(userId)
|
||||
users.value = data
|
||||
finished.value = true
|
||||
} catch (error) {
|
||||
showToast('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!finished.value) loadUsers()
|
||||
}
|
||||
|
||||
const toggleFollow = async (user: UserFollow) => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
showToast('请先登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (user.isFollowed) {
|
||||
await userApi.unfollow(user.id)
|
||||
user.isFollowed = false
|
||||
showSuccessToast('已取消关注')
|
||||
} else {
|
||||
await userApi.follow(user.id)
|
||||
user.isFollowed = true
|
||||
showSuccessToast('关注成功')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const goToProfile = (id: number) => {
|
||||
router.push(`/user/${id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.follow-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
|
||||
.nickname {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.signature {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="follow-page">
|
||||
<van-nav-bar title="关注" fixed placeholder left-arrow @click-left="router.back()" />
|
||||
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<van-cell-group inset>
|
||||
<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" />
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="nickname">{{ user.nickname }}</span>
|
||||
</template>
|
||||
<template #label>
|
||||
<span class="signature">{{ user.signature || '这个人很懒' }}</span>
|
||||
</template>
|
||||
<template #value>
|
||||
<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-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { showToast, showSuccessToast } from 'vant'
|
||||
import { userApi, type UserFollow } from '@/api/user.ts'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userId = Number(route.params.id)
|
||||
|
||||
const users = ref<UserFollow[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = 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 loadUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await userApi.getFollowing(userId)
|
||||
users.value = data
|
||||
finished.value = true
|
||||
} catch (error) {
|
||||
showToast('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!finished.value) loadUsers()
|
||||
}
|
||||
|
||||
const unfollow = async (user: UserFollow) => {
|
||||
try {
|
||||
await userApi.unfollow(user.id)
|
||||
users.value = users.value.filter((u) => u.id !== user.id)
|
||||
showSuccessToast('已取消关注')
|
||||
} catch (error) {
|
||||
showToast('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const goToProfile = (id: number) => {
|
||||
router.push(`/user/${id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.follow-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
|
||||
.nickname {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.signature {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<van-nav-bar title="播放历史" fixed placeholder left-arrow @click-left="router.back()">
|
||||
<template #right>
|
||||
<van-icon name="delete-o" size="22" @click="clearHistory" />
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<van-cell-group v-if="videos.length" inset>
|
||||
<van-cell v-for="item in videos" :key="item.videoId" @click="goToVideo(item.videoId)">
|
||||
<template #icon>
|
||||
<van-image :src="item.coverUrl" width="80" height="45" fit="cover" radius="4" />
|
||||
</template>
|
||||
<template #title>
|
||||
<div class="video-title">{{ item.title }}</div>
|
||||
</template>
|
||||
<template #label>
|
||||
<div class="video-progress">
|
||||
观看至 {{ formatDuration(item.progress) }} / {{ formatDuration(item.duration) }}
|
||||
</div>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
<van-empty v-else description="暂无播放记录" />
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showConfirmDialog } from 'vant'
|
||||
import { formatDuration } from '@/utils'
|
||||
|
||||
interface HistoryItem {
|
||||
videoId: number
|
||||
title: string
|
||||
coverUrl: string
|
||||
progress: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const videos = ref<HistoryItem[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const refreshing = ref(false)
|
||||
|
||||
const loadVideos = async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
videos.value = []
|
||||
finished.value = false
|
||||
}
|
||||
loading.value = true
|
||||
// TODO: 实际从API获取播放历史
|
||||
finished.value = true
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (!finished.value) loadVideos()
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
loadVideos(true)
|
||||
}
|
||||
|
||||
const clearHistory = async () => {
|
||||
try {
|
||||
await showConfirmDialog({
|
||||
title: '提示',
|
||||
message: '确定要清空播放历史吗?'
|
||||
})
|
||||
videos.value = []
|
||||
showToast('已清空')
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
|
||||
const goToVideo = (id: number) => {
|
||||
router.push(`/video/${id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVideos(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.history-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
|
||||
.video-title {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-progress {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="my-videos-page">
|
||||
<van-nav-bar title="我的视频" fixed placeholder left-arrow @click-left="router.back()">
|
||||
<template #right>
|
||||
<van-icon name="plus" size="22" @click="router.push('/upload')" />
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多了"
|
||||
@load="loadMore"
|
||||
>
|
||||
<van-cell-group v-if="videos.length" inset>
|
||||
<van-cell v-for="video in videos" :key="video.id" @click="goToVideo(video.id)">
|
||||
<template #icon>
|
||||
<van-image :src="video.coverUrl" width="80" height="45" fit="cover" radius="4" />
|
||||
</template>
|
||||
<template #title>
|
||||
<div class="video-title">{{ video.title }}</div>
|
||||
</template>
|
||||
<template #label>
|
||||
<div class="video-stats">
|
||||
<span>{{ formatNumber(video.viewCount) }}播放</span>
|
||||
<span>{{ formatNumber(video.likeCount) }}赞</span>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } 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 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 getStatusType = (status: number) => {
|
||||
const types: Record<number, string> = {
|
||||
0: 'warning',
|
||||
1: 'success',
|
||||
2: 'danger',
|
||||
3: 'default'
|
||||
}
|
||||
return types[status] || 'default'
|
||||
}
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
const texts: Record<number, string> = {
|
||||
0: '审核中',
|
||||
1: '已发布',
|
||||
2: '审核拒绝',
|
||||
3: '已下架'
|
||||
}
|
||||
return texts[status] || '未知'
|
||||
}
|
||||
|
||||
const loadVideos = async (isRefresh = false) => {
|
||||
if (!userStore.userId) return
|
||||
|
||||
if (isRefresh) {
|
||||
currentPage.value = 1
|
||||
videos.value = []
|
||||
finished.value = false
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await videoApi.getUserVideos(userStore.userId, { page: currentPage.value, size: 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) {
|
||||
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)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.my-videos-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
|
||||
.video-title {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast, showSuccessToast } from 'vant'
|
||||
import { userApi } from '@/api/user.ts'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const saving = ref(false)
|
||||
const showBirthdayPicker = ref(false)
|
||||
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 form = reactive({
|
||||
avatar: '',
|
||||
nickname: '',
|
||||
signature: '',
|
||||
gender: 0,
|
||||
birthday: '',
|
||||
region: ''
|
||||
})
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const profile = await userApi.getCurrentProfile()
|
||||
form.avatar = profile.avatar || ''
|
||||
form.nickname = profile.nickname || ''
|
||||
form.signature = profile.signature || ''
|
||||
form.gender = profile.gender || 0
|
||||
form.birthday = profile.birthday || ''
|
||||
form.region = profile.region || ''
|
||||
if (form.birthday) {
|
||||
selectedDate.value = form.birthday.split('-')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('加载资料失败')
|
||||
}
|
||||
}
|
||||
|
||||
const chooseAvatar = () => {
|
||||
avatarInput.value?.click()
|
||||
}
|
||||
|
||||
const onAvatarChange = async (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const url = await userApi.uploadAvatar(file)
|
||||
form.avatar = url
|
||||
showSuccessToast('头像上传成功')
|
||||
} catch (error) {
|
||||
showToast('头像上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onBirthdayConfirm = ({ selectedValues }: any) => {
|
||||
form.birthday = selectedValues.join('-')
|
||||
showBirthdayPicker.value = false
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.nickname.trim()) {
|
||||
showToast('请输入昵称')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await userApi.updateProfile({
|
||||
nickname: form.nickname,
|
||||
signature: form.signature,
|
||||
gender: form.gender,
|
||||
birthday: form.birthday,
|
||||
region: form.region
|
||||
})
|
||||
if (form.avatar) {
|
||||
// 头像已单独上传
|
||||
}
|
||||
showSuccessToast('保存成功')
|
||||
router.back()
|
||||
} catch (error) {
|
||||
showToast('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.profile-edit-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
|
||||
.van-cell-group {
|
||||
margin: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<van-nav-bar title="用户主页" fixed placeholder left-arrow @click-left="router.back()" />
|
||||
|
||||
<div v-if="loading" class="loading-container">
|
||||
<van-loading size="24px">加载中...</van-loading>
|
||||
</div>
|
||||
|
||||
<template v-else-if="userInfo">
|
||||
<div class="user-header">
|
||||
<div class="user-info">
|
||||
<van-image :src="userInfo.avatar || defaultAvatar" round width="80" height="80" />
|
||||
<div class="info">
|
||||
<h2 class="nickname">{{ userInfo.nickname }}</h2>
|
||||
<div class="level">Lv.{{ userInfo.level }}</div>
|
||||
<div class="signature">{{ userInfo.signature || '这个人很懒,什么都没写' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="value">{{ userInfo.videoCount }}</div>
|
||||
<div class="label">视频</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="value">{{ userInfo.followerCount }}</div>
|
||||
<div class="label">粉丝</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="value">{{ userInfo.followingCount }}</div>
|
||||
<div class="label">关注</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="value">{{ userInfo.likeCount }}</div>
|
||||
<div class="label">获赞</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<van-button v-if="!isSelf" :type="isFollowed ? 'default' : 'primary'" size="small" @click="toggleFollow">
|
||||
{{ isFollowed ? '已关注' : '关注' }}
|
||||
</van-button>
|
||||
<van-button v-else type="primary" size="small" @click="router.push('/settings/profile')">
|
||||
编辑资料
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-tabs v-model:active="activeTab" sticky>
|
||||
<van-tab title="视频" name="videos">
|
||||
<div class="video-grid">
|
||||
<VideoCard v-for="video in videos" :key="video.id" :video="video" @click="goToVideo(video.id)" />
|
||||
</div>
|
||||
</van-tab>
|
||||
<van-tab title="收藏" name="favorites">
|
||||
<van-empty description="暂无收藏" />
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
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 { useUserStore } from '@/stores/user.ts'
|
||||
import type { UserInfo, VideoInfo } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const userId = computed(() => Number(route.params.id))
|
||||
const isSelf = computed(() => userStore.userId === userId.value)
|
||||
|
||||
const loading = ref(true)
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const isFollowed = ref(false)
|
||||
const activeTab = ref('videos')
|
||||
const videos = ref<VideoInfo[]>([])
|
||||
|
||||
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 loadUserInfo = async () => {
|
||||
try {
|
||||
userInfo.value = await userApi.getPublicProfile(userId.value)
|
||||
if (!isSelf.value && userStore.isLoggedIn) {
|
||||
isFollowed.value = await userApi.checkFollow(userId.value)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('加载用户信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadUserVideos = async () => {
|
||||
try {
|
||||
const res = await videoApi.getUserVideos(userId.value, { page: 1, size: 20 })
|
||||
videos.value = res.records
|
||||
} catch (error) {
|
||||
console.error('加载视频失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFollow = async () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
showToast('请先登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (isFollowed.value) {
|
||||
await userApi.unfollow(userId.value)
|
||||
isFollowed.value = false
|
||||
showSuccessToast('已取消关注')
|
||||
} else {
|
||||
await userApi.follow(userId.value)
|
||||
isFollowed.value = true
|
||||
showSuccessToast('关注成功')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const goToVideo = (id: number) => {
|
||||
router.push(`/video/${id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
await Promise.all([loadUserInfo(), loadUserVideos()])
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.profile-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.user-header {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
|
||||
.nickname {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.level {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: linear-gradient(to right, #00a1d6, #fb7299);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.signature {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid #eee;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
|
||||
.value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<van-nav-bar title="设置" fixed placeholder left-arrow @click-left="router.back()" />
|
||||
|
||||
<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>
|
||||
|
||||
<van-cell-group inset>
|
||||
<van-cell title="清除缓存" :value="cacheSize" @click="clearCache" />
|
||||
<van-cell title="关于我们" is-link to="/about" />
|
||||
<van-cell title="版本号" value="1.0.0" />
|
||||
</van-cell-group>
|
||||
|
||||
<div class="logout-btn">
|
||||
<van-button type="danger" block @click="handleLogout">退出登录</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showConfirmDialog, showToast, showSuccessToast } from 'vant'
|
||||
import { useUserStore } from '@/stores/user.ts'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const cacheSize = ref('0KB')
|
||||
|
||||
const clearCache = () => {
|
||||
showToast('缓存已清除')
|
||||
cacheSize.value = '0KB'
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await showConfirmDialog({
|
||||
title: '提示',
|
||||
message: '确定要退出登录吗?'
|
||||
})
|
||||
await userStore.logout()
|
||||
showSuccessToast('已退出登录')
|
||||
router.push('/')
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
|
||||
.van-cell-group {
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<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>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import VideoCard from '@/components/video/VideoCard.vue'
|
||||
import { videoApi } from '@/api/video.ts'
|
||||
import type { VideoInfo } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userId = Number(route.params.id)
|
||||
|
||||
const videos = ref<VideoInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const currentPage = ref(1)
|
||||
|
||||
const loadVideos = async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
currentPage.value = 1
|
||||
videos.value = []
|
||||
finished.value = false
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await videoApi.getUserVideos(userId, { page: currentPage.value, size: 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) {
|
||||
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)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-videos-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user