chore(web): 收口前几轮后台改动(品牌 / 媒资上传 / 轮播图 / 类型管理 / 操作日志 / App 用户)
为什么:这些改动在前几轮就已完成并在 :3005 验证过,但一直留在工作区没有入库。 下一步 3-4 的四个页面依赖其中的公共样式入口(src/styles/business/index.css)与 api/admin 的公共类型(PageResult 等),如果不先收口,新页面的提交就会夹带旧改动, 既说不清 diff 边界,也让 index.css 这类多轮共用的文件长期处于半提交状态。 本提交只做归档,不改行为: - 品牌与接口基址改成 FruitShorts / 短剧服务(.env.*、README、vite 代理) - 去掉前端 mock 数据目录(src/data、src/permissions、stores/auth),改为走真实接口 - 新增 api/admin 下的接口封装与 src/utils/uploader.ts(媒资分片直传) - 新增/改造:轮播图管理、类型管理、操作日志、App 用户管理、后台布局与公共样式
This commit is contained in:
1 parent
40546bfd01
commit
44c2c46a68
59 files changed
+4071
-971
No files matched your search
@@ -8,13 +8,13 @@ VITE_API_BASE_PATH=
|
||||
VITE_BASE_PATH=/
|
||||
|
||||
# 标题
|
||||
VITE_APP_TITLE=ReelShort
|
||||
VITE_APP_TITLE=FruitShorts
|
||||
|
||||
# 是否全量引入element-plus样式
|
||||
VITE_USE_ALL_ELEMENT_PLUS_STYLE=true
|
||||
|
||||
# 是否开启mock
|
||||
VITE_USE_MOCK=true
|
||||
VITE_USE_MOCK=false
|
||||
|
||||
# 是否使用在线图标
|
||||
VITE_USE_ONLINE_ICON=true
|
||||
@@ -20,7 +20,7 @@ VITE_SOURCEMAP=true
|
||||
VITE_OUT_DIR=dist-dev
|
||||
|
||||
# 标题
|
||||
VITE_APP_TITLE=ElementAdmin
|
||||
VITE_APP_TITLE=FruitShorts
|
||||
|
||||
# 是否包分析
|
||||
VITE_USE_BUNDLE_ANALYZER=false
|
||||
|
||||
@@ -20,7 +20,7 @@ VITE_SOURCEMAP=false
|
||||
VITE_OUT_DIR=dist-pro
|
||||
|
||||
# 标题
|
||||
VITE_APP_TITLE=ElementAdmin
|
||||
VITE_APP_TITLE=FruitShorts
|
||||
|
||||
# 是否包分析
|
||||
VITE_USE_BUNDLE_ANALYZER=true
|
||||
|
||||
@@ -20,7 +20,7 @@ VITE_SOURCEMAP=true
|
||||
VITE_OUT_DIR=dist-test
|
||||
|
||||
# 标题
|
||||
VITE_APP_TITLE=ElementAdmin
|
||||
VITE_APP_TITLE=FruitShorts
|
||||
|
||||
# 是否包分析
|
||||
VITE_USE_BUNDLE_ANALYZER=false
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
# ReelShort 内容管理后台
|
||||
# FruitShorts 内容管理后台
|
||||
|
||||
短剧内容管理后台。界面和工程结构使用 [vue-element-plus-admin](https://element-plus-admin-doc.cn/) 的 `main` 分支,在上面接入了短剧、剧集、角色、菜单和管理员页面。
|
||||
短剧内容管理后台。界面与工程结构基于 [vue-element-plus-admin](https://element-plus-admin-doc.cn/),在其上接入了短剧、剧集、App 用户、角色、菜单和管理员页面,数据全部来自 Java 后端服务。
|
||||
|
||||
界面名称由 `VITE_APP_TITLE` 控制(`.env.base` 及 dev / test / pro 三个环境文件),当前值为 `FruitShorts`,同时作用于浏览器标题、侧边栏品牌与登录页。
|
||||
|
||||
后端工程在同级目录 `../FruitServer`,接口与启动方式见 [../FruitServer/README.md](../FruitServer/README.md)。
|
||||
|
||||
## 环境
|
||||
|
||||
- Node.js 18 或更高版本
|
||||
- pnpm 8 或更高版本
|
||||
- 已启动的后端服务(默认 `http://127.0.0.1:8080`)
|
||||
|
||||
## 开始
|
||||
|
||||
先启动后端(见 `../FruitServer/README.md`),再启动前端:
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
开发服务地址是 [http://localhost:3005](http://localhost:3005)。同一局域网的设备可以使用终端里的 Network 地址打开。
|
||||
开发服务地址是 http://localhost:3005 。`vite.config.mts` 已把 `/admin` 代理到 `http://127.0.0.1:8080`、`/files` 代理到后端本地媒资目录,因此前端不需要额外配置跨域。
|
||||
|
||||
演示账号:
|
||||
演示账号(密码均为 `admin123`,由后端 `sql/data.sql` 初始化):
|
||||
|
||||
| 账号 | 密码 |
|
||||
| ------- | ---------- |
|
||||
| `admin` | `admin123` |
|
||||
| `test` | `test` |
|
||||
| 账号 | 角色 | 说明 |
|
||||
| -------------------- | ---------- | ------------------------------ |
|
||||
| `admin` | 超级管理员 | 拥有全部菜单与操作权限 |
|
||||
| `content.editor01` | 内容运营 | 只能看到内容管理,且无审核权限 |
|
||||
| `content.reviewer01` | 审核专员 | 可审核发布 |
|
||||
| `data.analyst01` | 数据分析师 | 只读内容数据 |
|
||||
|
||||
`admin` 拥有全部菜单。`test` 只用于对照框架自带的权限示例。
|
||||
用不同账号登录可以直观看到菜单与按钮随权限变化。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -39,22 +48,26 @@ pnpm dev
|
||||
|
||||
登录后菜单包括:
|
||||
|
||||
- 工作台
|
||||
- 内容管理:短剧管理、剧集管理
|
||||
- 工作台:短剧总数、累计播放量、待审核数、已上线数与最近更新
|
||||
- 内容管理:短剧管理(含封面分片上传、提交审核/通过/驳回/上线/下线)、剧集管理(含视频分片上传、自动读取时长、播放顺序调整)、轮播图管理(含图片上传、跳转配置、排序与上下线)、类型管理(短剧类型的增删改)
|
||||
- 用户管理 → App 用户:按 uid / 昵称 / 手机号 / 邮箱检索 C 端用户,查看资料、观看流水与处置记录,支持封禁 / 限制 / 解封(处置后该用户全部会话立即失效)
|
||||
- 后台管理:角色权限配置、管理员用户列表、菜单管理、角色分类、系统设置
|
||||
- 后台管理 → 操作日志:查看后台写操作的请求与响应、操作人、耗时
|
||||
|
||||
这些页面的数据仍是前端演示数据,没有接后端。登录和菜单由框架的 mock 接口提供。
|
||||
左侧菜单由后端 `GET /admin/auth/menus` 下发,属于服务端动态路由;在菜单管理页改动菜单后,重新登录即可看到新的菜单结构。
|
||||
|
||||
## 目录
|
||||
|
||||
框架原有代码保持 [vue-element-plus-admin](https://github.com/kailong321200875/vue-element-plus-admin/tree/mini) 的结构。本项目新增的业务代码在:
|
||||
框架原有代码保持 vue-element-plus-admin 的结构。本项目新增的业务代码:
|
||||
|
||||
- `src/api/admin/`:管理端接口封装(短剧、剧集、角色、菜单、管理员、配置)
|
||||
- `src/views/common/DashboardView.vue`
|
||||
- `src/views/contentManagement/`
|
||||
- `src/views/permission/`
|
||||
- `src/views/userManagement/`
|
||||
- `src/views/permission/`、`src/views/permission/composables/`
|
||||
- `src/views/userManagement/`、`src/views/userManagement/composables/`
|
||||
- `src/views/systemManagement/`
|
||||
- `src/data/`、`src/permissions/`、`src/stores/`
|
||||
- `src/stores/rbac.js`:基于登录下发权限码的按钮级权限判断
|
||||
- `src/utils/status.js`、`src/utils/format.js`
|
||||
- `src/styles/business/`
|
||||
|
||||
路由写在 `src/router/index.ts` 的 `asyncRouterMap`。
|
||||
路由写在 `src/router/index.ts` 的 `asyncRouterMap`(服务端动态路由不可用时的兜底)。
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "reelshort-admin",
|
||||
"name": "fruitshorts-admin",
|
||||
"version": "1.0.0",
|
||||
"description": "ReelShort 内容管理后台,基于 vue-element-plus-admin。",
|
||||
"description": "FruitShorts 内容管理后台,基于 vue-element-plus-admin。",
|
||||
"author": "Archer <502431556@qq.com>",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import request from '@/axios'
|
||||
import type { AppUserBanRecord, AppUserItem, PageResult, WatchHistoryItem } from './types'
|
||||
|
||||
export interface AppUserPageParams {
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
/** uid / 昵称 / 手机号 / 邮箱 */
|
||||
keyword?: string
|
||||
status?: string
|
||||
countryCode?: string
|
||||
registerSource?: string
|
||||
}
|
||||
|
||||
export const pageAppUsersApi = (params: AppUserPageParams) => {
|
||||
return request.get<PageResult<AppUserItem>>({ url: '/admin/app-users/page', params })
|
||||
}
|
||||
|
||||
export const getAppUserApi = (id: number) => {
|
||||
return request.get<AppUserItem>({ url: `/admin/app-users/${id}` })
|
||||
}
|
||||
|
||||
export const listAppUserBanRecordsApi = (id: number) => {
|
||||
return request.get<AppUserBanRecord[]>({ url: `/admin/app-users/${id}/ban-records` })
|
||||
}
|
||||
|
||||
export const listAppUserWatchHistoryApi = (id: number, limit = 50) => {
|
||||
return request.get<WatchHistoryItem[]>({
|
||||
url: `/admin/app-users/${id}/watch-history`,
|
||||
params: { limit }
|
||||
})
|
||||
}
|
||||
|
||||
/** normal / limited / banned,封禁与限制会立即吊销该用户全部会话 */
|
||||
export const updateAppUserStatusApi = (id: number, status: string, reason?: string) => {
|
||||
return request.put<void>({
|
||||
url: `/admin/app-users/${id}/status`,
|
||||
params: { status, reason }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import request from '@/axios'
|
||||
import type { BannerItem, BannerSaveData, PageResult } from './types'
|
||||
|
||||
export interface BannerPageParams {
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
position?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
export const pageBannersApi = (params: BannerPageParams) => {
|
||||
return request.get<PageResult<BannerItem>>({ url: '/admin/banners/page', params })
|
||||
}
|
||||
|
||||
export const getBannerApi = (id: number) => {
|
||||
return request.get<BannerItem>({ url: `/admin/banners/${id}` })
|
||||
}
|
||||
|
||||
export const createBannerApi = (data: BannerSaveData) => {
|
||||
return request.post<number>({ url: '/admin/banners', data })
|
||||
}
|
||||
|
||||
export const updateBannerApi = (id: number, data: BannerSaveData) => {
|
||||
return request.put<void>({ url: `/admin/banners/${id}`, data })
|
||||
}
|
||||
|
||||
export const deleteBannerApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/banners/${id}` })
|
||||
}
|
||||
|
||||
export const updateBannerStatusApi = (id: number, status: number) => {
|
||||
return request.put<void>({ url: `/admin/banners/${id}/status`, params: { status } })
|
||||
}
|
||||
|
||||
export const sortBannersApi = (ids: number[]) => {
|
||||
return request.put<void>({ url: '/admin/banners/sort', data: { ids } })
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import request from '@/axios'
|
||||
import type { ConfigItem } from './types'
|
||||
|
||||
export const listConfigsApi = () => {
|
||||
return request.get<ConfigItem[]>({ url: '/admin/configs' })
|
||||
}
|
||||
|
||||
export const updateConfigsApi = (values: Record<string, string>) => {
|
||||
return request.put<void>({ url: '/admin/configs', data: values })
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import request from '@/axios'
|
||||
import type {
|
||||
DramaCategory,
|
||||
DramaItem,
|
||||
DramaReviewAction,
|
||||
DramaSaveData,
|
||||
PageResult
|
||||
} from './types'
|
||||
|
||||
export interface DramaPageParams {
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
categoryId?: number
|
||||
isHot?: boolean
|
||||
}
|
||||
|
||||
export const pageDramasApi = (params: DramaPageParams) => {
|
||||
return request.get<PageResult<DramaItem>>({ url: '/admin/dramas/page', params })
|
||||
}
|
||||
|
||||
export const getDramaApi = (id: number) => {
|
||||
return request.get<DramaItem>({ url: `/admin/dramas/${id}` })
|
||||
}
|
||||
|
||||
export const createDramaApi = (data: DramaSaveData) => {
|
||||
return request.post<number>({ url: '/admin/dramas', data })
|
||||
}
|
||||
|
||||
export const updateDramaApi = (id: number, data: DramaSaveData) => {
|
||||
return request.put<void>({ url: `/admin/dramas/${id}`, data })
|
||||
}
|
||||
|
||||
export const deleteDramaApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/dramas/${id}` })
|
||||
}
|
||||
|
||||
/** 审核与发布:submit / approve / reject / publish / offline */
|
||||
export const changeDramaStatusApi = (id: number, action: DramaReviewAction, reason?: string) => {
|
||||
return request.post<void>({ url: `/admin/dramas/${id}/${action}`, data: { reason } })
|
||||
}
|
||||
|
||||
/** 下拉选项(短剧表单用),只返回 id/name */
|
||||
export const listDramaCategoryOptionsApi = () => {
|
||||
return request.get<DramaCategory[]>({ url: '/admin/dicts/drama-category' })
|
||||
}
|
||||
|
||||
export interface DramaCategorySaveData {
|
||||
name: string
|
||||
sort?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
/** 类型管理列表,带短剧数量 */
|
||||
export const listDramaCategoriesApi = () => {
|
||||
return request.get<DramaCategory[]>({ url: '/admin/drama-categories' })
|
||||
}
|
||||
|
||||
export const createDramaCategoryApi = (data: DramaCategorySaveData) => {
|
||||
return request.post<number>({ url: '/admin/drama-categories', data })
|
||||
}
|
||||
|
||||
export const updateDramaCategoryApi = (id: number, data: DramaCategorySaveData) => {
|
||||
return request.put<void>({ url: `/admin/drama-categories/${id}`, data })
|
||||
}
|
||||
|
||||
export const deleteDramaCategoryApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/drama-categories/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import request from '@/axios'
|
||||
import type { EpisodeItem, EpisodeSaveData } from './types'
|
||||
|
||||
export const listEpisodesApi = (dramaId: number, status?: string) => {
|
||||
return request.get<EpisodeItem[]>({ url: '/admin/episodes', params: { dramaId, status } })
|
||||
}
|
||||
|
||||
export const createEpisodeApi = (data: EpisodeSaveData) => {
|
||||
return request.post<number>({ url: '/admin/episodes', data })
|
||||
}
|
||||
|
||||
export const updateEpisodeApi = (id: number, data: EpisodeSaveData) => {
|
||||
return request.put<void>({ url: `/admin/episodes/${id}`, data })
|
||||
}
|
||||
|
||||
export const deleteEpisodeApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/episodes/${id}` })
|
||||
}
|
||||
|
||||
export const sortEpisodesApi = (ids: number[]) => {
|
||||
return request.put<void>({ url: '/admin/episodes/sort', data: { ids } })
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import request from '@/axios'
|
||||
import type { OperationLogItem, PageResult } from './types'
|
||||
|
||||
export interface OperationLogPageParams {
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
username?: string
|
||||
module?: string
|
||||
action?: string
|
||||
status?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
export const pageOperationLogsApi = (params: OperationLogPageParams) => {
|
||||
return request.get<PageResult<OperationLogItem>>({ url: '/admin/operation-logs/page', params })
|
||||
}
|
||||
|
||||
export const getOperationLogApi = (id: number) => {
|
||||
return request.get<OperationLogItem>({ url: `/admin/operation-logs/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import request from '@/axios'
|
||||
import type { MediaAsset } from './types'
|
||||
|
||||
export interface UploadInitData {
|
||||
fileName: string
|
||||
fileSize: number
|
||||
/** DRAMA_COVER / EPISODE / AVATAR */
|
||||
bizType?: string
|
||||
chunkSize?: number
|
||||
}
|
||||
|
||||
export interface UploadInitResult {
|
||||
uploadId: string
|
||||
chunkSize: number
|
||||
totalChunks: number
|
||||
objectKey: string
|
||||
}
|
||||
|
||||
export const initUploadApi = (data: UploadInitData) => {
|
||||
return request.post<UploadInitResult>({ url: '/admin/media/upload/init', data })
|
||||
}
|
||||
|
||||
/** 不设置 Content-Type,交给浏览器带 boundary */
|
||||
export const uploadChunkApi = (uploadId: string, index: number, chunk: Blob, fileName: string) => {
|
||||
const form = new FormData()
|
||||
form.append('uploadId', uploadId)
|
||||
form.append('index', String(index))
|
||||
form.append('file', chunk, fileName)
|
||||
return request.post<number>({ url: '/admin/media/upload/chunk', data: form })
|
||||
}
|
||||
|
||||
export const completeUploadApi = (uploadId: string, durationSec?: number) => {
|
||||
return request.post<MediaAsset>({
|
||||
url: '/admin/media/upload/complete',
|
||||
data: { uploadId, durationSec }
|
||||
})
|
||||
}
|
||||
|
||||
export const getMediaApi = (id: number) => {
|
||||
return request.get<MediaAsset>({ url: `/admin/media/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import request from '@/axios'
|
||||
import type {
|
||||
MenuNode,
|
||||
MenuSaveData,
|
||||
PageResult,
|
||||
PermissionGroup,
|
||||
RoleCategoryItem,
|
||||
RoleItem,
|
||||
RoleSaveData
|
||||
} from './types'
|
||||
|
||||
export interface RolePageParams {
|
||||
keyword?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const pageRolesApi = (params: RolePageParams) => {
|
||||
return request.get<PageResult<RoleItem>>({ url: '/admin/roles/page', params })
|
||||
}
|
||||
|
||||
export const listRolesApi = () => {
|
||||
return request.get<RoleItem[]>({ url: '/admin/roles/list' })
|
||||
}
|
||||
|
||||
export const createRoleApi = (data: RoleSaveData) => {
|
||||
return request.post<number>({ url: '/admin/roles', data })
|
||||
}
|
||||
|
||||
export const updateRoleApi = (id: number, data: RoleSaveData) => {
|
||||
return request.put<void>({ url: `/admin/roles/${id}`, data })
|
||||
}
|
||||
|
||||
export const deleteRoleApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/roles/${id}` })
|
||||
}
|
||||
|
||||
export const getRolePermissionsApi = (id: number) => {
|
||||
return request.get<string[]>({ url: `/admin/roles/${id}/permissions` })
|
||||
}
|
||||
|
||||
export const updateRolePermissionsApi = (id: number, permissions: string[]) => {
|
||||
return request.put<void>({ url: `/admin/roles/${id}/permissions`, data: { permissions } })
|
||||
}
|
||||
|
||||
export const listPermissionGroupsApi = () => {
|
||||
return request.get<PermissionGroup[]>({ url: '/admin/permissions' })
|
||||
}
|
||||
|
||||
export interface RoleCategorySaveData {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const listRoleCategoriesApi = () => {
|
||||
return request.get<RoleCategoryItem[]>({ url: '/admin/role-categories' })
|
||||
}
|
||||
|
||||
export const createRoleCategoryApi = (data: RoleCategorySaveData) => {
|
||||
return request.post<number>({ url: '/admin/role-categories', data })
|
||||
}
|
||||
|
||||
export const updateRoleCategoryApi = (id: number, data: RoleCategorySaveData) => {
|
||||
return request.put<void>({ url: `/admin/role-categories/${id}`, data })
|
||||
}
|
||||
|
||||
export const deleteRoleCategoryApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/role-categories/${id}` })
|
||||
}
|
||||
|
||||
export const getMenuTreeApi = () => {
|
||||
return request.get<MenuNode[]>({ url: '/admin/menus/tree' })
|
||||
}
|
||||
|
||||
export const createMenuApi = (data: MenuSaveData) => {
|
||||
return request.post<number>({ url: '/admin/menus', data })
|
||||
}
|
||||
|
||||
export const updateMenuApi = (id: number, data: MenuSaveData) => {
|
||||
return request.put<void>({ url: `/admin/menus/${id}`, data })
|
||||
}
|
||||
|
||||
export const deleteMenuApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/menus/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
export interface PageResult<T> {
|
||||
total: number
|
||||
list: T[]
|
||||
}
|
||||
|
||||
export interface DramaCategory {
|
||||
id: number
|
||||
name: string
|
||||
sort?: number
|
||||
status?: number
|
||||
/** 该类型下的短剧数量,管理列表才有 */
|
||||
dramaCount?: number
|
||||
}
|
||||
|
||||
export interface DramaItem {
|
||||
id: number
|
||||
code?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
categoryId?: number
|
||||
category?: string
|
||||
genre?: string
|
||||
description?: string
|
||||
cover?: string
|
||||
language?: string
|
||||
episodes?: number
|
||||
freeEpisodes?: number
|
||||
unlockCoin?: number
|
||||
/** 是否热门 */
|
||||
isHot?: boolean
|
||||
status: string
|
||||
statusText: string
|
||||
views?: number
|
||||
sort?: number
|
||||
publishAt?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface DramaSaveData {
|
||||
title: string
|
||||
subtitle?: string
|
||||
categoryId?: number
|
||||
genre?: string
|
||||
description?: string
|
||||
cover?: string
|
||||
language?: string
|
||||
freeEpisodes?: number
|
||||
unlockCoin?: number
|
||||
isHot?: boolean
|
||||
sort?: number
|
||||
}
|
||||
|
||||
export type DramaReviewAction = 'submit' | 'approve' | 'reject' | 'publish' | 'offline'
|
||||
|
||||
export interface EpisodeItem {
|
||||
id: number
|
||||
dramaId: number
|
||||
number: number
|
||||
title: string
|
||||
duration: string
|
||||
durationSec: number
|
||||
cover?: string
|
||||
videoUrl?: string
|
||||
isFree: boolean
|
||||
unlockCoin: number
|
||||
status: string
|
||||
statusText: string
|
||||
views?: number
|
||||
sort?: number
|
||||
}
|
||||
|
||||
export interface EpisodeSaveData {
|
||||
dramaId: number
|
||||
number: number
|
||||
title?: string
|
||||
durationSec?: number
|
||||
cover?: string
|
||||
videoUrl?: string
|
||||
isFree?: boolean
|
||||
unlockCoin?: number
|
||||
status?: string
|
||||
sort?: number
|
||||
}
|
||||
|
||||
export interface AdminUserItem {
|
||||
id: number
|
||||
username: string
|
||||
name: string
|
||||
roleId: number | null
|
||||
role: string
|
||||
ipWhitelist: string
|
||||
status: number
|
||||
statusText: string
|
||||
twoFactor: number
|
||||
twoFactorText: string
|
||||
createdAt?: string
|
||||
lastLogin?: string
|
||||
}
|
||||
|
||||
export interface AdminUserSaveData {
|
||||
username?: string
|
||||
password?: string
|
||||
name: string
|
||||
roleId: number
|
||||
ipWhitelist?: string
|
||||
status?: number
|
||||
twoFactor?: number
|
||||
}
|
||||
|
||||
export interface RoleItem {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
description: string
|
||||
categoryId: number
|
||||
category: string
|
||||
level: number
|
||||
status: number
|
||||
statusText: string
|
||||
builtin: boolean
|
||||
memberCount: number
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export interface RoleSaveData {
|
||||
name: string
|
||||
code?: string
|
||||
description?: string
|
||||
categoryId?: number
|
||||
level?: number
|
||||
status?: number
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
export interface RoleCategoryItem {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
roleCount: number
|
||||
}
|
||||
|
||||
export interface PermissionItem {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface PermissionGroup {
|
||||
name: string
|
||||
items: PermissionItem[]
|
||||
}
|
||||
|
||||
export interface MenuNode {
|
||||
id: number
|
||||
parentId?: number
|
||||
path?: string
|
||||
name?: string
|
||||
component?: string
|
||||
redirect?: string
|
||||
icon?: string
|
||||
type?: string
|
||||
permissionCode?: string
|
||||
sort?: number
|
||||
visible?: number
|
||||
status?: number
|
||||
meta?: { title?: string; icon?: string; alwaysShow?: boolean; affix?: boolean }
|
||||
children?: MenuNode[]
|
||||
}
|
||||
|
||||
export interface MenuSaveData {
|
||||
parentId?: number
|
||||
title: string
|
||||
routePath?: string
|
||||
routeName?: string
|
||||
component?: string
|
||||
redirect?: string
|
||||
icon?: string
|
||||
permissionCode?: string
|
||||
type?: string
|
||||
sort?: number
|
||||
visible?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface ConfigItem {
|
||||
key: string
|
||||
value: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface MediaAsset {
|
||||
id: number
|
||||
bizType: string
|
||||
fileName: string
|
||||
objectKey: string
|
||||
url: string
|
||||
sizeBytes: number
|
||||
md5: string
|
||||
durationSec?: number
|
||||
/** uploading / uploaded / transcoding / ready / failed */
|
||||
status: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface BannerItem {
|
||||
id: number
|
||||
imageUrl: string
|
||||
/** HOME_TOP / DISCOVER */
|
||||
position: string
|
||||
positionText: string
|
||||
/** NONE / DRAMA / URL */
|
||||
linkType: string
|
||||
linkTypeText: string
|
||||
linkValue: string
|
||||
/** 跳转目标的展示文案 */
|
||||
linkText: string
|
||||
sort: number
|
||||
status: number
|
||||
statusText: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface BannerSaveData {
|
||||
imageUrl: string
|
||||
position: string
|
||||
linkType: string
|
||||
linkValue?: string
|
||||
sort?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface OperationLogItem {
|
||||
id: number
|
||||
userId: number
|
||||
username: string
|
||||
module: string
|
||||
action: string
|
||||
requestMethod: string
|
||||
requestUrl: string
|
||||
requestParams?: string
|
||||
requestBody?: string
|
||||
responseBody?: string
|
||||
/** SUCCESS / FAIL */
|
||||
status: string
|
||||
statusText: string
|
||||
errorMsg?: string
|
||||
durationMs: number
|
||||
ip: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
/** C 端用户(后台视图,比客户端多一个自增 id,用于后台按 id 寻址) */
|
||||
export interface AppUserItem {
|
||||
id: number
|
||||
uid: string
|
||||
nickname: string
|
||||
avatarUrl: string
|
||||
gender: string
|
||||
countryCode: string
|
||||
regionCode: string
|
||||
language: string
|
||||
timezone: string
|
||||
currency: string
|
||||
/** normal / limited / banned / deactivated */
|
||||
status: string
|
||||
/** 脱敏后的手机号或邮箱 */
|
||||
maskedTarget: string
|
||||
createdAt?: string
|
||||
lastLoginAt?: string
|
||||
/** 预留:会员等级与到期(第二阶段) */
|
||||
vipLevel?: number
|
||||
vipExpireAt?: string
|
||||
}
|
||||
|
||||
export interface AppUserBanRecord {
|
||||
id: number
|
||||
userId: number
|
||||
/** BAN / LIMIT / MUTE */
|
||||
type: string
|
||||
reasonCode: string
|
||||
reason: string
|
||||
refType: string
|
||||
refId: number
|
||||
startAt?: string
|
||||
endAt?: string
|
||||
operatorId: number
|
||||
/** none / pending / approved / rejected */
|
||||
appealStatus: string
|
||||
appealContent: string
|
||||
handleNote: string
|
||||
}
|
||||
|
||||
export interface WatchHistoryItem {
|
||||
id: number
|
||||
dramaId: number
|
||||
dramaTitle?: string
|
||||
dramaPublished?: boolean
|
||||
episodeId: number
|
||||
episodeNo?: number
|
||||
positionMs: number
|
||||
durationMs: number
|
||||
finished?: boolean
|
||||
source: string
|
||||
deviceId: string
|
||||
watchedAt?: string
|
||||
}
|
||||
|
||||
/** 评论(后台视图,字段与 /admin/comments/page 一一对应) */
|
||||
export interface CommentItem {
|
||||
id: number
|
||||
dramaId: number
|
||||
episodeId: number
|
||||
parentId: number
|
||||
rootId: number
|
||||
userId: number
|
||||
uid: string
|
||||
nickname: string
|
||||
content: string
|
||||
/** pending / published / rejected / deleted */
|
||||
status: string
|
||||
auditReason: string
|
||||
likeCount: number
|
||||
replyCount: number
|
||||
reportCount: number
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface ReportItem {
|
||||
id: number
|
||||
reporterId: number
|
||||
/** COMMENT / USER / DRAMA */
|
||||
targetType: string
|
||||
targetId: number
|
||||
reasonCode: string
|
||||
description: string
|
||||
/** pending / handled / rejected */
|
||||
status: string
|
||||
handleBy: number
|
||||
handleAt?: string
|
||||
handleNote: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface SensitiveWordItem {
|
||||
id: number
|
||||
word: string
|
||||
category: string
|
||||
/** BLOCK / REVIEW / REPLACE */
|
||||
level: string
|
||||
replaceText: string
|
||||
language: string
|
||||
status: number
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import request from '@/axios'
|
||||
import type { AdminUserItem, AdminUserSaveData, PageResult } from './types'
|
||||
|
||||
export interface AdminUserPageParams {
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
username?: string
|
||||
roleId?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export const pageAdminUsersApi = (params: AdminUserPageParams) => {
|
||||
return request.get<PageResult<AdminUserItem>>({ url: '/admin/admin-users/page', params })
|
||||
}
|
||||
|
||||
export const createAdminUserApi = (data: AdminUserSaveData) => {
|
||||
return request.post<number>({ url: '/admin/admin-users', data })
|
||||
}
|
||||
|
||||
export const updateAdminUserApi = (id: number, data: AdminUserSaveData) => {
|
||||
return request.put<void>({ url: `/admin/admin-users/${id}`, data })
|
||||
}
|
||||
|
||||
export const deleteAdminUserApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/admin-users/${id}` })
|
||||
}
|
||||
|
||||
export const updateAdminUserStatusApi = (id: number, status: number) => {
|
||||
return request.put<void>({ url: `/admin/admin-users/${id}/status`, params: { status } })
|
||||
}
|
||||
|
||||
export const resetAdminUserPasswordApi = (id: number, password: string) => {
|
||||
return request.put<void>({ url: `/admin/admin-users/${id}/password`, data: { password } })
|
||||
}
|
||||
@@ -6,17 +6,22 @@ interface RoleParams {
|
||||
}
|
||||
|
||||
export const loginApi = (data: UserType): Promise<IResponse<UserType>> => {
|
||||
return request.post({ url: '/mock/user/login', data })
|
||||
return request.post({ url: '/admin/auth/login', data })
|
||||
}
|
||||
|
||||
export const loginOutApi = (): Promise<IResponse> => {
|
||||
return request.get({ url: '/mock/user/loginOut' })
|
||||
return request.post({ url: '/admin/auth/logout' })
|
||||
}
|
||||
|
||||
/** 当前登录人信息(含实时权限) */
|
||||
export const getProfileApi = (): Promise<IResponse<UserType>> => {
|
||||
return request.get({ url: '/admin/auth/profile' })
|
||||
}
|
||||
|
||||
export const getAdminRoleApi = (
|
||||
params: RoleParams
|
||||
): Promise<IResponse<AppCustomRouteRecordRaw[]>> => {
|
||||
return request.get({ url: '/mock/role/list', params })
|
||||
return request.get({ url: '/admin/auth/menus', params })
|
||||
}
|
||||
|
||||
export const getTestRoleApi = (params: RoleParams): Promise<IResponse<string[]>> => {
|
||||
|
||||
@@ -9,4 +9,10 @@ export type UserType = {
|
||||
role: string
|
||||
roleId: string
|
||||
permissions: string | string[]
|
||||
/** 登录成功后由后端下发 */
|
||||
token?: string
|
||||
userId?: number
|
||||
name?: string
|
||||
avatar?: string
|
||||
roleName?: string
|
||||
}
|
||||
+6
-2
@@ -5,6 +5,8 @@ import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
const request = (option: AxiosConfig) => {
|
||||
const { url, method, params, data, headers, responseType } = option
|
||||
const userStore = useUserStoreWithOut()
|
||||
// FormData 不能带 JSON 的 Content-Type,否则 axios 会把表单转成 JSON,浏览器也无法补上 boundary
|
||||
const isFormData = typeof FormData !== 'undefined' && data instanceof FormData
|
||||
return service.request({
|
||||
url: url,
|
||||
method,
|
||||
@@ -12,8 +14,10 @@ const request = (option: AxiosConfig) => {
|
||||
data,
|
||||
responseType: responseType,
|
||||
headers: {
|
||||
'Content-Type': CONTENT_TYPE,
|
||||
[userStore.getTokenKey ?? 'Authorization']: userStore.getToken ?? '',
|
||||
...(isFormData ? {} : { 'Content-Type': CONTENT_TYPE }),
|
||||
[userStore.getTokenKey ?? 'Authorization']: userStore.getToken
|
||||
? `Bearer ${userStore.getToken}`
|
||||
: '',
|
||||
...headers
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const storageKey = 'reelshort-custom-menus'
|
||||
let savedMenus
|
||||
try {
|
||||
savedMenus = JSON.parse(localStorage.getItem(storageKey) || '[]')
|
||||
} catch {
|
||||
savedMenus = []
|
||||
}
|
||||
|
||||
export const customMenus = reactive(Array.isArray(savedMenus) ? savedMenus : [])
|
||||
export const menuIconNames = [
|
||||
'Document',
|
||||
'DataAnalysis',
|
||||
'Picture',
|
||||
'User',
|
||||
'Setting',
|
||||
'VideoCamera'
|
||||
]
|
||||
|
||||
export function saveCustomMenus() {
|
||||
localStorage.setItem(storageKey, JSON.stringify(customMenus))
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const dramas = reactive([
|
||||
{
|
||||
id: 1,
|
||||
title: '替嫁新娘:总裁的掌心宠',
|
||||
genre: '都市情感',
|
||||
status: '已上线',
|
||||
episodes: 68,
|
||||
views: '2.4M',
|
||||
updatedAt: '2025-06-12',
|
||||
description: '一场意外替嫁,让她成为冷面总裁的契约新娘。',
|
||||
cover: ''
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '重生之我在豪门当千金',
|
||||
genre: '重生逆袭',
|
||||
status: '审核中',
|
||||
episodes: 42,
|
||||
views: '—',
|
||||
updatedAt: '2025-06-18',
|
||||
description: '重活一世,她要拿回属于自己的一切。',
|
||||
cover: ''
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '心动信号:请和我恋爱',
|
||||
genre: '甜宠爱情',
|
||||
status: '已上线',
|
||||
episodes: 56,
|
||||
views: '1.8M',
|
||||
updatedAt: '2025-05-29',
|
||||
description: '恋爱观察室里,心动正在悄悄发生。',
|
||||
cover: ''
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '她从深渊中走来',
|
||||
genre: '悬疑复仇',
|
||||
status: '草稿',
|
||||
episodes: 24,
|
||||
views: '—',
|
||||
updatedAt: '2025-06-20',
|
||||
description: '真相只有一个,而她亲手揭开迷雾。',
|
||||
cover: ''
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '闪婚后,傅先生每天都在撒糖',
|
||||
genre: '都市情感',
|
||||
status: '已上线',
|
||||
episodes: 72,
|
||||
views: '3.1M',
|
||||
updatedAt: '2025-05-21',
|
||||
description: '一纸婚约,开启了甜蜜又心动的同居生活。',
|
||||
cover: ''
|
||||
}
|
||||
])
|
||||
@@ -1,40 +0,0 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const episodeData = reactive([
|
||||
{
|
||||
id: 1,
|
||||
dramaId: 1,
|
||||
number: 1,
|
||||
title: '命运的相遇',
|
||||
duration: '02:18',
|
||||
status: '已上线',
|
||||
views: '48,201'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
dramaId: 1,
|
||||
number: 2,
|
||||
title: '意外的婚约',
|
||||
duration: '01:56',
|
||||
status: '已上线',
|
||||
views: '42,817'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
dramaId: 1,
|
||||
number: 3,
|
||||
title: '心动的开始',
|
||||
duration: '02:04',
|
||||
status: '已上线',
|
||||
views: '39,506'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
dramaId: 1,
|
||||
number: 4,
|
||||
title: '他的秘密',
|
||||
duration: '02:31',
|
||||
status: '待发布',
|
||||
views: '—'
|
||||
}
|
||||
])
|
||||
@@ -1,40 +0,0 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const roleData = reactive([
|
||||
{
|
||||
id: 1,
|
||||
name: '超级管理员',
|
||||
description: '拥有系统全部管理权限',
|
||||
memberCount: 2,
|
||||
category: '系统管理员',
|
||||
level: 1,
|
||||
status: '启用'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '内容运营',
|
||||
description: '管理短剧、剧集及封面素材',
|
||||
memberCount: 8,
|
||||
category: '内容管理',
|
||||
level: 2,
|
||||
status: '启用'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '审核专员',
|
||||
description: '审核短剧内容与发布状态',
|
||||
memberCount: 5,
|
||||
category: '内容管理',
|
||||
level: 3,
|
||||
status: '启用'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '数据分析师',
|
||||
description: '查看内容数据与运营报表',
|
||||
memberCount: 3,
|
||||
category: '数据分析',
|
||||
level: 4,
|
||||
status: '启用'
|
||||
}
|
||||
])
|
||||
+24
-3
@@ -7,6 +7,7 @@ import { usePermissionStoreWithOut } from '@/store/modules/permission'
|
||||
import { usePageLoading } from '@/hooks/web/usePageLoading'
|
||||
import { NO_REDIRECT_WHITE_LIST } from '@/constants'
|
||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
import { getAdminRoleApi, getProfileApi } from '@/api/login'
|
||||
|
||||
const { start, done } = useNProgress()
|
||||
|
||||
@@ -32,9 +33,29 @@ router.beforeEach(async (to, from, next) => {
|
||||
|
||||
// 是否使用动态路由
|
||||
if (appStore.getDynamicRouter) {
|
||||
appStore.serverDynamicRouter
|
||||
? await permissionStore.generateRoutes('server', roleRouters as AppCustomRouteRecordRaw[])
|
||||
: await permissionStore.generateRoutes('frontEnd', roleRouters as string[])
|
||||
if (appStore.serverDynamicRouter) {
|
||||
// 每次进入应用都重新拉菜单与权限:后台改了菜单或角色权限,刷新即可生效,不必重新登录
|
||||
let routers = (userStore.getRoleRouters || []) as AppCustomRouteRecordRaw[]
|
||||
try {
|
||||
const username = userStore.getUserInfo?.username || ''
|
||||
const [profileRes, menuRes] = await Promise.all([
|
||||
getProfileApi(),
|
||||
getAdminRoleApi({ roleName: username })
|
||||
])
|
||||
if (profileRes?.data) {
|
||||
userStore.setUserInfo({ ...userStore.getUserInfo, ...profileRes.data })
|
||||
}
|
||||
if (menuRes?.data?.length) {
|
||||
routers = menuRes.data
|
||||
userStore.setRoleRouters(routers)
|
||||
}
|
||||
} catch {
|
||||
// 拉取失败时退回上次缓存的菜单,不阻塞进入
|
||||
}
|
||||
await permissionStore.generateRoutes('server', routers)
|
||||
} else {
|
||||
await permissionStore.generateRoutes('frontEnd', roleRouters as string[])
|
||||
}
|
||||
} else {
|
||||
await permissionStore.generateRoutes('static')
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export const permissionGroups = [
|
||||
{ name: '短剧管理', items: ['查看短剧列表', '创建短剧', '编辑短剧信息', '删除短剧'] },
|
||||
{ name: '剧集管理', items: ['查看剧集列表', '上传剧集视频', '管理剧集排序'] },
|
||||
{ name: '封面管理', items: ['上传封面素材'] },
|
||||
{ name: '审核与系统', items: ['审核并发布', '查看数据报表'] },
|
||||
{ name: '后台管理', items: ['角色与权限配置', '管理员用户列表', '菜单管理', '角色分类管理'] }
|
||||
]
|
||||
|
||||
export const allPermissions = permissionGroups.flatMap((group) => group.items)
|
||||
export const defaultRolePermissions = {
|
||||
超级管理员: [...allPermissions],
|
||||
内容运营: [
|
||||
'查看短剧列表',
|
||||
'创建短剧',
|
||||
'编辑短剧信息',
|
||||
'删除短剧',
|
||||
'查看剧集列表',
|
||||
'上传剧集视频',
|
||||
'管理剧集排序',
|
||||
'上传封面素材'
|
||||
],
|
||||
审核专员: ['查看短剧列表', '查看剧集列表', '审核并发布', '查看数据报表'],
|
||||
数据分析师: ['查看短剧列表', '查看数据报表']
|
||||
}
|
||||
+42
-31
@@ -2,7 +2,6 @@ import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import type { App } from 'vue'
|
||||
import { Layout } from '@/utils/routerHelper'
|
||||
import { customMenus } from '@/data/customMenus'
|
||||
|
||||
export const constantRouterMap: AppRouteRecordRaw[] = [
|
||||
{
|
||||
@@ -102,6 +101,43 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
meta: {
|
||||
title: '剧集管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'banners',
|
||||
name: 'banners',
|
||||
component: () => import('@/views/contentManagement/BannerManagementView.vue'),
|
||||
meta: {
|
||||
title: '轮播图管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'categories',
|
||||
component: () => import('@/views/contentManagement/CategoryManagementView.vue'),
|
||||
meta: {
|
||||
title: '类型管理'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
component: Layout,
|
||||
redirect: '/user/app-users',
|
||||
name: 'UserCenter',
|
||||
meta: {
|
||||
title: '用户管理',
|
||||
icon: 'ep:user',
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'app-users',
|
||||
name: 'app-users',
|
||||
component: () => import('@/views/userManagement/AppUsersView.vue'),
|
||||
meta: {
|
||||
title: 'App 用户'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -155,41 +191,18 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
meta: {
|
||||
title: '系统设置'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export function registerCustomMenu(menu: {
|
||||
key: string
|
||||
title: string
|
||||
route?: string
|
||||
type?: string
|
||||
}) {
|
||||
const name = `custom-menu-${menu.key}`
|
||||
if (router.hasRoute(name)) return
|
||||
if (menu.type === '按钮') return
|
||||
const path = `/${String(menu.route || `custom/${menu.key}`).replace(/^\/+/, '')}`
|
||||
router.addRoute({
|
||||
path,
|
||||
name,
|
||||
component: Layout,
|
||||
meta: {
|
||||
title: menu.title,
|
||||
hidden: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: `${name}-page`,
|
||||
component: () => import('@/views/permission/CustomMenuView.vue'),
|
||||
path: 'operation-logs',
|
||||
name: 'operation-logs',
|
||||
component: () => import('@/views/systemManagement/OperationLogView.vue'),
|
||||
meta: {
|
||||
title: menu.title
|
||||
title: '操作日志'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
@@ -198,8 +211,6 @@ const router = createRouter({
|
||||
scrollBehavior: () => ({ left: 0, top: 0 })
|
||||
})
|
||||
|
||||
customMenus.forEach(registerCustomMenu)
|
||||
|
||||
export const resetRouter = (): void => {
|
||||
const resetWhiteNameList = ['Redirect', 'RedirectParent', 'Login', 'NoFind', 'Root']
|
||||
router.getRoutes().forEach((route) => {
|
||||
|
||||
@@ -330,7 +330,19 @@ export const useAppStore = defineStore('app', {
|
||||
isDark.value = this.getIsDark
|
||||
}
|
||||
},
|
||||
persist: true
|
||||
/**
|
||||
* 界面偏好整体持久化,但 title 例外。
|
||||
*
|
||||
* title 来自构建期常量(.env 的 VITE_APP_TITLE),不是用户设置。
|
||||
* 如果跟着 localStorage 走,产品改名后老用户浏览器里存着旧值,
|
||||
* 刷新多少次都还是旧名称(浏览器标题和侧边栏品牌都读它)。
|
||||
* 所以恢复之后再强制覆盖一次。
|
||||
*/
|
||||
persist: {
|
||||
afterRestore: (context) => {
|
||||
context.store.title = import.meta.env.VITE_APP_TITLE
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const useAppStoreWithOut = () => {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const authenticated = ref(localStorage.getItem('reelshort-auth') === '1')
|
||||
const currentRole = ref('超级管理员')
|
||||
|
||||
export function useAuth() {
|
||||
const isAuthenticated = computed(() => authenticated.value)
|
||||
function login(username, password) {
|
||||
if (username !== 'admin' || password !== 'admin123') return false
|
||||
localStorage.setItem('reelshort-auth', '1')
|
||||
currentRole.value = '超级管理员'
|
||||
authenticated.value = true
|
||||
return true
|
||||
}
|
||||
function logout() {
|
||||
localStorage.removeItem('reelshort-auth')
|
||||
currentRole.value = '超级管理员'
|
||||
authenticated.value = false
|
||||
}
|
||||
function setRole(role) {
|
||||
currentRole.value = role
|
||||
}
|
||||
return { isAuthenticated, currentRole, login, logout, setRole }
|
||||
}
|
||||
+14
-41
@@ -1,44 +1,17 @@
|
||||
import { reactive, computed } from 'vue'
|
||||
import { defaultRolePermissions } from '../permissions/permissions'
|
||||
import { useAuth } from './auth'
|
||||
import { customMenus } from '../data/customMenus'
|
||||
|
||||
const rolePermissions = reactive(
|
||||
Object.fromEntries(
|
||||
Object.entries(defaultRolePermissions).map(([role, list]) => [role, [...list]])
|
||||
)
|
||||
)
|
||||
rolePermissions['超级管理员'].push(...customMenus.map((menu) => menu.permission))
|
||||
import { computed } from 'vue'
|
||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
|
||||
/**
|
||||
* 权限判断:权限码由登录接口下发并存在 user store 中。
|
||||
* 用法:const { can, hasAny } = useRbac(); can('drama:create')
|
||||
*/
|
||||
export function useRbac() {
|
||||
const { currentRole } = useAuth()
|
||||
const permissions = computed(() => rolePermissions[currentRole.value] || [])
|
||||
const can = (permission) => permissions.value.includes(permission)
|
||||
function updateRolePermission(role, permission, enabled) {
|
||||
const current = rolePermissions[role] || (rolePermissions[role] = [])
|
||||
rolePermissions[role] = enabled
|
||||
? [...new Set([...current, permission])]
|
||||
: current.filter((item) => item !== permission)
|
||||
}
|
||||
function createRole(name) {
|
||||
if (!rolePermissions[name]) rolePermissions[name] = []
|
||||
}
|
||||
function createPermission(permission) {
|
||||
rolePermissions['超级管理员'] = [
|
||||
...new Set([...(rolePermissions['超级管理员'] || []), permission])
|
||||
]
|
||||
}
|
||||
function removePermission(permission) {
|
||||
for (const role of Object.keys(rolePermissions))
|
||||
rolePermissions[role] = rolePermissions[role].filter((item) => item !== permission)
|
||||
}
|
||||
return {
|
||||
permissions,
|
||||
can,
|
||||
rolePermissions,
|
||||
updateRolePermission,
|
||||
createRole,
|
||||
createPermission,
|
||||
removePermission
|
||||
}
|
||||
const userStore = useUserStoreWithOut()
|
||||
const permissions = computed(() => {
|
||||
const list = userStore.getUserInfo?.permissions
|
||||
return Array.isArray(list) ? list : []
|
||||
})
|
||||
const can = (code) => permissions.value.includes(code)
|
||||
const hasAny = (...codes) => codes.some((code) => permissions.value.includes(code))
|
||||
return { permissions, can, hasAny }
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* App 用户管理页。
|
||||
* 窄窗口下的重点是两件事:筛选控件换行而不是被挤出可视区;长昵称在单元格里截断而不是撑破表格。
|
||||
*/
|
||||
|
||||
.app-user-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.app-user-toolbar .app-user-keyword {
|
||||
flex: 1 1 240px;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.app-user-toolbar .app-user-status,
|
||||
.app-user-toolbar .app-user-country {
|
||||
width: 150px;
|
||||
flex: 0 0 150px;
|
||||
}
|
||||
|
||||
.app-user-count {
|
||||
margin-left: auto;
|
||||
color: #858b95;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 用户单元格:头像固定宽度,文字块 min-width:0 才能让省略号生效 */
|
||||
.app-user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-user-avatar {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
flex: 0 0 34px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
background: #6f7cf0;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.app-user-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-user-name,
|
||||
.app-user-uid {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-user-name {
|
||||
color: #302d44;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-user-uid {
|
||||
color: #9a97a8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app-user-mono {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 详情抽屉 */
|
||||
.app-user-drawer .el-drawer__header {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.app-user-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0 16px;
|
||||
border-bottom: 1px solid #f0eef4;
|
||||
}
|
||||
|
||||
.app-user-summary .app-user-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.app-user-section {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.app-user-section-title {
|
||||
margin: 0 0 8px;
|
||||
color: #302d44;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
/* 抽屉内的表格:内容多时由 .el-drawer__body 统一滚动,这里不再套一层滚动条 */
|
||||
.app-user-drawer .el-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app-user-drawer .el-table .el-table__cell {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
html.dark .app-user-name,
|
||||
html.dark .app-user-section-title {
|
||||
color: #e8e6ef;
|
||||
}
|
||||
|
||||
html.dark .app-user-summary {
|
||||
border-bottom-color: #2c2c37;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
.banner-thumb {
|
||||
display: block;
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
background: #f0f2f4 !important;
|
||||
}
|
||||
|
||||
.banner-thumb-empty {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
border-radius: 4px;
|
||||
background: #f0f2f4 !important;
|
||||
color: #7e8791;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.banner-field {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
.banner-field-actions {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 编辑弹窗里的轮播图预览:和上传控件并排,控制弹窗高度 */
|
||||
.form-banner-preview {
|
||||
display: block;
|
||||
width: 200px;
|
||||
height: 112px;
|
||||
flex: 0 0 200px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
background: #f0f2f4 !important;
|
||||
}
|
||||
|
||||
.dark .banner-thumb,
|
||||
.dark .banner-thumb-empty,
|
||||
.dark .form-banner-preview {
|
||||
background: #353b45 !important;
|
||||
color: #b9c1cb;
|
||||
}
|
||||
@@ -3,12 +3,20 @@
|
||||
gap: 16px;
|
||||
min-height: 64px;
|
||||
padding: 12px 0 !important;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.drama-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
/* 允许收缩并换行:窄窗口下筛选控件排到第二行,而不是被挤出可视区 */
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.drama-filters > .el-date-editor {
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.drama-filters > .el-input {
|
||||
width: 300px !important;
|
||||
@@ -29,11 +37,21 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.table-actions .el-button {
|
||||
/* 统一尺寸:高度固定、水平内边距,靠行高垂直居中,避免文字被压扁 */
|
||||
height: 28px !important;
|
||||
min-height: 28px !important;
|
||||
padding: 0 10px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 400 !important;
|
||||
padding: 5px 4px !important;
|
||||
font-weight: 500 !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Element Plus 默认给相邻按钮加 12px 外边距,会和这里的 gap 叠加导致换行 */
|
||||
.table-actions .el-button + .el-button {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.table-actions .danger-action {
|
||||
color: #a66a6a !important;
|
||||
@@ -58,6 +76,95 @@
|
||||
color: #7e8791;
|
||||
background: #f0f2f4 !important;
|
||||
}
|
||||
|
||||
/* 名称列只放封面 + 标题,长标题在格子里换行,不撑破单元格 */
|
||||
.drama-cell > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.drama-cell b {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 空值占位,弱化显示 */
|
||||
.table-empty-text {
|
||||
color: #a8aeb8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 编辑弹窗里的封面预览:固定竖版尺寸,和上传控件并排,避免弹窗过高 */
|
||||
.cover-field {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
.cover-field-actions {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.form-cover-preview {
|
||||
display: block;
|
||||
width: 84px;
|
||||
height: 112px;
|
||||
flex: 0 0 84px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
background: #f0f2f4 !important;
|
||||
}
|
||||
.dark .form-cover-preview {
|
||||
background: #353b45 !important;
|
||||
}
|
||||
|
||||
/* 编辑弹窗里的视频预览 */
|
||||
.video-field {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
.video-field-actions {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.form-video-preview {
|
||||
display: block;
|
||||
width: 200px;
|
||||
height: 112px;
|
||||
flex: 0 0 200px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
background: #000 !important;
|
||||
}
|
||||
|
||||
/* 剧集预览弹窗里的播放器 */
|
||||
.episode-video-player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 60vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
background: #000 !important;
|
||||
}
|
||||
|
||||
/* 剧集列表里的视频缩略播放器:默认显示首帧,鼠标移入即播放 */
|
||||
.episode-video-thumb {
|
||||
display: block;
|
||||
width: 120px;
|
||||
height: 68px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
background: #000 !important;
|
||||
}
|
||||
|
||||
/* 列表里点视频即放大播放,给个悬停反馈 */
|
||||
.episode-video-thumb:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.25);
|
||||
}
|
||||
.dark .table-result-count {
|
||||
color: #a6aeb9;
|
||||
}
|
||||
@@ -123,11 +230,6 @@
|
||||
.dark .dashboard-el-table td.el-table__cell {
|
||||
color: #c0c6cf;
|
||||
}
|
||||
.table-actions .el-button {
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 7px 11px !important;
|
||||
}
|
||||
.table-actions .el-button--primary {
|
||||
--el-button-bg-color: #409eff;
|
||||
--el-button-border-color: #409eff;
|
||||
@@ -188,9 +290,6 @@
|
||||
.table-result-count {
|
||||
font-size: 14px;
|
||||
}
|
||||
.table-actions .el-button {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
.library-toolbar .el-input__inner,
|
||||
.library-toolbar .el-select__placeholder {
|
||||
font-size: 14px;
|
||||
@@ -293,9 +392,8 @@
|
||||
.admin-user-toolbar > .el-select {
|
||||
width: 190px;
|
||||
}
|
||||
.admin-users-table {
|
||||
min-width: 1250px;
|
||||
}
|
||||
/* 管理员表格不设最小宽度:列宽超出容器时由 el-table 自行横向滚动,
|
||||
否则会把固定列顶到可视区之外 */
|
||||
.menu-editor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(270px, 0.7fr) minmax(520px, 1.5fr);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 全局布局约束:弹窗与表格在窄窗口下不能溢出可视区。
|
||||
*/
|
||||
|
||||
/* 弹窗宽度不超过视口 */
|
||||
.el-dialog {
|
||||
max-width: 92vw;
|
||||
}
|
||||
|
||||
/* 内容过高时在弹窗内部滚动,避免标题和底部按钮被顶出屏幕。
|
||||
上限同时受视口高度约束,保证标题栏和底部按钮始终可见 */
|
||||
.el-dialog__body {
|
||||
max-height: min(78vh, calc(100vh - 140px));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 抽屉内容同样允许滚动 */
|
||||
.el-drawer__body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗表单的两列布局:成对字段并排,避免表单过高把后面的字段挤到可视区之外。
|
||||
* 注意不要复用 .app-dialog —— 那套样式带 10px 字号,会让弹窗文字过小。
|
||||
*/
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.log-detail {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.log-block {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.log-block h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-json {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: #f6f7f9 !important;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dark .log-json {
|
||||
background: #2a2e36 !important;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 播放量等大数值的展示格式:12345 → 1.2万,3100000 → 310.0万 */
|
||||
export function formatCount(value) {
|
||||
const num = Number(value || 0)
|
||||
if (!Number.isFinite(num)) return '0'
|
||||
if (num >= 100000000) return `${(num / 100000000).toFixed(2)}亿`
|
||||
if (num >= 10000) return `${(num / 10000).toFixed(1)}万`
|
||||
return String(num)
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间展示:直接返回实体的接口给的是 ISO 时间(2026-10-15T13:22:03),
|
||||
* 和后台其它页面「yyyy-MM-dd HH:mm:ss」的观感不一致,这里统一成后者。
|
||||
*/
|
||||
export function formatDateTime(value) {
|
||||
if (!value) return '-'
|
||||
const text = String(value).replace('T', ' ')
|
||||
return text.length > 19 ? text.slice(0, 19) : text
|
||||
}
|
||||
|
||||
/** el-table-column 的 formatter 适配(签名是 row, column, cellValue) */
|
||||
export function dateCell(_row, _column, cellValue) {
|
||||
return formatDateTime(cellValue)
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
const statusClasses = {
|
||||
published: 'is-online',
|
||||
reviewing: 'is-reviewing',
|
||||
draft: 'is-draft',
|
||||
approved: 'is-pending',
|
||||
offline: 'is-offline',
|
||||
已上线: 'is-online',
|
||||
审核中: 'is-reviewing',
|
||||
草稿: 'is-draft',
|
||||
待审核: 'is-reviewing',
|
||||
待发布: 'is-pending',
|
||||
已下线: 'is-offline'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { completeUploadApi, initUploadApi, uploadChunkApi } from '@/api/admin/media'
|
||||
import type { MediaAsset } from '@/api/admin/types'
|
||||
|
||||
export interface UploadFileOptions {
|
||||
/** DRAMA_COVER / EPISODE / AVATAR */
|
||||
bizType?: string
|
||||
/** 并发分片数,默认 3 */
|
||||
concurrency?: number
|
||||
/** 视频时长(秒),由调用方探测后传入 */
|
||||
durationSec?: number
|
||||
onProgress?: (percent: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 分片上传:init 建会话 → 并发传分片 → complete 合并。
|
||||
* 服务端返回合并后的媒资(含可访问 URL)。
|
||||
*/
|
||||
export async function uploadFile(file: File, options: UploadFileOptions = {}): Promise<MediaAsset> {
|
||||
const { bizType = 'EPISODE', concurrency = 3, onProgress } = options
|
||||
const initRes = await initUploadApi({
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
bizType
|
||||
})
|
||||
const info = initRes.data
|
||||
const total = Math.max(info.totalChunks, 1)
|
||||
const pending: number[] = Array.from({ length: total }, (_, index) => index)
|
||||
let finished = 0
|
||||
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const index = pending.shift()
|
||||
if (index === undefined) return
|
||||
const start = index * info.chunkSize
|
||||
const chunk = file.slice(start, Math.min(start + info.chunkSize, file.size))
|
||||
await uploadChunkApi(info.uploadId, index, chunk, file.name)
|
||||
finished += 1
|
||||
onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, total) }, worker))
|
||||
const doneRes = await completeUploadApi(info.uploadId, options.durationSec)
|
||||
onProgress?.(100)
|
||||
return doneRes.data
|
||||
}
|
||||
|
||||
/** 读取本地视频时长,取不到时返回 0。 */
|
||||
export function probeVideoDuration(file: File): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
const video = document.createElement('video')
|
||||
video.preload = 'metadata'
|
||||
const cleanup = (value: number) => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(value)
|
||||
}
|
||||
video.onloadedmetadata = () => {
|
||||
cleanup(Number.isFinite(video.duration) ? Math.round(video.duration) : 0)
|
||||
}
|
||||
video.onerror = () => cleanup(0)
|
||||
video.src = url
|
||||
})
|
||||
}
|
||||
@@ -31,7 +31,7 @@ const themeChange = () => {
|
||||
<span>{{ underlineToHump(appStore.getTitle) }}</span>
|
||||
</div>
|
||||
<div class="hero-copy">
|
||||
<p class="hero-kicker">ReelShort Studio</p>
|
||||
<p class="hero-kicker">FruitShorts Studio</p>
|
||||
<h1>{{ t('login.welcome') }}</h1>
|
||||
<p>{{ t('login.message') }}</p>
|
||||
<ul>
|
||||
|
||||
@@ -188,6 +188,9 @@ const signIn = async () => {
|
||||
}
|
||||
userStore.setRememberMe(unref(remember))
|
||||
userStore.setUserInfo(res.data)
|
||||
if (res.data?.token) {
|
||||
userStore.setToken(res.data.token)
|
||||
}
|
||||
// 是否使用动态路由
|
||||
if (appStore.getDynamicRouter) {
|
||||
getRole()
|
||||
|
||||
@@ -1,53 +1,85 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { VideoCamera, User, Clock, CaretRight, Plus } from '@element-plus/icons-vue'
|
||||
import { dramas } from '@/data/dramas'
|
||||
import { VideoCamera, CaretRight, Clock, CircleCheck, Plus } from '@element-plus/icons-vue'
|
||||
import { pageDramasApi } from '@/api/admin/drama'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
import { formatCount } from '@/utils/format'
|
||||
import { statusClass } from '@/utils/status'
|
||||
|
||||
const router = useRouter()
|
||||
const { can } = useRbac()
|
||||
|
||||
const loading = ref(false)
|
||||
const dramas = ref([])
|
||||
const total = ref(0)
|
||||
|
||||
const totalViews = computed(() =>
|
||||
dramas.value.reduce((sum, item) => sum + Number(item.views || 0), 0)
|
||||
)
|
||||
const reviewingCount = computed(
|
||||
() => dramas.value.filter((item) => item.status === 'reviewing').length
|
||||
)
|
||||
const publishedCount = computed(
|
||||
() => dramas.value.filter((item) => item.status === 'published').length
|
||||
)
|
||||
const recentDramas = computed(() =>
|
||||
[...dramas.value]
|
||||
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
|
||||
.slice(0, 5)
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 })
|
||||
dramas.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-heading"
|
||||
><div><div class="eyebrow">内容概览</div><h1>工作台</h1><p>查看短剧内容与近期处理事项。</p></div
|
||||
><el-button
|
||||
v-if="can('创建短剧')"
|
||||
v-if="can('drama:create')"
|
||||
type="primary"
|
||||
class="primary-button"
|
||||
@click="router.push({ name: 'dramas' })"
|
||||
><el-icon><Plus /></el-icon>上传新短剧</el-button
|
||||
></div
|
||||
>
|
||||
<div class="stats-grid">
|
||||
<div v-loading="loading" class="stats-grid">
|
||||
<div class="stat-card"
|
||||
><div class="stat-top"
|
||||
><span>短剧总数</span
|
||||
><span class="stat-icon"
|
||||
><el-icon><VideoCamera /></el-icon></span></div
|
||||
><div class="stat-value">{{ dramas.length }} <small>部</small></div></div
|
||||
><div class="stat-value">{{ total }} <small>部</small></div></div
|
||||
>
|
||||
<div class="stat-card"
|
||||
><div class="stat-top"
|
||||
><span>累计播放量</span
|
||||
><span class="stat-icon"
|
||||
><el-icon><CaretRight /></el-icon></span></div
|
||||
><div class="stat-value">8.64 <small>千万</small></div></div
|
||||
><div class="stat-value">{{ formatCount(totalViews) }}</div></div
|
||||
>
|
||||
<div class="stat-card"
|
||||
><div class="stat-top"
|
||||
><span>待审核内容</span
|
||||
><span class="stat-icon"
|
||||
><el-icon><Clock /></el-icon></span></div
|
||||
><div class="stat-value">06 <small>项</small></div></div
|
||||
><div class="stat-value">{{ reviewingCount }} <small>项</small></div></div
|
||||
>
|
||||
<div class="stat-card"
|
||||
><div class="stat-top"
|
||||
><span>本月新增用户</span
|
||||
><span>已上线</span
|
||||
><span class="stat-icon"
|
||||
><el-icon><User /></el-icon></span></div
|
||||
><div class="stat-value">12,486 <small>人</small></div></div
|
||||
><el-icon><CircleCheck /></el-icon></span></div
|
||||
><div class="stat-value">{{ publishedCount }} <small>部</small></div></div
|
||||
>
|
||||
</div>
|
||||
<section class="panel recent-panel"
|
||||
@@ -57,24 +89,29 @@ const { can } = useRbac()
|
||||
>短剧管理</el-button
|
||||
></div
|
||||
>
|
||||
<el-table :data="dramas.slice(0, 5)" row-key="id" class="dashboard-el-table"
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="recentDramas"
|
||||
row-key="id"
|
||||
empty-text="暂无短剧数据"
|
||||
class="dashboard-el-table"
|
||||
><el-table-column prop="title" label="短剧名称" min-width="280" /><el-table-column
|
||||
prop="genre"
|
||||
label="类型"
|
||||
width="150" /><el-table-column label="集数" width="120"
|
||||
><template #default="scope">{{ scope?.row?.episodes }} 集</template></el-table-column
|
||||
><el-table-column prop="views" label="播放量" width="140" /><el-table-column
|
||||
label="状态"
|
||||
width="130"
|
||||
><el-table-column label="播放量" width="140"
|
||||
><template #default="scope">{{ formatCount(scope?.row?.views) }}</template></el-table-column
|
||||
><el-table-column label="状态" width="130"
|
||||
><template #default="scope"
|
||||
><el-tag
|
||||
v-if="scope?.row"
|
||||
:class="['status-badge', statusClass(scope.row.status)]"
|
||||
effect="light"
|
||||
>{{ scope.row.status }}</el-tag
|
||||
>{{ scope.row.statusText }}</el-tag
|
||||
></template
|
||||
></el-table-column
|
||||
><el-table-column prop="updatedAt" label="更新时间" width="160"
|
||||
><el-table-column prop="updatedAt" label="更新时间" width="180"
|
||||
/></el-table>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,407 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import {
|
||||
createBannerApi,
|
||||
deleteBannerApi,
|
||||
pageBannersApi,
|
||||
sortBannersApi,
|
||||
updateBannerApi,
|
||||
updateBannerStatusApi
|
||||
} from '@/api/admin/banner'
|
||||
import { pageDramasApi } from '@/api/admin/drama'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
import { uploadFile } from '@/utils/uploader'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
|
||||
const { can } = useRbac()
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const pageIndex = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const positionFilter = ref(undefined)
|
||||
const statusFilter = ref(undefined)
|
||||
const dramas = ref([])
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const uploadingImage = ref(false)
|
||||
const imagePercent = ref(0)
|
||||
const editingId = ref(null)
|
||||
const form = reactive({
|
||||
imageUrl: '',
|
||||
position: 'HOME_TOP',
|
||||
linkType: 'NONE',
|
||||
linkValue: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const positionOptions = [
|
||||
{ code: 'HOME_TOP', text: '首页顶部' },
|
||||
{ code: 'DISCOVER', text: '发现页' }
|
||||
]
|
||||
|
||||
const linkTypeOptions = [
|
||||
{ code: 'NONE', text: '不跳转' },
|
||||
{ code: 'DRAMA', text: '跳转短剧' },
|
||||
{ code: 'URL', text: '打开链接' }
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageBannersApi({
|
||||
pageIndex: pageIndex.value,
|
||||
pageSize: pageSize.value,
|
||||
position: positionFilter.value,
|
||||
status: statusFilter.value
|
||||
})
|
||||
list.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDramas() {
|
||||
const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 })
|
||||
dramas.value = res.data?.list || []
|
||||
}
|
||||
|
||||
function search() {
|
||||
pageIndex.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
positionFilter.value = undefined
|
||||
statusFilter.value = undefined
|
||||
search()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
pageIndex.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
Object.assign(form, {
|
||||
imageUrl: '',
|
||||
position: 'HOME_TOP',
|
||||
linkType: 'NONE',
|
||||
linkValue: '',
|
||||
sort: nextSort(),
|
||||
status: 1
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
imageUrl: row.imageUrl,
|
||||
position: row.position,
|
||||
linkType: row.linkType,
|
||||
linkValue: row.linkValue || '',
|
||||
sort: row.sort ?? 0,
|
||||
status: row.status
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function nextSort() {
|
||||
return list.value.reduce((max, item) => Math.max(max, item.sort || 0), 0) + 1
|
||||
}
|
||||
|
||||
async function saveBanner() {
|
||||
if (!form.imageUrl.trim()) return ElMessage.warning('请上传轮播图片')
|
||||
if (form.linkType === 'DRAMA' && !form.linkValue) {
|
||||
return ElMessage.warning('请选择要跳转的短剧')
|
||||
}
|
||||
if (form.linkType === 'URL' && !form.linkValue.trim()) {
|
||||
return ElMessage.warning('请填写跳转地址')
|
||||
}
|
||||
const payload = {
|
||||
...form,
|
||||
imageUrl: form.imageUrl.trim(),
|
||||
linkValue: form.linkType === 'NONE' ? '' : String(form.linkValue).trim()
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateBannerApi(editingId.value, payload)
|
||||
ElMessage.success('轮播图已更新')
|
||||
} else {
|
||||
await createBannerApi(payload)
|
||||
ElMessage.success('轮播图已创建')
|
||||
pageIndex.value = 1
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadImage(options) {
|
||||
uploadingImage.value = true
|
||||
imagePercent.value = 0
|
||||
try {
|
||||
const asset = await uploadFile(options.file, {
|
||||
bizType: 'BANNER',
|
||||
onProgress: (percent) => (imagePercent.value = percent)
|
||||
})
|
||||
form.imageUrl = asset.url
|
||||
ElMessage.success('图片已上传')
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row) {
|
||||
const next = row.status === 1 ? 0 : 1
|
||||
await updateBannerStatusApi(row.id, next)
|
||||
ElMessage.success(next === 1 ? '轮播图已启用' : '轮播图已停用')
|
||||
await load()
|
||||
}
|
||||
|
||||
async function confirmDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除轮播图「${row.title}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await deleteBannerApi(row.id)
|
||||
ElMessage.success('轮播图已删除')
|
||||
if (list.value.length === 1 && pageIndex.value > 1) pageIndex.value -= 1
|
||||
await load()
|
||||
}
|
||||
|
||||
/** 上移/下移后整表提交新顺序 */
|
||||
async function move(index, offset) {
|
||||
const target = index + offset
|
||||
if (target < 0 || target >= list.value.length) return
|
||||
const next = [...list.value]
|
||||
const [moved] = next.splice(index, 1)
|
||||
next.splice(target, 0, moved)
|
||||
await sortBannersApi(next.map((item) => item.id))
|
||||
ElMessage.success('展示顺序已更新')
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDramas()
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeading
|
||||
eyebrow="内容管理"
|
||||
title="轮播图管理"
|
||||
description="配置客户端首页与发现页的轮播位,控制顺序、跳转和上下线。"
|
||||
>
|
||||
<el-button
|
||||
v-if="can('banner:create')"
|
||||
type="primary"
|
||||
class="primary-button"
|
||||
@click="openCreate"
|
||||
>
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建轮播图
|
||||
</el-button>
|
||||
</PageHeading>
|
||||
|
||||
<section class="panel library-panel">
|
||||
<div class="library-toolbar">
|
||||
<div class="drama-filters">
|
||||
<el-select v-model="positionFilter" clearable placeholder="全部位置" @change="search">
|
||||
<el-option
|
||||
v-for="item in positionOptions"
|
||||
:key="item.code"
|
||||
:label="item.text"
|
||||
:value="item.code"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="statusFilter" clearable placeholder="全部状态" @change="search">
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="停用" :value="0" />
|
||||
</el-select>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
<span class="table-result-count">共 {{ total }} 张轮播图</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
row-key="id"
|
||||
empty-text="还没有轮播图"
|
||||
class="drama-el-table"
|
||||
>
|
||||
<el-table-column label="预览" width="190">
|
||||
<template #default="{ row }">
|
||||
<img v-if="row.imageUrl" :src="row.imageUrl" class="banner-thumb" />
|
||||
<div v-else class="banner-thumb-empty">暂无图片</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="positionText" label="位置" width="110" />
|
||||
<el-table-column label="跳转" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.linkType === 'NONE'" class="table-empty-text">不跳转</span>
|
||||
<span v-else>{{ row.linkTypeText }}:{{ row.linkText }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" width="150">
|
||||
<template #default="{ row, $index }">
|
||||
<div class="table-actions">
|
||||
<el-button
|
||||
v-if="can('banner:update')"
|
||||
size="small"
|
||||
:disabled="$index === 0"
|
||||
@click="move($index, -1)"
|
||||
>上移</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="can('banner:update')"
|
||||
size="small"
|
||||
:disabled="$index === list.length - 1"
|
||||
@click="move($index, 1)"
|
||||
>下移</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="can('banner:update')"
|
||||
link
|
||||
:type="row.status === 1 ? 'primary' : 'info'"
|
||||
@click="toggleStatus(row)"
|
||||
>{{ row.statusText }}</el-button
|
||||
>
|
||||
<el-tag v-else :type="row.status === 1 ? 'success' : 'info'" effect="light">{{
|
||||
row.statusText
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="更新时间" width="170" />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button
|
||||
v-if="can('banner:update')"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openEdit(row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="can('banner:delete')"
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="confirmDelete(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pageIndex"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[5, 10, 20, 50]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑轮播图' : '新建轮播图'" width="620px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="轮播图片" required>
|
||||
<div class="banner-field">
|
||||
<div class="banner-field-actions">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
:http-request="uploadImage"
|
||||
:disabled="uploadingImage"
|
||||
>
|
||||
<el-button :loading="uploadingImage">{{
|
||||
form.imageUrl ? '更换图片' : '选择图片'
|
||||
}}</el-button>
|
||||
</el-upload>
|
||||
<p v-if="uploadingImage" class="selected-file">上传中 {{ imagePercent }}%</p>
|
||||
<p v-else-if="form.imageUrl" class="selected-file">图片已上传</p>
|
||||
<el-input v-model="form.imageUrl" placeholder="也可以直接填写图片地址" />
|
||||
</div>
|
||||
<img v-if="form.imageUrl" :src="form.imageUrl" class="form-banner-preview" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<div class="form-row">
|
||||
<el-form-item label="展示位置" required>
|
||||
<el-select v-model="form.position">
|
||||
<el-option
|
||||
v-for="item in positionOptions"
|
||||
:key="item.code"
|
||||
:label="item.text"
|
||||
:value="item.code"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="跳转类型">
|
||||
<el-select v-model="form.linkType">
|
||||
<el-option
|
||||
v-for="item in linkTypeOptions"
|
||||
:key="item.code"
|
||||
:label="item.text"
|
||||
:value="item.code"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="form.linkType === 'DRAMA'" label="跳转短剧" required>
|
||||
<el-select v-model="form.linkValue" filterable placeholder="搜索短剧">
|
||||
<el-option
|
||||
v-for="item in dramas"
|
||||
:key="item.id"
|
||||
:label="`${item.title}(${item.statusText})`"
|
||||
:value="String(item.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.linkType === 'URL'" label="跳转地址" required>
|
||||
<el-input v-model="form.linkValue" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
<div class="form-row">
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveBanner">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import {
|
||||
createDramaCategoryApi,
|
||||
deleteDramaCategoryApi,
|
||||
listDramaCategoriesApi,
|
||||
updateDramaCategoryApi
|
||||
} from '@/api/admin/drama'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
|
||||
const { can } = useRbac()
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref(null)
|
||||
const form = reactive({ name: '', sort: 1, status: 1 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listDramaCategoriesApi()
|
||||
list.value = res.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function nextSort() {
|
||||
return list.value.reduce((max, item) => Math.max(max, item.sort || 0), 0) + 1
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
Object.assign(form, { name: '', sort: nextSort(), status: 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, { name: row.name, sort: row.sort ?? 0, status: row.status ?? 1 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveCategory() {
|
||||
if (!form.name.trim()) return ElMessage.warning('请填写类型名称')
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { name: form.name.trim(), sort: form.sort, status: form.status }
|
||||
if (editingId.value) {
|
||||
await updateDramaCategoryApi(editingId.value, payload)
|
||||
ElMessage.success('类型已更新')
|
||||
} else {
|
||||
await createDramaCategoryApi(payload)
|
||||
ElMessage.success('类型已创建')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(row) {
|
||||
const count = row.dramaCount || 0
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
count > 0
|
||||
? `「${row.name}」下还有 ${count} 部短剧,删除会被拒绝。确定继续吗?`
|
||||
: `确定删除类型「${row.name}」吗?`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await deleteDramaCategoryApi(row.id)
|
||||
ElMessage.success('类型已删除')
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeading
|
||||
eyebrow="内容管理"
|
||||
title="类型管理"
|
||||
description="维护短剧类型,供新建短剧选择,也作为客户端分类筛选的依据。"
|
||||
>
|
||||
<el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建类型
|
||||
</el-button>
|
||||
</PageHeading>
|
||||
|
||||
<section class="panel">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
row-key="id"
|
||||
border
|
||||
class="drama-el-table"
|
||||
empty-text="还没有短剧类型"
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="80" />
|
||||
<el-table-column prop="name" label="类型名称" min-width="200" />
|
||||
<el-table-column label="短剧数" width="110">
|
||||
<template #default="{ row }">{{ row.dramaCount || 0 }} 部</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="100" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" effect="light">{{
|
||||
row.status === 1 ? '启用' : '停用'
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button v-if="can('drama:update')" type="primary" size="small" @click="openEdit(row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="can('drama:delete')"
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="confirmDelete(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑类型' : '新建类型'" width="460px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="类型名称" required>
|
||||
<el-input v-model="form.name" maxlength="20" placeholder="例如:都市情感" />
|
||||
</el-form-item>
|
||||
<div class="form-row">
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveCategory">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -1,83 +1,237 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Search } from '@element-plus/icons-vue'
|
||||
import { dramas } from '@/data/dramas'
|
||||
import {
|
||||
changeDramaStatusApi,
|
||||
createDramaApi,
|
||||
deleteDramaApi,
|
||||
listDramaCategoryOptionsApi,
|
||||
pageDramasApi,
|
||||
updateDramaApi
|
||||
} from '@/api/admin/drama'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
import { formatCount } from '@/utils/format'
|
||||
import { statusClass } from '@/utils/status'
|
||||
import { uploadFile } from '@/utils/uploader'
|
||||
|
||||
const { can } = useRbac()
|
||||
const search = ref('')
|
||||
const status = ref('全部状态')
|
||||
const currentPage = ref(1)
|
||||
const router = useRouter()
|
||||
const { can, hasAny } = useRbac()
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const categories = ref([])
|
||||
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref(undefined)
|
||||
const categoryId = ref(undefined)
|
||||
const hotFilter = ref(undefined)
|
||||
const pageIndex = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const uploadingCover = ref(false)
|
||||
const coverPercent = ref(0)
|
||||
const editingId = ref(null)
|
||||
const form = reactive({ title: '', genre: '', description: '', episodes: 1, cover: '' })
|
||||
const filtered = computed(() =>
|
||||
dramas.filter(
|
||||
(item) =>
|
||||
item.title.includes(search.value) &&
|
||||
(status.value === '全部状态' || item.status === status.value)
|
||||
)
|
||||
)
|
||||
const paginated = computed(() =>
|
||||
filtered.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value)
|
||||
)
|
||||
watch([search, status], () => {
|
||||
currentPage.value = 1
|
||||
const form = reactive({
|
||||
title: '',
|
||||
categoryId: undefined,
|
||||
description: '',
|
||||
freeEpisodes: 3,
|
||||
unlockCoin: 0,
|
||||
cover: ''
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ code: 'draft', text: '草稿' },
|
||||
{ code: 'reviewing', text: '审核中' },
|
||||
{ code: 'approved', text: '待发布' },
|
||||
{ code: 'published', text: '已上线' },
|
||||
{ code: 'offline', text: '已下线' }
|
||||
]
|
||||
|
||||
const actionMeta = {
|
||||
submit: { label: '提交审核', done: '已提交审核' },
|
||||
approve: { label: '审核通过', done: '已通过审核' },
|
||||
reject: { label: '驳回', done: '已驳回' },
|
||||
publish: { label: '上线', done: '已上线' },
|
||||
offline: { label: '下线', done: '已下线' }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageDramasApi({
|
||||
pageIndex: pageIndex.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
categoryId: categoryId.value,
|
||||
isHot: hotFilter.value
|
||||
})
|
||||
list.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
const res = await listDramaCategoryOptionsApi()
|
||||
categories.value = res.data || []
|
||||
}
|
||||
|
||||
function search() {
|
||||
pageIndex.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
keyword.value = ''
|
||||
statusFilter.value = undefined
|
||||
categoryId.value = undefined
|
||||
hotFilter.value = undefined
|
||||
search()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
pageIndex.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
Object.assign(form, { title: '', genre: '', description: '', episodes: 1, cover: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
function openEdit(item) {
|
||||
editingId.value = item.id
|
||||
Object.assign(form, {
|
||||
title: item.title,
|
||||
genre: item.genre,
|
||||
description: item.description,
|
||||
episodes: item.episodes,
|
||||
cover: item.cover || ''
|
||||
title: '',
|
||||
categoryId: undefined,
|
||||
description: '',
|
||||
freeEpisodes: 3,
|
||||
unlockCoin: 0,
|
||||
isHot: false,
|
||||
cover: ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
function saveDrama() {
|
||||
if (!form.title || !form.genre) return ElMessage.warning('请填写短剧名称和类型')
|
||||
const updatedAt = new Date().toISOString().slice(0, 10)
|
||||
if (editingId.value) {
|
||||
const drama = dramas.find((item) => item.id === editingId.value)
|
||||
if (drama) Object.assign(drama, { ...form, updatedAt })
|
||||
} else {
|
||||
dramas.unshift({ id: Date.now(), ...form, status: '草稿', views: '—', updatedAt })
|
||||
|
||||
function openEdit(row) {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
title: row.title,
|
||||
categoryId: row.categoryId || undefined,
|
||||
description: row.description || '',
|
||||
freeEpisodes: row.freeEpisodes ?? 3,
|
||||
unlockCoin: row.unlockCoin ?? 0,
|
||||
isHot: !!row.isHot,
|
||||
cover: row.cover || ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
Object.assign(form, { title: '', genre: '', description: '', episodes: 1, cover: '' })
|
||||
dialogVisible.value = false
|
||||
ElMessage.success(editingId.value ? '短剧信息已更新' : '短剧草稿已保存')
|
||||
editingId.value = null
|
||||
|
||||
async function saveDrama() {
|
||||
if (!form.title.trim()) return ElMessage.warning('请填写短剧名称')
|
||||
if (!form.categoryId) return ElMessage.warning('请选择短剧类型')
|
||||
const category = categories.value.find((item) => item.id === form.categoryId)
|
||||
const payload = {
|
||||
...form,
|
||||
title: form.title.trim(),
|
||||
genre: category?.name || ''
|
||||
}
|
||||
function uploadCover(file) {
|
||||
if (can('上传封面素材')) form.cover = URL.createObjectURL(file.raw)
|
||||
}
|
||||
async function confirmDelete(item) {
|
||||
saving.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除短剧「${item.title}」吗?删除后无法恢复。`, '删除确认', {
|
||||
if (editingId.value) {
|
||||
await updateDramaApi(editingId.value, payload)
|
||||
ElMessage.success('短剧信息已更新')
|
||||
} else {
|
||||
await createDramaApi(payload)
|
||||
ElMessage.success('短剧草稿已保存')
|
||||
pageIndex.value = 1
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 封面走分片上传(图片只有一个分片),成功后回填地址。 */
|
||||
async function uploadCover(options) {
|
||||
uploadingCover.value = true
|
||||
coverPercent.value = 0
|
||||
try {
|
||||
const asset = await uploadFile(options.file, {
|
||||
bizType: 'DRAMA_COVER',
|
||||
onProgress: (percent) => (coverPercent.value = percent)
|
||||
})
|
||||
form.cover = asset.url
|
||||
ElMessage.success('封面已上传')
|
||||
} finally {
|
||||
uploadingCover.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除短剧「${row.title}」吗?删除后无法恢复。`, '删除确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
distinguishCancelAndClose: true
|
||||
})
|
||||
const index = dramas.findIndex((drama) => drama.id === item.id)
|
||||
if (index !== -1) dramas.splice(index, 1)
|
||||
if (currentPage.value > Math.ceil(filtered.value.length / pageSize.value))
|
||||
currentPage.value = Math.max(1, currentPage.value - 1)
|
||||
ElMessage.success('短剧已删除')
|
||||
} catch {
|
||||
// 用户取消或关闭确认框时不执行删除。
|
||||
return
|
||||
}
|
||||
await deleteDramaApi(row.id)
|
||||
ElMessage.success('短剧已删除')
|
||||
if (list.value.length === 1 && pageIndex.value > 1) pageIndex.value -= 1
|
||||
await load()
|
||||
}
|
||||
|
||||
/** 审核发布动作:驳回需要填写原因,其余二次确认。 */
|
||||
async function runAction(row, action) {
|
||||
const meta = actionMeta[action]
|
||||
let reason = ''
|
||||
try {
|
||||
if (action === 'reject') {
|
||||
const { value } = await ElMessageBox.prompt('请填写驳回原因', `驳回「${row.title}」`, {
|
||||
confirmButtonText: '确认驳回',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '例如:封面素材不合规'
|
||||
})
|
||||
reason = value
|
||||
} else {
|
||||
await ElMessageBox.confirm(`确定将「${row.title}」${meta.label}吗?`, '操作确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await changeDramaStatusApi(row.id, action, reason)
|
||||
ElMessage.success(meta.done)
|
||||
await load()
|
||||
}
|
||||
|
||||
function reviewActions(row) {
|
||||
const canReview = can('drama:review')
|
||||
if (row.status === 'draft' || row.status === 'offline') {
|
||||
return hasAny('drama:update', 'drama:review') ? ['submit'] : []
|
||||
}
|
||||
if (row.status === 'reviewing') return canReview ? ['approve', 'reject'] : []
|
||||
if (row.status === 'approved') return canReview ? ['publish'] : []
|
||||
if (row.status === 'published') return canReview ? ['offline'] : []
|
||||
return []
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories()
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -85,7 +239,7 @@ async function confirmDelete(item) {
|
||||
><div
|
||||
><div class="eyebrow">内容管理</div><h1>短剧管理</h1
|
||||
><p>管理短剧信息、封面、剧集和发布状态。</p></div
|
||||
><el-button v-if="can('创建短剧')" type="primary" class="primary-button" @click="openCreate"
|
||||
><el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate"
|
||||
><el-icon><Plus /></el-icon>新建短剧</el-button
|
||||
></div
|
||||
>
|
||||
@@ -93,19 +247,39 @@ async function confirmDelete(item) {
|
||||
<div class="library-toolbar"
|
||||
><div class="drama-filters"
|
||||
><el-input
|
||||
v-model="search"
|
||||
v-model="keyword"
|
||||
placeholder="搜索短剧名称"
|
||||
:prefix-icon="Search"
|
||||
clearable /><el-select v-model="status"
|
||||
clearable
|
||||
@keyup.enter="search"
|
||||
@clear="search"
|
||||
/><el-select v-model="statusFilter" clearable placeholder="全部状态" @change="search"
|
||||
><el-option
|
||||
v-for="item in ['全部状态', '已上线', '审核中', '草稿']"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item" /></el-select></div
|
||||
><span class="table-result-count">共 {{ filtered.length }} 部短剧</span></div
|
||||
v-for="item in statusOptions"
|
||||
:key="item.code"
|
||||
:label="item.text"
|
||||
:value="item.code" /></el-select
|
||||
><el-select v-model="categoryId" clearable placeholder="全部类型" @change="search"
|
||||
><el-option
|
||||
v-for="item in categories"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id" /></el-select
|
||||
><el-select v-model="hotFilter" clearable placeholder="全部热度" @change="search"
|
||||
><el-option label="仅热门" :value="true" /><el-option
|
||||
label="非热门"
|
||||
:value="false" /></el-select
|
||||
><el-button @click="resetFilters">重置</el-button></div
|
||||
><span class="table-result-count">共 {{ total }} 部短剧</span></div
|
||||
>
|
||||
<el-table :data="paginated" row-key="id" empty-text="没有符合条件的短剧" class="drama-el-table">
|
||||
<el-table-column label="短剧名称" min-width="340"
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
row-key="id"
|
||||
empty-text="没有符合条件的短剧"
|
||||
class="drama-el-table"
|
||||
>
|
||||
<el-table-column label="短剧名称" min-width="220"
|
||||
><template #default="{ row }"
|
||||
><div class="drama-cell"
|
||||
><img v-if="row.cover" :src="row.cover" class="drama-cover-image" /><div
|
||||
@@ -113,42 +287,69 @@ async function confirmDelete(item) {
|
||||
class="drama-cover"
|
||||
>暂无封面</div
|
||||
><div
|
||||
><b>{{ row.title }}</b
|
||||
><small>{{ row.description }}</small></div
|
||||
><b>{{ row.title }}</b></div
|
||||
></div
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="genre" label="类型" width="140" />
|
||||
<el-table-column label="集数" width="110"
|
||||
<el-table-column label="简介" min-width="200" show-overflow-tooltip
|
||||
><template #default="{ row }"
|
||||
><span v-if="row.description">{{ row.description }}</span
|
||||
><span v-else class="table-empty-text">—</span></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="genre" label="类型" width="110" />
|
||||
<el-table-column label="集数" width="90"
|
||||
><template #default="{ row }">{{ row.episodes }} 集</template></el-table-column
|
||||
>
|
||||
<el-table-column prop="views" label="播放量" width="120" />
|
||||
<el-table-column label="状态" width="120"
|
||||
<el-table-column label="播放量" width="110"
|
||||
><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="状态" width="110"
|
||||
><template #default="{ row }"
|
||||
><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{
|
||||
row.status
|
||||
row.statusText
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="updatedAt" label="更新时间" width="150" />
|
||||
<el-table-column label="操作" width="240" fixed="right"
|
||||
<el-table-column label="热门" width="90"
|
||||
><template #default="{ row }"
|
||||
><el-tag v-if="row.isHot" type="danger" effect="light">热门</el-tag
|
||||
><span v-else class="table-empty-text">—</span></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="updatedAt" label="更新时间" width="160" />
|
||||
<el-table-column label="审核" width="140"
|
||||
><template #default="{ row }"
|
||||
><div class="table-actions"
|
||||
><el-button
|
||||
v-if="can('编辑短剧信息')"
|
||||
v-for="action in reviewActions(row)"
|
||||
:key="action"
|
||||
:type="action === 'reject' || action === 'offline' ? 'warning' : 'success'"
|
||||
size="small"
|
||||
@click="runAction(row, action)"
|
||||
>{{ actionMeta[action].label }}</el-button
|
||||
><span v-if="!reviewActions(row).length" class="table-empty-text">—</span></div
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="操作" width="200" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><div class="table-actions"
|
||||
><el-button
|
||||
v-if="can('drama:update')"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openEdit(row)"
|
||||
>编辑</el-button
|
||||
><el-button
|
||||
v-if="can('查看剧集列表')"
|
||||
v-if="can('episode:list')"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="$router.push({ name: 'episodes', query: { drama: row.id } })"
|
||||
@click="router.push({ name: 'episodes', query: { drama: row.id } })"
|
||||
>剧集</el-button
|
||||
><el-button
|
||||
v-if="can('删除短剧')"
|
||||
v-if="can('drama:delete')"
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="confirmDelete(row)"
|
||||
@@ -160,13 +361,15 @@ async function confirmDelete(item) {
|
||||
</el-table>
|
||||
<div class="pagination"
|
||||
><el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:current-page="pageIndex"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[5, 10, 20, 50]"
|
||||
:total="filtered.length"
|
||||
:total="total"
|
||||
:pager-count="7"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/></div>
|
||||
</section>
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑短剧' : '新建短剧'" width="600px"
|
||||
@@ -175,38 +378,48 @@ async function confirmDelete(item) {
|
||||
><el-form-item label="短剧名称" required
|
||||
><el-input v-model="form.title" placeholder="请输入短剧名称" /></el-form-item
|
||||
><el-form-item label="类型" required
|
||||
><el-select v-model="form.genre" placeholder="请选择"
|
||||
><el-select v-model="form.categoryId" placeholder="请选择"
|
||||
><el-option
|
||||
v-for="genre in ['都市情感', '甜宠爱情', '重生逆袭', '悬疑复仇', '古装言情']"
|
||||
:key="genre"
|
||||
:label="genre"
|
||||
:value="genre" /></el-select></el-form-item></div
|
||||
v-for="item in categories"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id" /></el-select></el-form-item></div
|
||||
><el-form-item label="简介"
|
||||
><el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:rows="2"
|
||||
maxlength="200"
|
||||
show-word-limit /></el-form-item
|
||||
><div class="form-row"
|
||||
><el-form-item label="预计集数"
|
||||
><el-input-number v-model="form.episodes" :min="1" :max="200" /></el-form-item
|
||||
><el-form-item v-if="can('上传封面素材')" label="短剧封面"
|
||||
><el-form-item label="免费集数"
|
||||
><el-input-number v-model="form.freeEpisodes" :min="0" :max="200" /></el-form-item
|
||||
><el-form-item label="单集解锁金币"
|
||||
><el-input-number v-model="form.unlockCoin" :min="0" :max="9999" /></el-form-item
|
||||
></div>
|
||||
<el-form-item label="是否热门"><el-switch v-model="form.isHot" /></el-form-item>
|
||||
<el-form-item label="短剧封面"
|
||||
><div class="cover-field"
|
||||
><div class="cover-field-actions"
|
||||
><el-upload
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
v-if="can('cover:upload')"
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
:on-change="uploadCover"
|
||||
><el-button size="small">{{
|
||||
:http-request="uploadCover"
|
||||
:disabled="uploadingCover"
|
||||
><el-button :loading="uploadingCover">{{
|
||||
form.cover ? '更换封面' : '选择封面'
|
||||
}}</el-button></el-upload
|
||||
></el-form-item
|
||||
></div
|
||||
><img v-if="form.cover" :src="form.cover" class="form-cover-preview" /></el-form
|
||||
><p v-if="uploadingCover" class="selected-file">上传中 {{ coverPercent }}%</p
|
||||
><p v-else-if="form.cover" class="selected-file">封面已上传</p
|
||||
><el-input v-model="form.cover" placeholder="也可以直接填写图片地址" /></div
|
||||
><img
|
||||
v-if="form.cover"
|
||||
:src="form.cover"
|
||||
class="form-cover-preview" /></div></el-form-item></el-form
|
||||
><template #footer
|
||||
><el-button @click="dialogVisible = false">取消</el-button
|
||||
><el-button type="primary" @click="saveDrama">{{
|
||||
><el-button type="primary" :loading="saving" @click="saveDrama">{{
|
||||
editingId ? '保存修改' : '保存草稿'
|
||||
}}</el-button></template
|
||||
></el-dialog
|
||||
|
||||
@@ -1,136 +1,401 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { dramas } from '@/data/dramas'
|
||||
import { episodeData } from '@/data/episodes'
|
||||
import { pageDramasApi } from '@/api/admin/drama'
|
||||
import {
|
||||
createEpisodeApi,
|
||||
deleteEpisodeApi,
|
||||
listEpisodesApi,
|
||||
sortEpisodesApi,
|
||||
updateEpisodeApi
|
||||
} from '@/api/admin/episode'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
import { formatCount } from '@/utils/format'
|
||||
import { statusClass } from '@/utils/status'
|
||||
import { probeVideoDuration, uploadFile } from '@/utils/uploader'
|
||||
|
||||
const route = useRoute()
|
||||
const { can } = useRbac()
|
||||
const selectedDramaId = ref(Number(route.query.drama) || dramas[0]?.id)
|
||||
|
||||
const loading = ref(false)
|
||||
const dramas = ref([])
|
||||
const episodes = ref([])
|
||||
const selectedDramaId = ref(undefined)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const form = ref({ title: '', fileName: '' })
|
||||
const selectedDrama = computed(
|
||||
() => dramas.find((item) => item.id === selectedDramaId.value) || dramas[0]
|
||||
)
|
||||
const episodes = computed(() =>
|
||||
episodeData
|
||||
.filter((item) => item.dramaId === selectedDramaId.value)
|
||||
.sort((a, b) => a.number - b.number)
|
||||
)
|
||||
function pickVideo(file) {
|
||||
form.value.fileName = file.name
|
||||
}
|
||||
function saveEpisode() {
|
||||
if (!form.value.title || !form.value.fileName)
|
||||
return ElMessage.warning('请填写标题并选择视频文件')
|
||||
episodeData.push({
|
||||
id: Date.now(),
|
||||
dramaId: selectedDramaId.value,
|
||||
number: episodes.value.length + 1,
|
||||
title: form.value.title,
|
||||
duration: '待处理',
|
||||
status: '待发布',
|
||||
views: '—',
|
||||
fileName: form.value.fileName
|
||||
const saving = ref(false)
|
||||
const uploadingVideo = ref(false)
|
||||
const videoPercent = ref(0)
|
||||
const previewVisible = ref(false)
|
||||
const previewEpisode = ref(null)
|
||||
const editingId = ref(null)
|
||||
const form = reactive({
|
||||
number: 1,
|
||||
title: '',
|
||||
durationSec: 0,
|
||||
videoUrl: '',
|
||||
isFree: true,
|
||||
unlockCoin: 0,
|
||||
status: 'draft'
|
||||
})
|
||||
selectedDrama.value.episodes += 1
|
||||
form.value = { title: '', fileName: '' }
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('剧集已加入本地上传队列')
|
||||
|
||||
const statusOptions = [
|
||||
{ code: 'draft', text: '草稿' },
|
||||
{ code: 'approved', text: '待发布' },
|
||||
{ code: 'published', text: '已上线' },
|
||||
{ code: 'offline', text: '已下线' }
|
||||
]
|
||||
|
||||
const selectedDrama = computed(() => dramas.value.find((item) => item.id === selectedDramaId.value))
|
||||
|
||||
async function loadDramas() {
|
||||
const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 })
|
||||
dramas.value = res.data?.list || []
|
||||
const fromQuery = Number(route.query.drama)
|
||||
if (fromQuery && dramas.value.some((item) => item.id === fromQuery)) {
|
||||
selectedDramaId.value = fromQuery
|
||||
} else if (!selectedDramaId.value && dramas.value.length) {
|
||||
selectedDramaId.value = dramas.value[0].id
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEpisodes() {
|
||||
if (!selectedDramaId.value) {
|
||||
episodes.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listEpisodesApi(selectedDramaId.value)
|
||||
episodes.value = res.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function nextNumber() {
|
||||
return episodes.value.reduce((max, item) => Math.max(max, item.number || 0), 0) + 1
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
Object.assign(form, {
|
||||
number: nextNumber(),
|
||||
title: '',
|
||||
durationSec: 0,
|
||||
videoUrl: '',
|
||||
isFree: true,
|
||||
unlockCoin: selectedDrama.value?.unlockCoin ?? 0,
|
||||
status: 'draft'
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
number: row.number,
|
||||
title: row.title,
|
||||
durationSec: row.durationSec || 0,
|
||||
videoUrl: row.videoUrl || '',
|
||||
isFree: !!row.isFree,
|
||||
unlockCoin: row.unlockCoin || 0,
|
||||
status: row.status
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveEpisode() {
|
||||
if (!form.title.trim()) return ElMessage.warning('请填写剧集标题')
|
||||
if (!form.number || form.number < 1) return ElMessage.warning('请填写正确的集号')
|
||||
const payload = {
|
||||
dramaId: selectedDramaId.value,
|
||||
number: form.number,
|
||||
title: form.title.trim(),
|
||||
durationSec: form.durationSec || 0,
|
||||
videoUrl: form.videoUrl.trim(),
|
||||
isFree: form.isFree,
|
||||
unlockCoin: form.isFree ? 0 : form.unlockCoin || 0,
|
||||
status: form.status
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateEpisodeApi(editingId.value, payload)
|
||||
ElMessage.success('剧集已更新')
|
||||
} else {
|
||||
await createEpisodeApi(payload)
|
||||
ElMessage.success('剧集已创建')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadEpisodes()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 视频分片上传:先探测时长,再交给上传器,完成后回填地址与时长。 */
|
||||
async function uploadVideo(options) {
|
||||
uploadingVideo.value = true
|
||||
videoPercent.value = 0
|
||||
try {
|
||||
const duration = await probeVideoDuration(options.file)
|
||||
const asset = await uploadFile(options.file, {
|
||||
bizType: 'EPISODE',
|
||||
durationSec: duration,
|
||||
onProgress: (percent) => (videoPercent.value = percent)
|
||||
})
|
||||
form.videoUrl = asset.url
|
||||
if (duration > 0) form.durationSec = duration
|
||||
ElMessage.success('视频已上传')
|
||||
} finally {
|
||||
uploadingVideo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除第 ${row.number} 集「${row.title}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await deleteEpisodeApi(row.id)
|
||||
ElMessage.success('剧集已删除')
|
||||
await loadEpisodes()
|
||||
}
|
||||
|
||||
function openPreview(row) {
|
||||
if (!row.videoUrl) {
|
||||
ElMessage.warning('该剧集还没有上传视频')
|
||||
return
|
||||
}
|
||||
previewEpisode.value = row
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
/** 鼠标移入播放、移出回到首帧,列表里就能直接看到视频内容 */
|
||||
function playThumb(event) {
|
||||
const video = event.currentTarget
|
||||
video.play?.()?.catch(() => {})
|
||||
}
|
||||
|
||||
function stopThumb(event) {
|
||||
const video = event.currentTarget
|
||||
try {
|
||||
video.pause()
|
||||
video.currentTime = 0
|
||||
} catch {
|
||||
// 元数据还没就绪时忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 上移/下移后把新顺序整表提交,由后端重排 sort。 */
|
||||
async function move(index, offset) {
|
||||
const target = index + offset
|
||||
if (target < 0 || target >= episodes.value.length) return
|
||||
const next = [...episodes.value]
|
||||
const [moved] = next.splice(index, 1)
|
||||
next.splice(target, 0, moved)
|
||||
await sortEpisodesApi(next.map((item) => item.id))
|
||||
ElMessage.success('播放顺序已更新')
|
||||
await loadEpisodes()
|
||||
}
|
||||
|
||||
async function onDramaChange() {
|
||||
await loadEpisodes()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDramas()
|
||||
await loadEpisodes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-heading"
|
||||
><div
|
||||
><div class="eyebrow">内容管理</div><h1>剧集管理</h1
|
||||
><p>为短剧添加视频剧集并维护播放顺序。</p></div
|
||||
><p>为短剧维护剧集信息、视频地址与播放顺序。</p></div
|
||||
><el-button
|
||||
v-if="can('上传剧集视频')"
|
||||
v-if="can('episode:upload')"
|
||||
type="primary"
|
||||
class="primary-button"
|
||||
@click="dialogVisible = true"
|
||||
><el-icon><Plus /></el-icon>上传剧集</el-button
|
||||
:disabled="!selectedDramaId"
|
||||
@click="openCreate"
|
||||
><el-icon><Plus /></el-icon>新增剧集</el-button
|
||||
></div
|
||||
>
|
||||
<section class="panel episode-panel">
|
||||
<div class="episode-context"
|
||||
><div class="episode-context-copy"
|
||||
><span>选择短剧</span><b>{{ selectedDrama?.title }}</b></div
|
||||
><el-select v-model="selectedDramaId" class="drama-select"
|
||||
><span>选择短剧</span><b>{{ selectedDrama?.title || '暂无短剧' }}</b></div
|
||||
><el-select v-model="selectedDramaId" class="drama-select" @change="onDramaChange"
|
||||
><el-option
|
||||
v-for="item in dramas"
|
||||
:key="item.id"
|
||||
:label="item.title"
|
||||
:label="`${item.title}(${item.episodes} 集)`"
|
||||
:value="item.id" /></el-select
|
||||
></div>
|
||||
<div class="episode-toolbar"
|
||||
><div
|
||||
><h2>剧集列表({{ episodes.length }})</h2><p>按剧集编号顺序播放</p></div
|
||||
><h2>剧集列表({{ episodes.length }})</h2><p>按列表顺序播放</p></div
|
||||
></div
|
||||
>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="episodes"
|
||||
row-key="id"
|
||||
empty-text="该短剧暂无剧集"
|
||||
class="drama-el-table episode-el-table"
|
||||
>
|
||||
<el-table-column label="集数" width="100"
|
||||
<el-table-column label="集数" width="90"
|
||||
><template #default="{ row }">{{
|
||||
String(row.number).padStart(2, '0')
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="剧集名称" min-width="280"
|
||||
<el-table-column label="剧集名称" min-width="240"
|
||||
><template #default="{ row }"
|
||||
><div class="episode-table-title"
|
||||
><b>{{ row.title }}</b
|
||||
><small v-if="row.fileName">{{ row.fileName }}</small></div
|
||||
><small v-if="!row.videoUrl">未上传视频</small></div
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column prop="duration" label="时长" width="140" />
|
||||
<el-table-column prop="views" label="播放量" width="180"
|
||||
<el-table-column label="视频" width="150"
|
||||
><template #default="{ row }"
|
||||
>{{ row.views }}<span v-if="row.views !== '—'"> 次</span></template
|
||||
><video
|
||||
v-if="row.videoUrl"
|
||||
:src="row.videoUrl"
|
||||
class="episode-video-thumb"
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
title="点击放大播放"
|
||||
@mouseenter="playThumb"
|
||||
@mouseleave="stopThumb"
|
||||
@click="openPreview(row)"
|
||||
></video
|
||||
><span v-else class="table-empty-text">—</span></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="状态" width="150"
|
||||
<el-table-column prop="duration" label="时长" width="110" />
|
||||
<el-table-column label="付费" width="120"
|
||||
><template #default="{ row }"
|
||||
><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{
|
||||
row.status
|
||||
><el-tag :type="row.isFree ? 'success' : 'warning'" effect="light">{{
|
||||
row.isFree ? '免费' : `${row.unlockCoin} 金币`
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="操作" width="150" fixed="right"
|
||||
><template #default
|
||||
><el-button v-if="can('管理剧集排序')" type="primary" link>调整顺序</el-button></template
|
||||
<el-table-column label="播放量" width="120"
|
||||
><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="状态" width="110"
|
||||
><template #default="{ row }"
|
||||
><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{
|
||||
row.statusText
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="操作" width="250" fixed="right"
|
||||
><template #default="{ row, $index }"
|
||||
><div class="table-actions"
|
||||
><el-button
|
||||
v-if="can('episode:upload')"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openEdit(row)"
|
||||
>编辑</el-button
|
||||
><el-button
|
||||
v-if="can('episode:sort')"
|
||||
size="small"
|
||||
:disabled="$index === 0"
|
||||
@click="move($index, -1)"
|
||||
>上移</el-button
|
||||
><el-button
|
||||
v-if="can('episode:sort')"
|
||||
size="small"
|
||||
:disabled="$index === episodes.length - 1"
|
||||
@click="move($index, 1)"
|
||||
>下移</el-button
|
||||
><el-button
|
||||
v-if="can('episode:upload')"
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="confirmDelete(row)"
|
||||
>删除</el-button
|
||||
></div
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
</section>
|
||||
<el-dialog v-model="dialogVisible" title="上传剧集" width="560px"
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑剧集' : '新增剧集'" width="560px"
|
||||
><el-form label-position="top"
|
||||
><div class="form-row"
|
||||
><el-form-item label="集号" required
|
||||
><el-input-number v-model="form.number" :min="1" :max="999" /></el-form-item
|
||||
><el-form-item label="时长(秒)"
|
||||
><el-input-number v-model="form.durationSec" :min="0" :max="9999" /></el-form-item></div
|
||||
><el-form-item label="剧集标题" required
|
||||
><el-input v-model="form.title" placeholder="例如:命运的相遇" /></el-form-item
|
||||
><el-form-item label="视频文件" required
|
||||
><el-form-item label="视频文件"
|
||||
><div class="video-field"
|
||||
><div class="video-field-actions"
|
||||
><el-upload
|
||||
drag
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
accept="video/mp4,video/quicktime"
|
||||
:on-change="pickVideo"
|
||||
:show-file-list="false"
|
||||
accept="video/*"
|
||||
:http-request="uploadVideo"
|
||||
:disabled="uploadingVideo"
|
||||
><div class="upload-copy"
|
||||
>拖放视频文件到此处,或点击选择<br /><small>支持 MP4、MOV</small></div
|
||||
>拖放视频文件到此处,或点击选择<br /><small>支持 MP4、MOV,自动分片上传</small></div
|
||||
></el-upload
|
||||
></el-form-item
|
||||
><p v-if="form.fileName" class="selected-file">已选择:{{ form.fileName }}</p></el-form
|
||||
><template #footer
|
||||
><p v-if="uploadingVideo" class="selected-file">上传中 {{ videoPercent }}%</p
|
||||
><p v-else-if="form.videoUrl" class="selected-file">视频已上传</p
|
||||
><el-input v-model="form.videoUrl" placeholder="也可以直接填写视频地址" /></div
|
||||
><video
|
||||
v-if="form.videoUrl"
|
||||
:src="form.videoUrl"
|
||||
controls
|
||||
class="form-video-preview"
|
||||
></video></div></el-form-item
|
||||
><div class="form-row"
|
||||
><el-form-item label="是否免费"><el-switch v-model="form.isFree" /></el-form-item
|
||||
><el-form-item v-if="!form.isFree" label="解锁金币"
|
||||
><el-input-number v-model="form.unlockCoin" :min="0" :max="9999" /></el-form-item
|
||||
><el-form-item label="状态"
|
||||
><el-select v-model="form.status"
|
||||
><el-option
|
||||
v-for="item in statusOptions"
|
||||
:key="item.code"
|
||||
:label="item.text"
|
||||
:value="item.code" /></el-select></el-form-item></div
|
||||
></el-form>
|
||||
<template #footer
|
||||
><el-button @click="dialogVisible = false">取消</el-button
|
||||
><el-button type="primary" @click="saveEpisode">加入上传队列</el-button></template
|
||||
><el-button type="primary" :loading="saving" @click="saveEpisode">保存</el-button></template
|
||||
></el-dialog
|
||||
>
|
||||
<el-dialog
|
||||
v-model="previewVisible"
|
||||
:title="`预览:第 ${previewEpisode?.number ?? ''} 集 ${previewEpisode?.title ?? ''}`"
|
||||
width="720px"
|
||||
destroy-on-close
|
||||
>
|
||||
<video
|
||||
v-if="previewEpisode?.videoUrl"
|
||||
:src="previewEpisode.videoUrl"
|
||||
controls
|
||||
autoplay
|
||||
class="episode-video-player"
|
||||
></video>
|
||||
<el-empty v-else description="该剧集还没有上传视频" />
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -1,17 +1,16 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { customMenus } from '@/data/customMenus'
|
||||
|
||||
const route = useRoute()
|
||||
const menu = computed(() => customMenus.find((item) => item.key === route.meta.menuKey))
|
||||
const title = computed(() => (route.meta?.title as string) || '菜单页面')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-heading"
|
||||
><div
|
||||
><div class="eyebrow">自定义菜单</div><h1>{{ menu?.title || '菜单页面' }}</h1
|
||||
><p>这是「{{ menu?.title }}」菜单的前端演示页面。</p></div
|
||||
><div class="eyebrow">自定义菜单</div><h1>{{ title }}</h1
|
||||
><p>该菜单已注册,可在菜单管理中把组件路径指向已有页面。</p></div
|
||||
></div
|
||||
>
|
||||
<section class="panel"><el-empty description="菜单已创建,可以继续配置页面内容。" /></section>
|
||||
|
||||
@@ -1,42 +1,30 @@
|
||||
<script setup>
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { menuIconNames } from '@/data/customMenus'
|
||||
import { useMenuManagement } from '@/views/permission/composables/useMenuManagement'
|
||||
import { menuIconNames, useMenuManagement } from '@/views/permission/composables/useMenuManagement'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
|
||||
const {
|
||||
selectedKey,
|
||||
selectedParent,
|
||||
form,
|
||||
menuTree,
|
||||
parentOptions,
|
||||
resetForm,
|
||||
selectNode,
|
||||
saveMenu,
|
||||
deleteMenu
|
||||
} = useMenuManagement()
|
||||
const { treeData, loading, form, parentOptions, resetForm, selectNode, saveMenu, deleteMenu } =
|
||||
useMenuManagement()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeading
|
||||
eyebrow="后台管理"
|
||||
title="菜单管理"
|
||||
description="维护系统菜单结构、路由和按钮权限。"
|
||||
/>
|
||||
|
||||
<div class="menu-editor-layout">
|
||||
<section class="panel menu-tree-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>系统菜单</h2></div>
|
||||
<el-button type="primary" @click="resetForm(selectedParent)">
|
||||
<PageHeading eyebrow="后台管理" title="菜单管理" description="维护系统菜单结构、路由与按钮权限。">
|
||||
<el-button type="primary" @click="resetForm(0)">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增菜单
|
||||
</el-button>
|
||||
</PageHeading>
|
||||
|
||||
<div class="menu-editor-layout">
|
||||
<section v-loading="loading" class="panel menu-tree-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>系统菜单</h2></div>
|
||||
</div>
|
||||
<el-tree
|
||||
:data="menuTree"
|
||||
:data="treeData"
|
||||
node-key="id"
|
||||
default-expand-all
|
||||
highlight-current
|
||||
:expand-on-click-node="false"
|
||||
@node-click="selectNode"
|
||||
/>
|
||||
@@ -45,51 +33,60 @@ const {
|
||||
<section class="panel menu-form-panel">
|
||||
<div class="panel-heading">
|
||||
<div
|
||||
><h2>{{ selectedKey ? '编辑菜单权限' : '新增菜单权限' }}</h2></div
|
||||
><h2>{{ form.id ? '编辑菜单' : '新增菜单' }}</h2></div
|
||||
>
|
||||
<el-button v-if="selectedKey" type="danger" plain @click="deleteMenu">删除菜单</el-button>
|
||||
<el-button v-if="form.id" type="danger" plain @click="deleteMenu">删除菜单</el-button>
|
||||
</div>
|
||||
<el-form label-width="110px" label-position="right" class="menu-editor-form">
|
||||
<el-form-item label="菜单名称" required>
|
||||
<el-input v-model="form.title" maxlength="30" />
|
||||
</el-form-item>
|
||||
<el-form-item label="对应路由" :required="form.type !== '按钮'">
|
||||
<el-input v-model="form.route" placeholder="例如 /reports" />
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单标识" required>
|
||||
<el-input
|
||||
v-model="form.permission"
|
||||
:placeholder="`自动生成:访问菜单:${form.title || '菜单名'}`"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="上级菜单">
|
||||
<el-select v-model="form.parent">
|
||||
<el-option v-for="item in parentOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
<el-input v-model="form.title" maxlength="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单类型">
|
||||
<el-radio-group v-model="form.type">
|
||||
<el-radio value="路径">路径</el-radio>
|
||||
<el-radio value="页面">页面</el-radio>
|
||||
<el-radio value="按钮">按钮</el-radio>
|
||||
<el-radio value="CATALOG">目录</el-radio>
|
||||
<el-radio value="MENU">菜单</el-radio>
|
||||
<el-radio value="BUTTON">按钮</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单图标">
|
||||
<el-form-item label="上级菜单">
|
||||
<el-select v-model="form.parentId">
|
||||
<el-option
|
||||
v-for="item in parentOptions"
|
||||
:key="item.id"
|
||||
:label="item.label"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.type !== 'BUTTON'" label="路由地址" required>
|
||||
<el-input v-model="form.routePath" placeholder="目录填 /content,子菜单填 dramas" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.type === 'MENU'" label="路由名称">
|
||||
<el-input v-model="form.routeName" placeholder="例如 dramas" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.type === 'MENU'" label="组件路径">
|
||||
<el-input
|
||||
v-model="form.component"
|
||||
placeholder="例如 views/contentManagement/DramaManagementView"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="权限标识">
|
||||
<el-input v-model="form.permissionCode" placeholder="例如 drama:list" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.type !== 'BUTTON'" label="菜单图标">
|
||||
<el-select v-model="form.icon" clearable placeholder="请选择">
|
||||
<el-option v-for="icon in menuIconNames" :key="icon" :label="icon" :value="icon" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="使用方法">
|
||||
<el-input
|
||||
v-model="form.method"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="可填写按钮权限对应的操作说明"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单排序">
|
||||
<el-input-number v-model="form.sort" :min="1" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否显示">
|
||||
<el-switch v-model="form.visible" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="menu-form-footer">
|
||||
<el-button type="primary" @click="saveMenu">保存菜单</el-button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import PageHeading from '@/views/components/PageHeading.vue'
|
||||
const {
|
||||
dialogVisible,
|
||||
name,
|
||||
description,
|
||||
editingId,
|
||||
tableData,
|
||||
openCreateDialog,
|
||||
@@ -36,9 +37,11 @@ const {
|
||||
empty-text="暂无角色分类"
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="100" />
|
||||
<el-table-column prop="id" label="ID" width="140" />
|
||||
<el-table-column prop="name" label="分类名称" min-width="260" />
|
||||
<el-table-column label="操作" width="230">
|
||||
<el-table-column prop="id" label="ID" width="120" />
|
||||
<el-table-column prop="name" label="分类名称" min-width="180" />
|
||||
<el-table-column prop="description" label="描述" min-width="240" />
|
||||
<el-table-column prop="roleCount" label="角色数" width="100" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" @click="editCategory(row)">编辑</el-button>
|
||||
<el-button type="danger" @click="deleteCategory(row)">删除</el-button>
|
||||
@@ -61,6 +64,9 @@ const {
|
||||
@keyup.enter="saveCategory"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="description" maxlength="50" placeholder="选填,例如:负责内容维护" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import PageHeading from '@/views/components/PageHeading.vue'
|
||||
|
||||
const {
|
||||
roleData,
|
||||
categories,
|
||||
selectedRole,
|
||||
selectedRoleRecord,
|
||||
canEditSelectedRole,
|
||||
@@ -12,10 +13,18 @@ const {
|
||||
treeRef,
|
||||
dialogVisible,
|
||||
newRoleName,
|
||||
newRoleCategoryId,
|
||||
selectRole,
|
||||
handleCheck,
|
||||
addRole
|
||||
addRole,
|
||||
removeRole
|
||||
} = useRolePermissionEditor()
|
||||
|
||||
/** 表格里点删除时先选中该行,再走统一的删除流程。 */
|
||||
function removeRoleRow(row) {
|
||||
selectRole(row)
|
||||
removeRole()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -51,9 +60,22 @@ const {
|
||||
<el-table-column prop="name" label="角色名称" min-width="120" />
|
||||
<el-table-column prop="description" label="描述" min-width="180" />
|
||||
<el-table-column prop="level" label="角色等级" width="100" sortable />
|
||||
<el-table-column label="权限数" width="90">
|
||||
<template #default="{ row }">{{ row.permissions?.length || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="memberCount" label="成员" width="80" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="success" effect="light">{{ row.status }}</el-tag>
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" effect="light">{{
|
||||
row.statusText
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link :disabled="row.builtin" @click="removeRoleRow(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -92,6 +114,16 @@ const {
|
||||
@keyup.enter="addRole"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色分类">
|
||||
<el-select v-model="newRoleCategoryId" clearable placeholder="请选择角色分类">
|
||||
<el-option
|
||||
v-for="item in categories"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
title="新角色等级将低于当前账号,等级数字越大权限越低。"
|
||||
type="info"
|
||||
|
||||
@@ -1,192 +1,165 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { customMenus, saveCustomMenus } from '../../../data/customMenus'
|
||||
import { useRbac } from '../../../stores/rbac'
|
||||
import router, { registerCustomMenu } from '../../../router'
|
||||
import { createMenuApi, deleteMenuApi, getMenuTreeApi, updateMenuApi } from '@/api/admin/role'
|
||||
|
||||
const builtInMenus = [
|
||||
{
|
||||
id: 'builtin-content',
|
||||
label: '内容管理',
|
||||
children: [
|
||||
{
|
||||
id: 'builtin-dramas',
|
||||
label: '短剧管理',
|
||||
route: '/content/dramas',
|
||||
permission: '查看短剧列表'
|
||||
},
|
||||
{
|
||||
id: 'builtin-episodes',
|
||||
label: '剧集管理',
|
||||
route: '/content/episodes',
|
||||
permission: '查看剧集列表'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'builtin-admin',
|
||||
label: '后台管理',
|
||||
children: [
|
||||
{
|
||||
id: 'builtin-access',
|
||||
label: '角色权限配置',
|
||||
route: '/system/access',
|
||||
permission: '角色与权限配置'
|
||||
},
|
||||
{
|
||||
id: 'builtin-users',
|
||||
label: '管理员用户列表',
|
||||
route: '/system/admin-users',
|
||||
permission: '管理员用户列表'
|
||||
},
|
||||
{ id: 'builtin-menus', label: '菜单管理', route: '/system/menus', permission: '菜单管理' },
|
||||
{
|
||||
id: 'builtin-categories',
|
||||
label: '角色分类',
|
||||
route: '/system/role-categories',
|
||||
permission: '角色分类管理'
|
||||
}
|
||||
]
|
||||
}
|
||||
export const menuIconNames = [
|
||||
'Document',
|
||||
'DataAnalysis',
|
||||
'Picture',
|
||||
'User',
|
||||
'Setting',
|
||||
'VideoCamera',
|
||||
'Menu',
|
||||
'Grid'
|
||||
]
|
||||
|
||||
function buildCustomNodes(parent) {
|
||||
return customMenus
|
||||
.filter((menu) => (menu.parent || '后台管理') === parent)
|
||||
.map((menu) => ({
|
||||
id: menu.key,
|
||||
label: menu.title,
|
||||
menu,
|
||||
children: buildCustomNodes(menu.title)
|
||||
}))
|
||||
}
|
||||
|
||||
/** 菜单管理:直接读写后端 sys_menu,变更后重新登录生效。 */
|
||||
export function useMenuManagement() {
|
||||
const { createPermission, removePermission } = useRbac()
|
||||
const selectedKey = ref('')
|
||||
const selectedParent = ref('后台管理')
|
||||
const form = reactive({
|
||||
key: '',
|
||||
title: '',
|
||||
route: '',
|
||||
permission: '',
|
||||
parent: '后台管理',
|
||||
type: '页面',
|
||||
icon: 'Document',
|
||||
method: '',
|
||||
sort: 1
|
||||
})
|
||||
const menuTree = computed(() =>
|
||||
builtInMenus.map((group) => ({
|
||||
...group,
|
||||
children: [...group.children, ...buildCustomNodes(group.label)]
|
||||
}))
|
||||
)
|
||||
const parentOptions = computed(() => [
|
||||
'内容管理',
|
||||
'后台管理',
|
||||
...customMenus.filter((menu) => menu.type !== '按钮').map((menu) => menu.title)
|
||||
])
|
||||
const menuTree = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
function resetForm(parent = selectedParent.value) {
|
||||
selectedKey.value = ''
|
||||
Object.assign(form, {
|
||||
key: '',
|
||||
const form = reactive({
|
||||
id: null,
|
||||
parentId: 0,
|
||||
title: '',
|
||||
route: '',
|
||||
permission: '',
|
||||
parent,
|
||||
type: '页面',
|
||||
icon: 'Document',
|
||||
method: '',
|
||||
sort: 1
|
||||
routePath: '',
|
||||
routeName: '',
|
||||
component: '',
|
||||
icon: '',
|
||||
permissionCode: '',
|
||||
type: 'MENU',
|
||||
sort: 1,
|
||||
visible: 1,
|
||||
status: 1
|
||||
})
|
||||
|
||||
function mapNodes(nodes) {
|
||||
return (nodes || []).map((node) => ({
|
||||
id: node.id,
|
||||
label: node.meta?.title || node.path || String(node.id),
|
||||
raw: node,
|
||||
children: node.children?.length ? mapNodes(node.children) : undefined
|
||||
}))
|
||||
}
|
||||
|
||||
const treeData = computed(() => mapNodes(menuTree.value))
|
||||
|
||||
const parentOptions = computed(() => {
|
||||
const options = [{ id: 0, label: '顶级菜单' }]
|
||||
const walk = (nodes, depth) => {
|
||||
;(nodes || []).forEach((node) => {
|
||||
if (node.type !== 'BUTTON') {
|
||||
options.push({
|
||||
id: node.id,
|
||||
label: `${' '.repeat(depth)}${node.meta?.title || node.path || node.id}`
|
||||
})
|
||||
}
|
||||
function selectNode(node) {
|
||||
if (node.menu) {
|
||||
selectedKey.value = node.menu.key
|
||||
selectedParent.value = node.menu.parent || '后台管理'
|
||||
Object.assign(form, {
|
||||
...node.menu,
|
||||
route: node.menu.route || `/custom/${node.menu.key}`,
|
||||
permission: node.menu.permission || `访问菜单:${node.menu.title}`,
|
||||
type: node.menu.type || '页面',
|
||||
method: node.menu.method || '',
|
||||
sort: node.menu.sort || 1
|
||||
if (node.children?.length) walk(node.children, depth + 1)
|
||||
})
|
||||
} else if (node.children) {
|
||||
selectedParent.value = node.label
|
||||
resetForm(node.label)
|
||||
}
|
||||
}
|
||||
function saveMenu() {
|
||||
if (!form.title.trim()) return ElMessage.warning('请填写菜单名称')
|
||||
if (form.type !== '按钮' && !form.route.trim()) return ElMessage.warning('请填写对应路由')
|
||||
const duplicate = customMenus.some(
|
||||
(menu) => menu.title === form.title.trim() && menu.key !== selectedKey.value
|
||||
)
|
||||
if (duplicate) return ElMessage.warning('菜单名称已存在')
|
||||
const isNew = !selectedKey.value
|
||||
const key = selectedKey.value || `${Date.now()}`
|
||||
const existingMenu = customMenus.find((menu) => menu.key === key)
|
||||
const permission = form.permission.trim() || `访问菜单:${form.title.trim()}`
|
||||
if (existingMenu) {
|
||||
if (router.hasRoute(`custom-menu-${key}`)) router.removeRoute(`custom-menu-${key}`)
|
||||
if (existingMenu.permission !== permission) {
|
||||
removePermission(existingMenu.permission)
|
||||
createPermission(permission)
|
||||
}
|
||||
Object.assign(existingMenu, {
|
||||
...form,
|
||||
key,
|
||||
title: form.title.trim(),
|
||||
permission,
|
||||
route: form.route.trim(),
|
||||
sort: Number(form.sort) || 1
|
||||
walk(menuTree.value, 0)
|
||||
return options
|
||||
})
|
||||
} else {
|
||||
customMenus.push({
|
||||
...form,
|
||||
key,
|
||||
title: form.title.trim(),
|
||||
permission,
|
||||
route: form.route.trim(),
|
||||
sort: Number(form.sort) || 1
|
||||
})
|
||||
createPermission(permission)
|
||||
}
|
||||
const savedMenu = customMenus.find((menu) => menu.key === key)
|
||||
if (savedMenu?.type !== '按钮') registerCustomMenu(savedMenu)
|
||||
saveCustomMenus()
|
||||
selectedKey.value = key
|
||||
ElMessage.success(isNew ? '菜单已添加' : '菜单已保存')
|
||||
}
|
||||
async function deleteMenu() {
|
||||
const menu = customMenus.find((item) => item.key === selectedKey.value)
|
||||
if (!menu) return ElMessage.warning('请先在左侧选择一个自定义菜单')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除「${menu.title}」菜单吗?`, '删除确认', {
|
||||
const res = await getMenuTreeApi()
|
||||
menuTree.value = res.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm(parentId = 0) {
|
||||
Object.assign(form, {
|
||||
id: null,
|
||||
parentId,
|
||||
title: '',
|
||||
routePath: '',
|
||||
routeName: '',
|
||||
component: '',
|
||||
icon: '',
|
||||
permissionCode: '',
|
||||
type: 'MENU',
|
||||
sort: 1,
|
||||
visible: 1,
|
||||
status: 1
|
||||
})
|
||||
}
|
||||
|
||||
function selectNode(node) {
|
||||
const raw = node.raw || {}
|
||||
Object.assign(form, {
|
||||
id: raw.id,
|
||||
parentId: raw.parentId ?? 0,
|
||||
title: raw.meta?.title || '',
|
||||
routePath: raw.path || '',
|
||||
routeName: raw.name || '',
|
||||
component: raw.component === '#' ? '' : raw.component || '',
|
||||
icon: raw.icon || '',
|
||||
permissionCode: raw.permissionCode || '',
|
||||
type: raw.type || 'MENU',
|
||||
sort: raw.sort ?? 1,
|
||||
visible: raw.visible ?? 1,
|
||||
status: raw.status ?? 1
|
||||
})
|
||||
}
|
||||
|
||||
async function saveMenu() {
|
||||
if (!form.title.trim()) return ElMessage.warning('请填写菜单名称')
|
||||
if (form.type !== 'BUTTON' && !form.routePath.trim()) {
|
||||
return ElMessage.warning('请填写对应路由')
|
||||
}
|
||||
const payload = {
|
||||
parentId: form.parentId || 0,
|
||||
title: form.title.trim(),
|
||||
routePath: form.routePath.trim(),
|
||||
routeName: form.routeName.trim(),
|
||||
component: form.type === 'CATALOG' ? '#' : form.component.trim(),
|
||||
icon: form.icon,
|
||||
permissionCode: form.permissionCode.trim(),
|
||||
type: form.type,
|
||||
sort: Number(form.sort) || 1,
|
||||
visible: form.visible,
|
||||
status: form.status
|
||||
}
|
||||
const isCreate = !form.id
|
||||
if (isCreate) {
|
||||
await createMenuApi(payload)
|
||||
} else {
|
||||
await updateMenuApi(form.id, payload)
|
||||
}
|
||||
await load()
|
||||
resetForm()
|
||||
ElMessage.success(isCreate ? '菜单已添加,重新登录后生效' : '菜单已保存,重新登录后生效')
|
||||
}
|
||||
|
||||
async function deleteMenu() {
|
||||
if (!form.id) return ElMessage.warning('请先在左侧选择一个菜单')
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除「${form.title}」菜单吗?`, '删除确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
})
|
||||
customMenus.splice(customMenus.indexOf(menu), 1)
|
||||
removePermission(menu.permission)
|
||||
if (router.hasRoute(`custom-menu-${menu.key}`)) router.removeRoute(`custom-menu-${menu.key}`)
|
||||
saveCustomMenus()
|
||||
resetForm()
|
||||
ElMessage.success('菜单已删除')
|
||||
} catch {
|
||||
// 用户取消时不删除菜单。
|
||||
return
|
||||
}
|
||||
await deleteMenuApi(form.id)
|
||||
ElMessage.success('菜单已删除,重新登录后生效')
|
||||
resetForm()
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
return {
|
||||
selectedKey,
|
||||
selectedParent,
|
||||
form,
|
||||
menuTree,
|
||||
treeData,
|
||||
loading,
|
||||
form,
|
||||
parentOptions,
|
||||
resetForm,
|
||||
selectNode,
|
||||
|
||||
@@ -1,57 +1,55 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { roleData } from '../../../data/roles'
|
||||
import {
|
||||
createRoleCategoryApi,
|
||||
deleteRoleCategoryApi,
|
||||
listRoleCategoriesApi,
|
||||
updateRoleCategoryApi
|
||||
} from '@/api/admin/role'
|
||||
|
||||
export function useRoleCategories() {
|
||||
const tableData = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const editingId = ref(null)
|
||||
const categories = ref([
|
||||
{ id: 1, name: '系统管理员', description: '负责账号、权限和菜单配置' },
|
||||
{ id: 2, name: '内容管理', description: '负责短剧与剧集内容维护' },
|
||||
{ id: 3, name: '数据分析', description: '负责查看运营数据' }
|
||||
])
|
||||
const tableData = computed(() =>
|
||||
categories.value.map((category) => ({
|
||||
...category,
|
||||
roleCount: roleData.filter((role) => role.category === category.name).length
|
||||
}))
|
||||
)
|
||||
|
||||
async function load() {
|
||||
const res = await listRoleCategoriesApi()
|
||||
tableData.value = res.data || []
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
editingId.value = null
|
||||
name.value = ''
|
||||
description.value = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function editCategory(category) {
|
||||
editingId.value = category.id
|
||||
name.value = category.name
|
||||
description.value = category.description || ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
function saveCategory() {
|
||||
|
||||
async function saveCategory() {
|
||||
const value = name.value.trim()
|
||||
if (!value) return ElMessage.warning('请输入分类名称')
|
||||
if (
|
||||
categories.value.some(
|
||||
(category) => category.name === value && category.id !== editingId.value
|
||||
)
|
||||
) {
|
||||
return ElMessage.warning('分类名称已存在')
|
||||
}
|
||||
if (editingId.value) {
|
||||
const category = categories.value.find((item) => item.id === editingId.value)
|
||||
const oldName = category?.name
|
||||
if (category) category.name = value
|
||||
roleData.forEach((role) => {
|
||||
if (role.category === oldName) role.category = value
|
||||
})
|
||||
await updateRoleCategoryApi(editingId.value, { name: value, description: description.value })
|
||||
} else {
|
||||
categories.value.push({ id: Date.now(), name: value, description: '自定义角色分类' })
|
||||
await createRoleCategoryApi({ name: value, description: description.value })
|
||||
}
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('角色分类已保存')
|
||||
await load()
|
||||
}
|
||||
|
||||
async function deleteCategory(category) {
|
||||
if (category.roleCount > 0) {
|
||||
return ElMessage.warning(`「${category.name}」下还有 ${category.roleCount} 个角色,无法删除`)
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${category.name}」分类吗?`, '删除确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
@@ -59,16 +57,20 @@ export function useRoleCategories() {
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
})
|
||||
categories.value = categories.value.filter((item) => item.id !== category.id)
|
||||
ElMessage.success('分类已删除')
|
||||
} catch {
|
||||
// 用户取消时不删除分类。
|
||||
return
|
||||
}
|
||||
await deleteRoleCategoryApi(category.id)
|
||||
ElMessage.success('分类已删除')
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
return {
|
||||
dialogVisible,
|
||||
name,
|
||||
description,
|
||||
editingId,
|
||||
tableData,
|
||||
openCreateDialog,
|
||||
|
||||
@@ -1,54 +1,77 @@
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { roleData } from '../../../data/roles'
|
||||
import { customMenus } from '../../../data/customMenus'
|
||||
import { permissionGroups } from '../../../permissions/permissions'
|
||||
import { useAuth } from '../../../stores/auth'
|
||||
import { useRbac } from '../../../stores/rbac'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
createRoleApi,
|
||||
deleteRoleApi,
|
||||
listPermissionGroupsApi,
|
||||
listRoleCategoriesApi,
|
||||
listRolesApi,
|
||||
updateRolePermissionsApi
|
||||
} from '@/api/admin/role'
|
||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
|
||||
export function useRolePermissionEditor() {
|
||||
const { rolePermissions, updateRolePermission, createRole } = useRbac()
|
||||
const { currentRole } = useAuth()
|
||||
const selectedRole = ref(roleData[0].name)
|
||||
const userStore = useUserStoreWithOut()
|
||||
const { can } = useRbac()
|
||||
|
||||
const roleData = ref([])
|
||||
const categories = ref([])
|
||||
const permissionGroups = ref([])
|
||||
const selectedRoleId = ref(null)
|
||||
const dialogVisible = ref(false)
|
||||
const newRoleName = ref('')
|
||||
const newRoleCategoryId = ref(undefined)
|
||||
const treeRef = ref()
|
||||
const syncingTree = ref(false)
|
||||
const permissionTree = computed(() => {
|
||||
const groups = permissionGroups.map((group) => ({
|
||||
|
||||
const permissionTree = computed(() =>
|
||||
permissionGroups.value.map((group) => ({
|
||||
id: `group-${group.name}`,
|
||||
label: group.name,
|
||||
children: group.items.map((permission) => ({ id: permission, label: permission, permission }))
|
||||
children: group.items.map((item) => ({
|
||||
id: item.code,
|
||||
label: item.name,
|
||||
permission: item.code
|
||||
}))
|
||||
if (customMenus.length) {
|
||||
groups.push({
|
||||
id: 'group-custom-menus',
|
||||
label: '自定义菜单',
|
||||
children: customMenus.map((menu) => ({
|
||||
id: menu.permission,
|
||||
label: menu.title,
|
||||
permission: menu.permission
|
||||
}))
|
||||
})
|
||||
}
|
||||
return groups
|
||||
})
|
||||
const currentLevel = computed(
|
||||
() => roleData.find((role) => role.name === currentRole.value)?.level || 1
|
||||
)
|
||||
const selectedRoleRecord = computed(() =>
|
||||
roleData.find((role) => role.name === selectedRole.value)
|
||||
)
|
||||
const canEditSelectedRole = computed(
|
||||
() => (selectedRoleRecord.value?.level || 1) >= currentLevel.value
|
||||
)
|
||||
|
||||
const selectedRoleRecord = computed(() =>
|
||||
roleData.value.find((role) => role.id === selectedRoleId.value)
|
||||
)
|
||||
const selectedRole = computed(() => selectedRoleRecord.value?.name || '—')
|
||||
const currentLevel = computed(() => {
|
||||
const code = userStore.getUserInfo?.role
|
||||
return roleData.value.find((role) => role.code === code)?.level ?? 1
|
||||
})
|
||||
const canEditSelectedRole = computed(
|
||||
() =>
|
||||
can('system:role') &&
|
||||
!!selectedRoleRecord.value &&
|
||||
selectedRoleRecord.value.level >= currentLevel.value
|
||||
)
|
||||
|
||||
async function load() {
|
||||
const [rolesRes, groupsRes, categoriesRes] = await Promise.all([
|
||||
listRolesApi(),
|
||||
listPermissionGroupsApi(),
|
||||
listRoleCategoriesApi()
|
||||
])
|
||||
roleData.value = rolesRes.data || []
|
||||
permissionGroups.value = groupsRes.data || []
|
||||
categories.value = categoriesRes.data || []
|
||||
if (!selectedRoleId.value && roleData.value.length) {
|
||||
selectedRoleId.value = roleData.value[0].id
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedRole,
|
||||
async (role) => {
|
||||
[selectedRoleId, permissionGroups],
|
||||
async () => {
|
||||
syncingTree.value = true
|
||||
await nextTick()
|
||||
treeRef.value?.setCheckedKeys(rolePermissions[role] || [])
|
||||
treeRef.value?.setCheckedKeys(selectedRoleRecord.value?.permissions || [])
|
||||
await nextTick()
|
||||
syncingTree.value = false
|
||||
},
|
||||
@@ -56,36 +79,72 @@ export function useRolePermissionEditor() {
|
||||
)
|
||||
|
||||
function selectRole(row) {
|
||||
selectedRole.value = row.name
|
||||
selectedRoleId.value = row.id
|
||||
}
|
||||
function handleCheck(data, checked) {
|
||||
if (!syncingTree.value && data.permission && canEditSelectedRole.value) {
|
||||
updateRolePermission(selectedRole.value, data.permission, checked)
|
||||
|
||||
function readCheckedCodes() {
|
||||
const checked = treeRef.value?.getCheckedKeys() || []
|
||||
return checked.filter((key) => typeof key === 'string' && !key.startsWith('group-'))
|
||||
}
|
||||
|
||||
/** 勾选即保存:把整棵树的权限码提交给后端覆盖。 */
|
||||
async function handleCheck() {
|
||||
if (syncingTree.value || !canEditSelectedRole.value) return
|
||||
const role = selectedRoleRecord.value
|
||||
if (!role) return
|
||||
const codes = readCheckedCodes()
|
||||
await updateRolePermissionsApi(role.id, codes)
|
||||
role.permissions = codes
|
||||
ElMessage.success('权限已保存')
|
||||
}
|
||||
function addRole() {
|
||||
|
||||
async function addRole() {
|
||||
const name = newRoleName.value.trim()
|
||||
if (!name) return ElMessage.warning('请输入角色名称')
|
||||
if (roleData.some((role) => role.name === name)) return ElMessage.warning('角色名称已存在')
|
||||
roleData.push({
|
||||
id: Date.now(),
|
||||
if (roleData.value.some((role) => role.name === name)) {
|
||||
return ElMessage.warning('角色名称已存在')
|
||||
}
|
||||
await createRoleApi({
|
||||
name,
|
||||
description: '自定义权限角色',
|
||||
memberCount: 0,
|
||||
category: '自定义角色',
|
||||
categoryId: newRoleCategoryId.value || categories.value[0]?.id || 0,
|
||||
level: currentLevel.value + 1,
|
||||
status: '启用'
|
||||
description: '自定义角色',
|
||||
permissions: []
|
||||
})
|
||||
createRole(name)
|
||||
selectedRole.value = name
|
||||
newRoleName.value = ''
|
||||
newRoleCategoryId.value = undefined
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('角色已创建')
|
||||
await load()
|
||||
const created = roleData.value.find((role) => role.name === name)
|
||||
if (created) selectedRoleId.value = created.id
|
||||
}
|
||||
|
||||
async function removeRole() {
|
||||
const role = selectedRoleRecord.value
|
||||
if (!role) return ElMessage.warning('请先选择角色')
|
||||
if (role.builtin) return ElMessage.warning('内置角色不可删除')
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除角色「${role.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await deleteRoleApi(role.id)
|
||||
ElMessage.success('角色已删除')
|
||||
selectedRoleId.value = null
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
return {
|
||||
roleData,
|
||||
rolePermissions,
|
||||
categories,
|
||||
selectedRole,
|
||||
selectedRoleRecord,
|
||||
canEditSelectedRole,
|
||||
@@ -93,8 +152,10 @@ export function useRolePermissionEditor() {
|
||||
treeRef,
|
||||
dialogVisible,
|
||||
newRoleName,
|
||||
newRoleCategoryId,
|
||||
selectRole,
|
||||
handleCheck,
|
||||
addRole
|
||||
addRole,
|
||||
removeRole
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { getOperationLogApi, pageOperationLogsApi } from '@/api/admin/log'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
|
||||
const filters = reactive({
|
||||
username: '',
|
||||
module: '',
|
||||
status: '',
|
||||
range: null
|
||||
})
|
||||
const pageIndex = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
|
||||
const moduleOptions = [
|
||||
{ value: '认证', label: '认证' },
|
||||
{ value: '内容管理', label: '内容管理' },
|
||||
{ value: '后台管理', label: '后台管理' }
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageOperationLogsApi({
|
||||
pageIndex: pageIndex.value,
|
||||
pageSize: pageSize.value,
|
||||
username: filters.username.trim() || undefined,
|
||||
module: filters.module || undefined,
|
||||
status: filters.status || undefined,
|
||||
startTime: filters.range?.[0] || undefined,
|
||||
endTime: filters.range?.[1] || undefined
|
||||
})
|
||||
list.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
pageIndex.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.username = ''
|
||||
filters.module = ''
|
||||
filters.status = ''
|
||||
filters.range = null
|
||||
search()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
pageIndex.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function openDetail(row) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
const res = await getOperationLogApi(row.id)
|
||||
detail.value = res.data
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求/响应体按 JSON 缩进显示,解析失败就原样展示 */
|
||||
function pretty(text) {
|
||||
if (!text) return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeading
|
||||
eyebrow="后台管理"
|
||||
title="操作日志"
|
||||
description="记录后台的写操作,包含操作人、时间、请求与响应内容。"
|
||||
/>
|
||||
|
||||
<section class="panel">
|
||||
<div class="library-toolbar">
|
||||
<div class="drama-filters">
|
||||
<el-input
|
||||
v-model="filters.username"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
placeholder="操作人"
|
||||
@keyup.enter="search"
|
||||
@clear="search"
|
||||
/>
|
||||
<el-select v-model="filters.module" clearable placeholder="全部模块" @change="search">
|
||||
<el-option
|
||||
v-for="item in moduleOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部结果" @change="search">
|
||||
<el-option label="成功" value="SUCCESS" />
|
||||
<el-option label="失败" value="FAIL" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="filters.range"
|
||||
type="datetimerange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
@change="search"
|
||||
/>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
<span class="table-result-count">共 {{ total }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
row-key="id"
|
||||
empty-text="暂无操作记录"
|
||||
class="drama-el-table"
|
||||
>
|
||||
<el-table-column prop="createdAt" label="操作时间" width="170" />
|
||||
<el-table-column label="操作人" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.username || '(未登录)' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="module" label="模块" width="110" />
|
||||
<el-table-column prop="action" label="操作" min-width="150" />
|
||||
<el-table-column label="请求" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="episode-table-title">
|
||||
<b>{{ row.requestMethod }} {{ row.requestUrl }}</b>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'SUCCESS' ? 'success' : 'danger'" effect="light">
|
||||
{{ row.statusText }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="耗时" width="90">
|
||||
<template #default="{ row }">{{ row.durationMs }} ms</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip" label="IP" width="130" />
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pageIndex"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="操作详情" width="760px">
|
||||
<div v-loading="detailLoading" class="log-detail">
|
||||
<el-descriptions v-if="detail" :column="2" border>
|
||||
<el-descriptions-item label="操作人">{{
|
||||
detail.username || '(未登录)'
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作时间">{{ detail.createdAt }}</el-descriptions-item>
|
||||
<el-descriptions-item label="模块">{{ detail.module }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作">{{ detail.action }}</el-descriptions-item>
|
||||
<el-descriptions-item label="请求方式">{{ detail.requestMethod }}</el-descriptions-item>
|
||||
<el-descriptions-item label="接口地址">{{ detail.requestUrl }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结果">
|
||||
<el-tag :type="detail.status === 'SUCCESS' ? 'success' : 'danger'" effect="light">
|
||||
{{ detail.statusText }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="耗时">{{ detail.durationMs }} ms</el-descriptions-item>
|
||||
<el-descriptions-item label="IP">{{ detail.ip }}</el-descriptions-item>
|
||||
<el-descriptions-item label="查询参数">{{
|
||||
detail.requestParams || '—'
|
||||
}}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template v-if="detail">
|
||||
<div class="log-block">
|
||||
<h4>请求内容</h4>
|
||||
<pre class="log-json">{{ pretty(detail.requestBody) || '—' }}</pre>
|
||||
</div>
|
||||
<div class="log-block">
|
||||
<h4>响应内容</h4>
|
||||
<pre class="log-json">{{ pretty(detail.responseBody) || '—' }}</pre>
|
||||
</div>
|
||||
<div v-if="detail.errorMsg" class="log-block">
|
||||
<h4>错误信息</h4>
|
||||
<pre class="log-json">{{ detail.errorMsg }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -1,17 +1,68 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listConfigsApi, updateConfigsApi } from '@/api/admin/config'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
|
||||
const { can } = useRbac()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const workspaceName = ref('')
|
||||
const defaultStatus = ref('draft')
|
||||
|
||||
const statusOptions = [
|
||||
{ code: 'draft', text: '草稿' },
|
||||
{ code: 'reviewing', text: '待审核' }
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listConfigsApi()
|
||||
const map = Object.fromEntries((res.data || []).map((item) => [item.key, item.value]))
|
||||
workspaceName.value = map['workspace.name'] || ''
|
||||
defaultStatus.value = map['content.default-status'] || 'draft'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateConfigsApi({
|
||||
'workspace.name': workspaceName.value,
|
||||
'content.default-status': defaultStatus.value
|
||||
})
|
||||
ElMessage.success('设置已保存')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-heading"
|
||||
><div><div class="eyebrow">系统管理</div><h1>系统设置</h1><p>工作空间基础设置。</p></div></div
|
||||
>
|
||||
<section class="panel settings-panel"
|
||||
<section v-loading="loading" class="panel settings-panel"
|
||||
><h2>工作空间</h2
|
||||
><el-form label-position="top" class="settings-form"
|
||||
><el-form-item label="空间名称"><el-input model-value="ReelShort Studio" /></el-form-item
|
||||
><el-form-item label="空间名称"
|
||||
><el-input v-model="workspaceName" placeholder="请输入工作空间名称" /></el-form-item
|
||||
><el-form-item label="内容默认状态"
|
||||
><el-select model-value="草稿"
|
||||
><el-option label="草稿" value="草稿" /><el-option
|
||||
label="待审核"
|
||||
value="待审核" /></el-select></el-form-item
|
||||
><el-button type="primary" disabled>保存设置</el-button></el-form
|
||||
><el-select v-model="defaultStatus"
|
||||
><el-option
|
||||
v-for="item in statusOptions"
|
||||
:key="item.code"
|
||||
:label="item.text"
|
||||
:value="item.code" /></el-select></el-form-item
|
||||
><el-button type="primary" :loading="saving" :disabled="!can('system:role')" @click="save"
|
||||
>保存设置</el-button
|
||||
></el-form
|
||||
></section
|
||||
>
|
||||
</template>
|
||||
@@ -1,21 +1,33 @@
|
||||
<script setup>
|
||||
import { Plus, Search } from '@element-plus/icons-vue'
|
||||
import { roleData } from '@/data/roles'
|
||||
import { useAdminUsers } from '@/views/userManagement/composables/useAdminUsers'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
|
||||
const { can } = useRbac()
|
||||
|
||||
const {
|
||||
roles,
|
||||
keyword,
|
||||
roleFilter,
|
||||
currentPage,
|
||||
pageSize,
|
||||
loading,
|
||||
total,
|
||||
dialogVisible,
|
||||
saving,
|
||||
editingId,
|
||||
form,
|
||||
filtered,
|
||||
paginated,
|
||||
users,
|
||||
load,
|
||||
queryUsers,
|
||||
handleSizeChange,
|
||||
openCreate,
|
||||
openEdit,
|
||||
saveUser,
|
||||
toggleStatus,
|
||||
addUser
|
||||
removeUser,
|
||||
resetPassword
|
||||
} = useAdminUsers()
|
||||
</script>
|
||||
|
||||
@@ -32,27 +44,23 @@ const {
|
||||
v-model="keyword"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
placeholder="搜索用户ID或用户名"
|
||||
placeholder="搜索登录账号"
|
||||
@keyup.enter="queryUsers"
|
||||
@clear="queryUsers"
|
||||
/>
|
||||
<el-select v-model="roleFilter" clearable placeholder="搜索权限角色" @change="queryUsers">
|
||||
<el-option
|
||||
v-for="role in roleData"
|
||||
:key="role.name"
|
||||
:label="role.name"
|
||||
:value="role.name"
|
||||
/>
|
||||
<el-select v-model="roleFilter" clearable placeholder="全部角色" @change="queryUsers">
|
||||
<el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="queryUsers">查询</el-button>
|
||||
<el-button type="primary" @click="dialogVisible = true">
|
||||
<el-button v-if="can('system:admin-user')" type="primary" @click="openCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加用户
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="paginated"
|
||||
v-loading="loading"
|
||||
:data="users"
|
||||
row-key="id"
|
||||
border
|
||||
class="drama-el-table admin-users-table"
|
||||
@@ -65,25 +73,47 @@ const {
|
||||
:index="(currentPage - 1) * pageSize + 1"
|
||||
/>
|
||||
<el-table-column prop="id" label="ID" width="90" />
|
||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||
<el-table-column prop="name" label="姓名" min-width="130" />
|
||||
<el-table-column prop="roleId" label="权限ID" width="110" />
|
||||
<el-table-column prop="role" label="权限角色" min-width="150" />
|
||||
<el-table-column prop="ipWhitelist" label="IP白名单" min-width="140" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<el-table-column prop="username" label="登录账号" min-width="150" />
|
||||
<el-table-column prop="name" label="姓名" min-width="120" />
|
||||
<el-table-column prop="roleId" label="权限ID" width="100" />
|
||||
<el-table-column prop="role" label="权限角色" min-width="130" />
|
||||
<el-table-column prop="ipWhitelist" label="IP白名单" min-width="130" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="toggleStatus(row)">
|
||||
{{ row.status }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="can('system:admin-user')"
|
||||
link
|
||||
:type="row.status === 1 ? 'primary' : 'info'"
|
||||
@click="toggleStatus(row)"
|
||||
>{{ row.statusText }}</el-button
|
||||
>
|
||||
<el-tag v-else :type="row.status === 1 ? 'success' : 'info'" effect="light">{{
|
||||
row.statusText
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="2FA状态" width="120">
|
||||
<el-table-column label="2FA状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="info" effect="plain">{{ row.twoFactor }}</el-tag>
|
||||
<el-tag type="info" effect="plain">{{ row.twoFactorText }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170" />
|
||||
<el-table-column prop="lastLogin" label="最后登录" min-width="170" />
|
||||
<el-table-column label="操作" width="245" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" @click="resetPassword(row)">重置密码</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="row.username === 'admin'"
|
||||
@click="removeUser(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="185" />
|
||||
<el-table-column prop="lastLogin" label="最后登录" min-width="185" />
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
@@ -91,40 +121,47 @@ const {
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="filtered.length"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next, sizes, jumper"
|
||||
background
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="添加管理员" width="520px">
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑管理员' : '添加管理员'" width="520px">
|
||||
<el-form label-position="top">
|
||||
<div class="form-row">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="form.username" />
|
||||
<el-form-item label="登录账号" required>
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
:disabled="!!editingId"
|
||||
placeholder="例如 content.editor03"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" required>
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="!editingId" label="初始密码" required>
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="至少 6 位" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权限角色" required>
|
||||
<el-select v-model="form.role" placeholder="请选择角色">
|
||||
<el-option
|
||||
v-for="role in roleData"
|
||||
:key="role.name"
|
||||
:label="role.name"
|
||||
:value="role.name"
|
||||
/>
|
||||
<el-select v-model="form.roleId" placeholder="请选择角色">
|
||||
<el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="IP白名单">
|
||||
<el-input v-model="form.ipWhitelist" placeholder="选填,多个 IP 用逗号分隔" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="addUser">确认添加</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveUser">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,355 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
import {
|
||||
APP_USER_STATUS,
|
||||
COUNTRY_OPTIONS,
|
||||
formatDuration,
|
||||
statusMeta,
|
||||
useAppUsers
|
||||
} from './composables/useAppUsers'
|
||||
|
||||
const { can } = useRbac()
|
||||
const canUpdate = can('app-user:update')
|
||||
|
||||
const {
|
||||
users,
|
||||
total,
|
||||
loading,
|
||||
keyword,
|
||||
statusFilter,
|
||||
countryFilter,
|
||||
currentPage,
|
||||
pageSize,
|
||||
detailVisible,
|
||||
detailLoading,
|
||||
detail,
|
||||
banRecords,
|
||||
watchHistory,
|
||||
statusDialogVisible,
|
||||
statusSaving,
|
||||
statusTarget,
|
||||
statusForm,
|
||||
load,
|
||||
queryUsers,
|
||||
resetFilters,
|
||||
handleSizeChange,
|
||||
openDetail,
|
||||
openStatusDialog,
|
||||
confirmStatus,
|
||||
restoreUser
|
||||
} = useAppUsers()
|
||||
|
||||
const SOURCE_TEXT = {
|
||||
HOME: '首页',
|
||||
DETAIL: '详情页',
|
||||
SEARCH: '搜索',
|
||||
FAVORITE: '收藏'
|
||||
}
|
||||
const BAN_TYPE_TEXT = { BAN: '封禁', LIMIT: '限制', MUTE: '禁言' }
|
||||
const APPEAL_TEXT = {
|
||||
none: '未申诉',
|
||||
pending: '待处理',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回'
|
||||
}
|
||||
|
||||
const statusTargetLabel = computed(() => {
|
||||
const row = statusTarget.value
|
||||
if (!row) return ''
|
||||
return `${row.nickname || '未设置昵称'}(${row.uid})`
|
||||
})
|
||||
|
||||
/** 没有头像时用昵称首字母占位,避免空白的灰色方框 */
|
||||
function avatarText(row) {
|
||||
const name = (row.nickname || row.uid || '?').trim()
|
||||
return name.slice(0, 1).toUpperCase()
|
||||
}
|
||||
|
||||
function canBeBanned(row) {
|
||||
return row.status !== 'banned' && row.status !== 'deactivated'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeading
|
||||
eyebrow="用户管理"
|
||||
title="App 用户"
|
||||
description="查询 C 端用户、处置违规账号,并核对观看记录。"
|
||||
/>
|
||||
|
||||
<section class="panel app-user-panel">
|
||||
<div class="app-user-toolbar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
class="app-user-keyword"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
placeholder="搜索 uid / 昵称 / 手机号 / 邮箱"
|
||||
@keyup.enter="queryUsers"
|
||||
@clear="queryUsers"
|
||||
/>
|
||||
<el-select
|
||||
v-model="statusFilter"
|
||||
class="app-user-status"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
@change="queryUsers"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in APP_USER_STATUS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="countryFilter"
|
||||
class="app-user-country"
|
||||
clearable
|
||||
placeholder="全部国家"
|
||||
@change="queryUsers"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in COUNTRY_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="queryUsers">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<span class="app-user-count">共 {{ total }} 位用户</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="users"
|
||||
row-key="id"
|
||||
border
|
||||
class="drama-el-table"
|
||||
empty-text="没有符合条件的用户"
|
||||
>
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="70"
|
||||
:index="(currentPage - 1) * pageSize + 1"
|
||||
/>
|
||||
<el-table-column label="用户" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="app-user-cell">
|
||||
<span class="app-user-avatar">
|
||||
<img v-if="row.avatarUrl" :src="row.avatarUrl" alt="" />
|
||||
<template v-else>{{ avatarText(row) }}</template>
|
||||
</span>
|
||||
<div class="app-user-meta">
|
||||
<span class="app-user-name" :title="row.nickname">{{
|
||||
row.nickname || '未设置昵称'
|
||||
}}</span>
|
||||
<span class="app-user-uid" :title="row.uid">{{ row.uid }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="手机 / 邮箱" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="app-user-mono">{{ row.maskedTarget || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="国家" width="90">
|
||||
<template #default="{ row }">{{ row.countryCode || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusMeta(row.status).tag" effect="light">
|
||||
{{ statusMeta(row.status).label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="注册时间" min-width="165" show-overflow-tooltip />
|
||||
<el-table-column label="最后登录" min-width="165" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.lastLoginAt || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button type="primary" size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button
|
||||
v-if="canUpdate && canBeBanned(row)"
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="openStatusDialog(row, 'banned')"
|
||||
>封禁</el-button
|
||||
>
|
||||
<el-button
|
||||
v-else-if="canUpdate && row.status === 'banned'"
|
||||
size="small"
|
||||
@click="restoreUser(row)"
|
||||
>解封</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next, sizes, jumper"
|
||||
background
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="detailVisible" class="app-user-drawer" title="用户详情" size="520px">
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
<div class="app-user-summary">
|
||||
<span class="app-user-avatar">
|
||||
<img v-if="detail.avatarUrl" :src="detail.avatarUrl" alt="" />
|
||||
<template v-else>{{ avatarText(detail) }}</template>
|
||||
</span>
|
||||
<div class="app-user-meta">
|
||||
<span class="app-user-name">{{ detail.nickname || '未设置昵称' }}</span>
|
||||
<span class="app-user-uid">{{ detail.uid }}</span>
|
||||
</div>
|
||||
<el-tag :type="statusMeta(detail.status).tag" effect="light" style="margin-left: auto">
|
||||
{{ statusMeta(detail.status).label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="app-user-section">
|
||||
<el-descriptions :column="2" size="small" border>
|
||||
<el-descriptions-item label="手机 / 邮箱">
|
||||
{{ detail.maskedTarget || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="国家 / 地区">
|
||||
{{ detail.countryCode || '—' }} / {{ detail.regionCode || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="语言">{{ detail.language || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="时区">{{ detail.timezone || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{
|
||||
detail.createdAt || '—'
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后登录">{{
|
||||
detail.lastLoginAt || '—'
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="会员">
|
||||
{{ detail.vipLevel ? `等级 ${detail.vipLevel}` : '未开通(第二阶段)' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="币种">{{ detail.currency || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="app-user-section">
|
||||
<h3 class="app-user-section-title">观看流水(最近 {{ watchHistory.length }} 条)</h3>
|
||||
<el-table :data="watchHistory" size="small" border empty-text="还没有观看记录">
|
||||
<el-table-column label="短剧 / 集" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span :title="row.dramaTitle">
|
||||
{{ row.dramaTitle || `短剧 #${row.dramaId}` }}
|
||||
<template v-if="row.episodeNo"> · 第 {{ row.episodeNo }} 集</template>
|
||||
</span>
|
||||
<el-tag v-if="row.dramaPublished === false" type="info" size="small" effect="plain"
|
||||
>已下线</el-tag
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="进度" width="96">
|
||||
<template #default="{ row }">
|
||||
<span class="app-user-mono">
|
||||
{{ formatDuration(row.positionMs) }} / {{ formatDuration(row.durationMs) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="完成" width="70">
|
||||
<template #default="{ row }">{{ row.finished ? '是' : '否' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="80">
|
||||
<template #default="{ row }">{{
|
||||
SOURCE_TEXT[row.source] || row.source || '—'
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="watchedAt" label="时间" width="160" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="app-user-section">
|
||||
<h3 class="app-user-section-title">处置记录</h3>
|
||||
<el-table :data="banRecords" size="small" border empty-text="没有处置记录">
|
||||
<el-table-column label="类型" width="76">
|
||||
<template #default="{ row }">{{ BAN_TYPE_TEXT[row.type] || row.type }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原因" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.reason || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startAt" label="时间" width="160" show-overflow-tooltip />
|
||||
<el-table-column label="申诉" width="84">
|
||||
<template #default="{ row }">{{
|
||||
APPEAL_TEXT[row.appealStatus] || row.appealStatus
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
<el-button
|
||||
v-if="canUpdate && detail && canBeBanned(detail)"
|
||||
type="danger"
|
||||
@click="openStatusDialog(detail, 'banned')"
|
||||
>封禁该用户</el-button
|
||||
>
|
||||
<el-button
|
||||
v-else-if="canUpdate && detail && detail.status === 'banned'"
|
||||
type="primary"
|
||||
@click="restoreUser(detail)"
|
||||
>解封</el-button
|
||||
>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="statusDialogVisible" title="处置账号" width="460px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="目标账号">
|
||||
<el-input :model-value="statusTargetLabel" disabled placeholder="—" />
|
||||
</el-form-item>
|
||||
<el-form-item label="处置方式">
|
||||
<el-radio-group v-model="statusForm.status">
|
||||
<el-radio value="banned">封禁(无法登录)</el-radio>
|
||||
<el-radio value="limited">限制(可登录)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="原因(可选,会记入处置记录)">
|
||||
<el-input
|
||||
v-model="statusForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="例如:发布违规评论"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="处置后该用户所有设备的登录会话会立即失效"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="statusDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="statusSaving" @click="confirmStatus">确认处置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -1,71 +1,202 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { roleData } from '../../../data/roles'
|
||||
import { demoAdminUsers } from '../data/adminUsers'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
createAdminUserApi,
|
||||
deleteAdminUserApi,
|
||||
pageAdminUsersApi,
|
||||
resetAdminUserPasswordApi,
|
||||
updateAdminUserApi,
|
||||
updateAdminUserStatusApi
|
||||
} from '@/api/admin/user'
|
||||
import { listRolesApi } from '@/api/admin/role'
|
||||
|
||||
export function useAdminUsers() {
|
||||
const roles = ref([])
|
||||
const users = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const keyword = ref('')
|
||||
const roleFilter = ref('')
|
||||
const roleFilter = ref(undefined)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const form = reactive({ username: '', name: '', role: '', ipWhitelist: '' })
|
||||
const users = ref(demoAdminUsers.map((user) => ({ ...user })))
|
||||
const filtered = computed(() =>
|
||||
users.value.filter((user) => {
|
||||
const query = keyword.value.trim().toLowerCase()
|
||||
const matchesKeyword =
|
||||
!query || `${user.id} ${user.username} ${user.name}`.toLowerCase().includes(query)
|
||||
return matchesKeyword && (!roleFilter.value || user.role === roleFilter.value)
|
||||
const saving = ref(false)
|
||||
const editingId = ref(null)
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
name: '',
|
||||
roleId: undefined,
|
||||
ipWhitelist: '',
|
||||
status: 1
|
||||
})
|
||||
)
|
||||
const paginated = computed(() =>
|
||||
filtered.value.slice(
|
||||
(currentPage.value - 1) * pageSize.value,
|
||||
currentPage.value * pageSize.value
|
||||
)
|
||||
)
|
||||
|
||||
async function loadRoles() {
|
||||
const res = await listRolesApi()
|
||||
roles.value = res.data || []
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageAdminUsersApi({
|
||||
pageIndex: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
username: keyword.value.trim() || undefined,
|
||||
roleId: roleFilter.value
|
||||
})
|
||||
users.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function queryUsers() {
|
||||
currentPage.value = 1
|
||||
}
|
||||
function toggleStatus(user) {
|
||||
user.status = user.status === '启用' ? '停用' : '启用'
|
||||
}
|
||||
function addUser() {
|
||||
if (!form.username.trim() || !form.name.trim() || !form.role)
|
||||
return ElMessage.warning('请填写用户名、姓名并选择权限角色')
|
||||
const role = roleData.find((item) => item.name === form.role)
|
||||
users.value.unshift({
|
||||
id: Math.max(0, ...users.value.map((user) => user.id)) + 1,
|
||||
username: form.username.trim(),
|
||||
name: form.name.trim(),
|
||||
roleId: role?.id || 0,
|
||||
role: form.role,
|
||||
ipWhitelist: form.ipWhitelist.trim() || '不限',
|
||||
status: '启用',
|
||||
twoFactor: '未开启',
|
||||
createdAt: new Date().toLocaleString('sv-SE'),
|
||||
lastLogin: '—'
|
||||
})
|
||||
Object.assign(form, { username: '', name: '', role: '', ipWhitelist: '' })
|
||||
dialogVisible.value = false
|
||||
currentPage.value = 1
|
||||
ElMessage.success('管理员已添加')
|
||||
load()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
Object.assign(form, {
|
||||
username: '',
|
||||
password: '',
|
||||
name: '',
|
||||
roleId: undefined,
|
||||
ipWhitelist: '',
|
||||
status: 1
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
username: row.username,
|
||||
password: '',
|
||||
name: row.name,
|
||||
roleId: row.roleId || undefined,
|
||||
ipWhitelist: row.ipWhitelist === '不限' ? '' : row.ipWhitelist,
|
||||
status: row.status
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
if (!form.name.trim()) return ElMessage.warning('请填写姓名')
|
||||
if (!form.roleId) return ElMessage.warning('请选择权限角色')
|
||||
if (!editingId.value) {
|
||||
if (!form.username.trim()) return ElMessage.warning('请填写登录账号')
|
||||
if (!form.password || form.password.length < 6) {
|
||||
return ElMessage.warning('初始密码不能少于 6 位')
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
roleId: form.roleId,
|
||||
ipWhitelist: form.ipWhitelist.trim(),
|
||||
status: form.status
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateAdminUserApi(editingId.value, payload)
|
||||
ElMessage.success('管理员已更新')
|
||||
} else {
|
||||
await createAdminUserApi({
|
||||
...payload,
|
||||
username: form.username.trim(),
|
||||
password: form.password
|
||||
})
|
||||
ElMessage.success('管理员已添加')
|
||||
currentPage.value = 1
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row) {
|
||||
const next = row.status === 1 ? 0 : 1
|
||||
await updateAdminUserStatusApi(row.id, next)
|
||||
ElMessage.success(next === 1 ? '账号已启用' : '账号已停用')
|
||||
await load()
|
||||
}
|
||||
|
||||
async function removeUser(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除管理员「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await deleteAdminUserApi(row.id)
|
||||
ElMessage.success('管理员已删除')
|
||||
if (users.value.length === 1 && currentPage.value > 1) currentPage.value -= 1
|
||||
await load()
|
||||
}
|
||||
|
||||
async function resetPassword(row) {
|
||||
let password = ''
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
`为「${row.username}」设置新密码(至少 6 位)`,
|
||||
'重置密码',
|
||||
{
|
||||
confirmButtonText: '确认重置',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '请输入新密码',
|
||||
inputValidator: (val) => (val && val.length >= 6 ? true : '密码不能少于 6 位')
|
||||
}
|
||||
)
|
||||
password = value
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await resetAdminUserPasswordApi(row.id, password)
|
||||
ElMessage.success('密码已重置')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRoles()
|
||||
await load()
|
||||
})
|
||||
|
||||
return {
|
||||
roles,
|
||||
keyword,
|
||||
roleFilter,
|
||||
currentPage,
|
||||
pageSize,
|
||||
loading,
|
||||
total,
|
||||
dialogVisible,
|
||||
saving,
|
||||
editingId,
|
||||
form,
|
||||
users,
|
||||
filtered,
|
||||
paginated,
|
||||
load,
|
||||
queryUsers,
|
||||
handleSizeChange,
|
||||
openCreate,
|
||||
openEdit,
|
||||
saveUser,
|
||||
toggleStatus,
|
||||
addUser
|
||||
removeUser,
|
||||
resetPassword
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getAppUserApi,
|
||||
listAppUserBanRecordsApi,
|
||||
listAppUserWatchHistoryApi,
|
||||
pageAppUsersApi,
|
||||
updateAppUserStatusApi
|
||||
} from '@/api/admin/appUser'
|
||||
|
||||
/** 账号状态与展示文案,与后端 AppUserStatus 保持一致 */
|
||||
export const APP_USER_STATUS = [
|
||||
{ value: 'normal', label: '正常', tag: 'success' },
|
||||
{ value: 'limited', label: '受限', tag: 'warning' },
|
||||
{ value: 'banned', label: '封禁', tag: 'danger' },
|
||||
{ value: 'deactivated', label: '已注销', tag: 'info' }
|
||||
]
|
||||
|
||||
export const COUNTRY_OPTIONS = [
|
||||
{ value: 'ID', label: '印度尼西亚' },
|
||||
{ value: 'SG', label: '新加坡' },
|
||||
{ value: 'MY', label: '马来西亚' },
|
||||
{ value: 'PH', label: '菲律宾' },
|
||||
{ value: 'TH', label: '泰国' },
|
||||
{ value: 'VN', label: '越南' },
|
||||
{ value: 'CN', label: '中国' }
|
||||
]
|
||||
|
||||
export function statusMeta(status) {
|
||||
return (
|
||||
APP_USER_STATUS.find((item) => item.value === status) || { label: status || '-', tag: 'info' }
|
||||
)
|
||||
}
|
||||
|
||||
/** 毫秒转 mm:ss,观看流水里的进度用 */
|
||||
export function formatDuration(ms) {
|
||||
const total = Math.max(0, Math.floor((ms || 0) / 1000))
|
||||
const minute = String(Math.floor(total / 60)).padStart(2, '0')
|
||||
const second = String(total % 60).padStart(2, '0')
|
||||
return `${minute}:${second}`
|
||||
}
|
||||
|
||||
export function useAppUsers() {
|
||||
const users = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref(undefined)
|
||||
const countryFilter = ref(undefined)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const banRecords = ref([])
|
||||
const watchHistory = ref([])
|
||||
|
||||
const statusDialogVisible = ref(false)
|
||||
const statusSaving = ref(false)
|
||||
const statusTarget = ref(null)
|
||||
const statusForm = reactive({ status: 'banned', reason: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageAppUsersApi({
|
||||
pageIndex: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
status: statusFilter.value,
|
||||
countryCode: countryFilter.value
|
||||
})
|
||||
users.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function queryUsers() {
|
||||
currentPage.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
keyword.value = ''
|
||||
statusFilter.value = undefined
|
||||
countryFilter.value = undefined
|
||||
queryUsers()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
currentPage.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
const detailTabs = reactive({ active: 'watch' })
|
||||
|
||||
async function openDetail(row) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = row
|
||||
banRecords.value = []
|
||||
watchHistory.value = []
|
||||
detailTabs.active = 'watch'
|
||||
try {
|
||||
// 三个请求互不依赖,并发拉取,抽屉打开更快
|
||||
const [profile, bans, history] = await Promise.all([
|
||||
getAppUserApi(row.id),
|
||||
listAppUserBanRecordsApi(row.id),
|
||||
listAppUserWatchHistoryApi(row.id, 50)
|
||||
])
|
||||
detail.value = profile.data || row
|
||||
banRecords.value = bans.data || []
|
||||
watchHistory.value = history.data || []
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openStatusDialog(row, preset = 'banned') {
|
||||
statusTarget.value = row
|
||||
statusForm.status = preset
|
||||
statusForm.reason = ''
|
||||
statusDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function confirmStatus() {
|
||||
const row = statusTarget.value
|
||||
if (!row) return
|
||||
const label = statusMeta(statusForm.status).label
|
||||
statusSaving.value = true
|
||||
try {
|
||||
await updateAppUserStatusApi(row.id, statusForm.status, statusForm.reason.trim() || undefined)
|
||||
ElMessage.success(`已${label},该用户全部登录会话已失效`)
|
||||
statusDialogVisible.value = false
|
||||
await load()
|
||||
if (detailVisible.value && detail.value?.id === row.id) {
|
||||
detail.value = { ...detail.value, status: statusForm.status }
|
||||
banRecords.value = (await listAppUserBanRecordsApi(row.id)).data || []
|
||||
}
|
||||
} finally {
|
||||
statusSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 恢复正常:不需要填原因,但仍要二次确认,避免误点 */
|
||||
async function restoreUser(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认把「${row.nickname || row.uid}」恢复为正常账号?`,
|
||||
'恢复正常',
|
||||
{ confirmButtonText: '确认恢复', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await updateAppUserStatusApi(row.id, 'normal')
|
||||
ElMessage.success('账号已恢复正常')
|
||||
await load()
|
||||
if (detailVisible.value && detail.value?.id === row.id) {
|
||||
detail.value = { ...detail.value, status: 'normal' }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
return {
|
||||
users,
|
||||
total,
|
||||
loading,
|
||||
keyword,
|
||||
statusFilter,
|
||||
countryFilter,
|
||||
currentPage,
|
||||
pageSize,
|
||||
detailVisible,
|
||||
detailLoading,
|
||||
detail,
|
||||
banRecords,
|
||||
watchHistory,
|
||||
detailTabs,
|
||||
statusDialogVisible,
|
||||
statusSaving,
|
||||
statusTarget,
|
||||
statusForm,
|
||||
load,
|
||||
queryUsers,
|
||||
resetFilters,
|
||||
handleSizeChange,
|
||||
openDetail,
|
||||
openStatusDialog,
|
||||
confirmStatus,
|
||||
restoreUser
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
export const demoAdminUsers = [
|
||||
{
|
||||
id: 1001,
|
||||
username: 'lin.xiaoman',
|
||||
name: '林小满',
|
||||
roleId: 1,
|
||||
role: '超级管理员',
|
||||
ipWhitelist: '不限',
|
||||
status: '启用',
|
||||
twoFactor: '已开启',
|
||||
createdAt: '2026-09-18 09:30:00',
|
||||
lastLogin: '2026-10-12 09:41:22'
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
username: 'content.editor01',
|
||||
name: '陈一诺',
|
||||
roleId: 2,
|
||||
role: '内容运营',
|
||||
ipWhitelist: '不限',
|
||||
status: '启用',
|
||||
twoFactor: '未开启',
|
||||
createdAt: '2026-09-21 10:15:00',
|
||||
lastLogin: '2026-10-11 16:20:15'
|
||||
},
|
||||
{
|
||||
id: 1003,
|
||||
username: 'content.editor02',
|
||||
name: '周子瑜',
|
||||
roleId: 2,
|
||||
role: '内容运营',
|
||||
ipWhitelist: '不限',
|
||||
status: '启用',
|
||||
twoFactor: '未开启',
|
||||
createdAt: '2026-09-25 14:20:00',
|
||||
lastLogin: '2026-10-11 13:34:10'
|
||||
},
|
||||
{
|
||||
id: 1004,
|
||||
username: 'content.reviewer01',
|
||||
name: '林晓彤',
|
||||
roleId: 3,
|
||||
role: '审核专员',
|
||||
ipWhitelist: '不限',
|
||||
status: '启用',
|
||||
twoFactor: '已开启',
|
||||
createdAt: '2026-10-02 11:05:00',
|
||||
lastLogin: '2026-10-10 11:20:08'
|
||||
},
|
||||
{
|
||||
id: 1005,
|
||||
username: 'data.analyst01',
|
||||
name: '程予安',
|
||||
roleId: 4,
|
||||
role: '数据分析师',
|
||||
ipWhitelist: '不限',
|
||||
status: '启用',
|
||||
twoFactor: '未开启',
|
||||
createdAt: '2026-10-05 13:02:00',
|
||||
lastLogin: '2026-10-09 14:10:02'
|
||||
}
|
||||
]
|
||||
@@ -138,6 +138,16 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/api/, '')
|
||||
},
|
||||
// 短剧服务管理端接口
|
||||
'/admin': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true
|
||||
},
|
||||
// 本地媒资(封面、视频)
|
||||
'/files': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
},
|
||||
hmr: {
|
||||
|
||||
Reference in new issue
Block a user