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