refactor(project): Reorganize frontend and backend directories

This commit is contained in:
2026-03-18 10:45:15 +08:00
parent cc9eaf4cdf
commit 92a139e470
513 changed files with 28251 additions and 287 deletions
+13
View File
@@ -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"
}
}
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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')
}
}
+87
View File
@@ -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')
}
+20
View File
@@ -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>
+23
View File
@@ -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']
}
}
}
}
})