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:
Vicky committed 2026-10-15 13:28:01 +08:00
1 parent 40546bfd01
commit 44c2c46a68
59 files changed
+4081 -981

No files matched your search

+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+30 -17
View File
@@ -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
View File
@@ -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": {
+39
View File
@@ -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 }
})
}
+37
View File
@@ -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 } })
}
+10
View File
@@ -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 })
}
+70
View File
@@ -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}` })
}
+22
View File
@@ -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 } })
}
+21
View File
@@ -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}` })
}
+41
View File
@@ -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}` })
}
+85
View File
@@ -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}` })
}
+353
View File
@@ -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
}
+34
View File
@@ -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 } })
}
+8 -3
View File
@@ -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[]>> => {
+6
View File
@@ -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
View File
@@ -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
}
})
-23
View File
@@ -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))
}
-59
View File
@@ -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: ''
}
])
-40
View File
@@ -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: '—'
}
])
-40
View File
@@ -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
View File
@@ -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')
}
-24
View File
@@ -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],
内容运营: [
'查看短剧列表',
'创建短剧',
'编辑短剧信息',
'删除短剧',
'查看剧集列表',
'上传剧集视频',
'管理剧集排序',
'上传封面素材'
],
审核专员: ['查看短剧列表', '查看剧集列表', '审核并发布', '查看数据报表'],
数据分析师: ['查看短剧列表', '查看数据报表']
}
+45 -34
View File
@@ -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,42 +191,19 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
meta: {
title: '系统设置'
}
},
{
path: 'operation-logs',
name: 'operation-logs',
component: () => import('@/views/systemManagement/OperationLogView.vue'),
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'),
meta: {
title: menu.title
}
}
]
})
}
const router = createRouter({
history: createWebHashHistory(),
strict: true,
@@ -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) => {
+13 -1
View File
@@ -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 = () => {
-24
View File
@@ -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
View File
@@ -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 }
}
+135
View File
@@ -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;
}
+49
View File
@@ -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;
}
+112 -14
View File
@@ -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);
+36
View File
@@ -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;
}
}
+32
View File
@@ -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;
}
+23
View File
@@ -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)
}
+6
View File
@@ -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'
}
+64
View File
@@ -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
})
}
+1 -1
View File
@@ -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>
+3
View File
@@ -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()
+53 -16
View File
@@ -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
}
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 || ''
}
Object.assign(form, { title: '', genre: '', description: '', episodes: 1, cover: '' })
dialogVisible.value = false
ElMessage.success(editingId.value ? '短剧信息已更新' : '短剧草稿已保存')
editingId.value = null
}
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-upload
action="#"
:auto-upload="false"
:show-file-list="false"
accept="image/*"
:on-change="uploadCover"
><el-button size="small">{{
form.cover ? '更换封面' : '选择封面'
}}</el-button></el-upload
></el-form-item
></div
><img v-if="form.cover" :src="form.cover" class="form-cover-preview" /></el-form
><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
v-if="can('cover:upload')"
:show-file-list="false"
accept="image/*"
:http-request="uploadCover"
:disabled="uploadingCover"
><el-button :loading="uploadingCover">{{
form.cover ? '更换封面' : '选择封面'
}}</el-button></el-upload
><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
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'
})
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
}
}
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
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'
})
selectedDrama.value.episodes += 1
form.value = { title: '', fileName: '' }
dialogVisible.value = false
ElMessage.success('剧集已加入本地上传队列')
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-upload
drag
action="#"
:auto-upload="false"
accept="video/mp4,video/quicktime"
:on-change="pickVideo"
><div class="upload-copy"
>拖放视频文件到此处,或点击选择<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
><el-form-item label="视频文件"
><div class="video-field"
><div class="video-field-actions"
><el-upload
drag
:show-file-list="false"
accept="video/*"
:http-request="uploadVideo"
:disabled="uploadingVideo"
><div class="upload-copy"
>拖放视频文件到此处,或点击选择<br /><small>支持 MP4、MOV,自动分片上传</small></div
></el-upload
><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>
+4 -5
View File
@@ -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>
+50 -53
View File
@@ -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="维护系统菜单结构、路由和按钮权限。"
/>
<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 class="panel menu-tree-panel">
<section v-loading="loading" class="panel menu-tree-panel">
<div class="panel-heading">
<div><h2>系统菜单</h2></div>
<el-button type="primary" @click="resetForm(selectedParent)">
<el-icon><Plus /></el-icon>
新增菜单
</el-button>
</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>
+9 -3
View File
@@ -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>
+34 -2
View File
@@ -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 = ''
const form = reactive({
id: null,
parentId: 0,
title: '',
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}`
})
}
if (node.children?.length) walk(node.children, depth + 1)
})
}
walk(menuTree.value, 0)
return options
})
async function load() {
loading.value = true
try {
const res = await getMenuTreeApi()
menuTree.value = res.data || []
} finally {
loading.value = false
}
}
function resetForm(parentId = 0) {
Object.assign(form, {
key: '',
id: null,
parentId,
title: '',
route: '',
permission: '',
parent,
type: '页面',
icon: 'Document',
method: '',
sort: 1
routePath: '',
routeName: '',
component: '',
icon: '',
permissionCode: '',
type: 'MENU',
sort: 1,
visible: 1,
status: 1
})
}
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
})
} else if (node.children) {
selectedParent.value = node.label
resetForm(node.label)
}
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
})
}
function saveMenu() {
async 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
})
} else {
customMenus.push({
...form,
key,
title: form.title.trim(),
permission,
route: form.route.trim(),
sort: Number(form.sort) || 1
})
createPermission(permission)
if (form.type !== 'BUTTON' && !form.routePath.trim()) {
return ElMessage.warning('请填写对应路由')
}
const savedMenu = customMenus.find((menu) => menu.key === key)
if (savedMenu?.type !== '按钮') registerCustomMenu(savedMenu)
saveCustomMenus()
selectedKey.value = key
ElMessage.success(isNew ? '菜单已添加' : '菜单已保存')
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() {
const menu = customMenus.find((item) => item.key === selectedKey.value)
if (!menu) return ElMessage.warning('请先在左侧选择一个自定义菜单')
if (!form.id) return ElMessage.warning('请先在左侧选择一个菜单')
try {
await ElMessageBox.confirm(`确认删除「${menu.title}」菜单吗?`, '删除确认', {
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-'))
}
function addRole() {
/** 勾选即保存:把整棵树的权限码提交给后端覆盖。 */
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('权限已保存')
}
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>
+58 -7
View File
@@ -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>
+76 -39
View File
@@ -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>
+355
View File
@@ -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 paginated = computed(() =>
filtered.value.slice(
(currentPage.value - 1) * pageSize.value,
currentPage.value * pageSize.value
)
)
const saving = ref(false)
const editingId = ref(null)
const form = reactive({
username: '',
password: '',
name: '',
roleId: undefined,
ipWhitelist: '',
status: 1
})
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'
}
]
+10
View File
@@ -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: {