Compare commits
454 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2b21f7e23 | ||
|
|
f6a5d1dfa0 | ||
|
|
cf61a42749 | ||
|
|
3a45d7e9de | ||
|
|
486704c6d2 | ||
|
|
c55e97b74e | ||
|
|
7c02bf7c5c | ||
|
|
30b5c6445a | ||
|
|
b8214c7f7a | ||
|
|
c8b1047923 | ||
|
|
058b8f2299 | ||
|
|
5847f9e936 | ||
|
|
72dc35ff41 | ||
|
|
f2ecc6af73 | ||
|
|
cec2169c13 | ||
|
|
abdf1bf29c | ||
|
|
ca2f813810 | ||
|
|
5277f148a1 | ||
|
|
17d4dbed1d | ||
|
|
2d02106a7b | ||
|
|
3455d91f4b | ||
|
|
95bdeb6ebf | ||
|
|
19e8e06798 | ||
|
|
9b747c67a9 | ||
|
|
ea6d0c8178 | ||
|
|
4f1c40af12 | ||
|
|
3d1361281d | ||
|
|
aef379f3ec | ||
|
|
78298f288e | ||
|
|
281e36c6d3 | ||
|
|
dab2b9d5bd | ||
|
|
38139d95eb | ||
|
|
fcc6f68519 | ||
|
|
104a314c4c | ||
|
|
3b758ea543 | ||
|
|
0355ce5fdb | ||
|
|
dabdf7c5f7 | ||
|
|
68ad3ec77c | ||
|
|
a4d253e103 | ||
|
|
c98542b9bb | ||
|
|
89da6be8bb | ||
|
|
b8b8fabe15 | ||
|
|
0518817dd0 | ||
|
|
03c1b893b8 | ||
|
|
635f32a6c4 | ||
|
|
415600c123 | ||
|
|
774ef77639 | ||
|
|
0acb604466 | ||
|
|
4f6537400d | ||
|
|
fdc663e5ad | ||
|
|
aaa92d9673 | ||
|
|
5bb93bd26b | ||
|
|
7b1765e43a | ||
|
|
485f599489 | ||
|
|
b65682da53 | ||
|
|
f8f43058c5 | ||
|
|
6bd684d1bd | ||
|
|
937d28b944 | ||
|
|
17f26408eb | ||
|
|
8ecb4a6203 | ||
|
|
3bc1030a41 | ||
|
|
2757f8d72b | ||
|
|
e2eaf54433 | ||
|
|
3dfc835cb6 | ||
|
|
2754db6ccb | ||
|
|
fd21968dc9 | ||
|
|
4559524190 | ||
|
|
6a0217d18c | ||
|
|
4aaeac360e | ||
|
|
b2a57c6fb0 | ||
|
|
9654bd9852 | ||
|
|
b3b9248af6 | ||
|
|
509dca37ce | ||
|
|
96d3991579 | ||
|
|
e4b1ba34e1 | ||
|
|
2c4b859729 | ||
|
|
2b0325e02b | ||
|
|
0ea12f75f7 | ||
|
|
4fce4cd171 | ||
|
|
1355e3460e | ||
|
|
f5f7a35e29 | ||
|
|
d7534ecba4 | ||
|
|
a423a2ce04 | ||
|
|
6768f4e8a7 | ||
|
|
5e58d904fe | ||
|
|
155ecffd89 | ||
|
|
d29033c233 | ||
|
|
26fcda661d | ||
|
|
df4d697845 | ||
|
|
6c384bf1ee | ||
|
|
247fb80e3c | ||
|
|
e21fe705b5 | ||
|
|
c46aef725d | ||
|
|
8e88a1ba15 | ||
|
|
3631cd1f22 | ||
|
|
6e76de293f | ||
|
|
5db3ccc6a1 | ||
|
|
202235d88a | ||
|
|
bf0e57a92f | ||
|
|
e14b39c574 | ||
|
|
bd691bb404 | ||
|
|
59a296d429 | ||
|
|
b8e454114a | ||
|
|
70d6bd1f52 | ||
|
|
b9dd74aa7b | ||
|
|
1a303b4323 | ||
|
|
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 | ||
|
|
4378c4843b | ||
|
|
5676e0b9b1 | ||
|
|
38a841dc9c | ||
|
|
6e92673438 | ||
|
|
5e2585b210 | ||
|
|
c3c323b018 | ||
|
|
5390af50ac | ||
|
|
c0f58c7df4 | ||
|
|
9bdf102f7a | ||
|
|
52df6bef38 | ||
|
|
c5da08e388 | ||
|
|
8a069db55e | ||
|
|
9900042bc3 | ||
|
|
e4f02a326f | ||
|
|
199693abf8 | ||
|
|
32b0c1c00c | ||
|
|
e4d0afc380 | ||
|
|
8e89078055 | ||
|
|
2e0a582d22 | ||
|
|
9c2c4d9fc4 | ||
|
|
4e0191ba5c | ||
|
|
9247850411 | ||
|
|
29b4854dae | ||
|
|
338af516be | ||
|
|
ea892a31c0 | ||
|
|
c0f3814560 | ||
|
|
15f984ba44 | ||
|
|
362c601c7e | ||
|
|
4f0204d465 | ||
|
|
d92e79169c | ||
|
|
563849aab3 | ||
|
|
b0a27c6405 | ||
|
|
b664e0b447 | ||
|
|
d625d1ad46 | ||
|
|
cec9563963 | ||
|
|
2df3ce1b86 | ||
|
|
2a2ed9fc6a | ||
|
|
0921932601 | ||
|
|
50b8ea1bc9 | ||
|
|
f88ae5643c | ||
|
|
cf5f4e5b6a | ||
|
|
a8db14a069 | ||
|
|
a20977e522 | ||
|
|
7efc32637b | ||
|
|
5d1445fe64 | ||
|
|
2070240b50 | ||
|
|
1d130c99a3 | ||
|
|
7861afb2d7 | ||
|
|
5c4511f935 | ||
|
|
212ba94e11 | ||
|
|
f863a09212 | ||
|
|
728ced475b | ||
|
|
a192e00db5 | ||
|
|
85e6b17db7 | ||
|
|
156d835d6d | ||
|
|
b27eeea6c0 | ||
|
|
39a9fe0541 | ||
|
|
92d153760b | ||
|
|
ff9dc26040 | ||
|
|
269da6eefe | ||
|
|
817bc1019d | ||
|
|
de8c22c180 | ||
|
|
8f074fb381 | ||
|
|
e16870859d | ||
|
|
e1ba13abf2 | ||
|
|
8d1f226035 | ||
|
|
5367a07a90 | ||
|
|
8e681dbbe7 | ||
|
|
c26c3c4284 | ||
|
|
461ed560f0 | ||
|
|
dd27bcdaca | ||
|
|
c076be868a | ||
|
|
d2634095e4 | ||
|
|
a5a3e1e1e4 | ||
|
|
09a20967c0 | ||
|
|
9a2af0a412 | ||
|
|
6bbac7db7d | ||
|
|
b212e066b6 | ||
|
|
f7d354fca4 | ||
|
|
693b081ecf | ||
|
|
d5f0fed954 | ||
|
|
67ef98bfee | ||
|
|
1094d80b9c | ||
|
|
98e6481812 | ||
|
|
7db088eb79 | ||
|
|
dec154c042 | ||
|
|
d06ce5d984 | ||
|
|
af604d0c5c | ||
|
|
fa39c3750c | ||
|
|
ce64fb03ce | ||
|
|
764f14a7f4 | ||
|
|
621200b3b2 | ||
|
|
60c46ebbaf |
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: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
28
.github/workflows/build.yml
vendored
@@ -1,19 +1,23 @@
|
||||
name: Build Moviepilot-Frontend v2
|
||||
name: Build Moviepilot-Frontend v3
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- v2
|
||||
- v3
|
||||
paths:
|
||||
- 'package.json'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Release version
|
||||
id: release_version
|
||||
@@ -22,22 +26,30 @@ jobs:
|
||||
echo "frontend_version=v$frontend_version" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'yarn'
|
||||
node-version: '24'
|
||||
cache: yarn
|
||||
cache-dependency-path: yarn.lock
|
||||
|
||||
- name: Enable project Yarn version
|
||||
run: |
|
||||
corepack enable
|
||||
corepack install --global yarn@1.22.22
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: Download Icons
|
||||
run: |
|
||||
pwd
|
||||
curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp -
|
||||
curl -fsSL --retry 3 "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp -
|
||||
mv /tmp/MoviePilot-Plugins-main/icons public/plugin_icon
|
||||
rm -rf /tmp/MoviePilot-Plugins-main
|
||||
|
||||
- name: Build frontend
|
||||
id: build_frontend
|
||||
run: |
|
||||
yarn
|
||||
yarn build
|
||||
echo "$frontend_version" > dist/version.txt
|
||||
zip -r dist.zip dist
|
||||
|
||||
101
.github/workflows/pr-agent.yml
vendored
@@ -1,9 +1,8 @@
|
||||
name: PR Agent
|
||||
name: PR-Agent
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
|
||||
# pull_request_target 允许 fork PR 使用仓库 secrets,因此 workflow 只运行固定 digest 的 PR-Agent 容器。
|
||||
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
@@ -11,24 +10,17 @@ on:
|
||||
- review_requested
|
||||
- synchronize
|
||||
issue_comment:
|
||||
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
|
||||
# issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 PR。
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
# 读取仓库内容和 PR diff。
|
||||
contents: read
|
||||
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
|
||||
pull-requests: write
|
||||
# PR 评论在 GitHub API 中属于 issue comments,手动命令和总结评论需要该权限。
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
pr-agent:
|
||||
name: PR-Agent review and describe
|
||||
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
|
||||
if: >-
|
||||
github.event.sender.type != 'Bot' &&
|
||||
(
|
||||
@@ -36,88 +28,25 @@ jobs:
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
|
||||
(
|
||||
github.event.comment.body == '/review' ||
|
||||
startsWith(github.event.comment.body, '/review ') ||
|
||||
github.event.comment.body == '/describe' ||
|
||||
startsWith(github.event.comment.body, '/describe ') ||
|
||||
github.event.comment.body == '/improve' ||
|
||||
startsWith(github.event.comment.body, '/improve ') ||
|
||||
github.event.comment.body == '/ask' ||
|
||||
startsWith(github.event.comment.body, '/ask ')
|
||||
)
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association)
|
||||
)
|
||||
)
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Run PR-Agent
|
||||
id: pragent
|
||||
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
|
||||
uses: docker://pragent/pr-agent:0.37.0-github_action@sha256:4ec7bac814050a1bc8c96ab2fab6b7b0f65df0049a5ec43f3fee1a0b551c28ca
|
||||
- name: Run PR Review
|
||||
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
|
||||
env:
|
||||
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
# 仓库设置中添加的 Secret:Settings -> Secrets and variables -> Actions。
|
||||
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
|
||||
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
|
||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
|
||||
# 模型、输出语言和大 diff 处理策略。
|
||||
config.model: "gpt-5.5"
|
||||
config.fallback_models: '["gpt-5.4"]'
|
||||
config.reasoning_effort: "xhigh"
|
||||
config.ai_timeout: "900"
|
||||
config.response_language: "zh-CN"
|
||||
config.large_patch_policy: "clip"
|
||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
||||
|
||||
# pull_request_target 事件默认自动执行 /review 和 /describe;/improve 保持手动触发。
|
||||
github_action_config.auto_review: "true"
|
||||
github_action_config.auto_describe: "true"
|
||||
github_action_config.auto_improve: "false"
|
||||
|
||||
# 允许触发自动工具的 PR 动作。包含 synchronize,便于新 commit 推送后刷新结果。
|
||||
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
|
||||
|
||||
# 保留 action outputs,便于后续 workflow 编排或排查。
|
||||
github_action_config.enable_output: "true"
|
||||
|
||||
# /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略。
|
||||
pr_description.generate_ai_title: "false"
|
||||
pr_description.publish_labels: "false"
|
||||
pr_description.enable_pr_diagram: "false"
|
||||
pr_description.collapsible_file_list: "adaptive"
|
||||
pr_description.add_original_user_description: "true"
|
||||
|
||||
# /review 输出策略,聚焦维护者需要处理的风险和缺口。
|
||||
pr_reviewer.extra_instructions: |
|
||||
请用中文输出。
|
||||
优先指出 P0/P1 风险,避免纠结纯格式问题。
|
||||
重点检查安全、权限、状态一致性、异步/缓存、副作用和测试缺口。
|
||||
pr_reviewer.num_max_findings: "5"
|
||||
pr_reviewer.persistent_comment: "true"
|
||||
pr_reviewer.publish_output_no_suggestions: "true"
|
||||
pr_reviewer.require_tests_review: "true"
|
||||
pr_reviewer.require_security_review: "true"
|
||||
pr_reviewer.require_estimate_effort_to_review: "true"
|
||||
pr_reviewer.require_can_be_split_review: "true"
|
||||
pr_reviewer.require_todo_scan: "false"
|
||||
pr_reviewer.enable_review_labels_effort: "false"
|
||||
pr_reviewer.enable_review_labels_security: "true"
|
||||
|
||||
# /improve 和 /ask 的手动命令策略。
|
||||
pr_code_suggestions.focus_only_on_problems: "true"
|
||||
pr_code_suggestions.suggestions_score_threshold: "7"
|
||||
pr_code_suggestions.commitable_code_suggestions: "false"
|
||||
pr_questions.use_conversation_history: "true"
|
||||
|
||||
# 可选成本和噪音控制:
|
||||
# github_action_config.auto_improve: "true"
|
||||
# config.verbosity_level: "1"
|
||||
# pr_reviewer.num_max_findings: "3"
|
||||
PRR_AUTO_REVIEW_SCOPE: all
|
||||
PRR_ALLOWED_ASSOCIATIONS: '["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'
|
||||
PRR_DISABLED_COMMANDS: '["/improve"]'
|
||||
PRR_SKIP_LABEL: skip pr-agent
|
||||
PRR_SKIP_TITLE_PATTERN: '^(?:\[Auto\]|Auto)'
|
||||
config.response_language: zh-CN
|
||||
|
||||
93
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
name: Frontend Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- v3
|
||||
push:
|
||||
branches:
|
||||
- v3
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
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
|
||||
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
|
||||
typecheck-and-tests:
|
||||
runs-on: ubuntu-latest
|
||||
name: Unit tests (${{ matrix.shard }})
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- 1/2
|
||||
- 2/2
|
||||
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: Unit tests
|
||||
run: yarn test:run --shard=${{ matrix.shard }} --silent=passed-only
|
||||
2
.gitignore
vendored
@@ -12,6 +12,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
coverage
|
||||
*.local
|
||||
package-lock.json
|
||||
|
||||
@@ -38,3 +39,4 @@ public/plugin_icon/**
|
||||
docs-lock/
|
||||
.trae/
|
||||
output/
|
||||
.codex-qa/
|
||||
|
||||
1
.node-version
Normal file
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -1,2 +1,9 @@
|
||||
dist
|
||||
node_modules
|
||||
node_modules
|
||||
/.worktrees/
|
||||
|
||||
# 自动生成文件的格式由各自生成器决定。
|
||||
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",
|
||||
|
||||
13
README.md
@@ -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`。
|
||||
|
||||
## 特性
|
||||
|
||||
@@ -40,6 +40,17 @@ yarn dev
|
||||
yarn build
|
||||
```
|
||||
|
||||
### 单元测试
|
||||
|
||||
```sh
|
||||
yarn test:run
|
||||
yarn test:coverage
|
||||
```
|
||||
|
||||
测试文件组织、共享测试设施、HTTP mock、覆盖率门禁和新增用例规范见[单元测试架构](docs/testing.md)。
|
||||
|
||||
ESLint、Prettier、Node 兼容范围及渐进式 CI 门禁见[前端代码质量工具链演进](docs/code-quality.md)。
|
||||
|
||||
### 静态运行
|
||||
|
||||
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件,nginx配置参考 `public/nginx.conf`。
|
||||
|
||||
20
README_EN.md
@@ -1,8 +1,9 @@
|
||||
# MoviePilot-Frontend
|
||||
|
||||
*[中文](README.md) | English*
|
||||
_[中文](README.md) | English_
|
||||
|
||||
Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS version required: >= `v20.12.1`.
|
||||
Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot). The minimum supported Node.js version is
|
||||
`20.19`; Node.js `24` is recommended.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -17,7 +18,7 @@ Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (disable Vetur).
|
||||
|
||||
### Configure Vite
|
||||
### Configure Vite
|
||||
|
||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
@@ -39,6 +40,17 @@ yarn dev
|
||||
yarn build
|
||||
```
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```sh
|
||||
yarn test:run
|
||||
yarn test:coverage
|
||||
```
|
||||
|
||||
See the [unit testing architecture](docs/testing.md) for test organization, shared facilities, HTTP fixtures, and
|
||||
coverage conventions. See the [frontend code quality evolution](docs/code-quality.md) for ESLint, Prettier, Node.js
|
||||
compatibility, and incremental CI gates.
|
||||
|
||||
### Static Deployment
|
||||
|
||||
1. Host the `dist` static files using a web server like `nginx`. Refer to `public/nginx.conf` for nginx configuration.
|
||||
@@ -47,7 +59,7 @@ yarn build
|
||||
|
||||
```shell
|
||||
node dist/service.js
|
||||
```
|
||||
```
|
||||
|
||||
### Module Federation
|
||||
|
||||
|
||||
185
docs/code-quality.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 前端代码质量工具链演进
|
||||
|
||||
本文档定义 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 # 本地默认并行执行两个 Vitest shard
|
||||
yarn test:run --serial
|
||||
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`。
|
||||
|
||||
baseline 是刻意设计的渐进治理机制,不等于被抑制的违规已被认可为长期编码规范。2026-08-24 的基线快照为
|
||||
141 个文件、564 项受控违规,其中 `@typescript-eslint/no-explicit-any` 300 项、`vue/no-mutating-props`
|
||||
82 项、`@typescript-eslint/no-unused-vars` 64 项。生产 PR 修改到某个受控文件时,应审计并偿还该文件内
|
||||
不改变业务语义且可安全处理的存量,随后 prune;纯测试、文档或 CI 变更不为清理未触及生产文件扩大 diff。
|
||||
|
||||
迁移完成的最低验收为:
|
||||
|
||||
```sh
|
||||
yarn --frozen-lockfile
|
||||
yarn lint
|
||||
yarn typecheck
|
||||
yarn test:run
|
||||
yarn build
|
||||
```
|
||||
|
||||
若新配置导致大量与业务目标无关的格式、排序或额外文件类型问题,应停止扩大规则范围,先调整配置职责;不得以一次性自动修复数百个文件作为通过迁移的手段。
|
||||
|
||||
## ESLint CI:先观察,再强制
|
||||
|
||||
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job:
|
||||
|
||||
1. `lint` job 集中执行 ESLint 与 typecheck,单元测试使用独立的两分片 job,使静态检查与测试关键路径并行;本地默认并行执行两个 Vitest shard,并与 CI 共用 `yarn test:run` 入口。
|
||||
2. 初始阶段作为普通 check 运行,不立即配置 required check。
|
||||
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint` / `yarn typecheck`,不执行自动修复或更新 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,18 @@ export default defineConfig({
|
||||
// 自定义事件,用于通知主应用刷新数据
|
||||
const emit = defineEmits(['action', 'switch', 'close'])
|
||||
|
||||
// 接收API对象
|
||||
// 接收主应用能力
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
default: () => {},
|
||||
},
|
||||
pluginId: { type: String, default: '' },
|
||||
sourcePluginId: { type: String, default: '' },
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 页面逻辑代码...
|
||||
@@ -179,20 +181,26 @@ function notifyClose() {
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 接收初始配置和API对象
|
||||
// 接收初始配置和主应用能力
|
||||
const props = defineProps({
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
default: () => ({}),
|
||||
},
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
default: () => {},
|
||||
},
|
||||
pluginId: { type: String, default: '' },
|
||||
sourcePluginId: { type: String, default: '' },
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 配置数据
|
||||
const config = ref({...props.initialConfig})
|
||||
const config = ref({ ...props.initialConfig })
|
||||
|
||||
// 自定义事件,用于保存配置
|
||||
const emit = defineEmits(['save', 'close', 'switch'])
|
||||
@@ -217,7 +225,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 +242,23 @@ function notifyClose() {
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 接收配置和刷新控制
|
||||
// 接收配置、刷新控制和主应用能力
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
default: () => ({}),
|
||||
},
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
default: true,
|
||||
},
|
||||
api: { type: Object, default: () => ({}) },
|
||||
pluginId: { type: String, default: '' },
|
||||
sourcePluginId: { type: String, default: '' },
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 仪表板逻辑...
|
||||
@@ -276,18 +291,22 @@ const props = defineProps({
|
||||
|
||||
主应用传入的 props:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
| 属性 | 说明 |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `nativeSubscribe` | 打开主应用原生订阅交互 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
| `sourcePluginId` | 虚拟分身共享资源的源插件 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: '' },
|
||||
sourcePluginId: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['action'])
|
||||
</script>
|
||||
@@ -300,18 +319,198 @@ const emit = defineEmits(['action'])
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5.5 主应用宿主能力
|
||||
|
||||
登录后的联邦组件宿主会向插件开放以下能力:
|
||||
|
||||
| 能力 | Page | Config | Dashboard | AppPage | 调用方式 |
|
||||
| ---------------- | ---- | ------ | --------- | ------- | ---------------------------------------------------------------- |
|
||||
| 认证 API | ✓ | ✓ | ✓ | ✓ | `api` prop |
|
||||
| 当前实例 ID | ✓ | ✓ | ✓ | ✓ | `pluginId` prop |
|
||||
| 共享源码 ID | ✓ | ✓ | ✓ | ✓ | `sourcePluginId` prop;普通插件为空 |
|
||||
| 原生订阅交互 | ✓ | ✓ | ✓ | ✓ | `nativeSubscribe` prop 或 `inject('moviepilot:nativeSubscribe')` |
|
||||
| 主应用统一 Toast | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:toast')` |
|
||||
| 主应用公共弹窗 | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:dialog')` |
|
||||
| 主应用确认弹窗 | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:confirm')` |
|
||||
|
||||
`nativeSubscribe`、Toast、公共弹窗和确认弹窗都由主应用宿主提供。插件不应复制主程序订阅弹窗、创建另一套 Toast 容器或自行挂载全局弹窗。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。
|
||||
|
||||
V3 新建插件分身会复用源插件的同一份联邦产物。宿主传入的 `api` 已绑定当前
|
||||
`pluginId`:即使旧组件仍调用 `plugin/<sourcePluginId>/...`,也会被映射到实例 API。
|
||||
新组件应直接用 `pluginId` 拼接路径,并始终优先使用 `api` prop;只读取全局
|
||||
`window.MoviePilotAPI` 会绕过实例作用域,不适合多实例插件。
|
||||
|
||||
### 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`。注入不存在时应静默降级,关键错误仍需保留页面内状态提示。
|
||||
|
||||
### 5.9 调用主应用公共弹窗
|
||||
|
||||
`Page`、`Config`、`Dashboard` 与 `AppPage` 的宿主容器会通过固定键提供主应用公共弹窗函数。该函数会将插件组件挂载到主应用 `App.vue` 的 `SharedDialogHost`,因此弹窗不会受插件页面、卡片或父级容器的层叠上下文限制。远程组件应复用该入口,不要自行创建额外的弹窗容器:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject, type Component } from 'vue'
|
||||
|
||||
interface DialogController {
|
||||
id: number
|
||||
close(): void
|
||||
updateProps(props: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
type HostDialog = (
|
||||
component: Component,
|
||||
props?: Record<string, unknown>,
|
||||
events?: Record<string, (...args: unknown[]) => unknown>,
|
||||
options?: { closeOn?: string[] | false; replace?: boolean },
|
||||
) => DialogController
|
||||
|
||||
const openDialog = inject<HostDialog | null>('moviepilot:dialog', null)
|
||||
|
||||
function openPluginDialog(DialogComponent: Component, props: Record<string, unknown>) {
|
||||
return openDialog?.(
|
||||
DialogComponent,
|
||||
props,
|
||||
{
|
||||
close: () => {
|
||||
// 处理插件弹窗关闭后的业务逻辑。
|
||||
},
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
公共弹窗函数签名为 `openDialog(component, props, events, options)`,返回控制器:
|
||||
|
||||
- `closeOn`:收到指定事件后自动从公共层移除,默认监听 `close`;传 `false` 表示不自动关闭;
|
||||
- `replace`:是否替换当前公共弹窗栈;
|
||||
- `props` 和 `events`:也可以使用 `openDialogWithOptions` 的对象参数形式传入;
|
||||
- `close()`:主动关闭当前弹窗;
|
||||
- `updateProps(props)`:合并更新已打开弹窗的 props。
|
||||
|
||||
插件弹窗组件应提供 `close` 或 `update:modelValue` 事件,并自行处理组件内部交互。旧版主应用未提供该注入时,插件应保留页面内弹窗或其他 fallback。
|
||||
|
||||
### 5.10 调用主应用确认弹窗
|
||||
|
||||
确认弹窗使用独立的固定键,适合不需要自定义组件内容的确认场景:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
interface ConfirmOptions {
|
||||
type?: 'info' | 'warn' | 'error'
|
||||
title?: string
|
||||
content?: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
width?: string | number
|
||||
}
|
||||
|
||||
type HostConfirm = (options?: ConfirmOptions) => Promise<boolean>
|
||||
|
||||
const confirm = inject<HostConfirm | null>('moviepilot:confirm', null)
|
||||
|
||||
async function removeItem() {
|
||||
const confirmed = await confirm?.({
|
||||
type: 'warn',
|
||||
title: '确认删除',
|
||||
content: '删除后无法恢复,是否继续?',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
// 执行删除请求。
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
确认弹窗返回 `Promise<boolean>`:用户点击确认时为 `true`,点击取消或关闭按钮时为 `false`;注入能力不存在时返回 `undefined`,插件应按未确认处理。可用配置项包括 `type`(`info` / `warn` / `error`)、`title`、`content`、`confirmText`、`cancelText` 和 `width`。
|
||||
|
||||
#### 后端:注册侧栏入口
|
||||
|
||||
插件需为 **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 +532,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 +564,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,10 +578,11 @@ 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`)
|
||||
// 使用宿主传入的当前实例 ID,普通插件和虚拟分身使用同一份组件代码
|
||||
recentItems.value = await props.api.get(`plugin/${props.pluginId}/history`)
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -402,7 +601,6 @@ def get_api(self) -> List[Dict[str, Any]]:
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
## 7. 调试与排错
|
||||
|
||||
### 常见问题
|
||||
@@ -449,24 +647,19 @@ shared: {
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 开发环境测试
|
||||
### 8.3 本地监听构建
|
||||
|
||||
开发期间可以使用以下配置在本地测试:
|
||||
插件前端可使用 Vite 的监听构建模式:
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 5001, // 使用不同于主应用的端口
|
||||
cors: true, // 启用CORS
|
||||
origin: 'http://localhost:5001'
|
||||
}
|
||||
})
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
将 `dev` 脚本配置为 `vite build --watch` 后,源码变化会自动重新构建。使用本地插件仓并启用 `DEV` 或 `PLUGIN_AUTO_RELOAD` 时,MoviePilot 会同步新的构建产物;刷新页面即可看到修改。
|
||||
|
||||
## 9. 示例代码
|
||||
|
||||
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
|
||||
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
|
||||
- [模块联邦问题排查指南](./federation-troubleshooting.md) - 常见问题排查
|
||||
|
||||
## 10. 参考资料
|
||||
@@ -476,4 +669,4 @@ export default defineConfig({
|
||||
|
||||
---
|
||||
|
||||
如有问题,请提交Issue。
|
||||
如有问题,请提交Issue。
|
||||
|
||||
119
docs/testing.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 单元测试架构
|
||||
|
||||
MoviePilot-Frontend 使用 Vitest 运行单元测试和组件测试,使用 jsdom 提供 DOM 环境。测试代码参与 TypeScript 类型检查,但不作为生产构建入口。
|
||||
|
||||
## 测试类型
|
||||
|
||||
- 单元测试覆盖纯函数、store、composable、路由规则和独立模块的输入、输出及副作用。
|
||||
- 组件测试挂载 Vue 组件或页面,覆盖 props、emits、用户交互、可见 DOM、Router、Pinia、HTTP 请求和生命周期清理。
|
||||
- PWA、Service Worker、模块联邦远程入口、真实布局、拖拽和浏览器原生能力由真实浏览器验证,不由 jsdom 测试单独证明。
|
||||
|
||||
## 目录结构
|
||||
|
||||
业务 spec 与源码共置在对应责任域的 `__tests__/` 目录中,文件名与被测源码保持一致并使用 `*.spec.ts`:
|
||||
|
||||
```text
|
||||
src/
|
||||
├── pages/
|
||||
│ ├── recommend.vue
|
||||
│ └── __tests__/recommend.spec.ts
|
||||
├── stores/
|
||||
│ ├── auth.ts
|
||||
│ └── __tests__/auth.spec.ts
|
||||
├── utils/
|
||||
│ ├── permission.ts
|
||||
│ └── __tests__/permission.spec.ts
|
||||
└── views/dashboard/
|
||||
├── MediaRecommend.vue
|
||||
└── __tests__/MediaRecommend.spec.ts
|
||||
```
|
||||
|
||||
跨业务 spec 复用的测试设施位于 `tests/`:
|
||||
|
||||
```text
|
||||
tests/
|
||||
├── setup.ts
|
||||
└── support/
|
||||
├── render.ts
|
||||
├── factories/
|
||||
└── msw/
|
||||
├── server.ts
|
||||
└── handlers/
|
||||
```
|
||||
|
||||
- `tests/setup.ts` 注册 DOM matcher、MSW 生命周期、浏览器 API stub 和每例清理逻辑。
|
||||
- `tests/support/render.ts` 提供带 Vuetify、i18n、Router 和 Pinia 的标准渲染入口。
|
||||
- `tests/support/factories/` 按业务对象提供最小有效测试数据工厂。
|
||||
- `tests/support/msw/handlers/` 按业务域定义 HTTP handler;`server.ts` 只负责 MSW server 实例。
|
||||
- `tests/support/msw/response.ts` 显式构造主程序普通 API 的成功或业务失败 envelope。
|
||||
- spec 通过 `@tests/*` 访问共享测试设施,通过 `@/*` 访问生产源码。
|
||||
|
||||
## 工具职责
|
||||
|
||||
- Vitest 提供 runner、断言、mock、fake timers 和覆盖率执行入口。
|
||||
- Vue Test Utils 用于 Vue 特有的 props、emits、slots 和局部组件控制。
|
||||
- Testing Library、jest-dom 和 user-event 用于按角色、可访问名称和用户操作验证可见行为。
|
||||
- MSW 在 HTTP 边界拦截真实 API 客户端请求。未声明请求会使测试失败,测试不得访问真实后端或外网。
|
||||
- `@pinia/testing` 用于依赖 store 的组件测试;store 自身使用真实 `createPinia()` 测试。
|
||||
|
||||
## 编写规范
|
||||
|
||||
- 一个 spec 对应一个主要源码文件;测试名称描述可观察行为或业务规则。
|
||||
- 组件测试断言可见 DOM、emits、路由、请求和持久化结果,不读取组件私有状态或私有方法。
|
||||
- 纯逻辑优先直接调用;依赖生命周期、provide 或 inject 的 composable 通过宿主组件挂载。
|
||||
- HTTP handler 和 factory 按业务域拆分,不建立包含所有接口或所有数据字段的全局万能 mock。
|
||||
- 只在与当前断言无关或无法由 jsdom 正确执行时 stub 子组件、浏览器能力或第三方重型组件。
|
||||
- 每个用例保持独立,不依赖文件执行顺序;timer、mock、storage、DOM 和未完成请求由全局 setup 恢复。
|
||||
- 不使用大面积快照或覆盖率占位用例。
|
||||
|
||||
## HTTP 响应夹具
|
||||
|
||||
主程序普通 API 使用固定的 `{ success, message, data }` envelope。MSW handler 必须通过
|
||||
`apiJson(data)` 或 `apiFailureJson(message, data)` 显式声明成功或业务失败,不得在全局 setup 中改写
|
||||
`HttpResponse.json()`、自动包装裸数据或补齐缺失字段。这样测试夹具与当前后端协议不一致时会直接失败,
|
||||
不会由兼容层掩盖。
|
||||
|
||||
HTTP 4xx/5xx 的原始错误体、插件自定义端点和其他明确不使用主程序 envelope 的协议继续直接调用
|
||||
`HttpResponse.json()`。是否使用 envelope 由端点契约决定,不根据状态码或载荷形状自动猜测。
|
||||
|
||||
## 测试性能
|
||||
|
||||
- 根据逐文件和逐用例耗时日志识别热点,不按文件行数或用例数量机械拆分。
|
||||
- 业务测试可以对与断言无关的第三方重型 UI 边界使用局部 test double,但必须保留 props、emits、
|
||||
`v-model`、可访问名称及被测业务使用的值类型;不得以直接赋值组件私有状态替代用户交互。
|
||||
- 优先减少动画、布局、定位和重复挂载等 jsdom 无法证明的成本。真实组件集成仍由代表性组件测试或浏览器回归负责。
|
||||
- 优化前后使用同一命令和 reporter 比较 focused 文件与热点用例;只有保持断言和业务契约后取得稳定收益才保留优化。
|
||||
|
||||
## 新增测试
|
||||
|
||||
1. 业务测试在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`;工具链配置契约测试放在 `tests/config/`。
|
||||
2. 纯函数、store 和无渲染模块直接使用 Vitest;Vue 组件使用标准渲染入口。
|
||||
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
|
||||
主程序普通 API 响应使用 `apiJson()` / `apiFailureJson()`;只有明确的非 envelope 协议才直接使用
|
||||
`HttpResponse.json()`。
|
||||
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
|
||||
5. 核心覆盖范围发生变化时,同步更新 `vite.config.ts` 的 `coverage.include`。
|
||||
6. 提交前按影响面运行测试、类型检查、lint 和生产构建;覆盖率报告按需本地执行。
|
||||
|
||||
## 配置边界
|
||||
|
||||
Vitest 收集 `src/**/__tests__/**/*.spec.ts` 和 `tests/config/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。配置契约测试随全量测试执行,但不加入业务源码覆盖率统计范围。
|
||||
|
||||
当前核心覆盖范围在 `vite.config.ts` 的 `coverage.include` 中显式维护,覆盖率报告写入 `coverage/`。覆盖率配置保留给本地质量分析,不作为 GitHub Actions 的单元测试门禁。
|
||||
|
||||
## 命令与 CI
|
||||
|
||||
```sh
|
||||
yarn test # watch 模式
|
||||
yarn test:run # 默认并行两个 shard 单次运行
|
||||
yarn test:run --serial # 单进程运行,适合排查顺序或共享状态问题
|
||||
yarn test:coverage # 单次运行并检查覆盖率
|
||||
yarn test:lint-config # 聚焦执行 ESLint 配置契约测试
|
||||
yarn typecheck
|
||||
yarn lint
|
||||
yarn build
|
||||
```
|
||||
|
||||
`yarn test:run` 默认并行执行两个 Vitest shard,本地全量测试和 CI 使用同一个入口。传入 `--shard=1/2` 之类的显式分片参数时只执行对应 shard;`yarn test:run --serial` 强制单进程运行。传入测试文件或名称过滤条件时自动使用单进程,避免聚焦测试因文件数不足而无法分片。
|
||||
|
||||
`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile,在面向 `v3` 的 Pull Request 和推送到 `v3` 时运行。`lint` job 依次执行全仓只读 ESLint 和类型检查;单元测试 matrix 通过统一入口分别执行两个 Vitest shard,成功用例的标准输出默认静默,失败详情仍保留。CI 不要求覆盖率达标;覆盖率可按需在本地运行 `yarn test:coverage`。变更文件格式检查依赖 Pull Request 的 base/head SHA,因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
|
||||
914
eslint-suppressions.json
Normal file
@@ -0,0 +1,914 @@
|
||||
{
|
||||
"examples/plugin-component/src/components/AppPage.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"examples/plugin-component/src/components/AppPageSettings.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/@core/libs/apex-chart/apexCharConfig.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/@core/utils/formatters.ts": {
|
||||
"sonarjs/super-linear-regex": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/@core/utils/index.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/@core/utils/navigator.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/@core/utils/theme.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/@core/utils/workflow.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/@validators/index.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/App.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/ace-config.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 24
|
||||
},
|
||||
"no-useless-escape": {
|
||||
"count": 4
|
||||
},
|
||||
"sonarjs/empty-string-repetition": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/super-linear-regex": {
|
||||
"count": 9
|
||||
}
|
||||
},
|
||||
"src/api/types.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 38
|
||||
},
|
||||
"@typescript-eslint/no-wrapper-object-types": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/agent/AgentAssistantEntry.vue": {
|
||||
"no-useless-escape": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/super-linear-regex": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/agent/AgentAssistantPanel.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 7
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 10
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/components/cards/DirectoryCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/super-linear-regex": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-mutating-props": {
|
||||
"count": 27
|
||||
}
|
||||
},
|
||||
"src/components/cards/FilterRuleCard.vue": {
|
||||
"vue/no-mutating-props": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/FilterRuleGroupCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/MediaCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"no-useless-assignment": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/PersonCard.vue": {
|
||||
"no-useless-assignment": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/PluginMixedSortCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/cards/SubscribeCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/dialog/AboutDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 8
|
||||
},
|
||||
"vue/no-parsing-error": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/AlistConfigDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-mutating-props": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/dialog/AliyunAuthDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/CategoryEditDialog.vue": {
|
||||
"@typescript-eslint/ban-ts-comment": {
|
||||
"count": 16
|
||||
},
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/components/dialog/ContentToggleSettingsDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/FilterRuleGroupInfoDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/MediaInfoDialog.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/NotificationChannelInfoDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/RcloneConfigDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-mutating-props": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/dialog/ReorganizeDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
},
|
||||
"sonarjs/super-linear-regex": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SharedDialogHost.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SiteAddEditDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SiteCookieUpdateDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SiteImportDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SiteStatisticsDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SmbConfigDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-mutating-props": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SubscribeEditDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SubscribeHistoryDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SubscribeSeasonDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SubscribeShareStatisticsDialog.vue": {
|
||||
"vue/no-side-effects-in-computed-properties": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/U115AuthDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/UserAuthDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/dialog/WorkflowActionsDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 17
|
||||
}
|
||||
},
|
||||
"src/components/dialog/WorkflowAddEditDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/field/CronField.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/field/PathField.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/filebrowser/FileList.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/input/PathInput.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/misc/DashboardElement.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/misc/MediaIdSelector.vue": {
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/misc/OpticalLogoLab.vue": {
|
||||
"no-useless-assignment": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/misc/ProgressiveCardGrid.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
},
|
||||
"@typescript-eslint/no-unused-expressions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/pwa/PWAInstallPrompt.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/render/DashboardRender.vue": {
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/render/FormRender.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 17
|
||||
},
|
||||
"sonarjs/code-eval": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/render/PageRender.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/slide/SlideViewTitle.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/slide/VirtualSlideView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
},
|
||||
"no-useless-assignment": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/workflow/AddDownloadAction.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-mutating-props": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/workflow/FetchDownloadsAction.vue": {
|
||||
"vue/no-mutating-props": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/workflow/FetchMediasAction.vue": {
|
||||
"vue/no-mutating-props": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/workflow/FetchRssAction.vue": {
|
||||
"vue/no-mutating-props": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/components/workflow/FetchTorrentsAction.vue": {
|
||||
"vue/no-mutating-props": {
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"src/components/workflow/FilterMediasAction.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-mutating-props": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/workflow/FilterTorrentsAction.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-mutating-props": {
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"src/components/workflow/InvokePluginAction.vue": {
|
||||
"vue/no-mutating-props": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/workflow/NoteAction.vue": {
|
||||
"vue/no-mutating-props": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/workflow/ScanFileAction.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-mutating-props": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/workflow/SendMessageAction.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-mutating-props": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/workflow/TransferFileAction.vue": {
|
||||
"vue/no-mutating-props": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/composables/__tests__/useMediaSubscribe.spec.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/composables/useCacheManager.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/composables/useCardAccentColor.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/composables/useConfirm.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/composables/useDynamicButton.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/composables/useDynamicHeaderTab.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
},
|
||||
"no-useless-assignment": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-dead-store": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/composables/useMediaSubscribe.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/composables/usePWA.ts": {
|
||||
"no-async-promise-executor": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/composables/usePWAInstall.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/composables/useSetupWizard.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/composables/useSharedDialog.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/composables/useStateRestore.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/composables/useTorrentFilter.ts": {
|
||||
"no-useless-assignment": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/layouts/default/components/Footer.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 6
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/layouts/default/components/QuickAccess.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
},
|
||||
"no-useless-assignment": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/layouts/default/components/UserNotification.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/layouts/default/components/UserProfile.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 5
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/pages/__tests__/subscribe.spec.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/pages/credits.vue": {
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/pages/resource.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
},
|
||||
"sonarjs/no-duplicated-branches": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/pages/setting.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/plugins/i18n.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/plugins/stateRestore.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/plugins/vuetify/AppDialog.ts": {
|
||||
"sonarjs/super-linear-regex": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/plugins/vuetify/icons.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/router/index.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"src/service-worker.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
},
|
||||
"no-useless-assignment": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/stores/global.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/stores/types.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/utils/agentAssistantBubble.ts": {
|
||||
"@typescript-eslint/no-empty-object-type": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/utils/apexCharts.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/utils/appDeepLink.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
},
|
||||
"no-case-declarations": {
|
||||
"count": 1
|
||||
},
|
||||
"no-useless-escape": {
|
||||
"count": 10
|
||||
},
|
||||
"sonarjs/no-dead-store": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/utils/backgroundManager.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/utils/badge.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/utils/globalSetting.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/utils/loadingStateManager.ts": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/utils/permission.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 9
|
||||
}
|
||||
},
|
||||
"src/utils/requestOptimizer.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/utils/themeLogo.ts": {
|
||||
"no-useless-assignment": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/dashboard/AnalyticsCpu.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/dashboard/AnalyticsMemory.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/dashboard/AnalyticsSpeed.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/dashboard/MediaServerLibrary.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/dashboard/MediaServerPlaying.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/discover/MediaCardSlideView.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/discover/PersonCardListView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/views/discover/PersonDetailView.vue": {
|
||||
"no-useless-assignment": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/reorganize/TransferHistoryView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
},
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/views/setting/AccountSettingDirectory.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
},
|
||||
"sonarjs/super-linear-regex": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/setting/AccountSettingRule.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 8
|
||||
}
|
||||
},
|
||||
"src/views/setting/AccountSettingSearch.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/views/setting/AccountSettingSite.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/views/setting/AccountSettingSubscribe.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/views/setting/AccountSettingSystem.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/setup/MediaServerSettingsStep.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/setup/PreferencesSettingsStep.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/subscribe/FullCalendarView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/subscribe/SubscribeListView.vue": {
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/subscribe/SubscribePopularView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/subscribe/SubscribeShareView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/system/CacheView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/system/LoggingView.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"no-control-regex": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/super-linear-regex": {
|
||||
"count": 8
|
||||
}
|
||||
},
|
||||
"src/views/system/ModuleTestView.vue": {
|
||||
"vue/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/system/NameTestView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/system/RuleTestView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/system/WordsView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/views/workflow/WorkflowShareView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"vite.config.ts": {
|
||||
"@typescript-eslint/ban-ts-comment": {
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
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
62
package.json
@@ -1,16 +1,28 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.14.0",
|
||||
"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": "node scripts/run-tests.mjs",
|
||||
"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"
|
||||
@@ -20,6 +32,10 @@
|
||||
"dist/**/*"
|
||||
]
|
||||
},
|
||||
"resolutions": {
|
||||
"vitest/**/vite": "5.4.18",
|
||||
"vitest/vite": "5.4.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.15",
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
@@ -50,6 +66,7 @@
|
||||
"express": "^4.21.2",
|
||||
"express-http-proxy": "^2.1.1",
|
||||
"gridstack": "^12.6.0",
|
||||
"gsap": "^3.15.0",
|
||||
"http-proxy-middleware": "^3.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash-es": "^4.17.21",
|
||||
@@ -57,11 +74,12 @@
|
||||
"markdown-it-link-attributes": "^4.0.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.83.4",
|
||||
"tailwindcss": "^ 3.4.17",
|
||||
"three": "^0.185.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-toastification": "^2.0.0-rc.5",
|
||||
@@ -73,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",
|
||||
@@ -82,7 +101,12 @@
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.3",
|
||||
"@originjs/vite-plugin-federation": "^1.4.1",
|
||||
"@pinia/testing": "1.0.3",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@testing-library/dom": "9.3.4",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
@@ -91,22 +115,24 @@
|
||||
"@types/node": "^20.1.4",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@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",
|
||||
@@ -114,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",
|
||||
@@ -123,10 +150,15 @@
|
||||
"vite-plugin-top-level-await": "^1.5.0",
|
||||
"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.22",
|
||||
"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 }
|
||||
]
|
||||
56
scripts/run-tests.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const defaultShards = ['1/2', '2/2']
|
||||
const serialFlag = '--serial'
|
||||
const vitestCliPath = fileURLToPath(import.meta.resolve('vitest/vitest.mjs'))
|
||||
|
||||
/**
|
||||
* 生成一次 test:run 所需的 Vitest 参数组。全量测试默认并行;显式分片、串行模式和聚焦过滤保持单进程。
|
||||
*
|
||||
* @param {string[]} args test:run 接收的命令行参数
|
||||
* @returns {string[][]} 每个 Vitest 进程接收的一组参数
|
||||
*/
|
||||
export function planTestRuns(args) {
|
||||
const vitestArgs = args.filter(arg => arg !== serialFlag)
|
||||
const hasExplicitShard = vitestArgs.some(arg => arg === '--shard' || arg.startsWith('--shard='))
|
||||
const hasPositionalFilter = vitestArgs.some(arg => !arg.startsWith('-'))
|
||||
|
||||
if (args.includes(serialFlag) || hasExplicitShard || hasPositionalFilter) return [vitestArgs]
|
||||
|
||||
return defaultShards.map(shard => [...vitestArgs, `--shard=${shard}`])
|
||||
}
|
||||
|
||||
function runVitest(args) {
|
||||
return new Promise(resolveRun => {
|
||||
const child = spawn(process.execPath, [vitestCliPath, 'run', ...args], {
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
child.on('error', error => {
|
||||
console.error(`无法启动 Vitest: ${error.message}`)
|
||||
resolveRun(1)
|
||||
})
|
||||
child.on('close', (code, signal) => {
|
||||
if (signal) {
|
||||
console.error(`Vitest 被信号 ${signal} 终止`)
|
||||
resolveRun(1)
|
||||
return
|
||||
}
|
||||
|
||||
resolveRun(code ?? 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const runs = planTestRuns(process.argv.slice(2))
|
||||
const exitCodes = await Promise.all(runs.map(runVitest))
|
||||
|
||||
process.exitCode = exitCodes.find(code => code !== 0) ?? 0
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
await main()
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
37
src/@core/components/__tests__/DialogCloseBtn.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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 = button.querySelector('svg.v-icon')
|
||||
expect(container.querySelectorAll('svg.v-icon')).toHaveLength(1)
|
||||
expect(icon).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(icon).toHaveAttribute('role', 'img')
|
||||
expect(icon).toHaveAttribute('height', '1em')
|
||||
expect(icon).toHaveAttribute('width', '1em')
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
115
src/@core/utils/__tests__/image.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getColor: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('colorthief', () => ({
|
||||
default: class ColorThief {
|
||||
getColor(image: HTMLImageElement, quality?: number) {
|
||||
return mocks.getColor(image, quality)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
import { extractDominantColor, getDominantColor, preloadCorsImage } from '@/@core/utils/image'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
function createImage(cacheKey: string) {
|
||||
const image = document.createElement('img')
|
||||
Object.defineProperty(image, 'currentSrc', { configurable: true, value: `https://image.example/${cacheKey}.png` })
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
describe('dominant color extraction', () => {
|
||||
beforeEach(() => {
|
||||
mocks.getColor.mockReset()
|
||||
})
|
||||
|
||||
it('shares a pending extraction and reuses only the successful result', async () => {
|
||||
const image = createImage('shared-success')
|
||||
mocks.getColor.mockReturnValue([18, 52, 86])
|
||||
|
||||
await expect(Promise.all([extractDominantColor(image), extractDominantColor(image)])).resolves.toEqual([
|
||||
'#123456',
|
||||
'#123456',
|
||||
])
|
||||
await expect(extractDominantColor(image)).resolves.toBe('#123456')
|
||||
expect(mocks.getColor).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not cache failures or let one caller fallback pollute another', async () => {
|
||||
const image = createImage('retry-after-failure')
|
||||
mocks.getColor.mockImplementation(() => {
|
||||
throw new Error('tainted canvas')
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await expect(getDominantColor(image, { fallback: '#111111' })).resolves.toBe('#111111')
|
||||
await expect(getDominantColor(image, { fallback: '#222222' })).resolves.toBe('#222222')
|
||||
expect(mocks.getColor).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the existing default fallback contract for callers such as QuickAccess', async () => {
|
||||
await expect(getDominantColor(null)).resolves.toBe('#28A9E1')
|
||||
expect(mocks.getColor).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains the bounded FIFO success cache', async () => {
|
||||
mocks.getColor.mockReturnValue([1, 2, 3])
|
||||
|
||||
for (let index = 0; index <= 100; index += 1) await extractDominantColor(createImage(`fifo-${index}`))
|
||||
|
||||
expect(mocks.getColor).toHaveBeenCalledTimes(101)
|
||||
await extractDominantColor(createImage('fifo-50'))
|
||||
expect(mocks.getColor).toHaveBeenCalledTimes(101)
|
||||
await extractDominantColor(createImage('fifo-0'))
|
||||
expect(mocks.getColor).toHaveBeenCalledTimes(102)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
199
src/@core/utils/__tests__/workflow.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import useDragAndDrop from '@core/utils/workflow'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
addNodes: vi.fn(),
|
||||
screenToFlowCoordinate: vi.fn(),
|
||||
onNodesInitialized: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
off: vi.fn(),
|
||||
onNodesInitializedCallback: undefined as (() => void) | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@vue-flow/core', () => ({
|
||||
useVueFlow: () => ({
|
||||
addNodes: mocks.addNodes,
|
||||
screenToFlowCoordinate: mocks.screenToFlowCoordinate,
|
||||
onNodesInitialized: mocks.onNodesInitialized,
|
||||
updateNode: mocks.updateNode,
|
||||
}),
|
||||
}))
|
||||
|
||||
type DragAndDrop = ReturnType<typeof useDragAndDrop>
|
||||
|
||||
let current: DragAndDrop | undefined
|
||||
let wrapper: ReturnType<typeof mount> | undefined
|
||||
|
||||
const Harness = defineComponent({
|
||||
name: 'WorkflowDragAndDropHarness',
|
||||
setup() {
|
||||
current = useDragAndDrop()
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
function mountHarness() {
|
||||
wrapper = mount(Harness)
|
||||
return current!
|
||||
}
|
||||
|
||||
function createDataTransfer() {
|
||||
return {
|
||||
effectAllowed: '',
|
||||
dropEffect: '',
|
||||
setData: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('useDragAndDrop', () => {
|
||||
beforeEach(() => {
|
||||
mocks.addNodes.mockReset()
|
||||
mocks.screenToFlowCoordinate.mockReset().mockImplementation(({ x, y }: { x: number; y: number }) => ({ x, y }))
|
||||
mocks.onNodesInitialized.mockReset().mockImplementation((callback: () => void) => {
|
||||
mocks.onNodesInitializedCallback = callback
|
||||
return { off: mocks.off }
|
||||
})
|
||||
mocks.updateNode.mockReset()
|
||||
mocks.off.mockReset()
|
||||
mocks.onNodesInitializedCallback = undefined
|
||||
document.body.style.userSelect = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
current = undefined
|
||||
document.body.style.userSelect = ''
|
||||
})
|
||||
|
||||
it('does not enter drag-over or create a node before a drag starts', async () => {
|
||||
const dnd = mountHarness()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
dnd.onDragOver({ preventDefault, dataTransfer: createDataTransfer() })
|
||||
dnd.onDrop({ clientX: 10, clientY: 20 })
|
||||
await nextTick()
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(dnd.isDragOver.value).toBe(false)
|
||||
expect(mocks.addNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks drag state, prevents selection, and clears everything on document drop', async () => {
|
||||
const dnd = mountHarness()
|
||||
const dataTransfer = createDataTransfer()
|
||||
const data = { type: 'ScanFileAction', name: '扫描目录' }
|
||||
const addEventListener = vi.spyOn(document, 'addEventListener')
|
||||
const removeEventListener = vi.spyOn(document, 'removeEventListener')
|
||||
|
||||
dnd.onDragStart({ dataTransfer }, data)
|
||||
await nextTick()
|
||||
expect(dataTransfer.setData).toHaveBeenCalledWith('application/vueflow', data)
|
||||
expect(dataTransfer.effectAllowed).toBe('move')
|
||||
expect(dnd.draggedData.value).toEqual(data)
|
||||
expect(dnd.isDragging.value).toBe(true)
|
||||
expect(document.body.style.userSelect).toBe('none')
|
||||
expect(addEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
|
||||
expect(addEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
|
||||
|
||||
const dragOverTransfer = createDataTransfer()
|
||||
const preventDefault = vi.fn()
|
||||
dnd.onDragOver({ preventDefault, dataTransfer: dragOverTransfer })
|
||||
await nextTick()
|
||||
expect(dnd.isDragOver.value).toBe(true)
|
||||
expect(dragOverTransfer.dropEffect).toBe('move')
|
||||
dnd.onDragLeave()
|
||||
expect(dnd.isDragOver.value).toBe(false)
|
||||
|
||||
document.dispatchEvent(new Event('drop'))
|
||||
await nextTick()
|
||||
expect(dnd.draggedData.value).toBeNull()
|
||||
expect(dnd.isDragging.value).toBe(false)
|
||||
expect(dnd.isDragOver.value).toBe(false)
|
||||
expect(document.body.style.userSelect).toBe('')
|
||||
expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
|
||||
expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
|
||||
})
|
||||
|
||||
it('clears the drag state when the browser cancels the native drag', async () => {
|
||||
const dnd = mountHarness()
|
||||
const removeEventListener = vi.spyOn(document, 'removeEventListener')
|
||||
|
||||
dnd.onDragStart({ dataTransfer: createDataTransfer() }, { type: 'NoteAction', name: '备注' })
|
||||
await nextTick()
|
||||
dnd.onDragOver({ preventDefault: vi.fn(), dataTransfer: createDataTransfer() })
|
||||
await nextTick()
|
||||
|
||||
document.dispatchEvent(new Event('dragend'))
|
||||
await nextTick()
|
||||
|
||||
expect(dnd.draggedData.value).toBeNull()
|
||||
expect(dnd.isDragging.value).toBe(false)
|
||||
expect(dnd.isDragOver.value).toBe(false)
|
||||
expect(document.body.style.userSelect).toBe('')
|
||||
expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
|
||||
expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
|
||||
})
|
||||
|
||||
it('deep clones dropped data and centers the node after Vue Flow initializes it', async () => {
|
||||
const dnd = mountHarness()
|
||||
const data = {
|
||||
type: 'ScanFileAction',
|
||||
name: '扫描目录',
|
||||
description: '扫描媒体目录',
|
||||
data: { nested: { enabled: true } },
|
||||
}
|
||||
|
||||
dnd.onDragStart({ dataTransfer: createDataTransfer() }, data)
|
||||
dnd.onDrop({ clientX: 80, clientY: 120 })
|
||||
|
||||
expect(mocks.screenToFlowCoordinate).toHaveBeenCalledWith({ x: 80, y: 120 })
|
||||
expect(mocks.addNodes).toHaveBeenCalledOnce()
|
||||
const node = mocks.addNodes.mock.calls[0][0]
|
||||
expect(node).toMatchObject({
|
||||
type: 'ScanFileAction',
|
||||
name: '扫描目录',
|
||||
description: '扫描媒体目录',
|
||||
position: { x: 80, y: 120 },
|
||||
data: { nested: { enabled: true } },
|
||||
})
|
||||
expect(node.id).toMatch(/^act_/)
|
||||
expect(node.data).not.toBe(data.data)
|
||||
|
||||
data.data.nested.enabled = false
|
||||
expect(node.data.nested.enabled).toBe(true)
|
||||
|
||||
expect(mocks.onNodesInitialized).toHaveBeenCalledOnce()
|
||||
mocks.onNodesInitializedCallback?.()
|
||||
expect(mocks.updateNode).toHaveBeenCalledWith(node.id, expect.any(Function))
|
||||
const update = mocks.updateNode.mock.calls[0][1] as (node: {
|
||||
position: { x: number; y: number }
|
||||
dimensions: { width: number; height: number }
|
||||
}) => { position: { x: number; y: number } }
|
||||
expect(update({ position: { x: 80, y: 120 }, dimensions: { width: 40, height: 20 } })).toEqual({
|
||||
position: { x: 60, y: 110 },
|
||||
})
|
||||
expect(mocks.off).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cleans the global selection style and drop listener when unmounted during a drag', async () => {
|
||||
const dnd = mountHarness()
|
||||
const removeEventListener = vi.spyOn(document, 'removeEventListener')
|
||||
|
||||
dnd.onDragStart({ dataTransfer: createDataTransfer() }, { type: 'NoteAction', name: '备注' })
|
||||
await nextTick()
|
||||
expect(document.body.style.userSelect).toBe('none')
|
||||
|
||||
wrapper!.unmount()
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.style.userSelect).toBe('')
|
||||
expect(dnd.isDragging.value).toBe(false)
|
||||
expect(dnd.isDragOver.value).toBe(false)
|
||||
expect(dnd.draggedData.value).toBeNull()
|
||||
expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
|
||||
expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
|
||||
})
|
||||
})
|
||||
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,9 +1,11 @@
|
||||
import ColorThief from 'colorthief'
|
||||
export { preloadCorsImage } from './corsImage'
|
||||
|
||||
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
|
||||
const DOMINANT_COLOR_CACHE_LIMIT = 100
|
||||
const colorThief = new ColorThief()
|
||||
const dominantColorCache = new Map<string, Promise<string>>()
|
||||
const dominantColorCache = new Map<string, string>()
|
||||
const pendingDominantColorRequests = new Map<string, Promise<string | undefined>>()
|
||||
|
||||
interface DominantColorOptions {
|
||||
fallback?: string
|
||||
@@ -28,64 +30,89 @@ function getImageCacheKey(image: HTMLImageElement) {
|
||||
return image.currentSrc || image.src || ''
|
||||
}
|
||||
|
||||
function rememberDominantColor(key: string, colorPromise: Promise<string>) {
|
||||
if (!key) return colorPromise
|
||||
function rememberDominantColor(key: string, color: string) {
|
||||
if (!key) return
|
||||
|
||||
if (dominantColorCache.size >= DOMINANT_COLOR_CACHE_LIMIT) {
|
||||
const firstKey = dominantColorCache.keys().next().value
|
||||
if (firstKey) dominantColorCache.delete(firstKey)
|
||||
}
|
||||
|
||||
dominantColorCache.set(key, colorPromise)
|
||||
dominantColorCache.set(key, color)
|
||||
}
|
||||
|
||||
/** 提取真实主色;失败不写入成功缓存,允许后续请求重试。 */
|
||||
export async function extractDominantColor(
|
||||
image: HTMLImageElement | undefined | null,
|
||||
options: Pick<DominantColorOptions, 'quality'> = {},
|
||||
): Promise<string | undefined> {
|
||||
if (!image) return undefined
|
||||
|
||||
const cacheKey = getImageCacheKey(image)
|
||||
const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined
|
||||
if (cachedColor) return cachedColor
|
||||
|
||||
const pendingRequest = cacheKey ? pendingDominantColorRequests.get(cacheKey) : undefined
|
||||
if (pendingRequest) return pendingRequest
|
||||
|
||||
const colorPromise = Promise.resolve()
|
||||
.then(() => {
|
||||
const dominantColor = colorThief.getColor(image, options.quality ?? 20)
|
||||
const color = rgbStringToHex(dominantColor)
|
||||
rememberDominantColor(cacheKey, color)
|
||||
|
||||
return color
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('Failed to extract dominant color:', error)
|
||||
return undefined
|
||||
})
|
||||
.finally(() => {
|
||||
if (cacheKey) pendingDominantColorRequests.delete(cacheKey)
|
||||
})
|
||||
|
||||
if (cacheKey) pendingDominantColorRequests.set(cacheKey, colorPromise)
|
||||
|
||||
return colorPromise
|
||||
}
|
||||
|
||||
// 提取主要颜色
|
||||
/** 提取主色并在失败时解析调用方 fallback,保持既有调用合同。 */
|
||||
export async function getDominantColor(
|
||||
image: HTMLImageElement | undefined | null,
|
||||
options: DominantColorOptions = {},
|
||||
): Promise<string> {
|
||||
const fallback = options.fallback ?? DEFAULT_DOMINANT_COLOR
|
||||
|
||||
if (!image) return fallback
|
||||
|
||||
const cacheKey = getImageCacheKey(image)
|
||||
const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined
|
||||
if (cachedColor) return cachedColor
|
||||
|
||||
const colorPromise = Promise.resolve()
|
||||
.then(() => {
|
||||
const dominantColor = colorThief.getColor(image, options.quality ?? 20)
|
||||
return rgbStringToHex(dominantColor)
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('Failed to extract dominant color:', error)
|
||||
return fallback
|
||||
})
|
||||
|
||||
return rememberDominantColor(cacheKey, colorPromise)
|
||||
return (await extractDominantColor(image, options)) ?? fallback
|
||||
}
|
||||
|
||||
// 预加载图片
|
||||
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,5 +1,5 @@
|
||||
import { useVueFlow } from '@vue-flow/core'
|
||||
import { ref, watch } from 'vue'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
/**
|
||||
@@ -18,7 +18,7 @@ const state = {
|
||||
/**
|
||||
* The type of the node being dragged.
|
||||
*/
|
||||
draggedData: ref<any | null>({}),
|
||||
draggedData: ref<any | null>(null),
|
||||
isDragOver: ref(false),
|
||||
isDragging: ref(false),
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export default function useDragAndDrop() {
|
||||
const { draggedData, isDragOver, isDragging } = state
|
||||
|
||||
const { addNodes, screenToFlowCoordinate, onNodesInitialized, updateNode } = useVueFlow()
|
||||
let ownsDragListeners = false
|
||||
|
||||
watch(isDragging, dragging => {
|
||||
document.body.style.userSelect = dragging ? 'none' : ''
|
||||
@@ -42,6 +43,8 @@ export default function useDragAndDrop() {
|
||||
isDragging.value = true
|
||||
|
||||
document.addEventListener('drop', onDragEnd)
|
||||
document.addEventListener('dragend', onDragEnd)
|
||||
ownsDragListeners = true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +73,8 @@ export default function useDragAndDrop() {
|
||||
isDragOver.value = false
|
||||
draggedData.value = null
|
||||
document.removeEventListener('drop', onDragEnd)
|
||||
document.removeEventListener('dragend', onDragEnd)
|
||||
ownsDragListeners = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,6 +83,8 @@ export default function useDragAndDrop() {
|
||||
* @param {DragEvent} event
|
||||
*/
|
||||
function onDrop(event: any) {
|
||||
if (!draggedData.value) return
|
||||
|
||||
const position = screenToFlowCoordinate({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
@@ -110,6 +117,12 @@ export default function useDragAndDrop() {
|
||||
addNodes(newNode)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (!ownsDragListeners) return
|
||||
onDragEnd()
|
||||
document.body.style.userSelect = ''
|
||||
})
|
||||
|
||||
return {
|
||||
draggedData,
|
||||
isDragOver,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import logo from '@images/logo.svg?raw'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
interface Props {
|
||||
tag?: string | Component
|
||||
@@ -14,6 +16,17 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
})
|
||||
|
||||
const { mdAndDown } = useDisplay()
|
||||
const { t } = useI18n()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const pythonFreeThreaded = computed(() => globalSettingsStore.get('PYTHON_FREE_THREADED') === true)
|
||||
const runtimeVersion = computed(() => (pythonFreeThreaded.value ? 'v3t' : 'v3'))
|
||||
const runtimeGilFallback = computed(
|
||||
() => pythonFreeThreaded.value && globalSettingsStore.get('PYTHON_GIL_ENABLED') === true,
|
||||
)
|
||||
const runtimeStatusIcon = computed(() => (runtimeGilFallback.value ? 'mdi-alert-circle-outline' : 'mdi-flask-outline'))
|
||||
const runtimeStatusHint = computed(() =>
|
||||
t(runtimeGilFallback.value ? 'app.freeThreadedGilFallbackWarning' : 'app.freeThreadedExperimentalHint'),
|
||||
)
|
||||
const refNav = ref()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -51,10 +64,23 @@ 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="runtime-version text-sm text-gray-500 d-inline-flex align-center"
|
||||
:class="{
|
||||
'runtime-version--free-threaded': pythonFreeThreaded,
|
||||
'runtime-version--degraded': runtimeGilFallback,
|
||||
}"
|
||||
>
|
||||
{{ runtimeVersion }}
|
||||
<VIcon v-if="pythonFreeThreaded" :icon="runtimeStatusIcon" size="13" :aria-label="runtimeStatusHint" />
|
||||
<VTooltip v-if="pythonFreeThreaded" activator="parent" location="bottom">
|
||||
{{ runtimeStatusHint }}
|
||||
</VTooltip>
|
||||
</span>
|
||||
</h1>
|
||||
</RouterLink>
|
||||
</slot>
|
||||
@@ -92,7 +118,10 @@ function handleNavScroll(evt: Event) {
|
||||
inline-size: variables.$layout-vertical-nav-width;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
transition: transform 0.25s ease-in-out, inline-size 0.25s ease-in-out, box-shadow 0.25s ease-in-out;
|
||||
transition:
|
||||
transform 0.25s ease-in-out,
|
||||
inline-size 0.25s ease-in-out,
|
||||
box-shadow 0.25s ease-in-out;
|
||||
visibility: hidden;
|
||||
will-change: transform, inline-size;
|
||||
|
||||
@@ -113,6 +142,20 @@ function handleNavScroll(evt: Event) {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.runtime-version {
|
||||
margin-inline-start: 0.25rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.runtime-version--free-threaded {
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.runtime-version--degraded {
|
||||
color: rgb(var(--v-theme-warning)) !important;
|
||||
}
|
||||
|
||||
.nav-items {
|
||||
block-size: 100%;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
85
src/@layouts/components/__tests__/VerticalNav.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import VerticalNav from '@/@layouts/components/VerticalNav.vue'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
freeThreaded: false,
|
||||
gilEnabled: true,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useGlobalSettingsStore: () => ({
|
||||
get: (key: string) => {
|
||||
if (key === 'PYTHON_FREE_THREADED') return mocks.freeThreaded
|
||||
if (key === 'PYTHON_GIL_ENABLED') return mocks.gilEnabled
|
||||
return undefined
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('vue-i18n')>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('vue-router')>()),
|
||||
useRoute: () => ({ path: '/' }),
|
||||
}))
|
||||
|
||||
vi.mock('vuetify', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('vuetify')>()),
|
||||
useDisplay: () => ({ mdAndDown: { value: false } }),
|
||||
}))
|
||||
|
||||
function renderNavigation() {
|
||||
return shallowMount(VerticalNav, {
|
||||
props: {
|
||||
isOverlayNavActive: false,
|
||||
toggleIsOverlayNavActive: vi.fn(),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
PerfectScrollbar: { template: '<ul><slot /></ul>' },
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
ThemeLogoMark: true,
|
||||
VIcon: { props: ['ariaLabel'], template: '<span>{{ ariaLabel }}</span>' },
|
||||
VTooltip: { template: '<span><slot /></span>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('VerticalNav runtime version', () => {
|
||||
beforeEach(() => {
|
||||
mocks.freeThreaded = false
|
||||
mocks.gilEnabled = true
|
||||
})
|
||||
|
||||
it('shows v3 for the standard runtime', () => {
|
||||
const navigation = renderNavigation()
|
||||
|
||||
expect(navigation.text()).toContain('MOVIEPILOT')
|
||||
expect(navigation.get('.runtime-version').text()).toBe('v3')
|
||||
})
|
||||
|
||||
it('shows v3t for the free-threaded runtime', () => {
|
||||
mocks.freeThreaded = true
|
||||
mocks.gilEnabled = false
|
||||
|
||||
const navigation = renderNavigation()
|
||||
|
||||
expect(navigation.get('.runtime-version--free-threaded').text()).toContain('v3t')
|
||||
expect(navigation.get('.runtime-version--free-threaded').classes()).not.toContain('runtime-version--degraded')
|
||||
expect(navigation.text()).toContain('app.freeThreadedExperimentalHint')
|
||||
})
|
||||
|
||||
it('marks v3t when the runtime has enabled the GIL', () => {
|
||||
mocks.freeThreaded = true
|
||||
|
||||
const navigation = renderNavigation()
|
||||
|
||||
expect(navigation.get('.runtime-version--degraded').text()).toContain('v3t')
|
||||
expect(navigation.text()).toContain('app.freeThreadedGilFallbackWarning')
|
||||
})
|
||||
})
|
||||
8
src/@layouts/types.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
import type { Component, Ref, VNode } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import type { UserPermissionKey } from '@/utils/permission'
|
||||
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
|
||||
import { ContentWidth, FooterType, NavbarType } from './enums'
|
||||
|
||||
export interface UserConfig {
|
||||
@@ -113,14 +113,18 @@ export interface NavLinkProps {
|
||||
rel?: ATagRelAttrValues
|
||||
}
|
||||
|
||||
export type NavMenuIconColor = 'primary' | 'info' | 'success' | 'warning' | 'secondary'
|
||||
|
||||
export interface NavLink extends NavLinkProps, Partial<AclProperties> {
|
||||
title: string
|
||||
full_title?: string
|
||||
icon?: unknown
|
||||
iconColor?: NavMenuIconColor
|
||||
badgeContent?: string
|
||||
badgeClass?: string
|
||||
disable?: boolean
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
}
|
||||
|
||||
export interface NavMenuTabItem {
|
||||
@@ -128,6 +132,8 @@ export interface NavMenuTabItem {
|
||||
icon?: string
|
||||
tab: string
|
||||
description?: string
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
}
|
||||
|
||||
export interface NavMenu extends NavLink {
|
||||
|
||||
970
src/App.vue
214
src/__tests__/ace-config.spec.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
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[] }
|
||||
}
|
||||
}
|
||||
|
||||
interface JinjaMode {
|
||||
getTokenizer: () => {
|
||||
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
|
||||
}
|
||||
}
|
||||
|
||||
const WordListMode = ace.require('ace/mode/word_list').Mode as new (options?: { syntax?: boolean }) => WordListMode
|
||||
const Jinja2Mode = ace.require('ace/mode/jinja2').Mode as new () => JinjaMode
|
||||
const Jinja2JsonMode = ace.require('ace/mode/jinja2_json').Mode as new () => JinjaMode
|
||||
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
|
||||
}
|
||||
|
||||
function findToken(mode: new () => JinjaMode, line: string, value: string) {
|
||||
return new mode()
|
||||
.getTokenizer()
|
||||
.getLineTokens(line, 'start')
|
||||
.tokens.find(token => token.value === value)
|
||||
}
|
||||
|
||||
describe('Jinja context variables', () => {
|
||||
it('keeps source-specific IDs and excludes unified identity from rename formats', () => {
|
||||
expect(findToken(Jinja2Mode, '{{ tmdbid }}', 'tmdbid')?.type).toBe('variable.language.jinja2')
|
||||
expect(findToken(Jinja2Mode, '{{ media_source }}', 'media_source')?.type).toBe('identifier')
|
||||
expect(findToken(Jinja2Mode, '{{ media_id }}', 'media_id')?.type).toBe('identifier')
|
||||
})
|
||||
|
||||
it('keeps unified identity available in notification JSON templates', () => {
|
||||
const template = '{"source": "{{ media_source }}", "id": "{{ media_id }}"}'
|
||||
|
||||
expect(findToken(Jinja2JsonMode, template, 'media_source')?.type).toBe('variable.language.jinja2')
|
||||
expect(findToken(Jinja2JsonMode, template, 'media_id')?.type).toBe('variable.language.jinja2')
|
||||
})
|
||||
})
|
||||
|
||||
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('rejects unified media identity parameters in custom identifiers', () => {
|
||||
const invalidKeys = tokenize('旧名 => {[media_source=douban;media_id=1295644]}')
|
||||
.filter(token => token.type === 'invalid.word-list')
|
||||
.map(token => token.value)
|
||||
|
||||
expect(invalidKeys).toEqual(['media_source', 'media_id'])
|
||||
})
|
||||
|
||||
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|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,278 @@ 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',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const WordListHighlightRules = function (this: any, options?: { syntax?: boolean }) {
|
||||
this.$rules = buildWordListRules(options?.syntax === true)
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(WordListHighlightRules, TextHighlightRules)
|
||||
exports.WordListHighlightRules = WordListHighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/word_list',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextMode = require('./text').Mode
|
||||
const WordListHighlightRules = require('./word_list_highlight_rules').WordListHighlightRules
|
||||
|
||||
const Mode = function (this: any, options?: { syntax?: boolean }) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = WordListHighlightRules
|
||||
this.$highlightRuleConfig = options || {}
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
;(function (this: any) {
|
||||
this.$id = 'ace/mode/word_list'
|
||||
}).call(Mode.prototype)
|
||||
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
||||
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
|
||||
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
|
||||
@@ -555,4 +827,5 @@ ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
|
||||
|
||||
registerJinja2Mode()
|
||||
registerWordListMode()
|
||||
ace.require('ace/ext/language_tools')
|
||||
|
||||
442
src/api/__tests__/client.spec.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosHeaders,
|
||||
CanceledError,
|
||||
type AxiosAdapter,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
import { ApiRequestError, createApiClients, type ApiFeedbackNotifier } from '@/api/client'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const notifier: ApiFeedbackNotifier = {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
}
|
||||
|
||||
/** 构造 Axios 适配器响应,避免客户端单测依赖真实网络。 */
|
||||
function createResponse<T>(
|
||||
config: InternalAxiosRequestConfig,
|
||||
data: T,
|
||||
status = 200,
|
||||
headers: Record<string, string> = {},
|
||||
): AxiosResponse<T> {
|
||||
return {
|
||||
config,
|
||||
data,
|
||||
headers: new AxiosHeaders(headers),
|
||||
status,
|
||||
statusText: status >= 400 ? 'Error' : 'OK',
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建始终成功返回指定数据的 Axios 适配器。 */
|
||||
function resolveWith<T>(data: T, headers: Record<string, string> = {}): AxiosAdapter {
|
||||
return async config => createResponse(config, data, 200, headers)
|
||||
}
|
||||
|
||||
/** 创建带 Axios 响应元数据的 HTTP 失败适配器。 */
|
||||
function rejectWith<T>(data: T, status: number, headers: Record<string, string> = {}): AxiosAdapter {
|
||||
return async config => {
|
||||
const response = createResponse(config, data, status, headers)
|
||||
throw new AxiosError('Request failed', AxiosError.ERR_BAD_RESPONSE, config, undefined, response)
|
||||
}
|
||||
}
|
||||
|
||||
/** 断言并收窄统一 API 错误,避免测试绕过 unknown 错误边界。 */
|
||||
function requireApiRequestError(reason: unknown): ApiRequestError {
|
||||
expect(reason).toBeInstanceOf(ApiRequestError)
|
||||
if (!(reason instanceof ApiRequestError)) throw reason
|
||||
return reason
|
||||
}
|
||||
|
||||
describe('MoviePilot API client', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(notifier.error).mockReset()
|
||||
vi.mocked(notifier.success).mockReset()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['object', { id: 7 }],
|
||||
['array', ['one', 'two']],
|
||||
['scalar', 42],
|
||||
['null', null],
|
||||
])('仅向内部调用方返回成功 envelope 的 data:%s', async (_label, data) => {
|
||||
const envelope: ApiResponse<typeof data> = { success: true, message: '', data }
|
||||
const { api } = createApiClients({ adapter: resolveWith(envelope), notifier })
|
||||
|
||||
await expect(api.get<typeof data>('/resource')).resolves.toEqual(data)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('只在 feedback=all 时展示后端成功消息', async () => {
|
||||
const envelope: ApiResponse<{ saved: boolean }> = {
|
||||
success: true,
|
||||
message: 'Saved',
|
||||
data: { saved: true },
|
||||
}
|
||||
const { api } = createApiClients({ adapter: resolveWith(envelope), notifier })
|
||||
|
||||
await api.post('/setting', {}, { feedback: 'default' })
|
||||
await api.post('/setting', {}, { feedback: 'all' })
|
||||
|
||||
expect(notifier.success).toHaveBeenCalledOnce()
|
||||
expect(notifier.success).toHaveBeenCalledWith('Saved')
|
||||
})
|
||||
|
||||
it('将 success=false 转为保留完整响应元数据的 ApiRequestError', async () => {
|
||||
const envelope: ApiResponse<{ reason: string }> = {
|
||||
success: false,
|
||||
message: 'Cannot save',
|
||||
data: { reason: 'locked' },
|
||||
}
|
||||
const { api } = createApiClients({
|
||||
adapter: resolveWith(envelope, { 'x-request-id': 'request-1' }),
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.post('/setting', {}).catch(reason => reason))
|
||||
|
||||
expect(axios.isAxiosError(error)).toBe(true)
|
||||
expect(error).toMatchObject({
|
||||
message: 'Cannot save',
|
||||
payload: envelope,
|
||||
status: 200,
|
||||
})
|
||||
expect(error.response?.data).toEqual(envelope)
|
||||
expect(AxiosHeaders.from(error.headers as AxiosHeaders | undefined).get('x-request-id')).toBe('request-1')
|
||||
expect(notifier.error).toHaveBeenCalledWith('Cannot save')
|
||||
})
|
||||
|
||||
it('将 HTTP 错误统一转换为 ApiRequestError 并保留 payload、status 和 headers', async () => {
|
||||
const envelope: ApiResponse<{ mfa_methods: string[] }> = {
|
||||
success: false,
|
||||
message: 'Additional verification required',
|
||||
data: { mfa_methods: ['otp'] },
|
||||
}
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith(envelope, 403, { 'x-mfa-required': 'true' }),
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.post('/login/access-token', {}).catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('Additional verification required')
|
||||
expect(error.status).toBe(403)
|
||||
expect(error.payload).toEqual(envelope)
|
||||
expect(error.response?.data).toEqual(envelope)
|
||||
expect(AxiosHeaders.from(error.response?.headers as AxiosHeaders | undefined).get('x-mfa-required')).toBe('true')
|
||||
})
|
||||
|
||||
it('silent 关闭失败 Toast,但不丢失错误元数据', async () => {
|
||||
const envelope: ApiResponse<null> = { success: false, message: 'Invalid credentials', data: null }
|
||||
const { api } = createApiClients({ adapter: rejectWith(envelope, 401), notifier })
|
||||
|
||||
const error = requireApiRequestError(
|
||||
await api.post('/login/access-token', {}, { feedback: 'silent' }).catch(reason => reason),
|
||||
)
|
||||
|
||||
expect(error.status).toBe(401)
|
||||
expect(error.payload).toEqual(envelope)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('插件客户端保留完整 envelope,同时沿用统一失败反馈', async () => {
|
||||
const envelope: ApiResponse<{ plugin: string }> = {
|
||||
success: false,
|
||||
message: 'Plugin rejected request',
|
||||
data: { plugin: 'demo' },
|
||||
}
|
||||
const { pluginApi } = createApiClients({ adapter: resolveWith(envelope), notifier })
|
||||
|
||||
await expect(pluginApi.get<unknown, ApiResponse<{ plugin: string }>>('/plugin/demo')).resolves.toEqual(envelope)
|
||||
expect(notifier.error).toHaveBeenCalledWith('Plugin rejected request')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['object', { plugin: 'demo', count: 2 }],
|
||||
['array', [{ id: 1 }, { id: 2 }]],
|
||||
['scalar', 'ready'],
|
||||
['null', null],
|
||||
])('插件客户端原样交付非 envelope payload:%s', async (_label, payload) => {
|
||||
const { pluginApi } = createApiClients({ adapter: resolveWith(payload), notifier })
|
||||
|
||||
await expect(pluginApi.get('/plugin/demo')).resolves.toEqual(payload)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
expect(notifier.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Blob 成功响应绕过 envelope 解包', async () => {
|
||||
const blob = new Blob(['moviepilot'], { type: 'application/octet-stream' })
|
||||
const { api } = createApiClients({ adapter: resolveWith(blob), notifier })
|
||||
|
||||
await expect(api.get<Blob>('/download', { responseType: 'blob' })).resolves.toBe(blob)
|
||||
})
|
||||
|
||||
it('解析 Blob 中的 JSON HTTP 错误后再构造 ApiRequestError', async () => {
|
||||
const envelope: ApiResponse<{ file: string }> = {
|
||||
success: false,
|
||||
message: 'File is unavailable',
|
||||
data: { file: 'movie.mkv' },
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(envelope)], { type: 'application/json' })
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith(blob, 404, { 'content-type': 'application/json' }),
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.get('/download', { responseType: 'blob' }).catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('File is unavailable')
|
||||
expect(error.payload).toEqual(envelope)
|
||||
expect(error.response?.data).toEqual(envelope)
|
||||
})
|
||||
|
||||
it('相同技术错误在去重窗口内只提示一次,避免并发失败刷屏', async () => {
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith({ message: 'Server exploded' }, 500),
|
||||
notifier,
|
||||
})
|
||||
|
||||
await api.get('/a').catch(reason => reason)
|
||||
await api.get('/b').catch(reason => reason)
|
||||
|
||||
expect(notifier.error).toHaveBeenCalledTimes(1)
|
||||
expect(notifier.error).toHaveBeenCalledWith('Server exploded')
|
||||
})
|
||||
|
||||
it('去重窗口过期后相同技术错误可以再次提示', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith({ message: 'Server exploded' }, 500),
|
||||
notifier,
|
||||
})
|
||||
|
||||
await api.get('/a').catch(reason => reason)
|
||||
expect(notifier.error).toHaveBeenCalledTimes(1)
|
||||
|
||||
// 窗口(15 秒)过后,新一轮相同错误应能再次提示。
|
||||
vi.advanceTimersByTime(15000)
|
||||
await api.get('/b').catch(reason => reason)
|
||||
expect(notifier.error).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('成功响应后清空去重缓存,让后续技术错误可以再次提示', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith({ message: 'Server exploded' }, 500),
|
||||
notifier,
|
||||
})
|
||||
|
||||
await api.get('/a').catch(reason => reason)
|
||||
expect(notifier.error).toHaveBeenCalledTimes(1)
|
||||
|
||||
// 服务恢复后缓存应被清空,即使仍在窗口内,相同错误也可再次提示。
|
||||
api.defaults.adapter = resolveWith<ApiResponse<{ ok: boolean }>>({
|
||||
success: true,
|
||||
message: '',
|
||||
data: { ok: true },
|
||||
})
|
||||
await api.get('/ping')
|
||||
api.defaults.adapter = rejectWith({ message: 'Server exploded' }, 500)
|
||||
await api.get('/c').catch(reason => reason)
|
||||
|
||||
expect(notifier.error).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('连续无效 envelope 响应只提示一次,避免坏缓存命中时刷屏', async () => {
|
||||
// 无效 envelope 是 HTTP 200 但响应体不是标准三键结构,模拟 SW 缓存回退出的坏响应。
|
||||
const { api } = createApiClients({
|
||||
adapter: resolveWith('<html>legacy app shell</html>'),
|
||||
notifier,
|
||||
})
|
||||
|
||||
await api.get('/a').catch(reason => reason)
|
||||
await api.get('/b').catch(reason => reason)
|
||||
await api.get('/c').catch(reason => reason)
|
||||
|
||||
expect(notifier.error).toHaveBeenCalledTimes(1)
|
||||
expect(notifier.error).toHaveBeenCalledWith('Invalid API response envelope')
|
||||
})
|
||||
|
||||
it('无效 envelope 响应不清空去重缓存,窗口内其他技术错误保持收敛', async () => {
|
||||
const adapter: AxiosAdapter = async config => {
|
||||
if (config.url === '/bad') return createResponse(config, '<html></html>', 200)
|
||||
const response = createResponse(config, { message: 'Server exploded' }, 500)
|
||||
throw new AxiosError('Request failed', AxiosError.ERR_BAD_RESPONSE, config, undefined, response)
|
||||
}
|
||||
const { api } = createApiClients({ adapter, notifier })
|
||||
|
||||
await api.get('/a').catch(reason => reason)
|
||||
await api.get('/bad').catch(reason => reason)
|
||||
await api.get('/b').catch(reason => reason)
|
||||
|
||||
// 500 技术错误在窗口内只提示一次;无效 envelope 属于不同消息单独提示一次,
|
||||
// 且它不得清空去重缓存,否则第 3 个相同 500 错误会被再次弹出。
|
||||
expect(notifier.error).toHaveBeenCalledTimes(2)
|
||||
expect(notifier.error).toHaveBeenCalledWith('Server exploded')
|
||||
expect(notifier.error).toHaveBeenCalledWith('Invalid API response envelope')
|
||||
})
|
||||
|
||||
it('不同消息的技术错误互不影响,分别提示', async () => {
|
||||
const adapter: AxiosAdapter = async config => {
|
||||
const message = config.url === '/a' ? 'First failure' : 'Second failure'
|
||||
const response = createResponse(config, { message }, 500)
|
||||
throw new AxiosError('Request failed', AxiosError.ERR_BAD_RESPONSE, config, undefined, response)
|
||||
}
|
||||
const { api } = createApiClients({ adapter, notifier })
|
||||
|
||||
await api.get('/a').catch(reason => reason)
|
||||
await api.get('/b').catch(reason => reason)
|
||||
|
||||
expect(notifier.error).toHaveBeenCalledTimes(2)
|
||||
expect(notifier.error).toHaveBeenCalledWith('First failure')
|
||||
expect(notifier.error).toHaveBeenCalledWith('Second failure')
|
||||
})
|
||||
|
||||
it('业务失败不参与技术错误去重,始终逐条提示', async () => {
|
||||
const envelope: ApiResponse<null> = { success: false, message: 'Cannot save', data: null }
|
||||
const { api } = createApiClients({ adapter: resolveWith(envelope), notifier })
|
||||
|
||||
await api.post('/setting', {}).catch(reason => reason)
|
||||
await api.post('/setting', {}).catch(reason => reason)
|
||||
|
||||
expect(notifier.error).toHaveBeenCalledTimes(2)
|
||||
expect(notifier.error).toHaveBeenCalledWith('Cannot save')
|
||||
})
|
||||
|
||||
it('取消请求保持原始 CanceledError,且不提示或触发离线探测', async () => {
|
||||
const reportConnectionFailure = vi.fn()
|
||||
const adapter: AxiosAdapter = async () => {
|
||||
throw new CanceledError('Request cancelled')
|
||||
}
|
||||
const { api } = createApiClients({ adapter, hooks: { reportConnectionFailure }, notifier })
|
||||
|
||||
const error = await api.get('/slow').catch(reason => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(CanceledError)
|
||||
expect(error).not.toBeInstanceOf(ApiRequestError)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
expect(reportConnectionFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([502, 503, 504])('网关错误 %d 上报连接失败且不弹请求层 Toast', async status => {
|
||||
const reportConnectionFailure = vi.fn()
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith({ message: 'Gateway unavailable' }, status),
|
||||
hooks: { reportConnectionFailure },
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.get('/resource').catch(reason => reason))
|
||||
|
||||
expect(error.status).toBe(status)
|
||||
expect(reportConnectionFailure).toHaveBeenCalledWith('server-unreachable')
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('网络错误仅上报离线状态,不弹请求层 Toast', async () => {
|
||||
const reportConnectionFailure = vi.fn()
|
||||
const adapter: AxiosAdapter = async () => {
|
||||
throw new AxiosError('Network Error', AxiosError.ERR_NETWORK)
|
||||
}
|
||||
const { api } = createApiClients({ adapter, hooks: { reportConnectionFailure }, notifier })
|
||||
|
||||
await api.get('/resource').catch(reason => reason)
|
||||
|
||||
expect(reportConnectionFailure).toHaveBeenCalledWith('network-error')
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('网关错误不算服务在线证据,仅成功响应恢复在线状态', async () => {
|
||||
const markServerOnline = vi.fn()
|
||||
const reportConnectionFailure = vi.fn()
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith({ message: 'Bad Gateway' }, 502),
|
||||
hooks: { markServerOnline, reportConnectionFailure },
|
||||
notifier,
|
||||
})
|
||||
|
||||
await api.get('/resource').catch(reason => reason)
|
||||
|
||||
expect(markServerOnline).not.toHaveBeenCalled()
|
||||
expect(reportConnectionFailure).toHaveBeenCalledWith('server-unreachable')
|
||||
})
|
||||
|
||||
it('401 被 onUnauthorized 接管时不逐条弹请求层 Toast', async () => {
|
||||
const onUnauthorized = vi.fn(() => true)
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith({ detail: 'Not authenticated' }, 401),
|
||||
hooks: { onUnauthorized },
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.get('/resource').catch(reason => reason))
|
||||
|
||||
expect(error.status).toBe(401)
|
||||
expect(onUnauthorized).toHaveBeenCalledWith(error)
|
||||
expect(notifier.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('401 未被 onUnauthorized 接管时保留逐条错误提示', async () => {
|
||||
const onUnauthorized = vi.fn(() => false)
|
||||
const { api } = createApiClients({
|
||||
adapter: rejectWith({ detail: 'Not authenticated' }, 401),
|
||||
hooks: { onUnauthorized },
|
||||
notifier,
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.get('/resource').catch(reason => reason))
|
||||
|
||||
expect(error.status).toBe(401)
|
||||
expect(notifier.error).toHaveBeenCalledWith('Not authenticated')
|
||||
})
|
||||
|
||||
it('拒绝缺少标准字段的普通 JSON 响应', async () => {
|
||||
const { api } = createApiClients({ adapter: resolveWith({ value: 1 }), notifier })
|
||||
|
||||
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('Invalid API response envelope')
|
||||
})
|
||||
|
||||
it('拒绝带旧顶层 message_i18n 的 envelope', async () => {
|
||||
const legacyEnvelope = {
|
||||
success: false,
|
||||
message: 'Failed',
|
||||
message_i18n: '旧版错误',
|
||||
data: null,
|
||||
}
|
||||
const { api } = createApiClients({ adapter: resolveWith(legacyEnvelope), notifier })
|
||||
|
||||
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe('Invalid API response envelope')
|
||||
expect(error.payload).toEqual(legacyEnvelope)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zh-CN', '服务器返回了无效响应'],
|
||||
['en-US', 'The server returned an invalid response'],
|
||||
])('使用应用注入的 %s fallback 文案提示协议异常', async (_locale, translatedMessage) => {
|
||||
const { api } = createApiClients({
|
||||
adapter: resolveWith({ value: 1 }),
|
||||
notifier,
|
||||
resolveFallbackMessage: key => (key === 'invalid-envelope' ? translatedMessage : key),
|
||||
})
|
||||
|
||||
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
|
||||
|
||||
expect(error.message).toBe(translatedMessage)
|
||||
expect(notifier.error).toHaveBeenCalledWith(translatedMessage)
|
||||
})
|
||||
})
|
||||
131
src/api/__tests__/index.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { AxiosError, AxiosHeaders, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
i18nT: vi.fn((key: string) => `translated:${key}`),
|
||||
authState: { token: null as string | null },
|
||||
logout: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
default: { push: mocks.routerPush },
|
||||
}))
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({
|
||||
get token() {
|
||||
return mocks.authState.token
|
||||
},
|
||||
// 与真实 store 一致:登出时清空 token,后续在途请求按无 token 处理。
|
||||
logout: mocks.logout.mockImplementation(() => {
|
||||
mocks.authState.token = null
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/utils/requestOptimizer', () => ({
|
||||
initializeRequestOptimizer: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/composables/useOfflineStatus', () => ({
|
||||
useGlobalOfflineStatus: () => ({
|
||||
markServerOnline: vi.fn(),
|
||||
reportNetworkError: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/plugins/i18n', () => ({
|
||||
default: { global: { t: mocks.i18nT } },
|
||||
getCurrentLocale: () => 'zh-CN',
|
||||
}))
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: vi.fn() }),
|
||||
}))
|
||||
|
||||
/** 安装始终返回指定 HTTP 失败的适配器。 */
|
||||
async function installFailingAdapter(status: number, data: unknown) {
|
||||
const module = await import('@/api')
|
||||
module.default.defaults.adapter = async config => {
|
||||
const response: AxiosResponse = {
|
||||
config: config as InternalAxiosRequestConfig,
|
||||
data,
|
||||
headers: new AxiosHeaders(),
|
||||
status,
|
||||
statusText: 'Error',
|
||||
}
|
||||
throw new AxiosError(
|
||||
'Request failed',
|
||||
AxiosError.ERR_BAD_RESPONSE,
|
||||
config as InternalAxiosRequestConfig,
|
||||
undefined,
|
||||
response,
|
||||
)
|
||||
}
|
||||
return module
|
||||
}
|
||||
|
||||
describe('API application wiring', () => {
|
||||
beforeEach(() => {
|
||||
mocks.authState.token = null
|
||||
mocks.logout.mockClear()
|
||||
mocks.routerPush.mockClear()
|
||||
mocks.toastError.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('向 window 暴露插件最终 payload 客户端,而内部默认导出数据客户端', async () => {
|
||||
const module = await import('@/api')
|
||||
|
||||
expect(window.MoviePilotAPI).toBe(module.pluginApi)
|
||||
expect(module.default).not.toBe(module.pluginApi)
|
||||
})
|
||||
|
||||
it('通过 i18n 实例解析请求层 fallback 文案', async () => {
|
||||
const module = await import('@/api')
|
||||
module.default.defaults.adapter = async config => ({
|
||||
config: config as InternalAxiosRequestConfig,
|
||||
data: { legacy: true },
|
||||
headers: new AxiosHeaders(),
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
})
|
||||
|
||||
const error = await module.default.get('/legacy').catch(reason => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(module.ApiRequestError)
|
||||
expect((error as Error).message).toBe('translated:common.invalidApiResponse')
|
||||
expect(mocks.i18nT).toHaveBeenCalledWith('common.invalidApiResponse')
|
||||
})
|
||||
|
||||
it('已登录时并发 401 只统一登出并提示一次本地化文案', async () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.authState.token = 'expired-token'
|
||||
const module = await installFailingAdapter(401, { detail: 'Not authenticated' })
|
||||
|
||||
await Promise.allSettled([module.default.get('/dashboard'), module.default.get('/subscribe')])
|
||||
|
||||
expect(mocks.logout).toHaveBeenCalledOnce()
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith('/login')
|
||||
expect(mocks.toastError).toHaveBeenCalledOnce()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('translated:common.sessionExpired')
|
||||
})
|
||||
|
||||
it('登出后短暂窗口内的在途 401 保持静默,窗口过后恢复逐条提示', async () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.authState.token = 'expired-token'
|
||||
const module = await installFailingAdapter(401, { detail: 'Not authenticated' })
|
||||
|
||||
await module.default.get('/dashboard').catch(() => {})
|
||||
expect(mocks.toastError).toHaveBeenCalledOnce()
|
||||
|
||||
// 窗口内的连带 401 不再弹出英文提示。
|
||||
await module.default.get('/subscribe').catch(() => {})
|
||||
expect(mocks.toastError).toHaveBeenCalledOnce()
|
||||
|
||||
vi.setSystemTime(Date.now() + 6000)
|
||||
await module.default.get('/resource').catch(() => {})
|
||||
expect(mocks.toastError).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.toastError).toHaveBeenLastCalledWith('Not authenticated')
|
||||
})
|
||||
})
|
||||
55
src/api/__tests__/manage.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { manageLlmProvider, manageNotificationChannel, manageStorage } from '@/api/manage'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiPost: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: createDataApiMock({
|
||||
post: (...args: unknown[]) => mocks.apiPost(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('manage API adapters', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.apiPost.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('透传通知渠道的目标、动作、参数和请求配置', async () => {
|
||||
const config = { timeout: 1200 }
|
||||
|
||||
await expect(manageNotificationChannel('telegram', 'refresh', { force: true }, config)).resolves.toEqual({
|
||||
ok: true,
|
||||
})
|
||||
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith(
|
||||
'notification/manage',
|
||||
{ target: 'telegram', action: 'refresh', params: { force: true } },
|
||||
config,
|
||||
)
|
||||
})
|
||||
|
||||
it('为网盘存储动作补充空参数对象', async () => {
|
||||
await manageStorage('alist', 'disconnect')
|
||||
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith(
|
||||
'storage/manage',
|
||||
{ target: 'alist', action: 'disconnect', params: {} },
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('透传 LLM Provider 管理动作并返回已解包业务数据', async () => {
|
||||
mocks.apiPost.mockResolvedValueOnce([{ id: 'openai' }])
|
||||
|
||||
await expect(manageLlmProvider('', 'list_providers')).resolves.toEqual([{ id: 'openai' }])
|
||||
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith(
|
||||
'llm/manage',
|
||||
{ target: '', action: 'list_providers', params: {} },
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
})
|
||||
57
src/api/__tests__/pluginInstance.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createScopedPluginApi } from '@/api/pluginInstance'
|
||||
import type { PluginApiClient } from '@/api/client'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/** 创建可调用且带 Axios 风格方法的最小插件客户端。 */
|
||||
function createClient() {
|
||||
const client = Object.assign(vi.fn(), {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
request: vi.fn(),
|
||||
})
|
||||
return client as unknown as PluginApiClient
|
||||
}
|
||||
|
||||
describe('createScopedPluginApi', () => {
|
||||
it('rewrites only the source plugin dynamic API namespace', () => {
|
||||
const client = createClient()
|
||||
const scoped = createScopedPluginApi(client, 'DemoPluginwork', 'DemoPlugin')
|
||||
|
||||
void scoped.get('plugin/DemoPlugin/items')
|
||||
void scoped.post('/api/v1/plugin/demoplugin/action?sync=1', { value: 1 })
|
||||
void scoped.get('system/config')
|
||||
|
||||
expect(client.get).toHaveBeenNthCalledWith(1, 'plugin/DemoPluginwork/items')
|
||||
expect(client.post).toHaveBeenCalledWith(
|
||||
'/api/v1/plugin/DemoPluginwork/action?sync=1',
|
||||
{ value: 1 },
|
||||
)
|
||||
expect(client.get).toHaveBeenNthCalledWith(2, 'system/config')
|
||||
})
|
||||
|
||||
it('supports callable and request-config forms without mutating input', () => {
|
||||
const client = createClient()
|
||||
const scoped = createScopedPluginApi(client, 'DemoPluginhome', 'DemoPlugin')
|
||||
const config = { url: '/plugin/DemoPlugin/status', method: 'GET' }
|
||||
|
||||
void scoped(config)
|
||||
void scoped.request(config)
|
||||
|
||||
expect(client).toHaveBeenCalledWith({
|
||||
url: '/plugin/DemoPluginhome/status',
|
||||
method: 'GET',
|
||||
})
|
||||
expect(client.request).toHaveBeenCalledWith({
|
||||
url: '/plugin/DemoPluginhome/status',
|
||||
method: 'GET',
|
||||
})
|
||||
expect(config.url).toBe('/plugin/DemoPlugin/status')
|
||||
})
|
||||
|
||||
it('returns the original client for ordinary plugins', () => {
|
||||
const client = createClient()
|
||||
|
||||
expect(createScopedPluginApi(client, 'DemoPlugin')).toBe(client)
|
||||
expect(createScopedPluginApi(client, 'DemoPlugin', 'DemoPlugin')).toBe(client)
|
||||
})
|
||||
})
|
||||
417
src/api/client.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import axios, {
|
||||
AxiosError,
|
||||
type AxiosInstance,
|
||||
type AxiosRequestConfig,
|
||||
type AxiosResponse,
|
||||
type CreateAxiosDefaults,
|
||||
} from 'axios'
|
||||
import type { ApiResponse } from './types'
|
||||
|
||||
/** 控制请求层是否展示成功或失败反馈。 */
|
||||
export type ApiFeedbackMode = 'silent' | 'default' | 'all'
|
||||
|
||||
/** 请求层无法从后端取得 message 时使用的本地化文案类别。 */
|
||||
export type ApiFallbackMessageKey = 'invalid-envelope' | 'network-error' | 'request-failed' | 'timeout'
|
||||
|
||||
/** 将请求层 fallback 类别解析为当前语言文案,避免基础客户端依赖 Vue i18n。 */
|
||||
export type ApiFallbackMessageResolver = (key: ApiFallbackMessageKey) => string
|
||||
|
||||
declare module 'axios' {
|
||||
interface AxiosRequestConfig {
|
||||
feedback?: ApiFeedbackMode
|
||||
skipNavigationCancellation?: boolean
|
||||
skipConnectionTracking?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** 普通 API 客户端在响应拦截器解包后返回业务数据,而不是 AxiosResponse。 */
|
||||
export interface DataApiClient extends Omit<
|
||||
AxiosInstance,
|
||||
'delete' | 'get' | 'head' | 'options' | 'patch' | 'patchForm' | 'post' | 'postForm' | 'put' | 'putForm' | 'request'
|
||||
> {
|
||||
<T = unknown, R = T, D = AxiosRequestConfig['data']>(config: AxiosRequestConfig<D>): Promise<R>
|
||||
<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
request<T = unknown, R = T, D = AxiosRequestConfig['data']>(config: AxiosRequestConfig<D>): Promise<R>
|
||||
get<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
delete<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
head<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
options<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
|
||||
post<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
put<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
patch<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
postForm<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
putForm<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
patchForm<T = unknown, R = T, D = AxiosRequestConfig['data']>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<R>
|
||||
}
|
||||
|
||||
/** 插件与联邦组件接收 endpoint 最终 payload,方法签名不暴露 AxiosResponse 外壳。 */
|
||||
export type PluginApiClient = DataApiClient
|
||||
|
||||
/** 请求层使用的反馈出口,由应用入口接到全局 Toast。 */
|
||||
export interface ApiFeedbackNotifier {
|
||||
error(message: string): void
|
||||
success(message: string): void
|
||||
}
|
||||
|
||||
/** 请求生命周期钩子用于隔离认证和离线状态等应用级副作用。 */
|
||||
export interface ApiClientHooks {
|
||||
markServerOnline?(): void
|
||||
onForbidden?(error: ApiRequestError): void
|
||||
/** 返回 true 表示认证失效已由应用层统一接管,请求层不再弹出逐条错误提示。 */
|
||||
onUnauthorized?(error: ApiRequestError): boolean | void
|
||||
reportConnectionFailure?(reason: 'network-error' | 'timeout' | 'server-unreachable'): void
|
||||
}
|
||||
|
||||
/** 创建内部数据客户端与插件最终 payload 客户端时所需的配置。 */
|
||||
export interface CreateApiClientsOptions extends CreateAxiosDefaults {
|
||||
hooks?: ApiClientHooks
|
||||
notifier?: ApiFeedbackNotifier
|
||||
resolveFallbackMessage?: ApiFallbackMessageResolver
|
||||
setupInstance?(instance: AxiosInstance): void
|
||||
}
|
||||
|
||||
const defaultFallbackMessages: Record<ApiFallbackMessageKey, string> = {
|
||||
'invalid-envelope': 'Invalid API response envelope',
|
||||
'network-error': 'Network connection failed',
|
||||
'request-failed': 'API request failed',
|
||||
timeout: 'Request timeout',
|
||||
}
|
||||
|
||||
/** 技术类失败提示的去重窗口:后端异常时并发请求会得到相同错误,窗口内只提示一次避免刷屏。 */
|
||||
const TECHNICAL_ERROR_DEDUP_MS = 15000
|
||||
|
||||
interface ApiRequestErrorOptions<T> {
|
||||
businessFailure?: boolean
|
||||
cause?: unknown
|
||||
code?: string
|
||||
config?: AxiosRequestConfig
|
||||
payload?: T
|
||||
request?: unknown
|
||||
response?: AxiosResponse<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一表示业务失败、HTTP 失败和请求协议错误。
|
||||
*
|
||||
* 该类型继承 AxiosError,确保认证流程和既有调用方仍可读取 response、status、headers 与 config。
|
||||
*/
|
||||
export class ApiRequestError<T = unknown> extends AxiosError<T> {
|
||||
readonly businessFailure: boolean
|
||||
readonly headers?: AxiosResponse<T>['headers']
|
||||
readonly payload?: T
|
||||
|
||||
/** 保留原始 Axios 元数据,并额外暴露已解析的响应载荷。 */
|
||||
constructor(message: string, options: ApiRequestErrorOptions<T> = {}) {
|
||||
super(
|
||||
message,
|
||||
options.code,
|
||||
options.config as AxiosResponse<T>['config'] | undefined,
|
||||
options.request,
|
||||
options.response,
|
||||
)
|
||||
this.name = 'ApiRequestError'
|
||||
this.businessFailure = options.businessFailure ?? false
|
||||
this.headers = options.response?.headers
|
||||
this.payload = options.payload
|
||||
this.status = options.response?.status
|
||||
if (options.cause instanceof Error) this.cause = options.cause
|
||||
Object.setPrototypeOf(this, new.target.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅返回后端在成功 HTTP 响应中声明的业务失败消息。 */
|
||||
export function getApiBusinessErrorMessage(error: unknown): string | undefined {
|
||||
if (!(error instanceof ApiRequestError) || !error.businessFailure) return undefined
|
||||
const payload = error.payload
|
||||
if (!payload || typeof payload !== 'object') return undefined
|
||||
const message = (payload as { message?: unknown }).message
|
||||
return typeof message === 'string' && message.trim() ? message : undefined
|
||||
}
|
||||
|
||||
/** 判断错误是否来自 HTTP 200 响应中的业务失败。 */
|
||||
export function isApiBusinessFailure(error: unknown): error is ApiRequestError {
|
||||
return error instanceof ApiRequestError && error.businessFailure
|
||||
}
|
||||
|
||||
/** 判断未知值是否为 MoviePilot 标准响应 envelope。 */
|
||||
export function isApiResponse<T = unknown>(value: unknown): value is ApiResponse<T> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const record = value as Record<string, unknown>
|
||||
const keys = Object.keys(record)
|
||||
if (keys.length !== 3 || !keys.every(key => key === 'success' || key === 'message' || key === 'data')) return false
|
||||
return typeof record.success === 'boolean' && typeof record.message === 'string' && 'data' in record
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Axios 连接错误归类为全局服务探测可识别的原因。
|
||||
*
|
||||
* 网关不可用状态码(502/503/504)同样视为服务不可达:后端重启或崩溃时网关
|
||||
* 会返回这类响应,若只按“无响应”判断会漏掉重启场景的离线检测。
|
||||
*/
|
||||
export function resolveConnectionFailureReason(
|
||||
error: AxiosError,
|
||||
): 'network-error' | 'timeout' | 'server-unreachable' | null {
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout'
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') {
|
||||
return 'network-error'
|
||||
}
|
||||
const status = error.response?.status
|
||||
if (status === 502 || status === 503 || status === 504) return 'server-unreachable'
|
||||
return null
|
||||
}
|
||||
|
||||
/** 创建普通数据客户端和保持插件自由响应 ABI 的最终 payload 客户端。 */
|
||||
export function createApiClients(options: CreateApiClientsOptions = {}): {
|
||||
api: DataApiClient
|
||||
pluginApi: PluginApiClient
|
||||
} {
|
||||
const { hooks, notifier, resolveFallbackMessage, setupInstance, ...axiosConfig } = options
|
||||
const api = axios.create(axiosConfig)
|
||||
const pluginApi = axios.create(axiosConfig)
|
||||
|
||||
// 技术类失败提示的去重缓存:同一消息在窗口内只提示一次,避免后端异常时大量并发请求刷屏。
|
||||
// 两个客户端共用同一份缓存,保证重复提示被整体收敛。
|
||||
const technicalErrorDedup = new Map<string, number>()
|
||||
|
||||
// 优化器等底层拦截器必须先安装,确保它们在业务数据解包前仍能看到完整 AxiosResponse。
|
||||
setupInstance?.(api)
|
||||
setupInstance?.(pluginApi)
|
||||
installResponseInterceptors(api, 'data', hooks, notifier, resolveFallbackMessage, technicalErrorDedup)
|
||||
installResponseInterceptors(pluginApi, 'envelope', hooks, notifier, resolveFallbackMessage, technicalErrorDedup)
|
||||
|
||||
return {
|
||||
api: api as DataApiClient,
|
||||
pluginApi: pluginApi as PluginApiClient,
|
||||
}
|
||||
}
|
||||
|
||||
/** 为客户端安装统一的可达性、反馈、解包和错误标准化处理。 */
|
||||
function installResponseInterceptors(
|
||||
instance: AxiosInstance,
|
||||
responseMode: 'data' | 'envelope',
|
||||
hooks?: ApiClientHooks,
|
||||
notifier?: ApiFeedbackNotifier,
|
||||
resolveFallbackMessage?: ApiFallbackMessageResolver,
|
||||
technicalErrorDedup?: Map<string, number>,
|
||||
) {
|
||||
instance.interceptors.response.use(
|
||||
response => {
|
||||
hooks?.markServerOnline?.()
|
||||
|
||||
if (isBinarySuccess(response)) return response.data
|
||||
|
||||
const payload: unknown = response.data
|
||||
if (!isApiResponse(payload)) {
|
||||
// 联邦插件自行声明 API 响应;非宿主 envelope 必须原样交给插件调用方。
|
||||
if (responseMode === 'envelope') return payload
|
||||
const error = new ApiRequestError(resolveFallback('invalid-envelope', resolveFallbackMessage), {
|
||||
code: AxiosError.ERR_BAD_RESPONSE,
|
||||
config: response.config,
|
||||
payload,
|
||||
request: response.request,
|
||||
response,
|
||||
})
|
||||
// 无效 envelope 本身就是技术错误,必须留在去重窗口内收敛;
|
||||
// 此处绝不能清空去重缓存,否则每个坏响应都会重置窗口并逐条弹 Toast 刷屏。
|
||||
notifyFailure(response.config.feedback, notifier, error.message, technicalErrorDedup)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 只有通过 envelope 校验的真实响应才算“服务恢复在线”的证据,
|
||||
// 此时清空技术错误去重缓存,让新一轮故障可以再次提示,避免永久静默。
|
||||
technicalErrorDedup?.clear()
|
||||
|
||||
if (!payload.success) {
|
||||
notifyFailure(response.config.feedback, notifier, payload.message)
|
||||
if (responseMode === 'envelope') return payload
|
||||
return Promise.reject(
|
||||
new ApiRequestError(payload.message || resolveFallback('request-failed', resolveFallbackMessage), {
|
||||
businessFailure: true,
|
||||
code: AxiosError.ERR_BAD_RESPONSE,
|
||||
config: response.config,
|
||||
payload,
|
||||
request: response.request,
|
||||
response,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
notifySuccess(response.config.feedback, notifier, payload.message)
|
||||
return responseMode === 'envelope' ? payload : payload.data
|
||||
},
|
||||
async (reason: unknown) => {
|
||||
if (isCancellation(reason)) return Promise.reject(reason)
|
||||
// fulfilled 分支生成的协议错误已经携带完整上下文,不应再次按响应 message 包装。
|
||||
if (reason instanceof ApiRequestError) return Promise.reject(reason)
|
||||
|
||||
const original = reason instanceof AxiosError ? reason : undefined
|
||||
const response = original?.response ? await normalizeErrorResponse(original.response) : undefined
|
||||
// 只有成功响应才算服务在线证据;网关错误响应说明后端当前不可达,不能恢复在线状态。
|
||||
if (response && response.status >= 200 && response.status < 300) hooks?.markServerOnline?.()
|
||||
|
||||
const payload = response?.data
|
||||
const error = new ApiRequestError(resolveErrorMessage(payload, original, resolveFallbackMessage), {
|
||||
cause: reason,
|
||||
code: original?.code,
|
||||
config: original?.config,
|
||||
payload,
|
||||
request: original?.request,
|
||||
response,
|
||||
})
|
||||
|
||||
const requestConfig = original?.config
|
||||
const failureReason = original ? resolveConnectionFailureReason(original) : null
|
||||
if (!requestConfig?.skipConnectionTracking && failureReason) {
|
||||
hooks?.reportConnectionFailure?.(failureReason)
|
||||
}
|
||||
if (response?.status === 403) hooks?.onForbidden?.(error)
|
||||
|
||||
// 认证失效(如后端重启导致 token 作废)由应用层统一登出跳转,
|
||||
// 避免并发请求逐条弹出 "Not authenticated" 等英文提示刷屏。
|
||||
if (response?.status === 401 && hooks?.onUnauthorized?.(error) === true) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 连接类失败(无响应、超时、网关不可用)统一交给离线状态系统按阈值提示,
|
||||
// 不在请求层逐个弹出,避免后端重启时刷屏。
|
||||
if (!failureReason) notifyFailure(requestConfig?.feedback, notifier, error.message, technicalErrorDedup)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 二进制成功响应不参与 JSON envelope 校验和解包。 */
|
||||
function isBinarySuccess(response: AxiosResponse): boolean {
|
||||
if (typeof Blob !== 'undefined' && response.data instanceof Blob) return true
|
||||
return response.config.responseType === 'arraybuffer' || response.config.responseType === 'stream'
|
||||
}
|
||||
|
||||
/** 将 Blob 形式的 JSON 错误响应解析为结构化载荷,便于调用方读取错误元数据。 */
|
||||
async function normalizeErrorResponse(response: AxiosResponse): Promise<AxiosResponse> {
|
||||
if (typeof Blob === 'undefined' || !(response.data instanceof Blob) || !isJsonBlob(response)) return response
|
||||
|
||||
try {
|
||||
const data = JSON.parse(await readBlobText(response.data)) as unknown
|
||||
return { ...response, data }
|
||||
} catch {
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
/** 优先使用现代 Blob API,并为测试环境和旧浏览器保留 FileReader 回退。 */
|
||||
function readBlobText(blob: Blob): Promise<string> {
|
||||
if (typeof blob.text === 'function') return blob.text()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read response Blob'))
|
||||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
|
||||
reader.readAsText(blob)
|
||||
})
|
||||
}
|
||||
|
||||
/** 根据响应头或 Blob 自身类型识别 JSON 错误载荷。 */
|
||||
function isJsonBlob(response: AxiosResponse): boolean {
|
||||
const blobType = response.data instanceof Blob ? response.data.type : ''
|
||||
const headers = response.headers as AxiosResponse['headers'] & { get?: (name: string) => unknown }
|
||||
const rawContentType = typeof headers.get === 'function' ? headers.get('content-type') : headers['content-type']
|
||||
const contentType = typeof rawContentType === 'string' ? rawContentType : ''
|
||||
return blobType.includes('json') || contentType.includes('json')
|
||||
}
|
||||
|
||||
/** 取消属于正常控制流,保持原始取消对象且不触发 Toast 或离线探测。 */
|
||||
function isCancellation(reason: unknown): boolean {
|
||||
if (axios.isCancel(reason)) return true
|
||||
if (!(reason instanceof Error)) return false
|
||||
return reason.name === 'AbortError' || reason.name === 'CanceledError'
|
||||
}
|
||||
|
||||
/** 优先使用后端 message,并兼容尚未进入统一 envelope 的 detail 错误。 */
|
||||
function resolveErrorMessage(
|
||||
payload: unknown,
|
||||
error?: AxiosError,
|
||||
resolveFallbackMessage?: ApiFallbackMessageResolver,
|
||||
): string {
|
||||
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
||||
const record = payload as Record<string, unknown>
|
||||
if (typeof record.message === 'string' && record.message) return record.message
|
||||
if (typeof record.detail === 'string' && record.detail) return record.detail
|
||||
}
|
||||
if (error?.code === 'ECONNABORTED' || error?.code === 'ETIMEDOUT') {
|
||||
return resolveFallback('timeout', resolveFallbackMessage)
|
||||
}
|
||||
if (error?.code === 'NETWORK_ERROR' || error?.code === 'ERR_NETWORK') {
|
||||
return resolveFallback('network-error', resolveFallbackMessage)
|
||||
}
|
||||
if (error?.message) return error.message
|
||||
return resolveFallback('request-failed', resolveFallbackMessage)
|
||||
}
|
||||
|
||||
/** 调用应用注入的本地化 resolver,并为独立使用保留稳定默认值。 */
|
||||
function resolveFallback(key: ApiFallbackMessageKey, resolver?: ApiFallbackMessageResolver): string {
|
||||
return resolver?.(key) || defaultFallbackMessages[key]
|
||||
}
|
||||
|
||||
/** 默认模式只提示失败,silent 模式完全关闭请求层反馈。 */
|
||||
function notifyFailure(
|
||||
mode: ApiFeedbackMode | undefined,
|
||||
notifier: ApiFeedbackNotifier | undefined,
|
||||
message: string,
|
||||
technicalErrorDedup?: Map<string, number>,
|
||||
) {
|
||||
if (mode !== 'silent' && message && !isTechnicalErrorCached(message, technicalErrorDedup)) {
|
||||
notifier?.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 技术错误去重:相同消息在窗口内只提示一次。
|
||||
*
|
||||
* 后端异常或重启时大量并发请求会携带同一技术错误(协议错误、HTTP 5xx 等),
|
||||
* 逐条弹出会占满屏幕;此处以消息为键缓存最近提示时间,窗口内重复消息不再提示。
|
||||
*/
|
||||
function isTechnicalErrorCached(message: string, dedup?: Map<string, number>): boolean {
|
||||
if (!dedup) return false
|
||||
const now = Date.now()
|
||||
const lastShownAt = dedup.get(message)
|
||||
dedup.set(message, now)
|
||||
if (lastShownAt !== undefined && now - lastShownAt < TECHNICAL_ERROR_DEDUP_MS) {
|
||||
return true
|
||||
}
|
||||
// 顺带清理过期缓存项,避免长时间运行后缓存无限增长。
|
||||
if (dedup.size > 64) {
|
||||
for (const [key, at] of dedup) {
|
||||
if (now - at >= TECHNICAL_ERROR_DEDUP_MS) dedup.delete(key)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** all 模式用于显式要求请求层展示后端成功消息。 */
|
||||
function notifySuccess(mode: ApiFeedbackMode | undefined, notifier: ApiFeedbackNotifier | undefined, message: string) {
|
||||
if (mode === 'all' && message) notifier?.success(message)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
169
src/api/index.ts
@@ -1,90 +1,119 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import router from '@/router'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
import i18n, { getCurrentLocale } from '@/plugins/i18n'
|
||||
import {
|
||||
ApiRequestError,
|
||||
createApiClients,
|
||||
getApiBusinessErrorMessage,
|
||||
isApiBusinessFailure,
|
||||
isApiResponse,
|
||||
type ApiFeedbackMode,
|
||||
type ApiFallbackMessageKey,
|
||||
type DataApiClient,
|
||||
type PluginApiClient,
|
||||
} from './client'
|
||||
import { createScopedPluginApi } from './pluginInstance'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
/** 带连接探测和反馈策略的 MoviePilot 请求配置。 */
|
||||
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
|
||||
feedback?: ApiFeedbackMode
|
||||
skipConnectionTracking?: boolean
|
||||
}
|
||||
|
||||
const globalOfflineStatus = useGlobalOfflineStatus()
|
||||
const toast = useToast()
|
||||
// 会话失效后短暂窗口内的 401 都是同一次 token 作废的连带失败,统一静默避免刷屏。
|
||||
const SESSION_EXPIRED_SUPPRESSION_MS = 5000
|
||||
let sessionExpiredAt = 0
|
||||
const fallbackMessageKeys: Record<ApiFallbackMessageKey, string> = {
|
||||
'invalid-envelope': 'common.invalidApiResponse',
|
||||
'network-error': 'common.networkConnectionFailed',
|
||||
'request-failed': 'common.apiRequestFailed',
|
||||
timeout: 'common.requestTimeout',
|
||||
}
|
||||
const { api, pluginApi } = createApiClients({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
setupInstance: initializeClient,
|
||||
hooks: {
|
||||
markServerOnline: globalOfflineStatus.markServerOnline,
|
||||
reportConnectionFailure: globalOfflineStatus.reportNetworkError,
|
||||
onForbidden: () => {
|
||||
const authStore = useAuthStore()
|
||||
// 未登录的 403 可能是登录或 MFA 流程的一部分,不应触发全局登出跳转。
|
||||
if (!authStore.token) return
|
||||
authStore.logout()
|
||||
void router.push('/login')
|
||||
},
|
||||
onUnauthorized: () => {
|
||||
const authStore = useAuthStore()
|
||||
if (!authStore.token) {
|
||||
// 无 token 的 401(如登录页验证失败)交给调用方自行展示;
|
||||
// 但刚登出后的在途请求属于同一会话失效,继续静默。
|
||||
return Date.now() - sessionExpiredAt < SESSION_EXPIRED_SUPPRESSION_MS
|
||||
}
|
||||
// 后端重启会使旧 token 作废:只提示一次并统一登出,不逐条弹英文错误。
|
||||
sessionExpiredAt = Date.now()
|
||||
authStore.logout()
|
||||
toast.error(i18n.global.t('common.sessionExpired'))
|
||||
void router.push('/login')
|
||||
return true
|
||||
},
|
||||
},
|
||||
notifier: {
|
||||
error: message => toast.error(message),
|
||||
success: message => toast.success(message),
|
||||
},
|
||||
resolveFallbackMessage: key => i18n.global.t(fallbackMessageKeys[key]),
|
||||
})
|
||||
const pluginInstanceApis = new Map<string, PluginApiClient>()
|
||||
|
||||
// 声明全局变量类型
|
||||
declare global {
|
||||
interface Window {
|
||||
MoviePilotAPI: typeof api
|
||||
MoviePilotAPI: PluginApiClient
|
||||
}
|
||||
}
|
||||
|
||||
// 将 API 实例暴露到全局,供插件使用
|
||||
window.MoviePilotAPI = api
|
||||
/** 为两个客户端安装同一套取消、认证和语言请求头。 */
|
||||
function initializeClient(instance: AxiosInstance | DataApiClient) {
|
||||
initializeRequestOptimizer(instance)
|
||||
instance.interceptors.request.use(config => {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.token) config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
|
||||
// 初始化请求优化器(必须在其他拦截器之前)
|
||||
initializeRequestOptimizer(api)
|
||||
const locale = getCurrentLocale()
|
||||
config.headers['X-MoviePilot-Locale'] = locale
|
||||
config.headers['Accept-Language'] = locale
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
// 添加请求拦截器
|
||||
api.interceptors.request.use(config => {
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 在请求头中添加token
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
/** 返回复用同一拦截器链、但把源插件 API 映射到实例命名空间的客户端。 */
|
||||
function createPluginInstanceApi(instanceId: string, sourcePluginId?: string): PluginApiClient {
|
||||
if (!sourcePluginId || instanceId === sourcePluginId) return pluginApi
|
||||
const cacheKey = `${instanceId}\u0000${sourcePluginId}`
|
||||
let scopedApi = pluginInstanceApis.get(cacheKey)
|
||||
if (!scopedApi) {
|
||||
scopedApi = createScopedPluginApi(pluginApi, instanceId, sourcePluginId)
|
||||
pluginInstanceApis.set(cacheKey, scopedApi)
|
||||
}
|
||||
return config
|
||||
})
|
||||
return scopedApi
|
||||
}
|
||||
|
||||
// 离线状态管理
|
||||
const globalOfflineStatus = useGlobalOfflineStatus()
|
||||
// 插件远程组件接收 endpoint 的最终 payload,内部页面默认使用严格 envelope 解包客户端。
|
||||
if (typeof window !== 'undefined') window.MoviePilotAPI = pluginApi
|
||||
|
||||
// 添加响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
// 成功响应时,清除应用离线状态并重置连续错误计数
|
||||
globalOfflineStatus.setAppOffline(false)
|
||||
globalOfflineStatus.resetConsecutiveErrors()
|
||||
return response.data
|
||||
},
|
||||
error => {
|
||||
if (!error.response) {
|
||||
// 网络错误或请求超时 - 通知离线状态管理系统
|
||||
const isNetworkError =
|
||||
error.code === 'NETWORK_ERROR' ||
|
||||
error.code === 'ERR_NETWORK' ||
|
||||
error.code === 'ECONNABORTED' ||
|
||||
error.name === 'NetworkError'
|
||||
|
||||
if (isNetworkError) {
|
||||
let reason = 'Network connection failed'
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
reason = 'Request timeout'
|
||||
}
|
||||
// 记录网络错误,只有连续三次才会设置为离线模式
|
||||
globalOfflineStatus.recordNetworkError(reason)
|
||||
}
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
|
||||
// 网络连接问题
|
||||
return Promise.reject(new Error('Network connection failed, please check your network status'))
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
// 请求超时
|
||||
return Promise.reject(new Error('Request timeout, please try again later'))
|
||||
} else if (error.name === 'AbortError') {
|
||||
// 请求被中止(路由切换等)
|
||||
return Promise.reject(new Error('Request cancelled'))
|
||||
}
|
||||
// 其他网络错误
|
||||
return Promise.reject(new Error(error.message || 'Network error'))
|
||||
} else if (error.response.status === 403) {
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 清除登录状态信息
|
||||
authStore.logout()
|
||||
// token验证失败,跳转到登录页面
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
export {
|
||||
ApiRequestError,
|
||||
createPluginInstanceApi,
|
||||
getApiBusinessErrorMessage,
|
||||
isApiBusinessFailure,
|
||||
isApiResponse,
|
||||
pluginApi,
|
||||
}
|
||||
export type { ApiFeedbackMode, DataApiClient, PluginApiClient }
|
||||
|
||||
export default api
|
||||
|
||||
53
src/api/manage.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import api from './index'
|
||||
|
||||
/** 通用管理请求:目标标识 + 管理动作 + 透传参数,与后端 ManageRequest 一致。 */
|
||||
export interface ManageRequest {
|
||||
target: string
|
||||
action: string
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用统一管理端点(通知渠道 / 网盘存储 / LLM 提供商)
|
||||
*
|
||||
* 端点层不定义任何目标特定的名称与参数,
|
||||
* 目标标识、管理动作与表单参数原样透传给后端模块
|
||||
*/
|
||||
function manageTarget<T = Record<string, unknown>>(
|
||||
endpoint: 'notification/manage' | 'storage/manage' | 'llm/manage',
|
||||
request: ManageRequest,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
return api.post<T>(endpoint, { params: {}, ...request }, config)
|
||||
}
|
||||
|
||||
/** 对指定通知渠道执行管理动作,返回响应中的业务数据。 */
|
||||
export function manageNotificationChannel<T = Record<string, unknown>>(
|
||||
channel: string,
|
||||
action: string,
|
||||
params: Record<string, unknown> = {},
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
return manageTarget<T>('notification/manage', { target: channel, action, params }, config)
|
||||
}
|
||||
|
||||
/** 对指定网盘存储执行管理动作,返回响应中的业务数据。 */
|
||||
export function manageStorage<T = Record<string, unknown>>(
|
||||
storage: string,
|
||||
action: string,
|
||||
params: Record<string, unknown> = {},
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
return manageTarget<T>('storage/manage', { target: storage, action, params }, config)
|
||||
}
|
||||
|
||||
/** 对指定 LLM 提供商执行管理动作,返回响应中的业务数据。 */
|
||||
export function manageLlmProvider<T = Record<string, unknown>>(
|
||||
provider: string,
|
||||
action: string,
|
||||
params: Record<string, unknown> = {},
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
return manageTarget<T>('llm/manage', { target: provider, action, params }, config)
|
||||
}
|
||||
83
src/api/pluginInstance.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { PluginApiClient } from './client'
|
||||
|
||||
const URL_METHODS = new Set([
|
||||
'delete',
|
||||
'get',
|
||||
'head',
|
||||
'options',
|
||||
'patch',
|
||||
'patchForm',
|
||||
'post',
|
||||
'postForm',
|
||||
'put',
|
||||
'putForm',
|
||||
])
|
||||
|
||||
/** 转义插件 ID,避免其进入正则表达式后改变路径匹配语义。 */
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/** 把源插件动态 API 路径映射到当前虚拟实例的服务端命名空间。 */
|
||||
function rewritePluginUrl(url: string, instanceId: string, sourcePluginId: string): string {
|
||||
if (!instanceId || !sourcePluginId || instanceId === sourcePluginId) return url
|
||||
const sourcePattern = new RegExp(
|
||||
`(^|/)plugin/${escapeRegExp(sourcePluginId)}(?=/|[?#]|$)`,
|
||||
'i',
|
||||
)
|
||||
return url.replace(sourcePattern, `$1plugin/${instanceId}`)
|
||||
}
|
||||
|
||||
/** 复制请求配置并改写其中的 URL,避免修改远程插件持有的原对象。 */
|
||||
function rewriteRequestConfig(
|
||||
config: AxiosRequestConfig,
|
||||
instanceId: string,
|
||||
sourcePluginId: string,
|
||||
): AxiosRequestConfig {
|
||||
if (typeof config.url !== 'string') return config
|
||||
return {
|
||||
...config,
|
||||
url: rewritePluginUrl(config.url, instanceId, sourcePluginId),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建实例作用域 API。
|
||||
*
|
||||
* 仅改写插件自己的动态 API 前缀,其余系统 API 和 Axios 能力保持原合同,
|
||||
* 因而无需要求存量联邦插件改造路径拼接方式。
|
||||
*/
|
||||
export function createScopedPluginApi(
|
||||
client: PluginApiClient,
|
||||
instanceId: string,
|
||||
sourcePluginId?: string,
|
||||
): PluginApiClient {
|
||||
if (!sourcePluginId || instanceId === sourcePluginId) return client
|
||||
|
||||
return new Proxy(client, {
|
||||
apply(target, thisArg, argumentList: unknown[]) {
|
||||
const [request, ...rest] = argumentList
|
||||
const rewritten =
|
||||
typeof request === 'string'
|
||||
? rewritePluginUrl(request, instanceId, sourcePluginId)
|
||||
: request && typeof request === 'object'
|
||||
? rewriteRequestConfig(request as AxiosRequestConfig, instanceId, sourcePluginId)
|
||||
: request
|
||||
return Reflect.apply(target, thisArg, [rewritten, ...rest])
|
||||
},
|
||||
get(target, property, receiver) {
|
||||
const value = Reflect.get(target, property, receiver)
|
||||
if (typeof property !== 'string' || typeof value !== 'function') return value
|
||||
if (property === 'request') {
|
||||
return (config: AxiosRequestConfig) =>
|
||||
value.call(target, rewriteRequestConfig(config, instanceId, sourcePluginId))
|
||||
}
|
||||
if (URL_METHODS.has(property)) {
|
||||
return (url: string, ...args: unknown[]) =>
|
||||
value.call(target, rewritePluginUrl(url, instanceId, sourcePluginId), ...args)
|
||||
}
|
||||
return value.bind(target)
|
||||
},
|
||||
}) as PluginApiClient
|
||||
}
|
||||
645
src/api/types.ts
@@ -1,3 +1,41 @@
|
||||
/** 主程序内置的媒体来源常量;插件可以注册额外的稳定来源标识。 */
|
||||
export enum MediaSource {
|
||||
TMDB = 'themoviedb',
|
||||
Douban = 'douban',
|
||||
Bangumi = 'bangumi',
|
||||
AniList = 'anilist',
|
||||
IMDb = 'imdb',
|
||||
TVDB = 'tvdb',
|
||||
MusicBrainz = 'musicbrainz',
|
||||
TheAudioDB = 'theaudiodb',
|
||||
DoubanMusic = 'doubanmusic',
|
||||
Bilibili = 'bilibili',
|
||||
MangoTV = 'mangguodiscover',
|
||||
MiguVideo = 'migu',
|
||||
TencentVideo = 'tencentvideodiscover',
|
||||
}
|
||||
|
||||
export type MediaDataSource = `${MediaSource}` | (string & {})
|
||||
|
||||
/** 后端返回的媒体来源注册描述,供各类来源选择器共享。 */
|
||||
export interface MediaSourceInfo {
|
||||
name: string
|
||||
media_source: MediaDataSource
|
||||
media_types: string[]
|
||||
}
|
||||
|
||||
// 手动刮削选项
|
||||
export interface ManualScrapeOptions {
|
||||
// 媒体数据源
|
||||
media_source: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 媒体类型
|
||||
type_name?: string
|
||||
// 音乐实体类型
|
||||
music_type?: MusicEntityType
|
||||
}
|
||||
|
||||
// 订阅
|
||||
export interface Subscribe {
|
||||
// 订阅ID
|
||||
@@ -6,18 +44,18 @@ export interface Subscribe {
|
||||
name: string
|
||||
// 订阅年份
|
||||
year: string
|
||||
// 订阅类型 电影/电视剧
|
||||
// 订阅类型 电影/电视剧/音乐
|
||||
type: string
|
||||
// 搜索关键字
|
||||
keyword?: string
|
||||
// TMDB ID
|
||||
tmdbid: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: string
|
||||
// 其它媒体ID
|
||||
mediaid?: string
|
||||
// 主媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 音乐实体类型:recording 单曲、album 专辑、artist 艺术家
|
||||
music_type?: MusicEntityType
|
||||
// 专辑总曲目数
|
||||
total_tracks?: number
|
||||
// 季号
|
||||
season?: number
|
||||
// 海报
|
||||
@@ -40,6 +78,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 +114,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>
|
||||
// 保存目录
|
||||
@@ -110,10 +166,14 @@ export interface SubscribeShare {
|
||||
type?: string
|
||||
// 搜索关键字
|
||||
keyword?: string
|
||||
// TMDB ID
|
||||
tmdbid?: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// 主媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 音乐实体类型:recording 单曲、album 专辑、artist 艺术家
|
||||
music_type?: MusicEntityType
|
||||
// 专辑总曲目数
|
||||
total_tracks?: number
|
||||
// 季号
|
||||
season?: number
|
||||
// 海报
|
||||
@@ -208,14 +268,10 @@ export interface TransferHistory {
|
||||
title?: string
|
||||
// 年份
|
||||
year?: string
|
||||
// TMDBID
|
||||
tmdbid?: number
|
||||
// IMDBID
|
||||
imdbid?: string
|
||||
// TVDBID
|
||||
tvdbid?: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// 媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 季Sxx
|
||||
seasons?: string
|
||||
// 集Exx
|
||||
@@ -230,20 +286,82 @@ 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
|
||||
// 媒体数据源
|
||||
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 {
|
||||
// 主媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 类型 电影、电视剧、音乐、合集
|
||||
type?: string
|
||||
// 媒体标题
|
||||
title?: string
|
||||
// 年份(音乐等数据源返回数字,订阅接口要求字符串)
|
||||
year?: string | number
|
||||
// 标题(年)
|
||||
title_year?: string
|
||||
// 季号
|
||||
@@ -254,15 +372,19 @@ export interface MediaInfo {
|
||||
imdb_id?: string
|
||||
// TVDB ID
|
||||
tvdb_id?: string
|
||||
// TVDB Slug(别名,用于构建 TheTvDb 直达链接)
|
||||
tvdb_slug?: string
|
||||
// 豆瓣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前缀
|
||||
mediaid_prefix?: string
|
||||
// 其它媒体ID值
|
||||
// 主媒体数据源的原生 ID
|
||||
media_id?: string
|
||||
// 媒体原语种
|
||||
original_language?: string
|
||||
@@ -338,6 +460,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
|
||||
// 媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 专辑标准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
|
||||
// 媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 艺术家标准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 +723,7 @@ export interface TmdbEpisode {
|
||||
|
||||
// TMDB人物信息
|
||||
export interface Person {
|
||||
// 来源:themoviedb、douban、bangumi
|
||||
// 来源:themoviedb、douban、bangumi、anilist
|
||||
source?: string
|
||||
// ID
|
||||
id?: number
|
||||
@@ -646,6 +955,8 @@ export interface Plugin {
|
||||
installed?: boolean
|
||||
// 运行状态
|
||||
state?: boolean
|
||||
// 插件源码、依赖和运行时加载状态
|
||||
runtime_status?: 'source_missing' | 'dependency_pending' | 'ready' | 'active' | 'blocked_by_policy' | 'load_failed'
|
||||
// 是否有详情页面
|
||||
has_page?: boolean
|
||||
// 是否有新版本
|
||||
@@ -668,6 +979,37 @@ export interface Plugin {
|
||||
add_time?: number
|
||||
// 页面打开状态
|
||||
page_open?: boolean
|
||||
// 平均评分
|
||||
average_rating?: number
|
||||
// 评分人数
|
||||
rating_count?: number
|
||||
// 当前安装实例评分
|
||||
user_rating?: number | null
|
||||
// 共享代码和联邦资源的源插件 ID
|
||||
source_plugin_id?: string
|
||||
// 是否为共享源码的虚拟实例
|
||||
is_instance?: boolean
|
||||
// 实例实现模式
|
||||
instance_mode?: 'virtual'
|
||||
}
|
||||
|
||||
export interface PluginRuntimeSummary {
|
||||
// 本轮插件源码、依赖和加载是否已收敛
|
||||
ready: boolean
|
||||
// 插件运行状态变化代次
|
||||
generation: number
|
||||
// 仍处于准备阶段的插件数量
|
||||
pending_count: number
|
||||
// 加载失败或被策略阻止的插件数量
|
||||
failed_count: number
|
||||
}
|
||||
|
||||
// 插件评分结果
|
||||
export interface PluginRating {
|
||||
plugin_id: string
|
||||
average_rating: number
|
||||
rating_count: number
|
||||
user_rating?: number | null
|
||||
}
|
||||
|
||||
// 插件 Release 可安装版本
|
||||
@@ -742,6 +1084,8 @@ export interface DashboardItem {
|
||||
elements: RenderProps[]
|
||||
// 渲染方式
|
||||
render_mode?: string
|
||||
// 共享联邦资源的源插件 ID
|
||||
source_plugin_id?: string
|
||||
}
|
||||
|
||||
// 种子信息
|
||||
@@ -764,8 +1108,10 @@ export interface TorrentInfo {
|
||||
title?: string
|
||||
// 种子副标题
|
||||
description?: string
|
||||
// IMDB ID
|
||||
imdbid: string
|
||||
// 种子页面声明的媒体来源
|
||||
media_source?: MediaDataSource
|
||||
// 种子页面声明的数据源原生 ID
|
||||
media_id?: string
|
||||
// 种子链接
|
||||
enclosure?: string
|
||||
// 详情页面
|
||||
@@ -871,7 +1217,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 +1262,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
|
||||
@@ -952,8 +1332,8 @@ export interface User {
|
||||
id: number
|
||||
// 用户名称
|
||||
name: string
|
||||
// 用户密码
|
||||
password: string
|
||||
// 用户密码仅用于写入,查询响应不返回该字段
|
||||
password?: string
|
||||
// 用户邮箱
|
||||
email: string
|
||||
// 是否激活
|
||||
@@ -962,7 +1342,7 @@ export interface User {
|
||||
is_superuser: boolean
|
||||
// 头像
|
||||
avatar: string
|
||||
// 是否开启双重验证
|
||||
// 是否开启二次验证
|
||||
is_otp: boolean
|
||||
// 用户权限 json
|
||||
permissions: { [key: string]: any }
|
||||
@@ -972,6 +1352,11 @@ export interface User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 头像上传响应数据
|
||||
export interface AvatarUploadData {
|
||||
filename: string
|
||||
}
|
||||
|
||||
// 通行密钥
|
||||
export interface PassKey {
|
||||
id: number
|
||||
@@ -998,14 +1383,18 @@ export interface MediaStatistic {
|
||||
tv_count: number
|
||||
// 电视剧总集数,未获取时为 null
|
||||
episode_count: number | null
|
||||
// 用户数量
|
||||
user_count: number
|
||||
// 音乐总数
|
||||
music_count: number
|
||||
// 用户数量,仪表板已改为展示音乐数量,仅保留接口字段兼容
|
||||
user_count?: number
|
||||
// 本月新增电影数量
|
||||
movie_count_month: number
|
||||
// 本月新增电视剧数量
|
||||
tv_count_month: number
|
||||
// 本月新增剧集数量
|
||||
episode_count_month: number
|
||||
// 本月新增音乐数量
|
||||
music_count_month: number
|
||||
}
|
||||
|
||||
// 后台进程
|
||||
@@ -1072,14 +1461,20 @@ export interface ScheduleProgress {
|
||||
id?: string
|
||||
// 名称
|
||||
name?: string
|
||||
// 多语言名称
|
||||
name_i18n?: string
|
||||
// 提供者
|
||||
provider?: string
|
||||
// 多语言提供者
|
||||
provider_i18n?: string
|
||||
// 是否正在执行
|
||||
enable?: boolean
|
||||
// 当前完成百分比
|
||||
value?: number
|
||||
// 当前进度文本
|
||||
text?: string
|
||||
// 多语言进度文本
|
||||
text_i18n?: string
|
||||
// 执行状态
|
||||
status?: string
|
||||
// 最近一次执行是否成功
|
||||
@@ -1090,6 +1485,8 @@ export interface ScheduleProgress {
|
||||
finished_at?: string
|
||||
// 最近一次错误信息
|
||||
error?: string
|
||||
// 多语言错误信息
|
||||
error_i18n?: string
|
||||
// 扩展数据
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
@@ -1100,16 +1497,26 @@ export interface ScheduleInfo {
|
||||
id: string
|
||||
// 名称
|
||||
name: string
|
||||
// 多语言名称
|
||||
name_i18n?: string
|
||||
// 提供者
|
||||
provider: string
|
||||
// 多语言提供者
|
||||
provider_i18n?: string
|
||||
// 状态
|
||||
status: string
|
||||
// 多语言状态
|
||||
status_i18n?: string
|
||||
// 下次运行时间
|
||||
next_run: string
|
||||
next_run?: string
|
||||
// 多语言下次运行时间
|
||||
next_run_i18n?: string
|
||||
// 当前完成百分比
|
||||
progress?: number
|
||||
// 进度文本
|
||||
progress_text?: string
|
||||
// 多语言进度文本
|
||||
progress_text_i18n?: string
|
||||
// 是否正在更新进度
|
||||
progress_enable?: boolean
|
||||
// 进度详情
|
||||
@@ -1325,7 +1732,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 }
|
||||
@@ -1333,6 +1740,8 @@ export interface MediaServerConf {
|
||||
enabled: boolean
|
||||
// 同步媒体体库列表
|
||||
sync_libraries?: string[]
|
||||
// 自动同步间隔(小时),为空时使用旧全局配置
|
||||
sync_interval?: number | null
|
||||
}
|
||||
|
||||
// 文件整理目录配置
|
||||
@@ -1407,6 +1816,28 @@ export interface FilterRuleGroup {
|
||||
category?: string
|
||||
}
|
||||
|
||||
// 规则测试结果
|
||||
export interface RuleTestData {
|
||||
// 原始标题
|
||||
title?: string
|
||||
// 原始副标题
|
||||
subtitle?: string
|
||||
// 规则组名称
|
||||
rulegroup_name?: string
|
||||
// 规则组详情
|
||||
rulegroup?: FilterRuleGroup | null
|
||||
// 识别元数据
|
||||
meta_info?: MetaInfo | null
|
||||
// 媒体信息
|
||||
media_info?: MediaInfo | null
|
||||
// 种子信息
|
||||
torrent_info?: TorrentInfo | null
|
||||
// 优先级
|
||||
priority?: number | null
|
||||
// 是否命中过滤规则
|
||||
matched?: boolean
|
||||
}
|
||||
|
||||
// 订阅下载文件详情
|
||||
export interface SubscribeDownloadFileInfo {
|
||||
// 种子名称
|
||||
@@ -1427,6 +1858,12 @@ export interface SubscribeLibraryFileInfo {
|
||||
storage?: string
|
||||
// 文件路径
|
||||
file_path?: string
|
||||
// 媒体服务器名称
|
||||
server?: string
|
||||
// 媒体服务器类型:emby、jellyfin、plex 等
|
||||
server_type?: string
|
||||
// 媒体服务器条目 ID
|
||||
itemid?: string
|
||||
}
|
||||
|
||||
// 订阅集详情
|
||||
@@ -1461,10 +1898,12 @@ export interface TransferForm {
|
||||
target_storage: string | null
|
||||
// 目标路径
|
||||
target_path: string | null
|
||||
// TMDB ID
|
||||
tmdbid?: number
|
||||
// 豆瓣 ID
|
||||
doubanid?: string
|
||||
// 媒体数据源
|
||||
media_source?: MediaDataSource | null
|
||||
// 数据源原生ID
|
||||
media_id?: string | null
|
||||
// 音乐实体类型
|
||||
music_type?: Exclude<MusicEntityType, 'artist'> | null
|
||||
// 季号
|
||||
season?: number
|
||||
// 类型
|
||||
@@ -1493,6 +1932,8 @@ export interface TransferForm {
|
||||
episode_group?: string | null
|
||||
// 预览模式
|
||||
preview?: boolean
|
||||
// 清理已有成功记录后重新整理
|
||||
reorganize: boolean
|
||||
}
|
||||
|
||||
// 手动整理请求
|
||||
@@ -1519,6 +1960,14 @@ export interface ManualTransferTargetPathData {
|
||||
library_category_folder?: boolean | null
|
||||
}
|
||||
|
||||
// 手动整理命中的成功历史摘要
|
||||
export interface ManualTransferHistoryInfo {
|
||||
// 是否应执行重新整理
|
||||
reorganize: boolean
|
||||
// 命中的成功历史数量
|
||||
history_count: number
|
||||
}
|
||||
|
||||
// 手动整理预览统计
|
||||
export interface ManualTransferPreviewSummary {
|
||||
// 总数
|
||||
@@ -1594,6 +2043,8 @@ export interface TransferQueue {
|
||||
export interface DiscoverSource {
|
||||
// 数据源名称
|
||||
name: string
|
||||
// 内置或插件扩展媒体来源
|
||||
media_source?: MediaDataSource
|
||||
// 媒体ID的前缀,不含:
|
||||
mediaid_prefix: string
|
||||
// 媒体数据源API地址
|
||||
@@ -1681,6 +2132,12 @@ export interface TorrentCacheItem {
|
||||
media_year?: string
|
||||
// 识别的媒体类型
|
||||
media_type?: string
|
||||
// 识别结果的数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生媒体 ID
|
||||
media_id?: string
|
||||
// 音乐实体类型
|
||||
music_type?: Exclude<MusicEntityType, 'artist'>
|
||||
// 季集信息
|
||||
season_episode?: string
|
||||
// 资源信息
|
||||
@@ -1705,6 +2162,74 @@ export interface TorrentCacheData {
|
||||
data: TorrentCacheItem[]
|
||||
}
|
||||
|
||||
// 媒体识别缓存项
|
||||
export interface RecognitionCacheItem {
|
||||
// 缓存键
|
||||
key: string
|
||||
// TMDB ID,0 表示未识别
|
||||
tmdb_id?: number
|
||||
// 识别缓存对应的字符串 ID
|
||||
recognition_id?: string
|
||||
// 识别后的标题
|
||||
title: string
|
||||
// 识别后的年份
|
||||
year: string
|
||||
// 媒体类型
|
||||
media_type: string
|
||||
// TMDB 海报相对路径
|
||||
poster_path?: string
|
||||
// TMDB 背景图相对路径
|
||||
backdrop_path?: string
|
||||
}
|
||||
|
||||
// 媒体识别缓存数据
|
||||
export interface RecognitionCacheData {
|
||||
// 缓存总数
|
||||
count: number
|
||||
// 已识别数量
|
||||
recognized: number
|
||||
// 未识别数量
|
||||
unrecognized: number
|
||||
// 共享识别成功命中数量
|
||||
shared_recognized: number
|
||||
// 是否开启共享识别
|
||||
shared_recognize_enabled: boolean
|
||||
// 缓存数据
|
||||
data: RecognitionCacheItem[]
|
||||
}
|
||||
|
||||
// 音乐识别缓存项
|
||||
export interface MusicRecognitionCacheItem {
|
||||
// 缓存键
|
||||
key: string
|
||||
// MusicBrainz ID,空字符串表示未识别
|
||||
media_id?: string
|
||||
// 识别后的曲名/专辑名
|
||||
title: string
|
||||
// 艺术家列表
|
||||
artists?: string[]
|
||||
// 所属专辑
|
||||
album?: string
|
||||
// 发行年份
|
||||
year?: string | number
|
||||
// 音乐实体类型:recording/album/artist
|
||||
music_type?: string
|
||||
// 封面图片地址
|
||||
cover_url?: string
|
||||
}
|
||||
|
||||
// 音乐识别缓存数据
|
||||
export interface MusicRecognitionCacheData {
|
||||
// 缓存总数
|
||||
count: number
|
||||
// 已识别数量
|
||||
recognized: number
|
||||
// 未识别数量
|
||||
unrecognized: number
|
||||
// 缓存数据
|
||||
data: MusicRecognitionCacheItem[]
|
||||
}
|
||||
|
||||
// 订阅分享统计
|
||||
export interface SubscribeShareStatistics {
|
||||
// 分享人
|
||||
@@ -1715,11 +2240,31 @@ export interface SubscribeShareStatistics {
|
||||
total_reuse_count?: number
|
||||
}
|
||||
|
||||
// 通用API响应
|
||||
export interface ApiResponse<T = any> {
|
||||
/** 后端 API 的固定 envelope;失败及无返回值操作允许 data 为 null。 */
|
||||
export type SystemUpdateState = 'idle' | 'available' | 'downloading' | 'ready' | 'installing' | 'failed'
|
||||
|
||||
/** 后端后台更新状态机快照。 */
|
||||
export interface SystemUpdateStatus {
|
||||
state: SystemUpdateState
|
||||
current_version: string
|
||||
version?: string | null
|
||||
frontend_version?: string | null
|
||||
release_name?: string | null
|
||||
release_notes?: string | null
|
||||
published_at?: string | null
|
||||
checked_at?: string | null
|
||||
downloaded_bytes: number
|
||||
total_bytes: number
|
||||
progress: number
|
||||
error?: string | null
|
||||
can_update: boolean
|
||||
can_install: boolean
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: T
|
||||
message: string
|
||||
data: T | null
|
||||
}
|
||||
|
||||
// 分类规则
|
||||
|
||||
BIN
src/assets/images/logos/dingtalk.jpg
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 41 KiB |
@@ -1,26 +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>
|
||||
|
||||