chore(merge): 将 release/v1.0.0 同步到 main
This commit is contained in:
commit
ada3b56d14
152 files changed
+10290
-1866
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
|
||||
@@ -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,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 为项目既有警告。
|
||||
+8
-25
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
+2
-4
@@ -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,6 @@
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@faker-js/faker": "^8.4.0",
|
||||
"@iconify/iconify": "^3.1.1",
|
||||
"@iconify/vue": "^4.1.1",
|
||||
"@vueuse/core": "^10.7.2",
|
||||
@@ -40,6 +38,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 +102,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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.0 KiB |
@@ -13,6 +13,20 @@ export interface AppUserPageParams {
|
||||
registerSource?: string
|
||||
}
|
||||
|
||||
export interface WatchHistoryPageParams {
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
userId?: number
|
||||
dramaTitle?: string
|
||||
dramaId?: number
|
||||
episodeId?: number
|
||||
source?: string
|
||||
finished?: boolean
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
export const pageAppUsersApi = (params: AppUserPageParams) => {
|
||||
return request.get<PageResult<AppUserItem>>({ url: '/admin/app-users/page', params })
|
||||
}
|
||||
@@ -32,6 +46,13 @@ export const listAppUserWatchHistoryApi = (id: number, limit = 50) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const pageWatchHistoryApi = (params: WatchHistoryPageParams) => {
|
||||
return request.get<PageResult<WatchHistoryItem>>({
|
||||
url: '/admin/watch-history/page',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** normal / limited / banned,封禁与限制会立即吊销该用户全部会话 */
|
||||
export const updateAppUserStatusApi = (id: number, status: string, reason?: string) => {
|
||||
return request.put<void>({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
import type { PageResult } from './types'
|
||||
|
||||
export interface DramaClassificationItem {
|
||||
id: number
|
||||
name: string
|
||||
sort: number
|
||||
status: number
|
||||
dramaCount: number
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface DramaClassificationSaveData {
|
||||
name: string
|
||||
sort?: number
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface ClassificationDramaItem {
|
||||
id: number
|
||||
title: string
|
||||
cover: string
|
||||
status: string
|
||||
totalEpisodes: number
|
||||
sort: 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<number>({ url: '/admin/drama-classifications', data })
|
||||
}
|
||||
|
||||
export const updateClassificationApi = (id: number, data: DramaClassificationSaveData) => {
|
||||
return request.put<void>({ url: `/admin/drama-classifications/${id}`, data })
|
||||
}
|
||||
|
||||
export const deleteClassificationApi = (id: number) => {
|
||||
return request.delete<void>({ url: `/admin/drama-classifications/${id}` })
|
||||
}
|
||||
|
||||
/** 分类下已关联的短剧 */
|
||||
export const listClassificationDramasApi = (id: number, params: ClassificationDramaPageParams) => {
|
||||
return request.get<PageResult<ClassificationDramaItem>>({
|
||||
url: `/admin/drama-classifications/${id}/dramas`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** 尚未关联到该分类的短剧(添加弹窗用) */
|
||||
export const listClassificationDramaCandidatesApi = (
|
||||
id: number,
|
||||
params: ClassificationDramaPageParams
|
||||
) => {
|
||||
return request.get<PageResult<ClassificationDramaItem>>({
|
||||
url: `/admin/drama-classifications/${id}/dramas/candidates`,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** 批量关联短剧,返回实际新增数量 */
|
||||
export const bindClassificationDramasApi = (id: number, dramaIds: number[]) => {
|
||||
return request.post<number>({
|
||||
url: `/admin/drama-classifications/${id}/dramas`,
|
||||
data: { dramaIds }
|
||||
})
|
||||
}
|
||||
|
||||
/** 从分类移除短剧 */
|
||||
export const unbindClassificationDramaApi = (id: number, dramaId: number) => {
|
||||
return request.delete<void>({
|
||||
url: `/admin/drama-classifications/${id}/dramas/${dramaId}`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// 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
|
||||
description: string
|
||||
sort: number
|
||||
status: number
|
||||
dramaCount: number
|
||||
}
|
||||
|
||||
export interface DramaPlaylistCategorySaveData {
|
||||
name: string
|
||||
description?: 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}`
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
+18
-1
@@ -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,66 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
import type { PageResult } from './types'
|
||||
|
||||
/** 系统公告:字段与 /admin/notices 的响应一一对应 */
|
||||
export interface NoticeItem {
|
||||
/** 雪花 id,后端以字符串下发(超过 JS 安全整数范围),原样回传即可 */
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
/** ALL / USER / CREATOR */
|
||||
target: string
|
||||
targetText: string
|
||||
/** SYSTEM / AUDIT / PAYMENT / ACTIVITY */
|
||||
type: string
|
||||
typeText: string
|
||||
/** 1 已发布 / 0 已下架 */
|
||||
status: number
|
||||
statusText: string
|
||||
publishAt?: string
|
||||
expireAt?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface NoticePageParams {
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
status?: number
|
||||
target?: string
|
||||
type?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface NoticeSaveData {
|
||||
title: string
|
||||
content: string
|
||||
target: string
|
||||
type: string
|
||||
publishAt?: string | null
|
||||
expireAt?: string | null
|
||||
}
|
||||
|
||||
export const pageNoticesApi = (params: NoticePageParams) => {
|
||||
return request.get<PageResult<NoticeItem>>({ url: '/admin/notices/page', params })
|
||||
}
|
||||
|
||||
export const getNoticeApi = (id: string | number) => {
|
||||
return request.get<NoticeItem>({ url: `/admin/notices/${id}` })
|
||||
}
|
||||
|
||||
export const createNoticeApi = (data: NoticeSaveData) => {
|
||||
return request.post<string>({ url: '/admin/notices', data })
|
||||
}
|
||||
|
||||
export const updateNoticeApi = (id: string | number, data: NoticeSaveData) => {
|
||||
return request.put<void>({ url: `/admin/notices/${id}`, data })
|
||||
}
|
||||
|
||||
export const updateNoticeStatusApi = (id: string | number, status: number) => {
|
||||
return request.put<void>({ url: `/admin/notices/${id}/status`, params: { status } })
|
||||
}
|
||||
|
||||
export const deleteNoticeApi = (id: string | number) => {
|
||||
return request.delete<void>({ url: `/admin/notices/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
import type { PageResult } from './types'
|
||||
|
||||
/** 商品(金币包):字段与 /admin/products 的响应一一对应 */
|
||||
export interface ProductItem {
|
||||
/** 雪花 id,后端以字符串下发(超过 JS 安全整数范围),原样回传即可 */
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
/** COIN_PACK / VIP */
|
||||
type: string
|
||||
typeText: string
|
||||
/** ALL / GOOGLE / APPLE */
|
||||
platform: string
|
||||
storeProductId: string
|
||||
coinAmount: number
|
||||
bonusCoin: number
|
||||
/** coinAmount + bonusCoin,服务端算好,前端不再自己加 */
|
||||
totalCoin: number
|
||||
priceAmount: number
|
||||
currency: string
|
||||
displayPrice: string
|
||||
regionCodes: string
|
||||
vipDays: number
|
||||
sort: number
|
||||
/** 1 已上架 / 0 已下架 */
|
||||
status: number
|
||||
statusText: string
|
||||
effectiveAt?: string
|
||||
expireAt?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface ProductPageParams {
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
type?: string
|
||||
status?: number
|
||||
platform?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface ProductSaveData {
|
||||
code: string
|
||||
name: string
|
||||
type: string
|
||||
platform: string
|
||||
storeProductId: string
|
||||
coinAmount: number
|
||||
bonusCoin: number
|
||||
priceAmount: number
|
||||
currency: string
|
||||
displayPrice: string
|
||||
regionCodes: string
|
||||
vipDays: number
|
||||
sort: number
|
||||
effectiveAt?: string | null
|
||||
expireAt?: string | null
|
||||
}
|
||||
|
||||
export const pageProductsApi = (params: ProductPageParams) => {
|
||||
return request.get<PageResult<ProductItem>>({ url: '/admin/products/page', params })
|
||||
}
|
||||
|
||||
export const getProductApi = (id: string | number) => {
|
||||
return request.get<ProductItem>({ url: `/admin/products/${id}` })
|
||||
}
|
||||
|
||||
export const createProductApi = (data: ProductSaveData) => {
|
||||
return request.post<string>({ url: '/admin/products', data })
|
||||
}
|
||||
|
||||
export const updateProductApi = (id: string | number, data: ProductSaveData) => {
|
||||
return request.put<void>({ url: `/admin/products/${id}`, data })
|
||||
}
|
||||
|
||||
export const updateProductStatusApi = (id: string | number, status: number) => {
|
||||
return request.put<void>({ url: `/admin/products/${id}/status`, params: { status } })
|
||||
}
|
||||
|
||||
export const deleteProductApi = (id: string | number) => {
|
||||
return request.delete<void>({ url: `/admin/products/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import request from '@/axios'
|
||||
import type { EpisodeTranscodeItem, MediaThumbnailResult, 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`
|
||||
})
|
||||
}
|
||||
@@ -20,11 +20,15 @@ export interface DramaItem {
|
||||
title: string
|
||||
subtitle?: string
|
||||
categoryId?: number
|
||||
categoryIds?: number[]
|
||||
categoryNames?: string[]
|
||||
category?: string
|
||||
genre?: string
|
||||
description?: string
|
||||
cover?: string
|
||||
language?: string
|
||||
dramaType?: 'ANIME' | 'LIVE'
|
||||
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
||||
episodes?: number
|
||||
freeEpisodes?: number
|
||||
unlockCoin?: number
|
||||
@@ -33,6 +37,9 @@ export interface DramaItem {
|
||||
status: string
|
||||
statusText: string
|
||||
views?: number
|
||||
favoriteCount?: number
|
||||
likeCount?: number
|
||||
commentCount?: number
|
||||
sort?: number
|
||||
publishAt?: string
|
||||
createdAt?: string
|
||||
@@ -43,12 +50,18 @@ export interface DramaSaveData {
|
||||
title: string
|
||||
subtitle?: string
|
||||
categoryId?: number
|
||||
categoryIds?: number[]
|
||||
genre?: string
|
||||
description?: string
|
||||
cover?: string
|
||||
language?: string
|
||||
dramaType?: 'ANIME' | 'LIVE'
|
||||
dramaTypes?: Array<'ANIME' | 'LIVE'>
|
||||
freeEpisodes?: number
|
||||
unlockCoin?: number
|
||||
favoriteCount?: number
|
||||
likeCount?: number
|
||||
heat?: number
|
||||
isHot?: boolean
|
||||
sort?: number
|
||||
}
|
||||
@@ -69,6 +82,10 @@ export interface EpisodeItem {
|
||||
status: string
|
||||
statusText: string
|
||||
views?: number
|
||||
likeCount?: number
|
||||
commentCount?: number
|
||||
favoriteCount?: number
|
||||
heat?: number
|
||||
sort?: number
|
||||
}
|
||||
|
||||
@@ -81,6 +98,7 @@ export interface EpisodeSaveData {
|
||||
videoUrl?: string
|
||||
isFree?: boolean
|
||||
unlockCoin?: number
|
||||
likeCount?: number
|
||||
status?: string
|
||||
sort?: number
|
||||
}
|
||||
@@ -204,6 +222,105 @@ 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 BannerItem {
|
||||
id: number
|
||||
imageUrl: string
|
||||
@@ -269,6 +386,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 +415,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 +438,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 +460,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,7 +2,6 @@
|
||||
|
||||
export type UserLoginType = {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export type UserType = {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,5 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import AppIcon from './src/AppIcon.vue'
|
||||
|
||||
export { AppIcon }
|
||||
@@ -0,0 +1,40 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { normalizeAppIcon } from '@/constants/icons'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
|
||||
/**
|
||||
* 后台统一图标入口:默认使用 Material Symbols Rounded。
|
||||
*/
|
||||
const props = defineProps({
|
||||
icon: propTypes.string,
|
||||
color: propTypes.string,
|
||||
size: propTypes.number.def(18),
|
||||
hoverColor: propTypes.string
|
||||
})
|
||||
|
||||
const resolvedIcon = computed(() => normalizeAppIcon(props.icon))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon
|
||||
:icon="resolvedIcon"
|
||||
:color="color"
|
||||
:size="size"
|
||||
:hover-color="hoverColor"
|
||||
class="app-icon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -9,7 +9,7 @@ import { filterBreadcrumb } from './helper'
|
||||
import { filter, treeToList } from '@/utils/tree'
|
||||
import type { RouteLocationNormalizedLoaded, RouteMeta } from 'vue-router'
|
||||
import { useI18n } from '@/hooks/web/useI18n'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { AppIcon } from '@/components/AppIcon'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
@@ -54,7 +54,7 @@ export default defineComponent({
|
||||
<ElBreadcrumbItem to={{ path: disabled ? '' : v.path }} key={v.name}>
|
||||
{meta?.icon && breadcrumbIcon.value ? (
|
||||
<>
|
||||
<Icon icon={meta.icon} class="mr-[5px]"></Icon> {t(v?.meta?.title)}
|
||||
<AppIcon icon={meta.icon} class="mr-[5px]"></AppIcon> {t(v?.meta?.title)}
|
||||
</>
|
||||
) : (
|
||||
t(v?.meta?.title)
|
||||
@@ -117,10 +117,10 @@ export default defineComponent({
|
||||
|
||||
:deep(&__item):last-child {
|
||||
.@{prefix-cls}__inner {
|
||||
color: var(--el-text-color-placeholder);
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
&:hover {
|
||||
color: var(--el-text-color-placeholder);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, unref } from 'vue'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
@@ -26,9 +27,9 @@ const toggleCollapse = () => {
|
||||
|
||||
<template>
|
||||
<div :class="prefixCls" @click="toggleCollapse">
|
||||
<Icon
|
||||
<AppIcon
|
||||
:size="18"
|
||||
:icon="collapse ? 'ant-design:menu-unfold-outlined' : 'ant-design:menu-fold-outlined'"
|
||||
:icon="collapse ? AppIcons.menuOpen : AppIcons.menu"
|
||||
:color="color"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { ElCard, ElTooltip } from 'element-plus'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
@@ -24,7 +25,7 @@ defineProps({
|
||||
<template #content>
|
||||
<div class="max-w-200px">{{ message }}</div>
|
||||
</template>
|
||||
<Icon class="ml-5px" icon="bi:question-circle-fill" :size="14" />
|
||||
<AppIcon class="ml-5px" :icon="AppIcons.help" :size="14" />
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -66,7 +66,7 @@ defineExpose({
|
||||
:disabled="item.disabled"
|
||||
:command="item"
|
||||
>
|
||||
<Icon :icon="item.icon" /> {{ t(item.label) }}
|
||||
<AppIcon :icon="item.icon" /> {{ t(item.label) }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
|
||||
@@ -7,8 +7,9 @@ import { propTypes } from '@/utils/propTypes'
|
||||
import { ref, unref, PropType, computed, defineComponent } from 'vue'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { DescriptionsSchema } from './types'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { AppIcon } from '@/components/AppIcon'
|
||||
import { get } from 'lodash-es'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
@@ -94,12 +95,14 @@ export default defineComponent({
|
||||
{props.title}
|
||||
{props.message ? (
|
||||
<ElTooltip content={props.message} placement="right">
|
||||
<Icon icon="bi:question-circle-fill" class="ml-5px" size={14} />
|
||||
<AppIcon icon={AppIcons.help} class="ml-5px" size={14} />
|
||||
</ElTooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{props.collapse ? <Icon icon={show.value ? 'ep:arrow-down' : 'ep:arrow-up'} /> : null}
|
||||
{props.collapse ? (
|
||||
<AppIcon icon={show.value ? AppIcons.chevronDown : AppIcons.chevronUp} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ElDialog, ElScrollbar } from 'element-plus'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { computed, useAttrs, ref, unref, useSlots, watch, nextTick } from 'vue'
|
||||
import { isNumber } from '@/utils/is'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
@@ -77,17 +78,17 @@ const dialogStyle = computed(() => {
|
||||
<div
|
||||
class="h-54px flex justify-between items-center absolute top-[50%] right-15px translate-y-[-50%]"
|
||||
>
|
||||
<Icon
|
||||
<AppIcon
|
||||
v-if="fullscreen"
|
||||
class="cursor-pointer is-hover !h-54px mr-10px"
|
||||
:icon="isFullscreen ? 'radix-icons:exit-full-screen' : 'radix-icons:enter-full-screen'"
|
||||
:icon="isFullscreen ? AppIcons.fullscreenExit : AppIcons.fullscreen"
|
||||
color="var(--el-color-info)"
|
||||
hover-color="var(--el-color-primary)"
|
||||
@click="toggleFull"
|
||||
/>
|
||||
<Icon
|
||||
<AppIcon
|
||||
class="cursor-pointer is-hover !h-54px"
|
||||
icon="ep:close"
|
||||
:icon="AppIcons.close"
|
||||
hover-color="var(--el-color-primary)"
|
||||
color="var(--el-color-info)"
|
||||
@click="close"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ElIcon } from 'element-plus'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { normalizeAppIcon } from '@/constants/icons'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
@@ -22,9 +23,10 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const isLocal = computed(() => props.icon.startsWith('svg-icon:'))
|
||||
const normalizedIcon = computed(() => normalizeAppIcon(props.icon))
|
||||
|
||||
const symbolId = computed(() => {
|
||||
return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : props.icon
|
||||
return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : unref(normalizedIcon)
|
||||
})
|
||||
|
||||
// 是否使用在线图标
|
||||
@@ -48,8 +50,8 @@ const getIconifyStyle = computed(() => {
|
||||
</svg>
|
||||
|
||||
<template v-else>
|
||||
<Icon v-if="isUseOnline" :icon="icon" :style="getIconifyStyle" />
|
||||
<div v-else :class="`${icon} iconify`" :style="getIconifyStyle"></div>
|
||||
<Icon v-if="isUseOnline" :icon="normalizedIcon" :style="getIconifyStyle" />
|
||||
<div v-else :class="`${normalizedIcon} iconify`" :style="getIconifyStyle"></div>
|
||||
</template>
|
||||
</ElIcon>
|
||||
</template>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLocaleStore } from '@/store/modules/locale'
|
||||
import { useLocale } from '@/hooks/web/useLocale'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
@@ -36,9 +37,9 @@ const setLang = (lang: LocaleType) => {
|
||||
|
||||
<template>
|
||||
<ElDropdown :class="prefixCls" trigger="click" @command="setLang">
|
||||
<Icon
|
||||
<AppIcon
|
||||
:size="18"
|
||||
icon="ion:language-sharp"
|
||||
:icon="AppIcons.language"
|
||||
class="cursor-pointer !p-0"
|
||||
:class="$attrs.class"
|
||||
:color="color"
|
||||
|
||||
@@ -67,8 +67,13 @@ watch(
|
||||
to="/"
|
||||
>
|
||||
<img
|
||||
src="@/assets/imgs/logo.png"
|
||||
class="w-[calc(var(--logo-height)-10px)] h-[calc(var(--logo-height)-10px)]"
|
||||
src="@/assets/imgs/brand-mark.png"
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import type { RouteMeta } from 'vue-router'
|
||||
import { Icon } from '@/components/Icon'
|
||||
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 ? (
|
||||
<>
|
||||
<Icon icon={meta.icon}></Icon>
|
||||
<AppIcon icon={meta.icon}></AppIcon>
|
||||
<span class="v-menu__title overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||
{t(title as string)}
|
||||
</span>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { AppIcon } from '@/components/AppIcon'
|
||||
import { useFullscreen } from '@vueuse/core'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
@@ -23,9 +24,9 @@ const toggleFullscreen = () => {
|
||||
|
||||
<template>
|
||||
<div :class="prefixCls" @click="toggleFullscreen">
|
||||
<Icon
|
||||
<AppIcon
|
||||
:size="18"
|
||||
:icon="isFullscreen ? 'zmdi:fullscreen-exit' : 'zmdi:fullscreen'"
|
||||
:icon="isFullscreen ? AppIcons.fullscreenExit : AppIcons.fullscreen"
|
||||
:color="color"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ElButton } from 'element-plus'
|
||||
import { useIcon } from '@/hooks/web/useIcon'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useI18n } from '@/hooks/web/useI18n'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const emit = defineEmits(['search', 'reset', 'expand'])
|
||||
|
||||
@@ -37,7 +38,7 @@ const onExpand = () => {
|
||||
v-if="showSearch"
|
||||
type="primary"
|
||||
:loading="searchLoading"
|
||||
:icon="useIcon({ icon: 'ep:search' })"
|
||||
:icon="useIcon({ icon: AppIcons.search })"
|
||||
@click="onSearch"
|
||||
>
|
||||
{{ t('common.query') }}
|
||||
@@ -45,14 +46,14 @@ const onExpand = () => {
|
||||
<ElButton
|
||||
v-if="showReset"
|
||||
:loading="resetLoading"
|
||||
:icon="useIcon({ icon: 'ep:refresh-right' })"
|
||||
:icon="useIcon({ icon: AppIcons.refresh })"
|
||||
@click="onReset"
|
||||
>
|
||||
{{ t('common.reset') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="showExpand"
|
||||
:icon="useIcon({ icon: visible ? 'ep:arrow-down' : 'ep:arrow-up' })"
|
||||
:icon="useIcon({ icon: visible ? AppIcons.chevronDown : AppIcons.chevronUp })"
|
||||
text
|
||||
@click="onExpand"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElDrawer, ElDivider, ElMessage } from 'element-plus'
|
||||
import { ElDrawer, ElDivider, ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ref, unref } from 'vue'
|
||||
import { useI18n } from '@/hooks/web/useI18n'
|
||||
import { ThemeSwitch } from '@/components/ThemeSwitch'
|
||||
@@ -14,6 +14,7 @@ import LayoutRadioPicker from './components/LayoutRadioPicker.vue'
|
||||
import { useStorage } from '@/hooks/web/useStorage'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const { clear: storageClear } = useStorage('localStorage')
|
||||
|
||||
@@ -145,9 +146,18 @@ const copyConfig = async () => {
|
||||
}
|
||||
|
||||
// 清空缓存
|
||||
const clear = () => {
|
||||
storageClear()
|
||||
window.location.reload()
|
||||
const clear = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('setting.clearCacheMessage'), t('setting.clearAndReset'), {
|
||||
confirmButtonText: t('common.ok'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
type: 'warning'
|
||||
})
|
||||
storageClear()
|
||||
window.location.reload()
|
||||
} catch {
|
||||
// 取消清除时保持当前状态
|
||||
}
|
||||
}
|
||||
|
||||
const themeChange = () => {
|
||||
@@ -163,7 +173,7 @@ const themeChange = () => {
|
||||
class="fixed top-[45%] right-0 w-40px h-40px flex items-center justify-center bg-[var(--el-color-primary)] cursor-pointer z-10"
|
||||
@click="drawer = true"
|
||||
>
|
||||
<Icon icon="ant-design:setting-outlined" color="#fff" />
|
||||
<AppIcon :icon="AppIcons.settings" color="#fff" />
|
||||
</div>
|
||||
|
||||
<ElDrawer v-model="drawer" direction="rtl" size="350px" :z-index="4000">
|
||||
@@ -185,7 +195,7 @@ const themeChange = () => {
|
||||
<ColorRadioPicker
|
||||
v-model="systemTheme"
|
||||
:schema="[
|
||||
'#409eff',
|
||||
'#c4322a',
|
||||
'#009688',
|
||||
'#536dfe',
|
||||
'#ff5c93',
|
||||
@@ -254,6 +264,6 @@ const themeChange = () => {
|
||||
@prefix-cls: ~'@{namespace}-setting';
|
||||
|
||||
.@{prefix-cls} {
|
||||
border-radius: 6px 0 0 6px;
|
||||
border-radius: var(--app-radius-md) 0 0 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
import { PropType, watch, unref, ref } from 'vue'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
@@ -51,7 +52,7 @@ watch(
|
||||
}"
|
||||
@click="colorVal = item"
|
||||
>
|
||||
<Icon v-if="colorVal === item" color="#fff" icon="ep:check" :size="16" />
|
||||
<AppIcon v-if="colorVal === item" color="#fff" :icon="AppIcons.check" :size="16" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -67,7 +67,7 @@ const layout = computed(() => appStore.getLayout)
|
||||
.@{prefix-cls} {
|
||||
&__classic {
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
@@ -77,7 +77,7 @@ const layout = computed(() => appStore.getLayout)
|
||||
width: 33%;
|
||||
height: 100%;
|
||||
background-color: #273352;
|
||||
border-radius: 4px 0 0 4px;
|
||||
border-radius: var(--app-radius-sm) 0 0 4px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
@@ -88,14 +88,14 @@ const layout = computed(() => appStore.getLayout)
|
||||
width: 100%;
|
||||
height: 25%;
|
||||
background-color: #fff;
|
||||
border-radius: 4px 4px 0;
|
||||
border-radius: var(--app-radius-sm) 4px 0;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
&__top-left {
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
@@ -105,7 +105,7 @@ const layout = computed(() => appStore.getLayout)
|
||||
width: 100%;
|
||||
height: 33%;
|
||||
background-color: #273352;
|
||||
border-radius: 4px 4px 0 0;
|
||||
border-radius: var(--app-radius-sm) 4px 0 0;
|
||||
content: '';
|
||||
}
|
||||
|
||||
@@ -116,14 +116,14 @@ const layout = computed(() => appStore.getLayout)
|
||||
width: 33%;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
border-radius: 4px 0 0 4px;
|
||||
border-radius: var(--app-radius-sm) 0 0 4px;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
&__top {
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
@@ -133,14 +133,14 @@ const layout = computed(() => appStore.getLayout)
|
||||
width: 100%;
|
||||
height: 33%;
|
||||
background-color: #273352;
|
||||
border-radius: 4px 4px 0 0;
|
||||
border-radius: var(--app-radius-sm) 4px 0 0;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
&__cut-menu {
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
@@ -150,7 +150,7 @@ const layout = computed(() => appStore.getLayout)
|
||||
width: 100%;
|
||||
height: 33%;
|
||||
background-color: #273352;
|
||||
border-radius: 4px 4px 0 0;
|
||||
border-radius: var(--app-radius-sm) 4px 0 0;
|
||||
content: '';
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ const layout = computed(() => appStore.getLayout)
|
||||
width: 10%;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
border-radius: 4px 0 0 4px;
|
||||
border-radius: var(--app-radius-sm) 0 0 4px;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAppStore } from '@/store/modules/app'
|
||||
import { useI18n } from '@/hooks/web/useI18n'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
@@ -29,7 +30,7 @@ const setCurrentSize = (size: ComponentSize) => {
|
||||
|
||||
<template>
|
||||
<ElDropdown :class="prefixCls" trigger="click" @command="setCurrentSize">
|
||||
<Icon :size="18" icon="mdi:format-size" :color="color" class="cursor-pointer" />
|
||||
<AppIcon :size="18" :icon="AppIcons.formatSize" :color="color" class="cursor-pointer" />
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem v-for="item in sizeMap" :key="item" :command="item">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useAppStore } from '@/store/modules/app'
|
||||
import { computed, unref, defineComponent, watch, ref, onMounted } from 'vue'
|
||||
import { useI18n } from '@/hooks/web/useI18n'
|
||||
import { ElScrollbar } from 'element-plus'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { AppIcon } from '@/components/AppIcon'
|
||||
import { Menu } from '@/components/Menu'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { pathResolve } from '@/utils/routerHelper'
|
||||
@@ -14,6 +14,7 @@ import { cloneDeep } from 'lodash-es'
|
||||
import { filterMenusPath, initTabMap, tabPathMap } from './helper'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { isUrl } from '@/utils/is'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const { getPrefixCls, variables } = useDesign()
|
||||
|
||||
@@ -180,7 +181,7 @@ export default defineComponent({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Icon icon={item?.meta?.icon}></Icon>
|
||||
<AppIcon icon={item?.meta?.icon}></AppIcon>
|
||||
</div>
|
||||
{!unref(showTitle) ? undefined : (
|
||||
<p class="break-words mt-5px px-2px">{t(item.meta?.title)}</p>
|
||||
@@ -198,7 +199,7 @@ export default defineComponent({
|
||||
]}
|
||||
onClick={setCollapse}
|
||||
>
|
||||
<Icon icon={unref(collapse) ? 'ep:d-arrow-right' : 'ep:d-arrow-left'}></Icon>
|
||||
<AppIcon icon={unref(collapse) ? AppIcons.chevronRight : AppIcons.chevronLeft}></AppIcon>
|
||||
</div>
|
||||
<Menu
|
||||
class={[
|
||||
|
||||
@@ -20,8 +20,9 @@ import { CSSProperties } from 'vue'
|
||||
import { getSlot } from '@/utils/tsxHelper'
|
||||
import TableActions from './components/TableActions.vue'
|
||||
import { createVideoViewer } from '@/components/VideoPlayer'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { AppIcon } from '@/components/AppIcon'
|
||||
import { BaseButton } from '@/components/Button'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Table',
|
||||
@@ -408,7 +409,7 @@ export default defineComponent({
|
||||
) : videoPreview.includes(field) ? (
|
||||
<BaseButton
|
||||
type="primary"
|
||||
icon={<Icon icon="ep:video-play" />}
|
||||
icon={<AppIcon icon={AppIcons.videoPlay} />}
|
||||
onClick={() => {
|
||||
createVideoViewer({
|
||||
url
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TableColumn } from '../types'
|
||||
import { PropType, ref, watch, unref } from 'vue'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { DEFAULT_FILTER_COLUMN } from '@/constants'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
|
||||
const modelValue = defineModel<boolean>()
|
||||
@@ -141,17 +142,19 @@ watch(
|
||||
<div class="flex items-center">
|
||||
<ElRadioGroup size="small" v-model="item.fixed">
|
||||
<ElRadioButton label="left">
|
||||
<Icon icon="ep:arrow-left" />
|
||||
<AppIcon :icon="AppIcons.chevronLeft" />
|
||||
</ElRadioButton>
|
||||
<ElRadioButton :label="undefined">
|
||||
<Icon icon="ep:close" />
|
||||
<AppIcon :icon="AppIcons.close" />
|
||||
</ElRadioButton>
|
||||
<ElRadioButton label="right">
|
||||
<Icon icon="ep:arrow-right" />
|
||||
<AppIcon :icon="AppIcons.chevronRight" />
|
||||
</ElRadioButton>
|
||||
</ElRadioGroup>
|
||||
|
||||
<div class="ml-12px cursor-move handle"><Icon icon="ep:rank" /></div>
|
||||
<div class="ml-12px cursor-move handle">
|
||||
<AppIcon :icon="AppIcons.drag" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCheckboxGroup>
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
<script lang="tsx">
|
||||
import { defineComponent, unref, computed, PropType, ref } from 'vue'
|
||||
import { ElDropdown, ElDropdownMenu, ElDropdownItem, ComponentSize } from 'element-plus'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { AppIcon } from '@/components/AppIcon'
|
||||
import { useI18n } from '@/hooks/web/useI18n'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { TableColumn } from '../types'
|
||||
import ColumnSetting from './ColumnSetting.vue'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableActions',
|
||||
@@ -47,8 +48,8 @@ export default defineComponent({
|
||||
<>
|
||||
<div class="text-right h-28px flex items-center justify-end">
|
||||
<div title="刷新" class="w-30px h-20px flex items-center justify-end" onClick={refresh}>
|
||||
<Icon
|
||||
icon="ant-design:sync-outlined"
|
||||
<AppIcon
|
||||
icon={AppIcons.refresh}
|
||||
class="cursor-pointer"
|
||||
hover-color="var(--el-color-primary)"
|
||||
/>
|
||||
@@ -59,8 +60,8 @@ export default defineComponent({
|
||||
default: () => {
|
||||
return (
|
||||
<div title="尺寸" class="w-30px h-20px flex items-center justify-end">
|
||||
<Icon
|
||||
icon="ant-design:column-height-outlined"
|
||||
<AppIcon
|
||||
icon={AppIcons.viewColumn}
|
||||
class="cursor-pointer"
|
||||
hover-color="var(--el-color-primary)"
|
||||
/>
|
||||
@@ -92,8 +93,8 @@ export default defineComponent({
|
||||
class="w-30px h-20px flex items-center justify-end"
|
||||
onClick={showColumnSetting}
|
||||
>
|
||||
<Icon
|
||||
icon="ant-design:setting-outlined"
|
||||
<AppIcon
|
||||
icon={AppIcons.settings}
|
||||
class="cursor-pointer"
|
||||
hover-color="var(--el-color-primary)"
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ElScrollbar } from 'element-plus'
|
||||
import { useScrollTo } from '@/hooks/event/useScrollTo'
|
||||
import { useTagsView } from '@/hooks/web/useTagsView'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
@@ -262,18 +263,14 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="prefixCls"
|
||||
:class="prefixCls"
|
||||
class="flex w-full relative bg-[#fff] dark:bg-[var(--el-bg-color)]"
|
||||
>
|
||||
<div :id="prefixCls" :class="prefixCls" class="flex w-full relative bg-[var(--el-bg-color)]">
|
||||
<span
|
||||
:class="`${prefixCls}__tool ${prefixCls}__tool--first`"
|
||||
class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer"
|
||||
@click="move(-200)"
|
||||
>
|
||||
<Icon
|
||||
icon="ep:d-arrow-left"
|
||||
<AppIcon
|
||||
:icon="AppIcons.chevronLeft"
|
||||
color="var(--el-text-color-placeholder)"
|
||||
:hover-color="isDark ? '#fff' : 'var(--el-color-black)'"
|
||||
/>
|
||||
@@ -285,7 +282,7 @@ watch(
|
||||
:ref="itemRefs.set"
|
||||
:schema="[
|
||||
{
|
||||
icon: 'ant-design:sync-outlined',
|
||||
icon: AppIcons.refresh,
|
||||
label: t('common.reload'),
|
||||
disabled: selectedTag?.fullPath !== item.fullPath,
|
||||
command: () => {
|
||||
@@ -293,7 +290,7 @@ watch(
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: 'ant-design:close-outlined',
|
||||
icon: AppIcons.close,
|
||||
label: t('common.closeTab'),
|
||||
disabled: !!visitedViews?.length && selectedTag?.meta.affix,
|
||||
command: () => {
|
||||
@@ -302,7 +299,7 @@ watch(
|
||||
},
|
||||
{
|
||||
divided: true,
|
||||
icon: 'ant-design:vertical-right-outlined',
|
||||
icon: AppIcons.splitRight,
|
||||
label: t('common.closeTheLeftTab'),
|
||||
disabled:
|
||||
!!visitedViews?.length &&
|
||||
@@ -313,7 +310,7 @@ watch(
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: 'ant-design:vertical-left-outlined',
|
||||
icon: AppIcons.splitLeft,
|
||||
label: t('common.closeTheRightTab'),
|
||||
disabled:
|
||||
!!visitedViews?.length &&
|
||||
@@ -325,7 +322,7 @@ watch(
|
||||
},
|
||||
{
|
||||
divided: true,
|
||||
icon: 'ant-design:tag-outlined',
|
||||
icon: AppIcons.tag,
|
||||
label: t('common.closeOther'),
|
||||
disabled: selectedTag?.fullPath !== item.fullPath,
|
||||
command: () => {
|
||||
@@ -333,7 +330,7 @@ watch(
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: 'ant-design:line-outlined',
|
||||
icon: AppIcons.line,
|
||||
label: t('common.closeAll'),
|
||||
command: () => {
|
||||
closeAllTags()
|
||||
@@ -358,7 +355,7 @@ watch(
|
||||
@click="navigate"
|
||||
class="h-full flex justify-center items-center whitespace-nowrap pl-15px"
|
||||
>
|
||||
<Icon
|
||||
<AppIcon
|
||||
v-if="
|
||||
item?.matched &&
|
||||
item?.matched[1] &&
|
||||
@@ -370,10 +367,10 @@ watch(
|
||||
class="mr-5px"
|
||||
/>
|
||||
{{ t(item?.meta?.title as string) }}
|
||||
<Icon
|
||||
<AppIcon
|
||||
:class="`${prefixCls}__item--close`"
|
||||
color="#333"
|
||||
icon="ant-design:close-outlined"
|
||||
color="var(--el-text-color-secondary)"
|
||||
:icon="AppIcons.close"
|
||||
:size="12"
|
||||
@click.prevent.stop="closeSelectedTag(item)"
|
||||
/>
|
||||
@@ -389,8 +386,8 @@ watch(
|
||||
class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer"
|
||||
@click="move(200)"
|
||||
>
|
||||
<Icon
|
||||
icon="ep:d-arrow-right"
|
||||
<AppIcon
|
||||
:icon="AppIcons.chevronRight"
|
||||
color="var(--el-text-color-placeholder)"
|
||||
:hover-color="isDark ? '#fff' : 'var(--el-color-black)'"
|
||||
/>
|
||||
@@ -400,8 +397,8 @@ watch(
|
||||
class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer"
|
||||
@click="refreshSelectedTag(selectedTag)"
|
||||
>
|
||||
<Icon
|
||||
icon="ant-design:reload-outlined"
|
||||
<AppIcon
|
||||
:icon="AppIcons.refresh"
|
||||
color="var(--el-text-color-placeholder)"
|
||||
:hover-color="isDark ? '#fff' : 'var(--el-color-black)'"
|
||||
/>
|
||||
@@ -410,14 +407,14 @@ watch(
|
||||
trigger="click"
|
||||
:schema="[
|
||||
{
|
||||
icon: 'ant-design:sync-outlined',
|
||||
icon: AppIcons.refresh,
|
||||
label: t('common.reload'),
|
||||
command: () => {
|
||||
refreshSelectedTag(selectedTag)
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: 'ant-design:close-outlined',
|
||||
icon: AppIcons.close,
|
||||
label: t('common.closeTab'),
|
||||
disabled: !!visitedViews?.length && selectedTag?.meta.affix,
|
||||
command: () => {
|
||||
@@ -426,7 +423,7 @@ watch(
|
||||
},
|
||||
{
|
||||
divided: true,
|
||||
icon: 'ant-design:vertical-right-outlined',
|
||||
icon: AppIcons.splitRight,
|
||||
label: t('common.closeTheLeftTab'),
|
||||
disabled: !!visitedViews?.length && selectedTag?.fullPath === visitedViews[0].fullPath,
|
||||
command: () => {
|
||||
@@ -434,7 +431,7 @@ watch(
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: 'ant-design:vertical-left-outlined',
|
||||
icon: AppIcons.splitLeft,
|
||||
label: t('common.closeTheRightTab'),
|
||||
disabled:
|
||||
!!visitedViews?.length &&
|
||||
@@ -445,14 +442,14 @@ watch(
|
||||
},
|
||||
{
|
||||
divided: true,
|
||||
icon: 'ant-design:tag-outlined',
|
||||
icon: AppIcons.tag,
|
||||
label: t('common.closeOther'),
|
||||
command: () => {
|
||||
closeOthersTags()
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: 'ant-design:line-outlined',
|
||||
icon: AppIcons.line,
|
||||
label: t('common.closeAll'),
|
||||
command: () => {
|
||||
closeAllTags()
|
||||
@@ -464,8 +461,8 @@ watch(
|
||||
:class="`${prefixCls}__tool`"
|
||||
class="w-[var(--tags-view-height)] h-[var(--tags-view-height)] flex items-center justify-center cursor-pointer block"
|
||||
>
|
||||
<Icon
|
||||
icon="ant-design:setting-outlined"
|
||||
<AppIcon
|
||||
:icon="AppIcons.settings"
|
||||
color="var(--el-text-color-placeholder)"
|
||||
:hover-color="isDark ? '#fff' : 'var(--el-color-black)'"
|
||||
/>
|
||||
@@ -542,8 +539,8 @@ watch(
|
||||
|
||||
&__item.is-active {
|
||||
color: var(--el-color-white);
|
||||
background-color: var(--el-color-primary);
|
||||
border: 1px solid var(--el-color-primary);
|
||||
background-color: var(--app-primary-strong);
|
||||
border: 1px solid var(--app-primary-strong);
|
||||
.@{prefix-cls}__item--close {
|
||||
:deep(svg) {
|
||||
color: var(--el-color-white) !important;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAppStore } from '@/store/modules/app'
|
||||
import { ElSwitch } from 'element-plus'
|
||||
import { useIcon } from '@/hooks/web/useIcon'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
@@ -13,18 +14,15 @@ const emit = defineEmits(['change'])
|
||||
|
||||
const prefixCls = getPrefixCls('theme-switch')
|
||||
|
||||
const Sun = useIcon({ icon: 'emojione-monotone:sun', color: '#fde047' })
|
||||
const Sun = useIcon({ icon: AppIcons.lightMode, color: '#fde047' })
|
||||
|
||||
const CrescentMoon = useIcon({ icon: 'emojione-monotone:crescent-moon', color: '#fde047' })
|
||||
const CrescentMoon = useIcon({ icon: AppIcons.darkMode, color: '#fde047' })
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 初始化获取是否是暗黑主题
|
||||
const isDark = ref(appStore.getIsDark)
|
||||
|
||||
// 设置switch的背景颜色
|
||||
const blackColor = 'var(--el-color-black)'
|
||||
|
||||
const themeChange = (val: boolean) => {
|
||||
appStore.setIsDark(val)
|
||||
emit('change', val)
|
||||
@@ -36,9 +34,6 @@ const themeChange = (val: boolean) => {
|
||||
:class="prefixCls"
|
||||
v-model="isDark"
|
||||
inline-prompt
|
||||
:border-color="blackColor"
|
||||
:inactive-color="blackColor"
|
||||
:active-color="blackColor"
|
||||
:active-icon="Sun"
|
||||
:inactive-icon="CrescentMoon"
|
||||
@change="themeChange"
|
||||
@@ -46,7 +41,20 @@ const themeChange = (val: boolean) => {
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.el-switch__core .el-switch__inner .is-icon) {
|
||||
overflow: visible;
|
||||
.v-theme-switch {
|
||||
:deep(.el-switch__core .el-switch__inner .is-icon) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:deep(.el-switch__core) {
|
||||
min-width: 44px;
|
||||
background: var(--el-fill-color-darker);
|
||||
border-color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.v-theme-switch.is-checked :deep(.el-switch__core) {
|
||||
background: var(--app-primary-strong) !important;
|
||||
border-color: var(--app-primary-strong) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,8 @@ import { ref, computed } from 'vue'
|
||||
import LockPage from './components/LockPage.vue'
|
||||
import { useLockStore } from '@/store/modules/lock'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AppIcon } from '@/components/AppIcon'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -22,6 +24,12 @@ const prefixCls = getPrefixCls('user-info')
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const displayName = computed(
|
||||
() => userStore.getUserInfo?.name || userStore.getUserInfo?.username || '-'
|
||||
)
|
||||
|
||||
const avatarUrl = computed(() => userStore.getUserInfo?.avatar || '')
|
||||
|
||||
const loginOut = () => {
|
||||
userStore.logoutConfirm()
|
||||
}
|
||||
@@ -38,12 +46,19 @@ const lockScreen = () => {
|
||||
<ElDropdown class="custom-hover" :class="prefixCls" trigger="click">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src="@/assets/imgs/avatar.jpg"
|
||||
v-if="avatarUrl"
|
||||
:src="avatarUrl"
|
||||
alt=""
|
||||
class="w-[calc(var(--logo-height)-25px)] rounded-[50%]"
|
||||
class="w-[calc(var(--logo-height)-25px)] h-[calc(var(--logo-height)-25px)] rounded-[50%] object-cover"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="w-[calc(var(--logo-height)-25px)] h-[calc(var(--logo-height)-25px)] rounded-[50%] flex items-center justify-center bg-[var(--el-fill-color)]"
|
||||
>
|
||||
<AppIcon :icon="AppIcons.person" />
|
||||
</span>
|
||||
<span class="<lg:hidden text-14px pl-[5px] text-[var(--top-header-text-color)]">{{
|
||||
userStore.getUserInfo?.username
|
||||
displayName
|
||||
}}</span>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
<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'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { useLockStore } from '@/store/modules/lock'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AppIcon } from '@/components/AppIcon'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
const prefixCls = getPrefixCls('lock-dialog')
|
||||
@@ -21,6 +23,12 @@ const { required } = useValidator()
|
||||
const { t } = useI18n()
|
||||
|
||||
const lockStore = useLockStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const displayName = computed(
|
||||
() => userStore.getUserInfo?.name || userStore.getUserInfo?.username || '-'
|
||||
)
|
||||
const avatarUrl = computed(() => userStore.getUserInfo?.avatar || '')
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -32,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()]
|
||||
@@ -51,7 +56,10 @@ const schema: FormSchema[] = reactive([
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
type: 'password',
|
||||
showPassword: true
|
||||
showPassword: true,
|
||||
style: {
|
||||
width: '100%'
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
@@ -73,31 +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 src="@/assets/imgs/avatar.jpg" alt="" class="w-70px h-70px rounded-[50%]" />
|
||||
<span class="text-14px my-10px text-[var(--top-header-text-color)]">Archer</span>
|
||||
<template #title>
|
||||
<div class="lock-dialog-heading">
|
||||
<AppIcon :icon="AppIcons.lock" :size="20" />
|
||||
<div>
|
||||
<h2>{{ dialogTitle }}</h2>
|
||||
<p>{{ t('lock.description') }}</p>
|
||||
</div>
|
||||
</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>
|
||||
<Form :is-col="false" :schema="schema" :rules="rules" @register="formRegister" />
|
||||
|
||||
<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>
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElInput, ElButton } from 'element-plus'
|
||||
import { resetRouter } from '@/router'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -10,11 +10,14 @@ import { useLockStore } from '@/store/modules/lock'
|
||||
import { useI18n } from '@/hooks/web/useI18n'
|
||||
import { useNow } from '@/hooks/web/useNow'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { AppIcon } from '@/components/AppIcon'
|
||||
import { loginOutApi } from '@/api/login'
|
||||
import { useTagsViewStore } from '@/store/modules/tagsView'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const { clear } = useStorage()
|
||||
|
||||
@@ -34,6 +37,11 @@ const { hour, month, minute, meridiem, year, day, week } = useNow(true)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const displayName = computed(
|
||||
() => userStore.getUserInfo?.name || userStore.getUserInfo?.username || '-'
|
||||
)
|
||||
const avatarUrl = computed(() => userStore.getUserInfo?.avatar || '')
|
||||
|
||||
// 解锁
|
||||
async function unLock() {
|
||||
if (!password.value) {
|
||||
@@ -77,7 +85,7 @@ function handleShowForm(show = false) {
|
||||
@click="handleShowForm(false)"
|
||||
v-show="showDate"
|
||||
>
|
||||
<Icon icon="ep:lock" />
|
||||
<AppIcon :icon="AppIcons.lock" />
|
||||
<span>{{ t('lock.unlock') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -96,8 +104,20 @@ function handleShowForm(show = false) {
|
||||
<div :class="`${prefixCls}-entry`" v-show="!showDate">
|
||||
<div :class="`${prefixCls}-entry-content`">
|
||||
<div class="flex flex-col items-center">
|
||||
<img src="@/assets/imgs/avatar.jpg" alt="" class="w-70px h-70px rounded-[50%]" />
|
||||
<span class="text-14px my-10px text-[var(--logo-title-text-color)]">Archer</span>
|
||||
<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(--logo-title-text-color)]">{{
|
||||
displayName
|
||||
}}</span>
|
||||
</div>
|
||||
<ElInput
|
||||
type="password"
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import { VideoPlayer } from '@/components/VideoPlayer'
|
||||
import { ElOverlay } from 'element-plus'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { AppIcon } from '@/components/AppIcon'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -43,7 +44,7 @@ const close = async () => {
|
||||
class="w-44px h-44px color-[#fff] bg-[var(--el-text-color-regular)] rounded-full border-[#fff] flex justify-center items-center cursor-pointer absolute top-40px right-40px"
|
||||
@click="close"
|
||||
>
|
||||
<Icon icon="ep:close" :size="24" />
|
||||
<AppIcon :icon="AppIcons.close" :size="24" />
|
||||
</div>
|
||||
<VideoPlayer :url="url" :poster="poster" />
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
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,105 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/**
|
||||
* 后台统一图标语义表。所有值都来自 material-symbols-rounded。
|
||||
*/
|
||||
export const AppIcons = {
|
||||
menu: 'material-symbols:menu-rounded',
|
||||
menuOpen: 'material-symbols:menu-open-rounded',
|
||||
dashboard: 'material-symbols:dashboard-rounded',
|
||||
content: 'material-symbols:video-library-rounded',
|
||||
settings: 'material-symbols:settings-rounded',
|
||||
group: 'material-symbols:group-rounded',
|
||||
movieEdit: 'material-symbols:movie-edit-rounded',
|
||||
policy: 'material-symbols:policy-rounded',
|
||||
payments: 'material-symbols:payments-rounded',
|
||||
monitoring: 'material-symbols:monitoring-rounded',
|
||||
videoCameraFront: 'material-symbols:video-camera-front-rounded',
|
||||
campaign: 'material-symbols:campaign-rounded',
|
||||
fullscreen: 'material-symbols:fullscreen-rounded',
|
||||
fullscreenExit: 'material-symbols:fullscreen-exit-rounded',
|
||||
formatSize: 'material-symbols:format-size-rounded',
|
||||
language: 'material-symbols:translate-rounded',
|
||||
darkMode: 'material-symbols:dark-mode-rounded',
|
||||
lightMode: 'material-symbols:light-mode-rounded',
|
||||
refresh: 'material-symbols:refresh-rounded',
|
||||
restart: 'material-symbols:restart-alt-rounded',
|
||||
close: 'material-symbols:close-rounded',
|
||||
chevronLeft: 'material-symbols:chevron-left-rounded',
|
||||
chevronRight: 'material-symbols:chevron-right-rounded',
|
||||
chevronDown: 'material-symbols:keyboard-arrow-down-rounded',
|
||||
chevronUp: 'material-symbols:keyboard-arrow-up-rounded',
|
||||
search: 'material-symbols:search-rounded',
|
||||
viewColumn: 'material-symbols:combine-columns-rounded',
|
||||
help: 'material-symbols:help-rounded',
|
||||
add: 'material-symbols:add-rounded',
|
||||
edit: 'material-symbols:edit-rounded',
|
||||
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',
|
||||
arrowBack: 'material-symbols:arrow-back-rounded',
|
||||
arrowForward: 'material-symbols:arrow-forward-rounded',
|
||||
check: 'material-symbols:check-rounded',
|
||||
checkCircle: 'material-symbols:check-circle-rounded',
|
||||
cancel: 'material-symbols:cancel-rounded',
|
||||
lock: 'material-symbols:lock-person-rounded',
|
||||
logout: 'material-symbols:logout-rounded',
|
||||
person: 'material-symbols:person-rounded',
|
||||
clock: 'material-symbols:schedule-rounded',
|
||||
videoPlay: 'material-symbols:smart-display-rounded',
|
||||
drag: 'material-symbols:drag-handle-rounded',
|
||||
tag: 'material-symbols:label-rounded',
|
||||
line: 'material-symbols:horizontal-rule-rounded',
|
||||
splitLeft: 'material-symbols:split-scene-left-rounded',
|
||||
splitRight: 'material-symbols:split-scene-right-rounded',
|
||||
warning: 'material-symbols:warning-rounded',
|
||||
error: 'material-symbols:error-rounded',
|
||||
info: 'material-symbols:info-rounded'
|
||||
} as const
|
||||
|
||||
/** 菜单管理提供的合法图标候选。 */
|
||||
export const MenuIconOptions = [
|
||||
AppIcons.dashboard,
|
||||
AppIcons.content,
|
||||
AppIcons.settings,
|
||||
AppIcons.group,
|
||||
AppIcons.movieEdit,
|
||||
AppIcons.policy,
|
||||
AppIcons.payments,
|
||||
AppIcons.monitoring,
|
||||
AppIcons.videoCameraFront,
|
||||
AppIcons.campaign
|
||||
] as const
|
||||
|
||||
const legacyIconAliases: Record<string, string> = {
|
||||
Document: AppIcons.content,
|
||||
DataAnalysis: AppIcons.monitoring,
|
||||
Picture: AppIcons.content,
|
||||
User: AppIcons.group,
|
||||
Setting: AppIcons.settings,
|
||||
VideoCamera: AppIcons.content,
|
||||
Menu: AppIcons.menu,
|
||||
Grid: AppIcons.dashboard,
|
||||
'ep:setting': AppIcons.settings,
|
||||
'ep:user': AppIcons.group,
|
||||
'ep:user-filled': AppIcons.group,
|
||||
'ep:chat-dot-round': AppIcons.policy,
|
||||
'ep:coin': AppIcons.payments,
|
||||
'ep:data-line': AppIcons.monitoring,
|
||||
'ep:film': AppIcons.videoCameraFront,
|
||||
'ep:bell': AppIcons.campaign,
|
||||
'ep:video-camera': AppIcons.content,
|
||||
'ant-design:dashboard-filled': AppIcons.dashboard
|
||||
}
|
||||
|
||||
/** 兼容历史短名与旧 Iconify 名,统一返回 Material Symbols Rounded。 */
|
||||
export function normalizeAppIcon(icon?: string): string {
|
||||
if (!icon) return AppIcons.menu
|
||||
if (icon.startsWith('material-symbols:') || icon.startsWith('svg-icon:')) return icon
|
||||
return legacyIconAliases[icon] || AppIcons.menu
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { h } from 'vue'
|
||||
import type { VNode } from 'vue'
|
||||
import { Icon, IconTypes } from '@/components/Icon'
|
||||
import { AppIcon } from '@/components/AppIcon'
|
||||
import type { IconTypes } from '@/components/Icon'
|
||||
|
||||
export const useIcon = (props: IconTypes): VNode => {
|
||||
return h(Icon, props)
|
||||
return h(AppIcon, props)
|
||||
}
|
||||
@@ -56,14 +56,20 @@ export default defineComponent({
|
||||
) : undefined}
|
||||
<div class="h-full flex items-center">
|
||||
{screenfull.value ? (
|
||||
<Screenfull class="custom-hover" color="var(--top-header-text-color)"></Screenfull>
|
||||
<Screenfull
|
||||
class="<md:hidden custom-hover"
|
||||
color="var(--top-header-text-color)"
|
||||
></Screenfull>
|
||||
) : undefined}
|
||||
{size.value ? (
|
||||
<SizeDropdown class="custom-hover" color="var(--top-header-text-color)"></SizeDropdown>
|
||||
<SizeDropdown
|
||||
class="<md:hidden custom-hover"
|
||||
color="var(--top-header-text-color)"
|
||||
></SizeDropdown>
|
||||
) : undefined}
|
||||
{locale.value ? (
|
||||
<LocaleDropdown
|
||||
class="custom-hover"
|
||||
class="<sm:hidden custom-hover"
|
||||
color="var(--top-header-text-color)"
|
||||
></LocaleDropdown>
|
||||
) : undefined}
|
||||
@@ -79,6 +85,20 @@ export default defineComponent({
|
||||
@prefix-cls: ~'@{namespace}-tool-header';
|
||||
|
||||
.@{prefix-cls} {
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
transition: left var(--transition-time-02);
|
||||
|
||||
:deep(.el-dropdown),
|
||||
:deep(.v-user-info) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.@{prefix-cls} {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+169
-13
@@ -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',
|
||||
@@ -86,6 +87,7 @@ export default {
|
||||
cutMenu: 'Cut Menu',
|
||||
copy: 'Copy',
|
||||
clearAndReset: 'Clear cache and reset',
|
||||
clearCacheMessage: 'Clear local cache and reload the admin console?',
|
||||
copySuccess: 'Copy success',
|
||||
copyFailed: 'Copy failed',
|
||||
footer: 'Footer',
|
||||
@@ -103,25 +105,16 @@ export default {
|
||||
},
|
||||
login: {
|
||||
welcome: 'Short-drama workspace',
|
||||
message: 'Manage dramas, episodes, publishing, and access.',
|
||||
consoleLabel: 'Content Operations',
|
||||
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',
|
||||
@@ -170,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'
|
||||
@@ -534,5 +528,167 @@ 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',
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
-13
@@ -52,6 +52,7 @@ export default {
|
||||
lockScreen: '锁定屏幕',
|
||||
lock: '锁定',
|
||||
lockPassword: '锁屏密码',
|
||||
description: '输入密码解锁当前会话。',
|
||||
unlock: '点击解锁',
|
||||
backToLogin: '返回登录',
|
||||
entrySystem: '进入系统',
|
||||
@@ -86,6 +87,7 @@ export default {
|
||||
cutMenu: '切割菜单',
|
||||
copy: '拷贝',
|
||||
clearAndReset: '清除缓存并且重置',
|
||||
clearCacheMessage: '确认清除本地缓存并重新加载后台吗?',
|
||||
copySuccess: '拷贝成功',
|
||||
copyFailed: '拷贝失败',
|
||||
footer: '页脚',
|
||||
@@ -103,25 +105,16 @@ export default {
|
||||
},
|
||||
login: {
|
||||
welcome: '短剧内容管理',
|
||||
message: '管理短剧、剧集、发布状态和后台权限。',
|
||||
consoleLabel: '内容运营后台',
|
||||
hint: '使用管理员账号进入工作台',
|
||||
noAccount: '还没有账号?',
|
||||
pointDrama: '短剧与剧集',
|
||||
pointPublish: '发布状态',
|
||||
pointAccess: '角色权限',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
register: '注册',
|
||||
checkPassword: '确认密码',
|
||||
login: '登录',
|
||||
otherLogin: '其它登录方式',
|
||||
remember: '记住我',
|
||||
hasUser: '已有账号?去登录',
|
||||
forgetPassword: '忘记密码',
|
||||
forgetPasswordHint: '请联系管理员重置密码',
|
||||
usernamePlaceholder: '请输入用户名',
|
||||
passwordPlaceholder: '请输入密码',
|
||||
code: '验证码',
|
||||
codePlaceholder: '请输入验证码'
|
||||
passwordPlaceholder: '请输入密码'
|
||||
},
|
||||
router: {
|
||||
login: '登录',
|
||||
@@ -170,7 +163,8 @@ export default {
|
||||
treeTable: '树形表格',
|
||||
PicturePreview: '表格图片预览',
|
||||
department: '部门管理',
|
||||
menuManagement: '菜单管理'
|
||||
menuManagement: '菜单管理',
|
||||
transcodeSettings: '转码设置'
|
||||
},
|
||||
permission: {
|
||||
hasPermission: '请设置操作权限值'
|
||||
@@ -528,5 +522,161 @@ 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: '当前账号没有转码设置权限',
|
||||
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: '请修正标记的转码配置后再保存'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -68,6 +69,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[])
|
||||
|
||||
+23
-5
@@ -4,6 +4,7 @@ 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[] = [
|
||||
{
|
||||
@@ -62,7 +63,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
name: 'Dashboard',
|
||||
meta: {
|
||||
title: '工作台',
|
||||
icon: 'ant-design:dashboard-filled'
|
||||
icon: AppIcons.dashboard
|
||||
},
|
||||
children: [
|
||||
{
|
||||
@@ -71,7 +72,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 +85,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
name: 'Content',
|
||||
meta: {
|
||||
title: '内容管理',
|
||||
icon: 'ep:video-camera',
|
||||
icon: AppIcons.content,
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
@@ -119,6 +120,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 +139,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
name: 'UserCenter',
|
||||
meta: {
|
||||
title: '用户管理',
|
||||
icon: 'ep:user',
|
||||
icon: AppIcons.group,
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
@@ -150,7 +160,7 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
name: 'System',
|
||||
meta: {
|
||||
title: '后台管理',
|
||||
icon: 'ep:setting',
|
||||
icon: AppIcons.settings,
|
||||
alwaysShow: true
|
||||
},
|
||||
children: [
|
||||
@@ -201,6 +211,14 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
|
||||
meta: {
|
||||
title: '操作日志'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'legal-documents',
|
||||
name: 'legal-documents',
|
||||
component: () => import('@/views/systemManagement/LegalDocumentView.vue'),
|
||||
meta: {
|
||||
title: '协议设置'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export const useAppStore = defineStore('app', {
|
||||
currentSize: 'default', // 组件尺寸
|
||||
theme: {
|
||||
// 主题色
|
||||
elColorPrimary: '#409eff',
|
||||
elColorPrimary: '#c4322a',
|
||||
// 左侧菜单边框颜色
|
||||
leftMenuBorderColor: 'inherit',
|
||||
// 左侧菜单背景颜色
|
||||
@@ -75,9 +75,9 @@ export const useAppStore = defineStore('app', {
|
||||
// 左侧菜单浅色背景颜色
|
||||
leftMenuBgLightColor: '#0f2438',
|
||||
// 左侧菜单选中背景颜色
|
||||
leftMenuBgActiveColor: 'var(--el-color-primary)',
|
||||
leftMenuBgActiveColor: 'var(--app-primary-strong)',
|
||||
// 左侧菜单收起选中背景颜色
|
||||
leftMenuCollapseBgActiveColor: 'var(--el-color-primary)',
|
||||
leftMenuCollapseBgActiveColor: 'var(--app-primary-strong)',
|
||||
// 左侧菜单字体颜色
|
||||
leftMenuTextColor: '#bfcbd9',
|
||||
// 左侧菜单选中字体颜色
|
||||
@@ -89,7 +89,7 @@ export const useAppStore = defineStore('app', {
|
||||
// 头部背景颜色
|
||||
topHeaderBgColor: '#fff',
|
||||
// 头部字体颜色
|
||||
topHeaderTextColor: 'inherit',
|
||||
topHeaderTextColor: '#303133',
|
||||
// 头部悬停颜色
|
||||
topHeaderHoverColor: '#f6f6f6',
|
||||
// 头部边框颜色
|
||||
@@ -288,18 +288,18 @@ export const useAppStore = defineStore('app', {
|
||||
leftMenuBgLightColor: isDarkColor ? lighten(color!, 6) : color,
|
||||
// 左侧菜单选中背景颜色
|
||||
leftMenuBgActiveColor: isDarkColor
|
||||
? 'var(--el-color-primary)'
|
||||
? 'var(--app-primary-strong)'
|
||||
: hexToRGB(unref(primaryColor), 0.1),
|
||||
// 左侧菜单收起选中背景颜色
|
||||
leftMenuCollapseBgActiveColor: isDarkColor
|
||||
? 'var(--el-color-primary)'
|
||||
? 'var(--app-primary-strong)'
|
||||
: hexToRGB(unref(primaryColor), 0.1),
|
||||
// 左侧菜单字体颜色
|
||||
leftMenuTextColor: isDarkColor ? '#bfcbd9' : '#333',
|
||||
// 左侧菜单选中字体颜色
|
||||
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,11 @@ 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.setMenuTheme(context.store.theme.leftMenuBgColor)
|
||||
context.store.setHeaderTheme(context.store.theme.topHeaderBgColor)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ export const useUserStore = defineStore('user', {
|
||||
token: '',
|
||||
roleRouters: undefined,
|
||||
// 记住我
|
||||
rememberMe: true,
|
||||
rememberMe: false,
|
||||
loginInfo: undefined
|
||||
}
|
||||
},
|
||||
@@ -87,6 +87,8 @@ export const useUserStore = defineStore('user', {
|
||||
this.setToken('')
|
||||
this.setUserInfo(undefined)
|
||||
this.setRoleRouters([])
|
||||
this.setLoginInfo(undefined)
|
||||
this.setRememberMe(false)
|
||||
tagsViewStore.delAllViews()
|
||||
// 菜单/路由一起清:否则换账号后 permissionStore 里还是上一个账号的路由表
|
||||
permissionStore.clearRoutes()
|
||||
@@ -102,7 +104,17 @@ export const useUserStore = defineStore('user', {
|
||||
this.loginInfo = loginInfo
|
||||
}
|
||||
},
|
||||
persist: true
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const useUserStoreWithOut = () => {
|
||||
|
||||
@@ -53,7 +53,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;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
height: 90px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: #f0f2f4 !important;
|
||||
}
|
||||
|
||||
@@ -13,37 +13,212 @@
|
||||
place-content: center;
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: #f0f2f4 !important;
|
||||
color: #7e8791;
|
||||
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: 6px;
|
||||
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;
|
||||
}
|
||||
@@ -116,7 +116,7 @@ section .console-hint.el-alert {
|
||||
min-height: 74px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e6e8eb;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--app-radius-md);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
width: 34px;
|
||||
height: 46px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: #eef0f5;
|
||||
color: #9a97a8;
|
||||
font-size: 14px;
|
||||
@@ -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;
|
||||
@@ -193,7 +195,7 @@
|
||||
height: 76px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e6e8eb;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: #fafbfc;
|
||||
color: #9a97a8;
|
||||
font-size: 12px;
|
||||
@@ -205,7 +207,7 @@
|
||||
width: 48px;
|
||||
height: 30px;
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: #eef0f5;
|
||||
color: #9a97a8;
|
||||
font-size: 12px;
|
||||
@@ -226,7 +228,7 @@
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid #f0e6cf;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--app-radius-md);
|
||||
background: #fdf9ef;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ html.dark .panel-heading h2,
|
||||
html.dark .episode-toolbar h2,
|
||||
html.dark .stat-value,
|
||||
html.dark .drama-cell b,
|
||||
html.dark .brand b,
|
||||
html.dark .role-card,
|
||||
html.dark .cover-card-info b {
|
||||
color: var(--el-text-color-primary);
|
||||
@@ -22,25 +21,19 @@ html.dark .table-result-count {
|
||||
|
||||
html.dark .stat-card,
|
||||
html.dark .panel,
|
||||
html.dark .role-card,
|
||||
html.dark .topbar,
|
||||
html.dark .sidebar {
|
||||
html.dark .role-card {
|
||||
background: var(--el-bg-color-overlay);
|
||||
border-color: var(--el-border-color-light);
|
||||
}
|
||||
|
||||
html.dark .stat-icon,
|
||||
html.dark .role-icon,
|
||||
html.dark .help-icon,
|
||||
html.dark .workspace-avatar,
|
||||
html.dark .profile-avatar,
|
||||
html.dark .top-avatar {
|
||||
background: rgb(64 158 255 / 16%) !important;
|
||||
html.dark .role-icon {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -113,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);
|
||||
@@ -128,3 +121,33 @@ html.dark .el-tag.status-badge.is-offline {
|
||||
border-color: rgba(245, 108, 108, 0.45) !important;
|
||||
color: #f3a6a6 !important;
|
||||
}
|
||||
|
||||
.v-tool-header {
|
||||
color: var(--top-header-text-color);
|
||||
}
|
||||
|
||||
.v-tags-view {
|
||||
background: var(--el-bg-color);
|
||||
border-color: var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.v-tags-view__item--close {
|
||||
color: var(--el-text-color-secondary) !important;
|
||||
}
|
||||
|
||||
html.dark .v-tool-header,
|
||||
html.dark .v-tags-view {
|
||||
background: var(--el-bg-color-overlay);
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
|
||||
html.dark .v-tags-view__item {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
html.dark #v-menu .el-menu-item.is-active,
|
||||
html.dark #v-menu .el-sub-menu.is-active > .el-sub-menu__title,
|
||||
html.dark .v-tags-view__item.is-active {
|
||||
background-color: var(--app-primary-strong) !important;
|
||||
border-color: var(--app-primary-strong) !important;
|
||||
}
|
||||
@@ -37,7 +37,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.table-actions .el-button {
|
||||
/* 统一尺寸:高度固定、水平内边距,靠行高垂直居中,避免文字被压扁 */
|
||||
@@ -46,25 +46,19 @@
|
||||
padding: 0 10px !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 500 !important;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
}
|
||||
|
||||
/* Element Plus 默认给相邻按钮加 12px 外边距,会和这里的 gap 叠加导致换行 */
|
||||
.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;
|
||||
height: 56px;
|
||||
flex: 0 0 44px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
.drama-cell .drama-cover {
|
||||
@@ -91,51 +85,461 @@
|
||||
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: 6px;
|
||||
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-editor {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.cover-preview-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 0 0 96px;
|
||||
width: 96px;
|
||||
height: 128px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
.cover-preview-card img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-preview-empty {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cover-upload-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: color-mix(in srgb, var(--el-color-primary) 78%, transparent);
|
||||
}
|
||||
|
||||
.cover-upload-mask b {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.cover-editor-meta {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-video-preview {
|
||||
|
||||
.cover-status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cover-filename {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cover-hint {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cover-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cover-actions .el-button {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.cover-original-link {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cover-original-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.drama-editor-dialog {
|
||||
width: calc(100vw - 24px) !important;
|
||||
margin: 12px auto !important;
|
||||
}
|
||||
|
||||
.drama-editor-dialog .el-dialog__body {
|
||||
padding-right: 18px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.editor-grid--two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cover-editor {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cover-preview-card {
|
||||
flex-basis: 128px;
|
||||
}
|
||||
|
||||
.cover-editor-meta {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark .cover-preview-card,
|
||||
html.dark .cover-preview-empty {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
/* 剧集编辑弹窗 */
|
||||
.episode-editor-dialog {
|
||||
display: flex;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
border-radius: var(--app-radius-lg) !important;
|
||||
}
|
||||
|
||||
.episode-editor-dialog .el-dialog__body {
|
||||
flex: 1 1 auto;
|
||||
padding: 8px 24px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.episode-editor-dialog .el-dialog__footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 14px 24px;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.episode-editor-dialog .el-dialog__footer .el-button {
|
||||
min-width: 88px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.episode-editor-section {
|
||||
padding: 4px 0 18px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.episode-editor-section + .episode-editor-section {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.episode-editor-section:last-child {
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.episode-editor-section-title {
|
||||
margin: 0 0 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.episode-editor-grid {
|
||||
display: grid;
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.episode-editor-grid--two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.episode-editor-grid--three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.episode-editor-number {
|
||||
width: 180px !important;
|
||||
}
|
||||
|
||||
.episode-video-editor {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.episode-video-preview-card {
|
||||
position: relative;
|
||||
flex: 0 0 240px;
|
||||
width: 240px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 10px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.episode-editor-video {
|
||||
display: block;
|
||||
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: 6px;
|
||||
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);
|
||||
}
|
||||
|
||||
/* 剧集预览弹窗里的播放器 */
|
||||
@@ -144,7 +548,7 @@
|
||||
width: 100%;
|
||||
max-height: 60vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--app-radius-md);
|
||||
background: #000 !important;
|
||||
}
|
||||
|
||||
@@ -156,14 +560,14 @@
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: #000 !important;
|
||||
}
|
||||
|
||||
/* 列表里点视频即放大播放,给个悬停反馈 */
|
||||
.episode-video-thumb:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.25);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--el-color-primary) 25%, transparent);
|
||||
}
|
||||
.dark .table-result-count {
|
||||
color: #a6aeb9;
|
||||
@@ -231,10 +635,10 @@
|
||||
color: #c0c6cf;
|
||||
}
|
||||
.table-actions .el-button--primary {
|
||||
--el-button-bg-color: #409eff;
|
||||
--el-button-border-color: #409eff;
|
||||
--el-button-hover-bg-color: #79bbff;
|
||||
--el-button-hover-border-color: #79bbff;
|
||||
--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);
|
||||
}
|
||||
.table-actions .el-button--success {
|
||||
--el-button-bg-color: #4d866a;
|
||||
@@ -243,10 +647,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;
|
||||
@@ -259,7 +663,7 @@
|
||||
font-size: 13px !important;
|
||||
--el-pagination-font-size: 13px;
|
||||
--el-pagination-button-bg-color: #f1f3f6;
|
||||
--el-pagination-hover-color: #409eff;
|
||||
--el-pagination-hover-color: var(--el-color-primary);
|
||||
--el-pagination-button-color: #4c5561;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
word-break: break-all;
|
||||
background: #f6f7f9 !important;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--app-radius-md);
|
||||
}
|
||||
|
||||
.dark .log-json {
|
||||
|
||||
+146
-392
@@ -1,315 +1,11 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&family=Noto+Sans+SC:wght@400;500;600;700&display=swap');
|
||||
:root {
|
||||
font-family: 'DM Sans', 'Noto Sans SC', sans-serif;
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--el-bg-color-page);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 400;
|
||||
--purple: #409eff;
|
||||
--muted: #9695a8;
|
||||
--line: #eeedf3;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 980px;
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
background: #fff;
|
||||
border-right: 1px solid #eeedf4;
|
||||
padding: 27px 17px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 4;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 0 9px 28px;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
background: #409eff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
.brand-mark span {
|
||||
width: 4px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
transform: skew(-15deg);
|
||||
}
|
||||
.brand-mark span:nth-child(1) {
|
||||
height: 11px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.brand-mark span:nth-child(2) {
|
||||
height: 18px;
|
||||
}
|
||||
.brand-mark span:nth-child(3) {
|
||||
height: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.brand b {
|
||||
display: block;
|
||||
font:
|
||||
800 18px Manrope,
|
||||
sans-serif;
|
||||
letter-spacing: -0.8px;
|
||||
color: #242139;
|
||||
}
|
||||
.brand small {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.5px;
|
||||
color: #a3a1b1;
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.workspace {
|
||||
height: 58px;
|
||||
border: 1px solid #efedf4;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
margin-bottom: 27px;
|
||||
gap: 10px;
|
||||
}
|
||||
.workspace-avatar,
|
||||
.profile-avatar,
|
||||
.top-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.workspace-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.workspace-copy {
|
||||
flex: 1;
|
||||
}
|
||||
.workspace-copy b,
|
||||
.workspace-copy small {
|
||||
display: block;
|
||||
}
|
||||
.workspace-copy b {
|
||||
font-size: 11px;
|
||||
}
|
||||
.workspace-copy small {
|
||||
color: #a09eae;
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.workspace > .el-icon {
|
||||
color: #aaa8b7;
|
||||
font-size: 13px;
|
||||
}
|
||||
.nav-label {
|
||||
padding: 0 11px 10px;
|
||||
color: #aaa8b7;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.side-menu {
|
||||
border: 0 !important;
|
||||
}
|
||||
.side-menu .el-menu-item {
|
||||
height: 43px;
|
||||
line-height: 43px;
|
||||
border-radius: 8px;
|
||||
margin: 2px 0;
|
||||
color: #777589;
|
||||
font-size: 12px;
|
||||
padding: 0 11px !important;
|
||||
}
|
||||
.side-menu .el-menu-item .el-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.side-menu .el-menu-item:hover {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
.side-menu .el-menu-item.is-active {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.menu-count {
|
||||
margin-left: auto;
|
||||
border-radius: 10px;
|
||||
background: #ecf5ff;
|
||||
color: #79bbff;
|
||||
padding: 2px 7px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.access-label {
|
||||
margin-top: 27px;
|
||||
}
|
||||
.sidebar-bottom {
|
||||
margin-top: auto;
|
||||
}
|
||||
.help-card {
|
||||
background: #ecf5ff;
|
||||
border: 1px solid #ecf5ff;
|
||||
border-radius: 10px;
|
||||
padding: 13px 13px 14px;
|
||||
margin: 0 2px 17px;
|
||||
}
|
||||
.help-icon {
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #d9ecff;
|
||||
border-radius: 7px;
|
||||
color: #409eff;
|
||||
margin-bottom: 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.help-card b {
|
||||
font-size: 11px;
|
||||
}
|
||||
.help-card p {
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
color: #9290a2;
|
||||
margin: 5px 0 9px;
|
||||
}
|
||||
.help-card a {
|
||||
font-size: 10px;
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.help-card a span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.user-profile {
|
||||
border-top: 1px solid #f0eff4;
|
||||
padding: 15px 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.profile-avatar {
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
}
|
||||
.profile-info {
|
||||
flex: 1;
|
||||
}
|
||||
.profile-info b,
|
||||
.profile-info small {
|
||||
display: block;
|
||||
}
|
||||
.profile-info b {
|
||||
font-size: 11px;
|
||||
}
|
||||
.profile-info small {
|
||||
font-size: 10px;
|
||||
color: #a09eae;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.user-profile > .el-icon {
|
||||
color: #aaa8b7;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.main-area {
|
||||
margin-left: 250px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.topbar {
|
||||
height: 64px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-bottom: 1px solid #eeedf4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 39px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
}
|
||||
.breadcrumb {
|
||||
font-size: 11px;
|
||||
color: #aaa8b6;
|
||||
}
|
||||
.breadcrumb .slash {
|
||||
padding: 0 10px;
|
||||
color: #d4d2dd;
|
||||
}
|
||||
.breadcrumb b {
|
||||
font-weight: 600;
|
||||
color: #555367;
|
||||
}
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
.environment {
|
||||
border: 1px solid #eeedf2;
|
||||
border-radius: 15px;
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
color: #777589;
|
||||
}
|
||||
.environment i {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #62c491;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.icon-button {
|
||||
position: relative;
|
||||
color: #777589;
|
||||
}
|
||||
.notification-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #ef7d85;
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 5px;
|
||||
border: 1px solid white;
|
||||
}
|
||||
.top-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
}
|
||||
.page-content {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 35px 40px 46px;
|
||||
}
|
||||
.page-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -323,7 +19,7 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
.eyebrow span {
|
||||
color: #79bbff;
|
||||
color: var(--el-color-primary-light-3);
|
||||
margin-left: 5px;
|
||||
}
|
||||
.page-heading h1 {
|
||||
@@ -344,17 +40,17 @@ body {
|
||||
font-size: 20px;
|
||||
}
|
||||
.primary-button {
|
||||
background: #409eff !important;
|
||||
border-color: #409eff !important;
|
||||
border-radius: 7px !important;
|
||||
background: var(--el-color-primary) !important;
|
||||
border-color: var(--el-color-primary) !important;
|
||||
border-radius: var(--app-radius-md) !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
height: 37px !important;
|
||||
padding: 0 15px !important;
|
||||
box-shadow: 0 4px 10px #409eff22;
|
||||
box-shadow: 0 4px 10px color-mix(in srgb, var(--el-color-primary) 13%, transparent);
|
||||
}
|
||||
.primary-button:hover {
|
||||
background: #337ecc !important;
|
||||
background: var(--el-color-primary-dark-2) !important;
|
||||
}
|
||||
.primary-button .el-icon {
|
||||
margin-right: 6px;
|
||||
@@ -369,7 +65,7 @@ body {
|
||||
.panel {
|
||||
background: #fff;
|
||||
border: 1px solid #efedf3;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--app-radius-md);
|
||||
}
|
||||
.stat-card {
|
||||
padding: 16px 17px 13px;
|
||||
@@ -387,12 +83,12 @@ body {
|
||||
height: 27px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--app-radius-md);
|
||||
font-size: 13px;
|
||||
}
|
||||
.lilac {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.blush {
|
||||
background: #fff0f2;
|
||||
@@ -435,7 +131,7 @@ body {
|
||||
color: #49b786;
|
||||
background: #eef9f4;
|
||||
padding: 3px 5px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
}
|
||||
.neutral {
|
||||
color: #aaa8b6;
|
||||
@@ -565,9 +261,9 @@ body {
|
||||
.muted-count {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
border-radius: var(--app-radius-md);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
font:
|
||||
600 9px 'DM Sans',
|
||||
sans-serif;
|
||||
@@ -578,7 +274,7 @@ body {
|
||||
}
|
||||
.view-all {
|
||||
font-size: 10px !important;
|
||||
color: #409eff !important;
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
.view-all span {
|
||||
margin-left: 5px;
|
||||
@@ -599,7 +295,7 @@ body {
|
||||
.review-art {
|
||||
width: 34px;
|
||||
height: 37px;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--app-radius-md);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 17px;
|
||||
@@ -608,7 +304,7 @@ body {
|
||||
background: #fff0ed;
|
||||
}
|
||||
.art-two {
|
||||
background: #ecf5ff;
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
.art-three {
|
||||
background: #eaf6f7;
|
||||
@@ -661,7 +357,7 @@ body {
|
||||
padding: 10px 11px;
|
||||
}
|
||||
.drama-table th:first-child {
|
||||
border-radius: 5px 0 0 5px;
|
||||
border-radius: var(--app-radius-sm) 0 0 5px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.drama-table th:last-child {
|
||||
@@ -689,7 +385,7 @@ body {
|
||||
.drama-cover {
|
||||
width: 31px;
|
||||
height: 38px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 16px;
|
||||
@@ -700,7 +396,11 @@ body {
|
||||
background: linear-gradient(145deg, #f8d6d7, #ffece4);
|
||||
}
|
||||
.purple {
|
||||
background: linear-gradient(145deg, #d9ecff, #ecf5ff);
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--el-color-primary-light-8),
|
||||
var(--el-color-primary-light-9)
|
||||
);
|
||||
}
|
||||
.peach {
|
||||
background: linear-gradient(145deg, #ffd6bd, #fff0d7);
|
||||
@@ -713,7 +413,7 @@ body {
|
||||
}
|
||||
.row-action {
|
||||
font-size: 9px !important;
|
||||
color: #409eff !important;
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
.inner-heading {
|
||||
margin-bottom: 24px;
|
||||
@@ -744,11 +444,11 @@ body {
|
||||
}
|
||||
.library-toolbar .el-tabs__item.is-active,
|
||||
.cover-toolbar .el-tabs__item.is-active {
|
||||
color: #409eff;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.library-toolbar .el-tabs__active-bar,
|
||||
.cover-toolbar .el-tabs__active-bar {
|
||||
background: #409eff;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
.toolbar-filters {
|
||||
display: flex;
|
||||
@@ -795,7 +495,7 @@ body {
|
||||
}
|
||||
.pagination .el-pagination {
|
||||
--el-pagination-button-bg-color: #fff;
|
||||
--el-pagination-hover-color: #409eff;
|
||||
--el-pagination-hover-color: var(--el-color-primary);
|
||||
}
|
||||
.pagination .el-pager li {
|
||||
font-size: 10px;
|
||||
@@ -811,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;
|
||||
}
|
||||
@@ -818,34 +520,70 @@ body {
|
||||
width: 48px;
|
||||
height: 58px;
|
||||
font-size: 22px;
|
||||
border-radius: 6px;
|
||||
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;
|
||||
@@ -854,7 +592,7 @@ body {
|
||||
padding: 20px 0 14px;
|
||||
}
|
||||
.secondary-button {
|
||||
border-radius: 6px !important;
|
||||
border-radius: var(--app-radius-md) !important;
|
||||
border-color: #eae8f0 !important;
|
||||
color: #6e6c80 !important;
|
||||
font-size: 10px !important;
|
||||
@@ -866,7 +604,7 @@ body {
|
||||
.episode-row {
|
||||
height: 69px;
|
||||
border: 1px solid #efedf4;
|
||||
border-radius: 7px;
|
||||
border-radius: var(--app-radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -881,7 +619,7 @@ body {
|
||||
.episode-thumb {
|
||||
width: 62px;
|
||||
height: 45px;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: linear-gradient(135deg, #29273b, #55516e);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -919,9 +657,6 @@ body {
|
||||
padding: 5px;
|
||||
color: #9997a7;
|
||||
}
|
||||
.episode-actions .danger-action:hover {
|
||||
color: #e77680;
|
||||
}
|
||||
.episode-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -946,7 +681,7 @@ body {
|
||||
.add-cover {
|
||||
background: white;
|
||||
border: 1px solid #eeedf3;
|
||||
border-radius: 9px;
|
||||
border-radius: var(--app-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.poster-art {
|
||||
@@ -1023,7 +758,7 @@ body {
|
||||
border: 1px solid #ffffff8a;
|
||||
padding: 4px 6px;
|
||||
font-size: 8px;
|
||||
border-radius: 3px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
}
|
||||
.cover-card-info {
|
||||
padding: 10px;
|
||||
@@ -1063,8 +798,8 @@ body {
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
font-size: 17px;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
@@ -1096,7 +831,7 @@ body {
|
||||
width: 100%;
|
||||
border: 1px solid #efedf4;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--app-radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
@@ -1106,10 +841,10 @@ body {
|
||||
font: inherit;
|
||||
}
|
||||
.role-card:hover {
|
||||
border-color: #c6e2ff;
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
}
|
||||
.role-active {
|
||||
border-color: #a0cfff;
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
background: #f5faff;
|
||||
}
|
||||
.role-icon {
|
||||
@@ -1117,7 +852,7 @@ body {
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--app-radius-md);
|
||||
font-size: 15px;
|
||||
}
|
||||
.role-icon.red {
|
||||
@@ -1125,12 +860,12 @@ body {
|
||||
color: #e47b79;
|
||||
}
|
||||
.role-icon.purple {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.role-icon.blue {
|
||||
background: #edf5ff;
|
||||
color: #409eff;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.role-icon.green {
|
||||
background: #eaf8f0;
|
||||
@@ -1165,9 +900,9 @@ body {
|
||||
}
|
||||
.role-badge {
|
||||
font-size: 8px;
|
||||
color: #409eff;
|
||||
background: #ecf5ff;
|
||||
border-radius: 10px;
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-radius: var(--app-radius-md);
|
||||
padding: 4px 7px;
|
||||
vertical-align: 2px;
|
||||
margin-left: 5px;
|
||||
@@ -1193,7 +928,7 @@ body {
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
color: #409eff;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.permission-group .el-checkbox {
|
||||
height: auto;
|
||||
@@ -1204,13 +939,13 @@ body {
|
||||
color: #858397;
|
||||
}
|
||||
.permission-group .el-checkbox__input.is-checked .el-checkbox__inner {
|
||||
background: #409eff;
|
||||
border-color: #409eff;
|
||||
background: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
.permission-note {
|
||||
padding: 10px;
|
||||
background: #f8f7fc;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--app-radius-md);
|
||||
font-size: 8px;
|
||||
color: #9997a8;
|
||||
display: flex;
|
||||
@@ -1218,7 +953,7 @@ body {
|
||||
gap: 7px;
|
||||
}
|
||||
.permission-note .el-icon {
|
||||
color: #79bbff;
|
||||
color: var(--el-color-primary-light-3);
|
||||
}
|
||||
.placeholder-panel {
|
||||
text-align: center;
|
||||
@@ -1229,10 +964,10 @@ body {
|
||||
place-items: center;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--app-radius-lg);
|
||||
margin: auto;
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
font-size: 20px;
|
||||
}
|
||||
.placeholder-panel h2 {
|
||||
@@ -1244,7 +979,7 @@ body {
|
||||
color: #a09eae;
|
||||
}
|
||||
.el-dialog {
|
||||
border-radius: 11px !important;
|
||||
border-radius: var(--app-radius-md) !important;
|
||||
}
|
||||
.el-dialog__header {
|
||||
padding: 20px 22px 12px !important;
|
||||
@@ -1296,13 +1031,13 @@ body {
|
||||
.upload-area .el-upload-dragger {
|
||||
height: 112px;
|
||||
padding: 14px;
|
||||
border-radius: 7px;
|
||||
border-radius: var(--app-radius-md);
|
||||
border-color: #e8e5ef;
|
||||
background: #fcfbfe;
|
||||
}
|
||||
.upload-icon {
|
||||
font-size: 20px;
|
||||
color: #409eff;
|
||||
color: var(--el-color-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.upload-area .el-upload__text {
|
||||
@@ -1311,7 +1046,7 @@ body {
|
||||
}
|
||||
.upload-area .el-upload__text em {
|
||||
font-style: normal;
|
||||
color: #409eff;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.upload-hint {
|
||||
font-size: 8px;
|
||||
@@ -1325,10 +1060,6 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 1500px) {
|
||||
.page-content {
|
||||
padding-left: 48px;
|
||||
padding-right: 48px;
|
||||
}
|
||||
.stats-grid {
|
||||
gap: 18px;
|
||||
}
|
||||
@@ -1341,17 +1072,15 @@ body {
|
||||
}
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
.page-heading {
|
||||
gap: 14px;
|
||||
}
|
||||
.main-area {
|
||||
margin-left: 220px;
|
||||
}
|
||||
.page-content {
|
||||
padding: 30px 26px;
|
||||
}
|
||||
.topbar {
|
||||
padding: 0 26px;
|
||||
.library-toolbar,
|
||||
.admin-user-toolbar,
|
||||
.app-user-toolbar,
|
||||
.console-toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.stats-grid {
|
||||
gap: 10px;
|
||||
@@ -1367,3 +1096,28 @@ body {
|
||||
grid-template-columns: repeat(4, minmax(130px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-heading {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.page-heading h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
.panel {
|
||||
padding: 14px;
|
||||
}
|
||||
.library-toolbar > .el-input,
|
||||
.library-toolbar > .el-select,
|
||||
.admin-user-toolbar > .el-input,
|
||||
.admin-user-toolbar > .el-select,
|
||||
.app-user-toolbar > .el-input,
|
||||
.app-user-toolbar > .el-select {
|
||||
width: 100%;
|
||||
}
|
||||
.stats-grid,
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
+96
-125
@@ -1,14 +1,27 @@
|
||||
:root {
|
||||
font-family: Arial, 'Noto Sans SC', sans-serif;
|
||||
--font-display: 'Manrope', 'Noto Sans SC', sans-serif;
|
||||
--font-body: 'DM Sans', 'Noto Sans SC', sans-serif;
|
||||
--app-radius-sm: 4px;
|
||||
--app-radius-md: 6px;
|
||||
--app-radius-lg: 12px;
|
||||
--app-brand: #c4322a;
|
||||
--app-brand-pressed: #8e221c;
|
||||
--app-brand-soft: #ff8a72;
|
||||
--app-primary-strong: color-mix(in srgb, var(--el-color-primary) 72%, #000 28%);
|
||||
--brand-mark-size: 38px;
|
||||
--brand-mark-radius: var(--app-radius-lg);
|
||||
--el-border-radius-base: var(--app-radius-md);
|
||||
--el-border-radius-small: var(--app-radius-sm);
|
||||
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;
|
||||
@@ -17,82 +30,40 @@ body {
|
||||
background: var(--el-bg-color-page);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.sidebar {
|
||||
background: #fff;
|
||||
border-right-color: #e7e9ec;
|
||||
padding-top: 22px;
|
||||
.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);
|
||||
}
|
||||
.brand {
|
||||
padding-bottom: 23px;
|
||||
.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;
|
||||
}
|
||||
.brand-mark {
|
||||
background: #409eff;
|
||||
border-radius: 7px;
|
||||
transform: none;
|
||||
.el-button--danger:hover {
|
||||
background: var(--el-color-danger-dark-2) !important;
|
||||
border-color: var(--el-color-danger-dark-2) !important;
|
||||
}
|
||||
.brand b {
|
||||
font:
|
||||
700 16px Arial,
|
||||
sans-serif;
|
||||
letter-spacing: -0.4px;
|
||||
color: #29313b;
|
||||
.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;
|
||||
}
|
||||
.brand small {
|
||||
letter-spacing: 0.9px;
|
||||
}
|
||||
.workspace {
|
||||
border-radius: 5px;
|
||||
border-color: #e5e7eb;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.workspace-avatar,
|
||||
.profile-avatar,
|
||||
.top-avatar {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.nav-label {
|
||||
color: #9298a1;
|
||||
font-size: 10px;
|
||||
}
|
||||
.side-menu .el-menu-item {
|
||||
border-radius: 5px;
|
||||
color: #616873;
|
||||
}
|
||||
.side-menu .el-menu-item:hover {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
.side-menu .el-menu-item.is-active {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
.menu-count {
|
||||
background: #eef1f4;
|
||||
color: #747d88;
|
||||
}
|
||||
.help-card {
|
||||
background: #f7f8fa;
|
||||
border-color: #eceef0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.help-icon {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.help-card a {
|
||||
color: #409eff;
|
||||
}
|
||||
.topbar {
|
||||
background: #fff;
|
||||
}
|
||||
.environment {
|
||||
border-radius: 4px;
|
||||
}
|
||||
.page-content {
|
||||
padding-top: 30px;
|
||||
.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;
|
||||
@@ -110,14 +81,14 @@ body {
|
||||
color: #818791;
|
||||
}
|
||||
.primary-button {
|
||||
background: #409eff !important;
|
||||
border-color: #409eff !important;
|
||||
border-radius: 5px !important;
|
||||
background: var(--el-color-primary) !important;
|
||||
border-color: var(--el-color-primary) !important;
|
||||
border-radius: var(--app-radius-sm) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.primary-button:hover {
|
||||
background: #79bbff !important;
|
||||
border-color: #79bbff !important;
|
||||
background: var(--el-color-primary-dark-2) !important;
|
||||
border-color: var(--el-color-primary-dark-2) !important;
|
||||
}
|
||||
.stats-grid {
|
||||
gap: 12px;
|
||||
@@ -125,7 +96,7 @@ body {
|
||||
.stat-card,
|
||||
.panel {
|
||||
border-color: #e6e8eb;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--app-radius-md);
|
||||
box-shadow: none;
|
||||
}
|
||||
.stat-card {
|
||||
@@ -136,12 +107,12 @@ body {
|
||||
color: #727983;
|
||||
}
|
||||
.stat-icon {
|
||||
border-radius: 5px;
|
||||
background: #ecf5ff !important;
|
||||
color: #409eff !important;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: var(--el-color-primary-light-9) !important;
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
.stat-value {
|
||||
font-family: Arial, sans-serif;
|
||||
font-family: var(--font-display);
|
||||
color: #303640;
|
||||
}
|
||||
.stat-foot .positive {
|
||||
@@ -161,18 +132,18 @@ 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: #409eff;
|
||||
stroke: var(--el-color-primary);
|
||||
}
|
||||
.view-all,
|
||||
.row-action {
|
||||
color: #409eff !important;
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
.review-art {
|
||||
background: #f1f3f5 !important;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
font-size: 0;
|
||||
}
|
||||
.review-art:after {
|
||||
@@ -183,12 +154,12 @@ body {
|
||||
.review-row .el-tag,
|
||||
.drama-table .el-tag,
|
||||
.episode-row .el-tag {
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: #f0f2f4 !important;
|
||||
color: #606b78 !important;
|
||||
}
|
||||
.drama-cover {
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
box-shadow: none;
|
||||
font-size: 14px;
|
||||
background: #eceff2 !important;
|
||||
@@ -220,16 +191,16 @@ body {
|
||||
}
|
||||
.library-toolbar .el-tabs__item.is-active,
|
||||
.cover-toolbar .el-tabs__item.is-active {
|
||||
color: #409eff;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.library-toolbar .el-tabs__active-bar,
|
||||
.cover-toolbar .el-tabs__active-bar {
|
||||
background: #409eff;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
.toolbar-filters .el-input__wrapper,
|
||||
.toolbar-filters .el-select__wrapper,
|
||||
.drama-select .el-input__wrapper {
|
||||
border-radius: 5px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
}
|
||||
.episode-panel {
|
||||
padding-left: 20px;
|
||||
@@ -237,10 +208,10 @@ body {
|
||||
}
|
||||
.episode-row {
|
||||
border-color: #e7e9ec;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
}
|
||||
.episode-thumb {
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: #e9ecef;
|
||||
color: #7d8792;
|
||||
}
|
||||
@@ -256,7 +227,7 @@ body {
|
||||
.cover-card,
|
||||
.add-cover {
|
||||
border-color: #e6e8eb;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
}
|
||||
.poster-art {
|
||||
background: #e9ecef !important;
|
||||
@@ -296,61 +267,61 @@ body {
|
||||
color: #656d77;
|
||||
}
|
||||
.add-cover > span {
|
||||
border-radius: 5px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: #edf0f3;
|
||||
color: #68798d;
|
||||
}
|
||||
.role-card {
|
||||
border-radius: 5px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
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: 5px;
|
||||
background: #ecf5ff !important;
|
||||
color: #409eff !important;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: var(--el-color-primary-light-9) !important;
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
.role-badge {
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: #eef1f4;
|
||||
color: #65758a;
|
||||
}
|
||||
.permission-group h3 .el-icon {
|
||||
color: #409eff;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.permission-group .el-checkbox__input.is-checked .el-checkbox__inner {
|
||||
background: #409eff;
|
||||
border-color: #409eff;
|
||||
background: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
.permission-note {
|
||||
background: #f6f7f8;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
}
|
||||
.el-dialog {
|
||||
border-radius: 6px !important;
|
||||
border-radius: var(--app-radius-md) !important;
|
||||
}
|
||||
.el-dialog__header,
|
||||
.el-dialog__footer {
|
||||
border-color: #e8eaed;
|
||||
}
|
||||
.upload-area .el-upload-dragger {
|
||||
border-radius: 5px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
background: #fafbfc;
|
||||
}
|
||||
.upload-icon,
|
||||
.upload-area .el-upload__text em {
|
||||
color: #409eff;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.el-tabs__item,
|
||||
.el-input__inner,
|
||||
.el-select__placeholder {
|
||||
font-family: Arial, 'Noto Sans SC', sans-serif;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
.el-tag.status-badge {
|
||||
border-radius: 4px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.el-tag.status-badge.is-online {
|
||||
@@ -378,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;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
flex: 0 0 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 5px;
|
||||
border-radius: var(--app-radius-sm);
|
||||
}
|
||||
.stat-top > .stat-icon .el-icon {
|
||||
font-size: 16px;
|
||||
|
||||
+10
-7
@@ -6,17 +6,17 @@
|
||||
|
||||
--left-menu-min-width: 64px;
|
||||
|
||||
--left-menu-bg-color: #001529;
|
||||
--left-menu-bg-color: #17110f;
|
||||
|
||||
--left-menu-bg-light-color: #0f2438;
|
||||
--left-menu-bg-light-color: #221815;
|
||||
|
||||
--left-menu-bg-active-color: var(--el-color-primary);
|
||||
--left-menu-bg-active-color: var(--app-primary-strong);
|
||||
|
||||
--left-menu-text-color: #bfcbd9;
|
||||
--left-menu-text-color: #d7cbc4;
|
||||
|
||||
--left-menu-text-active-color: #fff;
|
||||
|
||||
--left-menu-collapse-bg-active-color: var(--el-color-primary);
|
||||
--left-menu-collapse-bg-active-color: var(--app-primary-strong);
|
||||
/* left menu end */
|
||||
|
||||
/* logo start */
|
||||
@@ -26,9 +26,9 @@
|
||||
/* logo end */
|
||||
|
||||
/* header start */
|
||||
--top-header-bg-color: '#fff';
|
||||
--top-header-bg-color: #fff;
|
||||
|
||||
--top-header-text-color: 'inherit';
|
||||
--top-header-text-color: #303133;
|
||||
|
||||
--top-header-hover-color: #f6f6f6;
|
||||
|
||||
@@ -58,4 +58,7 @@
|
||||
|
||||
.dark {
|
||||
--app-content-bg-color: var(--el-bg-color);
|
||||
--top-header-bg-color: var(--el-bg-color-overlay);
|
||||
--top-header-text-color: var(--el-text-color-primary);
|
||||
--top-header-hover-color: var(--el-fill-color);
|
||||
}
|
||||
@@ -11,17 +11,22 @@ import { ref } from 'vue'
|
||||
*/
|
||||
const STORAGE_KEY = 'creator-center:impersonate-id'
|
||||
|
||||
function readStored(): number | null {
|
||||
/**
|
||||
* 代看目标按**字符串**存:创作者 id 是雪花 id(19 位),
|
||||
* 用 Number 过一遍会被 JS 四舍五入,请求头就会带一个不存在的 id(见 api-phase1.md §0)。
|
||||
*/
|
||||
function readStored(): string | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
const value = raw ? Number(raw) : NaN
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
const value = (raw || '').trim()
|
||||
return value && value !== '0' ? value : null
|
||||
}
|
||||
|
||||
export const impersonateCreatorId = ref<number | null>(readStored())
|
||||
export const impersonateCreatorId = ref<string | null>(readStored())
|
||||
|
||||
export function setImpersonateCreatorId(id: number | null) {
|
||||
impersonateCreatorId.value = id
|
||||
if (id) localStorage.setItem(STORAGE_KEY, String(id))
|
||||
export function setImpersonateCreatorId(id: string | number | null) {
|
||||
const value = id === null || id === undefined || id === '' ? null : String(id)
|
||||
impersonateCreatorId.value = value
|
||||
if (value) localStorage.setItem(STORAGE_KEY, value)
|
||||
else localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
+61
-75
@@ -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'))
|
||||
|
||||
@@ -29,18 +28,12 @@ const themeChange = () => {
|
||||
<div class="login-shell">
|
||||
<section class="login-hero">
|
||||
<div class="hero-brand">
|
||||
<img src="@/assets/imgs/logo.png" alt="" />
|
||||
<img src="@/assets/imgs/brand-mark.png" alt="" />
|
||||
<span>{{ underlineToHump(appStore.getTitle) }}</span>
|
||||
</div>
|
||||
<div class="hero-copy">
|
||||
<p class="hero-kicker">FruitShorts Studio</p>
|
||||
<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 +43,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>
|
||||
@@ -63,7 +55,7 @@ const themeChange = () => {
|
||||
.login-page {
|
||||
height: 100%;
|
||||
color: var(--el-text-color-primary);
|
||||
background: #eef3f9;
|
||||
background: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
.login-scroll {
|
||||
@@ -82,9 +74,9 @@ const themeChange = () => {
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(circle at 18% 18%, rgb(255 255 255 / 16%), transparent 28%),
|
||||
radial-gradient(circle at 82% 78%, rgb(64 158 255 / 45%), transparent 32%),
|
||||
linear-gradient(155deg, #0d2344 0%, #1a4f96 52%, #409eff 100%);
|
||||
radial-gradient(circle at 18% 18%, rgb(255 255 255 / 10%), transparent 28%),
|
||||
radial-gradient(circle at 82% 78%, rgb(196 50 42 / 34%), transparent 34%),
|
||||
linear-gradient(155deg, #17100f 0%, #241714 55%, #3b1d19 100%);
|
||||
flex: 1.05;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
@@ -111,8 +103,10 @@ const themeChange = () => {
|
||||
font-weight: 700;
|
||||
|
||||
img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
width: var(--brand-mark-size);
|
||||
height: var(--brand-mark-size);
|
||||
border-radius: var(--brand-mark-radius);
|
||||
box-shadow: 0 10px 24px rgb(196 50 42 / 28%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,38 +122,14 @@ const themeChange = () => {
|
||||
line-height: 1.2;
|
||||
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 {
|
||||
@@ -169,7 +139,7 @@ const themeChange = () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 88px 32px 48px;
|
||||
background: #f4f7fb;
|
||||
background: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
.login-tools {
|
||||
@@ -184,28 +154,28 @@ const themeChange = () => {
|
||||
.login-card {
|
||||
width: min(420px, 100%);
|
||||
padding: 36px 32px 28px;
|
||||
background: #fff;
|
||||
border: 1px solid #e6edf5;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 18px 50px rgb(15 39 68 / 8%);
|
||||
box-shadow: 0 18px 50px rgb(42 24 16 / 10%);
|
||||
}
|
||||
|
||||
.is-dark {
|
||||
background: #0e1116;
|
||||
background: var(--el-bg-color-page);
|
||||
|
||||
.login-hero {
|
||||
background:
|
||||
radial-gradient(circle at 18% 18%, rgb(64 158 255 / 18%), transparent 30%),
|
||||
linear-gradient(155deg, #07111f 0%, #0e2744 58%, #163e73 100%);
|
||||
radial-gradient(circle at 18% 18%, rgb(255 255 255 / 8%), transparent 30%),
|
||||
linear-gradient(155deg, #0e0a09 0%, #1a1110 58%, #2c1513 100%);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
background: #0e1116;
|
||||
background: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: #161b22;
|
||||
border-color: #2a313c;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border-color: var(--el-border-color);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
@@ -217,16 +187,38 @@ const themeChange = () => {
|
||||
|
||||
.login-hero {
|
||||
flex: none;
|
||||
min-height: 280px;
|
||||
padding: 28px 24px 32px;
|
||||
min-height: 132px;
|
||||
padding: 16px 24px 20px;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
font-size: 30px;
|
||||
margin: 6px 0 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
padding: 72px 16px 32px;
|
||||
padding: 44px 16px 12px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding-top: 28px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-tools {
|
||||
top: 8px;
|
||||
right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.login-hero {
|
||||
min-height: 118px;
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 28px 20px 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -250,23 +242,17 @@ 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;
|
||||
.login-card :deep(.el-button--primary) {
|
||||
--el-button-bg-color: var(--app-brand);
|
||||
--el-button-border-color: var(--app-brand);
|
||||
--el-button-hover-bg-color: var(--app-brand-pressed);
|
||||
--el-button-hover-border-color: var(--app-brand-pressed);
|
||||
--el-button-active-bg-color: var(--app-brand-pressed);
|
||||
--el-button-active-border-color: var(--app-brand-pressed);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
.login-card :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px var(--app-brand) inset;
|
||||
}
|
||||
|
||||
body:has(.login-page) {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
import { reactive, ref, watch, onMounted, unref } from 'vue'
|
||||
import { Form, FormSchema } from '@/components/Form'
|
||||
import { useI18n } from '@/hooks/web/useI18n'
|
||||
import { ElCheckbox, ElLink } from 'element-plus'
|
||||
import { ElCheckbox, ElLink, ElMessage } from 'element-plus'
|
||||
import { useForm } from '@/hooks/web/useForm'
|
||||
import { loginApi, getTestRoleApi, getAdminRoleApi } from '@/api/login'
|
||||
import { loginApi, getAdminRoleApi } from '@/api/login'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { usePermissionStore } from '@/store/modules/permission'
|
||||
import { landingPath } from '@/utils/landing'
|
||||
@@ -19,8 +19,6 @@ import { BaseButton } from '@/components/Button'
|
||||
|
||||
const { required } = useValidator()
|
||||
|
||||
const emit = defineEmits(['to-register'])
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const userStore = useUserStore()
|
||||
@@ -64,7 +62,7 @@ const schema = reactive<FormSchema[]>([
|
||||
span: 24
|
||||
},
|
||||
componentProps: {
|
||||
placeholder: 'admin'
|
||||
placeholder: t('login.usernamePlaceholder')
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -79,7 +77,7 @@ const schema = reactive<FormSchema[]>([
|
||||
style: {
|
||||
width: '100%'
|
||||
},
|
||||
placeholder: 'admin123'
|
||||
placeholder: t('login.passwordPlaceholder')
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -94,7 +92,11 @@ const schema = reactive<FormSchema[]>([
|
||||
<>
|
||||
<div class="flex justify-between items-center w-[100%]">
|
||||
<ElCheckbox v-model={remember.value} label={t('login.remember')} size="small" />
|
||||
<ElLink type="primary" underline={false}>
|
||||
<ElLink
|
||||
type="primary"
|
||||
underline={false}
|
||||
onClick={() => ElMessage.info(t('login.forgetPasswordHint'))}
|
||||
>
|
||||
{t('login.forgetPassword')}
|
||||
</ElLink>
|
||||
</div>
|
||||
@@ -113,24 +115,11 @@ const schema = reactive<FormSchema[]>([
|
||||
slots: {
|
||||
default: () => {
|
||||
return (
|
||||
<>
|
||||
<div class="w-[100%]">
|
||||
<BaseButton
|
||||
loading={loading.value}
|
||||
type="primary"
|
||||
class="w-[100%]"
|
||||
onClick={signIn}
|
||||
>
|
||||
{t('login.login')}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="login-register">
|
||||
<span>{t('login.noAccount')}</span>
|
||||
<button type="button" onClick={toRegister}>
|
||||
{t('login.register')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
<div class="w-[100%]">
|
||||
<BaseButton loading={loading.value} type="primary" class="w-[100%]" onClick={signIn}>
|
||||
{t('login.login')}
|
||||
</BaseButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -143,8 +132,7 @@ const remember = ref(userStore.getRememberMe)
|
||||
const initLoginInfo = () => {
|
||||
const loginInfo = userStore.getLoginInfo
|
||||
if (loginInfo) {
|
||||
const { username, password } = loginInfo
|
||||
setValues({ username, password })
|
||||
setValues({ username: loginInfo.username })
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
@@ -170,6 +158,7 @@ watch(
|
||||
|
||||
// 登录
|
||||
const signIn = async () => {
|
||||
if (loading.value) return
|
||||
const formRef = await getElFormExpose()
|
||||
await formRef?.validate(async (isValid) => {
|
||||
if (isValid) {
|
||||
@@ -183,8 +172,7 @@ const signIn = async () => {
|
||||
// 是否记住我
|
||||
if (unref(remember)) {
|
||||
userStore.setLoginInfo({
|
||||
username: formData.username,
|
||||
password: formData.password
|
||||
username: formData.username
|
||||
})
|
||||
} else {
|
||||
userStore.setLoginInfo(undefined)
|
||||
@@ -196,7 +184,7 @@ const signIn = async () => {
|
||||
}
|
||||
// 是否使用动态路由
|
||||
if (appStore.getDynamicRouter) {
|
||||
getRole()
|
||||
await getRole()
|
||||
} else {
|
||||
await permissionStore.generateRoutes('static').catch(() => {})
|
||||
permissionStore.getAddRouters.forEach((route) => {
|
||||
@@ -219,16 +207,11 @@ const getRole = async () => {
|
||||
const params = {
|
||||
roleName: formData.username
|
||||
}
|
||||
const res =
|
||||
appStore.getDynamicRouter && appStore.getServerDynamicRouter
|
||||
? await getAdminRoleApi(params)
|
||||
: await getTestRoleApi(params)
|
||||
const res = await getAdminRoleApi(params)
|
||||
if (res) {
|
||||
const routers = res.data || []
|
||||
userStore.setRoleRouters(routers)
|
||||
appStore.getDynamicRouter && appStore.getServerDynamicRouter
|
||||
? await permissionStore.generateRoutes('server', routers).catch(() => {})
|
||||
: await permissionStore.generateRoutes('frontEnd', routers).catch(() => {})
|
||||
await permissionStore.generateRoutes('server', routers).catch(() => {})
|
||||
|
||||
permissionStore.getAddRouters.forEach((route) => {
|
||||
addRoute(route as RouteRecordRaw) // 动态添加可访问路由表
|
||||
@@ -239,11 +222,6 @@ const getRole = async () => {
|
||||
push({ path: redirect.value || landingPath() })
|
||||
}
|
||||
}
|
||||
|
||||
// 去注册页面
|
||||
const toRegister = () => {
|
||||
emit('to-register')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -254,6 +232,7 @@ const toRegister = () => {
|
||||
hide-required-asterisk
|
||||
size="large"
|
||||
class="login-form"
|
||||
@keyup.enter="signIn"
|
||||
@register="formRegister"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,172 +0,0 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<script setup lang="tsx">
|
||||
import { Form, FormSchema } from '@/components/Form'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useI18n } from '@/hooks/web/useI18n'
|
||||
import { useForm } from '@/hooks/web/useForm'
|
||||
import { ElButton, ElInput, FormRules } from 'element-plus'
|
||||
import { useValidator } from '@/hooks/web/useValidator'
|
||||
|
||||
const emit = defineEmits(['to-login'])
|
||||
|
||||
const { formRegister, formMethods } = useForm()
|
||||
const { getElFormExpose } = formMethods
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { required } = useValidator()
|
||||
|
||||
const schema = reactive<FormSchema[]>([
|
||||
{
|
||||
field: 'title',
|
||||
colProps: {
|
||||
span: 24
|
||||
},
|
||||
formItemProps: {
|
||||
slots: {
|
||||
default: () => {
|
||||
return (
|
||||
<div class="login-heading">
|
||||
<h2>{t('login.register')}</h2>
|
||||
<p>{t('login.message')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'username',
|
||||
label: t('login.username'),
|
||||
value: '',
|
||||
component: 'Input',
|
||||
colProps: {
|
||||
span: 24
|
||||
},
|
||||
componentProps: {
|
||||
placeholder: t('login.usernamePlaceholder')
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'password',
|
||||
label: t('login.password'),
|
||||
value: '',
|
||||
component: 'InputPassword',
|
||||
colProps: {
|
||||
span: 24
|
||||
},
|
||||
componentProps: {
|
||||
style: {
|
||||
width: '100%'
|
||||
},
|
||||
strength: true,
|
||||
placeholder: t('login.passwordPlaceholder')
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'check_password',
|
||||
label: t('login.checkPassword'),
|
||||
value: '',
|
||||
component: 'InputPassword',
|
||||
colProps: {
|
||||
span: 24
|
||||
},
|
||||
componentProps: {
|
||||
style: {
|
||||
width: '100%'
|
||||
},
|
||||
strength: true,
|
||||
placeholder: t('login.passwordPlaceholder')
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'code',
|
||||
label: t('login.code'),
|
||||
colProps: {
|
||||
span: 24
|
||||
},
|
||||
formItemProps: {
|
||||
slots: {
|
||||
default: (formData) => {
|
||||
return (
|
||||
<div class="w-[100%] flex">
|
||||
<ElInput v-model={formData.code} placeholder={t('login.codePlaceholder')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'register',
|
||||
colProps: {
|
||||
span: 24
|
||||
},
|
||||
formItemProps: {
|
||||
slots: {
|
||||
default: () => {
|
||||
return (
|
||||
<>
|
||||
<div class="w-[100%]">
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="w-[100%]"
|
||||
loading={loading.value}
|
||||
onClick={loginRegister}
|
||||
>
|
||||
{t('login.register')}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div class="w-[100%] mt-15px">
|
||||
<ElButton class="w-[100%]" onClick={toLogin}>
|
||||
{t('login.hasUser')}
|
||||
</ElButton>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [required()],
|
||||
password: [required()],
|
||||
check_password: [required()],
|
||||
code: [required()]
|
||||
}
|
||||
|
||||
const toLogin = () => {
|
||||
emit('to-login')
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const loginRegister = async () => {
|
||||
const formRef = await getElFormExpose()
|
||||
formRef?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
loading.value = true
|
||||
toLogin()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form
|
||||
:schema="schema"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
hide-required-asterisk
|
||||
size="large"
|
||||
class="login-form"
|
||||
@register="formRegister"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,6 +1,5 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import LoginForm from './LoginForm.vue'
|
||||
import RegisterForm from './RegisterForm.vue'
|
||||
|
||||
export { LoginForm, RegisterForm }
|
||||
export { LoginForm }
|
||||
@@ -102,6 +102,7 @@ async function refund(row) {
|
||||
{
|
||||
confirmButtonText: '确认退款',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
inputValidator: (val) => (val && val.trim() ? true : '退款原因不能为空')
|
||||
}
|
||||
)
|
||||
@@ -375,8 +376,8 @@ function onTabChange(name) {
|
||||
>
|
||||
<el-button
|
||||
v-if="canOrderUpdate && (row.status === 'PAID' || row.status === 'DELIVERED')"
|
||||
type="danger"
|
||||
size="small"
|
||||
class="danger-action"
|
||||
@click="refund(row)"
|
||||
>退款</el-button
|
||||
>
|
||||
@@ -461,8 +462,8 @@ function onTabChange(name) {
|
||||
<div class="table-actions">
|
||||
<el-button
|
||||
v-if="canEntitlementUpdate && row.status === 'ACTIVE'"
|
||||
type="danger"
|
||||
size="small"
|
||||
class="danger-action"
|
||||
@click="revokeEntitlement(row)"
|
||||
>回收</el-button
|
||||
>
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { AppIcons } from '@/constants/icons'
|
||||
import {
|
||||
createProductApi,
|
||||
deleteProductApi,
|
||||
pageProductsApi,
|
||||
updateProductApi,
|
||||
updateProductStatusApi
|
||||
} from '@/api/admin/product'
|
||||
import { useRbac } from '@/stores/rbac'
|
||||
import { dateCell, formatCount } from '@/utils/format'
|
||||
import PageHeading from '@/views/components/PageHeading.vue'
|
||||
|
||||
const { can } = useRbac()
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const pageIndex = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const typeFilter = ref(undefined)
|
||||
const statusFilter = ref(undefined)
|
||||
const platformFilter = ref(undefined)
|
||||
const keyword = ref('')
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref(null)
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
type: 'COIN_PACK',
|
||||
platform: 'ALL',
|
||||
storeProductId: '',
|
||||
coinAmount: 0,
|
||||
bonusCoin: 0,
|
||||
priceAmount: 0,
|
||||
currency: 'IDR',
|
||||
displayPrice: '',
|
||||
regionCodes: 'ID',
|
||||
vipDays: 0,
|
||||
sort: 0,
|
||||
effectiveAt: null,
|
||||
expireAt: null
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'COIN_PACK', label: '金币包' },
|
||||
{ value: 'VIP', label: '会员(第二阶段)' }
|
||||
]
|
||||
|
||||
const platformOptions = [
|
||||
{ value: 'ALL', label: '全平台通用' },
|
||||
{ value: 'GOOGLE', label: 'Google Play' },
|
||||
{ value: 'APPLE', label: 'App Store' }
|
||||
]
|
||||
|
||||
const currencyOptions = ['IDR', 'USD']
|
||||
|
||||
function platformLabel(value) {
|
||||
return platformOptions.find((item) => item.value === value)?.label || value || '-'
|
||||
}
|
||||
|
||||
function priceText(row) {
|
||||
if (row.displayPrice) return row.displayPrice
|
||||
if (row.priceAmount === null || row.priceAmount === undefined) return '-'
|
||||
return `${row.priceAmount} ${row.currency || ''}`.trim()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pageProductsApi({
|
||||
pageIndex: pageIndex.value,
|
||||
pageSize: pageSize.value,
|
||||
type: typeFilter.value,
|
||||
status: statusFilter.value,
|
||||
platform: platformFilter.value,
|
||||
keyword: keyword.value.trim() || undefined
|
||||
})
|
||||
list.value = res?.data?.list || []
|
||||
total.value = res?.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
pageIndex.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
typeFilter.value = undefined
|
||||
statusFilter.value = undefined
|
||||
platformFilter.value = undefined
|
||||
keyword.value = ''
|
||||
search()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
pageIndex.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function nextSort() {
|
||||
return list.value.reduce((max, item) => Math.max(max, item.sort || 0), 0) + 1
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
Object.assign(form, {
|
||||
code: '',
|
||||
name: '',
|
||||
type: 'COIN_PACK',
|
||||
platform: 'ALL',
|
||||
storeProductId: '',
|
||||
coinAmount: 0,
|
||||
bonusCoin: 0,
|
||||
priceAmount: 0,
|
||||
currency: 'IDR',
|
||||
displayPrice: '',
|
||||
regionCodes: 'ID',
|
||||
vipDays: 0,
|
||||
sort: nextSort(),
|
||||
effectiveAt: null,
|
||||
expireAt: null
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
platform: row.platform,
|
||||
storeProductId: row.storeProductId || '',
|
||||
coinAmount: row.coinAmount ?? 0,
|
||||
bonusCoin: row.bonusCoin ?? 0,
|
||||
priceAmount: row.priceAmount ?? 0,
|
||||
currency: row.currency || 'IDR',
|
||||
displayPrice: row.displayPrice || '',
|
||||
regionCodes: row.regionCodes || '',
|
||||
vipDays: row.vipDays ?? 0,
|
||||
sort: row.sort ?? 0,
|
||||
effectiveAt: row.effectiveAt || null,
|
||||
expireAt: row.expireAt || null
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
if (!form.code.trim()) return '请填写商品编码'
|
||||
if (!form.name.trim()) return '请填写商品名称'
|
||||
if (form.storeProductId.trim() && !['GOOGLE', 'APPLE'].includes(form.platform)) {
|
||||
return '填写了商店商品 id 时,平台要选 Google Play 或 App Store,否则客户端认不出来'
|
||||
}
|
||||
if (form.type === 'VIP') {
|
||||
if (!form.vipDays || form.vipDays <= 0) return '会员商品的会员天数必须大于 0'
|
||||
} else if (!form.coinAmount || form.coinAmount <= 0) {
|
||||
return '金币包的金币数量必须大于 0'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function saveProduct() {
|
||||
const invalid = validateForm()
|
||||
if (invalid) return ElMessage.warning(invalid)
|
||||
|
||||
const payload = {
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
platform: form.platform,
|
||||
storeProductId: form.storeProductId.trim(),
|
||||
coinAmount: form.coinAmount || 0,
|
||||
bonusCoin: form.bonusCoin || 0,
|
||||
priceAmount: form.priceAmount || 0,
|
||||
currency: form.currency,
|
||||
displayPrice: form.displayPrice.trim(),
|
||||
regionCodes: form.regionCodes.trim(),
|
||||
vipDays: form.vipDays || 0,
|
||||
sort: form.sort || 0,
|
||||
effectiveAt: form.effectiveAt || null,
|
||||
expireAt: form.expireAt || null
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateProductApi(editingId.value, payload)
|
||||
ElMessage.success('商品已更新')
|
||||
} else {
|
||||
await createProductApi(payload)
|
||||
ElMessage.success('商品已创建(默认下架,请确认后再上架)')
|
||||
pageIndex.value = 1
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row) {
|
||||
const next = row.status === 1 ? 0 : 1
|
||||
await updateProductStatusApi(row.id, next)
|
||||
await load()
|
||||
}
|
||||
|
||||
async function confirmDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除商品「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await deleteProductApi(row.id)
|
||||
ElMessage.success('商品已删除')
|
||||
if (list.value.length === 1 && pageIndex.value > 1) pageIndex.value -= 1
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeading
|
||||
eyebrow="订单与权益"
|
||||
title="商品配置"
|
||||
description="维护金币包与商店商品 id,客户端按商店商品 id 对齐发币口径。"
|
||||
>
|
||||
<el-button
|
||||
v-if="can('product:update')"
|
||||
type="primary"
|
||||
class="primary-button"
|
||||
@click="openCreate"
|
||||
>
|
||||
<AppIcon :icon="AppIcons.add" />
|
||||
新建商品
|
||||
</el-button>
|
||||
</PageHeading>
|
||||
|
||||
<section class="panel library-panel">
|
||||
<div class="library-toolbar">
|
||||
<div class="drama-filters">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="商品编码 / 名称"
|
||||
@keyup.enter="search"
|
||||
@clear="search"
|
||||
/>
|
||||
<el-select v-model="typeFilter" clearable placeholder="全部类型" @change="search">
|
||||
<el-option
|
||||
v-for="item in typeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="platformFilter" clearable placeholder="全部平台" @change="search">
|
||||
<el-option
|
||||
v-for="item in platformOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="statusFilter" clearable placeholder="全部状态" @change="search">
|
||||
<el-option label="已上架" :value="1" />
|
||||
<el-option label="已下架" :value="0" />
|
||||
</el-select>
|
||||
<el-button @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
<span class="table-result-count">共 {{ total }} 个商品</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
row-key="id"
|
||||
empty-text="还没有配置商品"
|
||||
class="drama-el-table"
|
||||
>
|
||||
<el-table-column prop="code" label="商品编码" min-width="150" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" />
|
||||
<el-table-column prop="typeText" label="类型" width="140" />
|
||||
<el-table-column label="平台" width="130">
|
||||
<template #default="{ row }">{{ platformLabel(row.platform) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商店商品 id" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.storeProductId">{{ row.storeProductId }}</span>
|
||||
<span v-else class="table-empty-text">未配置</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到账金币" width="110">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.type === 'COIN_PACK'">{{ formatCount(row.totalCoin) }}</span>
|
||||
<span v-else class="table-empty-text">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" width="140">
|
||||
<template #default="{ row }">{{ priceText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="80" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="can('product:update')"
|
||||
link
|
||||
:type="row.status === 1 ? 'primary' : 'info'"
|
||||
@click="toggleStatus(row)"
|
||||
>{{ row.statusText }}</el-button
|
||||
>
|
||||
<el-tag v-else :type="row.status === 1 ? 'success' : 'info'" effect="light">{{
|
||||
row.statusText
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="170" :formatter="dateCell" />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button
|
||||
v-if="can('product:update')"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="openEdit(row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="can('product:update')"
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="confirmDelete(row)"
|
||||
>删除</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pageIndex"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[5, 10, 20, 50]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@current-change="load"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑商品' : '新建商品'" width="720px">
|
||||
<el-form label-position="top">
|
||||
<div class="form-row">
|
||||
<el-form-item label="商品编码" required>
|
||||
<el-input v-model="form.code" placeholder="全局唯一,例如 coin_500_id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品名称" required>
|
||||
<el-input v-model="form.name" placeholder="展示给用户的名字" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="form.type">
|
||||
<el-option
|
||||
v-for="item in typeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="平台">
|
||||
<el-select v-model="form.platform">
|
||||
<el-option
|
||||
v-for="item in platformOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="商店商品 id">
|
||||
<el-input v-model="form.storeProductId" placeholder="Google Play / App Store 的商品 id" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<el-form-item v-if="form.type === 'COIN_PACK'" label="金币数量" required>
|
||||
<el-input-number v-model="form.coinAmount" :min="0" :max="1000000" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.type === 'COIN_PACK'" label="赠送金币">
|
||||
<el-input-number v-model="form.bonusCoin" :min="0" :max="1000000" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.type === 'VIP'" label="会员天数" required>
|
||||
<el-input-number v-model="form.vipDays" :min="0" :max="3650" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<el-form-item label="价格">
|
||||
<el-input-number v-model="form.priceAmount" :min="0" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="币种">
|
||||
<el-select v-model="form.currency">
|
||||
<el-option v-for="item in currencyOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="展示价">
|
||||
<el-input v-model="form.displayPrice" placeholder="例如 Rp 9.900" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<el-form-item label="生效区域">
|
||||
<el-input v-model="form.regionCodes" placeholder="多个区域用逗号分隔,例如 ID,MY" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<el-form-item label="生效时间">
|
||||
<el-date-picker
|
||||
v-model="form.effectiveAt"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
placeholder="留空表示立即生效"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="失效时间">
|
||||
<el-date-picker
|
||||
v-model="form.expireAt"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
placeholder="留空表示长期有效"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<p class="console-hint">
|
||||
新建商品默认下架,确认无误后再上架;会员商品可以建档,但订阅功能上线前不能上架。
|
||||
</p>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveProduct">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user