Compare commits
255 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a4af66683 | ||
|
|
bb53a181a9 | ||
|
|
c5f7924484 | ||
|
|
cf5b95ec04 | ||
|
|
b62e8dc1a6 | ||
|
|
eb3b7b63b6 | ||
|
|
309ce3c92e | ||
|
|
ce0b0d6330 | ||
|
|
2a01a2ffe0 | ||
|
|
6ca606a864 | ||
|
|
73840e3480 | ||
|
|
98a9f87768 | ||
|
|
dd6f644f05 | ||
|
|
6db19ee6f7 | ||
|
|
d1d46353dd | ||
|
|
cfd08059a4 | ||
|
|
278a7587c1 | ||
|
|
b2c4a69173 | ||
|
|
a98271f8a1 | ||
|
|
eabe8f6951 | ||
|
|
e6b26cc831 | ||
|
|
e242b881de | ||
|
|
e23d9a6f41 | ||
|
|
f21274ebca | ||
|
|
139721d62a | ||
|
|
6bf9cf9d18 | ||
|
|
f080da1ebb | ||
|
|
4b70b98ffc | ||
|
|
fe442fda4b | ||
|
|
ede4fe94ce | ||
|
|
20fcd699f2 | ||
|
|
009ce6173b | ||
|
|
b9194ffb74 | ||
|
|
942a44cde6 | ||
|
|
3bf52231f7 | ||
|
|
109a0b1af5 | ||
|
|
777c4ec7e3 | ||
|
|
1064156062 | ||
|
|
693e6fb352 | ||
|
|
70bb6e1040 | ||
|
|
02e3314853 | ||
|
|
d48ee55bab | ||
|
|
d72bb75996 | ||
|
|
95674fa47a | ||
|
|
6afa2bdc8a | ||
|
|
7ea14bc919 | ||
|
|
1dfb51f3c4 | ||
|
|
871a9a072b | ||
|
|
0eb81b4ecb | ||
|
|
4814f53c4e | ||
|
|
0703a99e41 | ||
|
|
21b485ca84 | ||
|
|
fc6313b79e | ||
|
|
8badfb0da9 | ||
|
|
bfd61cd479 | ||
|
|
ac29818fd3 | ||
|
|
8d18f82e68 | ||
|
|
fd1bf8df7f | ||
|
|
6fd259427e | ||
|
|
0045ecf5d9 | ||
|
|
50fa751eb5 | ||
|
|
aa6dfd8569 | ||
|
|
b5ee946919 | ||
|
|
6a21bb55c3 | ||
|
|
737d464950 | ||
|
|
2c467335fa | ||
|
|
24f1580501 | ||
|
|
1129d63fcc | ||
|
|
b1f3d3eacb | ||
|
|
487e567d4c | ||
|
|
872d46132e | ||
|
|
c65b79f4b8 | ||
|
|
09bbeda108 | ||
|
|
afc95ad990 | ||
|
|
a545f88b3c | ||
|
|
084b950fec | ||
|
|
2728896a1e | ||
|
|
b4afba99bd | ||
|
|
df1e72e871 | ||
|
|
7e46428721 | ||
|
|
14a4d9dc9c | ||
|
|
3ac798ac5e | ||
|
|
e3ee714732 | ||
|
|
41d5f579f7 | ||
|
|
102abf7df9 | ||
|
|
47b9633a86 | ||
|
|
e2902adba8 | ||
|
|
cd993e3cf4 | ||
|
|
4097fd74dd | ||
|
|
37e645ed9c | ||
|
|
fa254499bc | ||
|
|
be8b20da8d | ||
|
|
cef44692a8 | ||
|
|
a627d63107 | ||
|
|
b13674d0e7 | ||
|
|
9a533cd103 | ||
|
|
1298a5f72c | ||
|
|
b2c2cacc4c | ||
|
|
d27b402b74 | ||
|
|
5ca6c520ae | ||
|
|
aa63782645 | ||
|
|
a7df9c432e | ||
|
|
422aba8d0e | ||
|
|
245a1f8a5c | ||
|
|
777e30d137 | ||
|
|
ed5d955ae7 | ||
|
|
bcc5543f4c | ||
|
|
eedf038a70 | ||
|
|
88f81ecce4 | ||
|
|
9e35378ab4 | ||
|
|
36b901f717 | ||
|
|
cfbbd89b1b | ||
|
|
5acf6ee84c | ||
|
|
985dafe45f | ||
|
|
21558372d5 | ||
|
|
53f4ba23be | ||
|
|
9b3388f45f | ||
|
|
eaf6b5427c | ||
|
|
39395ca6b6 | ||
|
|
38fe17ab99 | ||
|
|
c957264b72 | ||
|
|
41a36e2279 | ||
|
|
246b8ff54b | ||
|
|
3f414afff4 | ||
|
|
7f2bd5b3ae | ||
|
|
2c2f55b6d7 | ||
|
|
a1802f7f93 | ||
|
|
b85d412308 | ||
|
|
999f8bf18c | ||
|
|
544acc0758 | ||
|
|
e67edd9feb | ||
|
|
bf77c8ee04 | ||
|
|
6d5550f198 | ||
|
|
097beb6f63 | ||
|
|
55e3a8cba2 | ||
|
|
ca2f3da7a5 | ||
|
|
32e4a9b817 | ||
|
|
7e3e95fbcd | ||
|
|
4362e76ca0 | ||
|
|
f761c59637 | ||
|
|
d29a008e13 | ||
|
|
715da1929b | ||
|
|
6104fb861e | ||
|
|
01e7dcee56 | ||
|
|
9ef45918b8 | ||
|
|
502a593985 | ||
|
|
287cbc76ae | ||
|
|
90d626e7c6 | ||
|
|
8a3b99ae51 | ||
|
|
e5339d524b | ||
|
|
e218508c2d | ||
|
|
e7bfc4ec1c | ||
|
|
5c63fe2313 | ||
|
|
8d86bd6e92 | ||
|
|
8a4ca2482c | ||
|
|
3daa031a04 | ||
|
|
15ef332ea2 | ||
|
|
57448ca609 | ||
|
|
0f7c95cfaf | ||
|
|
3886909e9e | ||
|
|
0f8a817413 | ||
|
|
eb47545b76 | ||
|
|
6fc123e0f6 | ||
|
|
5bdef2ba8f | ||
|
|
42ca5b8f94 | ||
|
|
ba48a25982 | ||
|
|
76c524eb9b | ||
|
|
48f8e8fc71 | ||
|
|
2a0f15c1bd | ||
|
|
e94ade364e | ||
|
|
27f6c33782 | ||
|
|
b3e596c761 | ||
|
|
0ee9e466c3 | ||
|
|
5325d813b4 | ||
|
|
dc3f0abf9b | ||
|
|
e63fc9a8bd | ||
|
|
eb5366f129 | ||
|
|
77c4a0f84c | ||
|
|
1bf6c1f1d8 | ||
|
|
31caf8f5f8 | ||
|
|
787076355b | ||
|
|
4da764ddef | ||
|
|
590755169f | ||
|
|
f7ef9dd116 | ||
|
|
4bc16eb220 | ||
|
|
032bda61f6 | ||
|
|
91ce2c8aee | ||
|
|
9b230d14b4 | ||
|
|
6201e01914 | ||
|
|
441f2ecc06 | ||
|
|
1ed69ccc64 | ||
|
|
80519e6839 | ||
|
|
b4be18e71f | ||
|
|
4a196731f1 | ||
|
|
b32a4e7c6a | ||
|
|
dc8becb41a | ||
|
|
d2896d5936 | ||
|
|
0315481eb6 | ||
|
|
ca955b48e2 | ||
|
|
19e93edca0 | ||
|
|
d039efbefe | ||
|
|
103472404f | ||
|
|
4405a69d59 | ||
|
|
c5f3a4db44 | ||
|
|
b66bc353b3 | ||
|
|
17cdfe6885 | ||
|
|
5cf0dc116a | ||
|
|
c1bb54a31e | ||
|
|
0fb5bbebc6 | ||
|
|
9f375e8413 | ||
|
|
1d7cfbfed8 | ||
|
|
0cc254c07d | ||
|
|
65d93b70e4 | ||
|
|
8dc36a2007 | ||
|
|
42ba550bb4 | ||
|
|
1aa8c9122d | ||
|
|
225d1d2ec0 | ||
|
|
00c0c86345 | ||
|
|
eee83542df | ||
|
|
4da856fb6d | ||
|
|
03126d32c1 | ||
|
|
ad555b3b36 | ||
|
|
7f8e6952df | ||
|
|
2542339733 | ||
|
|
0d094b01b5 | ||
|
|
47f007480f | ||
|
|
374a0866be | ||
|
|
126a204b2a | ||
|
|
c491195b19 | ||
|
|
395401e493 | ||
|
|
52479ebbd5 | ||
|
|
f6d8ab8aeb | ||
|
|
67e78d0eb1 | ||
|
|
ef7f346e7a | ||
|
|
707bfc87a1 | ||
|
|
3416c879de | ||
|
|
bbed5bf0ae | ||
|
|
985874506e | ||
|
|
8434b75af3 | ||
|
|
8a1c10bbf7 | ||
|
|
e4e5bce234 | ||
|
|
ae4f304cc2 | ||
|
|
9a838647fe | ||
|
|
435e9ecfdd | ||
|
|
2b310c9a97 | ||
|
|
7d2c98c5ae | ||
|
|
ff871e7049 | ||
|
|
20f80d0f1f | ||
|
|
36bb99cf06 | ||
|
|
24c2e3b49c | ||
|
|
486a471c0c | ||
|
|
ce32da9176 | ||
|
|
39dd1b9e00 | ||
|
|
807711cae8 | ||
|
|
00feeb86ca |
69
.eslintrc.js
@@ -1,69 +0,0 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
},
|
||||
extends: ['@antfu/eslint-config-vue', 'plugin:sonarjs/recommended'],
|
||||
ignorePatterns: ['src/@iconify/*.js', 'node_modules', 'dist', '*.d.ts'],
|
||||
plugins: ['regex'],
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
|
||||
'sonarjs/no-duplicate-string': 'warn',
|
||||
|
||||
'vue/valid-v-slot': ['error', {
|
||||
allowModifiers: true,
|
||||
}],
|
||||
|
||||
// https://github.com/gmullerb/eslint-plugin-regex
|
||||
'regex/invalid': [
|
||||
'error',
|
||||
[
|
||||
{
|
||||
regex: '@/assets/images',
|
||||
replacement: '@images',
|
||||
message: 'Use \'@images\' path alias for image imports',
|
||||
},
|
||||
{
|
||||
regex: '@/styles',
|
||||
replacement: '@styles',
|
||||
message: 'Use \'@styles\' path alias for importing styles from \'src/styles\'',
|
||||
},
|
||||
|
||||
// {
|
||||
// id: 'Disallow icon of icon library',
|
||||
// regex: 'tabler-\\w',
|
||||
// message: 'Only \'mdi\' icons are allowed',
|
||||
// },
|
||||
|
||||
{
|
||||
regex: '@core/\\w',
|
||||
message: 'You can\'t use @core when you are in @layouts module',
|
||||
files: {
|
||||
inspect: '@layouts/.*',
|
||||
},
|
||||
},
|
||||
{
|
||||
regex: 'useLayouts\\(',
|
||||
message:
|
||||
'`useLayouts` composable is only allowed in @layouts & @core directory. Please use `useThemeConfig` composable instead.',
|
||||
files: {
|
||||
inspect: '^(?!.*(@core|@layouts)).*',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Ignore files
|
||||
'.eslintrc.js',
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.ts', '.js', '.tsx', '.jsx', '.mjs', '.png', '.jpg'],
|
||||
},
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
4
.github/workflows/build.yml
vendored
@@ -1,10 +1,10 @@
|
||||
name: Build Moviepilot-Frontend v2
|
||||
name: Build Moviepilot-Frontend v3
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- v2
|
||||
- v3
|
||||
paths:
|
||||
- 'package.json'
|
||||
|
||||
|
||||
59
.github/workflows/test.yml
vendored
@@ -3,17 +3,66 @@ name: Frontend Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- v2
|
||||
- v3
|
||||
push:
|
||||
branches:
|
||||
- v3
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-tests-${{ github.event.pull_request.number }}
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
format:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: yarn
|
||||
cache-dependency-path: yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: Check changed-file formatting
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: yarn format:check --base "$BASE_SHA" --head "$HEAD_SHA"
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: yarn
|
||||
cache-dependency-path: yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
|
||||
typecheck-and-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -33,5 +82,5 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
|
||||
- name: Unit tests with coverage
|
||||
run: yarn test:coverage
|
||||
- name: Unit tests
|
||||
run: yarn test:run
|
||||
|
||||
1
.node-version
Normal file
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -1,2 +1,8 @@
|
||||
dist
|
||||
node_modules
|
||||
node_modules
|
||||
|
||||
# 自动生成文件的格式由各自生成器决定。
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
eslint-suppressions.json
|
||||
/vite.config.*.timestamp-*.mjs
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"bracketSpacing": true,
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 120,
|
||||
"proseWrap": "preserve",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
*中文 | [English](README_EN.md)*
|
||||
|
||||
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目,NodeJS版本:>= `v20.12.1`。
|
||||
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目,最低支持 Node.js `20.19`,推荐使用 Node.js `24`。
|
||||
|
||||
## 特性
|
||||
|
||||
@@ -49,6 +49,8 @@ yarn test:coverage
|
||||
|
||||
测试文件组织、共享测试设施、HTTP mock、覆盖率门禁和新增用例规范见[单元测试架构](docs/testing.md)。
|
||||
|
||||
ESLint、Prettier、Node 兼容范围及渐进式 CI 门禁见[前端代码质量工具链演进](docs/code-quality.md)。
|
||||
|
||||
### 静态运行
|
||||
|
||||
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件,nginx配置参考 `public/nginx.conf`。
|
||||
|
||||
179
docs/code-quality.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# 前端代码质量工具链演进
|
||||
|
||||
本文档定义 MoviePilot-Frontend 的代码质量工具链、运行入口和渐进式演进路线。目标是让人工开发者和自动化 Agent 都通过同一组项目命令完成格式化、静态检查、类型检查、测试和构建,并由 CI 在干净环境中复现结果;编辑器及其扩展只提供即时反馈,不作为项目行为的事实源。
|
||||
|
||||
## 工具职责
|
||||
|
||||
| 工具 | 职责 | 不负责 |
|
||||
| ---------------------- | ------------------------------------------------------------ | -------------------------------------- |
|
||||
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
|
||||
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
|
||||
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
|
||||
| Vitest | 单元测试、组件测试和按需覆盖率报告 | 生产构建与真实浏览器行为 |
|
||||
| Vite | 生产构建和构建期集成验证 | 类型完整性和代码质量规则 |
|
||||
|
||||
ESLint 配置由仓库显式维护,不继承 Antfu 等覆盖面较大的个人风格预设。JavaScript、TypeScript 与 Vue 使用各自面向正确性的 recommended/essential 基线;SonarJS 不整包展开 recommended,而只显式启用安全和正确性的高信号规则,避免插件升级隐式扩大检查范围。
|
||||
|
||||
历史规则迁移前必须核对当前源码和运行职责,不能仅因为旧配置出现过就写入 baseline。当前保留 Vuetify 点号 slot 兼容、`@layouts` 不反向依赖 `@core`、浏览器与 Node globals 分域及统一禁止 `debugger`。图片/样式 alias 强制只约束 import 拼写且已与当前实践脱节;`useLayouts` 目录限制所针对的 API 从未存在于源码;重复字符串属于高噪声风格治理;伪随机数限制会把界面效果误判为安全问题,因此这四类历史规则不再作为门禁。
|
||||
|
||||
Prettier 独立运行,ESLint 中与格式重叠的规则保持关闭。任何编辑器都可以调用项目内 ESLint 和 Prettier,但保存时是否自动执行不影响 CI 结果。
|
||||
|
||||
## Node 与依赖边界
|
||||
|
||||
- 最低兼容目标为 Node.js 20.19;Node.js 20.19、22 和 24 的开发环境可以安装依赖并运行项目命令。
|
||||
- 推荐开发环境和 CI 主环境使用 Node.js 24。
|
||||
- CI 若继续声明兼容 Node.js 20.19,应至少在该版本验证 frozen lockfile 安装、lint、typecheck 和 build;主测试 job 使用 Node.js 24 执行单元测试。
|
||||
- Yarn 1 与 `yarn.lock` 继续作为依赖安装事实源,CI 使用 `yarn --frozen-lockfile`。
|
||||
- Node 只用于依赖安装、开发、测试和前端构建;MoviePilot 正式 Docker 镜像使用预构建前端产物,不因开发工具链升级而增加 Node 运行时。
|
||||
|
||||
最低 Node 版本变化属于开发环境兼容性变更,必须独立说明并在 CI 验证。不得仅提高 `package.json` 的 `engines.node` 而不更新 README、版本文件和 workflow。
|
||||
|
||||
## 统一命令
|
||||
|
||||
工具链完成演进后,仓库提供以下入口:
|
||||
|
||||
```sh
|
||||
yarn lint # 只读 ESLint 检查,不修改文件
|
||||
yarn lint:fix # 显式执行 ESLint 安全修复
|
||||
yarn lint:suppressions:prune # 修复存量问题后裁剪过期 baseline
|
||||
yarn format # 格式化当前分支、暂存区、工作区和未跟踪文件中的受支持变更文件
|
||||
yarn format:check # 检查相同变更文件,不修改文件
|
||||
yarn format:all:check # 只读测量全仓格式收敛情况
|
||||
yarn typecheck
|
||||
yarn test:run
|
||||
yarn test:coverage
|
||||
yarn build
|
||||
```
|
||||
|
||||
`yarn lint` 不得包含 `--fix`。CI 只运行只读命令,任何自动修改都由开发者或 Agent 在提交前显式执行。
|
||||
|
||||
## ESLint 演进:先恢复可执行门禁
|
||||
|
||||
ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业务代码修复。
|
||||
|
||||
第一阶段完成以下事项:
|
||||
|
||||
1. 使用 ESLint 10 flat config,移除旧 `.eslintrc`。
|
||||
2. 不恢复已删除的 `@antfu/eslint-config-vue`。
|
||||
3. 显式组合 JavaScript、TypeScript、Vue 和经过审计的 SonarJS 规则。
|
||||
4. 使用 ESLint 原生 AST 限制维护仍成立的模块边界,并删除只约束 import 拼写或已无现实对象的历史 regex 规则。
|
||||
5. 只检查 JavaScript、TypeScript、Vue 与相关测试文件;Markdown、JSON、YAML、TOML、构建产物和生成文件不在首阶段扩展范围内。
|
||||
6. 默认 lint 只读,并提供单独的 `lint:fix`。
|
||||
7. 对确有价值但存在存量的问题使用明确基线或分阶段启用,不通过全仓自动修复制造大面积混合变更。
|
||||
8. 项目边界与新增问题门禁使用 Vitest 配置契约测试保护,并随现有 `test:run` CI 自动执行。
|
||||
|
||||
`eslint-suppressions.json` 只冻结迁移时已经确认的文件、规则和数量。新增问题不得加入 baseline;修复存量问题后运行 `yarn lint:suppressions:prune`,并把裁剪结果与代码修复一同提交。日常开发和 CI 不得使用 `--suppress-all`。
|
||||
|
||||
迁移完成的最低验收为:
|
||||
|
||||
```sh
|
||||
yarn --frozen-lockfile
|
||||
yarn lint
|
||||
yarn typecheck
|
||||
yarn test:run
|
||||
yarn build
|
||||
```
|
||||
|
||||
若新配置导致大量与业务目标无关的格式、排序或额外文件类型问题,应停止扩大规则范围,先调整配置职责;不得以一次性自动修复数百个文件作为通过迁移的手段。
|
||||
|
||||
## ESLint CI:先观察,再强制
|
||||
|
||||
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job:
|
||||
|
||||
1. lint 与 `typecheck-and-tests` 使用不同 job,保持静态检查和单元测试职责独立。
|
||||
2. 初始阶段作为普通 check 运行,不立即配置 required check。
|
||||
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。
|
||||
4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
|
||||
5. 连续多个 PR 稳定通过后,再由维护者决定是否设为 required。
|
||||
|
||||
ESLint CI 必须检查全仓受管源码,而不是只检查 PR 变更文件。已有问题通过规则选择或受控基线管理,保证新问题不能借由“只检查改动行”绕过项目约束。
|
||||
|
||||
## Prettier 演进:改到即格式化
|
||||
|
||||
Prettier 采用与后端 `unittest → pytest` 相同的渐进原则:不为转换而转换,修改到存量文件时再纳入统一格式。
|
||||
|
||||
- **新文件**:必须通过当前 Prettier 配置。
|
||||
- **修改文件**:Agent 或开发者在提交前对本次触及的受支持文件运行 Prettier。
|
||||
- **未修改存量文件**:暂时保持原样,不建立一次性全仓格式化任务。
|
||||
- **大型或高冲突文件**:若格式化产生大量机械差异,格式调整与业务修改拆分为独立 commit;必要时暂缓该文件并记录原因。
|
||||
- **生成文件**:按生成器事实源管理,不直接用 Prettier 修补生成结果;需要统一格式时修改生成器或生成配置。
|
||||
|
||||
Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,并作为变更范围门禁运行。检查脚本应:
|
||||
|
||||
1. 从 PR base 与 head 计算文件集合。
|
||||
2. 排除已删除文件、构建产物、覆盖率报告和生成文件。
|
||||
3. 仅把 Prettier 支持且由仓库管理的文件传给 `prettier --check`。
|
||||
4. 文件集合为空时正常通过。
|
||||
|
||||
本地直接运行 `yarn format` 或 `yarn format:check` 时,脚本按 `refs/remotes/upstream/v3`、`refs/remotes/origin/v3`、`refs/heads/v3` 的顺序选择与当前提交具有共同祖先的基线,并合并当前工作区与未跟踪文件。完整引用可避免同名标签造成歧义;CI 使用显式提交 SHA 保证输入可复现:
|
||||
|
||||
```sh
|
||||
yarn format:check --base <base-sha> --head <head-sha>
|
||||
```
|
||||
|
||||
文件名通过 Git 的 NUL 分隔输出和 Node 参数数组传递,不经过 shell 字符串拼接;重命名只检查新路径,删除文件、ignore 文件和 Prettier 无法推断 parser 的文件不进入格式检查。
|
||||
|
||||
该门禁不要求未修改的存量文件通过 Prettier,但所有进入 PR diff 的受支持文件都必须通过。Prettier 是文件级格式化工具,因此修改存量文件时检查的是整个文件,而不是仅检查变更行。Agent 或开发者应在提交前运行同一项目脚本完成格式化;CI 只验证结果,不自动提交修改。
|
||||
|
||||
禁止在普通业务 PR 中直接运行 `prettier . --write` 或其他全仓格式写入命令。`yarn format` 只处理选择器返回的变更文件;单个存量文件被触及时仍可能整体重排,差异过大时应拆分 commit,便于 reviewer 区分机械格式与业务逻辑。
|
||||
|
||||
具体交付拆分为:
|
||||
|
||||
1. Prettier 基础设施 PR:安装项目 CLI,定义配置、命令、变更文件选择脚本和回归测试,不修改 workflow。
|
||||
2. 变更文件门禁 PR:复用已合并脚本接入 Pull Request workflow;验证 fork PR、文件重命名、删除文件、空文件集和特殊文件名。初期作为普通 check 观察,稳定后再由维护者通过仓库规则独立配置为 required。
|
||||
3. 全仓门禁 PR:仅在存量收敛条件满足后择机实施,不与变更文件门禁绑定。
|
||||
|
||||
## 全仓格式门禁的启用条件
|
||||
|
||||
不单独安排大爆炸式格式化阶段。存量通过日常“改到即格式化”逐步收敛,并定期执行全仓 `yarn format:all:check` 观察剩余范围。
|
||||
|
||||
Prettier 3.9.5 基础设施接入时,全仓只读检查在 `v2` 基线报告 203 个存量文件需要格式化。该数字是收敛起点而不是忽略白名单;变更文件仍必须完整通过检查,存量数量随日常修改逐步下降。
|
||||
|
||||
变更文件 CI 接入前在 `v2` 再次测量为 206 个文件,较基础设施合并后的 201 个文件出现回升。这说明仅提供本地命令不足以阻止新改动扩大存量;变更文件 check 用于守住新增 diff,不因此要求在本阶段批量格式化已有文件。
|
||||
|
||||
变更文件 required check 不需要等待全仓收敛。只有同时满足以下条件,才把 Prettier 从变更文件 required check 切换为全仓 required check:
|
||||
|
||||
- 全仓 `yarn format:all:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
|
||||
- 最近的活跃分支已合并或完成同步,避免集中格式变化制造冲突。
|
||||
- `yarn lint`、`yarn typecheck`、`yarn test:run` 和 `yarn build` 在格式收敛后全部通过。
|
||||
- Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。
|
||||
- workflow 已以非 required 状态稳定运行多个 PR。
|
||||
|
||||
最终 Pull Request 门禁目标为:
|
||||
|
||||
```sh
|
||||
yarn --frozen-lockfile
|
||||
yarn format:all:check
|
||||
yarn lint
|
||||
yarn typecheck
|
||||
yarn test:run
|
||||
yarn build
|
||||
```
|
||||
|
||||
PR-Agent、编辑器诊断和人工 review 可以补充判断,但不能替代可重复执行的项目命令。
|
||||
|
||||
## Agent 与开发者提交规范
|
||||
|
||||
无论改动由 Agent 还是人工完成,提交前遵循同一顺序:
|
||||
|
||||
1. 只对本次新增或修改的文件执行 Prettier。
|
||||
2. 运行全仓只读 lint。
|
||||
3. 运行 typecheck。
|
||||
4. 按影响面运行聚焦测试;覆盖率报告按需本地运行,不作为 Actions 门禁。
|
||||
5. 涉及构建配置、依赖、Vue SFC 或模块联邦时运行生产 build。
|
||||
|
||||
编辑器设置不得成为通过验证的前提。文档、Agent 指令和 PR 说明应引用 `yarn` 命令,而不是“在 VS Code 保存一次”或依赖某个 IDE 扩展完成修复。
|
||||
|
||||
## 风险与回滚
|
||||
|
||||
| 风险 | 控制方式 | 回滚边界 |
|
||||
| ---------------------------- | ------------------------------------------ | ------------------------------ |
|
||||
| ESLint 自动修复改变语义 | 默认 lint 只读;基础设施迁移不批量 fix | 独立回退规则或依赖提交 |
|
||||
| 格式化制造大面积冲突 | 改到即格式化;大型文件拆分机械 commit | 回退单文件格式 commit |
|
||||
| 新规则让所有 PR 变红 | 先测量、再启用;使用受控基线 | 暂停单条规则,不回退整套工具链 |
|
||||
| 检查范围扩展到生成物或文档 | 首阶段限制文件类型并显式 ignore | 收窄 flat config 文件匹配 |
|
||||
| Node 最低版本阻断安装 | 最低保持 20.19,Node 24 作为推荐和主 CI | 保持兼容 job,版本提升独立处理 |
|
||||
| 全仓 required check 配置过早 | 先强制变更文件,持续观察全仓剩余范围 | 保留变更文件门禁,暂缓全仓切换 |
|
||||
| ESLint 与 Prettier 循环改写 | ESLint 关闭格式规则,Prettier 单独负责格式 | 关闭冲突规则并增加回归样例 |
|
||||
|
||||
依赖迁移、规则迁移、存量代码修复、格式化和 GitHub required checks 应保持可独立评审、验证和回退,禁止合并成一个难以定位风险的大型变更。
|
||||
@@ -6,7 +6,6 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
|
||||
|
||||
关联阅读后端插件开发文档:[第三方插件开发说明](https://github.com/jxxghp/MoviePilot-Plugins/blob/main/README.md)
|
||||
|
||||
|
||||
## 2. 技术要求
|
||||
|
||||
- Node.js 20+
|
||||
@@ -18,13 +17,13 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
|
||||
|
||||
每个 Vue 联邦插件需要提供下列标准组件(`AppPage` 为可选,用于主界面侧栏全页入口):
|
||||
|
||||
| 组件名称 | 暴露名 | 文件名 | 用途 |
|
||||
|---------|--------|--------|------|
|
||||
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
|
||||
| Config | `./Config` | Config.vue | 插件配置页面 |
|
||||
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
|
||||
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
|
||||
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
|
||||
| 组件名称 | 暴露名 | 文件名 | 用途 |
|
||||
| --------- | ---------------- | ---------------------- | --------------------------------------------- |
|
||||
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
|
||||
| Config | `./Config` | Config.vue | 插件配置页面 |
|
||||
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
|
||||
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
|
||||
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
|
||||
|
||||
主应用在侧栏全页路由中按 `nav_key` 解析暴露名(如 `AppPageSettings`),再回退 `AppPage` → `Page`;`nav_key` 为 `main` 时仅尝试 `AppPage` → `Page`。
|
||||
|
||||
@@ -79,55 +78,52 @@ export default defineConfig({
|
||||
singleton: true,
|
||||
},
|
||||
},
|
||||
format: 'esm'
|
||||
})
|
||||
format: 'esm',
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
target: 'esnext', // 必须设置为esnext以支持顶层await
|
||||
minify: false, // 开发阶段建议关闭混淆
|
||||
target: 'esnext', // 必须设置为esnext以支持顶层await
|
||||
minify: false, // 开发阶段建议关闭混淆
|
||||
cssCodeSplit: true, // 改为true以便能分离样式文件
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: '/* 覆盖vuetify样式 */',
|
||||
}
|
||||
},
|
||||
},
|
||||
postcss: {
|
||||
plugins: [
|
||||
{
|
||||
postcssPlugin: 'internal:charset-removal',
|
||||
AtRule: {
|
||||
charset: (atRule) => {
|
||||
charset: atRule => {
|
||||
if (atRule.name === 'charset') {
|
||||
atRule.remove();
|
||||
atRule.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
postcssPlugin: 'vuetify-filter',
|
||||
Root(root) {
|
||||
// 过滤掉所有vuetify相关的CSS
|
||||
root.walkRules(rule => {
|
||||
if (rule.selector && (
|
||||
rule.selector.includes('.v-') ||
|
||||
rule.selector.includes('.mdi-'))) {
|
||||
rule.remove();
|
||||
if (rule.selector && (rule.selector.includes('.v-') || rule.selector.includes('.mdi-'))) {
|
||||
rule.remove()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5001, // 使用不同于主应用的端口
|
||||
cors: true, // 启用CORS
|
||||
origin: 'http://localhost:5001'
|
||||
port: 5001, // 使用不同于主应用的端口
|
||||
cors: true, // 启用CORS
|
||||
origin: 'http://localhost:5001',
|
||||
},
|
||||
})
|
||||
|
||||
})
|
||||
```
|
||||
|
||||
## 5. 组件开发规范
|
||||
@@ -139,12 +135,16 @@ export default defineConfig({
|
||||
// 自定义事件,用于通知主应用刷新数据
|
||||
const emit = defineEmits(['action', 'switch', 'close'])
|
||||
|
||||
// 接收API对象
|
||||
// 接收主应用能力
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
default: () => {},
|
||||
},
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 页面逻辑代码...
|
||||
@@ -179,20 +179,24 @@ function notifyClose() {
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 接收初始配置和API对象
|
||||
// 接收初始配置和主应用能力
|
||||
const props = defineProps({
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
default: () => ({}),
|
||||
},
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
default: () => {},
|
||||
},
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 配置数据
|
||||
const config = ref({...props.initialConfig})
|
||||
const config = ref({ ...props.initialConfig })
|
||||
|
||||
// 自定义事件,用于保存配置
|
||||
const emit = defineEmits(['save', 'close', 'switch'])
|
||||
@@ -217,7 +221,7 @@ function notifyClose() {
|
||||
<div class="plugin-config">
|
||||
<!-- 配置表单示例 -->
|
||||
<v-text-field v-model="config.someField" label="配置项"></v-text-field>
|
||||
|
||||
|
||||
<!-- 保存按钮示例 -->
|
||||
<v-btn color="primary" @click="saveConfig">保存配置</v-btn>
|
||||
|
||||
@@ -234,16 +238,20 @@ function notifyClose() {
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 接收配置和刷新控制
|
||||
// 接收配置、刷新控制和主应用能力
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
default: () => ({}),
|
||||
},
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
default: true,
|
||||
},
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 仪表板逻辑...
|
||||
@@ -276,16 +284,18 @@ const props = defineProps({
|
||||
|
||||
主应用传入的 props:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
| 属性 | 说明 |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `nativeSubscribe` | 打开主应用原生订阅交互 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
nativeSubscribe: { type: Function, default: null },
|
||||
navKey: { type: String, default: 'main' },
|
||||
pluginId: { type: String, default: '' },
|
||||
})
|
||||
@@ -300,18 +310,105 @@ const emit = defineEmits(['action'])
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5.5 主应用宿主能力
|
||||
|
||||
登录后的联邦组件宿主会向插件开放以下能力:
|
||||
|
||||
| 能力 | Page | Config | Dashboard | AppPage | 调用方式 |
|
||||
| ---------------- | ---- | ------ | --------- | ------- | ---------------------------------------------------------------- |
|
||||
| 认证 API | ✓ | ✓ | ✓ | ✓ | `api` prop |
|
||||
| 原生订阅交互 | ✓ | ✓ | ✓ | ✓ | `nativeSubscribe` prop 或 `inject('moviepilot:nativeSubscribe')` |
|
||||
| 主应用统一 Toast | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:toast')` |
|
||||
|
||||
`nativeSubscribe` 和 Toast 都由主应用宿主提供。插件不应复制主程序订阅弹窗,也不应自行创建另一套 Toast 容器。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。
|
||||
|
||||
### 5.6 玻璃光学表面
|
||||
|
||||
主应用的 `Page`、`Config` 与 `AppPage` 宿主在玻璃主题下默认采用 `static-material` 光学模式:保留壁纸透射、材质色调和方向反射,但不响应指针流场、局部折射、拖尾或动态焦散。插件列表与 `Dashboard` 继续使用完整动态光学。视觉型插件可以在自己控制的 DOM 区域显式恢复完整动态光学:
|
||||
|
||||
```html
|
||||
<div data-glass-optical-surface data-glass-optical-mode="dynamic">
|
||||
<!-- 插件自己的视觉内容 -->
|
||||
</div>
|
||||
```
|
||||
|
||||
使用时需同时声明 `data-glass-optical-surface` 和 `data-glass-optical-mode="dynamic"`。模式会从最近的祖先容器继承,因此显式声明的动态子表面不会沿用宿主的静态模式。该合同适用于插件在 `Page`、`Config` 或 `AppPage` 中自行渲染并控制的区域;主应用生成的插件列表、插件市场卡片、`Dashboard` 及其他宿主 DOM 不属于插件的修改边界。
|
||||
|
||||
动态模式只在主应用启用玻璃主题和实时光学能力时生效。其他主题、降低动态效果或光学能力不可用时,插件必须保持内容与交互正常,不应依赖动态光学表达业务状态或必要反馈。
|
||||
|
||||
### 5.7 调用主应用原生订阅
|
||||
|
||||
`Page`、`Config`、`Dashboard` 与 `AppPage` 都会收到 `nativeSubscribe(mediaInfo)` prop。插件传入媒体信息后,电视剧会打开主应用的选季抽屉,电影会进入现有电影订阅流程。宿主也会用 `moviepilot:nativeSubscribe` 键提供同一个方法,深层子组件可以使用 `inject`,无需逐层传递 prop。
|
||||
|
||||
媒体信息必须包含:
|
||||
|
||||
- `type`:`电影` / `电视剧`,也兼容 `movie` / `tv`;
|
||||
- `title`;
|
||||
- 至少一个有效媒体标识:`tmdb_id` / `tmdbid`、`douban_id` / `doubanid`、`bangumi_id` / `bangumiid`、`anilist_id` / `anilistid`,或者 `media_id` 与 `mediaid_prefix` / `source` / `media_source` 的组合。
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
type NativeSubscribeResult =
|
||||
{ success: true } | { success: false; code: 'INVALID_MEDIA' | 'PERMISSION_DENIED'; message: string }
|
||||
|
||||
const props = defineProps<{
|
||||
nativeSubscribe?: (mediaInfo: Record<string, unknown>) => Promise<NativeSubscribeResult>
|
||||
}>()
|
||||
|
||||
const nativeSubscribe = inject('moviepilot:nativeSubscribe', props.nativeSubscribe)
|
||||
|
||||
/** 使用主应用订阅交互,宿主不接受时保留插件自己的 fallback。 */
|
||||
async function subscribeMedia(mediaInfo: Record<string, unknown>) {
|
||||
const result = await nativeSubscribe?.(mediaInfo)
|
||||
if (!result?.success) {
|
||||
// 插件可在这里执行自己的 fallback;宿主已同时显示明确错误提示。
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
`success: true` 表示主应用已接受调用并启动原生交互,不表示用户已经完成订阅。字段无效或当前用户没有订阅权限时返回 `success: false`,插件可以依据 `code` 执行 fallback。
|
||||
|
||||
### 5.8 调用主应用 Toast
|
||||
|
||||
`Page`、`Config`、`Dashboard` 与 `AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例,不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
interface HostToast {
|
||||
error(message: string): unknown
|
||||
info(message: string): unknown
|
||||
success(message: string): unknown
|
||||
warning(message: string): unknown
|
||||
}
|
||||
|
||||
const toast = inject<HostToast | null>('moviepilot:toast', null)
|
||||
|
||||
// 保存完成后调用主应用的统一通知。
|
||||
function saveComplete() {
|
||||
toast?.success('保存成功')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
可用方法与主项目 `vue-toastification` 一致,包括 `success`、`info`、`warning` 和 `error`。注入不存在时应静默降级,关键错误仍需保留页面内状态提示。
|
||||
|
||||
#### 后端:注册侧栏入口
|
||||
|
||||
插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
|
||||
| `title` | 侧栏显示标题 |
|
||||
| `icon` | MDI 图标名,如 `mdi-rss` |
|
||||
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
|
||||
| 字段 | 说明 |
|
||||
| ------------ | ------------------------------------------------------------------------------------- |
|
||||
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
|
||||
| `title` | 侧栏显示标题 |
|
||||
| `icon` | MDI 图标名,如 `mdi-rss` |
|
||||
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
|
||||
| `permission` | 可选:`subscribe` / `discovery` / `search` / `manage` / `admin`,与主应用菜单权限一致 |
|
||||
| `order` | 可选:同组内排序,数值越小越靠前 |
|
||||
| `order` | 可选:同组内排序,数值越小越靠前 |
|
||||
|
||||
```python
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
@@ -333,16 +430,16 @@ def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
|
||||
前端加载远程组件的顺序为:
|
||||
|
||||
| `nav_key` | 依次尝试的联邦暴露名 |
|
||||
|-----------|----------------------|
|
||||
| `main` 或省略 | `./AppPage` → `./Page` |
|
||||
| `nav_key` | 依次尝试的联邦暴露名 |
|
||||
| -------------------------------- | ------------------------------------------------ |
|
||||
| `main` 或省略 | `./AppPage` → `./Page` |
|
||||
| 其它(如 `settings`、`my_tool`) | `./AppPage{PascalCase}` → `./AppPage` → `./Page` |
|
||||
|
||||
`PascalCase` 规则:按 `-`、`_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings`;`my_tool` → `./AppPageMyTool`。
|
||||
|
||||
**两种实现方式(二选一或混用):**
|
||||
|
||||
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
|
||||
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
|
||||
2. **多文件**:为某个入口单独暴露 `./AppPageSettings.vue` 等,主应用会优先加载对应模块,失败再回退到 `AppPage`。
|
||||
|
||||
`vite.config` 多暴露示例:
|
||||
@@ -365,8 +462,7 @@ yarn build
|
||||
|
||||
- 将生成的dist文件夹上传到插件后端目录下(默认为`dist/assets`)
|
||||
|
||||
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*`、`_plugin-vue_export-helper-*`、`remoteEntry.js`
|
||||
|
||||
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*`、`_plugin-vue_export-helper-*`、`remoteEntry.js`
|
||||
|
||||
- 在插件的后端python代码中,实现以下方法来集成远程组件:
|
||||
|
||||
@@ -380,7 +476,8 @@ def get_render_mode() -> Tuple[str, str]:
|
||||
return "vue", "dist/assets"
|
||||
```
|
||||
|
||||
- 需要在插件前端页面调用后端接口时,通过传入的api模块发起调用,后端api接口声明认证类型为:`bear`
|
||||
- 需要在插件前端页面调用后端接口时,通过传入的api模块发起调用,后端api接口声明认证类型为:`bear`
|
||||
|
||||
```typescript
|
||||
// 演示使用api模块调用插件接口
|
||||
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
|
||||
@@ -402,7 +499,6 @@ def get_api(self) -> List[Dict[str, Any]]:
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
## 7. 调试与排错
|
||||
|
||||
### 常见问题
|
||||
@@ -461,7 +557,7 @@ yarn dev
|
||||
|
||||
## 9. 示例代码
|
||||
|
||||
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
|
||||
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
|
||||
- [模块联邦问题排查指南](./federation-troubleshooting.md) - 常见问题排查
|
||||
|
||||
## 10. 参考资料
|
||||
@@ -471,4 +567,4 @@ yarn dev
|
||||
|
||||
---
|
||||
|
||||
如有问题,请提交Issue。
|
||||
如有问题,请提交Issue。
|
||||
|
||||
@@ -67,18 +67,18 @@ tests/
|
||||
|
||||
## 新增测试
|
||||
|
||||
1. 在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`。
|
||||
1. 业务测试在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`;工具链配置契约测试放在 `tests/config/`。
|
||||
2. 纯函数、store 和无渲染模块直接使用 Vitest;Vue 组件使用标准渲染入口。
|
||||
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
|
||||
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
|
||||
5. 核心覆盖范围发生变化时,同步更新 `vite.config.ts` 的 `coverage.include`。
|
||||
6. 提交前运行测试、覆盖率、类型检查、lint 和生产构建。
|
||||
6. 提交前按影响面运行测试、类型检查、lint 和生产构建;覆盖率报告按需本地执行。
|
||||
|
||||
## 配置边界
|
||||
|
||||
Vitest 只收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。
|
||||
Vitest 收集 `src/**/__tests__/**/*.spec.ts` 和 `tests/config/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。配置契约测试随全量测试执行,但不加入业务源码覆盖率统计范围。
|
||||
|
||||
当前核心覆盖范围在 `vite.config.ts` 的 `coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%,Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%,Branches 不低于 75%。覆盖率报告写入 `coverage/`。
|
||||
当前核心覆盖范围在 `vite.config.ts` 的 `coverage.include` 中显式维护,覆盖率报告写入 `coverage/`。覆盖率配置保留给本地质量分析,不作为 GitHub Actions 的单元测试门禁。
|
||||
|
||||
## 命令与 CI
|
||||
|
||||
@@ -86,9 +86,10 @@ Vitest 只收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue
|
||||
yarn test # watch 模式
|
||||
yarn test:run # 单次运行
|
||||
yarn test:coverage # 单次运行并检查覆盖率
|
||||
yarn test:lint-config # 聚焦执行 ESLint 配置契约测试
|
||||
yarn typecheck
|
||||
yarn lint
|
||||
yarn build
|
||||
```
|
||||
|
||||
Pull Request 测试工作流使用 Node 24 LTS 和 frozen lockfile,依次执行类型检查和覆盖率门禁。现有 lint 基线问题按仓库当前维护约定单独处理,新增测试代码不得引入新的 lint 错误。
|
||||
`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile,在面向 `v3` 的 Pull Request 和推送到 `v3` 时运行。`typecheck-and-tests` job 执行类型检查和单元测试(`yarn test:run`),不要求覆盖率达标;覆盖率可按需在本地运行 `yarn test:coverage`。独立的 `lint` job 执行全仓只读 ESLint 检查。变更文件格式检查依赖 Pull Request 的 base/head SHA,因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
|
||||
|
||||
1054
eslint-suppressions.json
Normal file
195
eslint.config.js
Normal file
@@ -0,0 +1,195 @@
|
||||
import js from '@eslint/js'
|
||||
import sonarjs from 'eslint-plugin-sonarjs'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
const javascriptFiles = ['**/*.{js,mjs,cjs,jsx}']
|
||||
|
||||
const typescriptFiles = ['**/*.{ts,tsx,mts,cts}', '**/*.vue']
|
||||
|
||||
const managedScriptExtensions = 'js,mjs,cjs,jsx,ts,tsx,mts,cts'
|
||||
|
||||
const managedFiles = [...javascriptFiles, ...typescriptFiles]
|
||||
|
||||
const browserFiles = [
|
||||
`src/**/*.{${managedScriptExtensions},vue}`,
|
||||
`examples/**/src/**/*.{${managedScriptExtensions},vue}`,
|
||||
]
|
||||
|
||||
const nodeFiles = ['**/*.config.{js,mjs,cjs,ts,mts,cts}', 'public/service.js', 'scripts/**/*.{js,mjs,cjs,ts,mts,cts}']
|
||||
|
||||
// TypeScript 关闭核心 no-undef;显式禁止浏览器域中的 Node-only globals,保证 JS、TS 与 Vue 使用同一运行时边界。
|
||||
const browserGlobalNames = new Set(Object.keys(globals.browser))
|
||||
const nodeOnlyGlobalRestrictions = Object.keys(globals.node)
|
||||
.filter(name => !browserGlobalNames.has(name))
|
||||
.sort()
|
||||
.map(name => ({
|
||||
name,
|
||||
message: `'${name}' is only available in Node.js code.`,
|
||||
}))
|
||||
|
||||
const restrictedCoreSourcePattern = '/(?:^|\\/)@core(?:\\/|$)/'
|
||||
|
||||
// @layouts 是 @core 的下游依赖;禁止反向引用,避免两层形成直接循环。
|
||||
const layoutCoreImportRestrictions = [
|
||||
'ImportDeclaration',
|
||||
'ExportNamedDeclaration',
|
||||
'ExportAllDeclaration',
|
||||
'ImportExpression',
|
||||
].map(nodeType => ({
|
||||
selector: `${nodeType}[source.value=${restrictedCoreSourcePattern}]`,
|
||||
message: "The '@layouts' module must not import from '@core'.",
|
||||
}))
|
||||
|
||||
const nonLiteralLayoutDynamicImportRestriction = {
|
||||
selector: "ImportExpression:not([source.type='Literal'])",
|
||||
message: 'Dynamic imports in @layouts must use a literal path so module boundaries remain statically verifiable.',
|
||||
}
|
||||
|
||||
const viteGlobCallSelectors = [
|
||||
"CallExpression[callee.object.type='MetaProperty'][callee.object.meta.name='import'][callee.object.property.name='meta'][callee.property.name=/^glob(?:Eager)?$/]",
|
||||
"CallExpression[callee.object.type='MetaProperty'][callee.object.meta.name='import'][callee.object.property.name='meta'][callee.property.value=/^glob(?:Eager)?$/]",
|
||||
]
|
||||
|
||||
const layoutGlobRestrictions = viteGlobCallSelectors.map(callSelector => ({
|
||||
selector: callSelector,
|
||||
message:
|
||||
'import.meta.glob is not allowed in @layouts because wildcard targets cannot be proven to stay within the module boundary.',
|
||||
}))
|
||||
|
||||
const sonarRules = {
|
||||
'sonarjs/array-callback-without-return': 'error',
|
||||
'sonarjs/code-eval': 'error',
|
||||
'sonarjs/empty-string-repetition': 'error',
|
||||
'sonarjs/no-all-duplicated-branches': 'error',
|
||||
'sonarjs/no-dead-store': 'error',
|
||||
'sonarjs/no-duplicated-branches': 'error',
|
||||
'sonarjs/no-element-overwrite': 'error',
|
||||
'sonarjs/no-hardcoded-passwords': 'error',
|
||||
'sonarjs/no-hardcoded-secrets': 'error',
|
||||
'sonarjs/no-identical-conditions': 'error',
|
||||
'sonarjs/no-identical-expressions': 'error',
|
||||
'sonarjs/no-ignored-exceptions': 'error',
|
||||
'sonarjs/no-unthrown-error': 'error',
|
||||
'sonarjs/no-use-of-empty-return-value': 'error',
|
||||
'sonarjs/reduce-initial-value': 'error',
|
||||
'sonarjs/slow-regex': 'error',
|
||||
'sonarjs/stateful-regex': 'error',
|
||||
'sonarjs/super-linear-regex': 'error',
|
||||
}
|
||||
|
||||
const typescriptConfigs = tseslint.configs.recommended.map(config => ({
|
||||
...config,
|
||||
files: typescriptFiles,
|
||||
}))
|
||||
|
||||
const vueConfigs = pluginVue.configs['flat/essential'].map(config => ({
|
||||
...config,
|
||||
files: ['**/*.vue'],
|
||||
}))
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores([
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/dev-dist/**',
|
||||
'**/coverage/**',
|
||||
'**/.worktrees/**',
|
||||
'**/vite.config.*.timestamp-*.mjs',
|
||||
'public/plugin_icon/**',
|
||||
'src/@iconify/**',
|
||||
'**/*.d.ts',
|
||||
]),
|
||||
{
|
||||
...js.configs.recommended,
|
||||
name: 'moviepilot/javascript',
|
||||
files: managedFiles,
|
||||
},
|
||||
...typescriptConfigs,
|
||||
...vueConfigs,
|
||||
{
|
||||
name: 'moviepilot/vue-contracts',
|
||||
files: ['**/*.vue'],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/valid-v-slot': [
|
||||
'error',
|
||||
{
|
||||
allowModifiers: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/language-options',
|
||||
files: managedFiles,
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'off',
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/browser-globals',
|
||||
files: browserFiles,
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'no-restricted-globals': ['error', ...nodeOnlyGlobalRestrictions],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/node-globals',
|
||||
files: nodeFiles,
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
// Node 配置可能位于浏览器源码目录;显式覆盖浏览器专用限制,避免 flat config 合并后误报。
|
||||
rules: {
|
||||
'no-restricted-globals': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/sonarjs',
|
||||
files: managedFiles,
|
||||
plugins: {
|
||||
sonarjs,
|
||||
},
|
||||
rules: sonarRules,
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/sonarjs-locales',
|
||||
files: [`src/locales/**/*.{${managedScriptExtensions}}`],
|
||||
rules: {
|
||||
'sonarjs/no-hardcoded-passwords': 'off',
|
||||
'sonarjs/no-hardcoded-secrets': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/source-hygiene',
|
||||
files: managedFiles,
|
||||
rules: {
|
||||
'no-debugger': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/layout-boundary',
|
||||
files: [`src/@layouts/**/*.{${managedScriptExtensions},vue}`],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...layoutCoreImportRestrictions,
|
||||
...layoutGlobRestrictions,
|
||||
nonLiteralLayoutDynamicImportRestriction,
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
1450
index.html
39
package.json
@@ -1,19 +1,28 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.14.4",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"prebuild": "npm run build:icons",
|
||||
"dev:pwa": "vite --host --port 5174",
|
||||
"prebuild": "npm run build:icons && npm run generate:pwa-splash",
|
||||
"build": "vite build",
|
||||
"generate:pwa-splash": "node scripts/generate-pwa-splash.mjs",
|
||||
"preview": "vite preview --port 5050",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:lint-config": "vitest run tests/config/eslint-config.spec.ts",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:suppressions:prune": "eslint . --prune-suppressions",
|
||||
"format": "node scripts/format-changed.mjs --write",
|
||||
"format:check": "node scripts/format-changed.mjs --check",
|
||||
"format:all:check": "prettier . --check",
|
||||
"test:format-config": "vitest run tests/config/format-changed.spec.ts tests/config/frontend-workflow.spec.ts",
|
||||
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
|
||||
"postinstall": "npm run build:icons",
|
||||
"pkg": "pkg . -t node18-win-x64 -o MoviePilot-Frontend.exe"
|
||||
@@ -82,6 +91,7 @@
|
||||
"webfontloader": "^1.6.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iconify-json/line-md": "^1.2.13",
|
||||
"@iconify-json/lucide": "^1.2.85",
|
||||
"@iconify-json/material-symbols": "^1.2.51",
|
||||
@@ -107,26 +117,22 @@
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/three": "^0.185.1",
|
||||
"@types/webfontloader": "^1.6.34",
|
||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
||||
"@typescript-eslint/parser": "^8.20.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vitejs/plugin-vue-jsx": "^4.1.1",
|
||||
"@vitest/coverage-v8": "3.2.7",
|
||||
"@vue/compiler-dom": "3.5.13",
|
||||
"@vue/test-utils": "2.4.11",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-import-resolver-typescript": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-promise": "^7.2.1",
|
||||
"eslint-plugin-regex": "^1.10.0",
|
||||
"eslint-plugin-sonarjs": "^3.0.1",
|
||||
"eslint-plugin-unicorn": "^56.0.1",
|
||||
"eslint-plugin-vue": "^9.12.0",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-sonarjs": "^4.2.0",
|
||||
"eslint-plugin-vue": "^10.9.2",
|
||||
"globals": "^17.3.0",
|
||||
"jsdom": "26.1.0",
|
||||
"msw": "2.15.0",
|
||||
"postcss": "^8.5.1",
|
||||
"postcss-html": "^1.5.0",
|
||||
"prettier": "^3.9.5",
|
||||
"sharp": "^0.33.5",
|
||||
"stylelint": "^16.13.2",
|
||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||
"stylelint-config-standard-scss": "^14.0.0",
|
||||
@@ -134,6 +140,7 @@
|
||||
"terser": "^5.36.0",
|
||||
"type-fest": "^4.15.0",
|
||||
"typescript": "^5.0.4",
|
||||
"typescript-eslint": "^8.64.0",
|
||||
"unplugin-auto-import": "^19.0.0",
|
||||
"unplugin-vue-components": "^28.0.0",
|
||||
"unplugin-vue-define-options": "^1.5.3",
|
||||
@@ -144,10 +151,14 @@
|
||||
"vite-plugin-vue-layouts": "^0.11.0",
|
||||
"vite-plugin-vuetify": "2.0.4",
|
||||
"vitest": "3.2.7",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-shepherd": "^4.1.0",
|
||||
"vue-tsc": "^2.0.10",
|
||||
"workbox-build": "^7.3.0",
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.18"
|
||||
"packageManager": "yarn@1.22.18",
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +65,14 @@ http {
|
||||
root html;
|
||||
}
|
||||
|
||||
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
|
||||
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$|message/agent/stream$) {
|
||||
# SSE MIME类型设置
|
||||
default_type text/event-stream;
|
||||
|
||||
# 禁用缓存
|
||||
add_header Cache-Control no-cache;
|
||||
add_header Cache-Control "no-cache, no-transform";
|
||||
add_header X-Accel-Buffering no;
|
||||
gzip off;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #9155FD;
|
||||
--primary-color: #8D51F9;
|
||||
--surface-color: #FFFFFF;
|
||||
--text-color: #333333;
|
||||
--border-color: rgba(0, 0, 0, 0.12);
|
||||
@@ -52,7 +52,7 @@
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 32px;
|
||||
background: rgba(145, 85, 253, 0.1);
|
||||
background: rgba(141, 81, 249, 0.1);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -100,7 +100,7 @@
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(145, 85, 253, 0.1);
|
||||
background: rgba(141, 81, 249, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
BIN
public/splash/apple-splash-1125-2436.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/splash/apple-splash-1136-640.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/splash/apple-splash-1170-2532.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-1179-2556.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-1206-2622.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-1242-2208.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-1242-2688.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-1260-2736.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-1284-2778.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-1290-2796.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-1320-2868.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/splash/apple-splash-1334-750.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/splash/apple-splash-1488-2266.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/splash/apple-splash-1536-2048.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/splash/apple-splash-1620-2160.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/splash/apple-splash-1640-2360.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-1668-2224.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-1668-2388.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-1792-828.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/splash/apple-splash-2048-1536.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/splash/apple-splash-2048-2732.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/splash/apple-splash-2160-1620.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2208-1242.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2224-1668.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/splash/apple-splash-2266-1488.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2360-1640.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-2388-1668.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-2436-1125.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2532-1170.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-2556-1179.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-2622-1206.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-2688-1242.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-2732-2048.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/splash/apple-splash-2736-1260.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-2778-1284.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-2796-1290.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-2868-1320.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/splash/apple-splash-640-1136.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/splash/apple-splash-750-1334.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/splash/apple-splash-828-1792.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 16 KiB |
208
scripts/format-changed.mjs
Normal file
@@ -0,0 +1,208 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { existsSync, lstatSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import * as prettier from 'prettier'
|
||||
|
||||
const DEFAULT_BASE_REFS = ['refs/remotes/upstream/v3', 'refs/remotes/origin/v3', 'refs/heads/v3']
|
||||
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024
|
||||
const PRETTIER_ARGUMENT_BUDGET = 24_000
|
||||
const prettierCliPath = fileURLToPath(new URL('../node_modules/prettier/bin/prettier.cjs', import.meta.url))
|
||||
|
||||
/** 解析 Git 的 NUL 分隔输出,保留文件名中的空格、换行和通配符字符。 */
|
||||
function parseNullDelimited(output) {
|
||||
return output.toString('utf8').split('\0').filter(Boolean)
|
||||
}
|
||||
|
||||
/** 运行只返回仓库相对路径的 Git 查询。 */
|
||||
function gitPaths(cwd, args) {
|
||||
return parseNullDelimited(
|
||||
execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'buffer',
|
||||
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** 尝试把引用解析为提交 SHA;完整引用可避免分支与标签同名时产生歧义。 */
|
||||
function tryResolveCommit(cwd, ref) {
|
||||
const result = spawnSync('git', ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
return result.status === 0 ? result.stdout.trim() : undefined
|
||||
}
|
||||
|
||||
/** 把外部引用解析为提交 SHA,后续 Git diff 不再解释用户提供的选项样式字符串。 */
|
||||
function resolveCommit(cwd, ref) {
|
||||
const commit = tryResolveCommit(cwd, ref)
|
||||
if (commit) return commit
|
||||
throw new Error(`无法解析 Git 提交引用:${ref}`)
|
||||
}
|
||||
|
||||
/** 判断两个提交能否进行三点比较。 */
|
||||
function hasMergeBase(cwd, baseCommit, headCommit) {
|
||||
return spawnSync('git', ['merge-base', baseCommit, headCommit], { cwd, stdio: 'ignore' }).status === 0
|
||||
}
|
||||
|
||||
/** 为本地命令选择与当前提交具有共同祖先的 v3 基线。 */
|
||||
function resolveDefaultBase(cwd, headCommit) {
|
||||
for (const ref of DEFAULT_BASE_REFS) {
|
||||
const commit = tryResolveCommit(cwd, ref)
|
||||
if (commit && hasMergeBase(cwd, commit, headCommit)) return commit
|
||||
}
|
||||
|
||||
throw new Error('无法定位可比较的 v3 基线,请通过 --base <ref> 显式指定比较起点。')
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集当前分支相对基线的文件集合。
|
||||
* 显式 base/head 模式只读取提交区间,默认本地模式还会合并暂存区、工作区和未跟踪文件。
|
||||
*/
|
||||
function collectChangedPaths({ cwd, base, head }) {
|
||||
if (Boolean(base) !== Boolean(head)) {
|
||||
throw new Error('--base 与 --head 必须一起使用。')
|
||||
}
|
||||
|
||||
const explicitRange = Boolean(base)
|
||||
const headCommit = resolveCommit(cwd, head ?? 'HEAD')
|
||||
const baseCommit = base ? resolveCommit(cwd, base) : resolveDefaultBase(cwd, headCommit)
|
||||
if (!hasMergeBase(cwd, baseCommit, headCommit)) {
|
||||
throw new Error('Git 比较起点与终点没有共同祖先,无法执行三点比较。')
|
||||
}
|
||||
const paths = new Set(
|
||||
gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', `${baseCommit}...${headCommit}`]),
|
||||
)
|
||||
|
||||
if (!explicitRange) {
|
||||
for (const filePath of gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', 'HEAD'])) {
|
||||
paths.add(filePath)
|
||||
}
|
||||
for (const filePath of gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z'])) {
|
||||
paths.add(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
return [...paths]
|
||||
.filter(filePath => {
|
||||
const absolutePath = resolve(cwd, filePath)
|
||||
if (!existsSync(absolutePath)) return false
|
||||
try {
|
||||
return lstatSync(absolutePath).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.sort()
|
||||
}
|
||||
|
||||
/** 使用仓库 ignore 文件和 Prettier parser 推断筛出真正受格式化工具管理的文件。 */
|
||||
async function selectPrettierFiles(filePaths, cwd) {
|
||||
const ignorePaths = ['.gitignore', '.prettierignore'].map(filePath => resolve(cwd, filePath)).filter(existsSync)
|
||||
const selected = []
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const absolutePath = resolve(cwd, filePath)
|
||||
const fileInfo = await prettier.getFileInfo(absolutePath)
|
||||
if (!fileInfo.inferredParser) continue
|
||||
|
||||
let ignored = false
|
||||
for (const ignorePath of ignorePaths) {
|
||||
if ((await prettier.getFileInfo(absolutePath, { ignorePath })).ignored) {
|
||||
ignored = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!ignored) selected.push(filePath)
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
/** 按保守的参数长度预算拆批,避免大型 PR 超过操作系统命令行上限。 */
|
||||
function batchPaths(filePaths) {
|
||||
const batches = []
|
||||
let batch = []
|
||||
let length = 0
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
if (batch.length > 0 && length + filePath.length + 1 > PRETTIER_ARGUMENT_BUDGET) {
|
||||
batches.push(batch)
|
||||
batch = []
|
||||
length = 0
|
||||
}
|
||||
batch.push(filePath)
|
||||
length += filePath.length + 1
|
||||
}
|
||||
|
||||
if (batch.length > 0) batches.push(batch)
|
||||
return batches
|
||||
}
|
||||
|
||||
/** 通过参数数组调用项目内 Prettier,避免 shell 展开或拼接特殊文件名。 */
|
||||
function runPrettier(filePaths, mode, cwd) {
|
||||
if (filePaths.length === 0) {
|
||||
console.log('没有需要 Prettier 处理的变更文件。')
|
||||
return
|
||||
}
|
||||
|
||||
for (const batch of batchPaths(filePaths)) {
|
||||
const result = spawnSync(process.execPath, [prettierCliPath, mode, '--ignore-unknown', '--', ...batch], {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Prettier ${mode} 失败,退出码:${result.status ?? 'unknown'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析面向开发者和 CI 的稳定命令行契约。 */
|
||||
function parseArguments(args) {
|
||||
const options = { action: undefined, base: undefined, head: undefined }
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const argument = args[index]
|
||||
if (['--check', '--write', '--list'].includes(argument)) {
|
||||
if (options.action) throw new Error('只能指定 --check、--write 或 --list 中的一种操作。')
|
||||
options.action = argument
|
||||
continue
|
||||
}
|
||||
if (argument === '--base' || argument === '--head') {
|
||||
const value = args[index + 1]
|
||||
if (!value) throw new Error(`${argument} 缺少引用值。`)
|
||||
options[argument.slice(2)] = value
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
throw new Error(`未知参数:${argument}`)
|
||||
}
|
||||
|
||||
if (!options.action) throw new Error('必须指定 --check、--write 或 --list。')
|
||||
return options
|
||||
}
|
||||
|
||||
/** 执行变更文件选择与格式化;显式引用模式为 CI 提供确定输入。 */
|
||||
async function main() {
|
||||
const cwd = process.cwd()
|
||||
const { action, base, head } = parseArguments(process.argv.slice(2))
|
||||
const changedPaths = collectChangedPaths({ cwd, base, head })
|
||||
const selectedPaths = await selectPrettierFiles(changedPaths, cwd)
|
||||
|
||||
if (action === '--list') {
|
||||
process.stdout.write(`${JSON.stringify(selectedPaths)}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
runPrettier(selectedPaths, action, cwd)
|
||||
}
|
||||
|
||||
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
|
||||
main().catch(error => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
68
scripts/generate-pwa-splash.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
import appleSplashSpecs from './pwa-splash-specs.json' with { type: 'json' }
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const logoPath = path.join(projectRoot, 'public', 'logo.svg')
|
||||
const outputDirectory = path.join(projectRoot, 'public', 'splash')
|
||||
const background = '#0E1116'
|
||||
|
||||
async function createSplash(width, height, scaleFactor, outputPath, format) {
|
||||
// Match the DOM loader's `min(160px, 36vw)` in CSS pixels, then convert it
|
||||
// to physical pixels for the selected Apple launch image.
|
||||
const logoSize = Math.round(Math.min(160, (width / scaleFactor) * 0.36) * scaleFactor)
|
||||
const logo = await sharp(logoPath).resize(logoSize, logoSize, { fit: 'contain' }).png().toBuffer()
|
||||
const image = sharp({
|
||||
create: {
|
||||
width,
|
||||
height,
|
||||
channels: 4,
|
||||
background,
|
||||
},
|
||||
}).composite([
|
||||
{
|
||||
input: logo,
|
||||
left: Math.round((width - logoSize) / 2),
|
||||
top: Math.round((height - logoSize) / 2),
|
||||
},
|
||||
])
|
||||
|
||||
if (format === 'png') {
|
||||
await image.png({ compressionLevel: 9 }).toFile(outputPath)
|
||||
return
|
||||
}
|
||||
|
||||
await image.flatten({ background }).jpeg({ quality: 88, progressive: true }).toFile(outputPath)
|
||||
}
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true })
|
||||
|
||||
for (const { width: portraitWidth, height: portraitHeight, scaleFactor } of appleSplashSpecs) {
|
||||
const landscapeWidth = portraitHeight
|
||||
const landscapeHeight = portraitWidth
|
||||
|
||||
await createSplash(
|
||||
portraitWidth,
|
||||
portraitHeight,
|
||||
scaleFactor,
|
||||
path.join(outputDirectory, `apple-splash-${portraitWidth}-${portraitHeight}.jpg`),
|
||||
'jpg',
|
||||
)
|
||||
await createSplash(
|
||||
landscapeWidth,
|
||||
landscapeHeight,
|
||||
scaleFactor,
|
||||
path.join(outputDirectory, `apple-splash-${landscapeWidth}-${landscapeHeight}.jpg`),
|
||||
'jpg',
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the previous fallback filename for older deployments and bookmarked
|
||||
// entries that may still reference it. Its palette matches the new assets.
|
||||
await createSplash(750, 1334, 2, path.join(outputDirectory, 'apple-splash.png'), 'png')
|
||||
|
||||
console.log(
|
||||
`Generated ${appleSplashSpecs.length * 2 + 1} PWA splash assets in ${path.relative(projectRoot, outputDirectory)}`,
|
||||
)
|
||||
294
scripts/pwa-development.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const PWA_DEVELOPMENT_SCRIPT = 'dev:pwa'
|
||||
export const DEV_SW_CLEANUP_PATH = '/__moviepilot_dev_sw_cleanup__'
|
||||
|
||||
const devEntryScriptTag = '<script type="module" src="/src/main.ts"></script>'
|
||||
const devEntryScriptUrl = '/src/main.ts'
|
||||
const moviePilotWorkerScripts = ['dev-sw.js?dev-sw', 'service-worker.js']
|
||||
const moviePilotIdentityMessage = 'GET_UNREAD_COUNT'
|
||||
const moviePilotIdentityTimeoutMs = 1500
|
||||
const moviePilotIdentityAttempts = 2
|
||||
|
||||
/** 普通 development mode 仅在显式 dev:pwa 脚本中启用开发 Service Worker。 */
|
||||
export const isPwaDevelopmentEnabled = (mode: string, lifecycleEvent?: string) =>
|
||||
mode === 'development' && lifecycleEvent === PWA_DEVELOPMENT_SCRIPT
|
||||
|
||||
/** 普通开发服务器才执行历史 Service Worker 清理,production preview 保持构建产物行为。 */
|
||||
export const shouldEnableDevServiceWorkerCleanup = (
|
||||
command: 'build' | 'serve',
|
||||
mode: string,
|
||||
isPreview: boolean | undefined,
|
||||
lifecycleEvent?: string,
|
||||
) =>
|
||||
command === 'serve' && mode === 'development' && isPreview !== true && !isPwaDevelopmentEnabled(mode, lifecycleEvent)
|
||||
|
||||
/** 解析当前页面所属的应用根目录,兼容根路径和子路径部署。 */
|
||||
export function resolveDevAppScope(pageUrl: string): URL {
|
||||
return new URL('./', new URL(pageUrl))
|
||||
}
|
||||
|
||||
/** Worker 的 scope 和脚本 URL 都必须精确落在当前应用根目录。 */
|
||||
export function isManagedServiceWorkerRegistration(
|
||||
scriptUrl: string,
|
||||
registrationScope: string,
|
||||
pageUrl: string,
|
||||
): boolean {
|
||||
const appScope = resolveDevAppScope(pageUrl)
|
||||
const scope = new URL(registrationScope)
|
||||
const script = new URL(scriptUrl)
|
||||
|
||||
return (
|
||||
scope.href === appScope.href &&
|
||||
moviePilotWorkerScripts.some(workerScript => script.href === new URL(workerScript, appScope).href)
|
||||
)
|
||||
}
|
||||
|
||||
/** 复用现有轻量消息协议确认 Worker 确由 MoviePilot 提供。 */
|
||||
export function isMoviePilotServiceWorkerIdentityResponse(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
|
||||
return typeof (value as { count?: unknown }).count === 'number'
|
||||
}
|
||||
|
||||
/** 身份探测允许有限次重试;持续失败时保持现有注册,避免误清理未知 Worker。 */
|
||||
export async function retryMoviePilotIdentityVerification(
|
||||
verifyOnce: () => Promise<boolean>,
|
||||
attempts: number,
|
||||
): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
if (await verifyOnce()) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 删除当前 origin 的全部 Cache Storage;调用方必须先确认该 dev origin 由 MoviePilot Worker 管理。 */
|
||||
export async function deleteCurrentOriginCaches(cacheStorage: Pick<CacheStorage, 'delete' | 'keys'>): Promise<void> {
|
||||
const cacheNames = await cacheStorage.keys()
|
||||
await Promise.all(cacheNames.map(cacheName => cacheStorage.delete(cacheName)))
|
||||
}
|
||||
|
||||
/** 清理完成后只允许返回当前 origin,避免开发中间页形成开放重定向。 */
|
||||
export function resolveDevCleanupReturnUrl(requested: string | null, origin: string): URL {
|
||||
if (!requested) return new URL('/', origin)
|
||||
const target = new URL(requested, origin)
|
||||
return target.origin === origin ? target : new URL('/', origin)
|
||||
}
|
||||
|
||||
/**
|
||||
* 普通开发服务器不应被历史 Service Worker 控制;受控页面先进入独立清理页,避免旧缓存模块抢先执行。
|
||||
*/
|
||||
export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
||||
const workerScripts = JSON.stringify(moviePilotWorkerScripts)
|
||||
const identityMessage = JSON.stringify(moviePilotIdentityMessage)
|
||||
const identityTimeoutMs = JSON.stringify(moviePilotIdentityTimeoutMs)
|
||||
const identityAttempts = JSON.stringify(moviePilotIdentityAttempts)
|
||||
const retryIdentityVerification = retryMoviePilotIdentityVerification.toString()
|
||||
const deleteOriginCaches = deleteCurrentOriginCaches.toString()
|
||||
const entryScriptUrl = JSON.stringify(devEntryScriptUrl)
|
||||
const cleanupPath = JSON.stringify(DEV_SW_CLEANUP_PATH)
|
||||
const cleanupAttemptKeyPrefix = JSON.stringify('moviepilot:dev-sw-cleanup')
|
||||
|
||||
const redirectScript = `
|
||||
(() => {
|
||||
const entryScriptUrl = ${entryScriptUrl}
|
||||
const deleteCurrentOriginCaches = ${deleteOriginCaches}
|
||||
let appStarted = false
|
||||
const startApp = () => {
|
||||
if (appStarted) return
|
||||
appStarted = true
|
||||
const entry = document.createElement('script')
|
||||
entry.type = 'module'
|
||||
entry.src = entryScriptUrl
|
||||
document.head.appendChild(entry)
|
||||
}
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
startApp()
|
||||
return
|
||||
}
|
||||
|
||||
const workerScripts = ${workerScripts}
|
||||
const identityMessage = ${identityMessage}
|
||||
const identityTimeoutMs = ${identityTimeoutMs}
|
||||
const identityAttempts = ${identityAttempts}
|
||||
const retryIdentityVerification = ${retryIdentityVerification}
|
||||
const cleanupPath = ${cleanupPath}
|
||||
const appScope = new URL('./', location.href)
|
||||
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
|
||||
const cleanupState = sessionStorage.getItem(cleanupAttemptKey)
|
||||
const getCandidateWorker = registration => {
|
||||
if (new URL(registration.scope).href !== appScope.href) return null
|
||||
return [registration.active, registration.waiting, registration.installing].find(worker => {
|
||||
if (!worker) return false
|
||||
const scriptUrl = new URL(worker.scriptURL).href
|
||||
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
|
||||
}) || null
|
||||
}
|
||||
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
|
||||
const channel = new MessageChannel()
|
||||
const finish = result => {
|
||||
window.clearTimeout(timeout)
|
||||
channel.port1.close()
|
||||
resolve(result)
|
||||
}
|
||||
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
|
||||
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
|
||||
try {
|
||||
worker.postMessage({ type: identityMessage }, [channel.port2])
|
||||
} catch {
|
||||
finish(false)
|
||||
}
|
||||
})
|
||||
const verifyMoviePilotWorker = worker =>
|
||||
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
|
||||
const hasVerifiedRegistration = async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
for (const registration of registrations) {
|
||||
const worker = getCandidateWorker(registration)
|
||||
if (worker && await verifyMoviePilotWorker(worker)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const redirectToCleanup = () => {
|
||||
sessionStorage.setItem(cleanupAttemptKey, 'pending')
|
||||
const target = new URL(cleanupPath.slice(1), appScope)
|
||||
target.searchParams.set('return', location.href)
|
||||
location.replace(target.href)
|
||||
}
|
||||
|
||||
// unregister 不会立即解除当前 document 的 controller;应用模块加载前需再导航一次以脱离旧 Worker。
|
||||
if (cleanupState === 'complete') {
|
||||
void (async () => {
|
||||
// 旧 Worker 可能在注销后的首次导航中重新创建缓存;脱离控制后再清理一次。
|
||||
if ('caches' in window) await deleteCurrentOriginCaches(caches)
|
||||
sessionStorage.removeItem(cleanupAttemptKey)
|
||||
location.reload()
|
||||
})().catch(error => {
|
||||
console.error('[PWA] Failed to finish stale development cache cleanup', error)
|
||||
document.body.textContent = 'Failed to finish stale development cache cleanup. Reload to retry.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
void hasVerifiedRegistration().then(hasRegistration => {
|
||||
if (hasRegistration) {
|
||||
redirectToCleanup()
|
||||
return
|
||||
}
|
||||
sessionStorage.removeItem(cleanupAttemptKey)
|
||||
startApp()
|
||||
}).catch(error => {
|
||||
console.warn('[PWA] Failed to inspect historical Service Worker state', error)
|
||||
startApp()
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
const cleanupDocument = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="UTF-8"><title>MoviePilot Dev Cleanup</title></head>
|
||||
<body>
|
||||
<script>
|
||||
(() => {
|
||||
const workerScripts = ${workerScripts}
|
||||
const identityMessage = ${identityMessage}
|
||||
const identityTimeoutMs = ${identityTimeoutMs}
|
||||
const identityAttempts = ${identityAttempts}
|
||||
const retryIdentityVerification = ${retryIdentityVerification}
|
||||
const deleteCurrentOriginCaches = ${deleteOriginCaches}
|
||||
const appScope = new URL('./', location.href)
|
||||
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
|
||||
const resolveReturnUrl = () => {
|
||||
const requested = new URLSearchParams(location.search).get('return')
|
||||
if (!requested) return appScope
|
||||
const target = new URL(requested, appScope)
|
||||
return target.href.startsWith(appScope.href) ? target : appScope
|
||||
}
|
||||
const returnUrl = resolveReturnUrl()
|
||||
const getCandidateWorker = registration => {
|
||||
if (new URL(registration.scope).href !== appScope.href) return null
|
||||
return [registration.active, registration.waiting, registration.installing].find(worker => {
|
||||
if (!worker) return false
|
||||
const scriptUrl = new URL(worker.scriptURL).href
|
||||
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
|
||||
}) || null
|
||||
}
|
||||
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
|
||||
const channel = new MessageChannel()
|
||||
const finish = result => {
|
||||
window.clearTimeout(timeout)
|
||||
channel.port1.close()
|
||||
resolve(result)
|
||||
}
|
||||
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
|
||||
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
|
||||
try {
|
||||
worker.postMessage({ type: identityMessage }, [channel.port2])
|
||||
} catch {
|
||||
finish(false)
|
||||
}
|
||||
})
|
||||
const verifyMoviePilotWorker = worker =>
|
||||
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
|
||||
const cleanup = async () => {
|
||||
if (sessionStorage.getItem(cleanupAttemptKey) !== 'pending') {
|
||||
throw new Error('Missing Service Worker cleanup context for current application scope')
|
||||
}
|
||||
const registrations = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistrations() : []
|
||||
const managedRegistrations = []
|
||||
for (const registration of registrations) {
|
||||
const worker = getCandidateWorker(registration)
|
||||
if (worker && await verifyMoviePilotWorker(worker)) managedRegistrations.push(registration)
|
||||
}
|
||||
if (!managedRegistrations.length) throw new Error('MoviePilot Service Worker identity verification failed')
|
||||
await Promise.allSettled(managedRegistrations.map(registration => registration.unregister()))
|
||||
|
||||
// Vite dev server 使用独立 origin;仅在确认 MoviePilot Worker 后清除其遗留模块响应。
|
||||
if ('caches' in window) await deleteCurrentOriginCaches(caches)
|
||||
|
||||
// 回跳入口后由 head-prepend 脚本完成第二次导航,避免同一 client 继续复用旧模块响应。
|
||||
sessionStorage.setItem(cleanupAttemptKey, 'complete')
|
||||
location.replace(returnUrl.href)
|
||||
}
|
||||
|
||||
void cleanup().catch(error => {
|
||||
console.error('[PWA] Failed to clean stale development Service Worker state', error)
|
||||
document.body.textContent = 'Failed to clean stale development Service Worker state. Reload to retry.'
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return {
|
||||
name: 'moviepilot:dev-service-worker-cleanup',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const pathname = new URL(request.url || '/', 'http://localhost').pathname
|
||||
if (!pathname.endsWith(DEV_SW_CLEANUP_PATH)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
response.statusCode = 200
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
response.setHeader('Cache-Control', 'no-store')
|
||||
response.end(cleanupDocument)
|
||||
})
|
||||
},
|
||||
transformIndexHtml: {
|
||||
order: 'pre',
|
||||
handler(html) {
|
||||
if (!html.includes(devEntryScriptTag)) {
|
||||
throw new Error(`Expected development entry tag: ${devEntryScriptTag}`)
|
||||
}
|
||||
|
||||
return {
|
||||
html: html.replace(devEntryScriptTag, ''),
|
||||
tags: [{ tag: 'script', children: redirectScript, injectTo: 'head-prepend' }],
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
22
scripts/pwa-splash-specs.json
Normal file
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{ "width": 2048, "height": 2732, "scaleFactor": 2 },
|
||||
{ "width": 1668, "height": 2388, "scaleFactor": 2 },
|
||||
{ "width": 1536, "height": 2048, "scaleFactor": 2 },
|
||||
{ "width": 1640, "height": 2360, "scaleFactor": 2 },
|
||||
{ "width": 1668, "height": 2224, "scaleFactor": 2 },
|
||||
{ "width": 1620, "height": 2160, "scaleFactor": 2 },
|
||||
{ "width": 1488, "height": 2266, "scaleFactor": 2 },
|
||||
{ "width": 1320, "height": 2868, "scaleFactor": 3 },
|
||||
{ "width": 1206, "height": 2622, "scaleFactor": 3 },
|
||||
{ "width": 1260, "height": 2736, "scaleFactor": 3 },
|
||||
{ "width": 1290, "height": 2796, "scaleFactor": 3 },
|
||||
{ "width": 1179, "height": 2556, "scaleFactor": 3 },
|
||||
{ "width": 1170, "height": 2532, "scaleFactor": 3 },
|
||||
{ "width": 1284, "height": 2778, "scaleFactor": 3 },
|
||||
{ "width": 1125, "height": 2436, "scaleFactor": 3 },
|
||||
{ "width": 1242, "height": 2688, "scaleFactor": 3 },
|
||||
{ "width": 828, "height": 1792, "scaleFactor": 2 },
|
||||
{ "width": 1242, "height": 2208, "scaleFactor": 3 },
|
||||
{ "width": 750, "height": 1334, "scaleFactor": 2 },
|
||||
{ "width": 640, "height": 1136, "scaleFactor": 2 }
|
||||
]
|
||||
@@ -72,7 +72,7 @@ function handleCancel() {
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
<VCardActions class="mx-auto">
|
||||
<VCardActions class="app-confirm-dialog-actions mx-auto">
|
||||
<VBtn variant="tonal" color="secondary" class="px-5" @click="handleCancel">
|
||||
{{ cancelText }}
|
||||
</VBtn>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
// 定义输入参数
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps({
|
||||
// 是否显示
|
||||
/** 覆盖关闭按钮默认的右上角定位 class。 */
|
||||
innerClass: String,
|
||||
})
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['click', 'update:modelValue'])
|
||||
// 按钮点击
|
||||
|
||||
function onClick() {
|
||||
emit('update:modelValue', false)
|
||||
emit('click')
|
||||
@@ -15,6 +16,7 @@ function onClick() {
|
||||
|
||||
<template>
|
||||
<IconBtn
|
||||
:aria-label="t('common.close')"
|
||||
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'"
|
||||
@click.stop="onClick"
|
||||
>
|
||||
|
||||
40
src/@core/components/__tests__/DialogCloseBtn.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
i18n.global.locale.value = 'zh-CN'
|
||||
})
|
||||
|
||||
describe('DialogCloseBtn', () => {
|
||||
it('keeps its visual and event contracts while exposing a localized name', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container, emitted, rerender } = await renderWithProviders(DialogCloseBtn)
|
||||
const button = screen.getByRole('button', { name: '关闭' })
|
||||
|
||||
expect(button).toHaveClass('absolute', 'right-3', 'top-3', 'z-10')
|
||||
const icon = container.querySelector('svg.v-icon')
|
||||
expect(icon).not.toBeNull()
|
||||
expect(icon).toHaveAttribute('aria-hidden', 'true')
|
||||
await waitFor(() =>
|
||||
expect(icon?.querySelector('path')).toHaveAttribute(
|
||||
'd',
|
||||
'M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z',
|
||||
),
|
||||
)
|
||||
|
||||
i18n.global.locale.value = 'en-US'
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toBe(button))
|
||||
|
||||
await user.click(button)
|
||||
expect(emitted()['update:modelValue']).toEqual([[false]])
|
||||
expect(emitted().click).toEqual([[]])
|
||||
|
||||
await rerender({ innerClass: 'dialog-close-custom' })
|
||||
expect(button).toHaveClass('dialog-close-custom')
|
||||
expect(button).not.toHaveClass('absolute', 'right-3', 'top-3', 'z-10')
|
||||
})
|
||||
})
|
||||
@@ -96,7 +96,8 @@
|
||||
|
||||
// 👉 Nav Link
|
||||
.nav-link {
|
||||
overflow: hidden;
|
||||
// 交互项已预留下边距承接激活阴影,父级不能再次裁切这段视觉溢出。
|
||||
overflow: visible;
|
||||
|
||||
> :first-child {
|
||||
@extend %vertical-nav-item;
|
||||
|
||||
@@ -33,11 +33,3 @@ $ps-track-size: 0.5rem;
|
||||
.ps__rail-y:hover > .ps__thumb-y {
|
||||
inline-size: $ps-hover-size;
|
||||
}
|
||||
|
||||
// fix bug
|
||||
@media(hover: none) {
|
||||
.ps > .ps__rail-x,
|
||||
.ps > .ps__rail-y {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
53
src/@core/utils/__tests__/image.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('colorthief', () => ({
|
||||
default: class ColorThief {},
|
||||
}))
|
||||
|
||||
import { preloadCorsImage } from '@/@core/utils/image'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
describe('preloadCorsImage', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('uses a CORS-clean cached response without reloading it', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
|
||||
ok: true,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(preloadCorsImage('https://image.example/wallpaper.jpg')).resolves.toBe(true)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL('https://image.example/wallpaper.jpg'),
|
||||
expect.objectContaining({ cache: 'force-cache', credentials: 'omit', mode: 'cors' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('reloads a response when an earlier non-CORS cache entry blocks the first request', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
.mockResolvedValueOnce({
|
||||
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
|
||||
ok: true,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(preloadCorsImage('/wallpaper.jpg')).resolves.toBe(true)
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
new URL('/wallpaper.jpg', window.location.href),
|
||||
expect.objectContaining({ cache: 'reload', credentials: 'same-origin', mode: 'cors' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when the source cannot be read with CORS', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
|
||||
await expect(preloadCorsImage('https://image.example/wallpaper.jpg')).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
27
src/@core/utils/corsImage.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** 建立带 CORS 响应头的浏览器缓存,必要时修复旧的非 CORS 缓存条目。 */
|
||||
export async function preloadCorsImage(url: string): Promise<boolean> {
|
||||
const request = async (cache: RequestCache) => {
|
||||
const source = new URL(url, window.location.href)
|
||||
const response = await fetch(source, {
|
||||
cache,
|
||||
credentials: source.origin === window.location.origin ? 'same-origin' : 'omit',
|
||||
mode: 'cors',
|
||||
})
|
||||
if (!response.ok) return false
|
||||
|
||||
await response.blob()
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
if (await request('force-cache')) return true
|
||||
} catch {
|
||||
// 缓存中的非 CORS 响应可能使首次读取失败,重新验证后再决定是否回退。
|
||||
}
|
||||
|
||||
try {
|
||||
return await request('reload')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import ColorThief from 'colorthief'
|
||||
export { preloadCorsImage } from './corsImage'
|
||||
|
||||
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
|
||||
const DOMINANT_COLOR_CACHE_LIMIT = 100
|
||||
@@ -70,22 +71,29 @@ export async function getDominantColor(
|
||||
export async function preloadImage(url: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image()
|
||||
let settled = false
|
||||
const finish = (available: boolean) => {
|
||||
if (settled) return
|
||||
|
||||
img.onload = () => resolve(true)
|
||||
img.onerror = () => resolve(false)
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
resolve(available)
|
||||
}
|
||||
|
||||
img.onload = () => finish(true)
|
||||
img.onerror = () => finish(false)
|
||||
|
||||
// 设置超时,防止图片长时间加载
|
||||
const timeout = setTimeout(() => {
|
||||
img.src = ''
|
||||
resolve(false)
|
||||
finish(false)
|
||||
}, 5000) // 5秒超时
|
||||
|
||||
img.src = url
|
||||
|
||||
// 如果图片已经缓存,onload可能不会触发
|
||||
if (img.complete) {
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
finish(img.naturalWidth > 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,4 +3,9 @@ export function saveLocalTheme(name: string, theme: any) {
|
||||
localStorage.setItem('theme', name)
|
||||
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
|
||||
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)
|
||||
|
||||
// 自动主题下次恢复时需要一个稳定的首帧明暗结果,避免媒体查询短暂返回浅色。
|
||||
if (name === 'auto') {
|
||||
localStorage.setItem('materio-initial-resolved-theme', theme.current.value.dark ? 'dark' : 'light')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import logo from '@images/logo.svg?raw'
|
||||
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
|
||||
|
||||
interface Props {
|
||||
tag?: string | Component
|
||||
@@ -51,10 +51,10 @@ function handleNavScroll(evt: Event) {
|
||||
<div class="nav-header">
|
||||
<slot name="nav-header">
|
||||
<RouterLink to="/" class="app-logo d-flex align-center app-title-wrapper">
|
||||
<div class="d-flex" v-html="logo" />
|
||||
<ThemeLogoMark />
|
||||
|
||||
<h1 class="font-weight-bold leading-normal text-xl">
|
||||
MOVIEPILOT <span class="text-sm text-gray-500">v2</span>
|
||||
<h1 class="leading-normal text-xl">
|
||||
<span class="moviepilot-wordmark">MOVIEPILOT</span> <span class="text-sm text-gray-500">v3</span>
|
||||
</h1>
|
||||
</RouterLink>
|
||||
</slot>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
||||
import {
|
||||
readThemeCustomizerSettings,
|
||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||
type ThemeCustomizerSettings,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import { useGlassFixedShellBackplate } from '@/composables/useGlassFixedShellBackplate'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
export default defineComponent({
|
||||
@@ -17,6 +19,7 @@ export default defineComponent({
|
||||
const route = useRoute()
|
||||
const { mdAndDown } = useDisplay()
|
||||
const { appMode } = usePWA()
|
||||
const fixedShellBackplate = useGlassFixedShellBackplate()
|
||||
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
||||
const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value)
|
||||
const isCollapsedLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'collapsed')
|
||||
@@ -73,6 +76,8 @@ export default defineComponent({
|
||||
})
|
||||
|
||||
return () => {
|
||||
const hasFixedShellBackplate = fixedShellBackplate.layers.value.length > 0
|
||||
|
||||
// 👉 Vertical nav
|
||||
const verticalNav = h(
|
||||
VerticalNav,
|
||||
@@ -85,6 +90,15 @@ export default defineComponent({
|
||||
},
|
||||
)
|
||||
|
||||
const fixedShellBackplateNode = hasFixedShellBackplate
|
||||
? h(GlassFixedShellBackplate, {
|
||||
isOverlayNav: mdAndDown.value,
|
||||
isOverlayNavActive: isOverlayNavActive.value,
|
||||
layers: fixedShellBackplate.layers.value,
|
||||
transitionDurationMs: fixedShellBackplate.transitionDurationMs,
|
||||
})
|
||||
: null
|
||||
|
||||
// 👉 Navbar
|
||||
const navbar = h(
|
||||
'header',
|
||||
@@ -145,11 +159,17 @@ export default defineComponent({
|
||||
isCollapsedLayout.value && 'layout-vertical-nav-collapsed',
|
||||
isHorizontalLayout.value && 'layout-horizontal-nav-active',
|
||||
isHorizontalLayout.value && isNavbarScrolled && 'layout-horizontal-nav-scrolled',
|
||||
hasFixedShellBackplate && 'layout-fixed-shell-backplate-active',
|
||||
route.meta.layoutWrapperClasses,
|
||||
!isHorizontalLayout.value && isNavbarScrolled && 'window-scrolled',
|
||||
],
|
||||
},
|
||||
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
|
||||
[
|
||||
fixedShellBackplateNode,
|
||||
verticalNav,
|
||||
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
|
||||
layoutOverlay,
|
||||
],
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -193,7 +213,7 @@ export default defineComponent({
|
||||
// 单独提升顶栏图层可避免导航栏短暂上移到安全区下方。
|
||||
backface-visibility: hidden;
|
||||
block-size: var(--layout-navbar-block-size);
|
||||
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
|
||||
inline-size: calc(100% - variables.$layout-vertical-nav-width);
|
||||
inset-block-start: 0;
|
||||
transform: translate3d(0, 0, 0);
|
||||
|
||||
@@ -257,7 +277,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
&.layout-vertical-nav-collapsed .layout-navbar {
|
||||
inline-size: calc(100vw - variables.$layout-vertical-nav-collapsed-width - 0.5rem);
|
||||
inline-size: calc(100% - variables.$layout-vertical-nav-collapsed-width);
|
||||
}
|
||||
|
||||
&.layout-vertical-nav-collapsed .layout-vertical-nav:not(.overlay-nav) {
|
||||
@@ -273,16 +293,7 @@ export default defineComponent({
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.app-logo > div {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 2.75rem;
|
||||
inline-size: 2.75rem;
|
||||
}
|
||||
|
||||
.app-logo svg {
|
||||
.app-logo .theme-logo-mark {
|
||||
block-size: 2.5rem;
|
||||
inline-size: 2.5rem;
|
||||
}
|
||||
|
||||
903
src/App.vue
176
src/__tests__/ace-config.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import ace from 'ace-builds'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import '@/ace-config'
|
||||
|
||||
interface AceToken {
|
||||
type: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface WordListMode {
|
||||
getTokenizer: () => {
|
||||
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
|
||||
}
|
||||
}
|
||||
|
||||
const WordListMode = ace.require('ace/mode/word_list').Mode as new (options?: { syntax?: boolean }) => WordListMode
|
||||
const syntaxTokenizer = new WordListMode({ syntax: true }).getTokenizer()
|
||||
const plainTokenizer = new WordListMode().getTokenizer()
|
||||
|
||||
function tokenize(line: string) {
|
||||
return syntaxTokenizer.getLineTokens(line, 'start').tokens
|
||||
}
|
||||
|
||||
function tokenizePlain(line: string) {
|
||||
return plainTokenizer.getLineTokens(line, 'start').tokens
|
||||
}
|
||||
|
||||
describe('word list syntax mode', () => {
|
||||
it('highlights a block word as one field', () => {
|
||||
expect(tokenize('屏蔽词')).toEqual([{ type: 'word_list_block', value: '屏蔽词' }])
|
||||
})
|
||||
|
||||
it('parses valid replacement parameters', () => {
|
||||
expect(tokenize('旧名.* => 新名 {[tmdbid=123;type=tv]}')).toEqual([
|
||||
{ type: 'word_list_replaced', value: '旧名.*' },
|
||||
{ type: 'keyword.operator.word-list', value: ' => ' },
|
||||
{ type: 'word_list_replacement', value: '新名 ' },
|
||||
{ type: 'word_list_parameter_syntax', value: '{[' },
|
||||
{ type: 'word_list_parameter_key', value: 'tmdbid' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: '123' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'type' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: 'tv' },
|
||||
{ type: 'word_list_parameter_syntax', value: ']}' },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks invalid replacement parameter keys and values', () => {
|
||||
expect(tokenize('旧名 => 新名 {[unknown=1;tmdbid=abc;type=anime;g=group;s=2;e=3]}')).toEqual([
|
||||
{ type: 'word_list_replaced', value: '旧名' },
|
||||
{ type: 'keyword.operator.word-list', value: ' => ' },
|
||||
{ type: 'word_list_replacement', value: '新名 ' },
|
||||
{ type: 'word_list_parameter_syntax', value: '{[' },
|
||||
{ type: 'invalid.word-list', value: 'unknown' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: '1' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'tmdbid' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'invalid.word-list', value: 'abc' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'type' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'invalid.word-list', value: 'anime' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'g' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: 'group' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 's' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: '2' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'e' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: '3' },
|
||||
{ type: 'word_list_parameter_syntax', value: ']}' },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks an unknown replacement parameter key as invalid', () => {
|
||||
expect(tokenize('旧名 => {[unknown=1]}')).toContainEqual({ type: 'invalid.word-list', value: 'unknown' })
|
||||
})
|
||||
|
||||
it('marks visible syntax for missing or malformed replacement parameters', () => {
|
||||
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123;broken;;]}')
|
||||
.filter(token => token.type === 'invalid.word-list')
|
||||
.map(token => token.value)
|
||||
|
||||
expect(invalidTokenValues).toEqual(['tmdbid=', '=123', 'broken;;'])
|
||||
expect(tokenize('旧名 => {[]}')).toContainEqual({ type: 'invalid.word-list', value: '{[]}' })
|
||||
})
|
||||
|
||||
it('marks the second semicolon when it creates an empty parameter', () => {
|
||||
const semicolonTokens = tokenize('旧名 => {[tmdbid=1;;type=tv]}').filter(token => token.value.includes(';'))
|
||||
|
||||
expect(semicolonTokens).toEqual([{ type: 'invalid.word-list', value: ';;' }])
|
||||
})
|
||||
|
||||
it('marks both sides of missing keys and values', () => {
|
||||
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123]}')
|
||||
.filter(token => token.type === 'invalid.word-list')
|
||||
.map(token => token.value)
|
||||
|
||||
expect(invalidTokenValues).toEqual(['tmdbid=', '=123'])
|
||||
})
|
||||
|
||||
it('treats an unclosed parameter block as ordinary replacement text', () => {
|
||||
expect(tokenize('旧名 => 新名 {[tmdbid=1')).toEqual([
|
||||
{ type: 'word_list_replaced', value: '旧名' },
|
||||
{ type: 'keyword.operator.word-list', value: ' => ' },
|
||||
{ type: 'word_list_replacement', value: '新名 {[tmdbid=1' },
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts every supported replacement parameter type', () => {
|
||||
expect(
|
||||
tokenize('旧名 => {[tmdbid=1;doubanid=2;bangumiid=3;anilistid=4;type=movie;g=group;s=5;e=6]}'),
|
||||
).not.toContainEqual(expect.objectContaining({ type: 'invalid.word-list' }))
|
||||
})
|
||||
|
||||
it('accepts season and episode number ranges', () => {
|
||||
expect(tokenize('旧名 => {[s=1-2;e=3-5]}')).not.toContainEqual(
|
||||
expect.objectContaining({ type: 'invalid.word-list' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('separates front, back, and episode offset fields', () => {
|
||||
expect(tokenize('第 <> 集 >> EP+1')).toEqual([
|
||||
{ type: 'word_list_front', value: '第' },
|
||||
{ type: 'keyword.operator.word-list', value: ' <> ' },
|
||||
{ type: 'word_list_back', value: '集' },
|
||||
{ type: 'keyword.operator.word-list', value: ' >> ' },
|
||||
{ type: 'word_list_offset', value: 'EP+1' },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses all six field types in combined and standalone rules', () => {
|
||||
const combinedTokens = tokenize('旧名 => 新名 && 第 <> 集 >> 2*EP-1')
|
||||
|
||||
expect(combinedTokens).toEqual([
|
||||
{ type: 'word_list_replaced', value: '旧名' },
|
||||
{ type: 'keyword.operator.word-list', value: ' => ' },
|
||||
{ type: 'word_list_replacement', value: '新名' },
|
||||
{ type: 'keyword.operator.word-list', value: ' && ' },
|
||||
{ type: 'word_list_front', value: '第' },
|
||||
{ type: 'keyword.operator.word-list', value: ' <> ' },
|
||||
{ type: 'word_list_back', value: '集' },
|
||||
{ type: 'keyword.operator.word-list', value: ' >> ' },
|
||||
{ type: 'word_list_offset', value: '2*EP-1' },
|
||||
])
|
||||
expect(tokenize('屏蔽词')[0].type).toBe('word_list_block')
|
||||
})
|
||||
|
||||
it('highlights comment lines', () => {
|
||||
expect(tokenize('# 这是一个注释')).toEqual([{ type: 'comment.word-list', value: '# 这是一个注释' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('word list plain mode', () => {
|
||||
it('does not highlight comment lines', () => {
|
||||
expect(tokenizePlain('# 这是一个注释')).toEqual([{ type: 'text', value: '# 这是一个注释' }])
|
||||
})
|
||||
|
||||
it('does not highlight word list fields', () => {
|
||||
expect(tokenizePlain('旧名 => 新名 && 第 <> 集 >> 2*EP-1')).toEqual([
|
||||
{ type: 'text', value: '旧名 => 新名 && 第 <> 集 >> 2*EP-1' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no tokens for empty lines', () => {
|
||||
expect(tokenizePlain('')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -70,7 +70,7 @@ function registerJinja2Mode() {
|
||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||
const operators = 'and|in|is|not|or'
|
||||
const contextVariables =
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
@@ -282,7 +282,7 @@ function registerJinja2Mode() {
|
||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||
const operators = 'and|in|is|not|or'
|
||||
const contextVariables =
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
@@ -530,6 +530,234 @@ function registerJinja2Mode() {
|
||||
)
|
||||
}
|
||||
|
||||
interface WordListToken {
|
||||
type: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const wordListReplacementParametersPattern = /\{\[([^\]]*)\]\}/g
|
||||
const wordListUnsignedIntegerPattern = /^\d+$/
|
||||
const wordListUnsignedIntegerOrRangePattern = /^\d+(?:-\d+)?$/
|
||||
const wordListParameterTypes = {
|
||||
tmdbid: 'uint',
|
||||
doubanid: 'uint',
|
||||
bangumiid: 'uint',
|
||||
anilistid: 'uint',
|
||||
type: 'media-type',
|
||||
g: 'string',
|
||||
s: 'uint-or-range',
|
||||
e: 'uint-or-range',
|
||||
} as const
|
||||
|
||||
function appendWordListToken(tokens: WordListToken[], type: string, value: string) {
|
||||
if (!value) return
|
||||
|
||||
const previousToken = tokens.at(-1)
|
||||
if (previousToken?.type === type) previousToken.value += value
|
||||
else tokens.push({ type, value })
|
||||
}
|
||||
|
||||
function isValidWordListParameterValue(key: keyof typeof wordListParameterTypes, value: string) {
|
||||
switch (wordListParameterTypes[key]) {
|
||||
case 'uint':
|
||||
return wordListUnsignedIntegerPattern.test(value)
|
||||
case 'uint-or-range':
|
||||
return wordListUnsignedIntegerOrRangePattern.test(value)
|
||||
case 'media-type':
|
||||
return value === 'movie' || value === 'tv'
|
||||
case 'string':
|
||||
return value.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
function tokenizeWordListParameters(parameters: string): WordListToken[] {
|
||||
const tokens: WordListToken[] = []
|
||||
const parameterList = parameters.split(';')
|
||||
|
||||
parameterList.forEach((parameter, index) => {
|
||||
const isEmptyParameter = parameter.length === 0
|
||||
if (index > 0) {
|
||||
appendWordListToken(
|
||||
tokens,
|
||||
parameterList[index - 1] === '' || isEmptyParameter ? 'invalid.word-list' : 'word_list_parameter_syntax',
|
||||
';',
|
||||
)
|
||||
}
|
||||
if (isEmptyParameter) return
|
||||
|
||||
const equalsIndex = parameter.indexOf('=')
|
||||
if (equalsIndex === -1) {
|
||||
appendWordListToken(tokens, 'invalid.word-list', parameter)
|
||||
return
|
||||
}
|
||||
|
||||
const key = parameter.slice(0, equalsIndex)
|
||||
const value = parameter.slice(equalsIndex + 1)
|
||||
const hasKey = key.length > 0
|
||||
const hasValue = value.length > 0
|
||||
const isKnownKey = hasKey && Object.hasOwn(wordListParameterTypes, key)
|
||||
|
||||
appendWordListToken(tokens, isKnownKey && hasValue ? 'word_list_parameter_key' : 'invalid.word-list', key)
|
||||
appendWordListToken(tokens, hasKey && hasValue ? 'word_list_parameter_syntax' : 'invalid.word-list', '=')
|
||||
|
||||
const isValidValue =
|
||||
isKnownKey &&
|
||||
!value.includes('=') &&
|
||||
isValidWordListParameterValue(key as keyof typeof wordListParameterTypes, value)
|
||||
appendWordListToken(
|
||||
tokens,
|
||||
isValidValue || (!isKnownKey && hasKey) ? 'word_list_parameter_value' : 'invalid.word-list',
|
||||
value,
|
||||
)
|
||||
})
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
function tokenizeWordListReplacement(replacement: string): WordListToken[] {
|
||||
const tokens: WordListToken[] = []
|
||||
let replacementStart = 0
|
||||
|
||||
for (const match of replacement.matchAll(wordListReplacementParametersPattern)) {
|
||||
const parameterStart = match.index ?? 0
|
||||
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart, parameterStart))
|
||||
if (!match[1]) appendWordListToken(tokens, 'invalid.word-list', match[0])
|
||||
else {
|
||||
appendWordListToken(tokens, 'word_list_parameter_syntax', '{[')
|
||||
tokenizeWordListParameters(match[1]).forEach(token => appendWordListToken(tokens, token.type, token.value))
|
||||
appendWordListToken(tokens, 'word_list_parameter_syntax', ']}')
|
||||
}
|
||||
replacementStart = parameterStart + match[0].length
|
||||
}
|
||||
|
||||
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart))
|
||||
return tokens
|
||||
}
|
||||
|
||||
function splitWordListField(value: string, operator: string) {
|
||||
let operatorIndex = value.indexOf(operator)
|
||||
|
||||
while (operatorIndex !== -1) {
|
||||
const operatorEnd = operatorIndex + operator.length
|
||||
if (value[operatorIndex - 1] === ' ' && value[operatorEnd] === ' ') {
|
||||
let operatorStart = operatorIndex
|
||||
while (value[operatorStart - 1] === ' ') operatorStart -= 1
|
||||
|
||||
let operatorWithSpacesEnd = operatorEnd
|
||||
while (value[operatorWithSpacesEnd] === ' ') operatorWithSpacesEnd += 1
|
||||
|
||||
return {
|
||||
before: value.slice(0, operatorStart),
|
||||
operator: value.slice(operatorStart, operatorWithSpacesEnd),
|
||||
after: value.slice(operatorWithSpacesEnd),
|
||||
}
|
||||
}
|
||||
|
||||
operatorIndex = value.indexOf(operator, operatorEnd)
|
||||
}
|
||||
}
|
||||
|
||||
function getWordListLineWhitespace(value: string) {
|
||||
const leadingSpaceLength = value.length - value.trimStart().length
|
||||
const trailingSpaceLength = value.length - value.trimEnd().length
|
||||
|
||||
return {
|
||||
leadingSpace: value.slice(0, leadingSpaceLength),
|
||||
content: value.slice(leadingSpaceLength, value.length - trailingSpaceLength),
|
||||
trailingSpace: value.slice(value.length - trailingSpaceLength),
|
||||
}
|
||||
}
|
||||
|
||||
function tokenizeWordListReplacementLine(value: string): WordListToken[] {
|
||||
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
|
||||
const replacementField = splitWordListField(content, '=>')
|
||||
if (!replacementField) return [{ type: 'text', value }]
|
||||
|
||||
const tokens: WordListToken[] = []
|
||||
appendWordListToken(tokens, 'text', leadingSpace)
|
||||
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
|
||||
tokenizeWordListReplacement(replacementField.after).forEach(token =>
|
||||
appendWordListToken(tokens, token.type, token.value),
|
||||
)
|
||||
appendWordListToken(tokens, 'text', trailingSpace)
|
||||
return tokens
|
||||
}
|
||||
|
||||
function tokenizeCombinedWordListLine(value: string): WordListToken[] {
|
||||
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
|
||||
const replacementField = splitWordListField(content, '=>')
|
||||
const frontField = replacementField && splitWordListField(replacementField.after, '&&')
|
||||
const backField = frontField && splitWordListField(frontField.after, '<>')
|
||||
const offsetField = backField && splitWordListField(backField.after, '>>')
|
||||
if (!replacementField || !frontField || !backField || !offsetField) return [{ type: 'text', value }]
|
||||
|
||||
const tokens: WordListToken[] = []
|
||||
appendWordListToken(tokens, 'text', leadingSpace)
|
||||
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
|
||||
tokenizeWordListReplacement(frontField.before).forEach(token => appendWordListToken(tokens, token.type, token.value))
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', frontField.operator)
|
||||
appendWordListToken(tokens, 'word_list_front', backField.before)
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', backField.operator)
|
||||
appendWordListToken(tokens, 'word_list_back', offsetField.before)
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', offsetField.operator)
|
||||
appendWordListToken(tokens, 'word_list_offset', offsetField.after)
|
||||
appendWordListToken(tokens, 'text', trailingSpace)
|
||||
return tokens
|
||||
}
|
||||
|
||||
function buildWordListRules(syntax: boolean) {
|
||||
if (!syntax) {
|
||||
return {
|
||||
start: [
|
||||
{
|
||||
token: 'empty_line',
|
||||
regex: '^$',
|
||||
},
|
||||
{
|
||||
defaultToken: 'text',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.word-list',
|
||||
regex: /^#.*/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
|
||||
onMatch: tokenizeCombinedWordListLine,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
|
||||
onMatch: tokenizeWordListReplacementLine,
|
||||
},
|
||||
{
|
||||
token: [
|
||||
'text',
|
||||
'word_list_front',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_back',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_offset',
|
||||
'text',
|
||||
],
|
||||
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
|
||||
},
|
||||
{
|
||||
token: ['text', 'word_list_block', 'text'],
|
||||
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function registerWordListMode() {
|
||||
aceModule.define?.(
|
||||
'ace/mode/word_list_highlight_rules',
|
||||
@@ -538,15 +766,8 @@ function registerWordListMode() {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const WordListHighlightRules = function (this: any) {
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.word-list',
|
||||
regex: /^#.*/,
|
||||
},
|
||||
],
|
||||
}
|
||||
const WordListHighlightRules = function (this: any, options?: { syntax?: boolean }) {
|
||||
this.$rules = buildWordListRules(options?.syntax === true)
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
@@ -564,9 +785,10 @@ function registerWordListMode() {
|
||||
const TextMode = require('./text').Mode
|
||||
const WordListHighlightRules = require('./word_list_highlight_rules').WordListHighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
const Mode = function (this: any, options?: { syntax?: boolean }) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = WordListHighlightRules
|
||||
this.$highlightRuleConfig = options || {}
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
@@ -26,6 +26,11 @@ export const storageAttributes = [
|
||||
icon: 'mdi-server-network-outline',
|
||||
remote: true,
|
||||
},
|
||||
{
|
||||
type: 'alistgo',
|
||||
icon: 'mdi-server-network-outline',
|
||||
remote: true,
|
||||
},
|
||||
{
|
||||
type: 'smb',
|
||||
icon: 'mdi-folder-network-outline',
|
||||
@@ -33,15 +38,21 @@ export const storageAttributes = [
|
||||
},
|
||||
]
|
||||
|
||||
export const storageIconDict = storageAttributes.reduce((dict, item) => {
|
||||
dict[item.type] = item.icon
|
||||
return dict
|
||||
}, {} as Record<string, string>)
|
||||
export const storageIconDict = storageAttributes.reduce(
|
||||
(dict, item) => {
|
||||
dict[item.type] = item.icon
|
||||
return dict
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
|
||||
export const storageRemoteDict = storageAttributes.reduce((dict, item) => {
|
||||
dict[item.type] = item.remote
|
||||
return dict
|
||||
}, {} as Record<string, boolean>)
|
||||
export const storageRemoteDict = storageAttributes.reduce(
|
||||
(dict, item) => {
|
||||
dict[item.type] = item.remote
|
||||
return dict
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
)
|
||||
|
||||
export const downloaderOptions = [
|
||||
{
|
||||
@@ -58,10 +69,13 @@ export const downloaderOptions = [
|
||||
},
|
||||
]
|
||||
|
||||
export const downloaderDict = downloaderOptions.reduce((dict, item) => {
|
||||
dict[item.value] = item.title
|
||||
return dict
|
||||
}, {} as Record<string, string>)
|
||||
export const downloaderDict = downloaderOptions.reduce(
|
||||
(dict, item) => {
|
||||
dict[item.value] = item.title
|
||||
return dict
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
|
||||
export const mediaServerOptions = [
|
||||
{
|
||||
@@ -88,12 +102,19 @@ export const mediaServerOptions = [
|
||||
value: 'ugreen',
|
||||
title: i18n.global.t('setting.system.ugreen'),
|
||||
},
|
||||
{
|
||||
value: 'navidrome',
|
||||
title: i18n.global.t('setting.system.navidrome'),
|
||||
},
|
||||
]
|
||||
|
||||
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {
|
||||
dict[item.value] = item.title
|
||||
return dict
|
||||
}, {} as Record<string, string>)
|
||||
export const mediaServerDict = mediaServerOptions.reduce(
|
||||
(dict, item) => {
|
||||
dict[item.value] = item.title
|
||||
return dict
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
|
||||
export const innerFilterRules = [
|
||||
{ title: i18n.global.t('filterRules.specSub'), value: ' SPECSUB ' },
|
||||
@@ -104,6 +125,19 @@ export const innerFilterRules = [
|
||||
{ title: i18n.global.t('filterRules.hkVoi'), value: ' HKVOI ' },
|
||||
{ title: i18n.global.t('filterRules.notHkVoi'), value: ' !HKVOI ' },
|
||||
{ title: i18n.global.t('filterRules.free'), value: ' FREE ' },
|
||||
{ title: i18n.global.t('filterRules.audioHires'), value: ' HIRES ' },
|
||||
{ title: i18n.global.t('filterRules.audioLossless'), value: ' LOSSLESS ' },
|
||||
{ title: i18n.global.t('filterRules.audioFlac'), value: ' FLAC ' },
|
||||
{ title: i18n.global.t('filterRules.audioAlac'), value: ' ALAC ' },
|
||||
{ title: i18n.global.t('filterRules.audioApe'), value: ' APE ' },
|
||||
{ title: i18n.global.t('filterRules.audioWav'), value: ' WAV ' },
|
||||
{ title: i18n.global.t('filterRules.audioDsd'), value: ' DSD ' },
|
||||
{ title: i18n.global.t('filterRules.audioMp3'), value: ' MP3 ' },
|
||||
{ title: i18n.global.t('filterRules.audioAac'), value: ' AAC ' },
|
||||
{ title: i18n.global.t('filterRules.audioOpus'), value: ' OPUS ' },
|
||||
{ title: i18n.global.t('filterRules.bitrate320'), value: ' BITRATE320 ' },
|
||||
{ title: i18n.global.t('filterRules.bitrate256'), value: ' BITRATE256 ' },
|
||||
{ title: i18n.global.t('filterRules.bitrate192'), value: ' BITRATE192 ' },
|
||||
{ title: i18n.global.t('filterRules.resolution4k'), value: ' 4K ' },
|
||||
{ title: i18n.global.t('filterRules.resolution1080p'), value: ' 1080P ' },
|
||||
{ title: i18n.global.t('filterRules.resolution720p'), value: ' 720P ' },
|
||||
@@ -226,6 +260,46 @@ export const effectOptions = ref([
|
||||
},
|
||||
])
|
||||
|
||||
// 音乐音质等级选择框数据
|
||||
export const audioQualityOptions = ref([
|
||||
{ title: i18n.global.t('audioQualityOptions.all'), value: '' },
|
||||
{ title: i18n.global.t('audioQualityOptions.hires'), value: 'hires' },
|
||||
{ title: i18n.global.t('audioQualityOptions.lossless'), value: 'hires|lossless' },
|
||||
{ title: i18n.global.t('audioQualityOptions.lossy'), value: 'lossy' },
|
||||
])
|
||||
|
||||
// 音频格式选择框数据
|
||||
export const audioFormatOptions = ref([
|
||||
{ title: i18n.global.t('audioFormatOptions.all'), value: '' },
|
||||
{ title: i18n.global.t('audioFormatOptions.lossless'), value: 'DSD|FLAC|ALAC|APE|WAV|AIFF|PCM' },
|
||||
...['DSD', 'FLAC', 'ALAC', 'APE', 'WAV', 'AIFF', 'PCM', 'MP3', 'AAC', 'OGG', 'OPUS', 'WMA'].map(value => ({
|
||||
title: value,
|
||||
value,
|
||||
})),
|
||||
])
|
||||
|
||||
export const audioBitrateOptions = [
|
||||
{ title: '128 kbps', value: 128000 },
|
||||
{ title: '192 kbps', value: 192000 },
|
||||
{ title: '256 kbps', value: 256000 },
|
||||
{ title: '320 kbps', value: 320000 },
|
||||
]
|
||||
|
||||
export const audioBitDepthOptions = [
|
||||
{ title: '16-bit', value: 16 },
|
||||
{ title: '24-bit', value: 24 },
|
||||
{ title: '32-bit', value: 32 },
|
||||
]
|
||||
|
||||
export const audioSampleRateOptions = [
|
||||
{ title: '44.1 kHz', value: 44100 },
|
||||
{ title: '48 kHz', value: 48000 },
|
||||
{ title: '88.2 kHz', value: 88200 },
|
||||
{ title: '96 kHz', value: 96000 },
|
||||
{ title: '176.4 kHz', value: 176400 },
|
||||
{ title: '192 kHz', value: 192000 },
|
||||
]
|
||||
|
||||
// 媒体类型选项
|
||||
export const mediaTypeOptions = [
|
||||
{
|
||||
@@ -236,6 +310,10 @@ export const mediaTypeOptions = [
|
||||
title: i18n.global.t('mediaType.tv'),
|
||||
value: '电视剧',
|
||||
},
|
||||
{
|
||||
title: i18n.global.t('mediaType.music'),
|
||||
value: '音乐',
|
||||
},
|
||||
{
|
||||
title: i18n.global.t('mediaType.anime'),
|
||||
value: '动漫',
|
||||
@@ -251,10 +329,13 @@ export const mediaTypeOptions = [
|
||||
]
|
||||
|
||||
// 媒体类型字典
|
||||
export const mediaTypeDict = mediaTypeOptions.reduce((dict, item) => {
|
||||
dict[item.value] = item.title
|
||||
return dict
|
||||
}, {} as Record<string, string>)
|
||||
export const mediaTypeDict = mediaTypeOptions.reduce(
|
||||
(dict, item) => {
|
||||
dict[item.value] = item.title
|
||||
return dict
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
|
||||
// 通知开关选项
|
||||
export const notificationSwitchOptions = [
|
||||
@@ -297,10 +378,13 @@ export const notificationSwitchOptions = [
|
||||
]
|
||||
|
||||
// 通知开关字典
|
||||
export const notificationSwitchDict = notificationSwitchOptions.reduce((dict, item) => {
|
||||
dict[item.value] = item.title
|
||||
return dict
|
||||
}, {} as Record<string, string>)
|
||||
export const notificationSwitchDict = notificationSwitchOptions.reduce(
|
||||
(dict, item) => {
|
||||
dict[item.value] = item.title
|
||||
return dict
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
|
||||
// 操作步骤选项
|
||||
export const actionStepOptions = [
|
||||
@@ -367,7 +451,10 @@ export const actionStepOptions = [
|
||||
]
|
||||
|
||||
// 操作步骤字典
|
||||
export const actionStepDict = actionStepOptions.reduce((dict, item) => {
|
||||
dict[item.value] = item.title
|
||||
return dict
|
||||
}, {} as Record<string, string>)
|
||||
export const actionStepDict = actionStepOptions.reduce(
|
||||
(dict, item) => {
|
||||
dict[item.value] = item.title
|
||||
return dict
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
|
||||
438
src/api/types.ts
@@ -1,3 +1,15 @@
|
||||
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz' | (string & {})
|
||||
|
||||
// 手动刮削选项
|
||||
export interface ManualScrapeOptions {
|
||||
// 媒体数据源
|
||||
media_source: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 媒体类型
|
||||
type_name?: string
|
||||
}
|
||||
|
||||
// 订阅
|
||||
export interface Subscribe {
|
||||
// 订阅ID
|
||||
@@ -6,7 +18,7 @@ export interface Subscribe {
|
||||
name: string
|
||||
// 订阅年份
|
||||
year: string
|
||||
// 订阅类型 电影/电视剧
|
||||
// 订阅类型 电影/电视剧/音乐
|
||||
type: string
|
||||
// 搜索关键字
|
||||
keyword?: string
|
||||
@@ -15,7 +27,17 @@ export interface Subscribe {
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: string
|
||||
bangumiid?: number
|
||||
// AniList ID
|
||||
anilistid?: number
|
||||
// 主媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 音乐实体类型:recording 单曲、album 专辑、artist 艺术家
|
||||
music_type?: MusicEntityType
|
||||
// 专辑总曲目数
|
||||
total_tracks?: number
|
||||
// 其它媒体ID
|
||||
mediaid?: string
|
||||
// 季号
|
||||
@@ -40,6 +62,16 @@ export interface Subscribe {
|
||||
resolution?: string
|
||||
// 特效
|
||||
effect?: string
|
||||
// 音乐音质等级正则
|
||||
audio_quality?: string
|
||||
// 音频格式正则
|
||||
audio_format?: string
|
||||
// 最低码率(bps)
|
||||
min_bitrate?: number
|
||||
// 最低位深(bit)
|
||||
min_bit_depth?: number
|
||||
// 最低采样率(Hz)
|
||||
min_sample_rate?: number
|
||||
// 总集数
|
||||
total_episode?: number
|
||||
// 开始集数
|
||||
@@ -66,6 +98,14 @@ export interface Subscribe {
|
||||
search_imdbid?: any
|
||||
// 当前优先级
|
||||
current_priority: number
|
||||
// 当前音乐版本格式
|
||||
current_audio_format?: string
|
||||
// 当前音乐版本码率(bps)
|
||||
current_bitrate?: number
|
||||
// 当前音乐版本位深(bit)
|
||||
current_bit_depth?: number
|
||||
// 当前音乐版本采样率(Hz)
|
||||
current_sample_rate?: number
|
||||
// 洗版时已下载剧集的优先级状态
|
||||
episode_priority?: Record<string, number>
|
||||
// 保存目录
|
||||
@@ -114,6 +154,18 @@ export interface SubscribeShare {
|
||||
tmdbid?: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: number
|
||||
// AniList ID
|
||||
anilistid?: number
|
||||
// 主媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 音乐实体类型:recording 单曲、album 专辑、artist 艺术家
|
||||
music_type?: MusicEntityType
|
||||
// 专辑总曲目数
|
||||
total_tracks?: number
|
||||
// 季号
|
||||
season?: number
|
||||
// 海报
|
||||
@@ -216,6 +268,14 @@ export interface TransferHistory {
|
||||
tvdbid?: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: number
|
||||
// AniList ID
|
||||
anilistid?: number
|
||||
// 媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 季Sxx
|
||||
seasons?: string
|
||||
// 集Exx
|
||||
@@ -230,20 +290,94 @@ export interface TransferHistory {
|
||||
errmsg?: string
|
||||
// 日期
|
||||
date?: string
|
||||
// 音乐实体类型
|
||||
music_type?: MusicEntityType
|
||||
// 专辑预期总曲目数
|
||||
total_tracks?: number
|
||||
// 实际音频格式
|
||||
audio_format?: string
|
||||
// 是否无损音频
|
||||
audio_lossless?: boolean
|
||||
// 实际位深(bit)
|
||||
bit_depth?: number
|
||||
// 实际采样率(Hz)
|
||||
sample_rate?: number
|
||||
// 实际码率(bps)
|
||||
bitrate?: number
|
||||
// 源文件项
|
||||
src_fileitem?: FileItem
|
||||
}
|
||||
|
||||
// 媒体信息
|
||||
export interface MediaInfo {
|
||||
// 来源:themoviedb、douban、bangumi
|
||||
source?: string
|
||||
// 类型 电影、电视剧、合集
|
||||
// 下载历史记录
|
||||
export interface DownloadHistory {
|
||||
// ID
|
||||
id: number
|
||||
// 保存路径
|
||||
path?: string
|
||||
// 类型:电影、电视剧
|
||||
type?: string
|
||||
// 媒体标题
|
||||
// 标题
|
||||
title?: string
|
||||
// 年份
|
||||
year?: string
|
||||
// TMDB ID
|
||||
tmdbid?: number
|
||||
// IMDB ID
|
||||
imdbid?: string
|
||||
// TVDB ID
|
||||
tvdbid?: number
|
||||
// 豆瓣 ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: number
|
||||
// AniList ID
|
||||
anilistid?: number
|
||||
// 媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生 ID
|
||||
media_id?: string
|
||||
// 季 Sxx
|
||||
seasons?: string
|
||||
// 集 Exx
|
||||
episodes?: string
|
||||
// 背景图
|
||||
image?: string
|
||||
// 海报
|
||||
poster?: string
|
||||
// 下载器 Hash
|
||||
download_hash?: string
|
||||
// 种子名称
|
||||
torrent_name?: string
|
||||
// 种子描述
|
||||
torrent_description?: string
|
||||
// 站点
|
||||
torrent_site?: string
|
||||
// 下载用户 ID
|
||||
userid?: string
|
||||
// 下载用户名或插件名
|
||||
username?: string
|
||||
// 下载渠道
|
||||
channel?: string
|
||||
// 创建时间
|
||||
date?: string
|
||||
// 附加信息
|
||||
note?: unknown
|
||||
// 自定义媒体类别
|
||||
media_category?: string
|
||||
// 自定义剧集组
|
||||
episode_group?: string
|
||||
}
|
||||
|
||||
// 媒体信息
|
||||
export interface MediaInfo {
|
||||
// 来源:themoviedb、douban、bangumi、anilist、musicbrainz
|
||||
source?: string
|
||||
// 类型 电影、电视剧、音乐、合集
|
||||
type?: string
|
||||
// 媒体标题
|
||||
title?: string
|
||||
// 年份(音乐等数据源返回数字,订阅接口要求字符串)
|
||||
year?: string | number
|
||||
// 标题(年)
|
||||
title_year?: string
|
||||
// 季号
|
||||
@@ -257,7 +391,11 @@ export interface MediaInfo {
|
||||
// 豆瓣ID
|
||||
douban_id?: string
|
||||
// Bangumi ID
|
||||
bangumi_id?: string
|
||||
bangumi_id?: string | number
|
||||
// AniList ID
|
||||
anilist_id?: number
|
||||
// AniDB ID
|
||||
anidb_id?: number
|
||||
// 合集ID
|
||||
collection_id?: number
|
||||
// 其它媒体ID前缀
|
||||
@@ -338,6 +476,193 @@ export interface MediaInfo {
|
||||
names?: string[]
|
||||
// 剧集组
|
||||
episode_group?: string
|
||||
// 音乐艺术家列表
|
||||
artists?: string[]
|
||||
// 音乐艺术家展示文本
|
||||
artist?: string
|
||||
// 音乐实体类型:recording 单曲、album 专辑、artist 艺术家
|
||||
music_type?: MusicEntityType
|
||||
// 音乐艺术家标准ID,顺序与 artists 一致
|
||||
artist_ids?: string[]
|
||||
// 专辑
|
||||
album?: string
|
||||
// 专辑艺术家
|
||||
album_artist?: string
|
||||
// 所属专辑标准ID
|
||||
album_id?: string
|
||||
// 专辑主类型:Album、EP、Single 等
|
||||
album_type?: string
|
||||
// 发行版本
|
||||
version?: string
|
||||
// 音轨号
|
||||
track_number?: number
|
||||
// 碟号
|
||||
disc_number?: number
|
||||
// 总音轨数
|
||||
total_tracks?: number
|
||||
// 音轨时长(秒)
|
||||
duration?: number
|
||||
// ISRC
|
||||
isrc?: string
|
||||
// 音乐封面
|
||||
cover_url?: string
|
||||
// ListenBrainz 收听次数
|
||||
listen_count?: number
|
||||
// 音频格式
|
||||
audio_format?: string
|
||||
// 是否无损音频
|
||||
audio_lossless?: boolean
|
||||
// 音质等级
|
||||
audio_quality?: 'hires' | 'lossless' | 'lossy'
|
||||
// 音质洗版分数
|
||||
audio_quality_score?: number
|
||||
// 格式化音频参数
|
||||
audio_specs?: string
|
||||
// 音频位深
|
||||
bit_depth?: number
|
||||
// 音频采样率
|
||||
sample_rate?: number
|
||||
// 音频码率
|
||||
bitrate?: number
|
||||
}
|
||||
|
||||
// 音乐可浏览实体类型
|
||||
export type MusicEntityType = 'recording' | 'album' | 'artist'
|
||||
|
||||
// 音乐专辑下的单个发行版本
|
||||
export interface MusicRelease {
|
||||
// 发行版本标准ID
|
||||
media_id?: string
|
||||
// 发行版本名称
|
||||
title?: string
|
||||
// 发行日期
|
||||
date?: string
|
||||
// 发行年份
|
||||
year?: number
|
||||
// 发行地区
|
||||
country?: string
|
||||
// 发行状态:Official、Promotion 等
|
||||
status?: string
|
||||
// 包装形式
|
||||
packaging?: string
|
||||
// 介质格式:CD、Vinyl 等
|
||||
formats?: string[]
|
||||
// 音轨总数
|
||||
track_count?: number
|
||||
// 封面
|
||||
cover_url?: string
|
||||
}
|
||||
|
||||
// 音乐专辑详情
|
||||
export interface MusicAlbumInfo {
|
||||
// 类型,固定为音乐
|
||||
type?: string
|
||||
// 实体类型,固定为 album
|
||||
music_type?: MusicEntityType
|
||||
// 来源
|
||||
source?: string
|
||||
// 专辑标准ID
|
||||
media_id?: string
|
||||
// 专辑名称
|
||||
title?: string
|
||||
// 艺术家列表
|
||||
artists?: string[]
|
||||
// 艺术家展示文本
|
||||
artist?: string
|
||||
// 艺术家标准ID,顺序与 artists 一致
|
||||
artist_ids?: string[]
|
||||
// 专辑名称,与 title 一致,便于复用音乐展示组件
|
||||
album?: string
|
||||
// 专辑主类型
|
||||
album_type?: string
|
||||
// 专辑副类型
|
||||
secondary_types?: string[]
|
||||
// 首次发行年份
|
||||
year?: number
|
||||
// 首次发行日期
|
||||
release_date?: string
|
||||
// 音轨总数
|
||||
total_tracks?: number
|
||||
// 专辑总时长(秒)
|
||||
duration?: number
|
||||
// 封面
|
||||
cover_url?: string
|
||||
// 风格
|
||||
genres?: string[]
|
||||
// 标签
|
||||
tags?: string[]
|
||||
// 主类型与副类型组合文本
|
||||
category?: string
|
||||
// 10 分制评分
|
||||
rating?: number
|
||||
// 评分人数
|
||||
rating_votes?: number
|
||||
// 详情页面
|
||||
detail_link?: string
|
||||
// 专辑内的音乐
|
||||
tracks?: MediaInfo[]
|
||||
// 同一专辑的其它发行版本
|
||||
releases?: MusicRelease[]
|
||||
// 海报
|
||||
poster_path?: string
|
||||
// 背景图
|
||||
backdrop_path?: string
|
||||
// 摘要
|
||||
overview?: string
|
||||
}
|
||||
|
||||
// 音乐艺术家详情
|
||||
export interface MusicArtistInfo {
|
||||
// 类型,固定为音乐
|
||||
type?: string
|
||||
// 实体类型,固定为 artist
|
||||
music_type?: MusicEntityType
|
||||
// 来源
|
||||
source?: string
|
||||
// 艺术家标准ID
|
||||
media_id?: string
|
||||
// 艺术家名称
|
||||
name?: string
|
||||
// 名称,与 name 一致,便于复用通用展示组件
|
||||
title?: string
|
||||
// 排序名称
|
||||
sort_name?: string
|
||||
// 消歧义说明
|
||||
disambiguation?: string
|
||||
// 艺术家类型:Person、Group 等
|
||||
artist_type?: string
|
||||
// 性别
|
||||
gender?: string
|
||||
// 国家代码
|
||||
country?: string
|
||||
// 地区
|
||||
area?: string
|
||||
// 出道或成立日期
|
||||
begin_date?: string
|
||||
// 解散或去世日期
|
||||
end_date?: string
|
||||
// 是否已结束活动
|
||||
ended?: boolean
|
||||
// 活跃时间区间文本
|
||||
life_span?: string
|
||||
// 风格
|
||||
genres?: string[]
|
||||
// 标签
|
||||
tags?: string[]
|
||||
// 别名
|
||||
aliases?: string[]
|
||||
// 与当前艺术家的关系文本
|
||||
relation?: string
|
||||
// 艺术家图片
|
||||
image_url?: string
|
||||
// 详情页面
|
||||
detail_link?: string
|
||||
// 外部站点链接
|
||||
external_links?: Record<string, string>
|
||||
// 海报
|
||||
poster_path?: string
|
||||
// 摘要
|
||||
overview?: string
|
||||
}
|
||||
|
||||
// 季信息
|
||||
@@ -414,7 +739,7 @@ export interface TmdbEpisode {
|
||||
|
||||
// TMDB人物信息
|
||||
export interface Person {
|
||||
// 来源:themoviedb、douban、bangumi
|
||||
// 来源:themoviedb、douban、bangumi、anilist
|
||||
source?: string
|
||||
// ID
|
||||
id?: number
|
||||
@@ -668,6 +993,20 @@ export interface Plugin {
|
||||
add_time?: number
|
||||
// 页面打开状态
|
||||
page_open?: boolean
|
||||
// 平均评分
|
||||
average_rating?: number
|
||||
// 评分人数
|
||||
rating_count?: number
|
||||
// 当前安装实例评分
|
||||
user_rating?: number | null
|
||||
}
|
||||
|
||||
// 插件评分结果
|
||||
export interface PluginRating {
|
||||
plugin_id: string
|
||||
average_rating: number
|
||||
rating_count: number
|
||||
user_rating?: number | null
|
||||
}
|
||||
|
||||
// 插件 Release 可安装版本
|
||||
@@ -871,7 +1210,25 @@ export interface MetaInfo {
|
||||
// 识别的英文名
|
||||
en_name?: string
|
||||
// 年份
|
||||
year?: string
|
||||
year?: string | number
|
||||
// 音乐艺术家列表
|
||||
artists?: string[]
|
||||
// 音乐艺术家展示文本
|
||||
artist?: string
|
||||
// 音乐专辑
|
||||
album?: string
|
||||
// 音乐专辑艺术家
|
||||
album_artist?: string
|
||||
// 音乐碟号
|
||||
disc_number?: number
|
||||
// 音乐音轨号
|
||||
track_number?: number
|
||||
// 音乐总音轨数
|
||||
total_tracks?: number
|
||||
// 音乐时长(秒)
|
||||
duration?: number
|
||||
// 音乐 ISRC
|
||||
isrc?: string
|
||||
// 总季数
|
||||
total_season: number
|
||||
// 识别的开始季 数字
|
||||
@@ -898,6 +1255,22 @@ export interface MetaInfo {
|
||||
video_encode?: string
|
||||
// 音频编码
|
||||
audio_encode?: string
|
||||
// 音频格式
|
||||
audio_format?: string
|
||||
// 是否无损音频
|
||||
audio_lossless?: boolean
|
||||
// 音质等级
|
||||
audio_quality?: 'hires' | 'lossless' | 'lossy'
|
||||
// 音质洗版分数
|
||||
audio_quality_score?: number
|
||||
// 格式化音频参数
|
||||
audio_specs?: string
|
||||
// 音频位深
|
||||
bit_depth?: number
|
||||
// 音频采样率
|
||||
sample_rate?: number
|
||||
// 音频码率
|
||||
bitrate?: number
|
||||
// 名称(自动中英文)
|
||||
name: string
|
||||
// SXX-SXX
|
||||
@@ -962,7 +1335,7 @@ export interface User {
|
||||
is_superuser: boolean
|
||||
// 头像
|
||||
avatar: string
|
||||
// 是否开启双重验证
|
||||
// 是否开启二次验证
|
||||
is_otp: boolean
|
||||
// 用户权限 json
|
||||
permissions: { [key: string]: any }
|
||||
@@ -972,6 +1345,11 @@ export interface User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 头像上传响应数据
|
||||
export interface AvatarUploadData {
|
||||
filename: string
|
||||
}
|
||||
|
||||
// 通行密钥
|
||||
export interface PassKey {
|
||||
id: number
|
||||
@@ -998,8 +1376,10 @@ export interface MediaStatistic {
|
||||
tv_count: number
|
||||
// 电视剧总集数,未获取时为 null
|
||||
episode_count: number | null
|
||||
// 用户数量
|
||||
user_count: number
|
||||
// 音乐总数
|
||||
music_count: number
|
||||
// 用户数量,仪表板已改为展示音乐数量,仅保留接口字段兼容
|
||||
user_count?: number
|
||||
// 本月新增电影数量
|
||||
movie_count_month: number
|
||||
// 本月新增电视剧数量
|
||||
@@ -1343,7 +1723,7 @@ export interface StorageConf {
|
||||
export interface MediaServerConf {
|
||||
// 名称
|
||||
name: string
|
||||
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen
|
||||
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen/navidrome
|
||||
type: string
|
||||
// 配置
|
||||
config: { [key: string]: any }
|
||||
@@ -1351,6 +1731,8 @@ export interface MediaServerConf {
|
||||
enabled: boolean
|
||||
// 同步媒体体库列表
|
||||
sync_libraries?: string[]
|
||||
// 自动同步间隔(小时),为空时使用旧全局配置
|
||||
sync_interval?: number | null
|
||||
}
|
||||
|
||||
// 文件整理目录配置
|
||||
@@ -1511,6 +1893,14 @@ export interface TransferForm {
|
||||
tmdbid?: number
|
||||
// 豆瓣 ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: number
|
||||
// AniList ID
|
||||
anilistid?: number
|
||||
// 媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string | null
|
||||
// 季号
|
||||
season?: number
|
||||
// 类型
|
||||
@@ -1539,6 +1929,8 @@ export interface TransferForm {
|
||||
episode_group?: string | null
|
||||
// 预览模式
|
||||
preview?: boolean
|
||||
// 清理已有成功记录后重新整理
|
||||
reorganize: boolean
|
||||
}
|
||||
|
||||
// 手动整理请求
|
||||
@@ -1565,6 +1957,14 @@ export interface ManualTransferTargetPathData {
|
||||
library_category_folder?: boolean | null
|
||||
}
|
||||
|
||||
// 手动整理命中的成功历史摘要
|
||||
export interface ManualTransferHistoryInfo {
|
||||
// 是否应执行重新整理
|
||||
reorganize: boolean
|
||||
// 命中的成功历史数量
|
||||
history_count: number
|
||||
}
|
||||
|
||||
// 手动整理预览统计
|
||||
export interface ManualTransferPreviewSummary {
|
||||
// 总数
|
||||
@@ -1757,9 +2157,7 @@ export interface RecognitionCacheItem {
|
||||
key: string
|
||||
// TMDB ID,0 表示未识别
|
||||
tmdb_id?: number
|
||||
// 豆瓣 ID,0 表示未识别
|
||||
douban_id?: string | number
|
||||
// 当前识别数据源对应的统一 ID
|
||||
// 识别缓存对应的字符串 ID
|
||||
recognition_id?: string
|
||||
// 识别后的标题
|
||||
title: string
|
||||
@@ -1781,6 +2179,10 @@ export interface RecognitionCacheData {
|
||||
recognized: number
|
||||
// 未识别数量
|
||||
unrecognized: number
|
||||
// 共享识别成功命中数量
|
||||
shared_recognized: number
|
||||
// 是否开启共享识别
|
||||
shared_recognize_enabled: boolean
|
||||
// 缓存数据
|
||||
data: RecognitionCacheItem[]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import AgentPetStage from './pet/AgentPetStage.vue'
|
||||
import type { AgentPetActionName, AgentPetIntent } from './pet/types'
|
||||
import { useAgentPetMachine } from './pet/useAgentPetMachine'
|
||||
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||||
|
||||
interface AgentAssistantEntryBubble {
|
||||
id: string
|
||||
@@ -35,14 +36,19 @@ interface AgentAssistantEntryBubbleInput {
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
active?: boolean
|
||||
/** 是否允许入口的随机动作、指针跟随和自动贴边,不影响 thinking 等业务状态。 */
|
||||
motionActive?: boolean
|
||||
thinking?: boolean
|
||||
}>(),
|
||||
{
|
||||
active: true,
|
||||
motionActive: true,
|
||||
thinking: false,
|
||||
},
|
||||
)
|
||||
|
||||
const ASSISTANT_PREVIEW_MAX_LENGTH = 480
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
}>()
|
||||
@@ -160,6 +166,7 @@ const fabPositionStyle = computed(() => {
|
||||
...fabPointerStyle.value,
|
||||
'--agent-assistant-fab-x': `${position.x}px`,
|
||||
'--agent-assistant-fab-y': `${position.y}px`,
|
||||
zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.entry,
|
||||
}
|
||||
})
|
||||
const fabBubblePlacement = ref<FabBubblePlacement>('top')
|
||||
@@ -214,7 +221,7 @@ const {
|
||||
playAction: playAgentPetAction,
|
||||
scheduleRandomAction: scheduleFabRandomAction,
|
||||
} = useAgentPetMachine({
|
||||
active: () => props.active,
|
||||
active: () => props.active && props.motionActive,
|
||||
docked: fabDocked,
|
||||
dragging: fabDragging,
|
||||
pressed: fabPressed,
|
||||
@@ -237,8 +244,7 @@ function getViewportSize() {
|
||||
|
||||
// 取布局视口和可见视口的较小值,避免两者短暂不同步时把入口计算到屏幕外。
|
||||
return {
|
||||
height:
|
||||
visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight,
|
||||
height: visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight,
|
||||
width: visualWidth > 0 && layoutWidth > 0 ? Math.min(visualWidth, layoutWidth) : visualWidth || layoutWidth,
|
||||
}
|
||||
}
|
||||
@@ -826,17 +832,18 @@ function updateFabPointerFromPoint(point: FabPointerPoint) {
|
||||
|
||||
// 使用 requestAnimationFrame 合并高频指针事件,降低全局跟随的渲染开销。
|
||||
function queueFabPointerUpdate(clientX: number, clientY: number) {
|
||||
if (!props.active) return
|
||||
if (!props.active || !props.motionActive) return
|
||||
|
||||
fabPendingPointerPoint = { clientX, clientY }
|
||||
if (fabPointerFrame) return
|
||||
|
||||
fabPointerFrame = window.requestAnimationFrame(() => {
|
||||
fabPointerFrame = 0
|
||||
if (!fabPendingPointerPoint) return
|
||||
|
||||
updateFabPointerFromPoint(fabPendingPointerPoint)
|
||||
const point = fabPendingPointerPoint
|
||||
fabPendingPointerPoint = null
|
||||
if (!point || !props.active || !props.motionActive) return
|
||||
|
||||
updateFabPointerFromPoint(point)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -850,22 +857,6 @@ function updateFabPointer(event: PointerEvent) {
|
||||
queueFabPointerUpdate(event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
// 重置机器人按压状态和眼神跟随位移。
|
||||
function resetFabPointer() {
|
||||
fabPressed.value = false
|
||||
fabPointerStyle.value = {
|
||||
'--agent-assistant-body-x': '0px',
|
||||
'--agent-assistant-body-y': '0px',
|
||||
'--agent-assistant-eye-x': '0px',
|
||||
'--agent-assistant-eye-y': '0px',
|
||||
'--agent-assistant-head-x': '0px',
|
||||
'--agent-assistant-head-y': '0px',
|
||||
'--agent-assistant-pointer-x': '0px',
|
||||
'--agent-assistant-pointer-y': '0px',
|
||||
'--agent-assistant-robot-tilt': '0deg',
|
||||
}
|
||||
}
|
||||
|
||||
// 清理入口自动贴边计时器。
|
||||
function clearFabIdleTimer() {
|
||||
if (fabIdleTimer === null) return
|
||||
@@ -895,11 +886,20 @@ function suppressNextFabClick() {
|
||||
// 在入口靠近右侧边缘且空闲时安排自动贴边收起。
|
||||
function scheduleFabAutoDock() {
|
||||
clearFabIdleTimer()
|
||||
if (fabDocked.value || hasKeepOpenFabBubbles.value || fabRandomAction.value || !shouldFabAutoDock()) return
|
||||
if (
|
||||
!props.active ||
|
||||
!props.motionActive ||
|
||||
fabDocked.value ||
|
||||
hasKeepOpenFabBubbles.value ||
|
||||
fabRandomAction.value ||
|
||||
!shouldFabAutoDock()
|
||||
)
|
||||
return
|
||||
|
||||
fabIdleTimer = window.setTimeout(() => {
|
||||
fabIdleTimer = null
|
||||
if (fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock()) return
|
||||
if (!props.active || !props.motionActive || fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock())
|
||||
return
|
||||
|
||||
if (fabRandomAction.value) {
|
||||
scheduleFabAutoDock()
|
||||
@@ -915,14 +915,36 @@ function pauseFabAutoDock() {
|
||||
clearFabIdleTimer()
|
||||
}
|
||||
|
||||
// 取消挂起的全局指针帧并移除监听器。
|
||||
function teardownFabPointerTracking() {
|
||||
// 取消挂起的全局指针帧,避免状态切换后迟到回调重新写入位移。
|
||||
function cancelFabPointerUpdate() {
|
||||
if (fabPointerFrame) {
|
||||
window.cancelAnimationFrame(fabPointerFrame)
|
||||
fabPointerFrame = 0
|
||||
}
|
||||
|
||||
fabPendingPointerPoint = null
|
||||
}
|
||||
|
||||
// 重置机器人按压状态和眼神跟随位移。
|
||||
function resetFabPointer() {
|
||||
cancelFabPointerUpdate()
|
||||
fabPressed.value = false
|
||||
fabPointerStyle.value = {
|
||||
'--agent-assistant-body-x': '0px',
|
||||
'--agent-assistant-body-y': '0px',
|
||||
'--agent-assistant-eye-x': '0px',
|
||||
'--agent-assistant-eye-y': '0px',
|
||||
'--agent-assistant-head-x': '0px',
|
||||
'--agent-assistant-head-y': '0px',
|
||||
'--agent-assistant-pointer-x': '0px',
|
||||
'--agent-assistant-pointer-y': '0px',
|
||||
'--agent-assistant-robot-tilt': '0deg',
|
||||
}
|
||||
}
|
||||
|
||||
// 取消挂起的全局指针帧并移除监听器。
|
||||
function teardownFabPointerTracking() {
|
||||
cancelFabPointerUpdate()
|
||||
window.removeEventListener('pointermove', handleGlobalFabPointer)
|
||||
window.removeEventListener('pointerdown', handleGlobalFabPointer)
|
||||
}
|
||||
@@ -994,6 +1016,15 @@ function scheduleFabBubbleRemoval(id: string, duration = FAB_NOTIFICATION_BUBBLE
|
||||
function upsertFabBubble(bubble: AgentAssistantEntryBubble, options: { autoClose?: boolean; duration?: number } = {}) {
|
||||
if (!props.active || !bubble.text) return
|
||||
|
||||
const existingIndex = fabBubbles.value.findIndex(item => item.id === bubble.id)
|
||||
if (existingIndex >= 0) {
|
||||
fabBubbles.value[existingIndex] = bubble
|
||||
setFabDocked(false)
|
||||
nextTick(scheduleFabBubblePositionUpdate)
|
||||
if (options.autoClose) scheduleFabBubbleRemoval(bubble.id, options.duration)
|
||||
return
|
||||
}
|
||||
|
||||
const hadBubbles = hasFabBubbles.value
|
||||
const wasDocked = fabDocked.value
|
||||
const existingBubbles = fabBubbles.value.filter(item => item.id !== bubble.id)
|
||||
@@ -1041,7 +1072,7 @@ function showAssistantReplyPreview(value: string) {
|
||||
showBubble({
|
||||
id: 'assistant-preview',
|
||||
kind: 'assistant',
|
||||
text: value,
|
||||
text: value.slice(0, ASSISTANT_PREVIEW_MAX_LENGTH),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1432,6 +1463,19 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.motionActive,
|
||||
motionActive => {
|
||||
if (motionActive) {
|
||||
if (props.active && shouldFabAutoDock()) scheduleFabAutoDock()
|
||||
return
|
||||
}
|
||||
|
||||
clearFabIdleTimer()
|
||||
resetFabPointer()
|
||||
},
|
||||
)
|
||||
|
||||
onScopeDispose(clearFabIdleTimer)
|
||||
onScopeDispose(clearFabSuppressNextClickTimer)
|
||||
onScopeDispose(resetFabBubbles)
|
||||
@@ -1539,20 +1583,18 @@ defineExpose({
|
||||
.agent-assistant-fab {
|
||||
position: fixed;
|
||||
|
||||
/* 保持高于菜单浮层,但低于 agent 会话面板(2101)。 */
|
||||
z-index: 2100;
|
||||
|
||||
--agent-assistant-robot-outline: #5b00c5;
|
||||
--agent-assistant-robot-outline-soft: #7432df;
|
||||
--agent-assistant-robot-shell-start: #d3bbff;
|
||||
--agent-assistant-robot-shell-mid: #a576ff;
|
||||
--agent-assistant-robot-shell-end: #8d51f9;
|
||||
--agent-assistant-robot-face-start: #24124e;
|
||||
--agent-assistant-robot-face-end: #100525;
|
||||
--agent-assistant-robot-eye: #f1dcff;
|
||||
--agent-assistant-robot-play: #fff;
|
||||
--agent-assistant-robot-shadow: rgba(54, 0, 126, 28%);
|
||||
--agent-assistant-robot-shadow-strong: rgba(54, 0, 126, 34%);
|
||||
--agent-assistant-robot-outline: color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, #090510 28%);
|
||||
--agent-assistant-robot-outline-soft: color-mix(in srgb, rgb(var(--v-theme-primary)) 84%, #090510 16%);
|
||||
--agent-assistant-robot-shell-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 38%, white 62%);
|
||||
--agent-assistant-robot-shell-mid: color-mix(in srgb, rgb(var(--v-theme-primary)) 74%, white 26%);
|
||||
--agent-assistant-robot-shell-end: rgb(var(--v-theme-primary));
|
||||
--agent-assistant-robot-face-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 30%, #080a12 70%);
|
||||
--agent-assistant-robot-face-end: color-mix(in srgb, rgb(var(--v-theme-primary)) 14%, #03050a 86%);
|
||||
--agent-assistant-robot-eye: color-mix(in srgb, rgb(var(--v-theme-primary)) 12%, white 88%);
|
||||
--agent-assistant-robot-play: rgb(var(--v-theme-on-primary));
|
||||
--agent-assistant-robot-shadow: color-mix(in srgb, rgb(var(--v-theme-primary)) 28%, transparent);
|
||||
--agent-assistant-robot-shadow-strong: color-mix(in srgb, rgb(var(--v-theme-primary)) 36%, transparent);
|
||||
--agent-assistant-robot-shade: color-mix(in srgb, rgb(var(--v-theme-primary)) 25%, transparent);
|
||||
--agent-assistant-bubble-bg: rgba(var(--v-theme-surface), 0.92);
|
||||
--agent-assistant-bot-scale: 1;
|
||||
--agent-assistant-bot-pressed-scale: 0.96;
|
||||
@@ -1752,12 +1794,8 @@ defineExpose({
|
||||
|
||||
.agent-assistant-fab__bubbles--arrow-notification::before {
|
||||
--agent-assistant-bubble-arrow-border: rgba(var(--v-theme-primary), 0.22);
|
||||
--agent-assistant-bubble-arrow-bg: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--v-theme-primary), 0.1),
|
||||
transparent 48%
|
||||
),
|
||||
rgba(var(--v-theme-surface), 0.94);
|
||||
--agent-assistant-bubble-arrow-bg:
|
||||
linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1), transparent 48%), rgba(var(--v-theme-surface), 0.94);
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubbles--arrow-success::before {
|
||||
@@ -1778,11 +1816,8 @@ defineExpose({
|
||||
|
||||
.agent-assistant-fab__bubbles--arrow-toast::before {
|
||||
--agent-assistant-bubble-arrow-border: rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.3);
|
||||
--agent-assistant-bubble-arrow-bg: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12),
|
||||
transparent 54%
|
||||
),
|
||||
--agent-assistant-bubble-arrow-bg:
|
||||
linear-gradient(135deg, rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12), transparent 54%),
|
||||
rgba(var(--v-theme-surface), 0.95);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import AgentAssistantEntry from './AgentAssistantEntry.vue'
|
||||
import AgentAssistantPanel from './AgentAssistantPanel.vue'
|
||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||
import { useTheme } from 'vuetify'
|
||||
|
||||
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
|
||||
|
||||
const panelOpen = ref(false)
|
||||
const thinking = ref(false)
|
||||
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
||||
const { allowsDecorativeMotion } = useAppActivityLifecycle()
|
||||
const { themeClasses } = useTheme()
|
||||
const ASSISTANT_PREVIEW_INTERVAL = 125
|
||||
let assistantPreviewTimer: number | null = null
|
||||
let assistantPreviewPendingValue = ''
|
||||
let assistantPreviewLastShownAt = 0
|
||||
let assistantPreviewHasShown = false
|
||||
|
||||
function clearAssistantPreviewTimer() {
|
||||
if (assistantPreviewTimer === null) return
|
||||
|
||||
window.clearTimeout(assistantPreviewTimer)
|
||||
assistantPreviewTimer = null
|
||||
}
|
||||
|
||||
function showPendingAssistantPreview() {
|
||||
assistantPreviewTimer = null
|
||||
if (panelOpen.value || !assistantPreviewPendingValue) return
|
||||
|
||||
entryRef.value?.showAssistantReplyPreview(assistantPreviewPendingValue)
|
||||
assistantPreviewLastShownAt = performance.now()
|
||||
assistantPreviewHasShown = true
|
||||
}
|
||||
|
||||
// 打开 Agent 面板并清空入口预览气泡。
|
||||
function openPanel() {
|
||||
panelOpen.value = true
|
||||
assistantPreviewPendingValue = ''
|
||||
clearAssistantPreviewTimer()
|
||||
entryRef.value?.clearBubbles()
|
||||
}
|
||||
|
||||
// 在面板关闭时展示助手回复预览。
|
||||
// 面板关闭时限制预览更新频率,避免每个流式 token 都触发气泡布局。
|
||||
function handleAssistantPreview(value: string) {
|
||||
if (panelOpen.value) return
|
||||
|
||||
entryRef.value?.showAssistantReplyPreview(value)
|
||||
assistantPreviewPendingValue = value
|
||||
const elapsed = performance.now() - assistantPreviewLastShownAt
|
||||
if (!assistantPreviewHasShown || elapsed >= ASSISTANT_PREVIEW_INTERVAL) {
|
||||
clearAssistantPreviewTimer()
|
||||
showPendingAssistantPreview()
|
||||
return
|
||||
}
|
||||
|
||||
if (assistantPreviewTimer !== null) return
|
||||
assistantPreviewTimer = window.setTimeout(showPendingAssistantPreview, ASSISTANT_PREVIEW_INTERVAL - elapsed)
|
||||
}
|
||||
|
||||
onScopeDispose(clearAssistantPreviewTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" />
|
||||
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" />
|
||||
<!-- 脱离 .v-application 的层叠上下文,确保弹窗打开时入口、消息气泡和面板仍在最上层。 -->
|
||||
<Teleport to="body">
|
||||
<div class="agent-assistant-layer" :class="themeClasses">
|
||||
<AgentAssistantEntry
|
||||
ref="entryRef"
|
||||
:active="!panelOpen"
|
||||
:motion-active="allowsDecorativeMotion"
|
||||
:thinking="thinking"
|
||||
@open="openPanel"
|
||||
/>
|
||||
<AgentAssistantPanel
|
||||
v-model="panelOpen"
|
||||
:motion-active="allowsDecorativeMotion"
|
||||
@assistant-preview="handleAssistantPreview"
|
||||
@thinking-change="thinking = $event"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
74
src/components/agent/AgentMarkdownContent.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { renderAgentMarkdown } from '@/utils/agentMarkdown'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: string
|
||||
streaming?: boolean
|
||||
variant?: 'choice' | 'message'
|
||||
}>(),
|
||||
{
|
||||
streaming: false,
|
||||
variant: 'message',
|
||||
},
|
||||
)
|
||||
|
||||
const STREAM_MARKDOWN_RENDER_INTERVAL = 96
|
||||
|
||||
const renderedHtml = shallowRef('')
|
||||
let renderTimer: number | null = null
|
||||
let lastRenderedAt = 0
|
||||
let hasRendered = false
|
||||
|
||||
function clearRenderTimer() {
|
||||
if (renderTimer === null) return
|
||||
|
||||
window.clearTimeout(renderTimer)
|
||||
renderTimer = null
|
||||
}
|
||||
|
||||
// 流式阶段限制 Markdown 全量解析频率;结束时立即渲染最终内容。
|
||||
function renderContent(immediate = false) {
|
||||
const now = performance.now()
|
||||
const elapsed = now - lastRenderedAt
|
||||
if (immediate || !hasRendered || elapsed >= STREAM_MARKDOWN_RENDER_INTERVAL) {
|
||||
clearRenderTimer()
|
||||
renderedHtml.value = renderAgentMarkdown(props.content)
|
||||
lastRenderedAt = now
|
||||
hasRendered = true
|
||||
return
|
||||
}
|
||||
|
||||
if (renderTimer !== null) return
|
||||
renderTimer = window.setTimeout(() => {
|
||||
renderTimer = null
|
||||
renderedHtml.value = renderAgentMarkdown(props.content)
|
||||
lastRenderedAt = performance.now()
|
||||
hasRendered = true
|
||||
}, STREAM_MARKDOWN_RENDER_INTERVAL - elapsed)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
() => renderContent(!props.streaming),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.streaming,
|
||||
streaming => {
|
||||
if (!streaming) renderContent(true)
|
||||
},
|
||||
)
|
||||
|
||||
onScopeDispose(clearRenderTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="renderedHtml"
|
||||
class="markdown-body"
|
||||
:class="variant === 'choice' ? 'agent-assistant-choice__prompt' : 'agent-assistant-message__bubble'"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
</template>
|
||||
111
src/components/agent/__tests__/AgentAssistantEntry.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentAssistantEntry from '@/components/agent/AgentAssistantEntry.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
describe('AgentAssistantEntry lifecycle motion', () => {
|
||||
let animationFrameCallbacks: Map<number, FrameRequestCallback>
|
||||
let nextAnimationFrameId: number
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
animationFrameCallbacks = new Map()
|
||||
nextAnimationFrameId = 1
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextAnimationFrameId++
|
||||
animationFrameCallbacks.set(id, callback)
|
||||
return id
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'cancelAnimationFrame',
|
||||
vi.fn((id: number) => {
|
||||
animationFrameCallbacks.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cancels pointer frames and auto-dock timers when decorative motion stops', async () => {
|
||||
const wrapper = shallowMount(AgentAssistantEntry, {
|
||||
global: {
|
||||
stubs: {
|
||||
AgentPetStage: true,
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
active: true,
|
||||
motionActive: true,
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const pointerEvent = new Event('pointermove')
|
||||
Object.assign(pointerEvent, { clientX: 480, clientY: 320 })
|
||||
const frameCountBeforePointer = animationFrameCallbacks.size
|
||||
window.dispatchEvent(pointerEvent)
|
||||
const pointerFrameId = nextAnimationFrameId - 1
|
||||
|
||||
expect(animationFrameCallbacks.size).toBe(frameCountBeforePointer + 1)
|
||||
expect(vi.getTimerCount()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
await wrapper.setProps({ motionActive: false })
|
||||
await nextTick()
|
||||
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledWith(pointerFrameId)
|
||||
expect(animationFrameCallbacks.has(pointerFrameId)).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('updates an existing assistant preview without recreating its resize observer', async () => {
|
||||
const observe = vi.fn()
|
||||
const disconnect = vi.fn()
|
||||
const resizeObserverConstructor = vi.fn()
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor() {
|
||||
resizeObserverConstructor()
|
||||
}
|
||||
|
||||
observe = observe
|
||||
disconnect = disconnect
|
||||
},
|
||||
)
|
||||
const wrapper = shallowMount(AgentAssistantEntry, {
|
||||
global: {
|
||||
stubs: {
|
||||
AgentPetStage: true,
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
active: true,
|
||||
motionActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
wrapper.vm.showAssistantReplyPreview('第一段')
|
||||
await nextTick()
|
||||
expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
|
||||
|
||||
wrapper.vm.showAssistantReplyPreview('第二段')
|
||||
await nextTick()
|
||||
expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.find('.agent-assistant-fab__bubble').text()).toContain('第二段')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
552
src/components/agent/__tests__/AgentAssistantPanel.spec.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentAssistantPanel from '@/components/agent/AgentAssistantPanel.vue'
|
||||
|
||||
const displayState = vi.hoisted(() => ({ mdAndDown: { value: true } }))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('vuetify', () => ({
|
||||
useDisplay: () => displayState,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({ token: null }),
|
||||
useUserStore: () => ({ getUserName: 'Tester' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/plugins/i18n', () => ({
|
||||
getCurrentLocale: () => 'zh-CN',
|
||||
}))
|
||||
|
||||
interface MockServerSession {
|
||||
session_id: string
|
||||
client_session_id: string
|
||||
updated_at: string
|
||||
is_processing: boolean
|
||||
messages: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
const agentMarkdownContentStub = {
|
||||
props: ['content', 'variant'],
|
||||
template:
|
||||
"<div :class=\"variant === 'choice' ? 'agent-assistant-choice__prompt' : 'agent-assistant-message__bubble'\">{{ content }}</div>",
|
||||
}
|
||||
|
||||
// 构造符合 Agent 标准响应包装的 fetch 返回值。
|
||||
function createAgentResponse(data: unknown) {
|
||||
return {
|
||||
json: vi.fn().mockResolvedValue({ success: true, data }),
|
||||
ok: true,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
describe('AgentAssistantPanel stream recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
displayState.mdAndDown.value = true
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('toggles the desktop assistant between the side panel and fullscreen modes', async () => {
|
||||
displayState.mdAndDown.value = false
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => createAgentResponse([])),
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const panel = wrapper.get('.agent-assistant-panel')
|
||||
const fullscreenToggle = wrapper.get('.agent-assistant-fullscreen-toggle')
|
||||
expect(panel.classes()).not.toContain('is-fullscreen')
|
||||
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 30rem')
|
||||
expect(fullscreenToggle.attributes('title')).toBe('agentAssistant.enterFullscreen')
|
||||
expect(fullscreenToggle.attributes('aria-pressed')).toBe('false')
|
||||
|
||||
await fullscreenToggle.trigger('click')
|
||||
|
||||
expect(panel.classes()).toContain('is-fullscreen')
|
||||
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 100vw')
|
||||
expect(fullscreenToggle.attributes('title')).toBe('agentAssistant.exitFullscreen')
|
||||
expect(fullscreenToggle.attributes('aria-pressed')).toBe('true')
|
||||
|
||||
await fullscreenToggle.trigger('click')
|
||||
|
||||
expect(panel.classes()).not.toContain('is-fullscreen')
|
||||
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 30rem')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the narrow assistant fullscreen without a redundant toggle', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => createAgentResponse([])),
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.agent-assistant-panel').attributes('style')).toContain('--agent-assistant-panel-width: 100vw')
|
||||
expect(wrapper.find('.agent-assistant-fullscreen-toggle').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('restores a legacy empty message to loading and waits for the final reply after a PWA reload', async () => {
|
||||
const startedAt = Date.now() - 5000
|
||||
const localSessionId = 'web-local-session'
|
||||
const serverSessionId = 'web-agent:server-session'
|
||||
const userMessage = {
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: '检查后台任务',
|
||||
createdAt: startedAt - 100,
|
||||
status: 'done',
|
||||
attachments: [],
|
||||
choices: [],
|
||||
tools: [],
|
||||
}
|
||||
const assistantPlaceholder = {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: startedAt,
|
||||
status: 'done',
|
||||
attachments: [],
|
||||
choices: [],
|
||||
tools: [],
|
||||
}
|
||||
const processingSession: MockServerSession = {
|
||||
session_id: serverSessionId,
|
||||
client_session_id: localSessionId,
|
||||
updated_at: new Date(startedAt + 1000).toISOString(),
|
||||
is_processing: true,
|
||||
messages: [userMessage],
|
||||
}
|
||||
const completedSession: MockServerSession = {
|
||||
...processingSession,
|
||||
updated_at: new Date(startedAt + 2000).toISOString(),
|
||||
is_processing: false,
|
||||
messages: [
|
||||
userMessage,
|
||||
{
|
||||
...assistantPlaceholder,
|
||||
content: '后台任务已经完成',
|
||||
createdAt: startedAt + 2000,
|
||||
status: 'done',
|
||||
},
|
||||
],
|
||||
}
|
||||
const detailResponses = [processingSession, completedSession]
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes(`/sessions/${localSessionId}`)) {
|
||||
return createAgentResponse(detailResponses.shift() || completedSession)
|
||||
}
|
||||
|
||||
return createAgentResponse([])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
localStorage.setItem(
|
||||
'moviepilot-agent-assistant-state',
|
||||
JSON.stringify({
|
||||
sessionId: localSessionId,
|
||||
messages: [userMessage, assistantPlaceholder],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.agent-assistant-typing').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('agentAssistant.thinking')
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) => String(input).includes(`/sessions/${localSessionId}`)),
|
||||
).toHaveLength(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1200)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.agent-assistant-typing').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('后台任务已经完成')
|
||||
expect(wrapper.text()).toContain('agentAssistant.ready')
|
||||
expect(JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')).toMatchObject({
|
||||
sessionId: serverSessionId,
|
||||
streamRecovery: null,
|
||||
})
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('persists an active stream in the background and restores it when the PWA becomes visible', async () => {
|
||||
let visibilityState: DocumentVisibilityState = 'visible'
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
const startedAt = Date.now()
|
||||
const localSessionId = 'web-local-active'
|
||||
const serverSessionId = 'web-agent:server-active'
|
||||
const userMessage = {
|
||||
id: 'user-active',
|
||||
role: 'user',
|
||||
content: '继续后台处理',
|
||||
createdAt: startedAt,
|
||||
status: 'done',
|
||||
attachments: [],
|
||||
choices: [],
|
||||
tools: [],
|
||||
}
|
||||
const completedSession: MockServerSession = {
|
||||
session_id: serverSessionId,
|
||||
client_session_id: localSessionId,
|
||||
updated_at: new Date(startedAt + 2000).toISOString(),
|
||||
is_processing: false,
|
||||
messages: [
|
||||
userMessage,
|
||||
{
|
||||
id: 'assistant-active',
|
||||
role: 'assistant',
|
||||
content: '前后台恢复成功',
|
||||
createdAt: startedAt + 2000,
|
||||
status: 'done',
|
||||
attachments: [],
|
||||
choices: [],
|
||||
tools: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
const encoder = new TextEncoder()
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/message/agent/stream')) {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'start', session_id: serverSessionId })}\n\n`),
|
||||
)
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
controller.error(new DOMException('Aborted', 'AbortError'))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(body, { status: 200 })
|
||||
}
|
||||
if (url.includes(`/sessions/${encodeURIComponent(serverSessionId)}`)) return createAgentResponse(completedSession)
|
||||
|
||||
return createAgentResponse([])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('继续后台处理')
|
||||
await textarea.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.agent-assistant-typing').exists()).toBe(true)
|
||||
visibilityState = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await flushPromises()
|
||||
|
||||
expect(JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')).toMatchObject({
|
||||
sessionId: serverSessionId,
|
||||
streamRecovery: {
|
||||
sessionId: serverSessionId,
|
||||
},
|
||||
})
|
||||
|
||||
visibilityState = 'visible'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.agent-assistant-typing').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('前后台恢复成功')
|
||||
expect(wrapper.text()).toContain('agentAssistant.ready')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not send when Enter confirms an IME composition', async () => {
|
||||
const fetchMock = vi.fn(async () => createAgentResponse([]))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
await flushPromises()
|
||||
fetchMock.mockClear()
|
||||
await textarea.setValue('搜索电影')
|
||||
await textarea.trigger('compositionstart')
|
||||
await textarea.trigger('keydown', { key: 'Enter', isComposing: true })
|
||||
await textarea.trigger('compositionend')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
await textarea.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
expect(fetchMock).toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders interleaved assistant text and tool events in their SSE order', async () => {
|
||||
const serverSessionId = 'web-agent:ordered-segments'
|
||||
const streamEvents = [
|
||||
{ type: 'start', session_id: serverSessionId },
|
||||
{ type: 'delta', content: '先检查服务器。' },
|
||||
{ type: 'tool', message: '(执行了 1 条命令)' },
|
||||
{ type: 'delta', content: '检查完成,没有发现错误。' },
|
||||
{ type: 'done' },
|
||||
]
|
||||
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return new Response(streamBody, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
})
|
||||
}
|
||||
|
||||
return createAgentResponse([])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('检查服务器')
|
||||
await textarea.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
|
||||
expect(renderedSegments).toHaveLength(3)
|
||||
expect(renderedSegments[0].classes()).toContain('agent-assistant-message__bubble')
|
||||
expect(renderedSegments[0].text()).toContain('先检查服务器。')
|
||||
expect(renderedSegments[1].classes()).toContain('agent-assistant-tool')
|
||||
expect(renderedSegments[1].text()).toContain('执行了 1 条命令')
|
||||
expect(renderedSegments[2].classes()).toContain('agent-assistant-message__bubble')
|
||||
expect(renderedSegments[2].text()).toContain('检查完成,没有发现错误。')
|
||||
|
||||
const saveCall = fetchMock.mock.calls.find(([input]) => String(input).includes('/display'))
|
||||
const savedMessages = JSON.parse(String(saveCall?.[1]?.body || '{}')).messages as Array<Record<string, unknown>>
|
||||
expect(savedMessages.at(-1)).toMatchObject({
|
||||
content: '先检查服务器。检查完成,没有发现错误。',
|
||||
segments: [
|
||||
{ type: 'text', content: '先检查服务器。' },
|
||||
{ type: 'tool', toolIndex: 0 },
|
||||
{ type: 'text', content: '检查完成,没有发现错误。' },
|
||||
],
|
||||
})
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not render an empty text bubble for trailing whitespace after a tool summary', async () => {
|
||||
const streamEvents = [
|
||||
{ type: 'start', session_id: 'web-agent:trailing-whitespace' },
|
||||
{ type: 'delta', content: '最终结论' },
|
||||
{ type: 'tool', message: '(查询了 1 次数据)' },
|
||||
{ type: 'delta', content: '\n\n' },
|
||||
{ type: 'done' },
|
||||
]
|
||||
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return new Response(streamBody, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
})
|
||||
}
|
||||
return createAgentResponse([])
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await wrapper.find('textarea').setValue('检查配置')
|
||||
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
|
||||
expect(renderedSegments).toHaveLength(2)
|
||||
expect(renderedSegments[0].classes()).toContain('agent-assistant-message__bubble')
|
||||
expect(renderedSegments[0].text()).toBe('最终结论')
|
||||
expect(renderedSegments[1].classes()).toContain('agent-assistant-tool')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('aggregates adjacent non-verbose tools and starts a new group after text', async () => {
|
||||
const streamEvents = [
|
||||
{ type: 'start', session_id: 'web-agent:tool-groups' },
|
||||
{ type: 'tool', message: '(查询了 1 次数据)' },
|
||||
{ type: 'tool', message: '(查看了 1 个目录)' },
|
||||
{ type: 'tool', message: '(查询了 1 次数据)' },
|
||||
{ type: 'delta', content: '继续分析。' },
|
||||
{ type: 'tool', message: '(读取了 1 个文件)' },
|
||||
{ type: 'tool', message: '(读取了 1 个文件)' },
|
||||
{ type: 'done' },
|
||||
]
|
||||
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return new Response(streamBody, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
})
|
||||
}
|
||||
return createAgentResponse([])
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await wrapper.find('textarea').setValue('分析插件')
|
||||
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
|
||||
expect(renderedSegments).toHaveLength(3)
|
||||
expect(renderedSegments[0].text()).toContain('查询了 2 次数据,查看了 1 个目录')
|
||||
expect(renderedSegments[1].classes()).toContain('agent-assistant-message__bubble')
|
||||
expect(renderedSegments[1].text()).toBe('继续分析。')
|
||||
expect(renderedSegments[2].text()).toContain('读取了 2 个文件')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('coalesces consecutive text deltas into one UI update before a terminal event', async () => {
|
||||
const streamEvents = [
|
||||
{ type: 'start', session_id: 'web-agent:coalesced' },
|
||||
...Array.from({ length: 100 }, (_item, index) => ({ type: 'delta', content: String(index % 10) })),
|
||||
{ type: 'done' },
|
||||
]
|
||||
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return new Response(streamBody, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
})
|
||||
}
|
||||
return createAgentResponse([])
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await wrapper.find('textarea').setValue('测试突发事件')
|
||||
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('assistant-preview')).toHaveLength(1)
|
||||
expect(wrapper.find('.agent-assistant-message--assistant .agent-assistant-message__bubble').text()).toBe(
|
||||
Array.from({ length: 100 }, (_item, index) => String(index % 10)).join(''),
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
94
src/components/agent/__tests__/AgentAssistantWidget.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
|
||||
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||||
|
||||
vi.mock('vuetify', () => ({
|
||||
useTheme: () => ({ themeClasses: ref('v-theme--test') }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppActivityLifecycle', () => ({
|
||||
useAppActivityLifecycle: () => ({ allowsDecorativeMotion: ref(true) }),
|
||||
}))
|
||||
|
||||
describe('AgentAssistantWidget layering', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body
|
||||
.querySelectorAll('.agent-assistant-layer, .agent-assistant-test-host')
|
||||
.forEach(element => element.remove())
|
||||
})
|
||||
|
||||
it('teleports the assistant outside the application stacking context while preserving the active theme', () => {
|
||||
const host = document.createElement('div')
|
||||
host.className = 'agent-assistant-test-host v-application'
|
||||
document.body.append(host)
|
||||
|
||||
const wrapper = mount(AgentAssistantWidget, {
|
||||
attachTo: host,
|
||||
global: {
|
||||
stubs: {
|
||||
AgentAssistantEntry: { template: '<div data-agent-assistant-entry />' },
|
||||
AgentAssistantPanel: { template: '<div data-agent-assistant-panel />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const layer = document.body.querySelector(':scope > .agent-assistant-layer')
|
||||
|
||||
expect(layer).not.toBeNull()
|
||||
expect(host.contains(layer)).toBe(false)
|
||||
expect(layer).toHaveClass('v-theme--test')
|
||||
expect(layer?.querySelector('[data-agent-assistant-entry]')).not.toBeNull()
|
||||
expect(layer?.querySelector('[data-agent-assistant-panel]')).not.toBeNull()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reserves the highest CSS stacking levels in assistant display order', () => {
|
||||
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.entry).toBe(2_147_483_645)
|
||||
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.panel).toBe(2_147_483_646)
|
||||
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.overlay).toBe(2_147_483_647)
|
||||
})
|
||||
|
||||
it('limits closed-panel assistant preview updates and keeps the latest text', async () => {
|
||||
vi.useFakeTimers()
|
||||
const showAssistantReplyPreview = vi.fn()
|
||||
const entryStub = defineComponent({
|
||||
setup(_props, { expose }) {
|
||||
expose({ clearBubbles: vi.fn(), showAssistantReplyPreview })
|
||||
return () => h('div', { 'data-agent-assistant-entry': '' })
|
||||
},
|
||||
})
|
||||
const panelStub = defineComponent({
|
||||
emits: ['assistant-preview', 'thinking-change', 'update:modelValue'],
|
||||
setup() {
|
||||
return () => h('div', { 'data-agent-assistant-panel': '' })
|
||||
},
|
||||
})
|
||||
const wrapper = mount(AgentAssistantWidget, {
|
||||
global: {
|
||||
stubs: {
|
||||
AgentAssistantEntry: entryStub,
|
||||
AgentAssistantPanel: panelStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
const panel = wrapper.findComponent(panelStub)
|
||||
|
||||
panel.vm.$emit('assistant-preview', '第一段')
|
||||
panel.vm.$emit('assistant-preview', '第二段')
|
||||
panel.vm.$emit('assistant-preview', '最终预览')
|
||||
await nextTick()
|
||||
|
||||
expect(showAssistantReplyPreview).toHaveBeenCalledTimes(1)
|
||||
expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('第一段')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(125)
|
||||
expect(showAssistantReplyPreview).toHaveBeenCalledTimes(2)
|
||||
expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('最终预览')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
46
src/components/agent/__tests__/AgentMarkdownContent.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentMarkdownContent from '@/components/agent/AgentMarkdownContent.vue'
|
||||
|
||||
describe('AgentMarkdownContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('throttles streaming Markdown and renders the final content immediately', async () => {
|
||||
const wrapper = mount(AgentMarkdownContent, {
|
||||
props: { content: '**开始**', streaming: true },
|
||||
})
|
||||
|
||||
expect(wrapper.html()).toContain('<strong>开始</strong>')
|
||||
await wrapper.setProps({ content: '**开始继续**' })
|
||||
expect(wrapper.html()).not.toContain('<strong>开始继续</strong>')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(96)
|
||||
expect(wrapper.html()).toContain('<strong>开始继续</strong>')
|
||||
|
||||
await wrapper.setProps({ content: '**最终结果**', streaming: false })
|
||||
expect(wrapper.html()).toContain('<strong>最终结果</strong>')
|
||||
})
|
||||
|
||||
it('escapes raw HTML from Agent output', () => {
|
||||
const wrapper = mount(AgentMarkdownContent, {
|
||||
props: { content: '<img src=x onerror="alert(1)">' },
|
||||
})
|
||||
|
||||
expect(wrapper.html()).not.toContain('<img')
|
||||
expect(wrapper.text()).toContain('<img src=x onerror=')
|
||||
})
|
||||
|
||||
it('does not render a bubble for whitespace-only Markdown', () => {
|
||||
const wrapper = mount(AgentMarkdownContent, {
|
||||
props: { content: '\n\n' },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.agent-assistant-message__bubble').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_PET_RANDOM_ACTION_MIN_DELAY } from '../agentPetActions'
|
||||
import { useAgentPetMachine } from '../useAgentPetMachine'
|
||||
|
||||
describe('useAgentPetMachine', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('runs finite random actions and clears the queue when decorative motion stops', async () => {
|
||||
const active = ref(true)
|
||||
const docked = ref(false)
|
||||
const dragging = ref(false)
|
||||
const pressed = ref(false)
|
||||
const thinking = ref(false)
|
||||
const scope = effectScope()
|
||||
const machine = scope.run(() =>
|
||||
useAgentPetMachine({
|
||||
active,
|
||||
docked,
|
||||
dragging,
|
||||
pressed,
|
||||
scheduleAutoDock: vi.fn(),
|
||||
shouldAutoDock: () => false,
|
||||
thinking,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(machine).toBeDefined()
|
||||
|
||||
machine?.scheduleRandomAction()
|
||||
vi.advanceTimersByTime(AGENT_PET_RANDOM_ACTION_MIN_DELAY)
|
||||
|
||||
expect(machine?.currentAction.value).toBe('wave')
|
||||
|
||||
active.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(machine?.currentAction.value).toBeNull()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
active.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(AGENT_PET_RANDOM_ACTION_MIN_DELAY)
|
||||
|
||||
expect(machine?.currentAction.value).not.toBeNull()
|
||||
|
||||
scope.stop()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,6 @@
|
||||
z-index: 3;
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
animation: agent-fab-antenna-idle 3.9s ease-in-out infinite;
|
||||
background: var(--agent-assistant-robot-outline);
|
||||
block-size: 0.66rem;
|
||||
inline-size: 0.18rem;
|
||||
@@ -58,7 +57,6 @@
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 11px;
|
||||
animation: agent-fab-head-idle 4.6s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-start) 0%,
|
||||
@@ -66,7 +64,7 @@
|
||||
);
|
||||
block-size: 2.05rem;
|
||||
box-shadow:
|
||||
inset 0 -0.2rem 0 rgba(54, 0, 126, 26%),
|
||||
inset 0 -0.2rem 0 var(--agent-assistant-robot-shade),
|
||||
inset 0.15rem 0.14rem 0 rgba(255, 255, 255, 24%);
|
||||
inline-size: 2.82rem;
|
||||
inset-block-start: 1.42rem;
|
||||
@@ -97,7 +95,6 @@
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 0 0 999px 999px;
|
||||
animation: agent-fab-blink 4.8s ease-in-out infinite;
|
||||
block-size: 0.42rem;
|
||||
border-block-end: 0.15rem solid var(--agent-assistant-robot-eye);
|
||||
inline-size: 0.42rem;
|
||||
@@ -136,7 +133,6 @@
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 0.65rem 0.65rem 0.55rem 0.55rem;
|
||||
animation: agent-fab-body-idle 4.2s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-mid) 0%,
|
||||
@@ -144,7 +140,7 @@
|
||||
);
|
||||
block-size: 1.34rem;
|
||||
box-shadow:
|
||||
inset 0 -0.18rem 0 rgba(54, 0, 126, 24%),
|
||||
inset 0 -0.18rem 0 var(--agent-assistant-robot-shade),
|
||||
inset 0.16rem 0.12rem 0 rgba(255, 255, 255, 24%);
|
||||
inline-size: 1.88rem;
|
||||
inset-block-start: 3.24rem;
|
||||
@@ -190,7 +186,7 @@
|
||||
var(--agent-assistant-robot-shell-mid) 0%,
|
||||
var(--agent-assistant-robot-outline-soft) 100%
|
||||
);
|
||||
box-shadow: inset 0 -0.12rem 0 rgba(54, 0, 126, 24%);
|
||||
box-shadow: inset 0 -0.12rem 0 var(--agent-assistant-robot-shade);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.22s ease;
|
||||
@@ -204,14 +200,12 @@
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-arm-left-idle 3.8s ease-in-out infinite;
|
||||
inset-inline-start: 0.9rem;
|
||||
transform: rotate(17deg);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-arm-right-idle 4.1s ease-in-out infinite;
|
||||
inset-inline-start: 3.08rem;
|
||||
transform: rotate(-17deg);
|
||||
transform-origin: top center;
|
||||
@@ -225,13 +219,11 @@
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-leg-left-idle 4.8s ease-in-out infinite;
|
||||
inset-inline-start: 1.48rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-leg-right-idle 4.8s ease-in-out 0.35s infinite;
|
||||
inset-inline-start: 2.46rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
@@ -257,8 +249,7 @@
|
||||
}
|
||||
|
||||
.agent-assistant-fab__trigger:focus-visible .agent-assistant-fab__bot {
|
||||
filter:
|
||||
drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
|
||||
filter: drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
|
||||
drop-shadow(0 0 0.34rem rgba(var(--v-theme-primary), 0.55));
|
||||
}
|
||||
|
||||
@@ -315,83 +306,6 @@
|
||||
transform: translate(0.34rem, 0.02rem) rotate(2deg) scale(0.82);
|
||||
}
|
||||
|
||||
@keyframes agent-fab-head-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-1.8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-body-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.04rem)) scaleY(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-antenna-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(15deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(12deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-11deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-core-pulse {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
193
src/components/auth/LoginMfaStep.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MfaMethod } from '@/types/auth'
|
||||
|
||||
interface Props {
|
||||
/** 当前验证步骤的错误信息。 */
|
||||
errorMessage: string
|
||||
/** 密码验证通过后服务端声明的可用方式。 */
|
||||
methods: MfaMethod[]
|
||||
/** OTP 提交状态。 */
|
||||
otpLoading: boolean
|
||||
/** 当前输入的 OTP。 */
|
||||
otpPassword: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'back'): void
|
||||
(event: 'otp'): void
|
||||
(event: 'update:otpPassword', value: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const hasOtp = computed(() => props.methods.includes('otp'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mfa-step" :class="{ 'mfa-step--unavailable': !hasOtp }" aria-labelledby="mfa-step-title">
|
||||
<header class="mfa-step__header">
|
||||
<VBtn
|
||||
data-testid="mfa-back"
|
||||
icon="mdi-arrow-left"
|
||||
size="small"
|
||||
variant="text"
|
||||
:aria-label="t('login.mfa.back')"
|
||||
:disabled="props.otpLoading"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<div>
|
||||
<h2 id="mfa-step-title" class="mfa-step__title">{{ t('login.secondaryVerification') }}</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form v-if="hasOtp" data-testid="mfa-otp-form" class="mfa-step__method" @submit.prevent="emit('otp')">
|
||||
<p class="mfa-step__description">{{ t('login.mfa.otpPrompt') }}</p>
|
||||
<div class="mfa-step__field">
|
||||
<VIcon icon="mdi-shield-key" class="mfa-step__field-icon" aria-hidden="true" />
|
||||
<input
|
||||
:value="props.otpPassword"
|
||||
class="mfa-step__input"
|
||||
type="text"
|
||||
name="otp"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
:placeholder="t('login.otpCode')"
|
||||
:aria-label="t('login.otpCode')"
|
||||
autofocus
|
||||
:disabled="props.otpLoading"
|
||||
@input="emit('update:otpPassword', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
<VBtn
|
||||
block
|
||||
type="submit"
|
||||
color="primary"
|
||||
class="mfa-step__submit"
|
||||
prepend-icon="mdi-login"
|
||||
:loading="props.otpLoading"
|
||||
:disabled="!props.otpPassword"
|
||||
>
|
||||
{{ t('login.loginWithOtp') }}
|
||||
</VBtn>
|
||||
</form>
|
||||
|
||||
<VAlert v-if="props.errorMessage" class="mfa-step__alert" type="error" variant="tonal" role="alert">
|
||||
{{ props.errorMessage }}
|
||||
</VAlert>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mfa-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mfa-step__header {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
margin-block-end: 20px;
|
||||
}
|
||||
|
||||
.mfa-step__title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mfa-step__method {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mfa-step__field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.38);
|
||||
min-block-size: 52px;
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--v-theme-surface), 0.13);
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
background 220ms ease;
|
||||
}
|
||||
|
||||
.mfa-step__field:focus-within {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.mfa-step__field-icon {
|
||||
position: absolute;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
inset-inline-start: 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mfa-step__input {
|
||||
border: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
block-size: 50px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font: inherit;
|
||||
inline-size: 100%;
|
||||
outline: none;
|
||||
padding-block: 0;
|
||||
padding-inline: 48px 16px;
|
||||
}
|
||||
|
||||
.mfa-step__input::placeholder {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mfa-step__input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: var(--v-disabled-opacity);
|
||||
}
|
||||
|
||||
.mfa-step__submit {
|
||||
flex: 0 0 48px !important;
|
||||
block-size: 48px !important;
|
||||
max-block-size: 48px !important;
|
||||
min-block-size: 48px !important;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mfa-step__description {
|
||||
margin: 0;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mfa-step__alert {
|
||||
margin-block-start: 18px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mfa-step--unavailable {
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.mfa-step--unavailable .mfa-step__header {
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
.mfa-step--unavailable .mfa-step__alert {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
</style>
|
||||
47
src/components/auth/__tests__/LoginMfaStep.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import LoginMfaStep from '@/components/auth/LoginMfaStep.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
const slotStub = { template: '<div><slot /></div>' }
|
||||
const buttonStub = {
|
||||
emits: ['click'],
|
||||
props: ['loading'],
|
||||
template: '<button :disabled="loading" @click="$emit(\'click\')"><slot /></button>',
|
||||
}
|
||||
|
||||
function mountStep(methods: Array<'otp'>) {
|
||||
return shallowMount(LoginMfaStep, {
|
||||
global: {
|
||||
stubs: {
|
||||
VAlert: slotStub,
|
||||
VBtn: buttonStub,
|
||||
VIcon: true,
|
||||
VTextField: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
errorMessage: '',
|
||||
methods,
|
||||
otpLoading: false,
|
||||
otpPassword: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('LoginMfaStep', () => {
|
||||
it('shows only the OTP form for an OTP-only account', () => {
|
||||
const wrapper = mountStep(['otp'])
|
||||
|
||||
expect(wrapper.find('[data-testid="mfa-otp-form"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows no authentication action when the server declares no supported method', () => {
|
||||
const wrapper = mountStep([])
|
||||
|
||||
expect(wrapper.find('[data-testid="mfa-otp-form"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
71
src/components/cache/RecognitionCachePanel.vue
vendored
@@ -11,9 +11,9 @@ import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
|
||||
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
|
||||
type RecognitionCacheSource = 'tmdb' | 'douban'
|
||||
|
||||
const MOBILE_CACHE_PAGE_SIZE = 20
|
||||
const RECOGNITION_CACHE_ENDPOINT = 'tmdb/cache'
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
@@ -22,18 +22,8 @@ const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
const isMobile = computed(() => display.smAndDown.value)
|
||||
const recognitionSource = computed<RecognitionCacheSource>(() =>
|
||||
globalSettingsStore.globalSettings.RECOGNIZE_SOURCE === 'douban' ? 'douban' : 'tmdb',
|
||||
)
|
||||
const recognitionSourceName = computed(() =>
|
||||
recognitionSource.value === 'douban'
|
||||
? t('setting.cache.recognitionSource.douban')
|
||||
: t('setting.cache.recognitionSource.themoviedb'),
|
||||
)
|
||||
const recognitionIdLabel = computed(() =>
|
||||
recognitionSource.value === 'douban' ? t('setting.cache.doubanId') : t('setting.cache.tmdbId'),
|
||||
)
|
||||
const recognitionCacheEndpoint = computed(() => `${recognitionSource.value}/cache`)
|
||||
const recognitionSourceName = computed(() => t('setting.cache.recognitionSource.themoviedb'))
|
||||
const recognitionIdLabel = computed(() => t('setting.cache.tmdbId'))
|
||||
const recognitionFilterPlaceholder = computed(() =>
|
||||
t('setting.cache.filterRecognitionCache', { source: recognitionSourceName.value }),
|
||||
)
|
||||
@@ -45,6 +35,8 @@ const cacheData = ref<RecognitionCacheData>({
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
shared_recognized: 0,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
})
|
||||
const mobileVisibleCount = ref(MOBILE_CACHE_PAGE_SIZE)
|
||||
@@ -104,16 +96,25 @@ function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) =>
|
||||
done(mobileHasMore.value ? 'ok' : 'empty')
|
||||
}
|
||||
|
||||
/** 加载当前识别数据源对应的缓存列表。 */
|
||||
/** 加载 TMDB 主识别缓存列表。 */
|
||||
async function loadCacheData(showSuccess = false) {
|
||||
const requestId = ++cacheLoadRequestId
|
||||
try {
|
||||
loading.value = true
|
||||
const response = (await api.get(recognitionCacheEndpoint.value)) as unknown as ApiResponse<RecognitionCacheData>
|
||||
const response = (await api.get(RECOGNITION_CACHE_ENDPOINT)) as unknown as ApiResponse<RecognitionCacheData>
|
||||
if (requestId !== cacheLoadRequestId) return
|
||||
const responseData = response.data ?? { count: 0, recognized: 0, unrecognized: 0, data: [] }
|
||||
const responseData = response.data ?? {
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
shared_recognized: 0,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
}
|
||||
cacheData.value = {
|
||||
...responseData,
|
||||
shared_recognized: responseData.shared_recognized ?? 0,
|
||||
shared_recognize_enabled: responseData.shared_recognize_enabled ?? false,
|
||||
data: responseData.data.map(item => ({ ...item, recognition_id: getRecognitionId(item) })),
|
||||
}
|
||||
selectedItems.value = selectedItems.value.filter(key => cacheData.value.data.some(item => item.key === key))
|
||||
@@ -128,7 +129,7 @@ async function loadCacheData(showSuccess = false) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空当前识别数据源的全部识别缓存。 */
|
||||
/** 清空全部 TMDB 主识别缓存。 */
|
||||
async function clearAllCache() {
|
||||
const confirmed = await createConfirm({
|
||||
type: 'warn',
|
||||
@@ -139,7 +140,7 @@ async function clearAllCache() {
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const response = (await api.delete(recognitionCacheEndpoint.value)) as unknown as ApiResponse
|
||||
const response = (await api.delete(RECOGNITION_CACHE_ENDPOINT)) as unknown as ApiResponse
|
||||
if (!response.success) throw new Error(response.message)
|
||||
$toast.success(response.message || t('setting.cache.clearSuccess'))
|
||||
await loadCacheData()
|
||||
@@ -152,10 +153,10 @@ async function clearAllCache() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求当前识别数据源接口删除指定识别缓存。 */
|
||||
/** 请求 TMDB 接口删除指定识别缓存。 */
|
||||
async function deleteCacheItem(key: string) {
|
||||
const response = (await api.delete(
|
||||
`${recognitionCacheEndpoint.value}/${encodeURIComponent(key)}`,
|
||||
`${RECOGNITION_CACHE_ENDPOINT}/${encodeURIComponent(key)}`,
|
||||
)) as unknown as ApiResponse
|
||||
if (!response.success) throw new Error(response.message)
|
||||
}
|
||||
@@ -206,10 +207,9 @@ function getPosterUrl(item: RecognitionCacheItem): string {
|
||||
return getDisplayImageUrl(sourceUrl, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
|
||||
/** 获取当前识别数据源对应的媒体 ID。 */
|
||||
/** 获取识别缓存对应的 TMDB ID。 */
|
||||
function getRecognitionId(item: RecognitionCacheItem): string {
|
||||
const recognitionId = recognitionSource.value === 'douban' ? item.douban_id : item.tmdb_id
|
||||
return recognitionId ? String(recognitionId) : ''
|
||||
return item.tmdb_id ? String(item.tmdb_id) : ''
|
||||
}
|
||||
|
||||
/** 判断识别缓存条目是否包含有效媒体 ID。 */
|
||||
@@ -249,13 +249,6 @@ onMounted(() => {
|
||||
watch([searchFilter, statusFilter], () => {
|
||||
resetMobilePagination()
|
||||
})
|
||||
|
||||
watch(recognitionSource, () => {
|
||||
searchFilter.value = ''
|
||||
statusFilter.value = 'all'
|
||||
selectedItems.value = []
|
||||
void loadCacheData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -276,13 +269,20 @@ watch(recognitionSource, () => {
|
||||
<span>{{ t('setting.cache.recognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isMobile" class="cache-panel-stat cache-panel-stat--warning">
|
||||
<div v-if="!isMobile || cacheData.shared_recognize_enabled" class="cache-panel-stat cache-panel-stat--warning">
|
||||
<VIcon icon="mdi-help-circle-outline" size="22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.unrecognized }}</strong>
|
||||
<span>{{ t('setting.cache.unrecognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="cacheData.shared_recognize_enabled" class="cache-panel-stat cache-panel-stat--info">
|
||||
<VIcon icon="mdi-cloud-check-outline" :size="isMobile ? 32 : 22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.shared_recognized }}</strong>
|
||||
<span>{{ t('setting.cache.sharedRecognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isMobile" class="cache-panel-actions">
|
||||
@@ -376,7 +376,7 @@ watch(recognitionSource, () => {
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<article class="recognition-cache-mobile-item">
|
||||
<div class="recognition-cache-poster">
|
||||
<div class="recognition-cache-poster rounded-md">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" size="28" />
|
||||
</div>
|
||||
@@ -432,7 +432,7 @@ watch(recognitionSource, () => {
|
||||
:loading-text="t('common.loadingText')"
|
||||
>
|
||||
<template #item.poster="{ item }">
|
||||
<div class="recognition-cache-table__poster">
|
||||
<div class="recognition-cache-table__poster rounded-md">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" />
|
||||
</div>
|
||||
@@ -550,6 +550,10 @@ watch(recognitionSource, () => {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
}
|
||||
|
||||
.cache-panel-stat--info {
|
||||
color: rgb(var(--v-theme-info));
|
||||
}
|
||||
|
||||
.cache-panel-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
@@ -590,7 +594,6 @@ watch(recognitionSource, () => {
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--app-control-radius);
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
color: rgba(var(--v-theme-on-surface), 0.36);
|
||||
}
|
||||
|
||||
99
src/components/cache/__tests__/RecognitionCachePanel.spec.ts
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
import RecognitionCachePanel from '@/components/cache/RecognitionCachePanel.vue'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: mocks.apiGet,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({
|
||||
error: mocks.toastError,
|
||||
success: mocks.toastSuccess,
|
||||
}),
|
||||
}))
|
||||
|
||||
async function renderRecognitionCachePanel(recognitionSource = 'themoviedb') {
|
||||
return renderWithProviders(RecognitionCachePanel, {
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: {
|
||||
GLOBAL_IMAGE_CACHE: false,
|
||||
RECOGNIZE_SOURCE: recognitionSource,
|
||||
TMDB_IMAGE_DOMAIN: 'image.tmdb.org',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('RecognitionCachePanel shared recognition statistics', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
})
|
||||
|
||||
it('shows the persisted shared recognition count when sharing is enabled', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
count: 12,
|
||||
recognized: 9,
|
||||
unrecognized: 3,
|
||||
shared_recognized: 27,
|
||||
shared_recognize_enabled: true,
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
|
||||
await renderRecognitionCachePanel()
|
||||
|
||||
expect(await screen.findByText('共享识别')).toBeInTheDocument()
|
||||
expect(screen.getByText('27')).toBeInTheDocument()
|
||||
expect(screen.getByText('未识别')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides shared recognition statistics when sharing is disabled', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
count: 12,
|
||||
recognized: 9,
|
||||
unrecognized: 3,
|
||||
shared_recognized: 27,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
|
||||
await renderRecognitionCachePanel()
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('tmdb/cache'))
|
||||
|
||||
expect(screen.queryByText('共享识别')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loads TMDB cache even when Douban is selected as the recognition source', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
shared_recognized: 0,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
|
||||
await renderRecognitionCachePanel('douban')
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('tmdb/cache'))
|
||||
})
|
||||
})
|
||||
@@ -2,12 +2,15 @@
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getProxyImageUrl } from '@/utils/imageUtils'
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaServerPlayItem>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
@@ -40,12 +43,10 @@ async function goPlay() {
|
||||
const getImgUrl = computed(() => {
|
||||
const image = props.media?.image || ''
|
||||
if (!image || imageLoadError.value) return noImage
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const use_cookies = props.media?.use_cookies
|
||||
if (use_cookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return url
|
||||
return getProxyImageUrl(image, {
|
||||
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
useCookies: props.media?.use_cookies,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -63,43 +64,43 @@ const getImgUrl = computed(() => {
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="getImgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="backdrop-card-image"
|
||||
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="backdrop-card-placeholder">
|
||||
<VSkeletonLoader class="backdrop-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<VCardText
|
||||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
<h1
|
||||
class="mb-1 text-white text-shadow font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ..."
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="getImgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="backdrop-card-image"
|
||||
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="backdrop-card-placeholder">
|
||||
<VSkeletonLoader class="backdrop-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<VCardText
|
||||
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
<span class="text-shadow text-sm">{{ props.media?.subtitle }}</span>
|
||||
</VCardText>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear
|
||||
v-if="props.media?.percent"
|
||||
:model-value="props.media?.percent"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
<h1
|
||||
class="mb-1 text-white text-shadow font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
<span class="text-shadow text-sm">{{ props.media?.subtitle }}</span>
|
||||
</VCardText>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear
|
||||
v-if="props.media?.percent"
|
||||
:model-value="props.media?.percent"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,13 +5,14 @@ import { nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storageRemoteDict } from '@/api/constants'
|
||||
|
||||
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253'
|
||||
const DEFAULT_DIRECTORY_ACCENT_RGB = '141, 81, 249'
|
||||
const STORAGE_ACCENT_COLOR_MAP = {
|
||||
local: '#FFB400',
|
||||
alipan: '#00A7F2',
|
||||
u115: '#17B26A',
|
||||
rclone: '#6675FF',
|
||||
alist: '#12B8D7',
|
||||
alistgo: '#1BA0D8',
|
||||
smb: '#3B82F6',
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ const typeItems = computed(() => [
|
||||
{ title: t('common.all'), value: '' },
|
||||
{ title: t('mediaType.movie'), value: '电影' },
|
||||
{ title: t('mediaType.tv'), value: '电视剧' },
|
||||
{ title: t('mediaType.music'), value: '音乐' },
|
||||
])
|
||||
|
||||
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
|
||||
|
||||
@@ -1,33 +1,113 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { DownloadingInfo } from '@/api/types'
|
||||
import type { ApiResponse, DownloadingInfo } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 输入参数
|
||||
/** 卡片使用的下载任务信息,兼容接口已经返回但公共类型尚未声明的来源站点。 */
|
||||
interface DownloadingCardInfo extends DownloadingInfo {
|
||||
site_name?: string
|
||||
trackers?: string[]
|
||||
}
|
||||
|
||||
/** 正在下载任务卡片,负责展示任务状态并提供暂停、继续和删除操作。 */
|
||||
const props = defineProps({
|
||||
info: Object as PropType<DownloadingInfo>,
|
||||
info: Object as PropType<DownloadingCardInfo>,
|
||||
downloaderName: String,
|
||||
})
|
||||
|
||||
// 是否显示卡片
|
||||
const { t } = useI18n()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 卡片在删除成功后就地隐藏,等待外层轮询同步任务列表。
|
||||
const cardState = ref(true)
|
||||
const pendingAction = ref<'delete' | 'toggle' | null>(null)
|
||||
const imageLoadError = ref(false)
|
||||
const media = computed(() => props.info?.media ?? {})
|
||||
|
||||
// 进度条
|
||||
function getPercentage() {
|
||||
return props.info?.progress ?? 0
|
||||
watch(
|
||||
() => media.value.poster,
|
||||
() => {
|
||||
imageLoadError.value = false
|
||||
},
|
||||
)
|
||||
|
||||
const posterUrl = computed(() =>
|
||||
getDisplayImageUrl(media.value.poster || '', globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||
)
|
||||
const hasPosterImage = computed(() => Boolean(posterUrl.value && !imageLoadError.value))
|
||||
|
||||
const mediaTitle = computed(() => media.value.title || props.info?.name || props.info?.title || t('common.unknown'))
|
||||
|
||||
const episodeText = computed(() => {
|
||||
const recognizedEpisode = [media.value.season, media.value.episode].filter(Boolean).join(' ')
|
||||
return recognizedEpisode || props.info?.season_episode || ''
|
||||
})
|
||||
|
||||
const titleMetaText = computed(() => [props.info?.year?.trim(), episodeText.value].filter(Boolean).join(' · '))
|
||||
|
||||
const mediaTypeText = computed(() => {
|
||||
const type = String(media.value.type || '').trim()
|
||||
if (type === '电影' || type.toLowerCase() === 'movie') return t('mediaType.movie')
|
||||
if (type === '电视剧' || type.toLowerCase() === 'tv') return t('mediaType.tv')
|
||||
if (type === '音乐' || type.toLowerCase() === 'music') return t('mediaType.music')
|
||||
if (type) return type
|
||||
if (media.value.season || media.value.episode || props.info?.season_episode) return t('mediaType.tv')
|
||||
return media.value.title ? t('mediaType.movie') : ''
|
||||
})
|
||||
|
||||
const mediaTypeIcon = computed(() => {
|
||||
const type = String(media.value.type || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (type === '电影' || type === 'movie') return 'mdi-movie-outline'
|
||||
if (type === '电视剧' || type === 'tv') return 'mdi-television-classic'
|
||||
if (type === '音乐' || type === 'music') return 'mdi-music-note'
|
||||
return 'mdi-play-box-outline'
|
||||
})
|
||||
|
||||
const progressValue = computed(() => {
|
||||
const progress = Number(props.info?.progress ?? 0)
|
||||
if (!Number.isFinite(progress)) return 0
|
||||
return Math.min(Math.max(progress, 0), 100)
|
||||
})
|
||||
|
||||
const progressText = computed(() => `${Math.round(progressValue.value)}%`)
|
||||
const sizeText = computed(() => formatFileSize(props.info?.size || 0))
|
||||
const remainingTimeText = computed(() => props.info?.left_time?.trim() || '--')
|
||||
|
||||
/** 从 Tracker 地址中仅提取可展示的主机名,避免暴露路径、查询参数或 passkey。 */
|
||||
function getTrackerHostname(tracker?: string) {
|
||||
if (!tracker) return ''
|
||||
try {
|
||||
return new URL(tracker).hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 速度
|
||||
function getSpeedText() {
|
||||
return `${formatFileSize(props.info?.size || 0)} ↑ ${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${
|
||||
props.info?.left_time
|
||||
}`
|
||||
const sourceSiteText = computed(() => {
|
||||
const siteName = String(props.info?.site_name || media.value.site_name || '').trim()
|
||||
if (siteName) return siteName
|
||||
return props.info?.trackers?.map(getTrackerHostname).find(Boolean) || ''
|
||||
})
|
||||
|
||||
/** 为下载器返回的速率补齐单位,并兼容已经包含每秒单位的值。 */
|
||||
function formatSpeed(speed?: string) {
|
||||
const value = speed?.trim() || '0 B'
|
||||
return /\/s$/i.test(value) ? value : `${value}/s`
|
||||
}
|
||||
|
||||
// 下载状态
|
||||
const downloadSpeedText = computed(() => formatSpeed(props.info?.dlspeed))
|
||||
const uploadSpeedText = computed(() => formatSpeed(props.info?.upspeed))
|
||||
const hasDownloadSpeed = computed(() => Number.parseFloat(props.info?.dlspeed || '') > 0)
|
||||
const hasUploadSpeed = computed(() => Number.parseFloat(props.info?.upspeed || '') > 0)
|
||||
|
||||
// 下载状态跟随轮询数据变化,操作成功时也会立即响应。
|
||||
const isDownloading = ref(props.info?.state === 'downloading')
|
||||
|
||||
// 监听props.info?.state的变化
|
||||
watch(
|
||||
() => props.info?.state,
|
||||
newValue => {
|
||||
@@ -35,19 +115,14 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 下载状态控制
|
||||
/** 暂停或继续当前任务,并防止请求完成前重复触发。 */
|
||||
async function toggleDownload() {
|
||||
if (pendingAction.value) return
|
||||
|
||||
const operation = isDownloading.value ? 'stop' : 'start'
|
||||
pendingAction.value = 'toggle'
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`, {
|
||||
const result: ApiResponse<unknown> = await api.get(`download/${operation}/${props.info?.hash}`, {
|
||||
params: {
|
||||
name: props.downloaderName,
|
||||
},
|
||||
@@ -56,16 +131,25 @@ async function toggleDownload() {
|
||||
if (result.success) isDownloading.value = !isDownloading.value
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 删除下截
|
||||
/** 删除当前下载任务,并仅在业务请求成功后隐藏卡片。 */
|
||||
async function deleteDownload() {
|
||||
if (pendingAction.value) return
|
||||
|
||||
pendingAction.value = 'delete'
|
||||
try {
|
||||
await api.delete(`download/${props.info?.hash}`, { params: { name: props.downloaderName } })
|
||||
cardState.value = false
|
||||
const result: ApiResponse<unknown> = await api.delete(`download/${props.info?.hash}`, {
|
||||
params: { name: props.downloaderName },
|
||||
})
|
||||
if (result.success) cardState.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -75,62 +159,137 @@ async function deleteDownload() {
|
||||
<template #default="hover">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-if="cardState" v-bind="hover.props" class="downloading-card-hover-area h-full">
|
||||
<VCard
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-hover-lift-card app-surface flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
<div
|
||||
class="downloading-card-shell app-hover-lift-card h-full"
|
||||
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
|
||||
>
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="props.info?.media.image"
|
||||
class="downloading-card-image"
|
||||
aspect-ratio="2/3"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
position="top"
|
||||
<VCard
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card h-full overflow-hidden"
|
||||
:class="{ 'downloading-card--no-image': !hasPosterImage }"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
<div v-if="hasPosterImage" class="downloading-card__poster">
|
||||
<VImg
|
||||
:src="posterUrl"
|
||||
class="downloading-card__image"
|
||||
cover
|
||||
position="center"
|
||||
@error="imageLoadError = true"
|
||||
>
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="downloading-card__image-loader h-full" />
|
||||
</template>
|
||||
</VImg>
|
||||
<div class="downloading-card__poster-edge" />
|
||||
</div>
|
||||
|
||||
<VCardText class="downloading-card__body">
|
||||
<div class="downloading-card__heading">
|
||||
<div class="downloading-card__title" :title="mediaTitle">
|
||||
<span>{{ mediaTitle }}</span>
|
||||
<span v-if="titleMetaText" class="downloading-card__title-meta">{{ titleMetaText }}</span>
|
||||
</div>
|
||||
<div class="downloading-card__torrent-title" :title="props.info?.title">
|
||||
{{ props.info?.title || t('common.unknown') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none downloading-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<VCardTitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.media.title || props.info?.name }}
|
||||
{{
|
||||
props.info?.media.episode
|
||||
? `${props.info?.media.season} ${props.info?.media.episode}`
|
||||
: props.info?.season_episode
|
||||
}}
|
||||
</VCardTitle>
|
||||
<div class="downloading-card__meta">
|
||||
<span v-if="mediaTypeText" class="downloading-card__meta-item downloading-card__meta-type">
|
||||
<VIcon :icon="mediaTypeIcon" size="14" />
|
||||
{{ mediaTypeText }}
|
||||
</span>
|
||||
<span v-if="mediaTypeText" aria-hidden="true" class="downloading-card__meta-separator">·</span>
|
||||
<span class="downloading-card__meta-item downloading-card__meta-size">{{ sizeText }}</span>
|
||||
<template v-if="sourceSiteText">
|
||||
<span aria-hidden="true" class="downloading-card__meta-separator">·</span>
|
||||
<span class="downloading-card__meta-item downloading-card__meta-source" :title="sourceSiteText">
|
||||
{{ sourceSiteText }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<VCardSubtitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.title }}
|
||||
</VCardSubtitle>
|
||||
<div
|
||||
v-if="progressValue > 0"
|
||||
class="downloading-card__progress"
|
||||
:class="isDownloading ? 'downloading-card__progress--active' : 'downloading-card__progress--paused'"
|
||||
>
|
||||
<div class="downloading-card__progress-label">
|
||||
<div class="downloading-card__progress-copy">
|
||||
<span class="downloading-card__progress-state">
|
||||
<VIcon :icon="isDownloading ? 'mdi-download' : 'mdi-pause'" size="14" />
|
||||
{{ isDownloading ? t('common.download') : t('common.pause') }}
|
||||
</span>
|
||||
<span class="downloading-card__progress-remaining">
|
||||
<span aria-hidden="true" class="downloading-card__progress-separator">·</span>
|
||||
{{ remainingTimeText }}
|
||||
</span>
|
||||
</div>
|
||||
<strong>{{ progressText }}</strong>
|
||||
</div>
|
||||
<VProgressLinear
|
||||
:aria-label="isDownloading ? t('common.download') : t('common.pause')"
|
||||
:model-value="progressValue"
|
||||
:color="isDownloading ? 'info' : 'warning'"
|
||||
bg-color="surface-variant"
|
||||
height="6"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
|
||||
{{ getSpeedText() }}
|
||||
</VCardText>
|
||||
<div class="downloading-card__footer">
|
||||
<div class="downloading-card__speeds">
|
||||
<div
|
||||
class="downloading-card__speed downloading-card__speed--download"
|
||||
:class="{ 'downloading-card__speed--idle': !hasDownloadSpeed }"
|
||||
>
|
||||
<VIcon icon="mdi-arrow-down" size="16" />
|
||||
<strong :title="downloadSpeedText">{{ downloadSpeedText }}</strong>
|
||||
</div>
|
||||
<div
|
||||
class="downloading-card__speed downloading-card__speed--upload"
|
||||
:class="{ 'downloading-card__speed--idle': !hasUploadSpeed }"
|
||||
>
|
||||
<VIcon icon="mdi-arrow-up" size="16" />
|
||||
<strong :title="uploadSpeedText">{{ uploadSpeedText }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VCardText v-if="getPercentage() > 0" class="text-white">
|
||||
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-space-between">
|
||||
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
<VCardActions class="downloading-card__actions pa-0">
|
||||
<VBtn
|
||||
:aria-label="isDownloading ? t('common.pause') : t('common.download')"
|
||||
:disabled="pendingAction === 'delete'"
|
||||
icon
|
||||
:loading="pendingAction === 'toggle'"
|
||||
:color="isDownloading ? 'info' : 'warning'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
@click="toggleDownload"
|
||||
>
|
||||
<VIcon :icon="isDownloading ? 'mdi-pause' : 'mdi-play'" />
|
||||
<VTooltip activator="parent" location="top">
|
||||
{{ isDownloading ? t('common.pause') : t('common.download') }}
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
<VBtn
|
||||
:aria-label="t('common.delete')"
|
||||
class="downloading-card__delete-action"
|
||||
color="on-surface"
|
||||
:disabled="pendingAction === 'toggle'"
|
||||
:loading="pendingAction === 'delete'"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="deleteDownload"
|
||||
>
|
||||
<VIcon icon="mdi-trash-can-outline" />
|
||||
<VTooltip activator="parent" location="top">{{ t('common.delete') }}</VTooltip>
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
@@ -140,16 +299,296 @@ async function deleteDownload() {
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.downloading-card-hover-area {
|
||||
block-size: 100%;
|
||||
container-type: inline-size;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.downloading-card-image {
|
||||
block-size: 100%;
|
||||
.downloading-card-shell {
|
||||
border-radius: var(--app-surface-radius);
|
||||
}
|
||||
|
||||
.downloading-card-background {
|
||||
border-radius: inherit;
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
.downloading-card {
|
||||
display: grid;
|
||||
min-block-size: 12rem;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
grid-template-columns: 8rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.downloading-card__poster {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-block-size: 12rem;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
|
||||
.downloading-card__image,
|
||||
.downloading-card__image-loader {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.downloading-card__poster-edge {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, rgba(var(--v-theme-surface), 0) 72%, rgba(var(--v-theme-surface), 0.38));
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.downloading-card__body {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
.downloading-card__meta {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.72rem;
|
||||
gap: 0.35rem;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.downloading-card__meta-item {
|
||||
display: inline-flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.downloading-card__meta-type,
|
||||
.downloading-card__meta-size,
|
||||
.downloading-card__meta-separator {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.downloading-card__meta-type {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.downloading-card__meta-source {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.downloading-card__heading {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.downloading-card__title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.downloading-card__title-meta {
|
||||
margin-inline-start: 0.35rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.76rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.downloading-card__torrent-title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-block-start: 0.25rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.downloading-card__progress {
|
||||
min-inline-size: 0;
|
||||
|
||||
--downloading-card-status-color: rgb(var(--v-theme-info));
|
||||
}
|
||||
|
||||
.downloading-card__progress--paused {
|
||||
--downloading-card-status-color: rgb(var(--v-theme-warning));
|
||||
}
|
||||
|
||||
.downloading-card__progress-label {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-block-end: 0.35rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.72rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.downloading-card__progress-copy {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.downloading-card__progress-state {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
color: var(--downloading-card-status-color);
|
||||
font-weight: 650;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.downloading-card__progress-remaining {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.downloading-card__progress-label strong {
|
||||
flex: 0 0 auto;
|
||||
color: var(--downloading-card-status-color);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.downloading-card__progress-separator {
|
||||
padding-inline: 0.12rem;
|
||||
}
|
||||
|
||||
.downloading-card__footer {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-block-start: auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.downloading-card__speeds {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
column-gap: 0.8rem;
|
||||
row-gap: 0.15rem;
|
||||
}
|
||||
|
||||
.downloading-card__speed {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.downloading-card__speed strong {
|
||||
overflow: hidden;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.downloading-card__speed--download .v-icon {
|
||||
color: rgb(var(--v-theme-info));
|
||||
}
|
||||
|
||||
.downloading-card__speed--download strong {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.downloading-card__speed--upload .v-icon {
|
||||
color: rgb(var(--v-theme-success));
|
||||
}
|
||||
|
||||
.downloading-card__speed--idle {
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
.downloading-card__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.downloading-card__delete-action {
|
||||
opacity: var(--v-medium-emphasis-opacity);
|
||||
}
|
||||
|
||||
.downloading-card__delete-action:hover,
|
||||
.downloading-card__delete-action:focus-visible {
|
||||
color: rgb(var(--v-theme-error)) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@container (width <= 25rem) {
|
||||
.downloading-card {
|
||||
min-block-size: 11rem;
|
||||
grid-template-columns: 7.333rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.downloading-card__poster {
|
||||
min-block-size: 11rem;
|
||||
}
|
||||
|
||||
.downloading-card__body {
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
.downloading-card__title {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.downloading-card__torrent-title {
|
||||
font-size: 0.69rem;
|
||||
}
|
||||
|
||||
.downloading-card__meta,
|
||||
.downloading-card__progress-label {
|
||||
font-size: 0.69rem;
|
||||
}
|
||||
|
||||
.downloading-card__speeds {
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.downloading-card__speed strong {
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width <= 21rem) {
|
||||
.downloading-card__body {
|
||||
padding-inline: 0.75rem !important;
|
||||
}
|
||||
|
||||
.downloading-card__actions :deep(.v-btn) {
|
||||
block-size: 2.25rem;
|
||||
inline-size: 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.downloading-card.downloading-card--no-image {
|
||||
min-block-size: 0;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { MediaServerLibrary } from '@/api/types'
|
||||
import plex from '@images/misc/plex.png'
|
||||
import emby from '@images/misc/emby.png'
|
||||
import jellyfin from '@images/misc/jellyfin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -12,6 +13,7 @@ const props = defineProps({
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// canvas
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
@@ -96,11 +98,10 @@ async function goPlay() {
|
||||
*/
|
||||
function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
if (!url || imageError.value) return getDefaultImage()
|
||||
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
if (use_cookies) {
|
||||
imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return imgurl
|
||||
return getProxyImageUrl(url, {
|
||||
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
useCookies: use_cookies,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,10 +116,10 @@ async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
|
||||
// 为所有图片添加system/img前缀
|
||||
for (let i = 0; i < IMAGES.length; i++) {
|
||||
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(IMAGES[i])}`
|
||||
if (use_cookies) {
|
||||
IMAGES[i] += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
IMAGES[i] = getProxyImageUrl(IMAGES[i], {
|
||||
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
useCookies: use_cookies,
|
||||
})
|
||||
}
|
||||
|
||||
// canvas
|
||||
@@ -213,33 +214,33 @@ onMounted(async () => {
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||
<VImg
|
||||
:src="imgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="library-card-image"
|
||||
:class="{ 'library-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="library-card-placeholder">
|
||||
<VSkeletonLoader class="library-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="library-card-shade" aria-hidden="true" />
|
||||
<div v-if="showCountCorner" class="library-card-count-corner">
|
||||
<span>{{ countLabel }}</span>
|
||||
</div>
|
||||
<div class="library-card-label">
|
||||
<span>{{ props.media?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||
<VImg
|
||||
:src="imgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="library-card-image"
|
||||
:class="{ 'library-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="library-card-placeholder">
|
||||
<VSkeletonLoader class="library-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="library-card-shade" aria-hidden="true" />
|
||||
<div v-if="showCountCorner" class="library-card-count-corner">
|
||||
<span>{{ countLabel }}</span>
|
||||
</div>
|
||||
<div class="library-card-label">
|
||||
<span>{{ props.media?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -310,7 +311,7 @@ onMounted(async () => {
|
||||
background: rgba(8, 13, 22, 72%);
|
||||
block-size: 100%;
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||
content: "";
|
||||
content: '';
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 100%;
|
||||
@@ -321,7 +322,7 @@ onMounted(async () => {
|
||||
background: rgba(var(--v-theme-primary), 62%);
|
||||
block-size: 100%;
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||
content: "";
|
||||
content: '';
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -6,7 +6,6 @@ let mediaCardIdSeed = 0
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { formatRating } from '@/@core/utils/formatters'
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
import { buildMusicDetailRoute, getMusicKey } from '@/utils/music'
|
||||
|
||||
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
|
||||
|
||||
@@ -62,6 +62,34 @@ const isImageLoaded = ref(false)
|
||||
// 图片加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 图片请求代际隔离复用卡片的迟到事件,避免旧海报改变新媒体的 renderer 资格。
|
||||
const imageRequestRevision = ref(0)
|
||||
|
||||
// renderer 只能在真实海报完成可见淡入后退出,资源 load 本身不代表像素已完全覆盖卡片。
|
||||
const hasCompletedPosterReveal = ref(false)
|
||||
const POSTER_REVEAL_FALLBACK_MS = 400
|
||||
let posterRevealFallbackTimer: number | null = null
|
||||
|
||||
/** 清理当前海报的视觉覆盖状态与兜底提交。 */
|
||||
function resetPosterRevealState() {
|
||||
hasCompletedPosterReveal.value = false
|
||||
if (posterRevealFallbackTimer === null) return
|
||||
|
||||
window.clearTimeout(posterRevealFallbackTimer)
|
||||
posterRevealFallbackTimer = null
|
||||
}
|
||||
|
||||
/** 仅允许当前真实海报请求完成 renderer 排除提交。 */
|
||||
function completePosterReveal(revision: number, usesFallback: boolean) {
|
||||
if (revision !== imageRequestRevision.value || usesFallback) return
|
||||
|
||||
if (posterRevealFallbackTimer !== null) {
|
||||
window.clearTimeout(posterRevealFallbackTimer)
|
||||
posterRevealFallbackTimer = null
|
||||
}
|
||||
hasCompletedPosterReveal.value = true
|
||||
}
|
||||
|
||||
// 当前订阅状态
|
||||
const isSubscribed = ref(false)
|
||||
|
||||
@@ -122,10 +150,11 @@ function openSearchSiteDialog() {
|
||||
)
|
||||
}
|
||||
|
||||
// 查询所有站点
|
||||
// 查询与当前媒体类型兼容的站点
|
||||
async function querySites() {
|
||||
try {
|
||||
const data: Site[] = await api.get('site/')
|
||||
const mediaType = props.media?.type === '电视剧' ? 'tv' : props.media?.type === '音乐' ? 'music' : 'movie'
|
||||
const data: Site[] = await api.get(`site/media/${mediaType}`)
|
||||
|
||||
// 过滤站点,只有启用的站点才显示
|
||||
allSites.value = data.filter(item => item.is_active)
|
||||
@@ -150,7 +179,8 @@ function getMediaId() {
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
|
||||
return `${getMediaId()}::${season ?? 'all'}`
|
||||
const identity = props.media?.type === '音乐' ? getMusicKey(props.media) : getMediaId()
|
||||
return `${identity}::${season ?? 'all'}`
|
||||
}
|
||||
|
||||
function getExistsStatusKey() {
|
||||
@@ -166,18 +196,29 @@ function getExistsStatusKey() {
|
||||
}
|
||||
|
||||
function isSameSubscribeMedia(subscribe: Subscribe) {
|
||||
if (props.media?.type === '音乐') {
|
||||
const expectedMusicType = props.media.music_type ?? 'recording'
|
||||
const subscribeMusicType = subscribe.music_type ?? 'recording'
|
||||
if (subscribeMusicType !== expectedMusicType) return false
|
||||
}
|
||||
const mediaId = getMediaId()
|
||||
if (subscribe.media_source && subscribe.media_id) {
|
||||
const prefix = subscribe.media_source === 'themoviedb' ? 'tmdb' : subscribe.media_source
|
||||
return mediaId === `${prefix}:${subscribe.media_id}`
|
||||
}
|
||||
if (subscribe.mediaid) return mediaId === subscribe.mediaid
|
||||
if (props.media?.tmdb_id && subscribe.tmdbid) return props.media.tmdb_id === subscribe.tmdbid
|
||||
if (props.media?.douban_id && subscribe.doubanid) return props.media.douban_id === subscribe.doubanid
|
||||
if (props.media?.bangumi_id && subscribe.bangumiid) return props.media.bangumi_id === subscribe.bangumiid
|
||||
|
||||
const mediaId = props.media?.media_id ? `${props.media.mediaid_prefix}:${props.media.media_id}` : ''
|
||||
return Boolean(mediaId && subscribe.mediaid === mediaId)
|
||||
if (props.media?.anilist_id && subscribe.anilistid) return props.media.anilist_id === subscribe.anilistid
|
||||
return false
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
else if (type === '电视剧') return ' bg-indigo-500 border-indigo-600'
|
||||
else if (type === '音乐') return 'border-pink-500 bg-pink-600'
|
||||
else return 'border-purple-600 bg-purple-600'
|
||||
}
|
||||
|
||||
@@ -225,6 +266,7 @@ async function querySubscribedSeasons() {
|
||||
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
if (props.media?.type === '音乐') return
|
||||
try {
|
||||
const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
@@ -263,7 +305,9 @@ function goMediaDetail(isHovering = false) {
|
||||
if (isHovering) {
|
||||
resetMediaCardDetailState()
|
||||
|
||||
if (props.media?.collection_id) {
|
||||
if (props.media?.type === '音乐') {
|
||||
router.push(buildMusicDetailRoute(props.media))
|
||||
} else if (props.media?.collection_id) {
|
||||
// 跳转到合集列表
|
||||
router.push({
|
||||
path: `/browse/tmdb/collection/${props.media?.collection_id}`,
|
||||
@@ -387,13 +431,80 @@ function setupIntersectionObserver() {
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体占位图标:电影/电视剧/音乐各自使用对应图标,缺失封面时统一渲染图标 + 底色占位
|
||||
const placeholderIcon = computed(() => {
|
||||
switch (props.media?.type) {
|
||||
case '音乐':
|
||||
return 'mdi-album'
|
||||
case '电视剧':
|
||||
return 'mdi-television-classic'
|
||||
case '电影':
|
||||
default:
|
||||
return 'mdi-movie-open-outline'
|
||||
}
|
||||
})
|
||||
|
||||
// 计算图片地址
|
||||
const getImgUrl: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
|
||||
if (imageLoadError.value) return ''
|
||||
if (props.media?.type === '音乐') {
|
||||
// 音乐封面优先使用 cover_url(ListenBrainz/MusicBrainz 统计接口返回),回退到 poster_path
|
||||
const musicCover = props.media?.cover_url || props.media?.poster_path
|
||||
if (!musicCover) return ''
|
||||
return getDisplayImageUrl(musicCover, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
const url = props.media?.poster_path?.replace('original', 'w500')
|
||||
if (!url) return ''
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
const hasLoadedRealPoster = computed(
|
||||
() => Boolean(getImgUrl.value) && isImageLoaded.value && hasCompletedPosterReveal.value && !imageLoadError.value,
|
||||
)
|
||||
|
||||
/** 为当前图片实例绑定不可跨媒体复用的成功与失败回调。 */
|
||||
const imageRequest = computed(() => {
|
||||
const revision = imageRequestRevision.value
|
||||
const src = getImgUrl.value
|
||||
const usesFallback = !src
|
||||
|
||||
return {
|
||||
handleError: () => {
|
||||
if (revision !== imageRequestRevision.value || usesFallback) return
|
||||
|
||||
resetPosterRevealState()
|
||||
isImageLoaded.value = false
|
||||
imageLoadError.value = true
|
||||
imageRequestRevision.value += 1
|
||||
},
|
||||
handleLoad: () => {
|
||||
if (revision !== imageRequestRevision.value) return
|
||||
|
||||
resetPosterRevealState()
|
||||
isImageLoaded.value = true
|
||||
if (!usesFallback) {
|
||||
posterRevealFallbackTimer = window.setTimeout(
|
||||
() => completePosterReveal(revision, usesFallback),
|
||||
POSTER_REVEAL_FALLBACK_MS,
|
||||
)
|
||||
}
|
||||
},
|
||||
handleReveal: (event: TransitionEvent) => {
|
||||
if (
|
||||
event.propertyName !== 'opacity' ||
|
||||
!(event.target instanceof Element) ||
|
||||
!event.target.matches('.v-img__img')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
completePosterReveal(revision, usesFallback)
|
||||
},
|
||||
key: revision,
|
||||
src,
|
||||
}
|
||||
})
|
||||
|
||||
// 获取媒体类型文本
|
||||
function getMediaTypeText(type: string | undefined) {
|
||||
if (!type) return ''
|
||||
@@ -420,13 +531,21 @@ watch(isSubscribed, subscribed => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.media,
|
||||
() => {
|
||||
[() => props.media, () => props.media?.poster_path, () => props.media?.cover_url],
|
||||
([media], [previousMedia]) => {
|
||||
imageRequestRevision.value += 1
|
||||
resetPosterRevealState()
|
||||
isImageLoaded.value = false
|
||||
imageLoadError.value = false
|
||||
// 海报补全只重置图片代际;详情和订阅状态绑定媒体对象身份。
|
||||
if (media === previousMedia) return
|
||||
|
||||
resetMediaCardDetailState()
|
||||
subscribedSeasons.value = []
|
||||
subscribedSeasonModes.value = {}
|
||||
subscribedSeasonsLoaded.value = false
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -440,6 +559,7 @@ onActivated(resetMediaCardDetailState)
|
||||
onDeactivated(resetMediaCardDetailState)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetPosterRevealState()
|
||||
resetMediaCardDetailState()
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
observer.value?.disconnect()
|
||||
@@ -456,20 +576,28 @@ onBeforeUnmount(() => {
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
:ripple="false"
|
||||
:data-glass-optical-mode="hasLoadedRealPoster ? 'excluded' : undefined"
|
||||
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
|
||||
'media-card--image-loaded': isImageLoaded,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="handleMediaCardClick(hover.isHovering)"
|
||||
>
|
||||
<div v-if="!getImgUrl" class="media-card-placeholder d-flex align-center justify-center">
|
||||
<VIcon :icon="placeholderIcon" size="64" color="medium-emphasis" />
|
||||
</div>
|
||||
<VImg
|
||||
:key="imageRequest.key"
|
||||
v-else
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
:src="imageRequest.src"
|
||||
class="object-cover aspect-w-2 aspect-h-3"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
@load="imageRequest.handleLoad"
|
||||
@error="imageRequest.handleError"
|
||||
@transitionend="imageRequest.handleReveal"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
@@ -535,7 +663,14 @@ onBeforeUnmount(() => {
|
||||
tile
|
||||
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
<VIcon v-if="props.media?.source === 'anilist'" color="#02a9ff" icon="mdi-alpha-a-circle" size="24" />
|
||||
<VIcon
|
||||
v-else-if="props.media?.source === 'musicbrainz'"
|
||||
color="#eb743b"
|
||||
icon="mdi-music-circle"
|
||||
size="24"
|
||||
/>
|
||||
<VImg v-else cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
</VCard>
|
||||
</div>
|
||||
@@ -547,6 +682,13 @@ onBeforeUnmount(() => {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.media-card-placeholder {
|
||||
aspect-ratio: 2 / 3;
|
||||
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.media-card-title {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.25rem;
|
||||
|
||||
@@ -2,12 +2,27 @@
|
||||
import type { PropType } from 'vue'
|
||||
import type { Context } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { formatMusicDuration, getMusicAudioSpecItems } from '@/utils/music'
|
||||
|
||||
const { t } = useI18n()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 输入参数
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
context: Object as PropType<Context>,
|
||||
})
|
||||
|
||||
// 音乐元数据使用 title,影视元数据使用 name,识别结果卡片统一兼容两种字段。
|
||||
const recognizedName = computed(() => props.context?.meta_info?.name || props.context?.meta_info?.title)
|
||||
|
||||
// 是否为音乐识别结果
|
||||
const isMusic = computed(() => props.context?.media_info?.type === '音乐' || props.context?.meta_info?.type === '音乐')
|
||||
|
||||
// 音乐封面加载失败时保留方形唱片占位,避免弹窗信息结构塌缩。
|
||||
const musicCoverLoadError = ref(false)
|
||||
|
||||
// TMDB图片转换为w500大小
|
||||
function getW500Image(url = '') {
|
||||
if (!url) return ''
|
||||
@@ -21,21 +36,132 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
const url = `https://www.themoviedb.org/${type === '电影' ? 'movie' : 'tv'}/${tmdbId}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// 音乐封面优先使用方形封面,仅 W500 图片处理对TMDB类 URL 生效
|
||||
const rawMusicCover = computed(() =>
|
||||
getW500Image(props.context?.media_info?.cover_url || props.context?.media_info?.poster_path || ''),
|
||||
)
|
||||
const musicCover = computed(() =>
|
||||
getDisplayImageUrl(rawMusicCover.value, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||
)
|
||||
|
||||
// 影视识别海报与音乐封面使用同一全局图片缓存开关。
|
||||
const mediaPoster = computed(() =>
|
||||
getDisplayImageUrl(
|
||||
getW500Image(props.context?.media_info?.poster_path || ''),
|
||||
globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
),
|
||||
)
|
||||
|
||||
// 音乐艺术家优先使用标准识别结果,远端未命中时回退到文件标签。
|
||||
const musicArtist = computed(() => {
|
||||
const mediaInfo = props.context?.media_info
|
||||
const metaInfo = props.context?.meta_info
|
||||
return (
|
||||
mediaInfo?.artist || mediaInfo?.artists?.join(' / ') || metaInfo?.artist || metaInfo?.artists?.join(' / ') || ''
|
||||
)
|
||||
})
|
||||
|
||||
// 专辑艺术家只在与歌曲艺术家不同时单独展示。
|
||||
const musicAlbumArtist = computed(() => {
|
||||
const albumArtist = props.context?.media_info?.album_artist || props.context?.meta_info?.album_artist
|
||||
return albumArtist && albumArtist !== musicArtist.value ? albumArtist : ''
|
||||
})
|
||||
|
||||
// 音乐专辑优先使用标准识别结果,远端未命中时回退到文件标签。
|
||||
const musicAlbum = computed(() => props.context?.media_info?.album || props.context?.meta_info?.album || '')
|
||||
|
||||
// 音乐实体类型转换为面向用户的本地化名称。
|
||||
const musicTypeLabel = computed(() => {
|
||||
const labels = {
|
||||
album: t('music.entityAlbum'),
|
||||
artist: t('music.entityArtist'),
|
||||
recording: t('music.entityRecording'),
|
||||
}
|
||||
const musicType = props.context?.media_info?.music_type
|
||||
return musicType ? labels[musicType] : ''
|
||||
})
|
||||
|
||||
// 音乐标题下方汇总发行时间、时长和曲序,信息密度与影视识别结果保持一致。
|
||||
const musicSummary = computed(() => {
|
||||
const mediaInfo = props.context?.media_info
|
||||
const metaInfo = props.context?.meta_info
|
||||
const values: string[] = []
|
||||
const releaseDate = mediaInfo?.release_date || mediaInfo?.year || metaInfo?.year
|
||||
if (releaseDate) values.push(String(releaseDate))
|
||||
const duration = formatMusicDuration(mediaInfo?.duration || metaInfo?.duration)
|
||||
if (duration) values.push(duration)
|
||||
const trackNumber = mediaInfo?.track_number || metaInfo?.track_number
|
||||
const totalTracks = mediaInfo?.total_tracks || metaInfo?.total_tracks
|
||||
if (trackNumber) {
|
||||
const track = totalTracks ? `${trackNumber}/${totalTracks}` : String(trackNumber)
|
||||
values.push(`${t('music.track')} ${track}`)
|
||||
}
|
||||
return values.join(' · ')
|
||||
})
|
||||
|
||||
// 本地文件实际参数优先,资源标题识别场景回退到标准音乐信息。
|
||||
const musicAudioChips = computed(() => {
|
||||
const metaInfo = props.context?.meta_info
|
||||
const mediaInfo = props.context?.media_info
|
||||
return getMusicAudioSpecItems({
|
||||
audio_format: metaInfo?.audio_format || mediaInfo?.audio_format,
|
||||
bit_depth: metaInfo?.bit_depth || mediaInfo?.bit_depth,
|
||||
sample_rate: metaInfo?.sample_rate || mediaInfo?.sample_rate,
|
||||
bitrate: metaInfo?.bitrate || mediaInfo?.bitrate,
|
||||
})
|
||||
})
|
||||
|
||||
// 音乐详情外链
|
||||
const musicLink = computed(() => props.context?.media_info?.detail_link || '')
|
||||
|
||||
// 打开音乐详情外链
|
||||
function openMusicDetail() {
|
||||
if (musicLink.value) window.open(musicLink.value, '_blank')
|
||||
}
|
||||
|
||||
watch(musicCover, () => {
|
||||
musicCoverLoadError.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="context">
|
||||
<VCol>
|
||||
<div
|
||||
v-if="context?.meta_info?.name"
|
||||
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
|
||||
>
|
||||
<div v-if="context?.media_info?.poster_path" class="ma-auto">
|
||||
<div v-if="recognizedName" class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
<div v-if="isMusic" class="ma-auto recognized-music-cover">
|
||||
<VImg
|
||||
v-if="musicCover && !musicCoverLoadError"
|
||||
width="10rem"
|
||||
aspect-ratio="1"
|
||||
class="object-cover rounded-lg ring-1 ring-gray-500"
|
||||
:src="musicCover"
|
||||
cover
|
||||
eager
|
||||
@error="musicCoverLoadError = true"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
<VSheet
|
||||
v-else
|
||||
width="10rem"
|
||||
height="10rem"
|
||||
rounded="lg"
|
||||
class="d-flex align-center justify-center music-cover-placeholder"
|
||||
>
|
||||
<VIcon icon="mdi-album" size="64" color="medium-emphasis" />
|
||||
</VSheet>
|
||||
</div>
|
||||
<div v-else-if="mediaPoster" class="ma-auto">
|
||||
<VImg
|
||||
width="10rem"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover aspect-w-2 aspect-h-3 rounded-lg ring-1 ring-gray-500"
|
||||
:src="getW500Image(context?.media_info?.poster_path)"
|
||||
:src="mediaPoster"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
@@ -48,12 +174,26 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
<div class="flex-grow">
|
||||
<VCardItem class="pb-1">
|
||||
<div class="text-center text-md-left text-h6 font-weight-bold line-clamp-2 overflow-hidden text-ellipsis">
|
||||
{{ context?.media_info?.title || context?.meta_info?.name }}
|
||||
{{ context?.media_info?.title || recognizedName }}
|
||||
<span v-if="context?.meta_info?.season_episode" class="text-sm text-medium-emphasis align-top">
|
||||
{{ context?.meta_info?.season_episode }}
|
||||
</span>
|
||||
</div>
|
||||
<VCardSubtitle class="text-center text-md-left">
|
||||
<div v-if="isMusic" class="music-recognition-summary text-center text-md-left">
|
||||
<div v-if="musicArtist" class="text-body-1 font-weight-medium text-high-emphasis">
|
||||
{{ musicArtist }}
|
||||
</div>
|
||||
<div v-if="musicAlbum" class="text-body-2 text-medium-emphasis">
|
||||
{{ t('music.album') }}:{{ musicAlbum }}
|
||||
</div>
|
||||
<div v-if="musicAlbumArtist" class="text-body-2 text-medium-emphasis">
|
||||
{{ t('music.albumArtist') }}:{{ musicAlbumArtist }}
|
||||
</div>
|
||||
<div v-if="musicSummary" class="text-body-2 text-medium-emphasis">
|
||||
{{ musicSummary }}
|
||||
</div>
|
||||
</div>
|
||||
<VCardSubtitle v-else class="text-center text-md-left">
|
||||
{{ context?.media_info?.year || context?.meta_info?.year }}
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
@@ -74,51 +214,115 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
>
|
||||
{{ context?.media_info?.type || context?.meta_info?.type }}
|
||||
</VChip>
|
||||
<!-- 音乐实体类型 -->
|
||||
<VChip
|
||||
v-if="isMusic && context?.media_info?.music_type"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-blue-500"
|
||||
>
|
||||
{{ musicTypeLabel }}
|
||||
</VChip>
|
||||
<!-- 音乐分类 -->
|
||||
<VChip
|
||||
v-if="isMusic && (context?.media_info?.category || context?.media_info?.album_type)"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-purple-500"
|
||||
>
|
||||
{{ context?.media_info?.category || context?.media_info?.album_type }}
|
||||
</VChip>
|
||||
<!-- 风格 -->
|
||||
<VChip
|
||||
v-for="genre in isMusic ? context?.media_info?.genres : []"
|
||||
:key="genre"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-purple-500"
|
||||
>
|
||||
{{ genre }}
|
||||
</VChip>
|
||||
<!-- 音频技术参数 -->
|
||||
<VChip
|
||||
v-for="audioChip in isMusic ? musicAudioChips : []"
|
||||
:key="audioChip"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-orange-500"
|
||||
>
|
||||
{{ audioChip }}
|
||||
</VChip>
|
||||
<!-- ISRC -->
|
||||
<VChip
|
||||
v-if="isMusic && context?.media_info?.isrc"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-red-500"
|
||||
>
|
||||
{{ context?.media_info?.isrc }}
|
||||
</VChip>
|
||||
<!-- MusicBrainz 外链 -->
|
||||
<VChip
|
||||
v-if="isMusic && musicLink"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-green-500"
|
||||
@click="openMusicDetail"
|
||||
>
|
||||
详情
|
||||
</VChip>
|
||||
<!-- 二级分类 -->
|
||||
<VChip v-if="context?.media_info?.category" variant="elevated" class="me-1 mb-1 text-white bg-blue-500">
|
||||
<VChip
|
||||
v-if="!isMusic && context?.media_info?.category"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-blue-500"
|
||||
>
|
||||
{{ context?.media_info?.category }}
|
||||
</VChip>
|
||||
<!-- TMDBID -->
|
||||
<VChip
|
||||
v-if="context?.media_info?.tmdb_id"
|
||||
v-if="!isMusic && context?.media_info?.tmdb_id"
|
||||
variant="elevated"
|
||||
color="success"
|
||||
class="me-1 mb-1"
|
||||
class="me-1 mb-1 text-white bg-green-500"
|
||||
@click="openTmdbPage(context?.media_info?.type || '', context?.media_info?.tmdb_id)"
|
||||
>
|
||||
{{ context?.media_info?.tmdb_id }}
|
||||
</VChip>
|
||||
<!-- meta_info -->
|
||||
<VChip v-if="context?.meta_info?.web_source" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
|
||||
{{ context?.meta_info?.web_source }}
|
||||
</VChip>
|
||||
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ context?.meta_info?.edition }}
|
||||
</VChip>
|
||||
<VChip v-if="context?.meta_info?.resource_pix" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ context?.meta_info?.resource_pix }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="context?.meta_info?.video_encode"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-orange-500"
|
||||
>
|
||||
{{ context?.meta_info?.video_encode }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="context?.meta_info?.audio_encode"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-orange-500"
|
||||
>
|
||||
{{ context?.meta_info?.audio_encode }}
|
||||
</VChip>
|
||||
<VChip v-if="context?.meta_info?.resource_team" variant="elevated" class="me-1 mb-1 text-white bg-cyan-500">
|
||||
{{ context?.meta_info?.resource_team }}
|
||||
</VChip>
|
||||
<!-- meta_info(音乐不显示影视资源信息) -->
|
||||
<template v-if="!isMusic">
|
||||
<VChip
|
||||
v-if="context?.meta_info?.web_source"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-purple-500"
|
||||
>
|
||||
{{ context?.meta_info?.web_source }}
|
||||
</VChip>
|
||||
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ context?.meta_info?.edition }}
|
||||
</VChip>
|
||||
<VChip v-if="context?.meta_info?.resource_pix" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ context?.meta_info?.resource_pix }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="context?.meta_info?.video_encode"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-orange-500"
|
||||
>
|
||||
{{ context?.meta_info?.video_encode }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="context?.meta_info?.audio_encode"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-orange-500"
|
||||
>
|
||||
{{ context?.meta_info?.audio_encode }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="context?.meta_info?.resource_team"
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 text-white bg-cyan-500"
|
||||
>
|
||||
{{ context?.meta_info?.resource_team }}
|
||||
</VChip>
|
||||
</template>
|
||||
</VCardItem>
|
||||
</div>
|
||||
</div>
|
||||
<VAlert v-if="!context?.meta_info?.name" icon="mdi-alert-circle-outline"> 识别失败,无法识别到有效信息! </VAlert>
|
||||
<VAlert v-if="!recognizedName" icon="mdi-alert-circle-outline"> 识别失败,无法识别到有效信息! </VAlert>
|
||||
</VCol>
|
||||
<VExpansionPanels v-show="!isNullOrEmptyObject(context?.meta_info.apply_words)">
|
||||
<VExpansionPanel>
|
||||
@@ -142,3 +346,19 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
</VExpansionPanels>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recognized-music-cover {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.music-cover-placeholder {
|
||||
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.music-recognition-summary {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
margin-block-start: 0.35rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,6 +25,11 @@ const props = defineProps({
|
||||
type: Array as PropType<MediaServerConf[]>,
|
||||
required: true,
|
||||
},
|
||||
// 旧版全局同步间隔,用作服务器未单独设置时的默认值
|
||||
defaultSyncInterval: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -43,8 +48,8 @@ const infoItems = ref([
|
||||
amount: '0',
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-account',
|
||||
title: t('common.user'),
|
||||
avatar: 'mdi-music-box-multiple',
|
||||
title: t('mediaType.music'),
|
||||
amount: '0',
|
||||
},
|
||||
])
|
||||
@@ -56,6 +61,7 @@ function openMediaServerInfoDialog() {
|
||||
{
|
||||
mediaserver: props.mediaserver,
|
||||
mediaservers: props.mediaservers,
|
||||
defaultSyncInterval: props.defaultSyncInterval,
|
||||
},
|
||||
{
|
||||
change: (...args: unknown[]) => emit('change', ...args),
|
||||
@@ -81,6 +87,7 @@ const getIcon = computed(() => {
|
||||
case 'plex':
|
||||
return getLogoUrl('plex')
|
||||
default:
|
||||
// Navidrome 等未内置图标的服务器统一回退到通用媒体服务器图标
|
||||
return getLogoUrl('mediaserver')
|
||||
}
|
||||
})
|
||||
@@ -112,9 +119,9 @@ async function loadMediaStatistic() {
|
||||
amount: res.tv_count.toLocaleString(),
|
||||
},
|
||||
{
|
||||
avatar: 'mdi-account',
|
||||
title: t('common.user'),
|
||||
amount: res.user_count.toLocaleString(),
|
||||
avatar: 'mdi-music-box-multiple',
|
||||
title: t('mediaType.music'),
|
||||
amount: (res.music_count || 0).toLocaleString(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
76
src/components/cards/MusicArtistCard.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MusicArtistInfo } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { buildMusicArtistRoute, getMusicArtistSubtitle } from '@/utils/music'
|
||||
|
||||
const router = useRouter()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
const props = defineProps({
|
||||
artist: Object as PropType<MusicArtistInfo>,
|
||||
width: String,
|
||||
})
|
||||
|
||||
// 艺术家图片加载失败后回退到占位图标
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
const rawImageUrl = computed(() => props.artist?.image_url || props.artist?.poster_path || '')
|
||||
const imageUrl = computed(() =>
|
||||
getDisplayImageUrl(rawImageUrl.value, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
|
||||
)
|
||||
const showImage = computed(() => Boolean(imageUrl.value) && !imageLoadError.value)
|
||||
const subtitle = computed(() => getMusicArtistSubtitle(props.artist))
|
||||
|
||||
/** 打开艺术家详情页。 */
|
||||
function goArtistDetail() {
|
||||
if (!props.artist?.media_id) return
|
||||
router.push(buildMusicArtistRoute(props.artist.media_id, props.artist.name, props.artist.source))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div v-bind="hover.props" class="music-artist-card-hover-area">
|
||||
<VCard
|
||||
:width="props.width"
|
||||
class="app-hover-lift-card"
|
||||
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
|
||||
@click.stop="goArtistDetail"
|
||||
>
|
||||
<div class="music-artist-card relative cursor-pointer">
|
||||
<div style="padding-block-end: 150%">
|
||||
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
|
||||
<VAvatar size="100" class="music-artist-card-avatar">
|
||||
<VImg v-if="showImage" :src="imageUrl" cover @error="imageLoadError = true" />
|
||||
<VIcon v-else icon="mdi-account-music" size="48" color="medium-emphasis" />
|
||||
</VAvatar>
|
||||
</div>
|
||||
<div class="w-full truncate text-center font-bold">{{ props.artist?.name }}</div>
|
||||
<div class="overflow-hidden whitespace-normal text-center text-sm text-ellipsis line-clamp-2">
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.music-artist-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.music-artist-card-avatar {
|
||||
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.music-artist-card {
|
||||
background-image: linear-gradient(45deg, rgba(var(--v-theme-background), 0.3), rgba(var(--v-theme-surface), 0.3) 60%);
|
||||
}
|
||||
</style>
|
||||