19 Commits
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
60 changed files with 4468 additions and 510 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 内旧任务不属于本次用户明确要求,不执行、不标记完成。
## 验收
- 待执行。
+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>
+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`,
+3 -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 */
+23 -9
View File
@@ -4,11 +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
@@ -18,17 +21,20 @@ 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
}
@@ -43,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
@@ -64,7 +70,7 @@ export const listClassificationDramasApi = (id: number, params: ClassificationDr
/** 尚未关联到该分类的短剧(添加弹窗用) */
export const listClassificationDramaCandidatesApi = (
id: number,
id: string,
params: ClassificationDramaPageParams
) => {
return request.get<PageResult<ClassificationDramaItem>>({
@@ -74,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 }
@@ -82,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 }
})
}
+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 })
+6 -1
View File
@@ -29,6 +29,8 @@ export interface DramaItem {
genre?: string
description?: string
cover?: string
/** 横版封面,用于继续观看横幅等横向展示位 */
horizontalCover?: string
language?: string
dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'>
@@ -57,6 +59,8 @@ export interface DramaSaveData {
genre?: string
description?: string
cover?: string
/** 横版封面,用于继续观看横幅等横向展示位 */
horizontalCover?: string
language?: string
dramaType?: 'ANIME' | 'LIVE'
dramaTypes?: Array<'ANIME' | 'LIVE'>
@@ -69,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
+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>
+27 -1
View File
@@ -10,6 +10,7 @@ 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()
@@ -18,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)
@@ -74,7 +85,9 @@ export default defineComponent({
color="var(--top-header-text-color)"
></LocaleDropdown>
) : undefined}
<TranscodeNoticeButton color="var(--top-header-text-color)"></TranscodeNoticeButton>
{canSeeTranscodeNotice.value ? (
<TranscodeNoticeButton color="var(--top-header-text-color)"></TranscodeNoticeButton>
) : undefined}
<UserInfo></UserInfo>
</div>
</div>
@@ -95,6 +108,19 @@ 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 (width <= 768px) {
@@ -83,7 +83,9 @@ onBeforeUnmount(() => {
}
.notice-badge :deep(.el-badge__content.is-fixed) {
top: 8px;
right: 2px;
/* 角标中心骑在铃铛右上角:right 9px + 右移 50%,与位数宽度无关 */
top: calc(50% - 9px);
right: 9px;
transform: translate(50%, -50%);
}
</style>
+3
View File
@@ -5,6 +5,9 @@ import 'vue/jsx'
// 引入unocss
import '@/plugins/unocss'
// 注册后台使用的本地图标子集
import '@/plugins/icons'
// 导入全局的svg图标
import '@/plugins/svgIcon'
+8 -1
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
}
+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)
+2 -1
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
+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 = () => {
+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%;
}
+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)
})
}
+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)
}
}
+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>
@@ -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"
+174 -11
View File
@@ -4,6 +4,7 @@
<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 {
@@ -19,6 +20,7 @@ import {
listClassificationDramaCandidatesApi,
listClassificationDramasApi,
listClassificationsApi,
saveRankingItemsApi,
unbindClassificationDramaApi,
updateClassificationApi
} from '@/api/admin/dramaClassification'
@@ -43,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([])
@@ -50,12 +53,19 @@ 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 languages = ref(['zh'])
const emptyForm = () => ({ locales: { zh: '' }, sort: 1, status: 1 })
const emptyForm = () => ({
locales: { zh: '' },
code: '',
layoutType: 'GRID',
sort: 1,
status: 1
})
const form = reactive(emptyForm())
const candidateVisible = ref(false)
@@ -91,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
}
@@ -105,6 +116,7 @@ function selectClassification(row) {
activeId.value = row.id
pageIndex.value = 1
keyword.value = ''
rankingDirty.value = false
loadDramas()
}
@@ -132,6 +144,8 @@ function openEdit(row) {
languages.value = languageCodes(locales)
Object.assign(form, {
locales,
code: row.code || '',
layoutType: row.layoutType || 'GRID',
sort: row.sort ?? 0,
status: row.status ?? 1
})
@@ -150,6 +164,10 @@ async function saveClassification() {
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
}
@@ -197,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()])
}
@@ -247,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 {
@@ -259,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)
@@ -271,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" />
@@ -293,9 +344,11 @@ onMounted(loadClassifications)
<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
@@ -303,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" />
添加短剧
@@ -311,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"
@@ -353,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"
@@ -434,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;
@@ -64,6 +64,23 @@ const emit = defineEmits([
@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
@@ -29,6 +29,9 @@ const { can } = useRbac()
>
<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>
@@ -26,9 +26,14 @@ 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([])
@@ -48,6 +53,10 @@ 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)
@@ -66,7 +75,8 @@ const form = reactive({
heat: 0,
isHot: false,
sort: 0,
cover: ''
cover: '',
horizontalCover: ''
})
const MAX_COVER_SIZE = 5 * 1024 * 1024
@@ -75,6 +85,11 @@ const COVER_TYPES = ['image/jpeg', 'image/png', 'image/webp']
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))
@@ -95,7 +110,8 @@ const actionMeta = {
approve: { label: '审核通过', done: '已通过审核' },
reject: { label: '驳回', done: '已驳回' },
publish: { label: '上线', done: '已上线' },
offline: { label: '下线', done: '已下线' }
offline: { label: '下线', done: '已下线' },
approvePublish: { label: '审核通过并上线', done: '已通过审核并上线' }
}
async function load() {
@@ -192,10 +208,13 @@ function openCreate() {
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
@@ -223,10 +242,13 @@ function openEdit(row) {
heat: toCount(row.views),
isHot: !!row.isHot,
sort: toCount(row.sort),
cover: row.cover || ''
cover: row.cover || '',
horizontalCover: row.horizontalCover || ''
})
coverFileName.value = row.cover ? coverBasename(row.cover) : ''
coverError.value = ''
horizontalFileName.value = row.horizontalCover ? coverBasename(row.horizontalCover) : ''
horizontalError.value = ''
editingCommentCount.value = toCount(row.commentCount)
editingLikeCount.value = toCount(row.likeCount)
dialogVisible.value = true
@@ -245,7 +267,7 @@ function rowCategoryList(row) {
}
async function saveDrama() {
if (saving.value || uploadingCover.value) return
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) {
@@ -282,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 {
@@ -315,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 = ''
@@ -322,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
}
@@ -378,9 +436,9 @@ 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 []
@@ -572,65 +630,140 @@ onMounted(async () => {
<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-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 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 v-if="uploadingCover" class="cover-upload-mask">
<span>上传中</span>
<b>{{ coverPercent }}%</b>
<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-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 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>
<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 ? '更换封面' : '选择封面' }}
<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>
</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>
<a
v-if="form.horizontalCover"
class="cover-original-link"
:href="form.horizontalCover"
target="_blank"
rel="noopener noreferrer"
>
查看原图
</a>
</div>
</div>
</div>
</div>
@@ -33,6 +33,7 @@ const DEFAULT_TAG_COLOR = '#E5484D'
const emptyForm = () => ({
locales: { zh: '' },
bgColor: DEFAULT_TAG_COLOR,
code: '',
sort: 1,
status: 1
})
@@ -66,6 +67,7 @@ function openEdit(row) {
Object.assign(form, {
locales,
bgColor: row.bgColor || DEFAULT_TAG_COLOR,
code: row.code || '',
sort: row.sort ?? 0,
status: row.status ?? 1
})
@@ -83,12 +85,18 @@ async function save() {
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
}
@@ -209,6 +217,16 @@ onMounted(load)
<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" />
@@ -236,4 +254,11 @@ onMounted(load)
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,
+18 -8
View File
@@ -3,7 +3,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import echarts from '@/plugins/echarts'
import type { ECharts } from 'echarts/core'
import type { DashboardOverview, DashboardTrend } from '@/api/admin/dashboard'
import { formatCount } from '@/utils/format'
@@ -16,8 +16,10 @@ const props = defineProps<{
const trendRef = ref<HTMLElement>()
const deviceRef = ref<HTMLElement>()
const clock = ref('')
let trendChart: echarts.ECharts | null = null
let deviceChart: echarts.ECharts | null = null
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'
@@ -78,12 +80,19 @@ function tick() {
clock.value = new Date().toLocaleString('zh-CN', { hour12: false })
}
function renderCharts() {
renderTrend()
renderDevices()
async function loadEcharts() {
echartsPromise ??= import('@/plugins/echarts').then((module) => module.default)
return echartsPromise
}
function renderTrend() {
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
@@ -130,7 +139,7 @@ function renderTrend() {
})
}
function renderDevices() {
function renderDevices(echarts: typeof import('@/plugins/echarts').default) {
if (!deviceRef.value) return
deviceChart = deviceChart ?? echarts.init(deviceRef.value)
const android = metric('user', 'androidActive7d') ?? 0
@@ -186,6 +195,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
active = false
window.clearInterval(timer)
window.removeEventListener('resize', onResize)
trendChart?.dispose()
+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>
+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: {