mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-25 18:22:48 +08:00
Compare commits
310 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4fe65b770e | ||
|
|
c010162e54 | ||
|
|
5cc4ea7740 | ||
|
|
8a19fbc584 | ||
|
|
24412989bb | ||
|
|
55d0693e41 | ||
|
|
0459a2d108 | ||
|
|
624b55576d | ||
|
|
c848b90599 | ||
|
|
79bc99e624 | ||
|
|
850f7ee9be | ||
|
|
4cc105287f | ||
|
|
527c0845bc | ||
|
|
2a3ef1a1f6 | ||
|
|
7539fa26aa | ||
|
|
3547c146a8 | ||
|
|
da630b0966 | ||
|
|
cff4c1fecc | ||
|
|
3f525c6457 | ||
|
|
8c1eb3e981 | ||
|
|
effc83b9a1 | ||
|
|
39ce8d93dd | ||
|
|
fa32ac9f48 | ||
|
|
ab243bbbb7 | ||
|
|
abcd3f5e81 | ||
|
|
92bf1ca790 | ||
|
|
faadf39b80 | ||
|
|
878c3c98b2 | ||
|
|
2d8ae9f462 | ||
|
|
00051a0eac | ||
|
|
88726c0f12 | ||
|
|
79bc9f0ff0 | ||
|
|
4492e211bb | ||
|
|
bb5299c812 | ||
|
|
f54a5285d6 | ||
|
|
03b80d6434 | ||
|
|
a916a1569d | ||
|
|
5c27c32914 | ||
|
|
c57a8b7b48 | ||
|
|
0aa7d37e45 | ||
|
|
d3b3b08318 | ||
|
|
5127ab6c08 | ||
|
|
a3810fb9e2 | ||
|
|
f05eac0b93 | ||
|
|
ef170b54c6 | ||
|
|
536844d291 | ||
|
|
21d4f9fd73 | ||
|
|
2aae0f08ad | ||
|
|
248984261e | ||
|
|
82ab594e0c | ||
|
|
5ca374349d | ||
|
|
7452e18fc3 | ||
|
|
75dffdb7c2 | ||
|
|
a3d74929b3 | ||
|
|
fd9c314d55 | ||
|
|
d02e98736f | ||
|
|
2e46ebe353 | ||
|
|
fffb526a87 | ||
|
|
98769a313f | ||
|
|
1c28d98d70 | ||
|
|
7a430b095a | ||
|
|
864af36b1c | ||
|
|
e3fa0b9dae | ||
|
|
ed28832484 | ||
|
|
e692a91113 | ||
|
|
7574719e04 | ||
|
|
ca800f7ae7 | ||
|
|
7d0550825b | ||
|
|
59abda63e3 | ||
|
|
0776bc9fa0 | ||
|
|
8c78f9ca90 | ||
|
|
1889d5d1e3 | ||
|
|
165247b263 | ||
|
|
9a2384bf02 | ||
|
|
71c4f587d3 | ||
|
|
8edbfa0b0e | ||
|
|
ee187a1c75 | ||
|
|
afb617724c | ||
|
|
05bbecba53 | ||
|
|
deb9b103da | ||
|
|
2d92315e17 | ||
|
|
077f0c1366 | ||
|
|
eab4caa279 | ||
|
|
3bba73abf1 | ||
|
|
8edf1f686a | ||
|
|
3440b377ee | ||
|
|
9161e5e300 | ||
|
|
25fdb09c61 | ||
|
|
25119b224d | ||
|
|
76f79e1656 | ||
|
|
b5230cef79 | ||
|
|
f871746efb | ||
|
|
d17ca69a28 | ||
|
|
e445437129 | ||
|
|
090c5476c6 | ||
|
|
646c7b33dd | ||
|
|
0902f16f84 | ||
|
|
ec4eaed1df | ||
|
|
175f610524 | ||
|
|
530fe9d35b | ||
|
|
48ed396a19 | ||
|
|
b0356c217d | ||
|
|
55eed1ecb5 | ||
|
|
50ae739a4d | ||
|
|
d9cbcc2991 | ||
|
|
ad12701fe2 | ||
|
|
2b426a47c6 | ||
|
|
7f0f12ac41 | ||
|
|
6789d63ca1 | ||
|
|
3202251f55 | ||
|
|
8e99ad9cf9 | ||
|
|
83dde400e7 | ||
|
|
1b57f925ee | ||
|
|
16428066b9 | ||
|
|
e211a80cf4 | ||
|
|
ea0b5b62d9 | ||
|
|
62dc2c4f66 | ||
|
|
b2a2c7080e | ||
|
|
05c2e7855a | ||
|
|
8d9c622dc5 | ||
|
|
bf0b17c314 | ||
|
|
37f31f6554 | ||
|
|
3de409fb07 | ||
|
|
7e9c0fd206 | ||
|
|
fb4f5658a8 | ||
|
|
a9f4ec963b | ||
|
|
542e33d7b4 | ||
|
|
39c250ba09 | ||
|
|
924fcef403 | ||
|
|
e586342b19 | ||
|
|
c795de9b2d | ||
|
|
6fa1cf28f4 | ||
|
|
3f70aafdad | ||
|
|
f8ceee39b3 | ||
|
|
0a22f33e34 | ||
|
|
cf88ed9a58 | ||
|
|
49dfd794c1 | ||
|
|
68f2f010d1 | ||
|
|
9eed2fea87 | ||
|
|
1f170030ee | ||
|
|
e78ed20936 | ||
|
|
b1787b207d | ||
|
|
fdb34732cc | ||
|
|
fc1f163a94 | ||
|
|
a771dc5354 | ||
|
|
d28360a161 | ||
|
|
a730abc437 | ||
|
|
5b72eda4fc | ||
|
|
6c49d7a59e | ||
|
|
8900366faf | ||
|
|
e8e0ac9084 | ||
|
|
c66ee881b1 | ||
|
|
c055740926 | ||
|
|
a5bc4e6baf | ||
|
|
15b4ee5893 | ||
|
|
8868403ff3 | ||
|
|
3abff72e25 | ||
|
|
0c56cf0be7 | ||
|
|
ce12d04648 | ||
|
|
efc0ae4df6 | ||
|
|
2530c3bcd9 | ||
|
|
60e2402aff | ||
|
|
1a478f97fb | ||
|
|
33666703af | ||
|
|
cd69172a99 | ||
|
|
61749e3595 | ||
|
|
b658533262 | ||
|
|
d8015b7def | ||
|
|
33599cc21d | ||
|
|
bf22a4809d | ||
|
|
4a6f7390e6 | ||
|
|
405e460ad6 | ||
|
|
18566c0e9d | ||
|
|
2c471a936f | ||
|
|
2efb07402f | ||
|
|
9434ef71e4 | ||
|
|
e06b9537ff | ||
|
|
2829e3b082 | ||
|
|
1a0fc10559 | ||
|
|
5a1aec3323 | ||
|
|
48913b8811 |
69
.eslintrc.js
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: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
52
.github/workflows/pr-agent.yml
vendored
Normal file
52
.github/workflows/pr-agent.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: PR-Agent
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- ready_for_review
|
||||
- review_requested
|
||||
- synchronize
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
pr-agent:
|
||||
if: >-
|
||||
github.event.sender.type != 'Bot' &&
|
||||
(
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(
|
||||
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)
|
||||
)
|
||||
)
|
||||
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 Review
|
||||
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
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
|
||||
82
.github/workflows/test.yml
vendored
Normal file
82
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
name: Frontend Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- v2
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-tests-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
format:
|
||||
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
|
||||
|
||||
unit-tests:
|
||||
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: Typecheck
|
||||
run: yarn typecheck
|
||||
|
||||
- name: Unit tests with coverage
|
||||
run: yarn test:coverage
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,6 +12,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
coverage
|
||||
*.local
|
||||
package-lock.json
|
||||
|
||||
@@ -37,3 +38,5 @@ src/@iconify/*.js
|
||||
public/plugin_icon/**
|
||||
docs-lock/
|
||||
.trae/
|
||||
output/
|
||||
.codex-qa/
|
||||
|
||||
1
.node-version
Normal file
1
.node-version
Normal file
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -1,2 +1,8 @@
|
||||
dist
|
||||
node_modules
|
||||
node_modules
|
||||
|
||||
# 自动生成文件的格式由各自生成器决定。
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
eslint-suppressions.json
|
||||
/vite.config.*.timestamp-*.mjs
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"bracketSpacing": true,
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 120,
|
||||
"proseWrap": "preserve",
|
||||
|
||||
13
README.md
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`。
|
||||
|
||||
179
docs/code-quality.md
Normal file
179
docs/code-quality.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# 前端代码质量工具链演进
|
||||
|
||||
本文档定义 MoviePilot-Frontend 的代码质量工具链、运行入口和渐进式演进路线。目标是让人工开发者和自动化 Agent 都通过同一组项目命令完成格式化、静态检查、类型检查、测试和构建,并由 CI 在干净环境中复现结果;编辑器及其扩展只提供即时反馈,不作为项目行为的事实源。
|
||||
|
||||
## 工具职责
|
||||
|
||||
| 工具 | 职责 | 不负责 |
|
||||
| ---------------------- | ------------------------------------------------------------ | -------------------------------------- |
|
||||
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
|
||||
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
|
||||
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
|
||||
| Vitest | 单元测试、组件测试和覆盖率门槛 | 生产构建与真实浏览器行为 |
|
||||
| Vite | 生产构建和构建期集成验证 | 类型完整性和代码质量规则 |
|
||||
|
||||
ESLint 配置由仓库显式维护,不继承 Antfu 等覆盖面较大的个人风格预设。JavaScript、TypeScript 与 Vue 使用各自面向正确性的 recommended/essential 基线;SonarJS 不整包展开 recommended,而只显式启用安全和正确性的高信号规则,避免插件升级隐式扩大检查范围。
|
||||
|
||||
历史规则迁移前必须核对当前源码和运行职责,不能仅因为旧配置出现过就写入 baseline。当前保留 Vuetify 点号 slot 兼容、`@layouts` 不反向依赖 `@core`、浏览器与 Node globals 分域及统一禁止 `debugger`。图片/样式 alias 强制只约束 import 拼写且已与当前实践脱节;`useLayouts` 目录限制所针对的 API 从未存在于源码;重复字符串属于高噪声风格治理;伪随机数限制会把界面效果误判为安全问题,因此这四类历史规则不再作为门禁。
|
||||
|
||||
Prettier 独立运行,ESLint 中与格式重叠的规则保持关闭。任何编辑器都可以调用项目内 ESLint 和 Prettier,但保存时是否自动执行不影响 CI 结果。
|
||||
|
||||
## Node 与依赖边界
|
||||
|
||||
- 最低兼容目标为 Node.js 20.19;Node.js 20.19、22 和 24 的开发环境可以安装依赖并运行项目命令。
|
||||
- 推荐开发环境和 CI 主环境使用 Node.js 24。
|
||||
- CI 若继续声明兼容 Node.js 20.19,应至少在该版本验证 frozen lockfile 安装、lint、typecheck 和 build;完整覆盖率门禁在 Node.js 24 执行。
|
||||
- Yarn 1 与 `yarn.lock` 继续作为依赖安装事实源,CI 使用 `yarn --frozen-lockfile`。
|
||||
- Node 只用于依赖安装、开发、测试和前端构建;MoviePilot 正式 Docker 镜像使用预构建前端产物,不因开发工具链升级而增加 Node 运行时。
|
||||
|
||||
最低 Node 版本变化属于开发环境兼容性变更,必须独立说明并在 CI 验证。不得仅提高 `package.json` 的 `engines.node` 而不更新 README、版本文件和 workflow。
|
||||
|
||||
## 统一命令
|
||||
|
||||
工具链完成演进后,仓库提供以下入口:
|
||||
|
||||
```sh
|
||||
yarn lint # 只读 ESLint 检查,不修改文件
|
||||
yarn lint:fix # 显式执行 ESLint 安全修复
|
||||
yarn lint:suppressions:prune # 修复存量问题后裁剪过期 baseline
|
||||
yarn format # 格式化当前分支、暂存区、工作区和未跟踪文件中的受支持变更文件
|
||||
yarn format:check # 检查相同变更文件,不修改文件
|
||||
yarn format:all:check # 只读测量全仓格式收敛情况
|
||||
yarn typecheck
|
||||
yarn test:run
|
||||
yarn test:coverage
|
||||
yarn build
|
||||
```
|
||||
|
||||
`yarn lint` 不得包含 `--fix`。CI 只运行只读命令,任何自动修改都由开发者或 Agent 在提交前显式执行。
|
||||
|
||||
## ESLint 演进:先恢复可执行门禁
|
||||
|
||||
ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业务代码修复。
|
||||
|
||||
第一阶段完成以下事项:
|
||||
|
||||
1. 使用 ESLint 10 flat config,移除旧 `.eslintrc`。
|
||||
2. 不恢复已删除的 `@antfu/eslint-config-vue`。
|
||||
3. 显式组合 JavaScript、TypeScript、Vue 和经过审计的 SonarJS 规则。
|
||||
4. 使用 ESLint 原生 AST 限制维护仍成立的模块边界,并删除只约束 import 拼写或已无现实对象的历史 regex 规则。
|
||||
5. 只检查 JavaScript、TypeScript、Vue 与相关测试文件;Markdown、JSON、YAML、TOML、构建产物和生成文件不在首阶段扩展范围内。
|
||||
6. 默认 lint 只读,并提供单独的 `lint:fix`。
|
||||
7. 对确有价值但存在存量的问题使用明确基线或分阶段启用,不通过全仓自动修复制造大面积混合变更。
|
||||
8. 项目边界与新增问题门禁使用 Vitest 配置契约测试保护,并随现有 `test:coverage` CI 自动执行。
|
||||
|
||||
`eslint-suppressions.json` 只冻结迁移时已经确认的文件、规则和数量。新增问题不得加入 baseline;修复存量问题后运行 `yarn lint:suppressions:prune`,并把裁剪结果与代码修复一同提交。日常开发和 CI 不得使用 `--suppress-all`。
|
||||
|
||||
迁移完成的最低验收为:
|
||||
|
||||
```sh
|
||||
yarn --frozen-lockfile
|
||||
yarn lint
|
||||
yarn typecheck
|
||||
yarn test:coverage
|
||||
yarn build
|
||||
```
|
||||
|
||||
若新配置导致大量与业务目标无关的格式、排序或额外文件类型问题,应停止扩大规则范围,先调整配置职责;不得以一次性自动修复数百个文件作为通过迁移的手段。
|
||||
|
||||
## ESLint CI:先观察,再强制
|
||||
|
||||
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job:
|
||||
|
||||
1. lint 与既有 `unit-tests` 使用不同 job,避免改变现有测试 check 的名称和职责。
|
||||
2. 初始阶段作为普通 check 运行,不立即配置 required check。
|
||||
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。
|
||||
4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
|
||||
5. 连续多个 PR 稳定通过后,再由维护者决定是否设为 required。
|
||||
|
||||
ESLint CI 必须检查全仓受管源码,而不是只检查 PR 变更文件。已有问题通过规则选择或受控基线管理,保证新问题不能借由“只检查改动行”绕过项目约束。
|
||||
|
||||
## Prettier 演进:改到即格式化
|
||||
|
||||
Prettier 采用与后端 `unittest → pytest` 相同的渐进原则:不为转换而转换,修改到存量文件时再纳入统一格式。
|
||||
|
||||
- **新文件**:必须通过当前 Prettier 配置。
|
||||
- **修改文件**:Agent 或开发者在提交前对本次触及的受支持文件运行 Prettier。
|
||||
- **未修改存量文件**:暂时保持原样,不建立一次性全仓格式化任务。
|
||||
- **大型或高冲突文件**:若格式化产生大量机械差异,格式调整与业务修改拆分为独立 commit;必要时暂缓该文件并记录原因。
|
||||
- **生成文件**:按生成器事实源管理,不直接用 Prettier 修补生成结果;需要统一格式时修改生成器或生成配置。
|
||||
|
||||
Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,并作为变更范围门禁运行。检查脚本应:
|
||||
|
||||
1. 从 PR base 与 head 计算文件集合。
|
||||
2. 排除已删除文件、构建产物、覆盖率报告和生成文件。
|
||||
3. 仅把 Prettier 支持且由仓库管理的文件传给 `prettier --check`。
|
||||
4. 文件集合为空时正常通过。
|
||||
|
||||
本地直接运行 `yarn format` 或 `yarn format:check` 时,脚本按 `refs/remotes/upstream/v2`、`refs/remotes/origin/v2`、`refs/heads/v2` 的顺序选择与当前提交具有共同祖先的基线,并合并当前工作区与未跟踪文件。完整引用可避免同名标签造成歧义;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:coverage` 和 `yarn build` 在格式收敛后全部通过。
|
||||
- Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。
|
||||
- workflow 已以非 required 状态稳定运行多个 PR。
|
||||
|
||||
最终 Pull Request 门禁目标为:
|
||||
|
||||
```sh
|
||||
yarn --frozen-lockfile
|
||||
yarn format:all:check
|
||||
yarn lint
|
||||
yarn typecheck
|
||||
yarn test:coverage
|
||||
yarn build
|
||||
```
|
||||
|
||||
PR-Agent、编辑器诊断和人工 review 可以补充判断,但不能替代可重复执行的项目命令。
|
||||
|
||||
## Agent 与开发者提交规范
|
||||
|
||||
无论改动由 Agent 还是人工完成,提交前遵循同一顺序:
|
||||
|
||||
1. 只对本次新增或修改的文件执行 Prettier。
|
||||
2. 运行全仓只读 lint。
|
||||
3. 运行 typecheck。
|
||||
4. 按影响面运行聚焦测试;提交 PR 前运行覆盖率门禁。
|
||||
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. 组件开发规范
|
||||
@@ -143,8 +139,8 @@ const emit = defineEmits(['action', 'switch', 'close'])
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
// 页面逻辑代码...
|
||||
@@ -183,16 +179,16 @@ function notifyClose() {
|
||||
const props = defineProps({
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
default: () => ({}),
|
||||
},
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
default: () => {},
|
||||
},
|
||||
})
|
||||
|
||||
// 配置数据
|
||||
const config = ref({...props.initialConfig})
|
||||
const config = ref({ ...props.initialConfig })
|
||||
|
||||
// 自定义事件,用于保存配置
|
||||
const emit = defineEmits(['save', 'close', 'switch'])
|
||||
@@ -217,7 +213,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>
|
||||
|
||||
@@ -238,12 +234,12 @@ function notifyClose() {
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
default: () => ({}),
|
||||
},
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 仪表板逻辑...
|
||||
@@ -276,11 +272,11 @@ const props = defineProps({
|
||||
|
||||
主应用传入的 props:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
| 属性 | 说明 |
|
||||
| ---------- | ----------------------------------------------------- |
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
@@ -300,18 +296,37 @@ const emit = defineEmits(['action'])
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5.5 调用主应用 Toast
|
||||
|
||||
`Page`、`Config`、`Dashboard` 与 `AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例,不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
const toast = inject<any>('moviepilot:toast', null)
|
||||
|
||||
// 保存完成后调用主应用的统一通知。
|
||||
function saveComplete() {
|
||||
toast?.success('保存成功')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
可用方法与主项目 `vue-toastification` 一致,包括 `success`、`info`、`warning` 和 `error`。注入不存在时应静默降级,关键错误仍需保留页面内状态提示。
|
||||
|
||||
#### 后端:注册侧栏入口
|
||||
|
||||
插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
|
||||
| `title` | 侧栏显示标题 |
|
||||
| `icon` | MDI 图标名,如 `mdi-rss` |
|
||||
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
|
||||
| 字段 | 说明 |
|
||||
| ------------ | ------------------------------------------------------------------------------------- |
|
||||
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
|
||||
| `title` | 侧栏显示标题 |
|
||||
| `icon` | MDI 图标名,如 `mdi-rss` |
|
||||
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
|
||||
| `permission` | 可选:`subscribe` / `discovery` / `search` / `manage` / `admin`,与主应用菜单权限一致 |
|
||||
| `order` | 可选:同组内排序,数值越小越靠前 |
|
||||
| `order` | 可选:同组内排序,数值越小越靠前 |
|
||||
|
||||
```python
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
@@ -333,16 +348,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 +380,7 @@ yarn build
|
||||
|
||||
- 将生成的dist文件夹上传到插件后端目录下(默认为`dist/assets`)
|
||||
|
||||
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*`、`_plugin-vue_export-helper-*`、`remoteEntry.js`
|
||||
|
||||
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*`、`_plugin-vue_export-helper-*`、`remoteEntry.js`
|
||||
|
||||
- 在插件的后端python代码中,实现以下方法来集成远程组件:
|
||||
|
||||
@@ -380,7 +394,8 @@ def get_render_mode() -> Tuple[str, str]:
|
||||
return "vue", "dist/assets"
|
||||
```
|
||||
|
||||
- 需要在插件前端页面调用后端接口时,通过传入的api模块发起调用,后端api接口声明认证类型为:`bear`
|
||||
- 需要在插件前端页面调用后端接口时,通过传入的api模块发起调用,后端api接口声明认证类型为:`bear`
|
||||
|
||||
```typescript
|
||||
// 演示使用api模块调用插件接口
|
||||
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
|
||||
@@ -402,7 +417,6 @@ def get_api(self) -> List[Dict[str, Any]]:
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
## 7. 调试与排错
|
||||
|
||||
### 常见问题
|
||||
@@ -449,24 +463,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 +485,4 @@ export default defineConfig({
|
||||
|
||||
---
|
||||
|
||||
如有问题,请提交Issue。
|
||||
如有问题,请提交Issue。
|
||||
|
||||
95
docs/testing.md
Normal file
95
docs/testing.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# 单元测试架构
|
||||
|
||||
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 实例。
|
||||
- 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 恢复。
|
||||
- 不使用大面积快照或覆盖率占位用例。
|
||||
|
||||
## 新增测试
|
||||
|
||||
1. 业务测试在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`;工具链配置契约测试放在 `tests/config/`。
|
||||
2. 纯函数、store 和无渲染模块直接使用 Vitest;Vue 组件使用标准渲染入口。
|
||||
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
|
||||
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
|
||||
5. 核心覆盖范围发生变化时,同步更新 `vite.config.ts` 的 `coverage.include`。
|
||||
6. 提交前运行测试、覆盖率、类型检查、lint 和生产构建。
|
||||
|
||||
## 配置边界
|
||||
|
||||
Vitest 收集 `src/**/__tests__/**/*.spec.ts` 和 `tests/config/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。配置契约测试随全量测试执行,但不加入业务源码覆盖率统计范围。
|
||||
|
||||
当前核心覆盖范围在 `vite.config.ts` 的 `coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%,Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%,Branches 不低于 75%。覆盖率报告写入 `coverage/`。
|
||||
|
||||
## 命令与 CI
|
||||
|
||||
```sh
|
||||
yarn test # watch 模式
|
||||
yarn test:run # 单次运行
|
||||
yarn test:coverage # 单次运行并检查覆盖率
|
||||
yarn test:lint-config # 聚焦执行 ESLint 配置契约测试
|
||||
yarn typecheck
|
||||
yarn lint
|
||||
yarn build
|
||||
```
|
||||
|
||||
Pull Request 工作流使用 Node 24 LTS 和 frozen lockfile。`unit-tests` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查,当前处于普通 check 观察阶段,不改变既有 required checks。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
|
||||
1240
eslint-suppressions.json
Normal file
1240
eslint-suppressions.json
Normal file
File diff suppressed because it is too large
Load Diff
195
eslint.config.js
Normal file
195
eslint.config.js
Normal file
@@ -0,0 +1,195 @@
|
||||
import js from '@eslint/js'
|
||||
import sonarjs from 'eslint-plugin-sonarjs'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
const javascriptFiles = ['**/*.{js,mjs,cjs,jsx}']
|
||||
|
||||
const typescriptFiles = ['**/*.{ts,tsx,mts,cts}', '**/*.vue']
|
||||
|
||||
const managedScriptExtensions = 'js,mjs,cjs,jsx,ts,tsx,mts,cts'
|
||||
|
||||
const managedFiles = [...javascriptFiles, ...typescriptFiles]
|
||||
|
||||
const browserFiles = [
|
||||
`src/**/*.{${managedScriptExtensions},vue}`,
|
||||
`examples/**/src/**/*.{${managedScriptExtensions},vue}`,
|
||||
]
|
||||
|
||||
const nodeFiles = ['**/*.config.{js,mjs,cjs,ts,mts,cts}', 'public/service.js', 'scripts/**/*.{js,mjs,cjs,ts,mts,cts}']
|
||||
|
||||
// TypeScript 关闭核心 no-undef;显式禁止浏览器域中的 Node-only globals,保证 JS、TS 与 Vue 使用同一运行时边界。
|
||||
const browserGlobalNames = new Set(Object.keys(globals.browser))
|
||||
const nodeOnlyGlobalRestrictions = Object.keys(globals.node)
|
||||
.filter(name => !browserGlobalNames.has(name))
|
||||
.sort()
|
||||
.map(name => ({
|
||||
name,
|
||||
message: `'${name}' is only available in Node.js code.`,
|
||||
}))
|
||||
|
||||
const restrictedCoreSourcePattern = '/(?:^|\\/)@core(?:\\/|$)/'
|
||||
|
||||
// @layouts 是 @core 的下游依赖;禁止反向引用,避免两层形成直接循环。
|
||||
const layoutCoreImportRestrictions = [
|
||||
'ImportDeclaration',
|
||||
'ExportNamedDeclaration',
|
||||
'ExportAllDeclaration',
|
||||
'ImportExpression',
|
||||
].map(nodeType => ({
|
||||
selector: `${nodeType}[source.value=${restrictedCoreSourcePattern}]`,
|
||||
message: "The '@layouts' module must not import from '@core'.",
|
||||
}))
|
||||
|
||||
const nonLiteralLayoutDynamicImportRestriction = {
|
||||
selector: "ImportExpression:not([source.type='Literal'])",
|
||||
message: 'Dynamic imports in @layouts must use a literal path so module boundaries remain statically verifiable.',
|
||||
}
|
||||
|
||||
const viteGlobCallSelectors = [
|
||||
"CallExpression[callee.object.type='MetaProperty'][callee.object.meta.name='import'][callee.object.property.name='meta'][callee.property.name=/^glob(?:Eager)?$/]",
|
||||
"CallExpression[callee.object.type='MetaProperty'][callee.object.meta.name='import'][callee.object.property.name='meta'][callee.property.value=/^glob(?:Eager)?$/]",
|
||||
]
|
||||
|
||||
const layoutGlobRestrictions = viteGlobCallSelectors.map(callSelector => ({
|
||||
selector: callSelector,
|
||||
message:
|
||||
'import.meta.glob is not allowed in @layouts because wildcard targets cannot be proven to stay within the module boundary.',
|
||||
}))
|
||||
|
||||
const sonarRules = {
|
||||
'sonarjs/array-callback-without-return': 'error',
|
||||
'sonarjs/code-eval': 'error',
|
||||
'sonarjs/empty-string-repetition': 'error',
|
||||
'sonarjs/no-all-duplicated-branches': 'error',
|
||||
'sonarjs/no-dead-store': 'error',
|
||||
'sonarjs/no-duplicated-branches': 'error',
|
||||
'sonarjs/no-element-overwrite': 'error',
|
||||
'sonarjs/no-hardcoded-passwords': 'error',
|
||||
'sonarjs/no-hardcoded-secrets': 'error',
|
||||
'sonarjs/no-identical-conditions': 'error',
|
||||
'sonarjs/no-identical-expressions': 'error',
|
||||
'sonarjs/no-ignored-exceptions': 'error',
|
||||
'sonarjs/no-unthrown-error': 'error',
|
||||
'sonarjs/no-use-of-empty-return-value': 'error',
|
||||
'sonarjs/reduce-initial-value': 'error',
|
||||
'sonarjs/slow-regex': 'error',
|
||||
'sonarjs/stateful-regex': 'error',
|
||||
'sonarjs/super-linear-regex': 'error',
|
||||
}
|
||||
|
||||
const typescriptConfigs = tseslint.configs.recommended.map(config => ({
|
||||
...config,
|
||||
files: typescriptFiles,
|
||||
}))
|
||||
|
||||
const vueConfigs = pluginVue.configs['flat/essential'].map(config => ({
|
||||
...config,
|
||||
files: ['**/*.vue'],
|
||||
}))
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores([
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/dev-dist/**',
|
||||
'**/coverage/**',
|
||||
'**/.worktrees/**',
|
||||
'**/vite.config.*.timestamp-*.mjs',
|
||||
'public/plugin_icon/**',
|
||||
'src/@iconify/**',
|
||||
'**/*.d.ts',
|
||||
]),
|
||||
{
|
||||
...js.configs.recommended,
|
||||
name: 'moviepilot/javascript',
|
||||
files: managedFiles,
|
||||
},
|
||||
...typescriptConfigs,
|
||||
...vueConfigs,
|
||||
{
|
||||
name: 'moviepilot/vue-contracts',
|
||||
files: ['**/*.vue'],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/valid-v-slot': [
|
||||
'error',
|
||||
{
|
||||
allowModifiers: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/language-options',
|
||||
files: managedFiles,
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'off',
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/browser-globals',
|
||||
files: browserFiles,
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'no-restricted-globals': ['error', ...nodeOnlyGlobalRestrictions],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/node-globals',
|
||||
files: nodeFiles,
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
// Node 配置可能位于浏览器源码目录;显式覆盖浏览器专用限制,避免 flat config 合并后误报。
|
||||
rules: {
|
||||
'no-restricted-globals': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/sonarjs',
|
||||
files: managedFiles,
|
||||
plugins: {
|
||||
sonarjs,
|
||||
},
|
||||
rules: sonarRules,
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/sonarjs-locales',
|
||||
files: [`src/locales/**/*.{${managedScriptExtensions}}`],
|
||||
rules: {
|
||||
'sonarjs/no-hardcoded-passwords': 'off',
|
||||
'sonarjs/no-hardcoded-secrets': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/source-hygiene',
|
||||
files: managedFiles,
|
||||
rules: {
|
||||
'no-debugger': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'moviepilot/layout-boundary',
|
||||
files: [`src/@layouts/**/*.{${managedScriptExtensions},vue}`],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...layoutCoreImportRestrictions,
|
||||
...layoutGlobRestrictions,
|
||||
nonLiteralLayoutDynamicImportRestriction,
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
212
index.html
212
index.html
@@ -4,7 +4,7 @@
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
--initial-loader-bg: #0E1116;
|
||||
--initial-loader-color: #9155FD;
|
||||
--initial-loader-color: #8D51F9;
|
||||
--initial-loader-height: 100svh;
|
||||
--initial-loader-width: 100vw;
|
||||
--initial-color-scheme: dark;
|
||||
@@ -18,7 +18,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 核心viewport设置 - 针对PWA优化 -->
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no, interactive-widget=resizes-content" />
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
|
||||
|
||||
<!-- 防止缩放和选择,提供原生应用体验 -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
|
||||
@@ -33,9 +33,9 @@
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
|
||||
<!-- PWA - 基础图标 -->
|
||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="64x64" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" sizes="192x192" />
|
||||
<link id="theme-favicon" rel="icon" type="image/svg+xml" href="/logo.svg" sizes="any" />
|
||||
|
||||
<!-- iOS Safari PWA 优化 -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
@@ -171,6 +171,11 @@
|
||||
display: block;
|
||||
block-size: auto;
|
||||
inline-size: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.loading-logo img[data-theme-ready='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.loading-footer {
|
||||
@@ -273,11 +278,11 @@
|
||||
}
|
||||
|
||||
#timeout-btn {
|
||||
color: var(--initial-loader-color, #9155FD);
|
||||
color: var(--initial-loader-color, #8D51F9);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
margin-inline-start: 8px;
|
||||
border-bottom: 1px solid var(--initial-loader-color, #9155FD);
|
||||
border-bottom: 1px solid var(--initial-loader-color, #8D51F9);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -303,7 +308,7 @@
|
||||
const launchThemePalettes = {
|
||||
light: {
|
||||
background: '#F4F5FA',
|
||||
primary: '#9155FD',
|
||||
primary: '#8D51F9',
|
||||
},
|
||||
dark: {
|
||||
background: '#0E1116',
|
||||
@@ -311,7 +316,7 @@
|
||||
},
|
||||
purple: {
|
||||
background: '#28243D',
|
||||
primary: '#9155FD',
|
||||
primary: '#8D51F9',
|
||||
},
|
||||
transparent: {
|
||||
background: '#1C1C1C',
|
||||
@@ -362,6 +367,194 @@
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
|
||||
let logoSvgSourcePromise
|
||||
let pendingFaviconColor = '#8D51F9'
|
||||
const themeLogoCacheKey = 'moviepilot-themed-logo-cache'
|
||||
|
||||
const sourceLogoPalette = {
|
||||
'rgb(141,81,249)': 'primary',
|
||||
'rgb(165,118,255)': 'light',
|
||||
'rgb(211,187,255)': 'highlight',
|
||||
'rgb(116,50,223)': 'dark',
|
||||
'rgb(110,38,217)': 'darker',
|
||||
'rgb(104,0,197)': 'deep',
|
||||
'rgb(91,0,197)': 'deepest',
|
||||
}
|
||||
|
||||
const sourceLogoRgb = {
|
||||
primary: [141, 81, 249],
|
||||
light: [165, 118, 255],
|
||||
highlight: [211, 187, 255],
|
||||
dark: [116, 50, 223],
|
||||
darker: [110, 38, 217],
|
||||
deep: [104, 0, 197],
|
||||
deepest: [91, 0, 197],
|
||||
}
|
||||
|
||||
function clampLogoChannel(value, min = 0, max = 1) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function logoRgbToHsl([red, green, blue]) {
|
||||
const r = red / 255
|
||||
const g = green / 255
|
||||
const b = blue / 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
const delta = max - min
|
||||
const l = (max + min) / 2
|
||||
|
||||
if (delta === 0) return { h: 0, l, s: 0 }
|
||||
|
||||
const s = delta / (1 - Math.abs(2 * l - 1))
|
||||
let h = 0
|
||||
|
||||
if (max === r) h = ((g - b) / delta) % 6
|
||||
else if (max === g) h = (b - r) / delta + 2
|
||||
else h = (r - g) / delta + 4
|
||||
|
||||
return { h: (h * 60 + 360) % 360, l, s }
|
||||
}
|
||||
|
||||
function logoHslToRgb({ h, l, s }) {
|
||||
const chroma = (1 - Math.abs(2 * l - 1)) * s
|
||||
const segment = h / 60
|
||||
const secondary = chroma * (1 - Math.abs((segment % 2) - 1))
|
||||
let channels
|
||||
|
||||
if (segment < 1) channels = [chroma, secondary, 0]
|
||||
else if (segment < 2) channels = [secondary, chroma, 0]
|
||||
else if (segment < 3) channels = [0, chroma, secondary]
|
||||
else if (segment < 4) channels = [0, secondary, chroma]
|
||||
else if (segment < 5) channels = [secondary, 0, chroma]
|
||||
else channels = [chroma, 0, secondary]
|
||||
|
||||
const offset = l - chroma / 2
|
||||
const rgb = channels.map(channel => Math.round((channel + offset) * 255))
|
||||
|
||||
return `rgb(${rgb.join(',')})`
|
||||
}
|
||||
|
||||
function shiftLogoTone(color, hueOffset, lightnessOffset, saturationScale = 1) {
|
||||
return logoHslToRgb({
|
||||
h: (color.h + hueOffset + 360) % 360,
|
||||
l: clampLogoChannel(color.l + lightnessOffset, 0.08, 0.92),
|
||||
s: clampLogoChannel(color.s * saturationScale),
|
||||
})
|
||||
}
|
||||
|
||||
function createLaunchLogoPalette(primaryColor) {
|
||||
const normalized = primaryColor.slice(1)
|
||||
const rgb = [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16))
|
||||
const hsl = logoRgbToHsl(rgb)
|
||||
const sourcePrimaryHsl = logoRgbToHsl(sourceLogoRgb.primary)
|
||||
const lightDirection = hsl.l >= 0.78 ? -1 : 1
|
||||
const darkDirection = hsl.l <= 0.22 ? 1 : -1
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(sourceLogoRgb).map(([key, sourceRgb]) => {
|
||||
if (key === 'primary') return [key, `rgb(${rgb.join(',')})`]
|
||||
|
||||
const sourceHsl = logoRgbToHsl(sourceRgb)
|
||||
const hueOffset = sourceHsl.h - sourcePrimaryHsl.h
|
||||
const sourceLightnessDelta = sourceHsl.l - sourcePrimaryHsl.l
|
||||
const lightnessDelta =
|
||||
Math.abs(sourceLightnessDelta) * (sourceLightnessDelta >= 0 ? lightDirection : darkDirection)
|
||||
const saturationScale = sourcePrimaryHsl.s ? sourceHsl.s / sourcePrimaryHsl.s : 1
|
||||
|
||||
return [key, shiftLogoTone(hsl, hueOffset, lightnessDelta, saturationScale)]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function createThemedLogoDataUrl(svgSource, primaryColor) {
|
||||
const palette = createLaunchLogoPalette(primaryColor)
|
||||
const themedSvg = Object.entries(sourceLogoPalette).reduce(
|
||||
(svg, [sourceColor, paletteKey]) => svg.replaceAll(sourceColor, palette[paletteKey]),
|
||||
svgSource,
|
||||
)
|
||||
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(themedSvg)}`
|
||||
}
|
||||
|
||||
function withLoadingLogo(callback) {
|
||||
const loadingLogo = document.querySelector('.loading-logo img')
|
||||
if (loadingLogo) {
|
||||
callback(loadingLogo)
|
||||
return
|
||||
}
|
||||
|
||||
if (document.readyState !== 'loading') return
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const nextLoadingLogo = document.querySelector('.loading-logo img')
|
||||
if (!nextLoadingLogo) return
|
||||
|
||||
observer.disconnect()
|
||||
callback(nextLoadingLogo)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
function applyThemedLogoUrl(themedLogoUrl) {
|
||||
const faviconLink = document.querySelector('#theme-favicon')
|
||||
|
||||
faviconLink?.setAttribute('type', 'image/svg+xml')
|
||||
faviconLink?.setAttribute('href', themedLogoUrl)
|
||||
|
||||
withLoadingLogo(loadingLogo => {
|
||||
const revealLogo = () => loadingLogo.setAttribute('data-theme-ready', 'true')
|
||||
loadingLogo.addEventListener('load', revealLogo, { once: true })
|
||||
loadingLogo.setAttribute('src', themedLogoUrl)
|
||||
if (loadingLogo.complete) revealLogo()
|
||||
})
|
||||
}
|
||||
|
||||
function revealOriginalLoadingLogo() {
|
||||
withLoadingLogo(loadingLogo => loadingLogo.setAttribute('data-theme-ready', 'true'))
|
||||
}
|
||||
|
||||
// 启动层和 Tab 图标共享原始矢量结构,主题切换只替换色阶,不破坏分面和透明高光。
|
||||
function syncThemeFavicon(primaryColor) {
|
||||
if (!/^#[0-9a-f]{6}$/i.test(primaryColor)) return
|
||||
|
||||
pendingFaviconColor = primaryColor
|
||||
|
||||
try {
|
||||
const cachedLogo = JSON.parse(localStorage.getItem(themeLogoCacheKey) || 'null')
|
||||
if (cachedLogo?.color === primaryColor && typeof cachedLogo.url === 'string') applyThemedLogoUrl(cachedLogo.url)
|
||||
} catch {
|
||||
// 缓存异常不影响根据品牌源文件重新生成主题标识。
|
||||
}
|
||||
|
||||
logoSvgSourcePromise ||= fetch('/logo.svg').then(response => {
|
||||
if (!response.ok) throw new Error(`Logo SVG request failed: ${response.status}`)
|
||||
return response.text()
|
||||
})
|
||||
|
||||
logoSvgSourcePromise
|
||||
.then(svgSource => {
|
||||
if (primaryColor !== pendingFaviconColor) return
|
||||
|
||||
const themedLogoUrl = createThemedLogoDataUrl(svgSource, primaryColor)
|
||||
applyThemedLogoUrl(themedLogoUrl)
|
||||
|
||||
try {
|
||||
localStorage.setItem(themeLogoCacheKey, JSON.stringify({ color: primaryColor, url: themedLogoUrl }))
|
||||
} catch {
|
||||
// 存储空间不可用时仍保留当前页面内的主题标识。
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 原始 SVG 始终保留为无网络或解析异常时的可见回退。
|
||||
revealOriginalLoadingLogo()
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('moviepilot-theme-primary-color-change', event => {
|
||||
syncThemeFavicon(event.detail?.color)
|
||||
})
|
||||
|
||||
function applyLaunchThemeChrome() {
|
||||
const themePreference = getSavedThemePreference()
|
||||
const resolvedLaunchTheme = resolveLaunchTheme(themePreference)
|
||||
@@ -391,6 +584,7 @@
|
||||
|
||||
setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark')
|
||||
syncThemeColorMeta(loaderColor)
|
||||
syncThemeFavicon(primaryColor)
|
||||
|
||||
return {
|
||||
background: loaderColor,
|
||||
|
||||
57
package.json
57
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.9",
|
||||
"version": "2.14.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
@@ -9,8 +9,18 @@
|
||||
"prebuild": "npm run build:icons",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5050",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:lint-config": "vitest run tests/config/eslint-config.spec.ts",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:suppressions:prune": "eslint . --prune-suppressions",
|
||||
"format": "node scripts/format-changed.mjs --write",
|
||||
"format:check": "node scripts/format-changed.mjs --check",
|
||||
"format:all:check": "prettier . --check",
|
||||
"test:format-config": "vitest run tests/config/format-changed.spec.ts tests/config/frontend-workflow.spec.ts",
|
||||
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
|
||||
"postinstall": "npm run build:icons",
|
||||
"pkg": "pkg . -t node18-win-x64 -o MoviePilot-Frontend.exe"
|
||||
@@ -20,6 +30,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 +64,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 +72,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 +89,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 +99,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 +113,23 @@
|
||||
"@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",
|
||||
"stylelint": "^16.13.2",
|
||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||
"stylelint-config-standard-scss": "^14.0.0",
|
||||
@@ -114,6 +137,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 +147,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.18",
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,23 @@ http {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location = /service-worker.js {
|
||||
# Service Worker 必须保持稳定 URL 并每次重新验证,避免前端更新后继续注册旧版本。
|
||||
expires off;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
root html;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
# Web App Manifest 参与 PWA 安装与资源发现,不能跟普通静态资源一起长缓存。
|
||||
expires off;
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
root html;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
# 静态资源
|
||||
expires 1y;
|
||||
@@ -44,8 +61,7 @@ http {
|
||||
|
||||
location /assets {
|
||||
# 静态资源
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
root html;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
12
scripts/check-season-label.ts
Normal file
12
scripts/check-season-label.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { formatSeasonLabel } from '../src/@core/utils/season.ts'
|
||||
|
||||
assert.equal(formatSeasonLabel(0, '特别篇'), '特别篇')
|
||||
assert.equal(formatSeasonLabel('0', 'Specials'), 'Specials')
|
||||
assert.equal(formatSeasonLabel(1, '特别篇'), 'S01')
|
||||
assert.equal(formatSeasonLabel('12', '特别篇'), 'S12')
|
||||
assert.equal(formatSeasonLabel(null, '特别篇'), '')
|
||||
assert.equal(formatSeasonLabel(undefined, '特别篇'), '')
|
||||
|
||||
console.log('season label checks passed')
|
||||
208
scripts/format-changed.mjs
Normal file
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/v2', 'refs/remotes/origin/v2', 'refs/heads/v2']
|
||||
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
|
||||
}
|
||||
|
||||
/** 为本地命令选择与当前提交具有共同祖先的 v2 基线。 */
|
||||
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('无法定位可比较的 v2 基线,请通过 --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
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
// 👉 Apex chart
|
||||
.apexcharts-canvas {
|
||||
// 图表仅保留纵向页面滚动,禁止双指缩放等触摸手势。
|
||||
touch-action: pan-y;
|
||||
|
||||
// For RTL alignment
|
||||
.apexcharts-yaxis-texts-g {
|
||||
text-align: start;
|
||||
|
||||
@@ -66,6 +66,43 @@ export const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : valu
|
||||
// 格式化为Sxx
|
||||
export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
|
||||
|
||||
/**
|
||||
* 格式化为 SxxExx 季集标识,多个连续集会合并为范围。
|
||||
*/
|
||||
export function formatSeasonEpisode(
|
||||
season: number | string | null | undefined,
|
||||
episodeNumbers: number[],
|
||||
): string {
|
||||
const seasonText = season === null || season === undefined || season === '' ? '' : formatSeason(String(season))
|
||||
const normalizedNumbers = [...new Set(episodeNumbers)]
|
||||
.map(number => Number(number))
|
||||
.filter(number => Number.isFinite(number) && number > 0)
|
||||
.sort((first, second) => first - second)
|
||||
|
||||
if (!normalizedNumbers.length) return seasonText
|
||||
|
||||
const formatEpisode = (number: number) => `E${String(number).padStart(2, '0')}`
|
||||
const ranges: string[] = []
|
||||
let start = normalizedNumbers[0]
|
||||
let end = normalizedNumbers[0]
|
||||
|
||||
for (let index = 1; index < normalizedNumbers.length; index++) {
|
||||
const currentNumber = normalizedNumbers[index]
|
||||
|
||||
if (currentNumber === end + 1) {
|
||||
end = currentNumber
|
||||
} else {
|
||||
ranges.push(start === end ? formatEpisode(start) : `${formatEpisode(start)}-${formatEpisode(end)}`)
|
||||
start = currentNumber
|
||||
end = currentNumber
|
||||
}
|
||||
}
|
||||
|
||||
ranges.push(start === end ? formatEpisode(start) : `${formatEpisode(start)}-${formatEpisode(end)}`)
|
||||
|
||||
return `${seasonText}${ranges.join('、')}`
|
||||
}
|
||||
|
||||
// 格式化为xx[TGMK]B
|
||||
export function formatFileSize(bytes: number, decimals = 2, prefix = false) {
|
||||
// 负数标记
|
||||
|
||||
15
src/@core/utils/season.ts
Normal file
15
src/@core/utils/season.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 格式化用户可见的季标签。
|
||||
*
|
||||
* TMDB 使用季号 0 表示特别季;调用方传入当前语言的特别季名称,
|
||||
* 其余季号保持 MoviePilot 现有的 Sxx 展示口径。
|
||||
*/
|
||||
export function formatSeasonLabel(
|
||||
season: number | string | null | undefined,
|
||||
specialsLabel: string,
|
||||
): string {
|
||||
if (season === null || season === undefined || season === '') return ''
|
||||
if (Number(season) === 0) return specialsLabel
|
||||
|
||||
return `S${String(season).padStart(2, '0')}`
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import logo from '@images/logo.svg?raw'
|
||||
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
|
||||
|
||||
interface Props {
|
||||
tag?: string | Component
|
||||
@@ -51,7 +51,7 @@ 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>
|
||||
|
||||
@@ -170,6 +170,10 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
--layout-navbar-block-size: calc(
|
||||
env(safe-area-inset-top, 0px) + #{variables.$layout-vertical-nav-navbar-height} + var(--navbar-tab-height)
|
||||
);
|
||||
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
min-block-size: 100%;
|
||||
|
||||
@@ -185,13 +189,16 @@ export default defineComponent({
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
|
||||
// iOS Safari 在地址栏收起和惯性滚动时可能把 fixed 顶栏和页面滚动层合成到一起,
|
||||
// 单独提升顶栏图层可避免导航栏短暂上移到安全区下方。
|
||||
backface-visibility: hidden;
|
||||
block-size: var(--layout-navbar-block-size);
|
||||
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
|
||||
inset-block-start: 0;
|
||||
transform: translate3d(0, 0, 0);
|
||||
|
||||
.navbar-content-container {
|
||||
block-size: calc(
|
||||
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height + var(--navbar-tab-height)
|
||||
);
|
||||
block-size: var(--layout-navbar-block-size);
|
||||
}
|
||||
|
||||
@at-root {
|
||||
@@ -266,16 +273,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;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ body {
|
||||
background: rgb(var(--v-theme-background));
|
||||
overscroll-behavior-y: contain;
|
||||
|
||||
--webkit-overflow-scrolling: touch;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
body,
|
||||
|
||||
8
src/@layouts/types.d.ts
vendored
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 {
|
||||
|
||||
360
src/App.vue
360
src/App.vue
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { useTheme } from 'vuetify'
|
||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||
import api from '@/api'
|
||||
import api, { type ConnectionAwareRequestConfig } from '@/api'
|
||||
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
||||
import { SupportedLocale } from '@/types/i18n'
|
||||
@@ -9,16 +9,28 @@ import { checkAndEmitUnreadMessages } from '@/utils/badge'
|
||||
import { preloadImage } from './@core/utils/image'
|
||||
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
||||
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
|
||||
import {
|
||||
applyStoredTransparencySettings,
|
||||
TRANSPARENCY_SETTINGS_CHANGED_EVENT,
|
||||
type TransparencyGlassQuality,
|
||||
type TransparencySettings,
|
||||
} from '@/composables/useTransparencySettings'
|
||||
import { completeLaunchLoading } from '@/composables/useLaunchLoading'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
import {
|
||||
useGlobalOfflineStatus,
|
||||
type ConnectionFailureReason,
|
||||
} from '@/composables/useOfflineStatus'
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
||||
const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 180_000
|
||||
|
||||
// 生效主题
|
||||
const vuetifyTheme = useTheme()
|
||||
@@ -48,6 +60,7 @@ const authStore = useAuthStore()
|
||||
const isLogin = computed(() => authStore.token)
|
||||
const route = useRoute()
|
||||
const { initializePWA } = usePWA()
|
||||
const offlineStatus = useGlobalOfflineStatus()
|
||||
|
||||
// 全局设置store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
@@ -58,65 +71,233 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
|
||||
// 背景图片
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const previousImageIndex = ref<number | null>(null)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
|
||||
const shouldLoadBackgroundImages = computed(
|
||||
() => (!isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||
)
|
||||
const transparentBackgroundBlur = ref(16)
|
||||
const transparencyGlassQuality = ref<TransparencyGlassQuality>(
|
||||
localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight',
|
||||
)
|
||||
const shouldRenderGlobalBlurLayer = computed(
|
||||
() =>
|
||||
shouldUseTransparentBackgroundTreatment.value &&
|
||||
transparentBackgroundBlur.value > 0 &&
|
||||
transparencyGlassQuality.value === 'realtime',
|
||||
)
|
||||
const isRenderThrottled = ref(document.visibilityState === 'hidden')
|
||||
let backgroundRetryTimer: number | null = null
|
||||
let backgroundRequestController: AbortController | null = null
|
||||
let backgroundCrossfadeTimer: number | null = null
|
||||
let authenticatedStateTimer: number | null = null
|
||||
let windowBlurRenderThrottleTimer: number | null = null
|
||||
|
||||
function getStoredNumber(key: string, fallback: number, min: number, max: number) {
|
||||
const parsed = Number.parseFloat(localStorage.getItem(key) || '')
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
// 读取并同步透明主题背景设置到根组件响应式状态。
|
||||
function applyTransparentBackgroundSettings() {
|
||||
const settings = applyStoredTransparencySettings()
|
||||
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
transparentBackgroundBlur.value = settings.backgroundBlur
|
||||
transparencyGlassQuality.value = settings.glassQuality
|
||||
}
|
||||
|
||||
function applyTransparentBackgroundSettings() {
|
||||
document.documentElement.style.setProperty(
|
||||
'--transparent-background-poster-opacity',
|
||||
(1 - getStoredNumber('transparency-background-poster-opacity', 0, 0, 1)).toString(),
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--transparent-background-blur',
|
||||
`${getStoredNumber('transparency-background-blur', 16, 0, 30)}px`,
|
||||
)
|
||||
// 响应透明主题设置变更事件,刷新背景模糊和玻璃质量。
|
||||
function handleTransparencySettingsChanged(event: Event) {
|
||||
const { backgroundBlur, glassQuality } = (event as CustomEvent<TransparencySettings>).detail
|
||||
|
||||
transparentBackgroundBlur.value = backgroundBlur
|
||||
transparencyGlassQuality.value = glassQuality
|
||||
}
|
||||
|
||||
applyTransparentBackgroundSettings()
|
||||
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
|
||||
function clearWindowBlurRenderThrottleTimer() {
|
||||
if (windowBlurRenderThrottleTimer) {
|
||||
window.clearTimeout(windowBlurRenderThrottleTimer)
|
||||
windowBlurRenderThrottleTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
// 如果已经有心跳,则先停止
|
||||
if (heartbeatInterval) {
|
||||
stopHeartbeat()
|
||||
function restoreForegroundRendering() {
|
||||
const wasRenderThrottled = isRenderThrottled.value
|
||||
|
||||
clearWindowBlurRenderThrottleTimer()
|
||||
isRenderThrottled.value = false
|
||||
|
||||
if (wasRenderThrottled && backgroundImages.value.length > 1) {
|
||||
startBackgroundRotation()
|
||||
rotateBackgroundImage()
|
||||
}
|
||||
}
|
||||
|
||||
function throttleBackgroundRendering() {
|
||||
clearWindowBlurRenderThrottleTimer()
|
||||
resetBackgroundCrossfade()
|
||||
isRenderThrottled.value = true
|
||||
}
|
||||
|
||||
function handleWindowBlurRenderThrottle() {
|
||||
clearWindowBlurRenderThrottleTimer()
|
||||
if (document.visibilityState === 'hidden') {
|
||||
throttleBackgroundRendering()
|
||||
return
|
||||
}
|
||||
|
||||
// 开始心跳任务
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
try {
|
||||
if (isLogin.value) {
|
||||
await api.get('system/ping')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Heartbeat request failed:', error)
|
||||
windowBlurRenderThrottleTimer = window.setTimeout(() => {
|
||||
if (document.visibilityState === 'visible' && !document.hasFocus()) {
|
||||
isRenderThrottled.value = true
|
||||
}
|
||||
windowBlurRenderThrottleTimer = null
|
||||
}, WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS)
|
||||
}
|
||||
|
||||
function handleWindowFocusRenderThrottle() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
restoreForegroundRendering()
|
||||
}
|
||||
}
|
||||
|
||||
let heartbeatInterval: number | null = null
|
||||
let connectionRetryTimer: number | null = null
|
||||
let connectionProbePromise: Promise<boolean> | null = null
|
||||
let connectionProbeFailures = 0
|
||||
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
|
||||
|
||||
const SERVER_PROBE_TIMEOUT_MS = 8_000
|
||||
const SERVER_PROBE_FAILURE_THRESHOLD = 2
|
||||
const SERVER_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000] as const
|
||||
|
||||
/** 清除等待中的服务重连任务。 */
|
||||
function clearConnectionRetryTimer() {
|
||||
if (!connectionRetryTimer) return
|
||||
|
||||
window.clearTimeout(connectionRetryTimer)
|
||||
connectionRetryTimer = null
|
||||
}
|
||||
|
||||
/** 根据浏览器状态和请求错误判断本次探测失败原因。 */
|
||||
function resolveProbeFailureReason(error: unknown): ConnectionFailureReason {
|
||||
if (!offlineStatus.browserOnline.value) return 'browser-offline'
|
||||
|
||||
const errorCode = (error as { code?: string } | null)?.code
|
||||
if (errorCode === 'ECONNABORTED' || errorCode === 'ETIMEDOUT') return 'timeout'
|
||||
|
||||
return 'server-unreachable'
|
||||
}
|
||||
|
||||
/** 按退避间隔安排下一次 MoviePilot 服务探测。 */
|
||||
function scheduleConnectionRetry() {
|
||||
clearConnectionRetryTimer()
|
||||
|
||||
const retryIndex = Math.min(Math.max(connectionProbeFailures - 1, 0), SERVER_RETRY_DELAYS_MS.length - 1)
|
||||
connectionRetryTimer = window.setTimeout(() => {
|
||||
connectionRetryTimer = null
|
||||
void probeServerConnection()
|
||||
}, SERVER_RETRY_DELAYS_MS[retryIndex])
|
||||
}
|
||||
|
||||
/** 使用后端 ping 接口执行去重后的权威服务连通性探测。 */
|
||||
async function probeServerConnection(showChecking = false): Promise<boolean> {
|
||||
if (!isLogin.value) return false
|
||||
if (connectionProbePromise) return connectionProbePromise
|
||||
|
||||
clearConnectionRetryTimer()
|
||||
if (showChecking) offlineStatus.markConnectionChecking(offlineStatus.connectionReason.value ?? undefined)
|
||||
|
||||
const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value
|
||||
const probePromise = (async () => {
|
||||
try {
|
||||
await api.get(
|
||||
'system/ping',
|
||||
{
|
||||
skipConnectionTracking: true,
|
||||
timeout: SERVER_PROBE_TIMEOUT_MS,
|
||||
} as ConnectionAwareRequestConfig,
|
||||
)
|
||||
connectionProbeFailures = 0
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!isLogin.value) {
|
||||
offlineStatus.markServerOnline()
|
||||
return false
|
||||
}
|
||||
|
||||
// 探测期间若已有其他接口成功,则以更新的成功响应为准,避免旧失败覆盖新状态。
|
||||
if (offlineStatus.serverSuccessSequence.value > successSequenceAtProbeStart) {
|
||||
connectionProbeFailures = 0
|
||||
return true
|
||||
}
|
||||
|
||||
connectionProbeFailures += 1
|
||||
const failureReason = resolveProbeFailureReason(error)
|
||||
|
||||
if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) {
|
||||
offlineStatus.markServerOffline(failureReason)
|
||||
} else {
|
||||
offlineStatus.markConnectionChecking(failureReason)
|
||||
}
|
||||
|
||||
scheduleConnectionRetry()
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
connectionProbePromise = probePromise
|
||||
try {
|
||||
return await probePromise
|
||||
} finally {
|
||||
if (connectionProbePromise === probePromise) connectionProbePromise = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动即时服务探测和五分钟在线心跳。 */
|
||||
function startHeartbeat() {
|
||||
if (heartbeatInterval) window.clearInterval(heartbeatInterval)
|
||||
|
||||
void probeServerConnection()
|
||||
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
if (isLogin.value) await probeServerConnection()
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
// 停止心跳
|
||||
const stopHeartbeat = () => {
|
||||
/** 停止心跳和等待中的自动重连任务。 */
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatInterval) {
|
||||
window.clearInterval(heartbeatInterval)
|
||||
heartbeatInterval = null
|
||||
}
|
||||
|
||||
clearConnectionRetryTimer()
|
||||
connectionProbeFailures = 0
|
||||
}
|
||||
|
||||
watch(
|
||||
() => offlineStatus.connectionCheckRequestId.value,
|
||||
() => {
|
||||
if (isLogin.value) void probeServerConnection(true)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => offlineStatus.connectionStatus.value,
|
||||
status => {
|
||||
if (status !== 'online') return
|
||||
connectionProbeFailures = 0
|
||||
clearConnectionRetryTimer()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => offlineStatus.browserOnline.value,
|
||||
browserIsOnline => {
|
||||
if (!isLogin.value) return
|
||||
offlineStatus.requestConnectionCheck(browserIsOnline ? undefined : 'browser-offline')
|
||||
},
|
||||
)
|
||||
|
||||
// 更新data-theme属性以便CSS选择器能正确匹配
|
||||
function updateHtmlThemeAttribute(themeName: string) {
|
||||
document.documentElement.setAttribute('data-theme', themeName)
|
||||
@@ -124,6 +305,7 @@ function updateHtmlThemeAttribute(themeName: string) {
|
||||
syncRootLaunchPalette()
|
||||
}
|
||||
|
||||
// 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。
|
||||
function syncThemePreferenceFromStorage() {
|
||||
themeValue = localStorage.getItem('theme') || 'auto'
|
||||
|
||||
@@ -147,22 +329,60 @@ function syncThemePreferenceFromStorage() {
|
||||
})
|
||||
}
|
||||
|
||||
// 系统配色变化时,在自动主题模式下刷新当前实际主题。
|
||||
function handleSystemThemeChange() {
|
||||
if ((localStorage.getItem('theme') || 'auto') === 'auto') {
|
||||
syncThemePreferenceFromStorage()
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
|
||||
function handleVisibilityThemeSync() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
restoreForegroundRendering()
|
||||
syncThemePreferenceFromStorage()
|
||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||
} else {
|
||||
throttleBackgroundRendering()
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面从缓存或重新聚焦恢复时刷新主题偏好和异常连接状态。 */
|
||||
function handlePageShowThemeSync() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
restoreForegroundRendering()
|
||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||
}
|
||||
syncThemePreferenceFromStorage()
|
||||
}
|
||||
|
||||
// 清理背景图交叉淡入淡出定时器。
|
||||
function clearBackgroundCrossfadeTimer() {
|
||||
if (backgroundCrossfadeTimer) {
|
||||
window.clearTimeout(backgroundCrossfadeTimer)
|
||||
backgroundCrossfadeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 重置背景图交叉淡入淡出状态。
|
||||
function resetBackgroundCrossfade() {
|
||||
clearBackgroundCrossfadeTimer()
|
||||
previousImageIndex.value = null
|
||||
}
|
||||
|
||||
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
|
||||
function activateBackgroundImage(nextIndex: number) {
|
||||
if (nextIndex === activeImageIndex.value) return
|
||||
|
||||
clearBackgroundCrossfadeTimer()
|
||||
previousImageIndex.value = activeImageIndex.value
|
||||
activeImageIndex.value = nextIndex
|
||||
backgroundCrossfadeTimer = window.setTimeout(() => {
|
||||
previousImageIndex.value = null
|
||||
backgroundCrossfadeTimer = null
|
||||
}, BACKGROUND_CROSSFADE_DURATION_MS)
|
||||
}
|
||||
|
||||
// 获取背景图片
|
||||
async function fetchBackgroundImages() {
|
||||
try {
|
||||
@@ -171,6 +391,7 @@ async function fetchBackgroundImages() {
|
||||
backgroundImages.value = await api.get(`/login/wallpapers`, {
|
||||
signal: backgroundRequestController.signal,
|
||||
})
|
||||
resetBackgroundCrossfade()
|
||||
activeImageIndex.value = 0
|
||||
} catch (e) {
|
||||
throw e
|
||||
@@ -179,6 +400,8 @@ async function fetchBackgroundImages() {
|
||||
|
||||
// 背景图片轮换函数
|
||||
function rotateBackgroundImage() {
|
||||
if (isRenderThrottled.value) return
|
||||
|
||||
if (backgroundImages.value.length > 1) {
|
||||
// 计算下一个图片索引
|
||||
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
|
||||
@@ -186,7 +409,7 @@ function rotateBackgroundImage() {
|
||||
preloadImage(backgroundImages.value[nextIndex]).then(success => {
|
||||
// 只有图片成功加载才切换
|
||||
if (success) {
|
||||
activeImageIndex.value = nextIndex
|
||||
activateBackgroundImage(nextIndex)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -211,6 +434,7 @@ function startBackgroundRotation() {
|
||||
}
|
||||
}
|
||||
|
||||
// 停止登录页或透明主题背景图加载、重试和轮播。
|
||||
function stopBackgroundLoading() {
|
||||
backgroundRequestController?.abort()
|
||||
backgroundRequestController = null
|
||||
@@ -220,9 +444,11 @@ function stopBackgroundLoading() {
|
||||
backgroundRetryTimer = null
|
||||
}
|
||||
|
||||
resetBackgroundCrossfade()
|
||||
removeBackgroundTimer('background-rotation')
|
||||
}
|
||||
|
||||
// 初始化登录后的全局设置和用户设置状态。
|
||||
async function initializeAuthenticatedState() {
|
||||
if (!isLogin.value) return
|
||||
|
||||
@@ -235,6 +461,7 @@ async function initializeAuthenticatedState() {
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟初始化登录态数据,避开登录成功后的即时路由跳转窗口。
|
||||
function scheduleAuthenticatedStateInitialization() {
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
@@ -359,6 +586,9 @@ onMounted(async () => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityThemeSync)
|
||||
window.addEventListener('pageshow', handlePageShowThemeSync)
|
||||
window.addEventListener('focus', handlePageShowThemeSync)
|
||||
window.addEventListener('focus', handleWindowFocusRenderThrottle)
|
||||
window.addEventListener('blur', handleWindowBlurRenderThrottle)
|
||||
window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
|
||||
|
||||
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
|
||||
watch(
|
||||
@@ -394,6 +624,7 @@ onMounted(async () => {
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
stopHeartbeat()
|
||||
offlineStatus.markServerOnline()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -405,6 +636,7 @@ onUnmounted(() => {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
clearWindowBlurRenderThrottleTimer()
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
|
||||
@@ -412,29 +644,36 @@ onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
|
||||
window.removeEventListener('pageshow', handlePageShowThemeSync)
|
||||
window.removeEventListener('focus', handlePageShowThemeSync)
|
||||
window.removeEventListener('focus', handleWindowFocusRenderThrottle)
|
||||
window.removeEventListener('blur', handleWindowBlurRenderThrottle)
|
||||
window.removeEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-wrapper">
|
||||
<div class="app-wrapper" :class="{ 'app-wrapper--render-throttled': isRenderThrottled }">
|
||||
<!-- 透明主题背景 -->
|
||||
<div
|
||||
v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)"
|
||||
class="background-container"
|
||||
:class="{ 'is-transparent-theme': isTransparentTheme && isLogin }"
|
||||
:class="{
|
||||
'is-transparent-theme': shouldUseTransparentBackgroundTreatment,
|
||||
'is-transparent-glass-lightweight':
|
||||
shouldUseTransparentBackgroundTreatment && transparencyGlassQuality === 'lightweight',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(imageUrl, index) in backgroundImages"
|
||||
:key="`bg-${index}-${loginStateKey}`"
|
||||
class="background-image"
|
||||
:class="{ 'active': index === activeImageIndex }"
|
||||
:class="{ 'active': index === activeImageIndex, 'previous': index === previousImageIndex }"
|
||||
:style="{ 'backgroundImage': `url(${imageUrl})` }"
|
||||
/>
|
||||
<!-- 全局磨砂层 -->
|
||||
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
|
||||
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
|
||||
</div>
|
||||
<!-- 页面内容 -->
|
||||
<VApp>
|
||||
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
||||
<RouterView />
|
||||
<!-- 全局共享弹窗入口,列表与卡片按需在这里挂载业务弹窗。 -->
|
||||
<SharedDialogHost />
|
||||
@@ -485,18 +724,36 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
&.active {
|
||||
z-index: 2;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.previous {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.background-container.is-transparent-theme .background-image.active {
|
||||
opacity: var(--transparent-background-poster-opacity, 1);
|
||||
}
|
||||
|
||||
.background-container.is-transparent-glass-lightweight .background-image.active,
|
||||
.background-container.is-transparent-glass-lightweight .background-image.previous {
|
||||
filter: blur(var(--transparent-background-blur, 16px));
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.background-container.is-transparent-glass-lightweight .background-image.active::after,
|
||||
.background-container.is-transparent-glass-lightweight .background-image.previous::after {
|
||||
background:
|
||||
linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%),
|
||||
rgba(128, 128, 128, 30%);
|
||||
}
|
||||
|
||||
/* 全局磨砂层 */
|
||||
.global-blur-layer {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
z-index: 3;
|
||||
backdrop-filter: blur(var(--transparent-background-blur, 16px));
|
||||
background-color: rgba(128, 128, 128, 30%);
|
||||
block-size: 100%;
|
||||
@@ -504,4 +761,25 @@ onUnmounted(() => {
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
.app-wrapper--render-throttled {
|
||||
.global-blur-layer {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.login-bg-decor *,
|
||||
.login-logo,
|
||||
.login-logo-wrapper,
|
||||
.login-logo-wrapper::before,
|
||||
.login-title,
|
||||
.login-subtitle,
|
||||
.agent-assistant-fab * {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录页壁纸在 VApp 外层渲染,登录页 VApp 需要透明才能露出壁纸。 */
|
||||
.app-shell--login-wallpaper.v-application {
|
||||
background: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
62
src/__tests__/ace-config.spec.ts
Normal file
62
src/__tests__/ace-config.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import ace from 'ace-builds'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import '@/ace-config'
|
||||
|
||||
interface AceToken {
|
||||
type: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface WordListSyntaxMode {
|
||||
getTokenizer: () => {
|
||||
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
|
||||
}
|
||||
}
|
||||
|
||||
const WordListSyntaxMode = ace.require('ace/mode/word_list_syntax').Mode as new () => WordListSyntaxMode
|
||||
const tokenizer = new WordListSyntaxMode().getTokenizer()
|
||||
|
||||
function tokenize(line: string) {
|
||||
return tokenizer.getLineTokens(line, 'start').tokens
|
||||
}
|
||||
|
||||
describe('word list syntax mode', () => {
|
||||
it('highlights a block word as one field', () => {
|
||||
expect(tokenize('屏蔽词')).toEqual([{ type: 'word_list_block', value: '屏蔽词' }])
|
||||
})
|
||||
|
||||
it('separates replaced and replacement fields without parsing their content', () => {
|
||||
expect(tokenize('旧名.* => 新名 {[tmdbid=123;type=tv]}')).toEqual([
|
||||
{ type: 'word_list_replaced', value: '旧名.*' },
|
||||
{ type: 'keyword.operator.word-list', value: ' => ' },
|
||||
{ type: 'word_list_replacement', value: '新名 {[tmdbid=123;type=tv]}' },
|
||||
])
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -530,6 +530,140 @@ function registerJinja2Mode() {
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.word-list',
|
||||
regex: /^#.*/,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
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) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = WordListHighlightRules
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
;(function (this: any) {
|
||||
this.$id = 'ace/mode/word_list'
|
||||
}).call(Mode.prototype)
|
||||
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/word_list_syntax_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 WordListSyntaxHighlightRules = function (this: any) {
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.word-list',
|
||||
regex: /^#.*/,
|
||||
},
|
||||
{
|
||||
token: [
|
||||
'text',
|
||||
'word_list_replaced',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_replacement',
|
||||
'keyword.operator.word-list',
|
||||
'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_replaced', 'keyword.operator.word-list', 'word_list_replacement', 'text'],
|
||||
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
|
||||
},
|
||||
{
|
||||
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*)$/,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(WordListSyntaxHighlightRules, TextHighlightRules)
|
||||
exports.WordListSyntaxHighlightRules = WordListSyntaxHighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/word_list_syntax',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_syntax_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextMode = require('./text').Mode
|
||||
const WordListSyntaxHighlightRules = require('./word_list_syntax_highlight_rules').WordListSyntaxHighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = WordListSyntaxHighlightRules
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
;(function (this: any) {
|
||||
this.$id = 'ace/mode/word_list_syntax'
|
||||
}).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 +689,5 @@ ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
|
||||
|
||||
registerJinja2Mode()
|
||||
registerWordListMode()
|
||||
ace.require('ace/ext/language_tools')
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import axios from 'axios'
|
||||
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios'
|
||||
import router from '@/router'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
})
|
||||
|
||||
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
|
||||
skipConnectionTracking?: boolean
|
||||
}
|
||||
|
||||
// 声明全局变量类型
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -30,42 +35,68 @@ api.interceptors.request.use(config => {
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
const locale = getCurrentLocale()
|
||||
config.headers['X-MoviePilot-Locale'] = locale
|
||||
config.headers['Accept-Language'] = locale
|
||||
return config
|
||||
})
|
||||
|
||||
// 离线状态管理
|
||||
const globalOfflineStatus = useGlobalOfflineStatus()
|
||||
|
||||
/** 将 Axios 连接错误归类为全局服务探测可识别的原因。 */
|
||||
function resolveConnectionFailureReason(error: AxiosError): 'network-error' | 'timeout' | 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'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
interface LocalizedApiPayload {
|
||||
detail?: unknown
|
||||
detail_i18n?: unknown
|
||||
message?: unknown
|
||||
message_i18n?: unknown
|
||||
}
|
||||
|
||||
/** 前端展示默认使用后端提供的多语言消息,同时不改变后端接口兼容字段。 */
|
||||
function normalizeLocalizedMessage(payload: any): any {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload
|
||||
|
||||
const localizedPayload = payload as LocalizedApiPayload
|
||||
if (typeof localizedPayload.message_i18n === 'string' && localizedPayload.message_i18n) {
|
||||
localizedPayload.message = localizedPayload.message_i18n
|
||||
}
|
||||
if (typeof localizedPayload.detail_i18n === 'string' && localizedPayload.detail_i18n) {
|
||||
localizedPayload.detail = localizedPayload.detail_i18n
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// 添加响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
// 成功响应时,清除应用离线状态并重置连续错误计数
|
||||
globalOfflineStatus.setAppOffline(false)
|
||||
globalOfflineStatus.resetConsecutiveErrors()
|
||||
return response.data
|
||||
// 任意 API 成功响应都可以证明 MoviePilot 服务当前可达。
|
||||
globalOfflineStatus.markServerOnline()
|
||||
return normalizeLocalizedMessage(response.data)
|
||||
},
|
||||
error => {
|
||||
(error: AxiosError) => {
|
||||
if (!error.response) {
|
||||
// 网络错误或请求超时 - 通知离线状态管理系统
|
||||
const isNetworkError =
|
||||
error.code === 'NETWORK_ERROR' ||
|
||||
error.code === 'ERR_NETWORK' ||
|
||||
error.code === 'ECONNABORTED' ||
|
||||
error.name === 'NetworkError'
|
||||
const requestConfig = error.config as ConnectionAwareRequestConfig | undefined
|
||||
const failureReason = resolveConnectionFailureReason(error)
|
||||
|
||||
if (isNetworkError) {
|
||||
let reason = 'Network connection failed'
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
reason = 'Request timeout'
|
||||
}
|
||||
// 记录网络错误,只有连续三次才会设置为离线模式
|
||||
globalOfflineStatus.recordNetworkError(reason)
|
||||
// 普通请求失败只触发权威探测;探测请求自身失败由心跳管理器处理,避免递归。
|
||||
if (!requestConfig?.skipConnectionTracking && failureReason) {
|
||||
globalOfflineStatus.reportNetworkError(failureReason)
|
||||
}
|
||||
|
||||
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') {
|
||||
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||
// 请求超时
|
||||
return Promise.reject(new Error('Request timeout, please try again later'))
|
||||
} else if (error.name === 'AbortError') {
|
||||
@@ -75,12 +106,15 @@ api.interceptors.response.use(
|
||||
// 其他网络错误
|
||||
return Promise.reject(new Error(error.message || 'Network error'))
|
||||
} else if (error.response.status === 403) {
|
||||
normalizeLocalizedMessage(error.response.data)
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 清除登录状态信息
|
||||
authStore.logout()
|
||||
// token验证失败,跳转到登录页面
|
||||
router.push('/login')
|
||||
} else {
|
||||
normalizeLocalizedMessage(error.response.data)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
|
||||
209
src/api/types.ts
209
src/api/types.ts
@@ -49,7 +49,7 @@ export interface Subscribe {
|
||||
// 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数)
|
||||
completed_episode?: number
|
||||
// 附加信息
|
||||
note?: string
|
||||
note?: string | number[]
|
||||
// 状态:N-新建 R-订阅中 P-待定 S-暂停
|
||||
state: string
|
||||
// 最后更新时间
|
||||
@@ -114,6 +114,8 @@ export interface SubscribeShare {
|
||||
tmdbid?: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: number
|
||||
// 季号
|
||||
season?: number
|
||||
// 海报
|
||||
@@ -656,6 +658,8 @@ export interface Plugin {
|
||||
system_version_message?: string
|
||||
// 主系统版本限定范围
|
||||
system_version?: string
|
||||
// 是否声明支持通过 GitHub Release 资产安装
|
||||
release?: boolean
|
||||
// 是否本地插件
|
||||
is_local?: boolean
|
||||
// 插件仓库地址
|
||||
@@ -668,6 +672,38 @@ export interface Plugin {
|
||||
page_open?: boolean
|
||||
}
|
||||
|
||||
// 插件 Release 可安装版本
|
||||
export interface PluginReleaseVersion {
|
||||
// 插件版本
|
||||
version: string
|
||||
// GitHub Release tag
|
||||
tag_name: string
|
||||
// Release 标题
|
||||
name?: string
|
||||
// 发布时间
|
||||
published_at?: string
|
||||
// Release 说明
|
||||
body?: string
|
||||
// 匹配到的资产文件名
|
||||
asset_name?: string
|
||||
// 是否为当前市场最新版本
|
||||
is_latest?: boolean
|
||||
// 是否为本地已安装版本
|
||||
is_current?: boolean
|
||||
}
|
||||
|
||||
// 插件 Release 可安装版本响应
|
||||
export interface PluginReleaseVersionsResponse {
|
||||
// 当前插件是否存在可直接安装的 Release 资产
|
||||
release_supported: boolean
|
||||
// 当前市场 package 声明的最新版本
|
||||
latest_version?: string | null
|
||||
// 本地已安装版本
|
||||
current_version?: string | null
|
||||
// 可安装版本列表
|
||||
items: PluginReleaseVersion[]
|
||||
}
|
||||
|
||||
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
|
||||
export interface PluginSidebarNavItem {
|
||||
plugin_id: string
|
||||
@@ -966,6 +1002,12 @@ export interface MediaStatistic {
|
||||
episode_count: number | null
|
||||
// 用户数量
|
||||
user_count: number
|
||||
// 本月新增电影数量
|
||||
movie_count_month: number
|
||||
// 本月新增电视剧数量
|
||||
tv_count_month: number
|
||||
// 本月新增剧集数量
|
||||
episode_count_month: number
|
||||
}
|
||||
|
||||
// 后台进程
|
||||
@@ -986,6 +1028,18 @@ export interface Process {
|
||||
memory: number
|
||||
}
|
||||
|
||||
// 仪表板系统摘要
|
||||
export interface DashboardSystemInfo {
|
||||
// 主机名称
|
||||
hostname: string
|
||||
// 操作系统名称
|
||||
operating_system: string
|
||||
// MoviePilot 运行时间,单位秒
|
||||
runtime: number
|
||||
// MoviePilot 后端版本
|
||||
version: string
|
||||
}
|
||||
|
||||
// 下载器信息
|
||||
export interface DownloaderInfo {
|
||||
// 下载速度
|
||||
@@ -1000,18 +1054,86 @@ export interface DownloaderInfo {
|
||||
free_space: number
|
||||
}
|
||||
|
||||
// 仪表板系统内存信息
|
||||
export interface DashboardMemoryInfo {
|
||||
// 总内存字节数
|
||||
total: number
|
||||
// 已使用内存字节数,不包含缓存
|
||||
used: number
|
||||
// 缓存与缓冲区占用字节数
|
||||
cached: number
|
||||
// 可用内存字节数
|
||||
available: number
|
||||
// 已使用内存占总内存百分比,不包含缓存
|
||||
usage: number
|
||||
}
|
||||
|
||||
// 定时服务进度信息
|
||||
export interface ScheduleProgress {
|
||||
// ID
|
||||
id?: string
|
||||
// 名称
|
||||
name?: string
|
||||
// 多语言名称
|
||||
name_i18n?: string
|
||||
// 提供者
|
||||
provider?: string
|
||||
// 多语言提供者
|
||||
provider_i18n?: string
|
||||
// 是否正在执行
|
||||
enable?: boolean
|
||||
// 当前完成百分比
|
||||
value?: number
|
||||
// 当前进度文本
|
||||
text?: string
|
||||
// 多语言进度文本
|
||||
text_i18n?: string
|
||||
// 执行状态
|
||||
status?: string
|
||||
// 最近一次执行是否成功
|
||||
success?: boolean
|
||||
// 最近一次开始时间
|
||||
started_at?: string
|
||||
// 最近一次结束时间
|
||||
finished_at?: string
|
||||
// 最近一次错误信息
|
||||
error?: string
|
||||
// 多语言错误信息
|
||||
error_i18n?: string
|
||||
// 扩展数据
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// 定时服务信息
|
||||
export interface ScheduleInfo {
|
||||
// ID
|
||||
id: string
|
||||
// 名称
|
||||
name: string
|
||||
// 多语言名称
|
||||
name_i18n?: string
|
||||
// 提供者
|
||||
provider: string
|
||||
// 多语言提供者
|
||||
provider_i18n?: string
|
||||
// 状态
|
||||
status: string
|
||||
// 多语言状态
|
||||
status_i18n?: string
|
||||
// 下次运行时间
|
||||
next_run: string
|
||||
// 多语言下次运行时间
|
||||
next_run_i18n?: string
|
||||
// 当前完成百分比
|
||||
progress?: number
|
||||
// 进度文本
|
||||
progress_text?: string
|
||||
// 多语言进度文本
|
||||
progress_text_i18n?: string
|
||||
// 是否正在更新进度
|
||||
progress_enable?: boolean
|
||||
// 进度详情
|
||||
progress_detail?: ScheduleProgress
|
||||
}
|
||||
|
||||
// 消息通知
|
||||
@@ -1117,6 +1239,8 @@ export interface MediaServerLibrary {
|
||||
path?: string
|
||||
// 类型
|
||||
type?: string
|
||||
// 库内媒体数量
|
||||
item_count?: number
|
||||
// 图片
|
||||
image?: string
|
||||
// 图片列表
|
||||
@@ -1131,6 +1255,12 @@ export interface MediaServerLibrary {
|
||||
|
||||
// 消息通知
|
||||
export interface Message {
|
||||
// 消息ID
|
||||
id?: number
|
||||
// 消息渠道
|
||||
channel?: string
|
||||
// 消息来源
|
||||
source?: string
|
||||
// 消息类型
|
||||
mtype?: string
|
||||
// 消息标题
|
||||
@@ -1150,19 +1280,15 @@ export interface Message {
|
||||
// 消息方向:0-接收,1-发送
|
||||
action?: number
|
||||
// JSON
|
||||
note?: string
|
||||
note?: string | any[] | Record<string, any>
|
||||
}
|
||||
|
||||
// 系统通知
|
||||
export interface SystemNotification {
|
||||
// 通知类型 user/system/plugin
|
||||
type: string
|
||||
// 通知标题
|
||||
title: string
|
||||
// 通知内容
|
||||
text: string
|
||||
export interface SystemNotification extends Message {
|
||||
// 通知类型 user/system/plugin/notification
|
||||
type?: string
|
||||
// 通知时间
|
||||
date: string
|
||||
date?: string
|
||||
// 是否已读
|
||||
read?: boolean
|
||||
}
|
||||
@@ -1301,6 +1427,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 {
|
||||
// 种子名称
|
||||
@@ -1321,6 +1469,12 @@ export interface SubscribeLibraryFileInfo {
|
||||
storage?: string
|
||||
// 文件路径
|
||||
file_path?: string
|
||||
// 媒体服务器名称
|
||||
server?: string
|
||||
// 媒体服务器类型:emby、jellyfin、plex 等
|
||||
server_type?: string
|
||||
// 媒体服务器条目 ID
|
||||
itemid?: string
|
||||
}
|
||||
|
||||
// 订阅集详情
|
||||
@@ -1599,6 +1753,40 @@ export interface TorrentCacheData {
|
||||
data: TorrentCacheItem[]
|
||||
}
|
||||
|
||||
// 媒体识别缓存项
|
||||
export interface RecognitionCacheItem {
|
||||
// 缓存键
|
||||
key: string
|
||||
// TMDB ID,0 表示未识别
|
||||
tmdb_id?: number
|
||||
// 豆瓣 ID,0 表示未识别
|
||||
douban_id?: string | 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
|
||||
// 缓存数据
|
||||
data: RecognitionCacheItem[]
|
||||
}
|
||||
|
||||
// 订阅分享统计
|
||||
export interface SubscribeShareStatistics {
|
||||
// 分享人
|
||||
@@ -1613,6 +1801,7 @@ export interface SubscribeShareStatistics {
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
message?: string
|
||||
message_i18n?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1864
src/components/agent/AgentAssistantEntry.vue
Normal file
1864
src/components/agent/AgentAssistantEntry.vue
Normal file
File diff suppressed because it is too large
Load Diff
3392
src/components/agent/AgentAssistantPanel.vue
Normal file
3392
src/components/agent/AgentAssistantPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
28
src/components/agent/AgentAssistantWidget.vue
Normal file
28
src/components/agent/AgentAssistantWidget.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import AgentAssistantEntry from './AgentAssistantEntry.vue'
|
||||
import AgentAssistantPanel from './AgentAssistantPanel.vue'
|
||||
|
||||
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
|
||||
|
||||
const panelOpen = ref(false)
|
||||
const thinking = ref(false)
|
||||
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
||||
|
||||
// 打开 Agent 面板并清空入口预览气泡。
|
||||
function openPanel() {
|
||||
panelOpen.value = true
|
||||
entryRef.value?.clearBubbles()
|
||||
}
|
||||
|
||||
// 在面板关闭时展示助手回复预览。
|
||||
function handleAssistantPreview(value: string) {
|
||||
if (panelOpen.value) return
|
||||
|
||||
entryRef.value?.showAssistantReplyPreview(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" />
|
||||
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" />
|
||||
</template>
|
||||
28
src/components/agent/pet/AgentPetStage.vue
Normal file
28
src/components/agent/pet/AgentPetStage.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import CssRobotRenderer from './renderers/CssRobotRenderer.vue'
|
||||
import type { AgentPetActionName, AgentPetIntent, AgentPetRendererKind } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action?: AgentPetActionName | null
|
||||
intent?: AgentPetIntent
|
||||
renderer?: AgentPetRendererKind
|
||||
thinking?: boolean
|
||||
}>(),
|
||||
{
|
||||
action: null,
|
||||
intent: 'idle',
|
||||
renderer: 'css-robot',
|
||||
thinking: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CssRobotRenderer
|
||||
v-if="props.renderer === 'css-robot'"
|
||||
:action="props.action"
|
||||
:intent="props.intent"
|
||||
:thinking="props.thinking"
|
||||
/>
|
||||
</template>
|
||||
156
src/components/agent/pet/agentPetActions.ts
Normal file
156
src/components/agent/pet/agentPetActions.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { AgentPetActionDefinition, AgentPetActionName } from './types'
|
||||
|
||||
export const AGENT_PET_RANDOM_ACTION_MIN_DELAY = 8000
|
||||
export const AGENT_PET_RANDOM_ACTION_MAX_DELAY = 18000
|
||||
|
||||
export const AGENT_PET_RANDOM_ACTIONS = [
|
||||
'wave',
|
||||
'sit',
|
||||
'eye-roll',
|
||||
'faint',
|
||||
'disassemble',
|
||||
'happy-jump',
|
||||
'sleep',
|
||||
'stretch',
|
||||
'peek',
|
||||
'scan',
|
||||
'charge',
|
||||
'spin-cheer',
|
||||
'shy',
|
||||
'confused',
|
||||
] as const
|
||||
|
||||
export const AGENT_PET_ACTIONS: Record<AgentPetActionName, AgentPetActionDefinition> = {
|
||||
wave: {
|
||||
name: 'wave',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-wave',
|
||||
duration: 2450,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
sit: {
|
||||
name: 'sit',
|
||||
intent: 'idle',
|
||||
clip: 'agent-fab-action-sit',
|
||||
duration: 4200,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
'eye-roll': {
|
||||
name: 'eye-roll',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-eye-roll',
|
||||
duration: 1900,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
faint: {
|
||||
name: 'faint',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-faint',
|
||||
duration: 4800,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
disassemble: {
|
||||
name: 'disassemble',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-disassemble',
|
||||
duration: 6200,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
'happy-jump': {
|
||||
name: 'happy-jump',
|
||||
intent: 'success',
|
||||
clip: 'agent-fab-action-happy-jump',
|
||||
duration: 5200,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
sleep: {
|
||||
name: 'sleep',
|
||||
intent: 'sleeping',
|
||||
clip: 'agent-fab-action-sleep',
|
||||
duration: 5800,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
stretch: {
|
||||
name: 'stretch',
|
||||
intent: 'idle',
|
||||
clip: 'agent-fab-action-stretch',
|
||||
duration: 4400,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
peek: {
|
||||
name: 'peek',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-peek',
|
||||
duration: 2800,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
scan: {
|
||||
name: 'scan',
|
||||
intent: 'thinking',
|
||||
clip: 'agent-fab-action-scan',
|
||||
duration: 3200,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
charge: {
|
||||
name: 'charge',
|
||||
intent: 'thinking',
|
||||
clip: 'agent-fab-action-charge',
|
||||
duration: 4800,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
'spin-cheer': {
|
||||
name: 'spin-cheer',
|
||||
intent: 'success',
|
||||
clip: 'agent-fab-action-spin-cheer',
|
||||
duration: 3600,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
shy: {
|
||||
name: 'shy',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-shy',
|
||||
duration: 3000,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
confused: {
|
||||
name: 'confused',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-confused',
|
||||
duration: 3400,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
}
|
||||
|
||||
/** 获取指定宠物动作的播放时长。 */
|
||||
export function getAgentPetActionDuration(action: AgentPetActionName) {
|
||||
return AGENT_PET_ACTIONS[action].duration
|
||||
}
|
||||
|
||||
/** 生成下一次空闲趣味动作的随机等待时间。 */
|
||||
export function getAgentPetRandomActionDelay() {
|
||||
return (
|
||||
AGENT_PET_RANDOM_ACTION_MIN_DELAY +
|
||||
Math.round(Math.random() * (AGENT_PET_RANDOM_ACTION_MAX_DELAY - AGENT_PET_RANDOM_ACTION_MIN_DELAY))
|
||||
)
|
||||
}
|
||||
|
||||
/** 从动作池中选择一个不同于上一次的趣味动作。 */
|
||||
export function pickAgentPetRandomAction(lastAction: AgentPetActionName | null): AgentPetActionName {
|
||||
const candidates = AGENT_PET_RANDOM_ACTIONS.filter(action => action !== lastAction)
|
||||
|
||||
return candidates[Math.floor(Math.random() * candidates.length)] || AGENT_PET_RANDOM_ACTIONS[0]
|
||||
}
|
||||
44
src/components/agent/pet/renderers/CssRobotRenderer.vue
Normal file
44
src/components/agent/pet/renderers/CssRobotRenderer.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { AgentPetActionName, AgentPetIntent } from '../types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action?: AgentPetActionName | null
|
||||
intent?: AgentPetIntent
|
||||
thinking?: boolean
|
||||
}>(),
|
||||
{
|
||||
action: null,
|
||||
intent: 'idle',
|
||||
thinking: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="agent-assistant-fab__bot"
|
||||
:data-agent-pet-action="props.action || undefined"
|
||||
:data-agent-pet-intent="props.intent"
|
||||
:data-agent-pet-thinking="props.thinking ? 'true' : undefined"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="agent-assistant-fab__antenna" />
|
||||
<span class="agent-assistant-fab__head">
|
||||
<span class="agent-assistant-fab__face">
|
||||
<span class="agent-assistant-fab__eye agent-assistant-fab__eye--left" />
|
||||
<span class="agent-assistant-fab__eye agent-assistant-fab__eye--right" />
|
||||
<span class="agent-assistant-fab__smile" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="agent-assistant-fab__body">
|
||||
<span class="agent-assistant-fab__core" />
|
||||
</span>
|
||||
<span class="agent-assistant-fab__arm agent-assistant-fab__arm--left" />
|
||||
<span class="agent-assistant-fab__arm agent-assistant-fab__arm--right" />
|
||||
<span class="agent-assistant-fab__leg agent-assistant-fab__leg--left" />
|
||||
<span class="agent-assistant-fab__leg agent-assistant-fab__leg--right" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style src="./cssRobot.scss" lang="scss"></style>
|
||||
100
src/components/agent/pet/renderers/actions/_charge.scss
Normal file
100
src/components/agent/pet/renderers/actions/_charge.scss
Normal file
@@ -0,0 +1,100 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-charge-bot 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-charge-antenna 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__face {
|
||||
animation: agent-fab-action-charge-face 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__core {
|
||||
animation: agent-fab-action-charge-core 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__arm--left,
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-charge-arm 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
18%,
|
||||
42%,
|
||||
66% {
|
||||
transform: translateY(0.1rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 2deg));
|
||||
}
|
||||
|
||||
30%,
|
||||
54%,
|
||||
78% {
|
||||
transform: translateY(-0.12rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 2deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
30%,
|
||||
54%,
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.16rem)) rotate(-20deg)
|
||||
scale(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-face {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
|
||||
}
|
||||
|
||||
20%,
|
||||
84% {
|
||||
box-shadow:
|
||||
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
|
||||
0 0 0.9rem rgba(166, 118, 255, 56%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-core {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.78;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
20%,
|
||||
40%,
|
||||
60%,
|
||||
80% {
|
||||
opacity: 1;
|
||||
transform: scale(1.32);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-arm {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translateY(0.12rem) scaleY(0.9);
|
||||
}
|
||||
}
|
||||
101
src/components/agent/pet/renderers/actions/_confused.scss
Normal file
101
src/components/agent/pet/renderers/actions/_confused.scss
Normal file
@@ -0,0 +1,101 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-confused-antenna 3.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-confused-head 3.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__eye {
|
||||
animation:
|
||||
agent-fab-blink 4.8s ease-in-out infinite,
|
||||
agent-fab-action-confused-eye 0.85s ease-in-out 4;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-confused-arm-left 3.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-confused-arm-right 3.4s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(58deg);
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(-28deg);
|
||||
}
|
||||
|
||||
74% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(42deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-10deg);
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(10deg);
|
||||
}
|
||||
|
||||
74% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.03rem)) rotate(-6deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-eye {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(0.22rem, -0.16rem);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translate(-0.22rem, 0.08rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
74% {
|
||||
transform: translate(-0.12rem, -0.12rem) rotate(88deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
74% {
|
||||
transform: translate(0.16rem, -0.08rem) rotate(-42deg);
|
||||
}
|
||||
}
|
||||
195
src/components/agent/pet/renderers/actions/_disassemble.scss
Normal file
195
src/components/agent/pet/renderers/actions/_disassemble.scss
Normal file
@@ -0,0 +1,195 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-disassemble-bot 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-disassemble-antenna 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-disassemble-head 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-disassemble-body 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-disassemble-arm-left 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-disassemble-arm-right 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-action-disassemble-leg-left 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-action-disassemble-leg-right 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-bot {
|
||||
/* 34%-70% 让散落状态停留片刻,再用 84% 的轻微过冲表现回装吸附感。 */
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
12% {
|
||||
transform: translateY(-0.08rem) scale(var(--agent-assistant-bot-scale)) rotate(-5deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
72% {
|
||||
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale)) rotate(0deg);
|
||||
}
|
||||
|
||||
86% {
|
||||
transform: translateY(-0.04rem) scale(var(--agent-assistant-bot-scale)) rotate(3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
16% {
|
||||
transform: translate(0.02rem, -0.14rem) rotate(44deg);
|
||||
}
|
||||
|
||||
32%,
|
||||
70% {
|
||||
transform: translate(0.96rem, 3.42rem) rotate(268deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(-0.05rem, -0.08rem) rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
16% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.24rem)) rotate(-8deg);
|
||||
}
|
||||
|
||||
32%,
|
||||
70% {
|
||||
transform: translate(-1.18rem, 2.72rem) rotate(-46deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(0.08rem, -0.1rem) rotate(6deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.08rem)) rotate(4deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(0.26rem, 1.56rem) rotate(18deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(-0.05rem, -0.08rem) rotate(-5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(-0.16rem, -0.08rem) rotate(78deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(-1.72rem, 1.84rem) rotate(248deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(0.08rem, -0.04rem) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(0.16rem, -0.08rem) rotate(-78deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(1.58rem, 1.72rem) rotate(-238deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(-0.08rem, -0.04rem) rotate(4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(-0.12rem, 0.18rem) rotate(34deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(-0.98rem, 0.96rem) rotate(112deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(0.07rem, -0.08rem) rotate(-8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(0.12rem, 0.18rem) rotate(-34deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(0.92rem, 1.04rem) rotate(-118deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(-0.07rem, -0.08rem) rotate(8deg);
|
||||
}
|
||||
}
|
||||
50
src/components/agent/pet/renderers/actions/_eye-roll.scss
Normal file
50
src/components/agent/pet/renderers/actions/_eye-roll.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-eye-roll .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-eye-roll-head 1.9s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-eye-roll .agent-assistant-fab__eye {
|
||||
animation:
|
||||
agent-fab-blink 4.8s ease-in-out infinite,
|
||||
agent-fab-action-eye-roll 0.95s ease-in-out 2;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-eye-roll-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
24% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-6deg);
|
||||
}
|
||||
|
||||
52% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(5deg);
|
||||
}
|
||||
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.03rem)) rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-eye-roll {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
}
|
||||
|
||||
22% {
|
||||
transform: translate(0.22rem, -0.2rem);
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translate(0, -0.3rem);
|
||||
}
|
||||
|
||||
72% {
|
||||
transform: translate(-0.22rem, -0.2rem);
|
||||
}
|
||||
}
|
||||
138
src/components/agent/pet/renderers/actions/_faint.scss
Normal file
138
src/components/agent/pet/renderers/actions/_faint.scss
Normal file
@@ -0,0 +1,138 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-faint-bot 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-faint-antenna 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-faint-head 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-faint-body 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-faint-arm-left 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-faint-arm-right 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-action-faint-leg-left 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-action-faint-leg-right 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
26% {
|
||||
transform: translateY(-0.1rem) scale(var(--agent-assistant-bot-scale)) rotate(12deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.58rem, 1.08rem) scale(var(--agent-assistant-bot-scale)) rotate(98deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.08rem, 0.02rem) rotate(-18deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
28% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(9deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.04rem, 0.02rem) rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.04rem, 0.02rem) scaleY(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(-0.12rem, 0.16rem) rotate(118deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.12rem, 0.16rem) rotate(-118deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(-0.24rem, -0.02rem) rotate(82deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.24rem, -0.02rem) rotate(-82deg);
|
||||
}
|
||||
}
|
||||
309
src/components/agent/pet/renderers/actions/_happy-jump.scss
Normal file
309
src/components/agent/pet/renderers/actions/_happy-jump.scss
Normal file
@@ -0,0 +1,309 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-happy-jump-bot 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-happy-jump-antenna 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-happy-jump-head 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-happy-jump-eye 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__smile {
|
||||
animation: agent-fab-action-happy-jump-smile 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-happy-jump-body 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-happy-jump-arm-left 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__arm--right {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-happy-jump-arm-right 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-action-happy-jump-leg-left 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-action-happy-jump-leg-right 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-bot {
|
||||
/* 三段蓄力、起跳、落地,让开心连跳比单纯上下移动更像真人动作。 */
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translateY(0.22rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 3deg));
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translateY(-0.76rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 4deg));
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translateY(0.1rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 2deg));
|
||||
}
|
||||
|
||||
92% {
|
||||
transform: translateY(-0.1rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 1deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(34deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.2rem)) rotate(-18deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.04rem)) rotate(38deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(-4deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.16rem)) rotate(7deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.04rem)) rotate(-5deg);
|
||||
}
|
||||
|
||||
92% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-eye {
|
||||
0%,
|
||||
100% {
|
||||
border-block-end-width: 0.15rem;
|
||||
opacity: 1;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1, 1);
|
||||
}
|
||||
|
||||
8%,
|
||||
36%,
|
||||
64%,
|
||||
92% {
|
||||
border-block-end-width: 0.18rem;
|
||||
transform: translate(0, -0.06rem) scale(1.08, 0.72);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
border-block-end-width: 0.19rem;
|
||||
transform: translate(0, -0.12rem) scale(1.16, 0.58);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-smile {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.72);
|
||||
}
|
||||
|
||||
6%,
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translateX(-50%) translateY(0.02rem) scale(0.92, 0.82);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translateX(-50%) translateY(-0.04rem) scale(1.22, 1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.18rem)) scaleY(0.84)
|
||||
rotate(-2deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) - 0.1rem)) scaleY(1.08)
|
||||
rotate(4deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.08rem)) scaleY(0.94)
|
||||
rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(-0.08rem, 0.06rem) rotate(54deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(-0.24rem, -0.38rem) rotate(152deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(-0.1rem, -0.02rem) rotate(96deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(0.08rem, 0.06rem) rotate(-54deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(0.24rem, -0.38rem) rotate(-152deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(0.1rem, -0.02rem) rotate(-96deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(0.08rem, -0.22rem) rotate(82deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(-0.18rem, 0.08rem) rotate(-32deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(0.12rem, -0.08rem) rotate(44deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(-0.08rem, -0.22rem) rotate(-82deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(0.18rem, 0.08rem) rotate(32deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(-0.12rem, -0.08rem) rotate(-44deg);
|
||||
}
|
||||
}
|
||||
106
src/components/agent/pet/renderers/actions/_peek.scss
Normal file
106
src/components/agent/pet/renderers/actions/_peek.scss
Normal file
@@ -0,0 +1,106 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-peek-bot 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-peek-antenna 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-peek-head 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-peek-eye 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-peek-arm-left 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-peek-arm-right 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translateX(-0.48rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 8deg));
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translateX(-0.58rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 11deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(-0.12rem, -0.06rem) rotate(-12deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(calc(var(--agent-assistant-head-x) - 0.18rem), calc(var(--agent-assistant-head-y) - 0.04rem))
|
||||
rotate(-10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-eye {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(-0.18rem, -0.02rem) scale(1.12, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(-0.24rem, -0.12rem) rotate(116deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(0.04rem, 0.06rem) rotate(-34deg);
|
||||
}
|
||||
}
|
||||
84
src/components/agent/pet/renderers/actions/_scan.scss
Normal file
84
src/components/agent/pet/renderers/actions/_scan.scss
Normal file
@@ -0,0 +1,84 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-scan .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-scan-head 3.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-scan .agent-assistant-fab__face {
|
||||
animation: agent-fab-action-scan-face 3.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-scan .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-scan-eye 3.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-scan .agent-assistant-fab__core {
|
||||
animation: agent-fab-action-scan-core 3.2s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-scan-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
24% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(-8deg);
|
||||
}
|
||||
|
||||
52% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(8deg);
|
||||
}
|
||||
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-scan-face {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
|
||||
}
|
||||
|
||||
18%,
|
||||
78% {
|
||||
box-shadow:
|
||||
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
|
||||
0 0 0.72rem rgba(114, 255, 240, 48%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-scan-eye {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(-0.28rem, -0.02rem) scale(1.1, 0.9);
|
||||
}
|
||||
|
||||
46% {
|
||||
transform: translate(0.28rem, -0.02rem) scale(1.1, 0.9);
|
||||
}
|
||||
|
||||
74% {
|
||||
transform: translate(0, -0.18rem) scale(1.18, 0.72);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-scan-core {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.78;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
25%,
|
||||
75% {
|
||||
opacity: 1;
|
||||
transform: scale(1.24);
|
||||
}
|
||||
}
|
||||
14
src/components/agent/pet/renderers/actions/_shared.scss
Normal file
14
src/components/agent/pet/renderers/actions/_shared.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__antenna,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__head,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__face,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__body,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__core,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__arm,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__leg,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__eye,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__smile {
|
||||
transition: none;
|
||||
}
|
||||
88
src/components/agent/pet/renderers/actions/_shy.scss
Normal file
88
src/components/agent/pet/renderers/actions/_shy.scss
Normal file
@@ -0,0 +1,88 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-shy-bot 3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-shy-head 3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-shy-eye 3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-shy-arm-left 3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__arm--right {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-shy-arm-right 3s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
transform: translateX(0.16rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 7deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
transform: translate(calc(var(--agent-assistant-head-x) + 0.08rem), calc(var(--agent-assistant-head-y) + 0.14rem))
|
||||
rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-eye {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
opacity: 0.78;
|
||||
transform: translate(0, 0.08rem) scale(0.86, 0.48);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
transform: translate(0.02rem, -0.16rem) rotate(118deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
transform: translate(-0.02rem, -0.16rem) rotate(-118deg);
|
||||
}
|
||||
}
|
||||
116
src/components/agent/pet/renderers/actions/_sit.scss
Normal file
116
src/components/agent/pet/renderers/actions/_sit.scss
Normal file
@@ -0,0 +1,116 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-sit-bot 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-sit-head 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-sit-body 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-sit-arm-left 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-sit-arm-right 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__leg--left {
|
||||
z-index: 5;
|
||||
animation: agent-fab-action-sit-leg-left 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__leg--right {
|
||||
z-index: 5;
|
||||
animation: agent-fab-action-sit-leg-right 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translateY(0.36rem) scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.22rem)) scaleY(0.82);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(0.08rem, 0.22rem) rotate(76deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(-0.08rem, 0.22rem) rotate(-76deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(0.18rem, -0.18rem) rotate(94deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(-0.18rem, -0.18rem) rotate(-94deg);
|
||||
}
|
||||
}
|
||||
144
src/components/agent/pet/renderers/actions/_sleep.scss
Normal file
144
src/components/agent/pet/renderers/actions/_sleep.scss
Normal file
@@ -0,0 +1,144 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-sleep-bot 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-sleep-antenna 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-sleep-head 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-sleep-eye 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-sleep-body 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-sleep-arm-left 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-sleep-arm-right 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__leg {
|
||||
animation: agent-fab-action-sleep-leg 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translateY(0.42rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 8deg));
|
||||
}
|
||||
|
||||
46%,
|
||||
64% {
|
||||
transform: translateY(0.36rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 10deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
opacity: 0.62;
|
||||
transform: translate(0.1rem, 0.16rem) rotate(78deg) scale(0.78);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translate(calc(var(--agent-assistant-head-x) - 0.12rem), calc(var(--agent-assistant-head-y) + 0.22rem))
|
||||
rotate(-12deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-eye {
|
||||
0%,
|
||||
100% {
|
||||
border-block-end-width: 0.15rem;
|
||||
opacity: 1;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
|
||||
}
|
||||
|
||||
18%,
|
||||
86% {
|
||||
border-block-end-width: 0.1rem;
|
||||
opacity: 0.62;
|
||||
transform: translate(0, 0.1rem) scale(1.18, 0.24);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.26rem)) scaleY(0.78);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translate(0.12rem, 0.2rem) rotate(92deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translate(-0.12rem, 0.2rem) rotate(-92deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-leg {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
opacity: 0.35;
|
||||
transform: translateY(-0.16rem) scale(0.76);
|
||||
}
|
||||
}
|
||||
130
src/components/agent/pet/renderers/actions/_spin-cheer.scss
Normal file
130
src/components/agent/pet/renderers/actions/_spin-cheer.scss
Normal file
@@ -0,0 +1,130 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-spin-cheer-bot 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-spin-cheer-antenna 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-spin-cheer-head 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-spin-cheer-eye 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__smile {
|
||||
animation: agent-fab-action-spin-cheer-smile 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-spin-cheer-arm-left 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__arm--right {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-spin-cheer-arm-right 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 7deg));
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translateY(-0.58rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 184deg));
|
||||
}
|
||||
|
||||
78% {
|
||||
transform: translateY(-0.14rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 356deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.18rem)) rotate(-28deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.12rem)) rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-eye {
|
||||
0%,
|
||||
100% {
|
||||
border-block-end-width: 0.15rem;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
|
||||
}
|
||||
|
||||
20%,
|
||||
80% {
|
||||
border-block-end-width: 0.19rem;
|
||||
transform: translate(0, -0.12rem) scale(1.18, 0.58);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-smile {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.72);
|
||||
}
|
||||
|
||||
16%,
|
||||
84% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(-0.03rem) scale(1.24, 1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
80% {
|
||||
transform: translate(-0.28rem, -0.44rem) rotate(148deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
80% {
|
||||
transform: translate(0.28rem, -0.44rem) rotate(-148deg);
|
||||
}
|
||||
}
|
||||
146
src/components/agent/pet/renderers/actions/_stretch.scss
Normal file
146
src/components/agent/pet/renderers/actions/_stretch.scss
Normal file
@@ -0,0 +1,146 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-stretch-bot 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-stretch-antenna 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-stretch-head 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-stretch-body 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-stretch-arm-left 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__arm--right {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-stretch-arm-right 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-action-stretch-leg-left 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-action-stretch-leg-right 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale)) scaleY(0.92)
|
||||
rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translateY(-0.34rem) scale(var(--agent-assistant-bot-scale)) scaleY(1.06)
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 2deg));
|
||||
}
|
||||
|
||||
86% {
|
||||
transform: translateY(0.08rem) scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.2rem)) rotate(-24deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.28rem)) rotate(5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.12rem)) scaleY(0.88);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) - 0.08rem)) scaleY(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(-0.24rem, -0.7rem) rotate(168deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(0.24rem, -0.7rem) rotate(-168deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(-0.18rem, 0.02rem) rotate(-18deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(0.18rem, 0.02rem) rotate(18deg);
|
||||
}
|
||||
}
|
||||
87
src/components/agent/pet/renderers/actions/_wave.scss
Normal file
87
src/components/agent/pet/renderers/actions/_wave.scss
Normal file
@@ -0,0 +1,87 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-wave .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-wave-bot 2.3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-wave .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-wave-head 2.3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-wave .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-wave-arm-left 2.3s ease-in-out both;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-wave .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-wave-arm-right 2.3s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-wave-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
22%,
|
||||
66% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(calc(var(--agent-assistant-robot-tilt) - 4deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-wave-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
30%,
|
||||
66% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.1rem)) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-wave-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
16% {
|
||||
transform: translate(-0.08rem, -0.06rem) rotate(64deg);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(-0.26rem, -0.28rem) rotate(132deg);
|
||||
}
|
||||
|
||||
44% {
|
||||
transform: translate(-0.18rem, -0.2rem) rotate(92deg);
|
||||
}
|
||||
|
||||
58% {
|
||||
transform: translate(-0.26rem, -0.28rem) rotate(132deg);
|
||||
}
|
||||
|
||||
72% {
|
||||
transform: translate(-0.1rem, -0.08rem) rotate(64deg);
|
||||
}
|
||||
|
||||
88% {
|
||||
transform: translate(-0.02rem, -0.02rem) rotate(28deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-wave-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
30%,
|
||||
70% {
|
||||
transform: rotate(-28deg) translateY(0.06rem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@use './shared';
|
||||
@use './wave';
|
||||
@use './sit';
|
||||
@use './eye-roll';
|
||||
@use './faint';
|
||||
@use './disassemble';
|
||||
@use './happy-jump';
|
||||
@use './sleep';
|
||||
@use './stretch';
|
||||
@use './peek';
|
||||
@use './scan';
|
||||
@use './charge';
|
||||
@use './spin-cheer';
|
||||
@use './shy';
|
||||
@use './confused';
|
||||
2
src/components/agent/pet/renderers/cssRobot.scss
Normal file
2
src/components/agent/pet/renderers/cssRobot.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
@use './cssRobotBase';
|
||||
@use './actions/cssRobotActions';
|
||||
484
src/components/agent/pet/renderers/cssRobotBase.scss
Normal file
484
src/components/agent/pet/renderers/cssRobotBase.scss
Normal file
@@ -0,0 +1,484 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab__bot,
|
||||
.agent-assistant-fab__bot span {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bot {
|
||||
position: absolute;
|
||||
display: block;
|
||||
block-size: 4.7rem;
|
||||
filter: drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow));
|
||||
inline-size: 3.85rem;
|
||||
inset-block-end: 0.1rem;
|
||||
inset-inline-end: 1.42rem;
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
transform-origin: 50% 72%;
|
||||
transition:
|
||||
inset-inline-end 0.24s ease,
|
||||
filter 0.18s ease,
|
||||
transform 0.14s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__antenna {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
animation: agent-fab-antenna-idle 3.9s ease-in-out infinite;
|
||||
background: var(--agent-assistant-robot-outline);
|
||||
block-size: 0.66rem;
|
||||
inline-size: 0.18rem;
|
||||
inset-block-start: 0.72rem;
|
||||
inset-inline-start: 2.62rem;
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
transform-origin: bottom center;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__antenna::after {
|
||||
position: absolute;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 999px;
|
||||
background: var(--agent-assistant-robot-shell-start);
|
||||
block-size: 0.38rem;
|
||||
content: '';
|
||||
inline-size: 0.38rem;
|
||||
inset-block-start: -0.34rem;
|
||||
inset-inline-start: -0.13rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__head {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 11px;
|
||||
animation: agent-fab-head-idle 4.6s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-start) 0%,
|
||||
var(--agent-assistant-robot-shell-end) 100%
|
||||
);
|
||||
block-size: 2.05rem;
|
||||
box-shadow:
|
||||
inset 0 -0.2rem 0 rgba(54, 0, 126, 26%),
|
||||
inset 0.15rem 0.14rem 0 rgba(255, 255, 255, 24%);
|
||||
inline-size: 2.82rem;
|
||||
inset-block-start: 1.42rem;
|
||||
inset-inline-start: 0.88rem;
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y));
|
||||
transform-origin: 50% 85%;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__face {
|
||||
position: absolute;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--agent-assistant-robot-outline-soft);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--agent-assistant-robot-face-start) 0%,
|
||||
var(--agent-assistant-robot-face-end) 100%
|
||||
);
|
||||
block-size: 1.28rem;
|
||||
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
|
||||
inline-size: 2.1rem;
|
||||
inset-block-start: 0.33rem;
|
||||
inset-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__eye {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 0 0 999px 999px;
|
||||
animation: agent-fab-blink 4.8s ease-in-out infinite;
|
||||
block-size: 0.42rem;
|
||||
border-block-end: 0.15rem solid var(--agent-assistant-robot-eye);
|
||||
inline-size: 0.42rem;
|
||||
inset-block-start: 0.36rem;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
|
||||
/* 触屏设备没有连续 hover 轨迹,给眼神位移补过渡避免点按时瞬移。 */
|
||||
transition: transform 0.2s ease-out;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__eye--left {
|
||||
inset-inline-start: 0.43rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__eye--right {
|
||||
inset-inline-end: 0.43rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__smile {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 0 0 999px 999px;
|
||||
block-size: 0.32rem;
|
||||
border-block-end: 0.13rem solid var(--agent-assistant-robot-eye);
|
||||
inline-size: 0.7rem;
|
||||
inset-block-start: 0.75rem;
|
||||
inset-inline-start: 50%;
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.72);
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__body {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 0.65rem 0.65rem 0.55rem 0.55rem;
|
||||
animation: agent-fab-body-idle 4.2s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-mid) 0%,
|
||||
var(--agent-assistant-robot-shell-end) 82%
|
||||
);
|
||||
block-size: 1.34rem;
|
||||
box-shadow:
|
||||
inset 0 -0.18rem 0 rgba(54, 0, 126, 24%),
|
||||
inset 0.16rem 0.12rem 0 rgba(255, 255, 255, 24%);
|
||||
inline-size: 1.88rem;
|
||||
inset-block-start: 3.24rem;
|
||||
inset-inline-start: 1.32rem;
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
|
||||
transform-origin: 50% 18%;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__core {
|
||||
position: absolute;
|
||||
display: block;
|
||||
background: transparent;
|
||||
block-size: 100%;
|
||||
box-shadow: none;
|
||||
inline-size: 100%;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__core::before {
|
||||
position: absolute;
|
||||
background: var(--agent-assistant-robot-play);
|
||||
block-size: 0.46rem;
|
||||
clip-path: polygon(18% 8%, 18% 92%, 90% 50%);
|
||||
content: '';
|
||||
inline-size: 0.48rem;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
transform: translate(-42%, -50%);
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm,
|
||||
.agent-assistant-fab__leg {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
var(--agent-assistant-robot-shell-mid) 0%,
|
||||
var(--agent-assistant-robot-outline-soft) 100%
|
||||
);
|
||||
box-shadow: inset 0 -0.12rem 0 rgba(54, 0, 126, 24%);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm {
|
||||
border-radius: 999px;
|
||||
block-size: 1rem;
|
||||
inline-size: 0.46rem;
|
||||
inset-block-start: 3.3rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-arm-left-idle 3.8s ease-in-out infinite;
|
||||
inset-inline-start: 0.9rem;
|
||||
transform: rotate(17deg);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-arm-right-idle 4.1s ease-in-out infinite;
|
||||
inset-inline-start: 3.08rem;
|
||||
transform: rotate(-17deg);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg {
|
||||
border-radius: 0.35rem;
|
||||
block-size: 0.66rem;
|
||||
inline-size: 0.48rem;
|
||||
inset-block-start: 4.36rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-leg-left-idle 4.8s ease-in-out infinite;
|
||||
inset-inline-start: 1.48rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-leg-right-idle 4.8s ease-in-out 0.35s infinite;
|
||||
inset-inline-start: 2.46rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.agent-assistant-fab.is-bubble-visible .agent-assistant-fab__bubble:hover {
|
||||
box-shadow: var(--app-surface-hover-shadow);
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble:hover .agent-assistant-fab__bubble-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__trigger:hover .agent-assistant-fab__bot {
|
||||
filter: drop-shadow(0 0.7rem 0.7rem var(--agent-assistant-robot-shadow-strong));
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.agent-assistant-fab__bubble-close {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-assistant-fab__trigger:focus-visible .agent-assistant-fab__bot {
|
||||
filter:
|
||||
drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
|
||||
drop-shadow(0 0 0.34rem rgba(var(--v-theme-primary), 0.55));
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-pressed .agent-assistant-fab__bot {
|
||||
transform: translateY(0.22rem) scale(var(--agent-assistant-bot-pressed-scale))
|
||||
rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-thinking .agent-assistant-fab__face {
|
||||
box-shadow:
|
||||
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
|
||||
0 0 0.65rem rgba(114, 255, 240, 50%);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-thinking .agent-assistant-fab__core {
|
||||
animation: agent-fab-core-pulse 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__bot {
|
||||
inset-inline-end: -0.42rem;
|
||||
transform: translateY(-0.2rem) scale(var(--agent-assistant-bot-scale)) rotate(-19deg);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__eye {
|
||||
transform: translate(calc(var(--agent-assistant-eye-x) * 0.24 - 0.22rem), calc(var(--agent-assistant-eye-y) * 0.24));
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__body,
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__leg {
|
||||
opacity: 0;
|
||||
transform: translateX(0.8rem) scale(0.72);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--left,
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--right {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
transform: translateX(0.8rem) scale(0.72);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--left {
|
||||
z-index: 5;
|
||||
block-size: 0.95rem;
|
||||
inline-size: 0.42rem;
|
||||
inset-block-start: 3.62rem;
|
||||
inset-inline-start: 1.86rem;
|
||||
opacity: 1;
|
||||
transform: rotate(78deg) scale(0.9);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__antenna {
|
||||
animation: none;
|
||||
opacity: 0.75;
|
||||
transform: translate(0.34rem, 0.02rem) rotate(2deg) scale(0.82);
|
||||
}
|
||||
|
||||
@keyframes agent-fab-head-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-1.8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-body-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.04rem)) scaleY(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-antenna-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(15deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(12deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-11deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-core-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.78;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.18);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-blink {
|
||||
0%,
|
||||
4%,
|
||||
8%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
scale: 1 1;
|
||||
}
|
||||
|
||||
6% {
|
||||
opacity: 0.45;
|
||||
scale: 1 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.agent-assistant-fab {
|
||||
block-size: 6.65rem;
|
||||
inline-size: min(12.4rem, calc(100vw - 1rem));
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked {
|
||||
inline-size: 3.45rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubbles {
|
||||
inline-size: min(16.5rem, calc(100vw - 5.6rem));
|
||||
max-block-size: min(30rem, calc(100vh - 9.2rem));
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble-stack {
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble {
|
||||
padding-block: 0.56rem;
|
||||
padding-inline: 0.72rem 1.62rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble-title {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble > span {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__trigger {
|
||||
block-size: 4.8rem;
|
||||
inline-size: 5rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bot {
|
||||
inset-inline-end: 1.02rem;
|
||||
transform-origin: 70% 78%;
|
||||
}
|
||||
|
||||
.agent-assistant-fab {
|
||||
--agent-assistant-bot-scale: 0.82;
|
||||
--agent-assistant-bot-pressed-scale: 0.78;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__bot {
|
||||
inset-inline-end: -0.72rem;
|
||||
transform: translateY(-0.16rem) scale(var(--agent-assistant-bot-scale)) rotate(-19deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-assistant-fab,
|
||||
.agent-assistant-fab * {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
41
src/components/agent/pet/types.ts
Normal file
41
src/components/agent/pet/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type AgentPetActionName =
|
||||
| 'wave'
|
||||
| 'sit'
|
||||
| 'eye-roll'
|
||||
| 'faint'
|
||||
| 'disassemble'
|
||||
| 'happy-jump'
|
||||
| 'sleep'
|
||||
| 'stretch'
|
||||
| 'peek'
|
||||
| 'scan'
|
||||
| 'charge'
|
||||
| 'spin-cheer'
|
||||
| 'shy'
|
||||
| 'confused'
|
||||
|
||||
export type AgentPetIntent =
|
||||
| 'idle'
|
||||
| 'thinking'
|
||||
| 'speaking'
|
||||
| 'notify'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'error'
|
||||
| 'dragging'
|
||||
| 'docked'
|
||||
| 'sleeping'
|
||||
| 'reaction'
|
||||
|
||||
export type AgentPetRendererKind = 'css-robot'
|
||||
|
||||
export interface AgentPetActionDefinition {
|
||||
name: AgentPetActionName
|
||||
intent: AgentPetIntent
|
||||
clip: string
|
||||
duration: number
|
||||
priority: number
|
||||
interruptible: boolean
|
||||
cooldown?: number
|
||||
next?: AgentPetActionName
|
||||
}
|
||||
127
src/components/agent/pet/useAgentPetMachine.ts
Normal file
127
src/components/agent/pet/useAgentPetMachine.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
|
||||
import {
|
||||
getAgentPetActionDuration,
|
||||
getAgentPetRandomActionDelay,
|
||||
pickAgentPetRandomAction,
|
||||
} from './agentPetActions'
|
||||
import type { AgentPetActionName } from './types'
|
||||
|
||||
interface AgentPetMachineOptions {
|
||||
active: MaybeRefOrGetter<boolean>
|
||||
thinking: MaybeRefOrGetter<boolean>
|
||||
docked: Ref<boolean>
|
||||
dragging: Ref<boolean>
|
||||
pressed: Ref<boolean>
|
||||
shouldAutoDock: () => boolean
|
||||
scheduleAutoDock: () => void
|
||||
}
|
||||
|
||||
/** 管理桌面宠物的空闲动作队列,避免入口组件直接维护动画计时器。 */
|
||||
export function useAgentPetMachine(options: AgentPetMachineOptions) {
|
||||
const currentAction = ref<AgentPetActionName | null>(null)
|
||||
|
||||
let lastAction: AgentPetActionName | null = null
|
||||
let randomActionTimer: number | null = null
|
||||
let actionEndTimer: number | null = null
|
||||
|
||||
/** 判断当前交互状态是否适合播放空闲趣味动作。 */
|
||||
function canRunRandomAction() {
|
||||
return (
|
||||
toValue(options.active) &&
|
||||
!options.docked.value &&
|
||||
!options.dragging.value &&
|
||||
!options.pressed.value &&
|
||||
!toValue(options.thinking)
|
||||
)
|
||||
}
|
||||
|
||||
/** 清理等待中的随机动作计时器。 */
|
||||
function clearRandomActionTimer() {
|
||||
if (randomActionTimer === null) return
|
||||
|
||||
window.clearTimeout(randomActionTimer)
|
||||
randomActionTimer = null
|
||||
}
|
||||
|
||||
/** 清理正在播放动作的结束计时器。 */
|
||||
function clearActionEndTimer() {
|
||||
if (actionEndTimer === null) return
|
||||
|
||||
window.clearTimeout(actionEndTimer)
|
||||
actionEndTimer = null
|
||||
}
|
||||
|
||||
/** 停止当前动作并清理全部宠物动作计时器。 */
|
||||
function clearAction() {
|
||||
clearRandomActionTimer()
|
||||
clearActionEndTimer()
|
||||
currentAction.value = null
|
||||
}
|
||||
|
||||
/** 完成当前动作后恢复空闲态,并按贴边规则决定是否自动收起。 */
|
||||
function finishRandomAction() {
|
||||
clearActionEndTimer()
|
||||
currentAction.value = null
|
||||
|
||||
if (!options.docked.value && options.shouldAutoDock()) {
|
||||
options.scheduleAutoDock()
|
||||
return
|
||||
}
|
||||
|
||||
scheduleRandomAction()
|
||||
}
|
||||
|
||||
/** 立即播放指定动作,供通知、交互或外部 renderer 编排主动触发。 */
|
||||
function playAction(action: AgentPetActionName) {
|
||||
clearRandomActionTimer()
|
||||
clearActionEndTimer()
|
||||
currentAction.value = action
|
||||
actionEndTimer = window.setTimeout(finishRandomAction, getAgentPetActionDuration(action))
|
||||
}
|
||||
|
||||
/** 播放一个随机趣味动作,由 renderer 根据动作名呈现具体动画。 */
|
||||
function runRandomAction() {
|
||||
if (!canRunRandomAction()) return
|
||||
|
||||
const action = pickAgentPetRandomAction(lastAction)
|
||||
|
||||
lastAction = action
|
||||
playAction(action)
|
||||
}
|
||||
|
||||
/** 安排下一次随机动作,只在宠物可见且空闲时生效。 */
|
||||
function scheduleRandomAction() {
|
||||
clearRandomActionTimer()
|
||||
if (!canRunRandomAction() || currentAction.value || actionEndTimer !== null) return
|
||||
|
||||
randomActionTimer = window.setTimeout(() => {
|
||||
randomActionTimer = null
|
||||
runRandomAction()
|
||||
}, getAgentPetRandomActionDelay())
|
||||
}
|
||||
|
||||
/** 根据入口交互状态同步随机动作队列。 */
|
||||
function syncSchedule() {
|
||||
if (canRunRandomAction()) {
|
||||
if (!currentAction.value && randomActionTimer === null && actionEndTimer === null) scheduleRandomAction()
|
||||
return
|
||||
}
|
||||
|
||||
clearAction()
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => toValue(options.active), () => toValue(options.thinking), options.docked, options.dragging, options.pressed],
|
||||
syncSchedule,
|
||||
)
|
||||
|
||||
onScopeDispose(clearAction)
|
||||
|
||||
return {
|
||||
clearAction,
|
||||
currentAction,
|
||||
playAction,
|
||||
scheduleRandomAction,
|
||||
syncSchedule,
|
||||
}
|
||||
}
|
||||
798
src/components/cache/RecognitionCachePanel.vue
vendored
Normal file
798
src/components/cache/RecognitionCachePanel.vue
vendored
Normal file
@@ -0,0 +1,798 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, RecognitionCacheData, RecognitionCacheItem } from '@/api/types'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
|
||||
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
|
||||
type RecognitionCacheSource = 'tmdb' | 'douban'
|
||||
|
||||
const MOBILE_CACHE_PAGE_SIZE = 20
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
const createConfirm = useConfirm()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
const isMobile = computed(() => display.smAndDown.value)
|
||||
const recognitionSource = computed<RecognitionCacheSource>(() =>
|
||||
globalSettingsStore.globalSettings.RECOGNIZE_SOURCE === 'douban' ? 'douban' : 'tmdb',
|
||||
)
|
||||
const recognitionSourceName = computed(() =>
|
||||
recognitionSource.value === 'douban'
|
||||
? t('setting.cache.recognitionSource.douban')
|
||||
: t('setting.cache.recognitionSource.themoviedb'),
|
||||
)
|
||||
const recognitionIdLabel = computed(() =>
|
||||
recognitionSource.value === 'douban' ? t('setting.cache.doubanId') : t('setting.cache.tmdbId'),
|
||||
)
|
||||
const recognitionCacheEndpoint = computed(() => `${recognitionSource.value}/cache`)
|
||||
const recognitionFilterPlaceholder = computed(() =>
|
||||
t('setting.cache.filterRecognitionCache', { source: recognitionSourceName.value }),
|
||||
)
|
||||
const loading = ref(false)
|
||||
const searchFilter = ref('')
|
||||
const statusFilter = ref<RecognitionStatusFilter>('all')
|
||||
const selectedItems = ref<string[]>([])
|
||||
const cacheData = ref<RecognitionCacheData>({
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
data: [],
|
||||
})
|
||||
const mobileVisibleCount = ref(MOBILE_CACHE_PAGE_SIZE)
|
||||
const mobileInfiniteKey = ref(0)
|
||||
let cacheLoadRequestId = 0
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ title: t('setting.cache.allStatuses'), value: 'all' },
|
||||
{ title: t('setting.cache.recognizedOnly'), value: 'recognized' },
|
||||
{ title: t('setting.cache.unrecognizedOnly'), value: 'unrecognized' },
|
||||
])
|
||||
|
||||
const tableHeaders = computed(() => [
|
||||
{ title: '', key: 'data-table-select', sortable: false, width: '48px' },
|
||||
{ title: t('setting.cache.poster'), key: 'poster', sortable: false, width: '76px' },
|
||||
{ title: t('setting.cache.cacheKey'), key: 'key', sortable: true },
|
||||
{ title: t('setting.cache.recognitionResult'), key: 'result', sortable: false, width: '220px' },
|
||||
{ title: recognitionIdLabel.value, key: 'recognition_id', sortable: true, width: '120px' },
|
||||
{ title: t('setting.cache.recognitionStatus'), key: 'status', sortable: true, width: '120px' },
|
||||
{ title: t('setting.cache.actions'), key: 'actions', sortable: false, width: '72px' },
|
||||
])
|
||||
|
||||
const filteredData = computed(() => {
|
||||
const keyword = searchFilter.value.trim().toLowerCase()
|
||||
return cacheData.value.data.filter(item => {
|
||||
const matchesKeyword =
|
||||
!keyword ||
|
||||
[item.key, item.title, item.year, getRecognitionId(item)].some(value => value.toLowerCase().includes(keyword))
|
||||
const matchesStatus =
|
||||
statusFilter.value === 'all' || (statusFilter.value === 'recognized' ? isRecognized(item) : !isRecognized(item))
|
||||
return matchesKeyword && matchesStatus
|
||||
})
|
||||
})
|
||||
|
||||
const mobileVisibleData = computed(() => filteredData.value.slice(0, mobileVisibleCount.value))
|
||||
const mobileHasMore = computed(() => mobileVisibleData.value.length < filteredData.value.length)
|
||||
|
||||
/** 重置移动端分页,让筛选或刷新后的识别缓存从第一页开始展示。 */
|
||||
function resetMobilePagination() {
|
||||
mobileVisibleCount.value = MOBILE_CACHE_PAGE_SIZE
|
||||
mobileInfiniteKey.value++
|
||||
}
|
||||
|
||||
/** 追加移动端下一页识别缓存,并由虚拟滚动限制实际渲染节点。 */
|
||||
function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) => void }) {
|
||||
if (loading.value) {
|
||||
done('ok')
|
||||
return
|
||||
}
|
||||
|
||||
if (!mobileHasMore.value) {
|
||||
done('empty')
|
||||
return
|
||||
}
|
||||
|
||||
mobileVisibleCount.value = Math.min(mobileVisibleCount.value + MOBILE_CACHE_PAGE_SIZE, filteredData.value.length)
|
||||
done(mobileHasMore.value ? 'ok' : 'empty')
|
||||
}
|
||||
|
||||
/** 加载当前识别数据源对应的缓存列表。 */
|
||||
async function loadCacheData(showSuccess = false) {
|
||||
const requestId = ++cacheLoadRequestId
|
||||
try {
|
||||
loading.value = true
|
||||
const response = (await api.get(recognitionCacheEndpoint.value)) as unknown as ApiResponse<RecognitionCacheData>
|
||||
if (requestId !== cacheLoadRequestId) return
|
||||
const responseData = response.data ?? { count: 0, recognized: 0, unrecognized: 0, data: [] }
|
||||
cacheData.value = {
|
||||
...responseData,
|
||||
data: responseData.data.map(item => ({ ...item, recognition_id: getRecognitionId(item) })),
|
||||
}
|
||||
selectedItems.value = selectedItems.value.filter(key => cacheData.value.data.some(item => item.key === key))
|
||||
resetMobilePagination()
|
||||
if (showSuccess) $toast.success(t('setting.cache.listRefreshSuccess'))
|
||||
} catch (error) {
|
||||
if (requestId !== cacheLoadRequestId) return
|
||||
console.error(error)
|
||||
$toast.error(t('setting.cache.loadFailed'))
|
||||
} finally {
|
||||
if (requestId === cacheLoadRequestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空当前识别数据源的全部识别缓存。 */
|
||||
async function clearAllCache() {
|
||||
const confirmed = await createConfirm({
|
||||
type: 'warn',
|
||||
title: t('common.confirm'),
|
||||
content: t('setting.cache.recognitionClearConfirm', { source: recognitionSourceName.value }),
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const response = (await api.delete(recognitionCacheEndpoint.value)) as unknown as ApiResponse
|
||||
if (!response.success) throw new Error(response.message)
|
||||
$toast.success(response.message || t('setting.cache.clearSuccess'))
|
||||
await loadCacheData()
|
||||
selectedItems.value = []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('setting.cache.clearFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求当前识别数据源接口删除指定识别缓存。 */
|
||||
async function deleteCacheItem(key: string) {
|
||||
const response = (await api.delete(
|
||||
`${recognitionCacheEndpoint.value}/${encodeURIComponent(key)}`,
|
||||
)) as unknown as ApiResponse
|
||||
if (!response.success) throw new Error(response.message)
|
||||
}
|
||||
|
||||
/** 删除桌面端表格中选中的识别缓存。 */
|
||||
async function deleteSelectedItems() {
|
||||
if (selectedItems.value.length === 0) {
|
||||
$toast.warning(t('setting.cache.selectDeleteWarning'))
|
||||
return
|
||||
}
|
||||
|
||||
const deleteCount = selectedItems.value.length
|
||||
try {
|
||||
loading.value = true
|
||||
await Promise.all(selectedItems.value.map(deleteCacheItem))
|
||||
$toast.success(t('setting.cache.deleteSelectedSuccess', { count: deleteCount }))
|
||||
await loadCacheData()
|
||||
selectedItems.value = []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('setting.cache.deleteSelectedFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除单条识别缓存。 */
|
||||
async function deleteSingleItem(item: RecognitionCacheItem) {
|
||||
try {
|
||||
loading.value = true
|
||||
await deleteCacheItem(item.key)
|
||||
$toast.success(t('setting.cache.deleteSuccess'))
|
||||
await loadCacheData()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('setting.cache.deleteFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取识别缓存海报的可展示地址。 */
|
||||
function getPosterUrl(item: RecognitionCacheItem): string {
|
||||
if (!item.poster_path) return ''
|
||||
const sourceUrl = item.poster_path.startsWith('/')
|
||||
? `https://${globalSettingsStore.globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w300${item.poster_path}`
|
||||
: item.poster_path
|
||||
return getDisplayImageUrl(sourceUrl, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
|
||||
/** 获取当前识别数据源对应的媒体 ID。 */
|
||||
function getRecognitionId(item: RecognitionCacheItem): string {
|
||||
const recognitionId = recognitionSource.value === 'douban' ? item.douban_id : item.tmdb_id
|
||||
return recognitionId ? String(recognitionId) : ''
|
||||
}
|
||||
|
||||
/** 判断识别缓存条目是否包含有效媒体 ID。 */
|
||||
function isRecognized(item: RecognitionCacheItem): boolean {
|
||||
const recognitionId = getRecognitionId(item)
|
||||
return Boolean(recognitionId && recognitionId !== '0')
|
||||
}
|
||||
|
||||
/** 获取移动端识别缓存卡片的稳定渲染 key。 */
|
||||
function getRecognitionCacheItemKey(item: RecognitionCacheItem): string {
|
||||
return item.key
|
||||
}
|
||||
|
||||
/** 获取本地化的媒体类型名称。 */
|
||||
function getMediaTypeLabel(mediaType: string): string {
|
||||
if (mediaType === 'movie') return t('setting.cache.mediaType.movie')
|
||||
if (mediaType === 'tv') return t('setting.cache.mediaType.tv')
|
||||
return t('setting.cache.mediaType.unknown')
|
||||
}
|
||||
|
||||
/** 获取媒体类型对应的主题颜色。 */
|
||||
function getMediaTypeColor(mediaType: string): string {
|
||||
if (mediaType === 'movie') return 'primary'
|
||||
if (mediaType === 'tv') return 'success'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
/** 获取识别状态的本地化名称。 */
|
||||
function getRecognitionStatusLabel(item: RecognitionCacheItem): string {
|
||||
return isRecognized(item) ? t('setting.cache.recognized') : t('setting.cache.unrecognized')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadCacheData()
|
||||
})
|
||||
|
||||
watch([searchFilter, statusFilter], () => {
|
||||
resetMobilePagination()
|
||||
})
|
||||
|
||||
watch(recognitionSource, () => {
|
||||
searchFilter.value = ''
|
||||
statusFilter.value = 'all'
|
||||
selectedItems.value = []
|
||||
void loadCacheData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="recognition-cache-panel">
|
||||
<div class="cache-panel-toolbar">
|
||||
<div class="cache-panel-stats">
|
||||
<div class="cache-panel-stat cache-panel-stat--primary">
|
||||
<VIcon icon="mdi-database-outline" :size="isMobile ? 32 : 22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.count }}</strong>
|
||||
<span>{{ t('setting.cache.totalCount') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cache-panel-stat cache-panel-stat--success">
|
||||
<VIcon icon="mdi-check-decagram-outline" :size="isMobile ? 32 : 22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.recognized }}</strong>
|
||||
<span>{{ t('setting.cache.recognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isMobile" class="cache-panel-stat cache-panel-stat--warning">
|
||||
<VIcon icon="mdi-help-circle-outline" size="22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.unrecognized }}</strong>
|
||||
<span>{{ t('setting.cache.unrecognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isMobile" class="cache-panel-actions">
|
||||
<VBtn icon variant="text" color="primary" :loading="loading" @click="loadCacheData(true)">
|
||||
<VIcon icon="mdi-refresh" />
|
||||
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.refreshList') }}</VTooltip>
|
||||
</VBtn>
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
color="warning"
|
||||
:disabled="selectedItems.length === 0"
|
||||
:loading="loading"
|
||||
@click="deleteSelectedItems"
|
||||
>
|
||||
<VIcon icon="mdi-delete-sweep-outline" />
|
||||
<VTooltip activator="parent" location="bottom">
|
||||
{{ t('setting.cache.deleteSelected') }} ({{ selectedItems.length }})
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
<VBtn icon variant="text" color="error" :loading="loading" @click="clearAllCache">
|
||||
<VIcon icon="mdi-delete-variant" />
|
||||
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.clearAll') }}</VTooltip>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cache-panel-filters">
|
||||
<VTextField
|
||||
v-model="searchFilter"
|
||||
class="cache-panel-filter"
|
||||
:label="isMobile ? undefined : recognitionFilterPlaceholder"
|
||||
:placeholder="isMobile ? recognitionFilterPlaceholder : undefined"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
:density="isMobile ? 'comfortable' : 'compact'"
|
||||
:single-line="isMobile"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
<VSelect
|
||||
v-model="statusFilter"
|
||||
class="cache-panel-filter"
|
||||
:label="isMobile ? undefined : t('setting.cache.recognitionStatus')"
|
||||
:placeholder="isMobile ? t('setting.cache.recognitionStatus') : undefined"
|
||||
:items="statusOptions"
|
||||
prepend-inner-icon="mdi-list-status"
|
||||
variant="outlined"
|
||||
:density="isMobile ? 'comfortable' : 'compact'"
|
||||
:single-line="isMobile"
|
||||
hide-details
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="cache-panel-mobile-actions">
|
||||
<VBtn variant="tonal" color="primary" :loading="loading" prepend-icon="mdi-refresh" @click="loadCacheData(true)">
|
||||
{{ t('setting.cache.refresh') }}
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" color="error" :loading="loading" prepend-icon="mdi-delete-variant" @click="clearAllCache">
|
||||
{{ t('setting.cache.clearAll') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<template v-if="isMobile">
|
||||
<VInfiniteScroll
|
||||
v-if="mobileVisibleData.length > 0 || loading"
|
||||
:key="mobileInfiniteKey"
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="mobileVisibleData"
|
||||
class="recognition-cache-mobile-scroll"
|
||||
@load="loadMoreMobileCache"
|
||||
>
|
||||
<template #loading>
|
||||
<div class="cache-panel-load-state">
|
||||
<VProgressCircular indeterminate color="primary" size="22" width="3" />
|
||||
<span>{{ t('setting.cache.loadingMore') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty />
|
||||
|
||||
<ProgressiveCardGrid
|
||||
v-if="mobileVisibleData.length > 0"
|
||||
:items="mobileVisibleData"
|
||||
:columns="1"
|
||||
:gap="10"
|
||||
:estimated-item-height="152"
|
||||
:overscan-rows="5"
|
||||
:get-item-key="getRecognitionCacheItemKey"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<article class="recognition-cache-mobile-item">
|
||||
<div class="recognition-cache-poster">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" size="28" />
|
||||
</div>
|
||||
|
||||
<div class="recognition-cache-mobile-item__content">
|
||||
<div class="recognition-cache-mobile-item__title">
|
||||
{{ item.title || t('setting.cache.unrecognized') }}
|
||||
</div>
|
||||
<div class="recognition-cache-mobile-item__meta">
|
||||
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
|
||||
{{ getMediaTypeLabel(item.media_type) }}
|
||||
</VChip>
|
||||
<span v-if="item.year">{{ item.year }}</span>
|
||||
<span v-if="isRecognized(item)">{{ recognitionIdLabel }} #{{ getRecognitionId(item) }}</span>
|
||||
</div>
|
||||
<div class="recognition-cache-mobile-item__key">{{ item.key }}</div>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="deleteSingleItem(item)"
|
||||
>
|
||||
<VIcon icon="mdi-delete-outline" size="20" />
|
||||
</VBtn>
|
||||
</article>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</VInfiniteScroll>
|
||||
|
||||
<div v-else class="cache-panel-empty">
|
||||
<VIcon icon="mdi-database-search-outline" size="42" />
|
||||
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
|
||||
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VDataTable
|
||||
v-else
|
||||
v-model="selectedItems"
|
||||
class="recognition-cache-table"
|
||||
:headers="tableHeaders"
|
||||
:items="filteredData"
|
||||
:loading="loading"
|
||||
item-value="key"
|
||||
show-select
|
||||
hover
|
||||
:items-per-page-text="t('common.itemsPerPage')"
|
||||
:no-data-text="t('common.noDataText')"
|
||||
:loading-text="t('common.loadingText')"
|
||||
>
|
||||
<template #item.poster="{ item }">
|
||||
<div class="recognition-cache-table__poster">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.key="{ item }">
|
||||
<div class="recognition-cache-table__key">{{ item.key }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.result="{ item }">
|
||||
<div class="recognition-cache-result">
|
||||
<strong>{{ item.title || t('setting.cache.unrecognized') }}</strong>
|
||||
<span v-if="item.year">{{ item.year }}</span>
|
||||
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
|
||||
{{ getMediaTypeLabel(item.media_type) }}
|
||||
</VChip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.recognition_id="{ item }">
|
||||
<span v-if="isRecognized(item)" class="font-weight-medium">#{{ getRecognitionId(item) }}</span>
|
||||
<span v-else class="text-medium-emphasis">-</span>
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<VChip size="small" variant="tonal" :color="isRecognized(item) ? 'success' : 'warning'">
|
||||
{{ getRecognitionStatusLabel(item) }}
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<VBtn icon size="small" variant="text" color="error" @click="deleteSingleItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" size="18" />
|
||||
<VTooltip activator="parent" location="start">{{ t('common.delete') }}</VTooltip>
|
||||
</VBtn>
|
||||
</template>
|
||||
|
||||
<template #no-data>
|
||||
<div class="cache-panel-empty">
|
||||
<VIcon icon="mdi-database-search-outline" size="42" />
|
||||
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
|
||||
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</VDataTable>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.recognition-cache-panel {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
gap: 16px;
|
||||
min-block-size: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cache-panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cache-panel-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cache-panel-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: var(--app-surface-border);
|
||||
border-radius: var(--app-surface-radius);
|
||||
background: var(--app-grouped-list-background);
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
gap: 10px;
|
||||
min-block-size: 58px;
|
||||
min-inline-size: 126px;
|
||||
padding-block: 10px;
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.cache-panel-stat strong,
|
||||
.cache-panel-stat span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cache-panel-stat strong {
|
||||
color: rgba(var(--v-theme-on-surface), 0.9);
|
||||
font-size: 18px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.cache-panel-stat span {
|
||||
color: rgba(var(--v-theme-on-surface), 0.58);
|
||||
font-size: 12px;
|
||||
margin-block-start: 3px;
|
||||
}
|
||||
|
||||
.cache-panel-stat--primary {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.cache-panel-stat--success {
|
||||
color: rgb(var(--v-theme-success));
|
||||
}
|
||||
|
||||
.cache-panel-stat--warning {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
}
|
||||
|
||||
.cache-panel-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cache-panel-filters {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.35fr);
|
||||
}
|
||||
|
||||
.cache-panel-filters :deep(.v-field) {
|
||||
border-radius: var(--app-field-radius);
|
||||
background: var(--app-grouped-list-background);
|
||||
}
|
||||
|
||||
.cache-panel-mobile-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cache-panel-mobile-actions :deep(.v-btn) {
|
||||
min-block-size: 44px;
|
||||
}
|
||||
|
||||
.recognition-cache-table {
|
||||
overflow: hidden;
|
||||
border: var(--app-surface-border);
|
||||
border-radius: var(--app-surface-radius);
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
}
|
||||
|
||||
.recognition-cache-table__poster,
|
||||
.recognition-cache-poster {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--app-control-radius);
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
color: rgba(var(--v-theme-on-surface), 0.36);
|
||||
}
|
||||
|
||||
.recognition-cache-table__poster {
|
||||
block-size: 62px;
|
||||
inline-size: 44px;
|
||||
margin-block: 4px;
|
||||
}
|
||||
|
||||
.recognition-cache-table__poster :deep(.v-img),
|
||||
.recognition-cache-poster :deep(.v-img) {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.recognition-cache-table__key {
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-inline-size: 36rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.recognition-cache-result {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.recognition-cache-result strong {
|
||||
color: rgba(var(--v-theme-on-surface), 0.88);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.recognition-cache-result span {
|
||||
color: rgba(var(--v-theme-on-surface), 0.56);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cache-panel-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.48);
|
||||
gap: 8px;
|
||||
min-block-size: 14rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cache-panel-empty strong {
|
||||
color: rgba(var(--v-theme-on-surface), 0.78);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.cache-panel-empty span {
|
||||
font-size: 13px;
|
||||
max-inline-size: 30rem;
|
||||
}
|
||||
|
||||
@media (width <= 959.98px) {
|
||||
.recognition-cache-panel {
|
||||
block-size: 100%;
|
||||
padding-block: 14px calc(18px + env(safe-area-inset-bottom));
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.cache-panel-toolbar {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.cache-panel-stats {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cache-panel-stat {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 18px;
|
||||
gap: 14px;
|
||||
min-block-size: 92px;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.cache-panel-stat strong {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cache-panel-stat span {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-block-start: 8px;
|
||||
}
|
||||
|
||||
.cache-panel-filters {
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cache-panel-filter :deep(.v-field__outline) {
|
||||
color: rgba(var(--v-theme-on-surface), 0.18);
|
||||
}
|
||||
|
||||
.cache-panel-filter :deep(.v-field__input) {
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 16px;
|
||||
min-block-size: 54px;
|
||||
}
|
||||
|
||||
.recognition-cache-mobile-scroll {
|
||||
overflow: visible !important;
|
||||
min-block-size: 20rem;
|
||||
}
|
||||
|
||||
.recognition-cache-mobile-scroll :deep(.v-infinite-scroll__container),
|
||||
.recognition-cache-mobile-scroll :deep(.progressive-card-grid),
|
||||
.recognition-cache-mobile-scroll :deep(.progressive-card-grid__track) {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.recognition-cache-mobile-scroll :deep(.v-infinite-scroll__side) {
|
||||
padding-block: 14px 2px;
|
||||
}
|
||||
|
||||
.cache-panel-load-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(var(--v-theme-on-surface), 0.58);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
gap: 8px;
|
||||
min-block-size: 70px;
|
||||
}
|
||||
|
||||
.recognition-cache-mobile-item {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
padding: 12px;
|
||||
border: var(--app-surface-border);
|
||||
border-radius: var(--app-surface-radius);
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
background: var(--app-grouped-list-background);
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
gap: 12px;
|
||||
grid-template-columns: 54px minmax(0, 1fr) 36px;
|
||||
}
|
||||
|
||||
.recognition-cache-poster {
|
||||
block-size: 78px;
|
||||
inline-size: 54px;
|
||||
}
|
||||
|
||||
.recognition-cache-mobile-item__content {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.recognition-cache-mobile-item__title {
|
||||
color: rgba(var(--v-theme-on-surface), 0.9);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.recognition-cache-mobile-item__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), 0.58);
|
||||
font-size: 12px;
|
||||
gap: 7px;
|
||||
margin-block-start: 7px;
|
||||
}
|
||||
|
||||
.recognition-cache-mobile-item__key {
|
||||
color: rgba(var(--v-theme-on-surface), 0.48);
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
margin-block-start: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 374.98px) {
|
||||
.recognition-cache-panel {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.cache-panel-stat {
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,12 @@ const props = defineProps({
|
||||
const imageLoaded = ref(false)
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '3 / 2',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
@@ -46,22 +52,30 @@ const getImgUrl = computed(() => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'ring-1': imageLoaded,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="backdrop-card-hover-area">
|
||||
<VCard
|
||||
:style="cardStyle"
|
||||
class="app-hover-lift-card ring-gray-500"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'ring-1': imageLoaded,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<VImg
|
||||
:src="getImgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="backdrop-card-image"
|
||||
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
<div class="backdrop-card-placeholder">
|
||||
<VSkeletonLoader class="backdrop-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
@@ -86,7 +100,43 @@ const getImgUrl = computed(() => {
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.backdrop-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-placeholder,
|
||||
.backdrop-card-skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.backdrop-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storageRemoteDict } from '@/api/constants'
|
||||
|
||||
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253'
|
||||
const DEFAULT_DIRECTORY_ACCENT_RGB = '141, 81, 249'
|
||||
const STORAGE_ACCENT_COLOR_MAP = {
|
||||
local: '#FFB400',
|
||||
alipan: '#00A7F2',
|
||||
@@ -75,10 +75,12 @@ const transferSourceItems = computed(() => [
|
||||
{ title: t('directory.manualTransfer'), value: 'manual' },
|
||||
])
|
||||
|
||||
/** 判断存储类型是否具备预设强调色。 */
|
||||
function hasKnownStorageType(storageType?: string): storageType is keyof typeof STORAGE_ACCENT_COLOR_MAP {
|
||||
return !!storageType && Object.prototype.hasOwnProperty.call(STORAGE_ACCENT_COLOR_MAP, storageType)
|
||||
}
|
||||
|
||||
/** 将十六进制颜色转换为 CSS RGB 通道字符串。 */
|
||||
function hexToRgbString(hexColor: string) {
|
||||
const normalizedColor = hexColor.replace('#', '')
|
||||
const colorValue = Number.parseInt(normalizedColor, 16)
|
||||
@@ -88,6 +90,7 @@ function hexToRgbString(hexColor: string) {
|
||||
return `${(colorValue >> 16) & 255}, ${(colorValue >> 8) & 255}, ${colorValue & 255}`
|
||||
}
|
||||
|
||||
/** 根据自定义存储序号选取离散的强调色。 */
|
||||
function getCustomStoragePaletteColor(storageType?: string) {
|
||||
const customStorageIndex = Math.max(Number(storageType?.match(/\d+$/)?.[0] ?? 1) - 1, 0)
|
||||
const customStorageColors = ['#F97316', '#8B5CF6', '#06B6D4', '#84CC16', '#EC4899', '#14B8A6']
|
||||
@@ -95,6 +98,7 @@ function getCustomStoragePaletteColor(storageType?: string) {
|
||||
return customStorageColors[customStorageIndex % customStorageColors.length]
|
||||
}
|
||||
|
||||
/** 获取指定存储类型在目录卡片中使用的强调色。 */
|
||||
function getStorageAccentColor(storageType?: string) {
|
||||
if (hasKnownStorageType(storageType)) return STORAGE_ACCENT_COLOR_MAP[storageType]
|
||||
|
||||
@@ -108,6 +112,7 @@ const directoryAccentStyle = computed(() => ({
|
||||
'--app-card-accent-end-rgb': libraryAccentRgb.value,
|
||||
}))
|
||||
|
||||
/** 根据目录两端的存储类型刷新卡片强调色。 */
|
||||
function updateDirectoryAccentColors() {
|
||||
const downloadStorage = props.directory.storage
|
||||
const libraryStorage = props.directory.library_storage || props.directory.storage
|
||||
@@ -270,6 +275,7 @@ watch(
|
||||
v-model="props.directory.name"
|
||||
variant="underlined"
|
||||
:label="t('directory.alias')"
|
||||
mobile-control-width="65%"
|
||||
class="me-20 text-high-emphasis font-weight-bold"
|
||||
/>
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
@@ -287,6 +293,7 @@ watch(
|
||||
variant="underlined"
|
||||
:items="typeItems"
|
||||
:label="t('directory.mediaType')"
|
||||
mobile-control-width="65%"
|
||||
@update:modelValue="props.directory.media_category = ''"
|
||||
/>
|
||||
</VCol>
|
||||
@@ -296,6 +303,7 @@ watch(
|
||||
variant="underlined"
|
||||
:items="getCategories"
|
||||
:label="t('directory.mediaCategory')"
|
||||
mobile-control-width="65%"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="4">
|
||||
@@ -304,6 +312,7 @@ watch(
|
||||
variant="underlined"
|
||||
:items="resourceStorageOptions"
|
||||
:label="t('directory.resourceStorage')"
|
||||
mobile-control-width="65%"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="8">
|
||||
@@ -312,6 +321,7 @@ watch(
|
||||
:storage="props.directory.storage"
|
||||
variant="underlined"
|
||||
:label="t('directory.resourceDirectory')"
|
||||
mobile-control-width="65%"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
|
||||
@@ -332,6 +342,7 @@ watch(
|
||||
variant="underlined"
|
||||
:items="transferSourceItems"
|
||||
:label="t('directory.autoTransfer')"
|
||||
mobile-control-width="65%"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -342,6 +353,7 @@ watch(
|
||||
variant="underlined"
|
||||
:items="MonitorModeItems"
|
||||
:label="t('directory.monitorMode')"
|
||||
mobile-control-width="65%"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="4">
|
||||
@@ -350,6 +362,7 @@ watch(
|
||||
variant="underlined"
|
||||
:items="libraryStorageOptions"
|
||||
:label="t('directory.libraryStorage')"
|
||||
mobile-control-width="65%"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="8">
|
||||
@@ -358,6 +371,7 @@ watch(
|
||||
:storage="props.directory.library_storage"
|
||||
variant="underlined"
|
||||
:label="t('directory.libraryDirectory')"
|
||||
mobile-control-width="65%"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="4">
|
||||
@@ -367,6 +381,7 @@ watch(
|
||||
:items="transferTypeItems"
|
||||
:label="t('directory.transferType')"
|
||||
:no-data-text="computedNoDataText"
|
||||
mobile-control-width="65%"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="8">
|
||||
@@ -375,6 +390,7 @@ watch(
|
||||
variant="underlined"
|
||||
:items="overwriteModeItems"
|
||||
:label="t('directory.overwriteMode')"
|
||||
mobile-control-width="65%"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
|
||||
|
||||
@@ -73,16 +73,16 @@ async function deleteDownload() {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-if="cardState"
|
||||
v-bind="hover.props"
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-surface flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-if="cardState" v-bind="hover.props" class="downloading-card-hover-area h-full">
|
||||
<VCard
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-hover-lift-card app-surface flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
>
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="props.info?.media.image"
|
||||
@@ -130,7 +130,8 @@ async function deleteDownload() {
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
@@ -138,6 +139,10 @@ async function deleteDownload() {
|
||||
<style lang="scss" scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.downloading-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.downloading-card-image {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ const props = defineProps({
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['close', 'changed'])
|
||||
|
||||
// 按钮点击
|
||||
/** 关闭当前优先级规则卡片。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 选项变化
|
||||
/** 将当前优先级的规则选择结果通知父组件。 */
|
||||
function filtersChanged(value: string[]) {
|
||||
emit('changed', props.pri, value)
|
||||
}
|
||||
@@ -61,6 +61,7 @@ const selectFilterOptions = computed<{ [key: string]: string }[]>(() => {
|
||||
:items="selectFilterOptions"
|
||||
chips
|
||||
:label="t('filterRule.rules')"
|
||||
mobile-control-width="80%"
|
||||
multiple
|
||||
clearable
|
||||
@update:modelValue="filtersChanged"
|
||||
|
||||
@@ -25,18 +25,51 @@ const imageLoaded = ref(false)
|
||||
// 图片是否加载错误
|
||||
const imageError = ref(false)
|
||||
|
||||
// 图片加载完成响应
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '3 / 2',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 媒体库内条目数量,兼容不同后端字段。
|
||||
const libraryItemCount = computed(() => props.media?.item_count)
|
||||
|
||||
// 是否展示右上角数量折角。
|
||||
const showCountCorner = computed(() => typeof libraryItemCount.value === 'number' && libraryItemCount.value >= 0)
|
||||
|
||||
// 右上角数量文案。
|
||||
const countLabel = computed(() => {
|
||||
if (!showCountCorner.value) return ''
|
||||
return formatLibraryCount(libraryItemCount.value || 0)
|
||||
})
|
||||
|
||||
/**
|
||||
* 格式化右上角媒体库数量。
|
||||
* @param count 媒体库内媒体数量
|
||||
*/
|
||||
function formatLibraryCount(count: number) {
|
||||
if (count >= 10000) return `${(count / 10000).toFixed(1).replace(/\.0$/, '')}万`
|
||||
return `${count}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记封面加载完成。
|
||||
*/
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 图片加载错误
|
||||
/**
|
||||
* 标记封面加载失败并切换默认图。
|
||||
*/
|
||||
function imageErrorHandler() {
|
||||
imageError.value = true
|
||||
imgUrl.value = getDefaultImage()
|
||||
}
|
||||
|
||||
// 默认图片
|
||||
/**
|
||||
* 获取媒体服务器默认封面图。
|
||||
*/
|
||||
function getDefaultImage() {
|
||||
if (props.media?.server_type === 'plex') return plex
|
||||
else if (props.media?.server_type === 'emby') return emby
|
||||
@@ -47,14 +80,20 @@ function getDefaultImage() {
|
||||
else return plex
|
||||
}
|
||||
|
||||
// 跳转播放
|
||||
/**
|
||||
* 跳转到媒体服务器媒体库页面。
|
||||
*/
|
||||
async function goPlay() {
|
||||
if (props.media) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成图片代理路径
|
||||
/**
|
||||
* 生成图片代理路径。
|
||||
* @param url 原始图片地址
|
||||
* @param use_cookies 是否携带 Cookie 代理图片
|
||||
*/
|
||||
function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
if (!url || imageError.value) return getDefaultImage()
|
||||
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
@@ -64,7 +103,11 @@ function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
return imgurl
|
||||
}
|
||||
|
||||
// 根据多张图片生成媒体库封面
|
||||
/**
|
||||
* 根据多张图片生成媒体库封面。
|
||||
* @param imageList 媒体库封面候选图列表
|
||||
* @param use_cookies 是否携带 Cookie 代理图片
|
||||
*/
|
||||
async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
// 图片
|
||||
const IMAGES = [...imageList]
|
||||
@@ -96,7 +139,11 @@ async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
// 设置背景色为透明
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 绘制图片
|
||||
/**
|
||||
* 绘制单张海报及其倒影。
|
||||
* @param imgSrc 海报图片地址
|
||||
* @param index 海报位置
|
||||
*/
|
||||
async function drawImageWithReflection(imgSrc: string, index: number) {
|
||||
if (!canvas) return
|
||||
|
||||
@@ -156,35 +203,162 @@ onMounted(async () => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="library-card-hover-area">
|
||||
<VCard
|
||||
:style="cardStyle"
|
||||
class="app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<VImg
|
||||
:src="imgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="library-card-image"
|
||||
:class="{ 'library-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
<div class="library-card-placeholder">
|
||||
<VSkeletonLoader class="library-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<VCardText
|
||||
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
<h1 class="mb-1 text-white text-shadow font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.name }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
<div class="library-card-shade" aria-hidden="true" />
|
||||
<div v-if="showCountCorner" class="library-card-count-corner">
|
||||
<span>{{ countLabel }}</span>
|
||||
</div>
|
||||
<div class="library-card-label">
|
||||
<span>{{ props.media?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.library-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 6, 23, 0%) 0%, rgba(2, 6, 23, 2%) 56%, rgba(2, 6, 23, 28%) 100%),
|
||||
linear-gradient(90deg, rgba(2, 6, 23, 8%) 0%, rgba(2, 6, 23, 0%) 42%, rgba(2, 6, 23, 10%) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.library-card-label {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(6, 10, 17, 58%);
|
||||
block-size: 1.75rem;
|
||||
border-block-start: 1px solid rgba(255, 255, 255, 12%);
|
||||
border-end-end-radius: var(--app-surface-radius);
|
||||
border-end-start-radius: var(--app-surface-radius);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
line-height: 1;
|
||||
padding-inline: 0.75rem;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 48%);
|
||||
}
|
||||
|
||||
.library-card-label span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.library-card-count-corner {
|
||||
position: absolute;
|
||||
block-size: 3.35rem;
|
||||
color: #fff;
|
||||
inline-size: 3.35rem;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.library-card-count-corner::before {
|
||||
position: absolute;
|
||||
background: rgba(8, 13, 22, 72%);
|
||||
block-size: 100%;
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||
content: "";
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-count-corner::after {
|
||||
position: absolute;
|
||||
background: rgba(var(--v-theme-primary), 62%);
|
||||
block-size: 100%;
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||
content: "";
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-count-corner span {
|
||||
position: absolute;
|
||||
inset-block-start: 0.63rem;
|
||||
inset-inline-end: 0.18rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 44%);
|
||||
transform: rotate(45deg);
|
||||
transform-origin: center;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.library-card-placeholder,
|
||||
.library-card-skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.library-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.library-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeTouchMediaCardId = ref<number | null>(null)
|
||||
let mediaCardIdSeed = 0
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
|
||||
import { formatRating } from '@/@core/utils/formatters'
|
||||
import type { MediaInfo, Site, Subscribe } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import {
|
||||
getMediaSubscribeId,
|
||||
getSubscribeMode,
|
||||
useMediaSubscribe,
|
||||
type SeasonSubscribeModes,
|
||||
} from '@/composables/useMediaSubscribe'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
setCachedMediaSubscribeStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
|
||||
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeSeasonDialog = defineAsyncComponent(() => import('../dialog/SubscribeSeasonDialog.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -49,9 +56,6 @@ const userPermissions = computed(() => buildUserPermissionContext(userStore.supe
|
||||
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 图片加载状态
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
@@ -64,8 +68,15 @@ const isSubscribed = ref(false)
|
||||
// 本地存在状态
|
||||
const isExists = ref(false)
|
||||
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<MediaSeason[]>([])
|
||||
// 当前媒体已订阅的季号
|
||||
const subscribedSeasons = ref<number[]>([])
|
||||
|
||||
// 当前媒体已订阅季的订阅模式
|
||||
const subscribedSeasonModes = ref<SeasonSubscribeModes>({})
|
||||
|
||||
const subscribedSeasonsLoaded = ref(false)
|
||||
|
||||
const subscribedSeasonsLoading = ref(false)
|
||||
|
||||
// 来源角标字典
|
||||
const sourceIconDict: { [key: string]: any } = {
|
||||
@@ -89,32 +100,12 @@ const selectedSites = ref<number[]>([])
|
||||
// 搜索菜单显示状态
|
||||
const searchMenuShow = ref(false)
|
||||
|
||||
// 选择的剧集组
|
||||
const episodeGroup = ref('')
|
||||
const mediaCardId = ++mediaCardIdSeed
|
||||
|
||||
// 打开订阅季选择弹窗,避免每个媒体卡片都持有弹窗实例。
|
||||
function openSubscribeSeasonDialog() {
|
||||
openSharedDialog(
|
||||
SubscribeSeasonDialog,
|
||||
{ media: props.media },
|
||||
{
|
||||
subscribe: subscribeSeasons,
|
||||
},
|
||||
{ closeOn: ['close', 'subscribe'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开订阅编辑弹窗,保存、关闭或删除时释放共享弹窗实例。
|
||||
function openSubscribeEditDialog(subid: number) {
|
||||
openSharedDialog(
|
||||
SubscribeEditDialog,
|
||||
{ subid },
|
||||
{
|
||||
remove: onRemoveSubscribe,
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
// 粗指针设备使用点击展开详情,避免 iOS 返回后沿用 VHover 的触摸态。
|
||||
const isTouchLikePointer = ref(
|
||||
typeof window !== 'undefined' && Boolean(window.matchMedia?.('(hover: none), (pointer: coarse)').matches),
|
||||
)
|
||||
|
||||
// 打开站点选择弹窗,并把选择结果交回当前媒体卡片继续搜索。
|
||||
function openSearchSiteDialog() {
|
||||
@@ -155,10 +146,7 @@ async function querySelectedSites() {
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
|
||||
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
|
||||
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
return getMediaSubscribeId(props.media)
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
|
||||
@@ -177,6 +165,15 @@ function getExistsStatusKey() {
|
||||
].join('::')
|
||||
}
|
||||
|
||||
function isSameSubscribeMedia(subscribe: Subscribe) {
|
||||
if (props.media?.tmdb_id && subscribe.tmdbid) return props.media.tmdb_id === subscribe.tmdbid
|
||||
if (props.media?.douban_id && subscribe.doubanid) return props.media.douban_id === subscribe.doubanid
|
||||
if (props.media?.bangumi_id && subscribe.bangumiid) return props.media.bangumi_id === subscribe.bangumiid
|
||||
|
||||
const mediaId = props.media?.media_id ? `${props.media.mediaid_prefix}:${props.media.media_id}` : ''
|
||||
return Boolean(mediaId && subscribe.mediaid === mediaId)
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -184,101 +181,6 @@ function getChipColor(type: string) {
|
||||
else return 'border-purple-600 bg-purple-600'
|
||||
}
|
||||
|
||||
// 添加订阅处理
|
||||
async function handleAddSubscribe() {
|
||||
if (props.media?.type === '电视剧') {
|
||||
// 弹出季选择列表,支持多选
|
||||
seasonsSelected.value = []
|
||||
openSubscribeSeasonDialog()
|
||||
} else {
|
||||
// 电影
|
||||
addSubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API添加订阅,电视剧的话需要指定季
|
||||
async function addSubscribe(season: number | null = null, best_version: number = 0) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
// 是否洗版
|
||||
if (!best_version && props.media?.type == '电影') best_version = isExists.value ? 1 : 0
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', {
|
||||
name: props.media?.title,
|
||||
type: props.media?.type,
|
||||
year: props.media?.year,
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
doubanid: props.media?.douban_id,
|
||||
bangumiid: props.media?.bangumi_id,
|
||||
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
|
||||
season: props.media?.type === '电影' ? null : season,
|
||||
best_version,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
|
||||
// 订阅状态
|
||||
if (result.success) {
|
||||
// 订阅成功
|
||||
isSubscribed.value = true
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
|
||||
}
|
||||
|
||||
// 提示
|
||||
showSubscribeAddToast(result.success, props.media?.title ?? '', season, result.message, best_version)
|
||||
|
||||
// 弹出订阅编辑弹窗
|
||||
if (result.success && seasonsSelected.value.length <= 1) {
|
||||
const show_edit_dialog = await queryDefaultSubscribeConfig()
|
||||
if (show_edit_dialog) {
|
||||
openSubscribeEditDialog(result.data.id)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 弹出添加订阅提示
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
|
||||
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
let subname = t('subscribe.normalSub')
|
||||
if (best_version > 0) subname = t('subscribe.versionSub')
|
||||
|
||||
if (result) $toast.success(`${title} ${t('subscribe.addSuccess', { name: subname })}`)
|
||||
else if (!result) $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message: message })}`)
|
||||
}
|
||||
|
||||
// 调用API取消订阅
|
||||
async function removeSubscribe() {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season: props.media?.season,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
isSubscribed.value = false
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
|
||||
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
@@ -291,6 +193,36 @@ async function handleCheckSubscribe() {
|
||||
}
|
||||
}
|
||||
|
||||
async function querySubscribedSeasons() {
|
||||
if (
|
||||
props.media?.type !== '电视剧' ||
|
||||
!isSubscribed.value ||
|
||||
subscribedSeasonsLoaded.value ||
|
||||
subscribedSeasonsLoading.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
subscribedSeasonsLoading.value = true
|
||||
try {
|
||||
const subscribes: Subscribe[] = await api.get('subscribe/')
|
||||
const mediaSubscribes = subscribes.filter(
|
||||
item => item.type === '电视剧' && item.season !== undefined && isSameSubscribeMedia(item),
|
||||
)
|
||||
|
||||
subscribedSeasons.value = mediaSubscribes.map(item => item.season as number).sort((a, b) => a - b)
|
||||
subscribedSeasonModes.value = mediaSubscribes.reduce<SeasonSubscribeModes>((modes, item) => {
|
||||
if (item.season !== undefined) modes[item.season] = getSubscribeMode(item)
|
||||
return modes
|
||||
}, {})
|
||||
subscribedSeasonsLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
subscribedSeasonsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
@@ -317,63 +249,20 @@ async function handleCheckExists() {
|
||||
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season: number | null) {
|
||||
try {
|
||||
// AbortController 现在由全局请求优化器自动管理
|
||||
const mediaid = getMediaId()
|
||||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season,
|
||||
title: props.media?.title,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
return false
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
if (!canSubscribe.value) return false
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
if (result.data?.value) return result.data.value.show_edit_dialog
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
return false
|
||||
return subscribeActions.checkSubscribe(season)
|
||||
}
|
||||
|
||||
// 爱心订阅按钮响应
|
||||
function handleSubscribe() {
|
||||
if (isSubscribed.value) removeSubscribe()
|
||||
else handleAddSubscribe()
|
||||
}
|
||||
|
||||
// 订阅多季
|
||||
function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {
|
||||
episodeGroup.value = groudId
|
||||
seasonsSelected.value = seasons || []
|
||||
seasonsSelected.value.forEach(season => {
|
||||
let best_version = 0
|
||||
if (season && props.media?.tmdb_id)
|
||||
// 全部存在时洗版
|
||||
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
|
||||
addSubscribe(season.season_number ?? null, best_version)
|
||||
})
|
||||
async function handleSubscribe() {
|
||||
await querySubscribedSeasons()
|
||||
subscribeActions.handleSubscribe()
|
||||
}
|
||||
|
||||
// 打开详情页
|
||||
function goMediaDetail(isHovering = false) {
|
||||
if (isHovering) {
|
||||
resetMediaCardDetailState()
|
||||
|
||||
if (props.media?.collection_id) {
|
||||
// 跳转到合集列表
|
||||
router.push({
|
||||
@@ -397,6 +286,42 @@ function goMediaDetail(isHovering = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// 当前卡片是否进入可操作详情态,桌面使用 hover,触摸端使用显式点击态。
|
||||
function isMediaCardActive(isHovering: boolean | null | undefined) {
|
||||
return activeTouchMediaCardId.value === mediaCardId || (!isTouchLikePointer.value && Boolean(isHovering))
|
||||
}
|
||||
|
||||
// 当前卡片详情层是否显示,保留图片错误和站点选择时的强制显示。
|
||||
function isMediaCardDetailVisible(isHovering: boolean | null | undefined) {
|
||||
return isMediaCardActive(isHovering) || imageLoadError.value || searchMenuShow.value
|
||||
}
|
||||
|
||||
// 清理移动端详情层展开态,避免路由返回后继续显示上一次的详情层。
|
||||
function resetMediaCardDetailState() {
|
||||
if (activeTouchMediaCardId.value === mediaCardId) {
|
||||
activeTouchMediaCardId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理媒体卡片点击:触摸端第一次点击展开,展开后再次点击进入详情。
|
||||
function handleMediaCardClick(isHovering: boolean | null | undefined) {
|
||||
if (isTouchLikePointer.value && !isMediaCardDetailVisible(isHovering)) {
|
||||
activeTouchMediaCardId.value = mediaCardId
|
||||
querySubscribedSeasons()
|
||||
return
|
||||
}
|
||||
|
||||
goMediaDetail(isMediaCardActive(isHovering) || isMediaCardDetailVisible(isHovering))
|
||||
}
|
||||
|
||||
function handleDocumentPointerDown(event: PointerEvent) {
|
||||
if (!isTouchLikePointer.value || activeTouchMediaCardId.value !== mediaCardId) return
|
||||
if (!(event.target instanceof Node)) return
|
||||
if (mediaCardRef.value?.contains(event.target)) return
|
||||
|
||||
resetMediaCardDetailState()
|
||||
}
|
||||
|
||||
// 点击搜索
|
||||
async function clickSearch() {
|
||||
if (allSites.value?.length == 0) {
|
||||
@@ -469,22 +394,54 @@ const getImgUrl: Ref<string> = computed(() => {
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 移除订阅
|
||||
function onRemoveSubscribe() {
|
||||
isSubscribed.value = false
|
||||
}
|
||||
|
||||
// 获取媒体类型文本
|
||||
function getMediaTypeText(type: string | undefined) {
|
||||
if (!type) return ''
|
||||
return mediaTypeDict[type]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupIntersectionObserver()
|
||||
const subscribeActions = useMediaSubscribe({
|
||||
media: () => props.media,
|
||||
canSubscribe: () => canSubscribe.value,
|
||||
isSubscribed,
|
||||
isExists: () => isExists.value,
|
||||
subscribedSeasons,
|
||||
subscribedSeasonModes,
|
||||
primarySeason: () => props.media?.season ?? null,
|
||||
getSubscribeStatusKey,
|
||||
})
|
||||
|
||||
watch(isSubscribed, subscribed => {
|
||||
subscribedSeasonsLoaded.value = false
|
||||
if (!subscribed) {
|
||||
subscribedSeasons.value = []
|
||||
subscribedSeasonModes.value = {}
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.media,
|
||||
() => {
|
||||
resetMediaCardDetailState()
|
||||
subscribedSeasons.value = []
|
||||
subscribedSeasonModes.value = {}
|
||||
subscribedSeasonsLoaded.value = false
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
setupIntersectionObserver()
|
||||
if (isTouchLikePointer.value) {
|
||||
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||
}
|
||||
})
|
||||
|
||||
onActivated(resetMediaCardDetailState)
|
||||
onDeactivated(resetMediaCardDetailState)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetMediaCardDetailState()
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
observer.value?.disconnect()
|
||||
observer.value = null
|
||||
})
|
||||
@@ -493,17 +450,18 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div ref="mediaCardRef">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div ref="mediaCardRef" v-bind="hover.props" class="media-card-hover-area" @mouseenter="querySubscribedSeasons">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none ring-gray-500 media-card"
|
||||
:ripple="false"
|
||||
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||
@click.stop="handleMediaCardClick(hover.isHovering)"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
@@ -522,7 +480,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError || searchMenuShow"
|
||||
v-show="isMediaCardDetailVisible(hover.isHovering)"
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
>
|
||||
@@ -535,17 +493,11 @@ onBeforeUnmount(() => {
|
||||
</p>
|
||||
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
|
||||
<div v-else class="flex align-center justify-between">
|
||||
<IconBtn
|
||||
v-if="canSearch"
|
||||
icon="mdi-magnify"
|
||||
color="white"
|
||||
size="small"
|
||||
@click.stop="clickSearch"
|
||||
/>
|
||||
<IconBtn v-if="canSearch" icon="mdi-magnify" color="white" size="small" @click.stop="clickSearch" />
|
||||
<VSpacer />
|
||||
<IconBtn
|
||||
v-if="canSubscribe"
|
||||
icon="mdi-heart"
|
||||
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
|
||||
:color="isSubscribed ? 'error' : 'white'"
|
||||
size="small"
|
||||
@click.stop="handleSubscribe"
|
||||
@@ -563,10 +515,10 @@ onBeforeUnmount(() => {
|
||||
{{ getMediaTypeText(props.media?.type) }}
|
||||
</VChip>
|
||||
<!-- 本地存在标识 -->
|
||||
<ExistIcon v-if="isExists && !hover.isHovering" />
|
||||
<ExistIcon v-if="isExists && !isMediaCardActive(hover.isHovering)" />
|
||||
<!-- 评分角标 -->
|
||||
<VChip
|
||||
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
|
||||
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !isMediaCardActive(hover.isHovering))"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor('rating')"
|
||||
@@ -577,10 +529,11 @@ onBeforeUnmount(() => {
|
||||
<!--来源图标-->
|
||||
<VAvatar
|
||||
size="24"
|
||||
variant="plain"
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
v-if="!hover.isHovering && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
@@ -590,6 +543,10 @@ onBeforeUnmount(() => {
|
||||
</VHover>
|
||||
</template>
|
||||
<style scoped>
|
||||
.media-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.media-card-title {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.25rem;
|
||||
@@ -599,4 +556,20 @@ onBeforeUnmount(() => {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.media-card-subscribe-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
gap: 0.25rem;
|
||||
line-height: 1rem;
|
||||
min-block-size: 1.25rem;
|
||||
}
|
||||
|
||||
.media-card-subscribe-summary span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import type { Message } from '@/api/types'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
message: Object as PropType<Message>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['imageload'])
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
// 插件:链接在新窗口打开
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
emit('imageload')
|
||||
}
|
||||
|
||||
// 链接打开新窗口
|
||||
function openLink() {
|
||||
if (props.message?.link) window.open(props.message.link, '_blank')
|
||||
}
|
||||
|
||||
// 将note转换为json
|
||||
function noteToJson() {
|
||||
if (props.message?.note) {
|
||||
try {
|
||||
return JSON.parse(props.message.note)
|
||||
} catch (error) {
|
||||
return props.message.note
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// 渲染 Markdown
|
||||
function renderMarkdown(value: string) {
|
||||
if (!value) return ''
|
||||
return md.render(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink" max-width="23rem">
|
||||
<div v-if="props.message?.image" class="relative text-center card-cover-blurred">
|
||||
<VImg
|
||||
:src="props.message?.image"
|
||||
aspect-ratio="3/2"
|
||||
cover
|
||||
position="top"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
min-height="10rem"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
props.message?.title &&
|
||||
!props.message?.text &&
|
||||
!props.message?.image &&
|
||||
isNullOrEmptyObject(props.message?.note) &&
|
||||
props.message?.action === 0
|
||||
"
|
||||
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
||||
>
|
||||
<p class="mb-0">{{ props.message?.title }}</p>
|
||||
</div>
|
||||
<VCardTitle v-else-if="props.message?.title" class="break-words whitespace-break-spaces">
|
||||
{{ props.message?.title }}
|
||||
</VCardTitle>
|
||||
<div
|
||||
v-if="props.message?.text && props.message?.action === 0"
|
||||
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
|
||||
>
|
||||
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
|
||||
</div>
|
||||
<VCardText
|
||||
v-if="props.message?.text && props.message?.action === 1"
|
||||
class="markdown-body"
|
||||
v-html="renderMarkdown(props.message?.text)"
|
||||
/>
|
||||
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
|
||||
<VList>
|
||||
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
|
||||
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ Number(key) + 1 }}. {{ value.title_year }}
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="value.type">
|
||||
类型:{{ value.type }} 评分:{{ value.vote_average }}
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle v-if="value.enclosure" class="whitespace-break-spaces">
|
||||
{{ value.description }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<div class="text-end">
|
||||
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
|
||||
<span class="text-sm italic me-2">{{
|
||||
formatDateDifference(props.message?.reg_time || props.message?.date || '')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.markdown-body {
|
||||
word-break: break-all;
|
||||
|
||||
p {
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
display: list-item;
|
||||
margin-block-end: 0.25rem;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
font-family: monospace;
|
||||
padding-block: 0.2rem;
|
||||
padding-inline: 0.4rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
margin-block-end: 0.5rem;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
|
||||
font-style: italic;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
inline-size: 100%;
|
||||
margin-block-end: 1rem;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.1);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: rgba(var(--v-border-color), 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
block-size: auto;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -75,15 +75,17 @@ function goPersonDetail() {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="person-card-hover-area">
|
||||
<VCard
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
class="app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
<div class="person-card relative cursor-pointer ring-gray-700">
|
||||
<div style="padding-block-end: 150%">
|
||||
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||
@@ -107,12 +109,17 @@ function goPersonDetail() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.person-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.person-card {
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
|
||||
365
src/components/cards/PlayingBackdropCard.vue
Normal file
365
src/components/cards/PlayingBackdropCard.vue
Normal file
@@ -0,0 +1,365 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaServerPlayItem>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 卡片内联尺寸,便于仪表盘网格按高度进行虚拟布局。
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '16 / 10',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 规范化后的播放进度,避免后端异常值撑破进度条。
|
||||
const progressValue = computed(() => {
|
||||
const percent = Number(props.media?.percent ?? 0)
|
||||
if (Number.isNaN(percent)) return 0
|
||||
return Math.min(Math.max(percent, 0), 100)
|
||||
})
|
||||
|
||||
// 是否存在可续播进度。
|
||||
const hasProgress = computed(() => progressValue.value > 0)
|
||||
|
||||
// 右上角进度标签。
|
||||
const progressLabel = computed(() => (hasProgress.value ? `${Math.round(progressValue.value)}%` : 'NEW'))
|
||||
|
||||
// 副信息兜底,避免空副标题导致卡片底部信息层过空。
|
||||
const subtitleText = computed(() => props.media?.subtitle || props.media?.type || '继续观看')
|
||||
|
||||
// 根据播放进度生成简短提示。
|
||||
const progressHint = computed(() => {
|
||||
if (!hasProgress.value) return '未开始'
|
||||
if (progressValue.value >= 90) return '快看完'
|
||||
if (progressValue.value <= 10) return '刚开始'
|
||||
return '继续上次进度'
|
||||
})
|
||||
|
||||
// 计算图片代理地址。
|
||||
const imageUrl = computed(() => {
|
||||
const image = props.media?.image || ''
|
||||
if (!image || imageLoadError.value) return noImage
|
||||
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const useCookies = props.media?.use_cookies
|
||||
if (useCookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(useCookies)}`
|
||||
}
|
||||
|
||||
return url
|
||||
})
|
||||
|
||||
/**
|
||||
* 标记封面加载完成。
|
||||
*/
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记封面加载失败并切换到默认图。
|
||||
*/
|
||||
function imageErrorHandler() {
|
||||
imageLoadError.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到媒体服务器播放页面。
|
||||
*/
|
||||
async function goPlay() {
|
||||
if (props.media) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="playing-card-hover-area">
|
||||
<VCard
|
||||
:style="cardStyle"
|
||||
class="playing-card app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering playing-card--hovering': hover.isHovering,
|
||||
'playing-card--loaded': imageLoaded,
|
||||
}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goPlay"
|
||||
@keyup.enter="goPlay"
|
||||
@keyup.space="goPlay"
|
||||
>
|
||||
<VImg
|
||||
:src="imageUrl"
|
||||
class="playing-card__image"
|
||||
:class="{ 'playing-card__image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="playing-card__placeholder">
|
||||
<VSkeletonLoader class="playing-card__skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
|
||||
<div class="playing-card__scrim" />
|
||||
<div class="playing-card__bottom-scrim" />
|
||||
|
||||
<div class="playing-card__percent">
|
||||
{{ progressLabel }}
|
||||
</div>
|
||||
|
||||
<div class="playing-card__play" aria-hidden="true">
|
||||
<VIcon icon="mdi-play" size="26" />
|
||||
</div>
|
||||
|
||||
<div class="playing-card__content">
|
||||
<div class="playing-card__title">
|
||||
{{ props.media?.title }}
|
||||
</div>
|
||||
<div class="playing-card__subtitle">
|
||||
{{ subtitleText }}
|
||||
</div>
|
||||
<div class="playing-card__meta">
|
||||
<span>{{ progressHint }}</span>
|
||||
<span v-if="props.media?.server_type" class="playing-card__server">
|
||||
{{ props.media.server_type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playing-card__progress" aria-hidden="true">
|
||||
<div class="playing-card__progress-track">
|
||||
<div class="playing-card__progress-bar" :style="{ inlineSize: `${progressValue}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.playing-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.playing-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.16);
|
||||
background: rgb(var(--v-theme-surface));
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.playing-card__image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.playing-card__placeholder,
|
||||
.playing-card__skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.playing-card__image :deep(.v-img__img) {
|
||||
filter: saturate(0.96) brightness(0.92);
|
||||
opacity: 0;
|
||||
transform: scale(1.01);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.35s ease;
|
||||
}
|
||||
|
||||
.playing-card__image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.playing-card--hovering .playing-card__image :deep(.v-img__img) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.playing-card__scrim,
|
||||
.playing-card__bottom-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.playing-card__scrim {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 24%) 0%, rgba(0, 0, 0, 4%) 48%, rgba(0, 0, 0, 28%) 100%),
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 8%) 0%, rgba(0, 0, 0, 0%) 42%);
|
||||
}
|
||||
|
||||
.playing-card__bottom-scrim {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
|
||||
}
|
||||
|
||||
.playing-card__percent,
|
||||
.playing-card__play,
|
||||
.playing-card__content,
|
||||
.playing-card__progress {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.playing-card__percent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 18%);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(255, 255, 255, 16%);
|
||||
block-size: 26px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
inline-size: 52px;
|
||||
inset-block-start: 12px;
|
||||
inset-inline-end: 12px;
|
||||
}
|
||||
|
||||
.playing-card__play {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 92%);
|
||||
block-size: 40px;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 24%);
|
||||
color: rgb(11, 15, 20);
|
||||
inline-size: 40px;
|
||||
inset-block-end: 46px;
|
||||
inset-inline-end: 18px;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.playing-card--hovering .playing-card__play {
|
||||
background: #fff;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.playing-card__content {
|
||||
inset-block-end: 18px;
|
||||
inset-inline: 16px 66px;
|
||||
min-inline-size: 0;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 45%);
|
||||
}
|
||||
|
||||
.playing-card__title {
|
||||
overflow: hidden;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playing-card__subtitle {
|
||||
overflow: hidden;
|
||||
margin-block-start: 4px;
|
||||
color: rgba(255, 255, 255, 86%);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playing-card__meta {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-block-start: 7px;
|
||||
color: rgba(255, 255, 255, 70%);
|
||||
font-size: 0.72rem;
|
||||
gap: 10px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playing-card__server {
|
||||
overflow: hidden;
|
||||
max-inline-size: 6rem;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.playing-card__progress {
|
||||
inset-block-end: 6px;
|
||||
inset-inline: 16px;
|
||||
}
|
||||
|
||||
.playing-card__progress-track {
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 24%);
|
||||
block-size: 5px;
|
||||
}
|
||||
|
||||
.playing-card__progress-bar {
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, rgb(var(--v-theme-success)) 0%, rgba(255, 255, 255, 92%) 100%);
|
||||
block-size: 100%;
|
||||
min-inline-size: 8px;
|
||||
transition: inline-size 0.25s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.playing-card__content {
|
||||
inset-inline-end: 58px;
|
||||
}
|
||||
|
||||
.playing-card__title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.playing-card__play {
|
||||
block-size: 36px;
|
||||
inline-size: 36px;
|
||||
inset-inline-end: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.playing-card__image :deep(.v-img__img),
|
||||
.playing-card__play,
|
||||
.playing-card__progress-bar {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.playing-card--hovering .playing-card__image :deep(.v-img__img),
|
||||
.playing-card--hovering .playing-card__play {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
||||
)
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -26,6 +30,11 @@ const emit = defineEmits(['install'])
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
|
||||
@@ -48,6 +57,21 @@ const isImageLoaded = ref(false)
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件安装进度弹窗。 */
|
||||
function showInstallProgress(text: string) {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
|
||||
}
|
||||
|
||||
/** 关闭插件安装进度弹窗。 */
|
||||
function closeInstallProgress() {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = null
|
||||
}
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
@@ -96,14 +120,69 @@ function visitPluginPage() {
|
||||
|
||||
// 显示更新日志
|
||||
function showUpdateHistory() {
|
||||
openSharedDialog(
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin },
|
||||
{},
|
||||
{ plugin: props.plugin, actionMode: 'install' },
|
||||
{
|
||||
update: installPlugin,
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
/** 从插件市场版本历史安装指定 Release;最新版本走普通安装路径以保留主程序兼容校验。 */
|
||||
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||
return
|
||||
}
|
||||
|
||||
if (releaseVersion) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('plugin.confirmInstallOldRelease', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion,
|
||||
}),
|
||||
confirmText: t('common.confirm'),
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
}
|
||||
|
||||
try {
|
||||
showInstallProgress(
|
||||
t('plugin.installing', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion || props.plugin?.plugin_version,
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: props.plugin?.has_update || Boolean(releaseVersion),
|
||||
},
|
||||
})
|
||||
|
||||
closeInstallProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
emit('install')
|
||||
} else {
|
||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closeInstallProgress()
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开共享插件市场详情弹窗。 */
|
||||
function showPluginDetail() {
|
||||
openSharedDialog(
|
||||
@@ -140,22 +219,28 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
onUnmounted(() => {
|
||||
closeInstallProgress()
|
||||
versionHistoryDialogController?.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="showPluginDetail"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="plugin-app-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="showPluginDetail"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
@@ -241,13 +326,18 @@ const dropdownItems = ref([
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-app-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-app-card__tags-section {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -69,6 +69,7 @@ const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
||||
function showPluginProgress(text: string) {
|
||||
@@ -103,11 +104,12 @@ async function imageLoaded() {
|
||||
|
||||
// 显示更新日志
|
||||
function showUpdateHistory(showUpdateAction: boolean = false) {
|
||||
openSharedDialog(
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin, showUpdateAction },
|
||||
{ update: updatePlugin },
|
||||
{ closeOn: ['close', 'update', 'update:modelValue'] },
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,19 +221,37 @@ async function resetPlugin() {
|
||||
}
|
||||
|
||||
// 更新插件
|
||||
async function updatePlugin() {
|
||||
if (props.plugin?.system_version_compatible === false) {
|
||||
async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||
return
|
||||
}
|
||||
|
||||
if (releaseVersion) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('plugin.confirmInstallOldRelease', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion,
|
||||
}),
|
||||
confirmText: t('common.confirm'),
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
}
|
||||
|
||||
try {
|
||||
// 显示等待提示框
|
||||
showPluginProgress(t('plugin.updating', { name: props.plugin?.plugin_name }))
|
||||
showPluginProgress(
|
||||
releaseVersion
|
||||
? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion })
|
||||
: t('plugin.updating', { name: props.plugin?.plugin_name }),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: true,
|
||||
},
|
||||
})
|
||||
@@ -241,6 +261,8 @@ async function updatePlugin() {
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
@@ -545,19 +567,19 @@ watch(
|
||||
<!-- 插件卡片 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-if="isVisible"
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="handleCardClick"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-if="isVisible" v-bind="hover.props" class="plugin-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="handleCardClick"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
@@ -647,7 +669,8 @@ watch(
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
@@ -655,6 +678,10 @@ watch(
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugin-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.card-cover-blurred::before {
|
||||
position: absolute;
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
|
||||
@@ -211,20 +211,21 @@ const dropdownItems = ref([
|
||||
<!-- 文件夹卡片 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:ripple="false"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="plugin-folder-card-hover-area h-full">
|
||||
<VCard
|
||||
:ripple="false"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card app-hover-lift-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<template v-if="backgroundImage" #image>
|
||||
<VImg :src="backgroundImage" cover position="top"> </VImg>
|
||||
</template>
|
||||
@@ -288,25 +289,29 @@ const dropdownItems = ref([
|
||||
</VMenu>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugin-folder-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-folder-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&--sortable {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
transform: translateY(-4px);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
&__bg {
|
||||
|
||||
@@ -17,6 +17,12 @@ const isImageLoaded = ref(false)
|
||||
// 图片加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '2 / 3',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -47,20 +53,21 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="poster-card-hover-area">
|
||||
<VCard
|
||||
:style="cardStyle"
|
||||
class="app-hover-lift-card outline-none ring-gray-500"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
class="object-cover aspect-w-2 aspect-h-3"
|
||||
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
|
||||
:class="{ 'poster-card-image--loaded': isImageLoaded }"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
@@ -77,7 +84,7 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
class="poster-card-chip absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
@@ -93,7 +100,41 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.poster-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.poster-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.poster-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.poster-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.poster-card-image :deep(.v-responsive__sizer) {
|
||||
padding-bottom: 150%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.poster-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import type { ApiResponse, Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
@@ -79,17 +79,17 @@ async function testSite() {
|
||||
testButtonText.value = t('site.testing')
|
||||
testButtonDisable.value = true
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`site/test/${cardProps.site?.id}`)
|
||||
const result = (await api.get(`site/test/${cardProps.site?.id}`)) as ApiResponse<unknown>
|
||||
if (result.success) $toast.success(t('site.testSuccess', { name: cardProps.site?.name }))
|
||||
else $toast.error(t('site.testFailed', { name: cardProps.site?.name, message: result.message }))
|
||||
|
||||
testButtonText.value = t('site.testConnectivity')
|
||||
testButtonDisable.value = false
|
||||
|
||||
// 测试完成后刷新统计数据
|
||||
emit('refresh-stats', cardProps.site?.domain)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
testButtonText.value = t('site.testConnectivity')
|
||||
testButtonDisable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ async function deleteSiteInfo() {
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(`site/${cardProps.site?.id}`)
|
||||
const result = (await api.delete(`site/${cardProps.site?.id}`)) as ApiResponse<unknown>
|
||||
if (result.success) emit('remove')
|
||||
else $toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: result.message }))
|
||||
} catch (error) {
|
||||
@@ -239,191 +239,217 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer hover:-translate-y-1': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- 装饰性状态指示器 -->
|
||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="site-card-hover-area h-full">
|
||||
<VCard
|
||||
class="site-card app-hover-lift-card relative h-full flex flex-col overflow-hidden group"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer site-card--hoverable': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- 装饰性状态指示器 -->
|
||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||
|
||||
<!-- 主体部分 -->
|
||||
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
|
||||
<!-- 顶部:图标和站点名称 -->
|
||||
<div class="mb-1 flex min-w-0 items-center gap-2">
|
||||
<!-- 站点图标 -->
|
||||
<VAvatar
|
||||
tile
|
||||
rounded="lg"
|
||||
size="32"
|
||||
class="shrink-0"
|
||||
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-square" />
|
||||
<!-- 主体部分 -->
|
||||
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
|
||||
<!-- 顶部:图标和站点名称 -->
|
||||
<div class="mb-1 flex min-w-0 items-center gap-2">
|
||||
<!-- 站点图标 -->
|
||||
<VAvatar
|
||||
tile
|
||||
rounded="lg"
|
||||
size="32"
|
||||
class="shrink-0"
|
||||
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-square" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</VAvatar>
|
||||
|
||||
<!-- 站点名称和特性图标 -->
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
|
||||
|
||||
<!-- 站点特性图标 -->
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||
<div
|
||||
v-if="cardProps.site?.limit_interval"
|
||||
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-speedometer"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="cardProps.site?.proxy"
|
||||
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-network-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="cardProps.site?.render"
|
||||
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-apple-safari"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="cardProps.site?.filter"
|
||||
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-filter-cog-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</VAvatar>
|
||||
|
||||
<!-- 站点名称和特性图标 -->
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
|
||||
|
||||
<!-- 站点特性图标 -->
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||
<div v-if="cardProps.site?.limit_interval" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-speedometer"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.proxy" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-network-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.render" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-apple-safari"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.filter" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-filter-cog-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间部分:网址 -->
|
||||
<div class="my-3">
|
||||
<!-- 中间部分:网址 -->
|
||||
<div class="my-3">
|
||||
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="handleSiteUrlClick">
|
||||
{{ cardProps.site?.url }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:数据统计 -->
|
||||
<div class="flex-1 flex flex-col justify-end">
|
||||
<!-- 更直观的上传下载数据条 -->
|
||||
<div class="border-t mt-1.5 pt-1.5">
|
||||
<!-- 上传数据 -->
|
||||
<div class="flex items-center justify-between gap-3 mb-1.5">
|
||||
<div class="text-sm text-medium-emphasis min-w-[70px]">
|
||||
<VIcon icon="mdi-arrow-up" size="14" color="info" class="mr-1" />
|
||||
<span>{{ formatFileSize(cardProps.data?.upload || 0) }}</span>
|
||||
<!-- 底部:数据统计 -->
|
||||
<div class="flex-1 flex flex-col justify-end">
|
||||
<!-- 更直观的上传下载数据条 -->
|
||||
<div class="border-t mt-1.5 pt-1.5">
|
||||
<!-- 上传数据 -->
|
||||
<div class="flex items-center justify-between gap-3 mb-1.5">
|
||||
<div class="text-sm text-medium-emphasis min-w-[70px]">
|
||||
<VIcon icon="mdi-arrow-up" size="14" color="info" class="mr-1" />
|
||||
<span>{{ formatFileSize(cardProps.data?.upload || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
|
||||
<VProgressLinear :model-value="getUploadPercent" color="info" height="4" rounded="lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
|
||||
<VProgressLinear :model-value="getUploadPercent" color="info" height="4" rounded="lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下载数据 -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center text-[0.8rem] text-medium-emphasis min-w-[70px]">
|
||||
<VIcon icon="mdi-arrow-down" size="14" color="success" class="mr-1" />
|
||||
<span>{{ formatFileSize(cardProps.data?.download || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
|
||||
<VProgressLinear :model-value="getDownloadPercent" color="warning" height="4" rounded="lg" />
|
||||
<!-- 下载数据 -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center text-[0.8rem] text-medium-emphasis min-w-[70px]">
|
||||
<VIcon icon="mdi-arrow-down" size="14" color="success" class="mr-1" />
|
||||
<span>{{ formatFileSize(cardProps.data?.download || 0) }}</span>
|
||||
</div>
|
||||
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
|
||||
<VProgressLinear :model-value="getDownloadPercent" color="warning" height="4" rounded="lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作按钮区 -->
|
||||
<VSheet v-if="!cardProps.sortable" class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
|
||||
<!-- 测试按钮 -->
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
class="mb-1 relative flex items-center justify-center rounded-full mx-auto"
|
||||
:disabled="testButtonDisable"
|
||||
@click.stop="testSite"
|
||||
size="36"
|
||||
<!-- 右侧操作按钮区 -->
|
||||
<VSheet
|
||||
v-if="!cardProps.sortable"
|
||||
class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1"
|
||||
>
|
||||
<div class="relative flex items-center justify-center w-full h-full">
|
||||
<div
|
||||
class="w-[20px] h-[20px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
|
||||
:class="statColor"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
v-if="testButtonDisable"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-surface/95 rounded-full shadow-md animate-fade-in"
|
||||
<!-- 测试按钮 -->
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
class="mb-1 relative flex items-center justify-center rounded-full mx-auto"
|
||||
:disabled="testButtonDisable"
|
||||
@click.stop="testSite"
|
||||
size="36"
|
||||
>
|
||||
<div class="relative w-6 h-6">
|
||||
<div class="spinner-circle"></div>
|
||||
<div class="relative flex items-center justify-center w-full h-full">
|
||||
<div
|
||||
class="w-[20px] h-[20px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
|
||||
:class="statColor"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</VBtn>
|
||||
<div
|
||||
v-if="testButtonDisable"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-surface/95 rounded-full shadow-md animate-fade-in"
|
||||
>
|
||||
<div class="relative w-6 h-6">
|
||||
<div class="spinner-circle"></div>
|
||||
</div>
|
||||
</div>
|
||||
</VBtn>
|
||||
|
||||
<!-- 用户数据按钮 -->
|
||||
<VBtn icon variant="text" @click.stop="handleSiteUserData" size="36">
|
||||
<VIcon icon="mdi-chart-bell-curve" size="20" />
|
||||
</VBtn>
|
||||
<!-- 用户数据按钮 -->
|
||||
<VBtn icon variant="text" @click.stop="handleSiteUserData" size="36">
|
||||
<VIcon icon="mdi-chart-bell-curve" size="20" />
|
||||
</VBtn>
|
||||
|
||||
<!-- 更新按钮 -->
|
||||
<VBtn icon variant="text" @click.stop="handleSiteUpdate" size="36">
|
||||
<VIcon icon="mdi-refresh" size="20" />
|
||||
</VBtn>
|
||||
<!-- 更新按钮 -->
|
||||
<VBtn icon variant="text" @click.stop="handleSiteUpdate" size="36">
|
||||
<VIcon icon="mdi-refresh" size="20" />
|
||||
</VBtn>
|
||||
|
||||
<!-- 更多选项按钮 -->
|
||||
<VBtn icon variant="text" class="mt-auto" size="36" @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" size="20" />
|
||||
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
|
||||
<VList>
|
||||
<VListItem @click="handleSiteEdit" base-color="info">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-file-edit-outline" size="20" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('site.actions.edit') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="deleteSiteInfo">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete-outline" size="20" color="error" />
|
||||
</template>
|
||||
<VListItemTitle class="text-error">{{ t('site.deleteSite') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
</VSheet>
|
||||
</VCard>
|
||||
<!-- 更多选项按钮 -->
|
||||
<VBtn icon variant="text" class="mt-auto" size="36" @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" size="20" />
|
||||
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
|
||||
<VList>
|
||||
<VListItem @click="handleSiteEdit" base-color="info">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-file-edit-outline" size="20" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('site.actions.edit') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="deleteSiteInfo">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete-outline" size="20" color="error" />
|
||||
</template>
|
||||
<VListItemTitle class="text-error">{{ t('site.deleteSite') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
</VSheet>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.site-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.site-card-hover-area:hover .site-card--hoverable {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.site-status-indicator {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
@@ -431,7 +457,9 @@ onMounted(() => {
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
opacity: 0.5;
|
||||
transition: block-size 0.3s ease, opacity 0.3s ease;
|
||||
transition:
|
||||
block-size 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.site-status-indicator.error {
|
||||
@@ -455,7 +483,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
/* 站点卡片悬停时状态指示器变化 */
|
||||
.site-card:not(.site-card--sortable):hover .site-status-indicator {
|
||||
.site-card-hover-area:hover .site-card:not(.site-card--sortable) .site-status-indicator {
|
||||
block-size: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -644,7 +672,7 @@ onMounted(() => {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.site-card:hover .site-card-actions {
|
||||
.site-card-hover-area:hover .site-card-actions {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import { formatSeasonLabel } from '@/@core/utils/season'
|
||||
import api from '@/api'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
import router from '@/router'
|
||||
@@ -91,6 +92,17 @@ const rightBottomStateDisplay = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
// 移动端紧凑卡片的状态展示,颜色统一映射到 Vuetify 全局主题 token。
|
||||
const compactStateDisplay = computed(() => {
|
||||
if (subscribeState.value === 'S') {
|
||||
return { color: 'secondary', icon: 'mdi-pause-circle-outline', label: t('subscribe.cardStatePaused') }
|
||||
}
|
||||
if (subscribeState.value === 'P') {
|
||||
return { color: 'info', icon: 'mdi-timer-sand', label: t('subscribe.cardStatePending') }
|
||||
}
|
||||
return { color: 'primary', icon: 'mdi-rss', label: t('subscribe.subscribing') }
|
||||
})
|
||||
|
||||
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
|
||||
const bestVersionBadge = computed(() => {
|
||||
if (!isEnabledFlag(props.media?.best_version)) return null
|
||||
@@ -161,6 +173,7 @@ async function removeSubscribe() {
|
||||
emit('remove')
|
||||
}
|
||||
} catch (e) {
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
@@ -172,7 +185,9 @@ async function searchSubscribe() {
|
||||
|
||||
// 提示
|
||||
if (result.success) $toast.success(`${props.media?.name} 提交搜索请求成功!`)
|
||||
else $toast.error(t('subscribe.requestFailed'))
|
||||
} catch (e) {
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
@@ -199,6 +214,7 @@ async function toggleSubscribeStatus(state: 'R' | 'S') {
|
||||
$toast.error(t('subscribe.toggleFailed', { action, message: result.message }))
|
||||
}
|
||||
} catch (e) {
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
@@ -221,6 +237,7 @@ async function resetSubscribe() {
|
||||
emit('save')
|
||||
} else $toast.error(t('subscribe.resetFailed', { name: props.media?.name, message: result.message }))
|
||||
} catch (e) {
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
@@ -289,22 +306,6 @@ const dropdownItems = computed(() => [
|
||||
click: searchSubscribe,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.details'),
|
||||
value: 3,
|
||||
props: {
|
||||
prependIcon: 'mdi-information-outline',
|
||||
click: viewMediaDetail,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.files'),
|
||||
value: 4,
|
||||
props: {
|
||||
prependIcon: 'mdi-file-document-outline',
|
||||
click: viewSubscribeFiles,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: subscribeState.value === 'S' ? t('common.enable') : t('common.pause'),
|
||||
value: 5,
|
||||
@@ -333,6 +334,22 @@ const dropdownItems = computed(() => [
|
||||
},
|
||||
show: props.media?.type === '电视剧',
|
||||
},
|
||||
{
|
||||
title: t('subscribe.mediaDetail'),
|
||||
value: 3,
|
||||
props: {
|
||||
prependIcon: 'mdi-information-outline',
|
||||
click: viewMediaDetail,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('subscribe.fileStatistics'),
|
||||
value: 4,
|
||||
props: {
|
||||
prependIcon: 'mdi-file-document-outline',
|
||||
click: viewSubscribeFiles,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.unsubscribe'),
|
||||
value: 8,
|
||||
@@ -403,63 +420,162 @@ function handleCardClick() {
|
||||
<div>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="subscribe-card-shell w-full h-full relative"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full overflow-hidden"
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="subscribe-card-hover-area w-full h-full">
|
||||
<div
|
||||
class="subscribe-card-shell app-hover-lift-card w-full h-full relative"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'cursor-move': props.sortable,
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'subscribe-card-shell--selected': props.batchMode && props.selected,
|
||||
}"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<div
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
class="best-version-badge"
|
||||
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
|
||||
<VCard
|
||||
:key="props.media?.id"
|
||||
class="subscribe-card flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="display.smAndUp.value && !props.batchMode && !props.sortable"
|
||||
>
|
||||
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
|
||||
</div>
|
||||
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
<div
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
class="best-version-badge"
|
||||
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
|
||||
>
|
||||
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
|
||||
</div>
|
||||
<div v-if="!props.sortable && display.smAndUp.value" class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<template #image v-if="display.smAndUp.value">
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
|
||||
<template v-if="display.xs.value">
|
||||
<div class="subscribe-card-mobile-media">
|
||||
<VImg
|
||||
:src="backdropUrl || posterUrl"
|
||||
:aspect-ratio="2"
|
||||
cover
|
||||
position="top"
|
||||
@load="imageLoadHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="h-full w-full" />
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<template #image>
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</VImg>
|
||||
|
||||
<div
|
||||
v-if="props.media?.username || lastUpdateText"
|
||||
class="subscribe-card-mobile-image-meta"
|
||||
:class="{ 'subscribe-card-mobile-image-meta--with-badge': bestVersionBadge }"
|
||||
>
|
||||
<div
|
||||
v-if="props.media?.username"
|
||||
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__user"
|
||||
:title="props.media?.username"
|
||||
>
|
||||
<VIcon icon="mdi-account" size="14" />
|
||||
<span>{{ props.media?.username }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="lastUpdateText"
|
||||
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__updated"
|
||||
>
|
||||
<VIcon icon="mdi-download" size="14" />
|
||||
<span>{{ lastUpdateText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<div>
|
||||
</div>
|
||||
|
||||
<div class="subscribe-card-mobile-body">
|
||||
<div class="subscribe-card-mobile-title">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</div>
|
||||
|
||||
<div class="subscribe-card-mobile-footer">
|
||||
<div class="subscribe-card-mobile-meta">
|
||||
<div
|
||||
class="subscribe-card-mobile-state"
|
||||
:style="{ color: `rgb(var(--v-theme-${compactStateDisplay.color}))` }"
|
||||
:title="compactStateDisplay.label"
|
||||
:aria-label="compactStateDisplay.label"
|
||||
>
|
||||
<VIcon :icon="compactStateDisplay.icon" size="18" />
|
||||
<span v-if="subscribeProgressText" class="subscribe-card-mobile-progress-text">
|
||||
{{ subscribeProgressText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<IconBtn
|
||||
v-if="!props.sortable"
|
||||
class="subscribe-card-mobile-menu"
|
||||
size="small"
|
||||
@click.stop
|
||||
>
|
||||
<VIcon icon="mdi-dots-horizontal" size="20" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
<VListItem
|
||||
v-if="item.show !== false"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
|
||||
<div v-if="props.media?.total_episode" class="subscribe-card-mobile-progress">
|
||||
<VProgressLinear
|
||||
:model-value="getPercentage()"
|
||||
:bg-color="compactStateDisplay.color"
|
||||
:color="compactStateDisplay.color"
|
||||
bg-opacity="0.18"
|
||||
height="4"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else>
|
||||
<VCardText class="flex items-center pt-3 pb-2">
|
||||
<div
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
|
||||
@@ -478,7 +594,7 @@ function handleCardClick() {
|
||||
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ...">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
@@ -566,14 +682,185 @@ function handleCardClick() {
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅卡片外壳:选中态虚线框复用同一圆角,避免 outline 在圆角卡片外形成直角。
|
||||
*/
|
||||
.subscribe-card-shell {
|
||||
border-radius: var(--app-surface-radius);
|
||||
}
|
||||
|
||||
.subscribe-card {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-media {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 2 / 1;
|
||||
flex-shrink: 0;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-media .v-img {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__item {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
isolation: isolate;
|
||||
line-height: 1.2;
|
||||
padding-block: 0.0625rem;
|
||||
padding-inline: 0.125rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__item::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 0.4rem;
|
||||
background: rgba(0, 0, 0, 0.36);
|
||||
content: '';
|
||||
filter: blur(3px);
|
||||
inset: -0.25rem -0.55rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__user {
|
||||
max-inline-size: calc(100% - 1rem);
|
||||
inset-block-start: 0.5rem;
|
||||
inset-inline-start: 0.5rem;
|
||||
transition: inset-block-start 0.2s ease;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta--with-badge .subscribe-card-mobile-image-meta__user {
|
||||
max-inline-size: calc(100% - 4.25rem);
|
||||
inset-block-start: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__user span {
|
||||
min-inline-size: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__updated {
|
||||
flex-shrink: 0;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
inset-block-end: 0.5rem;
|
||||
inset-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-block-start: auto;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-meta {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
min-block-size: 2rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-state {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-state span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-progress-text {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-menu {
|
||||
block-size: 2rem;
|
||||
min-block-size: 2rem;
|
||||
inline-size: 2rem;
|
||||
min-inline-size: 2rem;
|
||||
flex: 0 0 2rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-progress {
|
||||
display: flex;
|
||||
block-size: 4px;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-progress .v-progress-linear {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.subscribe-card-shell--selected::after {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
border: 2px solid rgb(var(--v-theme-primary));
|
||||
border-radius: inherit;
|
||||
content: '';
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
@@ -587,7 +874,7 @@ function handleCardClick() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
|
||||
* 待定:内发光挂在实际 VCard 上,跟随卡片圆角并被 overflow-hidden 裁剪。
|
||||
*/
|
||||
.subscribe-card-pending-tint {
|
||||
position: relative;
|
||||
@@ -597,7 +884,7 @@ function handleCardClick() {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 0 48px rgba(56, 189, 248, 40%); // sky-400
|
||||
box-shadow: inset 0 0 48px rgba(var(--v-theme-info), 0.28);
|
||||
content: '';
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
@@ -629,4 +916,30 @@ function handleCardClick() {
|
||||
background: rgba(255, 255, 255, 22%);
|
||||
box-shadow: 0 2px 8px rgba(255, 255, 255, 15%);
|
||||
}
|
||||
|
||||
@media (width <= 599px) {
|
||||
.subscribe-card {
|
||||
min-block-size: 0 !important;
|
||||
}
|
||||
|
||||
.subscribe-card-paused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.subscribe-card-paused .subscribe-card-mobile-media {
|
||||
filter: saturate(0.65);
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.best-version-badge {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-card-pending-tint::after {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--v-theme-info), 0.28),
|
||||
inset 0 -4rem 5rem rgba(var(--v-theme-info), 0.08);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,6 +49,7 @@ const posterUrl = computed(() => {
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
@@ -93,16 +94,17 @@ function doDelete() {
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="w-full h-full overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="subscribe-share-card-hover-area w-full h-full">
|
||||
<div
|
||||
class="app-hover-lift-card w-full h-full overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
min-height="150"
|
||||
@click="showForkSubscribe"
|
||||
>
|
||||
@@ -151,17 +153,22 @@ function doDelete() {
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-calcdar" class="me-1" />
|
||||
<VIcon icon="mdi-calendar" class="me-1" />
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-share-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
|
||||
@@ -100,12 +100,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="subtitle-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden subtitle-card"
|
||||
class="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden subtitle-card"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
@@ -203,11 +204,16 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-card {
|
||||
border: 1px solid transparent;
|
||||
.subtitle-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-card:hover {
|
||||
.subtitle-card {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.subtitle-card-hover-area:hover .subtitle-card {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -99,10 +99,11 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<!-- Hover 命中区域保持静止,避免列表项上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="subtitle-item-hover-area w-100">
|
||||
<VListItem
|
||||
:value="subtitle?.enclosure"
|
||||
class="pa-3 mb-2 rounded subtitle-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
class="app-hover-lift-card pa-3 mb-2 rounded subtitle-item overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
@@ -206,11 +207,16 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-item {
|
||||
border: 1px solid transparent;
|
||||
.subtitle-item-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-item:hover {
|
||||
.subtitle-item {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.subtitle-item-hover-area:hover .subtitle-item {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -146,12 +146,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="torrent-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload(props.torrent)"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
|
||||
class="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden torrent-card"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
@@ -316,13 +317,18 @@ watch(
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
/* 卡片悬停效果 */
|
||||
.torrent-card {
|
||||
border: 1px solid transparent;
|
||||
.torrent-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-card:hover {
|
||||
/* 卡片悬停效果 */
|
||||
.torrent-card {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.torrent-card-hover-area:hover .torrent-card {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
/* 优惠标签样式 */
|
||||
|
||||
@@ -115,10 +115,11 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<!-- Hover 命中区域保持静止,避免列表项上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="torrent-item-hover-area w-100">
|
||||
<VListItem
|
||||
:value="props.torrent?.torrent_info?.enclosure"
|
||||
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
class="app-hover-lift-card pa-3 mb-2 rounded torrent-item overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
@@ -262,12 +263,17 @@ watch(
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.torrent-item {
|
||||
border: 1px solid transparent;
|
||||
.torrent-item-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-item:hover {
|
||||
.torrent-item {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.torrent-item-hover-area:hover .torrent-item {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.chip-season {
|
||||
|
||||
@@ -127,14 +127,16 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VCard
|
||||
:class="[
|
||||
'transition-transform duration-300 hover:-translate-y-1',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="editUser"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="user-card-hover-area h-full">
|
||||
<VCard
|
||||
:class="[
|
||||
'app-hover-lift-card',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="editUser"
|
||||
>
|
||||
<div class="user-card__body flex-grow flex-grow-1">
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
||||
@@ -302,10 +304,19 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.user-card-hover-area:hover .user-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.user-card {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
@@ -95,17 +95,18 @@ function doDelete() {
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.workflow?.id"
|
||||
class="workflow-share-card flex flex-col h-full cursor-pointer overflow-hidden"
|
||||
:class="{
|
||||
'workflow-share-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="workflow-share-card-hover-area h-full">
|
||||
<VCard
|
||||
:key="props.workflow?.id"
|
||||
class="workflow-share-card app-hover-lift-card flex flex-col h-full cursor-pointer overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pa-3 pb-1 grow">
|
||||
<div class="flex flex-col justify-center w-full">
|
||||
@@ -134,20 +135,16 @@ function doDelete() {
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 阴影需要落在实际卡片上,不能被额外的 overflow 容器裁掉。
|
||||
.workflow-share-card {
|
||||
transition: transform 0.3s ease, box-shadow 0.2s ease;
|
||||
transform: translateZ(0);
|
||||
.workflow-share-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.workflow-share-card--hovering {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -220,14 +220,15 @@ const resolveProgress = (item: Workflow) => {
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
class="mx-auto h-full"
|
||||
@click="handleFlow(workflow)"
|
||||
:ripple="false"
|
||||
:loading="loading"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="workflow-task-card-hover-area h-full">
|
||||
<VCard
|
||||
class="app-hover-lift-card mx-auto h-full"
|
||||
@click="handleFlow(workflow)"
|
||||
:ripple="false"
|
||||
:loading="loading"
|
||||
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
|
||||
>
|
||||
<VCardItem
|
||||
class="px-2 py-2"
|
||||
:style="{
|
||||
@@ -367,7 +368,14 @@ const resolveProgress = (item: Workflow) => {
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-task-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
539
src/components/cards/__tests__/MediaCard.spec.ts
Normal file
539
src/components/cards/__tests__/MediaCard.spec.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import { clearCachedMediaSubscribeStatuses } from '@/utils/mediaStatusCache'
|
||||
import { fireEvent, waitFor } from '@testing-library/vue'
|
||||
import { createMediaInfo } from '@tests/support/factories/media'
|
||||
import { mediaExistsHandler } from '@tests/support/msw/handlers/media'
|
||||
import { querySubscribeByMediaHandler, subscribeListHandler } from '@tests/support/msw/handlers/subscribe'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openSharedDialog: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
default: {
|
||||
push: (...args: unknown[]) => mocks.routerPush(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||
const siteListUrl = new URL('site/', API_BASE_URL).href
|
||||
const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_URL).href
|
||||
|
||||
let intersectionObservers: IntersectionObserverMock[] = []
|
||||
|
||||
class IntersectionObserverMock implements IntersectionObserver {
|
||||
readonly root: Element | Document | null
|
||||
readonly rootMargin: string
|
||||
readonly thresholds: readonly number[]
|
||||
readonly disconnect = vi.fn()
|
||||
readonly observe = vi.fn((target: Element) => {
|
||||
this.target = target
|
||||
})
|
||||
readonly unobserve = vi.fn()
|
||||
private target: Element = document.body
|
||||
|
||||
constructor(
|
||||
private readonly callback: IntersectionObserverCallback,
|
||||
options: IntersectionObserverInit = {},
|
||||
) {
|
||||
this.root = options.root ?? null
|
||||
this.rootMargin = options.rootMargin ?? '0px'
|
||||
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold ?? 0]
|
||||
intersectionObservers.push(this)
|
||||
}
|
||||
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return []
|
||||
}
|
||||
|
||||
trigger(isIntersecting = true) {
|
||||
const bounds = this.target.getBoundingClientRect()
|
||||
this.callback(
|
||||
[
|
||||
{
|
||||
boundingClientRect: bounds,
|
||||
intersectionRatio: isIntersecting ? 1 : 0,
|
||||
intersectionRect: bounds,
|
||||
isIntersecting,
|
||||
rootBounds: null,
|
||||
target: this.target,
|
||||
time: 0,
|
||||
},
|
||||
],
|
||||
this,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface RenderCardOptions {
|
||||
permissions?: Record<string, boolean>
|
||||
superUser?: boolean
|
||||
}
|
||||
|
||||
async function renderCard(media: MediaInfo, options: RenderCardOptions = {}) {
|
||||
return renderWithProviders(MediaCard, {
|
||||
props: {
|
||||
media,
|
||||
width: '9rem',
|
||||
},
|
||||
initialState: {
|
||||
user: {
|
||||
permissions: options.permissions ?? {
|
||||
discovery: true,
|
||||
manage: false,
|
||||
search: true,
|
||||
subscribe: true,
|
||||
},
|
||||
superUser: options.superUser ?? true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getCard(container: Element) {
|
||||
const card = container.querySelector<HTMLElement>('.media-card')
|
||||
expect(card).not.toBeNull()
|
||||
return card as HTMLElement
|
||||
}
|
||||
|
||||
function getHoverArea(container: Element) {
|
||||
const area = container.querySelector<HTMLElement>('.media-card-hover-area')
|
||||
expect(area).not.toBeNull()
|
||||
return area as HTMLElement
|
||||
}
|
||||
|
||||
function getActionButtons(container: Element) {
|
||||
return [...container.querySelectorAll<HTMLButtonElement>('.media-card .v-card-text button')]
|
||||
}
|
||||
|
||||
function getSearchButton(container: Element) {
|
||||
const button = getActionButtons(container)[0]
|
||||
expect(button).toBeDefined()
|
||||
return button
|
||||
}
|
||||
|
||||
function getStatusObservers() {
|
||||
return intersectionObservers.filter(observer => observer.thresholds.includes(0.1))
|
||||
}
|
||||
|
||||
function installSearchHandlers(sites: Record<string, unknown>[], selected: number[]) {
|
||||
server.use(
|
||||
http.get(siteListUrl, () => HttpResponse.json(sites)),
|
||||
http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })),
|
||||
)
|
||||
}
|
||||
|
||||
describe('MediaCard', () => {
|
||||
beforeEach(() => {
|
||||
intersectionObservers = []
|
||||
clearCachedMediaSubscribeStatuses()
|
||||
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
})
|
||||
|
||||
it('loads exact subscribe and exists status only after entering the viewport', async () => {
|
||||
const media = createMediaInfo({ season: 2, title: '视口状态剧集', tmdb_id: 9101, type: '电视剧' })
|
||||
const subscribeRequest = vi.fn<(url: URL) => void>()
|
||||
const existsRequest = vi.fn<(url: URL) => void>()
|
||||
server.use(
|
||||
querySubscribeByMediaHandler('tmdb:9101', { id: 71, season: 2 }, 200, subscribeRequest),
|
||||
mediaExistsHandler({ data: { item: { id: 'library-item' } }, success: true }, 200, existsRequest),
|
||||
)
|
||||
|
||||
const { container } = await renderCard(media)
|
||||
expect(subscribeRequest).not.toHaveBeenCalled()
|
||||
expect(existsRequest).not.toHaveBeenCalled()
|
||||
|
||||
getStatusObservers()[0]?.trigger()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(subscribeRequest).toHaveBeenCalledOnce()
|
||||
expect(existsRequest).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(subscribeRequest.mock.calls[0][0].searchParams.get('season')).toBe('2')
|
||||
expect(subscribeRequest.mock.calls[0][0].searchParams.get('title')).toBe('视口状态剧集')
|
||||
expect(Object.fromEntries(existsRequest.mock.calls[0][0].searchParams)).toEqual({
|
||||
mtype: '电视剧',
|
||||
season: '2',
|
||||
title: '视口状态剧集',
|
||||
tmdbid: '9101',
|
||||
year: '2026',
|
||||
})
|
||||
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
|
||||
expect(getStatusObservers()[0]?.disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('coalesces status requests for duplicate cards while updating both card states', async () => {
|
||||
const media = createMediaInfo({ title: '重复媒体', tmdb_id: 9102 })
|
||||
const subscribeRequest = vi.fn<(url: URL) => void>()
|
||||
const existsRequest = vi.fn<(url: URL) => void>()
|
||||
server.use(
|
||||
querySubscribeByMediaHandler('tmdb:9102', { id: 72 }, 200, subscribeRequest),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest),
|
||||
)
|
||||
|
||||
const Harness = {
|
||||
components: { MediaCard },
|
||||
data: () => ({ media }),
|
||||
template: '<div><MediaCard :media="media" width="9rem" /><MediaCard :media="media" width="9rem" /></div>',
|
||||
}
|
||||
const { container } = await renderWithProviders(Harness, {
|
||||
initialState: { user: { superUser: true } },
|
||||
})
|
||||
|
||||
expect(getStatusObservers()).toHaveLength(2)
|
||||
getStatusObservers().forEach(observer => observer.trigger())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(subscribeRequest).toHaveBeenCalledOnce()
|
||||
expect(existsRequest).toHaveBeenCalledOnce()
|
||||
expect(container.querySelectorAll('.media-card .v-card-text button.text-error')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['TMDB', createMediaInfo({ season: 3, tmdb_id: 9201, type: '电视剧' }), 'tmdb:9201', '3'],
|
||||
[
|
||||
'Douban',
|
||||
createMediaInfo({ douban_id: 'db-9202', season: undefined, tmdb_id: undefined }),
|
||||
'douban:db-9202',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'Bangumi',
|
||||
createMediaInfo({ bangumi_id: '9203', season: 1, tmdb_id: undefined, type: '电视剧' }),
|
||||
'bangumi:9203',
|
||||
'1',
|
||||
],
|
||||
[
|
||||
'extension',
|
||||
createMediaInfo({ media_id: 'item-9204', mediaid_prefix: 'custom', tmdb_id: undefined }),
|
||||
'custom:item-9204',
|
||||
null,
|
||||
],
|
||||
])('queries the current %s media identifier and season', async (_source, media, mediaId, season) => {
|
||||
const subscribeRequest = vi.fn<(url: URL) => void>()
|
||||
server.use(
|
||||
querySubscribeByMediaHandler(mediaId, {}, 200, subscribeRequest),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }),
|
||||
)
|
||||
|
||||
await renderCard(media)
|
||||
getStatusObservers()[0]?.trigger()
|
||||
|
||||
await waitFor(() => expect(subscribeRequest).toHaveBeenCalledOnce())
|
||||
expect(subscribeRequest.mock.calls[0][0].searchParams.get('season')).toBe(season)
|
||||
})
|
||||
|
||||
it('skips status requests for collections and releases observer and touch listeners on unmount', async () => {
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
...window.matchMedia(''),
|
||||
matches: true,
|
||||
})
|
||||
const subscribeRequest = vi.fn<(url: URL) => void>()
|
||||
const existsRequest = vi.fn<(url: URL) => void>()
|
||||
const addListener = vi.spyOn(document, 'addEventListener')
|
||||
const removeListener = vi.spyOn(document, 'removeEventListener')
|
||||
server.use(
|
||||
querySubscribeByMediaHandler('tmdb:9301', {}, 200, subscribeRequest),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest),
|
||||
)
|
||||
|
||||
const { unmount } = await renderCard(createMediaInfo({ collection_id: 44, tmdb_id: 9301 }))
|
||||
getStatusObservers()[0]?.trigger()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(subscribeRequest).not.toHaveBeenCalled()
|
||||
expect(existsRequest).not.toHaveBeenCalled()
|
||||
expect(addListener).toHaveBeenCalledWith('pointerdown', expect.any(Function))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(getStatusObservers()[0]?.disconnect).toHaveBeenCalledOnce()
|
||||
expect(removeListener).toHaveBeenCalledWith('pointerdown', expect.any(Function))
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'media details',
|
||||
createMediaInfo({ title: '详情电影', tmdb_id: 9401 }),
|
||||
'/media',
|
||||
{ mediaid: 'tmdb:9401', title: '详情电影', type: '电影', year: '2026' },
|
||||
],
|
||||
[
|
||||
'collection browse',
|
||||
createMediaInfo({ collection_id: 88, title: '合集入口', tmdb_id: 9402 }),
|
||||
'/browse/tmdb/collection/88',
|
||||
{ title: '合集入口' },
|
||||
],
|
||||
])('opens %s from the desktop hover state', async (_case, media, path, query) => {
|
||||
const { container } = await renderCard(media)
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await waitFor(() => expect(getCard(container)).toHaveClass('app-hover-lift-card--hovering'))
|
||||
await fireEvent.click(getCard(container))
|
||||
|
||||
await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith({ path, query }))
|
||||
})
|
||||
|
||||
it('routes directly to resource search when no active sites are available', async () => {
|
||||
installSearchHandlers([], [3, 5])
|
||||
const media = createMediaInfo({ season: 4, title: '直接搜索剧集', tmdb_id: 9501, type: '电视剧' })
|
||||
const { container } = await renderCard(media)
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getSearchButton(container))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith({
|
||||
path: '/resource',
|
||||
query: {
|
||||
area: 'title',
|
||||
keyword: 'tmdb:9501',
|
||||
season: 4,
|
||||
sites: '3,5',
|
||||
title: '直接搜索剧集',
|
||||
type: '电视剧',
|
||||
year: '2026',
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to global search when site settings cannot provide active selections', async () => {
|
||||
server.use(
|
||||
http.get(siteListUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
|
||||
http.get(selectedSitesUrl, () => HttpResponse.json({ success: true })),
|
||||
)
|
||||
const media = createMediaInfo({ title: '站点失败搜索', tmdb_id: 9503 })
|
||||
const { container } = await renderCard(media)
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getSearchButton(container))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: '/resource', query: expect.objectContaining({ sites: '' }) }),
|
||||
),
|
||||
)
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens site selection and routes with the selected sites when active sites exist', async () => {
|
||||
installSearchHandlers(
|
||||
[
|
||||
{
|
||||
domain: 'tracker.example',
|
||||
downloader: 'default',
|
||||
id: 7,
|
||||
is_active: true,
|
||||
name: '测试站点',
|
||||
url: 'https://tracker.example',
|
||||
},
|
||||
],
|
||||
[7],
|
||||
)
|
||||
const media = createMediaInfo({ title: '多站搜索电影', tmdb_id: 9502 })
|
||||
const { container } = await renderCard(media)
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getSearchButton(container))
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
|
||||
const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
|
||||
unknown,
|
||||
{ selected: number[]; sites: Array<{ id: number }> },
|
||||
{ search: (sites: number[]) => void },
|
||||
]
|
||||
expect(dialogProps.selected).toEqual([7])
|
||||
expect(dialogProps.sites.map(site => site.id)).toEqual([7])
|
||||
dialogEvents.search([7, 9])
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/resource',
|
||||
query: expect.objectContaining({ sites: '7,9' }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('opens active sites with an empty selection when the saved setting fails', async () => {
|
||||
server.use(
|
||||
http.get(siteListUrl, () =>
|
||||
HttpResponse.json([
|
||||
{
|
||||
domain: 'fallback.example',
|
||||
downloader: 'default',
|
||||
id: 8,
|
||||
is_active: true,
|
||||
name: '备用站点',
|
||||
url: 'https://fallback.example',
|
||||
},
|
||||
]),
|
||||
),
|
||||
http.get(selectedSitesUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
|
||||
)
|
||||
const { container } = await renderCard(createMediaInfo({ tmdb_id: 9504 }))
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getSearchButton(container))
|
||||
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, { selected: number[] }]
|
||||
expect(dialogProps.selected).toEqual([])
|
||||
})
|
||||
|
||||
it('loads matching TV seasons before opening the subscription dialog', async () => {
|
||||
const media = createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' })
|
||||
server.use(
|
||||
querySubscribeByMediaHandler('tmdb:9551', { id: 81, season: 2 }),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }),
|
||||
subscribeListHandler([
|
||||
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
|
||||
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
|
||||
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
|
||||
{ id: 84, tmdbid: 9551, type: '电影' },
|
||||
]),
|
||||
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
|
||||
HttpResponse.json({ data: { value: { best_version: 0 } }, success: true }),
|
||||
),
|
||||
)
|
||||
const { container } = await renderCard(media)
|
||||
getStatusObservers()[0]?.trigger()
|
||||
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
|
||||
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
|
||||
expect(dialogProps).toMatchObject({
|
||||
selectedSeason: undefined,
|
||||
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
|
||||
subscribedSeasons: [1, 3],
|
||||
})
|
||||
})
|
||||
|
||||
it('matches custom media IDs when collecting subscribed TV seasons', async () => {
|
||||
const media = createMediaInfo({
|
||||
media_id: 'series-9553',
|
||||
mediaid_prefix: 'custom',
|
||||
season: 2,
|
||||
tmdb_id: undefined,
|
||||
type: '电视剧',
|
||||
})
|
||||
server.use(
|
||||
querySubscribeByMediaHandler('custom:series-9553', { id: 91, season: 2 }),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }),
|
||||
subscribeListHandler([
|
||||
{ id: 91, mediaid: 'custom:series-9553', season: 2, type: '电视剧' },
|
||||
{ id: 92, mediaid: 'custom:other', season: 5, type: '电视剧' },
|
||||
]),
|
||||
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
|
||||
HttpResponse.json({ data: { value: {} }, success: true }),
|
||||
),
|
||||
)
|
||||
const { container } = await renderCard(media)
|
||||
getStatusObservers()[0]?.trigger()
|
||||
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
|
||||
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
|
||||
expect(dialogProps).toMatchObject({ subscribedSeasons: [2] })
|
||||
})
|
||||
|
||||
it('updates image badges on load and falls back after an image error', async () => {
|
||||
const media = createMediaInfo({
|
||||
poster_path: '/original/poster.jpg',
|
||||
source: 'themoviedb',
|
||||
tmdb_id: 9552,
|
||||
type: '电视剧',
|
||||
vote_average: 8.6,
|
||||
})
|
||||
const VImgStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
props: { src: String },
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
h('div', { 'data-src': props.src }, [
|
||||
h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }),
|
||||
h('button', { 'aria-label': '图片加载失败', onClick: () => emit('error') }),
|
||||
slots.default?.(),
|
||||
])
|
||||
},
|
||||
})
|
||||
const { container } = await renderWithProviders(MediaCard, {
|
||||
props: { media, width: '9rem' },
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { VImg: VImgStub } },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
|
||||
await waitFor(() => expect(container.querySelector('.media-card')).toHaveClass('ring-1'))
|
||||
expect(container).toHaveTextContent('TV')
|
||||
expect(container).toHaveTextContent('8.6')
|
||||
|
||||
await fireEvent.click(container.querySelector('[aria-label="图片加载失败"]') as HTMLElement)
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('.media-card-title')?.parentElement).not.toHaveStyle({ display: 'none' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('hides search and subscribe actions when the user lacks both permissions', async () => {
|
||||
const { container } = await renderCard(createMediaInfo({ tmdb_id: 9601 }), {
|
||||
permissions: {
|
||||
discovery: true,
|
||||
manage: false,
|
||||
search: false,
|
||||
subscribe: false,
|
||||
},
|
||||
superUser: false,
|
||||
})
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
|
||||
expect(getActionButtons(container)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses first tap to reveal details, second tap to route, and outside pointerdown to collapse', async () => {
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
...window.matchMedia(''),
|
||||
matches: true,
|
||||
})
|
||||
const media = createMediaInfo({ title: '触摸卡片', tmdb_id: 9701 })
|
||||
const { container } = await renderCard(media)
|
||||
const detail = container.querySelector<HTMLElement>('.media-card-title')?.parentElement
|
||||
expect(detail).not.toBeNull()
|
||||
|
||||
await fireEvent.click(getCard(container))
|
||||
expect(detail).not.toHaveStyle({ display: 'none' })
|
||||
expect(mocks.routerPush).not.toHaveBeenCalled()
|
||||
|
||||
await fireEvent.pointerDown(document.body)
|
||||
expect(detail).toHaveStyle({ display: 'none' })
|
||||
|
||||
await fireEvent.click(getCard(container))
|
||||
await fireEvent.click(getCard(container))
|
||||
await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith(expect.objectContaining({ path: '/media' })))
|
||||
})
|
||||
})
|
||||
253
src/components/cards/__tests__/SiteCard.spec.ts
Normal file
253
src/components/cards/__tests__/SiteCard.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { createSite, createSiteStatistic, createSiteUserData } from '@tests/support/factories/site'
|
||||
import { deleteSiteHandler, siteIconHandler, testSiteConnectionHandler } from '@tests/support/msw/handlers/site'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
confirm: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
const ImageStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
alt: String,
|
||||
src: String,
|
||||
},
|
||||
setup: props => () => h('img', { alt: props.alt, src: props.src }),
|
||||
})
|
||||
|
||||
const imageStubs = { VImg: ImageStub }
|
||||
|
||||
async function renderCard(
|
||||
siteOverrides: Partial<Site> = {},
|
||||
props: Partial<{ data: SiteUserData; sortable: boolean; stats: SiteStatistic }> = {},
|
||||
) {
|
||||
const site = createSite(siteOverrides)
|
||||
server.use(siteIconHandler(site.id, `https://images.example.com/site-${site.id}.png`))
|
||||
const result = await renderWithProviders(SiteCard, {
|
||||
global: { stubs: imageStubs },
|
||||
props: { site, ...props },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.container.querySelector<HTMLImageElement>('img')?.src).toContain(`site-${site.id}.png`)
|
||||
})
|
||||
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
|
||||
return { ...result, site }
|
||||
}
|
||||
|
||||
function getActionButton(container: Element, index: number) {
|
||||
const button = container.querySelectorAll<HTMLButtonElement>('.site-card-actions > button')[index]
|
||||
if (!button) throw new Error(`Missing action button ${index}`)
|
||||
return button
|
||||
}
|
||||
|
||||
function getTestButton(container: Element) {
|
||||
const button = container.querySelector('.pulse-dot')?.closest('button')
|
||||
if (!button) throw new Error('Missing connectivity test button')
|
||||
return button
|
||||
}
|
||||
|
||||
function getDialogCall(index = 0) {
|
||||
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
|
||||
unknown,
|
||||
Record<string, unknown>,
|
||||
Record<string, () => void>,
|
||||
Record<string, unknown>,
|
||||
]
|
||||
return { events, options, props }
|
||||
}
|
||||
|
||||
describe('SiteCard display', () => {
|
||||
beforeEach(() => {
|
||||
mocks.confirm.mockResolvedValue(true)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
})
|
||||
|
||||
it('renders active site metadata, transfer values, feature flags, and a healthy border', async () => {
|
||||
const { container, site } = await renderCard(
|
||||
{ filter: 'free', limit_interval: 10, proxy: true, render: true },
|
||||
{
|
||||
data: createSiteUserData({ download: 1024, upload: 2048 }),
|
||||
stats: createSiteStatistic({ lst_state: 0, seconds: 2 }),
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByText(site.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(site.url)).toBeInTheDocument()
|
||||
expect(screen.getByText('2.00 KB')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.00 KB')).toBeInTheDocument()
|
||||
expect(container.querySelector('.site-card')).toHaveClass('border-success')
|
||||
expect(container.querySelectorAll('.ml-auto.flex.shrink-0.items-center.gap-2 > div')).toHaveLength(4)
|
||||
expect(
|
||||
[...container.querySelectorAll('.border-t .v-progress-linear')].map(progress =>
|
||||
progress.getAttribute('aria-valuenow'),
|
||||
),
|
||||
).toEqual(['100', '50'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['failed', createSiteStatistic({ lst_state: 1 }), 'border-error'],
|
||||
['slow', createSiteStatistic({ lst_state: 0, seconds: 5 }), 'border-warning'],
|
||||
['unknown without stats', undefined, null],
|
||||
['unknown without duration', createSiteStatistic({ lst_state: 0, seconds: 0 }), null],
|
||||
] as const)('projects %s connection state without inventing status', async (_case, stats, borderClass) => {
|
||||
const { container } = await renderCard({}, stats ? { stats } : {})
|
||||
const card = container.querySelector('.site-card')
|
||||
|
||||
if (borderClass) expect(card).toHaveClass(borderClass)
|
||||
else expect(card).not.toHaveClass('border-error', 'border-warning', 'border-success')
|
||||
})
|
||||
|
||||
it('keeps zero transfer data visible with stable minimum progress', async () => {
|
||||
await renderCard({ is_active: false }, { data: createSiteUserData({ download: 0, upload: 0 }) })
|
||||
|
||||
expect(screen.getAllByText('0.00 B')).toHaveLength(2)
|
||||
expect(
|
||||
[...document.querySelectorAll('.border-t .v-progress-linear')].map(progress =>
|
||||
progress.getAttribute('aria-valuenow'),
|
||||
),
|
||||
).toEqual(['3', '3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('SiteCard interactions', () => {
|
||||
beforeEach(() => {
|
||||
mocks.confirm.mockResolvedValue(true)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['success', 200, { success: true }, 'success'],
|
||||
['business failure', 200, { message: '认证失败', success: false }, 'error'],
|
||||
] as const)(
|
||||
'reports connectivity %s and refreshes the current domain',
|
||||
async (_case, status, response, toastType) => {
|
||||
const requested = vi.fn()
|
||||
const { container, emitted, site } = await renderCard()
|
||||
server.use(testSiteConnectionHandler(site.id, response, status, requested))
|
||||
|
||||
await fireEvent.click(getTestButton(container))
|
||||
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError
|
||||
await waitFor(() => expect(toast).toHaveBeenCalledOnce())
|
||||
expect(emitted('refresh-stats')).toEqual([[site.domain]])
|
||||
expect(getTestButton(container)).not.toBeDisabled()
|
||||
},
|
||||
)
|
||||
|
||||
it('restores connectivity controls after an HTTP failure', async () => {
|
||||
const requested = vi.fn()
|
||||
const { container, emitted, site } = await renderCard()
|
||||
server.use(testSiteConnectionHandler(site.id, { message: 'server down', success: false }, 500, requested))
|
||||
|
||||
await fireEvent.click(getTestButton(container))
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
|
||||
await waitFor(() => expect(getTestButton(container)).not.toBeDisabled())
|
||||
expect(emitted('refresh-stats') ?? []).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['cancelled', false, 200, { success: true }, false, null],
|
||||
['success', true, 200, { success: true }, true, null],
|
||||
['business failure', true, 200, { message: '仍在使用', success: false }, false, '仍在使用'],
|
||||
['HTTP failure', true, 500, { message: 'server down', success: false }, false, null],
|
||||
] as const)('handles deletion when %s', async (_case, confirmed, status, response, removed, expectedMessage) => {
|
||||
const requested = vi.fn()
|
||||
mocks.confirm.mockResolvedValue(confirmed)
|
||||
const { container, emitted, site } = await renderCard()
|
||||
server.use(deleteSiteHandler(site.id, response, status, requested))
|
||||
|
||||
await fireEvent.click(getActionButton(container, 3))
|
||||
await fireEvent.click(await screen.findByText('删除站点'))
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||
|
||||
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
else expect(requested).not.toHaveBeenCalled()
|
||||
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
|
||||
if (expectedMessage) expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining(expectedMessage))
|
||||
if (_case === 'HTTP failure') expect(mocks.toastError).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('opens each shared dialog with exact props, close events, and refresh ownership', async () => {
|
||||
const { container, emitted, site } = await renderCard()
|
||||
|
||||
await fireEvent.click(container.querySelector('.site-card') as Element)
|
||||
expect(getDialogCall().props).toEqual({ site })
|
||||
expect(getDialogCall().options).toEqual({ closeOn: ['close'] })
|
||||
getDialogCall().events.close()
|
||||
expect(emitted('refresh-stats')).toEqual([[site.domain]])
|
||||
|
||||
await fireEvent.click(getActionButton(container, 1))
|
||||
expect(getDialogCall(1).props).toEqual({ site })
|
||||
expect(getDialogCall(1).options).toEqual({ closeOn: ['close'] })
|
||||
|
||||
await fireEvent.click(getActionButton(container, 2))
|
||||
expect(getDialogCall(2).props).toEqual({ site })
|
||||
expect(getDialogCall(2).options).toEqual({ closeOn: ['close', 'done'] })
|
||||
getDialogCall(2).events.done()
|
||||
expect(emitted('refresh-stats')).toEqual([[site.domain], [site.domain]])
|
||||
|
||||
await fireEvent.click(getActionButton(container, 3))
|
||||
await fireEvent.click(await screen.findByText('编辑站点'))
|
||||
expect(getDialogCall(3).props).toEqual({ siteid: site.id })
|
||||
expect(getDialogCall(3).options).toEqual({ closeOn: ['close', 'save', 'remove'] })
|
||||
getDialogCall(3).events.save()
|
||||
getDialogCall(3).events.remove()
|
||||
expect(emitted('update')).toHaveLength(1)
|
||||
expect(emitted('remove')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens the site URL normally and isolates every card action in sortable mode', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const { container, rerender, site } = await renderCard()
|
||||
|
||||
await fireEvent.click(screen.getByText(site.url))
|
||||
expect(open).toHaveBeenCalledWith(site.url, '_blank')
|
||||
|
||||
await rerender({ site, sortable: true })
|
||||
expect(container.querySelector('.site-card-actions')).not.toBeInTheDocument()
|
||||
await fireEvent.click(screen.getByText(site.url))
|
||||
await fireEvent.click(container.querySelector('.site-card') as Element)
|
||||
expect(open).toHaveBeenCalledOnce()
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the default icon when the icon request fails', async () => {
|
||||
const site = createSite()
|
||||
const requested = vi.fn()
|
||||
server.use(siteIconHandler(site.id, null, 500, requested))
|
||||
const { container } = await renderWithProviders(SiteCard, {
|
||||
global: { stubs: imageStubs },
|
||||
props: { site },
|
||||
})
|
||||
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
|
||||
await waitFor(() => expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('/site.webp'))
|
||||
})
|
||||
})
|
||||
438
src/components/cards/__tests__/SubscribeCard.spec.ts
Normal file
438
src/components/cards/__tests__/SubscribeCard.spec.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { createSubscribe } from '@tests/support/factories/subscribe'
|
||||
import {
|
||||
deleteSubscribeByIdHandler,
|
||||
resetSubscribeByIdHandler,
|
||||
searchSubscribeByIdHandler,
|
||||
updateSubscribeStatusHandler,
|
||||
} from '@tests/support/msw/handlers/subscribe'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
confirm: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
default: { push: (...args: unknown[]) => mocks.routerPush(...args) },
|
||||
}))
|
||||
|
||||
function setViewport(width: number) {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
}
|
||||
|
||||
function observeElementsImmediately() {
|
||||
class ImmediateIntersectionObserver {
|
||||
readonly root = null
|
||||
readonly rootMargin = '0px'
|
||||
readonly thresholds = [0]
|
||||
|
||||
constructor(private readonly callback: IntersectionObserverCallback) {}
|
||||
|
||||
disconnect() {}
|
||||
|
||||
observe(target: Element) {
|
||||
this.callback([{ intersectionRatio: 1, isIntersecting: true, target } as IntersectionObserverEntry], this)
|
||||
}
|
||||
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return []
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('IntersectionObserver', ImmediateIntersectionObserver)
|
||||
}
|
||||
|
||||
async function renderCard(
|
||||
mediaOverrides: Partial<Subscribe> = {},
|
||||
props: Partial<{ batchMode: boolean; selected: boolean; sortable: boolean }> = {},
|
||||
globalImageCache = false,
|
||||
) {
|
||||
const media = createSubscribe({
|
||||
backdrop: 'https://images.example.com/backdrop.jpg',
|
||||
id: 2501,
|
||||
last_update: '2026-07-16 12:00:00',
|
||||
name: '卡片测试媒体',
|
||||
poster: 'https://images.example.com/poster.jpg',
|
||||
...mediaOverrides,
|
||||
})
|
||||
const result = await renderWithProviders(SubscribeCard, {
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
|
||||
initialized: true,
|
||||
loading: false,
|
||||
},
|
||||
},
|
||||
props: { media, ...props },
|
||||
})
|
||||
return { ...result, media }
|
||||
}
|
||||
|
||||
function getMenuButton(container: Element) {
|
||||
const selector = window.innerWidth < 600 ? '.subscribe-card-mobile-menu' : '.absolute.top-1.right-4 .v-btn'
|
||||
const button = container.querySelector<HTMLButtonElement>(selector)
|
||||
expect(button).not.toBeNull()
|
||||
return button as HTMLButtonElement
|
||||
}
|
||||
|
||||
async function openMenu(container: Element) {
|
||||
await fireEvent.click(getMenuButton(container))
|
||||
}
|
||||
|
||||
async function chooseMenuItem(container: Element, label: string) {
|
||||
await openMenu(container)
|
||||
await fireEvent.click(await screen.findByText(label))
|
||||
}
|
||||
|
||||
function getDialogCall(index = 0) {
|
||||
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
|
||||
unknown,
|
||||
Record<string, unknown>,
|
||||
Record<string, () => void>,
|
||||
Record<string, unknown>,
|
||||
]
|
||||
return { events, options, props }
|
||||
}
|
||||
|
||||
describe('SubscribeCard display and progress', () => {
|
||||
beforeEach(() => {
|
||||
setViewport(1024)
|
||||
observeElementsImmediately()
|
||||
mocks.confirm.mockResolvedValue(true)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('renders stable movie metadata and omits episode progress without a total', async () => {
|
||||
const { container, media } = await renderCard({ total_episode: undefined, type: '电影', year: '2025' }, {}, true)
|
||||
|
||||
expect(screen.getByText(media.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('2025')).toBeInTheDocument()
|
||||
expect(screen.getByText(media.username)).toHaveAttribute('title', media.username)
|
||||
const image = container.querySelector<HTMLImageElement>('img')
|
||||
expect(image).not.toBeNull()
|
||||
expect((image as HTMLImageElement).src).toContain('system/cache/image?url=')
|
||||
expect((image as HTMLImageElement).src).toContain(encodeURIComponent(media.backdrop || ''))
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['regular progress', 10, 4, '6 / 10', '60'],
|
||||
['negative missing episodes', 10, -2, '10 / 10', '100'],
|
||||
['missing episodes above the total', 10, 12, '0 / 10', null],
|
||||
['zero total', 0, 0, null, null],
|
||||
])('normalizes %s', async (_case, totalEpisode, lackEpisode, expectedText, expectedProgress) => {
|
||||
await renderCard({ lack_episode: lackEpisode, season: 2, total_episode: totalEpisode, type: '电视剧' })
|
||||
|
||||
if (expectedText) expect(screen.getByText(expectedText)).toBeInTheDocument()
|
||||
else expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
|
||||
|
||||
if (expectedProgress) expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', expectedProgress)
|
||||
else expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/卡片测试媒体 S02/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['boolean flag with tv type', true, false, 3, 'tv', 30, true, false],
|
||||
['numeric flags with negative completed episodes', 1, 1, -2, '电视剧', 0, true, true],
|
||||
['string flags with completed episodes above the total', '1', '1', 12, '电影', 100, true, true],
|
||||
['disabled flag', false, true, 3, '电视剧', 80, false, false],
|
||||
])(
|
||||
'normalizes %s for wash progress and badges',
|
||||
async (_case, bestVersion, bestVersionFull, completedEpisode, type, expectedProgress, expectedWash, expectedFull) => {
|
||||
const { container } = await renderCard({
|
||||
best_version: bestVersion,
|
||||
best_version_full: bestVersionFull,
|
||||
completed_episode: completedEpisode,
|
||||
lack_episode: 2,
|
||||
total_episode: 10,
|
||||
type,
|
||||
})
|
||||
const image = container.querySelector<HTMLImageElement>('img')
|
||||
expect(image).not.toBeNull()
|
||||
await fireEvent.load(image as HTMLImageElement)
|
||||
|
||||
const progress = screen.getByRole('progressbar')
|
||||
expect(progress).toHaveAttribute('aria-valuenow', String(expectedProgress))
|
||||
expect(progress.querySelector('.v-progress-linear__buffer')).toHaveStyle({ width: expectedWash ? '80%' : '0%' })
|
||||
expect(Boolean(container.querySelector('.best-version-badge'))).toBe(expectedWash)
|
||||
expect(Boolean(container.querySelector('.best-version-badge-full'))).toBe(expectedFull)
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps mobile wash progress compact while preserving P, S, and R metadata', async () => {
|
||||
setViewport(480)
|
||||
const { media, rerender } = await renderCard({
|
||||
best_version: true,
|
||||
completed_episode: 3,
|
||||
lack_episode: 2,
|
||||
state: 'P',
|
||||
total_episode: 10,
|
||||
type: '电视剧',
|
||||
})
|
||||
const lastUpdateText = formatDateDifference(media.last_update)
|
||||
|
||||
expect(screen.getByLabelText('待定中')).toBeInTheDocument()
|
||||
expect(screen.getByText('8 / 10')).toBeInTheDocument()
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '30')
|
||||
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
|
||||
expect(document.querySelector('.subscribe-card-mobile-menu')).toBeInTheDocument()
|
||||
|
||||
await rerender({ media: { ...media, state: 'S' } })
|
||||
expect(screen.getByLabelText('已暂停')).toBeInTheDocument()
|
||||
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
|
||||
|
||||
await rerender({ media: { ...media, state: 'R' } })
|
||||
expect(screen.getByLabelText('订阅中')).toBeInTheDocument()
|
||||
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('synchronizes desktop P, S, and R state from updated media props', async () => {
|
||||
const { container, media, rerender } = await renderCard({ state: 'P' })
|
||||
const lastUpdateText = formatDateDifference(media.last_update)
|
||||
|
||||
expect(screen.getByText('待定中')).toBeInTheDocument()
|
||||
expect(screen.queryByText(lastUpdateText)).not.toBeInTheDocument()
|
||||
|
||||
await rerender({ media: { ...media, state: 'S' } })
|
||||
expect(screen.getByText('已暂停')).toBeInTheDocument()
|
||||
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
|
||||
|
||||
await rerender({ media: { ...media, state: 'R' } })
|
||||
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
|
||||
expect(screen.queryByText('已暂停')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubscribeCard interaction boundaries', () => {
|
||||
beforeEach(() => {
|
||||
setViewport(1024)
|
||||
observeElementsImmediately()
|
||||
mocks.confirm.mockResolvedValue(true)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('routes normal, batch, selected, and sortable card clicks without overlap', async () => {
|
||||
const { container, emitted, media, rerender } = await renderCard()
|
||||
const card = container.querySelector('.subscribe-card') as HTMLElement
|
||||
|
||||
await fireEvent.click(card)
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
|
||||
await rerender({ batchMode: true, media, selected: true })
|
||||
await fireEvent.click(card)
|
||||
expect(emitted('select')).toHaveLength(1)
|
||||
expect(container.querySelector('.subscribe-card-shell')).toHaveClass('subscribe-card-shell--selected')
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
expect(container.querySelector('.absolute.top-1.right-4 .v-btn')).toBeInTheDocument()
|
||||
|
||||
await rerender({ batchMode: true, media, selected: true, sortable: true })
|
||||
await fireEvent.click(card)
|
||||
expect(emitted('select')).toHaveLength(1)
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
expect(container.querySelector('.absolute.top-1.right-4 .v-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens page-selected editing and forwards only save and remove events', async () => {
|
||||
const { emitted, media } = await renderCard({ page_open: true })
|
||||
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const dialog = getDialogCall()
|
||||
expect(dialog.props).toEqual({ subid: media.id })
|
||||
expect(dialog.options).toEqual({ closeOn: ['close', 'save', 'remove'] })
|
||||
|
||||
dialog.events.save()
|
||||
dialog.events.remove()
|
||||
expect(emitted('save')).toHaveLength(1)
|
||||
expect(emitted('remove')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('passes exact file and TV share data while keeping compatibility TV values unshared', async () => {
|
||||
const { container, media, rerender } = await renderCard({ season: 1, total_episode: 12, type: '电视剧' })
|
||||
|
||||
await chooseMenuItem(container, '分享')
|
||||
expect(getDialogCall().props).toEqual({ sub: media })
|
||||
expect(getDialogCall().options).toEqual({ closeOn: ['close'] })
|
||||
|
||||
await chooseMenuItem(container, '文件统计')
|
||||
expect(getDialogCall(1).props).toEqual({ subid: media.id })
|
||||
expect(getDialogCall(1).options).toEqual({ closeOn: ['close'] })
|
||||
|
||||
await rerender({ media: { ...media, type: 'tv' } })
|
||||
await openMenu(container)
|
||||
expect(screen.queryByText('分享')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['TMDB before all fallbacks', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'],
|
||||
['Douban before Bangumi', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'],
|
||||
['Bangumi before custom', { bangumiid: '33', doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'],
|
||||
['custom media ID last', { bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'custom:44'],
|
||||
])('routes media details with %s', async (_case, identifiers, expectedMediaId) => {
|
||||
const { container, media } = await renderCard(identifiers)
|
||||
|
||||
await chooseMenuItem(container, '媒体详情')
|
||||
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: expectedMediaId,
|
||||
title: media.name,
|
||||
type: media.type,
|
||||
year: media.year,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubscribeCard item operations', () => {
|
||||
beforeEach(() => {
|
||||
setViewport(1024)
|
||||
observeElementsImmediately()
|
||||
mocks.confirm.mockResolvedValue(true)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['success', 200, { success: true }, 'success', '卡片测试媒体 提交搜索请求成功!'],
|
||||
['business failure', 200, { message: 'rejected', success: false }, 'error', '请求失败,请稍后重试'],
|
||||
['HTTP failure', 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
|
||||
] as const)('reports search %s through the exact endpoint', async (_case, status, response, toastType, message) => {
|
||||
const requested = vi.fn()
|
||||
const { container, media } = await renderCard()
|
||||
server.use(searchSubscribeByIdHandler(media.id, response, status, requested))
|
||||
|
||||
await chooseMenuItem(container, '搜索')
|
||||
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError
|
||||
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
|
||||
})
|
||||
|
||||
it('pauses and enables only after confirmed successful status responses', async () => {
|
||||
const requested: URL[] = []
|
||||
const { container, emitted, media } = await renderCard({ state: 'R' })
|
||||
server.use(
|
||||
updateSubscribeStatusHandler(media.id, { success: true }, 200, url => {
|
||||
requested.push(url)
|
||||
}),
|
||||
)
|
||||
|
||||
await chooseMenuItem(container, '暂停')
|
||||
await waitFor(() => expect(requested).toHaveLength(1))
|
||||
expect(requested[0].searchParams.get('state')).toBe('S')
|
||||
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${media.name} 已暂停!`)
|
||||
|
||||
await chooseMenuItem(container, '启用')
|
||||
await waitFor(() => expect(requested).toHaveLength(2))
|
||||
expect(requested[1].searchParams.get('state')).toBe('R')
|
||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${media.name} 已启用!`)
|
||||
expect(emitted('save')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['confirmation cancellation', false, 200, { success: true }, null],
|
||||
['business failure', true, 200, { message: 'rejected', success: false }, '暂停失败:rejected'],
|
||||
['HTTP failure', true, 500, { message: 'server down', success: false }, '请求失败,请稍后重试'],
|
||||
] as const)(
|
||||
'keeps status unchanged after %s',
|
||||
async (_case, confirmed, status, response, expectedError) => {
|
||||
const requested = vi.fn()
|
||||
mocks.confirm.mockResolvedValue(confirmed)
|
||||
const { container, emitted, media } = await renderCard({ state: 'R' })
|
||||
server.use(updateSubscribeStatusHandler(media.id, response, status, requested))
|
||||
|
||||
await chooseMenuItem(container, '暂停')
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||
|
||||
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
else expect(requested).not.toHaveBeenCalled()
|
||||
if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError))
|
||||
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||
expect(emitted('save') ?? []).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
['success', true, 200, { success: true }, 'success', '卡片测试媒体 重置成功!'],
|
||||
['confirmation cancellation', false, 200, { success: true }, null, null],
|
||||
['business failure', true, 200, { message: 'rejected', success: false }, 'error', '卡片测试媒体 重置失败:rejected'],
|
||||
['HTTP failure', true, 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
|
||||
] as const)(
|
||||
'handles reset %s without speculative state',
|
||||
async (_case, confirmed, status, response, toastType, message) => {
|
||||
const requested = vi.fn()
|
||||
mocks.confirm.mockResolvedValue(confirmed)
|
||||
const { container, emitted, media } = await renderCard({ state: 'S' })
|
||||
server.use(resetSubscribeByIdHandler(media.id, response, status, requested))
|
||||
|
||||
await chooseMenuItem(container, '重置')
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||
|
||||
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
else expect(requested).not.toHaveBeenCalled()
|
||||
if (toastType && message) {
|
||||
const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError
|
||||
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
|
||||
} else {
|
||||
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
if (_case === 'success') {
|
||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||
expect(emitted('save')).toHaveLength(1)
|
||||
} else {
|
||||
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
|
||||
expect(emitted('save') ?? []).toHaveLength(0)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
['success', 200, { success: true }, true, null],
|
||||
['HTTP failure', 500, { message: 'server down', success: false }, false, '请求失败,请稍后重试'],
|
||||
] as const)('handles delete %s without a synthetic business-failure branch', async (_case, status, response, removed, error) => {
|
||||
const requested = vi.fn()
|
||||
const { container, emitted, media } = await renderCard()
|
||||
server.use(deleteSubscribeByIdHandler(media.id, response, status, requested))
|
||||
|
||||
await chooseMenuItem(container, '取消订阅')
|
||||
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
|
||||
if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error))
|
||||
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
180
src/components/cards/__tests__/SubscribeShareCard.spec.ts
Normal file
180
src/components/cards/__tests__/SubscribeShareCard.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import type { SubscribeShare } from '@/api/types'
|
||||
import SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { createSubscribeShare } from '@tests/support/factories/subscribe'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openSharedDialog: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
default: { push: (...args: unknown[]) => mocks.routerPush(...args) },
|
||||
}))
|
||||
|
||||
vi.mock('@iconify/vue', () => ({
|
||||
Icon: {
|
||||
props: ['icon'],
|
||||
template: '<span :data-icon="icon" />',
|
||||
},
|
||||
}))
|
||||
|
||||
type ShareOverrides = Partial<SubscribeShare>
|
||||
|
||||
function observeElementsImmediately() {
|
||||
class ImmediateIntersectionObserver {
|
||||
readonly root = null
|
||||
readonly rootMargin = '0px'
|
||||
readonly thresholds = [0]
|
||||
|
||||
constructor(private readonly callback: IntersectionObserverCallback) {}
|
||||
|
||||
disconnect() {}
|
||||
|
||||
observe(target: Element) {
|
||||
this.callback([{ intersectionRatio: 1, isIntersecting: true, target } as IntersectionObserverEntry], this)
|
||||
}
|
||||
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return []
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('IntersectionObserver', ImmediateIntersectionObserver)
|
||||
}
|
||||
|
||||
async function renderCard(overrides: ShareOverrides = {}, globalImageCache = false) {
|
||||
const media = createSubscribeShare(overrides)
|
||||
const result = await renderWithProviders(SubscribeShareCard, {
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
|
||||
initialized: true,
|
||||
loading: false,
|
||||
},
|
||||
},
|
||||
props: { media },
|
||||
})
|
||||
|
||||
return { ...result, media }
|
||||
}
|
||||
|
||||
async function loadPoster(container: Element) {
|
||||
const backdrop = container.querySelector<HTMLImageElement>('img')
|
||||
expect(backdrop).not.toBeNull()
|
||||
await fireEvent.load(backdrop as HTMLImageElement)
|
||||
await waitFor(() => expect(container.querySelectorAll('img')).toHaveLength(2))
|
||||
return container.querySelectorAll<HTMLImageElement>('img')[1]
|
||||
}
|
||||
|
||||
function getDialogCall(index = 0) {
|
||||
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
|
||||
unknown,
|
||||
Record<string, unknown>,
|
||||
Record<string, (...args: unknown[]) => void>,
|
||||
Record<string, unknown>,
|
||||
]
|
||||
return { events, options, props }
|
||||
}
|
||||
|
||||
describe('SubscribeShareCard', () => {
|
||||
beforeEach(() => {
|
||||
observeElementsImmediately()
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
})
|
||||
|
||||
it('renders sharing metadata and reveals the cached poster after the backdrop loads', async () => {
|
||||
const { container, media } = await renderCard({}, true)
|
||||
|
||||
expect(screen.getByText(media.share_title!)).toBeInTheDocument()
|
||||
expect(screen.getByText(media.share_comment!)).toBeInTheDocument()
|
||||
expect(screen.getByText(media.share_user!)).toBeInTheDocument()
|
||||
expect(screen.getByText(media.count!.toLocaleString())).toBeInTheDocument()
|
||||
expect(screen.getByText(formatDateDifference(media.date!))).toBeInTheDocument()
|
||||
|
||||
const backdrop = container.querySelector<HTMLImageElement>('img')
|
||||
expect(backdrop).not.toBeNull()
|
||||
expect((backdrop as HTMLImageElement).src).toContain('system/cache/image?url=')
|
||||
expect((backdrop as HTMLImageElement).src).toContain(encodeURIComponent(media.backdrop!))
|
||||
|
||||
const poster = await loadPoster(container)
|
||||
expect(poster.src).toContain('system/cache/image?url=')
|
||||
expect(poster.src).toContain(encodeURIComponent(media.poster!))
|
||||
|
||||
const dateMetadata = screen.getByText(formatDateDifference(media.date!)).closest('.v-card-text')
|
||||
expect(dateMetadata?.querySelector('[data-icon="mdi-calendar"], [data-icon="mdi:calendar"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the poster when the backdrop is missing and hides a zero reuse count', async () => {
|
||||
const { container, media } = await renderCard({ backdrop: undefined, count: 0 })
|
||||
|
||||
const backdrop = container.querySelector<HTMLImageElement>('img')
|
||||
expect(backdrop).not.toBeNull()
|
||||
expect((backdrop as HTMLImageElement).src).toContain(media.poster!)
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['TMDB before Douban', { doubanid: '2202', tmdbid: 1101 }, 'tmdb:1101'],
|
||||
['Douban without TMDB', { doubanid: '2202', tmdbid: undefined }, 'douban:2202'],
|
||||
['Bangumi without TMDB or Douban', { bangumiid: 3303, doubanid: undefined, tmdbid: undefined }, 'bangumi:3303'],
|
||||
] as const)('routes media details with %s while keeping the fork dialog closed', async (_case, ids, mediaid) => {
|
||||
const { container, media } = await renderCard(ids)
|
||||
const poster = await loadPoster(container)
|
||||
|
||||
await fireEvent.click(poster)
|
||||
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid,
|
||||
title: media.name,
|
||||
type: media.type,
|
||||
year: media.year,
|
||||
},
|
||||
})
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the fork dialog with the exact media and replaces it with editing after fork success', async () => {
|
||||
const { container, media } = await renderCard()
|
||||
const card = container.querySelector<HTMLElement>('.v-card')
|
||||
expect(card).not.toBeNull()
|
||||
|
||||
await fireEvent.click(card as HTMLElement)
|
||||
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
const forkDialog = getDialogCall()
|
||||
expect(forkDialog.props).toEqual({ media })
|
||||
expect(forkDialog.options).toEqual({ closeOn: ['close', 'fork', 'delete'] })
|
||||
|
||||
forkDialog.events.fork(4701)
|
||||
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(2)
|
||||
const editDialog = getDialogCall(1)
|
||||
expect(editDialog.props).toEqual({ subid: 4701 })
|
||||
expect(editDialog.events).toEqual({})
|
||||
expect(editDialog.options).toEqual({ closeOn: ['close', 'save', 'remove'] })
|
||||
})
|
||||
|
||||
it('forwards deletion from the fork dialog without depending on its response payload', async () => {
|
||||
const { container, emitted } = await renderCard()
|
||||
const card = container.querySelector<HTMLElement>('.v-card')
|
||||
expect(card).not.toBeNull()
|
||||
await fireEvent.click(card as HTMLElement)
|
||||
|
||||
getDialogCall().events.delete({ id: 9999 })
|
||||
|
||||
expect(emitted('delete')).toHaveLength(1)
|
||||
expect(emitted('delete')?.[0]).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -68,6 +68,14 @@ const buttonText = computed(() =>
|
||||
loading.value ? t('dialog.addDownload.downloading') : t('dialog.addDownload.startDownload'),
|
||||
)
|
||||
|
||||
// 下载确认副标题,未传媒体标题时回退到种子标题。
|
||||
const dialogSubtitle = computed(() => {
|
||||
const siteName = props.torrent?.site_name?.trim()
|
||||
const displayTitle = props.title?.trim() || props.torrent?.title?.trim()
|
||||
|
||||
return [siteName, displayTitle].filter(Boolean).join(' - ')
|
||||
})
|
||||
|
||||
// 加载目录设置
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
@@ -78,6 +86,7 @@ async function loadDirectories() {
|
||||
}
|
||||
}
|
||||
|
||||
// 将下载目录配置转换为下载器可识别的存储路径。
|
||||
function convertToUri(item: TransferDirectoryConf) {
|
||||
if (!item.download_path) {
|
||||
return undefined
|
||||
@@ -181,7 +190,7 @@ onMounted(() => {
|
||||
<VIcon icon="mdi-monitor-arrow-down-variant" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('dialog.addDownload.confirmDownload') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ torrent?.site_name }} - {{ title }}</VCardSubtitle>
|
||||
<VCardSubtitle>{{ dialogSubtitle }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
|
||||
449
src/components/dialog/AgentMcpSettingsDialog.vue
Normal file
449
src/components/dialog/AgentMcpSettingsDialog.vue
Normal file
@@ -0,0 +1,449 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
type AgentMcpTransport = 'stdio' | 'sse' | 'http' | 'streamable_http'
|
||||
|
||||
interface AgentMcpServer {
|
||||
id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
transport: AgentMcpTransport
|
||||
description?: string | null
|
||||
command?: string | null
|
||||
args: string[]
|
||||
env: Record<string, string>
|
||||
url?: string | null
|
||||
headers: Record<string, string>
|
||||
timeout: number
|
||||
tool_prefix?: string | null
|
||||
require_admin: boolean
|
||||
}
|
||||
|
||||
interface EditableAgentMcpServer extends AgentMcpServer {
|
||||
argsText: string
|
||||
envText: string
|
||||
headersText: string
|
||||
}
|
||||
|
||||
interface AgentMcpToolInfo {
|
||||
name: string
|
||||
agent_tool_name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface AgentMcpTestState {
|
||||
loading: boolean
|
||||
success?: boolean
|
||||
message?: string
|
||||
tools?: AgentMcpToolInfo[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
servers: AgentMcpServer[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'saved', value: AgentMcpServer[]): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const display = useDisplay()
|
||||
|
||||
const saving = ref(false)
|
||||
const localServers = ref<EditableAgentMcpServer[]>([])
|
||||
const testStates = ref<Record<string, AgentMcpTestState>>({})
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const transportItems = computed(() => [
|
||||
{ title: t('setting.system.aiAgentMcpTransportStdio'), value: 'stdio' },
|
||||
{ title: t('setting.system.aiAgentMcpTransportSse'), value: 'sse' },
|
||||
{ title: t('setting.system.aiAgentMcpTransportHttp'), value: 'http' },
|
||||
])
|
||||
|
||||
function createServerId() {
|
||||
return `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function dictToLines(value?: Record<string, string>) {
|
||||
return Object.entries(value || {})
|
||||
.map(([key, item]) => `${key}=${item}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function linesToDict(value: string) {
|
||||
return String(value || '')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.reduce<Record<string, string>>((result, line) => {
|
||||
const separatorIndex = line.indexOf('=')
|
||||
const key = separatorIndex >= 0 ? line.slice(0, separatorIndex).trim() : line
|
||||
if (!key) return result
|
||||
result[key] = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : ''
|
||||
return result
|
||||
}, {})
|
||||
}
|
||||
|
||||
function toEditableServer(server: AgentMcpServer): EditableAgentMcpServer {
|
||||
return {
|
||||
id: server.id || createServerId(),
|
||||
name: server.name || t('setting.system.aiAgentMcpUnnamedServer'),
|
||||
enabled: server.enabled !== false,
|
||||
transport: server.transport || 'stdio',
|
||||
description: server.description || '',
|
||||
command: server.command || '',
|
||||
args: [...(server.args || [])],
|
||||
env: { ...(server.env || {}) },
|
||||
url: server.url || '',
|
||||
headers: { ...(server.headers || {}) },
|
||||
timeout: Number(server.timeout || 30),
|
||||
tool_prefix: server.tool_prefix || '',
|
||||
require_admin: server.require_admin !== false,
|
||||
argsText: (server.args || []).join('\n'),
|
||||
envText: dictToLines(server.env),
|
||||
headersText: dictToLines(server.headers),
|
||||
}
|
||||
}
|
||||
|
||||
function toServerPayload(server: EditableAgentMcpServer): AgentMcpServer {
|
||||
return {
|
||||
id: server.id || createServerId(),
|
||||
name: String(server.name || '').trim() || t('setting.system.aiAgentMcpUnnamedServer'),
|
||||
enabled: Boolean(server.enabled),
|
||||
transport: server.transport || 'stdio',
|
||||
description: String(server.description || '').trim() || null,
|
||||
command: String(server.command || '').trim() || null,
|
||||
args: String(server.argsText || '')
|
||||
.split('\n')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean),
|
||||
env: linesToDict(server.envText),
|
||||
url: String(server.url || '').trim() || null,
|
||||
headers: linesToDict(server.headersText),
|
||||
timeout: Math.max(1, Number(server.timeout || 30)),
|
||||
tool_prefix: String(server.tool_prefix || '').trim() || null,
|
||||
require_admin: Boolean(server.require_admin),
|
||||
}
|
||||
}
|
||||
|
||||
function resetLocalServers() {
|
||||
localServers.value = (props.servers || []).map(toEditableServer)
|
||||
testStates.value = {}
|
||||
}
|
||||
|
||||
function addServer() {
|
||||
localServers.value.push(
|
||||
toEditableServer({
|
||||
id: createServerId(),
|
||||
name: t('setting.system.aiAgentMcpNewServer'),
|
||||
enabled: true,
|
||||
transport: 'stdio',
|
||||
description: '',
|
||||
command: '',
|
||||
args: [],
|
||||
env: {},
|
||||
url: '',
|
||||
headers: {},
|
||||
timeout: 30,
|
||||
tool_prefix: '',
|
||||
require_admin: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function removeServer(server: EditableAgentMcpServer) {
|
||||
localServers.value = localServers.value.filter(item => item.id !== server.id)
|
||||
}
|
||||
|
||||
async function testServer(server: EditableAgentMcpServer) {
|
||||
const payload = toServerPayload(server)
|
||||
testStates.value[payload.id] = { loading: true }
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('message/agent/mcp/servers/test', { server: payload })
|
||||
testStates.value[payload.id] = {
|
||||
loading: false,
|
||||
success: Boolean(result.success),
|
||||
message: result.message || result.data?.message || '',
|
||||
tools: result.data?.tools || [],
|
||||
}
|
||||
} catch (error) {
|
||||
testStates.value[payload.id] = {
|
||||
loading: false,
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
tools: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveServers() {
|
||||
saving.value = true
|
||||
try {
|
||||
const servers = localServers.value.map(toServerPayload)
|
||||
const result: { [key: string]: any } = await api.post('message/agent/mcp/servers', { servers })
|
||||
if (result.success) {
|
||||
toast.success(t('setting.system.aiAgentMcpSaveSuccess'))
|
||||
emit('saved', servers)
|
||||
dialogVisible.value = false
|
||||
return
|
||||
}
|
||||
toast.error(result.message || t('setting.system.aiAgentMcpSaveFailed'))
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
value => {
|
||||
if (value) resetLocalServers()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.servers,
|
||||
() => {
|
||||
if (props.modelValue) resetLocalServers()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="dialogVisible" scrollable max-width="72rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-server-network" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('setting.system.aiAgentMcpDialogTitle') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ t('setting.system.aiAgentMcpDialogDesc') }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="dialogVisible = false" />
|
||||
|
||||
<VCardText>
|
||||
<div class="d-flex justify-end mb-4">
|
||||
<VBtn color="success" variant="tonal" prepend-icon="mdi-plus" @click="addServer">
|
||||
{{ t('setting.system.aiAgentMcpAddServer') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<VAlert v-if="localServers.length === 0" type="info" variant="tonal">
|
||||
{{ t('setting.system.aiAgentMcpEmpty') }}
|
||||
</VAlert>
|
||||
|
||||
<VExpansionPanels v-else variant="accordion" class="agent-mcp-panels">
|
||||
<VExpansionPanel v-for="server in localServers" :key="server.id">
|
||||
<VExpansionPanelTitle>
|
||||
<div class="agent-mcp-panel-title">
|
||||
<VIcon :icon="server.enabled ? 'mdi-lan-connect' : 'mdi-lan-disconnect'" />
|
||||
<span>{{ server.name || t('setting.system.aiAgentMcpUnnamedServer') }}</span>
|
||||
<VChip size="small" variant="tonal">{{ server.transport }}</VChip>
|
||||
</div>
|
||||
</VExpansionPanelTitle>
|
||||
<VExpansionPanelText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="server.name"
|
||||
:label="t('setting.system.aiAgentMcpName')"
|
||||
prepend-inner-icon="mdi-label-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="server.transport"
|
||||
:label="t('setting.system.aiAgentMcpTransport')"
|
||||
:items="transportItems"
|
||||
prepend-inner-icon="mdi-transit-connection-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="server.enabled"
|
||||
:label="t('setting.system.aiAgentMcpEnabled')"
|
||||
:hint="t('setting.system.aiAgentMcpEnabledHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="server.require_admin"
|
||||
:label="t('setting.system.aiAgentMcpRequireAdmin')"
|
||||
:hint="t('setting.system.aiAgentMcpRequireAdminHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="server.timeout"
|
||||
:label="t('setting.system.aiAgentMcpTimeout')"
|
||||
type="number"
|
||||
min="1"
|
||||
prepend-inner-icon="mdi-timer-sand"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="server.tool_prefix"
|
||||
:label="t('setting.system.aiAgentMcpToolPrefix')"
|
||||
:hint="t('setting.system.aiAgentMcpToolPrefixHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-form-textbox"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="server.description"
|
||||
:label="t('setting.system.aiAgentMcpDescription')"
|
||||
rows="1"
|
||||
auto-grow
|
||||
prepend-inner-icon="mdi-text-box-outline"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<template v-if="server.transport === 'stdio'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="server.command"
|
||||
:label="t('setting.system.aiAgentMcpCommand')"
|
||||
placeholder="npx"
|
||||
prepend-inner-icon="mdi-console"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextarea
|
||||
v-model="server.argsText"
|
||||
:label="t('setting.system.aiAgentMcpArgs')"
|
||||
:hint="t('setting.system.aiAgentMcpArgsHint')"
|
||||
persistent-hint
|
||||
rows="2"
|
||||
auto-grow
|
||||
prepend-inner-icon="mdi-format-list-bulleted"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="server.envText"
|
||||
:label="t('setting.system.aiAgentMcpEnv')"
|
||||
:hint="t('setting.system.aiAgentMcpKeyValueHint')"
|
||||
persistent-hint
|
||||
rows="2"
|
||||
auto-grow
|
||||
prepend-inner-icon="mdi-code-braces"
|
||||
/>
|
||||
</VCol>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="server.url"
|
||||
:label="t('setting.system.aiAgentMcpUrl')"
|
||||
placeholder="http://127.0.0.1:3001/api/v1/mcp"
|
||||
prepend-inner-icon="mdi-link-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="server.headersText"
|
||||
:label="t('setting.system.aiAgentMcpHeaders')"
|
||||
:hint="t('setting.system.aiAgentMcpKeyValueHint')"
|
||||
persistent-hint
|
||||
rows="2"
|
||||
auto-grow
|
||||
prepend-inner-icon="mdi-format-align-left"
|
||||
/>
|
||||
</VCol>
|
||||
</template>
|
||||
</VRow>
|
||||
|
||||
<VAlert
|
||||
v-if="testStates[server.id]?.message"
|
||||
:type="testStates[server.id]?.success ? 'success' : 'error'"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
class="mt-3"
|
||||
>
|
||||
<div>{{ testStates[server.id]?.message }}</div>
|
||||
<div v-if="testStates[server.id]?.tools?.length" class="agent-mcp-tool-list mt-2">
|
||||
<VChip
|
||||
v-for="tool in testStates[server.id]?.tools"
|
||||
:key="tool.agent_tool_name"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ tool.agent_tool_name }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<div class="agent-mcp-actions mt-4">
|
||||
<VBtn
|
||||
color="info"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-connection"
|
||||
:loading="testStates[server.id]?.loading"
|
||||
@click="testServer(server)"
|
||||
>
|
||||
{{ t('setting.system.aiAgentMcpTest') }}
|
||||
</VBtn>
|
||||
<VBtn color="error" variant="text" prepend-icon="mdi-delete-outline" @click="removeServer(server)">
|
||||
{{ t('common.delete') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VExpansionPanelText>
|
||||
</VExpansionPanel>
|
||||
</VExpansionPanels>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
:loading="saving"
|
||||
@click="saveServers"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.agent-mcp-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-mcp-actions,
|
||||
.agent-mcp-tool-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,6 @@ type UnknownRecord = Record<string, any>
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
colors?: Record<string, string>
|
||||
enabled: Record<string, boolean>
|
||||
elevated?: boolean
|
||||
hint: string
|
||||
@@ -24,7 +23,6 @@ const props = withDefaults(
|
||||
valueGetter?: (item: UnknownRecord) => string
|
||||
}>(),
|
||||
{
|
||||
colors: () => ({}),
|
||||
elevated: false,
|
||||
labelGetter: undefined,
|
||||
modelValue: true,
|
||||
@@ -63,17 +61,42 @@ const elevatedValue = computed({
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.enabled, props.elevated, props.items],
|
||||
() => props.enabled,
|
||||
() => {
|
||||
resetLocalSettings()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.elevated,
|
||||
value => {
|
||||
localElevated.value = value
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.items.map(item => getItemValue(item)).join('\u0000'),
|
||||
() => {
|
||||
syncLocalEnabledItems()
|
||||
},
|
||||
)
|
||||
|
||||
// 重置弹窗内部设置副本,避免直接修改父级 props。
|
||||
function resetLocalSettings() {
|
||||
localEnabled.value = { ...props.enabled }
|
||||
localElevated.value = props.elevated
|
||||
syncLocalEnabledItems()
|
||||
}
|
||||
|
||||
// 同步新增设置项的默认状态,避免刷新列表时覆盖用户正在编辑的本地选择。
|
||||
function syncLocalEnabledItems() {
|
||||
props.items.forEach(item => {
|
||||
const key = getItemValue(item)
|
||||
if (key && !(key in localEnabled.value)) {
|
||||
localEnabled.value[key] = Boolean(props.enabled[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取设置项的稳定键值。
|
||||
@@ -129,12 +152,13 @@ function submitSettings() {
|
||||
<VCardText>
|
||||
<p class="settings-hint">{{ props.hint }}</p>
|
||||
<div class="settings-grid">
|
||||
<div
|
||||
<button
|
||||
v-for="item in props.items"
|
||||
:key="getItemValue(item)"
|
||||
type="button"
|
||||
class="setting-item"
|
||||
:class="{ 'enabled': localEnabled[getItemValue(item)] }"
|
||||
:style="{ '--item-color': props.colors[getItemValue(item)] }"
|
||||
:aria-pressed="Boolean(localEnabled[getItemValue(item)])"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<div class="setting-item-inner">
|
||||
@@ -147,7 +171,7 @@ function submitSettings() {
|
||||
</div>
|
||||
<span class="setting-label">{{ getItemLabel(item) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="props.switchLabel" class="mt-3">
|
||||
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
|
||||
@@ -186,43 +210,48 @@ function submitSettings() {
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex: 1;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
font-weight: 550;
|
||||
line-height: 1.35;
|
||||
text-align: start;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
appearance: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--app-surface-radius);
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
min-block-size: 48px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 10px;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
transition: all 0.2s ease;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item::before {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, var(--item-color, rgb(var(--v-theme-primary))) 0%, transparent 100%);
|
||||
background: rgb(var(--v-theme-primary));
|
||||
content: '';
|
||||
inline-size: 3px;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
opacity: 0.3;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item.enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.4);
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
@@ -230,6 +259,24 @@ function submitSettings() {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.32);
|
||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||
}
|
||||
|
||||
.setting-item:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.setting-item:focus-visible {
|
||||
outline: 3px solid rgba(var(--v-theme-primary), 0.28);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.setting-item.enabled .setting-label {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.setting-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -240,4 +287,11 @@ function submitSettings() {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -99,9 +99,9 @@ function submitCustomCSS() {
|
||||
<style scoped>
|
||||
.custom-css-dialog {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-css-header {
|
||||
@@ -111,7 +111,7 @@ function submitCustomCSS() {
|
||||
|
||||
.custom-css-editor-body {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
min-block-size: 240px;
|
||||
}
|
||||
|
||||
.custom-css-editor {
|
||||
@@ -141,8 +141,8 @@ function submitCustomCSS() {
|
||||
|
||||
.custom-css-editor {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
block-size: auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.custom-css-actions {
|
||||
|
||||
@@ -9,12 +9,10 @@ const display = useDisplay()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
colors?: Record<string, string>
|
||||
modelValue?: boolean
|
||||
tabs: DiscoverSource[]
|
||||
}>(),
|
||||
{
|
||||
colors: () => ({}),
|
||||
modelValue: true,
|
||||
},
|
||||
)
|
||||
@@ -55,9 +53,16 @@ function submitOrder() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="35rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VDialog
|
||||
v-if="visible"
|
||||
v-model="visible"
|
||||
width="35rem"
|
||||
class="settings-dialog"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard class="settings-card">
|
||||
<VCardItem class="settings-card-header">
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-order-alphabetical-ascending" size="small" class="me-2" />
|
||||
{{ t('discover.setTabOrder') }}
|
||||
@@ -72,14 +77,11 @@ function submitOrder() {
|
||||
handle=".cursor-move"
|
||||
item-key="mediaid_prefix"
|
||||
tag="div"
|
||||
:animation="180"
|
||||
:component-data="{ 'class': 'settings-grid' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<VCard
|
||||
variant="text"
|
||||
class="setting-item enabled"
|
||||
:style="{ '--item-color': props.colors[element.mediaid_prefix] }"
|
||||
>
|
||||
<VCard variant="text" class="setting-item enabled">
|
||||
<div class="setting-item-inner">
|
||||
<span class="setting-label">{{ element.name }}</span>
|
||||
<VIcon icon="mdi-drag" class="drag-icon cursor-move" />
|
||||
@@ -102,6 +104,11 @@ function submitOrder() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-card-header {
|
||||
padding-block: 16px;
|
||||
padding-inline: 20px;
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: 0.9rem;
|
||||
@@ -110,50 +117,86 @@ function submitOrder() {
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
min-block-size: 48px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 10px;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
cursor: grab;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item::before {
|
||||
position: absolute;
|
||||
background-color: var(--item-color, #4caf50);
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
block-size: 100%;
|
||||
content: '';
|
||||
inline-size: 4px;
|
||||
inline-size: 3px;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item.enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.setting-item.enabled::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.32);
|
||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||
}
|
||||
|
||||
.setting-item:active {
|
||||
cursor: grabbing;
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.setting-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex: 1;
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
font-weight: 550;
|
||||
line-height: 1.35;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item.enabled .setting-label {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.drag-icon {
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
color: rgba(var(--v-theme-on-surface), 0.52);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.setting-item:hover .drag-icon {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -54,7 +54,8 @@ async function queryFollowUsers() {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/FollowSubscribers')
|
||||
followUsers.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
console.error(error)
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +67,8 @@ async function followUser() {
|
||||
queryFollowUsers()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
console.error(error)
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +84,8 @@ async function unfollowUser() {
|
||||
queryFollowUsers()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
console.error(error)
|
||||
$toast.error(t('subscribe.requestFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +99,7 @@ const posterUrl = computed(() => {
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
@@ -129,6 +133,12 @@ async function doFork() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(
|
||||
t('subscribe.addFailed', {
|
||||
name: props.media?.share_title,
|
||||
message: t('subscribe.requestFailed'),
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
processing.value = false
|
||||
doneNProgress()
|
||||
@@ -151,12 +161,13 @@ async function doDelete() {
|
||||
if (result.success) {
|
||||
$toast.success(t('subscribe.cancelSuccess'))
|
||||
// 完成
|
||||
emit('delete', result.data.id)
|
||||
emit('delete')
|
||||
} else {
|
||||
$toast.error(t('subscribe.cancelFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('subscribe.cancelFailed', { message: t('subscribe.requestFailed') }))
|
||||
} finally {
|
||||
deleting.value = false
|
||||
doneNProgress()
|
||||
|
||||
@@ -105,9 +105,9 @@ function submitTemplate() {
|
||||
<style scoped>
|
||||
.notification-template-editor-dialog {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-editor-header {
|
||||
@@ -147,8 +147,8 @@ function submitTemplate() {
|
||||
|
||||
.template-ace-editor {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
block-size: auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.template-editor-actions {
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
type?: 'offline' | 'online'
|
||||
}>(),
|
||||
{
|
||||
modelValue: true,
|
||||
type: 'offline',
|
||||
},
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
|
||||
|
||||
// 重试连接
|
||||
const retrying = ref(false)
|
||||
|
||||
/** 尝试请求静态资源来触发网络状态重新检测。 */
|
||||
async function handleRetry() {
|
||||
if (retrying.value) return
|
||||
|
||||
retrying.value = true
|
||||
|
||||
try {
|
||||
await fetch('/favicon.ico?' + new Date().getTime(), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
retrying.value = false
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 状态文本
|
||||
const statusText = computed(() => {
|
||||
if (props.type === 'online') {
|
||||
return t('app.onlineMessage')
|
||||
}
|
||||
return getOfflineMessage()
|
||||
})
|
||||
|
||||
// 图标
|
||||
const statusIcon = computed(() => {
|
||||
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
|
||||
})
|
||||
|
||||
// 颜色主题
|
||||
const colorTheme = computed(() => {
|
||||
return props.type === 'online' ? 'success' : 'error'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="props.modelValue" persistent max-width="420" scrollable>
|
||||
<VCard class="offline-dialog">
|
||||
<div class="status-icon-wrapper">
|
||||
<div class="status-icon-bg">
|
||||
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VCardText class="text-center">
|
||||
<h2 class="offline-title mb-4">
|
||||
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
|
||||
</h2>
|
||||
|
||||
<p class="offline-message mb-6">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<div class="action-section mb-6">
|
||||
<VBtn
|
||||
v-if="props.type === 'offline'"
|
||||
:loading="retrying"
|
||||
:color="colorTheme"
|
||||
size="default"
|
||||
variant="flat"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" class="me-2" />
|
||||
{{ retrying ? t('common.checking') : t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div class="status-indicators">
|
||||
<VChip
|
||||
:color="isOnline ? 'success' : 'error'"
|
||||
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
class="me-2"
|
||||
>
|
||||
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
:color="canPerformNetworkAction ? 'success' : 'warning'"
|
||||
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-icon-wrapper {
|
||||
padding-block: 24px 0;
|
||||
padding-inline: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
animation: icon-pulse 3s ease-in-out infinite;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.5);
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
margin-block: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.status-icon-bg::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
animation: icon-glow 2s ease-in-out infinite alternate;
|
||||
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
|
||||
content: '';
|
||||
inset: -3px;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-glow {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -40,12 +40,23 @@ const progressText = ref('')
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 向联邦插件提供主应用 Toast,避免远程组件自行创建通知容器。
|
||||
provide('moviepilot:toast', $toast)
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 渲染模式: 'vuetify' 或 'vue'
|
||||
const renderMode = ref('vuetify')
|
||||
|
||||
// 插件未声明布局偏好时沿用标准配置弹窗宽度。
|
||||
const dialogMaxWidth = ref('60rem')
|
||||
|
||||
interface PluginConfigLayout {
|
||||
/** 插件配置界面期望的最大宽度,使用合法 CSS 尺寸。 */
|
||||
maxWidth?: string
|
||||
}
|
||||
|
||||
// Vue 模式:动态加载的组件
|
||||
const dynamicComponent = defineAsyncComponent({
|
||||
// 工厂函数
|
||||
@@ -89,6 +100,7 @@ async function loadPluginUIData() {
|
||||
pluginFormItems = []
|
||||
pluginConfigForm.value = {}
|
||||
renderMode.value = 'vuetify'
|
||||
dialogMaxWidth.value = '60rem'
|
||||
|
||||
try {
|
||||
// 获取UI定义
|
||||
@@ -123,6 +135,12 @@ function handleVueComponentSave(newConfig: Record<string, any>) {
|
||||
savePluginConf()
|
||||
}
|
||||
|
||||
// 联邦配置组件可按自身布局密度覆盖宿主弹窗宽度。
|
||||
function handleVueComponentLayout(layout?: PluginConfigLayout | null) {
|
||||
const maxWidth = typeof layout?.maxWidth === 'string' ? layout.maxWidth.trim() : ''
|
||||
dialogMaxWidth.value = maxWidth || '60rem'
|
||||
}
|
||||
|
||||
// 调用API保存配置数据
|
||||
async function savePluginConf() {
|
||||
// 显示等待提示框
|
||||
@@ -148,7 +166,7 @@ onBeforeMount(async () => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
|
||||
<!-- Vuetify 渲染模式 -->
|
||||
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
@@ -192,6 +210,7 @@ onBeforeMount(async () => {
|
||||
:initial-config="pluginConfigForm"
|
||||
:api="api"
|
||||
@save="handleVueComponentSave"
|
||||
@layout="handleVueComponentLayout"
|
||||
@switch="emit('switch')"
|
||||
@close="emit('close')"
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import PageRender from '@/components/render/PageRender.vue'
|
||||
import api from '@/api'
|
||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -26,6 +27,10 @@ const display = useDisplay()
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 向联邦插件提供主应用 Toast,确保通知沿用统一主题和路由逻辑。
|
||||
const $toast = useToast()
|
||||
provide('moviepilot:toast', $toast)
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
// 组件是否已加载成功
|
||||
|
||||
@@ -51,8 +51,14 @@ function downloadLogger() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" scrollable max-width="72rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialog
|
||||
v-if="visible"
|
||||
v-model="visible"
|
||||
:scrollable="display.mdAndUp.value"
|
||||
max-width="72rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard class="logging-dialog-card">
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-inline-flex">
|
||||
@@ -75,9 +81,29 @@ function downloadLogger() {
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<VCardText class="logging-dialog-content pa-0">
|
||||
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (width <= 960px) {
|
||||
.logging-dialog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.logging-dialog-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,8 +6,12 @@ import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
||||
)
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
@@ -15,6 +19,8 @@ const { t } = useI18n()
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -47,6 +53,7 @@ const imageRef = ref<any>()
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件安装进度弹窗。 */
|
||||
function showInstallProgress(text: string) {
|
||||
@@ -97,24 +104,38 @@ function visitPluginPage() {
|
||||
}
|
||||
|
||||
/** 安装插件并通知父级刷新市场列表。 */
|
||||
async function installPlugin() {
|
||||
if (props.plugin?.system_version_compatible === false) {
|
||||
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||
return
|
||||
}
|
||||
|
||||
if (releaseVersion) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('plugin.confirmInstallOldRelease', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion,
|
||||
}),
|
||||
confirmText: t('common.confirm'),
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
}
|
||||
|
||||
try {
|
||||
showInstallProgress(
|
||||
t('plugin.installing', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: props?.plugin?.plugin_version,
|
||||
version: releaseVersion || props?.plugin?.plugin_version,
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
force: props.plugin?.has_update,
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: props.plugin?.has_update || Boolean(releaseVersion),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -122,6 +143,8 @@ async function installPlugin() {
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
visible.value = false
|
||||
emit('install')
|
||||
} else {
|
||||
@@ -133,8 +156,22 @@ async function installPlugin() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开版本历史并支持从 Release 资产安装指定版本。 */
|
||||
function showUpdateHistory() {
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin, actionMode: 'install' },
|
||||
{
|
||||
update: installPlugin,
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
closeInstallProgress()
|
||||
versionHistoryDialogController?.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -190,16 +227,21 @@ onUnmounted(() => {
|
||||
class="mb-3"
|
||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
<div class="text-center text-md-left">
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="installPlugin"
|
||||
prepend-icon="mdi-download"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
>
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</VBtn>
|
||||
<div class="text-xs mt-2" v-if="props.count">
|
||||
<div class="plugin-market-detail-actions">
|
||||
<div class="plugin-market-detail-actions__buttons">
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="installPlugin()"
|
||||
prepend-icon="mdi-download"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
>
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" @click="showUpdateHistory" prepend-icon="mdi-update">
|
||||
{{ t('plugin.versionHistory') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<div class="plugin-market-detail-actions__downloads" v-if="props.count">
|
||||
<VIcon icon="mdi-fire" />
|
||||
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
||||
</div>
|
||||
@@ -212,3 +254,42 @@ onUnmounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-market-detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__buttons {
|
||||
/* 窄屏换行时用统一 gap 控制按钮间距,避免第二个按钮带左边距导致视觉偏移。 */
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__downloads {
|
||||
flex-basis: 100%;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (width >= 960px) {
|
||||
.plugin-market-detail-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__buttons {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__downloads {
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user