Author SHA1 Message Date
Zhou Weidong 88db96c374 feat(commerce): 新增打赏任务管理页
web-ci / build (push) Failing after 8s
2026-11-09 10:27:50 +08:00
Zhou Weidong 96b213354b fix(layout): 扩大顶栏图标与用户菜单点击热区 2026-11-09 10:27:49 +08:00
Zhou Weidong 0d862dfe1d fix(router): 按账号菜单落地避免创作者进入 404 2026-11-09 10:27:48 +08:00
Zhou Weidong 8535305185 style(creator-center): 数据看板改用管理端卡片布局 2026-11-09 10:27:47 +08:00
Zhou Weidong 837266ce7e feat(creator): 内容审核展示创作者与分集信息 2026-11-09 10:27:42 +08:00
Zhou Weidong 71ee469563 feat(creator-center): 分集支持视频预览与表格横向滚动 2026-11-09 10:27:41 +08:00
Zhou Weidong 3c730c7d33 feat(creator-center): 新增两步发布作品并支持删除作品 2026-11-09 10:27:40 +08:00
Vicky 4055b7a2a9 fix(layout): 修复顶栏通知角标脱离铃铛右上角
web-ci / build (push) Failing after 10s
角标改为锚定图标右上角(right 9px + 右移 50%),
位数变化时位置不漂移。
2026-11-05 10:56:09 +08:00
Vicky bbff52f324 feat(drama): 短剧审核合并为审核通过并上线一键操作
审核列草稿/已下线/审核中统一为「审核通过并上线」,
审核中保留驳回,待发布与已上线维持原按钮。
2026-11-05 10:56:09 +08:00
Zhou Weidong 9ce014e7b3 feat(classification): 分类支持榜单布局与顺序维护
web-ci / build (push) Failing after 12s
2026-11-02 14:47:59 +08:00
Vicky 23026dc371 style(setting): 按 eslint 规则规范系统设置模板格式
web-ci / build (push) Canceled after 0s
2026-10-30 17:17:39 +08:00
Vicky 2f52011491 perf(build): 图标改为离线子集并精简图表依赖
web-ci / build (push) Canceled after 0s
2026-10-30 17:14:51 +08:00
Vicky dd4be08d49 feat(setting): 系统设置支持上传 App 登录页背景 2026-10-30 17:14:50 +08:00
Vicky a94018d5d3 feat(prototype): 新增印尼市场短剧 App 静态原型
web-ci / build (push) Canceled after 0s
- public/indonesia/ 下独立静态原型,不影响后台构建
- 覆盖首页、发现、详情、播放、收藏、个人中心、金币解锁等核心流程
2026-10-29 17:33:07 +08:00
Vicky 923b7dce1e feat(upload): 封面上传支持横版与前端压缩
- 新增 imageCompress 与 imagePresets,上传前按规格高质量等比压缩
- 短剧封面拆分为竖版/横版两个上传位,带建议尺寸提示
- 轮播图、创作者中心封面与分集封面接入压缩与提示
2026-10-29 17:32:56 +08:00
Vicky dea414739e feat(tag): 标签管理支持配置语义码
web-ci / build (push) Canceled after 0s
客户端按语义码判断板块归属(如推荐页「上新」横滑),标签改名或换语言
都不再影响;新增/编辑弹窗增加语义码输入并做格式校验,留空则按名称兜底。
2026-10-29 15:44:42 +08:00
Zhou Weidong d13bdd5dc8 ci: 新增 Gitea Actions 流水线(测试/分析/构建门禁)
web-ci / build (push) Failing after 36s
2026-10-29 10:47:47 +08:00
Zhou Weidong 040ec3f382 fix(login): 退出登录不清除记住我的账号密码 2026-10-29 10:08:21 +08:00
Zhou Weidong 9a58b98f12 fix(login): 记住我同时记住密码并回填 2026-10-29 09:58:57 +08:00
Zhou Weidong e2618735ba chore(merge): 合并 main 的样式排序到 develop 2026-10-29 09:21:32 +08:00
Zhou Weidong b68361b037 chore(merge): 合并 phase1 UI prep 后台用户系统筛选与列表展示
合并后新增收藏/点赞列表页、用户单元格组件与系统筛选,
并统一观看时间格式与标签文案。
2026-10-27 20:20:31 +08:00
Vicky bedae02946 feat: phase1 UI prep — 后台 App 用户列表支持系统筛选 + 国家中文名 + 标签语义优化
新增: 系统下拉 (ANDROID/IOS)、国家中文名映射、用户列昵称/UID 冒号标签
详情抽屉: 同步系统/国家/标签展示
2026-10-27 15:45:23 +08:00
Vicky daf681ae1a fix(admin): 观看时间统一格式化为 yyyy-MM-dd HH:mm:ss
后端改输出格式后仍保留前端兜底,同时修掉用户详情里的原始 ISO 展示。
2026-10-27 14:39:18 +08:00
Vicky 111bbdaeb7 fix(admin): 用户列标签改用半角冒号 2026-10-27 14:29:56 +08:00
Vicky 5da72d1aa2 fix(admin): 用户列补全「昵称 / UID / 设备」标签与冒号
两行都以「字段名:值」呈现,避免只看到一串随机字母。
2026-10-27 14:28:59 +08:00
Vicky e1eb58d4c4 feat(admin): 列表支持按系统筛选,用户列补语义标签
- 收藏/点赞/观看增加「全部系统 / Android / iOS」下拉
- 用户列第二行标注 UID,游客行标注设备,避免一串字母看不出含义
2026-10-27 14:27:16 +08:00
Vicky 5dbc752798 feat(admin): 观看/收藏/点赞列表单独增加系统列
用户单元格只保留身份信息,Android/iOS 抽成独立「系统」列,三页列结构一致。
2026-10-27 14:17:37 +08:00
Vicky c89c5668e1 refactor(admin): 用户单元格只保留平台标识
机型与系统版本号属于多余信息,Android/iOS 加游客标签已足够区分。
2026-10-27 14:00:07 +08:00
Vicky 9ea22d5951 chore(merge): 合并后台用户单元格统一 2026-10-27 13:53:54 +08:00
Vicky f3fbc57c14 feat(admin): 统一用户单元格,展示游客设备号与客户端系统
- 新增 AppUserCell,游客显示标签+deviceId,第二行统一展示系统与机型
- 收藏、点赞、观看记录三页复用,观看记录去掉重复的系统列
2026-10-27 13:53:53 +08:00
Zhou Weidong c45820258e chore(merge): 合并游客标签样式修正 2026-10-27 13:11:10 +08:00
Zhou Weidong 12d63e9670 fix(admin): 修正游客标签拉伸 2026-10-27 13:10:19 +08:00
Zhou Weidong 7aa1571c15 chore(merge): 合并后台互动列表展示调整 2026-10-27 13:05:14 +08:00
Zhou Weidong 8cd4569a20 fix(admin): 收紧收藏与点赞列表展示 2026-10-27 13:03:53 +08:00
Zhou Weidong 83bb87eeb3 chore(merge): 合并后台 props lint 修复 2026-10-27 12:47:46 +08:00
Zhou Weidong 3409a2b427 fix(admin): 修复组件 props 变异的 lint 基线 2026-10-27 12:47:45 +08:00
Zhou Weidong a21fe6f189 chore(merge): 合并后台收藏与点赞列表页 2026-10-27 12:38:40 +08:00
Zhou Weidong 5580f9b99b feat(admin): 新增收藏与点赞列表页
全量 lint 仍有 5 处既有 vue/no-mutating-props 基线错误,
位于 LocaleTextFields.vue 与 ClassificationDramaDialogs.vue,本次未处理。
2026-10-27 12:38:31 +08:00
Zhou Weidong d846d2d864 Merge branch fix/icons-vue-deps 2026-10-26 13:06:45 +08:00
Zhou Weidong e6d1d6997b fix(deps): 补充 icons-vue 依赖声明 2026-10-26 13:06:38 +08:00
Zhou Weidong d661b808d1 chore(merge): 合并后台多语言工作台和登录页 2026-10-24 17:32:51 +08:00
Zhou Weidong d42a09fa36 feat(admin): 后台页面显示版权标识 2026-10-24 17:32:32 +08:00
Zhou Weidong 33bf5bd3bb feat(admin): 登录页补充水果装饰 2026-10-24 17:32:12 +08:00
Zhou Weidong a266d44483 feat(admin): 增加工作台大屏和转码通知 2026-10-24 17:31:16 +08:00
Zhou Weidong 2b2c6bd708 feat(admin): 内容管理支持多语言和标签 2026-10-24 17:30:52 +08:00
Zhou Weidong eaee765239 fix(app-version): 修复版本页类型检查 2026-10-24 17:30:29 +08:00
Zhou Weidong 18234ebd5b Merge branch fix/app-version-batch 2026-10-23 16:46:29 +08:00
Zhou Weidong 93d6d8c59f fix(app-version): 修复弹窗禁用并改批量提交 2026-10-23 16:46:22 +08:00
Zhou Weidong 9fc57e5c1a chore(merge): 合并 App 版本管理页面 2026-10-23 15:27:46 +08:00
Zhou Weidong 0a28eb0237 feat(admin): 新增 App 版本管理页面 2026-10-23 15:27:45 +08:00
Zhou Weidong 73a9b078ee chore(merge): 合并后台默认主题色调整 2026-10-23 15:13:00 +08:00
Zhou Weidong bb48f14ba7 feat(admin): 头部与菜单主题默认改为白色 2026-10-23 15:12:59 +08:00
Zhou Weidong ada74efc3a chore(merge): 合并运营大盘 ECharts 图表 2026-10-23 14:58:56 +08:00
Zhou Weidong 10372524ba feat(admin): 运营大盘接入 ECharts 图表 2026-10-23 14:58:55 +08:00
Zhou Weidong fd9a9c50c4 chore(merge): 合并立即补齐转码按钮 2026-10-23 14:14:48 +08:00
Zhou Weidong e51d371e07 feat(admin): 转码设置页新增立即补齐转码按钮 2026-10-23 14:14:46 +08:00
Zhou Weidong ada3b56d14 chore(merge): 将 release/v1.0.0 同步到 main 2026-10-23 13:08:30 +08:00
Zhou Weidong 7e8ae86d9f chore(merge): 对齐远程 main 2026-10-23 13:08:28 +08:00
94 changed files with 8259 additions and 1147 deletions

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# Author: fruit.com.cn
# ReelShortWeb 后台 CI:lint + 构建门禁
name: web-ci
on:
push:
branches: [develop, main]
pull_request:
branches: [develop, main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: pnpm
- name: 依赖
run: pnpm install --frozen-lockfile
- name: 类型检查
run: pnpm ts:check
- name: 构建
run: pnpm build:pro
@@ -0,0 +1,29 @@
# 印尼市场短剧 App UI / UX 原型
## 范围
- 新增独立静态原型 `public/indonesia/`,不修改管理后台、Flutter 或接口契约。
- 沿用现有 FruitShorts / DramaPulse 设计方向:深色、橙红主色、印尼语、三个主导航。
- 覆盖首页、发现/搜索、详情/选集、播放交互、收藏/历史、个人中心、金币解锁。
- 原型数据及钱包均仅保存在浏览器;不调用真实支付,不伪造支付成功。
## 步骤
1. 阅读项目规范、PRD、契约与设计基线,保留用户现有修改。
2. 独立实现响应式界面、可访问交互与本地状态。
3. 检查桌面/移动布局、搜索空态、收藏持久化与解锁幂等。
4. 验证原后台构建,记录结果并提供可直接打开的入口。
## 风险 / 待确认
- 当前目录为管理后台,故交付独立设计原型,不将 C 端覆盖到后台。
- 原型不代表已实现 Flutter App;虚构剧名、热度、价格只用于界面设计。
- 生图工具没有返回可用图片,采用示意照片;正式剧集图需替换为已授权内容。
- 播放器为分镜交互演示,不将静态图片伪装成已接入的剧集视频。
- 金币不足仅展示充值方案与商店结算说明,不接外部钱包或真实扣款。
- 原有 `TagManagementView.vue` 修改不属于本任务,保持原样。
- INBOX 内旧任务不属于本次用户明确要求,不执行、不标记完成。
## 验收
- 待执行。
+1
View File
@@ -24,6 +24,7 @@
"prepare": "husky install"
},
"dependencies": {
"@element-plus/icons-vue": "2.3.2",
"@iconify/iconify": "^3.1.1",
"@iconify/vue": "^4.1.1",
"@vueuse/core": "^10.7.2",
+93
View File
@@ -0,0 +1,93 @@
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

+12
View File
@@ -0,0 +1,12 @@
/**
* @license lucide v0.468.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/
(function(a,n){typeof exports=="object"&&typeof module<"u"?n(exports):typeof define=="function"&&define.amd?define(["exports"],n):(a=typeof globalThis<"u"?globalThis:a||self,n(a.lucide={}))})(this,function(a){"use strict";const n=(t,d,c=[])=>{const p=document.createElementNS("http://www.w3.org/2000/svg",t);return Object.keys(d).forEach(M=>{p.setAttribute(M,String(d[M]))}),c.length&&c.forEach(M=>{const v=n(...M);p.appendChild(v)}),p};var I0=([t,d,c])=>n(t,d,c);const Q$=t=>Array.from(t.attributes).reduce((d,c)=>(d[c.name]=c.value,d),{}),j$=t=>typeof t=="string"?t:!t||!t.class?"":t.class&&typeof t.class=="string"?t.class.split(" "):t.class&&Array.isArray(t.class)?t.class:"",Y$=t=>t.flatMap(j$).map(d=>d.trim()).filter(Boolean).filter((d,c,p)=>p.indexOf(d)===c).join(" "),_$=t=>t.replace(/(\w)(\w*)(_|-|\s*)/g,(d,c,p)=>c.toUpperCase()+p.toLowerCase()),E0=(t,{nameAttr:d,icons:c,attrs:p})=>{const M=t.getAttribute(d);if(M==null)return;const v=_$(M),X$=c[v];if(!X$)return console.warn(`${t.outerHTML} icon name was not found in the provided icons object.`);const N$=Q$(t),[hm,tm,dm]=X$,K$={...tm,"data-lucide":M,...p,...N$},J$=Y$(["lucide",`lucide-${M}`,N$,p]);J$&&Object.assign(K$,{class:J$});const cm=I0([hm,K$,dm]);return t.parentNode?.replaceChild(cm,t)},h={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"},x0=["svg",h,[["path",{d:"M3.5 13h6"}],["path",{d:"m2 16 4.5-9 4.5 9"}],["path",{d:"M18 7v9"}],["path",{d:"m14 12 4 4 4-4"}]]],W0=["svg",h,[["path",{d:"M3.5 13h6"}],["path",{d:"m2 16 4.5-9 4.5 9"}],["path",{d:"M18 16V7"}],["path",{d:"m14 11 4-4 4 4"}]]],X0=["svg",h,[["path",{d:"M21 14h-5"}],["path",{d:"M16 16v-3.5a2.5 2.5 0 0 1 5 0V16"}],["path",{d:"M4.5 13h6"}],["path",{d:"m3 16 4.5-9 4.5 9"}]]],N0=["svg",h,[["circle",{cx:"16",cy:"4",r:"1"}],["path",{d:"m18 19 1-7-6 1"}],["path",{d:"m5 8 3-3 5.5 3-2.36 3.5"}],["path",{d:"M4.24 14.5a5 5 0 0 0 6.88 6"}],["path",{d:"M13.76 17.5a5 5 0 0 0-6.88-6"}]]],K0=["svg",h,[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"}]]],J0=["svg",h,[["path",{d:"M6 12H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"}],["path",{d:"M6 8h12"}],["path",{d:"M18.3 17.7a2.5 2.5 0 0 1-3.16 3.83 2.53 2.53 0 0 1-1.14-2V12"}],["path",{d:"M6.6 15.6A2 2 0 1 0 10 17v-5"}]]],Q0=["svg",h,[["path",{d:"M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1"}],["path",{d:"m12 15 5 6H7Z"}]]],o=["svg",h,[["circle",{cx:"12",cy:"13",r:"8"}],["path",{d:"M5 3 2 6"}],["path",{d:"m22 6-3-3"}],["path",{d:"M6.38 18.7 4 21"}],["path",{d:"M17.64 18.67 20 21"}],["path",{d:"m9 13 2 2 4-4"}]]],s=["svg",h,[["circle",{cx:"12",cy:"13",r:"8"}],["path",{d:"M5 3 2 6"}],["path",{d:"m22 6-3-3"}],["path",{d:"M6.38 18.7 4 21"}],["path",{d:"M17.64 18.67 20 21"}],["path",{d:"M9 13h6"}]]],j0=["svg",h,[["path",{d:"M6.87 6.87a8 8 0 1 0 11.26 11.26"}],["path",{d:"M19.9 14.25a8 8 0 0 0-9.15-9.15"}],["path",{d:"m22 6-3-3"}],["path",{d:"M6.26 18.67 4 21"}],["path",{d:"m2 2 20 20"}],["path",{d:"M4 4 2 6"}]]],r=["svg",h,[["circle",{cx:"12",cy:"13",r:"8"}],["path",{d:"M5 3 2 6"}],["path",{d:"m22 6-3-3"}],["path",{d:"M6.38 18.7 4 21"}],["path",{d:"M17.64 18.67 20 21"}],["path",{d:"M12 10v6"}],["path",{d:"M9 13h6"}]]],Y0=["svg",h,[["circle",{cx:"12",cy:"13",r:"8"}],["path",{d:"M12 9v4l2 2"}],["path",{d:"M5 3 2 6"}],["path",{d:"m22 6-3-3"}],["path",{d:"M6.38 18.7 4 21"}],["path",{d:"M17.64 18.67 20 21"}]]],_0=["svg",h,[["path",{d:"M11 21c0-2.5 2-2.5 2-5"}],["path",{d:"M16 21c0-2.5 2-2.5 2-5"}],["path",{d:"m19 8-.8 3a1.25 1.25 0 0 1-1.2 1H7a1.25 1.25 0 0 1-1.2-1L5 8"}],["path",{d:"M21 3a1 1 0 0 1 1 1v2a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a1 1 0 0 1 1-1z"}],["path",{d:"M6 21c0-2.5 2-2.5 2-5"}]]],aa=["svg",h,[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2"}],["polyline",{points:"11 3 11 11 14 8 17 11 17 3"}]]],ha=["svg",h,[["path",{d:"M2 12h20"}],["path",{d:"M10 16v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-4"}],["path",{d:"M10 8V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v4"}],["path",{d:"M20 16v1a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-1"}],["path",{d:"M14 8V7c0-1.1.9-2 2-2h2a2 2 0 0 1 2 2v1"}]]],ta=["svg",h,[["path",{d:"M12 2v20"}],["path",{d:"M8 10H4a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h4"}],["path",{d:"M16 10h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-4"}],["path",{d:"M8 20H7a2 2 0 0 1-2-2v-2c0-1.1.9-2 2-2h1"}],["path",{d:"M16 14h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-1"}]]],da=["svg",h,[["path",{d:"M17 12H7"}],["path",{d:"M19 18H5"}],["path",{d:"M21 6H3"}]]],ca=["svg",h,[["rect",{width:"6",height:"16",x:"4",y:"2",rx:"2"}],["rect",{width:"6",height:"9",x:"14",y:"9",rx:"2"}],["path",{d:"M22 22H2"}]]],Ma=["svg",h,[["rect",{width:"16",height:"6",x:"2",y:"4",rx:"2"}],["rect",{width:"9",height:"6",x:"9",y:"14",rx:"2"}],["path",{d:"M22 22V2"}]]],pa=["svg",h,[["rect",{width:"6",height:"14",x:"4",y:"5",rx:"2"}],["rect",{width:"6",height:"10",x:"14",y:"7",Line truncated
If you want to use all the icons you can import it like:
\`import { createIcons, icons } from 'lucide';
lucide.createIcons({icons});\``);if(typeof document>"u")throw new Error("`createIcons()` only works in a browser environment.");const p=document.querySelectorAll(`[${d}]`);if(Array.from(p).forEach(M=>E0(M,{nameAttr:d,icons:t,attrs:c})),d==="data-lucide"){const M=document.querySelectorAll("[icon-name]");M.length>0&&(console.warn("[Lucide] Some icons were found with the now deprecated icon-name attribute. These will still be replaced for backwards compatibility, but will no longer be supported in v1.0 and you should switch to data-lucide"),Array.from(M).forEach(v=>E0(v,{nameAttr:"icon-name",icons:t,attrs:c})))}};a.AArrowDown=x0,a.AArrowUp=W0,a.ALargeSmall=X0,a.Accessibility=N0,a.Activity=K0,a.ActivitySquare=L2,a.AirVent=J0,a.Airplay=Q0,a.AlarmCheck=o,a.AlarmClock=Y0,a.AlarmClockCheck=o,a.AlarmClockMinus=s,a.AlarmClockOff=j0,a.AlarmClockPlus=r,a.AlarmMinus=s,a.AlarmPlus=r,a.AlarmSmoke=_0,a.Album=aa,a.AlertCircle=G,a.AlertOctagon=t2,a.AlertTriangle=P0,a.AlignCenter=da,a.AlignCenterHorizontal=ha,a.AlignCenterVertical=ta,a.AlignEndHorizontal=ca,a.AlignEndVertical=Ma,a.AlignHorizontalDistributeCenter=pa,a.AlignHorizontalDistributeEnd=ea,a.AlignHorizontalDistributeStart=na,a.AlignHorizontalJustifyCenter=ia,a.AlignHorizontalJustifyEnd=la,a.AlignHorizontalJustifyStart=va,a.AlignHorizontalSpaceAround=oa,a.AlignHorizontalSpaceBetween=sa,a.AlignJustify=ra,a.AlignLeft=ga,a.AlignRight=ya,a.AlignStartHorizontal=$a,a.AlignStartVertical=ma,a.AlignVerticalDistributeCenter=Ca,a.AlignVerticalDistributeEnd=ua,a.AlignVerticalDistributeStart=Ha,a.AlignVerticalJustifyCenter=wa,a.AlignVerticalJustifyEnd=Va,a.AlignVerticalJustifyStart=Aa,a.AlignVerticalSpaceAround=Sa,a.AlignVerticalSpaceBetween=La,a.Ambulance=fa,a.Ampersand=Pa,a.Ampersands=ka,a.Amphora=Ba,a.Anchor=Fa,a.Angry=Da,a.Annoyed=Ra,a.Antenna=za,a.Anvil=qa,a.Aperture=Ta,a.AppWindow=ba,a.AppWindowMac=Za,a.Apple=Ua,a.Archive=Ia,a.ArchiveRestore=Oa,a.ArchiveX=Ga,a.AreaChart=P,a.Armchair=Ea,a.ArrowBigDown=Wa,a.ArrowBigDownDash=xa,a.ArrowBigLeft=Na,a.ArrowBigLeftDash=Xa,a.ArrowBigRight=Ja,a.ArrowBigRightDash=Ka,a.ArrowBigUp=ja,a.ArrowBigUpDash=Qa,a.ArrowDown=eh,a.ArrowDown01=Ya,a.ArrowDown10=_a,a.ArrowDownAZ=g,a.ArrowDownAz=g,a.ArrowDownCircle=I,a.ArrowDownFromLine=ah,a.ArrowDownLeft=hh,a.ArrowDownLeftFromCircle=x,a.ArrowDownLeftFromSquare=F2,a.ArrowDownLeftSquare=f2,a.ArrowDownNarrowWide=th,a.ArrowDownRight=dh,a.ArrowDownRightFromCircle=W,a.ArrowDownRightFromSquare=D2,a.ArrowDownRightSquare=P2,a.ArrowDownSquare=k2,a.ArrowDownToDot=ch,a.ArrowDownToLine=Mh,a.ArrowDownUp=ph,a.ArrowDownWideNarrow=y,a.ArrowDownZA=$,a.ArrowDownZa=$,a.ArrowLeft=vh,a.ArrowLeftCircle=E,a.ArrowLeftFromLine=nh,a.ArrowLeftRight=ih,a.ArrowLeftSquare=B2,a.ArrowLeftToLine=lh,a.ArrowRight=gh,a.ArrowRightCircle=K,a.ArrowRightFromLine=oh,a.ArrowRightLeft=sh,a.ArrowRightSquare=q2,a.ArrowRightToLine=rh,a.ArrowUp=Sh,a.ArrowUp01=yh,a.ArrowUp10=$h,a.ArrowUpAZ=m,a.ArrowUpAz=m,a.ArrowUpCircle=J,a.ArrowUpDown=mh,a.ArrowUpFromDot=Ch,a.ArrowUpFromLine=uh,a.ArrowUpLeft=Hh,a.ArrowUpLeftFromCircle=X,a.ArrowUpLeftFromSquare=R2,a.ArrowUpLeftSquare=T2,a.ArrowUpNarrowWide=C,a.ArrowUpRight=wh,a.ArrowUpRightFromCircle=N,a.ArrowUpRightFromSquare=z2,a.ArrowUpRightSquare=Z2,a.ArrowUpSquare=b2,a.ArrowUpToLine=Vh,a.ArrowUpWideNarrow=Ah,a.ArrowUpZA=u,a.ArrowUpZa=u,a.ArrowsUpFromLine=Lh,a.Asterisk=fh,a.AsteriskSquare=U2,a.AtSign=Ph,a.Atom=kh,a.AudioLines=Bh,a.AudioWaveform=Fh,a.Award=Dh,a.Axe=Rh,a.Axis3D=H,a.Axis3d=H,a.Baby=zh,a.Backpack=qh,a.Badge=jh,a.BadgeAlert=Th,a.BadgeCent=Zh,a.BadgeCheck=w,a.BadgeDollarSign=bh,a.BadgeEuro=Uh,a.BadgeHelp=Oh,a.BadgeIndianRupee=Gh,a.BadgeInfo=Ih,a.BadgeJapaneseYen=Eh,a.BadgeMinus=xh,a.BadgePercent=Wh,a.BadgePlus=Xh,a.BadgePoundSterling=Nh,a.BadgeRussianRuble=Kh,a.BadgeSwissFranc=Jh,a.BadgeX=Qh,a.BaggageClaim=Yh,a.Ban=_h,a.Banana=at,a.Bandage=ht,a.Banknote=tt,a.BarChart=T,a.BarChart2=Z,a.BarChart3=z,a.BarChart4=R,a.BarChartBig=D,a.BarChartHorizontal=B,a.BarChartHorizontalBig=k,a.Barcode=dt,a.Baseline=ct,a.Bath=Mt,a.Battery=vt,a.BatteryCharging=pt,a.BatteryFull=et,a.BatteryLow=nt,a.BatteryMedium=it,a.BatteryWarning=lt,a.Beaker=ot,a.Bean=rt,a.BeanOff=st,a.Bed=$t,a.BedDouble=gt,a.BedSingle=yt,a.Beef=mt,a.Beer=ut,a.BeerOff=Ct,a.Bell=ft,a.BellDot=Ht,a.BellElectric=wt,a.BellMinus=Vt,a.BellOff=At,a.BellPlus=St,a.BellRing=Lt,a.BetweenHorizonalEnd=V,a.BetweenHorizonalStart=A,a.BetweenHorizontalEnd=V,a.BetweenHorizontalStart=A,a.BetweenVerticalEnd=Pt,a.BetweenVerticalStart=kt,a.BicepsFlexed=Bt,a.Bike=Ft,a.Binary=Dt,a.Binoculars=Rt,a.Biohazard=zt,a.Bird=qt,a.Bitcoin=Tt,a.Blend=Zt,a.Blinds=bt,a.Blocks=Ut,a.Bluetooth=Et,a.BluetoothConnected=Ot,a.BluetoothOff=Gt,a.BluetoothSearching=It,a.Bold=xt,a.Bolt=Wt,a.Bomb=Xt,a.Bone=Nt,a.Book=y4,a.BookA=Kt,a.BookAudio=Jt,a.BookCheck=Qt,a.BookCopy=jt,a.BookDashed=S,a.BookDown=Yt,a.BookHeadphones=_t,a.BookHeart=a4,a.BookImage=h4,a.BookKey=t4,a.BookLock=d4,a.BookMarked=c4,a.BookMinus=M4,a.BookOpen=n4,a.BookOpenCheck=p4,a.BookOpenText=e4,a.BookPlus=i4,a.BookTemplate=S,a.BookText=lLine truncated
//# sourceMappingURL=lucide.min.js.map
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

+72
View File
@@ -0,0 +1,72 @@
<!-- Author: fruit.com.cn -->
<!-- 印尼市场短剧客户端交互设计入口。 -->
<!doctype html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b0c10" />
<meta name="description" content="FruitShorts — drama pendek, cerita yang dekat." />
<title>FruitShorts · Drama pendek pilihanmu</title>
<link rel="icon" href="../brand-mark.png" />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="screens.css" />
<script defer src="assets/lucide.min.js"></script>
<script defer src="data.js"></script>
<script defer src="state.js"></script>
<script defer src="views.js"></script>
<script defer src="dialogs.js"></script>
<script defer src="app.js"></script>
</head>
<body>
<a class="skip-link" href="#main">Langsung ke konten</a>
<aside class="sidebar" aria-label="Navigasi utama">
<a class="brand" href="#home" aria-label="FruitShorts Beranda">
<span class="brand-mark"><i data-lucide="play" aria-hidden="true"></i></span>
<span>Fruit<span class="brand-accent">Shorts</span></span>
</a>
<span class="nav-caption">MENU UTAMA</span>
<nav class="main-nav">
<a href="#home" data-nav="home"><i data-lucide="house"></i><span>Beranda</span></a>
<a href="#discover" data-nav="discover"><i data-lucide="compass"></i><span>Jelajahi</span></a>
<a href="#me" data-nav="me"><i data-lucide="circle-user-round"></i><span>Saya</span></a>
</nav>
<div class="sidebar-divider"></div>
<span class="nav-caption">KOLEKSI SAYA</span>
<nav class="collection-nav">
<a href="#library"><i data-lucide="bookmark"></i>Daftar saya<span id="saved-count">0</span></a>
<a href="#history"><i data-lucide="history"></i>Riwayat tontonan</a>
</nav>
<div class="sidebar-bottom">
<div class="local-note"><i data-lucide="clapperboard"></i><span>Cerita singkat.<br /><strong>Rasa yang melekat.</strong></span></div>
<button class="help-link" data-action="help"><i data-lucide="circle-help"></i>Pusat bantuan<i data-lucide="arrow-up-right"></i></button>
<span class="copyright">© 2026 FruitShorts</span>
</div>
</aside>
<div class="app-shell">
<header class="topbar">
<a class="brand mobile-brand" href="#home"><span class="brand-mark"><i data-lucide="play"></i></span><span>Fruit<span class="brand-accent">Shorts</span></span></a>
<form class="search-form" role="search">
<i data-lucide="search" aria-hidden="true"></i>
<input id="global-search" type="search" placeholder="Cari drama, temukan cerita..." aria-label="Cari drama" autocomplete="off" />
<kbd aria-hidden="true">/</kbd>
</form>
<div class="topbar-actions">
<button class="language-button" data-action="language" title="Bahasa"><i data-lucide="globe-2"></i><span>ID</span><i data-lucide="chevron-down"></i></button>
<button class="wallet-button" data-action="wallet" title="Dompet koin"><i data-lucide="coins"></i><span data-balance>120</span><span class="wallet-add">+</span></button>
<button class="icon-button notification-button" data-action="notifications" aria-label="Notifikasi" title="Notifikasi"><i data-lucide="bell"></i><span></span></button>
<a class="avatar" href="#me" aria-label="Profil saya">N</a>
</div>
</header>
<main id="main"></main>
<footer class="page-footer"><a class="brand" href="#home">Fruit<span class="brand-accent">Shorts</span></a><span>Drama pendek. Momen besar.</span><button data-action="privacy">Privasi</button><button data-action="terms">Ketentuan</button><span>Indonesia · Bahasa Indonesia</span></footer>
</div>
<nav class="bottom-nav" aria-label="Navigasi seluler">
<a href="#home" data-nav="home"><i data-lucide="house"></i><span>Beranda</span></a>
<a href="#discover" data-nav="discover"><i data-lucide="compass"></i><span>Jelajahi</span></a>
<a href="#me" data-nav="me"><i data-lucide="circle-user-round"></i><span>Saya</span></a>
</nav>
<dialog id="app-dialog" aria-labelledby="dialog-title"></dialog>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
</body>
</html>
+12
View File
@@ -22,6 +22,7 @@ appStore.initTheme()
<template>
<ConfigGlobal :size="currentSize">
<RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" />
<footer class="app-copyright">© FruitShorts</footer>
</ConfigGlobal>
</template>
@@ -48,4 +49,15 @@ body {
.@{prefix-cls}-grey-mode {
filter: grayscale(100%);
}
.app-copyright {
position: fixed;
right: 20px;
bottom: 10px;
z-index: 5;
font-size: 12px;
line-height: 1;
color: var(--el-text-color-secondary, #909399);
pointer-events: none;
}
</style>
+4
View File
@@ -11,6 +11,8 @@ export interface AppUserPageParams {
status?: string
countryCode?: string
registerSource?: string
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
}
export interface WatchHistoryPageParams {
@@ -21,6 +23,8 @@ export interface WatchHistoryPageParams {
dramaTitle?: string
dramaId?: number
episodeId?: number
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
source?: string
finished?: boolean
startTime?: string
+69
View File
@@ -0,0 +1,69 @@
// Author: fruit.com.cn
import request from '@/axios'
export interface AppVersionItem {
id: string
platform: 'ANDROID' | 'IOS'
platformText: string
versionCode: number
versionName: string
downloadUrl: string
updateContent?: string
forceUpdate: number
minSupportedCode: number
status: number
statusText: string
releasedAt?: string
createdAt?: string
}
export interface AppVersionSaveData {
platform: 'ANDROID' | 'IOS'
versionCode: number
versionName: string
downloadUrl: string
updateContent?: string
forceUpdate?: 0 | 1
minSupportedCode?: number
}
export const listAppVersionsApi = (params: {
pageIndex?: number
pageSize?: number
platform?: string
status?: number
keyword?: string
}) => {
return request.get<{ total: number; list: AppVersionItem[] }>({
url: '/admin/app-versions/page',
params
})
}
export const getAppVersionApi = (id: string | number) => {
return request.get<AppVersionItem>({ url: `/admin/app-versions/${id}` })
}
export const createAppVersionApi = (data: AppVersionSaveData) => {
return request.post<string>({ url: '/admin/app-versions', data })
}
export const batchCreateAppVersionsApi = (data: AppVersionSaveData[]) => {
return request.post<string[]>({ url: '/admin/app-versions/batch', data })
}
export const updateAppVersionApi = (id: string | number, data: AppVersionSaveData) => {
return request.post<void>({ url: `/admin/app-versions/${id}`, data })
}
export const updateAppVersionStatusApi = (id: string | number, status: 0 | 1) => {
return request.post<void>({
url: `/admin/app-versions/${id}/status`,
params: { status }
})
}
export const deleteAppVersionApi = (id: string | number) => {
return request.delete<void>({ url: `/admin/app-versions/${id}` })
}
+41
View File
@@ -0,0 +1,41 @@
// Author: fruit.com.cn
import request from '@/axios'
export interface ContentTagItem {
id: number
name: string
nameEn: string
nameId: string
locales?: Record<string, string>
bgColor?: string
sort: number
status: number
}
export interface ContentTagSaveData {
name: string
locales: Record<string, string>
bgColor?: string
sort?: number
status?: number
}
export const listContentTagsApi = (enabledOnly = false) => {
return request.get<ContentTagItem[]>({
url: '/admin/content-tags',
params: enabledOnly ? { enabledOnly: true } : undefined
})
}
export const createContentTagApi = (data: ContentTagSaveData) => {
return request.post<number>({ url: '/admin/content-tags', data })
}
export const updateContentTagApi = (id: number, data: ContentTagSaveData) => {
return request.put<void>({ url: `/admin/content-tags/${id}`, data })
}
export const deleteContentTagApi = (id: number) => {
return request.delete<void>({ url: `/admin/content-tags/${id}` })
}
+5
View File
@@ -98,6 +98,11 @@ export const pageSubmissionsApi = (params: Record<string, unknown>) => {
return request.get<PageResult<SubmissionItem>>({ url: '/admin/submissions/page', params })
}
/** 内容提交详情:附带短剧预览 + 分集列表,审核弹窗展示用 */
export const detailSubmissionApi = (id: number) => {
return request.get<SubmissionItem>({ url: `/admin/submissions/${id}` })
}
/** action: APPROVE / REJECT / WITHDRAW */
export const auditSubmissionApi = (id: number, data: Record<string, unknown>) => {
return request.put<SubmissionItem>({ url: `/admin/submissions/${id}/audit`, data })
+39
View File
@@ -109,6 +109,35 @@ export interface CreatorDramaEditData {
language?: string
}
/** 一键发布的分集入参:assetId 来自已完成的分片上传 */
export interface PublishEpisodeItem {
assetId: number
title?: string
durationSec?: number
cover?: string
}
export interface PublishDramaPayload {
title: string
subtitle?: string
/** 短剧分类 id(下拉选择) */
categoryId?: number
/** 题材标签(自由文本,可选) */
genre?: string
description?: string
cover?: string
language?: string
/** 默认 true:创建后立即提交审核 */
submit?: boolean
episodes: PublishEpisodeItem[]
}
export interface PublishDramaResult {
dramaId: number
submissionId?: number | null
status: string
}
export interface PriceEpisodeConfig {
episodeId: number
number: number
@@ -176,6 +205,11 @@ export const createDramaApi = (data: CreatorDramaEditData) => {
return request.post<number>({ url: '/admin/creator-center/dramas', data })
}
/** 一键发布:一个事务里建作品 + 建分集 + 绑定视频(可选提交审核) */
export const publishDramaApi = (data: PublishDramaPayload) => {
return request.post<PublishDramaResult>({ url: '/admin/creator-center/publish', data })
}
export const updateDramaApi = (id: number, data: CreatorDramaEditData) => {
return request.put<void>({ url: `/admin/creator-center/dramas/${id}`, data })
}
@@ -205,6 +239,11 @@ export const deleteEpisodeApi = (dramaId: number, episodeId: number) => {
})
}
/** 删除作品(草稿 / 已下线 / 审核中可删,已上线需先下线) */
export const deleteDramaApi = (dramaId: number) => {
return request.delete<void>({ url: `/admin/creator-center/dramas/${dramaId}` })
}
export const sortEpisodesApi = (dramaId: number, ids: number[]) => {
return request.put<void>({
url: `/admin/creator-center/dramas/${dramaId}/episodes/sort`,
+30
View File
@@ -27,6 +27,28 @@ export interface TranscodeBoard {
notes: string[]
}
/** 近 7 天每日趋势(给折线图用)。 */
export interface DashboardTrend {
dates: string[]
newUsers: number[]
playEvents: number[]
newTranscodes: number[]
transcodeSuccess: number[]
}
/** 分布项(给饼图用)。 */
export interface DistributionItem {
label: string
value: number
}
export interface DashboardDistribution {
dramaStatus: Record<string, number>
transcodeStatus: Record<string, number>
episodeMedia: Record<string, number>
pendingModeration: DistributionItem[]
}
export const getDashboardOverviewApi = () => {
return request.get<DashboardOverview>({ url: '/admin/dashboard/overview' })
}
@@ -34,3 +56,11 @@ export const getDashboardOverviewApi = () => {
export const getTranscodeBoardApi = () => {
return request.get<TranscodeBoard>({ url: '/admin/dashboard/transcode' })
}
export const getDashboardTrendApi = () => {
return request.get<DashboardTrend>({ url: '/admin/dashboard/trend' })
}
export const getDashboardDistributionApi = () => {
return request.get<DashboardDistribution>({ url: '/admin/dashboard/distribution' })
}
+4 -2
View File
@@ -40,9 +40,10 @@ export const deleteDramaApi = (id: number) => {
return request.delete<void>({ url: `/admin/dramas/${id}` })
}
/** 审核与发布:submit / approve / reject / publish / offline */
/** 审核与发布:submit / approve / reject / publish / offline / approvePublish */
export const changeDramaStatusApi = (id: number, action: DramaReviewAction, reason?: string) => {
return request.post<void>({ url: `/admin/dramas/${id}/${action}`, data: { reason } })
const path = action === 'approvePublish' ? 'approve-publish' : action
return request.post<void>({ url: `/admin/dramas/${id}/${path}`, data: { reason } })
}
/** 下拉选项(短剧表单用),只返回 id/name */
@@ -52,6 +53,7 @@ export const listDramaCategoryOptionsApi = () => {
export interface DramaCategorySaveData {
name: string
locales: Record<string, string>
sort?: number
status?: number
}
+27 -9
View File
@@ -4,8 +4,14 @@ import request from '@/axios'
import type { PageResult } from './types'
export interface DramaClassificationItem {
id: number
id: string
name: string
code: string
nameEn: string
nameId: string
locales?: Record<string, string>
layoutType: 'GRID' | 'RANKING'
revision: string
sort: number
status: number
dramaCount: number
@@ -14,17 +20,21 @@ export interface DramaClassificationItem {
export interface DramaClassificationSaveData {
name: string
locales: Record<string, string>
code?: string
layoutType?: 'GRID' | 'RANKING'
sort?: number
status?: number
}
export interface ClassificationDramaItem {
id: number
id: string
title: string
cover: string
status: string
totalEpisodes: number
sort: number
position?: number
updatedAt: string
}
@@ -39,19 +49,19 @@ export const listClassificationsApi = () => {
}
export const createClassificationApi = (data: DramaClassificationSaveData) => {
return request.post<number>({ url: '/admin/drama-classifications', data })
return request.post<string>({ url: '/admin/drama-classifications', data })
}
export const updateClassificationApi = (id: number, data: DramaClassificationSaveData) => {
export const updateClassificationApi = (id: string, data: DramaClassificationSaveData) => {
return request.put<void>({ url: `/admin/drama-classifications/${id}`, data })
}
export const deleteClassificationApi = (id: number) => {
export const deleteClassificationApi = (id: string) => {
return request.delete<void>({ url: `/admin/drama-classifications/${id}` })
}
/** 分类下已关联的短剧 */
export const listClassificationDramasApi = (id: number, params: ClassificationDramaPageParams) => {
export const listClassificationDramasApi = (id: string, params: ClassificationDramaPageParams) => {
return request.get<PageResult<ClassificationDramaItem>>({
url: `/admin/drama-classifications/${id}/dramas`,
params
@@ -60,7 +70,7 @@ export const listClassificationDramasApi = (id: number, params: ClassificationDr
/** 尚未关联到该分类的短剧(添加弹窗用) */
export const listClassificationDramaCandidatesApi = (
id: number,
id: string,
params: ClassificationDramaPageParams
) => {
return request.get<PageResult<ClassificationDramaItem>>({
@@ -70,7 +80,7 @@ export const listClassificationDramaCandidatesApi = (
}
/** 批量关联短剧,返回实际新增数量 */
export const bindClassificationDramasApi = (id: number, dramaIds: number[]) => {
export const bindClassificationDramasApi = (id: string, dramaIds: string[]) => {
return request.post<number>({
url: `/admin/drama-classifications/${id}/dramas`,
data: { dramaIds }
@@ -78,8 +88,16 @@ export const bindClassificationDramasApi = (id: number, dramaIds: number[]) => {
}
/** 从分类移除短剧 */
export const unbindClassificationDramaApi = (id: number, dramaId: number) => {
export const unbindClassificationDramaApi = (id: string, dramaId: string) => {
return request.delete<void>({
url: `/admin/drama-classifications/${id}/dramas/${dramaId}`
})
}
/** 原子保存推荐榜完整内容与顺序 */
export const saveRankingItemsApi = (id: string, dramaIds: string[]) => {
return request.put<void>({
url: `/admin/drama-classifications/${id}/ranking-items`,
data: { dramaIds }
})
}
+8 -1
View File
@@ -7,7 +7,13 @@ import type { ClassificationDramaItem } from './dramaClassification'
export interface DramaPlaylistCategoryItem {
id: number
name: string
nameEn: string
nameId: string
description: string
descriptionEn: string
descriptionId: string
locales?: Record<string, string>
descriptionLocales?: Record<string, string>
sort: number
status: number
dramaCount: number
@@ -15,7 +21,8 @@ export interface DramaPlaylistCategoryItem {
export interface DramaPlaylistCategorySaveData {
name: string
description?: string
locales: Record<string, string>
descriptionLocales?: Record<string, string>
sort?: number
status?: number
}
+54
View File
@@ -0,0 +1,54 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
/** 收藏与点赞只读分页筛选条件。 */
export interface EngagementPageParams {
pageIndex?: number
pageSize?: number
keyword?: string
userId?: string | number
deviceId?: string
/** 客户端平台:ANDROID / IOS / WEB */
platform?: string
dramaId?: string | number
dramaTitle?: string
targetType?: string
targetId?: string | number
episodeId?: string | number
startTime?: string
endTime?: string
}
/** 后台互动列表行。 */
export interface EngagementItem {
id: string
userId: string | null
uid: string | null
nickname: string | null
deviceId: string | null
/** 客户端平台 ANDROID / IOS / WEB;老客户端未上报时为空。 */
platform: string | null
anonymous: boolean
dramaId: string | null
dramaTitle: string | null
dramaCover: string | null
dramaStatus: string | null
dramaPublished: boolean | null
targetType: string
targetId: string
episodeId: string | null
episodeNo: number | null
episodeTitle: string | null
commentSummary: string | null
createdAt: string
}
/** 查询收藏分页。 */
export const pageFavoritesApi = (params: EngagementPageParams) =>
request.get<PageResult<EngagementItem>>({ url: '/admin/favorites/page', params })
/** 查询点赞分页。 */
export const pageLikesApi = (params: EngagementPageParams) =>
request.get<PageResult<EngagementItem>>({ url: '/admin/likes/page', params })
+93
View File
@@ -0,0 +1,93 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { PageResult } from './types'
export interface RewardTaskItem {
id: string
code: string
sectionCode: string
type: string
verifyMode: string
targetValue: number
rewardCoin: number
cycleType: string
cycleLimit: number
actionType: string
actionUrl: string
iconUrl: string
titleId: string
titleEn: string
titleZh: string
subtitleId: string
subtitleEn: string
subtitleZh: string
ctaId: string
ctaEn: string
ctaZh: string
sectionTitleId: string
sectionTitleEn: string
sectionTitleZh: string
configJson: string
sort: number
status: number
statusText: string
startAt?: string
endAt?: string
}
export interface RewardTaskPageParams {
pageIndex?: number
pageSize?: number
keyword?: string
type?: string
sectionCode?: string
status?: number
}
export type RewardTaskSaveData = Omit<RewardTaskItem, 'id' | 'status' | 'statusText'>
export interface RewardRuleDay {
dayIndex: number
rewardCoin: number
}
export interface RewardGrantItem {
id: string
userId: string
taskId: string
cycleKey: string
amount: number
status: string
idempotentKey: string
coinLedgerId: string
source: string
operatorId: string
failureReason: string
grantedAt?: string
createdAt?: string
}
export const pageRewardTasksApi = (params: RewardTaskPageParams) =>
request.get<PageResult<RewardTaskItem>>({ url: '/admin/rewards/tasks/page', params })
export const createRewardTaskApi = (data: RewardTaskSaveData) =>
request.post<string>({ url: '/admin/rewards/tasks', data })
export const updateRewardTaskApi = (id: string, data: RewardTaskSaveData) =>
request.put<void>({ url: `/admin/rewards/tasks/${id}`, data })
export const updateRewardTaskStatusApi = (id: string, status: number) =>
request.put<void>({ url: `/admin/rewards/tasks/${id}/status`, params: { status } })
export const getRewardRulesApi = () =>
request.get<RewardRuleDay[]>({ url: '/admin/rewards/check-in/rules' })
export const saveRewardRulesApi = (days: RewardRuleDay[]) =>
request.put<void>({ url: '/admin/rewards/check-in/rules', data: { days } })
export const pageRewardGrantsApi = (params: Record<string, unknown>) =>
request.get<PageResult<RewardGrantItem>>({ url: '/admin/rewards/grants/page', params })
export const manualRewardGrantApi = (data: Record<string, unknown>) =>
request.post<RewardGrantItem>({ url: '/admin/rewards/grants/manual', data })
+14 -1
View File
@@ -1,7 +1,12 @@
// Author: fruit.com.cn
import request from '@/axios'
import type { EpisodeTranscodeItem, MediaThumbnailResult, TranscodeTriggerResult } from './types'
import type {
EpisodeTranscodeItem,
MediaThumbnailResult,
TranscodeBackfillResult,
TranscodeTriggerResult
} from './types'
/** 分集编辑页聚合查询转码任务、清晰度与帧图。 */
export const getEpisodeTranscodeApi = (episodeId: number | string) => {
@@ -28,3 +33,11 @@ export const triggerMediaTranscodeApi = (mediaAssetId: string) => {
url: `/admin/media/${mediaAssetId}/transcode`
})
}
/** 手动补齐转码:扫描缺码率产物/帧图的分集并入队。 */
export const runTranscodeBackfillApi = (batchSize?: number) => {
return request.post<TranscodeBackfillResult>({
url: '/admin/media/transcode/backfill',
params: batchSize ? { batchSize } : undefined
})
}
+27
View File
@@ -0,0 +1,27 @@
// Author: fruit.com.cn
import request from '@/axios'
export interface TranscodeNoticeItem {
id: number
status: string
dramaTitle: string
episodeNo?: number
episodeTitle?: string
progress?: number
errorMsg?: string
updatedAt?: string
}
export interface TranscodeNotice {
running: number
success: number
failed: number
runningList: TranscodeNoticeItem[]
successList: TranscodeNoticeItem[]
failedList: TranscodeNoticeItem[]
}
export const getTranscodeNoticesApi = () => {
return request.get<TranscodeNotice>({ url: '/admin/notifications/transcode' })
}
+19 -1
View File
@@ -8,6 +8,9 @@ export interface PageResult<T> {
export interface DramaCategory {
id: number
name: string
nameEn?: string
nameId?: string
locales?: Record<string, string>
sort?: number
status?: number
/** 该类型下的短剧数量,管理列表才有 */
@@ -26,6 +29,8 @@ export interface DramaItem {
genre?: string
description?: string
cover?: string
/** 横版封面,用于继续观看横幅等横向展示位 */
horizontalCover?: string
language?: string
dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'>
@@ -54,6 +59,8 @@ export interface DramaSaveData {
genre?: string
description?: string
cover?: string
/** 横版封面,用于继续观看横幅等横向展示位 */
horizontalCover?: string
language?: string
dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'>
@@ -66,7 +73,8 @@ export interface DramaSaveData {
sort?: number
}
export type DramaReviewAction = 'submit' | 'approve' | 'reject' | 'publish' | 'offline'
export type DramaReviewAction =
'submit' | 'approve' | 'reject' | 'publish' | 'offline' | 'approvePublish'
export interface EpisodeItem {
id: number
@@ -321,6 +329,16 @@ export interface TranscodeTriggerResult {
status: string
}
export interface TranscodeBackfillItem {
episodeId: string
taskId: string
}
export interface TranscodeBackfillResult {
triggered: number
items: TranscodeBackfillItem[]
}
export interface BannerItem {
id: number
imageUrl: string
+1
View File
@@ -2,6 +2,7 @@
export type UserLoginType = {
username: string
password?: string
}
export type UserType = {
@@ -0,0 +1 @@
{"prefix":"material-symbols","info":{"name":"Material Symbols","total":15728,"author":{"name":"Google","url":"https://github.com/google/material-design-icons"},"license":{"title":"Apache 2.0","spdx":"Apache-2.0","url":"https://github.com/google/material-design-icons/blob/master/LICENSE"},"samples":["downloading","privacy-tip","filter-drama-outline","assignment-ind","monitoring","desktop-access-disabled-outline-sharp"],"height":24,"category":"Material","tags":["Has Padding"],"palette":false},"lastModified":1791524209,"width":24,"height":24,"icons":{"menu-rounded":{"body":"<path fill=\"currentColor\" d=\"M4 18q-.425 0-.712-.288T3 17t.288-.712T4 16h16q.425 0 .713.288T21 17t-.288.713T20 18zm0-5q-.425 0-.712-.288T3 12t.288-.712T4 11h16q.425 0 .713.288T21 12t-.288.713T20 13zm0-5q-.425 0-.712-.288T3 7t.288-.712T4 6h16q.425 0 .713.288T21 7t-.288.713T20 8z\"/>"},"menu-open-rounded":{"body":"<path fill=\"currentColor\" d=\"M4 18q-.425 0-.712-.288T3 17t.288-.712T4 16h11q.425 0 .713.288T16 17t-.288.713T15 18zm14.9-1.7l-3.6-3.6q-.3-.3-.3-.7t.3-.7l3.6-3.6q.275-.275.7-.275t.7.275t.275.7t-.275.7L17.4 12l2.9 2.9q.275.275.275.7t-.275.7t-.7.275t-.7-.275M4 13q-.425 0-.712-.288T3 12t.288-.712T4 11h8q.425 0 .713.288T13 12t-.288.713T12 13zm0-5q-.425 0-.712-.288T3 7t.288-.712T4 6h11q.425 0 .713.288T16 7t-.288.713T15 8z\"/>"},"dashboard-rounded":{"body":"<path fill=\"currentColor\" d=\"M14 9q-.425 0-.712-.288T13 8V4q0-.425.288-.712T14 3h6q.425 0 .713.288T21 4v4q0 .425-.288.713T20 9zM4 13q-.425 0-.712-.288T3 12V4q0-.425.288-.712T4 3h6q.425 0 .713.288T11 4v8q0 .425-.288.713T10 13zm10 8q-.425 0-.712-.288T13 20v-8q0-.425.288-.712T14 11h6q.425 0 .713.288T21 12v8q0 .425-.288.713T20 21zM4 21q-.425 0-.712-.288T3 20v-4q0-.425.288-.712T4 15h6q.425 0 .713.288T11 16v4q0 .425-.288.713T10 21z\"/>"},"video-library-rounded":{"body":"<path fill=\"currentColor\" d=\"M17.525 10.625q.35-.225.35-.625t-.35-.625L12.65 6.25q-.375-.25-.763-.038t-.387.663v6.25q0 .45.388.663t.762-.038zM8 18q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm-4 4q-.825 0-1.412-.587T2 20V7q0-.425.288-.712T3 6t.713.288T4 7v13h13q.425 0 .713.288T18 21t-.288.713T17 22z\"/>"},"settings-rounded":{"body":"<path fill=\"currentColor\" d=\"M10.825 22q-.675 0-1.162-.45t-.588-1.1L8.85 18.8q-.325-.125-.612-.3t-.563-.375l-1.55.65q-.625.275-1.25.05t-.975-.8l-1.175-2.05q-.35-.575-.2-1.225t.675-1.075l1.325-1Q4.5 12.5 4.5 12.337v-.675q0-.162.025-.337l-1.325-1Q2.675 9.9 2.525 9.25t.2-1.225L3.9 5.975q.35-.575.975-.8t1.25.05l1.55.65q.275-.2.575-.375t.6-.3l.225-1.65q.1-.65.588-1.1T10.825 2h2.35q.675 0 1.163.45t.587 1.1l.225 1.65q.325.125.613.3t.562.375l1.55-.65q.625-.275 1.25-.05t.975.8l1.175 2.05q.35.575.2 1.225t-.675 1.075l-1.325 1q.025.175.025.338v.674q0 .163-.05.338l1.325 1q.525.425.675 1.075t-.2 1.225l-1.2 2.05q-.35.575-.975.8t-1.25-.05l-1.5-.65q-.275.2-.575.375t-.6.3l-.225 1.65q-.1.65-.587 1.1t-1.163.45zm1.225-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5\"/>"},"group-rounded":{"body":"<path fill=\"currentColor\" d=\"M1 17.2q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2v.8q0 .825-.587 1.413T15 20H3q-.825 0-1.412-.587T1 18zM18.45 20q.275-.45.413-.962T19 18v-1q0-1.1-.612-2.113T16.65 13.15q1.275.15 2.4.513t2.1.887q.9.5 1.375 1.112T23 17v1q0 .825-.587 1.413T21 20zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175m11.65 0Q16.65 12 15 12q-.275 0-.7-.062t-.7-.138q.675-.8 1.038-1.775T15 8t-.362-2.025T13.6 4.2q.35-.125.7-.163T15 4q1.65 0 2.825 1.175T19 8t-1.175 2.825\"/>"},"movie-edit-rounded":{"body":"<path fill=\"currentColor\" d=\"M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4l1.625 3.25q.175.35.5.55t.7.2q.75 0 1.15-.638t.05-1.312L7 4h2l1.625 3.25q.175.35.5.55t.7.2q.75 0 1.15-.638t.05-1.312L12 4h2l1.625 3.25q.175.35.5.55t.7.2q.75 0 1.15-.638t.05-1.312L17 4h3q.825 0 1.413.587T22 6v3.45q0 .3-.25.475t-.55.1q-.825-.15-1.638.087T18.1 11l-5.525 5.525q-.275.275-.425.638t-.15.762V19q0 .425-.288.713T11 20zm10 0v-1.65q0-.2.075-.387t.225-.338l5.225-5.2q.225-.225.5-.325t.55-.1q.3 0 .575.113t.5.337l.925.925q.2.225.313.5t.112.55t-.1.563t-.325.512l-5.2 5.2q-.15.15-.337.225T16.65 21H15q-.425 0-.712-.287T14 20m6.575-4.6l.925-.975l-.925-.925l-.95.95z\"/>"},"policy-rounded":{"body":"<path fill=\"currentColor\" d=\"M11.675 21.875q-.15-.025-.3-.075Q8 20.675 6 17.637T4 11.1V6.375q0-.625.363-1.125t.937-.725l6-2.25q.35-.125.7-.125t.7.125l6 2.25q.575.225.938.725T20 6.375V11.1q0 1.575-.413 3.063T18.4 17l-2.95-2.95q.275-.475.413-.987T16 12q0-1.65-1.175-2.825T12 8T9.175 9.175T8 12t1.175 2.825T12 16q.525 0 1.038-.137T14 15.45l3.225 3.2q-.95 1.125-2.087 1.913T12.625 21.8q-.15.05-.3.075T12 21.9t-.325-.025m-1.088-8.462Q10 12.825 10 12t.588-1.412T12 10t1.413.588T14 12t-.587 1.413T12 14t-1.412-.587\"/>"},"payments-rounded":{"body":"<path fill=\"currentColor\" d=\"M3 20q-.825 0-1.412-.587T1 18V8q0-.425.288-.712T2 7t.713.288T3Line truncated
+2 -10
View File
@@ -5,7 +5,7 @@ import { computed, unref } from 'vue'
import { ElIcon } from 'element-plus'
import { propTypes } from '@/utils/propTypes'
import { useDesign } from '@/hooks/web/useDesign'
import { Icon } from '@iconify/vue'
import { Icon } from '@iconify/vue/offline'
import { normalizeAppIcon } from '@/constants/icons'
const { getPrefixCls } = useDesign()
@@ -29,11 +29,6 @@ const symbolId = computed(() => {
return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : unref(normalizedIcon)
})
// 是否使用在线图标
const isUseOnline = computed(() => {
return import.meta.env.VITE_USE_ONLINE_ICON === 'true'
})
const getIconifyStyle = computed(() => {
const { color, size } = props
return {
@@ -49,10 +44,7 @@ const getIconifyStyle = computed(() => {
<use :xlink:href="symbolId" />
</svg>
<template v-else>
<Icon v-if="isUseOnline" :icon="normalizedIcon" :style="getIconifyStyle" />
<div v-else :class="`${normalizedIcon} iconify`" :style="getIconifyStyle"></div>
</template>
<Icon v-else :icon="normalizedIcon" :style="getIconifyStyle" />
</ElIcon>
</template>
+12 -7
View File
@@ -40,10 +40,19 @@ const dialogVisible = ref<boolean>(false)
const lockScreen = () => {
dialogVisible.value = true
}
// 点击整行菜单项都能触发(之前 click 绑在内层文字 div 上,点到菜单项左右内边距没反应)
const handleCommand = (command: string) => {
if (command === 'lock') {
lockScreen()
} else if (command === 'logout') {
loginOut()
}
}
</script>
<template>
<ElDropdown class="custom-hover" :class="prefixCls" trigger="click">
<ElDropdown class="custom-hover" :class="prefixCls" trigger="click" @command="handleCommand">
<div class="flex items-center">
<img
v-if="avatarUrl"
@@ -63,12 +72,8 @@ const lockScreen = () => {
</div>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem>
<div @click="lockScreen">{{ t('lock.lockScreen') }}</div>
</ElDropdownItem>
<ElDropdownItem>
<div @click="loginOut">{{ t('common.loginOut') }}</div>
</ElDropdownItem>
<ElDropdownItem command="lock">{{ t('lock.lockScreen') }}</ElDropdownItem>
<ElDropdownItem command="logout">{{ t('common.loginOut') }}</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
+46
View File
@@ -0,0 +1,46 @@
// Author: fruit.com.cn
export interface ContentLanguage {
code: string
label: string
}
export const CONTENT_LANGUAGES: ContentLanguage[] = [
{ code: 'zh', label: '中文' },
{ code: 'en', label: '英文' },
{ code: 'id', label: '印尼语' }
]
export function languageLabel(code: string) {
return CONTENT_LANGUAGES.find((item) => item.code === code)?.label || code
}
export function languageCodes(locales?: Record<string, string> | null) {
const source = locales || {}
return CONTENT_LANGUAGES.map((item) => item.code).filter((code) => source[code])
}
export function localesFromColumns(
locales: Record<string, string> | null | undefined,
zh?: string,
en?: string,
id?: string
) {
if (locales && Object.keys(locales).length) {
return { ...locales }
}
const result: Record<string, string> = {}
if (zh) result.zh = zh
if (en) result.en = en
if (id) result.id = id
if (!result.zh) result.zh = ''
return result
}
export function payloadLocales(locales: Record<string, string>, codes: string[]) {
const result: Record<string, string> = {}
codes.forEach((code) => {
result[code] = String(locales[code] || '').trim()
})
return result
}
+29 -1
View File
@@ -6,9 +6,11 @@ import { Collapse } from '@/components/Collapse'
import { LocaleDropdown } from '@/components/LocaleDropdown'
import { SizeDropdown } from '@/components/SizeDropdown'
import { UserInfo } from '@/components/UserInfo'
import TranscodeNoticeButton from './TranscodeNoticeButton.vue'
import { Screenfull } from '@/components/Screenfull'
import { Breadcrumb } from '@/components/Breadcrumb'
import { useAppStore } from '@/store/modules/app'
import { useUserStore } from '@/store/modules/user'
import { useDesign } from '@/hooks/web/useDesign'
const { getPrefixCls, variables } = useDesign()
@@ -17,6 +19,16 @@ const prefixCls = getPrefixCls('tool-header')
const appStore = useAppStore()
const userStore = useUserStore()
// 转码通知接口要求 drama:list,创作者账号没有该权限。
// 不给入口,避免顶栏每 30 秒轮询打一次 403。
const canSeeTranscodeNotice = computed(() => {
const permissions = userStore.getUserInfo?.permissions
if (Array.isArray(permissions)) return permissions.includes('drama:list')
return typeof permissions === 'string' && permissions.split(',').includes('drama:list')
})
// 面包屑
const breadcrumb = computed(() => appStore.getBreadcrumb)
@@ -73,6 +85,9 @@ export default defineComponent({
color="var(--top-header-text-color)"
></LocaleDropdown>
) : undefined}
{canSeeTranscodeNotice.value ? (
<TranscodeNoticeButton color="var(--top-header-text-color)"></TranscodeNoticeButton>
) : undefined}
<UserInfo></UserInfo>
</div>
</div>
@@ -93,9 +108,22 @@ export default defineComponent({
:deep(.v-user-info) {
flex-shrink: 0;
}
// 顶栏图标的点击热区原来只有 18px 图标本身,容易点偏导致「点了没反应」。
// 统一撑满顶栏高度并左右留出内边距。
:deep(.v-screenfull),
:deep(.v-size-dropdown),
:deep(.v-locale-dropdown),
:deep(.v-user-info) {
display: flex;
align-items: center;
height: 100%;
padding: 0 8px;
cursor: pointer;
}
}
@media (max-width: 768px) {
@media (width <= 768px) {
.@{prefix-cls} {
padding-right: 8px;
padding-left: 8px;
@@ -0,0 +1,91 @@
<!-- Author: fruit.com.cn -->
<!-- 顶栏转码通知:角标汇总转码中、成功、失败,点击进入通知页。 -->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Bell } from '@element-plus/icons-vue'
import { getTranscodeNoticesApi } from '@/api/admin/transcodeNotice'
import { TRANSCODE_NOTICE_SEEN, unreadCounts } from '@/utils/transcodeNoticeSeen'
const POLL_MS = 30000
const BADGE_MAX = 99
defineProps<{ color?: string }>()
const router = useRouter()
const running = ref(0)
const success = ref(0)
const failed = ref(0)
const seenTick = ref(0)
let timer = 0
const total = computed(() => {
seenTick.value
const unread = unreadCounts({
running: running.value,
success: success.value,
failed: failed.value
})
return unread.running + unread.success + unread.failed
})
async function refresh() {
const res = await getTranscodeNoticesApi()
running.value = Number(res.data?.running || 0)
success.value = Number(res.data?.success || 0)
failed.value = Number(res.data?.failed || 0)
}
function open() {
router.push({ name: 'transcode-notices' })
}
function onSeen() {
seenTick.value += 1
}
onMounted(() => {
refresh().catch(() => undefined)
window.addEventListener(TRANSCODE_NOTICE_SEEN, onSeen)
timer = window.setInterval(() => {
refresh().catch(() => undefined)
}, POLL_MS)
})
onBeforeUnmount(() => {
window.removeEventListener(TRANSCODE_NOTICE_SEEN, onSeen)
window.clearInterval(timer)
})
</script>
<template>
<el-badge :value="total" :max="BADGE_MAX" :hidden="total === 0" class="notice-badge">
<div class="notice-button" :style="{ color }" @click="open">
<el-icon :size="18"><Bell /></el-icon>
</div>
</el-badge>
</template>
<style scoped>
.notice-badge {
display: flex;
align-items: center;
height: 100%;
}
.notice-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 100%;
cursor: pointer;
}
.notice-badge :deep(.el-badge__content.is-fixed) {
/* 角标中心骑在铃铛右上角:right 9px + 右移 50%,与位数宽度无关 */
top: calc(50% - 9px);
right: 9px;
transform: translate(50%, -50%);
}
</style>
+5
View File
@@ -657,6 +657,11 @@ export default {
saveFailed: 'Failed to save transcode settings',
loadFailed: 'Failed to load transcode settings',
noPermission: 'This account cannot manage transcode settings',
backfill: 'Backfill transcodes',
backfillTip: 'Scan episodes missing renditions or thumbnails and enqueue them for transcoding',
backfillDone: 'Enqueued {count} episodes into the transcode queue',
backfillEmpty: 'No episodes need transcoding',
backfillFailed: 'Failed to run transcode backfill',
enabled: 'Enable transcode profile',
enabledDescription:
'Marks whether this transcode profile is enabled; the quality ladder is still validated when saved.',
+5
View File
@@ -648,6 +648,11 @@ export default {
saveFailed: '转码设置保存失败',
loadFailed: '转码设置加载失败',
noPermission: '当前账号没有转码设置权限',
backfill: '立即补齐转码',
backfillTip: '扫描缺少清晰度产物或帧图的分集并自动入队转码',
backfillDone: '本轮已补入 {count} 个分集进入转码队列',
backfillEmpty: '没有需要补转的分集',
backfillFailed: '补齐转码执行失败',
enabled: '启用转码配置',
enabledDescription: '标记当前转码配置是否启用;保存时仍会校验下方档位。',
segmentSeconds: 'HLS 分片时长(秒)',
+3
View File
@@ -5,6 +5,9 @@ import 'vue/jsx'
// 引入unocss
import '@/plugins/unocss'
// 注册后台使用的本地图标子集
import '@/plugins/icons'
// 导入全局的svg图标
import '@/plugins/svgIcon'
+14 -2
View File
@@ -25,13 +25,20 @@ router.beforeEach(async (to, from, next) => {
const userStore = useUserStoreWithOut()
if (userStore.getUserInfo) {
if (to.path === '/login') {
next({ path: '/' })
// 已登录再访问登录页:按当前账号的菜单落地,不能回 '/'(静态路由会 redirect 到 /dashboard/index,
// 创作者没有该路由,直接掉 404)
next({ path: landingPath(), replace: true })
} else {
if (permissionStore.getIsAddRouters) {
// 已加过路由:只有还是同一个账号才能直接放行。
// 换账号时(例如超管退出后创作者登录)必须重建,否则侧边栏/路由会残留上一个账号的
const username = userStore.getUserInfo?.username || ''
if (permissionStore.getRoutersOwner === username) {
// 访问根路径时同样按账号菜单落地,避免走静态 redirect 掉进 /dashboard/index
if (to.path === '/') {
next({ path: landingPath(), replace: true })
return
}
next()
return
}
@@ -96,7 +103,12 @@ router.beforeEach(async (to, from, next) => {
const redirectPath = from.query.redirect || to.path
const redirect = decodeURIComponent(redirectPath as string)
const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect }
// 只按路径重进。刷新时动态路由还没注册,先被兜底路由接住;
// 若把当时匹配到的 name 带回去,会一直停在 404。
const nextData =
to.path === redirect
? { path: to.path, query: to.query, hash: to.hash, replace: true }
: { path: redirect, replace: true }
permissionStore.setIsAddRouters(true)
next(nextData)
}
+2 -18
View File
@@ -2,22 +2,12 @@
import * as echarts from 'echarts/core'
import {
BarChart,
LineChart,
PieChart,
MapChart,
PictorialBarChart,
RadarChart
} from 'echarts/charts'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
GridComponent,
PolarComponent,
AriaComponent,
ParallelComponent,
LegendComponent
} from 'echarts/components'
@@ -28,16 +18,10 @@ echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
PolarComponent,
AriaComponent,
ParallelComponent,
BarChart,
LineChart,
PieChart,
MapChart,
CanvasRenderer,
PictorialBarChart,
RadarChart
CanvasRenderer
])
export default echarts
+6
View File
@@ -0,0 +1,6 @@
// Author: fruit.com.cn
import { addCollection } from '@iconify/vue/offline'
import materialSymbols from '@/assets/icons/material-symbols-subset.json'
addCollection(materialSymbols)
+39 -2
View File
@@ -8,9 +8,10 @@ import { AppIcons } from '@/constants/icons'
export const constantRouterMap: AppRouteRecordRaw[] = [
{
// 不写死 redirect:vue-router 会在守卫之前就把 '/' 解析成 redirect 目标,
// 创作者没有 /dashboard/index 路由会直接 404。落地页交给 permission.ts 按账号菜单决定。
path: '/',
component: Layout,
redirect: '/dashboard/index',
name: 'Root',
meta: {
hidden: true
@@ -52,6 +53,35 @@ export const constantRouterMap: AppRouteRecordRaw[] = [
title: '404',
noTagsView: true
}
},
{
path: '/notifications',
component: Layout,
name: 'Notifications',
meta: {
hidden: true,
title: '通知'
},
children: [
{
path: 'transcode',
name: 'transcode-notices',
component: () => import('@/views/common/TranscodeNoticeView.vue'),
meta: {
title: '转码通知'
}
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'UnresolvedPath',
component: () => import('@/views/Error/404.vue'),
meta: {
hidden: true,
title: '404',
noTagsView: true
}
}
]
@@ -232,7 +262,14 @@ const router = createRouter({
})
export const resetRouter = (): void => {
const resetWhiteNameList = ['Redirect', 'RedirectParent', 'Login', 'NoFind', 'Root']
const resetWhiteNameList = [
'Redirect',
'RedirectParent',
'Login',
'NoFind',
'Root',
'UnresolvedPath'
]
router.getRoutes().forEach((route) => {
const { name } = route
if (name && !resetWhiteNameList.includes(name as string)) {
+7 -5
View File
@@ -47,7 +47,7 @@ export const useAppStore = defineStore('app', {
breadcrumb: true, // 面包屑
breadcrumbIcon: true, // 面包屑图标
collapse: false, // 折叠菜单
uniqueOpened: false, // 是否只保持一个子菜单的展开
uniqueOpened: true, // 是否只保持一个子菜单的展开(菜单手风琴)
hamburger: true, // 折叠图标
screenfull: true, // 全屏图标
size: true, // 尺寸图标
@@ -71,9 +71,9 @@ export const useAppStore = defineStore('app', {
// 左侧菜单边框颜色
leftMenuBorderColor: 'inherit',
// 左侧菜单背景颜色
leftMenuBgColor: '#001529',
leftMenuBgColor: '#fff',
// 左侧菜单浅色背景颜色
leftMenuBgLightColor: '#0f2438',
leftMenuBgLightColor: '#fff',
// 左侧菜单选中背景颜色
leftMenuBgActiveColor: 'var(--app-primary-strong)',
// 左侧菜单收起选中背景颜色
@@ -346,8 +346,10 @@ export const useAppStore = defineStore('app', {
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)
// 头部主题默认强制为白色、菜单主题也强制为白色
// 避免老用户浏览器残留其他主题色,清缓存也能恢复到项目品牌配色
context.store.setHeaderTheme('#fff')
context.store.setMenuTheme('#fff')
}
}
})
+3 -13
View File
@@ -87,8 +87,8 @@ export const useUserStore = defineStore('user', {
this.setToken('')
this.setUserInfo(undefined)
this.setRoleRouters([])
this.setLoginInfo(undefined)
this.setRememberMe(false)
// 注意:不能清 loginInfo / rememberMe —— 那是「记住我」存的账号密码,
// 退出登录后回到登录页还要靠它来回填,清了就等于「记住我」没生效。
tagsViewStore.delAllViews()
// 菜单/路由一起清:否则换账号后 permissionStore 里还是上一个账号的路由表
permissionStore.clearRoutes()
@@ -104,17 +104,7 @@ export const useUserStore = defineStore('user', {
this.loginInfo = loginInfo
}
},
persist: {
afterRestore: (context) => {
const loginInfo = context.store.loginInfo as
(UserLoginType & { password?: string }) | undefined
if (loginInfo?.password !== undefined) {
context.store.setLoginInfo(
loginInfo.username ? { username: loginInfo.username } : undefined
)
}
}
}
persist: true
})
export const useUserStoreWithOut = () => {
+3 -1
View File
@@ -18,7 +18,8 @@
}
.app-user-toolbar .app-user-status,
.app-user-toolbar .app-user-country {
.app-user-toolbar .app-user-country,
.app-user-toolbar .app-user-platform {
width: 150px;
flex: 0 0 150px;
}
@@ -63,6 +64,7 @@
.app-user-meta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
+159
View File
@@ -188,6 +188,19 @@
gap: 10px;
}
.creator-cover-actions {
display: flex;
flex-direction: column;
gap: 6px;
}
.creator-cover-hint {
margin: 0;
color: #9a97a8;
font-size: 12px;
line-height: 1.4;
}
.creator-cover-preview {
display: grid;
place-content: center;
@@ -285,6 +298,11 @@ html.dark .creator-guide-title {
margin-bottom: 14px;
}
.creator-switch-hint {
margin-left: 10px;
font-size: 12px;
}
html.dark .creator-profile-name,
html.dark .creator-profile-meta b,
html.dark .creator-drama-title,
@@ -303,3 +321,144 @@ html.dark .creator-episode-cover {
border-color: var(--el-border-color-light);
background: var(--el-fill-color-light);
}
/* ---------- 移动端(创作者多手机上传) ---------- */
.creator-mobile-only {
display: none;
}
/* 作品表格:列宽合计超过容器时由外层横向滚动,
不依赖 el-table 内部的 scrollbar(其 header/body 会被各自裁切)。 */
.creator-desktop-only {
overflow-x: auto;
}
.creator-desktop-only .el-table.drama-el-table {
min-width: 1150px;
}
/* 作品编辑页:分集 / 提交记录表格由外层容器横向滚动(宽度见各表格 inline min-width) */
.creator-table-scroll {
overflow-x: auto;
}
/* 操作列按钮放不下时换行,避免「删除」被表格裁掉 */
.creator-table-scroll .table-actions {
flex-wrap: wrap;
white-space: normal;
row-gap: 6px;
}
.creator-video-preview {
display: block;
width: 100%;
max-height: 70vh;
background: #000;
}
@media (width <= 768px) {
.creator-desktop-only {
display: none;
}
.creator-mobile-only {
display: block;
}
.creator-profile-meta {
flex-wrap: wrap;
gap: 8px 16px;
}
.console-toolbar {
flex-wrap: wrap;
}
.console-toolbar .console-keyword,
.console-toolbar .console-select {
width: 100%;
}
.creator-drama-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.creator-drama-card {
display: flex;
gap: 12px;
padding: 12px;
border: 1px solid #e6e8eb;
border-radius: var(--app-radius-md);
background: #fff;
}
.creator-drama-card-cover {
display: grid;
flex-shrink: 0;
place-content: center;
width: 60px;
height: 80px;
overflow: hidden;
color: #9a97a8;
font-size: 16px;
background: #eef0f5;
border-radius: var(--app-radius-sm);
}
.creator-drama-card-cover img,
.creator-drama-card-cover .creator-drama-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.creator-drama-card-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.creator-drama-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.creator-drama-card-head .creator-drama-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.creator-drama-card-meta {
display: flex;
gap: 12px;
color: #727983;
font-size: 12px;
}
.creator-drama-card-actions {
display: flex;
gap: 8px;
margin-top: 6px;
}
.pagination {
justify-content: center;
}
html.dark .creator-drama-card {
border-color: var(--el-border-color-light);
background: var(--el-bg-color-overlay);
}
html.dark .creator-drama-card-cover {
background: var(--el-fill-color-light);
}
}
+23
View File
@@ -165,6 +165,19 @@
line-height: 1.4;
}
.cover-block + .cover-block {
margin-top: 18px;
padding-top: 16px;
border-top: 1px dashed var(--el-border-color-lighter);
}
.cover-label {
margin: 0 0 8px;
color: var(--el-text-color-regular);
font-size: 13px;
font-weight: 600;
}
.cover-editor {
display: flex;
gap: 18px;
@@ -183,6 +196,12 @@
background: var(--el-fill-color-lighter);
}
.cover-preview-card--landscape {
flex: 0 0 160px;
width: 160px;
height: 90px;
}
.cover-preview-card img {
position: absolute;
inset: 0;
@@ -297,6 +316,10 @@
flex-basis: 128px;
}
.cover-preview-card--landscape {
flex-basis: 160px;
}
.cover-editor-meta {
width: 100%;
}
+9 -7
View File
@@ -6,23 +6,25 @@
--left-menu-min-width: 64px;
--left-menu-bg-color: #17110f;
--left-menu-bg-color: #fff;
--left-menu-bg-light-color: #221815;
--left-menu-bg-light-color: #fff;
--left-menu-bg-active-color: var(--app-primary-strong);
--left-menu-bg-active-color: rgba(196, 50, 42, 0.1);
--left-menu-text-color: #d7cbc4;
--left-menu-text-color: #333;
--left-menu-text-active-color: #fff;
--left-menu-text-active-color: var(--el-color-primary);
--left-menu-collapse-bg-active-color: var(--app-primary-strong);
--left-menu-border-color: #eee;
--left-menu-collapse-bg-active-color: rgba(196, 50, 42, 0.1);
/* left menu end */
/* logo start */
--logo-height: 50px;
--logo-title-text-color: #fff;
--logo-title-text-color: #303133;
/* logo end */
/* header start */
+154
View File
@@ -0,0 +1,154 @@
// Author: fruit.com.cn
/**
* 上传前的图片压缩:只做「超出目标尺寸才等比缩小 + 同格式高质量重编码」,
* 尺寸没超、体积也不大时原样返回,避免无意义的二次编码损失。
*
* 说明:
* - 保留原格式(JPEG→JPEG / PNG→PNG / WebP→WebP),避免 PNG 透明底被抹掉;
* - GIF / SVG 直接跳过,动图与矢量图重编码会丢帧或失真;
* - 缩放用浏览器原生解码 + canvas,配合 EXIF 方向校正,避免竖拍图躺倒。
*/
export interface ImageCompressOptions {
/** 目标最大宽(像素)。 */
maxWidth: number
/** 目标最大高(像素)。 */
maxHeight: number
/** 重编码质量,默认 0.92,肉眼看不出差异。 */
quality?: number
/** 原图小于该体积且尺寸已达标时直接原样上传,默认 300KB。 */
skipBelowBytes?: number
}
const PASSTHROUGH_TYPES = ['image/gif', 'image/svg+xml']
/** 可压缩的位图格式;其余类型原样返回。 */
export function canCompressImage(file: File): boolean {
return file.type.startsWith('image/') && !PASSTHROUGH_TYPES.includes(file.type)
}
/** 按比例算出目标尺寸;不需要缩放时返回 null。 */
export function fitWithin(
width: number,
height: number,
maxWidth: number,
maxHeight: number
): { width: number; height: number } | null {
if (width <= 0 || height <= 0) return null
const scale = Math.min(maxWidth / width, maxHeight / height, 1)
if (scale >= 1) return null
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale))
}
}
export async function compressImage(file: File, options: ImageCompressOptions): Promise<File> {
if (!canCompressImage(file)) return file
const quality = options.quality ?? 0.92
const skipBelowBytes = options.skipBelowBytes ?? 300 * 1024
const bitmap = await decode(file)
if (!bitmap) return file
const { width, height } = bitmap
const target = fitWithin(width, height, options.maxWidth, options.maxHeight)
// 尺寸已经在目标范围内、体积也不大:直接用原图,一点不损失。
if (!target && file.size <= skipBelowBytes) {
close(bitmap)
return file
}
const canvas = document.createElement('canvas')
canvas.width = target?.width ?? width
canvas.height = target?.height ?? height
const context = canvas.getContext('2d')
if (!context) {
close(bitmap)
return file
}
context.imageSmoothingEnabled = true
context.imageSmoothingQuality = 'high'
context.drawImage(bitmap.source, 0, 0, canvas.width, canvas.height)
close(bitmap)
const mimeType = outputMime(file.type)
const blob = await toBlob(canvas, mimeType, quality)
if (!blob || blob.size >= file.size) return file
return new File([blob], renameWithMime(file.name, mimeType), {
type: mimeType,
lastModified: file.lastModified
})
}
function outputMime(type: string): string {
if (type === 'image/png' || type === 'image/webp') return type
return 'image/jpeg'
}
function renameWithMime(name: string, mimeType: string): string {
const ext = mimeType === 'image/png' ? 'png' : mimeType === 'image/webp' ? 'webp' : 'jpg'
const base = name.includes('.') ? name.slice(0, name.lastIndexOf('.')) : name
return `${base}.${ext}`
}
interface DecodedBitmap {
source: CanvasImageSource
width: number
height: number
release?: () => void
}
async function decode(file: File): Promise<DecodedBitmap | null> {
if (typeof createImageBitmap === 'function') {
try {
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' })
return {
source: bitmap,
width: bitmap.width,
height: bitmap.height,
release: () => bitmap.close()
}
} catch {
// 部分浏览器不支持 imageOrientation 选项,退回 <img> 解码。
}
}
const url = URL.createObjectURL(file)
try {
const image = await loadImage(url)
return {
source: image,
width: image.naturalWidth,
height: image.naturalHeight,
release: () => URL.revokeObjectURL(url)
}
} catch {
URL.revokeObjectURL(url)
return null
}
}
function close(bitmap: DecodedBitmap): void {
bitmap.release?.()
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('image decode failed'))
image.src = url
})
}
function toBlob(
canvas: HTMLCanvasElement,
mimeType: string,
quality: number
): Promise<Blob | null> {
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob), mimeType, quality)
})
}
+51
View File
@@ -0,0 +1,51 @@
// Author: fruit.com.cn
const SEEN_KEY = 'transcode-notice-seen'
export const TRANSCODE_NOTICE_SEEN = 'transcode-notice-seen'
export interface NoticeCounts {
running: number
success: number
failed: number
}
/** 上次打开通知页时已经看过的数量。 */
export function readSeen(): NoticeCounts {
try {
const raw = localStorage.getItem(SEEN_KEY)
if (!raw) return { running: 0, success: 0, failed: 0 }
const data = JSON.parse(raw) as NoticeCounts
return {
running: Number(data.running || 0),
success: Number(data.success || 0),
failed: Number(data.failed || 0)
}
} catch {
return { running: 0, success: 0, failed: 0 }
}
}
/** 比上次查看多出来的数量,看过的不再计入角标。 */
export function unreadCounts(current: NoticeCounts): NoticeCounts {
const seen = readSeen()
return {
running: Math.max(0, current.running - seen.running),
success: Math.max(0, current.success - seen.success),
failed: Math.max(0, current.failed - seen.failed)
}
}
/** 把当前数量记为已读,并通知顶栏收起角标。 */
export function markTranscodeNoticeSeen(current: NoticeCounts) {
const seen = readSeen()
localStorage.setItem(
SEEN_KEY,
JSON.stringify({
running: Math.max(seen.running, current.running),
success: Math.max(seen.success, current.success),
failed: Math.max(seen.failed, current.failed)
})
)
window.dispatchEvent(new Event(TRANSCODE_NOTICE_SEEN))
}
+78
View File
@@ -0,0 +1,78 @@
// Author: fruit.com.cn
/** 从本地视频文件截取封面(3:4 居中裁切),抖音式自动封面用 */
export interface CapturedCover {
file: File
previewUrl: string
}
function loadVideo(file: File): Promise<HTMLVideoElement> {
return new Promise((resolve, reject) => {
const video = document.createElement('video')
video.preload = 'metadata'
video.muted = true
video.playsInline = true
const src = URL.createObjectURL(file)
const fail = (message: string) => {
URL.revokeObjectURL(src)
reject(new Error(message))
}
video.onloadedmetadata = () => {
URL.revokeObjectURL(src)
resolve(video)
}
video.onerror = () => fail('当前浏览器无法解析该视频格式')
video.src = src
})
}
/** 截取第 atSeconds 秒画面,按 3:4 居中裁切输出 720×960 JPEG */
export async function captureVideoCover(file: File, atSeconds = 1): Promise<CapturedCover> {
const video = await loadVideo(file)
const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0
// 太短的视频从开头截,正常视频取第 1 秒,避免黑场
const target = duration ? Math.min(atSeconds, Math.max(0.1, duration * 0.2)) : 0
await new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error('截取封面超时')), 8000)
video.onseeked = () => {
window.clearTimeout(timer)
resolve()
}
video.currentTime = target
})
const width = video.videoWidth
const height = video.videoHeight
if (!width || !height) throw new Error('无法读取视频尺寸')
const ratio = 3 / 4
let sw = width
let sh = height
if (width / height > ratio) {
sw = height * ratio
} else {
sh = width / ratio
}
const sx = (width - sw) / 2
const sy = (height - sh) / 2
const canvas = document.createElement('canvas')
canvas.width = 720
canvas.height = 960
const context = canvas.getContext('2d')
if (!context) throw new Error('当前浏览器不支持 canvas')
context.drawImage(video, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height)
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('封面生成失败'))),
'image/jpeg',
0.85
)
})
const baseName = file.name.replace(/\.[^.]+$/, '') || 'cover'
return {
file: new File([blob], `${baseName}-cover.jpg`, { type: 'image/jpeg' }),
previewUrl: URL.createObjectURL(blob)
}
}
+146 -11
View File
@@ -31,6 +31,94 @@ const themeChange = () => {
<img src="@/assets/imgs/brand-mark.png" alt="" />
<span>{{ underlineToHump(appStore.getTitle) }}</span>
</div>
<div class="hero-fruits" aria-hidden="true">
<svg class="fruit cherry" viewBox="0 0 120 140">
<path
d="M62 38c8-16 24-24 36-22"
fill="none"
stroke="#7d9a62"
stroke-width="3"
stroke-linecap="round"
/>
<path
d="M58 40c2-18-8-30-6-36"
fill="none"
stroke="#6d8a55"
stroke-width="3"
stroke-linecap="round"
/>
<ellipse
cx="34"
cy="28"
rx="14"
ry="7"
fill="#8fb56f"
transform="rotate(-28 34 28)"
/>
<circle cx="42" cy="78" r="28" fill="#c4322a" />
<circle cx="78" cy="86" r="26" fill="#a82822" />
<circle cx="32" cy="68" r="8" fill="#fff" opacity=".28" />
</svg>
<svg class="fruit mango" viewBox="0 0 150 170">
<path d="M78 28c18 2 28 8 34 16-22 4-36 2-48-8 4-4 8-8 14-8z" fill="#6d8a55" />
<path
d="M86 36c6-16 2-24-2-28"
fill="none"
stroke="#5f7a4c"
stroke-width="3"
stroke-linecap="round"
/>
<path
d="M46 58c-18 28-16 62 8 84 28 24 62 10 74-16 14-30 4-62-16-80-18-16-46-10-66 12z"
fill="#f0b429"
/>
<path
d="M58 78c8 22 10 40 4 58"
fill="none"
stroke="#ffe08a"
stroke-width="8"
stroke-linecap="round"
opacity=".55"
/>
<ellipse
cx="62"
cy="86"
rx="14"
ry="22"
fill="#fff"
opacity=".2"
transform="rotate(-24 62 86)"
/>
</svg>
<svg class="fruit banana" viewBox="0 0 160 150">
<path
d="M28 108c18-46 48-74 92-86 8 10 10 22 4 34-28 10-50 28-66 58-6 12-16 16-30 12-8-2-12-10 0-18z"
fill="#f2d34b"
/>
<path
d="M36 102c16-38 42-62 80-74"
fill="none"
stroke="#ffe98a"
stroke-width="7"
stroke-linecap="round"
opacity=".7"
/>
<path
d="M118 24c8 2 14 10 12 16"
fill="none"
stroke="#6d8a55"
stroke-width="4"
stroke-linecap="round"
/>
<path
d="M30 112c-6 8-4 16 4 16"
fill="none"
stroke="#c9a227"
stroke-width="4"
stroke-linecap="round"
/>
</svg>
</div>
<div class="hero-copy">
<p class="hero-kicker">{{ t('login.consoleLabel') }}</p>
<h1>{{ t('login.welcome') }}</h1>
@@ -81,16 +169,58 @@ const themeChange = () => {
flex-direction: column;
justify-content: space-between;
&::before,
&::after {
position: absolute;
right: -80px;
bottom: -120px;
width: 360px;
height: 360px;
border: 1px solid rgb(255 255 255 / 16%);
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 50%;
content: '';
}
&::before {
top: -140px;
left: -80px;
width: 420px;
height: 420px;
}
&::after {
right: -120px;
bottom: -160px;
width: 460px;
height: 460px;
}
}
.hero-fruits {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.fruit {
position: absolute;
filter: drop-shadow(0 18px 24px rgb(0 0 0 / 28%));
}
.cherry {
top: 16%;
right: 14%;
width: 150px;
}
.mango {
top: 30%;
left: 38%;
width: 210px;
}
.banana {
top: 54%;
left: 12%;
width: 200px;
transform: rotate(-8deg);
}
.hero-brand {
@@ -113,13 +243,14 @@ const themeChange = () => {
.hero-copy {
position: relative;
z-index: 1;
max-width: 460px;
max-width: 420px;
h1 {
margin: 10px 0 12px;
font-size: 40px;
font-weight: 700;
line-height: 1.2;
margin: 12px 0 0;
font-size: 44px;
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.03em;
color: #fff;
}
}
@@ -191,8 +322,12 @@ const themeChange = () => {
padding: 16px 24px 20px;
}
.hero-fruits {
display: none;
}
.hero-copy h1 {
margin: 6px 0 8px;
margin: 6px 0 0;
font-size: 28px;
}
+5 -3
View File
@@ -132,7 +132,7 @@ const remember = ref(userStore.getRememberMe)
const initLoginInfo = () => {
const loginInfo = userStore.getLoginInfo
if (loginInfo) {
setValues({ username: loginInfo.username })
setValues({ username: loginInfo.username, password: loginInfo.password })
}
}
onMounted(() => {
@@ -169,10 +169,12 @@ const signIn = async () => {
const res = await loginApi(formData)
if (res) {
// 是否记住我
// 是否记住我:勾选时把账号密码一起持久化到 localStorage,
// 下次打开登录页自动回填,免重复输入。
if (unref(remember)) {
userStore.setLoginInfo({
username: formData.username
username: formData.username,
password: formData.password
})
} else {
userStore.setLoginInfo(undefined)
@@ -0,0 +1,616 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac'
import { dateCell, formatCount } from '@/utils/format'
import {
createRewardTaskApi,
getRewardRulesApi,
manualRewardGrantApi,
pageRewardGrantsApi,
pageRewardTasksApi,
saveRewardRulesApi,
updateRewardTaskApi,
updateRewardTaskStatusApi
} from '@/api/admin/reward'
const { can } = useRbac()
const activeTab = ref('tasks')
const typeOptions = [
['DAILY_CHECK_IN', '每日签到'],
['WATCH_DURATION', '观看时长'],
['WATCH_EPISODES', '观看集数'],
['FIRST_LOGIN', '首次登录'],
['COMPLETE_PROFILE', '完善资料'],
['FIRST_PURCHASE', '首次购买'],
['FOLLOW_SOCIAL', '关注社交账号'],
['INVITE_FRIEND', '邀请好友'],
['CUSTOM_LINK', '自定义任务']
]
const cycleOptions = [
['ONCE', '一次性'],
['DAILY', '每日'],
['WEEKLY', '每周']
]
const sectionOptions = [
['CHECK_IN', '签到奖励'],
['DAILY', '每日任务'],
['NEWBIE', '新手任务'],
['ACTIVITY', '活动任务']
]
const actionOptions = [
['NONE', '无动作'],
['OPEN_HOME', '打开首页'],
['OPEN_PROFILE', '打开个人资料'],
['OPEN_WALLET', '打开钱包'],
['OPEN_URL', '打开链接']
]
const tasks = reactive({ rows: [], total: 0, page: 1, size: 10, loading: false })
const filters = reactive({
keyword: '',
type: undefined,
sectionCode: undefined,
status: undefined
})
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const emptyForm = () => ({
code: '',
sectionCode: 'DAILY',
type: 'WATCH_DURATION',
verifyMode: 'SERVER',
targetValue: 1,
rewardCoin: 10,
cycleType: 'DAILY',
cycleLimit: 1,
actionType: 'OPEN_HOME',
actionUrl: '',
iconUrl: '',
titleId: '',
titleEn: '',
titleZh: '',
subtitleId: '',
subtitleEn: '',
subtitleZh: '',
ctaId: 'Mulai',
ctaEn: 'Start',
ctaZh: '去完成',
sectionTitleId: 'Tugas harian',
sectionTitleEn: 'Daily tasks',
sectionTitleZh: '每日任务',
configJson: '',
sort: 0,
startAt: null,
endAt: null
})
const form = reactive(emptyForm())
async function loadTasks() {
tasks.loading = true
try {
const res = await pageRewardTasksApi({
pageIndex: tasks.page,
pageSize: tasks.size,
keyword: filters.keyword.trim() || undefined,
type: filters.type,
sectionCode: filters.sectionCode,
status: filters.status
})
tasks.rows = res.data?.list || []
tasks.total = res.data?.total || 0
} finally {
tasks.loading = false
}
}
function searchTasks() {
tasks.page = 1
loadTasks()
}
function resetFilters() {
Object.assign(filters, {
keyword: '',
type: undefined,
sectionCode: undefined,
status: undefined
})
searchTasks()
}
function openCreate() {
editingId.value = null
Object.assign(form, emptyForm(), { sort: tasks.total + 1 })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, emptyForm(), row)
dialogVisible.value = true
}
function validateTask() {
if (!form.code.trim() || !form.titleId.trim() || !form.sectionTitleId.trim())
return '请填写编码、印尼语标题和分组标题'
if (form.targetValue <= 0 || form.rewardCoin < 0) return '目标值必须大于 0,奖励金币不能小于 0'
return ''
}
async function saveTask() {
const invalid = validateTask()
if (invalid) return ElMessage.warning(invalid)
saving.value = true
try {
const payload = {
...form,
code: form.code.trim(),
titleId: form.titleId.trim(),
sectionTitleId: form.sectionTitleId.trim()
}
if (editingId.value) await updateRewardTaskApi(editingId.value, payload)
else await createRewardTaskApi(payload)
ElMessage.success(editingId.value ? '任务已更新' : '任务已创建,默认下线')
dialogVisible.value = false
await loadTasks()
} finally {
saving.value = false
}
}
async function toggleTask(row) {
await updateRewardTaskStatusApi(row.id, row.status === 1 ? 0 : 1)
await loadTasks()
}
const rules = ref([])
const rulesLoading = ref(false)
const rulesSaving = ref(false)
async function loadRules() {
rulesLoading.value = true
try {
rules.value = (await getRewardRulesApi()).data || []
} finally {
rulesLoading.value = false
}
}
async function saveRules() {
rulesSaving.value = true
try {
await saveRewardRulesApi(
rules.value.map(({ dayIndex, rewardCoin }) => ({ dayIndex, rewardCoin }))
)
ElMessage.success('签到规则已保存')
} finally {
rulesSaving.value = false
}
}
const grants = reactive({
rows: [],
total: 0,
page: 1,
size: 10,
loading: false,
userId: '',
status: undefined
})
const grantVisible = ref(false)
const grantSaving = ref(false)
const grantForm = reactive({ userId: '', taskId: '', amount: 10, idempotentKey: '', reason: '' })
async function loadGrants() {
grants.loading = true
try {
const res = await pageRewardGrantsApi({
pageIndex: grants.page,
pageSize: grants.size,
userId: grants.userId || undefined,
status: grants.status
})
grants.rows = res.data?.list || []
grants.total = res.data?.total || 0
} finally {
grants.loading = false
}
}
function queryGrants() {
grants.page = 1
loadGrants()
}
function openGrant() {
Object.assign(grantForm, {
userId: grants.userId,
taskId: '',
amount: 10,
idempotentKey: '',
reason: ''
})
grantVisible.value = true
}
async function confirmGrant() {
if (!grantForm.userId || grantForm.amount <= 0)
return ElMessage.warning('请填写用户 ID 和补发金币')
grantSaving.value = true
try {
await manualRewardGrantApi({
...grantForm,
taskId: grantForm.taskId || undefined,
idempotentKey: grantForm.idempotentKey || undefined
})
ElMessage.success('奖励已通过钱包流水补发')
grantVisible.value = false
await loadGrants()
} finally {
grantSaving.value = false
}
}
function handleTab(name) {
if (name === 'rules' && !rules.value.length) loadRules()
if (name === 'grants' && !grants.rows.length) loadGrants()
}
function optionLabel(options, value) {
return options.find((item) => item[0] === value)?.[1] || value || '-'
}
onMounted(loadTasks)
</script>
<template>
<PageHeading
eyebrow="订单与权益"
title="奖励任务"
description="配置奖励页任务、签到规则和发放记录,所有金币变动均写钱包流水。"
>
<el-button
v-if="activeTab === 'tasks' && can('reward:update')"
type="primary"
@click="openCreate"
>新建任务</el-button
>
<el-button
v-if="activeTab === 'grants' && can('reward:grant')"
type="primary"
@click="openGrant"
>人工补发</el-button
>
</PageHeading>
<section class="panel reward-panel">
<el-tabs v-model="activeTab" @tab-change="handleTab">
<el-tab-pane label="任务配置" name="tasks">
<div class="reward-toolbar">
<el-input
v-model="filters.keyword"
clearable
placeholder="任务编码 / 标题"
@keyup.enter="searchTasks"
/>
<el-select v-model="filters.type" clearable placeholder="全部类型" @change="searchTasks"
><el-option
v-for="item in typeOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]"
/></el-select>
<el-select
v-model="filters.sectionCode"
clearable
placeholder="全部分组"
@change="searchTasks"
><el-option
v-for="item in sectionOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]"
/></el-select>
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="searchTasks"
><el-option label="已上线" :value="1" /><el-option label="已下线" :value="0"
/></el-select>
<el-button @click="searchTasks">查询</el-button
><el-button @click="resetFilters">重置</el-button>
</div>
<el-table
v-loading="tasks.loading"
:data="tasks.rows"
row-key="id"
empty-text="暂无奖励任务"
>
<el-table-column prop="code" label="任务编码" min-width="150" />
<el-table-column label="标题" min-width="180"
><template #default="{ row }"
><strong>{{ row.titleId }}</strong
><div class="sub-copy">{{ row.titleZh || row.titleEn }}</div></template
></el-table-column
>
<el-table-column label="类型" width="130"
><template #default="{ row }">{{
optionLabel(typeOptions, row.type)
}}</template></el-table-column
>
<el-table-column label="分组" width="110"
><template #default="{ row }">{{
optionLabel(sectionOptions, row.sectionCode)
}}</template></el-table-column
>
<el-table-column label="目标 / 奖励" width="130"
><template #default="{ row }"
>{{ formatCount(row.targetValue) }} / +{{ formatCount(row.rewardCoin) }}</template
></el-table-column
>
<el-table-column label="周期" width="100"
><template #default="{ row }">{{
optionLabel(cycleOptions, row.cycleType)
}}</template></el-table-column
>
<el-table-column label="状态" width="90"
><template #default="{ row }"
><el-tag :type="row.status === 1 ? 'success' : 'info'">{{
row.statusText
}}</el-tag></template
></el-table-column
>
<el-table-column label="操作" width="170" fixed="right"
><template #default="{ row }"
><el-button v-if="can('reward:update')" link type="primary" @click="openEdit(row)"
>编辑</el-button
><el-button
v-if="can('reward:update')"
link
:type="row.status === 1 ? 'danger' : 'success'"
@click="toggleTask(row)"
>{{ row.status === 1 ? '下线' : '上线' }}</el-button
></template
></el-table-column
>
</el-table>
<el-pagination
v-model:current-page="tasks.page"
v-model:page-size="tasks.size"
:total="tasks.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadTasks"
@size-change="searchTasks"
/>
</el-tab-pane>
<el-tab-pane label="签到规则" name="rules">
<div v-loading="rulesLoading" class="rule-grid"
><div v-for="day in rules" :key="day.dayIndex" class="rule-card"
><span>第 {{ day.dayIndex }} 天</span
><el-input-number v-model="day.rewardCoin" :min="0" /><small>金币</small></div
></div
>
<el-button
v-if="can('reward:checkin')"
type="primary"
:loading="rulesSaving"
@click="saveRules"
>保存签到规则</el-button
>
</el-tab-pane>
<el-tab-pane label="发放记录" name="grants">
<div class="reward-toolbar"
><el-input
v-model="grants.userId"
clearable
placeholder="用户 ID"
@keyup.enter="queryGrants"
/><el-select
v-model="grants.status"
clearable
placeholder="全部状态"
@change="queryGrants"
><el-option label="已发放" value="GRANTED" /><el-option
label="待处理"
value="PENDING" /><el-option label="失败" value="FAILED" /></el-select
><el-button @click="queryGrants">查询</el-button></div
>
<el-table
v-loading="grants.loading"
:data="grants.rows"
row-key="id"
empty-text="暂无发放记录"
>
<el-table-column prop="userId" label="用户 ID" min-width="160" /><el-table-column
prop="taskId"
label="任务 ID"
min-width="160"
/><el-table-column label="金币" width="100"
><template #default="{ row }">+{{ formatCount(row.amount) }}</template></el-table-column
><el-table-column prop="source" label="来源" width="90" /><el-table-column
prop="status"
label="状态"
width="100"
/><el-table-column
prop="idempotentKey"
label="幂等键"
min-width="240"
show-overflow-tooltip
/><el-table-column label="发放时间" width="180"
><template #default="{ row }">{{
dateCell(row.grantedAt || row.createdAt)
}}</template></el-table-column
>
</el-table>
<el-pagination
v-model:current-page="grants.page"
v-model:page-size="grants.size"
:total="grants.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadGrants"
@size-change="queryGrants"
/>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑奖励任务' : '新建奖励任务'"
width="780px"
>
<el-form label-position="top" class="task-form"
><el-row :gutter="16"
><el-col :span="8"
><el-form-item label="任务编码"
><el-input v-model="form.code" :disabled="Boolean(editingId)" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="任务类型"
><el-select v-model="form.type"
><el-option
v-for="item in typeOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]" /></el-select></el-form-item></el-col
><el-col :span="8"
><el-form-item label="分组"
><el-select v-model="form.sectionCode"
><el-option
v-for="item in sectionOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]" /></el-select></el-form-item></el-col
></el-row>
<el-row :gutter="16"
><el-col :span="6"
><el-form-item label="目标值"
><el-input-number v-model="form.targetValue" :min="1" /></el-form-item></el-col
><el-col :span="6"
><el-form-item label="奖励金币"
><el-input-number v-model="form.rewardCoin" :min="0" /></el-form-item></el-col
><el-col :span="6"
><el-form-item label="周期"
><el-select v-model="form.cycleType"
><el-option
v-for="item in cycleOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]" /></el-select></el-form-item></el-col
><el-col :span="6"
><el-form-item label="排序"
><el-input-number v-model="form.sort" :min="0" /></el-form-item></el-col
></el-row>
<el-divider content-position="left">多语言文案</el-divider
><el-row :gutter="16"
><el-col :span="8"
><el-form-item label="印尼语标题"
><el-input v-model="form.titleId" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="英文标题"><el-input v-model="form.titleEn" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="中文标题"><el-input v-model="form.titleZh" /></el-form-item></el-col
></el-row>
<el-row :gutter="16"
><el-col :span="8"
><el-form-item label="印尼语说明"
><el-input v-model="form.subtitleId" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="英文说明"
><el-input v-model="form.subtitleEn" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="中文说明"
><el-input v-model="form.subtitleZh" /></el-form-item></el-col
></el-row>
<el-row :gutter="16"
><el-col :span="8"
><el-form-item label="印尼语分组标题"
><el-input v-model="form.sectionTitleId" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="英文分组标题"
><el-input v-model="form.sectionTitleEn" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="中文分组标题"
><el-input v-model="form.sectionTitleZh" /></el-form-item></el-col
></el-row>
<el-divider content-position="left">动作与扩展</el-divider
><el-row :gutter="16"
><el-col :span="8"
><el-form-item label="完成按钮"><el-input v-model="form.ctaId" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="动作"
><el-select v-model="form.actionType"
><el-option
v-for="item in actionOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]" /></el-select></el-form-item></el-col
><el-col :span="8"
><el-form-item label="动作链接"
><el-input v-model="form.actionUrl" /></el-form-item></el-col></el-row
><el-form-item label="扩展配置 JSON"
><el-input v-model="form.configJson" type="textarea" :rows="3"
/></el-form-item>
</el-form>
<template #footer
><el-button @click="dialogVisible = false">取消</el-button
><el-button type="primary" :loading="saving" @click="saveTask">保存</el-button></template
>
</el-dialog>
<el-dialog v-model="grantVisible" title="人工补发奖励" width="520px"
><el-form label-position="top"
><el-form-item label="用户 ID"><el-input v-model="grantForm.userId" /></el-form-item
><el-form-item label="任务 ID(可选)"><el-input v-model="grantForm.taskId" /></el-form-item
><el-form-item label="补发金币"
><el-input-number v-model="grantForm.amount" :min="1" /></el-form-item
><el-form-item label="幂等键(重复补发时必须相同)"
><el-input v-model="grantForm.idempotentKey" /></el-form-item
><el-form-item label="原因"><el-input v-model="grantForm.reason" /></el-form-item></el-form
><template #footer
><el-button @click="grantVisible = false">取消</el-button
><el-button type="primary" :loading="grantSaving" @click="confirmGrant"
>确认补发</el-button
></template
></el-dialog
>
</template>
<style scoped>
.reward-panel {
padding: 20px;
}
.reward-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.reward-toolbar .el-input,
.reward-toolbar .el-select {
width: 180px;
}
.sub-copy {
color: var(--el-text-color-secondary);
font-size: 12px;
margin-top: 4px;
}
.rule-grid {
display: grid;
grid-template-columns: repeat(7, minmax(100px, 1fr));
gap: 12px;
margin: 8px 0 20px;
}
.rule-card {
display: grid;
gap: 8px;
padding: 16px;
border: 1px solid var(--el-border-color-light);
border-radius: 12px;
background: var(--el-fill-color-lighter);
}
.rule-card small {
color: var(--el-text-color-secondary);
}
.task-form :deep(.el-select),
.task-form :deep(.el-input-number) {
width: 100%;
}
.el-pagination {
justify-content: flex-end;
margin-top: 18px;
}
@media (max-width: 1100px) {
.rule-grid {
grid-template-columns: repeat(4, 1fr);
}
}
</style>
+93 -66
View File
@@ -5,6 +5,8 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { AppIcons } from '@/constants/icons'
import { pageDramasApi } from '@/api/admin/drama'
import { getDashboardOverviewApi, getDashboardTrendApi } from '@/api/admin/dashboard'
import DashboardMetricWall from '@/views/dashboard/DashboardMetricWall.vue'
import { useRbac } from '@/stores/rbac'
import { formatCount } from '@/utils/format'
import { statusClass } from '@/utils/status'
@@ -15,6 +17,8 @@ const { can } = useRbac()
const loading = ref(false)
const dramas = ref([])
const total = ref(0)
const overview = ref(null)
const trend = ref(null)
const totalViews = computed(() =>
dramas.value.reduce((sum, item) => sum + Number(item.views || 0), 0)
@@ -25,6 +29,12 @@ const reviewingCount = computed(
const publishedCount = computed(
() => dramas.value.filter((item) => item.status === 'published').length
)
const metricFallback = computed(() => ({
'content.dramaTotal': total.value,
'content.dramaReviewing': reviewingCount.value,
'content.dramaPublished': publishedCount.value,
'playback.viewTotal': totalViews.value
}))
const recentDramas = computed(() =>
[...dramas.value]
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
@@ -34,10 +44,16 @@ const recentDramas = computed(() =>
onMounted(async () => {
loading.value = true
try {
const res = await pageDramasApi({ pageIndex: 1, pageSize: 200 })
const [res, board, trendRes] = await Promise.all([
pageDramasApi({ pageIndex: 1, pageSize: 200 }),
getDashboardOverviewApi().catch(() => null),
getDashboardTrendApi().catch(() => null)
])
// 接口失败时 axios 拦截器会把业务错误吞成 undefined,这里不能让页面跟着崩
dramas.value = res?.data?.list || []
total.value = res?.data?.total || 0
overview.value = board?.data || null
trend.value = trendRes?.data || null
} finally {
loading.value = false
}
@@ -45,72 +61,83 @@ onMounted(async () => {
</script>
<template>
<div class="page-heading"
><div><div class="eyebrow">内容概览</div><h1>工作台</h1><p>查看短剧内容与近期处理事项。</p></div
><el-button
v-if="can('drama:create')"
type="primary"
class="primary-button"
@click="router.push({ name: 'dramas' })"
><AppIcon :icon="AppIcons.add" />上传新短剧</el-button
></div
>
<div v-loading="loading" class="stats-grid">
<div class="stat-card"
><div class="stat-top"
><span>短剧总数</span
><span class="stat-icon"><AppIcon :icon="AppIcons.content" /></span></div
><div class="stat-value">{{ total }} <small>部</small></div></div
>
<div class="stat-card"
><div class="stat-top"
><span>累计播放量</span
><span class="stat-icon"><AppIcon :icon="AppIcons.videoPlay" /></span></div
><div class="stat-value">{{ formatCount(totalViews) }}</div></div
>
<div class="stat-card"
><div class="stat-top"
><span>待审核内容</span
><span class="stat-icon"><AppIcon :icon="AppIcons.clock" /></span></div
><div class="stat-value">{{ reviewingCount }} <small>项</small></div></div
>
<div class="stat-card"
><div class="stat-top"
><span>已上线</span
><span class="stat-icon"><AppIcon :icon="AppIcons.checkCircle" /></span></div
><div class="stat-value">{{ publishedCount }} <small>部</small></div></div
>
</div>
<section class="panel recent-panel"
><div class="panel-heading"
><div><h2>最近更新</h2><p>短剧内容与发布状态</p></div
><el-button text class="view-all" @click="router.push({ name: 'dramas' })"
>短剧管理</el-button
<div class="workbench">
<div class="page-heading"
><div
><div class="eyebrow">内容概览</div><h1>工作台</h1
><p>用户、设备、留存与短剧内容总览。</p></div
><el-button
v-if="can('drama:create')"
type="primary"
class="primary-button"
@click="router.push({ name: 'dramas' })"
><AppIcon :icon="AppIcons.add" />上传新短剧</el-button
></div
>
<el-table
<DashboardMetricWall
v-loading="loading"
:data="recentDramas"
row-key="id"
empty-text="暂无短剧数据"
class="dashboard-el-table"
><el-table-column prop="title" label="短剧名称" min-width="280" /><el-table-column
prop="genre"
label="类型"
width="150" /><el-table-column label="集数" width="120"
><template #default="scope">{{ scope?.row?.episodes }} 集</template></el-table-column
><el-table-column label="播放量" width="140"
><template #default="scope">{{ formatCount(scope?.row?.views) }}</template></el-table-column
><el-table-column label="状态" width="130"
><template #default="scope"
><el-tag
v-if="scope?.row"
:class="['status-badge', statusClass(scope.row.status)]"
effect="light"
>{{ scope.row.statusText }}</el-tag
></template
></el-table-column
><el-table-column prop="updatedAt" label="更新时间" width="180"
/></el-table>
</section>
:overview="overview"
:trend="trend"
:fallback="metricFallback"
/>
<section class="panel recent-panel"
><div class="panel-heading"
><div><h2>最近更新</h2><p>短剧内容与发布状态</p></div
><el-button text class="view-all" @click="router.push({ name: 'dramas' })"
>短剧管理</el-button
></div
>
<el-table
v-loading="loading"
:data="recentDramas"
row-key="id"
empty-text="暂无短剧数据"
class="dashboard-el-table"
><el-table-column prop="title" label="短剧名称" min-width="280" /><el-table-column
prop="genre"
label="类型"
width="150" /><el-table-column label="集数" width="120"
><template #default="scope">{{ scope?.row?.episodes }} 集</template></el-table-column
><el-table-column label="播放量" width="140"
><template #default="scope">{{
formatCount(scope?.row?.views)
}}</template></el-table-column
><el-table-column label="状态" width="130"
><template #default="scope"
><el-tag
v-if="scope?.row"
:class="['status-badge', statusClass(scope.row.status)]"
effect="light"
>{{ scope.row.statusText }}</el-tag
></template
></el-table-column
><el-table-column prop="updatedAt" label="更新时间" width="180"
/></el-table>
</section>
</div>
</template>
<style scoped>
.workbench {
display: flex;
flex-direction: column;
min-width: 0;
}
.workbench .page-heading {
flex: none;
margin-bottom: 12px;
}
.workbench :deep(.screen) {
flex: 1;
min-height: 0;
}
.recent-panel {
flex: none;
max-height: 220px;
margin-top: 12px;
overflow: auto;
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<!-- Author: fruit.com.cn -->
<!-- 转码通知页:按转码中、成功、失败查看短剧和集数。 -->
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import PageHeading from '@/views/components/PageHeading.vue'
import {
getTranscodeNoticesApi,
type TranscodeNotice,
type TranscodeNoticeItem
} from '@/api/admin/transcodeNotice'
import { formatDateTime } from '@/utils/format'
import { markTranscodeNoticeSeen } from '@/utils/transcodeNoticeSeen'
const loading = ref(false)
const tab = ref('RUNNING')
const notice = ref<TranscodeNotice | null>(null)
const rows = computed<TranscodeNoticeItem[]>(() => {
if (tab.value === 'SUCCESS') return notice.value?.successList || []
if (tab.value === 'FAILED') return notice.value?.failedList || []
return notice.value?.runningList || []
})
function episodeText(row: TranscodeNoticeItem) {
const no = row.episodeNo ? `第${row.episodeNo}集` : ''
if (!row.episodeTitle || row.episodeTitle === no) return no || '集数未知'
return no ? `${no} ${row.episodeTitle}` : row.episodeTitle
}
async function load() {
loading.value = true
try {
const res = await getTranscodeNoticesApi()
notice.value = res.data || null
if (notice.value) {
markTranscodeNoticeSeen({
running: Number(notice.value.running || 0),
success: Number(notice.value.success || 0),
failed: Number(notice.value.failed || 0)
})
}
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<PageHeading
eyebrow="通知"
title="转码通知"
description="查看转码中、转码成功和转码失败的短剧与集数。"
>
<el-button :loading="loading" @click="load">刷新</el-button>
</PageHeading>
<section class="panel notice-panel">
<el-tabs v-model="tab">
<el-tab-pane label="转码中" name="RUNNING" />
<el-tab-pane label="转码成功" name="SUCCESS" />
<el-tab-pane label="转码失败" name="FAILED" />
</el-tabs>
<el-table
v-loading="loading"
:data="rows"
row-key="id"
border
class="drama-el-table"
empty-text="暂无通知"
>
<el-table-column prop="dramaTitle" label="短剧" min-width="220" show-overflow-tooltip />
<el-table-column label="集数" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ episodeText(row) }}</template>
</el-table-column>
<el-table-column label="进度" width="100">
<template #default="{ row }">{{ row.progress ?? 0 }}%</template>
</el-table-column>
<el-table-column
v-if="tab === 'FAILED'"
prop="errorMsg"
label="失败原因"
min-width="220"
show-overflow-tooltip
/>
<el-table-column label="更新时间" width="180">
<template #default="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
</el-table-column>
</el-table>
</section>
</template>
<style scoped>
.notice-panel {
padding: 8px 16px 16px;
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { computed } from 'vue'
/**
* 后台各列表共用的「用户」单元格。
*
* 口径:已登录显示昵称 + `UID xxx`;未登录显示「游客」标签 + `设备 xxx`。
* 两行都带标签,避免一串随机字母看不出是什么。平台由独立的「系统」列展示。
*/
const props = defineProps({
row: { type: Object, required: true }
})
const isGuest = computed(() => props.row?.userId == null)
</script>
<template>
<div class="app-user-meta">
<template v-if="isGuest">
<el-tag type="info" size="small">游客</el-tag>
<span class="app-user-uid app-user-device" :title="row.deviceId || ''">
设备: {{ row.deviceId || '—' }}
</span>
</template>
<template v-else>
<span class="app-user-name" :title="row.nickname || ''">
昵称: {{ row.nickname || '未设置昵称' }}
</span>
<span class="app-user-uid">UID: {{ row.uid || `#${row.userId}` }}</span>
</template>
</div>
</template>
<style scoped>
.app-user-device {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+75
View File
@@ -0,0 +1,75 @@
<!-- Author: fruit.com.cn -->
<!-- 先勾选语言,再填写对应文案。中文始终保留。 -->
<script setup>
import { CONTENT_LANGUAGES, languageLabel } from '@/constants/contentLanguages'
const props = defineProps({
languages: { type: Array, required: true },
locales: { type: Object, required: true },
label: { type: String, default: '名称' },
maxlength: { type: Number, default: 64 },
multiline: { type: Boolean, default: false },
showLanguages: { type: Boolean, default: true },
required: { type: Boolean, default: true }
})
const emit = defineEmits(['update:languages', 'update:locales'])
function changeLanguages(codes) {
const next = codes.includes('zh') ? codes : ['zh', ...codes]
const locales = { ...props.locales }
next.forEach((code) => {
if (locales[code] == null) locales[code] = ''
})
emit('update:locales', locales)
emit('update:languages', next)
}
function changeLocale(code, value) {
emit('update:locales', { ...props.locales, [code]: value })
}
</script>
<template>
<el-form-item v-if="showLanguages" label="语言" required>
<el-select
:model-value="languages"
multiple
placeholder="选择语言"
style="width: 100%"
@change="changeLanguages"
>
<el-option
v-for="item in CONTENT_LANGUAGES"
:key="item.code"
:label="item.label"
:value="item.code"
:disabled="item.code === 'zh'"
/>
</el-select>
</el-form-item>
<el-form-item
v-for="code in languages"
:key="label + code"
:label="languageLabel(code) + label"
:required="required"
>
<el-input
v-if="!multiline"
:model-value="locales[code]"
@update:model-value="changeLocale(code, $event)"
:maxlength="maxlength"
:placeholder="'请填写' + languageLabel(code) + label"
/>
<el-input
v-else
:model-value="locales[code]"
@update:model-value="changeLocale(code, $event)"
type="textarea"
:rows="2"
:maxlength="maxlength"
show-word-limit
:placeholder="'请填写' + languageLabel(code) + label"
/>
</el-form-item>
</template>
@@ -16,6 +16,7 @@ import {
import { pageDramasApi } from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac'
import { uploadFile } from '@/utils/uploader'
import { BANNER_IMAGE, compressForPreset } from '@/views/contentManagement/upload/imagePresets'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
@@ -218,12 +219,13 @@ async function uploadImage(options) {
imagePercent.value = 0
imageError.value = ''
try {
const asset = await uploadFile(options.file, {
const file = await compressForPreset(options.file, BANNER_IMAGE)
const asset = await uploadFile(file, {
bizType: 'BANNER',
onProgress: (percent) => (imagePercent.value = percent)
})
form.imageUrl = asset.url
imageFileName.value = options.file.name
imageFileName.value = file.name
await resetValidation()
ElMessage.success('图片已上传')
} catch {
@@ -467,7 +469,9 @@ onMounted(async () => {
{{ imageDisplayName }}
</span>
</div>
<p class="banner-image-hint">支持 JPG、PNG、WebP,单张不超过 5MB。</p>
<p class="banner-image-hint"
>{{ BANNER_IMAGE.hint }};支持 JPG、PNG、WebP,单张不超过 5MB。</p
>
<el-alert
v-if="imageError"
:title="imageError"
+232 -212
View File
@@ -4,8 +4,15 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VueDraggable } from 'vue-draggable-plus'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
import {
languageCodes,
languageLabel,
localesFromColumns,
payloadLocales
} from '@/constants/contentLanguages'
import {
bindClassificationDramasApi,
createClassificationApi,
@@ -13,11 +20,14 @@ import {
listClassificationDramaCandidatesApi,
listClassificationDramasApi,
listClassificationsApi,
saveRankingItemsApi,
unbindClassificationDramaApi,
updateClassificationApi
} from '@/api/admin/dramaClassification'
import { statusClass } from '@/utils/status'
import { useRbac } from '@/stores/rbac'
import ClassificationDramaDialogs from './ClassificationDramaDialogs.vue'
import ClassificationSideList from './ClassificationSideList.vue'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
@@ -35,6 +45,7 @@ const activeId = ref(null)
const activeClassification = computed(
() => classifications.value.find((item) => item.id === activeId.value) || null
)
const isRanking = computed(() => activeClassification.value?.layoutType === 'RANKING')
const loading = ref(false)
const dramas = ref([])
@@ -42,11 +53,20 @@ const total = ref(0)
const pageIndex = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const rankingDirty = ref(false)
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const form = reactive({ name: '', sort: 1, status: 1 })
const languages = ref(['zh'])
const emptyForm = () => ({
locales: { zh: '' },
code: '',
layoutType: 'GRID',
sort: 1,
status: 1
})
const form = reactive(emptyForm())
const candidateVisible = ref(false)
const candidateLoading = ref(false)
@@ -81,10 +101,11 @@ async function loadDramas() {
const res = await listClassificationDramasApi(activeId.value, {
keyword: keyword.value || undefined,
pageIndex: pageIndex.value,
pageSize: pageSize.value
pageSize: isRanking.value ? 50 : pageSize.value
})
dramas.value = res.data?.list || []
total.value = res.data?.total || 0
rankingDirty.value = false
} finally {
loading.value = false
}
@@ -95,6 +116,7 @@ function selectClassification(row) {
activeId.value = row.id
pageIndex.value = 1
keyword.value = ''
rankingDirty.value = false
loadDramas()
}
@@ -110,25 +132,45 @@ function changePage(value) {
function openCreate() {
editingId.value = null
languages.value = ['zh']
const maxSort = classifications.value.reduce((max, item) => Math.max(max, item.sort || 0), 0)
Object.assign(form, { name: '', sort: maxSort + 1, status: 1 })
Object.assign(form, emptyForm(), { sort: maxSort + 1 })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, { name: row.name, sort: row.sort ?? 0, status: row.status ?? 1 })
const locales = localesFromColumns(row.locales, row.name, row.nameEn, row.nameId)
languages.value = languageCodes(locales)
Object.assign(form, {
locales,
code: row.code || '',
layoutType: row.layoutType || 'GRID',
sort: row.sort ?? 0,
status: row.status ?? 1
})
dialogVisible.value = true
}
async function saveClassification() {
if (!form.name.trim()) {
ElMessage.warning('请填写分类名称')
const locales = payloadLocales(form.locales, languages.value)
const missing = languages.value.find((code) => !locales[code])
if (!locales.zh || missing) {
ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请填写中文名称')
return
}
saving.value = true
try {
const payload = { name: form.name.trim(), sort: form.sort, status: form.status }
const payload = {
name: locales.zh,
locales,
code:
form.code ||
(form.layoutType === 'RANKING' && !editingId.value ? 'RECOMMEND_RANKING' : undefined),
layoutType: form.layoutType,
sort: form.sort,
status: form.status
}
if (editingId.value) {
await updateClassificationApi(editingId.value, payload)
ElMessage.success('分类已更新')
@@ -173,7 +215,12 @@ async function removeDrama(row) {
} catch {
return
}
await unbindClassificationDramaApi(activeId.value, row.id)
if (isRanking.value) {
const remaining = dramas.value.filter((item) => item.id !== row.id).map((item) => item.id)
await saveRankingItemsApi(activeId.value, remaining)
} else {
await unbindClassificationDramaApi(activeId.value, row.id)
}
ElMessage.success('已移出')
await Promise.all([loadDramas(), loadClassifications()])
}
@@ -223,11 +270,23 @@ async function confirmBind() {
}
binding.value = true
try {
const res = await bindClassificationDramasApi(
activeId.value,
selectedDramas.value.map((item) => item.id)
)
ElMessage.success(`已关联 ${res.data ?? selectedDramas.value.length} 部短剧`)
if (isRanking.value) {
const existingIds = dramas.value.map((item) => item.id)
const selectedIds = selectedDramas.value.map((item) => item.id)
const nextIds = [...new Set([...existingIds, ...selectedIds])]
if (nextIds.length > 50) {
ElMessage.warning('推荐榜最多 50 部短剧')
return
}
await saveRankingItemsApi(activeId.value, nextIds)
ElMessage.success(`推荐榜已增加 ${selectedIds.length} 部短剧`)
} else {
const res = await bindClassificationDramasApi(
activeId.value,
selectedDramas.value.map((item) => item.id)
)
ElMessage.success(`已关联 ${res.data ?? selectedDramas.value.length} 部短剧`)
}
candidateVisible.value = false
await Promise.all([loadDramas(), loadClassifications()])
} finally {
@@ -235,6 +294,22 @@ async function confirmBind() {
}
}
function onRankingChange() {
dramas.value = dramas.value.map((item, index) => ({ ...item, position: index + 1 }))
rankingDirty.value = true
}
async function saveRankingOrder() {
if (!activeId.value || !dramas.value.length) return
await saveRankingItemsApi(
activeId.value,
dramas.value.map((item) => item.id)
)
rankingDirty.value = false
ElMessage.success('推荐榜顺序已保存')
await loadClassifications()
}
function formatTime(value) {
if (!value) return '—'
return String(value).replace('T', ' ').slice(0, 19)
@@ -247,7 +322,7 @@ onMounted(loadClassifications)
<PageHeading
eyebrow="内容管理"
title="分类管理"
description="分类是运营自定义的分组,与「类型」无关;一个分类可以关联任意多部短剧。"
description="普通分类使用剧目网格;推荐榜使用独立排行布局,并按后台配置顺序展示。"
>
<el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate">
<AppIcon :icon="AppIcons.add" />
@@ -256,57 +331,24 @@ onMounted(loadClassifications)
</PageHeading>
<div class="classification-layout">
<aside class="panel classification-layout__side">
<div class="classification-layout__side-title">全部分类</div>
<el-scrollbar height="560px">
<ul class="classification-list">
<li
v-for="item in classifications"
:key="item.id"
class="classification-list__item"
:class="{ 'is-active': item.id === activeId }"
@click="selectClassification(item)"
>
<div class="classification-list__main">
<span class="classification-list__name">{{ item.name }}</span>
<span class="classification-list__count">{{ item.dramaCount || 0 }}</span>
</div>
<div class="classification-list__actions" @click.stop>
<el-button
v-if="can('drama:update')"
text
circle
size="small"
@click="openEdit(item)"
>
<AppIcon :icon="AppIcons.edit" :size="15" />
</el-button>
<el-button
v-if="can('drama:delete')"
text
circle
size="small"
@click="confirmDelete(item)"
>
<AppIcon :icon="AppIcons.delete" :size="15" />
</el-button>
</div>
</li>
<li v-if="!classifications.length" class="classification-list__empty">
还没有分类,点右上角「新建分类」开始
</li>
</ul>
</el-scrollbar>
</aside>
<ClassificationSideList
:classifications="classifications"
:active-id="activeId"
@select="selectClassification"
@edit="openEdit"
@remove="confirmDelete"
/>
<section class="panel classification-layout__main">
<div class="classification-toolbar">
<div class="classification-toolbar__title">
{{ activeClassification?.name || '请选择分类' }}
<small>共 {{ total }} 部关联短剧</small>
<el-tag v-if="isRanking" size="small" type="warning">推荐榜</el-tag>
</div>
<div class="classification-toolbar__search">
<el-input
v-if="!isRanking"
v-model="keyword"
placeholder="搜索短剧名称"
clearable
@@ -314,7 +356,15 @@ onMounted(loadClassifications)
@keyup.enter="search"
@clear="search"
/>
<el-button type="primary" plain @click="search">查询</el-button>
<el-button v-if="!isRanking" type="primary" plain @click="search">查询</el-button>
<el-button
v-if="isRanking && can('drama:update')"
type="primary"
:disabled="!rankingDirty"
@click="saveRankingOrder"
>
保存排序
</el-button>
<el-button v-if="can('drama:update')" :disabled="!activeId" @click="openCandidates">
<AppIcon :icon="AppIcons.add" />
添加短剧
@@ -322,7 +372,41 @@ onMounted(loadClassifications)
</div>
</div>
<VueDraggable
v-if="isRanking"
v-model="dramas"
item-key="id"
handle=".ranking-row__drag"
class="ranking-list"
@end="onRankingChange"
>
<div v-for="(row, index) in dramas" :key="row.id" class="ranking-row">
<button class="ranking-row__drag" type="button" title="拖动调整排名">
<AppIcon :icon="AppIcons.drag" />
</button>
<span class="ranking-row__rank">{{ index + 1 }}</span>
<img v-if="row.cover" :src="row.cover" class="ranking-row__cover" alt="封面" />
<div v-else class="ranking-row__cover ranking-row__cover--empty">无</div>
<div class="ranking-row__content">
<strong>{{ row.title }}</strong>
<span class="status-dot" :class="statusClass(row.status)">
{{ STATUS_TEXT[row.status] || row.status }} · {{ row.totalEpisodes || 0 }} 集
</span>
</div>
<el-button
v-if="can('drama:update')"
type="danger"
size="small"
@click="removeDrama(row)"
>
移出
</el-button>
</div>
<el-empty v-if="!dramas.length" description="推荐榜还没有短剧" />
</VueDraggable>
<el-table
v-else
v-loading="loading"
:data="dramas"
row-key="id"
@@ -364,7 +448,7 @@ onMounted(loadClassifications)
</el-table-column>
</el-table>
<div class="classification-pagination">
<div v-if="!isRanking" class="classification-pagination">
<el-pagination
:current-page="pageIndex"
:page-size="pageSize"
@@ -376,77 +460,28 @@ onMounted(loadClassifications)
</section>
</div>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑分类' : '新建分类'" width="460px">
<el-form label-position="top">
<el-form-item label="分类名称" required>
<el-input v-model="form.name" maxlength="20" placeholder="例如:本周必看" />
</el-form-item>
<div class="classification-form-row">
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="999" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveClassification">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="candidateVisible" title="添加关联短剧" width="760px">
<div class="classification-toolbar__search" style="margin-bottom: 12px">
<el-input
v-model="candidateKeyword"
placeholder="搜索短剧名称"
clearable
style="width: 220px"
@keyup.enter="candidateSearch"
@clear="candidateSearch"
/>
<el-button type="primary" plain @click="candidateSearch">查询</el-button>
</div>
<el-table
v-loading="candidateLoading"
:data="candidates"
row-key="id"
border
max-height="380"
empty-text="没有可关联的短剧"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="48" />
<el-table-column prop="title" label="短剧名称" min-width="220" />
<el-table-column label="集数" width="90">
<template #default="{ row }">{{ row.totalEpisodes || 0 }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span class="status-dot" :class="statusClass(row.status)">
{{ STATUS_TEXT[row.status] || row.status }}
</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="90" />
</el-table>
<div class="classification-pagination">
<el-pagination
:current-page="candidatePage"
:page-size="candidatePageSize"
:total="candidateTotal"
layout="total, prev, pager, next"
@current-change="changeCandidatePage"
/>
</div>
<template #footer>
<el-button @click="candidateVisible = false">取消</el-button>
<el-button type="primary" :loading="binding" @click="confirmBind">
确定关联{{ selectedDramas.length ? `(${selectedDramas.length})` : '' }}
</el-button>
</template>
</el-dialog>
<ClassificationDramaDialogs
v-model:editor-visible="dialogVisible"
v-model:languages="languages"
v-model:candidate-visible="candidateVisible"
v-model:candidate-keyword="candidateKeyword"
:editing-id="editingId"
:form="form"
@update:form="Object.assign(form, $event)"
:saving="saving"
:candidate-loading="candidateLoading"
:candidates="candidates"
:candidate-page="candidatePage"
:candidate-page-size="candidatePageSize"
:candidate-total="candidateTotal"
:binding="binding"
:selected-count="selectedDramas.length"
@save="saveClassification"
@search="candidateSearch"
@page="changeCandidatePage"
@selection="onSelectionChange"
@confirm="confirmBind"
/>
</template>
<style scoped>
@@ -457,83 +492,6 @@ onMounted(loadClassifications)
align-items: start;
}
.classification-layout__side {
padding: 12px 0 8px;
}
.classification-layout__side-title {
padding: 0 16px 10px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.classification-list {
padding: 0;
margin: 0;
list-style: none;
}
.classification-list__item {
display: flex;
padding: 8px 12px 8px 16px;
color: var(--el-text-color-regular);
cursor: pointer;
border-left: 3px solid transparent;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.classification-list__item:hover {
background: var(--el-fill-color-light);
}
.classification-list__item.is-active {
font-weight: 600;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-left-color: var(--el-color-primary);
}
.classification-list__main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.classification-list__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.classification-list__count {
min-width: 24px;
padding: 0 6px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
text-align: center;
background: var(--el-fill-color);
border-radius: 9px;
}
.classification-list__actions {
display: none;
gap: 2px;
}
.classification-list__item:hover .classification-list__actions {
display: flex;
}
.classification-list__empty {
padding: 16px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
.classification-layout__main {
padding: 16px;
}
@@ -571,6 +529,74 @@ onMounted(loadClassifications)
border-radius: 4px;
}
.ranking-list {
display: grid;
gap: 1px;
overflow: hidden;
border: 1px solid var(--el-border-color);
}
.ranking-row {
display: grid;
grid-template-columns: 36px 48px 52px minmax(0, 1fr) auto;
min-height: 72px;
padding: 8px 12px;
background: var(--el-bg-color);
align-items: center;
gap: 12px;
}
.ranking-row + .ranking-row {
border-top: 1px solid var(--el-border-color-lighter);
}
.ranking-row__drag {
display: inline-flex;
width: 32px;
height: 32px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: grab;
background: transparent;
border: 0;
align-items: center;
justify-content: center;
}
.ranking-row__rank {
font-size: 24px;
font-weight: 700;
color: var(--el-text-color-primary);
text-align: center;
}
.ranking-row__cover {
width: 44px;
height: 58px;
object-fit: cover;
border-radius: 4px;
}
.ranking-row__cover--empty {
display: flex;
color: var(--el-text-color-placeholder);
background: var(--el-fill-color-light);
align-items: center;
justify-content: center;
}
.ranking-row__content {
display: grid;
min-width: 0;
gap: 6px;
}
.ranking-row__content strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.classification-cover--empty {
display: flex;
align-items: center;
@@ -585,10 +611,4 @@ onMounted(loadClassifications)
justify-content: flex-end;
margin-top: 14px;
}
.classification-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
</style>
@@ -4,6 +4,12 @@
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import {
languageCodes,
languageLabel,
localesFromColumns,
payloadLocales
} from '@/constants/contentLanguages'
import {
createDramaCategoryApi,
deleteDramaCategoryApi,
@@ -11,6 +17,7 @@ import {
updateDramaCategoryApi
} from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac'
import LocaleTextFields from '@/views/components/LocaleTextFields.vue'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
@@ -20,7 +27,8 @@ const list = ref([])
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const form = reactive({ name: '', sort: 1, status: 1 })
const languages = ref(['zh'])
const form = reactive({ locales: { zh: '' }, sort: 1, status: 1 })
async function load() {
loading.value = true
@@ -38,21 +46,29 @@ function nextSort() {
function openCreate() {
editingId.value = null
Object.assign(form, { name: '', sort: nextSort(), status: 1 })
languages.value = ['zh']
Object.assign(form, { locales: { zh: '' }, sort: nextSort(), status: 1 })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, { name: row.name, sort: row.sort ?? 0, status: row.status ?? 1 })
const locales = localesFromColumns(row.locales, row.name, row.nameEn, row.nameId)
languages.value = languageCodes(locales)
Object.assign(form, { locales, sort: row.sort ?? 0, status: row.status ?? 1 })
dialogVisible.value = true
}
async function saveCategory() {
if (!form.name.trim()) return ElMessage.warning('请填写类型名称')
const locales = payloadLocales(form.locales, languages.value)
const missing = languages.value.find((code) => !locales[code])
if (!locales.zh || missing) {
ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请填写中文名称')
return
}
saving.value = true
try {
const payload = { name: form.name.trim(), sort: form.sort, status: form.status }
const payload = { name: locales.zh, locales, sort: form.sort, status: form.status }
if (editingId.value) {
await updateDramaCategoryApi(editingId.value, payload)
ElMessage.success('类型已更新')
@@ -115,7 +131,20 @@ onMounted(load)
empty-text="还没有短剧类型"
>
<el-table-column type="index" label="序号" width="80" />
<el-table-column prop="name" label="类型名称" min-width="200" />
<el-table-column prop="name" label="类型名称" min-width="160" />
<el-table-column label="语言" min-width="180">
<template #default="{ row }">
<el-tag
v-for="code in languageCodes(
row.locales || localesFromColumns(null, row.name, row.nameEn, row.nameId)
)"
:key="code"
effect="light"
class="locale-tag"
>{{ languageLabel(code) }}</el-tag
>
</template>
</el-table-column>
<el-table-column label="短剧数" width="110">
<template #default="{ row }">{{ row.dramaCount || 0 }} 部</template>
</el-table-column>
@@ -146,11 +175,15 @@ onMounted(load)
</el-table>
</section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑类型' : '新建类型'" width="460px">
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑类型' : '新建类型'" width="560px">
<el-form label-position="top">
<el-form-item label="类型名称" required>
<el-input v-model="form.name" maxlength="20" placeholder="例如:都市情感" />
</el-form-item>
<LocaleTextFields
v-model:languages="languages"
:locales="form.locales"
@update:locales="form.locales = $event"
label="名称"
:maxlength="64"
/>
<div class="form-row">
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="999" />
@@ -166,3 +199,9 @@ onMounted(load)
</template>
</el-dialog>
</template>
<style scoped>
.locale-tag + .locale-tag {
margin-left: 6px;
}
</style>
@@ -0,0 +1,188 @@
<!-- Author: fruit.com.cn -->
<!-- 分类管理弹窗:编辑分类,以及把短剧关联进分类。 -->
<script setup>
import { statusClass } from '@/utils/status'
import LocaleTextFields from '@/views/components/LocaleTextFields.vue'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
const SORT_MAX = 9999
const STATUS_TEXT = {
draft: '草稿',
reviewing: '审核中',
approved: '待发布',
published: '已上线',
offline: '已下线'
}
defineProps({
editorVisible: { type: Boolean, default: false },
editingId: { type: [String, Number], default: null },
languages: { type: Array, default: () => ['zh'] },
form: { type: Object, required: true },
saving: { type: Boolean, default: false },
candidateVisible: { type: Boolean, default: false },
candidateLoading: { type: Boolean, default: false },
candidates: { type: Array, default: () => [] },
candidateKeyword: { type: String, default: '' },
candidatePage: { type: Number, default: 1 },
candidatePageSize: { type: Number, default: 10 },
candidateTotal: { type: Number, default: 0 },
binding: { type: Boolean, default: false },
selectedCount: { type: Number, default: 0 }
})
const emit = defineEmits([
'update:editorVisible',
'update:languages',
'update:form',
'update:candidateVisible',
'update:candidateKeyword',
'save',
'search',
'page',
'selection',
'confirm'
])
</script>
<template>
<el-dialog
:model-value="editorVisible"
:title="editingId ? '编辑分类' : '新建分类'"
width="560px"
@update:model-value="emit('update:editorVisible', $event)"
>
<el-form label-position="top">
<LocaleTextFields
:languages="languages"
:locales="form.locales"
label="名称"
:maxlength="64"
@update:languages="emit('update:languages', $event)"
@update:locales="emit('update:form', { ...form, locales: $event })"
/>
<el-form-item label="展示样式">
<el-radio-group
:model-value="form.layoutType"
@update:model-value="emit('update:form', { ...form, layoutType: $event })"
>
<el-radio-button label="GRID">普通分类</el-radio-button>
<el-radio-button label="RANKING">推荐榜</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="分类编码">
<el-input
:model-value="form.code"
maxlength="64"
placeholder="例如 RECOMMEND_RANKING"
@update:model-value="emit('update:form', { ...form, code: $event.toUpperCase() })"
/>
</el-form-item>
<div class="classification-form-row">
<el-form-item label="排序">
<el-input-number
:model-value="form.sort"
:min="0"
:max="SORT_MAX"
controls-position="right"
@update:model-value="emit('update:form', { ...form, sort: $event })"
/>
</el-form-item>
<el-form-item label="状态">
<el-switch
:model-value="form.status"
:active-value="1"
:inactive-value="0"
@update:model-value="emit('update:form', { ...form, status: $event })"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="emit('update:editorVisible', false)">取消</el-button>
<el-button type="primary" :loading="saving" @click="emit('save')">保存</el-button>
</template>
</el-dialog>
<el-dialog
:model-value="candidateVisible"
title="添加关联短剧"
width="760px"
@update:model-value="emit('update:candidateVisible', $event)"
>
<div class="classification-toolbar__search">
<el-input
:model-value="candidateKeyword"
placeholder="搜索短剧名称"
clearable
style="width: 220px"
@update:model-value="emit('update:candidateKeyword', $event)"
@keyup.enter="emit('search')"
@clear="emit('search')"
/>
<el-button type="primary" plain @click="emit('search')">查询</el-button>
</div>
<el-table
v-loading="candidateLoading"
:data="candidates"
row-key="id"
border
max-height="380"
empty-text="没有可关联的短剧"
@selection-change="emit('selection', $event)"
>
<el-table-column type="selection" width="48" />
<el-table-column prop="title" label="短剧名称" min-width="220" />
<el-table-column label="集数" width="90">
<template #default="{ row }">{{ row.totalEpisodes || 0 }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<span class="status-dot" :class="statusClass(row.status)">
{{ STATUS_TEXT[row.status] || row.status }}
</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="90" />
</el-table>
<div class="classification-pagination">
<el-pagination
:current-page="candidatePage"
:page-size="candidatePageSize"
:total="candidateTotal"
layout="total, prev, pager, next"
@current-change="emit('page', $event)"
/>
</div>
<template #footer>
<el-button @click="emit('update:candidateVisible', false)">取消</el-button>
<el-button type="primary" :loading="binding" @click="emit('confirm')">
确定关联{{ selectedCount ? `(${selectedCount})` : '' }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.classification-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.classification-toolbar__search {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.classification-pagination {
display: flex;
justify-content: flex-end;
margin-top: 14px;
}
</style>
@@ -0,0 +1,143 @@
<!-- Author: fruit.com.cn -->
<!-- 分类管理左侧列表:切换、编辑、删除。 -->
<script setup>
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
import { useRbac } from '@/stores/rbac'
defineProps({
classifications: { type: Array, default: () => [] },
activeId: { type: [String, Number], default: null }
})
const emit = defineEmits(['select', 'edit', 'remove'])
const { can } = useRbac()
</script>
<template>
<aside class="panel classification-layout__side">
<div class="classification-layout__side-title">全部分类</div>
<el-scrollbar height="560px">
<ul class="classification-list">
<li
v-for="item in classifications"
:key="item.id"
class="classification-list__item"
:class="{ 'is-active': item.id === activeId }"
@click="emit('select', item)"
>
<div class="classification-list__main">
<span class="classification-list__name">{{ item.name }}</span>
<el-tag v-if="item.layoutType === 'RANKING'" size="small" type="warning">
推荐榜
</el-tag>
<span class="classification-list__count">{{ item.dramaCount || 0 }}</span>
</div>
<div class="classification-list__actions" @click.stop>
<el-button
v-if="can('drama:update')"
text
circle
size="small"
@click="emit('edit', item)"
>
<AppIcon :icon="AppIcons.edit" :size="15" />
</el-button>
<el-button
v-if="can('drama:delete')"
text
circle
size="small"
@click="emit('remove', item)"
>
<AppIcon :icon="AppIcons.delete" :size="15" />
</el-button>
</div>
</li>
<li v-if="!classifications.length" class="classification-list__empty">
还没有分类,点右上角「新建分类」开始
</li>
</ul>
</el-scrollbar>
</aside>
</template>
<style scoped>
.classification-layout__side {
padding: 12px 0 8px;
}
.classification-layout__side-title {
padding: 0 16px 10px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.classification-list {
padding: 0;
margin: 0;
list-style: none;
}
.classification-list__item {
display: flex;
padding: 8px 12px 8px 16px;
color: var(--el-text-color-regular);
cursor: pointer;
border-left: 3px solid transparent;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.classification-list__item:hover {
background: var(--el-fill-color-light);
}
.classification-list__item.is-active {
font-weight: 600;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-left-color: var(--el-color-primary);
}
.classification-list__main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.classification-list__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.classification-list__count {
min-width: 24px;
padding: 0 6px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
text-align: center;
background: var(--el-fill-color);
border-radius: 9px;
}
.classification-list__actions {
display: none;
gap: 2px;
}
.classification-list__item:hover .classification-list__actions {
display: flex;
}
.classification-list__empty {
padding: 16px;
line-height: 1.6;
color: var(--el-text-color-secondary);
}
</style>
@@ -5,6 +5,13 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import {
languageLabel,
languageCodes,
localesFromColumns,
payloadLocales
} from '@/constants/contentLanguages'
import LocaleTextFields from '@/views/components/LocaleTextFields.vue'
import { AppImage } from '@/components/AppImage'
import {
changeDramaStatusApi,
@@ -16,11 +23,17 @@ import {
} from '@/api/admin/drama'
import { useRbac } from '@/stores/rbac'
import { formatCount } from '@/utils/format'
import { listContentTagsApi } from '@/api/admin/contentTag'
import { statusClass } from '@/utils/status'
import { uploadFile } from '@/utils/uploader'
import {
DRAMA_COVER_LANDSCAPE,
DRAMA_COVER_PORTRAIT,
compressForPreset
} from '@/views/contentManagement/upload/imagePresets'
const router = useRouter()
const { can, hasAny } = useRbac()
const { can } = useRbac()
const loading = ref(false)
const list = ref([])
@@ -30,8 +43,7 @@ const categories = ref([])
const keyword = ref('')
const statusFilter = ref(undefined)
const categoryIds = ref([])
const dramaTypes = ref([])
const hotFilter = ref(undefined)
const tags = ref([])
const pageIndex = ref(1)
const pageSize = ref(10)
@@ -41,40 +53,43 @@ const uploadingCover = ref(false)
const coverPercent = ref(0)
const coverError = ref('')
const coverFileName = ref('')
const uploadingHorizontal = ref(false)
const horizontalPercent = ref(0)
const horizontalError = ref('')
const horizontalFileName = ref('')
const editingId = ref(null)
const editingCommentCount = ref(0)
const editingLikeCount = ref(0)
const formRef = ref()
const textLanguages = ref(['zh'])
const form = reactive({
title: '',
titleLocales: { zh: '' },
categoryIds: [],
dramaTypes: ['LIVE'],
tagId: null,
description: '',
synopsisLocales: { zh: '' },
freeEpisodes: 3,
unlockCoin: 0,
favoriteCount: 0,
heat: 0,
isHot: false,
sort: 0,
cover: ''
cover: '',
horizontalCover: ''
})
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 horizontalDisplayName = computed(
() =>
horizontalFileName.value ||
(form.horizontalCover ? coverBasename(form.horizontalCover) : '尚未选择横版封面')
)
const pageCoverPreviewList = computed(() => list.value.map((item) => item.cover).filter(Boolean))
@@ -90,17 +105,13 @@ const statusOptions = [
{ code: 'offline', text: '已下线' }
]
const dramaTypeOptions = [
{ value: 'ANIME', label: '漫剧' },
{ value: 'LIVE', label: '真人剧' }
]
const actionMeta = {
submit: { label: '提交审核', done: '已提交审核' },
approve: { label: '审核通过', done: '已通过审核' },
reject: { label: '驳回', done: '已驳回' },
publish: { label: '上线', done: '已上线' },
offline: { label: '下线', done: '已下线' }
offline: { label: '下线', done: '已下线' },
approvePublish: { label: '审核通过并上线', done: '已通过审核并上线' }
}
async function load() {
@@ -111,9 +122,7 @@ async function load() {
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
status: statusFilter.value || undefined,
categoryIds: categoryIds.value.length ? categoryIds.value : undefined,
dramaTypes: dramaTypes.value.length ? dramaTypes.value : undefined,
isHot: hotFilter.value
categoryIds: categoryIds.value.length ? categoryIds.value : undefined
})
list.value = res.data?.list || []
total.value = res.data?.total || 0
@@ -127,6 +136,11 @@ async function loadCategories() {
categories.value = res.data || []
}
async function loadTags() {
const res = await listContentTagsApi(true)
tags.value = res.data || []
}
function search() {
pageIndex.value = 1
load()
@@ -136,8 +150,6 @@ function resetFilters() {
keyword.value = ''
statusFilter.value = undefined
categoryIds.value = []
dramaTypes.value = []
hotFilter.value = undefined
search()
}
@@ -146,34 +158,63 @@ function handleSizeChange() {
load()
}
function selectedCategoryIds(row) {
const values =
Array.isArray(row.categoryIds) && row.categoryIds.length
? row.categoryIds
: row.categoryId
? [row.categoryId]
: []
return values.filter((id) => Number(id) > 0)
}
function coverBasename(path) {
if (!path) return ''
const clean = path.split('?')[0].split('#')[0]
return decodeURIComponent(clean.split('/').pop() || clean)
}
/** 长整型计数字段在接口里是字符串,数字输入框只接受 number。 */
function toCount(value) {
const count = Number(value)
return Number.isFinite(count) ? count : 0
}
async function resetValidation() {
await nextTick()
formRef.value?.clearValidate()
}
function changeTextLanguages(codes) {
textLanguages.value = codes
codes.forEach((code) => {
if (form.synopsisLocales[code] == null) form.synopsisLocales[code] = ''
})
}
function openCreate() {
editingId.value = null
textLanguages.value = ['zh']
Object.assign(form, {
title: '',
titleLocales: { zh: '' },
categoryIds: [],
dramaTypes: ['LIVE'],
tagId: null,
description: '',
synopsisLocales: { zh: '' },
freeEpisodes: 3,
unlockCoin: 0,
favoriteCount: 0,
heat: 0,
isHot: false,
sort: 0,
cover: ''
cover: '',
horizontalCover: ''
})
coverFileName.value = ''
coverError.value = ''
horizontalFileName.value = ''
horizontalError.value = ''
editingCommentCount.value = 0
editingLikeCount.value = 0
dialogVisible.value = true
@@ -182,41 +223,38 @@ function openCreate() {
function openEdit(row) {
editingId.value = row.id
const titleLocales = localesFromColumns(row.titleLocales, row.title)
const synopsisLocales = localesFromColumns(row.synopsisLocales, row.description)
textLanguages.value = languageCodes(titleLocales)
textLanguages.value.forEach((code) => {
if (synopsisLocales[code] == null) synopsisLocales[code] = ''
})
Object.assign(form, {
title: row.title,
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'],
titleLocales,
categoryIds: selectedCategoryIds(row),
tagId: Number(row.tagId) > 0 ? row.tagId : null,
description: row.description || '',
freeEpisodes: row.freeEpisodes ?? 3,
unlockCoin: row.unlockCoin ?? 0,
favoriteCount: row.favoriteCount ?? 0,
heat: row.views ?? 0,
synopsisLocales,
freeEpisodes: toCount(row.freeEpisodes ?? 3),
unlockCoin: toCount(row.unlockCoin),
favoriteCount: toCount(row.favoriteCount),
heat: toCount(row.views),
isHot: !!row.isHot,
sort: row.sort ?? 0,
cover: row.cover || ''
sort: toCount(row.sort),
cover: row.cover || '',
horizontalCover: row.horizontalCover || ''
})
coverFileName.value = row.cover ? coverBasename(row.cover) : ''
coverError.value = ''
editingCommentCount.value = row.commentCount ?? 0
editingLikeCount.value = row.likeCount ?? 0
horizontalFileName.value = row.horizontalCover ? coverBasename(row.horizontalCover) : ''
horizontalError.value = ''
editingCommentCount.value = toCount(row.commentCount)
editingLikeCount.value = toCount(row.likeCount)
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)
@@ -228,25 +266,26 @@ function rowCategoryList(row) {
.filter(Boolean)
}
function rowDramaTypes(row) {
if (Array.isArray(row.dramaTypes) && row.dramaTypes.length) return row.dramaTypes
return row.dramaType ? [row.dramaType] : []
}
async function saveDrama() {
if (saving.value || uploadingCover.value) return
try {
await formRef.value?.validate()
} catch {
if (saving.value || uploadingCover.value || uploadingHorizontal.value) return
const titles = payloadLocales(form.titleLocales, textLanguages.value)
const missing = textLanguages.value.find((code) => !titles[code])
if (!titles.zh || missing) {
ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请输入短剧名称')
return
}
const synopses = payloadLocales(form.synopsisLocales, textLanguages.value)
const categoryNames = form.categoryIds
.map((id) => categories.value.find((item) => item.id === id)?.name)
.filter(Boolean)
const payload = {
...form,
title: form.title.trim(),
genre: categoryNames.join(' / ')
title: titles.zh,
titleLocales: titles,
description: synopses.zh || '',
synopsisLocales: synopses,
genre: categoryNames.join(' / '),
tagId: form.tagId || 0
}
saving.value = true
try {
@@ -265,32 +304,41 @@ async function saveDrama() {
}
}
function beforeCoverUpload(file) {
coverError.value = ''
function validateCoverFile(file, errorRef, label) {
errorRef.value = ''
if (!COVER_TYPES.includes(file.type)) {
coverError.value = '封面仅支持 JPG、PNG 或 WebP 格式'
errorRef.value = `${label}仅支持 JPG、PNG 或 WebP 格式`
return false
}
if (file.size > MAX_COVER_SIZE) {
coverError.value = '封面大小不能超过 5MB'
errorRef.value = `${label}大小不能超过 5MB`
return false
}
return true
}
/** 封面走分片上传(图片只有一个分片),成功后回填地址。 */
function beforeCoverUpload(file) {
return validateCoverFile(file, coverError, '竖版封面')
}
function beforeHorizontalUpload(file) {
return validateCoverFile(file, horizontalError, '横版封面')
}
/** 封面走分片上传(图片只有一个分片),上传前按展示规格等比压缩,成功后回填地址。 */
async function uploadCover(options) {
uploadingCover.value = true
coverPercent.value = 0
coverError.value = ''
try {
const asset = await uploadFile(options.file, {
const file = await compressForPreset(options.file, DRAMA_COVER_PORTRAIT)
const asset = await uploadFile(file, {
bizType: 'DRAMA_COVER',
onProgress: (percent) => (coverPercent.value = percent)
})
form.cover = asset.url
coverFileName.value = options.file.name
ElMessage.success('封面已上传')
coverFileName.value = file.name
ElMessage.success('竖版封面已上传')
} catch {
coverError.value = '封面上传失败,请检查网络后重试'
} finally {
@@ -298,6 +346,26 @@ async function uploadCover(options) {
}
}
async function uploadHorizontalCover(options) {
uploadingHorizontal.value = true
horizontalPercent.value = 0
horizontalError.value = ''
try {
const file = await compressForPreset(options.file, DRAMA_COVER_LANDSCAPE)
const asset = await uploadFile(file, {
bizType: 'DRAMA_COVER',
onProgress: (percent) => (horizontalPercent.value = percent)
})
form.horizontalCover = asset.url
horizontalFileName.value = file.name
ElMessage.success('横版封面已上传')
} catch {
horizontalError.value = '横版封面上传失败,请检查网络后重试'
} finally {
uploadingHorizontal.value = false
}
}
function removeCover() {
if (uploadingCover.value) return
form.cover = ''
@@ -305,8 +373,15 @@ function removeCover() {
coverError.value = ''
}
function removeHorizontalCover() {
if (uploadingHorizontal.value) return
form.horizontalCover = ''
horizontalFileName.value = ''
horizontalError.value = ''
}
function beforeDialogClose(done) {
if (saving.value || uploadingCover.value) {
if (saving.value || uploadingCover.value || uploadingHorizontal.value) {
ElMessage.warning(saving.value ? '正在保存,请稍候' : '封面正在上传,请稍候')
return
}
@@ -361,16 +436,16 @@ async function runAction(row, action) {
function reviewActions(row) {
const canReview = can('drama:review')
if (row.status === 'draft' || row.status === 'offline') {
return hasAny('drama:update', 'drama:review') ? ['submit'] : []
return canReview ? ['approvePublish'] : []
}
if (row.status === 'reviewing') return canReview ? ['approve', 'reject'] : []
if (row.status === 'reviewing') return canReview ? ['approvePublish', 'reject'] : []
if (row.status === 'approved') return canReview ? ['publish'] : []
if (row.status === 'published') return canReview ? ['offline'] : []
return []
}
onMounted(async () => {
await loadCategories()
await Promise.all([loadCategories(), loadTags()])
await load()
})
</script>
@@ -413,22 +488,6 @@ onMounted(async () => {
:key="item.id"
:label="item.name"
: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-option label="仅热门" :value="true" /><el-option
label="非热门"
:value="false" /></el-select
><el-button @click="resetFilters">重置</el-button></div
><span class="table-result-count">共 {{ total }} 部短剧</span></div
>
@@ -465,24 +524,17 @@ onMounted(async () => {
>
<el-table-column label="类型" min-width="200"
><template #default="{ row }"
><div class="tag-wrap"
><div v-if="rowCategoryList(row).length" class="tag-wrap"
><el-tag v-for="name in rowCategoryList(row)" :key="name" type="info" effect="light">{{
name
}}</el-tag></div
></template
><span v-else class="table-empty-text">—</span></template
>
</el-table-column>
<el-table-column label="内容类型" min-width="180"
<el-table-column label="标签" min-width="120"
><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
><span v-if="row.tagName">{{ row.tagName }}</span
><span v-else class="table-empty-text">—</span></template
></el-table-column
>
<el-table-column label="集数" width="90"
@@ -513,12 +565,6 @@ onMounted(async () => {
}}</el-tag></template
></el-table-column
>
<el-table-column label="热门" width="90"
><template #default="{ row }"
><el-tag v-if="row.isHot" type="danger" effect="light">热门</el-tag
><span v-else class="table-empty-text">—</span></template
></el-table-column
>
<el-table-column prop="updatedAt" label="更新时间" width="160" />
<el-table-column label="审核" width="140"
><template #default="{ row }"
@@ -576,31 +622,172 @@ onMounted(async () => {
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑短剧' : '新建短剧'"
width="680px"
width="760px"
class="drama-editor-dialog"
:close-on-click-modal="false"
:before-close="beforeDialogClose"
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-position="top"
class="drama-editor-form"
>
<el-form ref="formRef" :model="form" label-position="top" class="drama-editor-form">
<section class="editor-section">
<h3 class="editor-section-title">封面</h3>
<div class="cover-block">
<p class="cover-label">竖版封面</p>
<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"
>{{ DRAMA_COVER_PORTRAIT.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>
</div>
<div class="cover-block">
<p class="cover-label">横版封面</p>
<div class="cover-editor">
<div class="cover-preview-card cover-preview-card--landscape">
<img v-if="form.horizontalCover" :src="form.horizontalCover" alt="短剧横版封面" />
<div v-else class="cover-preview-empty">
<AppIcon :icon="AppIcons.content" :size="28" />
<span>暂无横版封面</span>
</div>
<div v-if="uploadingHorizontal" class="cover-upload-mask">
<span>上传中</span>
<b>{{ horizontalPercent }}%</b>
</div>
</div>
<div class="cover-editor-meta">
<div class="cover-status-line">
<el-tag :type="form.horizontalCover ? 'success' : 'info'" effect="light">
{{ form.horizontalCover ? '已上传' : '未上传' }}
</el-tag>
<span class="cover-filename" :title="horizontalDisplayName">{{
horizontalDisplayName
}}</span>
</div>
<p class="cover-hint">
{{ DRAMA_COVER_LANDSCAPE.hint }};支持 JPG、PNG、WebP,单张不超过 5MB。
</p>
<el-alert
v-if="horizontalError"
:title="horizontalError"
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="beforeHorizontalUpload"
:http-request="uploadHorizontalCover"
:disabled="uploadingHorizontal"
>
<el-button :loading="uploadingHorizontal" :disabled="uploadingHorizontal">
<AppIcon :icon="AppIcons.upload" />
{{ form.horizontalCover ? '更换横版封面' : '选择横版封面' }}
</el-button>
</el-upload>
<el-button
v-if="can('cover:upload') && form.horizontalCover"
type="danger"
:disabled="uploadingHorizontal"
@click="removeHorizontalCover"
>
移除
</el-button>
<a
v-if="form.horizontalCover"
class="cover-original-link"
:href="form.horizontalCover"
target="_blank"
rel="noopener noreferrer"
>
查看原图
</a>
</div>
</div>
</div>
</div>
</section>
<section class="editor-section">
<h3 class="editor-section-title">基础信息</h3>
<LocaleTextFields
:languages="textLanguages"
:locales="form.titleLocales"
@update:locales="form.titleLocales = $event"
label="名称"
:maxlength="80"
@update:languages="changeTextLanguages"
/>
<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-form-item label="类型">
<el-select
v-model="form.categoryIds"
class="drama-multi-select"
multiple
filterable
placeholder="请选择类型"
clearable
placeholder="可不选"
>
<el-option
v-for="item in categories"
@@ -610,33 +797,22 @@ onMounted(async () => {
/>
</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-form-item label="标签">
<el-select v-model="form.tagId" clearable filterable placeholder="可不选">
<el-option v-for="item in tags" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</div>
<el-form-item label="简介">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="简要介绍剧情"
/>
</el-form-item>
<LocaleTextFields
:languages="textLanguages"
:locales="form.synopsisLocales"
@update:locales="form.synopsisLocales = $event"
label="简介"
:maxlength="1000"
multiline
:show-languages="false"
:required="false"
/>
</section>
<section class="editor-section">
@@ -660,12 +836,7 @@ onMounted(async () => {
: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-form-item label="排序">
<el-input-number
v-model="form.sort"
class="editor-number"
@@ -710,72 +881,6 @@ onMounted(async () => {
</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>
@@ -816,6 +921,20 @@ onMounted(async () => {
min-width: 0;
}
.drama-editor-form :deep(.el-form-item) {
margin-right: 0;
}
.drama-editor-form :deep(.el-form-item__content) {
min-width: 0;
}
.drama-editor-form :deep(.el-input),
.drama-editor-form :deep(.el-textarea),
.drama-editor-form :deep(.el-select) {
width: 100%;
}
.tag-wrap {
display: flex;
flex-wrap: wrap;
@@ -6,6 +6,12 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcon } from '@/components/AppIcon'
import { AppIcons } from '@/constants/icons'
import {
languageCodes,
languageLabel,
localesFromColumns,
payloadLocales
} from '@/constants/contentLanguages'
import {
bindPlaylistCategoryDramasApi,
createPlaylistCategoryApi,
@@ -18,6 +24,7 @@ import {
} from '@/api/admin/dramaPlaylistCategory'
import { statusClass } from '@/utils/status'
import { useRbac } from '@/stores/rbac'
import LocaleTextFields from '@/views/components/LocaleTextFields.vue'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
@@ -46,7 +53,14 @@ const keyword = ref('')
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const form = reactive({ name: '', description: '', sort: 1, status: 1 })
const languages = ref(['zh'])
const emptyForm = () => ({
locales: { zh: '' },
descriptionLocales: { zh: '' },
sort: 1,
status: 1
})
const form = reactive(emptyForm())
const candidateVisible = ref(false)
const candidateLoading = ref(false)
@@ -108,18 +122,37 @@ function changePage(value) {
loadDramas()
}
function changeLanguages(codes) {
languages.value = codes
codes.forEach((code) => {
if (form.descriptionLocales[code] == null) form.descriptionLocales[code] = ''
})
}
function openCreate() {
editingId.value = null
languages.value = ['zh']
const maxSort = categories.value.reduce((max, item) => Math.max(max, item.sort || 0), 0)
Object.assign(form, { name: '', description: '', sort: maxSort + 1, status: 1 })
Object.assign(form, emptyForm(), { sort: maxSort + 1 })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
const locales = localesFromColumns(row.locales, row.name, row.nameEn, row.nameId)
languages.value = languageCodes(locales)
const descriptionLocales = localesFromColumns(
row.descriptionLocales,
row.description,
row.descriptionEn,
row.descriptionId
)
languages.value.forEach((code) => {
if (descriptionLocales[code] == null) descriptionLocales[code] = ''
})
Object.assign(form, {
name: row.name,
description: row.description || '',
locales,
descriptionLocales,
sort: row.sort ?? 0,
status: row.status ?? 1
})
@@ -127,15 +160,18 @@ function openEdit(row) {
}
async function saveCategory() {
if (!form.name.trim()) {
ElMessage.warning('请填写剧单分类名称')
const locales = payloadLocales(form.locales, languages.value)
const missing = languages.value.find((code) => !locales[code])
if (!locales.zh || missing) {
ElMessage.warning(missing ? `请填写${languageLabel(missing)}名称` : '请填写中文名称')
return
}
saving.value = true
try {
const payload = {
name: form.name.trim(),
description: form.description.trim(),
name: locales.zh,
locales,
descriptionLocales: payloadLocales(form.descriptionLocales, languages.value),
sort: form.sort,
status: form.status
}
@@ -390,22 +426,27 @@ onMounted(loadCategories)
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑剧单分类' : '新建剧单分类'"
width="460px"
width="640px"
>
<el-form label-position="top">
<el-form-item label="分类名称" required>
<el-input v-model="form.name" maxlength="20" placeholder="例如:本周必看" />
</el-form-item>
<el-form-item label="描述">
<el-input
v-model="form.description"
type="textarea"
:rows="2"
maxlength="255"
show-word-limit
placeholder="分类用途说明"
/>
</el-form-item>
<LocaleTextFields
:languages="languages"
:locales="form.locales"
@update:locales="form.locales = $event"
label="名称"
:maxlength="64"
@update:languages="changeLanguages"
/>
<LocaleTextFields
:languages="languages"
:locales="form.descriptionLocales"
@update:locales="form.descriptionLocales = $event"
label="描述"
:maxlength="255"
multiline
:show-languages="false"
:required="false"
/>
<div class="playlist-form-row">
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="999" />
@@ -0,0 +1,264 @@
<!-- Author: fruit.com.cn -->
<!-- 标签管理:名称分中文、英文、印尼语。短剧编辑时单选一个标签。 -->
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AppIcons } from '@/constants/icons'
import {
languageCodes,
languageLabel,
localesFromColumns,
payloadLocales
} from '@/constants/contentLanguages'
import {
createContentTagApi,
deleteContentTagApi,
listContentTagsApi,
updateContentTagApi
} from '@/api/admin/contentTag'
import { useRbac } from '@/stores/rbac'
import LocaleTextFields from '@/views/components/LocaleTextFields.vue'
import PageHeading from '@/views/components/PageHeading.vue'
const { can } = useRbac()
const tags = ref([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editingId = ref(null)
const languages = ref(['zh'])
const DEFAULT_TAG_COLOR = '#E5484D'
const emptyForm = () => ({
locales: { zh: '' },
bgColor: DEFAULT_TAG_COLOR,
code: '',
sort: 1,
status: 1
})
const form = reactive(emptyForm())
async function load() {
loading.value = true
try {
const res = await listContentTagsApi()
tags.value = res.data || []
} finally {
loading.value = false
}
}
function nextSort() {
return tags.value.reduce((max, item) => Math.max(max, item.sort || 0), 0) + 1
}
function openCreate() {
editingId.value = null
languages.value = ['zh']
Object.assign(form, emptyForm(), { sort: nextSort() })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
const locales = localesFromColumns(row.locales, row.name, row.nameEn, row.nameId)
languages.value = languageCodes(locales)
Object.assign(form, {
locales,
bgColor: row.bgColor || DEFAULT_TAG_COLOR,
code: row.code || '',
sort: row.sort ?? 0,
status: row.status ?? 1
})
dialogVisible.value = true
}
async function save() {
const locales = payloadLocales(form.locales, languages.value)
if (!locales.zh) {
ElMessage.warning('请填写中文名称')
return
}
const missing = languages.value.find((code) => !locales[code])
if (missing) {
ElMessage.warning(`请填写${languageLabel(missing)}名称`)
return
}
const code = (form.code || '').trim().toUpperCase()
if (code && !/^[A-Z][A-Z0-9_]{0,31}$/.test(code)) {
ElMessage.warning('语义码需为大写字母、数字或下划线,且以字母开头')
return
}
saving.value = true
try {
const payload = {
name: locales.zh,
locales,
bgColor: form.bgColor || DEFAULT_TAG_COLOR,
code,
sort: form.sort,
status: form.status
}
if (editingId.value) {
await updateContentTagApi(editingId.value, payload)
ElMessage.success('标签已更新')
} else {
await createContentTagApi(payload)
ElMessage.success('标签已创建')
}
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
async function confirmDelete(row) {
try {
await ElMessageBox.confirm(
`确定删除标签「${row.name}」吗?已选该标签的短剧会取消选择。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
}
)
} catch {
return
}
await deleteContentTagApi(row.id)
ElMessage.success('标签已删除')
await load()
}
onMounted(load)
</script>
<template>
<PageHeading
eyebrow="内容管理"
title="标签管理"
description="维护短剧标签。名称分中文、英文、印尼语,短剧编辑时单选一个。"
>
<el-button v-if="can('drama:create')" type="primary" class="primary-button" @click="openCreate">
<AppIcon :icon="AppIcons.add" />
新建标签
</el-button>
</PageHeading>
<section class="panel">
<el-table
v-loading="loading"
:data="tags"
row-key="id"
border
class="drama-el-table"
empty-text="还没有标签"
>
<el-table-column type="index" label="序号" width="80" />
<el-table-column prop="name" label="中文名称" min-width="160" />
<el-table-column label="语言" min-width="220">
<template #default="{ row }">
<el-tag
v-for="code in languageCodes(
row.locales || localesFromColumns(null, row.name, row.nameEn, row.nameId)
)"
:key="code"
effect="light"
class="locale-tag"
>{{ languageLabel(code) }}</el-tag
>
</template>
</el-table-column>
<el-table-column label="背景色" width="100">
<template #default="{ row }">
<span class="tag-color" :style="{ background: row.bgColor || DEFAULT_TAG_COLOR }"></span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="100" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" effect="light">{{
row.status === 1 ? '启用' : '停用'
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button v-if="can('drama:update')" type="primary" size="small" @click="openEdit(row)"
>编辑</el-button
>
<el-button
v-if="can('drama:delete')"
type="danger"
size="small"
@click="confirmDelete(row)"
>删除</el-button
>
</div>
</template>
</el-table-column>
</el-table>
</section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑标签' : '新建标签'" width="560px">
<el-form label-position="top">
<LocaleTextFields
v-model:languages="languages"
:locales="form.locales"
@update:locales="form.locales = $event"
label="名称"
:maxlength="64"
/>
<el-form-item label="背景色">
<el-color-picker v-model="form.bgColor" color-format="hex" />
</el-form-item>
<el-form-item label="语义码">
<el-input
v-model="form.code"
maxlength="32"
placeholder="如 NEW_ARRIVAL,留空则按名称匹配"
/>
<div class="form-hint">
客户端按语义码判断板块(如「上新」横滑),改名或换语言都不受影响。
</div>
</el-form-item>
<div class="form-row">
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="999" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.locale-tag + .locale-tag {
margin-left: 6px;
}
.tag-color {
display: inline-block;
width: 28px;
height: 16px;
border-radius: 4px;
}
.form-hint {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,62 @@
// Author: fruit.com.cn
import { compressImage } from '@/utils/imageCompress'
/**
* 图片上传规格:建议尺寸来自客户端实际展示位,按 3 倍屏预留像素。
*
* - 竖版封面 3:4:首页三列 ~118dp、推荐分类两列 ~175dp、追剧收藏 ~118dp,
* 同时作为播放器起播首帧铺满竖屏,1080×1440 是兼顾清晰度与体积的平衡点。
* - 横版封面 16:9:追剧页「继续观看」横幅(约 2.06:1)与后续横向位共用。
* - 推荐轮播:客户端轮播高度固定 420dp、按屏宽铺满(约 1:1.08)。
*/
export interface ImagePreset {
label: string
/** 建议尺寸文案,展示在上传按钮旁。 */
hint: string
maxWidth: number
maxHeight: number
}
export const DRAMA_COVER_PORTRAIT: ImagePreset = {
label: '竖版封面',
hint: '建议 1080×1440(3:4),最小 720×960',
maxWidth: 1080,
maxHeight: 1440
}
export const DRAMA_COVER_LANDSCAPE: ImagePreset = {
label: '横版封面',
hint: '建议 1280×720(16:9),用于继续观看横幅等横向位',
maxWidth: 1280,
maxHeight: 720
}
export const EPISODE_COVER: ImagePreset = {
label: '分集封面',
hint: '建议 720×960(3:4)',
maxWidth: 720,
maxHeight: 960
}
export const BANNER_IMAGE: ImagePreset = {
label: '推荐轮播图',
hint: '建议 1080×1164(约 1:1.08,与客户端 420dp 轮播高度一致)',
maxWidth: 1080,
maxHeight: 1164
}
export const LOGIN_BACKGROUND: ImagePreset = {
label: '登录页背景',
hint: '建议 1170×2532(390×844dp × 3,iPhone 整屏竖图),min 720×1560',
maxWidth: 1170,
maxHeight: 2532
}
/** 按规格压缩:尺寸未超且体积不大时原样返回,不做二次编码。 */
export function compressForPreset(file: File, preset: ImagePreset): Promise<File> {
return compressImage(file, {
maxWidth: preset.maxWidth,
maxHeight: preset.maxHeight
})
}
+453 -20
View File
@@ -1,39 +1,315 @@
<!-- Author: fruit.com.cn -->
<!-- 创作者数据看板:布局与视觉对齐管理端首页(BusinessDashboardView),指标口径复用 /admin/creator-center/dashboard。 -->
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PageHeading from '@/views/components/PageHeading.vue'
import CreatorCenterPage from './components/CreatorCenterPage.vue'
import { DASHBOARD_GROUPS, useCreatorDashboard } from './composables/useCreatorDashboard'
import { useCreatorDashboard } from './composables/useCreatorDashboard'
import { formatCount } from '@/utils/format'
import echarts from '@/plugins/echarts'
const { loading, board, lastUpdated, load, display, groupValue } = useCreatorDashboard()
const { loading, board, lastUpdated, load } = useCreatorDashboard()
const playbackRef = ref(null)
const statusRef = ref(null)
const mediaRef = ref(null)
const interactionRef = ref(null)
const revenueRef = ref(null)
const chartInstances = []
const COLORS = ['#5B8FF9', '#5AD8A6', '#F6BD16', '#E86452', '#945FB9', '#C9C9C9']
const statusLabel = [
{ key: 'draft', name: '草稿' },
{ key: 'reviewing', name: '审核中' },
{ key: 'approved', name: '待发布' },
{ key: 'published', name: '已上线' },
{ key: 'offline', name: '已下线' }
]
function num(group, key) {
const value = board.value?.[group]?.[key]
const n = Number(value)
return Number.isFinite(n) ? n : 0
}
function metricValue(group, key) {
const value = board.value?.[group]?.[key]
if (value === null || value === undefined) return null
const n = Number(value)
return Number.isFinite(n) ? n : null
}
function metricText(group, key) {
const value = metricValue(group, key)
return value === null ? '--' : formatCount(value)
}
const finishRate = computed(() => {
const value = metricValue('playback', 'finishRate')
if (value === null) return null
return Math.max(0, Math.min(100, value <= 1 ? value * 100 : value))
})
const finishRateText = computed(() =>
finishRate.value === null ? '--' : `${finishRate.value.toFixed(1)}%`
)
const kpis = computed(() => [
{ label: '作品总数', value: metricText('drama', 'total'), accent: '#5B8FF9' },
{ label: '累计观看', value: metricText('playback', 'viewTotal'), accent: '#5AD8A6' },
{ label: '累计播放', value: metricText('playback', 'playTotal'), accent: '#5B8FF9' },
{ label: '完播率', value: finishRateText.value, accent: '#F6BD16' },
{ label: '解锁笔数', value: metricText('revenue', 'unlockCount'), accent: '#E86452' },
{ label: '金币收入', value: metricText('revenue', 'coinIncome'), accent: '#945FB9' }
])
const statusItems = computed(() =>
statusLabel.map((item) => ({ name: item.name, value: num('drama', item.key) }))
)
const mediaItems = computed(() => {
const total = num('drama', 'episodeTotal')
const withMedia = num('drama', 'episodeWithMedia')
return [
{ name: '有片源', value: withMedia },
{ name: '无片源', value: Math.max(0, total - withMedia) }
]
})
const playbackItems = computed(() => [
{ name: '累计观看', value: num('playback', 'viewTotal') },
{ name: '累计播放', value: num('playback', 'playTotal') },
{ name: '近 7 天播放流水', value: num('playback', 'playEvents7d') },
{ name: '播放流水总量', value: num('playback', 'playEventsTotal') },
{ name: '完播事件', value: num('playback', 'finishEvents') }
])
const interactionItems = computed(() => [
{ name: '收藏', value: num('interaction', 'favoriteTotal') },
{ name: '评论', value: num('interaction', 'commentTotal') },
{ name: '待审评论', value: num('interaction', 'commentPending') }
])
const revenueItems = computed(() => [
{ name: '解锁笔数', value: num('revenue', 'unlockCount') },
{ name: '金币收入', value: num('revenue', 'coinIncome') },
{ name: '近 7 天金币', value: num('revenue', 'coinIncome7d') },
{ name: '法币金额', value: num('revenue', 'paidOrderAmount') }
])
const metrics = computed(() => [
{ label: '收藏总数', value: metricText('interaction', 'favoriteTotal') },
{ label: '评论总数', value: metricText('interaction', 'commentTotal') },
{ label: '待审评论', value: metricText('interaction', 'commentPending') },
{ label: '金币收入', value: metricText('revenue', 'coinIncome') },
{ label: '近 7 天金币', value: metricText('revenue', 'coinIncome7d') },
{ label: '法币金额', value: metricText('revenue', 'paidOrderAmount') }
])
const pieBase = {
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false, position: 'center' },
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } }
}
function renderPlayback() {
if (!playbackRef.value) return
const chart = echarts.init(playbackRef.value)
chartInstances.push(chart)
const items = playbackItems.value
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 90, right: 40, top: 12, bottom: 12 },
xAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
yAxis: {
type: 'category',
inverse: true,
data: items.map((item) => item.name),
axisLine: { show: false },
axisTick: { show: false }
},
series: [
{
type: 'bar',
barWidth: 12,
data: items.map((item) => item.value),
itemStyle: { borderRadius: [0, 4, 4, 0], color: '#5B8FF9' },
label: { show: true, position: 'right', formatter: ({ value }) => formatCount(value) }
}
]
})
}
function renderPie(el, name, data) {
if (!el) return
const chart = echarts.init(el)
chartInstances.push(chart)
const valid = data.filter((item) => item.value > 0)
chart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 0, itemWidth: 12, itemHeight: 12, textStyle: { fontSize: 12 } },
color: COLORS,
series: [
{
...pieBase,
name,
data: valid.length
? valid
: [{ name: '暂无数据', value: 1, itemStyle: { color: '#E6E8EB' } }]
}
]
})
}
function renderBar(el, items) {
if (!el) return
const chart = echarts.init(el)
chartInstances.push(chart)
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 12, right: 16, top: 24, bottom: 12, containLabel: true },
xAxis: {
type: 'category',
data: items.map((item) => item.name),
axisTick: { show: false },
axisLabel: { interval: 0, fontSize: 11 }
},
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
series: [
{
type: 'bar',
barWidth: 22,
data: items.map((item, index) => ({
value: item.value,
itemStyle: { color: COLORS[index % COLORS.length], borderRadius: [4, 4, 0, 0] }
})),
label: { show: true, position: 'top', fontSize: 11 }
}
]
})
}
function disposeAll() {
chartInstances.forEach((chart) => chart.dispose())
chartInstances.length = 0
}
function renderCharts() {
disposeAll()
renderPlayback()
renderPie(statusRef.value, '作品状态', statusItems.value)
renderPie(mediaRef.value, '片源覆盖', mediaItems.value)
renderBar(interactionRef.value, interactionItems.value)
renderBar(revenueRef.value, revenueItems.value)
}
function onResize() {
chartInstances.forEach((chart) => chart.resize())
}
watch(
() => board.value,
async (value) => {
if (!value) return
await nextTick()
renderCharts()
},
{ immediate: true }
)
onMounted(() => window.addEventListener('resize', onResize))
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
disposeAll()
})
</script>
<template>
<CreatorCenterPage>
<PageHeading
eyebrow="创作者中心"
title="数据看板"
description="我的作品在播放、互动与解锁收入上的表现。"
eyebrow="数据看板"
title="创作者数据"
description="本人作品的播放、互动与解锁收入总览。"
>
<el-button :loading="loading" @click="load">刷新</el-button>
</PageHeading>
<section v-loading="loading" class="panel">
<p class="console-hint">数据时间:{{ lastUpdated }}(统计范围仅限本人作品)</p>
<div v-for="group in DASHBOARD_GROUPS" :key="group.key" class="board-group">
<h3 class="board-title">{{ group.title }}</h3>
<div class="board-grid">
<div v-for="metric in group.metrics" :key="metric.key" class="board-card">
<span class="board-label">{{ metric.label }}</span>
<span
class="board-value"
:class="{ 'board-value-empty': groupValue(group.key, metric.key) === null }"
>
{{ display(groupValue(group.key, metric.key)) }}
</span>
<div v-loading="loading" class="board-wrap">
<section class="panel">
<div class="panel-heading">
<div>
<h2>创作概览</h2>
<p>作品规模、播放与收入核心指标</p>
</div>
<span class="muted">数据时间:{{ lastUpdated || '--' }}</span>
</div>
<div class="kpi-row">
<article v-for="item in kpis" :key="item.label" class="kpi">
<i :style="{ background: item.accent }"></i>
<div>
<strong>{{ item.value }}</strong>
<span>{{ item.label }}</span>
</div>
</article>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<h2>播放与片源</h2>
<p>播放漏斗、作品状态与分集片源覆盖</p>
</div>
</div>
</div>
<div class="viz-row">
<div class="viz-cell">
<h3>播放表现</h3>
<div ref="playbackRef" class="chart chart-bar"></div>
</div>
<div class="viz-cell">
<h3>作品状态</h3>
<div ref="statusRef" class="chart chart-pie"></div>
</div>
<div class="viz-cell">
<h3>片源覆盖</h3>
<div ref="mediaRef" class="chart chart-pie"></div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<h2>互动与收入</h2>
<p>收藏、评论与解锁收入</p>
</div>
</div>
<div class="viz-row">
<div class="viz-cell">
<h3>互动</h3>
<div ref="interactionRef" class="chart chart-bar"></div>
</div>
<div class="viz-cell">
<h3>解锁收入</h3>
<div ref="revenueRef" class="chart chart-bar"></div>
</div>
<div class="viz-cell">
<h3>关键指标</h3>
<ul class="metric-list">
<li v-for="item in metrics" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</li>
</ul>
</div>
</div>
</section>
<el-alert
v-if="board?.notes?.length"
class="board-notes"
@@ -46,6 +322,163 @@ const { loading, board, lastUpdated, load, display, groupValue } = useCreatorDas
<li v-for="note in board.notes" :key="note">{{ note }}</li>
</ul>
</el-alert>
</section>
</div>
</CreatorCenterPage>
</template>
<style scoped lang="less">
.board-wrap {
display: flex;
flex-direction: column;
gap: 16px;
}
.panel {
padding: 20px 24px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.panel-heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
p {
margin: 4px 0 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.muted {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
.kpi-row {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.kpi {
display: flex;
gap: 10px;
align-items: center;
min-width: 0;
min-height: 72px;
padding: 12px 14px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
i {
flex: none;
width: 3px;
height: 32px;
border-radius: 3px;
}
strong {
display: block;
overflow: hidden;
font-size: 22px;
line-height: 1.1;
color: var(--el-text-color-primary);
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
display: block;
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.viz-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr) minmax(0, 0.8fr);
gap: 16px;
}
.viz-cell {
min-width: 0;
h3 {
margin: 0 0 4px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-secondary);
}
}
.chart {
width: 100%;
}
.chart-bar {
height: 240px;
}
.chart-pie {
height: 240px;
}
.metric-list {
margin: 8px 0 0;
padding: 0;
list-style: none;
li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 9px 0;
border-bottom: 1px dashed var(--el-border-color-lighter);
font-size: 13px;
&:last-child {
border-bottom: 0;
}
span {
color: var(--el-text-color-secondary);
}
strong {
font-variant-numeric: tabular-nums;
color: var(--el-text-color-primary);
}
}
}
.board-notes {
:deep(ul) {
padding-left: 18px;
margin: 4px 0 0;
font-size: 12px;
}
}
@media (width <= 1100px) {
.kpi-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.viz-row {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
+186 -152
View File
@@ -1,7 +1,7 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { AppIcons } from '@/constants/icons'
import { AppImage } from '@/components/AppImage'
import PageHeading from '@/views/components/PageHeading.vue'
@@ -10,6 +10,7 @@ import { useRbac } from '@/stores/rbac'
import { useDramaEdit } from './composables/useDramaEdit'
import { useCreatorAccess } from './composables/useCreatorAccess'
import { dramaStatusMeta } from './composables/useCreatorWorkspace'
import { DRAMA_COVER_PORTRAIT, EPISODE_COVER } from '@/views/contentManagement/upload/imagePresets'
const { can } = useRbac()
const canEdit = can('creator-center:drama:edit')
@@ -44,7 +45,6 @@ const {
moveEpisode,
uploadVideo,
uploadCover,
hasResumeSession,
submit,
withdraw
} = useDramaEdit()
@@ -61,6 +61,16 @@ function back() {
window.history.back()
}
/* ---------- 分集视频预览 ---------- */
const videoPreviewVisible = ref(false)
const videoPreviewUrl = ref('')
function playEpisode(row) {
if (!row.videoUrl) return
videoPreviewUrl.value = row.videoUrl
videoPreviewVisible.value = true
}
/** el-upload 的 http-request 回调:拿到原始 File 后交给统一的(可续传)上传逻辑 */
function requestVideo(row, options) {
return uploadVideo(row, options.file)
@@ -122,15 +132,9 @@ function requestDramaCover(options) {
<el-form-item label="作品名称" required>
<el-input v-model="infoForm.title" maxlength="64" />
</el-form-item>
<el-form-item label="副标题">
<el-input v-model="infoForm.subtitle" maxlength="64" />
</el-form-item>
<el-form-item label="题材">
<el-input v-model="infoForm.genre" maxlength="32" placeholder="例如:都市情感" />
</el-form-item>
<el-form-item label="语言">
<el-input v-model="infoForm.language" maxlength="16" placeholder="zh-CN / id-ID" />
</el-form-item>
</div>
<el-form-item label="简介">
<el-input
@@ -148,24 +152,20 @@ function requestDramaCover(options) {
<img v-if="infoForm.cover" :src="infoForm.cover" alt="" />
<span v-else>无</span>
</div>
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="image/*"
:http-request="requestDramaCover"
:disabled="uploadingId !== null"
>
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
</el-upload>
<div class="creator-cover-actions">
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="image/*"
:http-request="requestDramaCover"
:disabled="uploadingId !== null"
>
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
</el-upload>
<p class="creator-cover-hint">{{ DRAMA_COVER_PORTRAIT.hint }}</p>
</div>
</div>
</el-form-item>
<el-form-item label="预告片地址">
<el-input
v-model="infoForm.trailerUrl"
maxlength="512"
placeholder="整剧一支预告片,可留空"
/>
</el-form-item>
</div>
<el-button
v-if="canEdit"
@@ -198,80 +198,92 @@ function requestDramaCover(options) {
show-icon
:title="resumeHint"
/>
<el-table :data="episodes" row-key="id" border class="drama-el-table" empty-text="还没有分集">
<el-table-column prop="number" label="集号" width="70" />
<el-table-column label="封面" width="88">
<template #default="{ row }">
<div class="creator-episode-cover">
<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>
</div>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
<el-table-column prop="duration" label="时长" width="90" />
<el-table-column label="片源" min-width="120">
<template #default="{ row }">
<span class="console-muted">{{ row.videoUrl ? '已上传' : '未上传' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag
size="small"
effect="light"
:type="row.status === 'published' ? 'success' : 'info'"
>
{{ row.statusText }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row, $index }">
<div class="table-actions">
<el-button
<div class="creator-table-scroll creator-table-scroll--episodes">
<el-table
:data="episodes"
row-key="id"
border
class="drama-el-table"
style="min-width: 1150px"
empty-text="还没有分集"
>
<el-table-column prop="number" label="集号" width="70" />
<el-table-column label="封面" width="88">
<template #default="{ row }">
<div class="creator-episode-cover">
<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>
</div>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
<el-table-column prop="duration" label="时长" width="90" />
<el-table-column label="片源" min-width="120">
<template #default="{ row }">
<el-button v-if="row.videoUrl" link type="primary" @click="playEpisode(row)"
>播放</el-button
>
<span v-else class="console-muted">未上传</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag
size="small"
:disabled="!editable || $index === 0"
@click="moveEpisode(row, -1)"
>上移</el-button
effect="light"
:type="row.status === 'published' ? 'success' : 'info'"
>
<el-button
size="small"
:disabled="!editable || $index === episodes.length - 1"
@click="moveEpisode(row, 1)"
>下移</el-button
>
<el-button size="small" :disabled="!editable" @click="openEpisode(row)"
>编辑</el-button
>
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="video/*"
:http-request="(options) => requestVideo(row, options)"
:disabled="!editable || uploadingId !== null"
>
<el-button size="small">{{ hasResumeSession ? '继续上传' : '上传视频' }}</el-button>
</el-upload>
<el-button
type="danger"
size="small"
:disabled="!editable"
@click="removeEpisode(row)"
>删除</el-button
>
</div>
</template>
</el-table-column>
</el-table>
{{ row.statusText }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="340">
<template #default="{ row, $index }">
<div class="table-actions">
<el-button
size="small"
:disabled="!editable || $index === 0"
@click="moveEpisode(row, -1)"
>上移</el-button
>
<el-button
size="small"
:disabled="!editable || $index === episodes.length - 1"
@click="moveEpisode(row, 1)"
>下移</el-button
>
<el-button size="small" :disabled="!editable" @click="openEpisode(row)"
>编辑</el-button
>
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="video/*"
:http-request="(options) => requestVideo(row, options)"
:disabled="!editable || uploadingId !== null"
>
<el-button size="small">上传视频</el-button>
</el-upload>
<el-button
type="danger"
size="small"
:disabled="!editable"
@click="removeEpisode(row)"
>删除</el-button
>
</div>
</template>
</el-table-column>
</el-table>
</div>
<p v-if="uploadingId !== null" class="creator-upload-hint"
>上传中 {{ uploadPercent }}%(分片上传,可断点续传)</p
>
@@ -279,56 +291,75 @@ function requestDramaCover(options) {
<section class="panel">
<h3 class="creator-section-title">提交记录</h3>
<el-table
:data="submissions"
row-key="id"
border
class="drama-el-table"
empty-text="还没有提交记录"
>
<el-table-column prop="version" label="版本" width="70" />
<el-table-column label="类型" width="100">
<template #default="{ row }">{{ submissionTypeText(row.type) }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag size="small" :type="submissionStatusMeta(row.status).tag" effect="light">
{{ submissionStatusMeta(row.status).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="submitAt"
label="提交时间"
min-width="165"
:formatter="dateCell"
show-overflow-tooltip
/>
<el-table-column
prop="reviewAt"
label="审核时间"
min-width="165"
:formatter="dateCell"
show-overflow-tooltip
/>
<el-table-column prop="reviewNote" label="审核意见" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button
v-if="canEdit && ['SUBMITTED', 'REVIEWING'].includes(row.status)"
type="danger"
size="small"
@click="withdraw(row.id)"
>撤回</el-button
>
<span v-else class="console-muted">—</span>
</div>
</template>
</el-table-column>
</el-table>
<div class="creator-table-scroll creator-table-scroll--submissions">
<el-table
:data="submissions"
row-key="id"
border
class="drama-el-table"
style="min-width: 1000px"
empty-text="还没有提交记录"
>
<el-table-column prop="version" label="版本" width="70" />
<el-table-column label="类型" width="100">
<template #default="{ row }">{{ submissionTypeText(row.type) }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag size="small" :type="submissionStatusMeta(row.status).tag" effect="light">
{{ submissionStatusMeta(row.status).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="submitAt"
label="提交时间"
min-width="165"
:formatter="dateCell"
show-overflow-tooltip
/>
<el-table-column
prop="reviewAt"
label="审核时间"
min-width="165"
:formatter="dateCell"
show-overflow-tooltip
/>
<el-table-column
prop="reviewNote"
label="审核意见"
min-width="200"
show-overflow-tooltip
/>
<el-table-column label="操作" width="110">
<template #default="{ row }">
<div class="table-actions">
<el-button
v-if="canEdit && ['SUBMITTED', 'REVIEWING'].includes(row.status)"
type="danger"
size="small"
@click="withdraw(row.id)"
>撤回</el-button
>
<span v-else class="console-muted">—</span>
</div>
</template>
</el-table-column>
</el-table>
</div>
</section>
<el-dialog v-model="videoPreviewVisible" title="视频预览" width="720px" append-to-body>
<video
v-if="videoPreviewUrl"
:src="videoPreviewUrl"
controls
autoplay
playsinline
class="creator-video-preview"
></video>
</el-dialog>
<el-dialog
v-model="episodeVisible"
:title="episodeEditingId ? '编辑分集' : '新增分集'"
@@ -353,14 +384,17 @@ function requestDramaCover(options) {
<img v-if="episodeForm.cover" :src="episodeForm.cover" alt="" />
<span v-else>无</span>
</div>
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="image/*"
:http-request="(options) => requestEpisodeCover({ id: episodeEditingId }, options)"
>
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
</el-upload>
<div class="creator-cover-actions">
<el-upload
v-if="canUpload"
:show-file-list="false"
accept="image/*"
:http-request="(options) => requestEpisodeCover({ id: episodeEditingId }, options)"
>
<el-button size="small"> <AppIcon :icon="AppIcons.upload" />上传封面 </el-button>
</el-upload>
<p class="creator-cover-hint">{{ EPISODE_COVER.hint }}</p>
</div>
</div>
</el-form-item>
<el-form-item v-if="episodeEditingId" label="集号">
File diff suppressed because it is too large. Load diff
+159 -112
View File
@@ -30,16 +30,12 @@ const {
profileLoading,
dramas,
notices,
createVisible,
createSaving,
createForm,
queryDramas,
loadDramas,
loadNotices,
submit,
withdraw,
openCreate,
confirmCreate
remove
} = useCreatorWorkspace()
const activeTab = ref('dramas')
@@ -59,6 +55,11 @@ function onTabChange(name) {
function openEdit(row) {
router.push(`/creator-center/dramas/${row.id}`)
}
/** 抖音式发布:直接进发布页选视频,不再先建空草稿 */
function openPublish() {
router.push('/creator-center/publish')
}
</script>
<template>
@@ -68,8 +69,8 @@ function openEdit(row) {
title="我的作品"
description="查看作品与审核进度,提交上线 / 下线申请。"
>
<el-button v-if="canEdit && !impersonating" type="primary" @click="openCreate"
>新建作品</el-button
<el-button v-if="canEdit && !impersonating" type="primary" @click="openPublish"
>发布作品</el-button
>
</PageHeading>
@@ -114,7 +115,7 @@ function openEdit(row) {
/>
</section>
<section class="panel">
<section class="panel creator-workspace-wrap">
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="我的作品" name="dramas">
<div class="console-toolbar">
@@ -146,47 +147,151 @@ function openEdit(row) {
<span class="console-count">共 {{ dramas.total }} 部作品</span>
</div>
<el-table
v-loading="dramas.loading"
:data="dramas.rows"
row-key="id"
border
class="drama-el-table"
empty-text="还没有作品,点右上角「新建作品」开始"
>
<el-table-column label="作品" min-width="220">
<template #default="{ row }">
<div class="creator-drama-cell">
<div class="creator-drama-cover">
<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>
<div class="creator-desktop-only">
<el-table
v-loading="dramas.loading"
:data="dramas.rows"
row-key="id"
border
class="drama-el-table"
empty-text="还没有作品,点右上角「发布作品」开始"
>
<el-table-column label="作品" min-width="220">
<template #default="{ row }">
<div class="creator-drama-cell">
<div class="creator-drama-cover">
<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>
</div>
<div class="creator-drama-meta">
<span class="creator-drama-title">{{ row.title }}</span>
<span class="creator-drama-code">{{ row.code }}</span>
</div>
</div>
<div class="creator-drama-meta">
<span class="creator-drama-title">{{ row.title }}</span>
<span class="creator-drama-code">{{ row.code }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="dramaStatusMeta(row.status).tag" effect="light">{{
dramaStatusMeta(row.status).label
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="episodes" label="分集" width="80" />
<el-table-column prop="views" label="播放" width="100" />
<el-table-column label="审核进度" min-width="200">
<template #default="{ row }">
<div v-if="row.submissionStatus" class="creator-progress">
<el-tag
size="small"
:type="submissionStatusMeta(row.submissionStatus).tag"
effect="plain"
>
{{ submissionTypeText(row.submissionType) }}·{{
submissionStatusMeta(row.submissionStatus).label
}}
</el-tag>
<span v-if="row.reviewNote" class="console-muted" :title="row.reviewNote">{{
row.reviewNote
}}</span>
</div>
<span v-else class="console-muted">尚未提交</span>
</template>
</el-table-column>
<el-table-column
prop="updatedAt"
label="更新时间"
min-width="165"
:formatter="dateCell"
show-overflow-tooltip
/>
<el-table-column label="操作" width="280">
<template #default="{ row }">
<div class="table-actions">
<el-button
v-if="canEdit && !impersonating"
type="primary"
size="small"
@click="openEdit(row)"
>编辑</el-button
>
<el-dropdown
v-if="canEdit && !impersonating && submitOptions(row).length"
trigger="click"
@command="(type) => submit(row, type)"
>
<el-button size="small">提交审核</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="type in submitOptions(row)"
:key="type"
:command="type"
>
{{ submissionTypeText(type) }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-if="
canEdit &&
!impersonating &&
row.submissionId &&
['SUBMITTED', 'REVIEWING'].includes(row.submissionStatus)
"
type="danger"
size="small"
@click="withdraw(row)"
>撤回</el-button
>
<el-button
v-if="canEdit && !impersonating"
text
type="danger"
size="small"
@click="remove(row)"
>删除</el-button
>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div v-loading="dramas.loading" class="creator-mobile-only creator-drama-list">
<el-empty v-if="!dramas.rows.length" description="还没有作品,点下方「发布作品」开始" />
<article v-for="row in dramas.rows" :key="row.id" class="creator-drama-card">
<div class="creator-drama-card-cover" @click="openEdit(row)">
<AppImage
v-if="row.cover"
:src="row.cover"
fit="cover"
class="creator-drama-image"
:alt="row.title"
/>
<span v-else>{{ (row.title || '?').slice(0, 1) }}</span>
</div>
<div class="creator-drama-card-body">
<div class="creator-drama-card-head">
<span class="creator-drama-title">{{ row.title }}</span>
<el-tag size="small" :type="dramaStatusMeta(row.status).tag" effect="light">
{{ dramaStatusMeta(row.status).label }}
</el-tag>
</div>
<span class="creator-drama-code">{{ row.code }}</span>
<div class="creator-drama-card-meta">
<span>{{ row.episodes }} 集</span>
<span>{{ row.views }} 播放</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="dramaStatusMeta(row.status).tag" effect="light">{{
dramaStatusMeta(row.status).label
}}</el-tag>
</template>
</el-table-column>
<el-table-column prop="episodes" label="分集" width="80" />
<el-table-column prop="views" label="播放" width="100" />
<el-table-column label="审核进度" min-width="200">
<template #default="{ row }">
<div v-if="row.submissionStatus" class="creator-progress">
<el-tag
size="small"
@@ -197,32 +302,12 @@ function openEdit(row) {
submissionStatusMeta(row.submissionStatus).label
}}
</el-tag>
<span v-if="row.reviewNote" class="console-muted" :title="row.reviewNote">{{
row.reviewNote
}}</span>
</div>
<span v-else class="console-muted">尚未提交</span>
</template>
</el-table-column>
<el-table-column
prop="updatedAt"
label="更新时间"
min-width="165"
:formatter="dateCell"
show-overflow-tooltip
/>
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<el-button
v-if="canEdit && !impersonating"
type="primary"
size="small"
@click="openEdit(row)"
>编辑</el-button
>
<div v-if="canEdit && !impersonating" class="creator-drama-card-actions">
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-dropdown
v-if="canEdit && !impersonating && submitOptions(row).length"
v-if="submitOptions(row).length"
trigger="click"
@command="(type) => submit(row, type)"
>
@@ -241,20 +326,18 @@ function openEdit(row) {
</el-dropdown>
<el-button
v-if="
canEdit &&
!impersonating &&
row.submissionId &&
['SUBMITTED', 'REVIEWING'].includes(row.submissionStatus)
row.submissionId && ['SUBMITTED', 'REVIEWING'].includes(row.submissionStatus)
"
type="danger"
size="small"
@click="withdraw(row)"
>撤回</el-button
>
<el-button text type="danger" size="small" @click="remove(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</article>
</div>
<div class="pagination">
<el-pagination
@@ -290,41 +373,5 @@ function openEdit(row) {
</el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="createVisible" title="新建作品" width="480px">
<el-form label-position="top">
<el-form-item label="作品名称" required>
<el-input
v-model="createForm.title"
maxlength="64"
placeholder="例如:闪婚后,傅先生每天都在撒糖"
/>
</el-form-item>
<el-form-item label="题材">
<el-input v-model="createForm.genre" maxlength="32" placeholder="例如:都市情感" />
</el-form-item>
<el-form-item label="简介">
<el-input
v-model="createForm.description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-alert
type="info"
:closable="false"
show-icon
title="先建草稿,再去作品编辑页完善信息、分集与视频,然后提交审核"
/>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createSaving" @click="confirmCreate"
>创建草稿</el-button
>
</template>
</el-dialog>
</CreatorCenterPage>
</template>
@@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createDramaApi,
deleteDramaApi,
getCreatorProfileApi,
listCreatorNoticesApi,
pageMyDramasApi,
@@ -168,6 +169,32 @@ export function useCreatorWorkspace() {
await loadDramas()
}
/** 删作品。已上线的后端会拦,前端按状态给提示。 */
async function remove(row) {
const label = row.title || '该作品'
try {
await ElMessageBox.confirm(
`《${label}》将被永久删除(分集、封面、审核记录一并清理),且不可恢复。确认删除?`,
'删除作品',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger'
}
)
} catch {
return
}
try {
await deleteDramaApi(row.id)
ElMessage.success('作品已删除')
await loadDramas()
} catch (e) {
// 后端可能抛"已上线,请先下线"之类的业务错误;ElMessage 全局拦截器会弹 message
}
}
const createVisible = ref(false)
const createSaving = ref(false)
const createForm = reactive({ title: '', genre: '', description: '' })
@@ -216,6 +243,7 @@ export function useCreatorWorkspace() {
loadNotices,
submit,
withdraw,
remove,
openCreate,
confirmCreate
}
@@ -18,6 +18,11 @@ import {
} from '@/api/admin/creatorCenter'
import { dateCell } from '@/utils/format'
import { probeVideoDuration, resumeUpload, uploadFile } from '@/utils/uploader'
import {
DRAMA_COVER_PORTRAIT,
EPISODE_COVER,
compressForPreset
} from '@/views/contentManagement/upload/imagePresets'
import { submissionStatusMeta, submissionTypeText, submitOptions } from './useCreatorWorkspace'
import { useCreatorAccess } from './useCreatorAccess'
@@ -254,7 +259,7 @@ export function useDramaEdit() {
ElMessage.success('视频已上传并挂到该分集')
await loadEpisodes()
} catch {
resumeHint.value = `「${file.name}」上传中断,进度已记住,可点「继续上传」接着传`
resumeHint.value = `「${file.name}」上传中断,进度已记住;重新选择同一个文件即可续传`
} finally {
uploadingId.value = null
uploadPercent.value = 0
@@ -266,7 +271,9 @@ export function useDramaEdit() {
uploadingId.value = row ? row.id : -1
uploadPercent.value = 0
try {
const asset = await uploadFile(file, {
const preset = row ? EPISODE_COVER : DRAMA_COVER_PORTRAIT
const compressed = await compressForPreset(file, preset)
const asset = await uploadFile(compressed, {
bizType: 'DRAMA_COVER',
onProgress: (percent) => (uploadPercent.value = percent)
})
+24 -9
View File
@@ -43,6 +43,7 @@ const {
openAudit,
confirmAudit,
freeze,
unfreeze,
createVisible,
createSaving,
createForm,
@@ -61,8 +62,8 @@ const {
<template>
<PageHeading
eyebrow="创作者管理"
title="创作者入驻"
description="审核创作者主体与资质,通过后创作者才能提交内容。"
title="创作者账号"
description="录入创作者并直接开通后台账号,账号即开即用,无需入驻审核。"
>
<el-button v-if="canAudit" type="primary" @click="openCreate">新增创作者</el-button>
</PageHeading>
@@ -170,13 +171,13 @@ const {
<template #default="{ row }">
<div class="table-actions">
<el-button type="primary" size="small" @click="openDocuments(row)">资质</el-button>
<!-- 状态机:通过可从 pending/rejected/frozen 发起,驳回只允许 pending,冻结只允许 approved。
已通过的行走「冻结 / 重置密码」,不再给「审核」入口(点了必然被后端拒) -->
<!-- 账号由后台直接发放:已通过 / 冻结行都不再给「审核」入口;
C 端提交的入驻申请仍是 pending,可在这里一次性通过 -->
<el-button
v-if="canAudit && row.status !== 'approved'"
v-if="canAudit && row.status === 'pending'"
size="small"
@click="openAudit(row, 'APPROVE')"
>审核</el-button
>通过</el-button
>
<el-button
v-if="canAudit && row.status === 'approved'"
@@ -185,12 +186,18 @@ const {
@click="freeze(row)"
>冻结</el-button
>
<el-button
v-if="canAudit && row.status === 'frozen'"
size="small"
@click="unfreeze(row)"
>解冻</el-button
>
<el-button
v-if="canOpenAccount && !row.adminUserId"
type="primary"
size="small"
:loading="accountSaving"
:disabled="row.status !== 'approved'"
:disabled="row.status === 'frozen'"
@click="openAccount(row)"
>开通账号</el-button
>
@@ -299,16 +306,24 @@ const {
<el-form-item label="国家 / 地区">
<el-input v-model="createForm.countryCode" maxlength="8" placeholder="ID" />
</el-form-item>
<el-form-item label="同时开通后台账号">
<el-switch v-model="createForm.openAccount" />
<span class="console-muted creator-switch-hint"
>开启后保存即生成登录账号与初始密码,一次完成</span
>
</el-form-item>
<el-alert
type="info"
:closable="false"
show-icon
title="编号自动生成(CR+日期+序号),也是开通账号时默认的登录名;录入后需要先通过入驻审核才能开通账号"
title="编号自动生成(CR+日期+序号),也是开通账号时默认的登录名;无需入驻审核"
/>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createSaving" @click="confirmCreate">录入</el-button>
<el-button type="primary" :loading="createSaving" @click="confirmCreate">{{
createForm.openAccount ? '创建并开通账号' : '仅创建'
}}</el-button>
</template>
</el-dialog>
+264 -16
View File
@@ -4,6 +4,7 @@
import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac'
import { dateCell } from '@/utils/format'
import { detailSubmissionApi } from '@/api/admin/creator'
import {
SUBMISSION_STATUS,
SUBMISSION_TYPE,
@@ -11,6 +12,7 @@ import {
submissionTypeText,
useSubmissionAudit
} from './composables/useSubmissionAudit'
import { ref } from 'vue'
const { can } = useRbac()
const canAudit = can('submission:audit')
@@ -32,9 +34,25 @@ const {
query,
reset,
handleSizeChange,
openAudit,
openAudit: rawOpenAudit,
confirmAudit
} = useSubmissionAudit()
const auditDetailLoading = ref(false)
async function openAudit(row, defaultAction = 'APPROVE') {
rawOpenAudit(row, defaultAction)
auditDetailLoading.value = true
try {
const detail = await detailSubmissionApi(row.id)
// 用 detail 里的完整字段覆盖 auditTarget(保留原始 openAudit 设置的 action)
Object.assign(auditTarget.value, detail)
} catch (e) {
console.warn('加载提交详情失败', e)
} finally {
auditDetailLoading.value = false
}
}
</script>
<template>
@@ -94,9 +112,19 @@ const {
class="drama-el-table"
empty-text="没有符合条件的提交记录"
>
<el-table-column prop="id" label="提交ID" width="90" />
<el-table-column prop="dramaId" label="短剧ID" width="90" />
<el-table-column prop="creatorId" label="创作者ID" width="100" />
<el-table-column prop="id" label="提交ID" width="140" />
<el-table-column prop="dramaId" label="短剧ID" width="140" />
<el-table-column label="创作者" min-width="180">
<template #default="{ row }">
<div style="display: flex; flex-direction: column; line-height: 1.4">
<span style="font-weight: 500">{{ row.creatorName || '—' }}</span>
<span style="font-size: 12px; color: var(--el-text-color-secondary)">
{{ row.creatorCode || '' }}{{ row.creatorCode && row.creatorMobile ? ' · ' : ''
}}{{ row.creatorMobile || '' }}
</span>
</div>
</template>
</el-table-column>
<el-table-column label="提交类型" width="110">
<template #default="{ row }">{{ submissionTypeText(row.type) }}</template>
</el-table-column>
@@ -153,18 +181,93 @@ const {
</div>
</section>
<el-dialog v-model="auditVisible" title="内容提交审核" width="480px">
<el-form label-position="top">
<el-form-item label="提交信息">
<el-input
:model-value="
auditTarget
? `提交 #${auditTarget.id} · 短剧 ${auditTarget.dramaId} · ${submissionTypeText(auditTarget.type)}`
: ''
"
disabled
/>
</el-form-item>
<el-dialog v-model="auditVisible" title="内容提交审核" width="640px">
<div v-loading="auditDetailLoading" class="audit-detail">
<!-- 顶部:创作者 + 提交信息 -->
<div class="audit-meta">
<div class="audit-meta-item">
<span class="audit-meta-label">创作者</span>
<span class="audit-meta-value">
{{ auditTarget?.creatorName || '—' }}
<span v-if="auditTarget?.creatorCode" class="audit-meta-sub">
· {{ auditTarget.creatorCode }}
</span>
<span v-if="auditTarget?.creatorMobile" class="audit-meta-sub">
· {{ auditTarget.creatorMobile }}
</span>
</span>
</div>
<div class="audit-meta-item">
<span class="audit-meta-label">提交</span>
<span class="audit-meta-value">
#{{ auditTarget?.id }} ·
<el-tag size="small" effect="plain">
{{ submissionTypeText(auditTarget?.type) }}
</el-tag>
·
<el-tag size="small" :type="submissionStatusMeta(auditTarget?.status).tag">
{{ submissionStatusMeta(auditTarget?.status).label }}
</el-tag>
<span v-if="auditTarget?.version" class="audit-meta-sub">
· v{{ auditTarget.version }}
</span>
</span>
</div>
</div>
<!-- 短剧预览卡片 -->
<div v-if="auditTarget?.dramaTitle" class="audit-drama">
<div class="audit-drama-cover">
<img
v-if="auditTarget.dramaCover"
:src="auditTarget.dramaCover"
:alt="auditTarget.dramaTitle"
/>
<div v-else class="audit-drama-cover-placeholder">无封面</div>
</div>
<div class="audit-drama-info">
<h4 class="audit-drama-title">{{ auditTarget.dramaTitle }}</h4>
<div class="audit-drama-tags">
<el-tag v-if="auditTarget.dramaGenre" size="small">{{ auditTarget.dramaGenre }}</el-tag>
<el-tag v-if="auditTarget.dramaLanguage" size="small" type="info">
{{ auditTarget.dramaLanguage }}
</el-tag>
<el-tag v-if="auditTarget.dramaTotalEpisodes != null" size="small" type="success">
共 {{ auditTarget.dramaTotalEpisodes }} 集
</el-tag>
</div>
<p v-if="auditTarget.dramaDescription" class="audit-drama-desc">
{{ auditTarget.dramaDescription }}
</p>
</div>
</div>
<div v-else class="audit-drama audit-drama-empty">短剧预览加载中…</div>
<!-- 分集预览 -->
<div v-if="auditTarget?.episodePreviews?.length" class="audit-episodes">
<div class="audit-section-title">分集列表({{ auditTarget.episodePreviews.length }})</div>
<div class="audit-episode-list">
<div v-for="ep in auditTarget.episodePreviews" :key="ep.id" class="audit-episode-item">
<div class="audit-episode-no">第{{ ep.epNo }}集</div>
<div class="audit-episode-title">{{ ep.title || '(未命名)' }}</div>
<div class="audit-episode-meta">
<span v-if="ep.durationSec">{{ ep.durationSec }}s</span>
<el-tag
v-if="ep.status"
size="small"
:type="
ep.status === 'success' ? 'success' : ep.status === 'failed' ? 'danger' : 'info'
"
>
{{ ep.status }}
</el-tag>
</div>
</div>
</div>
</div>
</div>
<el-form label-position="top" style="margin-top: 16px">
<el-form-item label="审核结果">
<el-radio-group v-model="auditForm.action">
<el-radio label="APPROVE">通过</el-radio>
@@ -201,3 +304,148 @@ const {
</template>
</el-dialog>
</template>
<style scoped>
.audit-detail {
display: flex;
flex-direction: column;
gap: 14px;
}
.audit-meta {
display: flex;
flex-wrap: wrap;
gap: 16px 28px;
padding: 10px 14px;
background: var(--el-fill-color-light);
border-radius: 8px;
font-size: 13px;
}
.audit-meta-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.audit-meta-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.audit-meta-value {
font-weight: 500;
color: var(--el-text-color-primary);
}
.audit-meta-sub {
font-weight: 400;
color: var(--el-text-color-secondary);
}
.audit-drama {
display: flex;
gap: 14px;
padding: 14px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
}
.audit-drama-cover {
width: 84px;
height: 112px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
background: var(--el-fill-color-light);
display: flex;
align-items: center;
justify-content: center;
}
.audit-drama-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.audit-drama-cover-placeholder {
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.audit-drama-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.audit-drama-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.audit-drama-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.audit-drama-desc {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.audit-drama-empty {
justify-content: center;
align-items: center;
color: var(--el-text-color-placeholder);
font-size: 13px;
}
.audit-episodes {
display: flex;
flex-direction: column;
gap: 8px;
}
.audit-section-title {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.audit-episode-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 180px;
overflow-y: auto;
padding-right: 4px;
}
.audit-episode-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--el-fill-color-light);
border-radius: 6px;
font-size: 13px;
}
.audit-episode-no {
flex-shrink: 0;
color: var(--el-text-color-secondary);
font-weight: 500;
min-width: 56px;
}
.audit-episode-title {
flex: 1;
min-width: 0;
color: var(--el-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audit-episode-meta {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>
@@ -146,6 +146,22 @@ export function useCreatorAudit() {
await load()
}
/** 解冻:冻结是可控状态,解冻后即可恢复使用与提交 */
async function unfreeze(row) {
try {
await ElMessageBox.confirm(`解冻后「${row.name}」可继续使用,确认解冻?`, '解冻创作者', {
confirmButtonText: '确认解冻',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
await auditCreatorApi(row.id, { action: 'APPROVE', reason: '运营解冻' })
ElMessage.success('已解冻')
await load()
}
onMounted(load)
// ---------- 手工录入创作者主体 ----------
@@ -156,7 +172,9 @@ export function useCreatorAudit() {
type: 'COMPANY',
contactName: '',
contactEmail: '',
countryCode: 'ID'
countryCode: 'ID',
// 录入即开号:默认勾选,保存后直接弹出账号密码
openAccount: true
})
function openCreate() {
@@ -165,7 +183,8 @@ export function useCreatorAudit() {
type: 'COMPANY',
contactName: '',
contactEmail: '',
countryCode: 'ID'
countryCode: 'ID',
openAccount: true
})
createVisible.value = true
}
@@ -174,9 +193,17 @@ export function useCreatorAudit() {
if (!createForm.name.trim()) return ElMessage.warning('请填写创作者名称')
createSaving.value = true
try {
await createCreatorApi({ ...createForm, name: createForm.name.trim() })
ElMessage.success('已录入,待入驻审核通过后才能开通账号')
const created = await createCreatorApi({ ...createForm, name: createForm.name.trim() })
const creatorId = created.data
createVisible.value = false
if (createForm.openAccount && creatorId) {
// 录入后立刻开号,凭据只在这里出现一次
const res = await openCreatorAccountApi(creatorId)
accountResult.value = res.data || null
accountVisible.value = true
} else {
ElMessage.success('创作者已创建')
}
await load()
} finally {
createSaving.value = false
@@ -264,6 +291,7 @@ export function useCreatorAudit() {
openAudit,
confirmAudit,
freeze,
unfreeze,
createVisible,
createSaving,
createForm,
+389 -136
View File
@@ -1,145 +1,324 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { onMounted, ref } from 'vue'
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import PageHeading from '@/views/components/PageHeading.vue'
import { getDashboardOverviewApi, getTranscodeBoardApi } from '@/api/admin/dashboard'
import DashboardMetricWall from '@/views/dashboard/DashboardMetricWall.vue'
import echarts from '@/plugins/echarts'
import {
getDashboardDistributionApi,
getDashboardOverviewApi,
getDashboardTrendApi,
getTranscodeBoardApi
} from '@/api/admin/dashboard'
import type {
DashboardDistribution,
DashboardOverview,
DashboardTrend,
TranscodeBoard
} from '@/api/admin/dashboard'
import { dateCell } from '@/utils/format'
/**
* 指标展示配置:key 与后端 /admin/dashboard/overview 返回的字段一一对应,
* 不在前端编字段;口径说明见后端 DashboardService 注释与 docs/api-phase1.md「2.8」。
*/
const GROUPS = [
{
key: 'user',
title: '用户',
metrics: [
{ key: 'total', label: '总用户数' },
{ key: 'newToday', label: '今日新增' },
{ key: 'new7d', label: '近 7 天新增' },
{ key: 'active7d', label: '近 7 天活跃' },
{ key: 'banned', label: '封禁' },
{ key: 'limited', label: '受限' },
{ key: 'deactivated', label: '已注销' }
]
},
{
key: 'content',
title: '内容',
metrics: [
{ key: 'dramaTotal', label: '短剧总数' },
{ key: 'dramaPublished', label: '已上线短剧' },
{ key: 'dramaReviewing', label: '待审短剧' },
{ key: 'dramaDraft', label: '草稿短剧' },
{ key: 'dramaOffline', label: '已下线短剧' },
{ key: 'episodeTotal', label: '剧集总数' },
{ key: 'episodePublished', label: '已上线剧集' },
{ key: 'episodeWithMedia', label: '有片源剧集' },
{ key: 'commentPending', label: '待审评论' },
{ key: 'reportPending', label: '待处理举报' }
]
},
{
key: 'playback',
title: '播放',
metrics: [
{ key: 'viewTotal', label: '累计观看' },
{ key: 'playTotal', label: '累计播放' },
{ key: 'watchedDramas', label: '看过的剧(去重)' },
{ key: 'finishedDramas', label: '看过且完播' },
{ key: 'finishRate', label: '完播率' },
{ key: 'playEvents7d', label: '近 7 天播放流水' },
{ key: 'playEventsTotal', label: '播放流水总量' }
]
},
{
key: 'revenue',
title: '收入',
metrics: [
{ key: 'orderTotal', label: '订单总数' },
{ key: 'orderDelivered', label: '已交付订单' },
{ key: 'orderRefunded', label: '已退款订单' },
{ key: 'paidAmount', label: '已支付金额' },
{ key: 'coinRecharged', label: '金币充值' },
{ key: 'coinConsumed', label: '金币消耗' },
{ key: 'coinBalance', label: '金币余额' },
{ key: 'entitlementActive', label: '有效权益' }
]
},
{
key: 'cost',
title: '成本',
metrics: [
{ key: 'transcodeQueued', label: '转码排队' },
{ key: 'transcodeRunning', label: '转码中' },
{ key: 'transcodeFailed', label: '转码失败' },
{ key: 'transcodeSuccess', label: '转码成功' },
{ key: 'renditions', label: '多码率产物' },
{ key: 'otpSent7d', label: '近 7 天验证码' },
{ key: 'cdnTrafficBytes', label: 'CDN 流量' }
]
}
]
const loading = ref(false)
const overview = ref(null)
const transcode = ref(null)
const overview = ref<DashboardOverview | null>(null)
const trend = ref<DashboardTrend | null>(null)
const distribution = ref<DashboardDistribution | null>(null)
const transcode = ref<TranscodeBoard | null>(null)
const lastUpdated = ref('')
async function load() {
/** ECharts 容器 ref 与实例,统一管理 resize / dispose。 */
const trendRef = ref<HTMLElement>()
const dramaPieRef = ref<HTMLElement>()
const transcodePieRef = ref<HTMLElement>()
const episodePieRef = ref<HTMLElement>()
const chartInstances: echarts.ECharts[] = []
const statusLabel = {
reviewing: '待审',
published: '已上线',
draft: '草稿',
offline: '已下线'
} as const
const transcodeStatusLabel = {
QUEUED: '排队中',
RUNNING: '执行中',
SUCCESS: '成功',
FAILED: '失败',
CANCELED: '已取消'
} as const
function renderCharts() {
disposeAll()
renderTrend()
renderDramaPie()
renderTranscodePie()
renderEpisodePie()
renderQueueBar()
}
const queueBarRef = ref<HTMLElement>()
function renderQueueBar() {
if (!queueBarRef.value || !transcode.value) return
const q = transcode.value.queue || {}
const chart = echarts.init(queueBarRef.value)
chartInstances.push(chart)
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 80, right: 24, top: 16, bottom: 24 },
xAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
yAxis: { type: 'category', data: ['排队', '执行中', '成功', '失败'] },
series: [
{
type: 'bar',
data: [q.queued || 0, q.running || 0, q.success || 0, q.failed || 0],
itemStyle: {
borderRadius: [0, 4, 4, 0],
color: (p: { dataIndex: number }) =>
['#F6BD16', '#5B8FF9', '#5AD8A6', '#E86452'][p.dataIndex]
},
label: { show: true, position: 'right' }
}
]
})
}
async function loadAndRender() {
loading.value = true
try {
const [board, queue] = await Promise.all([getDashboardOverviewApi(), getTranscodeBoardApi()])
const [board, t, dist, queue] = await Promise.all([
getDashboardOverviewApi(),
getDashboardTrendApi(),
getDashboardDistributionApi(),
getTranscodeBoardApi()
])
overview.value = board.data || null
trend.value = t.data || null
distribution.value = dist.data || null
transcode.value = queue.data || null
lastUpdated.value = new Date().toLocaleString()
await nextTick()
renderCharts()
} finally {
loading.value = false
}
}
/** 取不到的指标后端返回 null,这里显示「暂无数据源」而不是 0 */
function display(value) {
if (value === null || value === undefined) return '暂无数据源'
if (typeof value === 'number' && !Number.isInteger(value)) return value.toFixed(3)
return value
function disposeAll() {
chartInstances.forEach((c) => c.dispose())
chartInstances.length = 0
}
function groupValue(groupKey, metricKey) {
return overview.value?.[groupKey]?.[metricKey]
const basePieSeriesOption = {
type: 'pie' as const,
radius: ['40%', '70%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false, position: 'center' },
emphasis: {
label: { show: true, fontSize: 16, fontWeight: 'bold' }
}
}
onMounted(load)
function renderTrend() {
if (!trendRef.value || !trend.value) return
const chart = echarts.init(trendRef.value)
chartInstances.push(chart)
chart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
legend: { top: 0, textStyle: { fontSize: 12 } },
grid: { left: 48, right: 48, top: 40, bottom: 28 },
xAxis: { type: 'category', data: trend.value.dates, boundaryGap: false },
yAxis: [
{ type: 'value', name: '用户/播放', splitLine: { lineStyle: { type: 'dashed' } } },
{ type: 'value', name: '转码任务', splitLine: { show: false } }
],
series: [
{
name: '新增用户',
type: 'line',
smooth: true,
yAxisIndex: 0,
data: trend.value.newUsers,
itemStyle: { color: '#5B8FF9' }
},
{
name: '播放事件',
type: 'line',
smooth: true,
yAxisIndex: 0,
data: trend.value.playEvents,
itemStyle: { color: '#5AD8A6' }
},
{
name: '新增转码',
type: 'bar',
yAxisIndex: 1,
data: trend.value.newTranscodes,
itemStyle: { color: '#F6BD16', borderRadius: [3, 3, 0, 0] }
},
{
name: '转码成功',
type: 'bar',
yAxisIndex: 1,
data: trend.value.transcodeSuccess,
itemStyle: { color: '#945FB9', borderRadius: [3, 3, 0, 0] }
}
]
})
}
function renderDramaPie() {
if (!dramaPieRef.value || !distribution.value) return
const data = Object.entries(distribution.value.dramaStatus || {}).map(([k, v]) => ({
name: (statusLabel as Record<string, string>)[k] || k,
value: v
}))
const chart = echarts.init(dramaPieRef.value)
chartInstances.push(chart)
chart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 0, itemWidth: 12, itemHeight: 12, textStyle: { fontSize: 12 } },
color: ['#F6BD16', '#5AD8A6', '#5B8FF9', '#E86452'],
series: [{ ...basePieSeriesOption, name: '短剧状态', data }]
})
}
function renderTranscodePie() {
if (!transcodePieRef.value || !distribution.value) return
const data = Object.entries(distribution.value.transcodeStatus || {}).map(([k, v]) => ({
name: (transcodeStatusLabel as Record<string, string>)[k] || k,
value: v
}))
const chart = echarts.init(transcodePieRef.value)
chartInstances.push(chart)
chart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 0, itemWidth: 12, itemHeight: 12, textStyle: { fontSize: 12 } },
color: ['#F6BD16', '#5B8FF9', '#5AD8A6', '#E86452', '#C9C9C9'],
series: [{ ...basePieSeriesOption, name: '转码状态', data }]
})
}
function renderEpisodePie() {
if (!episodePieRef.value || !distribution.value) return
const data = Object.entries(distribution.value.episodeMedia || {}).map(([k, v]) => ({
name: k,
value: v
}))
const chart = echarts.init(episodePieRef.value)
chartInstances.push(chart)
chart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 0, itemWidth: 12, itemHeight: 12, textStyle: { fontSize: 12 } },
color: ['#5AD8A6', '#E86452'],
series: [{ ...basePieSeriesOption, name: '片源覆盖', data }]
})
}
function onResize() {
chartInstances.forEach((c) => c.resize())
}
onMounted(loadAndRender)
window.addEventListener('resize', onResize)
onBeforeUnmount(() => {
disposeAll()
window.removeEventListener('resize', onResize)
})
</script>
<template>
<PageHeading
eyebrow="数据看板"
title="运营大盘"
description="用户、内容、播放、收入与成本总览,以及转码队列。"
description="用户、设备、留存、内容、播放与成本总览,以及近 7 天趋势。"
>
<el-button :loading="loading" @click="load">刷新</el-button>
<el-button :loading="loading" @click="loadAndRender">刷新</el-button>
</PageHeading>
<section v-loading="loading" class="panel">
<p class="console-hint"
>数据时间:{{ lastUpdated }}(口径见每组的后端定义,缺数据源显示「暂无数据源」)</p
>
<div v-for="group in GROUPS" :key="group.key" class="board-group">
<h3 class="board-title">{{ group.title }}</h3>
<div class="board-grid">
<div v-for="metric in group.metrics" :key="metric.key" class="board-card">
<span class="board-label">{{ metric.label }}</span>
<span
class="board-value"
:class="{ 'board-value-empty': groupValue(group.key, metric.key) === null }"
>
{{ display(groupValue(group.key, metric.key)) }}
</span>
<div v-loading="loading" class="board-wrap">
<DashboardMetricWall :overview="overview" :trend="trend" />
<!-- 趋势折线图(双 Y 轴 + 柱) -->
<section class="panel chart-panel">
<div class="panel-heading">
<div>
<h2>近 7 天趋势</h2>
<p>每日新增用户 / 播放事件 / 转码任务</p>
</div>
<span class="muted">数据时间:{{ lastUpdated }}</span>
</div>
<div ref="trendRef" class="chart chart-lg"></div>
</section>
<!-- 分布饼图组 -->
<section class="panel">
<div class="panel-heading">
<div>
<h2>状态分布</h2>
<p>短剧状态 / 转码状态 / 片源覆盖</p>
</div>
</div>
</div>
<div class="pie-row">
<div class="pie-cell">
<div class="pie-title">短剧状态</div>
<div ref="dramaPieRef" class="chart chart-pie"></div>
</div>
<div class="pie-cell">
<div class="pie-title">转码状态</div>
<div ref="transcodePieRef" class="chart chart-pie"></div>
</div>
<div class="pie-cell">
<div class="pie-title">片源覆盖</div>
<div ref="episodePieRef" class="chart chart-pie"></div>
</div>
</div>
</section>
<!-- 转码队列 + 失败明细 -->
<section class="panel transcode-panel">
<div class="panel-heading">
<div>
<h2>转码看板</h2>
<p>队列积压与失败任务明细</p>
</div>
<p class="muted">{{ transcode?.notes?.join(';') || '' }}</p>
</div>
<div class="transcode-grid">
<div ref="queueBarRef" class="chart chart-bar"></div>
<div class="transcode-fail">
<h3>失败任务(最近 20 条)</h3>
<el-table
:data="transcode?.failedTasks || []"
border
class="drama-el-table"
empty-text="当前没有失败的转码任务"
size="small"
>
<el-table-column prop="id" label="任务ID" width="100" />
<el-table-column prop="episodeId" label="剧集" width="100" />
<el-table-column prop="status" label="状态" width="90" />
<el-table-column prop="retryCount" label="重试" width="70" />
<el-table-column
prop="errorMsg"
label="失败原因"
min-width="220"
show-overflow-tooltip
/>
<el-table-column
prop="createdAt"
label="创建时间"
min-width="160"
show-overflow-tooltip
:formatter="dateCell"
/>
</el-table>
</div>
</div>
</section>
<!-- 口径说明 -->
<el-alert
v-if="overview?.notes?.length"
class="board-notes"
@@ -152,31 +331,105 @@ onMounted(load)
<li v-for="note in overview.notes" :key="note">{{ note }}</li>
</ul>
</el-alert>
</section>
<section class="panel board-transcode">
<h3 class="board-title">转码看板</h3>
<p class="console-hint">
{{ transcode?.notes?.join(';') || '' }}
</p>
<el-table
:data="transcode?.failedTasks || []"
border
class="drama-el-table"
empty-text="当前没有失败的转码任务"
>
<el-table-column prop="id" label="任务ID" width="90" />
<el-table-column prop="episodeId" label="剧集ID" width="90" />
<el-table-column prop="status" label="状态" width="110" />
<el-table-column prop="retryCount" label="重试次数" width="100" />
<el-table-column prop="errorMsg" label="失败原因" min-width="240" show-overflow-tooltip />
<el-table-column
prop="createdAt"
label="创建时间"
min-width="165"
show-overflow-tooltip
:formatter="dateCell"
/>
</el-table>
</section>
</div>
</template>
<style scoped lang="less">
.board-wrap {
display: flex;
flex-direction: column;
gap: 16px;
}
.panel {
padding: 20px 24px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.panel-heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
h3 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
p {
margin: 4px 0 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.muted {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
.chart {
width: 100%;
}
.chart-lg {
height: 320px;
}
.chart-pie {
height: 220px;
}
.chart-bar {
height: 220px;
}
.pie-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.pie-cell {
display: flex;
flex-direction: column;
align-items: center;
.pie-title {
margin-bottom: 4px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.transcode-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 20px;
}
.board-notes {
:deep(ul) {
padding-left: 18px;
margin: 4px 0 0;
font-size: 12px;
}
}
@media (width <= 900px) {
.pie-row,
.transcode-grid {
grid-template-columns: 1fr;
}
}
</style>
+455
View File
@@ -0,0 +1,455 @@
<!-- Author: fruit.com.cn -->
<!-- 工作台可视化大屏:用户、设备、留存与内容趋势。 -->
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { ECharts } from 'echarts/core'
import type { DashboardOverview, DashboardTrend } from '@/api/admin/dashboard'
import { formatCount } from '@/utils/format'
const props = defineProps<{
overview: DashboardOverview | null
trend?: DashboardTrend | null
fallback?: Record<string, number>
}>()
const trendRef = ref<HTMLElement>()
const deviceRef = ref<HTMLElement>()
const clock = ref('')
let trendChart: ECharts | null = null
let deviceChart: ECharts | null = null
let echartsPromise: Promise<typeof import('@/plugins/echarts').default> | null = null
let active = true
let timer = 0
const AXIS = '#909399'
const SPLIT = '#e6e8eb'
const BRAND = '#c4322a'
const AMBER = '#e9aa4b'
const kpis = computed(() => [
{ label: '总用户数', value: text('user', 'total'), accent: BRAND },
{ label: '今日新增', value: text('user', 'newToday'), accent: '#55b987' },
{ label: '近 7 天新增', value: text('user', 'new7d'), accent: AMBER },
{ label: '近 7 天活跃', value: text('user', 'active7d'), accent: '#d3615b' },
{ label: '累计播放', value: text('playback', 'viewTotal'), accent: BRAND },
{ label: '已上线短剧', value: text('content', 'dramaPublished'), accent: '#55b987' }
])
const rails = computed(() => [
{ label: '短剧总数', value: text('content', 'dramaTotal') },
{ label: '待审核', value: text('content', 'dramaReviewing') },
{ label: '有片源', value: text('content', 'episodeWithMedia') },
{ label: '近 7 天播放', value: text('playback', 'playEvents7d') },
{ label: '完播率', value: rate('playback', 'finishRate') },
{ label: '金币消耗', value: text('revenue', 'coinConsumed') },
{ label: '转码中', value: text('cost', 'transcodeRunning') },
{ label: '转码排队', value: text('cost', 'transcodeQueued') }
])
const retentionDay = computed(() => percent('user', 'retention1d'))
const retentionWeek = computed(() => percent('user', 'retention7d'))
function metric(group: string, key: string) {
const source = props.overview?.[group as keyof DashboardOverview]
if (source && typeof source === 'object') {
const value = (source as Record<string, number | null>)[key]
if (value !== undefined && value !== null) return Number(value)
}
const fallback = props.fallback?.[`${group}.${key}`]
return fallback == null ? null : Number(fallback)
}
function text(group: string, key: string) {
const value = metric(group, key)
return value == null || Number.isNaN(value) ? '--' : formatCount(value)
}
function percent(group: string, key: string) {
const value = metric(group, key)
if (value == null || Number.isNaN(value)) return null
return Math.max(0, Math.min(100, Math.round(value * 1000) / 10))
}
function rate(group: string, key: string) {
const value = percent(group, key)
return value == null ? '--' : `${value.toFixed(1)}%`
}
function tick() {
clock.value = new Date().toLocaleString('zh-CN', { hour12: false })
}
async function loadEcharts() {
echartsPromise ??= import('@/plugins/echarts').then((module) => module.default)
return echartsPromise
}
async function renderCharts() {
const echarts = await loadEcharts()
if (!active) return
renderTrend(echarts)
renderDevices(echarts)
}
function renderTrend(echarts: typeof import('@/plugins/echarts').default) {
if (!trendRef.value) return
trendChart = trendChart ?? echarts.init(trendRef.value)
const trend = props.trend
trendChart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: '#fff',
borderColor: '#e6e8eb',
textStyle: { color: '#303640' }
},
legend: { top: 0, left: 0, textStyle: { color: AXIS }, itemWidth: 12, itemHeight: 8 },
grid: { left: 42, right: 16, top: 32, bottom: 24 },
xAxis: {
type: 'category',
data: trend?.dates ?? [],
axisLine: { lineStyle: { color: 'rgba(255,255,255,.16)' } },
axisLabel: { color: AXIS, fontSize: 11 }
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: SPLIT, type: 'dashed' } },
axisLabel: { color: AXIS, fontSize: 11 }
},
series: [
{
name: '新增用户',
type: 'line',
smooth: true,
showSymbol: false,
data: trend?.newUsers ?? [],
lineStyle: { width: 2, color: BRAND },
areaStyle: { color: 'rgba(196, 50, 42, 0.12)' }
},
{
name: '播放事件',
type: 'line',
smooth: true,
showSymbol: false,
data: trend?.playEvents ?? [],
lineStyle: { width: 2, color: AMBER }
}
]
})
}
function renderDevices(echarts: typeof import('@/plugins/echarts').default) {
if (!deviceRef.value) return
deviceChart = deviceChart ?? echarts.init(deviceRef.value)
const android = metric('user', 'androidActive7d') ?? 0
const ios = metric('user', 'iosActive7d') ?? 0
deviceChart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: '#fff',
borderColor: '#e6e8eb',
textStyle: { color: '#303640' }
},
legend: { bottom: 0, textStyle: { color: AXIS }, itemWidth: 10, itemHeight: 10 },
title: {
text: formatCount(android + ios),
subtext: '活跃设备',
left: 'center',
top: '38%',
textStyle: { color: '#303640', fontSize: 22, fontWeight: 700 },
subtextStyle: { color: AXIS, fontSize: 12 }
},
series: [
{
type: 'pie',
radius: ['58%', '76%'],
center: ['50%', '46%'],
label: { show: false },
data: [
{ name: 'Android', value: android, itemStyle: { color: '#55b987' } },
{ name: 'iOS', value: ios, itemStyle: { color: BRAND } }
]
}
]
})
}
function onResize() {
trendChart?.resize()
deviceChart?.resize()
}
watch(
() => [props.overview, props.trend],
() => nextTick(renderCharts),
{ deep: true }
)
onMounted(() => {
tick()
timer = window.setInterval(tick, 1000)
window.addEventListener('resize', onResize)
nextTick(renderCharts)
})
onBeforeUnmount(() => {
active = false
window.clearInterval(timer)
window.removeEventListener('resize', onResize)
trendChart?.dispose()
deviceChart?.dispose()
})
</script>
<template>
<section class="screen">
<header class="screen-head">
<h2>运营概览</h2>
<time>{{ clock }}</time>
</header>
<div class="kpi-row">
<article v-for="item in kpis" :key="item.label" class="kpi">
<i :style="{ background: item.accent }"></i>
<div>
<strong>{{ item.value }}</strong>
<span>{{ item.label }}</span>
</div>
</article>
</div>
<div class="viz-row">
<article class="panel trend-panel">
<h3>近 7 日新增与播放</h3>
<div ref="trendRef" class="chart"></div>
</article>
<article class="panel">
<h3>活跃设备占比</h3>
<div ref="deviceRef" class="chart"></div>
</article>
<article class="panel retain">
<h3>用户留存</h3>
<div class="rings">
<div class="ring" :style="{ '--p': `${retentionDay ?? 0}%` }">
<strong>{{ retentionDay == null ? '--' : `${retentionDay.toFixed(1)}%` }}</strong>
<span>次日留存</span>
</div>
<div class="ring week" :style="{ '--p': `${retentionWeek ?? 0}%` }">
<strong>{{ retentionWeek == null ? '--' : `${retentionWeek.toFixed(1)}%` }}</strong>
<span>7 日留存</span>
</div>
</div>
</article>
</div>
<div class="rail">
<div v-for="item in rails" :key="item.label">
<strong>{{ item.value }}</strong>
<span>{{ item.label }}</span>
</div>
</div>
</section>
</template>
<style scoped lang="less">
.screen {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
min-height: 0;
color: #303640;
}
.screen-head {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #2f343c;
}
}
time {
font-size: 12px;
color: #909399;
font-variant-numeric: tabular-nums;
}
.kpi-row,
.viz-row,
.rail {
min-width: 0;
}
.kpi-row {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.kpi {
display: flex;
gap: 10px;
align-items: center;
min-width: 0;
min-height: 72px;
padding: 12px 14px;
background: #fff;
border: 1px solid #e6e8eb;
border-radius: 6px;
i {
width: 3px;
height: 32px;
border-radius: 3px;
flex: none;
}
strong {
display: block;
overflow: hidden;
font-size: 22px;
line-height: 1.1;
color: #303640;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
display: block;
margin-top: 4px;
font-size: 12px;
color: #909399;
}
}
.viz-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr) minmax(0, 0.7fr);
gap: 12px;
flex: 1;
min-height: 220px;
}
.panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 12px 14px 8px;
background: #fff;
border: 1px solid #e6e8eb;
border-radius: 6px;
h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #2f343c;
}
}
.chart {
width: 100%;
height: 240px;
}
.rings {
display: flex;
flex: 1;
justify-content: space-around;
align-items: center;
min-height: 180px;
}
.ring {
--p: 0%;
display: grid;
width: 112px;
height: 112px;
background: conic-gradient(#c4322a var(--p), #f5d9d8 0);
border-radius: 50%;
place-items: center;
&::after {
width: 82px;
height: 82px;
background: #fff;
border-radius: 50%;
content: '';
grid-area: 1 / 1;
}
strong,
span {
grid-area: 1 / 1;
z-index: 1;
}
strong {
margin-top: -12px;
font-size: 16px;
color: #303640;
}
span {
margin-top: 24px;
font-size: 12px;
color: #909399;
}
}
.week {
background: conic-gradient(#e9aa4b var(--p), #fff5e7 0);
}
.rail {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 12px;
div {
min-width: 0;
padding: 10px 8px;
text-align: center;
background: #fff;
border: 1px solid #e6e8eb;
border-radius: 6px;
}
strong {
display: block;
overflow: hidden;
font-size: 16px;
color: #303640;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
font-size: 12px;
color: #909399;
}
}
@media (width <= 760px) {
.kpi-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.viz-row,
.rail {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -0,0 +1,519 @@
<!-- Author: fruit.com.cn -->
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, ref } from 'vue'
import PageHeading from '@/views/components/PageHeading.vue'
import { AppIcons } from '@/constants/icons'
import { dateCell } from '@/utils/format'
import {
batchCreateAppVersionsApi,
deleteAppVersionApi,
listAppVersionsApi,
updateAppVersionApi,
updateAppVersionStatusApi
} from '@/api/admin/appVersion'
import type { AppVersionItem, AppVersionSaveData } from '@/api/admin/appVersion'
const platformOptions = [
{ label: 'Android', value: 'ANDROID' },
{ label: 'iOS', value: 'IOS' }
]
const statusOptions = [
{ label: '已发布', value: 1 },
{ label: '已下架', value: 0 }
]
interface FilterState {
platform: string
status: number | undefined
keyword: string
}
const loading = ref(false)
const list = ref<AppVersionItem[]>([])
const total = ref(0)
const pageIndex = ref(1)
const pageSize = ref(20)
const filter = ref<FilterState>({ platform: '', status: undefined, keyword: '' })
const dialogOpen = ref(false)
const formLoading = ref(false)
const editingId = ref<string | null>(null)
const formRef = ref<FormInstance>()
/** 新建时两个平台一起配(各自可启停),编辑时用单份。 */
interface PlatformBlock {
enabled: boolean
data: AppVersionSaveData
}
const emptyPlatform = (platform: 'ANDROID' | 'IOS'): AppVersionSaveData => ({
platform,
versionCode: 0,
versionName: '',
downloadUrl: '',
updateContent: '',
forceUpdate: 0,
minSupportedCode: 0
})
const androidBlock = ref<PlatformBlock>({ enabled: true, data: emptyPlatform('ANDROID') })
const iosBlock = ref<PlatformBlock>({ enabled: true, data: emptyPlatform('IOS') })
const editForm = ref<AppVersionSaveData>(emptyPlatform('ANDROID'))
const rules: FormRules = {
platform: [{ required: true, message: '请选择平台', trigger: 'change' }],
versionCode: [
{ required: true, message: '请填写整数版本号', trigger: 'blur' },
{ type: 'number', min: 1, max: 999999, message: '范围 1~999999', trigger: 'blur' }
],
versionName: [
{ required: true, message: '请填写展示版本号', trigger: 'blur' },
{ max: 32, message: '最长 32 字符', trigger: 'blur' }
],
downloadUrl: [
{ required: true, message: '请填写下载地址', trigger: 'blur' },
{ max: 512, message: '最长 512 字符', trigger: 'blur' }
]
}
async function fetchList() {
loading.value = true
try {
const res = await listAppVersionsApi({
pageIndex: pageIndex.value,
pageSize: pageSize.value,
platform: filter.value.platform || undefined,
status: filter.value.status,
keyword: filter.value.keyword.trim() || undefined
})
list.value = res.data?.list ?? []
total.value = res.data?.total ?? 0
} catch {
/* axios interceptor 已弹消息 */
} finally {
loading.value = false
}
}
function openCreate() {
editingId.value = null
androidBlock.value = { enabled: true, data: emptyPlatform('ANDROID') }
iosBlock.value = { enabled: true, data: emptyPlatform('IOS') }
dialogOpen.value = true
}
function openEdit(row: AppVersionItem) {
editingId.value = row.id
editForm.value = {
platform: row.platform,
versionCode: row.versionCode,
versionName: row.versionName,
downloadUrl: row.downloadUrl,
updateContent: row.updateContent ?? '',
forceUpdate: row.forceUpdate === 1 ? 1 : 0,
minSupportedCode: row.minSupportedCode
}
dialogOpen.value = true
}
async function submit() {
formLoading.value = true
try {
if (editingId.value) {
await updateAppVersionApi(editingId.value, editForm.value)
ElMessage.success('已更新')
} else {
const toCreate: AppVersionSaveData[] = []
if (androidBlock.value.enabled) toCreate.push(androidBlock.value.data)
if (iosBlock.value.enabled) toCreate.push(iosBlock.value.data)
if (toCreate.length === 0) {
ElMessage.warning('至少启用一个平台')
return
}
await batchCreateAppVersionsApi(toCreate)
ElMessage.success(`已创建 ${toCreate.length} 条版本`)
}
dialogOpen.value = false
await fetchList()
} catch {
/* axios interceptor 已弹消息 */
} finally {
formLoading.value = false
}
}
async function toggleStatus(row: AppVersionItem) {
const next = row.status === 1 ? 0 : 1
await updateAppVersionStatusApi(row.id, next as 0 | 1)
ElMessage.success(next === 1 ? '已发布' : '已下架')
await fetchList()
}
async function remove(row: AppVersionItem) {
await ElMessageBox.confirm(
`确认删除 ${row.platformText} ${row.versionName}(${row.versionCode})?`,
'删除版本',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
await deleteAppVersionApi(row.id)
ElMessage.success('已删除')
await fetchList()
}
const forceText = computed(() => (v: number) => (v === 1 ? '强制' : '可选升级'))
</script>
<template>
<PageHeading eyebrow="后台管理" title="版本管理" description="App 版本发布、上下架与删除。" />
<section class="toolbar">
<el-form :inline="true" :model="filter" @submit.prevent="fetchList">
<el-form-item label="平台">
<el-select v-model="filter.platform" clearable placeholder="全部" style="width: 120px">
<el-option
v-for="o in platformOptions"
:key="o.value"
:label="o.label"
:value="o.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filter.status" clearable placeholder="全部" style="width: 120px">
<el-option v-for="o in statusOptions" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="关键字">
<el-input
v-model="filter.keyword"
placeholder="匹配 versionName"
clearable
style="width: 180px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="AppIcons.search" @click="fetchList">查询</el-button>
<el-button plain @click="openCreate" :icon="AppIcons.add">新建版本</el-button>
</el-form-item>
</el-form>
</section>
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column prop="platformText" label="平台" width="100" />
<el-table-column prop="versionCode" label="版本码" width="90" />
<el-table-column prop="versionName" label="展示版本" width="120" />
<el-table-column prop="downloadUrl" label="下载地址" show-overflow-tooltip min-width="220">
<template #default="{ row }">
<el-link :href="row.downloadUrl" target="_blank" type="primary" :underline="false">
{{ row.downloadUrl }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="forceUpdate" label="升级" width="80">
<template #default="{ row }">
<el-tag :type="row.forceUpdate === 1 ? 'danger' : 'info'" size="small">
{{ forceText(row.forceUpdate) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="minSupportedCode" label="兜底最低" width="100" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.statusText }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="releasedAt" label="发布时间" width="160" :formatter="dateCell" />
<el-table-column prop="createdAt" label="创建时间" width="160" :formatter="dateCell" />
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" link type="warning" @click="toggleStatus(row)">
{{ row.status === 1 ? '下架' : '发布' }}
</el-button>
<el-button size="small" link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
v-model:current-page="pageIndex"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 50]"
@current-change="fetchList"
@size-change="fetchList"
/>
<el-dialog
v-model="dialogOpen"
:title="editingId ? '编辑版本' : '新建版本'"
width="820px"
@close="() => formRef?.resetFields()"
>
<!-- 编辑模式:单平台 -->
<el-form
v-if="editingId"
ref="formRef"
:model="editForm"
:rules="rules"
label-width="120px"
v-loading="formLoading"
>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="整数版本" prop="versionCode">
<el-input-number
v-model="editForm.versionCode"
:min="1"
:max="999999"
controls-position="right"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="展示版本" prop="versionName">
<el-input v-model="editForm.versionName" placeholder="如 1.2.0" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="下载地址" prop="downloadUrl">
<el-input v-model="editForm.downloadUrl" placeholder="Android apk / iOS App Store 链接" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="强制升级">
<el-switch
v-model="editForm.forceUpdate"
:active-value="1"
:inactive-value="0"
active-text="强制"
inactive-text="可选"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="兜底最低版本">
<el-input-number
v-model="editForm.minSupportedCode"
:min="0"
controls-position="right"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="更新说明">
<el-input
v-model="editForm.updateContent"
type="textarea"
:rows="3"
placeholder="换行分隔多条更新内容"
maxlength="2000"
show-word-limit
/>
</el-form-item>
</el-form>
<!-- 新建模式:Android + iOS 两块 -->
<div v-else class="dual-blocks" v-loading="formLoading">
<div class="platform-block" :class="{ disabled: !androidBlock.enabled }">
<header>
<span class="platform-title">Android</span>
<el-switch
v-model="androidBlock.enabled"
active-text="启用"
inactive-text="跳过"
inline-prompt
/>
</header>
<el-form
:model="androidBlock.data"
:rules="rules"
label-width="120px"
:disabled="!androidBlock.enabled"
>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="整数版本" prop="versionCode">
<el-input-number
v-model="androidBlock.data.versionCode"
:min="1"
:max="999999"
controls-position="right"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="展示版本" prop="versionName">
<el-input v-model="androidBlock.data.versionName" placeholder="如 1.2.0" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="下载地址" prop="downloadUrl">
<el-input v-model="androidBlock.data.downloadUrl" placeholder="apk 下载链接" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="强制升级">
<el-switch
v-model="androidBlock.data.forceUpdate"
:active-value="1"
:inactive-value="0"
active-text="强制"
inactive-text="可选"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="兜底最低">
<el-input-number
v-model="androidBlock.data.minSupportedCode"
:min="0"
controls-position="right"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="更新说明">
<el-input
v-model="androidBlock.data.updateContent"
type="textarea"
:rows="3"
placeholder="换行分隔多条"
maxlength="2000"
show-word-limit
/>
</el-form-item>
</el-form>
</div>
<div class="platform-block" :class="{ disabled: !iosBlock.enabled }">
<header>
<span class="platform-title">iOS</span>
<el-switch
v-model="iosBlock.enabled"
active-text="启用"
inactive-text="跳过"
inline-prompt
/>
</header>
<el-form
:model="iosBlock.data"
:rules="rules"
label-width="120px"
:disabled="!iosBlock.enabled"
>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="整数版本" prop="versionCode">
<el-input-number
v-model="iosBlock.data.versionCode"
:min="1"
:max="999999"
controls-position="right"
class="w-full"
/>
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="展示版本" prop="versionName">
<el-input v-model="iosBlock.data.versionName" placeholder="如 1.2.0" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="下载地址" prop="downloadUrl">
<el-input v-model="iosBlock.data.downloadUrl" placeholder="App Store 链接" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="强制升级">
<el-switch
v-model="iosBlock.data.forceUpdate"
:active-value="1"
:inactive-value="0"
active-text="强制"
inactive-text="可选"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="兜底最低">
<el-input-number
v-model="iosBlock.data.minSupportedCode"
:min="0"
controls-position="right"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="更新说明">
<el-input
v-model="iosBlock.data.updateContent"
type="textarea"
:rows="3"
placeholder="换行分隔多条"
maxlength="2000"
show-word-limit
/>
</el-form-item>
</el-form>
</div>
</div>
<template #footer>
<el-button @click="dialogOpen = false">取消</el-button>
<el-button type="primary" :loading="formLoading" @click="submit">
{{ editingId ? '保存' : '创建并发布' }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="less">
.toolbar {
margin-bottom: 12px;
}
.pagination {
margin-top: 12px;
justify-content: flex-end;
}
.dual-blocks {
display: flex;
flex-direction: column;
gap: 16px;
}
.platform-block {
padding: 14px 16px 4px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
header {
display: flex;
padding-bottom: 8px;
margin-bottom: 6px;
border-bottom: 1px solid var(--el-border-color-lighter);
align-items: center;
justify-content: space-between;
}
.platform-title {
font-size: 15px;
font-weight: 600;
}
&.disabled {
border-style: dashed;
opacity: 0.5;
}
}
</style>
+128 -4
View File
@@ -1,9 +1,11 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { listConfigsApi, updateConfigsApi } from '@/api/admin/config'
import { uploadFile } from '@/utils/uploader'
import { compressForPreset, LOGIN_BACKGROUND } from '@/views/contentManagement/upload/imagePresets'
import { useRbac } from '@/stores/rbac'
const { can } = useRbac()
@@ -14,6 +16,15 @@ const workspaceName = ref('')
const defaultStatus = ref('draft')
const registerMaxPerIp = ref(5)
const registerMaxPerDevice = ref(3)
const loginBackgroundUrl = ref('')
const MAX_LOGIN_BG_SIZE = 8 * 1024 * 1024 // 8 MB
const ALLOWED_LOGIN_BG_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const uploadingBg = ref(false)
const bgError = ref('')
const bgFileName = ref('')
const canSave = computed(() => can('system:role'))
const statusOptions = [
{ code: 'draft', text: '草稿' },
@@ -34,6 +45,7 @@ async function load() {
defaultStatus.value = map['content.default-status'] || 'draft'
registerMaxPerIp.value = parseRegisterLimit(map['app.register.max-per-ip-per-day'], 5)
registerMaxPerDevice.value = parseRegisterLimit(map['app.register.max-per-device-per-day'], 3)
loginBackgroundUrl.value = map['app.login.background.url'] || ''
} finally {
loading.value = false
}
@@ -46,7 +58,8 @@ async function save() {
'workspace.name': workspaceName.value,
'content.default-status': defaultStatus.value,
'app.register.max-per-ip-per-day': String(registerMaxPerIp.value),
'app.register.max-per-device-per-day': String(registerMaxPerDevice.value)
'app.register.max-per-device-per-day': String(registerMaxPerDevice.value),
'app.login.background.url': loginBackgroundUrl.value
})
ElMessage.success('设置已保存')
} finally {
@@ -54,6 +67,52 @@ async function save() {
}
}
function beforeBgUpload(file) {
bgError.value = ''
if (!ALLOWED_LOGIN_BG_TYPES.includes(file.type)) {
bgError.value = '图片仅支持 JPG、PNG 或 WebP 格式'
return false
}
if (file.size > MAX_LOGIN_BG_SIZE) {
bgError.value = '图片大小不能超过 8MB'
return false
}
return true
}
async function uploadBg(options) {
uploadingBg.value = true
bgError.value = ''
try {
const file = await compressForPreset(options.file, LOGIN_BACKGROUND)
const asset = await uploadFile(file, {
bizType: 'LOGIN_BG'
})
loginBackgroundUrl.value = asset.url
bgFileName.value = file.name
ElMessage.success('登录页背景已上传')
} catch {
bgError.value = '图片上传失败,请检查网络后重试'
} finally {
uploadingBg.value = false
}
}
function removeBg() {
if (uploadingBg.value) return
loginBackgroundUrl.value = ''
bgFileName.value = ''
bgError.value = ''
}
const loginBgPreview = computed(() => {
const url = loginBackgroundUrl.value
if (!url) return ''
if (url.startsWith('http://') || url.startsWith('https://')) return url
if (url.startsWith('/')) return url
return `/${url}`
})
onMounted(load)
</script>
@@ -77,10 +136,75 @@ onMounted(load)
><el-form-item label="单 IP 24 小时注册上限(0 或负数表示不限制)"
><el-input-number v-model="registerMaxPerIp" :step="1" :precision="0" /></el-form-item
><el-form-item label="单设备 24 小时注册上限(0 或负数表示不限制)"
><el-input-number v-model="registerMaxPerDevice" :step="1" :precision="0" /></el-form-item
><el-button type="primary" :loading="saving" :disabled="!can('system:role')" @click="save"
><el-input-number v-model="registerMaxPerDevice" :step="1" :precision="0"
/></el-form-item>
<el-divider content-position="left">App 登录页</el-divider>
<el-form-item label="登录页背景图">
<div class="login-bg-field">
<el-upload
:show-file-list="false"
:before-upload="beforeBgUpload"
:http-request="uploadBg"
accept=".jpg,.jpeg,.png,.webp"
>
<el-button :disabled="uploadingBg" :loading="uploadingBg">
{{ loginBackgroundUrl ? '重新上传' : '上传背景图' }}
</el-button>
</el-upload>
<span class="field-hint">建议 {{ LOGIN_BACKGROUND.hint }}</span>
<span v-if="bgFileName" class="field-filename">当前:{{ bgFileName }}</span>
<span v-if="bgError" class="field-error">{{ bgError }}</span>
<div v-if="loginBgPreview" class="login-bg-preview">
<img :src="loginBgPreview" alt="登录页背景预览" />
<el-button class="remove-bg-btn" text type="danger" size="small" @click="removeBg"
>移除</el-button
>
</div>
</div>
</el-form-item>
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="save"
>保存设置</el-button
></el-form
></section
>
</template>
<style scoped>
.login-bg-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.field-filename {
font-size: 12px;
color: var(--el-text-color-primary);
}
.field-error {
font-size: 12px;
color: var(--el-color-danger);
}
.login-bg-preview {
position: relative;
margin-top: 4px;
width: 96px;
aspect-ratio: 1170 / 2532;
border-radius: 6px;
overflow: hidden;
background: var(--el-fill-color-light);
}
.login-bg-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove-bg-btn {
position: absolute;
top: 2px;
right: 2px;
padding: 2px 4px;
}
</style>
@@ -14,6 +14,7 @@ const {
rules,
loading,
saving,
backfilling,
loadError,
canManage,
qualityRules,
@@ -23,7 +24,8 @@ const {
loadProfile,
addRung,
deleteRung,
saveProfile
saveProfile,
runBackfill
} = useTranscodeSettings()
</script>
@@ -33,6 +35,18 @@ const {
:title="t('transcodeSettings.title')"
:description="t('transcodeSettings.description')"
>
<el-tooltip :content="t('transcodeSettings.backfillTip')" placement="top">
<el-button
type="success"
plain
:loading="backfilling"
:disabled="!canManage || loading"
@click="runBackfill"
>
<AppIcon v-if="!backfilling" :icon="AppIcons.videoPlay" />
{{ t('transcodeSettings.backfill') }}
</el-button>
</el-tooltip>
<el-button :disabled="loading" @click="loadProfile">
<AppIcon :icon="AppIcons.refresh" />
{{ t('transcodeSettings.reload') }}
@@ -5,6 +5,7 @@ import type { ComputedRef, Ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import { getMediaTranscodeProfileApi, saveMediaTranscodeProfileApi } from '@/api/admin/media'
import { runTranscodeBackfillApi } from '@/api/admin/transcode'
import type { MediaTranscodeProfile } from '@/api/admin/types'
import { applyProfileToForm, buildProfilePayload } from './transcodeProfileModel'
@@ -15,6 +16,7 @@ interface SettingsContext {
formRef: Ref<FormInstance | undefined>
loading: Ref<boolean>
saving: Ref<boolean>
backfilling: Ref<boolean>
loadError: Ref<string>
canManage: ComputedRef<boolean>
t: Translate
@@ -77,3 +79,28 @@ export const saveTranscodeProfile = async (context: SettingsContext): Promise<vo
context.saving.value = false
}
}
/** 手动补齐转码:扫一遍缺码率产物/帧图的分集并入队。 */
export const runTranscodeBackfill = async (context: SettingsContext): Promise<void> => {
if (!context.canManage.value || context.backfilling.value) {
return
}
context.backfilling.value = true
try {
const response = await runTranscodeBackfillApi()
if (response.code !== 0) {
ElMessage.error(response.message || context.t('transcodeSettings.backfillFailed'))
return
}
const triggered = response.data?.triggered ?? 0
if (triggered > 0) {
ElMessage.success(context.t('transcodeSettings.backfillDone', { count: triggered }))
} else {
ElMessage.info(context.t('transcodeSettings.backfillEmpty'))
}
} catch {
ElMessage.error(context.t('transcodeSettings.backfillFailed'))
} finally {
context.backfilling.value = false
}
}
@@ -13,7 +13,11 @@ import {
positiveEvenRule,
positiveIntegerRule
} from './transcodeProfileRules'
import { loadTranscodeProfile, saveTranscodeProfile } from './transcodeSettingsActions'
import {
loadTranscodeProfile,
runTranscodeBackfill,
saveTranscodeProfile
} from './transcodeSettingsActions'
/** 转码设置页的响应式状态与动作装配。 */
export function useTranscodeSettings() {
@@ -22,10 +26,11 @@ export function useTranscodeSettings() {
const formRef = ref<FormInstance>()
const loading = ref(false)
const saving = ref(false)
const backfilling = ref(false)
const loadError = ref('')
const form = reactive<MediaTranscodeProfile>(defaultProfile())
const canManage = computed(() => can('episode:upload'))
const context = { form, formRef, loading, saving, loadError, canManage, t }
const context = { form, formRef, loading, saving, backfilling, loadError, canManage, t }
const rules = computed(() => createProfileRules(t))
const enabledRules = computed(() => createEnabledRules(form, t))
const qualityRules = (index: number) => createQualityRules(form, index, t)
@@ -50,6 +55,7 @@ export function useTranscodeSettings() {
rules,
loading,
saving,
backfilling,
loadError,
canManage,
qualityRules,
@@ -59,6 +65,7 @@ export function useTranscodeSettings() {
loadProfile,
addRung,
deleteRung,
saveProfile
saveProfile,
runBackfill: () => runTranscodeBackfill(context)
}
}
+43 -16
View File
@@ -9,9 +9,11 @@ import { useRbac } from '@/stores/rbac'
import {
APP_USER_STATUS,
COUNTRY_OPTIONS,
countryText,
formatDateTime,
formatDuration,
platformText,
statusMeta,
systemText,
useAppUsers
} from './composables/useAppUsers'
@@ -25,6 +27,7 @@ const {
keyword,
statusFilter,
countryFilter,
platformFilter,
currentPage,
pageSize,
detailVisible,
@@ -53,6 +56,11 @@ const SOURCE_TEXT = {
FAVORITE: '收藏'
}
const BAN_TYPE_TEXT = { BAN: '封禁', LIMIT: '限制', MUTE: '禁言' }
const PLATFORM_OPTIONS = [
{ value: '', label: '全部系统' },
{ value: 'ANDROID', label: 'Android' },
{ value: 'IOS', label: 'iOS' }
]
const APPEAL_TEXT = {
none: '未申诉',
pending: '待处理',
@@ -116,6 +124,20 @@ function canBeBanned(row) {
:value="item.value"
/>
</el-select>
<el-select
v-model="platformFilter"
class="app-user-platform"
clearable
placeholder="全部系统"
@change="queryUsers"
>
<el-option
v-for="item in PLATFORM_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="countryFilter"
class="app-user-country"
@@ -165,24 +187,24 @@ function canBeBanned(row) {
<template v-else>{{ avatarText(row) }}</template>
</span>
<div class="app-user-meta">
<span class="app-user-name" :title="row.nickname">{{
row.nickname || '未设置昵称'
}}</span>
<span class="app-user-uid" :title="row.uid">{{ row.uid }}</span>
<span class="app-user-name" :title="row.nickname || ''">
昵称: {{ row.nickname || '未设置昵称' }}
</span>
<span class="app-user-uid">UID: {{ row.uid }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="系统" width="130" show-overflow-tooltip>
<template #default="{ row }">{{ systemText(row) }}</template>
<el-table-column label="系统" width="110" show-overflow-tooltip>
<template #default="{ row }">{{ platformText(row) }}</template>
</el-table-column>
<el-table-column label="手机 / 邮箱" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span class="app-user-mono">{{ row.maskedTarget || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="国家" width="90">
<template #default="{ row }">{{ row.countryCode || '—' }}</template>
<el-table-column label="国家" width="110">
<template #default="{ row }">{{ countryText(row.countryCode) }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
@@ -248,8 +270,8 @@ function canBeBanned(row) {
<template v-else>{{ avatarText(detail) }}</template>
</span>
<div class="app-user-meta">
<span class="app-user-name">{{ detail.nickname || '未设置昵称' }}</span>
<span class="app-user-uid">{{ detail.uid }}</span>
<span class="app-user-name"> 昵称: {{ detail.nickname || '未设置昵称' }} </span>
<span class="app-user-uid">UID: {{ detail.uid }}</span>
</div>
<el-tag :type="statusMeta(detail.status).tag" effect="light" style="margin-left: auto">
{{ statusMeta(detail.status).label }}
@@ -262,7 +284,10 @@ function canBeBanned(row) {
{{ detail.maskedTarget || '—' }}
</el-descriptions-item>
<el-descriptions-item label="国家 / 地区">
{{ detail.countryCode || '—' }} / {{ detail.regionCode || '—' }}
{{ countryText(detail.countryCode) }}
<template v-if="detail.regionCode && detail.regionCode !== detail.countryCode">
/ {{ countryText(detail.regionCode) }}
</template>
</el-descriptions-item>
<el-descriptions-item label="语言">{{ detail.language || '—' }}</el-descriptions-item>
<el-descriptions-item label="时区">{{ detail.timezone || '—' }}</el-descriptions-item>
@@ -272,7 +297,7 @@ function canBeBanned(row) {
<el-descriptions-item label="最后登录">{{
detail.lastLoginAt || '—'
}}</el-descriptions-item>
<el-descriptions-item label="系统">{{ systemText(detail) }}</el-descriptions-item>
<el-descriptions-item label="系统">{{ platformText(detail) }}</el-descriptions-item>
<el-descriptions-item label="会员">
{{ detail.vipLevel ? `等级 ${detail.vipLevel}` : '未开通(第二阶段)' }}
</el-descriptions-item>
@@ -304,15 +329,17 @@ function canBeBanned(row) {
<el-table-column label="完成" width="70">
<template #default="{ row }">{{ row.finished ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="系统" width="120" show-overflow-tooltip>
<template #default="{ row }">{{ systemText(row) }}</template>
<el-table-column label="系统" width="100" show-overflow-tooltip>
<template #default="{ row }">{{ platformText(row) }}</template>
</el-table-column>
<el-table-column label="来源" width="80">
<template #default="{ row }">{{
SOURCE_TEXT[row.source] || row.source || '—'
}}</template>
</el-table-column>
<el-table-column prop="watchedAt" label="时间" width="160" show-overflow-tooltip />
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.watchedAt) }}</template>
</el-table-column>
</el-table>
</div>
+194
View File
@@ -0,0 +1,194 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { onMounted, reactive, ref } from 'vue'
import dayjs from 'dayjs'
import PageHeading from '@/views/components/PageHeading.vue'
import AppUserCell from '@/views/components/AppUserCell.vue'
import { pageFavoritesApi } from '@/api/admin/engagement'
import { platformText } from './composables/useAppUsers'
const PAGE_SIZES = [10, 20, 50]
const PLATFORM_OPTIONS = [
{ value: '', label: '全部系统' },
{ value: 'ANDROID', label: 'Android' },
{ value: 'IOS', label: 'iOS' }
]
const filters = reactive({
keyword: '',
deviceId: '',
dramaTitle: '',
platform: '',
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 pageFavoritesApi({
pageIndex: pageIndex.value,
pageSize: pageSize.value,
keyword: filters.keyword.trim() || undefined,
deviceId: filters.deviceId.trim() || undefined,
dramaTitle: filters.dramaTitle.trim() || undefined,
platform: filters.platform || undefined,
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() {
Object.assign(filters, {
keyword: '',
deviceId: '',
dramaTitle: '',
platform: '',
range: null
})
search()
}
function handleSizeChange() {
pageIndex.value = 1
load()
}
function formatTime(value) {
return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
onMounted(load)
</script>
<template>
<PageHeading eyebrow="用户管理" title="收藏列表" />
<section class="panel">
<div class="console-toolbar">
<el-input
v-model="filters.keyword"
class="console-keyword"
clearable
placeholder="uid / 昵称"
@keyup.enter="search"
/>
<el-input
v-model="filters.deviceId"
class="console-keyword"
clearable
placeholder="设备 ID"
@keyup.enter="search"
/>
<el-input
v-model="filters.dramaTitle"
class="console-keyword"
clearable
placeholder="短剧名称"
@keyup.enter="search"
/>
<el-select v-model="filters.platform" class="console-select" placeholder="全部系统">
<el-option
v-for="item in PLATFORM_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-date-picker
v-model="filters.range"
class="engagement-range"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
<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 label="用户" min-width="200">
<template #default="{ row }">
<AppUserCell :row="row" />
</template>
</el-table-column>
<el-table-column label="系统" width="100">
<template #default="{ row }">{{ platformText(row) }}</template>
</el-table-column>
<el-table-column label="短剧" min-width="220">
<template #default="{ row }">
<div class="engagement-drama">
<el-image
v-if="row.dramaCover"
:src="row.dramaCover"
fit="cover"
class="engagement-cover"
/>
<span>{{ row.dramaTitle || `短剧 #${row.dramaId || '—'}` }}</span>
<el-tag v-if="row.dramaPublished === false" type="info" size="small" effect="plain"
>未发布</el-tag
>
</div>
</template>
</el-table-column>
<el-table-column label="收藏时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pageIndex"
v-model:page-size="pageSize"
:page-sizes="PAGE_SIZES"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="load"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>
<style scoped>
.engagement-range {
width: 360px;
max-width: 100%;
}
.engagement-drama {
display: flex;
align-items: center;
gap: 8px;
}
.engagement-cover {
width: 40px;
height: 56px;
flex: none;
}
</style>
+201
View File
@@ -0,0 +1,201 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { onMounted, reactive, ref } from 'vue'
import dayjs from 'dayjs'
import PageHeading from '@/views/components/PageHeading.vue'
import AppUserCell from '@/views/components/AppUserCell.vue'
import { pageLikesApi } from '@/api/admin/engagement'
import { platformText } from './composables/useAppUsers'
const PAGE_SIZES = [10, 20, 50]
const TARGET_OPTIONS = [
{ value: '', label: '全部' },
{ value: 'DRAMA', label: '短剧' },
{ value: 'EPISODE', label: '剧集' },
{ value: 'COMMENT', label: '评论' }
]
const PLATFORM_OPTIONS = [
{ value: '', label: '全部系统' },
{ value: 'ANDROID', label: 'Android' },
{ value: 'IOS', label: 'iOS' }
]
const filters = reactive({
keyword: '',
deviceId: '',
dramaTitle: '',
range: null,
targetType: '',
platform: ''
})
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 pageLikesApi({
pageIndex: pageIndex.value,
pageSize: pageSize.value,
keyword: filters.keyword.trim() || undefined,
deviceId: filters.deviceId.trim() || undefined,
dramaTitle: filters.dramaTitle.trim() || undefined,
targetType: filters.targetType || undefined,
platform: filters.platform || undefined,
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() {
Object.assign(filters, {
keyword: '',
deviceId: '',
dramaTitle: '',
range: null,
targetType: '',
platform: ''
})
search()
}
function handleSizeChange() {
pageIndex.value = 1
load()
}
function formatTime(value) {
return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
function targetText(row) {
if (row.targetType === 'COMMENT') return row.commentSummary || '—'
if (row.targetType === 'EPISODE') return `${row.dramaTitle || '—'} 第 ${row.episodeNo ?? '—'} 集`
return row.dramaTitle || '—'
}
function typeText(type) {
return TARGET_OPTIONS.find((item) => item.value === type)?.label || type || '—'
}
onMounted(load)
</script>
<template>
<PageHeading eyebrow="用户管理" title="点赞列表" />
<section class="panel">
<div class="console-toolbar">
<el-input
v-model="filters.keyword"
class="console-keyword"
clearable
placeholder="uid / 昵称"
@keyup.enter="search"
/>
<el-input
v-model="filters.deviceId"
class="console-keyword"
clearable
placeholder="设备 ID"
@keyup.enter="search"
/>
<el-input
v-model="filters.dramaTitle"
class="console-keyword"
clearable
placeholder="短剧名称"
@keyup.enter="search"
/>
<el-select v-model="filters.targetType" class="console-select" placeholder="全部目标类型">
<el-option
v-for="item in TARGET_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select v-model="filters.platform" class="console-select" placeholder="全部系统">
<el-option
v-for="item in PLATFORM_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-date-picker
v-model="filters.range"
class="engagement-range"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
<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 label="用户" min-width="200">
<template #default="{ row }">
<AppUserCell :row="row" />
</template>
</el-table-column>
<el-table-column label="系统" width="100">
<template #default="{ row }">{{ platformText(row) }}</template>
</el-table-column>
<el-table-column label="目标类型" width="110">
<template #default="{ row }">{{ typeText(row.targetType) }}</template>
</el-table-column>
<el-table-column label="目标" min-width="200" show-overflow-tooltip>
<template #default="{ row }"
><span :title="targetText(row)">{{ targetText(row) }}</span></template
>
</el-table-column>
<el-table-column prop="dramaTitle" label="所属短剧" min-width="180" show-overflow-tooltip />
<el-table-column label="点赞时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pageIndex"
v-model:page-size="pageSize"
:page-sizes="PAGE_SIZES"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="load"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>
<style scoped>
.engagement-range {
width: 360px;
max-width: 100%;
}
</style>
+31 -9
View File
@@ -3,8 +3,9 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import PageHeading from '@/views/components/PageHeading.vue'
import AppUserCell from '@/views/components/AppUserCell.vue'
import { pageWatchHistoryApi } from '@/api/admin/appUser'
import { formatDuration, systemText } from './composables/useAppUsers'
import { formatDateTime, formatDuration, platformText } from './composables/useAppUsers'
const SOURCE_OPTIONS = [
{ value: '', label: '全部' },
@@ -20,11 +21,18 @@ const FINISHED_OPTIONS = [
{ value: false, label: '未看完' }
]
const PLATFORM_OPTIONS = [
{ value: '', label: '全部系统' },
{ value: 'ANDROID', label: 'Android' },
{ value: 'IOS', label: 'iOS' }
]
const filters = reactive({
keyword: '',
dramaTitle: '',
source: '',
finished: '',
platform: '',
range: null
})
const rows = ref([])
@@ -43,6 +51,7 @@ async function load() {
dramaTitle: filters.dramaTitle.trim() || undefined,
source: filters.source || undefined,
finished: filters.finished === '' ? undefined : filters.finished,
platform: filters.platform || undefined,
startTime: filters.range?.[0] || undefined,
endTime: filters.range?.[1] || undefined
})
@@ -63,6 +72,7 @@ function resetFilters() {
filters.dramaTitle = ''
filters.source = ''
filters.finished = ''
filters.platform = ''
filters.range = null
search()
}
@@ -144,6 +154,19 @@ onMounted(load)
:value="item.value"
/>
</el-select>
<el-select
v-model="filters.platform"
class="console-select"
placeholder="全部系统"
@change="search"
>
<el-option
v-for="item in PLATFORM_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-date-picker
v-model="filters.range"
class="watch-history-range"
@@ -167,17 +190,16 @@ onMounted(load)
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">
<el-table-column label="观看时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.watchedAt) }}</template>
</el-table-column>
<el-table-column label="用户" min-width="200">
<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>
<AppUserCell :row="row" />
</template>
</el-table-column>
<el-table-column label="系统" width="130" show-overflow-tooltip>
<template #default="{ row }">{{ systemText(row) }}</template>
<el-table-column label="系统" width="100">
<template #default="{ row }">{{ platformText(row) }}</template>
</el-table-column>
<el-table-column label="短剧" min-width="190">
<template #default="{ row }">
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import { onMounted, reactive, ref } from 'vue'
import dayjs from 'dayjs'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getAppUserApi,
@@ -28,6 +29,69 @@ export const COUNTRY_OPTIONS = [
{ value: 'CN', label: '中国' }
]
/** 国家码 → 中文名,与后端 CountryProfile 保持一致;未收录的仍显示原码 */
const COUNTRY_TEXT = {
ID: '印度尼西亚',
MY: '马来西亚',
SG: '新加坡',
TH: '泰国',
VN: '越南',
PH: '菲律宾',
KH: '柬埔寨',
MM: '缅甸',
LA: '老挝',
BN: '文莱',
CN: '中国',
TW: '中国台湾',
HK: '中国香港',
MO: '中国澳门',
JP: '日本',
KR: '韩国',
IN: '印度',
PK: '巴基斯坦',
BD: '孟加拉国',
LK: '斯里兰卡',
NP: '尼泊尔',
US: '美国',
CA: '加拿大',
MX: '墨西哥',
BR: '巴西',
AR: '阿根廷',
CL: '智利',
CO: '哥伦比亚',
PE: '秘鲁',
VE: '委内瑞拉',
EC: '厄瓜多尔',
GT: '危地马拉',
DO: '多米尼加',
GB: '英国',
DE: '德国',
FR: '法国',
ES: '西班牙',
IT: '意大利',
NL: '荷兰',
PL: '波兰',
RU: '俄罗斯',
UA: '乌克兰',
TR: '土耳其',
SA: '沙特阿拉伯',
AE: '阿联酋',
IL: '以色列',
EG: '埃及',
NG: '尼日利亚',
ZA: '南非',
KE: '肯尼亚',
MA: '摩洛哥',
AU: '澳大利亚',
NZ: '新西兰'
}
/** 国家列统一展示中文名,无值时显示横线 */
export function countryText(code) {
const value = (code || '').toUpperCase()
return COUNTRY_TEXT[value] || value || '—'
}
export function statusMeta(status) {
return (
APP_USER_STATUS.find((item) => item.value === status) || { label: status || '-', tag: 'info' }
@@ -36,12 +100,15 @@ export function statusMeta(status) {
const PLATFORM_TEXT = { ANDROID: 'Android', IOS: 'iOS', WEB: 'Web' }
/** 登录会话上的系统,例如 Android 16。没有记录时显示横线。 */
export function systemText(row) {
const name = PLATFORM_TEXT[row?.platform] || row?.platform || ''
const version = row?.osVersion || ''
const text = `${name} ${version}`.trim()
return text || '—'
/** 客户端平台,只有 iOS / Android 两种取值(缺记录显示横线)。 */
export function platformText(row) {
const platform = row?.platform || ''
return PLATFORM_TEXT[platform] || platform || '—'
}
/** 后端返回 ISO 时间串,统一格式化成后台可读的本地时间 */
export function formatDateTime(value) {
return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
/** 毫秒转 mm:ss,观看流水里的进度用 */
@@ -60,6 +127,7 @@ export function useAppUsers() {
const keyword = ref('')
const statusFilter = ref(undefined)
const countryFilter = ref(undefined)
const platformFilter = ref(undefined)
const currentPage = ref(1)
const pageSize = ref(10)
@@ -82,7 +150,8 @@ export function useAppUsers() {
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
status: statusFilter.value,
countryCode: countryFilter.value
countryCode: countryFilter.value,
platform: platformFilter.value || undefined
})
users.value = res.data?.list || []
total.value = res.data?.total || 0
@@ -100,6 +169,7 @@ export function useAppUsers() {
keyword.value = ''
statusFilter.value = undefined
countryFilter.value = undefined
platformFilter.value = undefined
queryUsers()
}
@@ -186,6 +256,7 @@ export function useAppUsers() {
keyword,
statusFilter,
countryFilter,
platformFilter,
currentPage,
pageSize,
detailVisible,
+2 -31
View File
@@ -1,35 +1,7 @@
// Author: fruit.com.cn
import { defineConfig, toEscapedSelector as e, presetUno, presetIcons } from 'unocss'
import { defineConfig, toEscapedSelector as e, presetUno } from 'unocss'
import transformerVariantGroup from '@unocss/transformer-variant-group'
import { loadEnv } from 'vite'
import { AppIcons } from './src/constants/icons'
const root = process.cwd()
const createPresetIcons = () => {
const isBuild = !!process.argv[4]
let env = {} as any
if (!isBuild) {
env = loadEnv(process.argv[3], root)
} else {
env = loadEnv(process.argv[4], root)
}
// @ts-ignore
if (env.VITE_USE_ONLINE_ICON === 'true') {
return []
} else {
return [
presetIcons({
prefix: '',
collections: {
'material-symbols': () =>
import('@iconify/json/json/material-symbols.json').then((i) => i.default)
}
})
]
}
}
export default defineConfig({
// ...UnoCSS options
@@ -141,8 +113,7 @@ ${selector}:after {
}
]
],
safelist: Object.values(AppIcons),
presets: [presetUno({ dark: 'class', attributify: false }), ...createPresetIcons()],
presets: [presetUno({ dark: 'class', attributify: false })],
transformers: [transformerVariantGroup()],
content: {
pipeline: {