Author SHA1 Message Date
Zhou Weidong f174ba46ae feat(admin): 批量上传改为手动点击开始 2026-10-22 09:31:35 +08:00
Zhou Weidong 2e89f8460c chore(merge): 剧集列表分页条始终显示 2026-10-22 09:23:10 +08:00
Zhou Weidong 632bf13990 fix(admin): 剧集列表分页条始终显示 2026-10-22 09:23:04 +08:00
Zhou Weidong becbe18d76 chore(merge): 剧集列表改用服务端分页修复列表为空 2026-10-22 09:19:33 +08:00
Zhou Weidong 6bcabea261 fix(admin): 剧集列表改用服务端分页修复列表为空 2026-10-22 09:18:59 +08:00
Vicky 99bb666e47 chore(merge): 剧集列表改为服务端分页 2026-10-21 23:20:56 +08:00
Zhou Weidong 092fe18ae7 feat(admin): 剧集列表改为服务端分页 2026-10-21 23:20:38 +08:00
Zhou Weidong c2a76a593a chore(merge): 剧集列表分页默认每页100条 2026-10-21 23:00:13 +08:00
Zhou Weidong a28eaa4514 feat(admin): 剧集列表分页默认每页100条 2026-10-21 23:00:08 +08:00
Zhou Weidong 189a7f689a chore(merge): 批量上传创建的分集默认已上线 2026-10-21 22:53:22 +08:00
Zhou Weidong 93e1c1b8c9 feat(admin): 批量上传创建的分集默认已上线 2026-10-21 22:53:07 +08:00
Zhou Weidong 05b977cc33 fix(admin): 批量建分集是否免费改由短剧免费集数决定 2026-10-21 22:25:50 +08:00
Zhou Weidong 5741ebbf17 fix(admin): 简化批量建分集表单并将移除操作置右 2026-10-21 22:20:10 +08:00
Zhou Weidong 836600b811 chore(build): commit-msg 拦截 AI 署名 2026-10-21 22:10:35 +08:00
Zhou Weidong 4841b0bc24 fix(admin): 移除上传提示中的并发表述
上传为异步队列,文件数量不设上限,提示只保留格式与单文件大小限制
2026-10-21 22:08:47 +08:00
Vicky e1e0055915 chore(admin): 合并剧集上传文案统一与组件拆分 2026-10-21 22:07:07 +08:00
Zhou WeidongandCursor cff864d582 refactor(admin): 拆分剧集管理页超长组件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 22:07:04 +08:00
Zhou WeidongandCursor c71a12f6f4 fix(admin): 统一剧集上传格式与提示文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 22:04:36 +08:00
Zhou WeidongandCursor 7d39e3c684 chore(admin): 合并剧集多选分片上传与断点续传
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:58:40 +08:00
Zhou WeidongandCursor 6b3620c3c4 feat(admin): 剧集多选分片上传与断点续传
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:58:28 +08:00
Zhou Weidong 8e1a02ace0 chore(web): 忽略本机启动脚本与运行日志 2026-10-21 18:55:24 +08:00
Zhou Weidong 982346045d Merge branch 'develop' of http://git.reelshort2.com/jianghaifeng/ReelShortWeb into develop 2026-10-21 18:52:34 +08:00
Zhou Weidong 48b09ee8cf feat(admin): 短剧编辑支持排序权重 2026-10-21 18:52:15 +08:00
Zhou Weidong ceb7905697 Merge branch 'develop' of http://git.reelshort2.com/jianghaifeng/ReelShortWeb into develop 2026-10-21 18:10:21 +08:00
Zhou Weidong 979a502373 merge: 合并 K93.1 多选完整展示 2026-10-21 17:16:54 +08:00
Zhou Weidong 3e779e4efd fix(admin): 多选类型与内容类型完整展示 2026-10-21 17:16:40 +08:00
Zhou Weidong 481c231dd9 merge: 合并 K93 短剧多类型管理 2026-10-21 17:01:37 +08:00
Zhou Weidong 9e427d485f feat(admin): 短剧类型与内容类型多选 2026-10-21 17:01:22 +08:00
Zhou Weidong 58380170b0 merge: 合并 K92 举报列表短剧与分集展示 2026-10-21 16:45:08 +08:00
Zhou Weidong e583bfed5f feat(admin): 举报列表展示短剧与分集 2026-10-21 16:44:55 +08:00
Zhou Weidong 923a350cde merge: 合并 K91 观看记录页面与治理筛选 2026-10-21 16:36:09 +08:00
Zhou Weidong 0710ea55f3 feat(admin): 观看记录独立菜单与治理状态全部筛选 2026-10-21 16:35:41 +08:00
Zhou Weidong 50f2b177b1 merge: 合并 K89 分集互动数据管理 2026-10-21 15:59:13 +08:00
Zhou Weidong 0e814840b1 feat(admin): 剧集列表展示分集互动数据 2026-10-21 15:59:08 +08:00
Zhou Weidong c1e03f94be merge: 合并 K88 短剧内容类型与互动数据管理 2026-10-21 15:44:12 +08:00
Zhou Weidong c8fe571be1 feat(admin): 短剧列表展示并编辑互动数据 2026-10-21 15:43:51 +08:00
Zhou Weidong 45507dee8d perf(admin): 降低首屏体积并修复 Windows 访问白屏
1) Editor 改 defineAsyncComponent,IEditorConfig 改 import type
2) dev server 绑定 :: 双栈,避免 Windows 下 localhost 走 ::1 超时
3) dev 脚本去掉 POSIX 环境变量前缀,Windows 可直接 pnpm dev
2026-10-21 13:35:55 +08:00
Zhou Weidong 1737f37d82 fix(login): 忘记密码链接加提示,避免点击无响应 2026-10-20 09:15:19 +08:00
Zhou Weidong d283d223c0 chore: 忽略构建产物 apk 2026-10-20 09:12:11 +08:00
Zhou Weidong f78960fcce chore(web): 合并 release 分片上传修复 2026-10-19 18:46:27 +08:00
Zhou Weidong 8e9435ee7d fix(web): 修复分片上传 chunkSize 字符串拼接导致大文件超限 2026-10-19 18:38:06 +08:00
Zhou Weidong 83e535d12a chore(web): 合并剧集编辑弹窗 2026-10-19 13:55:29 +08:00
Zhou Weidong 2b5dda7c71 feat(web): 重做剧集编辑弹窗 2026-10-19 13:53:57 +08:00
Zhou Weidong 685d4fd1bd chore(web): 合并轮播图编辑弹窗 2026-10-19 13:49:09 +08:00
Zhou Weidong f281dcf1b3 feat(web): 重做轮播图编辑弹窗 2026-10-19 13:47:42 +08:00
Zhou Weidong 5db565b15d chore(web): 合并列表图片预览 2026-10-19 13:42:57 +08:00
Zhou Weidong 8a84f24baf feat(web): 支持后台列表图片大图预览 2026-10-19 13:42:28 +08:00
Zhou Weidong 046400ae4a chore(web): 合并剧集管理布局修复 2026-10-19 13:32:02 +08:00
Zhou Weidong 122aa21323 fix(web): 重做剧集管理顶部布局 2026-10-19 13:30:29 +08:00
Zhou Weidong 9f92d113f2 chore(web): 合并二级菜单背景修复 2026-10-19 13:28:07 +08:00
Zhou Weidong f6f4d264ee fix(web): 调整二级菜单背景样式 2026-10-19 13:27:44 +08:00
Zhou Weidong f023c7d416 chore(web): 合并工作台菜单图标修复 2026-10-19 13:22:44 +08:00
Zhou Weidong bb03a5ad84 fix(web): 修复工作台菜单图标继承 2026-10-19 13:21:09 +08:00
Zhou Weidong 73da0bbd85 chore(web): 合并 K23.1 布局修复 2026-10-19 12:58:02 +08:00
Zhou Weidong c8bf4a8fd4 fix(web): 修复禁用按钮与弹窗布局 2026-10-19 12:56:07 +08:00
Zhou Weidong 6f522303a0 chore(web): 合并 K23.1 后台修正 2026-10-19 12:34:21 +08:00
Zhou Weidong f5b399a52f fix(web): 统一危险操作按钮样式 2026-10-19 12:32:23 +08:00
Zhou Weidong e77db70278 fix(web): 修正浅色品牌文字颜色 2026-10-19 12:32:01 +08:00
Zhou Weidong fe1ae954e3 chore(web): 合并菜单对齐修复 2026-10-19 12:04:22 +08:00
Zhou Weidong 40fbd6ec7b fix(web): 对齐父子菜单文字基线 2026-10-19 12:04:18 +08:00
Zhou Weidong ee3bb1c286 chore(web): 合并 K23 后台视觉深化 2026-10-19 12:00:41 +08:00
Zhou Weidong 898e439b84 fix(web): 优化锁屏弹窗布局与交互 2026-10-19 12:00:30 +08:00
Zhou Weidong 805d7ddc52 feat(web): 重做短剧编辑弹窗 2026-10-19 12:00:26 +08:00
Zhou Weidong ba90bf9bfb style(web): 统一辣椒红主色与菜单层级 2026-10-19 12:00:21 +08:00
Zhou Weidong 3e8312ea58 chore(web): 合并首屏 loading 文字修复 2026-10-19 11:34:19 +08:00
Zhou Weidong a97b8cc026 fix(web): 修正首屏 loading 文字颜色 2026-10-19 11:34:15 +08:00
Zhou Weidong e5c5101450 chore(web): 合并 K22.1 后台收尾 2026-10-19 11:32:09 +08:00
Zhou Weidong 87970487af chore(web): 清理 mock 与旧品牌资源 2026-10-19 11:32:00 +08:00
Zhou Weidong ada741f53d style(web): 精简登录品牌与注册入口 2026-10-19 11:31:55 +08:00
Zhou Weidong 5494190330 chore(web): 合并 K22 路由图标收尾 2026-10-19 11:18:15 +08:00
Zhou Weidong 29465ab4ed fix(web): 统一静态路由图标 2026-10-19 11:18:11 +08:00
77 changed files with 4483 additions and 1151 deletions

No files matched your search

+1 -1
View File
@@ -17,4 +17,4 @@ VITE_USE_ALL_ELEMENT_PLUS_STYLE=true
VITE_USE_MOCK=false VITE_USE_MOCK=false
# 是否使用在线图标 # 是否使用在线图标
VITE_USE_ONLINE_ICON=true VITE_USE_ONLINE_ICON=false
+2 -2
View File
@@ -29,10 +29,10 @@ VITE_USE_BUNDLE_ANALYZER=false
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
# 是否开启mock # 是否开启mock
VITE_USE_MOCK=true VITE_USE_MOCK=false
# 是否切割css # 是否切割css
VITE_USE_CSS_SPLIT=true VITE_USE_CSS_SPLIT=true
# 是否使用在线图标 # 是否使用在线图标
VITE_USE_ONLINE_ICON=true VITE_USE_ONLINE_ICON=false
+2 -2
View File
@@ -29,10 +29,10 @@ VITE_USE_BUNDLE_ANALYZER=true
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
# 是否开启mock # 是否开启mock
VITE_USE_MOCK=true VITE_USE_MOCK=false
# 是否切割css # 是否切割css
VITE_USE_CSS_SPLIT=true VITE_USE_CSS_SPLIT=true
# 是否使用在线图标 # 是否使用在线图标
VITE_USE_ONLINE_ICON=true VITE_USE_ONLINE_ICON=false
+2 -2
View File
@@ -29,10 +29,10 @@ VITE_USE_BUNDLE_ANALYZER=false
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
# 是否开启mock # 是否开启mock
VITE_USE_MOCK=true VITE_USE_MOCK=false
# 是否切割css # 是否切割css
VITE_USE_CSS_SPLIT=false VITE_USE_CSS_SPLIT=false
# 是否使用在线图标 # 是否使用在线图标
VITE_USE_ONLINE_ICON=true VITE_USE_ONLINE_ICON=false
+6
View File
@@ -7,3 +7,9 @@ dist-ssr
*-lock.* *-lock.*
pnpm-debug pnpm-debug
stats.html stats.html
*.apk
# 本机运行时产物(start-dev.bat / vite 日志)
start-dev.bat
dev.out.log
dev.err.log
+5
View File
@@ -1,4 +1,9 @@
#!/bin/sh #!/bin/sh
. "$(dirname "$0")/_/husky.sh" . "$(dirname "$0")/_/husky.sh"
if grep -qiE "(co-authored-by|generated with|assisted by).*(cursor|claude|codex|gpt|copilot|ai)" "$1"; then
echo "commit message contains AI attribution, remove it before committing" >&2
exit 1
fi
pnpm commitlint --edit "$1" pnpm commitlint --edit "$1"
+8 -25
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" type="image/png" href="/brand-mark.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= title %></title> <title><%= title %></title>
</head> </head>
@@ -16,7 +16,8 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
background: #f0f2f5; color: #f8f1ea;
background: #100c0a;
} }
.app-loading .app-loading-wrap { .app-loading .app-loading-wrap {
@@ -38,8 +39,9 @@
} }
.app-loading .app-loading-logo { .app-loading .app-loading-logo {
width: 100px; width: 72px;
margin: 0 auto 15px; margin: 0 auto 15px;
border-radius: 12px;
} }
.app-loading .app-loading-item { .app-loading .app-loading-item {
@@ -55,7 +57,7 @@
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
border: 4px solid #2d8cf0; border: 4px solid #c4322a;
border-bottom: 0; border-bottom: 0;
border-left-color: transparent; border-left-color: transparent;
border-radius: 50%; border-radius: 50%;
@@ -68,7 +70,7 @@
left: calc(50% - 20px); left: calc(50% - 20px);
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 4px solid #87bdff; border: 4px solid #ff8a72;
border-right: 0; border-right: 0;
border-top-color: transparent; border-top-color: transparent;
border-radius: 50%; border-radius: 50%;
@@ -85,16 +87,6 @@
} }
} }
@keyframes loader-outter {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes loader-inner { @keyframes loader-inner {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
@@ -105,20 +97,11 @@
} }
} }
@keyframes loader-inner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-360deg);
}
}
</style> </style>
<div class="app-loading"> <div class="app-loading">
<div class="app-loading-wrap"> <div class="app-loading-wrap">
<div class="app-loading-title"> <div class="app-loading-title">
<img src="/logo.png" class="app-loading-logo" alt="Logo" /> <img src="/brand-mark.png" class="app-loading-logo" alt="Logo" />
<div class="app-loading-title"><%= title %></div> <div class="app-loading-title"><%= title %></div>
</div> </div>
<div class="app-loading-item"> <div class="app-loading-item">
-20
View File
@@ -1,20 +0,0 @@
// Author: fruit.com.cn
import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'
const modules = import.meta.glob('./**/*.mock.ts', {
import: 'default',
eager: true
})
const mockModules: any[] = []
Object.keys(modules).forEach(async (key) => {
if (key.includes('_')) {
return
}
mockModules.push(...(modules[key] as any))
})
export function setupProdMockServer() {
createProdMockServer(mockModules)
}
-131
View File
@@ -1,131 +0,0 @@
// Author: fruit.com.cn
import { MockMethod } from 'vite-plugin-mock'
import { SUCCESS_CODE } from '@/constants'
const timeout = 1000
const adminList = [
{
path: '/dashboard',
component: '#',
redirect: '/dashboard/index',
name: 'Dashboard',
meta: {
title: '工作台',
icon: 'ant-design:dashboard-filled'
},
children: [
{
path: 'index',
name: 'dashboard',
component: 'views/common/DashboardView',
meta: {
title: '工作台',
icon: 'ant-design:dashboard-filled',
affix: true
}
}
]
},
{
path: '/content',
component: '#',
redirect: '/content/dramas',
name: 'Content',
meta: {
title: '内容管理',
icon: 'ep:video-camera',
alwaysShow: true
},
children: [
{
path: 'dramas',
name: 'dramas',
component: 'views/contentManagement/DramaManagementView',
meta: {
title: '短剧管理'
}
},
{
path: 'episodes',
name: 'episodes',
component: 'views/contentManagement/EpisodeManagementView',
meta: {
title: '剧集管理'
}
}
]
},
{
path: '/system',
component: '#',
redirect: '/system/access',
name: 'System',
meta: {
title: '后台管理',
icon: 'ep:setting',
alwaysShow: true
},
children: [
{
path: 'access',
name: 'access',
component: 'views/permission/RoleManagementView',
meta: {
title: '角色权限配置'
}
},
{
path: 'admin-users',
name: 'admin-users',
component: 'views/userManagement/AdminUsersView',
meta: {
title: '管理员用户列表'
}
},
{
path: 'menus',
name: 'menus',
component: 'views/permission/MenuManagementView',
meta: {
title: '菜单管理'
}
},
{
path: 'role-categories',
name: 'role-categories',
component: 'views/permission/RoleCategoriesView',
meta: {
title: '角色分类'
}
},
{
path: 'settings',
name: 'settings',
component: 'views/systemManagement/SettingsView',
meta: {
title: '系统设置'
}
}
]
}
]
const testList = adminList.filter((route) => route.path !== '/system')
export default [
// 列表接口
{
url: '/mock/role/list',
method: 'get',
timeout,
response: ({ query }) => {
const { roleName } = query
return {
code: SUCCESS_CODE,
data: roleName === 'admin' ? adminList : testList
}
}
}
] as MockMethod[]
-92
View File
@@ -1,92 +0,0 @@
// Author: fruit.com.cn
import { SUCCESS_CODE } from '@/constants'
const timeout = 1000
const List: {
username: string
password: string
role: string
roleId: string
permissions: string | string[]
}[] = [
{
username: 'admin',
password: 'admin123',
role: 'admin',
roleId: '1',
permissions: ['*.*.*']
},
{
username: 'test',
password: 'test',
role: 'test',
roleId: '2',
permissions: ['example:dialog:create', 'example:dialog:delete']
}
]
export default [
// 列表接口
{
url: '/mock/user/list',
method: 'get',
response: ({ query }) => {
const { username, pageIndex, pageSize } = query
const mockList = List.filter((item) => {
if (username && item.username.indexOf(username) < 0) return false
return true
})
const pageList = mockList.filter(
(_, index) => index < pageSize * pageIndex && index >= pageSize * (pageIndex - 1)
)
return {
code: SUCCESS_CODE,
data: {
total: mockList.length,
list: pageList
}
}
}
},
// 登录接口
{
url: '/mock/user/login',
method: 'post',
timeout,
response: ({ body }) => {
const data = body
let hasUser = false
for (const user of List) {
if (user.username === data.username && user.password === data.password) {
hasUser = true
return {
code: SUCCESS_CODE,
data: user
}
}
}
if (!hasUser) {
return {
code: 500,
message: '账号或密码错误'
}
}
}
},
// 退出接口
{
url: '/mock/user/loginOut',
method: 'get',
timeout,
response: () => {
return {
code: SUCCESS_CODE,
data: null
}
}
}
]
+1 -4
View File
@@ -6,7 +6,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"i": "pnpm install", "i": "pnpm install",
"dev": "VITE_CJS_IGNORE_WARNING=1 vite --mode base", "dev": "vite --mode base",
"ts:check": "pnpm vue-tsc --noEmit --skipLibCheck", "ts:check": "pnpm vue-tsc --noEmit --skipLibCheck",
"build:pro": "VITE_CJS_IGNORE_WARNING=1 vite build --mode pro", "build:pro": "VITE_CJS_IGNORE_WARNING=1 vite build --mode pro",
"build:dev": "VITE_CJS_IGNORE_WARNING=1 vite build --mode dev", "build:dev": "VITE_CJS_IGNORE_WARNING=1 vite build --mode dev",
@@ -24,8 +24,6 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@faker-js/faker": "^8.4.0",
"@iconify/iconify": "^3.1.1", "@iconify/iconify": "^3.1.1",
"@iconify/vue": "^4.1.1", "@iconify/vue": "^4.1.1",
"@vueuse/core": "^10.7.2", "@vueuse/core": "^10.7.2",
@@ -103,7 +101,6 @@
"vite": "5.0.12", "vite": "5.0.12",
"vite-plugin-ejs": "^1.7.0", "vite-plugin-ejs": "^1.7.0",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",
"vite-plugin-mock": "2.9.6",
"vite-plugin-progress": "^0.0.7", "vite-plugin-progress": "^0.0.7",
"vite-plugin-purge-icons": "^0.10.0", "vite-plugin-purge-icons": "^0.10.0",
"vite-plugin-style-import": "2.0.0", "vite-plugin-style-import": "2.0.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

+21
View File
@@ -13,6 +13,20 @@ export interface AppUserPageParams {
registerSource?: string registerSource?: string
} }
export interface WatchHistoryPageParams {
pageIndex?: number
pageSize?: number
keyword?: string
userId?: number
dramaTitle?: string
dramaId?: number
episodeId?: number
source?: string
finished?: boolean
startTime?: string
endTime?: string
}
export const pageAppUsersApi = (params: AppUserPageParams) => { export const pageAppUsersApi = (params: AppUserPageParams) => {
return request.get<PageResult<AppUserItem>>({ url: '/admin/app-users/page', params }) return request.get<PageResult<AppUserItem>>({ url: '/admin/app-users/page', params })
} }
@@ -32,6 +46,13 @@ export const listAppUserWatchHistoryApi = (id: number, limit = 50) => {
}) })
} }
export const pageWatchHistoryApi = (params: WatchHistoryPageParams) => {
return request.get<PageResult<WatchHistoryItem>>({
url: '/admin/watch-history/page',
params
})
}
/** normal / limited / banned,封禁与限制会立即吊销该用户全部会话 */ /** normal / limited / banned,封禁与限制会立即吊销该用户全部会话 */
export const updateAppUserStatusApi = (id: number, status: string, reason?: string) => { export const updateAppUserStatusApi = (id: number, status: string, reason?: string) => {
return request.put<void>({ return request.put<void>({
+2
View File
@@ -15,7 +15,9 @@ export interface DramaPageParams {
keyword?: string keyword?: string
status?: string status?: string
categoryId?: number categoryId?: number
categoryIds?: number[]
isHot?: boolean isHot?: boolean
dramaTypes?: Array<'ANIME' | 'LIVE'>
} }
export const pageDramasApi = (params: DramaPageParams) => { export const pageDramasApi = (params: DramaPageParams) => {
+28 -3
View File
@@ -1,10 +1,35 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import request from '@/axios' import request from '@/axios'
import type { EpisodeItem, EpisodeSaveData } from './types' import type { EpisodeItem, EpisodeSaveData, PageResult } from './types'
export const listEpisodesApi = (dramaId: number, status?: string) => { export interface EpisodePageParams {
return request.get<EpisodeItem[]>({ url: '/admin/episodes', params: { dramaId, status } }) dramaId: number | string
status?: string
pageIndex?: number
pageSize?: number
}
/**
* 解包剧集列表分页。
* 契约(OpenAPI PageResultEpisodeRespVO):data = { total, list }。
* 兼容误把 data 当数组的旧调用,避免列表空白。
*/
export function parseEpisodePage(payload: unknown): PageResult<EpisodeItem> {
if (Array.isArray(payload)) {
return { total: payload.length, list: payload as EpisodeItem[] }
}
if (payload && typeof payload === 'object') {
const page = payload as { total?: unknown; list?: unknown }
const list = Array.isArray(page.list) ? (page.list as EpisodeItem[]) : []
const total = Number(page.total)
return { total: Number.isFinite(total) ? total : list.length, list }
}
return { total: 0, list: [] }
}
export const listEpisodesApi = (params: EpisodePageParams) => {
return request.get<PageResult<EpisodeItem>>({ url: '/admin/episodes', params })
} }
export const createEpisodeApi = (data: EpisodeSaveData) => { export const createEpisodeApi = (data: EpisodeSaveData) => {
+36
View File
@@ -20,11 +20,15 @@ export interface DramaItem {
title: string title: string
subtitle?: string subtitle?: string
categoryId?: number categoryId?: number
categoryIds?: number[]
categoryNames?: string[]
category?: string category?: string
genre?: string genre?: string
description?: string description?: string
cover?: string cover?: string
language?: string language?: string
dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'>
episodes?: number episodes?: number
freeEpisodes?: number freeEpisodes?: number
unlockCoin?: number unlockCoin?: number
@@ -33,6 +37,9 @@ export interface DramaItem {
status: string status: string
statusText: string statusText: string
views?: number views?: number
favoriteCount?: number
likeCount?: number
commentCount?: number
sort?: number sort?: number
publishAt?: string publishAt?: string
createdAt?: string createdAt?: string
@@ -43,12 +50,18 @@ export interface DramaSaveData {
title: string title: string
subtitle?: string subtitle?: string
categoryId?: number categoryId?: number
categoryIds?: number[]
genre?: string genre?: string
description?: string description?: string
cover?: string cover?: string
language?: string language?: string
dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'>
freeEpisodes?: number freeEpisodes?: number
unlockCoin?: number unlockCoin?: number
favoriteCount?: number
likeCount?: number
heat?: number
isHot?: boolean isHot?: boolean
sort?: number sort?: number
} }
@@ -69,6 +82,10 @@ export interface EpisodeItem {
status: string status: string
statusText: string statusText: string
views?: number views?: number
likeCount?: number
commentCount?: number
favoriteCount?: number
heat?: number
sort?: number sort?: number
} }
@@ -81,6 +98,7 @@ export interface EpisodeSaveData {
videoUrl?: string videoUrl?: string
isFree?: boolean isFree?: boolean
unlockCoin?: number unlockCoin?: number
likeCount?: number
status?: string status?: string
sort?: number sort?: number
} }
@@ -294,11 +312,15 @@ export interface AppUserBanRecord {
export interface WatchHistoryItem { export interface WatchHistoryItem {
id: number id: number
userId?: number
uid?: string
nickname?: string
dramaId: number dramaId: number
dramaTitle?: string dramaTitle?: string
dramaPublished?: boolean dramaPublished?: boolean
episodeId: number episodeId: number
episodeNo?: number episodeNo?: number
episodeTitle?: string
positionMs: number positionMs: number
durationMs: number durationMs: number
finished?: boolean finished?: boolean
@@ -311,7 +333,10 @@ export interface WatchHistoryItem {
export interface CommentItem { export interface CommentItem {
id: number id: number
dramaId: number dramaId: number
dramaTitle?: string
episodeId: number episodeId: number
episodeNo?: number
episodeTitle?: string
parentId: number parentId: number
rootId: number rootId: number
userId: number userId: number
@@ -330,9 +355,20 @@ export interface CommentItem {
export interface ReportItem { export interface ReportItem {
id: number id: number
reporterId: number reporterId: number
reporterNickname?: string
reporterUid?: string
/** COMMENT / USER / DRAMA */ /** COMMENT / USER / DRAMA */
targetType: string targetType: string
targetId: number targetId: number
targetSummary?: string
dramaId?: number
dramaTitle?: string
episodeId?: number
episodeNo?: number
episodeTitle?: string
commentContent?: string
targetNickname?: string
targetUid?: string
reasonCode: string reasonCode: string
description: string description: string
/** pending / handled / rejected */ /** pending / handled / rejected */
-4
View File
@@ -26,10 +26,6 @@ export const getAdminRoleApi = (
return request.get({ url: '/admin/auth/menus', params }) return request.get({ url: '/admin/auth/menus', params })
} }
export const getTestRoleApi = (params: RoleParams): Promise<IResponse<string[]>> => {
return request.get({ url: '/mock/role/list2', params })
}
/** 自助改密(首登强制改密也走这个接口) */ /** 自助改密(首登强制改密也走这个接口) */
export const changePasswordApi = (data: { oldPassword: string; newPassword: string }) => { export const changePasswordApi = (data: { oldPassword: string; newPassword: string }) => {
return request.post<void>({ url: '/admin/auth/password', data }) return request.post<void>({ url: '/admin/auth/password', data })
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

+5
View File
@@ -0,0 +1,5 @@
// Author: fruit.com.cn
import AppImage from './src/AppImage.vue'
export { AppImage }
+94
View File
@@ -0,0 +1,94 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts">
import { computed } from 'vue'
import type { PropType } from 'vue'
import { ElImage } from 'element-plus'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
defineOptions({ inheritAttrs: false })
const props = defineProps({
src: {
type: String,
required: true
},
previewList: {
type: Array as () => string[],
default: () => []
},
initialIndex: {
type: Number,
default: 0
},
fit: {
type: String as PropType<'fill' | 'contain' | 'cover' | 'none' | 'scale-down'>,
default: 'cover'
},
class: {
type: [String, Array, Object],
default: ''
},
style: {
type: [String, Array, Object],
default: undefined
},
alt: {
type: String,
default: ''
}
})
const resolvedPreviewList = computed(() => {
const images = (props.previewList || []).filter(Boolean)
return images.length ? images : [props.src]
})
const resolvedInitialIndex = computed(() =>
Math.max(0, Math.min(props.initialIndex, resolvedPreviewList.value.length - 1))
)
</script>
<template>
<ElImage
v-bind="$attrs"
:src="src"
:alt="alt"
:fit="fit"
:preview-src-list="resolvedPreviewList"
:initial-index="resolvedInitialIndex"
:preview-teleported="true"
:hide-on-click-modal="true"
:class="['app-image', props.class]"
:style="style"
>
<template #error>
<div class="app-image-error">
<AppIcon :icon="AppIcons.error" :size="20" />
</div>
</template>
</ElImage>
</template>
<style scoped>
.app-image {
cursor: zoom-in;
}
.app-image :deep(.el-image__inner) {
display: block;
width: 100%;
height: 100%;
}
.app-image-error {
display: flex;
width: 100%;
height: 100%;
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-lighter);
align-items: center;
justify-content: center;
}
</style>
@@ -1,5 +1,6 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import { defineAsyncComponent } from 'vue'
import type { Component } from 'vue' import type { Component } from 'vue'
import { import {
ElCascader, ElCascader,
@@ -23,9 +24,12 @@ import {
ElUpload ElUpload
} from 'element-plus' } from 'element-plus'
import { InputPassword } from '@/components/InputPassword' import { InputPassword } from '@/components/InputPassword'
import { Editor } from '@/components/Editor'
import { ComponentName } from '../types' import { ComponentName } from '../types'
// wang-editor 打包后约 1.1MB:改成异步组件,只有真正渲染编辑器字段时才加载,
// 避免登录页这类只是用了 Form 的页面把它一并拉进首屏。
const Editor = defineAsyncComponent(() => import('@/components/Editor'))
const componentMap: Recordable<Component, ComponentName> = { const componentMap: Recordable<Component, ComponentName> = {
RadioGroup: ElRadioGroup, RadioGroup: ElRadioGroup,
RadioButton: ElRadioGroup, RadioButton: ElRadioGroup,
+2 -1
View File
@@ -22,7 +22,8 @@ import {
ISelectProps, ISelectProps,
UploadProps UploadProps
} from 'element-plus' } from 'element-plus'
import { IEditorConfig } from '@wangeditor/editor' // 仅类型引用:必须用 import type,否则会把整个 wang-editor 打进登录页的依赖链
import type { IEditorConfig } from '@wangeditor/editor'
import { CSSProperties } from 'vue' import { CSSProperties } from 'vue'
export interface PlaceholderModel { export interface PlaceholderModel {
+6 -1
View File
@@ -68,7 +68,12 @@ watch(
> >
<img <img
src="@/assets/imgs/brand-mark.png" src="@/assets/imgs/brand-mark.png"
class="w-[var(--brand-mark-size)] h-[var(--brand-mark-size)] rounded-[var(--brand-mark-radius)] object-cover" class="object-cover"
style="
width: var(--brand-mark-size);
height: var(--brand-mark-size);
border-radius: var(--brand-mark-radius);
"
/> />
<div <div
v-if="show" v-if="show"
@@ -9,7 +9,7 @@ const { renderMenuTitle } = useRenderMenuTitle()
export const useRenderMenuItem = () => export const useRenderMenuItem = () =>
// allRouters: AppRouteRecordRaw[] = [], // allRouters: AppRouteRecordRaw[] = [],
{ {
const renderMenuItem = (routers: AppRouteRecordRaw[], parentPath = '/') => { const renderMenuItem = (routers: AppRouteRecordRaw[], parentPath = '/', nested = false) => {
return routers return routers
.filter((v) => !v.meta?.hidden) .filter((v) => !v.meta?.hidden)
.map((v) => { .map((v) => {
@@ -22,21 +22,25 @@ export const useRenderMenuItem = () =>
(!onlyOneChild?.children || onlyOneChild?.noShowingChildren) && (!onlyOneChild?.children || onlyOneChild?.noShowingChildren) &&
!meta?.alwaysShow !meta?.alwaysShow
) { ) {
const leafMeta = onlyOneChild?.meta
? {
...onlyOneChild.meta,
icon: onlyOneChild.meta.icon || meta.icon
}
: meta
return ( return (
<ElMenuItem <ElMenuItem
index={onlyOneChild ? pathResolve(fullPath, onlyOneChild.path) : fullPath} index={onlyOneChild ? pathResolve(fullPath, onlyOneChild.path) : fullPath}
> >
{{ {{ default: () => renderMenuTitle(leafMeta, !nested) }}
default: () => renderMenuTitle(onlyOneChild ? onlyOneChild?.meta : meta)
}}
</ElMenuItem> </ElMenuItem>
) )
} else { } else {
return ( return (
<ElSubMenu index={fullPath}> <ElSubMenu index={fullPath}>
{{ {{
title: () => renderMenuTitle(meta), title: () => renderMenuTitle(meta, !nested),
default: () => renderMenuItem(v.children!, fullPath) default: () => renderMenuItem(v.children!, fullPath, true)
}} }}
</ElSubMenu> </ElSubMenu>
) )
@@ -5,11 +5,11 @@ import { AppIcon } from '@/components/AppIcon'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
export const useRenderMenuTitle = () => { export const useRenderMenuTitle = () => {
const renderMenuTitle = (meta: RouteMeta) => { const renderMenuTitle = (meta: RouteMeta, showIcon = true) => {
const { t } = useI18n() const { t } = useI18n()
const { title = 'Please set title', icon } = meta const { title = 'Please set title', icon } = meta
return icon ? ( return icon && showIcon ? (
<> <>
<AppIcon icon={meta.icon}></AppIcon> <AppIcon icon={meta.icon}></AppIcon>
<span class="v-menu__title overflow-hidden overflow-ellipsis whitespace-nowrap"> <span class="v-menu__title overflow-hidden overflow-ellipsis whitespace-nowrap">
+1 -1
View File
@@ -195,7 +195,7 @@ const themeChange = () => {
<ColorRadioPicker <ColorRadioPicker
v-model="systemTheme" v-model="systemTheme"
:schema="[ :schema="[
'#409eff', '#c4322a',
'#009688', '#009688',
'#536dfe', '#536dfe',
'#ff5c93', '#ff5c93',
@@ -2,11 +2,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { ref } from 'vue' import { computed, reactive } from 'vue'
import { Dialog } from '@/components/Dialog' import { Dialog } from '@/components/Dialog'
import { Form } from '@/components/Form' import { Form } from '@/components/Form'
import { useForm } from '@/hooks/web/useForm' import { useForm } from '@/hooks/web/useForm'
import { reactive, computed } from 'vue'
import { useValidator } from '@/hooks/web/useValidator' import { useValidator } from '@/hooks/web/useValidator'
import { FormSchema } from '@/components/Form' import { FormSchema } from '@/components/Form'
import { ElButton } from 'element-plus' import { ElButton } from 'element-plus'
@@ -41,13 +40,10 @@ const emit = defineEmits(['update:modelValue'])
const dialogVisible = computed({ const dialogVisible = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (val) => { set: (val) => emit('update:modelValue', val)
console.log('set: ', val)
emit('update:modelValue', val)
}
}) })
const dialogTitle = ref(t('lock.lockScreen')) const dialogTitle = computed(() => t('lock.lockScreen'))
const rules = reactive({ const rules = reactive({
password: [required()] password: [required()]
@@ -60,7 +56,10 @@ const schema: FormSchema[] = reactive([
component: 'Input', component: 'Input',
componentProps: { componentProps: {
type: 'password', type: 'password',
showPassword: true showPassword: true,
style: {
width: '100%'
}
} }
} }
]) ])
@@ -82,41 +81,169 @@ const handleLock = async () => {
} }
}) })
} }
const cancel = () => {
dialogVisible.value = false
}
</script> </script>
<template> <template>
<Dialog <Dialog
v-model="dialogVisible" v-model="dialogVisible"
width="500px" width="440px"
max-height="170px" max-height="320px"
:class="prefixCls" :class="prefixCls"
:title="dialogTitle" :title="dialogTitle"
:fullscreen="false"
> >
<div class="flex flex-col items-center"> <template #title>
<img <div class="lock-dialog-heading">
v-if="avatarUrl" <AppIcon :icon="AppIcons.lock" :size="20" />
:src="avatarUrl" <div>
alt="" <h2>{{ dialogTitle }}</h2>
class="w-70px h-70px rounded-[50%] object-cover" <p>{{ t('lock.description') }}</p>
/> </div>
<span </div>
v-else </template>
class="w-70px h-70px rounded-[50%] flex items-center justify-center bg-[var(--el-fill-color)]"
><AppIcon :icon="AppIcons.person" :size="30" <div class="lock-dialog-body">
/></span> <div class="lock-dialog-user">
<span class="text-14px my-10px text-[var(--top-header-text-color)]">{{ displayName }}</span> <img v-if="avatarUrl" :src="avatarUrl" alt="" class="lock-dialog-avatar" />
<span v-else class="lock-dialog-avatar lock-dialog-avatar--empty">
<AppIcon :icon="AppIcons.person" :size="28" />
</span>
<span class="lock-dialog-name">{{ displayName }}</span>
</div>
<div class="lock-password-field" @keyup.enter.prevent.stop="handleLock">
<Form
:is-col="false"
:schema="schema"
:rules="rules"
label-position="top"
@register="formRegister"
/>
</div>
</div> </div>
<Form :is-col="false" :schema="schema" :rules="rules" @register="formRegister" />
<template #footer> <template #footer>
<ElButton type="primary" @click="handleLock">{{ t('lock.lock') }}</ElButton> <ElButton class="lock-dialog-cancel" @click="cancel">{{ t('common.cancel') }}</ElButton>
<ElButton type="primary" class="lock-dialog-submit" @click="handleLock">
{{ t('lock.lock') }}
</ElButton>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
:global(.v-lock-dialog) { :global(.v-lock-dialog) {
@media (width <= 767px) { display: flex;
width: 440px !important;
height: auto !important;
max-width: calc(100vw - 24px);
max-height: 440px;
overflow: hidden;
flex-direction: column;
}
:global(.v-lock-dialog .el-dialog__header) {
height: auto;
min-height: 56px;
padding: 8px 20px 4px !important;
border-bottom: 0;
}
:global(.v-lock-dialog .el-dialog__body) {
min-height: 0;
padding: 4px 20px 8px !important;
overflow: hidden;
}
:global(.v-lock-dialog .el-dialog__footer) {
padding: 6px 20px 10px;
}
:global(.v-lock-dialog .el-dialog__footer .el-button) {
height: 36px;
min-width: 96px;
}
:global(.v-lock-dialog .el-dialog__header .absolute) {
right: -14px !important;
}
@media (width <= 767px) {
:global(.v-lock-dialog) {
max-width: calc(100vw - 16px); max-width: calc(100vw - 16px);
} }
} }
.lock-dialog-heading {
display: flex;
align-items: flex-start;
gap: 10px;
padding-right: 40px;
color: var(--el-text-color-primary);
.app-icon {
margin-top: 2px;
color: var(--el-color-primary);
}
h2 {
margin: 0;
font-size: 16px;
font-weight: 650;
line-height: 1.4;
}
p {
margin: 3px 0 0;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
color: var(--el-text-color-secondary);
}
}
.lock-dialog-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.lock-dialog-user {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.lock-dialog-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
}
.lock-dialog-avatar--empty {
display: flex;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
align-items: center;
justify-content: center;
}
.lock-dialog-name {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-regular);
}
.lock-password-field {
width: 100%;
}
.lock-dialog-submit {
min-width: 96px;
}
</style> </style>
+2
View File
@@ -3,10 +3,12 @@
import type { App } from 'vue' import type { App } from 'vue'
import { Icon } from './Icon' import { Icon } from './Icon'
import { AppIcon } from './AppIcon' import { AppIcon } from './AppIcon'
import { AppImage } from './AppImage'
import { BaseButton } from './Button' import { BaseButton } from './Button'
export const setupGlobCom = (app: App<Element>): void => { export const setupGlobCom = (app: App<Element>): void => {
app.component('Icon', Icon) app.component('Icon', Icon)
app.component('AppIcon', AppIcon) app.component('AppIcon', AppIcon)
app.component('AppImage', AppImage)
app.component('BaseButton', BaseButton) app.component('BaseButton', BaseButton)
} }
+57 -12
View File
@@ -52,6 +52,7 @@ export default {
lockScreen: 'Lock screen', lockScreen: 'Lock screen',
lock: 'Lock', lock: 'Lock',
lockPassword: 'Lock screen password', lockPassword: 'Lock screen password',
description: 'Enter your password to unlock this session.',
unlock: 'Click to unlock', unlock: 'Click to unlock',
backToLogin: 'Back to login', backToLogin: 'Back to login',
entrySystem: 'Entry the system', entrySystem: 'Entry the system',
@@ -105,25 +106,15 @@ export default {
login: { login: {
welcome: 'Short-drama workspace', welcome: 'Short-drama workspace',
consoleLabel: 'Content Operations', consoleLabel: 'Content Operations',
message: 'Manage dramas, episodes, publishing, and access.',
hint: 'Sign in with an admin account', hint: 'Sign in with an admin account',
noAccount: 'No account yet?',
pointDrama: 'Dramas and episodes',
pointPublish: 'Publishing',
pointAccess: 'Access',
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',
register: 'Register',
checkPassword: 'Confirm password',
login: 'Sign in', login: 'Sign in',
otherLogin: 'Sign in with',
remember: 'Remember me', remember: 'Remember me',
hasUser: 'Existing account? Go to login',
forgetPassword: 'Forget password', forgetPassword: 'Forget password',
forgetPasswordHint: 'Please contact the administrator to reset your password',
usernamePlaceholder: 'Please input username', usernamePlaceholder: 'Please input username',
passwordPlaceholder: 'Please input password', passwordPlaceholder: 'Please input password'
code: 'Verification code',
codePlaceholder: 'Please input verification code'
}, },
router: { router: {
login: 'Login', login: 'Login',
@@ -536,5 +527,59 @@ export default {
inputPasswordDemo: { inputPasswordDemo: {
title: 'InputPassword', title: 'InputPassword',
inputPasswordDes: 'Secondary packaging of Input components based on ElementPlus' inputPasswordDes: 'Secondary packaging of Input components based on ElementPlus'
},
episodeBatch: {
title: 'Batch upload episodes',
entry: 'Batch upload',
selectFiles: 'Select videos',
startUpload: 'Start upload',
uploadingAction: 'Uploading',
pause: 'Pause',
resume: 'Resume',
retry: 'Retry',
retryAll: 'Retry all',
remove: 'Remove',
hint: 'Supports MP4 / MOV / MKV / WEBM, max 500MB each. Files stay queued until you click Start upload.',
invalidType: 'Only MP4, MOV, MKV, WEBM are allowed',
oversize: 'File exceeds 500MB and was not queued',
overallProgress: 'Overall progress',
empty: 'No videos selected',
resultSummary: 'Upload finished: {done} succeeded, {failed} failed',
createEpisodes: 'Create episodes',
closeWhileUploading:
'Uploads are still in progress. Closing will stop dispatching new tasks. Continue?',
resumeTitle: 'Unfinished uploads found',
resumeConfirm:
'{count} unfinished upload(s) found. Resume? Re-select the same local files to match.',
resumeYes: 'Pick files to resume',
resumeNo: 'Discard',
resumeAbandoned: 'Unfinished uploads discarded',
resumeMatched: 'Matched {count} file(s). Click Start upload to continue.',
resumeUnmatched: '{count} session(s) could not match local files; pick again manually',
createTitle: 'Create episodes in batch',
createHint:
'Title uses the video file name; episode numbers are assigned automatically. Free vs paid follows the drama free-episode count; episodes beyond that are paid. You can edit them later in episode settings.',
colSource: 'Source file',
colNumber: 'Episode No.',
colTitle: 'Title',
colFree: 'Pricing',
labelFree: 'Free',
labelPaid: 'Paid',
colResult: 'Result',
missingVideo: 'Missing video URL',
createOk: 'Created',
createFailed: 'Create failed',
createAllSuccess: 'All episodes created',
createPartial: 'Some episodes failed; you can retry',
submitCreate: 'Create',
retryFailed: 'Retry failed',
status: {
waiting: 'Pending',
uploading: 'Uploading',
merging: 'Merging',
done: 'Done',
failed: 'Failed',
paused: 'Paused'
}
} }
} }
+55 -12
View File
@@ -52,6 +52,7 @@ export default {
lockScreen: '锁定屏幕', lockScreen: '锁定屏幕',
lock: '锁定', lock: '锁定',
lockPassword: '锁屏密码', lockPassword: '锁屏密码',
description: '输入密码解锁当前会话。',
unlock: '点击解锁', unlock: '点击解锁',
backToLogin: '返回登录', backToLogin: '返回登录',
entrySystem: '进入系统', entrySystem: '进入系统',
@@ -105,25 +106,15 @@ export default {
login: { login: {
welcome: '短剧内容管理', welcome: '短剧内容管理',
consoleLabel: '内容运营后台', consoleLabel: '内容运营后台',
message: '管理短剧、剧集、发布状态和后台权限。',
hint: '使用管理员账号进入工作台', hint: '使用管理员账号进入工作台',
noAccount: '还没有账号?',
pointDrama: '短剧与剧集',
pointPublish: '发布状态',
pointAccess: '角色权限',
username: '用户名', username: '用户名',
password: '密码', password: '密码',
register: '注册',
checkPassword: '确认密码',
login: '登录', login: '登录',
otherLogin: '其它登录方式',
remember: '记住我', remember: '记住我',
hasUser: '已有账号?去登录',
forgetPassword: '忘记密码', forgetPassword: '忘记密码',
forgetPasswordHint: '请联系管理员重置密码',
usernamePlaceholder: '请输入用户名', usernamePlaceholder: '请输入用户名',
passwordPlaceholder: '请输入密码', passwordPlaceholder: '请输入密码'
code: '验证码',
codePlaceholder: '请输入验证码'
}, },
router: { router: {
login: '登录', login: '登录',
@@ -530,5 +521,57 @@ export default {
inputPasswordDemo: { inputPasswordDemo: {
title: '密码输入框', title: '密码输入框',
inputPasswordDes: '基于 ElementPlus 的 Input 组件二次封装' inputPasswordDes: '基于 ElementPlus 的 Input 组件二次封装'
},
episodeBatch: {
title: '批量上传剧集',
entry: '批量上传',
selectFiles: '选择视频',
startUpload: '开始上传',
uploadingAction: '上传中',
pause: '暂停',
resume: '继续',
retry: '重试',
retryAll: '全部重试',
remove: '移除',
hint: '支持 MP4 / MOV / MKV / WEBM,单文件不超过 500MB;选完文件后点击「开始上传」才会真正上传',
invalidType: '仅支持 MP4、MOV、MKV、WEBM',
oversize: '文件超过 500MB,未入队',
overallProgress: '总进度',
empty: '尚未选择视频',
resultSummary: '上传结束:成功 {done} 个,失败 {failed} 个',
createEpisodes: '批量创建分集',
closeWhileUploading: '仍有未完成上传,关闭后将暂停派发新任务,确定关闭吗?',
resumeTitle: '发现未完成上传',
resumeConfirm: '发现 {count} 个未完成上传,是否续传?请重新选择相同本地文件以匹配。',
resumeYes: '选择文件续传',
resumeNo: '放弃',
resumeAbandoned: '已放弃未完成上传',
resumeMatched: '已匹配 {count} 个文件,请点击「开始上传」继续',
resumeUnmatched: '有 {count} 个会话无法匹配本地文件,请手动重选',
createTitle: '批量创建分集',
createHint:
'标题取视频文件名,集号自动顺延分配;免费集数由短剧管理中的「免费集数」配置决定,超出部分为收费集;创建后可在分集编辑中修改',
colSource: '源文件',
colNumber: '集号',
colTitle: '标题',
colFree: '收费类型',
labelFree: '免费',
labelPaid: '收费',
colResult: '结果',
missingVideo: '缺少视频地址',
createOk: '已创建',
createFailed: '创建失败',
createAllSuccess: '全部分集已创建',
createPartial: '部分分集创建失败,可重试',
submitCreate: '创建',
retryFailed: '重试失败项',
status: {
waiting: '待上传',
uploading: '上传中',
merging: '合并中',
done: '已完成',
failed: '失败',
paused: '已暂停'
}
} }
} }
+6 -5
View File
@@ -4,6 +4,7 @@ import { createRouter, createWebHashHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import type { App } from 'vue' import type { App } from 'vue'
import { Layout } from '@/utils/routerHelper' import { Layout } from '@/utils/routerHelper'
import { AppIcons } from '@/constants/icons'
export const constantRouterMap: AppRouteRecordRaw[] = [ export const constantRouterMap: AppRouteRecordRaw[] = [
{ {
@@ -62,7 +63,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
name: 'Dashboard', name: 'Dashboard',
meta: { meta: {
title: '工作台', title: '工作台',
icon: 'ant-design:dashboard-filled' icon: AppIcons.dashboard
}, },
children: [ children: [
{ {
@@ -71,7 +72,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
component: () => import('@/views/common/DashboardView.vue'), component: () => import('@/views/common/DashboardView.vue'),
meta: { meta: {
title: '工作台', title: '工作台',
icon: 'ant-design:dashboard-filled', icon: AppIcons.dashboard,
affix: true affix: true
} }
} }
@@ -84,7 +85,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
name: 'Content', name: 'Content',
meta: { meta: {
title: '内容管理', title: '内容管理',
icon: 'ep:video-camera', icon: AppIcons.content,
alwaysShow: true alwaysShow: true
}, },
children: [ children: [
@@ -129,7 +130,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
name: 'UserCenter', name: 'UserCenter',
meta: { meta: {
title: '用户管理', title: '用户管理',
icon: 'ep:user', icon: AppIcons.group,
alwaysShow: true alwaysShow: true
}, },
children: [ children: [
@@ -150,7 +151,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
name: 'System', name: 'System',
meta: { meta: {
title: '后台管理', title: '后台管理',
icon: 'ep:setting', icon: AppIcons.settings,
alwaysShow: true alwaysShow: true
}, },
children: [ children: [
+9 -4
View File
@@ -67,7 +67,7 @@ export const useAppStore = defineStore('app', {
currentSize: 'default', // 组件尺寸 currentSize: 'default', // 组件尺寸
theme: { theme: {
// 主题色 // 主题色
elColorPrimary: '#409eff', elColorPrimary: '#c4322a',
// 左侧菜单边框颜色 // 左侧菜单边框颜色
leftMenuBorderColor: 'inherit', leftMenuBorderColor: 'inherit',
// 左侧菜单背景颜色 // 左侧菜单背景颜色
@@ -89,7 +89,7 @@ export const useAppStore = defineStore('app', {
// 头部背景颜色 // 头部背景颜色
topHeaderBgColor: '#fff', topHeaderBgColor: '#fff',
// 头部字体颜色 // 头部字体颜色
topHeaderTextColor: 'inherit', topHeaderTextColor: '#303133',
// 头部悬停颜色 // 头部悬停颜色
topHeaderHoverColor: '#f6f6f6', topHeaderHoverColor: '#f6f6f6',
// 头部边框颜色 // 头部边框颜色
@@ -299,7 +299,7 @@ export const useAppStore = defineStore('app', {
// 左侧菜单选中字体颜色 // 左侧菜单选中字体颜色
leftMenuTextActiveColor: isDarkColor ? '#fff' : 'var(--el-color-primary)', leftMenuTextActiveColor: isDarkColor ? '#fff' : 'var(--el-color-primary)',
// logo字体颜色 // logo字体颜色
logoTitleTextColor: isDarkColor ? '#fff' : 'inherit', logoTitleTextColor: isDarkColor ? '#fff' : '#303133',
// logo边框颜色 // logo边框颜色
logoBorderColor: isDarkColor ? color : '#eee' logoBorderColor: isDarkColor ? color : '#eee'
} }
@@ -308,7 +308,7 @@ export const useAppStore = defineStore('app', {
}, },
setHeaderTheme(color: string) { setHeaderTheme(color: string) {
const isDarkColor = colorIsDark(color) const isDarkColor = colorIsDark(color)
const textColor = isDarkColor ? '#fff' : 'inherit' const textColor = isDarkColor ? '#fff' : '#303133'
const textHoverColor = isDarkColor ? lighten(color!, 6) : '#f6f6f6' const textHoverColor = isDarkColor ? lighten(color!, 6) : '#f6f6f6'
const topToolBorderColor = isDarkColor ? color : '#eee' const topToolBorderColor = isDarkColor ? color : '#eee'
setCssVar('--top-header-bg-color', color) setCssVar('--top-header-bg-color', color)
@@ -343,6 +343,11 @@ export const useAppStore = defineStore('app', {
persist: { persist: {
afterRestore: (context) => { afterRestore: (context) => {
context.store.title = import.meta.env.VITE_APP_TITLE context.store.title = import.meta.env.VITE_APP_TITLE
if (context.store.theme?.elColorPrimary !== '#c4322a') {
context.store.setTheme({ elColorPrimary: '#c4322a' })
}
context.store.setMenuTheme(context.store.theme.leftMenuBgColor)
context.store.setHeaderTheme(context.store.theme.topHeaderBgColor)
} }
} }
}) })
+2 -1
View File
@@ -53,7 +53,8 @@
line-height: 1; line-height: 1;
} }
.app-user-avatar img { .app-user-avatar img,
.app-user-avatar .app-user-avatar-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
+191 -16
View File
@@ -19,31 +19,206 @@
font-size: 12px; font-size: 12px;
} }
.banner-field { .banner-editor-dialog {
display: flex; display: flex;
gap: 12px; max-height: calc(100vh - 40px);
align-items: flex-start; overflow: hidden;
width: 100%; flex-direction: column;
border-radius: var(--app-radius-lg) !important;
} }
.banner-field-actions {
.banner-editor-dialog .el-dialog__body {
flex: 1 1 auto;
padding: 8px 24px 0;
overflow-y: auto;
}
.banner-editor-dialog .el-dialog__footer {
flex: 0 0 auto;
padding: 14px 24px;
background: var(--el-bg-color-overlay);
border-top: 1px solid var(--el-border-color-lighter);
}
.banner-editor-dialog .el-dialog__footer .el-button {
min-width: 88px;
height: 36px;
}
.banner-editor-section {
padding: 4px 0 18px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.banner-editor-section + .banner-editor-section {
padding-top: 18px;
}
.banner-editor-section:last-child {
padding-bottom: 4px;
border-bottom: 0;
}
.banner-editor-section-title {
margin: 0 0 14px;
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 650;
line-height: 1.4;
}
.banner-editor-grid {
display: grid;
gap: 0 16px;
}
.banner-editor-grid--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.banner-editor-number {
width: 180px !important;
}
.banner-image-editor {
display: flex;
gap: 18px;
width: 100%;
align-items: flex-start;
}
.banner-image-preview-card {
position: relative;
flex: 0 0 240px;
width: 240px;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-fill-color-lighter);
}
.banner-image-preview-card > img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.banner-image-empty {
display: flex;
width: 100%;
height: 100%;
color: var(--el-text-color-placeholder);
font-size: 12px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed var(--el-border-color);
border-radius: 8px;
}
.banner-image-upload-mask {
position: absolute;
inset: 0;
display: flex;
color: #fff;
font-size: 12px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: color-mix(in srgb, var(--el-color-primary) 78%, transparent);
}
.banner-image-upload-mask b {
font-size: 16px;
}
.banner-image-meta {
display: flex;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
flex-direction: column;
gap: 10px;
} }
/* 编辑弹窗里的轮播图预览:和上传控件并排,控制弹窗高度 */
.form-banner-preview { .banner-image-status {
display: block; display: flex;
width: 200px; align-items: center;
height: 112px; gap: 10px;
flex: 0 0 200px; min-width: 0;
object-fit: contain; }
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--app-radius-md); .banner-image-filename {
background: #f0f2f4 !important; overflow: hidden;
color: var(--el-text-color-regular);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.banner-image-hint {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.banner-image-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.banner-image-actions .el-button {
height: 36px;
}
.banner-original-link {
color: var(--el-color-primary);
font-size: 13px;
text-decoration: none;
}
.banner-original-link:hover {
text-decoration: underline;
}
@media (max-width: 600px) {
.banner-editor-dialog {
width: calc(100vw - 24px) !important;
margin: 12px auto !important;
}
.banner-editor-dialog .el-dialog__body {
padding-right: 18px;
padding-left: 18px;
}
.banner-editor-grid--two {
grid-template-columns: 1fr;
}
.banner-image-editor {
flex-direction: column;
align-items: center;
}
.banner-image-preview-card {
flex-basis: 135px;
}
.banner-image-meta {
width: 100%;
}
} }
.dark .banner-thumb, .dark .banner-thumb,
.dark .banner-thumb-empty, .dark .banner-thumb-empty,
.dark .form-banner-preview { .dark .banner-image-preview-card,
.dark .banner-image-empty {
background: #353b45 !important; background: #353b45 !important;
color: #b9c1cb; color: #b9c1cb;
} }
+3 -1
View File
@@ -59,8 +59,10 @@
} }
.creator-drama-cover img, .creator-drama-cover img,
.creator-drama-cover .creator-drama-image,
.creator-cover-preview img, .creator-cover-preview img,
.creator-episode-cover img { .creator-episode-cover img,
.creator-episode-cover .creator-episode-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
+8 -8
View File
@@ -28,12 +28,12 @@ html.dark .role-card {
html.dark .stat-icon, html.dark .stat-icon,
html.dark .role-icon { html.dark .role-icon {
background: rgb(64 158 255 / 16%) !important; background: color-mix(in srgb, var(--el-color-primary) 18%, transparent) !important;
color: var(--el-color-primary) !important; color: var(--el-color-primary) !important;
} }
html.dark .role-active { html.dark .role-active {
background: rgb(64 158 255 / 12%); background: color-mix(in srgb, var(--el-color-primary) 14%, transparent);
border-color: var(--el-color-primary); border-color: var(--el-color-primary);
} }
@@ -106,12 +106,12 @@ html.dark .el-tag.status-badge.is-draft {
color: #c0c4cc !important; color: #c0c4cc !important;
} }
html.dark .el-tag.status-badge.is-pending { html.dark .el-tag.status-badge.is-pending {
--el-tag-bg-color: rgba(64, 158, 255, 0.18); --el-tag-bg-color: color-mix(in srgb, var(--el-color-primary) 18%, transparent);
--el-tag-border-color: rgba(121, 187, 255, 0.45); --el-tag-border-color: color-mix(in srgb, var(--el-color-primary) 45%, transparent);
--el-tag-text-color: #8cc5ff; --el-tag-text-color: var(--el-color-primary-light-5);
background: rgba(64, 158, 255, 0.18) !important; background: color-mix(in srgb, var(--el-color-primary) 18%, transparent) !important;
border-color: rgba(121, 187, 255, 0.45) !important; border-color: color-mix(in srgb, var(--el-color-primary) 45%, transparent) !important;
color: #8cc5ff !important; color: var(--el-color-primary-light-5) !important;
} }
html.dark .el-tag.status-badge.is-offline { html.dark .el-tag.status-badge.is-offline {
--el-tag-bg-color: rgba(245, 108, 108, 0.18); --el-tag-bg-color: rgba(245, 108, 108, 0.18);
+454 -50
View File
@@ -37,7 +37,7 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
white-space: nowrap; white-space: nowrap;
flex-wrap: wrap; flex-wrap: nowrap;
} }
.table-actions .el-button { .table-actions .el-button {
/* 统一尺寸:高度固定、水平内边距,靠行高垂直居中,避免文字被压扁 */ /* 统一尺寸:高度固定、水平内边距,靠行高垂直居中,避免文字被压扁 */
@@ -53,12 +53,6 @@
.table-actions .el-button + .el-button { .table-actions .el-button + .el-button {
margin-left: 0 !important; margin-left: 0 !important;
} }
.table-actions .danger-action {
color: #a66a6a !important;
}
.table-actions .danger-action:hover {
color: #b54848 !important;
}
.drama-cell .drama-cover, .drama-cell .drama-cover,
.drama-cell .drama-cover-image { .drama-cell .drama-cover-image {
width: 44px; width: 44px;
@@ -91,51 +85,461 @@
font-size: 13px; font-size: 13px;
} }
/* 编辑弹窗里的封面预览:固定竖版尺寸,和上传控件并排,避免弹窗过高 */ /* 短剧编辑弹窗:固定宽度、内容区滚动、底部操作固定。 */
.cover-field { .drama-editor-dialog {
display: flex; display: flex;
gap: 12px; max-height: calc(100vh - 40px);
align-items: flex-start; overflow: hidden;
width: 100%; flex-direction: column;
} border-radius: var(--app-radius-lg) !important;
.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: var(--app-radius-md);
background: #f0f2f4 !important;
}
.dark .form-cover-preview {
background: #353b45 !important;
} }
/* 编辑弹窗里的视频预览 */ .drama-editor-dialog .el-dialog__body {
.video-field { flex: 1 1 auto;
display: flex; padding: 8px 24px 0;
gap: 12px; overflow-y: auto;
align-items: flex-start;
width: 100%;
} }
.video-field-actions {
.drama-editor-dialog .el-dialog__footer {
flex: 0 0 auto;
padding: 14px 24px;
background: var(--el-bg-color-overlay);
border-top: 1px solid var(--el-border-color-lighter);
}
.drama-editor-dialog .el-dialog__footer .el-button {
min-width: 88px;
height: 36px;
}
.editor-section {
padding: 4px 0 18px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.editor-section + .editor-section {
padding-top: 18px;
}
.editor-section:last-child {
padding-bottom: 4px;
border-bottom: 0;
}
.editor-section-title {
margin: 0 0 14px;
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 650;
line-height: 1.4;
}
.editor-grid {
display: grid;
gap: 0 16px;
}
.editor-grid--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.editor-number {
width: 180px !important;
}
.readonly-stat {
min-width: 180px;
height: 32px;
padding: 0 12px;
color: var(--el-text-color-secondary);
line-height: 32px;
background: var(--el-fill-color-lighter);
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--el-border-radius-base);
}
.readonly-stat-hint {
margin: 4px 0 0;
color: var(--el-text-color-placeholder);
font-size: 12px;
line-height: 1.4;
}
.cover-editor {
display: flex;
gap: 18px;
align-items: flex-start;
}
.cover-preview-card {
position: relative;
display: grid;
flex: 0 0 96px;
width: 96px;
height: 128px;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-fill-color-lighter);
}
.cover-preview-card img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
max-height: 100%;
object-fit: cover;
}
.cover-preview-empty {
display: flex;
height: 100%;
color: var(--el-text-color-placeholder);
font-size: 12px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed var(--el-border-color);
border-radius: 8px;
}
.cover-upload-mask {
position: absolute;
inset: 0;
display: flex;
color: #fff;
font-size: 12px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: color-mix(in srgb, var(--el-color-primary) 78%, transparent);
}
.cover-upload-mask b {
font-size: 16px;
}
.cover-editor-meta {
display: flex;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
flex-direction: column;
gap: 10px;
} }
.form-video-preview {
.cover-status-line {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.cover-filename {
overflow: hidden;
color: var(--el-text-color-regular);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.cover-hint {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.cover-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.cover-actions .el-button {
height: 36px;
}
.cover-original-link {
color: var(--el-color-primary);
font-size: 13px;
text-decoration: none;
}
.cover-original-link:hover {
text-decoration: underline;
}
@media (max-width: 600px) {
.drama-editor-dialog {
width: calc(100vw - 24px) !important;
margin: 12px auto !important;
}
.drama-editor-dialog .el-dialog__body {
padding-right: 18px;
padding-left: 18px;
}
.editor-grid--two {
grid-template-columns: 1fr;
}
.cover-editor {
flex-direction: column;
align-items: center;
}
.cover-preview-card {
flex-basis: 128px;
}
.cover-editor-meta {
width: 100%;
}
}
html.dark .cover-preview-card,
html.dark .cover-preview-empty {
background: var(--el-fill-color-light);
}
/* 剧集编辑弹窗 */
.episode-editor-dialog {
display: flex;
max-height: calc(100vh - 40px);
overflow: hidden;
flex-direction: column;
border-radius: var(--app-radius-lg) !important;
}
.episode-editor-dialog .el-dialog__body {
flex: 1 1 auto;
padding: 8px 24px 0;
overflow-y: auto;
}
.episode-editor-dialog .el-dialog__footer {
flex: 0 0 auto;
padding: 14px 24px;
background: var(--el-bg-color-overlay);
border-top: 1px solid var(--el-border-color-lighter);
}
.episode-editor-dialog .el-dialog__footer .el-button {
min-width: 88px;
height: 36px;
}
.episode-editor-section {
padding: 4px 0 18px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.episode-editor-section + .episode-editor-section {
padding-top: 18px;
}
.episode-editor-section:last-child {
padding-bottom: 4px;
border-bottom: 0;
}
.episode-editor-section-title {
margin: 0 0 14px;
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 650;
line-height: 1.4;
}
.episode-editor-grid {
display: grid;
gap: 0 16px;
}
.episode-editor-grid--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.episode-editor-grid--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.episode-editor-number {
width: 180px !important;
}
.episode-video-editor {
display: flex;
gap: 18px;
width: 100%;
align-items: flex-start;
}
.episode-video-preview-card {
position: relative;
flex: 0 0 240px;
width: 240px;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: 10px;
background: #000;
}
.episode-editor-video {
display: block; display: block;
width: 200px; width: 100%;
height: 112px; height: 100%;
flex: 0 0 200px;
object-fit: contain; object-fit: contain;
border: 1px solid var(--el-border-color-lighter); background: #000;
border-radius: var(--app-radius-md); }
background: #000 !important;
.episode-video-empty-upload,
.episode-video-empty-upload .el-upload {
width: 100%;
height: 100%;
}
.episode-video-empty-upload .el-upload-dragger {
display: flex;
width: 100%;
height: 100%;
padding: 0;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-lighter);
border: 1px dashed var(--el-border-color);
border-radius: 9px;
align-items: center;
justify-content: center;
}
.episode-video-empty-copy {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
color: var(--el-text-color-secondary);
}
.episode-video-empty-copy b {
color: var(--el-text-color-regular);
font-size: 13px;
}
.episode-video-empty-copy span {
font-size: 11px;
}
.episode-video-upload-mask {
position: absolute;
inset: 0;
display: flex;
color: #fff;
font-size: 12px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: color-mix(in srgb, var(--el-color-primary) 78%, transparent);
}
.episode-video-upload-mask b {
font-size: 16px;
}
.episode-video-meta {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
gap: 10px;
}
.episode-video-status {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.episode-video-filename {
overflow: hidden;
color: var(--el-text-color-regular);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.episode-video-hint {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.episode-video-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.episode-video-actions .el-button {
height: 36px;
}
.episode-video-link {
color: var(--el-color-primary);
font-size: 13px;
text-decoration: none;
}
.episode-video-link:hover {
text-decoration: underline;
}
.episode-video-empty-upload {
cursor: pointer;
}
@media (max-width: 600px) {
.episode-editor-dialog {
width: calc(100vw - 24px) !important;
margin: 12px auto !important;
}
.episode-editor-dialog .el-dialog__body {
padding-right: 18px;
padding-left: 18px;
}
.episode-editor-grid--two,
.episode-editor-grid--three {
grid-template-columns: 1fr;
}
.episode-video-editor {
flex-direction: column;
align-items: center;
}
.episode-video-preview-card {
flex-basis: 135px;
}
.episode-video-meta {
width: 100%;
}
}
html.dark .episode-video-preview-card,
html.dark .episode-video-empty-upload .el-upload-dragger {
background: var(--el-fill-color-light);
} }
/* 剧集预览弹窗里的播放器 */ /* 剧集预览弹窗里的播放器 */
@@ -163,7 +567,7 @@
/* 列表里点视频即放大播放,给个悬停反馈 */ /* 列表里点视频即放大播放,给个悬停反馈 */
.episode-video-thumb:hover { .episode-video-thumb:hover {
border-color: var(--el-color-primary); border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.25); box-shadow: 0 0 0 2px color-mix(in srgb, var(--el-color-primary) 25%, transparent);
} }
.dark .table-result-count { .dark .table-result-count {
color: #a6aeb9; color: #a6aeb9;
@@ -233,8 +637,8 @@
.table-actions .el-button--primary { .table-actions .el-button--primary {
--el-button-bg-color: var(--el-color-primary); --el-button-bg-color: var(--el-color-primary);
--el-button-border-color: var(--el-color-primary); --el-button-border-color: var(--el-color-primary);
--el-button-hover-bg-color: var(--el-color-primary-light-3); --el-button-hover-bg-color: var(--el-color-primary-dark-2);
--el-button-hover-border-color: var(--el-color-primary-light-3); --el-button-hover-border-color: var(--el-color-primary-dark-2);
} }
.table-actions .el-button--success { .table-actions .el-button--success {
--el-button-bg-color: #4d866a; --el-button-bg-color: #4d866a;
@@ -243,10 +647,10 @@
--el-button-hover-border-color: #3c7358; --el-button-hover-border-color: #3c7358;
} }
.table-actions .el-button--danger { .table-actions .el-button--danger {
--el-button-bg-color: #bd5b5b; --el-button-bg-color: var(--el-color-danger);
--el-button-border-color: #bd5b5b; --el-button-border-color: var(--el-color-danger);
--el-button-hover-bg-color: #a94b4b; --el-button-hover-bg-color: var(--el-color-danger-dark-2);
--el-button-hover-border-color: #a94b4b; --el-button-hover-border-color: var(--el-color-danger-dark-2);
} }
.pagination { .pagination {
display: flex; display: flex;
+1
View File
@@ -8,4 +8,5 @@
@import './console.css'; @import './console.css';
@import './creator-center.css'; @import './creator-center.css';
@import './layout.css'; @import './layout.css';
@import './menu.css';
@import './dark.css'; @import './dark.css';
+144
View File
@@ -0,0 +1,144 @@
/* 左侧菜单:统一行高、缩进、图标和选中态。 */
#v-menu .el-menu {
padding: 4px 0;
background: transparent;
}
#v-menu .el-menu-item,
#v-menu .el-sub-menu__title {
/* 42px = 16px padding + 18px icon + 8px gap,父子菜单文字共用该基线。 */
height: 44px;
padding-left: 16px !important;
margin: 4px 8px 4px 0;
overflow: hidden;
color: var(--left-menu-text-color);
font-size: 14px;
line-height: 44px;
border-radius: 8px;
transition:
color var(--transition-time-02),
background-color var(--transition-time-02);
}
#v-menu .el-menu--inline {
padding-left: 0 !important;
}
#v-menu .el-menu--inline .el-menu-item,
#v-menu .el-menu--inline .el-sub-menu__title {
height: 40px;
padding-left: 0 !important;
margin: 3px 8px 3px 42px;
line-height: 40px;
}
#v-menu .el-menu--inline .app-icon {
display: none;
}
#v-menu .el-menu-item .app-icon,
#v-menu .el-sub-menu__title .app-icon {
flex: 0 0 18px;
width: 18px;
height: 18px;
margin-right: 8px;
}
#v-menu .el-menu-item:hover,
#v-menu .el-sub-menu__title:hover,
#v-menu .el-sub-menu.is-opened > .el-sub-menu__title,
#v-menu .el-sub-menu.is-active > .el-sub-menu__title,
#v-menu .el-menu-item.is-active {
color: var(--el-color-primary) !important;
background-color: var(--el-color-primary-light-9) !important;
}
#v-menu .el-menu--inline .el-menu-item:hover,
#v-menu .el-menu--inline .el-menu-item.is-active {
color: var(--el-color-primary) !important;
background-color: transparent !important;
}
#v-menu .el-menu .el-menu {
background: transparent;
}
#v-menu .el-menu--collapse .el-menu-item,
#v-menu .el-menu--collapse .el-sub-menu__title {
justify-content: center;
padding: 0 !important;
margin: 4px 6px;
}
#v-menu .el-menu--collapse .app-icon {
margin-right: 0;
}
html.dark #v-menu .el-menu-item:hover,
html.dark #v-menu .el-sub-menu__title:hover {
color: #fff !important;
background-color: color-mix(in srgb, var(--el-color-primary) 72%, transparent) !important;
}
html.dark #v-menu .el-sub-menu.is-opened > .el-sub-menu__title,
html.dark #v-menu .el-sub-menu.is-active > .el-sub-menu__title,
html.dark #v-menu .el-menu-item.is-active {
color: #fff !important;
background-color: var(--app-primary-strong) !important;
}
html.dark #v-menu .el-menu--inline .el-menu-item:hover,
html.dark #v-menu .el-menu--inline .el-menu-item.is-active {
color: #fff !important;
background-color: transparent !important;
}
.v-menu-popper--vertical,
.v-menu-popper--horizontal {
padding: 4px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-bg-color-overlay);
}
.v-menu-popper--vertical .el-menu-item,
.v-menu-popper--vertical .el-sub-menu__title,
.v-menu-popper--horizontal .el-menu-item,
.v-menu-popper--horizontal .el-sub-menu__title {
height: 40px;
padding-left: 16px !important;
margin: 2px 4px;
color: var(--el-text-color-regular);
font-size: 14px;
line-height: 40px;
border-radius: 8px;
}
.v-menu-popper--vertical .app-icon,
.v-menu-popper--horizontal .app-icon {
flex: 0 0 18px;
width: 18px;
height: 18px;
margin-right: 8px;
}
html .v-menu-popper--vertical .el-menu-item:hover,
html .v-menu-popper--horizontal .el-menu-item:hover,
html .v-menu-popper--vertical .el-menu-item.is-active,
html .v-menu-popper--horizontal .el-menu-item.is-active {
color: var(--el-color-primary) !important;
background-color: transparent !important;
}
html.dark .v-menu-popper--vertical,
html.dark .v-menu-popper--horizontal {
border-color: var(--el-border-color);
}
html.dark .v-menu-popper--vertical .el-menu-item:hover,
html.dark .v-menu-popper--horizontal .el-menu-item:hover,
html.dark .v-menu-popper--vertical .el-menu-item.is-active,
html.dark .v-menu-popper--horizontal .el-menu-item.is-active {
color: #fff !important;
background-color: transparent !important;
}
+60 -21
View File
@@ -6,7 +6,7 @@ body {
min-width: 0; min-width: 0;
min-height: 100vh; min-height: 100vh;
} }
.page-heading { .page-heading {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -396,7 +396,11 @@ body {
background: linear-gradient(145deg, #f8d6d7, #ffece4); background: linear-gradient(145deg, #f8d6d7, #ffece4);
} }
.purple { .purple {
background: linear-gradient(145deg, var(--el-color-primary-light-8), var(--el-color-primary-light-9)); background: linear-gradient(
145deg,
var(--el-color-primary-light-8),
var(--el-color-primary-light-9)
);
} }
.peach { .peach {
background: linear-gradient(145deg, #ffd6bd, #fff0d7); background: linear-gradient(145deg, #ffd6bd, #fff0d7);
@@ -507,6 +511,8 @@ body {
.episode-context { .episode-context {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 24px;
padding: 20px 0; padding: 20px 0;
border-bottom: 1px solid #f0eff4; border-bottom: 1px solid #f0eff4;
} }
@@ -516,32 +522,68 @@ body {
font-size: 22px; font-size: 22px;
border-radius: var(--app-radius-md); border-radius: var(--app-radius-md);
} }
.episode-context-copy { .episode-context-info {
margin-left: 12px;
display: flex; display: flex;
min-width: 0;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
} }
.episode-context-copy > span {
font-size: 9px; .episode-context-label {
color: #a7a5b3; color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.4;
} }
.episode-context-copy b {
font-size: 13px; .episode-context-title {
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 650;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
} }
.episode-context-copy small {
font-size: 9px; .episode-context-count {
color: #aaa8b6; color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.4;
} }
.drama-select {
margin-left: auto; .episode-switcher {
width: 215px; display: flex;
flex: 0 0 clamp(320px, 32vw, 360px);
width: clamp(320px, 32vw, 360px);
min-width: 0;
flex-direction: column;
gap: 6px;
} }
.drama-select .el-input__wrapper {
.drama-select,
.status-select {
width: 100%;
}
.drama-select .el-input__wrapper,
.status-select .el-input__wrapper {
box-shadow: 0 0 0 1px #efedf3 inset !important; box-shadow: 0 0 0 1px #efedf3 inset !important;
} }
.drama-select .el-input__inner { .drama-select .el-input__inner,
font-size: 10px; .status-select .el-input__inner {
font-size: 13px;
}
@media (max-width: 768px) {
.episode-context {
align-items: stretch;
gap: 14px;
flex-direction: column;
}
.episode-switcher {
flex-basis: auto;
width: 100%;
}
} }
.episode-toolbar { .episode-toolbar {
display: flex; display: flex;
@@ -615,9 +657,6 @@ body {
padding: 5px; padding: 5px;
color: #9997a7; color: #9997a7;
} }
.episode-actions .danger-action:hover {
color: #e77680;
}
.episode-footer { .episode-footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
+55 -20
View File
@@ -15,13 +15,13 @@
font-family: var(--font-body); font-family: var(--font-body);
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
background: var(--el-bg-color-page); background: var(--el-bg-color-page);
--el-color-primary: #409eff; --el-color-primary: #c4322a;
--el-color-primary-light-3: #79bbff; --el-color-primary-light-3: #d3615b;
--el-color-primary-light-5: #a0cfff; --el-color-primary-light-5: #e19995;
--el-color-primary-light-7: #c6e2ff; --el-color-primary-light-7: #f0c2c0;
--el-color-primary-light-8: #d9ecff; --el-color-primary-light-8: #f5d9d8;
--el-color-primary-light-9: #ecf5ff; --el-color-primary-light-9: #faeeed;
--el-color-primary-dark-2: #337ecc; --el-color-primary-dark-2: #9d2822;
--purple: var(--el-color-primary); --purple: var(--el-color-primary);
--muted: #909399; --muted: #909399;
--line: #e4e7ed; --line: #e4e7ed;
@@ -30,6 +30,41 @@ body {
background: var(--el-bg-color-page); background: var(--el-bg-color-page);
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
} }
.el-button--primary {
--el-button-bg-color: var(--el-color-primary);
--el-button-border-color: var(--el-color-primary);
--el-button-hover-bg-color: var(--el-color-primary-dark-2);
--el-button-hover-border-color: var(--el-color-primary-dark-2);
--el-button-active-bg-color: var(--el-color-primary-dark-2);
--el-button-active-border-color: var(--el-color-primary-dark-2);
}
.el-button--danger {
--el-button-text-color: #fff;
--el-button-bg-color: var(--el-color-danger);
--el-button-border-color: var(--el-color-danger);
--el-button-hover-bg-color: var(--el-color-danger-dark-2);
--el-button-hover-border-color: var(--el-color-danger-dark-2);
color: #fff !important;
background: var(--el-color-danger) !important;
border-color: var(--el-color-danger) !important;
}
.el-button--danger:hover {
background: var(--el-color-danger-dark-2) !important;
border-color: var(--el-color-danger-dark-2) !important;
}
.el-button--danger.is-disabled,
.el-button--danger.is-disabled:hover {
color: var(--el-text-color-placeholder) !important;
background: var(--el-fill-color-light) !important;
border-color: var(--el-border-color-lighter) !important;
cursor: not-allowed;
opacity: 1;
}
.el-message-box__btns .el-button--danger {
color: #fff !important;
background: var(--el-color-danger) !important;
border-color: var(--el-color-danger) !important;
}
.eyebrow { .eyebrow {
letter-spacing: 0.7px; letter-spacing: 0.7px;
color: #9298a1; color: #9298a1;
@@ -52,8 +87,8 @@ body {
box-shadow: none !important; box-shadow: none !important;
} }
.primary-button:hover { .primary-button:hover {
background: var(--el-color-primary-light-3) !important; background: var(--el-color-primary-dark-2) !important;
border-color: var(--el-color-primary-light-3) !important; border-color: var(--el-color-primary-dark-2) !important;
} }
.stats-grid { .stats-grid {
gap: 12px; gap: 12px;
@@ -73,7 +108,7 @@ body {
} }
.stat-icon { .stat-icon {
border-radius: var(--app-radius-sm); border-radius: var(--app-radius-sm);
background: #ecf5ff !important; background: var(--el-color-primary-light-9) !important;
color: var(--el-color-primary) !important; color: var(--el-color-primary) !important;
} }
.stat-value { .stat-value {
@@ -97,7 +132,7 @@ body {
color: #61738a; color: #61738a;
} }
.chart-svg path:first-of-type { .chart-svg path:first-of-type {
fill: #d9ecff; fill: var(--el-color-primary-light-8);
} }
.chart-svg path:last-of-type { .chart-svg path:last-of-type {
stroke: var(--el-color-primary); stroke: var(--el-color-primary);
@@ -241,12 +276,12 @@ body {
border-color: #e7e9ec; border-color: #e7e9ec;
} }
.role-active { .role-active {
border-color: #a0cfff; border-color: var(--el-color-primary-light-5);
background: #ecf5ff; background: var(--el-color-primary-light-9);
} }
.role-icon { .role-icon {
border-radius: var(--app-radius-sm); border-radius: var(--app-radius-sm);
background: #ecf5ff !important; background: var(--el-color-primary-light-9) !important;
color: var(--el-color-primary) !important; color: var(--el-color-primary) !important;
} }
.role-badge { .role-badge {
@@ -314,12 +349,12 @@ body {
color: #6b7280 !important; color: #6b7280 !important;
} }
.el-tag.status-badge.is-pending { .el-tag.status-badge.is-pending {
--el-tag-bg-color: #ecf5ff; --el-tag-bg-color: var(--el-color-primary-light-9);
--el-tag-border-color: #c6e2ff; --el-tag-border-color: var(--el-color-primary-light-7);
--el-tag-text-color: #2b7de9; --el-tag-text-color: var(--el-color-primary);
background: #ecf5ff !important; background: var(--el-color-primary-light-9) !important;
border-color: #c6e2ff !important; border-color: var(--el-color-primary-light-7) !important;
color: #2b7de9 !important; color: var(--el-color-primary) !important;
} }
.el-tag.status-badge.is-offline { .el-tag.status-badge.is-offline {
--el-tag-bg-color: #fdecec; --el-tag-bg-color: #fdecec;
+8 -6
View File
@@ -32,8 +32,9 @@ export async function uploadFile(file: File, options: UploadFileOptions = {}): P
bizType bizType
}) })
const info = initRes.data const info = initRes.data
const chunkSize = Number(info.chunkSize)
options.onSession?.(info.uploadId) options.onSession?.(info.uploadId)
const total = Math.max(info.totalChunks, 1) const total = Math.max(Number(info.totalChunks), 1)
const pending: number[] = Array.from({ length: total }, (_, index) => index) const pending: number[] = Array.from({ length: total }, (_, index) => index)
let finished = 0 let finished = 0
@@ -41,8 +42,8 @@ export async function uploadFile(file: File, options: UploadFileOptions = {}): P
for (;;) { for (;;) {
const index = pending.shift() const index = pending.shift()
if (index === undefined) return if (index === undefined) return
const start = index * info.chunkSize const start = index * chunkSize
const chunk = file.slice(start, Math.min(start + info.chunkSize, file.size)) const chunk = file.slice(start, Math.min(start + chunkSize, file.size))
await uploadChunkApi(info.uploadId, index, chunk, file.name) await uploadChunkApi(info.uploadId, index, chunk, file.name)
finished += 1 finished += 1
onProgress?.(Math.min(95, Math.round((finished / total) * 95))) onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
@@ -68,7 +69,8 @@ export async function resumeUpload(
): Promise<MediaAsset> { ): Promise<MediaAsset> {
const { concurrency = 3, onProgress } = options const { concurrency = 3, onProgress } = options
const progress = (await getUploadProgressApi(uploadId)).data const progress = (await getUploadProgressApi(uploadId)).data
const total = Math.max(progress.totalChunks, 1) const chunkSize = Number(progress.chunkSize)
const total = Math.max(Number(progress.totalChunks), 1)
const received = Math.min(progress.receivedChunks || 0, total) const received = Math.min(progress.receivedChunks || 0, total)
const pending: number[] = [] const pending: number[] = []
for (let index = received; index < total; index += 1) { for (let index = received; index < total; index += 1) {
@@ -81,8 +83,8 @@ export async function resumeUpload(
for (;;) { for (;;) {
const index = pending.shift() const index = pending.shift()
if (index === undefined) return if (index === undefined) return
const start = index * progress.chunkSize const start = index * chunkSize
const chunk = file.slice(start, Math.min(start + progress.chunkSize, file.size)) const chunk = file.slice(start, Math.min(start + chunkSize, file.size))
await uploadChunkApi(uploadId, index, chunk, file.name) await uploadChunkApi(uploadId, index, chunk, file.name)
finished += 1 finished += 1
onProgress?.(Math.min(95, Math.round((finished / total) * 95))) onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
+5 -64
View File
@@ -1,18 +1,17 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup lang="ts"> <script setup lang="ts">
import { LoginForm, RegisterForm } from './components' import { LoginForm } from './components'
import { ThemeSwitch } from '@/components/ThemeSwitch' import { ThemeSwitch } from '@/components/ThemeSwitch'
import { LocaleDropdown } from '@/components/LocaleDropdown' import { LocaleDropdown } from '@/components/LocaleDropdown'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { getCssVar, underlineToHump } from '@/utils' import { getCssVar, underlineToHump } from '@/utils'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { computed, ref } from 'vue' import { computed } from 'vue'
import { ElScrollbar } from 'element-plus' import { ElScrollbar } from 'element-plus'
const appStore = useAppStore() const appStore = useAppStore()
const { t } = useI18n() const { t } = useI18n()
const isLogin = ref(true)
const isDark = computed(() => appStore.getIsDark) const isDark = computed(() => appStore.getIsDark)
const toolColor = computed(() => (isDark.value ? '#e5eaf3' : '#303133')) const toolColor = computed(() => (isDark.value ? '#e5eaf3' : '#303133'))
@@ -35,12 +34,6 @@ const themeChange = () => {
<div class="hero-copy"> <div class="hero-copy">
<p class="hero-kicker">{{ t('login.consoleLabel') }}</p> <p class="hero-kicker">{{ t('login.consoleLabel') }}</p>
<h1>{{ t('login.welcome') }}</h1> <h1>{{ t('login.welcome') }}</h1>
<p>{{ t('login.message') }}</p>
<ul>
<li>{{ t('login.pointDrama') }}</li>
<li>{{ t('login.pointPublish') }}</li>
<li>{{ t('login.pointAccess') }}</li>
</ul>
</div> </div>
</section> </section>
@@ -50,8 +43,7 @@ const themeChange = () => {
<LocaleDropdown :color="toolColor" /> <LocaleDropdown :color="toolColor" />
</div> </div>
<div class="login-card"> <div class="login-card">
<LoginForm v-if="isLogin" @to-register="isLogin = false" /> <LoginForm />
<RegisterForm v-else @to-login="isLogin = true" />
</div> </div>
</section> </section>
</div> </div>
@@ -130,38 +122,14 @@ const themeChange = () => {
line-height: 1.2; line-height: 1.2;
color: #fff; color: #fff;
} }
p {
margin: 0;
font-size: 15px;
line-height: 1.7;
color: rgb(255 255 255 / 82%);
}
} }
.hero-kicker { .hero-kicker {
margin: 0;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: #d9ecff !important; color: var(--el-color-primary-light-8) !important;
}
.hero-copy ul {
display: flex;
padding: 0;
margin: 28px 0 0;
list-style: none;
flex-wrap: wrap;
gap: 10px;
li {
padding: 8px 14px;
font-size: 13px;
color: #fff;
background: rgb(255 255 255 / 12%);
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 999px;
}
} }
.login-panel { .login-panel {
@@ -228,10 +196,6 @@ const themeChange = () => {
font-size: 28px; font-size: 28px;
} }
.hero-copy ul {
display: none;
}
.login-panel { .login-panel {
padding: 44px 16px 12px; padding: 44px 16px 12px;
} }
@@ -253,10 +217,6 @@ const themeChange = () => {
padding: 16px 18px 18px; padding: 16px 18px 18px;
} }
.hero-copy p {
font-size: 13px;
}
.login-card { .login-card {
padding: 28px 20px 24px; padding: 28px 20px 24px;
} }
@@ -282,25 +242,6 @@ const themeChange = () => {
} }
} }
.login-register {
display: flex;
margin-top: 16px;
font-size: 14px;
color: var(--el-text-color-secondary);
gap: 6px;
align-items: center;
justify-content: center;
button {
padding: 0;
font: inherit;
color: var(--el-color-primary);
cursor: pointer;
background: none;
border: 0;
}
}
.login-card :deep(.el-button--primary) { .login-card :deep(.el-button--primary) {
--el-button-bg-color: var(--app-brand); --el-button-bg-color: var(--app-brand);
--el-button-border-color: var(--app-brand); --el-button-border-color: var(--app-brand);
+14 -35
View File
@@ -4,9 +4,9 @@
import { reactive, ref, watch, onMounted, unref } from 'vue' import { reactive, ref, watch, onMounted, unref } from 'vue'
import { Form, FormSchema } from '@/components/Form' import { Form, FormSchema } from '@/components/Form'
import { useI18n } from '@/hooks/web/useI18n' import { useI18n } from '@/hooks/web/useI18n'
import { ElCheckbox, ElLink } from 'element-plus' import { ElCheckbox, ElLink, ElMessage } from 'element-plus'
import { useForm } from '@/hooks/web/useForm' import { useForm } from '@/hooks/web/useForm'
import { loginApi, getTestRoleApi, getAdminRoleApi } from '@/api/login' import { loginApi, getAdminRoleApi } from '@/api/login'
import { useAppStore } from '@/store/modules/app' import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission' import { usePermissionStore } from '@/store/modules/permission'
import { landingPath } from '@/utils/landing' import { landingPath } from '@/utils/landing'
@@ -19,8 +19,6 @@ import { BaseButton } from '@/components/Button'
const { required } = useValidator() const { required } = useValidator()
const emit = defineEmits(['to-register'])
const appStore = useAppStore() const appStore = useAppStore()
const userStore = useUserStore() const userStore = useUserStore()
@@ -94,7 +92,11 @@ const schema = reactive<FormSchema[]>([
<> <>
<div class="flex justify-between items-center w-[100%]"> <div class="flex justify-between items-center w-[100%]">
<ElCheckbox v-model={remember.value} label={t('login.remember')} size="small" /> <ElCheckbox v-model={remember.value} label={t('login.remember')} size="small" />
<ElLink type="primary" underline={false}> <ElLink
type="primary"
underline={false}
onClick={() => ElMessage.info(t('login.forgetPasswordHint'))}
>
{t('login.forgetPassword')} {t('login.forgetPassword')}
</ElLink> </ElLink>
</div> </div>
@@ -113,24 +115,11 @@ const schema = reactive<FormSchema[]>([
slots: { slots: {
default: () => { default: () => {
return ( return (
<> <div class="w-[100%]">
<div class="w-[100%]"> <BaseButton loading={loading.value} type="primary" class="w-[100%]" onClick={signIn}>
<BaseButton {t('login.login')}
loading={loading.value} </BaseButton>
type="primary" </div>
class="w-[100%]"
onClick={signIn}
>
{t('login.login')}
</BaseButton>
</div>
<div class="login-register">
<span>{t('login.noAccount')}</span>
<button type="button" onClick={toRegister}>
{t('login.register')}
</button>
</div>
</>
) )
} }
} }
@@ -218,16 +207,11 @@ const getRole = async () => {
const params = { const params = {
roleName: formData.username roleName: formData.username
} }
const res = const res = await getAdminRoleApi(params)
appStore.getDynamicRouter && appStore.getServerDynamicRouter
? await getAdminRoleApi(params)
: await getTestRoleApi(params)
if (res) { if (res) {
const routers = res.data || [] const routers = res.data || []
userStore.setRoleRouters(routers) userStore.setRoleRouters(routers)
appStore.getDynamicRouter && appStore.getServerDynamicRouter await permissionStore.generateRoutes('server', routers).catch(() => {})
? await permissionStore.generateRoutes('server', routers).catch(() => {})
: await permissionStore.generateRoutes('frontEnd', routers).catch(() => {})
permissionStore.getAddRouters.forEach((route) => { permissionStore.getAddRouters.forEach((route) => {
addRoute(route as RouteRecordRaw) // 动态添加可访问路由表 addRoute(route as RouteRecordRaw) // 动态添加可访问路由表
@@ -238,11 +222,6 @@ const getRole = async () => {
push({ path: redirect.value || landingPath() }) push({ path: redirect.value || landingPath() })
} }
} }
// 去注册页面
const toRegister = () => {
emit('to-register')
}
</script> </script>
<template> <template>
-172
View File
@@ -1,172 +0,0 @@
<!-- Author: fruit.com.cn -->
<script setup lang="tsx">
import { Form, FormSchema } from '@/components/Form'
import { reactive, ref } from 'vue'
import { useI18n } from '@/hooks/web/useI18n'
import { useForm } from '@/hooks/web/useForm'
import { ElButton, ElInput, FormRules } from 'element-plus'
import { useValidator } from '@/hooks/web/useValidator'
const emit = defineEmits(['to-login'])
const { formRegister, formMethods } = useForm()
const { getElFormExpose } = formMethods
const { t } = useI18n()
const { required } = useValidator()
const schema = reactive<FormSchema[]>([
{
field: 'title',
colProps: {
span: 24
},
formItemProps: {
slots: {
default: () => {
return (
<div class="login-heading">
<h2>{t('login.register')}</h2>
<p>{t('login.message')}</p>
</div>
)
}
}
}
},
{
field: 'username',
label: t('login.username'),
value: '',
component: 'Input',
colProps: {
span: 24
},
componentProps: {
placeholder: t('login.usernamePlaceholder')
}
},
{
field: 'password',
label: t('login.password'),
value: '',
component: 'InputPassword',
colProps: {
span: 24
},
componentProps: {
style: {
width: '100%'
},
strength: true,
placeholder: t('login.passwordPlaceholder')
}
},
{
field: 'check_password',
label: t('login.checkPassword'),
value: '',
component: 'InputPassword',
colProps: {
span: 24
},
componentProps: {
style: {
width: '100%'
},
strength: true,
placeholder: t('login.passwordPlaceholder')
}
},
{
field: 'code',
label: t('login.code'),
colProps: {
span: 24
},
formItemProps: {
slots: {
default: (formData) => {
return (
<div class="w-[100%] flex">
<ElInput v-model={formData.code} placeholder={t('login.codePlaceholder')} />
</div>
)
}
}
}
},
{
field: 'register',
colProps: {
span: 24
},
formItemProps: {
slots: {
default: () => {
return (
<>
<div class="w-[100%]">
<ElButton
type="primary"
class="w-[100%]"
loading={loading.value}
onClick={loginRegister}
>
{t('login.register')}
</ElButton>
</div>
<div class="w-[100%] mt-15px">
<ElButton class="w-[100%]" onClick={toLogin}>
{t('login.hasUser')}
</ElButton>
</div>
</>
)
}
}
}
}
])
const rules: FormRules = {
username: [required()],
password: [required()],
check_password: [required()],
code: [required()]
}
const toLogin = () => {
emit('to-login')
}
const loading = ref(false)
const loginRegister = async () => {
const formRef = await getElFormExpose()
formRef?.validate(async (valid) => {
if (valid) {
try {
loading.value = true
toLogin()
} finally {
loading.value = false
}
}
})
}
</script>
<template>
<Form
:schema="schema"
:rules="rules"
label-position="top"
hide-required-asterisk
size="large"
class="login-form"
@register="formRegister"
/>
</template>
+1 -2
View File
@@ -1,6 +1,5 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import LoginForm from './LoginForm.vue' import LoginForm from './LoginForm.vue'
import RegisterForm from './RegisterForm.vue'
export { LoginForm, RegisterForm } export { LoginForm }
+3 -2
View File
@@ -102,6 +102,7 @@ async function refund(row) {
{ {
confirmButtonText: '确认退款', confirmButtonText: '确认退款',
cancelButtonText: '取消', cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger',
inputValidator: (val) => (val && val.trim() ? true : '退款原因不能为空') inputValidator: (val) => (val && val.trim() ? true : '退款原因不能为空')
} }
) )
@@ -375,8 +376,8 @@ function onTabChange(name) {
> >
<el-button <el-button
v-if="canOrderUpdate && (row.status === 'PAID' || row.status === 'DELIVERED')" v-if="canOrderUpdate && (row.status === 'PAID' || row.status === 'DELIVERED')"
type="danger"
size="small" size="small"
class="danger-action"
@click="refund(row)" @click="refund(row)"
>退款</el-button >退款</el-button
> >
@@ -461,8 +462,8 @@ function onTabChange(name) {
<div class="table-actions"> <div class="table-actions">
<el-button <el-button
v-if="canEntitlementUpdate && row.status === 'ACTIVE'" v-if="canEntitlementUpdate && row.status === 'ACTIVE'"
type="danger"
size="small" size="small"
class="danger-action"
@click="revokeEntitlement(row)" @click="revokeEntitlement(row)"
>回收</el-button >回收</el-button
> >
@@ -1,9 +1,10 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup> <script setup>
import { onMounted, reactive, ref } from 'vue' import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons' import { AppIcons } from '@/constants/icons'
import { AppImage } from '@/components/AppImage'
import { import {
createBannerApi, createBannerApi,
deleteBannerApi, deleteBannerApi,
@@ -32,7 +33,10 @@ const dialogVisible = ref(false)
const saving = ref(false) const saving = ref(false)
const uploadingImage = ref(false) const uploadingImage = ref(false)
const imagePercent = ref(0) const imagePercent = ref(0)
const imageError = ref('')
const imageFileName = ref('')
const editingId = ref(null) const editingId = ref(null)
const formRef = ref()
const form = reactive({ const form = reactive({
imageUrl: '', imageUrl: '',
position: 'HOME_TOP', position: 'HOME_TOP',
@@ -53,6 +57,47 @@ const linkTypeOptions = [
{ code: 'URL', text: '打开链接' } { code: 'URL', text: '打开链接' }
] ]
const MAX_BANNER_IMAGE_SIZE = 5 * 1024 * 1024
const BANNER_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const formRules = {
imageUrl: [{ required: true, message: '请上传轮播图片', trigger: 'change' }],
position: [{ required: true, message: '请选择展示位置', trigger: 'change' }],
linkValue: [
{
validator: (_rule, value, callback) => {
if (form.linkType === 'DRAMA' && !value) {
callback(new Error('请选择要跳转的短剧'))
} else if (form.linkType === 'URL' && !String(value || '').trim()) {
callback(new Error('请填写跳转地址'))
} else {
callback()
}
},
trigger: 'change'
}
]
}
const bannerPreviewList = computed(() => list.value.map((item) => item.imageUrl).filter(Boolean))
const imageDisplayName = computed(
() => imageFileName.value || (form.imageUrl ? bannerBasename(form.imageUrl) : '尚未选择图片')
)
function bannerPreviewIndex(row) {
return Math.max(0, bannerPreviewList.value.indexOf(row.imageUrl))
}
function bannerBasename(path) {
if (!path) return ''
const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean)
}
async function resetValidation() {
await nextTick()
formRef.value?.clearValidate()
}
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
@@ -100,7 +145,10 @@ function openCreate() {
sort: nextSort(), sort: nextSort(),
status: 1 status: 1
}) })
imageFileName.value = ''
imageError.value = ''
dialogVisible.value = true dialogVisible.value = true
resetValidation()
} }
function openEdit(row) { function openEdit(row) {
@@ -113,7 +161,10 @@ function openEdit(row) {
sort: row.sort ?? 0, sort: row.sort ?? 0,
status: row.status status: row.status
}) })
imageFileName.value = row.imageUrl ? bannerBasename(row.imageUrl) : ''
imageError.value = ''
dialogVisible.value = true dialogVisible.value = true
resetValidation()
} }
function nextSort() { function nextSort() {
@@ -121,12 +172,11 @@ function nextSort() {
} }
async function saveBanner() { async function saveBanner() {
if (!form.imageUrl.trim()) return ElMessage.warning('请上传轮播图片') if (saving.value || uploadingImage.value) return
if (form.linkType === 'DRAMA' && !form.linkValue) { try {
return ElMessage.warning('请选择要跳转的短剧') await formRef.value?.validate()
} } catch {
if (form.linkType === 'URL' && !form.linkValue.trim()) { return
return ElMessage.warning('请填写跳转地址')
} }
const payload = { const payload = {
...form, ...form,
@@ -150,21 +200,55 @@ async function saveBanner() {
} }
} }
function beforeImageUpload(file) {
imageError.value = ''
if (!BANNER_IMAGE_TYPES.includes(file.type)) {
imageError.value = '图片仅支持 JPG、PNG 或 WebP 格式'
return false
}
if (file.size > MAX_BANNER_IMAGE_SIZE) {
imageError.value = '图片大小不能超过 5MB'
return false
}
return true
}
async function uploadImage(options) { async function uploadImage(options) {
uploadingImage.value = true uploadingImage.value = true
imagePercent.value = 0 imagePercent.value = 0
imageError.value = ''
try { try {
const asset = await uploadFile(options.file, { const asset = await uploadFile(options.file, {
bizType: 'BANNER', bizType: 'BANNER',
onProgress: (percent) => (imagePercent.value = percent) onProgress: (percent) => (imagePercent.value = percent)
}) })
form.imageUrl = asset.url form.imageUrl = asset.url
imageFileName.value = options.file.name
await resetValidation()
ElMessage.success('图片已上传') ElMessage.success('图片已上传')
} catch {
imageError.value = '图片上传失败,请检查网络后重试'
} finally { } finally {
uploadingImage.value = false uploadingImage.value = false
} }
} }
function removeImage() {
if (uploadingImage.value) return
form.imageUrl = ''
imageFileName.value = ''
imageError.value = ''
resetValidation()
}
function beforeDialogClose(done) {
if (saving.value || uploadingImage.value) {
ElMessage.warning(saving.value ? '正在保存,请稍候' : '图片正在上传,请稍候')
return
}
done()
}
async function toggleStatus(row) { async function toggleStatus(row) {
const next = row.status === 1 ? 0 : 1 const next = row.status === 1 ? 0 : 1
await updateBannerStatusApi(row.id, next) await updateBannerStatusApi(row.id, next)
@@ -253,7 +337,15 @@ onMounted(async () => {
> >
<el-table-column label="预览" width="190"> <el-table-column label="预览" width="190">
<template #default="{ row }"> <template #default="{ row }">
<img v-if="row.imageUrl" :src="row.imageUrl" class="banner-thumb" /> <AppImage
v-if="row.imageUrl"
:src="row.imageUrl"
:preview-list="bannerPreviewList"
:initial-index="bannerPreviewIndex(row)"
fit="cover"
class="banner-thumb"
:alt="row.title"
/>
<div v-else class="banner-thumb-empty">暂无图片</div> <div v-else class="banner-thumb-empty">暂无图片</div>
</template> </template>
</el-table-column> </el-table-column>
@@ -335,75 +427,160 @@ onMounted(async () => {
</div> </div>
</section> </section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑轮播图' : '新建轮播图'" width="620px"> <el-dialog
<el-form label-position="top"> v-model="dialogVisible"
<el-form-item label="轮播图片" required> :title="editingId ? '编辑轮播图' : '新建轮播图'"
<div class="banner-field"> width="680px"
<div class="banner-field-actions"> class="banner-editor-dialog"
<el-upload :close-on-click-modal="false"
:show-file-list="false" :before-close="beforeDialogClose"
accept="image/*" >
:http-request="uploadImage" <el-form
:disabled="uploadingImage" ref="formRef"
> :model="form"
<el-button :loading="uploadingImage">{{ :rules="formRules"
form.imageUrl ? '更换图片' : '选择图片' label-position="top"
}}</el-button> class="banner-editor-form"
</el-upload> >
<p v-if="uploadingImage" class="selected-file">上传中 {{ imagePercent }}%</p> <section class="banner-editor-section">
<p v-else-if="form.imageUrl" class="selected-file">图片已上传</p> <h3 class="banner-editor-section-title">轮播图片</h3>
<el-input v-model="form.imageUrl" placeholder="也可以直接填写图片地址" /> <el-form-item prop="imageUrl">
<div class="banner-image-editor">
<div class="banner-image-preview-card">
<img v-if="form.imageUrl" :src="form.imageUrl" alt="轮播图预览" />
<div v-else class="banner-image-empty">
<AppIcon :icon="AppIcons.content" :size="28" />
<span>暂无图片</span>
</div>
<div v-if="uploadingImage" class="banner-image-upload-mask">
<span>上传中</span>
<b>{{ imagePercent }}%</b>
</div>
</div>
<div class="banner-image-meta">
<div class="banner-image-status">
<el-tag :type="form.imageUrl ? 'success' : 'info'" effect="light">
{{ form.imageUrl ? '已上传' : '未上传' }}
</el-tag>
<span class="banner-image-filename" :title="imageDisplayName">
{{ imageDisplayName }}
</span>
</div>
<p class="banner-image-hint">支持 JPG、PNG、WebP,单张不超过 5MB。</p>
<el-alert
v-if="imageError"
:title="imageError"
type="error"
:closable="false"
show-icon
/>
<div class="banner-image-actions">
<el-upload
:show-file-list="false"
accept="image/jpeg,image/png,image/webp"
:before-upload="beforeImageUpload"
:http-request="uploadImage"
:disabled="uploadingImage"
>
<el-button :loading="uploadingImage" :disabled="uploadingImage">
<AppIcon :icon="AppIcons.upload" />
{{ form.imageUrl ? '更换图片' : '选择图片' }}
</el-button>
</el-upload>
<el-button
v-if="form.imageUrl"
type="danger"
:disabled="uploadingImage"
@click="removeImage"
>
移除
</el-button>
<a
v-if="form.imageUrl"
class="banner-original-link"
:href="form.imageUrl"
target="_blank"
rel="noopener noreferrer"
>
查看原图
</a>
</div>
</div>
</div> </div>
<img v-if="form.imageUrl" :src="form.imageUrl" class="form-banner-preview" /> </el-form-item>
</section>
<section class="banner-editor-section">
<h3 class="banner-editor-section-title">跳转配置</h3>
<div class="banner-editor-grid banner-editor-grid--two">
<el-form-item label="展示位置" prop="position">
<el-select v-model="form.position" placeholder="请选择展示位置">
<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> </div>
</el-form-item> <el-form-item v-if="form.linkType === 'DRAMA'" label="跳转短剧" prop="linkValue">
<div class="form-row"> <el-select v-model="form.linkValue" filterable placeholder="搜索短剧">
<el-form-item label="展示位置" required>
<el-select v-model="form.position">
<el-option <el-option
v-for="item in positionOptions" v-for="item in dramas"
:key="item.code" :key="item.id"
:label="item.text" :label="`${item.title}(${item.statusText})`"
:value="item.code" :value="String(item.id)"
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="跳转类型"> <el-form-item v-if="form.linkType === 'URL'" label="跳转地址" prop="linkValue">
<el-select v-model="form.linkType"> <el-input v-model="form.linkValue" placeholder="https://..." />
<el-option </el-form-item>
v-for="item in linkTypeOptions" </section>
:key="item.code"
:label="item.text" <section class="banner-editor-section">
:value="item.code" <h3 class="banner-editor-section-title">排序与状态</h3>
<div class="banner-editor-grid banner-editor-grid--two">
<el-form-item label="排序">
<el-input-number
v-model="form.sort"
class="banner-editor-number"
controls-position="right"
:min="0"
:max="999"
/> />
</el-select> </el-form-item>
</el-form-item> <el-form-item label="状态">
</div> <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
<el-form-item v-if="form.linkType === 'DRAMA'" label="跳转短剧" required> </el-form-item>
<el-select v-model="form.linkValue" filterable placeholder="搜索短剧"> </div>
<el-option </section>
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> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button :disabled="saving || uploadingImage" @click="dialogVisible = false">
<el-button type="primary" :loading="saving" @click="saveBanner">保存</el-button> 取消
</el-button>
<el-button
type="primary"
:loading="saving"
:disabled="saving || uploadingImage"
@click="saveBanner"
>
保存
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@@ -1,10 +1,11 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup> <script setup>
import { onMounted, reactive, ref } from 'vue' import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons' import { AppIcons } from '@/constants/icons'
import { AppImage } from '@/components/AppImage'
import { import {
changeDramaStatusApi, changeDramaStatusApi,
createDramaApi, createDramaApi,
@@ -28,7 +29,8 @@ const categories = ref([])
const keyword = ref('') const keyword = ref('')
const statusFilter = ref(undefined) const statusFilter = ref(undefined)
const categoryId = ref(undefined) const categoryIds = ref([])
const dramaTypes = ref([])
const hotFilter = ref(undefined) const hotFilter = ref(undefined)
const pageIndex = ref(1) const pageIndex = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
@@ -37,16 +39,49 @@ const dialogVisible = ref(false)
const saving = ref(false) const saving = ref(false)
const uploadingCover = ref(false) const uploadingCover = ref(false)
const coverPercent = ref(0) const coverPercent = ref(0)
const coverError = ref('')
const coverFileName = ref('')
const editingId = ref(null) const editingId = ref(null)
const editingCommentCount = ref(0)
const editingLikeCount = ref(0)
const formRef = ref()
const form = reactive({ const form = reactive({
title: '', title: '',
categoryId: undefined, categoryIds: [],
dramaTypes: ['LIVE'],
description: '', description: '',
freeEpisodes: 3, freeEpisodes: 3,
unlockCoin: 0, unlockCoin: 0,
favoriteCount: 0,
heat: 0,
isHot: false,
sort: 0,
cover: '' cover: ''
}) })
const MAX_COVER_SIZE = 5 * 1024 * 1024
const COVER_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const formRules = {
title: [{ required: true, message: '请输入短剧名称', trigger: 'blur' }],
categoryIds: [
{ type: 'array', required: true, min: 1, message: '请至少选择一个类型', trigger: 'change' }
],
dramaTypes: [
{ type: 'array', required: true, min: 1, message: '请至少选择一个内容类型', trigger: 'change' }
]
}
const coverDisplayName = computed(
() => coverFileName.value || (form.cover ? coverBasename(form.cover) : '尚未选择封面')
)
const pageCoverPreviewList = computed(() => list.value.map((item) => item.cover).filter(Boolean))
function coverPreviewIndex(row) {
return Math.max(0, pageCoverPreviewList.value.indexOf(row.cover))
}
const statusOptions = [ const statusOptions = [
{ code: 'draft', text: '草稿' }, { code: 'draft', text: '草稿' },
{ code: 'reviewing', text: '审核中' }, { code: 'reviewing', text: '审核中' },
@@ -55,6 +90,11 @@ const statusOptions = [
{ code: 'offline', text: '已下线' } { code: 'offline', text: '已下线' }
] ]
const dramaTypeOptions = [
{ value: 'ANIME', label: '漫剧' },
{ value: 'LIVE', label: '真人剧' }
]
const actionMeta = { const actionMeta = {
submit: { label: '提交审核', done: '已提交审核' }, submit: { label: '提交审核', done: '已提交审核' },
approve: { label: '审核通过', done: '已通过审核' }, approve: { label: '审核通过', done: '已通过审核' },
@@ -71,7 +111,8 @@ async function load() {
pageSize: pageSize.value, pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined, keyword: keyword.value.trim() || undefined,
status: statusFilter.value || undefined, status: statusFilter.value || undefined,
categoryId: categoryId.value, categoryIds: categoryIds.value.length ? categoryIds.value : undefined,
dramaTypes: dramaTypes.value.length ? dramaTypes.value : undefined,
isHot: hotFilter.value isHot: hotFilter.value
}) })
list.value = res.data?.list || [] list.value = res.data?.list || []
@@ -94,7 +135,8 @@ function search() {
function resetFilters() { function resetFilters() {
keyword.value = '' keyword.value = ''
statusFilter.value = undefined statusFilter.value = undefined
categoryId.value = undefined categoryIds.value = []
dramaTypes.value = []
hotFilter.value = undefined hotFilter.value = undefined
search() search()
} }
@@ -104,42 +146,107 @@ function handleSizeChange() {
load() load()
} }
function coverBasename(path) {
if (!path) return ''
const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean)
}
async function resetValidation() {
await nextTick()
formRef.value?.clearValidate()
}
function openCreate() { function openCreate() {
editingId.value = null editingId.value = null
Object.assign(form, { Object.assign(form, {
title: '', title: '',
categoryId: undefined, categoryIds: [],
dramaTypes: ['LIVE'],
description: '', description: '',
freeEpisodes: 3, freeEpisodes: 3,
unlockCoin: 0, unlockCoin: 0,
favoriteCount: 0,
heat: 0,
isHot: false, isHot: false,
sort: 0,
cover: '' cover: ''
}) })
coverFileName.value = ''
coverError.value = ''
editingCommentCount.value = 0
editingLikeCount.value = 0
dialogVisible.value = true dialogVisible.value = true
resetValidation()
} }
function openEdit(row) { function openEdit(row) {
editingId.value = row.id editingId.value = row.id
Object.assign(form, { Object.assign(form, {
title: row.title, title: row.title,
categoryId: row.categoryId || undefined, categoryIds:
Array.isArray(row.categoryIds) && row.categoryIds.length
? row.categoryIds
: row.categoryId
? [row.categoryId]
: [],
dramaTypes:
Array.isArray(row.dramaTypes) && row.dramaTypes.length
? row.dramaTypes
: row.dramaType
? [row.dramaType]
: ['LIVE'],
description: row.description || '', description: row.description || '',
freeEpisodes: row.freeEpisodes ?? 3, freeEpisodes: row.freeEpisodes ?? 3,
unlockCoin: row.unlockCoin ?? 0, unlockCoin: row.unlockCoin ?? 0,
favoriteCount: row.favoriteCount ?? 0,
heat: row.views ?? 0,
isHot: !!row.isHot, isHot: !!row.isHot,
sort: row.sort ?? 0,
cover: row.cover || '' cover: row.cover || ''
}) })
coverFileName.value = row.cover ? coverBasename(row.cover) : ''
coverError.value = ''
editingCommentCount.value = row.commentCount ?? 0
editingLikeCount.value = row.likeCount ?? 0
dialogVisible.value = true dialogVisible.value = true
resetValidation()
}
function dramaTypeLabel(value) {
return dramaTypeOptions.find((item) => item.value === value)?.label || '真人剧'
}
function rowCategoryList(row) {
if (Array.isArray(row.categoryNames) && row.categoryNames.length) {
return row.categoryNames.filter(Boolean)
}
if (!row.genre) return []
return row.genre
.split('/')
.map((name) => name.trim())
.filter(Boolean)
}
function rowDramaTypes(row) {
if (Array.isArray(row.dramaTypes) && row.dramaTypes.length) return row.dramaTypes
return row.dramaType ? [row.dramaType] : []
} }
async function saveDrama() { async function saveDrama() {
if (!form.title.trim()) return ElMessage.warning('请填写短剧名称') if (saving.value || uploadingCover.value) return
if (!form.categoryId) return ElMessage.warning('请选择短剧类型') try {
const category = categories.value.find((item) => item.id === form.categoryId) await formRef.value?.validate()
} catch {
return
}
const categoryNames = form.categoryIds
.map((id) => categories.value.find((item) => item.id === id)?.name)
.filter(Boolean)
const payload = { const payload = {
...form, ...form,
title: form.title.trim(), title: form.title.trim(),
genre: category?.name || '' genre: categoryNames.join(' / ')
} }
saving.value = true saving.value = true
try { try {
@@ -158,22 +265,54 @@ async function saveDrama() {
} }
} }
function beforeCoverUpload(file) {
coverError.value = ''
if (!COVER_TYPES.includes(file.type)) {
coverError.value = '封面仅支持 JPG、PNG 或 WebP 格式'
return false
}
if (file.size > MAX_COVER_SIZE) {
coverError.value = '封面大小不能超过 5MB'
return false
}
return true
}
/** 封面走分片上传(图片只有一个分片),成功后回填地址。 */ /** 封面走分片上传(图片只有一个分片),成功后回填地址。 */
async function uploadCover(options) { async function uploadCover(options) {
uploadingCover.value = true uploadingCover.value = true
coverPercent.value = 0 coverPercent.value = 0
coverError.value = ''
try { try {
const asset = await uploadFile(options.file, { const asset = await uploadFile(options.file, {
bizType: 'DRAMA_COVER', bizType: 'DRAMA_COVER',
onProgress: (percent) => (coverPercent.value = percent) onProgress: (percent) => (coverPercent.value = percent)
}) })
form.cover = asset.url form.cover = asset.url
coverFileName.value = options.file.name
ElMessage.success('封面已上传') ElMessage.success('封面已上传')
} catch {
coverError.value = '封面上传失败,请检查网络后重试'
} finally { } finally {
uploadingCover.value = false uploadingCover.value = false
} }
} }
function removeCover() {
if (uploadingCover.value) return
form.cover = ''
coverFileName.value = ''
coverError.value = ''
}
function beforeDialogClose(done) {
if (saving.value || uploadingCover.value) {
ElMessage.warning(saving.value ? '正在保存,请稍候' : '封面正在上传,请稍候')
return
}
done()
}
async function confirmDelete(row) { async function confirmDelete(row) {
try { try {
await ElMessageBox.confirm(`确定删除短剧「${row.title}」吗?删除后无法恢复。`, '删除确认', { await ElMessageBox.confirm(`确定删除短剧「${row.title}」吗?删除后无法恢复。`, '删除确认', {
@@ -262,12 +401,30 @@ onMounted(async () => {
:key="item.code" :key="item.code"
:label="item.text" :label="item.text"
:value="item.code" /></el-select :value="item.code" /></el-select
><el-select v-model="categoryId" clearable placeholder="全部类型" @change="search" ><el-select
v-model="categoryIds"
class="drama-multi-select"
multiple
clearable
placeholder="全部类型"
@change="search"
><el-option ><el-option
v-for="item in categories" v-for="item in categories"
:key="item.id" :key="item.id"
:label="item.name" :label="item.name"
:value="item.id" /></el-select :value="item.id" /></el-select
><el-select
v-model="dramaTypes"
class="drama-multi-select"
multiple
clearable
placeholder="全部内容类型"
@change="search"
><el-option
v-for="item in dramaTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value" /></el-select
><el-select v-model="hotFilter" clearable placeholder="全部热度" @change="search" ><el-select v-model="hotFilter" clearable placeholder="全部热度" @change="search"
><el-option label="仅热门" :value="true" /><el-option ><el-option label="仅热门" :value="true" /><el-option
label="非热门" label="非热门"
@@ -285,10 +442,15 @@ onMounted(async () => {
<el-table-column label="短剧名称" min-width="220" <el-table-column label="短剧名称" min-width="220"
><template #default="{ row }" ><template #default="{ row }"
><div class="drama-cell" ><div class="drama-cell"
><img v-if="row.cover" :src="row.cover" class="drama-cover-image" /><div ><AppImage
v-else v-if="row.cover"
class="drama-cover" :src="row.cover"
>暂无封面</div :preview-list="pageCoverPreviewList"
:initial-index="coverPreviewIndex(row)"
fit="cover"
class="drama-cover-image"
:alt="row.title"
/><div v-else class="drama-cover">暂无封面</div
><div ><div
><b>{{ row.title }}</b></div ><b>{{ row.title }}</b></div
></div ></div
@@ -301,13 +463,49 @@ onMounted(async () => {
><span v-else class="table-empty-text">—</span></template ><span v-else class="table-empty-text">—</span></template
></el-table-column ></el-table-column
> >
<el-table-column prop="genre" label="类型" width="110" /> <el-table-column label="类型" min-width="200"
><template #default="{ row }"
><div class="tag-wrap"
><el-tag v-for="name in rowCategoryList(row)" :key="name" type="info" effect="light">{{
name
}}</el-tag></div
></template
>
</el-table-column>
<el-table-column label="内容类型" min-width="180"
><template #default="{ row }"
><div class="tag-wrap"
><el-tag
v-for="type in rowDramaTypes(row)"
:key="type"
:type="type === 'ANIME' ? 'warning' : 'info'"
effect="light"
>{{ dramaTypeLabel(type) }}</el-tag
></div
></template
></el-table-column
>
<el-table-column label="集数" width="90" <el-table-column label="集数" width="90"
><template #default="{ row }">{{ row.episodes }} 集</template></el-table-column ><template #default="{ row }">{{ row.episodes }} 集</template></el-table-column
> >
<el-table-column label="播放量" width="110" <el-table-column label="热度" width="110"
><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column ><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column
> >
<el-table-column label="收藏" width="90"
><template #default="{ row }">{{
formatCount(row.favoriteCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="点赞" width="90"
><template #default="{ row }">{{
formatCount(row.likeCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="评论" width="90"
><template #default="{ row }">{{
formatCount(row.commentCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="状态" width="110" <el-table-column label="状态" width="110"
><template #default="{ row }" ><template #default="{ row }"
><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{ ><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{
@@ -375,56 +573,252 @@ onMounted(async () => {
@size-change="handleSizeChange" @size-change="handleSizeChange"
/></div> /></div>
</section> </section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑短剧' : '新建短剧'" width="600px" <el-dialog
><el-form label-position="top" v-model="dialogVisible"
><div class="form-row" :title="editingId ? '编辑短剧' : '新建短剧'"
><el-form-item label="短剧名称" required width="680px"
><el-input v-model="form.title" placeholder="请输入短剧名称" /></el-form-item class="drama-editor-dialog"
><el-form-item label="类型" required :close-on-click-modal="false"
><el-select v-model="form.categoryId" placeholder="请选择" :before-close="beforeDialogClose"
><el-option
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="2"
maxlength="200"
show-word-limit /></el-form-item
><div class="form-row"
><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" :loading="saving" @click="saveDrama">{{
editingId ? '保存修改' : '保存草稿'
}}</el-button></template
></el-dialog
> >
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-position="top"
class="drama-editor-form"
>
<section class="editor-section">
<h3 class="editor-section-title">基础信息</h3>
<div class="editor-grid editor-grid--two">
<el-form-item label="短剧名称" prop="title">
<el-input v-model="form.title" maxlength="80" placeholder="请输入短剧名称" />
</el-form-item>
<el-form-item label="类型" prop="categoryIds">
<el-select
v-model="form.categoryIds"
class="drama-multi-select"
multiple
filterable
placeholder="请选择类型"
>
<el-option
v-for="item in categories"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="内容类型" prop="dramaTypes">
<el-select
v-model="form.dramaTypes"
class="drama-multi-select"
multiple
filterable
placeholder="请选择内容类型"
>
<el-option
v-for="item in dramaTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</div>
<el-form-item label="简介">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="简要介绍剧情"
/>
</el-form-item>
</section>
<section class="editor-section">
<h3 class="editor-section-title">付费设置</h3>
<div class="editor-grid editor-grid--two">
<el-form-item label="免费集数">
<el-input-number
v-model="form.freeEpisodes"
class="editor-number"
controls-position="right"
:min="0"
:max="200"
/>
</el-form-item>
<el-form-item label="单集解锁金币">
<el-input-number
v-model="form.unlockCoin"
class="editor-number"
controls-position="right"
:min="0"
:max="9999"
/>
</el-form-item>
</div>
<div class="editor-grid editor-grid--two">
<el-form-item label="是否热门">
<el-switch v-model="form.isHot" />
</el-form-item>
<el-form-item label="排序权重">
<el-input-number
v-model="form.sort"
class="editor-number"
controls-position="right"
:min="0"
:max="99999"
:precision="0"
/>
<p class="readonly-stat-hint">数值越大越靠前,相同权重按 ID 倒序</p>
</el-form-item>
</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">互动数据</h3>
<div class="editor-grid editor-grid--two">
<el-form-item label="收藏数">
<el-input-number
v-model="form.favoriteCount"
class="editor-number"
controls-position="right"
:min="0"
:precision="0"
/>
</el-form-item>
<el-form-item label="点赞数">
<div class="readonly-stat">{{ formatCount(editingLikeCount) }}</div>
<p class="readonly-stat-hint">全剧分集汇总,只读</p>
</el-form-item>
<el-form-item label="热度">
<el-input-number
v-model="form.heat"
class="editor-number"
controls-position="right"
:min="0"
:precision="0"
/>
</el-form-item>
<el-form-item label="评论数">
<div class="readonly-stat">{{ formatCount(editingCommentCount) }}</div>
<p class="readonly-stat-hint">全剧汇总,实时统计,只读</p>
</el-form-item>
</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">封面</h3>
<div class="cover-editor">
<div class="cover-preview-card">
<img v-if="form.cover" :src="form.cover" alt="短剧封面" />
<div v-else class="cover-preview-empty">
<AppIcon :icon="AppIcons.content" :size="28" />
<span>暂无封面</span>
</div>
<div v-if="uploadingCover" class="cover-upload-mask">
<span>上传中</span>
<b>{{ coverPercent }}%</b>
</div>
</div>
<div class="cover-editor-meta">
<div class="cover-status-line">
<el-tag :type="form.cover ? 'success' : 'info'" effect="light">
{{ form.cover ? '已上传' : '未上传' }}
</el-tag>
<span class="cover-filename" :title="coverDisplayName">{{ coverDisplayName }}</span>
</div>
<p class="cover-hint">支持 JPG、PNG、WebP,单张不超过 5MB。</p>
<el-alert
v-if="coverError"
:title="coverError"
type="error"
:closable="false"
show-icon
/>
<div class="cover-actions">
<el-upload
v-if="can('cover:upload')"
:show-file-list="false"
accept="image/jpeg,image/png,image/webp"
:before-upload="beforeCoverUpload"
:http-request="uploadCover"
:disabled="uploadingCover"
>
<el-button :loading="uploadingCover" :disabled="uploadingCover">
<AppIcon :icon="AppIcons.upload" />
{{ form.cover ? '更换封面' : '选择封面' }}
</el-button>
</el-upload>
<el-button
v-if="can('cover:upload') && form.cover"
type="danger"
:disabled="uploadingCover"
@click="removeCover"
>
移除
</el-button>
<a
v-if="form.cover"
class="cover-original-link"
:href="form.cover"
target="_blank"
rel="noopener noreferrer"
>
查看原图
</a>
</div>
</div>
</div>
</section>
</el-form>
<template #footer>
<el-button :disabled="saving || uploadingCover" @click="dialogVisible = false"
>取消</el-button
>
<el-button
type="primary"
:loading="saving"
:disabled="saving || uploadingCover"
@click="saveDrama"
>
{{ editingId ? '保存修改' : '保存草稿' }}
</el-button>
</template>
</el-dialog>
</template> </template>
<style scoped>
.drama-multi-select {
min-width: 220px;
}
.drama-multi-select :deep(.el-select__wrapper) {
height: auto;
min-height: 32px;
padding-top: 3px;
padding-bottom: 3px;
}
.drama-multi-select :deep(.el-select__selection) {
flex-wrap: wrap;
row-gap: 4px;
}
.drama-editor-form .drama-multi-select {
width: 100%;
min-width: 0;
}
.tag-wrap {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
</style>
@@ -1,56 +1,56 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons' import { AppIcons } from '@/constants/icons'
import { pageDramasApi } from '@/api/admin/drama' import { pageDramasApi } from '@/api/admin/drama'
import { import {
createEpisodeApi,
deleteEpisodeApi, deleteEpisodeApi,
listEpisodesApi, listEpisodesApi,
sortEpisodesApi, parseEpisodePage,
updateEpisodeApi sortEpisodesApi
} from '@/api/admin/episode' } from '@/api/admin/episode'
import { useRbac } from '@/stores/rbac' import { useRbac } from '@/stores/rbac'
import { formatCount } from '@/utils/format' import { formatCount } from '@/utils/format'
import { statusClass } from '@/utils/status' import { statusClass } from '@/utils/status'
import { probeVideoDuration, uploadFile } from '@/utils/uploader' import { useI18n } from '@/hooks/web/useI18n'
import BatchUploadDialog from './upload/BatchUploadDialog.vue'
import EpisodeFormDialog from './upload/EpisodeFormDialog.vue'
import { listPendingSessions } from './upload/uploadSessions'
/** 排序/批量建集需要全集快照,单次请求不超过接口 pageSize 上限。 */
const SNAPSHOT_PAGE_SIZE = 200
const DEFAULT_PAGE_SIZE = 100
const PAGE_SIZE_OPTIONS = [50, 100, 200]
const route = useRoute() const route = useRoute()
const { can } = useRbac() const { can } = useRbac()
const { t } = useI18n()
const loading = ref(false) const loading = ref(false)
const dramas = ref([]) const dramas = ref([])
const episodes = ref([]) const episodes = ref([])
const total = ref(0)
const selectedDramaId = ref(undefined) const selectedDramaId = ref(undefined)
const statusFilter = ref(undefined)
const dialogVisible = ref(false) const pageIndex = ref(1)
const saving = ref(false) const pageSize = ref(DEFAULT_PAGE_SIZE)
const uploadingVideo = ref(false) const batchUploadVisible = ref(false)
const videoPercent = ref(0)
const previewVisible = ref(false) const previewVisible = ref(false)
const previewEpisode = ref(null) const previewEpisode = ref(null)
const editingId = ref(null) const formDialogRef = ref()
const form = reactive({ /** 上移/下移与批量建集用的全集快照。 */
number: 1, const snapshotEpisodes = ref([])
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 batchNextNumber = computed(() => nextNumber())
const selectedDrama = computed(() => dramas.value.find((item) => item.id === selectedDramaId.value)) const selectedDrama = computed(() => dramas.value.find((item) => item.id === selectedDramaId.value))
const existingEpisodeNumbers = computed(() =>
snapshotEpisodes.value.map((item) => Number(item.number)).filter((n) => Number.isFinite(n))
)
const showEmpty = computed(() => !loading.value && episodes.value.length === 0)
async function loadDramas() { async function loadDramas() {
const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 }) const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 })
@@ -67,95 +67,89 @@ async function loadDramas() {
async function loadEpisodes() { async function loadEpisodes() {
if (!selectedDramaId.value) { if (!selectedDramaId.value) {
episodes.value = [] episodes.value = []
total.value = 0
snapshotEpisodes.value = []
return return
} }
loading.value = true loading.value = true
try { try {
const res = await listEpisodesApi(selectedDramaId.value) const res = await listEpisodesApi({
episodes.value = res.data || [] dramaId: selectedDramaId.value,
status: statusFilter.value || undefined,
pageIndex: pageIndex.value,
pageSize: pageSize.value
})
// 按 OpenAPI PageResult{total,list} 解包,禁止再把 data 当数组切片
const page = parseEpisodePage(res.data)
episodes.value = page.list
total.value = page.total
await clampOrRetreatPage()
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function nextNumber() { /** 拉当前剧全集快照,供排序与批量建集集号计算。不过滤 status,按页取完。 */
return episodes.value.reduce((max, item) => Math.max(max, item.number || 0), 0) + 1 async function refreshSnapshot() {
if (!selectedDramaId.value) {
snapshotEpisodes.value = []
return
}
const collected = []
let page = 1
let totalCount = 0
do {
const res = await listEpisodesApi({
dramaId: selectedDramaId.value,
pageIndex: page,
pageSize: SNAPSHOT_PAGE_SIZE
})
const parsed = parseEpisodePage(res.data)
totalCount = parsed.total
collected.push(...parsed.list)
if (!parsed.list.length) break
page += 1
} while (collected.length < totalCount)
snapshotEpisodes.value = collected
} }
function openCreate() { /**
editingId.value = null * 删除后若当前页空且仍有数据,回退上一页再拉;否则夹页。
Object.assign(form, { */
number: nextNumber(), async function clampOrRetreatPage() {
title: '', const maxPage = Math.max(1, Math.ceil(total.value / pageSize.value) || 1)
durationSec: 0, if (episodes.value.length === 0 && total.value > 0 && pageIndex.value > 1) {
videoUrl: '', pageIndex.value = Math.min(pageIndex.value - 1, maxPage)
isFree: true, await loadEpisodes()
unlockCoin: selectedDrama.value?.unlockCoin ?? 0, return
status: 'draft' }
}) if (pageIndex.value > maxPage) {
dialogVisible.value = true pageIndex.value = maxPage
await loadEpisodes()
}
}
function nextNumber() {
return snapshotEpisodes.value.reduce((max, item) => Math.max(max, item.number || 0), 0) + 1
}
async function openBatchUpload() {
if (!selectedDramaId.value) return
await refreshSnapshot()
batchUploadVisible.value = true
}
async function onBatchEpisodesCreated() {
await loadEpisodes()
}
async function openCreate() {
await refreshSnapshot()
formDialogRef.value?.openCreate()
} }
function openEdit(row) { function openEdit(row) {
editingId.value = row.id formDialogRef.value?.openEdit(row)
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) { async function confirmDelete(row) {
@@ -171,6 +165,9 @@ async function confirmDelete(row) {
} }
await deleteEpisodeApi(row.id) await deleteEpisodeApi(row.id)
ElMessage.success('剧集已删除') ElMessage.success('剧集已删除')
if (episodes.value.length === 1 && pageIndex.value > 1) {
pageIndex.value -= 1
}
await loadEpisodes() await loadEpisodes()
} }
@@ -199,11 +196,32 @@ function stopThumb(event) {
} }
} }
/** 上移/下移后把新顺序整表提交,由后端重排 sort。 */ function pageLocalIndex(row) {
async function move(index, offset) { return episodes.value.findIndex((item) => item.id === row.id)
}
function globalIndex(row) {
const local = pageLocalIndex(row)
if (local < 0) return -1
return (pageIndex.value - 1) * pageSize.value + local
}
function canMoveUp(row) {
return globalIndex(row) > 0
}
function canMoveDown(row) {
const index = globalIndex(row)
return index >= 0 && index < total.value - 1
}
/** 上移/下移后把快照新顺序整表提交,由后端重排 sort。 */
async function move(row, offset) {
await refreshSnapshot()
const index = snapshotEpisodes.value.findIndex((item) => item.id === row.id)
const target = index + offset const target = index + offset
if (target < 0 || target >= episodes.value.length) return if (index < 0 || target < 0 || target >= snapshotEpisodes.value.length) return
const next = [...episodes.value] const next = [...snapshotEpisodes.value]
const [moved] = next.splice(index, 1) const [moved] = next.splice(index, 1)
next.splice(target, 0, moved) next.splice(target, 0, moved)
await sortEpisodesApi(next.map((item) => item.id)) await sortEpisodesApi(next.map((item) => item.id))
@@ -212,12 +230,36 @@ async function move(index, offset) {
} }
async function onDramaChange() { async function onDramaChange() {
pageIndex.value = 1
await loadEpisodes() await loadEpisodes()
openBatchIfPendingSessions()
}
function onStatusChange() {
pageIndex.value = 1
loadEpisodes()
}
function handleSizeChange() {
pageIndex.value = 1
loadEpisodes()
}
function handlePageChange() {
loadEpisodes()
}
/** 进入页面或切换短剧时,若有 24h 内未完成上传则打开批量上传弹窗以提示续传。 */
function openBatchIfPendingSessions() {
if (!selectedDramaId.value || !can('episode:upload')) return
const pending = listPendingSessions(String(selectedDramaId.value))
if (pending.length) batchUploadVisible.value = true
} }
onMounted(async () => { onMounted(async () => {
await loadDramas() await loadDramas()
await loadEpisodes() await loadEpisodes()
openBatchIfPendingSessions()
}) })
</script> </script>
@@ -226,41 +268,79 @@ onMounted(async () => {
><div ><div
><div class="eyebrow">内容管理</div><h1>剧集管理</h1 ><div class="eyebrow">内容管理</div><h1>剧集管理</h1
><p>为短剧维护剧集信息、视频地址与播放顺序。</p></div ><p>为短剧维护剧集信息、视频地址与播放顺序。</p></div
><el-button ><div class="page-heading-actions">
v-if="can('episode:upload')" <el-button v-if="can('episode:upload')" :disabled="!selectedDramaId" @click="openBatchUpload"
type="primary" ><AppIcon :icon="AppIcons.upload" />{{ t('episodeBatch.entry') }}</el-button
class="primary-button" ><el-button
:disabled="!selectedDramaId" v-if="can('episode:upload')"
@click="openCreate" type="primary"
><AppIcon :icon="AppIcons.add" />新增剧集</el-button class="primary-button"
:disabled="!selectedDramaId"
@click="openCreate"
><AppIcon :icon="AppIcons.add" />新增剧集</el-button
></div
></div ></div
> >
<section class="panel episode-panel"> <section class="panel episode-panel">
<div class="episode-context" <div class="episode-context">
><div class="episode-context-copy" <div class="episode-context-info">
><span>选择短剧</span><b>{{ selectedDrama?.title || '暂无短剧' }}</b></div <span class="episode-context-label">当前短剧</span>
><el-select v-model="selectedDramaId" class="drama-select" @change="onDramaChange" <b class="episode-context-title">{{ selectedDrama?.title || '暂无短剧' }}</b>
><el-option <small class="episode-context-count"
v-for="item in dramas" >共 {{ selectedDrama?.episodes ?? total }} 集 · 收藏
:key="item.id" {{ formatCount(selectedDrama?.favoriteCount ?? 0) }} · 热度
:label="`${item.title}(${item.episodes} 集)`" {{ formatCount(selectedDrama?.views ?? 0) }}</small
:value="item.id" /></el-select >
></div> </div>
<div class="episode-switcher">
<span class="episode-context-label">切换短剧</span>
<el-select
v-model="selectedDramaId"
class="drama-select"
placeholder="请选择短剧"
@change="onDramaChange"
>
<el-option
v-for="item in dramas"
:key="item.id"
:label="`${item.title}(${item.episodes} 集)`"
:value="item.id"
/>
</el-select>
<span class="episode-context-label">状态</span>
<el-select
v-model="statusFilter"
clearable
placeholder="全部状态"
class="status-select"
@change="onStatusChange"
>
<el-option label="草稿" value="draft" />
<el-option label="已上线" value="published" />
<el-option label="已下线" value="offline" />
</el-select>
</div>
</div>
<div class="episode-toolbar" <div class="episode-toolbar"
><div ><div
><h2>剧集列表({{ episodes.length }})</h2><p>按列表顺序播放</p></div ><h2>剧集列表({{ total }})</h2><p>按列表顺序播放</p></div
></div ></div
> >
<el-table <el-table
v-loading="loading" v-loading="loading"
:data="episodes" :data="episodes"
row-key="id" row-key="id"
empty-text="该短剧暂无剧集"
class="drama-el-table episode-el-table" class="drama-el-table episode-el-table"
> >
<template #empty>
<el-empty
v-if="showEmpty"
:description="selectedDramaId ? '该短剧暂无剧集' : '请先选择短剧'"
/>
</template>
<el-table-column label="集数" width="90" <el-table-column label="集数" width="90"
><template #default="{ row }">{{ ><template #default="{ row }">{{
String(row.number).padStart(2, '0') String(row.number ?? 0).padStart(2, '0')
}}</template></el-table-column }}</template></el-table-column
> >
<el-table-column label="剧集名称" min-width="240" <el-table-column label="剧集名称" min-width="240"
@@ -299,6 +379,24 @@ onMounted(async () => {
<el-table-column label="播放量" width="120" <el-table-column label="播放量" width="120"
><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column ><template #default="{ row }">{{ formatCount(row.views) }}</template></el-table-column
> >
<el-table-column label="点赞(本集)" width="120"
><template #default="{ row }">{{
formatCount(row.likeCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="评论(本集)" width="120"
><template #default="{ row }">{{
formatCount(row.commentCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="所属剧收藏" width="120"
><template #default="{ row }">{{
formatCount(row.favoriteCount ?? 0)
}}</template></el-table-column
>
<el-table-column label="所属剧热度" width="120"
><template #default="{ row }">{{ formatCount(row.heat ?? 0) }}</template></el-table-column
>
<el-table-column label="状态" width="110" <el-table-column label="状态" width="110"
><template #default="{ row }" ><template #default="{ row }"
><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{ ><el-tag :class="['status-badge', statusClass(row.status)]" effect="light">{{
@@ -307,7 +405,7 @@ onMounted(async () => {
></el-table-column ></el-table-column
> >
<el-table-column label="操作" width="250" fixed="right" <el-table-column label="操作" width="250" fixed="right"
><template #default="{ row, $index }" ><template #default="{ row }"
><div class="table-actions" ><div class="table-actions"
><el-button ><el-button
v-if="can('episode:upload')" v-if="can('episode:upload')"
@@ -318,14 +416,14 @@ onMounted(async () => {
><el-button ><el-button
v-if="can('episode:sort')" v-if="can('episode:sort')"
size="small" size="small"
:disabled="$index === 0" :disabled="!canMoveUp(row)"
@click="move($index, -1)" @click="move(row, -1)"
>上移</el-button >上移</el-button
><el-button ><el-button
v-if="can('episode:sort')" v-if="can('episode:sort')"
size="small" size="small"
:disabled="$index === episodes.length - 1" :disabled="!canMoveDown(row)"
@click="move($index, 1)" @click="move(row, 1)"
>下移</el-button >下移</el-button
><el-button ><el-button
v-if="can('episode:upload')" v-if="can('episode:upload')"
@@ -338,54 +436,25 @@ onMounted(async () => {
></el-table-column ></el-table-column
> >
</el-table> </el-table>
<div class="pagination"
><el-pagination
v-model:current-page="pageIndex"
v-model:page-size="pageSize"
:page-sizes="PAGE_SIZE_OPTIONS"
:total="total"
:pager-count="7"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/></div>
</section> </section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑剧集' : '新增剧集'" width="560px" <EpisodeFormDialog
><el-form label-position="top" ref="formDialogRef"
><div class="form-row" :drama-options="selectedDrama"
><el-form-item label="集号" required :next-number="batchNextNumber"
><el-input-number v-model="form.number" :min="1" :max="999" /></el-form-item @saved="loadEpisodes"
><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="视频文件"
><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" :loading="saving" @click="saveEpisode">保存</el-button></template
></el-dialog
>
<el-dialog <el-dialog
v-model="previewVisible" v-model="previewVisible"
:title="`预览:第 ${previewEpisode?.number ?? ''} 集 ${previewEpisode?.title ?? ''}`" :title="`预览:第 ${previewEpisode?.number ?? ''} 集 ${previewEpisode?.title ?? ''}`"
@@ -401,4 +470,15 @@ onMounted(async () => {
></video> ></video>
<el-empty v-else description="该剧集还没有上传视频" /> <el-empty v-else description="该剧集还没有上传视频" />
</el-dialog> </el-dialog>
<BatchUploadDialog
v-if="selectedDramaId"
v-model="batchUploadVisible"
:drama-id="selectedDramaId"
:next-episode-number="batchNextNumber"
:default-unlock-coin="selectedDrama?.unlockCoin ?? 0"
:free-episodes="selectedDrama?.freeEpisodes ?? 0"
:existing-numbers="existingEpisodeNumbers"
@refreshed="loadEpisodes"
@created="onBatchEpisodesCreated"
/>
</template> </template>
@@ -0,0 +1,363 @@
<!-- Author: fruit.com.cn -->
<!-- 剧集批量上传弹窗:先入队待上传,点「开始上传」后再执行 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import { useI18n } from '@/hooks/web/useI18n'
import CreateEpisodesDialog from './CreateEpisodesDialog.vue'
import UploadTaskRow from './UploadTaskRow.vue'
import { useUploadQueue } from './useUploadQueue'
import { MAX_VIDEO_SIZE, VIDEO_ACCEPT_ATTR, VIDEO_EXTENSIONS } from './videoLimit'
import type { UploadSessionRecord } from './uploadSessions'
const props = withDefaults(
defineProps<{
modelValue: boolean
dramaId: string | number
nextEpisodeNumber: number
defaultUnlockCoin: number
existingNumbers: number[]
/** 所属短剧免费集数;不传默认 0 */
freeEpisodes?: number
}>(),
{
freeEpisodes: 0
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
created: []
refreshed: []
}>()
const { t } = useI18n()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const dramaKey = computed(() => String(props.dramaId || ''))
const fileInputRef = ref<HTMLInputElement | null>(null)
const resumeFileInputRef = ref<HTMLInputElement | null>(null)
const rejectedNames = ref<string[]>([])
const pendingSessions = ref<UploadSessionRecord[]>([])
const createVisible = ref(false)
const resumePromptShown = ref(false)
const queue = useUploadQueue({ getDramaId: () => dramaKey.value })
watch(
() => props.dramaId,
() => {
queue.clear()
resumePromptShown.value = false
}
)
const canCreateEpisodes = computed(() => queue.doneItems.value.length > 0)
const startButtonLabel = computed(() =>
queue.isUploading.value ? t('episodeBatch.uploadingAction') : t('episodeBatch.startUpload')
)
function extensionOf(fileName: string): string {
const parts = String(fileName || '').split('.')
return (parts.pop() || '').toLowerCase()
}
function validateFile(file: File): string {
if (!VIDEO_EXTENSIONS.includes(extensionOf(file.name))) {
return t('episodeBatch.invalidType')
}
if (file.size > MAX_VIDEO_SIZE) {
return t('episodeBatch.oversize')
}
return ''
}
function onPickFiles(event: Event): void {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
if (!files.length) return
const accepted: File[] = []
const rejected: string[] = []
for (const file of files) {
const reason = validateFile(file)
if (reason) {
rejected.push(`${file.name}: ${reason}`)
} else {
accepted.push(file)
}
}
rejectedNames.value = rejected
if (!accepted.length) return
// 只入队,不发起上传;等用户点「开始上传」
queue.enqueue(accepted)
}
function openFilePicker(): void {
fileInputRef.value?.click()
}
function onStartUpload(): void {
if (!queue.canStart.value) return
queue.start()
}
function onBeforeUnload(event: BeforeUnloadEvent): void {
if (!queue.hasActiveUpload.value) return
event.preventDefault()
event.returnValue = ''
}
watch(
() => queue.hasActiveUpload.value,
(active) => {
if (active) {
window.addEventListener('beforeunload', onBeforeUnload)
} else {
window.removeEventListener('beforeunload', onBeforeUnload)
}
}
)
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
})
async function promptResumeIfNeeded(): Promise<void> {
if (resumePromptShown.value || !dramaKey.value) return
const sessions = queue.getPendingSessions()
if (!sessions.length) return
resumePromptShown.value = true
pendingSessions.value = sessions
try {
await ElMessageBox.confirm(
t('episodeBatch.resumeConfirm', { count: sessions.length }),
t('episodeBatch.resumeTitle'),
{
confirmButtonText: t('episodeBatch.resumeYes'),
cancelButtonText: t('episodeBatch.resumeNo'),
type: 'info'
}
)
resumeFileInputRef.value?.click()
} catch {
queue.abandonSessions(sessions)
pendingSessions.value = []
ElMessage.info(t('episodeBatch.resumeAbandoned'))
}
}
async function onResumeFiles(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
input.value = ''
const sessions = pendingSessions.value
if (!files.length || !sessions.length) return
const { matched, unmatched } = await queue.resumeFromSessions(sessions, files)
pendingSessions.value = []
if (unmatched.length) {
ElMessage.warning(t('episodeBatch.resumeUnmatched', { count: unmatched.length }))
}
if (matched.length) {
ElMessage.success(t('episodeBatch.resumeMatched', { count: matched.length }))
}
}
function beforeClose(done: () => void): void {
if (!queue.hasActiveUpload.value) {
done()
return
}
ElMessageBox.confirm(t('episodeBatch.closeWhileUploading'), t('common.reminder'), {
type: 'warning',
confirmButtonText: t('common.ok'),
cancelButtonText: t('common.cancel')
})
.then(() => {
queue.pause()
done()
})
.catch(() => undefined)
}
watch(visible, (open) => {
if (open) {
void promptResumeIfNeeded()
}
})
function openCreateDialog(): void {
if (!canCreateEpisodes.value) return
createVisible.value = true
}
function onEpisodesCreated(): void {
createVisible.value = false
emit('created')
visible.value = false
queue.clear()
}
</script>
<template>
<el-dialog
v-model="visible"
:title="t('episodeBatch.title')"
width="720px"
class="batch-upload-dialog"
:close-on-click-modal="false"
:before-close="beforeClose"
>
<input
ref="fileInputRef"
type="file"
multiple
hidden
:accept="VIDEO_ACCEPT_ATTR"
@change="onPickFiles"
/>
<input
ref="resumeFileInputRef"
type="file"
multiple
hidden
:accept="VIDEO_ACCEPT_ATTR"
@change="onResumeFiles"
/>
<div class="batch-upload-toolbar">
<el-button type="primary" @click="openFilePicker">
<AppIcon :icon="AppIcons.upload" />
{{ t('episodeBatch.selectFiles') }}
</el-button>
<el-button
:disabled="!queue.started.value || !queue.items.value.length"
@click="queue.pause()"
>
{{ t('episodeBatch.pause') }}
</el-button>
<el-button :disabled="!queue.canStart.value" @click="queue.start()">
{{ t('episodeBatch.resume') }}
</el-button>
<el-button
:disabled="!queue.items.value.some((item) => item.status === 'failed')"
@click="queue.retryAll()"
>
{{ t('episodeBatch.retryAll') }}
</el-button>
</div>
<p class="batch-upload-hint">{{ t('episodeBatch.hint') }}</p>
<div v-if="rejectedNames.length" class="batch-upload-rejected">
<p v-for="name in rejectedNames" :key="name">{{ name }}</p>
</div>
<div v-if="queue.items.value.length" class="batch-upload-overall">
<span>{{ t('episodeBatch.overallProgress') }}</span>
<el-progress :percentage="queue.overallPercent.value" :stroke-width="12" />
</div>
<p v-if="queue.showResultSummary.value" class="batch-upload-summary">
{{
t('episodeBatch.resultSummary', {
done: queue.doneItems.value.length,
failed: queue.failedCount.value
})
}}
</p>
<div class="batch-upload-list">
<UploadTaskRow
v-for="(item, index) in queue.items.value"
:key="item.id"
:item="item"
:index="index + 1"
:allow-remove="!queue.started.value && item.status === 'waiting'"
@retry="queue.retry"
@remove="queue.remove"
/>
<el-empty v-if="!queue.items.value.length" :description="t('episodeBatch.empty')" />
</div>
<template #footer>
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
<el-button
type="primary"
:disabled="!queue.canStart.value"
:loading="queue.isUploading.value"
@click="onStartUpload"
>
{{ startButtonLabel }}
</el-button>
<el-button type="success" :disabled="!canCreateEpisodes" @click="openCreateDialog">
{{ t('episodeBatch.createEpisodes') }}
</el-button>
</template>
</el-dialog>
<CreateEpisodesDialog
v-model="createVisible"
:drama-id="dramaId"
:done-items="queue.doneItems.value"
:next-episode-number="nextEpisodeNumber"
:default-unlock-coin="defaultUnlockCoin"
:free-episodes="freeEpisodes"
:existing-numbers="existingNumbers"
@settled="emit('refreshed')"
@created="onEpisodesCreated"
/>
</template>
<style scoped>
.batch-upload-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.batch-upload-hint {
margin: 0 0 12px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.batch-upload-rejected {
margin-bottom: 12px;
padding: 8px 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--el-color-danger) 8%, transparent);
color: var(--el-color-danger);
font-size: 12px;
}
.batch-upload-rejected p {
margin: 0;
}
.batch-upload-overall {
margin-bottom: 12px;
}
.batch-upload-summary {
margin: 0 0 12px;
padding: 8px 12px;
border-radius: 6px;
background: color-mix(in srgb, var(--el-color-success) 10%, transparent);
color: var(--el-text-color-primary);
font-size: 13px;
}
.batch-upload-list {
max-height: 360px;
overflow: auto;
}
</style>
@@ -0,0 +1,263 @@
<!-- Author: fruit.com.cn -->
<!-- 批量创建分集确认清单:标题取文件名,集号自动顺延,失败可重试 -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { createEpisodeApi } from '@/api/admin/episode'
import { useI18n } from '@/hooks/web/useI18n'
import type { UploadQueueItem } from './useUploadQueue'
const props = withDefaults(
defineProps<{
modelValue: boolean
dramaId: string | number
doneItems: UploadQueueItem[]
nextEpisodeNumber: number
defaultUnlockCoin: number
existingNumbers: number[]
/** 所属短剧免费集数;不传默认 0 */
freeEpisodes?: number
}>(),
{
freeEpisodes: 0
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
settled: []
created: []
}>()
const { t } = useI18n()
interface EpisodeDraft {
key: string
sourceName: string
number: number
title: string
isFree: boolean
videoUrl: string
durationSec: number
saved: boolean
error: string
}
const PUBLISHED_STATUS = 'published'
const rows = ref<EpisodeDraft[]>([])
const submitting = ref(false)
function baseName(fileName: string): string {
const dot = fileName.lastIndexOf('.')
if (dot <= 0) return fileName
return fileName.slice(0, dot)
}
function compareNatural(left: string, right: string): number {
return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' })
}
/** 从起点起跳过已占用集号,返回下一个可用值。 */
function nextFreeNumber(start: number, used: Set<number>): number {
let candidate = start
while (used.has(candidate)) {
candidate += 1
}
return candidate
}
function buildRows(): void {
const sorted = [...props.doneItems].sort((left, right) =>
compareNatural(baseName(left.fileName), baseName(right.fileName))
)
const used = new Set(props.existingNumbers)
let cursor = props.nextEpisodeNumber
const freeLimit = props.freeEpisodes
rows.value = sorted.map((item) => {
const number = nextFreeNumber(cursor, used)
used.add(number)
cursor = number + 1
const isFree = number <= freeLimit
return {
key: item.id,
sourceName: item.fileName,
number,
title: baseName(item.fileName),
isFree,
videoUrl: item.url || '',
durationSec: item.durationSec || 0,
saved: false,
error: ''
}
})
}
watch(
() => props.modelValue,
(open) => {
if (open) buildRows()
}
)
function errorText(error: unknown): string {
if (error instanceof Error && error.message) return error.message
return t('episodeBatch.createFailed')
}
async function createOne(row: EpisodeDraft): Promise<void> {
if (!row.videoUrl) {
row.error = t('episodeBatch.missingVideo')
return
}
try {
await createEpisodeApi({
dramaId: props.dramaId as unknown as number,
number: row.number,
title: baseName(row.sourceName),
durationSec: row.durationSec,
videoUrl: row.videoUrl,
isFree: row.isFree,
unlockCoin: row.isFree ? 0 : props.defaultUnlockCoin,
status: PUBLISHED_STATUS
})
row.saved = true
row.error = ''
} catch (error) {
row.error = errorText(error)
}
}
async function submit(): Promise<void> {
if (submitting.value) return
submitting.value = true
try {
for (const row of rows.value) {
if (!row.saved) await createOne(row)
}
} finally {
submitting.value = false
}
emit('settled')
if (rows.value.every((row) => row.saved)) {
ElMessage.success(t('episodeBatch.createAllSuccess'))
emit('created')
return
}
ElMessage.warning(t('episodeBatch.createPartial'))
}
function closeDialog(): void {
emit('update:modelValue', false)
}
function hasFailed(): boolean {
return rows.value.some((row) => !row.saved && !!row.error)
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="t('episodeBatch.createTitle')"
width="760px"
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
>
<p class="create-episode-hint">{{ t('episodeBatch.createHint') }}</p>
<div class="create-episode-head">
<span>{{ t('episodeBatch.colSource') }}</span>
<span>{{ t('episodeBatch.colNumber') }}</span>
<span>{{ t('episodeBatch.colTitle') }}</span>
<span>{{ t('episodeBatch.colFree') }}</span>
<span>{{ t('episodeBatch.colResult') }}</span>
</div>
<div class="create-episode-list">
<div
v-for="row in rows"
:key="row.key"
class="create-episode-row"
:class="{ 'is-saved': row.saved }"
>
<b class="create-episode-source" :title="row.sourceName">{{ row.sourceName }}</b>
<span class="create-episode-number">{{ row.number }}</span>
<span class="create-episode-title" :title="row.title">{{ row.title }}</span>
<span class="create-episode-free" :class="row.isFree ? 'is-free' : 'is-paid'">
{{ row.isFree ? t('episodeBatch.labelFree') : t('episodeBatch.labelPaid') }}
</span>
<span class="create-episode-result">
{{ row.saved ? t('episodeBatch.createOk') : row.error }}
</span>
</div>
</div>
<template #footer>
<el-button @click="closeDialog">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="submitting" @click="submit">
{{ hasFailed() ? t('episodeBatch.retryFailed') : t('episodeBatch.submitCreate') }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.create-episode-hint {
margin: 0 0 12px;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.5;
}
.create-episode-head,
.create-episode-row {
display: grid;
grid-template-columns: minmax(120px, 1.2fr) 72px minmax(140px, 1.4fr) 56px minmax(80px, 0.8fr);
gap: 8px;
align-items: center;
}
.create-episode-free {
font-size: 12px;
}
.create-episode-free.is-free {
color: var(--el-color-success);
}
.create-episode-free.is-paid {
color: var(--el-color-warning);
}
.create-episode-head {
margin-bottom: 8px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.create-episode-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 420px;
overflow: auto;
}
.create-episode-source,
.create-episode-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.create-episode-number {
font-variant-numeric: tabular-nums;
}
.create-episode-result {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.create-episode-row.is-saved .create-episode-result {
color: var(--el-color-success);
}
</style>
@@ -0,0 +1,280 @@
<!-- Author: fruit.com.cn -->
<!-- 剧集新建/编辑弹窗:表单校验、单集视频上传与保存 -->
<script setup>
import { nextTick, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createEpisodeApi, updateEpisodeApi } from '@/api/admin/episode'
import { formatCount } from '@/utils/format'
import EpisodeVideoUploader from './EpisodeVideoUploader.vue'
const props = defineProps({
dramaOptions: { type: Object, default: null },
nextNumber: { type: Number, default: 1 },
row: { type: Object, default: null }
})
const emit = defineEmits(['saved'])
const dialogVisible = ref(false)
const saving = ref(false)
const uploadingVideo = ref(false)
const videoFileName = ref('')
const editingId = ref(null)
const editingCommentCount = ref(0)
const formRef = ref()
const uploaderRef = ref()
const form = reactive({
number: 1,
title: '',
durationSec: 0,
videoUrl: '',
isFree: true,
unlockCoin: 0,
likeCount: 0,
status: 'draft'
})
const statusOptions = [
{ code: 'draft', text: '草稿' },
{ code: 'approved', text: '待发布' },
{ code: 'published', text: '已上线' },
{ code: 'offline', text: '已下线' }
]
const formRules = {
number: [{ required: true, message: '请输入集号', trigger: 'change' }],
title: [{ required: true, message: '请输入剧集标题', trigger: 'blur' }],
videoUrl: [
{
validator: (_rule, value, callback) => {
if (form.status === 'published' && !value) {
callback(new Error('上线剧集必须上传视频'))
} else {
callback()
}
},
trigger: 'change'
}
]
}
function episodeBasename(path) {
if (!path) return ''
const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean)
}
async function resetValidation() {
await nextTick()
formRef.value?.clearValidate()
}
function openCreate() {
editingId.value = null
Object.assign(form, {
number: props.nextNumber,
title: '',
durationSec: 0,
videoUrl: '',
isFree: true,
unlockCoin: props.dramaOptions?.unlockCoin ?? 0,
likeCount: 0,
status: 'draft'
})
videoFileName.value = ''
uploaderRef.value?.clearError()
editingCommentCount.value = 0
dialogVisible.value = true
resetValidation()
}
function openEdit(row) {
const target = row || props.row
if (!target) return
editingId.value = target.id
Object.assign(form, {
number: target.number,
title: target.title,
durationSec: target.durationSec || 0,
videoUrl: target.videoUrl || '',
isFree: !!target.isFree,
unlockCoin: target.unlockCoin || 0,
likeCount: target.likeCount ?? 0,
status: target.status
})
videoFileName.value = target.videoUrl ? episodeBasename(target.videoUrl) : ''
uploaderRef.value?.clearError()
editingCommentCount.value = target.commentCount ?? 0
dialogVisible.value = true
resetValidation()
}
async function saveEpisode() {
if (saving.value || uploadingVideo.value) return
try {
await formRef.value?.validate()
} catch {
return
}
const dramaId = props.dramaOptions?.id
if (!dramaId) return
const payload = {
dramaId,
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,
likeCount: form.likeCount,
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
emit('saved')
} finally {
saving.value = false
}
}
function beforeDialogClose(done) {
if (saving.value || uploadingVideo.value) {
ElMessage.warning(saving.value ? '正在保存,请稍候' : '视频正在上传,请稍候')
return
}
done()
}
defineExpose({ openCreate, openEdit })
</script>
<template>
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑剧集' : '新增剧集'"
width="680px"
class="episode-editor-dialog"
:close-on-click-modal="false"
:before-close="beforeDialogClose"
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-position="top"
class="episode-editor-form"
>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">基础信息</h3>
<div class="episode-editor-grid episode-editor-grid--two">
<el-form-item label="集号" prop="number">
<el-input-number
v-model="form.number"
class="episode-editor-number"
controls-position="right"
:min="1"
:max="999"
/>
</el-form-item>
<el-form-item label="时长(秒)">
<el-input-number
v-model="form.durationSec"
class="episode-editor-number"
controls-position="right"
:min="0"
:max="9999"
/>
</el-form-item>
</div>
<el-form-item label="剧集标题" prop="title">
<el-input v-model="form.title" maxlength="100" placeholder="例如:命运的相遇" />
</el-form-item>
</section>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">视频文件</h3>
<el-form-item prop="videoUrl">
<EpisodeVideoUploader
ref="uploaderRef"
v-model:video-url="form.videoUrl"
v-model:duration-sec="form.durationSec"
v-model:file-name="videoFileName"
v-model:uploading="uploadingVideo"
@change="resetValidation"
/>
</el-form-item>
</section>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">互动数据</h3>
<div class="episode-editor-grid episode-editor-grid--two">
<el-form-item label="本集点赞数">
<el-input-number
v-model="form.likeCount"
class="episode-editor-number"
controls-position="right"
:min="0"
:precision="0"
/>
</el-form-item>
<el-form-item label="本集评论数">
<div class="readonly-stat">{{ formatCount(editingCommentCount) }}</div>
<p class="readonly-stat-hint">按集实时统计,不可编辑</p>
</el-form-item>
</div>
</section>
<section class="episode-editor-section">
<h3 class="episode-editor-section-title">付费与状态</h3>
<div class="episode-editor-grid episode-editor-grid--three">
<el-form-item label="是否免费">
<el-switch v-model="form.isFree" />
</el-form-item>
<el-form-item v-show="!form.isFree" label="解锁金币">
<el-input-number
v-model="form.unlockCoin"
class="episode-editor-number"
controls-position="right"
: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>
</section>
</el-form>
<template #footer>
<el-button :disabled="saving || uploadingVideo" @click="dialogVisible = false">
取消
</el-button>
<el-button
type="primary"
:loading="saving"
:disabled="saving || uploadingVideo"
@click="saveEpisode"
>
保存
</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,172 @@
<!-- Author: fruit.com.cn -->
<!-- 单集视频上传:格式/大小校验、分片上传、预览与移除 -->
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import { useI18n } from '@/hooks/web/useI18n'
import { probeVideoDuration, uploadFile } from '@/utils/uploader'
import { MAX_VIDEO_SIZE, VIDEO_ACCEPT_ATTR, VIDEO_EXTENSIONS } from './videoLimit'
const props = defineProps({
videoUrl: { type: String, default: '' },
durationSec: { type: Number, default: 0 },
fileName: { type: String, default: '' }
})
const emit = defineEmits([
'update:videoUrl',
'update:durationSec',
'update:fileName',
'update:uploading',
'change'
])
const { t } = useI18n()
const uploadingVideo = ref(false)
const videoPercent = ref(0)
const videoError = ref('')
const videoDisplayName = computed(
() => props.fileName || (props.videoUrl ? episodeBasename(props.videoUrl) : '尚未选择视频')
)
watch(uploadingVideo, (value) => emit('update:uploading', value))
function episodeBasename(path) {
if (!path) return ''
const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean)
}
function clearError() {
videoError.value = ''
}
function beforeVideoUpload(file) {
videoError.value = ''
const extension = String(file.name || '')
.split('.')
.pop()
?.toLowerCase()
if (!VIDEO_EXTENSIONS.includes(extension)) {
videoError.value = t('episodeBatch.invalidType')
return false
}
if (file.size > MAX_VIDEO_SIZE) {
videoError.value = '视频大小不能超过 500MB'
return false
}
return true
}
/** 视频分片上传:先探测时长,再交给上传器,完成后回填地址与时长。 */
async function uploadVideo(options) {
uploadingVideo.value = true
videoPercent.value = 0
videoError.value = ''
try {
const duration = await probeVideoDuration(options.file)
const asset = await uploadFile(options.file, {
bizType: 'EPISODE',
durationSec: duration,
onProgress: (percent) => (videoPercent.value = percent)
})
emit('update:videoUrl', asset.url)
emit('update:fileName', options.file.name)
if (duration > 0) emit('update:durationSec', duration)
emit('change')
ElMessage.success('视频已上传')
} catch {
videoError.value = '视频上传失败,请检查网络后重试'
} finally {
uploadingVideo.value = false
}
}
function removeVideo() {
if (uploadingVideo.value) return
emit('update:videoUrl', '')
emit('update:fileName', '')
videoError.value = ''
emit('change')
}
defineExpose({ clearError, uploadingVideo })
</script>
<template>
<div class="episode-video-editor">
<div class="episode-video-preview-card">
<video
v-if="videoUrl"
:src="videoUrl"
controls
preload="metadata"
class="episode-editor-video"
></video>
<el-upload
v-else
drag
class="episode-video-empty-upload"
:show-file-list="false"
:accept="VIDEO_ACCEPT_ATTR"
:before-upload="beforeVideoUpload"
:http-request="uploadVideo"
:disabled="uploadingVideo"
>
<div class="episode-video-empty-copy">
<AppIcon :icon="AppIcons.videoCameraFront" :size="30" />
<b>拖放或点击上传</b>
<span>MP4、MOV、MKV、WEBM</span>
</div>
</el-upload>
<div v-if="uploadingVideo" class="episode-video-upload-mask">
<span>上传中</span>
<b>{{ videoPercent }}%</b>
</div>
</div>
<div class="episode-video-meta">
<div class="episode-video-status">
<el-tag :type="videoUrl ? 'success' : 'info'" effect="light">
{{ videoUrl ? '已上传' : '未上传' }}
</el-tag>
<span class="episode-video-filename" :title="videoDisplayName">
{{ videoDisplayName }}
</span>
</div>
<p class="episode-video-hint">{{ t('episodeBatch.hint') }}</p>
<el-alert v-if="videoError" :title="videoError" type="error" :closable="false" show-icon />
<div class="episode-video-actions">
<el-upload
v-if="videoUrl"
:show-file-list="false"
:accept="VIDEO_ACCEPT_ATTR"
:before-upload="beforeVideoUpload"
:http-request="uploadVideo"
:disabled="uploadingVideo"
>
<el-button :loading="uploadingVideo" :disabled="uploadingVideo">
<AppIcon :icon="AppIcons.upload" />
更换视频
</el-button>
</el-upload>
<el-button v-if="videoUrl" type="danger" :disabled="uploadingVideo" @click="removeVideo">
移除
</el-button>
<a
v-if="videoUrl"
class="episode-video-link"
:href="videoUrl"
target="_blank"
rel="noopener noreferrer"
>
查看视频
</a>
</div>
</div>
</div>
</template>
@@ -0,0 +1,137 @@
<!-- Author: fruit.com.cn -->
<!-- 批量上传队列单行:序号、文件名、大小、进度、状态与操作 -->
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@/hooks/web/useI18n'
import type { UploadQueueItem } from './useUploadQueue'
const props = defineProps<{
item: UploadQueueItem
/** 自然排序后的展示序号(从 1 起) */
index: number
/** 选择阶段才允许移除 */
allowRemove: boolean
}>()
const emit = defineEmits<{
retry: [id: string]
remove: [id: string]
}>()
const { t } = useI18n()
const BYTES_PER_KB = 1024
const BYTES_PER_MB = 1024 * 1024
const sizeLabel = computed(() => {
const size = props.item.fileSize
if (size >= BYTES_PER_MB) {
return `${(size / BYTES_PER_MB).toFixed(1)} MB`
}
return `${Math.max(1, Math.round(size / BYTES_PER_KB))} KB`
})
const statusLabel = computed(() => t(`episodeBatch.status.${props.item.status}`))
const statusType = computed(() => {
switch (props.item.status) {
case 'done':
return 'success'
case 'failed':
return 'danger'
case 'paused':
return 'warning'
case 'merging':
case 'uploading':
return ''
default:
return 'info'
}
})
const canRetry = computed(() => props.item.status === 'failed')
</script>
<template>
<div class="upload-task-row" :class="{ 'is-failed': item.status === 'failed' }">
<div class="upload-task-row__meta">
<span class="upload-task-row__index">{{ index }}</span>
<b class="upload-task-row__name" :title="item.fileName">{{ item.fileName }}</b>
<small>{{ sizeLabel }}</small>
<el-tag :type="statusType" effect="light" size="small">{{ statusLabel }}</el-tag>
</div>
<el-progress
:percentage="item.percent"
:status="
item.status === 'failed' ? 'exception' : item.status === 'done' ? 'success' : undefined
"
:stroke-width="10"
/>
<p v-if="item.error" class="upload-task-row__error">{{ item.error }}</p>
<div class="upload-task-row__actions">
<el-button v-if="canRetry" size="small" type="primary" @click="emit('retry', item.id)">
{{ t('episodeBatch.retry') }}
</el-button>
<el-button
v-if="allowRemove"
size="small"
type="danger"
text
@click="emit('remove', item.id)"
>
{{ t('episodeBatch.remove') }}
</el-button>
</div>
</div>
</template>
<style scoped>
.upload-task-row {
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.upload-task-row__meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.upload-task-row__index {
flex: 0 0 auto;
min-width: 1.5em;
color: var(--el-text-color-secondary);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.upload-task-row__name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-task-row__error {
margin: 6px 0 0;
color: var(--el-color-danger);
font-size: 12px;
}
.upload-task-row__actions {
margin-top: 8px;
display: flex;
gap: 8px;
justify-content: flex-end;
margin-left: auto;
}
.upload-task-row.is-failed {
background: color-mix(in srgb, var(--el-color-danger) 6%, transparent);
border-radius: 6px;
padding-left: 8px;
padding-right: 8px;
}
</style>
@@ -0,0 +1,72 @@
// Author: fruit.com.cn
/** 剧集批量上传断点会话:localStorage 读写与清理 */
export const UPLOAD_SESSION_KEY = 'reel_upload_sessions'
export const UPLOAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000
export interface UploadSessionRecord {
uploadId: string
fileName: string
fileSize: number
lastModified: number
dramaId: string
bizType: string
createdAt: number
}
/** 读取全部会话,解析失败时返回空数组。 */
export function loadUploadSessions(): UploadSessionRecord[] {
try {
const raw = localStorage.getItem(UPLOAD_SESSION_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as UploadSessionRecord[]) : []
} catch {
return []
}
}
/** 覆盖写入全部会话。 */
export function saveUploadSessions(sessions: UploadSessionRecord[]): void {
localStorage.setItem(UPLOAD_SESSION_KEY, JSON.stringify(sessions))
}
/** 按 dramaId 过滤且未超过 TTL 的未完成会话。 */
export function listPendingSessions(dramaId: string, now = Date.now()): UploadSessionRecord[] {
const dramaKey = String(dramaId)
return loadUploadSessions().filter(
(item) =>
String(item.dramaId) === dramaKey &&
now - Number(item.createdAt || 0) <= UPLOAD_SESSION_TTL_MS
)
}
/** 新增或按 uploadId 覆盖一条会话。 */
export function upsertUploadSession(record: UploadSessionRecord): void {
const next = loadUploadSessions().filter((item) => item.uploadId !== record.uploadId)
next.push(record)
saveUploadSessions(next)
}
/** 按 uploadId 移除会话。 */
export function removeUploadSession(uploadId: string): void {
if (!uploadId) return
saveUploadSessions(loadUploadSessions().filter((item) => item.uploadId !== uploadId))
}
/** 批量移除会话。 */
export function removeUploadSessions(uploadIds: string[]): void {
if (!uploadIds.length) return
const idSet = new Set(uploadIds)
saveUploadSessions(loadUploadSessions().filter((item) => !idSet.has(item.uploadId)))
}
/** 用文件名 + size + lastModified 匹配本地文件。 */
export function matchFileForSession(session: UploadSessionRecord, files: File[]): File | undefined {
return files.find(
(file) =>
file.name === session.fileName &&
file.size === session.fileSize &&
file.lastModified === session.lastModified
)
}
@@ -0,0 +1,352 @@
// Author: fruit.com.cn
/** 剧集批量上传队列:手动开始、文件并发、进度加权、断点会话、失败隔离 */
import { computed, ref } from 'vue'
import { getUploadProgressApi } from '@/api/admin/media'
import { probeVideoDuration, resumeUpload, uploadFile } from '@/utils/uploader'
import {
listPendingSessions,
matchFileForSession,
removeUploadSession,
type UploadSessionRecord,
upsertUploadSession
} from './uploadSessions'
export type UploadItemStatus = 'waiting' | 'uploading' | 'merging' | 'done' | 'failed' | 'paused'
export interface UploadQueueItem {
id: string
file: File
fileName: string
fileSize: number
lastModified: number
percent: number
status: UploadItemStatus
error: string
uploadId: string
assetId?: number
url?: string
durationSec?: number
}
export interface UseUploadQueueOptions {
/** 当前短剧 id(调用时读取,避免切换剧后闭包过期) */
getDramaId: () => string
bizType?: string
}
const FILE_CONCURRENCY = 2
const CHUNK_CONCURRENCY = 3
const DEFAULT_BIZ_TYPE = 'EPISODE'
const MERGING_PERCENT = 95
const MAX_PERCENT = 100
let queueSeq = 0
function nextItemId(): string {
queueSeq += 1
return `uq-${Date.now()}-${queueSeq}`
}
function extractErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message
return String(error || 'upload failed')
}
function isHttpNotFound(error: unknown): boolean {
const status = (error as { response?: { status?: number } })?.response?.status
return status === 404
}
/** 文件名自然排序(数字感知),保证队列序号稳定。 */
function compareFileName(a: string, b: string): number {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
}
/**
* 创建上传队列控制器。
* @param options dramaId 与可选 bizType
*/
export function useUploadQueue(options: UseUploadQueueOptions) {
const items = ref<UploadQueueItem[]>([])
const paused = ref(false)
/** 用户是否已点过「开始上传」;选文件阶段不派发、不占用并发。 */
const started = ref(false)
const bizType = options.bizType || DEFAULT_BIZ_TYPE
let running = 0
const overallPercent = computed(() => {
const list = items.value
if (!list.length) return 0
const totalBytes = list.reduce((sum, item) => sum + item.fileSize, 0)
if (totalBytes <= 0) return 0
const weighted = list.reduce((sum, item) => sum + item.fileSize * item.percent, 0)
return Math.min(MAX_PERCENT, Math.round(weighted / totalBytes))
})
const isUploading = computed(() =>
items.value.some((item) => item.status === 'uploading' || item.status === 'merging')
)
/** 关闭弹窗需二次确认:仅在已开始且仍有未完成任务时。 */
const hasActiveUpload = computed(() => {
if (!started.value) return false
return items.value.some(
(item) =>
item.status === 'waiting' ||
item.status === 'uploading' ||
item.status === 'merging' ||
item.status === 'paused'
)
})
const canStart = computed(() => {
if (isUploading.value) return false
return items.value.some((item) => item.status === 'waiting' || item.status === 'paused')
})
const doneItems = computed(() => items.value.filter((item) => item.status === 'done'))
const failedCount = computed(() => items.value.filter((item) => item.status === 'failed').length)
/** 已开始且队列已无进行中任务时,可展示结果汇总。 */
const showResultSummary = computed(
() => started.value && items.value.length > 0 && !hasActiveUpload.value
)
function persistSession(item: UploadQueueItem, uploadId: string): void {
item.uploadId = uploadId
upsertUploadSession({
uploadId,
fileName: item.fileName,
fileSize: item.fileSize,
lastModified: item.lastModified,
dramaId: String(options.getDramaId()),
bizType,
createdAt: Date.now()
})
}
function clearSession(uploadId: string): void {
if (uploadId) removeUploadSession(uploadId)
}
function sortItemsByName(): void {
items.value.sort((a, b) => compareFileName(a.fileName, b.fileName))
}
async function runOne(item: UploadQueueItem, resumeId?: string): Promise<void> {
item.status = 'uploading'
item.error = ''
try {
const durationSec = await probeVideoDuration(item.file)
item.durationSec = durationSec
const uploadOptions = {
bizType,
concurrency: CHUNK_CONCURRENCY,
durationSec,
onSession: (uploadId: string) => persistSession(item, uploadId),
onProgress: (percent: number) => {
item.percent = percent
if (percent >= MERGING_PERCENT && item.status === 'uploading') {
item.status = 'merging'
}
}
}
const asset = resumeId
? await resumeUpload(item.file, resumeId, uploadOptions)
: await uploadFile(item.file, uploadOptions)
item.percent = MAX_PERCENT
item.status = 'done'
item.assetId = asset.id
item.url = asset.url
if (asset.durationSec != null) item.durationSec = asset.durationSec
clearSession(item.uploadId || resumeId || '')
} catch (error) {
item.status = 'failed'
item.error = extractErrorMessage(error)
}
}
function pump(): void {
if (!started.value || paused.value) return
while (running < FILE_CONCURRENCY && !paused.value) {
const next = items.value.find((item) => item.status === 'waiting')
if (!next) return
// 立刻占位,避免同步 while 循环把同一项派发两次
next.status = 'uploading'
running += 1
const resumeId = next.uploadId || undefined
void runOne(next, resumeId).finally(() => {
running -= 1
if (!paused.value) pump()
})
}
}
/** 入队本地文件(调用方已完成类型/大小校验);只展示待上传,不发起请求。 */
function enqueue(files: File[]): void {
const sorted = [...files].sort((a, b) => compareFileName(a.name, b.name))
for (const file of sorted) {
items.value.push({
id: nextItemId(),
file,
fileName: file.name,
fileSize: file.size,
lastModified: file.lastModified,
percent: 0,
status: 'waiting',
error: '',
uploadId: ''
})
}
sortItemsByName()
}
/** 开始或继续派发(含断点续传项上的 uploadId)。 */
function start(): void {
if (!items.value.length) return
started.value = true
paused.value = false
for (const item of items.value) {
if (item.status === 'paused') item.status = 'waiting'
}
void pump()
}
/** 暂停:不派新任务;进行中分片跑完。 */
function pause(): void {
if (!started.value) return
paused.value = true
for (const item of items.value) {
if (item.status === 'waiting') item.status = 'paused'
}
}
/** 重试单个失败项。 */
function retry(id: string): void {
const item = items.value.find((row) => row.id === id)
if (!item || item.status !== 'failed') return
item.error = ''
item.percent = item.uploadId ? item.percent : 0
item.status = paused.value ? 'paused' : 'waiting'
if (started.value && !paused.value) void pump()
}
/** 重试全部失败项。 */
function retryAll(): void {
for (const item of items.value) {
if (item.status === 'failed') {
item.error = ''
item.status = paused.value ? 'paused' : 'waiting'
}
}
if (started.value && !paused.value) void pump()
}
/** 仅选择阶段可移除;已开始上传后禁止直接移除,避免脏会话。 */
function remove(id: string): void {
if (started.value) return
const index = items.value.findIndex((row) => row.id === id)
if (index < 0) return
const [removed] = items.value.splice(index, 1)
clearSession(removed.uploadId)
}
/** 清空队列并清理本队列会话。 */
function clear(): void {
for (const item of items.value) {
clearSession(item.uploadId)
}
items.value = []
paused.value = false
started.value = false
running = 0
}
/** 读取当前剧未完成会话(24h 内)。 */
function getPendingSessions(): UploadSessionRecord[] {
return listPendingSessions(String(options.getDramaId()))
}
/**
* 用本地重选文件匹配会话并入队(待上传);不自动开始,由用户点「开始上传」续传。
* @returns matched / unmatched 会话列表
*/
async function resumeFromSessions(
sessions: UploadSessionRecord[],
files: File[]
): Promise<{ matched: UploadSessionRecord[]; unmatched: UploadSessionRecord[] }> {
const matched: UploadSessionRecord[] = []
const unmatched: UploadSessionRecord[] = []
for (const session of sessions) {
const file = matchFileForSession(session, files)
if (!file) {
unmatched.push(session)
continue
}
try {
const progressRes = await getUploadProgressApi(session.uploadId)
const progress = progressRes?.data
if (!progress) {
unmatched.push(session)
continue
}
if (String(progress.status || '').toLowerCase() === 'completed') {
clearSession(session.uploadId)
continue
}
items.value.push({
id: nextItemId(),
file,
fileName: file.name,
fileSize: file.size,
lastModified: file.lastModified,
percent: Math.min(MERGING_PERCENT, Number(progress.percent) || 0),
status: 'waiting',
error: '',
uploadId: session.uploadId
})
matched.push(session)
} catch (error) {
if (isHttpNotFound(error)) {
clearSession(session.uploadId)
} else {
unmatched.push(session)
}
}
}
sortItemsByName()
return { matched, unmatched }
}
/** 放弃续传:清理给定会话。 */
function abandonSessions(sessions: UploadSessionRecord[]): void {
for (const session of sessions) {
clearSession(session.uploadId)
}
}
return {
items,
overallPercent,
hasActiveUpload,
isUploading,
canStart,
doneItems,
failedCount,
showResultSummary,
started,
paused,
enqueue,
start,
pause,
retry,
retryAll,
remove,
clear,
getPendingSessions,
resumeFromSessions,
abandonSessions
}
}
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
/** 剧集视频大小上限,单文件上传与批量上传共用。 */
export const MAX_VIDEO_SIZE = 500 * 1024 * 1024
/** 允许的视频扩展名(小写,不含点),单文件与批量上传同源。 */
export const VIDEO_EXTENSIONS: string[] = ['mp4', 'mov', 'mkv', 'webm']
/** el-upload / input[type=file] 的 accept 属性,与 VIDEO_EXTENSIONS 保持一致。 */
export const VIDEO_ACCEPT_ATTR = '.mp4,.mov,.mkv,.webm,video/*'
+21 -3
View File
@@ -1,7 +1,9 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup> <script setup>
import { computed } from 'vue'
import { AppIcons } from '@/constants/icons' import { AppIcons } from '@/constants/icons'
import { AppImage } from '@/components/AppImage'
import PageHeading from '@/views/components/PageHeading.vue' import PageHeading from '@/views/components/PageHeading.vue'
import CreatorCenterPage from './components/CreatorCenterPage.vue' import CreatorCenterPage from './components/CreatorCenterPage.vue'
import { useRbac } from '@/stores/rbac' import { useRbac } from '@/stores/rbac'
@@ -47,6 +49,14 @@ const {
withdraw withdraw
} = useDramaEdit() } = useDramaEdit()
const episodeCoverPreviewList = computed(() =>
episodes.value.map((row) => row.cover).filter(Boolean)
)
function episodeCoverPreviewIndex(row) {
return Math.max(0, episodeCoverPreviewList.value.indexOf(row.cover))
}
function back() { function back() {
window.history.back() window.history.back()
} }
@@ -193,7 +203,15 @@ function requestDramaCover(options) {
<el-table-column label="封面" width="88"> <el-table-column label="封面" width="88">
<template #default="{ row }"> <template #default="{ row }">
<div class="creator-episode-cover"> <div class="creator-episode-cover">
<img v-if="row.cover" :src="row.cover" alt="" /> <AppImage
v-if="row.cover"
:src="row.cover"
:preview-list="episodeCoverPreviewList"
:initial-index="episodeCoverPreviewIndex(row)"
fit="cover"
class="creator-episode-image"
:alt="row.title"
/>
<span v-else>无</span> <span v-else>无</span>
</div> </div>
</template> </template>
@@ -244,8 +262,8 @@ function requestDramaCover(options) {
<el-button size="small">{{ hasResumeSession ? '继续上传' : '上传视频' }}</el-button> <el-button size="small">{{ hasResumeSession ? '继续上传' : '上传视频' }}</el-button>
</el-upload> </el-upload>
<el-button <el-button
type="danger"
size="small" size="small"
class="danger-action"
:disabled="!editable" :disabled="!editable"
@click="removeEpisode(row)" @click="removeEpisode(row)"
>删除</el-button >删除</el-button
@@ -299,8 +317,8 @@ function requestDramaCover(options) {
<div class="table-actions"> <div class="table-actions">
<el-button <el-button
v-if="canEdit && ['SUBMITTED', 'REVIEWING'].includes(row.status)" v-if="canEdit && ['SUBMITTED', 'REVIEWING'].includes(row.status)"
type="danger"
size="small" size="small"
class="danger-action"
@click="withdraw(row.id)" @click="withdraw(row.id)"
>撤回</el-button >撤回</el-button
> >
+20 -3
View File
@@ -1,9 +1,10 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup> <script setup>
import { ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { AppIcons } from '@/constants/icons' import { AppIcons } from '@/constants/icons'
import { AppImage } from '@/components/AppImage'
import PageHeading from '@/views/components/PageHeading.vue' import PageHeading from '@/views/components/PageHeading.vue'
import CreatorCenterPage from './components/CreatorCenterPage.vue' import CreatorCenterPage from './components/CreatorCenterPage.vue'
import { useCreatorAccess } from './composables/useCreatorAccess' import { useCreatorAccess } from './composables/useCreatorAccess'
@@ -43,6 +44,14 @@ const {
const activeTab = ref('dramas') const activeTab = ref('dramas')
const workspaceCoverPreviewList = computed(() =>
(dramas.rows || []).map((row) => row.cover).filter(Boolean)
)
function workspaceCoverPreviewIndex(row) {
return Math.max(0, workspaceCoverPreviewList.value.indexOf(row.cover))
}
function onTabChange(name) { function onTabChange(name) {
if (name === 'notices' && !notices.loaded) loadNotices() if (name === 'notices' && !notices.loaded) loadNotices()
} }
@@ -149,7 +158,15 @@ function openEdit(row) {
<template #default="{ row }"> <template #default="{ row }">
<div class="creator-drama-cell"> <div class="creator-drama-cell">
<div class="creator-drama-cover"> <div class="creator-drama-cover">
<img v-if="row.cover" :src="row.cover" alt="" /> <AppImage
v-if="row.cover"
:src="row.cover"
:preview-list="workspaceCoverPreviewList"
:initial-index="workspaceCoverPreviewIndex(row)"
fit="cover"
class="creator-drama-image"
:alt="row.title"
/>
<span v-else>{{ (row.title || '?').slice(0, 1) }}</span> <span v-else>{{ (row.title || '?').slice(0, 1) }}</span>
</div> </div>
<div class="creator-drama-meta"> <div class="creator-drama-meta">
@@ -229,8 +246,8 @@ function openEdit(row) {
row.submissionId && row.submissionId &&
['SUBMITTED', 'REVIEWING'].includes(row.submissionStatus) ['SUBMITTED', 'REVIEWING'].includes(row.submissionStatus)
" "
type="danger"
size="small" size="small"
class="danger-action"
@click="withdraw(row)" @click="withdraw(row)"
>撤回</el-button >撤回</el-button
> >
@@ -156,7 +156,8 @@ export function useCreatorWorkspace() {
'撤回提交', '撤回提交',
{ {
confirmButtonText: '确认撤回', confirmButtonText: '确认撤回',
cancelButtonText: '取消' cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger'
} }
) )
} catch { } catch {
+1 -1
View File
@@ -180,8 +180,8 @@ const {
> >
<el-button <el-button
v-if="canAudit && row.status === 'approved'" v-if="canAudit && row.status === 'approved'"
type="danger"
size="small" size="small"
class="danger-action"
@click="freeze(row)" @click="freeze(row)"
>冻结</el-button >冻结</el-button
> >
@@ -135,7 +135,8 @@ export function useCreatorAudit() {
await ElMessageBox.confirm(`冻结后「${row.name}」将无法提交内容,确认冻结?`, '冻结创作者', { await ElMessageBox.confirm(`冻结后「${row.name}」将无法提交内容,确认冻结?`, '冻结创作者', {
confirmButtonText: '确认冻结', confirmButtonText: '确认冻结',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning',
confirmButtonClass: 'el-button--danger'
}) })
} catch { } catch {
return return
+65 -20
View File
@@ -1,7 +1,7 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import PageHeading from '@/views/components/PageHeading.vue' import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac' import { useRbac } from '@/stores/rbac'
@@ -26,16 +26,23 @@ const canWordUpdate = can('sensitive-word:update')
const activeTab = ref('comments') const activeTab = ref('comments')
const COMMENT_STATUS = [ const COMMENT_STATUS = [
{ value: '', label: '全部状态', tag: 'info' },
{ value: 'pending', label: '待审核', tag: 'warning' }, { value: 'pending', label: '待审核', tag: 'warning' },
{ value: 'published', label: '已发布', tag: 'success' }, { value: 'published', label: '已发布', tag: 'success' },
{ value: 'rejected', label: '已驳回', tag: 'danger' }, { value: 'rejected', label: '已驳回', tag: 'danger' },
{ value: 'deleted', label: '已删除', tag: 'info' } { value: 'deleted', label: '已删除', tag: 'info' }
] ]
const REPORT_STATUS = [ const REPORT_STATUS = [
{ value: '', label: '全部状态', tag: 'info' },
{ value: 'pending', label: '待处理', tag: 'warning' }, { value: 'pending', label: '待处理', tag: 'warning' },
{ value: 'handled', label: '已处理', tag: 'success' }, { value: 'handled', label: '已处理', tag: 'success' },
{ value: 'rejected', label: '已驳回', tag: 'info' } { value: 'rejected', label: '已驳回', tag: 'info' }
] ]
const REPORT_TARGET_TYPES = {
COMMENT: { label: '评论', tag: 'warning' },
USER: { label: '用户', tag: 'success' },
DRAMA: { label: '短剧', tag: 'info' }
}
const WORD_LEVELS = [ const WORD_LEVELS = [
{ value: 'BLOCK', label: '禁止发布' }, { value: 'BLOCK', label: '禁止发布' },
{ value: 'REVIEW', label: '转人工审核' }, { value: 'REVIEW', label: '转人工审核' },
@@ -48,6 +55,22 @@ function meta(list, value) {
function levelText(level) { function levelText(level) {
return WORD_LEVELS.find((item) => item.value === level)?.label || level return WORD_LEVELS.find((item) => item.value === level)?.label || level
} }
function commentEpisodeText(row) {
if (row.episodeNo) return `第 ${row.episodeNo} 集`
return row.episodeId ? `分集 #${row.episodeId}` : '未关联分集'
}
function commentContentTooltip(row) {
return `短剧 ID:${row.dramaId} / 分集 ID:${row.episodeId || '未关联'}`
}
function reportTargetMeta(type) {
return REPORT_TARGET_TYPES[type] || { label: type || '-', tag: 'info' }
}
function reportTargetSummary(row) {
return row.targetSummary || `${reportTargetMeta(row.targetType).label} #${row.targetId}`
}
function reporterText(row) {
return row.reporterNickname || row.reporterUid || `#${row.reporterId}`
}
const comments = reactive({ const comments = reactive({
rows: [], rows: [],
@@ -55,7 +78,7 @@ const comments = reactive({
loading: false, loading: false,
page: 1, page: 1,
size: 10, size: 10,
status: 'pending', status: '',
keyword: '' keyword: ''
}) })
async function loadComments() { async function loadComments() {
@@ -123,7 +146,7 @@ const reports = reactive({
loading: false, loading: false,
page: 1, page: 1,
size: 10, size: 10,
status: 'pending' status: ''
}) })
async function loadReports() { async function loadReports() {
reports.loading = true reports.loading = true
@@ -271,11 +294,6 @@ function onTabChange(name) {
if (name === 'reports') loadReports() if (name === 'reports') loadReports()
if (name === 'words') loadWords() if (name === 'words') loadWords()
} }
const handleTargetText = computed(() => {
const row = handleTarget.value
return row ? `${row.targetType} #${row.targetId}` : ''
})
</script> </script>
<template> <template>
@@ -325,7 +343,17 @@ const handleTargetText = computed(() => {
<el-table-column prop="id" label="ID" width="80" /> <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="nickname" label="用户" min-width="120" show-overflow-tooltip />
<el-table-column prop="content" label="内容" min-width="220" 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="短剧 / 分集" min-width="190">
<template #default="{ row }">
<el-tooltip :content="commentContentTooltip(row)" placement="top">
<div class="episode-table-title">
<b>{{ row.dramaTitle || `短剧 #${row.dramaId}` }}</b>
<small>{{ commentEpisodeText(row) }}</small>
<small v-if="row.episodeTitle">{{ row.episodeTitle }}</small>
</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="状态" width="100"> <el-table-column label="状态" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="meta(COMMENT_STATUS, row.status).tag" effect="light">{{ <el-tag :type="meta(COMMENT_STATUS, row.status).tag" effect="light">{{
@@ -343,7 +371,7 @@ const handleTargetText = computed(() => {
show-overflow-tooltip show-overflow-tooltip
:formatter="dateCell" :formatter="dateCell"
/> />
<el-table-column label="操作" width="200" fixed="right"> <el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="table-actions"> <div class="table-actions">
<el-button <el-button
@@ -358,8 +386,8 @@ const handleTargetText = computed(() => {
> >
<el-button <el-button
v-if="canCommentAudit" v-if="canCommentAudit"
type="danger"
size="small" size="small"
class="danger-action"
@click="removeComment(row)" @click="removeComment(row)"
>删除</el-button >删除</el-button
> >
@@ -410,8 +438,22 @@ const handleTargetText = computed(() => {
empty-text="没有符合条件的工单" empty-text="没有符合条件的工单"
> >
<el-table-column prop="id" label="工单ID" width="90" /> <el-table-column prop="id" label="工单ID" width="90" />
<el-table-column prop="targetType" label="对象" width="100" /> <el-table-column label="对象" min-width="240">
<el-table-column prop="targetId" label="对象ID" width="90" /> <template #default="{ row }">
<el-tooltip placement="top">
<template #content>
<div>原始对象:#{{ row.targetId }}</div>
<div v-if="row.commentContent">评论内容:{{ row.commentContent }}</div>
</template>
<div class="episode-table-title">
<el-tag :type="reportTargetMeta(row.targetType).tag" size="small" effect="light">
{{ reportTargetMeta(row.targetType).label }}
</el-tag>
<b>{{ reportTargetSummary(row) }}</b>
</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="reasonCode" label="原因码" width="120" /> <el-table-column prop="reasonCode" label="原因码" width="120" />
<el-table-column <el-table-column
prop="description" prop="description"
@@ -419,7 +461,9 @@ const handleTargetText = computed(() => {
min-width="180" min-width="180"
show-overflow-tooltip show-overflow-tooltip
/> />
<el-table-column prop="reporterId" label="举报人" width="90" /> <el-table-column label="举报人" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ reporterText(row) }}</template>
</el-table-column>
<el-table-column label="状态" width="100"> <el-table-column label="状态" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="meta(REPORT_STATUS, row.status).tag" effect="light">{{ <el-tag :type="meta(REPORT_STATUS, row.status).tag" effect="light">{{
@@ -523,11 +567,7 @@ const handleTargetText = computed(() => {
<el-button v-if="canWordUpdate" type="primary" size="small" @click="openWord(row)" <el-button v-if="canWordUpdate" type="primary" size="small" @click="openWord(row)"
>编辑</el-button >编辑</el-button
> >
<el-button <el-button v-if="canWordUpdate" type="danger" size="small" @click="removeWord(row)"
v-if="canWordUpdate"
size="small"
class="danger-action"
@click="removeWord(row)"
>删除</el-button >删除</el-button
> >
<span v-if="!canWordUpdate" class="console-muted">只读</span> <span v-if="!canWordUpdate" class="console-muted">只读</span>
@@ -554,7 +594,12 @@ const handleTargetText = computed(() => {
<el-dialog v-model="handleVisible" title="处理举报工单" width="480px"> <el-dialog v-model="handleVisible" title="处理举报工单" width="480px">
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="工单对象"> <el-form-item label="工单对象">
<el-input :model-value="handleTargetText" disabled /> <div v-if="handleTarget" class="episode-table-title">
<el-tag :type="reportTargetMeta(handleTarget.targetType).tag" size="small" effect="light">
{{ reportTargetMeta(handleTarget.targetType).label }}
</el-tag>
<b>{{ reportTargetSummary(handleTarget) }}</b>
</div>
</el-form-item> </el-form-item>
<el-form-item label="处理结论"> <el-form-item label="处理结论">
<el-radio-group v-model="handleForm.status"> <el-radio-group v-model="handleForm.status">
-19
View File
@@ -1,19 +0,0 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const title = computed(() => (route.meta?.title as string) || '菜单页面')
</script>
<template>
<div class="page-heading"
><div
><div class="eyebrow">自定义菜单</div><h1>{{ title }}</h1
><p>该菜单已注册,可在菜单管理中把组件路径指向已有页面。</p></div
></div
>
<section class="panel"><el-empty description="菜单已创建,可以继续配置页面内容。" /></section>
</template>
+2 -2
View File
@@ -37,7 +37,7 @@ const { treeData, loading, form, parentOptions, resetForm, selectNode, saveMenu,
<div <div
><h2>{{ form.id ? '编辑菜单' : '新增菜单' }}</h2></div ><h2>{{ form.id ? '编辑菜单' : '新增菜单' }}</h2></div
> >
<el-button v-if="form.id" type="danger" plain @click="deleteMenu">删除菜单</el-button> <el-button v-if="form.id" type="danger" @click="deleteMenu">删除菜单</el-button>
</div> </div>
<el-form label-width="110px" label-position="right" class="menu-editor-form"> <el-form label-width="110px" label-position="right" class="menu-editor-form">
<el-form-item label="菜单名称" required> <el-form-item label="菜单名称" required>
@@ -75,7 +75,7 @@ const { treeData, loading, form, parentOptions, resetForm, selectNode, saveMenu,
<el-form-item label="权限标识"> <el-form-item label="权限标识">
<el-input v-model="form.permissionCode" placeholder="例如 drama:list" /> <el-input v-model="form.permissionCode" placeholder="例如 drama:list" />
</el-form-item> </el-form-item>
<el-form-item v-if="form.type !== 'BUTTON'" label="菜单图标"> <el-form-item v-if="form.type === 'CATALOG'" label="菜单图标">
<el-select v-model="form.icon" clearable placeholder="请选择"> <el-select v-model="form.icon" clearable placeholder="请选择">
<el-option v-for="icon in menuIconNames" :key="icon" :label="icon" :value="icon" /> <el-option v-for="icon in menuIconNames" :key="icon" :label="icon" :value="icon" />
</el-select> </el-select>
+2 -2
View File
@@ -45,8 +45,8 @@ const {
<el-table-column prop="roleCount" label="角色数" width="100" /> <el-table-column prop="roleCount" label="角色数" width="100" />
<el-table-column label="操作" width="200"> <el-table-column label="操作" width="200">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" @click="editCategory(row)">编辑</el-button> <el-button type="primary" size="small" @click="editCategory(row)">编辑</el-button>
<el-button type="danger" @click="deleteCategory(row)">删除</el-button> <el-button type="danger" size="small" @click="deleteCategory(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
+6 -2
View File
@@ -73,9 +73,13 @@ function removeRoleRow(row) {
}}</el-tag> }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="90" fixed="right"> <el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="danger" link :disabled="row.builtin" @click="removeRoleRow(row)" <el-button
type="danger"
size="small"
:disabled="row.builtin"
@click="removeRoleRow(row)"
>删除</el-button >删除</el-button
> >
</template> </template>
@@ -93,7 +93,7 @@ export function useMenuManagement() {
routePath: raw.path || '', routePath: raw.path || '',
routeName: raw.name || '', routeName: raw.name || '',
component: raw.component === '#' ? '' : raw.component || '', component: raw.component === '#' ? '' : raw.component || '',
icon: normalizeAppIcon(raw.icon), icon: raw.type === 'CATALOG' ? normalizeAppIcon(raw.icon) : '',
permissionCode: raw.permissionCode || '', permissionCode: raw.permissionCode || '',
type: raw.type || 'MENU', type: raw.type || 'MENU',
sort: raw.sort ?? 1, sort: raw.sort ?? 1,
@@ -113,7 +113,7 @@ export function useMenuManagement() {
routePath: form.routePath.trim(), routePath: form.routePath.trim(),
routeName: form.routeName.trim(), routeName: form.routeName.trim(),
component: form.type === 'CATALOG' ? '#' : form.component.trim(), component: form.type === 'CATALOG' ? '#' : form.component.trim(),
icon: form.type === 'BUTTON' ? '' : normalizeAppIcon(form.icon), icon: form.type === 'CATALOG' ? normalizeAppIcon(form.icon) : '',
permissionCode: form.permissionCode.trim(), permissionCode: form.permissionCode.trim(),
type: form.type, type: form.type,
sort: Number(form.sort) || 1, sort: Number(form.sort) || 1,
+25 -2
View File
@@ -3,6 +3,7 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { AppIcons } from '@/constants/icons' import { AppIcons } from '@/constants/icons'
import { AppImage } from '@/components/AppImage'
import PageHeading from '@/views/components/PageHeading.vue' import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac' import { useRbac } from '@/stores/rbac'
import { import {
@@ -70,6 +71,12 @@ function avatarText(row) {
return name.slice(0, 1).toUpperCase() return name.slice(0, 1).toUpperCase()
} }
const avatarPreviewList = computed(() => users.value.map((row) => row.avatarUrl).filter(Boolean))
function avatarPreviewIndex(row) {
return Math.max(0, avatarPreviewList.value.indexOf(row.avatarUrl))
}
function canBeBanned(row) { function canBeBanned(row) {
return row.status !== 'banned' && row.status !== 'deactivated' return row.status !== 'banned' && row.status !== 'deactivated'
} }
@@ -145,7 +152,15 @@ function canBeBanned(row) {
<template #default="{ row }"> <template #default="{ row }">
<div class="app-user-cell"> <div class="app-user-cell">
<span class="app-user-avatar"> <span class="app-user-avatar">
<img v-if="row.avatarUrl" :src="row.avatarUrl" alt="" /> <AppImage
v-if="row.avatarUrl"
:src="row.avatarUrl"
:preview-list="avatarPreviewList"
:initial-index="avatarPreviewIndex(row)"
fit="cover"
class="app-user-avatar-image"
:alt="row.nickname || row.uid"
/>
<template v-else>{{ avatarText(row) }}</template> <template v-else>{{ avatarText(row) }}</template>
</span> </span>
<div class="app-user-meta"> <div class="app-user-meta">
@@ -217,7 +232,15 @@ function canBeBanned(row) {
<template v-if="detail"> <template v-if="detail">
<div class="app-user-summary"> <div class="app-user-summary">
<span class="app-user-avatar"> <span class="app-user-avatar">
<img v-if="detail.avatarUrl" :src="detail.avatarUrl" alt="" /> <AppImage
v-if="detail.avatarUrl"
:src="detail.avatarUrl"
:preview-list="avatarPreviewList"
:initial-index="avatarPreviewIndex(detail)"
fit="cover"
class="app-user-avatar-image"
:alt="detail.nickname || detail.uid"
/>
<template v-else>{{ avatarText(detail) }}</template> <template v-else>{{ avatarText(detail) }}</template>
</span> </span>
<div class="app-user-meta"> <div class="app-user-meta">
@@ -0,0 +1,239 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { onMounted, reactive, ref } from 'vue'
import PageHeading from '@/views/components/PageHeading.vue'
import { pageWatchHistoryApi } from '@/api/admin/appUser'
import { formatDuration } from './composables/useAppUsers'
const SOURCE_OPTIONS = [
{ value: '', label: '全部' },
{ value: 'HOME', label: '首页' },
{ value: 'DETAIL', label: '详情页' },
{ value: 'SEARCH', label: '搜索' },
{ value: 'FAVORITE', label: '收藏' }
]
const FINISHED_OPTIONS = [
{ value: '', label: '全部' },
{ value: true, label: '已看完' },
{ value: false, label: '未看完' }
]
const filters = reactive({
keyword: '',
dramaTitle: '',
source: '',
finished: '',
range: null
})
const rows = ref([])
const total = ref(0)
const loading = ref(false)
const pageIndex = ref(1)
const pageSize = ref(10)
async function load() {
loading.value = true
try {
const res = await pageWatchHistoryApi({
pageIndex: pageIndex.value,
pageSize: pageSize.value,
keyword: filters.keyword.trim() || undefined,
dramaTitle: filters.dramaTitle.trim() || undefined,
source: filters.source || undefined,
finished: filters.finished === '' ? undefined : filters.finished,
startTime: filters.range?.[0] || undefined,
endTime: filters.range?.[1] || undefined
})
rows.value = res.data?.list || []
total.value = res.data?.total || 0
} finally {
loading.value = false
}
}
function search() {
pageIndex.value = 1
load()
}
function resetFilters() {
filters.keyword = ''
filters.dramaTitle = ''
filters.source = ''
filters.finished = ''
filters.range = null
search()
}
function handleSizeChange() {
pageIndex.value = 1
load()
}
function sourceText(source) {
return SOURCE_OPTIONS.find((item) => item.value === source)?.label || source || '—'
}
function episodeText(row) {
if (row.episodeNo) return `第 ${row.episodeNo} 集`
return row.episodeId ? `分集 #${row.episodeId}` : '未关联分集'
}
function progressText(row) {
if (!row.durationMs) return '—'
return `${formatDuration(row.positionMs)} / ${formatDuration(row.durationMs)}`
}
function contentTooltip(row) {
return `短剧 ID:${row.dramaId} / 分集 ID:${row.episodeId || '未关联'}`
}
onMounted(load)
</script>
<template>
<PageHeading
eyebrow="用户管理"
title="观看记录"
description="跨用户检索观看流水,核对短剧、分集、进度与来源。"
/>
<section class="panel">
<div class="console-toolbar">
<el-input
v-model="filters.keyword"
class="console-keyword"
clearable
placeholder="uid / 昵称"
@keyup.enter="search"
@clear="search"
/>
<el-input
v-model="filters.dramaTitle"
class="console-keyword"
clearable
placeholder="短剧名称"
@keyup.enter="search"
@clear="search"
/>
<el-select
v-model="filters.source"
class="console-select"
placeholder="全部来源"
@change="search"
>
<el-option
v-for="item in SOURCE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="filters.finished"
class="console-select"
placeholder="全部完成状态"
@change="search"
>
<el-option
v-for="item in FINISHED_OPTIONS"
:key="String(item.value)"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-date-picker
v-model="filters.range"
class="watch-history-range"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
@change="search"
/>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</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="watchedAt" label="观看时间" min-width="170" show-overflow-tooltip />
<el-table-column label="用户" min-width="180">
<template #default="{ row }">
<div class="app-user-meta">
<span class="app-user-name">{{ row.nickname || '未设置昵称' }}</span>
<span class="app-user-uid">{{ row.uid || `用户 #${row.userId || '—'}` }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="短剧" min-width="190">
<template #default="{ row }">
<el-tooltip :content="contentTooltip(row)" placement="top">
<div class="episode-table-title">
<b>{{ row.dramaTitle || `短剧 #${row.dramaId}` }}</b>
<el-tag v-if="row.dramaPublished === false" type="info" size="small" effect="plain"
>已下线</el-tag
>
</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="分集" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<div class="episode-table-title">
<b>{{ episodeText(row) }}</b>
<small v-if="row.episodeTitle">{{ row.episodeTitle }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="进度" min-width="120">
<template #default="{ row }">
<span class="app-user-mono">{{ progressText(row) }}</span>
</template>
</el-table-column>
<el-table-column label="完成" width="92">
<template #default="{ row }">
<el-tag :type="row.finished ? 'success' : 'info'" effect="light">
{{ row.finished ? '已看完' : '未看完' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="来源" width="96">
<template #default="{ row }">{{ sourceText(row.source) }}</template>
</el-table-column>
<el-table-column prop="deviceId" label="设备" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.deviceId || '—' }}</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]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="load"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>
<style scoped>
.watch-history-range {
width: 360px;
max-width: 100%;
}
</style>
+1 -1
View File
@@ -31,6 +31,6 @@
"@types/qrcode" "@types/qrcode"
] ]
}, },
"include": ["src", "types/**/*.d.ts", "mock/**/*.ts"] "include": ["src", "types/**/*.d.ts"]
// "exclude": ["dist", "node_modules"] // "exclude": ["dist", "node_modules"]
} }
+7 -7
View File
@@ -3,6 +3,7 @@
import { defineConfig, toEscapedSelector as e, presetUno, presetIcons } from 'unocss' import { defineConfig, toEscapedSelector as e, presetUno, presetIcons } from 'unocss'
import transformerVariantGroup from '@unocss/transformer-variant-group' import transformerVariantGroup from '@unocss/transformer-variant-group'
import { loadEnv } from 'vite' import { loadEnv } from 'vite'
import { AppIcons } from './src/constants/icons'
const root = process.cwd() const root = process.cwd()
@@ -20,13 +21,11 @@ const createPresetIcons = () => {
} else { } else {
return [ return [
presetIcons({ presetIcons({
prefix: '' prefix: '',
// 由于默认加载的是所有的图标,启动会非常慢,可以在这里去加载需要的图标,确保启动速度 collections: {
// collections: { 'material-symbols': () =>
// carbon: () => import('@iconify-json/carbon/icons.json').then(i => i.default), import('@iconify/json/json/material-symbols.json').then((i) => i.default)
// mdi: () => import('@iconify-json/mdi/icons.json').then(i => i.default), }
// logos: () => import('@iconify-json/logos/icons.json').then(i => i.default),
// }
}) })
] ]
} }
@@ -142,6 +141,7 @@ ${selector}:after {
} }
] ]
], ],
safelist: Object.values(AppIcons),
presets: [presetUno({ dark: 'class', attributify: false }), ...createPresetIcons()], presets: [presetUno({ dark: 'class', attributify: false }), ...createPresetIcons()],
transformers: [transformerVariantGroup()], transformers: [transformerVariantGroup()],
content: { content: {
+4 -15
View File
@@ -5,7 +5,6 @@ import Vue from '@vitejs/plugin-vue'
import VueJsx from '@vitejs/plugin-vue-jsx' import VueJsx from '@vitejs/plugin-vue-jsx'
import progress from 'vite-plugin-progress' import progress from 'vite-plugin-progress'
import { ViteEjsPlugin } from "vite-plugin-ejs" import { ViteEjsPlugin } from "vite-plugin-ejs"
import { viteMockServe } from 'vite-plugin-mock'
import PurgeIcons from 'vite-plugin-purge-icons' import PurgeIcons from 'vite-plugin-purge-icons'
import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite" import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite"
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons' import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
@@ -67,19 +66,6 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
svgoOptions: true svgoOptions: true
}), }),
PurgeIcons(), PurgeIcons(),
env.VITE_USE_MOCK === 'true'
? viteMockServe({
ignore: /^\_/,
mockPath: 'mock',
localEnabled: !isBuild,
prodEnabled: isBuild,
injectCode: `
import { setupProdMockServer } from '../mock/_createProductionServer'
setupProdMockServer()
`
})
: undefined,
ViteEjsPlugin({ ViteEjsPlugin({
title: env.VITE_APP_TITLE title: env.VITE_APP_TITLE
}), }),
@@ -153,7 +139,10 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
hmr: { hmr: {
overlay: false overlay: false
}, },
host: '0.0.0.0' // 必须同时监听 IPv6:Windows 上 localhost 先解析成 ::1,
// 只绑 0.0.0.0 会让每次新建连接都等 IPv6 超时(约 2s)再回退到 IPv4,
// 表现为页面白屏很久。'::' 是双栈,IPv4 局域网访问同样可用。
host: '::'
}, },
optimizeDeps: { optimizeDeps: {
include: [ include: [