Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88db96c374 | ||
|
|
96b213354b | ||
|
|
0d862dfe1d | ||
|
|
8535305185 | ||
|
|
837266ce7e | ||
|
|
71ee469563 | ||
|
|
3c730c7d33 | ||
|
|
4055b7a2a9 | ||
|
|
bbff52f324 | ||
|
|
9ce014e7b3 | ||
|
|
23026dc371 | ||
|
|
2f52011491 | ||
|
|
dd4be08d49 | ||
|
|
a94018d5d3 | ||
|
|
923b7dce1e | ||
|
|
dea414739e | ||
|
|
d13bdd5dc8 | ||
|
|
040ec3f382 | ||
|
|
9a58b98f12 | ||
|
|
e2618735ba | ||
|
|
b68361b037 | ||
|
|
bedae02946 | ||
|
|
daf681ae1a | ||
|
|
111bbdaeb7 | ||
|
|
5da72d1aa2 | ||
|
|
e1eb58d4c4 | ||
|
|
5dbc752798 | ||
|
|
c89c5668e1 | ||
|
|
9ea22d5951 | ||
|
|
f3fbc57c14 | ||
|
|
c45820258e | ||
|
|
12d63e9670 | ||
|
|
7aa1571c15 | ||
|
|
8cd4569a20 | ||
|
|
83bb87eeb3 | ||
|
|
3409a2b427 | ||
|
|
a21fe6f189 | ||
|
|
5580f9b99b | ||
|
|
d846d2d864 | ||
|
|
e6d1d6997b | ||
|
|
d661b808d1 | ||
|
|
d42a09fa36 | ||
|
|
33bf5bd3bb | ||
|
|
a266d44483 | ||
|
|
2b2c6bd708 | ||
|
|
eaee765239 | ||
|
|
18234ebd5b | ||
|
|
93d6d8c59f | ||
|
|
9fc57e5c1a | ||
|
|
0a28eb0237 | ||
|
|
73a9b078ee | ||
|
|
bb48f14ba7 | ||
|
|
ada74efc3a | ||
|
|
10372524ba | ||
|
|
fd9a9c50c4 | ||
|
|
e51d371e07 | ||
|
|
ada3b56d14 | ||
|
|
7e8ae86d9f | ||
|
|
430ba59613 | ||
|
|
d7d43fed44 | ||
|
|
aa892fe7a8 | ||
|
|
7c10e057a5 | ||
|
|
8bd6aa35cb | ||
|
|
0578052d6e | ||
|
|
c1fb6cc21c | ||
|
|
91f1f7d793 | ||
|
|
a47700a980 | ||
|
|
1d18b7b52b | ||
|
|
208b9f29d8 | ||
|
|
9b2ab1a425 | ||
|
|
0d5d0d841f | ||
|
|
fecc3b4817 | ||
|
|
a4893c31a2 | ||
|
|
e2ca3bfa81 | ||
|
|
52b10d3040 | ||
|
|
41151a7f17 | ||
|
|
75da3ea0ba | ||
|
|
218196ee9f | ||
|
|
723d056fc7 | ||
|
|
f174ba46ae | ||
|
|
2e89f8460c | ||
|
|
632bf13990 | ||
|
|
becbe18d76 | ||
|
|
6bcabea261 | ||
|
|
99bb666e47 | ||
|
|
092fe18ae7 | ||
|
|
c2a76a593a | ||
|
|
a28eaa4514 | ||
|
|
189a7f689a | ||
|
|
93e1c1b8c9 | ||
|
|
05b977cc33 | ||
|
|
5741ebbf17 | ||
|
|
836600b811 | ||
|
|
4841b0bc24 | ||
|
|
e1e0055915 | ||
|
|
cff864d582 | ||
|
|
c71a12f6f4 | ||
|
|
7d39e3c684 | ||
|
|
6b3620c3c4 | ||
|
|
8e1a02ace0 | ||
|
|
982346045d | ||
|
|
48b09ee8cf | ||
|
|
ceb7905697 | ||
|
|
979a502373 | ||
|
|
3e779e4efd | ||
|
|
481c231dd9 | ||
|
|
9e427d485f | ||
|
|
58380170b0 | ||
|
|
e583bfed5f | ||
|
|
923a350cde | ||
|
|
0710ea55f3 | ||
|
|
50f2b177b1 | ||
|
|
0e814840b1 | ||
|
|
c1e03f94be | ||
|
|
c8fe571be1 | ||
|
|
45507dee8d | ||
|
|
1737f37d82 | ||
|
|
d283d223c0 | ||
|
|
e9a956f16c | ||
|
|
f78960fcce | ||
|
|
8e9435ee7d | ||
|
|
83e535d12a | ||
|
|
2b5dda7c71 | ||
|
|
685d4fd1bd | ||
|
|
f281dcf1b3 | ||
|
|
5db565b15d | ||
|
|
8a84f24baf | ||
|
|
046400ae4a | ||
|
|
122aa21323 | ||
|
|
9f92d113f2 | ||
|
|
f6f4d264ee | ||
|
|
f023c7d416 | ||
|
|
bb03a5ad84 | ||
|
|
73da0bbd85 | ||
|
|
c8bf4a8fd4 | ||
|
|
6f522303a0 | ||
|
|
f5b399a52f | ||
|
|
e77db70278 | ||
|
|
fe1ae954e3 | ||
|
|
40fbd6ec7b | ||
|
|
ee3bb1c286 | ||
|
|
898e439b84 | ||
|
|
805d7ddc52 | ||
|
|
ba90bf9bfb | ||
|
|
3e8312ea58 | ||
|
|
a97b8cc026 | ||
|
|
e5c5101450 | ||
|
|
87970487af | ||
|
|
ada741f53d | ||
|
|
5494190330 | ||
|
|
29465ab4ed |
No files matched your search
@@ -17,4 +17,4 @@ VITE_USE_ALL_ELEMENT_PLUS_STYLE=true
|
||||
VITE_USE_MOCK=false
|
||||
|
||||
# 是否使用在线图标
|
||||
VITE_USE_ONLINE_ICON=true
|
||||
VITE_USE_ONLINE_ICON=false
|
||||
@@ -29,10 +29,10 @@ VITE_USE_BUNDLE_ANALYZER=false
|
||||
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
|
||||
|
||||
# 是否开启mock
|
||||
VITE_USE_MOCK=true
|
||||
VITE_USE_MOCK=false
|
||||
|
||||
# 是否切割css
|
||||
VITE_USE_CSS_SPLIT=true
|
||||
|
||||
# 是否使用在线图标
|
||||
VITE_USE_ONLINE_ICON=true
|
||||
VITE_USE_ONLINE_ICON=false
|
||||
@@ -29,10 +29,10 @@ VITE_USE_BUNDLE_ANALYZER=true
|
||||
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
|
||||
|
||||
# 是否开启mock
|
||||
VITE_USE_MOCK=true
|
||||
VITE_USE_MOCK=false
|
||||
|
||||
# 是否切割css
|
||||
VITE_USE_CSS_SPLIT=true
|
||||
|
||||
# 是否使用在线图标
|
||||
VITE_USE_ONLINE_ICON=true
|
||||
VITE_USE_ONLINE_ICON=false
|
||||
@@ -29,10 +29,10 @@ VITE_USE_BUNDLE_ANALYZER=false
|
||||
VITE_USE_ALL_ELEMENT_PLUS_STYLE=false
|
||||
|
||||
# 是否开启mock
|
||||
VITE_USE_MOCK=true
|
||||
VITE_USE_MOCK=false
|
||||
|
||||
# 是否切割css
|
||||
VITE_USE_CSS_SPLIT=false
|
||||
|
||||
# 是否使用在线图标
|
||||
VITE_USE_ONLINE_ICON=true
|
||||
VITE_USE_ONLINE_ICON=false
|
||||
@@ -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
|
||||
@@ -7,3 +7,9 @@ dist-ssr
|
||||
*-lock.*
|
||||
pnpm-debug
|
||||
stats.html
|
||||
*.apk
|
||||
|
||||
# 本机运行时产物(start-dev.bat / vite 日志)
|
||||
start-dev.bat
|
||||
dev.out.log
|
||||
dev.err.log
|
||||
@@ -1,4 +1,9 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
if grep -qiE "(co-authored-by|generated with|assisted by).*(cursor|claude|codex|gpt|copilot|ai)" "$1"; then
|
||||
echo "commit message contains AI attribution, remove it before committing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pnpm commitlint --edit "$1"
|
||||
@@ -0,0 +1,18 @@
|
||||
# 转码媒资 ID 前端精度修复
|
||||
|
||||
## 根因
|
||||
|
||||
聚合接口把 Snowflake 媒资 ID 以字符串返回,但面板使用
|
||||
`Number(detail.mediaAssetId)`,超过 `2^53` 后发生精度丢失,手动转码请求了错误 ID。
|
||||
|
||||
## 修复
|
||||
|
||||
- 聚合响应中的 episodeId/mediaAssetId/taskId 类型改为字符串。
|
||||
- 父面板不再 Number 归一化,媒体 ID 校验改为字符串 `/\d+/`。
|
||||
- 手动转码、帧图分页 URL 原样传递字符串。
|
||||
- mediaAssetId 为空时禁用重新转码并展示明确空态。
|
||||
|
||||
## 验证
|
||||
|
||||
- 超 2^53 ID 字符串保持逐位一致。
|
||||
- `pnpm lint:eslint`、`pnpm build:dev`。
|
||||
@@ -0,0 +1,26 @@
|
||||
# 剧集转码面板组件拆分
|
||||
|
||||
## 目标
|
||||
|
||||
- 将 `EpisodeTranscodePanel.vue` 从 816 行降到 500 行以内。
|
||||
- 状态轮询、分页请求与切集重置继续由父面板统一管理。
|
||||
- UI 按职责拆为状态徽标、状态操作区、清晰度表格、帧图预览。
|
||||
|
||||
## 组件
|
||||
|
||||
1. `EpisodeTranscodeStatusBadge.vue`:折叠标题中的状态与进度。
|
||||
2. `EpisodeTranscodeStatusPanel.vue`:状态、进度、重试次数、错误与重转按钮。
|
||||
3. `EpisodeTranscodeRenditions.vue`:清晰度表格、复制地址、新窗口打开。
|
||||
4. `EpisodeTranscodeFrames.vue`:逐帧网格、雪碧图、分页与放大预览。
|
||||
|
||||
公共状态映射与格式化放入 `episodeTranscodeUi.ts`。
|
||||
|
||||
## 风险
|
||||
|
||||
- 父组件仍负责全部接口、轮询、请求序号和分页状态,子组件只通过 props/emits 交互。
|
||||
- 不新增依赖、不改接口和 locales 文案。
|
||||
|
||||
## 验证
|
||||
|
||||
- `wc -l` 确认所有相关文件不超过 500 行。
|
||||
- `pnpm lint:eslint`、`pnpm build:dev`。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 后台转码设置页面
|
||||
|
||||
## 目标
|
||||
|
||||
1. 在内容管理下新增「转码设置」页面,权限沿用 `episode:upload`。
|
||||
2. 页面字段与 FruitServer `MediaTranscodeProfile` 完全一致。
|
||||
3. 对接 `GET/PUT /admin/media/transcode-profile`,保存后回填服务端响应。
|
||||
4. 前端校验规则与后端一致,并在失败时定位到首个错误字段。
|
||||
|
||||
## 方案
|
||||
|
||||
- API:在 `src/api/admin/media.ts` 增加读取、保存函数,类型定义放
|
||||
`src/api/admin/types.ts`;响应继续遵循现有 `Result {code,message,data}`。
|
||||
- 路由:静态路由表增加 `/content/media-transcode`;默认服务端动态路由模式下,
|
||||
根据 `episode:upload` 权限把同一条本地路由补充进服务端 `/content` 菜单,
|
||||
已存在同名菜单时不重复添加,仅统一 i18n 标题。
|
||||
- 页面:`views/systemManagement/TranscodeSettingsView.vue`,使用 Element Plus 表单、
|
||||
表格、数字输入和开关;保存按钮受 `episode:upload` 控制。
|
||||
- 校验:
|
||||
- quality 非空且忽略首尾空格后唯一;
|
||||
- width/height 为正偶数;
|
||||
- videoBitrateKbps/audioBitrateKbps 为正整数;
|
||||
- segmentSeconds 2–10;
|
||||
- sprite.intervalSeconds 1–10、columns 5–20、thumbWidth 120–240;
|
||||
- ladder 至少一档 enabled。
|
||||
- 校验失败由 Element Plus 定位到首个错误 prop,动态列表错误会定位到对应行。
|
||||
- 所有可见文案、提示和校验信息进入 `zh-CN.ts` / `en.ts`。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不修改 FruitServer、ReelShort 或接口契约。
|
||||
- 不新增权限码、不新增依赖、不启动服务。
|
||||
|
||||
## 测试与门禁
|
||||
|
||||
- 当前工程没有单元/组件测试框架,不引入新依赖;使用 TS 类型检查和构建门禁。
|
||||
- `pnpm lint:eslint`
|
||||
- `pnpm ts:check`
|
||||
- `pnpm build:dev`
|
||||
@@ -0,0 +1,12 @@
|
||||
# 转码面板实时进度与更新时间
|
||||
|
||||
## 目标
|
||||
|
||||
- 展示后端 task progress、状态文案与 updatedAt。
|
||||
- RUNNING/QUEUED 且 progress=0 时显示“处理中(等待进度上报)”。
|
||||
- FAILED 保持错误信息与重新转码入口。
|
||||
- 保持现有轮询停止、关闭清理与字符串 ID 逻辑。
|
||||
|
||||
## 验证
|
||||
|
||||
- `pnpm lint:eslint`、`pnpm build:dev`。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 印尼市场短剧 App UI / UX 原型
|
||||
|
||||
## 范围
|
||||
|
||||
- 新增独立静态原型 `public/indonesia/`,不修改管理后台、Flutter 或接口契约。
|
||||
- 沿用现有 FruitShorts / DramaPulse 设计方向:深色、橙红主色、印尼语、三个主导航。
|
||||
- 覆盖首页、发现/搜索、详情/选集、播放交互、收藏/历史、个人中心、金币解锁。
|
||||
- 原型数据及钱包均仅保存在浏览器;不调用真实支付,不伪造支付成功。
|
||||
|
||||
## 步骤
|
||||
|
||||
1. 阅读项目规范、PRD、契约与设计基线,保留用户现有修改。
|
||||
2. 独立实现响应式界面、可访问交互与本地状态。
|
||||
3. 检查桌面/移动布局、搜索空态、收藏持久化与解锁幂等。
|
||||
4. 验证原后台构建,记录结果并提供可直接打开的入口。
|
||||
|
||||
## 风险 / 待确认
|
||||
|
||||
- 当前目录为管理后台,故交付独立设计原型,不将 C 端覆盖到后台。
|
||||
- 原型不代表已实现 Flutter App;虚构剧名、热度、价格只用于界面设计。
|
||||
- 生图工具没有返回可用图片,采用示意照片;正式剧集图需替换为已授权内容。
|
||||
- 播放器为分镜交互演示,不将静态图片伪装成已接入的剧集视频。
|
||||
- 金币不足仅展示充值方案与商店结算说明,不接外部钱包或真实扣款。
|
||||
- 原有 `TagManagementView.vue` 修改不属于本任务,保持原样。
|
||||
- INBOX 内旧任务不属于本次用户明确要求,不执行、不标记完成。
|
||||
|
||||
## 验收
|
||||
|
||||
- 待执行。
|
||||
@@ -0,0 +1,189 @@
|
||||
## 2026-10-22 后台转码设置页面
|
||||
|
||||
### 开工确认与计划
|
||||
|
||||
- 仓库:ReelShortWeb
|
||||
- 基线:`develop` / `origin/develop` = `723d056`
|
||||
- 工作区:开工前干净
|
||||
- 分支:`feature/transcode-profile-settings`
|
||||
- 计划:`docs/plans/2026-10-22-transcode-profile-settings.md`
|
||||
|
||||
```text
|
||||
$ git status --short --branch && git rev-parse --short HEAD
|
||||
## develop...origin/develop
|
||||
723d056
|
||||
```
|
||||
|
||||
解读:基于指定 develop 基线创建隔离工作树。后端契约已确认字段为
|
||||
`enabled / segmentSeconds / ladder / sprite`,权限为 `episode:upload`。
|
||||
默认服务端动态菜单只读取 `sys_menu`,本单按“只改 Web”约束在不改后端的前提下,
|
||||
对具备 `episode:upload` 的账号补充本地受控菜单,后端接口仍执行最终鉴权。
|
||||
|
||||
### 实现与门禁
|
||||
|
||||
- 新增 `/content/media-transcode` 静态路由和服务端动态菜单补充;
|
||||
菜单标题走 `router.transcodeSettings`,只在账号具备 `episode:upload` 时加入。
|
||||
- 新增转码设置页面,字段严格为 `enabled / segmentSeconds / ladder / sprite`;
|
||||
支持档位增删、动态行校验、首个错误字段定位及服务端返回值回填。
|
||||
- API 使用现有 axios Result 包装,GET 初始化、PUT 保存;成功/失败/校验提示全部走中英 i18n。
|
||||
- 当前工程没有单元/组件测试框架,未新增依赖;使用 ESLint、TypeScript 与构建门禁。
|
||||
|
||||
```text
|
||||
$ pnpm lint:eslint
|
||||
> fruitshorts-admin@1.0.0 lint:eslint /private/tmp/reelshortweb-transcode-settings
|
||||
> eslint --fix --ext .js,.ts,.vue ./src
|
||||
|
||||
$ pnpm ts:check
|
||||
> fruitshorts-admin@1.0.0 ts:check /private/tmp/reelshortweb-transcode-settings
|
||||
> pnpm vue-tsc --noEmit --skipLibCheck
|
||||
```
|
||||
|
||||
解读:ESLint 零告警,TypeScript 类型检查通过。
|
||||
|
||||
```text
|
||||
$ pnpm build:dev
|
||||
> fruitshorts-admin@1.0.0 build:dev /private/tmp/reelshortweb-transcode-settings
|
||||
> VITE_CJS_IGNORE_WARNING=1 vite build --mode dev
|
||||
|
||||
WARN Circular chunk: element-plus -> vue-chunks -> element-plus. Please adjust the manual chunk logic for these chunks.
|
||||
|
||||
Build successful. Please see dist-dev directory
|
||||
```
|
||||
|
||||
解读:开发构建成功;circular chunk 为仓库既有 Element Plus 分包警告,本次未改构建配置。
|
||||
|
||||
## 2026-10-22 剧集转码面板组件拆分
|
||||
|
||||
- 分支:`feature/transcode-panel-split`
|
||||
- 基线:`develop` / `origin/develop` = `52b10d3`
|
||||
- 计划:`docs/plans/2026-10-22-transcode-panel-split.md`
|
||||
|
||||
已读取 `AGENTS.md` 与 `CODING_STANDARDS.md`。当前
|
||||
`EpisodeTranscodePanel.vue` 为 816 行,超过单文件 500 行红线。
|
||||
拆分将保持父组件的数据获取、轮询清理、分页请求与切集重置逻辑不变。
|
||||
|
||||
### 拆分结果
|
||||
|
||||
```text
|
||||
$ wc -l src/views/contentManagement/upload/EpisodeTranscodePanel.vue \
|
||||
src/views/contentManagement/upload/EpisodeTranscodeStatusBadge.vue \
|
||||
src/views/contentManagement/upload/EpisodeTranscodeStatusPanel.vue \
|
||||
src/views/contentManagement/upload/EpisodeTranscodeRenditions.vue \
|
||||
src/views/contentManagement/upload/EpisodeTranscodeFrames.vue \
|
||||
src/views/contentManagement/upload/episodeTranscodeUi.ts
|
||||
266 EpisodeTranscodePanel.vue
|
||||
45 EpisodeTranscodeStatusBadge.vue
|
||||
155 EpisodeTranscodeStatusPanel.vue
|
||||
119 EpisodeTranscodeRenditions.vue
|
||||
315 EpisodeTranscodeFrames.vue
|
||||
93 episodeTranscodeUi.ts
|
||||
```
|
||||
|
||||
解读:父面板只保留接口调用、轮询、请求序号、分页状态与组件组合;
|
||||
状态、清晰度、帧图分别独立,全部文件均低于 500 行。
|
||||
|
||||
### 门禁
|
||||
|
||||
```text
|
||||
$ pnpm ts:check
|
||||
> vue-tsc --noEmit --skipLibCheck
|
||||
(通过)
|
||||
|
||||
$ pnpm lint:eslint
|
||||
> eslint --fix --ext .js,.ts,.vue ./src
|
||||
(0 error / 0 warning)
|
||||
|
||||
$ pnpm build:dev
|
||||
WARN Circular chunk: element-plus -> vue-chunks -> element-plus.
|
||||
Build successful. Please see dist-dev directory
|
||||
```
|
||||
|
||||
解读:组件 props/emits、类型检查、ESLint 和开发构建全部通过;
|
||||
循环 chunk 为项目既有警告。轮询启停、接口路径、分页参数和 locales 文案未改变。
|
||||
|
||||
## 2026-10-22 转码媒资 ID 前端精度修复
|
||||
|
||||
- 分支:`feature/transcode-media-id-fix-ui`
|
||||
- 基线:`develop` / `origin/develop` = `a4893c3`
|
||||
|
||||
已复现 `Number('2113150398670966786')` 变为 `2113150398670966800`。
|
||||
本次将媒资、任务 id 全链路按字符串处理,禁止 Number 转换;没有媒资时
|
||||
禁用重新转码并显示空态。
|
||||
|
||||
```text
|
||||
$ node -e "const id='2113150398670966786'; console.log(String(Number(id)))"
|
||||
2113150398670966800
|
||||
```
|
||||
|
||||
解读:Snowflake id 超过 JS 安全整数,字符串化后的 Number 已失真。
|
||||
|
||||
### 实现
|
||||
|
||||
- `EpisodeTranscodeItem` 的 episodeId/mediaAssetId/taskId,以及 rendition/thumbnail
|
||||
相关 id 类型改为字符串。
|
||||
- 父面板使用 `detail.mediaAssetId` 原始字符串和正则校验,不再 Number 转换。
|
||||
- 手动转码与帧图分页接口原样传递字符串 id。
|
||||
- `mediaAssetId` 为空时按钮保持禁用,空态优先展示后端 errorMsg 原因。
|
||||
|
||||
静态检查确认:
|
||||
|
||||
```text
|
||||
$ rg -n "Number\([^\n]*mediaAssetId|mediaAssetId[^\n]*Number\(" \
|
||||
src/views/contentManagement/upload src/api/admin
|
||||
(无匹配)
|
||||
```
|
||||
|
||||
### 门禁
|
||||
|
||||
```text
|
||||
$ pnpm lint:eslint
|
||||
> eslint --fix --ext .js,.ts,.vue ./src
|
||||
(0 error / 0 warning)
|
||||
|
||||
$ pnpm ts:check
|
||||
> vue-tsc --noEmit --skipLibCheck
|
||||
(通过)
|
||||
|
||||
$ pnpm build:dev
|
||||
WARN Circular chunk: element-plus -> vue-chunks -> element-plus.
|
||||
Build successful. Please see dist-dev directory
|
||||
```
|
||||
|
||||
解读:字符串 id 链路、类型检查、ESLint 与开发构建均通过;
|
||||
循环 chunk 为项目既有警告。
|
||||
|
||||
## 2026-10-22 转码面板实时进度与更新时间
|
||||
|
||||
- 分支:`feature/transcode-progress-ui`
|
||||
- 基线:`develop` / `origin/develop` = `0d5d0d8`
|
||||
- 计划:`docs/plans/2026-10-22-transcode-progress-ui.md`
|
||||
|
||||
后端将补充 task.updatedAt;前端在现有轮询面板中展示进度、状态、
|
||||
最近更新时间,并在 RUNNING/QUEUED progress=0 时显示等待进度上报提示。
|
||||
|
||||
### 实现
|
||||
|
||||
- `EpisodeTranscodeItem` 增加 `updatedAt`。
|
||||
- 状态区展示“最近更新”,格式沿用 `formatDateTime`。
|
||||
- RUNNING/QUEUED 且 progress=0 时显示“处理中(等待进度上报)”。
|
||||
- SUCCESS/FAILED 继续停止轮询;关闭面板继续清理定时器。
|
||||
- 所有 id 保持字符串传递,不引入 Number 转换。
|
||||
|
||||
### 门禁
|
||||
|
||||
```text
|
||||
$ pnpm lint:eslint
|
||||
> eslint --fix --ext .js,.ts,.vue ./src
|
||||
(0 error / 0 warning)
|
||||
|
||||
$ pnpm ts:check
|
||||
> vue-tsc --noEmit --skipLibCheck
|
||||
(通过)
|
||||
|
||||
$ pnpm build:dev
|
||||
WARN Circular chunk: element-plus -> vue-chunks -> element-plus.
|
||||
Build successful. Please see dist-dev directory
|
||||
```
|
||||
|
||||
解读:进度与更新时间类型、ESLint、TypeScript 和开发构建全部通过;
|
||||
循环 chunk 为项目既有警告。
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" href="/brand-mark.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title><%= title %></title>
|
||||
</head>
|
||||
@@ -16,7 +16,8 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
background: #f0f2f5;
|
||||
color: #f8f1ea;
|
||||
background: #100c0a;
|
||||
}
|
||||
|
||||
.app-loading .app-loading-wrap {
|
||||
@@ -38,8 +39,9 @@
|
||||
}
|
||||
|
||||
.app-loading .app-loading-logo {
|
||||
width: 100px;
|
||||
width: 72px;
|
||||
margin: 0 auto 15px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.app-loading .app-loading-item {
|
||||
@@ -55,7 +57,7 @@
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 4px solid #2d8cf0;
|
||||
border: 4px solid #c4322a;
|
||||
border-bottom: 0;
|
||||
border-left-color: transparent;
|
||||
border-radius: 50%;
|
||||
@@ -68,7 +70,7 @@
|
||||
left: calc(50% - 20px);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #87bdff;
|
||||
border: 4px solid #ff8a72;
|
||||
border-right: 0;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
@@ -85,16 +87,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loader-outter {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loader-inner {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
@@ -105,20 +97,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loader-inner {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<div class="app-loading">
|
||||
<div class="app-loading-wrap">
|
||||
<div class="app-loading-title">
|
||||
<img src="/logo.png" class="app-loading-logo" alt="Logo" />
|
||||
<img src="/brand-mark.png" class="app-loading-logo" alt="Logo" />
|
||||
<div class="app-loading-title"><%= title %></div>
|
||||
</div>
|
||||
<div class="app-loading-item">
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'
|
||||
|
||||
const modules = import.meta.glob('./**/*.mock.ts', {
|
||||
import: 'default',
|
||||
eager: true
|
||||
})
|
||||
|
||||
const mockModules: any[] = []
|
||||
Object.keys(modules).forEach(async (key) => {
|
||||
if (key.includes('_')) {
|
||||
return
|
||||
}
|
||||
mockModules.push(...(modules[key] as any))
|
||||
})
|
||||
|
||||
export function setupProdMockServer() {
|
||||
createProdMockServer(mockModules)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import { MockMethod } from 'vite-plugin-mock'
|
||||
import { SUCCESS_CODE } from '@/constants'
|
||||
|
||||
const timeout = 1000
|
||||
|
||||
const adminList = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: '#',
|
||||
redirect: '/dashboard/index',
|
||||
name: 'Dashboard',
|
||||
meta: {
|
||||
title: '工作台',
|
||||
icon: 'ant-design:dashboard-filled'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
name: 'dashboard',
|
||||
component: 'views/common/DashboardView',
|
||||
meta: {
|
||||
title: '工作台',
|
||||
icon: 'ant-design:dashboard-filled',
|
||||
affix: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/content',
|
||||
component: '#',
|
||||
redirect: '/content/dramas',
|
||||
name: 'Content',
|
||||
meta: {
|
||||
title: '内容管理',
|
||||
icon: 'ep:video-camera',
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'dramas',
|
||||
name: 'dramas',
|
||||
component: 'views/contentManagement/DramaManagementView',
|
||||
meta: {
|
||||
title: '短剧管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'episodes',
|
||||
name: 'episodes',
|
||||
component: 'views/contentManagement/EpisodeManagementView',
|
||||
meta: {
|
||||
title: '剧集管理'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/system',
|
||||
component: '#',
|
||||
redirect: '/system/access',
|
||||
name: 'System',
|
||||
meta: {
|
||||
title: '后台管理',
|
||||
icon: 'ep:setting',
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'access',
|
||||
name: 'access',
|
||||
component: 'views/permission/RoleManagementView',
|
||||
meta: {
|
||||
title: '角色权限配置'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'admin-users',
|
||||
name: 'admin-users',
|
||||
component: 'views/userManagement/AdminUsersView',
|
||||
meta: {
|
||||
title: '管理员用户列表'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'menus',
|
||||
name: 'menus',
|
||||
component: 'views/permission/MenuManagementView',
|
||||
meta: {
|
||||
title: '菜单管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'role-categories',
|
||||
name: 'role-categories',
|
||||
component: 'views/permission/RoleCategoriesView',
|
||||
meta: {
|
||||
title: '角色分类'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: 'views/systemManagement/SettingsView',
|
||||
meta: {
|
||||
title: '系统设置'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const testList = adminList.filter((route) => route.path !== '/system')
|
||||
|
||||
export default [
|
||||
// 列表接口
|
||||
{
|
||||
url: '/mock/role/list',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: ({ query }) => {
|
||||
const { roleName } = query
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: roleName === 'admin' ? adminList : testList
|
||||
}
|
||||
}
|
||||
}
|
||||
] as MockMethod[]
|
||||
@@ -1,92 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import { SUCCESS_CODE } from '@/constants'
|
||||
|
||||
const timeout = 1000
|
||||
|
||||
const List: {
|
||||
username: string
|
||||
password: string
|
||||
role: string
|
||||
roleId: string
|
||||
permissions: string | string[]
|
||||
}[] = [
|
||||
{
|
||||
username: 'admin',
|
||||
password: 'admin123',
|
||||
role: 'admin',
|
||||
roleId: '1',
|
||||
permissions: ['*.*.*']
|
||||
},
|
||||
{
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
role: 'test',
|
||||
roleId: '2',
|
||||
permissions: ['example:dialog:create', 'example:dialog:delete']
|
||||
}
|
||||
]
|
||||
|
||||
export default [
|
||||
// 列表接口
|
||||
{
|
||||
url: '/mock/user/list',
|
||||
method: 'get',
|
||||
response: ({ query }) => {
|
||||
const { username, pageIndex, pageSize } = query
|
||||
|
||||
const mockList = List.filter((item) => {
|
||||
if (username && item.username.indexOf(username) < 0) return false
|
||||
return true
|
||||
})
|
||||
const pageList = mockList.filter(
|
||||
(_, index) => index < pageSize * pageIndex && index >= pageSize * (pageIndex - 1)
|
||||
)
|
||||
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: {
|
||||
total: mockList.length,
|
||||
list: pageList
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 登录接口
|
||||
{
|
||||
url: '/mock/user/login',
|
||||
method: 'post',
|
||||
timeout,
|
||||
response: ({ body }) => {
|
||||
const data = body
|
||||
let hasUser = false
|
||||
for (const user of List) {
|
||||
if (user.username === data.username && user.password === data.password) {
|
||||
hasUser = true
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: user
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUser) {
|
||||
return {
|
||||
code: 500,
|
||||
message: '账号或密码错误'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 退出接口
|
||||
{
|
||||
url: '/mock/user/loginOut',
|
||||
method: 'get',
|
||||
timeout,
|
||||
response: () => {
|
||||
return {
|
||||
code: SUCCESS_CODE,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -6,7 +6,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"i": "pnpm install",
|
||||
"dev": "VITE_CJS_IGNORE_WARNING=1 vite --mode base",
|
||||
"dev": "vite --mode base",
|
||||
"ts:check": "pnpm vue-tsc --noEmit --skipLibCheck",
|
||||
"build:pro": "VITE_CJS_IGNORE_WARNING=1 vite build --mode pro",
|
||||
"build:dev": "VITE_CJS_IGNORE_WARNING=1 vite build --mode dev",
|
||||
@@ -24,8 +24,7 @@
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@faker-js/faker": "^8.4.0",
|
||||
"@element-plus/icons-vue": "2.3.2",
|
||||
"@iconify/iconify": "^3.1.1",
|
||||
"@iconify/vue": "^4.1.1",
|
||||
"@vueuse/core": "^10.7.2",
|
||||
@@ -40,6 +39,7 @@
|
||||
"echarts": "^5.4.3",
|
||||
"echarts-wordcloud": "^2.1.0",
|
||||
"element-plus": "2.5.5",
|
||||
"hls.js": "^1.7.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mitt": "^3.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
@@ -103,7 +103,6 @@
|
||||
"vite": "5.0.12",
|
||||
"vite-plugin-ejs": "^1.7.0",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-mock": "2.9.6",
|
||||
"vite-plugin-progress": "^0.0.7",
|
||||
"vite-plugin-purge-icons": "^0.10.0",
|
||||
"vite-plugin-style-import": "2.0.0",
|
||||
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -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.
|
||||
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 261 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 605 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 971 KiB |
@@ -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>
|
||||
|
Before Width: | Height: | Size: 9.0 KiB |
@@ -22,6 +22,7 @@ appStore.initTheme()
|
||||
<template>
|
||||
<ConfigGlobal :size="currentSize">
|
||||
<RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" />
|
||||
<footer class="app-copyright">© FruitShorts</footer>
|
||||
</ConfigGlobal>
|
||||
</template>
|
||||
|
||||
@@ -48,4 +49,15 @@ body {
|
||||
.@{prefix-cls}-grey-mode {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.app-copyright {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 10px;
|
||||
z-index: 5;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: var(--el-text-color-secondary, #909399);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,24 @@ export interface AppUserPageParams {
|
||||
status?: string
|
||||
countryCode?: string
|
||||
registerSource?: string
|
||||
/** 客户端平台:ANDROID / IOS / WEB */
|
||||
platform?: string
|
||||
}
|
||||
|
||||
export interface WatchHistoryPageParams {
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
userId?: number
|
||||
dramaTitle?: string
|
||||
dramaId?: number
|
||||
episodeId?: number
|
||||
/** 客户端平台:ANDROID / IOS / WEB */
|
||||
platform?: string
|
||||
source?: string
|
||||
finished?: boolean
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
export const pageAppUsersApi = (params: AppUserPageParams) => {
|
||||
@@ -32,6 +50,13 @@ export const listAppUserWatchHistoryApi = (id: number, limit = 50) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const pageWatchHistoryApi = (params: WatchHistoryPageParams) => {
|
||||
return request.get<PageResult<WatchHistoryItem>>({
|
||||
url: '/admin/watch-history/page',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** normal / limited / banned,封禁与限制会立即吊销该用户全部会话 */
|
||||
export const updateAppUserStatusApi = (id: number, status: string, reason?: string) => {
|
||||
return request.put<void>({
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
|
||||
export interface AppVersionItem {
|
||||
id: string
|
||||
platform: 'ANDROID' | 'IOS'
|
||||
platformText: string
|
||||
versionCode: number
|
||||
versionName: string
|
||||
downloadUrl: string
|
||||
updateContent?: string
|
||||
forceUpdate: number
|
||||
minSupportedCode: number
|
||||
status: number
|
||||
statusText: string
|
||||
releasedAt?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface AppVersionSaveData {
|
||||
platform: 'ANDROID' | 'IOS'
|
||||
versionCode: number
|
||||
versionName: string
|
||||
downloadUrl: string
|
||||
updateContent?: string
|
||||
forceUpdate?: 0 | 1
|
||||
minSupportedCode?: number
|
||||
}
|
||||
|
||||
export const listAppVersionsApi = (params: {
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
platform?: string
|
||||
status?: number
|
||||
keyword?: string
|
||||
}) => {
|
||||
return request.get<{ total: number; list: AppVersionItem[] }>({
|
||||
url: '/admin/app-versions/page',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export const getAppVersionApi = (id: string | number) => {
|
||||
return request.get<AppVersionItem>({ url: `/admin/app-versions/${id}` })
|
||||
}
|
||||
|
||||
export const createAppVersionApi = (data: AppVersionSaveData) => {
|
||||
return request.post<string>({ url: '/admin/app-versions', data })
|
||||
}
|
||||
|
||||
export const batchCreateAppVersionsApi = (data: AppVersionSaveData[]) => {
|
||||
return request.post<string[]>({ url: '/admin/app-versions/batch', data })
|
||||
}
|
||||
|
||||
export const updateAppVersionApi = (id: string | number, data: AppVersionSaveData) => {
|
||||
return request.post<void>({ url: `/admin/app-versions/${id}`, data })
|
||||
}
|
||||
|
||||
export const updateAppVersionStatusApi = (id: string | number, status: 0 | 1) => {
|
||||
return request.post<void>({
|
||||
url: `/admin/app-versions/${id}/status`,
|
||||
params: { status }
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteAppVersionApi = (id: string | number) => {
|
||||
return request.delete<void>({ url: `/admin/app-versions/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
|
||||
export interface ContentTagItem {
|
||||
id: number
|
||||
name: string
|
||||
nameEn: string
|
||||
nameId: string
|
||||
locales?: Record<string, string>
|
||||
bgColor?: string
|
||||
sort: number
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface ContentTagSaveData {
|
||||
name: string
|
||||
locales: Record<string, string>
|
||||
bgColor?: string
|
||||
sort?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export const listContentTagsApi = (enabledOnly = false) => {
|
||||
return request.get<ContentTagItem[]>({
|
||||
url: '/admin/content-tags',
|
||||
params: enabledOnly ? { enabledOnly: true } : undefined
|
||||
})
|
||||
}
|
||||
|
||||
export const createContentTagApi = (data: ContentTagSaveData) => {
|
||||
return request.post<number>({ url: '/admin/content-tags', data })
|
||||
}
|
||||
|
||||
export const updateContentTagApi = (id: number, data: ContentTagSaveData) => {
|
||||
return request.put<void>({ url: `/admin/content-tags/${id}`, data })
|
||||
}
|
||||
|
||||
export const deleteContentTagApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/content-tags/${id}` })
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -27,6 +27,28 @@ export interface TranscodeBoard {
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
/** 近 7 天每日趋势(给折线图用)。 */
|
||||
export interface DashboardTrend {
|
||||
dates: string[]
|
||||
newUsers: number[]
|
||||
playEvents: number[]
|
||||
newTranscodes: number[]
|
||||
transcodeSuccess: number[]
|
||||
}
|
||||
|
||||
/** 分布项(给饼图用)。 */
|
||||
export interface DistributionItem {
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface DashboardDistribution {
|
||||
dramaStatus: Record<string, number>
|
||||
transcodeStatus: Record<string, number>
|
||||
episodeMedia: Record<string, number>
|
||||
pendingModeration: DistributionItem[]
|
||||
}
|
||||
|
||||
export const getDashboardOverviewApi = () => {
|
||||
return request.get<DashboardOverview>({ url: '/admin/dashboard/overview' })
|
||||
}
|
||||
@@ -34,3 +56,11 @@ export const getDashboardOverviewApi = () => {
|
||||
export const getTranscodeBoardApi = () => {
|
||||
return request.get<TranscodeBoard>({ url: '/admin/dashboard/transcode' })
|
||||
}
|
||||
|
||||
export const getDashboardTrendApi = () => {
|
||||
return request.get<DashboardTrend>({ url: '/admin/dashboard/trend' })
|
||||
}
|
||||
|
||||
export const getDashboardDistributionApi = () => {
|
||||
return request.get<DashboardDistribution>({ url: '/admin/dashboard/distribution' })
|
||||
}
|
||||
@@ -15,7 +15,9 @@ export interface DramaPageParams {
|
||||
keyword?: string
|
||||
status?: string
|
||||
categoryId?: number
|
||||
categoryIds?: number[]
|
||||
isHot?: boolean
|
||||
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
||||
}
|
||||
|
||||
export const pageDramasApi = (params: DramaPageParams) => {
|
||||
@@ -38,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 */
|
||||
@@ -50,6 +53,7 @@ export const listDramaCategoryOptionsApi = () => {
|
||||
|
||||
export interface DramaCategorySaveData {
|
||||
name: string
|
||||
locales: Record<string, string>
|
||||
sort?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
import type { PageResult } from './types'
|
||||
|
||||
export interface DramaClassificationItem {
|
||||
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
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface DramaClassificationSaveData {
|
||||
name: string
|
||||
locales: Record<string, string>
|
||||
code?: string
|
||||
layoutType?: 'GRID' | 'RANKING'
|
||||
sort?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface ClassificationDramaItem {
|
||||
id: string
|
||||
title: string
|
||||
cover: string
|
||||
status: string
|
||||
totalEpisodes: number
|
||||
sort: number
|
||||
position?: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ClassificationDramaPageParams {
|
||||
keyword?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const listClassificationsApi = () => {
|
||||
return request.get<DramaClassificationItem[]>({ url: '/admin/drama-classifications' })
|
||||
}
|
||||
|
||||
export const createClassificationApi = (data: DramaClassificationSaveData) => {
|
||||
return request.post<string>({ url: '/admin/drama-classifications', data })
|
||||
}
|
||||
|
||||
export const updateClassificationApi = (id: string, data: DramaClassificationSaveData) => {
|
||||
return request.put<void>({ url: `/admin/drama-classifications/${id}`, data })
|
||||
}
|
||||
|
||||
export const deleteClassificationApi = (id: string) => {
|
||||
return request.delete<void>({ url: `/admin/drama-classifications/${id}` })
|
||||
}
|
||||
|
||||
/** 分类下已关联的短剧 */
|
||||
export const listClassificationDramasApi = (id: string, params: ClassificationDramaPageParams) => {
|
||||
return request.get<PageResult<ClassificationDramaItem>>({
|
||||
url: `/admin/drama-classifications/${id}/dramas`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** 尚未关联到该分类的短剧(添加弹窗用) */
|
||||
export const listClassificationDramaCandidatesApi = (
|
||||
id: string,
|
||||
params: ClassificationDramaPageParams
|
||||
) => {
|
||||
return request.get<PageResult<ClassificationDramaItem>>({
|
||||
url: `/admin/drama-classifications/${id}/dramas/candidates`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** 批量关联短剧,返回实际新增数量 */
|
||||
export const bindClassificationDramasApi = (id: string, dramaIds: string[]) => {
|
||||
return request.post<number>({
|
||||
url: `/admin/drama-classifications/${id}/dramas`,
|
||||
data: { dramaIds }
|
||||
})
|
||||
}
|
||||
|
||||
/** 从分类移除短剧 */
|
||||
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 }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
import type { PageResult } from './types'
|
||||
import type { ClassificationDramaItem } from './dramaClassification'
|
||||
|
||||
export interface DramaPlaylistCategoryItem {
|
||||
id: number
|
||||
name: string
|
||||
nameEn: string
|
||||
nameId: string
|
||||
description: string
|
||||
descriptionEn: string
|
||||
descriptionId: string
|
||||
locales?: Record<string, string>
|
||||
descriptionLocales?: Record<string, string>
|
||||
sort: number
|
||||
status: number
|
||||
dramaCount: number
|
||||
}
|
||||
|
||||
export interface DramaPlaylistCategorySaveData {
|
||||
name: string
|
||||
locales: Record<string, string>
|
||||
descriptionLocales?: Record<string, string>
|
||||
sort?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface PlaylistCategoryDramaPageParams {
|
||||
keyword?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const listPlaylistCategoriesApi = () => {
|
||||
return request.get<DramaPlaylistCategoryItem[]>({ url: '/admin/drama-playlist-categories' })
|
||||
}
|
||||
|
||||
export const createPlaylistCategoryApi = (data: DramaPlaylistCategorySaveData) => {
|
||||
return request.post<number>({ url: '/admin/drama-playlist-categories', data })
|
||||
}
|
||||
|
||||
export const updatePlaylistCategoryApi = (id: number, data: DramaPlaylistCategorySaveData) => {
|
||||
return request.put<void>({ url: `/admin/drama-playlist-categories/${id}`, data })
|
||||
}
|
||||
|
||||
export const deletePlaylistCategoryApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/drama-playlist-categories/${id}` })
|
||||
}
|
||||
|
||||
/** 分类下已关联的短剧 */
|
||||
export const listPlaylistCategoryDramasApi = (
|
||||
id: number,
|
||||
params: PlaylistCategoryDramaPageParams
|
||||
) => {
|
||||
return request.get<PageResult<ClassificationDramaItem>>({
|
||||
url: `/admin/drama-playlist-categories/${id}/dramas`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** 尚未关联到该分类的短剧(选择弹窗用) */
|
||||
export const listPlaylistCategoryDramaCandidatesApi = (
|
||||
id: number,
|
||||
params: PlaylistCategoryDramaPageParams
|
||||
) => {
|
||||
return request.get<PageResult<ClassificationDramaItem>>({
|
||||
url: `/admin/drama-playlist-categories/${id}/dramas/candidates`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** 批量关联短剧,返回实际新增数量 */
|
||||
export const bindPlaylistCategoryDramasApi = (id: number, dramaIds: number[]) => {
|
||||
return request.post<number>({
|
||||
url: `/admin/drama-playlist-categories/${id}/dramas`,
|
||||
data: { dramaIds }
|
||||
})
|
||||
}
|
||||
|
||||
/** 从分类移除短剧 */
|
||||
export const unbindPlaylistCategoryDramaApi = (id: number, dramaId: number) => {
|
||||
return request.delete<void>({
|
||||
url: `/admin/drama-playlist-categories/${id}/dramas/${dramaId}`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
import type { PageResult } from './types'
|
||||
|
||||
/** 收藏与点赞只读分页筛选条件。 */
|
||||
export interface EngagementPageParams {
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
userId?: string | number
|
||||
deviceId?: string
|
||||
/** 客户端平台:ANDROID / IOS / WEB */
|
||||
platform?: string
|
||||
dramaId?: string | number
|
||||
dramaTitle?: string
|
||||
targetType?: string
|
||||
targetId?: string | number
|
||||
episodeId?: string | number
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
/** 后台互动列表行。 */
|
||||
export interface EngagementItem {
|
||||
id: string
|
||||
userId: string | null
|
||||
uid: string | null
|
||||
nickname: string | null
|
||||
deviceId: string | null
|
||||
/** 客户端平台 ANDROID / IOS / WEB;老客户端未上报时为空。 */
|
||||
platform: string | null
|
||||
anonymous: boolean
|
||||
dramaId: string | null
|
||||
dramaTitle: string | null
|
||||
dramaCover: string | null
|
||||
dramaStatus: string | null
|
||||
dramaPublished: boolean | null
|
||||
targetType: string
|
||||
targetId: string
|
||||
episodeId: string | null
|
||||
episodeNo: number | null
|
||||
episodeTitle: string | null
|
||||
commentSummary: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 查询收藏分页。 */
|
||||
export const pageFavoritesApi = (params: EngagementPageParams) =>
|
||||
request.get<PageResult<EngagementItem>>({ url: '/admin/favorites/page', params })
|
||||
|
||||
/** 查询点赞分页。 */
|
||||
export const pageLikesApi = (params: EngagementPageParams) =>
|
||||
request.get<PageResult<EngagementItem>>({ url: '/admin/likes/page', params })
|
||||
@@ -1,10 +1,35 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
import type { EpisodeItem, EpisodeSaveData } from './types'
|
||||
import type { EpisodeItem, EpisodeSaveData, PageResult } from './types'
|
||||
|
||||
export const listEpisodesApi = (dramaId: number, status?: string) => {
|
||||
return request.get<EpisodeItem[]>({ url: '/admin/episodes', params: { dramaId, status } })
|
||||
export interface EpisodePageParams {
|
||||
dramaId: number | string
|
||||
status?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 解包剧集列表分页。
|
||||
* 契约(OpenAPI PageResultEpisodeRespVO):data = { total, list }。
|
||||
* 兼容误把 data 当数组的旧调用,避免列表空白。
|
||||
*/
|
||||
export function parseEpisodePage(payload: unknown): PageResult<EpisodeItem> {
|
||||
if (Array.isArray(payload)) {
|
||||
return { total: payload.length, list: payload as EpisodeItem[] }
|
||||
}
|
||||
if (payload && typeof payload === 'object') {
|
||||
const page = payload as { total?: unknown; list?: unknown }
|
||||
const list = Array.isArray(page.list) ? (page.list as EpisodeItem[]) : []
|
||||
const total = Number(page.total)
|
||||
return { total: Number.isFinite(total) ? total : list.length, list }
|
||||
}
|
||||
return { total: 0, list: [] }
|
||||
}
|
||||
|
||||
export const listEpisodesApi = (params: EpisodePageParams) => {
|
||||
return request.get<PageResult<EpisodeItem>>({ url: '/admin/episodes', params })
|
||||
}
|
||||
|
||||
export const createEpisodeApi = (data: EpisodeSaveData) => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
|
||||
export interface LegalDocument {
|
||||
docCode: 'terms' | 'privacy' | string
|
||||
lang: 'zh' | 'en' | 'id' | string
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export const listLegalDocumentsApi = () => {
|
||||
return request.get<LegalDocument[]>({ url: '/admin/legal-documents' })
|
||||
}
|
||||
|
||||
export const saveLegalDocumentsApi = (items: LegalDocument[]) => {
|
||||
return request.put<void>({ url: '/admin/legal-documents', data: items })
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
import type { MediaAsset } from './types'
|
||||
import type { MediaAsset, MediaTranscodeProfile } from './types'
|
||||
|
||||
export interface UploadInitData {
|
||||
fileName: string
|
||||
@@ -61,3 +61,20 @@ export const getUploadProgressApi = (uploadId: string) => {
|
||||
export const getMediaApi = (id: number) => {
|
||||
return request.get<MediaAsset>({ url: `/admin/media/${id}` })
|
||||
}
|
||||
|
||||
/** 读取当前转码档位配置;缺省时后端返回四档默认值。 */
|
||||
export const getMediaTranscodeProfileApi = () => {
|
||||
return request.get<MediaTranscodeProfile>({
|
||||
url: '/admin/media/transcode-profile',
|
||||
silent: true
|
||||
})
|
||||
}
|
||||
|
||||
/** 保存转码档位配置,并返回服务端最终生效值。 */
|
||||
export const saveMediaTranscodeProfileApi = (data: MediaTranscodeProfile) => {
|
||||
return request.put<MediaTranscodeProfile>({
|
||||
url: '/admin/media/transcode-profile',
|
||||
data,
|
||||
silent: true
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
@@ -0,0 +1,43 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
import type {
|
||||
EpisodeTranscodeItem,
|
||||
MediaThumbnailResult,
|
||||
TranscodeBackfillResult,
|
||||
TranscodeTriggerResult
|
||||
} from './types'
|
||||
|
||||
/** 分集编辑页聚合查询转码任务、清晰度与帧图。 */
|
||||
export const getEpisodeTranscodeApi = (episodeId: number | string) => {
|
||||
return request.get<EpisodeTranscodeItem>({
|
||||
url: `/admin/episodes/${episodeId}/transcode`
|
||||
})
|
||||
}
|
||||
|
||||
/** 按媒体分页读取帧图;页面切换时复用该接口。 */
|
||||
export const getMediaThumbnailsApi = (
|
||||
mediaAssetId: string,
|
||||
pageIndex: number,
|
||||
pageSize: number
|
||||
) => {
|
||||
return request.get<MediaThumbnailResult>({
|
||||
url: `/admin/media/${mediaAssetId}/thumbnails`,
|
||||
params: { pageIndex, pageSize }
|
||||
})
|
||||
}
|
||||
|
||||
/** 手动触发或重转当前分集绑定的媒体。 */
|
||||
export const triggerMediaTranscodeApi = (mediaAssetId: string) => {
|
||||
return request.post<TranscodeTriggerResult>({
|
||||
url: `/admin/media/${mediaAssetId}/transcode`
|
||||
})
|
||||
}
|
||||
|
||||
/** 手动补齐转码:扫描缺码率产物/帧图的分集并入队。 */
|
||||
export const runTranscodeBackfillApi = (batchSize?: number) => {
|
||||
return request.post<TranscodeBackfillResult>({
|
||||
url: '/admin/media/transcode/backfill',
|
||||
params: batchSize ? { batchSize } : undefined
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
|
||||
export interface TranscodeNoticeItem {
|
||||
id: number
|
||||
status: string
|
||||
dramaTitle: string
|
||||
episodeNo?: number
|
||||
episodeTitle?: string
|
||||
progress?: number
|
||||
errorMsg?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface TranscodeNotice {
|
||||
running: number
|
||||
success: number
|
||||
failed: number
|
||||
runningList: TranscodeNoticeItem[]
|
||||
successList: TranscodeNoticeItem[]
|
||||
failedList: TranscodeNoticeItem[]
|
||||
}
|
||||
|
||||
export const getTranscodeNoticesApi = () => {
|
||||
return request.get<TranscodeNotice>({ url: '/admin/notifications/transcode' })
|
||||
}
|
||||
@@ -8,6 +8,9 @@ export interface PageResult<T> {
|
||||
export interface DramaCategory {
|
||||
id: number
|
||||
name: string
|
||||
nameEn?: string
|
||||
nameId?: string
|
||||
locales?: Record<string, string>
|
||||
sort?: number
|
||||
status?: number
|
||||
/** 该类型下的短剧数量,管理列表才有 */
|
||||
@@ -20,11 +23,17 @@ export interface DramaItem {
|
||||
title: string
|
||||
subtitle?: string
|
||||
categoryId?: number
|
||||
categoryIds?: number[]
|
||||
categoryNames?: string[]
|
||||
category?: string
|
||||
genre?: string
|
||||
description?: string
|
||||
cover?: string
|
||||
/** 横版封面,用于继续观看横幅等横向展示位 */
|
||||
horizontalCover?: string
|
||||
language?: string
|
||||
dramaType?: 'ANIME' | 'LIVE'
|
||||
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
||||
episodes?: number
|
||||
freeEpisodes?: number
|
||||
unlockCoin?: number
|
||||
@@ -33,6 +42,9 @@ export interface DramaItem {
|
||||
status: string
|
||||
statusText: string
|
||||
views?: number
|
||||
favoriteCount?: number
|
||||
likeCount?: number
|
||||
commentCount?: number
|
||||
sort?: number
|
||||
publishAt?: string
|
||||
createdAt?: string
|
||||
@@ -43,17 +55,26 @@ export interface DramaSaveData {
|
||||
title: string
|
||||
subtitle?: string
|
||||
categoryId?: number
|
||||
categoryIds?: number[]
|
||||
genre?: string
|
||||
description?: string
|
||||
cover?: string
|
||||
/** 横版封面,用于继续观看横幅等横向展示位 */
|
||||
horizontalCover?: string
|
||||
language?: string
|
||||
dramaType?: 'ANIME' | 'LIVE'
|
||||
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
||||
freeEpisodes?: number
|
||||
unlockCoin?: number
|
||||
favoriteCount?: number
|
||||
likeCount?: number
|
||||
heat?: number
|
||||
isHot?: boolean
|
||||
sort?: number
|
||||
}
|
||||
|
||||
export type DramaReviewAction = 'submit' | 'approve' | 'reject' | 'publish' | 'offline'
|
||||
export type DramaReviewAction =
|
||||
'submit' | 'approve' | 'reject' | 'publish' | 'offline' | 'approvePublish'
|
||||
|
||||
export interface EpisodeItem {
|
||||
id: number
|
||||
@@ -69,6 +90,10 @@ export interface EpisodeItem {
|
||||
status: string
|
||||
statusText: string
|
||||
views?: number
|
||||
likeCount?: number
|
||||
commentCount?: number
|
||||
favoriteCount?: number
|
||||
heat?: number
|
||||
sort?: number
|
||||
}
|
||||
|
||||
@@ -81,6 +106,7 @@ export interface EpisodeSaveData {
|
||||
videoUrl?: string
|
||||
isFree?: boolean
|
||||
unlockCoin?: number
|
||||
likeCount?: number
|
||||
status?: string
|
||||
sort?: number
|
||||
}
|
||||
@@ -204,6 +230,115 @@ export interface MediaAsset {
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
/** 单档清晰度配置,字段与 FruitServer MediaTranscodeProfile.Rung 一致。 */
|
||||
export interface MediaTranscodeLadderRung {
|
||||
quality: string
|
||||
width: number
|
||||
height: number
|
||||
videoBitrateKbps: number
|
||||
audioBitrateKbps: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
/** 雪碧图采样配置,字段与 FruitServer MediaTranscodeProfile.Sprite 一致。 */
|
||||
export interface MediaTranscodeSprite {
|
||||
enabled: boolean
|
||||
intervalSeconds: number
|
||||
columns: number
|
||||
thumbWidth: number
|
||||
}
|
||||
|
||||
/** 转码档位配置,字段与 FruitServer MediaTranscodeProfile 一致。 */
|
||||
export interface MediaTranscodeProfile {
|
||||
enabled: boolean
|
||||
segmentSeconds: number
|
||||
ladder: MediaTranscodeLadderRung[]
|
||||
sprite: MediaTranscodeSprite
|
||||
}
|
||||
|
||||
export interface MediaRenditionItem {
|
||||
id: string
|
||||
quality: string
|
||||
qualityName: string
|
||||
width: number
|
||||
height: number
|
||||
resolution: string
|
||||
videoBitrateKbps: number
|
||||
audioBitrateKbps: number
|
||||
videoCodec: string
|
||||
audioCodec?: string
|
||||
container: string
|
||||
hlsUrl: string
|
||||
sizeBytes: number
|
||||
durationSec: number
|
||||
segmentCount: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface MediaThumbnailFrameItem {
|
||||
frameIndex: number
|
||||
timeMs: number
|
||||
endTimeMs?: number
|
||||
imageUrl: string
|
||||
spriteUrl: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface MediaThumbnailSpriteInfo {
|
||||
spriteUrl?: string
|
||||
spriteUrls: string[]
|
||||
vttUrl?: string
|
||||
columns: number
|
||||
rows: number
|
||||
totalFrames: number
|
||||
thumbWidth: number
|
||||
thumbHeight: number
|
||||
intervalSeconds: number
|
||||
}
|
||||
|
||||
export interface MediaThumbnailResult {
|
||||
mediaAssetId?: string | null
|
||||
episodeId?: string | null
|
||||
/** READY / FAILED / EMPTY / INVALID / NO_EPISODE */
|
||||
status: string
|
||||
errorMsg?: string
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
frames: PageResult<MediaThumbnailFrameItem>
|
||||
sprite?: MediaThumbnailSpriteInfo | null
|
||||
}
|
||||
|
||||
export interface EpisodeTranscodeItem {
|
||||
episodeId: string
|
||||
mediaAssetId?: string | null
|
||||
taskId?: string | null
|
||||
status: string
|
||||
progress?: number
|
||||
retryCount?: number
|
||||
errorMsg?: string
|
||||
updatedAt?: string
|
||||
renditions: MediaRenditionItem[]
|
||||
thumbnails?: MediaThumbnailResult | null
|
||||
}
|
||||
|
||||
export interface TranscodeTriggerResult {
|
||||
taskId: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface TranscodeBackfillItem {
|
||||
episodeId: string
|
||||
taskId: string
|
||||
}
|
||||
|
||||
export interface TranscodeBackfillResult {
|
||||
triggered: number
|
||||
items: TranscodeBackfillItem[]
|
||||
}
|
||||
|
||||
export interface BannerItem {
|
||||
id: number
|
||||
imageUrl: string
|
||||
@@ -269,6 +404,10 @@ export interface AppUserItem {
|
||||
maskedTarget: string
|
||||
createdAt?: string
|
||||
lastLoginAt?: string
|
||||
/** ANDROID / IOS / WEB */
|
||||
platform?: string
|
||||
/** 系统版本,例如 Android 16 */
|
||||
osVersion?: string
|
||||
/** 预留:会员等级与到期(第二阶段) */
|
||||
vipLevel?: number
|
||||
vipExpireAt?: string
|
||||
@@ -294,16 +433,22 @@ export interface AppUserBanRecord {
|
||||
|
||||
export interface WatchHistoryItem {
|
||||
id: number
|
||||
userId?: number
|
||||
uid?: string
|
||||
nickname?: string
|
||||
dramaId: number
|
||||
dramaTitle?: string
|
||||
dramaPublished?: boolean
|
||||
episodeId: number
|
||||
episodeNo?: number
|
||||
episodeTitle?: string
|
||||
positionMs: number
|
||||
durationMs: number
|
||||
finished?: boolean
|
||||
source: string
|
||||
deviceId: string
|
||||
platform?: string
|
||||
osVersion?: string
|
||||
watchedAt?: string
|
||||
}
|
||||
|
||||
@@ -311,7 +456,10 @@ export interface WatchHistoryItem {
|
||||
export interface CommentItem {
|
||||
id: number
|
||||
dramaId: number
|
||||
dramaTitle?: string
|
||||
episodeId: number
|
||||
episodeNo?: number
|
||||
episodeTitle?: string
|
||||
parentId: number
|
||||
rootId: number
|
||||
userId: number
|
||||
@@ -330,9 +478,20 @@ export interface CommentItem {
|
||||
export interface ReportItem {
|
||||
id: number
|
||||
reporterId: number
|
||||
reporterNickname?: string
|
||||
reporterUid?: string
|
||||
/** COMMENT / USER / DRAMA */
|
||||
targetType: string
|
||||
targetId: number
|
||||
targetSummary?: string
|
||||
dramaId?: number
|
||||
dramaTitle?: string
|
||||
episodeId?: number
|
||||
episodeNo?: number
|
||||
episodeTitle?: string
|
||||
commentContent?: string
|
||||
targetNickname?: string
|
||||
targetUid?: string
|
||||
reasonCode: string
|
||||
description: string
|
||||
/** pending / handled / rejected */
|
||||
|
||||
@@ -26,10 +26,6 @@ export const getAdminRoleApi = (
|
||||
return request.get({ url: '/admin/auth/menus', params })
|
||||
}
|
||||
|
||||
export const getTestRoleApi = (params: RoleParams): Promise<IResponse<string[]>> => {
|
||||
return request.get({ url: '/mock/role/list2', params })
|
||||
}
|
||||
|
||||
/** 自助改密(首登强制改密也走这个接口) */
|
||||
export const changePasswordApi = (data: { oldPassword: string; newPassword: string }) => {
|
||||
return request.post<void>({ url: '/admin/auth/password', data })
|
||||
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,5 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import AppImage from './src/AppImage.vue'
|
||||
|
||||
export { AppImage }
|
||||
@@ -0,0 +1,94 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { ElImage } from 'element-plus'
|
||||
import { AppIcon } from '@/components/AppIcon'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
previewList: {
|
||||
type: Array as () => string[],
|
||||
default: () => []
|
||||
},
|
||||
initialIndex: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
fit: {
|
||||
type: String as PropType<'fill' | 'contain' | 'cover' | 'none' | 'scale-down'>,
|
||||
default: 'cover'
|
||||
},
|
||||
class: {
|
||||
type: [String, Array, Object],
|
||||
default: ''
|
||||
},
|
||||
style: {
|
||||
type: [String, Array, Object],
|
||||
default: undefined
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const resolvedPreviewList = computed(() => {
|
||||
const images = (props.previewList || []).filter(Boolean)
|
||||
return images.length ? images : [props.src]
|
||||
})
|
||||
|
||||
const resolvedInitialIndex = computed(() =>
|
||||
Math.max(0, Math.min(props.initialIndex, resolvedPreviewList.value.length - 1))
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElImage
|
||||
v-bind="$attrs"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:fit="fit"
|
||||
:preview-src-list="resolvedPreviewList"
|
||||
:initial-index="resolvedInitialIndex"
|
||||
:preview-teleported="true"
|
||||
:hide-on-click-modal="true"
|
||||
:class="['app-image', props.class]"
|
||||
:style="style"
|
||||
>
|
||||
<template #error>
|
||||
<div class="app-image-error">
|
||||
<AppIcon :icon="AppIcons.error" :size="20" />
|
||||
</div>
|
||||
</template>
|
||||
</ElImage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-image {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.app-image :deep(.el-image__inner) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-image-error {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--el-text-color-placeholder);
|
||||
background: var(--el-fill-color-lighter);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
ElCascader,
|
||||
@@ -23,9 +24,12 @@ import {
|
||||
ElUpload
|
||||
} from 'element-plus'
|
||||
import { InputPassword } from '@/components/InputPassword'
|
||||
import { Editor } from '@/components/Editor'
|
||||
import { ComponentName } from '../types'
|
||||
|
||||
// wang-editor 打包后约 1.1MB:改成异步组件,只有真正渲染编辑器字段时才加载,
|
||||
// 避免登录页这类只是用了 Form 的页面把它一并拉进首屏。
|
||||
const Editor = defineAsyncComponent(() => import('@/components/Editor'))
|
||||
|
||||
const componentMap: Recordable<Component, ComponentName> = {
|
||||
RadioGroup: ElRadioGroup,
|
||||
RadioButton: ElRadioGroup,
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
ISelectProps,
|
||||
UploadProps
|
||||
} from 'element-plus'
|
||||
import { IEditorConfig } from '@wangeditor/editor'
|
||||
// 仅类型引用:必须用 import type,否则会把整个 wang-editor 打进登录页的依赖链
|
||||
import type { IEditorConfig } from '@wangeditor/editor'
|
||||
import { CSSProperties } from 'vue'
|
||||
|
||||
export interface PlaceholderModel {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -68,7 +68,12 @@ watch(
|
||||
>
|
||||
<img
|
||||
src="@/assets/imgs/brand-mark.png"
|
||||
class="w-[var(--brand-mark-size)] h-[var(--brand-mark-size)] rounded-[var(--brand-mark-radius)] object-cover"
|
||||
class="object-cover"
|
||||
style="
|
||||
width: var(--brand-mark-size);
|
||||
height: var(--brand-mark-size);
|
||||
border-radius: var(--brand-mark-radius);
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="show"
|
||||
|
||||
@@ -9,7 +9,7 @@ const { renderMenuTitle } = useRenderMenuTitle()
|
||||
export const useRenderMenuItem = () =>
|
||||
// allRouters: AppRouteRecordRaw[] = [],
|
||||
{
|
||||
const renderMenuItem = (routers: AppRouteRecordRaw[], parentPath = '/') => {
|
||||
const renderMenuItem = (routers: AppRouteRecordRaw[], parentPath = '/', nested = false) => {
|
||||
return routers
|
||||
.filter((v) => !v.meta?.hidden)
|
||||
.map((v) => {
|
||||
@@ -22,21 +22,25 @@ export const useRenderMenuItem = () =>
|
||||
(!onlyOneChild?.children || onlyOneChild?.noShowingChildren) &&
|
||||
!meta?.alwaysShow
|
||||
) {
|
||||
const leafMeta = onlyOneChild?.meta
|
||||
? {
|
||||
...onlyOneChild.meta,
|
||||
icon: onlyOneChild.meta.icon || meta.icon
|
||||
}
|
||||
: meta
|
||||
return (
|
||||
<ElMenuItem
|
||||
index={onlyOneChild ? pathResolve(fullPath, onlyOneChild.path) : fullPath}
|
||||
>
|
||||
{{
|
||||
default: () => renderMenuTitle(onlyOneChild ? onlyOneChild?.meta : meta)
|
||||
}}
|
||||
{{ default: () => renderMenuTitle(leafMeta, !nested) }}
|
||||
</ElMenuItem>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<ElSubMenu index={fullPath}>
|
||||
{{
|
||||
title: () => renderMenuTitle(meta),
|
||||
default: () => renderMenuItem(v.children!, fullPath)
|
||||
title: () => renderMenuTitle(meta, !nested),
|
||||
default: () => renderMenuItem(v.children!, fullPath, true)
|
||||
}}
|
||||
</ElSubMenu>
|
||||
)
|
||||
|
||||
@@ -5,11 +5,11 @@ import { AppIcon } from '@/components/AppIcon'
|
||||
import { useI18n } from '@/hooks/web/useI18n'
|
||||
|
||||
export const useRenderMenuTitle = () => {
|
||||
const renderMenuTitle = (meta: RouteMeta) => {
|
||||
const renderMenuTitle = (meta: RouteMeta, showIcon = true) => {
|
||||
const { t } = useI18n()
|
||||
const { title = 'Please set title', icon } = meta
|
||||
|
||||
return icon ? (
|
||||
return icon && showIcon ? (
|
||||
<>
|
||||
<AppIcon icon={meta.icon}></AppIcon>
|
||||
<span class="v-menu__title overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||
|
||||
@@ -195,7 +195,7 @@ const themeChange = () => {
|
||||
<ColorRadioPicker
|
||||
v-model="systemTheme"
|
||||
:schema="[
|
||||
'#409eff',
|
||||
'#c4322a',
|
||||
'#009688',
|
||||
'#536dfe',
|
||||
'#ff5c93',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/hooks/web/useI18n'
|
||||
import { ref } from 'vue'
|
||||
import { computed, reactive } from 'vue'
|
||||
import { Dialog } from '@/components/Dialog'
|
||||
import { Form } from '@/components/Form'
|
||||
import { useForm } from '@/hooks/web/useForm'
|
||||
import { reactive, computed } from 'vue'
|
||||
import { useValidator } from '@/hooks/web/useValidator'
|
||||
import { FormSchema } from '@/components/Form'
|
||||
import { ElButton } from 'element-plus'
|
||||
@@ -41,13 +40,10 @@ const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
console.log('set: ', val)
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const dialogTitle = ref(t('lock.lockScreen'))
|
||||
const dialogTitle = computed(() => t('lock.lockScreen'))
|
||||
|
||||
const rules = reactive({
|
||||
password: [required()]
|
||||
@@ -60,7 +56,10 @@ const schema: FormSchema[] = reactive([
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
type: 'password',
|
||||
showPassword: true
|
||||
showPassword: true,
|
||||
style: {
|
||||
width: '100%'
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
@@ -82,41 +81,169 @@ const handleLock = async () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="dialogVisible"
|
||||
width="500px"
|
||||
max-height="170px"
|
||||
width="440px"
|
||||
max-height="320px"
|
||||
:class="prefixCls"
|
||||
:title="dialogTitle"
|
||||
:fullscreen="false"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
:src="avatarUrl"
|
||||
alt=""
|
||||
class="w-70px h-70px rounded-[50%] object-cover"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="w-70px h-70px rounded-[50%] flex items-center justify-center bg-[var(--el-fill-color)]"
|
||||
><AppIcon :icon="AppIcons.person" :size="30"
|
||||
/></span>
|
||||
<span class="text-14px my-10px text-[var(--top-header-text-color)]">{{ displayName }}</span>
|
||||
<template #title>
|
||||
<div class="lock-dialog-heading">
|
||||
<AppIcon :icon="AppIcons.lock" :size="20" />
|
||||
<div>
|
||||
<h2>{{ dialogTitle }}</h2>
|
||||
<p>{{ t('lock.description') }}</p>
|
||||
</div>
|
||||
<Form :is-col="false" :schema="schema" :rules="rules" @register="formRegister" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="lock-dialog-body">
|
||||
<div class="lock-dialog-user">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" alt="" class="lock-dialog-avatar" />
|
||||
<span v-else class="lock-dialog-avatar lock-dialog-avatar--empty">
|
||||
<AppIcon :icon="AppIcons.person" :size="28" />
|
||||
</span>
|
||||
<span class="lock-dialog-name">{{ displayName }}</span>
|
||||
</div>
|
||||
<div class="lock-password-field" @keyup.enter.prevent.stop="handleLock">
|
||||
<Form
|
||||
:is-col="false"
|
||||
:schema="schema"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
@register="formRegister"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<ElButton type="primary" @click="handleLock">{{ t('lock.lock') }}</ElButton>
|
||||
<ElButton class="lock-dialog-cancel" @click="cancel">{{ t('common.cancel') }}</ElButton>
|
||||
<ElButton type="primary" class="lock-dialog-submit" @click="handleLock">
|
||||
{{ t('lock.lock') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:global(.v-lock-dialog) {
|
||||
@media (width <= 767px) {
|
||||
display: flex;
|
||||
width: 440px !important;
|
||||
height: auto !important;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: 440px;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:global(.v-lock-dialog .el-dialog__header) {
|
||||
height: auto;
|
||||
min-height: 56px;
|
||||
padding: 8px 20px 4px !important;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.v-lock-dialog .el-dialog__body) {
|
||||
min-height: 0;
|
||||
padding: 4px 20px 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.v-lock-dialog .el-dialog__footer) {
|
||||
padding: 6px 20px 10px;
|
||||
}
|
||||
|
||||
:global(.v-lock-dialog .el-dialog__footer .el-button) {
|
||||
height: 36px;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
:global(.v-lock-dialog .el-dialog__header .absolute) {
|
||||
right: -14px !important;
|
||||
}
|
||||
|
||||
@media (width <= 767px) {
|
||||
:global(.v-lock-dialog) {
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
}
|
||||
|
||||
.lock-dialog-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding-right: 40px;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
.app-icon {
|
||||
margin-top: 2px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.lock-dialog-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lock-dialog-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lock-dialog-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.lock-dialog-avatar--empty {
|
||||
display: flex;
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lock-dialog-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.lock-password-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lock-dialog-submit {
|
||||
min-width: 96px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,10 +3,12 @@
|
||||
import type { App } from 'vue'
|
||||
import { Icon } from './Icon'
|
||||
import { AppIcon } from './AppIcon'
|
||||
import { AppImage } from './AppImage'
|
||||
import { BaseButton } from './Button'
|
||||
|
||||
export const setupGlobCom = (app: App<Element>): void => {
|
||||
app.component('Icon', Icon)
|
||||
app.component('AppIcon', AppIcon)
|
||||
app.component('AppImage', AppImage)
|
||||
app.component('BaseButton', BaseButton)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
export interface ContentLanguage {
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const CONTENT_LANGUAGES: ContentLanguage[] = [
|
||||
{ code: 'zh', label: '中文' },
|
||||
{ code: 'en', label: '英文' },
|
||||
{ code: 'id', label: '印尼语' }
|
||||
]
|
||||
|
||||
export function languageLabel(code: string) {
|
||||
return CONTENT_LANGUAGES.find((item) => item.code === code)?.label || code
|
||||
}
|
||||
|
||||
export function languageCodes(locales?: Record<string, string> | null) {
|
||||
const source = locales || {}
|
||||
return CONTENT_LANGUAGES.map((item) => item.code).filter((code) => source[code])
|
||||
}
|
||||
|
||||
export function localesFromColumns(
|
||||
locales: Record<string, string> | null | undefined,
|
||||
zh?: string,
|
||||
en?: string,
|
||||
id?: string
|
||||
) {
|
||||
if (locales && Object.keys(locales).length) {
|
||||
return { ...locales }
|
||||
}
|
||||
const result: Record<string, string> = {}
|
||||
if (zh) result.zh = zh
|
||||
if (en) result.en = en
|
||||
if (id) result.id = id
|
||||
if (!result.zh) result.zh = ''
|
||||
return result
|
||||
}
|
||||
|
||||
export function payloadLocales(locales: Record<string, string>, codes: string[]) {
|
||||
const result: Record<string, string> = {}
|
||||
codes.forEach((code) => {
|
||||
result[code] = String(locales[code] || '').trim()
|
||||
})
|
||||
return result
|
||||
}
|
||||
@@ -37,6 +37,8 @@ export const AppIcons = {
|
||||
delete: 'material-symbols:delete-rounded',
|
||||
upload: 'material-symbols:upload-rounded',
|
||||
download: 'material-symbols:download-rounded',
|
||||
copy: 'material-symbols:content-copy-rounded',
|
||||
openInNew: 'material-symbols:open-in-new-rounded',
|
||||
visibility: 'material-symbols:visibility-rounded',
|
||||
arrowUp: 'material-symbols:arrow-upward-rounded',
|
||||
arrowDown: 'material-symbols:arrow-downward-rounded',
|
||||
|
||||
@@ -6,9 +6,11 @@ import { Collapse } from '@/components/Collapse'
|
||||
import { LocaleDropdown } from '@/components/LocaleDropdown'
|
||||
import { SizeDropdown } from '@/components/SizeDropdown'
|
||||
import { UserInfo } from '@/components/UserInfo'
|
||||
import TranscodeNoticeButton from './TranscodeNoticeButton.vue'
|
||||
import { Screenfull } from '@/components/Screenfull'
|
||||
import { Breadcrumb } from '@/components/Breadcrumb'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
const { getPrefixCls, variables } = useDesign()
|
||||
@@ -17,6 +19,16 @@ const prefixCls = getPrefixCls('tool-header')
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 转码通知接口要求 drama:list,创作者账号没有该权限。
|
||||
// 不给入口,避免顶栏每 30 秒轮询打一次 403。
|
||||
const canSeeTranscodeNotice = computed(() => {
|
||||
const permissions = userStore.getUserInfo?.permissions
|
||||
if (Array.isArray(permissions)) return permissions.includes('drama:list')
|
||||
return typeof permissions === 'string' && permissions.split(',').includes('drama:list')
|
||||
})
|
||||
|
||||
// 面包屑
|
||||
const breadcrumb = computed(() => appStore.getBreadcrumb)
|
||||
|
||||
@@ -73,6 +85,9 @@ export default defineComponent({
|
||||
color="var(--top-header-text-color)"
|
||||
></LocaleDropdown>
|
||||
) : undefined}
|
||||
{canSeeTranscodeNotice.value ? (
|
||||
<TranscodeNoticeButton color="var(--top-header-text-color)"></TranscodeNoticeButton>
|
||||
) : undefined}
|
||||
<UserInfo></UserInfo>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,9 +108,22 @@ export default defineComponent({
|
||||
:deep(.v-user-info) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 顶栏图标的点击热区原来只有 18px 图标本身,容易点偏导致「点了没反应」。
|
||||
// 统一撑满顶栏高度并左右留出内边距。
|
||||
:deep(.v-screenfull),
|
||||
:deep(.v-size-dropdown),
|
||||
:deep(.v-locale-dropdown),
|
||||
:deep(.v-user-info) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (width <= 768px) {
|
||||
.@{prefix-cls} {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
<!-- 顶栏转码通知:角标汇总转码中、成功、失败,点击进入通知页。 -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Bell } from '@element-plus/icons-vue'
|
||||
import { getTranscodeNoticesApi } from '@/api/admin/transcodeNotice'
|
||||
import { TRANSCODE_NOTICE_SEEN, unreadCounts } from '@/utils/transcodeNoticeSeen'
|
||||
|
||||
const POLL_MS = 30000
|
||||
const BADGE_MAX = 99
|
||||
|
||||
defineProps<{ color?: string }>()
|
||||
const router = useRouter()
|
||||
const running = ref(0)
|
||||
const success = ref(0)
|
||||
const failed = ref(0)
|
||||
const seenTick = ref(0)
|
||||
let timer = 0
|
||||
|
||||
const total = computed(() => {
|
||||
seenTick.value
|
||||
const unread = unreadCounts({
|
||||
running: running.value,
|
||||
success: success.value,
|
||||
failed: failed.value
|
||||
})
|
||||
return unread.running + unread.success + unread.failed
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
const res = await getTranscodeNoticesApi()
|
||||
running.value = Number(res.data?.running || 0)
|
||||
success.value = Number(res.data?.success || 0)
|
||||
failed.value = Number(res.data?.failed || 0)
|
||||
}
|
||||
|
||||
function open() {
|
||||
router.push({ name: 'transcode-notices' })
|
||||
}
|
||||
|
||||
function onSeen() {
|
||||
seenTick.value += 1
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh().catch(() => undefined)
|
||||
window.addEventListener(TRANSCODE_NOTICE_SEEN, onSeen)
|
||||
timer = window.setInterval(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, POLL_MS)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(TRANSCODE_NOTICE_SEEN, onSeen)
|
||||
window.clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-badge :value="total" :max="BADGE_MAX" :hidden="total === 0" class="notice-badge">
|
||||
<div class="notice-button" :style="{ color }" @click="open">
|
||||
<el-icon :size="18"><Bell /></el-icon>
|
||||
</div>
|
||||
</el-badge>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notice-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notice-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notice-badge :deep(.el-badge__content.is-fixed) {
|
||||
/* 角标中心骑在铃铛右上角:right 9px + 右移 50%,与位数宽度无关 */
|
||||
top: calc(50% - 9px);
|
||||
right: 9px;
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
</style>
|
||||
@@ -52,6 +52,7 @@ export default {
|
||||
lockScreen: 'Lock screen',
|
||||
lock: 'Lock',
|
||||
lockPassword: 'Lock screen password',
|
||||
description: 'Enter your password to unlock this session.',
|
||||
unlock: 'Click to unlock',
|
||||
backToLogin: 'Back to login',
|
||||
entrySystem: 'Entry the system',
|
||||
@@ -105,25 +106,15 @@ export default {
|
||||
login: {
|
||||
welcome: 'Short-drama workspace',
|
||||
consoleLabel: 'Content Operations',
|
||||
message: 'Manage dramas, episodes, publishing, and access.',
|
||||
hint: 'Sign in with an admin account',
|
||||
noAccount: 'No account yet?',
|
||||
pointDrama: 'Dramas and episodes',
|
||||
pointPublish: 'Publishing',
|
||||
pointAccess: 'Access',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
register: 'Register',
|
||||
checkPassword: 'Confirm password',
|
||||
login: 'Sign in',
|
||||
otherLogin: 'Sign in with',
|
||||
remember: 'Remember me',
|
||||
hasUser: 'Existing account? Go to login',
|
||||
forgetPassword: 'Forget password',
|
||||
forgetPasswordHint: 'Please contact the administrator to reset your password',
|
||||
usernamePlaceholder: 'Please input username',
|
||||
passwordPlaceholder: 'Please input password',
|
||||
code: 'Verification code',
|
||||
codePlaceholder: 'Please input verification code'
|
||||
passwordPlaceholder: 'Please input password'
|
||||
},
|
||||
router: {
|
||||
login: 'Login',
|
||||
@@ -172,7 +163,8 @@ export default {
|
||||
treeTable: 'Tree table',
|
||||
PicturePreview: 'Table Image Preview',
|
||||
department: 'Department management',
|
||||
menuManagement: 'Menu management'
|
||||
menuManagement: 'Menu management',
|
||||
transcodeSettings: 'Transcode settings'
|
||||
},
|
||||
permission: {
|
||||
hasPermission: 'Please set the operation permission value'
|
||||
@@ -536,5 +528,172 @@ export default {
|
||||
inputPasswordDemo: {
|
||||
title: 'InputPassword',
|
||||
inputPasswordDes: 'Secondary packaging of Input components based on ElementPlus'
|
||||
},
|
||||
episodeTranscode: {
|
||||
title: 'Transcode outputs',
|
||||
description: 'Review the transcode task, renditions and frame previews for this episode.',
|
||||
statusLabel: 'Status',
|
||||
progress: 'Progress',
|
||||
retryCount: 'Retries',
|
||||
updatedAt: 'Last updated',
|
||||
waitingProgress: 'Processing (waiting for progress report)',
|
||||
reprocess: 'Transcode again',
|
||||
reprocessing: 'Submitting',
|
||||
triggerSuccess: 'Transcode task submitted',
|
||||
loadFailed: 'Failed to load transcode outputs',
|
||||
retryLoad: 'Reload',
|
||||
triggerFailed: 'Failed to retry transcode',
|
||||
noMedia: 'No uploaded media is bound to this episode yet.',
|
||||
noRendition: 'No rendition outputs',
|
||||
renditionTitle: 'Rendition outputs',
|
||||
quality: 'Quality',
|
||||
resolution: 'Resolution',
|
||||
videoBitrate: 'Video bitrate',
|
||||
audioBitrate: 'Audio bitrate',
|
||||
codec: 'Codec',
|
||||
container: 'Container',
|
||||
fileSize: 'File size',
|
||||
duration: 'Duration',
|
||||
segments: 'Segments',
|
||||
statusColumn: 'Status',
|
||||
actions: 'Actions',
|
||||
copyUrl: 'Copy playback URL',
|
||||
openUrl: 'Open in new window',
|
||||
play: 'Preview playback',
|
||||
playerFailed: 'Playback failed, please check the rendition output',
|
||||
playerUnsupported: 'This browser does not support HLS playback',
|
||||
copySuccess: 'Playback URL copied',
|
||||
copyFailed: 'Failed to copy playback URL',
|
||||
openFailed: 'Unable to open playback URL',
|
||||
frameTitle: 'Frame previews',
|
||||
frameView: 'Frames',
|
||||
spriteView: 'Sprites',
|
||||
frameCount: '{total} frames',
|
||||
frameTime: 'Timestamp',
|
||||
previewFrame: 'Preview frame at {time}',
|
||||
spriteSheet: 'Sprite {index}',
|
||||
spriteSheetFrames: '{count} frames',
|
||||
noFrames: 'No frame previews',
|
||||
noSprites: 'No sprite sheets available',
|
||||
zoomTitle: 'Frame preview',
|
||||
status: {
|
||||
queued: 'Queued',
|
||||
running: 'Transcoding',
|
||||
success: 'Success',
|
||||
failed: 'Failed',
|
||||
empty: 'No output',
|
||||
noVideo: 'No video',
|
||||
noMediaAsset: 'No media asset',
|
||||
noTask: 'No transcode task',
|
||||
noEpisode: 'No episode binding',
|
||||
episodeNotFound: 'Episode not found',
|
||||
invalid: 'Invalid output',
|
||||
ready: 'Ready',
|
||||
unknown: 'Unknown'
|
||||
}
|
||||
},
|
||||
episodeBatch: {
|
||||
title: 'Batch upload episodes',
|
||||
entry: 'Batch upload',
|
||||
selectFiles: 'Select videos',
|
||||
startUpload: 'Start upload',
|
||||
uploadingAction: 'Uploading',
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
retry: 'Retry',
|
||||
retryAll: 'Retry all',
|
||||
remove: 'Remove',
|
||||
hint: 'Supports MP4 / MOV / MKV / WEBM, max 500MB each. Files stay queued until you click Start upload.',
|
||||
invalidType: 'Only MP4, MOV, MKV, WEBM are allowed',
|
||||
oversize: 'File exceeds 500MB and was not queued',
|
||||
overallProgress: 'Overall progress',
|
||||
empty: 'No videos selected',
|
||||
resultSummary: 'Upload finished: {done} succeeded, {failed} failed',
|
||||
createEpisodes: 'Create episodes',
|
||||
closeWhileUploading:
|
||||
'Uploads are still in progress. Closing will stop dispatching new tasks. Continue?',
|
||||
resumeTitle: 'Unfinished uploads found',
|
||||
resumeConfirm:
|
||||
'{count} unfinished upload(s) found. Resume? Re-select the same local files to match.',
|
||||
resumeYes: 'Pick files to resume',
|
||||
resumeNo: 'Discard',
|
||||
resumeAbandoned: 'Unfinished uploads discarded',
|
||||
resumeMatched: 'Matched {count} file(s). Click Start upload to continue.',
|
||||
resumeUnmatched: '{count} session(s) could not match local files; pick again manually',
|
||||
createTitle: 'Create episodes in batch',
|
||||
createHint:
|
||||
'Title uses the video file name; episode numbers are assigned automatically. Free vs paid follows the drama free-episode count; episodes beyond that are paid. You can edit them later in episode settings.',
|
||||
colSource: 'Source file',
|
||||
colNumber: 'Episode No.',
|
||||
colTitle: 'Title',
|
||||
colFree: 'Pricing',
|
||||
labelFree: 'Free',
|
||||
labelPaid: 'Paid',
|
||||
colResult: 'Result',
|
||||
missingVideo: 'Missing video URL',
|
||||
createOk: 'Created',
|
||||
createFailed: 'Create failed',
|
||||
createAllSuccess: 'All episodes created',
|
||||
createPartial: 'Some episodes failed; you can retry',
|
||||
submitCreate: 'Create',
|
||||
retryFailed: 'Retry failed',
|
||||
status: {
|
||||
waiting: 'Pending',
|
||||
uploading: 'Uploading',
|
||||
merging: 'Merging',
|
||||
done: 'Done',
|
||||
failed: 'Failed',
|
||||
paused: 'Paused'
|
||||
}
|
||||
},
|
||||
transcodeSettings: {
|
||||
eyebrow: 'Content management',
|
||||
title: 'Transcode settings',
|
||||
description:
|
||||
'Configure the quality ladder, HLS segment duration and thumbnail sprite options for new tasks.',
|
||||
reload: 'Reload',
|
||||
save: 'Save settings',
|
||||
saved: 'Transcode settings saved',
|
||||
saveFailed: 'Failed to save transcode settings',
|
||||
loadFailed: 'Failed to load transcode settings',
|
||||
noPermission: 'This account cannot manage transcode settings',
|
||||
backfill: 'Backfill transcodes',
|
||||
backfillTip: 'Scan episodes missing renditions or thumbnails and enqueue them for transcoding',
|
||||
backfillDone: 'Enqueued {count} episodes into the transcode queue',
|
||||
backfillEmpty: 'No episodes need transcoding',
|
||||
backfillFailed: 'Failed to run transcode backfill',
|
||||
enabled: 'Enable transcode profile',
|
||||
enabledDescription:
|
||||
'Marks whether this transcode profile is enabled; the quality ladder is still validated when saved.',
|
||||
segmentSeconds: 'HLS segment duration (seconds)',
|
||||
ladderTitle: 'Quality ladder',
|
||||
ladderDescription:
|
||||
'Changes apply only to tasks queued afterwards; historical tasks are not reprocessed.',
|
||||
quality: 'Quality key',
|
||||
width: 'Width',
|
||||
height: 'Height',
|
||||
videoBitrateKbps: 'Video bitrate (kbps)',
|
||||
audioBitrateKbps: 'Audio bitrate (kbps)',
|
||||
rowEnabled: 'Enabled',
|
||||
addRung: 'Add quality',
|
||||
deleteRung: 'Delete',
|
||||
emptyLadder: 'No quality rungs configured',
|
||||
spriteTitle: 'Thumbnail sprite',
|
||||
spriteDescription:
|
||||
'Sample frames at a fixed interval, tile them into sprites and generate a WebVTT timeline.',
|
||||
spriteEnabled: 'Enable thumbnail sprites',
|
||||
intervalSeconds: 'Sampling interval (seconds)',
|
||||
columns: 'Frames per row',
|
||||
thumbWidth: 'Thumbnail width (px)',
|
||||
validation: {
|
||||
required: 'This field is required',
|
||||
qualityUnique: 'Quality keys must be unique',
|
||||
qualityTooLong: 'Quality key must not exceed 16 characters',
|
||||
positiveEven: 'Enter an even number greater than 0',
|
||||
positiveInteger: 'Enter an integer greater than 0',
|
||||
range: 'Enter an integer between {min} and {max}',
|
||||
atLeastOneEnabled: 'Enable at least one quality rung',
|
||||
fixErrors: 'Fix the marked transcode fields before saving'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export default {
|
||||
lockScreen: '锁定屏幕',
|
||||
lock: '锁定',
|
||||
lockPassword: '锁屏密码',
|
||||
description: '输入密码解锁当前会话。',
|
||||
unlock: '点击解锁',
|
||||
backToLogin: '返回登录',
|
||||
entrySystem: '进入系统',
|
||||
@@ -105,25 +106,15 @@ export default {
|
||||
login: {
|
||||
welcome: '短剧内容管理',
|
||||
consoleLabel: '内容运营后台',
|
||||
message: '管理短剧、剧集、发布状态和后台权限。',
|
||||
hint: '使用管理员账号进入工作台',
|
||||
noAccount: '还没有账号?',
|
||||
pointDrama: '短剧与剧集',
|
||||
pointPublish: '发布状态',
|
||||
pointAccess: '角色权限',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
register: '注册',
|
||||
checkPassword: '确认密码',
|
||||
login: '登录',
|
||||
otherLogin: '其它登录方式',
|
||||
remember: '记住我',
|
||||
hasUser: '已有账号?去登录',
|
||||
forgetPassword: '忘记密码',
|
||||
forgetPasswordHint: '请联系管理员重置密码',
|
||||
usernamePlaceholder: '请输入用户名',
|
||||
passwordPlaceholder: '请输入密码',
|
||||
code: '验证码',
|
||||
codePlaceholder: '请输入验证码'
|
||||
passwordPlaceholder: '请输入密码'
|
||||
},
|
||||
router: {
|
||||
login: '登录',
|
||||
@@ -172,7 +163,8 @@ export default {
|
||||
treeTable: '树形表格',
|
||||
PicturePreview: '表格图片预览',
|
||||
department: '部门管理',
|
||||
menuManagement: '菜单管理'
|
||||
menuManagement: '菜单管理',
|
||||
transcodeSettings: '转码设置'
|
||||
},
|
||||
permission: {
|
||||
hasPermission: '请设置操作权限值'
|
||||
@@ -530,5 +522,166 @@ export default {
|
||||
inputPasswordDemo: {
|
||||
title: '密码输入框',
|
||||
inputPasswordDes: '基于 ElementPlus 的 Input 组件二次封装'
|
||||
},
|
||||
episodeTranscode: {
|
||||
title: '转码产物',
|
||||
description: '查看当前分集的转码任务、清晰度与帧图产物。',
|
||||
statusLabel: '状态',
|
||||
progress: '进度',
|
||||
retryCount: '重试次数',
|
||||
updatedAt: '最近更新',
|
||||
waitingProgress: '处理中(等待进度上报)',
|
||||
reprocess: '重新转码',
|
||||
reprocessing: '正在触发',
|
||||
triggerSuccess: '已提交转码任务',
|
||||
loadFailed: '转码产物加载失败',
|
||||
retryLoad: '重新加载',
|
||||
triggerFailed: '重新转码失败',
|
||||
noMedia: '当前分集尚未绑定已上传视频,暂无可查询的转码产物。',
|
||||
noRendition: '暂无清晰度产物',
|
||||
renditionTitle: '清晰度产物',
|
||||
quality: '档位名称',
|
||||
resolution: '分辨率',
|
||||
videoBitrate: '视频码率',
|
||||
audioBitrate: '音频码率',
|
||||
codec: '编码',
|
||||
container: '容器',
|
||||
fileSize: '文件大小',
|
||||
duration: '时长',
|
||||
segments: '分片数',
|
||||
statusColumn: '状态',
|
||||
actions: '操作',
|
||||
copyUrl: '复制播放地址',
|
||||
openUrl: '新窗口打开',
|
||||
play: '预览播放',
|
||||
playerFailed: '播放失败,请检查产物是否完整',
|
||||
playerUnsupported: '当前浏览器不支持 HLS 播放',
|
||||
copySuccess: '播放地址已复制',
|
||||
copyFailed: '复制播放地址失败',
|
||||
openFailed: '无法打开播放地址',
|
||||
frameTitle: '帧图预览',
|
||||
frameView: '逐帧',
|
||||
spriteView: '雪碧图',
|
||||
frameCount: '共 {total} 帧',
|
||||
frameTime: '时间点',
|
||||
previewFrame: '预览 {time} 帧图',
|
||||
spriteSheet: '雪碧图 {index}',
|
||||
spriteSheetFrames: '{count} 帧',
|
||||
noFrames: '暂无帧图产物',
|
||||
noSprites: '暂无可展示的雪碧图',
|
||||
zoomTitle: '帧图预览',
|
||||
status: {
|
||||
queued: '排队中',
|
||||
running: '转码中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
empty: '暂无产物',
|
||||
noVideo: '无视频',
|
||||
noMediaAsset: '未绑定媒资',
|
||||
noTask: '无转码任务',
|
||||
noEpisode: '未绑定分集',
|
||||
episodeNotFound: '分集不存在',
|
||||
invalid: '产物异常',
|
||||
ready: '就绪',
|
||||
unknown: '未知状态'
|
||||
}
|
||||
},
|
||||
episodeBatch: {
|
||||
title: '批量上传剧集',
|
||||
entry: '批量上传',
|
||||
selectFiles: '选择视频',
|
||||
startUpload: '开始上传',
|
||||
uploadingAction: '上传中',
|
||||
pause: '暂停',
|
||||
resume: '继续',
|
||||
retry: '重试',
|
||||
retryAll: '全部重试',
|
||||
remove: '移除',
|
||||
hint: '支持 MP4 / MOV / MKV / WEBM,单文件不超过 500MB;选完文件后点击「开始上传」才会真正上传',
|
||||
invalidType: '仅支持 MP4、MOV、MKV、WEBM',
|
||||
oversize: '文件超过 500MB,未入队',
|
||||
overallProgress: '总进度',
|
||||
empty: '尚未选择视频',
|
||||
resultSummary: '上传结束:成功 {done} 个,失败 {failed} 个',
|
||||
createEpisodes: '批量创建分集',
|
||||
closeWhileUploading: '仍有未完成上传,关闭后将暂停派发新任务,确定关闭吗?',
|
||||
resumeTitle: '发现未完成上传',
|
||||
resumeConfirm: '发现 {count} 个未完成上传,是否续传?请重新选择相同本地文件以匹配。',
|
||||
resumeYes: '选择文件续传',
|
||||
resumeNo: '放弃',
|
||||
resumeAbandoned: '已放弃未完成上传',
|
||||
resumeMatched: '已匹配 {count} 个文件,请点击「开始上传」继续',
|
||||
resumeUnmatched: '有 {count} 个会话无法匹配本地文件,请手动重选',
|
||||
createTitle: '批量创建分集',
|
||||
createHint:
|
||||
'标题取视频文件名,集号自动顺延分配;免费集数由短剧管理中的「免费集数」配置决定,超出部分为收费集;创建后可在分集编辑中修改',
|
||||
colSource: '源文件',
|
||||
colNumber: '集号',
|
||||
colTitle: '标题',
|
||||
colFree: '收费类型',
|
||||
labelFree: '免费',
|
||||
labelPaid: '收费',
|
||||
colResult: '结果',
|
||||
missingVideo: '缺少视频地址',
|
||||
createOk: '已创建',
|
||||
createFailed: '创建失败',
|
||||
createAllSuccess: '全部分集已创建',
|
||||
createPartial: '部分分集创建失败,可重试',
|
||||
submitCreate: '创建',
|
||||
retryFailed: '重试失败项',
|
||||
status: {
|
||||
waiting: '待上传',
|
||||
uploading: '上传中',
|
||||
merging: '合并中',
|
||||
done: '已完成',
|
||||
failed: '失败',
|
||||
paused: '已暂停'
|
||||
}
|
||||
},
|
||||
transcodeSettings: {
|
||||
eyebrow: '内容管理',
|
||||
title: '转码设置',
|
||||
description: '配置之后新任务的清晰度阶梯、HLS 分片与缩略图雪碧图参数。',
|
||||
reload: '重新加载',
|
||||
save: '保存设置',
|
||||
saved: '转码设置已保存',
|
||||
saveFailed: '转码设置保存失败',
|
||||
loadFailed: '转码设置加载失败',
|
||||
noPermission: '当前账号没有转码设置权限',
|
||||
backfill: '立即补齐转码',
|
||||
backfillTip: '扫描缺少清晰度产物或帧图的分集并自动入队转码',
|
||||
backfillDone: '本轮已补入 {count} 个分集进入转码队列',
|
||||
backfillEmpty: '没有需要补转的分集',
|
||||
backfillFailed: '补齐转码执行失败',
|
||||
enabled: '启用转码配置',
|
||||
enabledDescription: '标记当前转码配置是否启用;保存时仍会校验下方档位。',
|
||||
segmentSeconds: 'HLS 分片时长(秒)',
|
||||
ladderTitle: '清晰度阶梯',
|
||||
ladderDescription: '保存后只影响之后进入队列的新任务,不回跑历史任务。',
|
||||
quality: '清晰度标识',
|
||||
width: '宽',
|
||||
height: '高',
|
||||
videoBitrateKbps: '视频码率(kbps)',
|
||||
audioBitrateKbps: '音频码率(kbps)',
|
||||
rowEnabled: '启用',
|
||||
addRung: '新增档位',
|
||||
deleteRung: '删除',
|
||||
emptyLadder: '尚未配置清晰度档位',
|
||||
spriteTitle: '缩略图雪碧图',
|
||||
spriteDescription: '按固定间隔抽帧并拼成雪碧图,同时生成 WebVTT 时间轴。',
|
||||
spriteEnabled: '启用雪碧图',
|
||||
intervalSeconds: '抽帧间隔(秒)',
|
||||
columns: '每行帧数',
|
||||
thumbWidth: '缩略图宽度(px)',
|
||||
validation: {
|
||||
required: '该项为必填项',
|
||||
qualityUnique: '清晰度标识不能重复',
|
||||
qualityTooLong: '清晰度标识不能超过 16 个字符',
|
||||
positiveEven: '请输入大于 0 的偶数',
|
||||
positiveInteger: '请输入大于 0 的整数',
|
||||
range: '请输入 {min} 到 {max} 之间的整数',
|
||||
atLeastOneEnabled: '至少需要启用一档清晰度',
|
||||
fixErrors: '请修正标记的转码配置后再保存'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import 'vue/jsx'
|
||||
// 引入unocss
|
||||
import '@/plugins/unocss'
|
||||
|
||||
// 注册后台使用的本地图标子集
|
||||
import '@/plugins/icons'
|
||||
|
||||
// 导入全局的svg图标
|
||||
import '@/plugins/svgIcon'
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { NO_REDIRECT_WHITE_LIST } from '@/constants'
|
||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
import { getAdminRoleApi, getProfileApi } from '@/api/login'
|
||||
import { landingPath } from '@/utils/landing'
|
||||
import { supplementLocalAdminRoutes } from '@/router/localRoutes'
|
||||
|
||||
const { start, done } = useNProgress()
|
||||
|
||||
@@ -24,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
|
||||
}
|
||||
@@ -68,6 +76,10 @@ router.beforeEach(async (to, from, next) => {
|
||||
} catch {
|
||||
// 拉取失败时退回上次缓存的菜单,不阻塞进入
|
||||
}
|
||||
const permissions = Array.isArray(userStore.getUserInfo?.permissions)
|
||||
? userStore.getUserInfo.permissions
|
||||
: []
|
||||
routers = supplementLocalAdminRoutes(routers, permissions)
|
||||
await permissionStore.generateRoutes('server', routers)
|
||||
} else {
|
||||
await permissionStore.generateRoutes('frontEnd', roleRouters as string[])
|
||||
@@ -91,7 +103,12 @@ router.beforeEach(async (to, from, next) => {
|
||||
|
||||
const redirectPath = from.query.redirect || to.path
|
||||
const redirect = decodeURIComponent(redirectPath as string)
|
||||
const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect }
|
||||
// 只按路径重进。刷新时动态路由还没注册,先被兜底路由接住;
|
||||
// 若把当时匹配到的 name 带回去,会一直停在 404。
|
||||
const nextData =
|
||||
to.path === redirect
|
||||
? { path: to.path, query: to.query, hash: to.hash, replace: true }
|
||||
: { path: redirect, replace: true }
|
||||
permissionStore.setIsAddRouters(true)
|
||||
next(nextData)
|
||||
}
|
||||
|
||||
@@ -2,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
|
||||
@@ -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)
|
||||
@@ -4,12 +4,14 @@ import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import type { App } from 'vue'
|
||||
import { Layout } from '@/utils/routerHelper'
|
||||
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
|
||||
@@ -51,6 +53,35 @@ export const constantRouterMap: AppRouteRecordRaw[] = [
|
||||
title: '404',
|
||||
noTagsView: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/notifications',
|
||||
component: Layout,
|
||||
name: 'Notifications',
|
||||
meta: {
|
||||
hidden: true,
|
||||
title: '通知'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'transcode',
|
||||
name: 'transcode-notices',
|
||||
component: () => import('@/views/common/TranscodeNoticeView.vue'),
|
||||
meta: {
|
||||
title: '转码通知'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'UnresolvedPath',
|
||||
component: () => import('@/views/Error/404.vue'),
|
||||
meta: {
|
||||
hidden: true,
|
||||
title: '404',
|
||||
noTagsView: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -62,7 +93,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
name: 'Dashboard',
|
||||
meta: {
|
||||
title: '工作台',
|
||||
icon: 'ant-design:dashboard-filled'
|
||||
icon: AppIcons.dashboard
|
||||
},
|
||||
children: [
|
||||
{
|
||||
@@ -71,7 +102,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
component: () => import('@/views/common/DashboardView.vue'),
|
||||
meta: {
|
||||
title: '工作台',
|
||||
icon: 'ant-design:dashboard-filled',
|
||||
icon: AppIcons.dashboard,
|
||||
affix: true
|
||||
}
|
||||
}
|
||||
@@ -84,7 +115,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
name: 'Content',
|
||||
meta: {
|
||||
title: '内容管理',
|
||||
icon: 'ep:video-camera',
|
||||
icon: AppIcons.content,
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
@@ -119,6 +150,15 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
meta: {
|
||||
title: '类型管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'media-transcode',
|
||||
name: 'media-transcode',
|
||||
component: () => import('@/views/systemManagement/TranscodeSettingsView.vue'),
|
||||
meta: {
|
||||
title: 'router.transcodeSettings',
|
||||
permission: ['episode:upload']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -129,7 +169,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
name: 'UserCenter',
|
||||
meta: {
|
||||
title: '用户管理',
|
||||
icon: 'ep:user',
|
||||
icon: AppIcons.group,
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
@@ -150,7 +190,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
name: 'System',
|
||||
meta: {
|
||||
title: '后台管理',
|
||||
icon: 'ep:setting',
|
||||
icon: AppIcons.settings,
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
@@ -201,6 +241,14 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
meta: {
|
||||
title: '操作日志'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'legal-documents',
|
||||
name: 'legal-documents',
|
||||
component: () => import('@/views/systemManagement/LegalDocumentView.vue'),
|
||||
meta: {
|
||||
title: '协议设置'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -214,7 +262,14 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
export const resetRouter = (): void => {
|
||||
const resetWhiteNameList = ['Redirect', 'RedirectParent', 'Login', 'NoFind', 'Root']
|
||||
const resetWhiteNameList = [
|
||||
'Redirect',
|
||||
'RedirectParent',
|
||||
'Login',
|
||||
'NoFind',
|
||||
'Root',
|
||||
'UnresolvedPath'
|
||||
]
|
||||
router.getRoutes().forEach((route) => {
|
||||
const { name } = route
|
||||
if (name && !resetWhiteNameList.includes(name as string)) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
const TRANSCODE_PERMISSION = 'episode:upload'
|
||||
const CONTENT_ROUTE_PATH = '/content'
|
||||
const CONTENT_ROUTE_NAME = 'Content'
|
||||
const TRANSCODE_ROUTE_NAME = 'media-transcode'
|
||||
const TRANSCODE_ROUTE_PATH = 'media-transcode'
|
||||
const TRANSCODE_COMPONENT = 'views/systemManagement/TranscodeSettingsView'
|
||||
const TRANSCODE_TITLE_KEY = 'router.transcodeSettings'
|
||||
|
||||
/** 服务端菜单可能用别的路由名指向同一个组件,这里按路由名或组件路径判定是否已存在 */
|
||||
const hasTranscodeRoute = (routes: AppCustomRouteRecordRaw[]): boolean => {
|
||||
for (const route of routes) {
|
||||
if (route.name === TRANSCODE_ROUTE_NAME || route.component === TRANSCODE_COMPONENT) {
|
||||
return true
|
||||
}
|
||||
if (hasTranscodeRoute(route.children || [])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端动态菜单未落库时,给具备内容上传权限的账号补充受控的本地转码菜单。
|
||||
*/
|
||||
export const supplementLocalAdminRoutes = (
|
||||
routes: AppCustomRouteRecordRaw[],
|
||||
permissions: string[]
|
||||
): AppCustomRouteRecordRaw[] => {
|
||||
if (!permissions.includes(TRANSCODE_PERMISSION)) {
|
||||
return routes
|
||||
}
|
||||
// 服务端菜单里已经有转码设置时不再补,否则侧边栏会出现两条
|
||||
if (hasTranscodeRoute(routes)) {
|
||||
return routes
|
||||
}
|
||||
const nextRoutes = cloneDeep(routes)
|
||||
const contentRoute = nextRoutes.find(
|
||||
(route) => route.path === CONTENT_ROUTE_PATH || route.name === CONTENT_ROUTE_NAME
|
||||
)
|
||||
if (!contentRoute) {
|
||||
return routes
|
||||
}
|
||||
contentRoute.children = contentRoute.children || []
|
||||
contentRoute.children.push({
|
||||
path: TRANSCODE_ROUTE_PATH,
|
||||
name: TRANSCODE_ROUTE_NAME,
|
||||
component: TRANSCODE_COMPONENT,
|
||||
redirect: '',
|
||||
meta: {
|
||||
title: TRANSCODE_TITLE_KEY,
|
||||
permission: [TRANSCODE_PERMISSION]
|
||||
}
|
||||
})
|
||||
return nextRoutes
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export const useAppStore = defineStore('app', {
|
||||
breadcrumb: true, // 面包屑
|
||||
breadcrumbIcon: true, // 面包屑图标
|
||||
collapse: false, // 折叠菜单
|
||||
uniqueOpened: false, // 是否只保持一个子菜单的展开
|
||||
uniqueOpened: true, // 是否只保持一个子菜单的展开(菜单手风琴)
|
||||
hamburger: true, // 折叠图标
|
||||
screenfull: true, // 全屏图标
|
||||
size: true, // 尺寸图标
|
||||
@@ -67,13 +67,13 @@ export const useAppStore = defineStore('app', {
|
||||
currentSize: 'default', // 组件尺寸
|
||||
theme: {
|
||||
// 主题色
|
||||
elColorPrimary: '#409eff',
|
||||
elColorPrimary: '#c4322a',
|
||||
// 左侧菜单边框颜色
|
||||
leftMenuBorderColor: 'inherit',
|
||||
// 左侧菜单背景颜色
|
||||
leftMenuBgColor: '#001529',
|
||||
leftMenuBgColor: '#fff',
|
||||
// 左侧菜单浅色背景颜色
|
||||
leftMenuBgLightColor: '#0f2438',
|
||||
leftMenuBgLightColor: '#fff',
|
||||
// 左侧菜单选中背景颜色
|
||||
leftMenuBgActiveColor: 'var(--app-primary-strong)',
|
||||
// 左侧菜单收起选中背景颜色
|
||||
@@ -89,7 +89,7 @@ export const useAppStore = defineStore('app', {
|
||||
// 头部背景颜色
|
||||
topHeaderBgColor: '#fff',
|
||||
// 头部字体颜色
|
||||
topHeaderTextColor: 'inherit',
|
||||
topHeaderTextColor: '#303133',
|
||||
// 头部悬停颜色
|
||||
topHeaderHoverColor: '#f6f6f6',
|
||||
// 头部边框颜色
|
||||
@@ -299,7 +299,7 @@ export const useAppStore = defineStore('app', {
|
||||
// 左侧菜单选中字体颜色
|
||||
leftMenuTextActiveColor: isDarkColor ? '#fff' : 'var(--el-color-primary)',
|
||||
// logo字体颜色
|
||||
logoTitleTextColor: isDarkColor ? '#fff' : 'inherit',
|
||||
logoTitleTextColor: isDarkColor ? '#fff' : '#303133',
|
||||
// logo边框颜色
|
||||
logoBorderColor: isDarkColor ? color : '#eee'
|
||||
}
|
||||
@@ -308,7 +308,7 @@ export const useAppStore = defineStore('app', {
|
||||
},
|
||||
setHeaderTheme(color: string) {
|
||||
const isDarkColor = colorIsDark(color)
|
||||
const textColor = isDarkColor ? '#fff' : 'inherit'
|
||||
const textColor = isDarkColor ? '#fff' : '#303133'
|
||||
const textHoverColor = isDarkColor ? lighten(color!, 6) : '#f6f6f6'
|
||||
const topToolBorderColor = isDarkColor ? color : '#eee'
|
||||
setCssVar('--top-header-bg-color', color)
|
||||
@@ -343,6 +343,13 @@ export const useAppStore = defineStore('app', {
|
||||
persist: {
|
||||
afterRestore: (context) => {
|
||||
context.store.title = import.meta.env.VITE_APP_TITLE
|
||||
if (context.store.theme?.elColorPrimary !== '#c4322a') {
|
||||
context.store.setTheme({ elColorPrimary: '#c4322a' })
|
||||
}
|
||||
// 头部主题默认强制为白色、菜单主题也强制为白色
|
||||
// 避免老用户浏览器残留其他主题色,清缓存也能恢复到项目品牌配色
|
||||
context.store.setHeaderTheme('#fff')
|
||||
context.store.setMenuTheme('#fff')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
}
|
||||
|
||||
.app-user-toolbar .app-user-status,
|
||||
.app-user-toolbar .app-user-country {
|
||||
.app-user-toolbar .app-user-country,
|
||||
.app-user-toolbar .app-user-platform {
|
||||
width: 150px;
|
||||
flex: 0 0 150px;
|
||||
}
|
||||
@@ -53,7 +54,8 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-user-avatar img {
|
||||
.app-user-avatar img,
|
||||
.app-user-avatar .app-user-avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
@@ -62,6 +64,7 @@
|
||||
.app-user-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -19,31 +19,206 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.banner-field {
|
||||
.banner-editor-dialog {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
border-radius: var(--app-radius-lg) !important;
|
||||
}
|
||||
.banner-field-actions {
|
||||
|
||||
.banner-editor-dialog .el-dialog__body {
|
||||
flex: 1 1 auto;
|
||||
padding: 8px 24px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.banner-editor-dialog .el-dialog__footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 14px 24px;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.banner-editor-dialog .el-dialog__footer .el-button {
|
||||
min-width: 88px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.banner-editor-section {
|
||||
padding: 4px 0 18px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.banner-editor-section + .banner-editor-section {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.banner-editor-section:last-child {
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.banner-editor-section-title {
|
||||
margin: 0 0 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.banner-editor-grid {
|
||||
display: grid;
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.banner-editor-grid--two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.banner-editor-number {
|
||||
width: 180px !important;
|
||||
}
|
||||
|
||||
.banner-image-editor {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.banner-image-preview-card {
|
||||
position: relative;
|
||||
flex: 0 0 240px;
|
||||
width: 240px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
.banner-image-preview-card > img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.banner-image-empty {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.banner-image-upload-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: color-mix(in srgb, var(--el-color-primary) 78%, transparent);
|
||||
}
|
||||
|
||||
.banner-image-upload-mask b {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.banner-image-meta {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
/* 编辑弹窗里的轮播图预览:和上传控件并排,控制弹窗高度 */
|
||||
.form-banner-preview {
|
||||
display: block;
|
||||
width: 200px;
|
||||
height: 112px;
|
||||
flex: 0 0 200px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: var(--app-radius-md);
|
||||
background: #f0f2f4 !important;
|
||||
|
||||
.banner-image-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.banner-image-filename {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-image-hint {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.banner-image-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.banner-image-actions .el-button {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.banner-original-link {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.banner-original-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.banner-editor-dialog {
|
||||
width: calc(100vw - 24px) !important;
|
||||
margin: 12px auto !important;
|
||||
}
|
||||
|
||||
.banner-editor-dialog .el-dialog__body {
|
||||
padding-right: 18px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.banner-editor-grid--two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.banner-image-editor {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.banner-image-preview-card {
|
||||
flex-basis: 135px;
|
||||
}
|
||||
|
||||
.banner-image-meta {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .banner-thumb,
|
||||
.dark .banner-thumb-empty,
|
||||
.dark .form-banner-preview {
|
||||
.dark .banner-image-preview-card,
|
||||
.dark .banner-image-empty {
|
||||
background: #353b45 !important;
|
||||
color: #b9c1cb;
|
||||
}
|
||||
@@ -59,8 +59,10 @@
|
||||
}
|
||||
|
||||
.creator-drama-cover img,
|
||||
.creator-drama-cover .creator-drama-image,
|
||||
.creator-cover-preview img,
|
||||
.creator-episode-cover img {
|
||||
.creator-episode-cover img,
|
||||
.creator-episode-cover .creator-episode-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
@@ -186,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;
|
||||
@@ -283,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,
|
||||
@@ -301,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,12 @@ html.dark .role-card {
|
||||
|
||||
html.dark .stat-icon,
|
||||
html.dark .role-icon {
|
||||
background: rgb(64 158 255 / 16%) !important;
|
||||
background: color-mix(in srgb, var(--el-color-primary) 18%, transparent) !important;
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
|
||||
html.dark .role-active {
|
||||
background: rgb(64 158 255 / 12%);
|
||||
background: color-mix(in srgb, var(--el-color-primary) 14%, transparent);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
@@ -106,12 +106,12 @@ html.dark .el-tag.status-badge.is-draft {
|
||||
color: #c0c4cc !important;
|
||||
}
|
||||
html.dark .el-tag.status-badge.is-pending {
|
||||
--el-tag-bg-color: rgba(64, 158, 255, 0.18);
|
||||
--el-tag-border-color: rgba(121, 187, 255, 0.45);
|
||||
--el-tag-text-color: #8cc5ff;
|
||||
background: rgba(64, 158, 255, 0.18) !important;
|
||||
border-color: rgba(121, 187, 255, 0.45) !important;
|
||||
color: #8cc5ff !important;
|
||||
--el-tag-bg-color: color-mix(in srgb, var(--el-color-primary) 18%, transparent);
|
||||
--el-tag-border-color: color-mix(in srgb, var(--el-color-primary) 45%, transparent);
|
||||
--el-tag-text-color: var(--el-color-primary-light-5);
|
||||
background: color-mix(in srgb, var(--el-color-primary) 18%, transparent) !important;
|
||||
border-color: color-mix(in srgb, var(--el-color-primary) 45%, transparent) !important;
|
||||
color: var(--el-color-primary-light-5) !important;
|
||||
}
|
||||
html.dark .el-tag.status-badge.is-offline {
|
||||
--el-tag-bg-color: rgba(245, 108, 108, 0.18);
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.table-actions .el-button {
|
||||
/* 统一尺寸:高度固定、水平内边距,靠行高垂直居中,避免文字被压扁 */
|
||||
@@ -53,12 +53,6 @@
|
||||
.table-actions .el-button + .el-button {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.table-actions .danger-action {
|
||||
color: #a66a6a !important;
|
||||
}
|
||||
.table-actions .danger-action:hover {
|
||||
color: #b54848 !important;
|
||||
}
|
||||
.drama-cell .drama-cover,
|
||||
.drama-cell .drama-cover-image {
|
||||
width: 44px;
|
||||
@@ -91,51 +85,484 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 编辑弹窗里的封面预览:固定竖版尺寸,和上传控件并排,避免弹窗过高 */
|
||||
.cover-field {
|
||||
/* 短剧编辑弹窗:固定宽度、内容区滚动、底部操作固定。 */
|
||||
.drama-editor-dialog {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
.cover-field-actions {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.form-cover-preview {
|
||||
display: block;
|
||||
width: 84px;
|
||||
height: 112px;
|
||||
flex: 0 0 84px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: var(--app-radius-md);
|
||||
background: #f0f2f4 !important;
|
||||
}
|
||||
.dark .form-cover-preview {
|
||||
background: #353b45 !important;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
border-radius: var(--app-radius-lg) !important;
|
||||
}
|
||||
|
||||
/* 编辑弹窗里的视频预览 */
|
||||
.video-field {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
.drama-editor-dialog .el-dialog__body {
|
||||
flex: 1 1 auto;
|
||||
padding: 8px 24px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.video-field-actions {
|
||||
|
||||
.drama-editor-dialog .el-dialog__footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 14px 24px;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.drama-editor-dialog .el-dialog__footer .el-button {
|
||||
min-width: 88px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.editor-section {
|
||||
padding: 4px 0 18px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.editor-section + .editor-section {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.editor-section:last-child {
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.editor-section-title {
|
||||
margin: 0 0 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editor-grid {
|
||||
display: grid;
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.editor-grid--two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.editor-number {
|
||||
width: 180px !important;
|
||||
}
|
||||
|
||||
.readonly-stat {
|
||||
min-width: 180px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 32px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
}
|
||||
|
||||
.readonly-stat-hint {
|
||||
margin: 4px 0 0;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cover-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;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.cover-preview-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 0 0 96px;
|
||||
width: 96px;
|
||||
height: 128px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
.cover-preview-card--landscape {
|
||||
flex: 0 0 160px;
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
.cover-preview-card img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-preview-empty {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cover-upload-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: color-mix(in srgb, var(--el-color-primary) 78%, transparent);
|
||||
}
|
||||
|
||||
.cover-upload-mask b {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.cover-editor-meta {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-video-preview {
|
||||
|
||||
.cover-status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cover-filename {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cover-hint {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cover-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cover-actions .el-button {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.cover-original-link {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cover-original-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.drama-editor-dialog {
|
||||
width: calc(100vw - 24px) !important;
|
||||
margin: 12px auto !important;
|
||||
}
|
||||
|
||||
.drama-editor-dialog .el-dialog__body {
|
||||
padding-right: 18px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.editor-grid--two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cover-editor {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cover-preview-card {
|
||||
flex-basis: 128px;
|
||||
}
|
||||
|
||||
.cover-preview-card--landscape {
|
||||
flex-basis: 160px;
|
||||
}
|
||||
|
||||
.cover-editor-meta {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark .cover-preview-card,
|
||||
html.dark .cover-preview-empty {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
/* 剧集编辑弹窗 */
|
||||
.episode-editor-dialog {
|
||||
display: flex;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
border-radius: var(--app-radius-lg) !important;
|
||||
}
|
||||
|
||||
.episode-editor-dialog .el-dialog__body {
|
||||
flex: 1 1 auto;
|
||||
padding: 8px 24px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.episode-editor-dialog .el-dialog__footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 14px 24px;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.episode-editor-dialog .el-dialog__footer .el-button {
|
||||
min-width: 88px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.episode-editor-section {
|
||||
padding: 4px 0 18px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.episode-editor-section + .episode-editor-section {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.episode-editor-section:last-child {
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.episode-editor-section-title {
|
||||
margin: 0 0 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.episode-editor-grid {
|
||||
display: grid;
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.episode-editor-grid--two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.episode-editor-grid--three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.episode-editor-number {
|
||||
width: 180px !important;
|
||||
}
|
||||
|
||||
.episode-video-editor {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.episode-video-preview-card {
|
||||
position: relative;
|
||||
flex: 0 0 240px;
|
||||
width: 240px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 10px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.episode-editor-video {
|
||||
display: block;
|
||||
width: 200px;
|
||||
height: 112px;
|
||||
flex: 0 0 200px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: var(--app-radius-md);
|
||||
background: #000 !important;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.episode-video-empty-upload,
|
||||
.episode-video-empty-upload .el-upload {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.episode-video-empty-upload .el-upload-dragger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color-lighter);
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 9px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.episode-video-empty-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.episode-video-empty-copy b {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.episode-video-empty-copy span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.episode-video-upload-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: color-mix(in srgb, var(--el-color-primary) 78%, transparent);
|
||||
}
|
||||
|
||||
.episode-video-upload-mask b {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.episode-video-meta {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.episode-video-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.episode-video-filename {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.episode-video-hint {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.episode-video-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.episode-video-actions .el-button {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.episode-video-link {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.episode-video-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.episode-video-empty-upload {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.episode-editor-dialog {
|
||||
width: calc(100vw - 24px) !important;
|
||||
margin: 12px auto !important;
|
||||
}
|
||||
|
||||
.episode-editor-dialog .el-dialog__body {
|
||||
padding-right: 18px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.episode-editor-grid--two,
|
||||
.episode-editor-grid--three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.episode-video-editor {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.episode-video-preview-card {
|
||||
flex-basis: 135px;
|
||||
}
|
||||
|
||||
.episode-video-meta {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark .episode-video-preview-card,
|
||||
html.dark .episode-video-empty-upload .el-upload-dragger {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
/* 剧集预览弹窗里的播放器 */
|
||||
@@ -163,7 +590,7 @@
|
||||
/* 列表里点视频即放大播放,给个悬停反馈 */
|
||||
.episode-video-thumb:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.25);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--el-color-primary) 25%, transparent);
|
||||
}
|
||||
.dark .table-result-count {
|
||||
color: #a6aeb9;
|
||||
@@ -233,8 +660,8 @@
|
||||
.table-actions .el-button--primary {
|
||||
--el-button-bg-color: var(--el-color-primary);
|
||||
--el-button-border-color: var(--el-color-primary);
|
||||
--el-button-hover-bg-color: var(--el-color-primary-light-3);
|
||||
--el-button-hover-border-color: var(--el-color-primary-light-3);
|
||||
--el-button-hover-bg-color: var(--el-color-primary-dark-2);
|
||||
--el-button-hover-border-color: var(--el-color-primary-dark-2);
|
||||
}
|
||||
.table-actions .el-button--success {
|
||||
--el-button-bg-color: #4d866a;
|
||||
@@ -243,10 +670,10 @@
|
||||
--el-button-hover-border-color: #3c7358;
|
||||
}
|
||||
.table-actions .el-button--danger {
|
||||
--el-button-bg-color: #bd5b5b;
|
||||
--el-button-border-color: #bd5b5b;
|
||||
--el-button-hover-bg-color: #a94b4b;
|
||||
--el-button-hover-border-color: #a94b4b;
|
||||
--el-button-bg-color: var(--el-color-danger);
|
||||
--el-button-border-color: var(--el-color-danger);
|
||||
--el-button-hover-bg-color: var(--el-color-danger-dark-2);
|
||||
--el-button-hover-border-color: var(--el-color-danger-dark-2);
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
@import './console.css';
|
||||
@import './creator-center.css';
|
||||
@import './layout.css';
|
||||
@import './menu.css';
|
||||
@import './dark.css';
|
||||
@@ -0,0 +1,144 @@
|
||||
/* 左侧菜单:统一行高、缩进、图标和选中态。 */
|
||||
#v-menu .el-menu {
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#v-menu .el-menu-item,
|
||||
#v-menu .el-sub-menu__title {
|
||||
/* 42px = 16px padding + 18px icon + 8px gap,父子菜单文字共用该基线。 */
|
||||
height: 44px;
|
||||
padding-left: 16px !important;
|
||||
margin: 4px 8px 4px 0;
|
||||
overflow: hidden;
|
||||
color: var(--left-menu-text-color);
|
||||
font-size: 14px;
|
||||
line-height: 44px;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
color var(--transition-time-02),
|
||||
background-color var(--transition-time-02);
|
||||
}
|
||||
|
||||
#v-menu .el-menu--inline {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
#v-menu .el-menu--inline .el-menu-item,
|
||||
#v-menu .el-menu--inline .el-sub-menu__title {
|
||||
height: 40px;
|
||||
padding-left: 0 !important;
|
||||
margin: 3px 8px 3px 42px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
#v-menu .el-menu--inline .app-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#v-menu .el-menu-item .app-icon,
|
||||
#v-menu .el-sub-menu__title .app-icon {
|
||||
flex: 0 0 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
#v-menu .el-menu-item:hover,
|
||||
#v-menu .el-sub-menu__title:hover,
|
||||
#v-menu .el-sub-menu.is-opened > .el-sub-menu__title,
|
||||
#v-menu .el-sub-menu.is-active > .el-sub-menu__title,
|
||||
#v-menu .el-menu-item.is-active {
|
||||
color: var(--el-color-primary) !important;
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
|
||||
#v-menu .el-menu--inline .el-menu-item:hover,
|
||||
#v-menu .el-menu--inline .el-menu-item.is-active {
|
||||
color: var(--el-color-primary) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
#v-menu .el-menu .el-menu {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#v-menu .el-menu--collapse .el-menu-item,
|
||||
#v-menu .el-menu--collapse .el-sub-menu__title {
|
||||
justify-content: center;
|
||||
padding: 0 !important;
|
||||
margin: 4px 6px;
|
||||
}
|
||||
|
||||
#v-menu .el-menu--collapse .app-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
html.dark #v-menu .el-menu-item:hover,
|
||||
html.dark #v-menu .el-sub-menu__title:hover {
|
||||
color: #fff !important;
|
||||
background-color: color-mix(in srgb, var(--el-color-primary) 72%, transparent) !important;
|
||||
}
|
||||
|
||||
html.dark #v-menu .el-sub-menu.is-opened > .el-sub-menu__title,
|
||||
html.dark #v-menu .el-sub-menu.is-active > .el-sub-menu__title,
|
||||
html.dark #v-menu .el-menu-item.is-active {
|
||||
color: #fff !important;
|
||||
background-color: var(--app-primary-strong) !important;
|
||||
}
|
||||
|
||||
html.dark #v-menu .el-menu--inline .el-menu-item:hover,
|
||||
html.dark #v-menu .el-menu--inline .el-menu-item.is-active {
|
||||
color: #fff !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.v-menu-popper--vertical,
|
||||
.v-menu-popper--horizontal {
|
||||
padding: 4px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
background: var(--el-bg-color-overlay);
|
||||
}
|
||||
|
||||
.v-menu-popper--vertical .el-menu-item,
|
||||
.v-menu-popper--vertical .el-sub-menu__title,
|
||||
.v-menu-popper--horizontal .el-menu-item,
|
||||
.v-menu-popper--horizontal .el-sub-menu__title {
|
||||
height: 40px;
|
||||
padding-left: 16px !important;
|
||||
margin: 2px 4px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 14px;
|
||||
line-height: 40px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.v-menu-popper--vertical .app-icon,
|
||||
.v-menu-popper--horizontal .app-icon {
|
||||
flex: 0 0 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
html .v-menu-popper--vertical .el-menu-item:hover,
|
||||
html .v-menu-popper--horizontal .el-menu-item:hover,
|
||||
html .v-menu-popper--vertical .el-menu-item.is-active,
|
||||
html .v-menu-popper--horizontal .el-menu-item.is-active {
|
||||
color: var(--el-color-primary) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
html.dark .v-menu-popper--vertical,
|
||||
html.dark .v-menu-popper--horizontal {
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
|
||||
html.dark .v-menu-popper--vertical .el-menu-item:hover,
|
||||
html.dark .v-menu-popper--horizontal .el-menu-item:hover,
|
||||
html.dark .v-menu-popper--vertical .el-menu-item.is-active,
|
||||
html.dark .v-menu-popper--horizontal .el-menu-item.is-active {
|
||||
color: #fff !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ body {
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.page-heading {
|
||||
.page-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -396,7 +396,11 @@ body {
|
||||
background: linear-gradient(145deg, #f8d6d7, #ffece4);
|
||||
}
|
||||
.purple {
|
||||
background: linear-gradient(145deg, var(--el-color-primary-light-8), var(--el-color-primary-light-9));
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--el-color-primary-light-8),
|
||||
var(--el-color-primary-light-9)
|
||||
);
|
||||
}
|
||||
.peach {
|
||||
background: linear-gradient(145deg, #ffd6bd, #fff0d7);
|
||||
@@ -507,6 +511,8 @@ body {
|
||||
.episode-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #f0eff4;
|
||||
}
|
||||
@@ -516,32 +522,68 @@ body {
|
||||
font-size: 22px;
|
||||
border-radius: var(--app-radius-md);
|
||||
}
|
||||
.episode-context-copy {
|
||||
margin-left: 12px;
|
||||
.episode-context-info {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.episode-context-copy > span {
|
||||
font-size: 9px;
|
||||
color: #a7a5b3;
|
||||
|
||||
.episode-context-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.episode-context-copy b {
|
||||
font-size: 13px;
|
||||
|
||||
.episode-context-title {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.episode-context-copy small {
|
||||
font-size: 9px;
|
||||
color: #aaa8b6;
|
||||
|
||||
.episode-context-count {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.drama-select {
|
||||
margin-left: auto;
|
||||
width: 215px;
|
||||
|
||||
.episode-switcher {
|
||||
display: flex;
|
||||
flex: 0 0 clamp(320px, 32vw, 360px);
|
||||
width: clamp(320px, 32vw, 360px);
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.drama-select .el-input__wrapper {
|
||||
|
||||
.drama-select,
|
||||
.status-select {
|
||||
width: 100%;
|
||||
}
|
||||
.drama-select .el-input__wrapper,
|
||||
.status-select .el-input__wrapper {
|
||||
box-shadow: 0 0 0 1px #efedf3 inset !important;
|
||||
}
|
||||
.drama-select .el-input__inner {
|
||||
font-size: 10px;
|
||||
.drama-select .el-input__inner,
|
||||
.status-select .el-input__inner {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.episode-context {
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.episode-switcher {
|
||||
flex-basis: auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.episode-toolbar {
|
||||
display: flex;
|
||||
@@ -615,9 +657,6 @@ body {
|
||||
padding: 5px;
|
||||
color: #9997a7;
|
||||
}
|
||||
.episode-actions .danger-action:hover {
|
||||
color: #e77680;
|
||||
}
|
||||
.episode-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
font-family: var(--font-body);
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--el-bg-color-page);
|
||||
--el-color-primary: #409eff;
|
||||
--el-color-primary-light-3: #79bbff;
|
||||
--el-color-primary-light-5: #a0cfff;
|
||||
--el-color-primary-light-7: #c6e2ff;
|
||||
--el-color-primary-light-8: #d9ecff;
|
||||
--el-color-primary-light-9: #ecf5ff;
|
||||
--el-color-primary-dark-2: #337ecc;
|
||||
--el-color-primary: #c4322a;
|
||||
--el-color-primary-light-3: #d3615b;
|
||||
--el-color-primary-light-5: #e19995;
|
||||
--el-color-primary-light-7: #f0c2c0;
|
||||
--el-color-primary-light-8: #f5d9d8;
|
||||
--el-color-primary-light-9: #faeeed;
|
||||
--el-color-primary-dark-2: #9d2822;
|
||||
--purple: var(--el-color-primary);
|
||||
--muted: #909399;
|
||||
--line: #e4e7ed;
|
||||
@@ -30,6 +30,41 @@ body {
|
||||
background: var(--el-bg-color-page);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--el-color-primary);
|
||||
--el-button-border-color: var(--el-color-primary);
|
||||
--el-button-hover-bg-color: var(--el-color-primary-dark-2);
|
||||
--el-button-hover-border-color: var(--el-color-primary-dark-2);
|
||||
--el-button-active-bg-color: var(--el-color-primary-dark-2);
|
||||
--el-button-active-border-color: var(--el-color-primary-dark-2);
|
||||
}
|
||||
.el-button--danger {
|
||||
--el-button-text-color: #fff;
|
||||
--el-button-bg-color: var(--el-color-danger);
|
||||
--el-button-border-color: var(--el-color-danger);
|
||||
--el-button-hover-bg-color: var(--el-color-danger-dark-2);
|
||||
--el-button-hover-border-color: var(--el-color-danger-dark-2);
|
||||
color: #fff !important;
|
||||
background: var(--el-color-danger) !important;
|
||||
border-color: var(--el-color-danger) !important;
|
||||
}
|
||||
.el-button--danger:hover {
|
||||
background: var(--el-color-danger-dark-2) !important;
|
||||
border-color: var(--el-color-danger-dark-2) !important;
|
||||
}
|
||||
.el-button--danger.is-disabled,
|
||||
.el-button--danger.is-disabled:hover {
|
||||
color: var(--el-text-color-placeholder) !important;
|
||||
background: var(--el-fill-color-light) !important;
|
||||
border-color: var(--el-border-color-lighter) !important;
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
.el-message-box__btns .el-button--danger {
|
||||
color: #fff !important;
|
||||
background: var(--el-color-danger) !important;
|
||||
border-color: var(--el-color-danger) !important;
|
||||
}
|
||||
.eyebrow {
|
||||
letter-spacing: 0.7px;
|
||||
color: #9298a1;
|
||||
@@ -52,8 +87,8 @@ body {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.primary-button:hover {
|
||||
background: var(--el-color-primary-light-3) !important;
|
||||
border-color: var(--el-color-primary-light-3) !important;
|
||||
background: var(--el-color-primary-dark-2) !important;
|
||||
border-color: var(--el-color-primary-dark-2) !important;
|
||||
}
|
||||
.stats-grid {
|
||||
gap: 12px;
|
||||
@@ -73,7 +108,7 @@ body {
|
||||
}
|
||||
.stat-icon {
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: #ecf5ff !important;
|
||||
background: var(--el-color-primary-light-9) !important;
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
.stat-value {
|
||||
@@ -97,7 +132,7 @@ body {
|
||||
color: #61738a;
|
||||
}
|
||||
.chart-svg path:first-of-type {
|
||||
fill: #d9ecff;
|
||||
fill: var(--el-color-primary-light-8);
|
||||
}
|
||||
.chart-svg path:last-of-type {
|
||||
stroke: var(--el-color-primary);
|
||||
@@ -241,12 +276,12 @@ body {
|
||||
border-color: #e7e9ec;
|
||||
}
|
||||
.role-active {
|
||||
border-color: #a0cfff;
|
||||
background: #ecf5ff;
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
.role-icon {
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: #ecf5ff !important;
|
||||
background: var(--el-color-primary-light-9) !important;
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
.role-badge {
|
||||
@@ -314,12 +349,12 @@ body {
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
.el-tag.status-badge.is-pending {
|
||||
--el-tag-bg-color: #ecf5ff;
|
||||
--el-tag-border-color: #c6e2ff;
|
||||
--el-tag-text-color: #2b7de9;
|
||||
background: #ecf5ff !important;
|
||||
border-color: #c6e2ff !important;
|
||||
color: #2b7de9 !important;
|
||||
--el-tag-bg-color: var(--el-color-primary-light-9);
|
||||
--el-tag-border-color: var(--el-color-primary-light-7);
|
||||
--el-tag-text-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9) !important;
|
||||
border-color: var(--el-color-primary-light-7) !important;
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
.el-tag.status-badge.is-offline {
|
||||
--el-tag-bg-color: #fdecec;
|
||||
|
||||
@@ -6,23 +6,25 @@
|
||||
|
||||
--left-menu-min-width: 64px;
|
||||
|
||||
--left-menu-bg-color: #17110f;
|
||||
--left-menu-bg-color: #fff;
|
||||
|
||||
--left-menu-bg-light-color: #221815;
|
||||
--left-menu-bg-light-color: #fff;
|
||||
|
||||
--left-menu-bg-active-color: var(--app-primary-strong);
|
||||
--left-menu-bg-active-color: rgba(196, 50, 42, 0.1);
|
||||
|
||||
--left-menu-text-color: #d7cbc4;
|
||||
--left-menu-text-color: #333;
|
||||
|
||||
--left-menu-text-active-color: #fff;
|
||||
--left-menu-text-active-color: var(--el-color-primary);
|
||||
|
||||
--left-menu-collapse-bg-active-color: var(--app-primary-strong);
|
||||
--left-menu-border-color: #eee;
|
||||
|
||||
--left-menu-collapse-bg-active-color: rgba(196, 50, 42, 0.1);
|
||||
/* left menu end */
|
||||
|
||||
/* logo start */
|
||||
--logo-height: 50px;
|
||||
|
||||
--logo-title-text-color: #fff;
|
||||
--logo-title-text-color: #303133;
|
||||
/* logo end */
|
||||
|
||||
/* header start */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
const SEEN_KEY = 'transcode-notice-seen'
|
||||
|
||||
export const TRANSCODE_NOTICE_SEEN = 'transcode-notice-seen'
|
||||
|
||||
export interface NoticeCounts {
|
||||
running: number
|
||||
success: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
/** 上次打开通知页时已经看过的数量。 */
|
||||
export function readSeen(): NoticeCounts {
|
||||
try {
|
||||
const raw = localStorage.getItem(SEEN_KEY)
|
||||
if (!raw) return { running: 0, success: 0, failed: 0 }
|
||||
const data = JSON.parse(raw) as NoticeCounts
|
||||
return {
|
||||
running: Number(data.running || 0),
|
||||
success: Number(data.success || 0),
|
||||
failed: Number(data.failed || 0)
|
||||
}
|
||||
} catch {
|
||||
return { running: 0, success: 0, failed: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/** 比上次查看多出来的数量,看过的不再计入角标。 */
|
||||
export function unreadCounts(current: NoticeCounts): NoticeCounts {
|
||||
const seen = readSeen()
|
||||
return {
|
||||
running: Math.max(0, current.running - seen.running),
|
||||
success: Math.max(0, current.success - seen.success),
|
||||
failed: Math.max(0, current.failed - seen.failed)
|
||||
}
|
||||
}
|
||||
|
||||
/** 把当前数量记为已读,并通知顶栏收起角标。 */
|
||||
export function markTranscodeNoticeSeen(current: NoticeCounts) {
|
||||
const seen = readSeen()
|
||||
localStorage.setItem(
|
||||
SEEN_KEY,
|
||||
JSON.stringify({
|
||||
running: Math.max(seen.running, current.running),
|
||||
success: Math.max(seen.success, current.success),
|
||||
failed: Math.max(seen.failed, current.failed)
|
||||
})
|
||||
)
|
||||
window.dispatchEvent(new Event(TRANSCODE_NOTICE_SEEN))
|
||||
}
|
||||
@@ -32,8 +32,9 @@ export async function uploadFile(file: File, options: UploadFileOptions = {}): P
|
||||
bizType
|
||||
})
|
||||
const info = initRes.data
|
||||
const chunkSize = Number(info.chunkSize)
|
||||
options.onSession?.(info.uploadId)
|
||||
const total = Math.max(info.totalChunks, 1)
|
||||
const total = Math.max(Number(info.totalChunks), 1)
|
||||
const pending: number[] = Array.from({ length: total }, (_, index) => index)
|
||||
let finished = 0
|
||||
|
||||
@@ -41,8 +42,8 @@ export async function uploadFile(file: File, options: UploadFileOptions = {}): P
|
||||
for (;;) {
|
||||
const index = pending.shift()
|
||||
if (index === undefined) return
|
||||
const start = index * info.chunkSize
|
||||
const chunk = file.slice(start, Math.min(start + info.chunkSize, file.size))
|
||||
const start = index * chunkSize
|
||||
const chunk = file.slice(start, Math.min(start + chunkSize, file.size))
|
||||
await uploadChunkApi(info.uploadId, index, chunk, file.name)
|
||||
finished += 1
|
||||
onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
|
||||
@@ -68,7 +69,8 @@ export async function resumeUpload(
|
||||
): Promise<MediaAsset> {
|
||||
const { concurrency = 3, onProgress } = options
|
||||
const progress = (await getUploadProgressApi(uploadId)).data
|
||||
const total = Math.max(progress.totalChunks, 1)
|
||||
const chunkSize = Number(progress.chunkSize)
|
||||
const total = Math.max(Number(progress.totalChunks), 1)
|
||||
const received = Math.min(progress.receivedChunks || 0, total)
|
||||
const pending: number[] = []
|
||||
for (let index = received; index < total; index += 1) {
|
||||
@@ -81,8 +83,8 @@ export async function resumeUpload(
|
||||
for (;;) {
|
||||
const index = pending.shift()
|
||||
if (index === undefined) return
|
||||
const start = index * progress.chunkSize
|
||||
const chunk = file.slice(start, Math.min(start + progress.chunkSize, file.size))
|
||||
const start = index * chunkSize
|
||||
const chunk = file.slice(start, Math.min(start + chunkSize, file.size))
|
||||
await uploadChunkApi(uploadId, index, chunk, file.name)
|
||||
finished += 1
|
||||
onProgress?.(Math.min(95, Math.round((finished / total) * 95)))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoginForm, RegisterForm } from './components'
|
||||
import { LoginForm } from './components'
|
||||
import { ThemeSwitch } from '@/components/ThemeSwitch'
|
||||
import { LocaleDropdown } from '@/components/LocaleDropdown'
|
||||
import { useI18n } from '@/hooks/web/useI18n'
|
||||
import { getCssVar, underlineToHump } from '@/utils'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { ElScrollbar } from 'element-plus'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { t } = useI18n()
|
||||
const isLogin = ref(true)
|
||||
const isDark = computed(() => appStore.getIsDark)
|
||||
const toolColor = computed(() => (isDark.value ? '#e5eaf3' : '#303133'))
|
||||
|
||||
@@ -32,15 +31,97 @@ const themeChange = () => {
|
||||
<img src="@/assets/imgs/brand-mark.png" alt="" />
|
||||
<span>{{ underlineToHump(appStore.getTitle) }}</span>
|
||||
</div>
|
||||
<div class="hero-fruits" aria-hidden="true">
|
||||
<svg class="fruit cherry" viewBox="0 0 120 140">
|
||||
<path
|
||||
d="M62 38c8-16 24-24 36-22"
|
||||
fill="none"
|
||||
stroke="#7d9a62"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M58 40c2-18-8-30-6-36"
|
||||
fill="none"
|
||||
stroke="#6d8a55"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<ellipse
|
||||
cx="34"
|
||||
cy="28"
|
||||
rx="14"
|
||||
ry="7"
|
||||
fill="#8fb56f"
|
||||
transform="rotate(-28 34 28)"
|
||||
/>
|
||||
<circle cx="42" cy="78" r="28" fill="#c4322a" />
|
||||
<circle cx="78" cy="86" r="26" fill="#a82822" />
|
||||
<circle cx="32" cy="68" r="8" fill="#fff" opacity=".28" />
|
||||
</svg>
|
||||
<svg class="fruit mango" viewBox="0 0 150 170">
|
||||
<path d="M78 28c18 2 28 8 34 16-22 4-36 2-48-8 4-4 8-8 14-8z" fill="#6d8a55" />
|
||||
<path
|
||||
d="M86 36c6-16 2-24-2-28"
|
||||
fill="none"
|
||||
stroke="#5f7a4c"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M46 58c-18 28-16 62 8 84 28 24 62 10 74-16 14-30 4-62-16-80-18-16-46-10-66 12z"
|
||||
fill="#f0b429"
|
||||
/>
|
||||
<path
|
||||
d="M58 78c8 22 10 40 4 58"
|
||||
fill="none"
|
||||
stroke="#ffe08a"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
opacity=".55"
|
||||
/>
|
||||
<ellipse
|
||||
cx="62"
|
||||
cy="86"
|
||||
rx="14"
|
||||
ry="22"
|
||||
fill="#fff"
|
||||
opacity=".2"
|
||||
transform="rotate(-24 62 86)"
|
||||
/>
|
||||
</svg>
|
||||
<svg class="fruit banana" viewBox="0 0 160 150">
|
||||
<path
|
||||
d="M28 108c18-46 48-74 92-86 8 10 10 22 4 34-28 10-50 28-66 58-6 12-16 16-30 12-8-2-12-10 0-18z"
|
||||
fill="#f2d34b"
|
||||
/>
|
||||
<path
|
||||
d="M36 102c16-38 42-62 80-74"
|
||||
fill="none"
|
||||
stroke="#ffe98a"
|
||||
stroke-width="7"
|
||||
stroke-linecap="round"
|
||||
opacity=".7"
|
||||
/>
|
||||
<path
|
||||
d="M118 24c8 2 14 10 12 16"
|
||||
fill="none"
|
||||
stroke="#6d8a55"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M30 112c-6 8-4 16 4 16"
|
||||
fill="none"
|
||||
stroke="#c9a227"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="hero-copy">
|
||||
<p class="hero-kicker">{{ t('login.consoleLabel') }}</p>
|
||||
<h1>{{ t('login.welcome') }}</h1>
|
||||
<p>{{ t('login.message') }}</p>
|
||||
<ul>
|
||||
<li>{{ t('login.pointDrama') }}</li>
|
||||
<li>{{ t('login.pointPublish') }}</li>
|
||||
<li>{{ t('login.pointAccess') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -50,8 +131,7 @@ const themeChange = () => {
|
||||
<LocaleDropdown :color="toolColor" />
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<LoginForm v-if="isLogin" @to-register="isLogin = false" />
|
||||
<RegisterForm v-else @to-login="isLogin = true" />
|
||||
<LoginForm />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -89,16 +169,58 @@ const themeChange = () => {
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: -80px;
|
||||
bottom: -120px;
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
border: 1px solid rgb(255 255 255 / 16%);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: -140px;
|
||||
left: -80px;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: -120px;
|
||||
bottom: -160px;
|
||||
width: 460px;
|
||||
height: 460px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-fruits {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fruit {
|
||||
position: absolute;
|
||||
filter: drop-shadow(0 18px 24px rgb(0 0 0 / 28%));
|
||||
}
|
||||
|
||||
.cherry {
|
||||
top: 16%;
|
||||
right: 14%;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.mango {
|
||||
top: 30%;
|
||||
left: 38%;
|
||||
width: 210px;
|
||||
}
|
||||
|
||||
.banana {
|
||||
top: 54%;
|
||||
left: 12%;
|
||||
width: 200px;
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
|
||||
.hero-brand {
|
||||
@@ -121,47 +243,24 @@ const themeChange = () => {
|
||||
.hero-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 460px;
|
||||
max-width: 420px;
|
||||
|
||||
h1 {
|
||||
margin: 10px 0 12px;
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 12px 0 0;
|
||||
font-size: 44px;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.03em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: rgb(255 255 255 / 82%);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: #d9ecff !important;
|
||||
}
|
||||
|
||||
.hero-copy ul {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
margin: 28px 0 0;
|
||||
list-style: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
|
||||
li {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
background: rgb(255 255 255 / 12%);
|
||||
border: 1px solid rgb(255 255 255 / 18%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
color: var(--el-color-primary-light-8) !important;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
@@ -223,13 +322,13 @@ const themeChange = () => {
|
||||
padding: 16px 24px 20px;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
margin: 6px 0 8px;
|
||||
font-size: 28px;
|
||||
.hero-fruits {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-copy ul {
|
||||
display: none;
|
||||
.hero-copy h1 {
|
||||
margin: 6px 0 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
@@ -253,10 +352,6 @@ const themeChange = () => {
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
|
||||
.hero-copy p {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 28px 20px 24px;
|
||||
}
|
||||
@@ -282,25 +377,6 @@ const themeChange = () => {
|
||||
}
|
||||
}
|
||||
|
||||
.login-register {
|
||||
display: flex;
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.login-card :deep(.el-button--primary) {
|
||||
--el-button-bg-color: var(--app-brand);
|
||||
--el-button-border-color: var(--app-brand);
|
||||
|
||||