2 Commits
Author SHA1 Message Date
Vicky f037d243cd feat(web): 08 后台四个页面落地(内容治理 / 订单与权益 / 创作者审核 / 数据看板)
为什么:08 运营后台的接口在前几轮已经实现,但运营在界面上点不到——
评论审核、举报工单、敏感词维护、订单补发/退款、权益回收、金币补发、对账、
创作者入驻与内容提交审核、运营大盘,都只能靠 curl。本轮把这四个页面补齐,
08 才算真正闭环。

- 内容治理:评论审核(通过/驳回/删除)、举报工单处理(可联动封禁)、敏感词库增删改查
- 订单与权益:订单检索与流水、补发、退款、权益检索/人工补发/回收、金币流水与人工补币、对账运行与历史
- 创作者审核:入驻审核(通过/驳回原因码/冻结)+ 资质抽屉;内容提交审核(通过/驳回)
- 数据看板:overview 五组指标 + transcode 队列与失败任务,只读;取不到的指标显示「暂无数据源」而不是 0
- 新增 console.css(工具条/抽屉/看板卡片,含深色模式),四个页面共用;时间列统一 yyyy-MM-dd HH:mm:ss
- 按钮按权限点显隐(没有 *:update / *:audit 的角色看到的是只读态),表格列与后端字段一一对应
2026-10-15 13:28:46 +08:00
Vicky 44c2c46a68 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 用户管理、后台布局与公共样式
2026-10-15 13:28:01 +08:00
72 changed files with 6762 additions and 982 deletions

No files matched your search

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