mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-26 02:31:05 +08:00
Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
101
.github/workflows/pr-agent.yml
vendored
101
.github/workflows/pr-agent.yml
vendored
@@ -1,9 +1,8 @@
|
||||
name: PR Agent
|
||||
name: PR-Agent
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
|
||||
# pull_request_target 允许 fork PR 使用仓库 secrets,因此 workflow 只运行固定 digest 的 PR-Agent 容器。
|
||||
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
@@ -11,24 +10,17 @@ on:
|
||||
- review_requested
|
||||
- synchronize
|
||||
issue_comment:
|
||||
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
|
||||
# issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 PR。
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
# 读取仓库内容和 PR diff。
|
||||
contents: read
|
||||
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
|
||||
pull-requests: write
|
||||
# PR 评论在 GitHub API 中属于 issue comments,手动命令和总结评论需要该权限。
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
pr-agent:
|
||||
name: PR-Agent review and describe
|
||||
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
|
||||
if: >-
|
||||
github.event.sender.type != 'Bot' &&
|
||||
(
|
||||
@@ -36,88 +28,25 @@ jobs:
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
|
||||
(
|
||||
github.event.comment.body == '/review' ||
|
||||
startsWith(github.event.comment.body, '/review ') ||
|
||||
github.event.comment.body == '/describe' ||
|
||||
startsWith(github.event.comment.body, '/describe ') ||
|
||||
github.event.comment.body == '/improve' ||
|
||||
startsWith(github.event.comment.body, '/improve ') ||
|
||||
github.event.comment.body == '/ask' ||
|
||||
startsWith(github.event.comment.body, '/ask ')
|
||||
)
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association)
|
||||
)
|
||||
)
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Run PR-Agent
|
||||
id: pragent
|
||||
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
|
||||
uses: docker://pragent/pr-agent:0.37.0-github_action@sha256:4ec7bac814050a1bc8c96ab2fab6b7b0f65df0049a5ec43f3fee1a0b551c28ca
|
||||
- name: Run PR Review
|
||||
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
|
||||
env:
|
||||
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
# 仓库设置中添加的 Secret:Settings -> Secrets and variables -> Actions。
|
||||
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
|
||||
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
|
||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||
|
||||
# 模型、输出语言和大 diff 处理策略。
|
||||
config.model: "gpt-5.5"
|
||||
config.fallback_models: '["gpt-5.4"]'
|
||||
config.reasoning_effort: "xhigh"
|
||||
config.ai_timeout: "900"
|
||||
config.response_language: "zh-CN"
|
||||
config.large_patch_policy: "clip"
|
||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
||||
|
||||
# pull_request_target 事件默认自动执行 /review 和 /describe;/improve 保持手动触发。
|
||||
github_action_config.auto_review: "true"
|
||||
github_action_config.auto_describe: "true"
|
||||
github_action_config.auto_improve: "false"
|
||||
|
||||
# 允许触发自动工具的 PR 动作。包含 synchronize,便于新 commit 推送后刷新结果。
|
||||
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
|
||||
|
||||
# 保留 action outputs,便于后续 workflow 编排或排查。
|
||||
github_action_config.enable_output: "true"
|
||||
|
||||
# /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略。
|
||||
pr_description.generate_ai_title: "false"
|
||||
pr_description.publish_labels: "false"
|
||||
pr_description.enable_pr_diagram: "false"
|
||||
pr_description.collapsible_file_list: "adaptive"
|
||||
pr_description.add_original_user_description: "true"
|
||||
|
||||
# /review 输出策略,聚焦维护者需要处理的风险和缺口。
|
||||
pr_reviewer.extra_instructions: |
|
||||
请用中文输出。
|
||||
优先指出 P0/P1 风险,避免纠结纯格式问题。
|
||||
重点检查安全、权限、状态一致性、异步/缓存、副作用和测试缺口。
|
||||
pr_reviewer.num_max_findings: "5"
|
||||
pr_reviewer.persistent_comment: "true"
|
||||
pr_reviewer.publish_output_no_suggestions: "true"
|
||||
pr_reviewer.require_tests_review: "true"
|
||||
pr_reviewer.require_security_review: "true"
|
||||
pr_reviewer.require_estimate_effort_to_review: "true"
|
||||
pr_reviewer.require_can_be_split_review: "true"
|
||||
pr_reviewer.require_todo_scan: "false"
|
||||
pr_reviewer.enable_review_labels_effort: "false"
|
||||
pr_reviewer.enable_review_labels_security: "true"
|
||||
|
||||
# /improve 和 /ask 的手动命令策略。
|
||||
pr_code_suggestions.focus_only_on_problems: "true"
|
||||
pr_code_suggestions.suggestions_score_threshold: "7"
|
||||
pr_code_suggestions.commitable_code_suggestions: "false"
|
||||
pr_questions.use_conversation_history: "true"
|
||||
|
||||
# 可选成本和噪音控制:
|
||||
# github_action_config.auto_improve: "true"
|
||||
# config.verbosity_level: "1"
|
||||
# pr_reviewer.num_max_findings: "3"
|
||||
PRR_AUTO_REVIEW_SCOPE: all
|
||||
PRR_ALLOWED_ASSOCIATIONS: '["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'
|
||||
PRR_DISABLED_COMMANDS: '["/improve"]'
|
||||
PRR_SKIP_LABEL: skip pr-agent
|
||||
PRR_SKIP_TITLE_PATTERN: '^(?:\[Auto\]|Auto)'
|
||||
config.response_language: zh-CN
|
||||
|
||||
37
.github/workflows/test.yml
vendored
Normal file
37
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
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:
|
||||
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
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,6 +12,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
coverage
|
||||
*.local
|
||||
package-lock.json
|
||||
|
||||
@@ -38,3 +39,4 @@ public/plugin_icon/**
|
||||
docs-lock/
|
||||
.trae/
|
||||
output/
|
||||
.codex-qa/
|
||||
|
||||
@@ -40,6 +40,15 @@ yarn dev
|
||||
yarn build
|
||||
```
|
||||
|
||||
### 单元测试
|
||||
|
||||
```sh
|
||||
yarn test:run
|
||||
yarn test:coverage
|
||||
```
|
||||
|
||||
测试文件组织、共享测试设施、HTTP mock、覆盖率门禁和新增用例规范见[单元测试架构](docs/testing.md)。
|
||||
|
||||
### 静态运行
|
||||
|
||||
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件,nginx配置参考 `public/nginx.conf`。
|
||||
|
||||
@@ -449,21 +449,16 @@ 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/) - 开发插件组件的完整示例项目
|
||||
|
||||
94
docs/testing.md
Normal file
94
docs/testing.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 单元测试架构
|
||||
|
||||
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`。
|
||||
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`。测试模式保留 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 typecheck
|
||||
yarn lint
|
||||
yarn build
|
||||
```
|
||||
|
||||
Pull Request 测试工作流使用 Node 24 LTS 和 frozen lockfile,依次执行类型检查和覆盖率门禁。现有 lint 基线问题按仓库当前维护约定单独处理,新增测试代码不得引入新的 lint 错误。
|
||||
25
package.json
25
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.16",
|
||||
"version": "2.14.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
@@ -9,6 +9,9 @@
|
||||
"prebuild": "npm run build:icons",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5050",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
|
||||
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
|
||||
@@ -20,6 +23,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 +57,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 +65,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",
|
||||
@@ -82,7 +91,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,11 +105,15 @@
|
||||
"@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",
|
||||
@@ -105,6 +123,8 @@
|
||||
"eslint-plugin-sonarjs": "^3.0.1",
|
||||
"eslint-plugin-unicorn": "^56.0.1",
|
||||
"eslint-plugin-vue": "^9.12.0",
|
||||
"jsdom": "26.1.0",
|
||||
"msw": "2.15.0",
|
||||
"postcss": "^8.5.1",
|
||||
"postcss-html": "^1.5.0",
|
||||
"stylelint": "^16.13.2",
|
||||
@@ -123,6 +143,7 @@
|
||||
"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-shepherd": "^4.1.0",
|
||||
"vue-tsc": "^2.0.10",
|
||||
"workbox-build": "^7.3.0",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
// 👉 Apex chart
|
||||
.apexcharts-canvas {
|
||||
// 图表仅保留纵向页面滚动,禁止双指缩放等触摸手势。
|
||||
touch-action: pan-y;
|
||||
|
||||
// For RTL alignment
|
||||
.apexcharts-yaxis-texts-g {
|
||||
text-align: start;
|
||||
|
||||
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 {
|
||||
|
||||
348
src/App.vue
348
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'
|
||||
@@ -12,13 +12,25 @@ import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundMan
|
||||
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,66 +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(
|
||||
() => 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)
|
||||
@@ -125,6 +305,7 @@ function updateHtmlThemeAttribute(themeName: string) {
|
||||
syncRootLaunchPalette()
|
||||
}
|
||||
|
||||
// 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。
|
||||
function syncThemePreferenceFromStorage() {
|
||||
themeValue = localStorage.getItem('theme') || 'auto'
|
||||
|
||||
@@ -148,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 {
|
||||
@@ -172,6 +391,7 @@ async function fetchBackgroundImages() {
|
||||
backgroundImages.value = await api.get(`/login/wallpapers`, {
|
||||
signal: backgroundRequestController.signal,
|
||||
})
|
||||
resetBackgroundCrossfade()
|
||||
activeImageIndex.value = 0
|
||||
} catch (e) {
|
||||
throw e
|
||||
@@ -180,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
|
||||
@@ -187,7 +409,7 @@ function rotateBackgroundImage() {
|
||||
preloadImage(backgroundImages.value[nextIndex]).then(success => {
|
||||
// 只有图片成功加载才切换
|
||||
if (success) {
|
||||
activeImageIndex.value = nextIndex
|
||||
activateBackgroundImage(nextIndex)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -212,6 +434,7 @@ function startBackgroundRotation() {
|
||||
}
|
||||
}
|
||||
|
||||
// 停止登录页或透明主题背景图加载、重试和轮播。
|
||||
function stopBackgroundLoading() {
|
||||
backgroundRequestController?.abort()
|
||||
backgroundRequestController = null
|
||||
@@ -221,9 +444,11 @@ function stopBackgroundLoading() {
|
||||
backgroundRetryTimer = null
|
||||
}
|
||||
|
||||
resetBackgroundCrossfade()
|
||||
removeBackgroundTimer('background-rotation')
|
||||
}
|
||||
|
||||
// 初始化登录后的全局设置和用户设置状态。
|
||||
async function initializeAuthenticatedState() {
|
||||
if (!isLogin.value) return
|
||||
|
||||
@@ -236,6 +461,7 @@ async function initializeAuthenticatedState() {
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟初始化登录态数据,避开登录成功后的即时路由跳转窗口。
|
||||
function scheduleAuthenticatedStateInitialization() {
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
@@ -360,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(
|
||||
@@ -395,6 +624,7 @@ onMounted(async () => {
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
stopHeartbeat()
|
||||
offlineStatus.markServerOnline()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -406,6 +636,7 @@ onUnmounted(() => {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
clearWindowBlurRenderThrottleTimer()
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
|
||||
@@ -413,26 +644,33 @@ 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 :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
||||
@@ -486,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%;
|
||||
@@ -506,6 +762,22 @@ onUnmounted(() => {
|
||||
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;
|
||||
|
||||
@@ -530,6 +530,56 @@ 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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
||||
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
|
||||
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
|
||||
@@ -555,4 +605,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)
|
||||
|
||||
151
src/api/types.ts
151
src/api/types.ts
@@ -1000,6 +1000,12 @@ export interface MediaStatistic {
|
||||
episode_count: number | null
|
||||
// 用户数量
|
||||
user_count: number
|
||||
// 本月新增电影数量
|
||||
movie_count_month: number
|
||||
// 本月新增电视剧数量
|
||||
tv_count_month: number
|
||||
// 本月新增剧集数量
|
||||
episode_count_month: number
|
||||
}
|
||||
|
||||
// 后台进程
|
||||
@@ -1020,6 +1026,18 @@ export interface Process {
|
||||
memory: number
|
||||
}
|
||||
|
||||
// 仪表板系统摘要
|
||||
export interface DashboardSystemInfo {
|
||||
// 主机名称
|
||||
hostname: string
|
||||
// 操作系统名称
|
||||
operating_system: string
|
||||
// MoviePilot 运行时间,单位秒
|
||||
runtime: number
|
||||
// MoviePilot 后端版本
|
||||
version: string
|
||||
}
|
||||
|
||||
// 下载器信息
|
||||
export interface DownloaderInfo {
|
||||
// 下载速度
|
||||
@@ -1034,18 +1052,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
|
||||
}
|
||||
|
||||
// 消息通知
|
||||
@@ -1151,6 +1237,8 @@ export interface MediaServerLibrary {
|
||||
path?: string
|
||||
// 类型
|
||||
type?: string
|
||||
// 库内媒体数量
|
||||
item_count?: number
|
||||
// 图片
|
||||
image?: string
|
||||
// 图片列表
|
||||
@@ -1337,6 +1425,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 {
|
||||
// 种子名称
|
||||
@@ -1357,6 +1467,12 @@ export interface SubscribeLibraryFileInfo {
|
||||
storage?: string
|
||||
// 文件路径
|
||||
file_path?: string
|
||||
// 媒体服务器名称
|
||||
server?: string
|
||||
// 媒体服务器类型:emby、jellyfin、plex 等
|
||||
server_type?: string
|
||||
// 媒体服务器条目 ID
|
||||
itemid?: string
|
||||
}
|
||||
|
||||
// 订阅集详情
|
||||
@@ -1635,6 +1751,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 {
|
||||
// 分享人
|
||||
@@ -1649,6 +1799,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
@@ -4,6 +4,7 @@ import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
|
||||
type AgentMessageRole = 'user' | 'assistant'
|
||||
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
|
||||
@@ -88,6 +89,7 @@ interface AgentStreamEvent {
|
||||
choice?: Omit<AgentChoiceCard, 'status'>
|
||||
content?: string
|
||||
message?: string
|
||||
message_i18n?: string
|
||||
message_id?: string
|
||||
target_message?: Partial<AgentChatMessage> & { id?: string }
|
||||
session_id?: string
|
||||
@@ -622,19 +624,16 @@ function dedupeHistorySessions(sessions: AgentSessionHistoryItem[]) {
|
||||
|
||||
// 调用智能助手接口,并统一处理鉴权和标准响应格式。
|
||||
async function fetchAgentApi(path: string, options: RequestInit = {}) {
|
||||
const headers = new Headers(options.headers || {})
|
||||
if (authStore.token) headers.set('Authorization', `Bearer ${authStore.token}`)
|
||||
|
||||
const response = await fetch(resolveApiUrl(path), {
|
||||
...options,
|
||||
headers,
|
||||
headers: buildAgentRequestHeaders(options.headers),
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
|
||||
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
|
||||
|
||||
const result = await response.json()
|
||||
if (!result?.success) throw new Error(result?.message || t('agentAssistant.error'))
|
||||
if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.error'))
|
||||
|
||||
return result.data
|
||||
}
|
||||
@@ -981,6 +980,31 @@ function resolveApiUrl(path: string) {
|
||||
return `${baseUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}`
|
||||
}
|
||||
|
||||
// 构造智能助手 fetch 请求头,补齐 Axios 拦截器无法覆盖的鉴权和语言信息。
|
||||
function buildAgentRequestHeaders(headers?: HeadersInit) {
|
||||
const requestHeaders = new Headers(headers || {})
|
||||
const locale = getCurrentLocale()
|
||||
|
||||
if (authStore.token) requestHeaders.set('Authorization', `Bearer ${authStore.token}`)
|
||||
requestHeaders.set('X-MoviePilot-Locale', locale)
|
||||
requestHeaders.set('Accept-Language', locale)
|
||||
|
||||
return requestHeaders
|
||||
}
|
||||
|
||||
// 解析智能助手 fetch 失败响应,优先使用后端返回的本地化错误文本。
|
||||
async function resolveAgentResponseErrorMessage(response: Response) {
|
||||
try {
|
||||
const payload = await response.clone().json()
|
||||
const message = payload?.detail_i18n || payload?.message_i18n || payload?.detail || payload?.message
|
||||
if (typeof message === 'string' && message) return message
|
||||
} catch {
|
||||
// 非 JSON 错误响应保留 HTTP 状态文本,避免吞掉原始错误。
|
||||
}
|
||||
|
||||
return `${response.status} ${response.statusText}`.trim()
|
||||
}
|
||||
|
||||
// 消息主列表使用原生滚动,避免流式回复时 JS 滚动库频繁测量影响手感。
|
||||
function getMessageScrollerElement() {
|
||||
return messageListRef.value
|
||||
@@ -1194,7 +1218,7 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
||||
case 'error':
|
||||
assistantMessage.status = 'error'
|
||||
// 后端流式错误已经以 AI 消息展示,避免底部提示条重复且持续占位。
|
||||
assistantMessage.content ||= event.message || t('agentAssistant.error')
|
||||
assistantMessage.content ||= event.message_i18n || event.message || t('agentAssistant.error')
|
||||
emit('assistant-preview', assistantMessage.content)
|
||||
markToolsDone(assistantMessage)
|
||||
break
|
||||
@@ -1370,17 +1394,15 @@ async function uploadAgentAttachment(file: File) {
|
||||
|
||||
const response = await fetch(resolveApiUrl('message/agent/upload'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
|
||||
},
|
||||
headers: buildAgentRequestHeaders(),
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
|
||||
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
|
||||
|
||||
const result = await response.json()
|
||||
if (!result?.success) throw new Error(result?.message || t('agentAssistant.uploadFailed'))
|
||||
if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.uploadFailed'))
|
||||
|
||||
return result.data as AgentMessageAttachment & AgentOutgoingFile
|
||||
}
|
||||
@@ -1452,10 +1474,9 @@ async function streamAgentMessage(
|
||||
try {
|
||||
const response = await fetch(resolveApiUrl('message/agent/stream'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
headers: buildAgentRequestHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
|
||||
},
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
text: content,
|
||||
display_text: displayContent || content,
|
||||
@@ -1473,7 +1494,7 @@ async function streamAgentMessage(
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`.trim())
|
||||
throw new Error(await resolveAgentResponseErrorMessage(response))
|
||||
}
|
||||
|
||||
await readAgentStream(response, assistantMessage)
|
||||
@@ -1698,10 +1719,9 @@ async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceC
|
||||
try {
|
||||
const response = await fetch(resolveApiUrl('message/agent/callback'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
headers: buildAgentRequestHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
|
||||
},
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId.value,
|
||||
callback_data: button.callback_data,
|
||||
@@ -1711,10 +1731,10 @@ async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceC
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
|
||||
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
|
||||
|
||||
const result = await response.json()
|
||||
if (!result?.success) throw new Error(result?.message || t('agentAssistant.choiceExpired'))
|
||||
if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.choiceExpired'))
|
||||
|
||||
const agentMessage = String(result.data?.message || '')
|
||||
if (result.data?.traditional) {
|
||||
@@ -2362,7 +2382,7 @@ onScopeDispose(() => {
|
||||
.agent-assistant-shell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
block-size: 100%;
|
||||
block-size: 100vh;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-block-size: 0;
|
||||
|
||||
@@ -2372,6 +2392,12 @@ onScopeDispose(() => {
|
||||
--agent-assistant-panel-blur: 10px;
|
||||
}
|
||||
|
||||
@supports (block-size: 100dvh) {
|
||||
.agent-assistant-shell {
|
||||
block-size: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-assistant-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2660,6 +2686,7 @@ onScopeDispose(() => {
|
||||
-ms-overflow-style: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
overscroll-behavior-y: contain;
|
||||
padding-block: 1rem;
|
||||
padding-inline: 1rem;
|
||||
scroll-behavior: auto;
|
||||
|
||||
@@ -8,11 +8,13 @@ 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
|
||||
|
||||
|
||||
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>
|
||||
@@ -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 === ''">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -31,18 +31,45 @@ const cardStyle = computed(() => ({
|
||||
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
|
||||
@@ -53,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)}`
|
||||
@@ -70,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]
|
||||
@@ -102,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
|
||||
|
||||
@@ -189,13 +230,13 @@ onMounted(async () => {
|
||||
</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>
|
||||
@@ -218,6 +259,88 @@ onMounted(async () => {
|
||||
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%;
|
||||
|
||||
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>
|
||||
@@ -92,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
|
||||
@@ -290,22 +301,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,
|
||||
@@ -334,6 +329,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,
|
||||
@@ -415,7 +426,7 @@ function handleCardClick() {
|
||||
>
|
||||
<VCard
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full overflow-hidden"
|
||||
class="subscribe-card flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
@@ -423,45 +434,143 @@ function handleCardClick() {
|
||||
}"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
:ripple="display.smAndUp.value && !props.batchMode && !props.sortable"
|
||||
>
|
||||
<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" 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"
|
||||
@@ -568,7 +677,7 @@ function handleCardClick() {
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
@@ -588,6 +697,155 @@ function handleCardClick() {
|
||||
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;
|
||||
@@ -621,7 +879,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;
|
||||
@@ -653,4 +911,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>
|
||||
|
||||
@@ -208,15 +208,12 @@ watch(
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-card-hover-area:hover .subtitle-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.subtitle-card {
|
||||
border: 1px solid transparent;
|
||||
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>
|
||||
|
||||
@@ -211,15 +211,12 @@ watch(
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-item-hover-area:hover .subtitle-item {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.subtitle-item {
|
||||
border: 1px solid transparent;
|
||||
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>
|
||||
|
||||
@@ -321,17 +321,14 @@ watch(
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-card-hover-area:hover .torrent-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
/* 卡片悬停效果 */
|
||||
.torrent-card {
|
||||
border: 1px solid transparent;
|
||||
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);
|
||||
}
|
||||
|
||||
/* 优惠标签样式 */
|
||||
|
||||
@@ -267,16 +267,13 @@ watch(
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-item-hover-area:hover .torrent-item {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.torrent-item {
|
||||
border: 1px solid transparent;
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -46,6 +46,14 @@ 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 +97,7 @@ async function loadPluginUIData() {
|
||||
pluginFormItems = []
|
||||
pluginConfigForm.value = {}
|
||||
renderMode.value = 'vuetify'
|
||||
dialogMaxWidth.value = '60rem'
|
||||
|
||||
try {
|
||||
// 获取UI定义
|
||||
@@ -123,6 +132,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 +163,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 +207,7 @@ onBeforeMount(async () => {
|
||||
:initial-config="pluginConfigForm"
|
||||
:api="api"
|
||||
@save="handleVueComponentSave"
|
||||
@layout="handleVueComponentLayout"
|
||||
@switch="emit('switch')"
|
||||
@close="emit('close')"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ManualTransferPayload,
|
||||
ManualTransferPreviewData,
|
||||
ManualTransferPreviewItem,
|
||||
MediaInfo,
|
||||
StorageConf,
|
||||
TransferDirectoryConf,
|
||||
TransferForm,
|
||||
@@ -123,6 +124,20 @@ interface TargetDirectoryOption {
|
||||
|
||||
const AUTO_TARGET_PATH_VALUE = '__moviepilot_auto_target_path__'
|
||||
|
||||
// 媒体类型映射到手动整理接口接受的类型名。
|
||||
function resolveTransferMediaType(type?: string) {
|
||||
const normalizedType = type?.trim().toLowerCase()
|
||||
if (!normalizedType) return undefined
|
||||
|
||||
const movieTypes = ['电影', 'movie']
|
||||
if (movieTypes.includes(normalizedType)) return '电影'
|
||||
|
||||
const tvTypes = ['电视剧', 'tv', 'series']
|
||||
if (tvTypes.includes(normalizedType)) return '电视剧'
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 生成文件项稳定键,用于去重和状态同步。
|
||||
function getFileItemKey(item?: FileItem) {
|
||||
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
||||
@@ -298,6 +313,14 @@ const transferForm = reactive<TransferForm>({
|
||||
episode_group: null,
|
||||
})
|
||||
|
||||
// 处理媒体搜索结果选择,同步搜索结果中已识别的媒体类型。
|
||||
function handleMediaSelected(item: Pick<MediaInfo, 'type'>) {
|
||||
const typeName = resolveTransferMediaType(item.type)
|
||||
if (!typeName) return
|
||||
|
||||
transferForm.type_name = typeName
|
||||
}
|
||||
|
||||
// 所有媒体库目录
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
|
||||
@@ -1019,6 +1042,19 @@ function mergePreviewData(target: ManualTransferPreviewData, incoming?: ManualTr
|
||||
}
|
||||
}
|
||||
|
||||
// 从标准响应中提取可展示的整理预览数据,优先保留顶层本地化消息。
|
||||
function resolvePreviewResponseData(result: ApiResponse<ManualTransferPreviewData>) {
|
||||
if (!result.data) return result.data
|
||||
|
||||
const message = result.message_i18n || result.message || result.data.message
|
||||
if (!message || message === result.data.message) return result.data
|
||||
|
||||
return {
|
||||
...result.data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
// 预览整理结果
|
||||
async function previewTransfer() {
|
||||
if (!props.logids && !normalizedItems.value.length) return
|
||||
@@ -1046,7 +1082,7 @@ async function previewTransfer() {
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
mergePreviewData(mergedPreviewData, result.data)
|
||||
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(`预览请求异常: ${err?.message}`)
|
||||
@@ -1078,7 +1114,7 @@ async function previewTransfer() {
|
||||
return
|
||||
}
|
||||
|
||||
mergePreviewData(mergedPreviewData, result.data)
|
||||
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
|
||||
} catch (err: any) {
|
||||
console.warn(`预览请求异常: ${err?.message}`)
|
||||
mergePreviewData(
|
||||
@@ -1114,7 +1150,7 @@ async function previewTransfer() {
|
||||
return
|
||||
}
|
||||
|
||||
mergePreviewData(mergedPreviewData, result.data)
|
||||
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
|
||||
} catch (err: any) {
|
||||
console.warn(`预览请求异常: ${err?.message}`)
|
||||
mergePreviewData(
|
||||
@@ -1196,7 +1232,7 @@ async function handleTransferLog(logid: number, background: boolean = false) {
|
||||
function handleProgressMessage(event: MessageEvent) {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (progress) {
|
||||
progressText.value = progress.text
|
||||
progressText.value = progress.text_i18n || progress.text
|
||||
progressValue.value = progress.value
|
||||
}
|
||||
}
|
||||
@@ -1714,12 +1750,14 @@ onUnmounted(() => {
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="transferForm.tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
@select="handleMediaSelected"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector
|
||||
v-else
|
||||
v-model="transferForm.doubanid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
@select="handleMediaSelected"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
</VDialog>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { VDialog, VMenu } from 'vuetify/components'
|
||||
import { buildUserPermissionContext, hasPermission, filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
// 显示器宽度
|
||||
@@ -15,10 +16,16 @@ const display = useDisplay()
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义props,接收modelValue
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
// 定义 props,接收浮层状态及是否显示响应式入口。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
showActivator?: boolean
|
||||
}>(),
|
||||
{
|
||||
showActivator: false,
|
||||
},
|
||||
)
|
||||
|
||||
// 路由
|
||||
const router = useRouter()
|
||||
@@ -78,16 +85,33 @@ const dialog = computed({
|
||||
set: val => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
// 桌面使用锚定下拉,小屏继续使用原有搜索弹窗。
|
||||
const searchOverlay = computed(() => (display.mdAndUp.value ? VMenu : VDialog))
|
||||
const searchOverlayProps = computed(() =>
|
||||
display.mdAndUp.value
|
||||
? {
|
||||
closeOnContentClick: false,
|
||||
location: 'bottom start' as const,
|
||||
offset: 8,
|
||||
scrollStrategy: 'reposition' as const,
|
||||
}
|
||||
: {
|
||||
fullscreen: true,
|
||||
maxWidth: '40rem',
|
||||
scrollable: true,
|
||||
},
|
||||
)
|
||||
|
||||
// 搜索词
|
||||
const searchWord = ref<string | null>(null)
|
||||
|
||||
// ref
|
||||
const searchWordInput = ref<HTMLElement | null>(null)
|
||||
// 当前尺寸下可见的搜索输入框。
|
||||
const searchWordInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// 近期搜索词条
|
||||
const recentSearches = ref<string[]>([])
|
||||
|
||||
// 检测操作系统是否是Mac
|
||||
/** 检测操作系统是否为 macOS。 */
|
||||
function isMac() {
|
||||
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
}
|
||||
@@ -95,7 +119,7 @@ function isMac() {
|
||||
// 计算属性:根据操作系统显示不同的按键提示
|
||||
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
||||
|
||||
// 保存近期搜索到本地
|
||||
/** 将有效关键词保存到近期搜索记录。 */
|
||||
function saveRecentSearches(keyword: string) {
|
||||
if (!keyword) return
|
||||
if (recentSearches.value.includes(keyword)) return
|
||||
@@ -103,7 +127,7 @@ function saveRecentSearches(keyword: string) {
|
||||
localStorage.setItem('MP_RecentSearches', JSON.stringify(recentSearches.value))
|
||||
}
|
||||
|
||||
// 从本地加载近期搜索
|
||||
/** 从本地存储加载并裁剪近期搜索记录。 */
|
||||
function loadRecentSearches() {
|
||||
const recentSearchesStr = localStorage.getItem('MP_RecentSearches')
|
||||
if (recentSearchesStr) {
|
||||
@@ -115,7 +139,7 @@ function loadRecentSearches() {
|
||||
}
|
||||
}
|
||||
|
||||
// 所有菜单功能
|
||||
/** 获取可参与全局搜索的导航菜单和设置入口。 */
|
||||
function getMenus(): NavMenu[] {
|
||||
let menus: NavMenu[] = []
|
||||
// 导航菜单
|
||||
@@ -129,6 +153,7 @@ function getMenus(): NavMenu[] {
|
||||
header: item.header,
|
||||
admin: item.admin,
|
||||
permission: item.permission,
|
||||
feature: item.feature,
|
||||
}),
|
||||
)
|
||||
// 设置标签页
|
||||
@@ -170,7 +195,7 @@ const matchedMenuItems = computed(() => {
|
||||
// 所有插件(已安装)
|
||||
const pluginItems = ref<Plugin[]>([])
|
||||
|
||||
// 获取插件列表数据
|
||||
/** 加载已安装插件,供搜索结果匹配。 */
|
||||
async function fetchInstalledPlugins() {
|
||||
try {
|
||||
pluginItems.value = await api.get('plugin/', {
|
||||
@@ -194,7 +219,7 @@ const matchedPluginItems = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 获取订阅列表数据
|
||||
/** 加载订阅列表,供搜索结果匹配。 */
|
||||
async function fetchSubscribes() {
|
||||
try {
|
||||
SubscribeItems.value = await api.get('subscribe/')
|
||||
@@ -203,7 +228,7 @@ async function fetchSubscribes() {
|
||||
}
|
||||
}
|
||||
|
||||
// 从接口加载用户站点偏好设置
|
||||
/** 从接口加载用户的站点搜索偏好。 */
|
||||
const loadUserSitePreferences = async () => {
|
||||
try {
|
||||
const result = await api.get('system/setting/public/IndexerSites')
|
||||
@@ -216,7 +241,7 @@ const loadUserSitePreferences = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有站点
|
||||
/** 查询所有启用站点,并初始化默认选择。 */
|
||||
async function queryAllSites() {
|
||||
try {
|
||||
const data: Site[] = await api.get('site/')
|
||||
@@ -231,7 +256,7 @@ async function queryAllSites() {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开站点选择对话框
|
||||
/** 打开指定资源类型的站点选择对话框。 */
|
||||
const openSiteDialog = (type: 'torrent' | 'subtitle' = 'torrent') => {
|
||||
siteSearchType.value = type
|
||||
chooseSiteDialog.value = true
|
||||
@@ -247,7 +272,7 @@ const matchedSubscribeItems = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 搜索多站点
|
||||
/** 使用选中的站点执行当前资源类型搜索。 */
|
||||
function searchSites(sites: number[]) {
|
||||
chooseSiteDialog.value = false
|
||||
selectedSites.value = sites
|
||||
@@ -258,7 +283,7 @@ function searchSites(sites: number[]) {
|
||||
searchTorrent()
|
||||
}
|
||||
|
||||
// 搜索资源
|
||||
/** 使用当前关键词搜索站点资源。 */
|
||||
function searchTorrent() {
|
||||
if (!searchWord.value || !hasSearchPermission.value) return
|
||||
// 记录搜索词
|
||||
@@ -273,12 +298,10 @@ function searchTorrent() {
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
// 关闭搜索对话框
|
||||
dialog.value = false
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 搜索字幕资源
|
||||
/** 使用当前关键词搜索字幕资源。 */
|
||||
function searchSubtitle() {
|
||||
if (!searchWord.value || !hasSearchPermission.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
@@ -291,11 +314,10 @@ function searchSubtitle() {
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
dialog.value = false
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转媒体搜索页面
|
||||
/** 跳转到指定类型的媒体搜索结果页。 */
|
||||
function searchMedia(searchType: string) {
|
||||
// 搜索类型 media/person
|
||||
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
||||
@@ -307,10 +329,10 @@ function searchMedia(searchType: string) {
|
||||
type: searchType,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转到历史记录页面
|
||||
/** 跳转到包含当前关键词的历史记录页。 */
|
||||
function searchHistory() {
|
||||
if (!searchWord.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
@@ -320,10 +342,10 @@ function searchHistory() {
|
||||
search: searchWord.value,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转到订阅分享页面
|
||||
/** 跳转到包含当前关键词的订阅分享页。 */
|
||||
function searchSubscribeShares() {
|
||||
if (!searchWord.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
@@ -333,10 +355,10 @@ function searchSubscribeShares() {
|
||||
keyword: searchWord.value,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转插件页面
|
||||
/** 打开匹配插件的已安装详情。 */
|
||||
function showPlugin(pluginId: string) {
|
||||
router.push({
|
||||
path: `/plugins/`,
|
||||
@@ -345,16 +367,16 @@ function showPlugin(pluginId: string) {
|
||||
id: pluginId,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转菜单页面
|
||||
/** 跳转到匹配的功能菜单。 */
|
||||
function goPage(to: string) {
|
||||
router.push(to)
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转订阅页面
|
||||
/** 根据订阅类型跳转到对应订阅详情。 */
|
||||
function goSubscribe(subscribe: Subscribe) {
|
||||
if (subscribe.type === '电影') {
|
||||
router.push({
|
||||
@@ -371,13 +393,29 @@ function goSubscribe(subscribe: Subscribe) {
|
||||
},
|
||||
})
|
||||
}
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
/** 关闭搜索浮层并通知外层入口同步状态。 */
|
||||
function closeSearch() {
|
||||
dialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
/** 聚焦当前可见输入框,供快捷键入口复用。 */
|
||||
function focusSearchInput() {
|
||||
searchWordInput.value?.focus()
|
||||
}
|
||||
|
||||
watch(dialog, async isOpen => {
|
||||
if (!isOpen) return
|
||||
await nextTick()
|
||||
focusSearchInput()
|
||||
})
|
||||
|
||||
defineExpose({ focusSearchInput })
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
searchWordInput.value?.focus()
|
||||
}, 500)
|
||||
// 根据权限加载不同的数据
|
||||
if (hasAdminPermission.value) {
|
||||
fetchInstalledPlugins()
|
||||
@@ -395,10 +433,20 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog v-model="dialog" max-width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="search-dialog">
|
||||
<!-- 搜索输入框区域 -->
|
||||
<div class="search-header">
|
||||
<component :is="searchOverlay" v-model="dialog" v-bind="searchOverlayProps">
|
||||
<template v-if="showActivator" #activator="{ props: activatorProps }">
|
||||
<!-- 小屏入口保持图标形态,点击后打开全屏搜索弹窗。 -->
|
||||
<IconBtn
|
||||
v-if="!display.mdAndUp.value"
|
||||
v-bind="activatorProps"
|
||||
class="search-icon-trigger"
|
||||
:aria-label="t('dialog.searchBar.openSearch')"
|
||||
>
|
||||
<VIcon class="search-icon-trigger__icon" icon="mdi-magnify" />
|
||||
</IconBtn>
|
||||
|
||||
<!-- 中屏及以上常驻搜索输入框,输入时直接在下方展示同一组选项。 -->
|
||||
<div v-else v-bind="activatorProps" class="search-desktop-activator">
|
||||
<div class="search-input-wrapper">
|
||||
<VIcon icon="mdi-magnify" size="22" class="search-input-icon" />
|
||||
<input
|
||||
@@ -408,7 +456,26 @@ onMounted(() => {
|
||||
class="search-native-input"
|
||||
:placeholder="t('dialog.searchBar.searchPlaceholder')"
|
||||
@keydown.enter="searchMedia('media')"
|
||||
@keydown.escape="emit('close')"
|
||||
@keydown.escape.stop="closeSearch"
|
||||
/>
|
||||
<kbd class="search-shortcut-badge">{{ metaKey }}</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VCard class="search-dialog" :class="{ 'search-dialog--dropdown': display.mdAndUp.value }">
|
||||
<!-- 弹窗模式保留原有搜索输入区。 -->
|
||||
<div v-if="!display.mdAndUp.value" class="search-header">
|
||||
<div class="search-input-wrapper">
|
||||
<VIcon icon="mdi-text" size="22" class="search-input-icon" />
|
||||
<input
|
||||
ref="searchWordInput"
|
||||
v-model="searchWord"
|
||||
type="text"
|
||||
class="search-native-input"
|
||||
:placeholder="t('dialog.searchBar.searchPlaceholder')"
|
||||
@keydown.enter="searchMedia('media')"
|
||||
@keydown.escape.stop="closeSearch"
|
||||
/>
|
||||
<VBtn icon size="small" variant="text" class="search-submit-btn" @click="searchMedia('media')">
|
||||
<VIcon icon="mdi-magnify" size="20" />
|
||||
@@ -697,26 +764,14 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部区域 -->
|
||||
<!-- 桌面端:快捷键提示 -->
|
||||
<div v-if="display.mdAndUp.value" class="search-footer">
|
||||
<div class="shortcut-group">
|
||||
<kbd>Esc</kbd>
|
||||
<span class="shortcut-label">{{ t('dialog.searchBar.escClose') }}</span>
|
||||
</div>
|
||||
<div class="shortcut-group">
|
||||
<kbd>{{ metaKey }}</kbd>
|
||||
<span class="shortcut-label">{{ t('dialog.searchBar.openSearch') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 移动端:关闭图标 -->
|
||||
<div v-else class="search-footer-mobile">
|
||||
<VBtn icon variant="tonal" @click="emit('close')">
|
||||
<!-- 弹窗形态保留底部关闭按钮,桌面下拉不显示页脚。 -->
|
||||
<div v-if="!display.mdAndUp.value" class="search-footer-mobile">
|
||||
<VBtn icon variant="tonal" @click="closeSearch">
|
||||
<VIcon icon="mdi-close" size="20" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</component>
|
||||
|
||||
<!-- 站点选择对话框 -->
|
||||
<SearchSiteDialog
|
||||
@@ -731,12 +786,46 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
|
||||
.search-dialog {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-dialog--dropdown {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
inline-size: min(32rem, calc(100vw - 2rem));
|
||||
max-block-size: min(72vh, 42rem);
|
||||
}
|
||||
|
||||
.search-desktop-activator {
|
||||
flex: 0 1 22rem;
|
||||
inline-size: clamp(15rem, 32vw, 22rem);
|
||||
max-inline-size: calc(100vw - 10rem);
|
||||
}
|
||||
|
||||
.search-desktop-activator .search-input-wrapper {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.12);
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
block-size: 42px;
|
||||
padding-inline: 14px 8px;
|
||||
}
|
||||
|
||||
html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
.v-theme--transparent .search-desktop-activator .search-input-wrapper {
|
||||
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
|
||||
}
|
||||
|
||||
.search-icon-trigger {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.search-icon-trigger__icon {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* 搜索头部区域 */
|
||||
.search-header {
|
||||
padding-block: 16px 12px;
|
||||
@@ -748,18 +837,18 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1.5px solid rgba(var(--v-theme-primary), 0.4);
|
||||
border-radius: 28px;
|
||||
border-radius: var(--app-vuetify-rounded-pill);
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.04);
|
||||
block-size: 48px;
|
||||
padding-inline: 14px 6px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
border-radius 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input-wrapper:focus-within {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
box-shadow: 0 0 0 3px rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
|
||||
.search-input-icon {
|
||||
@@ -797,13 +886,24 @@ onMounted(() => {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.12) !important;
|
||||
}
|
||||
|
||||
.search-shortcut-badge {
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.search-content {
|
||||
max-block-size: 600px;
|
||||
min-block-size: 150px;
|
||||
overscroll-behavior-y: contain;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-dialog--dropdown .search-content {
|
||||
max-block-size: min(58vh, 34rem);
|
||||
}
|
||||
|
||||
.search-list {
|
||||
background: transparent !important;
|
||||
}
|
||||
@@ -848,28 +948,13 @@ onMounted(() => {
|
||||
|
||||
.recent-searches-section {
|
||||
inline-size: 100%;
|
||||
padding-block-start: 1rem;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 底部快捷键提示 */
|
||||
.search-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
gap: 16px;
|
||||
padding-block: 10px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.shortcut-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.15);
|
||||
border-radius: 5px;
|
||||
@@ -883,11 +968,6 @@ kbd {
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.shortcut-label {
|
||||
color: rgba(var(--v-theme-on-surface), 0.45);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 移动端底部关闭图标 */
|
||||
.search-footer-mobile {
|
||||
display: flex;
|
||||
@@ -912,10 +992,5 @@ kbd {
|
||||
.search-native-input {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-footer {
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { Site } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
const props = defineProps({
|
||||
sites: {
|
||||
type: Array as PropType<Site[]>,
|
||||
@@ -84,7 +88,7 @@ const filteredSites = computed(() => {
|
||||
</script>
|
||||
<template>
|
||||
<!-- Site Selection Dialog -->
|
||||
<VDialog max-width="40rem" fullscreen-mobile>
|
||||
<VDialog max-width="40rem" :fullscreen="!display.smAndUp.value">
|
||||
<VCard class="site-dialog">
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
|
||||
@@ -42,8 +42,14 @@ function allLoggingDownloadUrl() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialog
|
||||
v-if="visible"
|
||||
v-model="visible"
|
||||
:scrollable="display.mdAndUp.value"
|
||||
max-width="80rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard class="logging-dialog-card">
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-inline-flex">
|
||||
@@ -66,9 +72,29 @@ function allLoggingDownloadUrl() {
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<VCardText class="logging-dialog-content pa-0">
|
||||
<LoggingView logfile="moviepilot.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>
|
||||
|
||||
@@ -104,6 +104,21 @@ const bodyClasses = computed(() => [
|
||||
}
|
||||
|
||||
@media (max-width: 959.98px) {
|
||||
.words-shortcut-dialog-card {
|
||||
display: flex;
|
||||
overflow: hidden !important;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.words-shortcut-dialog-body {
|
||||
display: flex;
|
||||
overflow: hidden !important;
|
||||
flex: 1 1 auto;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.scheduler-shortcut-dialog-card--transparent {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
@@ -114,13 +129,8 @@ const bodyClasses = computed(() => [
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
html[data-theme='transparent'] .cache-shortcut-dialog-card,
|
||||
.v-theme--transparent .cache-shortcut-dialog-card {
|
||||
backdrop-filter: blur(var(--transparent-blur, 10px));
|
||||
background: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
background: var(--app-grouped-list-background);
|
||||
}
|
||||
|
||||
.cache-shortcut-dialog-body {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -13,6 +13,9 @@ const { t, locale } = useI18n()
|
||||
// 响应式断点
|
||||
const display = useDisplay()
|
||||
|
||||
// 当前主题
|
||||
const theme = useTheme()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
@@ -92,9 +95,13 @@ const resultSummaryText = computed(() => {
|
||||
// 是否小屏幕
|
||||
const isMobileLayout = computed(() => display.smAndDown.value)
|
||||
|
||||
// 是否透明主题
|
||||
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
|
||||
|
||||
// 移动端分页数据
|
||||
const mobileResourceList = computed(() => resourceDataList.value)
|
||||
|
||||
// 获取资源项唯一标识
|
||||
function getResourceItemKey(item: TorrentInfo, index: number) {
|
||||
return item.page_url || item.enclosure || `${item.title}-${item.pubdate || ''}-${index}`
|
||||
}
|
||||
@@ -188,10 +195,12 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 切换移动端搜索栏
|
||||
function toggleMobileSearch() {
|
||||
mobileSearchExpanded.value = !mobileSearchExpanded.value
|
||||
}
|
||||
|
||||
// 关闭移动端搜索栏
|
||||
function closeMobileSearch() {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
@@ -480,7 +489,11 @@ onMounted(() => {
|
||||
:get-item-key="getResourceItemKey"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<VCard class="site-resource-card" variant="flat">
|
||||
<VCard
|
||||
class="site-resource-card"
|
||||
:class="{ 'site-resource-card--transparent': isTransparentTheme }"
|
||||
variant="flat"
|
||||
>
|
||||
<VCardText class="pa-3">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||
<div class="site-resource-card__title text-body-1 font-weight-medium text-high-emphasis">
|
||||
@@ -746,7 +759,7 @@ onMounted(() => {
|
||||
background: var(--site-resource-card-bg);
|
||||
}
|
||||
|
||||
:global(html[data-theme="transparent"]) .site-resource-card {
|
||||
.site-resource-card--transparent {
|
||||
--site-resource-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity));
|
||||
|
||||
backdrop-filter: blur(var(--transparent-blur));
|
||||
|
||||
@@ -64,8 +64,10 @@ const historyChartOptions = computed(() => {
|
||||
enabled: true,
|
||||
},
|
||||
zoom: {
|
||||
autoScaleYaxis: true,
|
||||
enabled: false,
|
||||
allowMouseWheelZoom: false,
|
||||
},
|
||||
selection: { enabled: false },
|
||||
},
|
||||
theme: {
|
||||
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||
@@ -156,8 +158,10 @@ const seedingChartOptions = computed(() => {
|
||||
background: currentTheme.value.surface, // 新增背景色同步
|
||||
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
|
||||
zoom: {
|
||||
autoScaleYaxis: true,
|
||||
enabled: false,
|
||||
allowMouseWheelZoom: false,
|
||||
},
|
||||
selection: { enabled: false },
|
||||
},
|
||||
theme: {
|
||||
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||
|
||||
@@ -10,6 +10,17 @@ import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constant
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
|
||||
// 从变更请求异常中提取可展示消息,并为非标准错误提供稳定兜底。
|
||||
function getRequestErrorMessage(error: unknown, fallback: string) {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const responseMessage = (error as { response?: { data?: { message?: unknown } } }).response?.data?.message
|
||||
if (typeof responseMessage === 'string' && responseMessage) return responseMessage
|
||||
}
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
return fallback
|
||||
}
|
||||
|
||||
// i18n
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
@@ -104,6 +115,12 @@ function getSubscribeDisplayName() {
|
||||
return `${name} ${formatSeason(season.toString())}`
|
||||
}
|
||||
|
||||
function getDefaultSubscribeTypeName() {
|
||||
if (props.type === '电影') return t('mediaType.movie')
|
||||
if (props.type === '电视剧') return t('mediaType.tv')
|
||||
return props.type ?? ''
|
||||
}
|
||||
|
||||
// 剧集组选项属性
|
||||
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||
return {
|
||||
@@ -162,18 +179,30 @@ const filterRuleGroupOptions = computed(() => {
|
||||
|
||||
// 调用API修改订阅
|
||||
async function updateSubscribeInfo() {
|
||||
const displayName = getSubscribeDisplayName()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value)
|
||||
// 提示
|
||||
if (result.success) {
|
||||
$toast.success(`${getSubscribeDisplayName()} 更新成功!`)
|
||||
$toast.success(t('dialog.subscribeEdit.updateSuccess', { name: displayName }))
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
emit('save', subscribeForm.value)
|
||||
} else {
|
||||
$toast.error(`${getSubscribeDisplayName()} 更新失败:${result.message}!`)
|
||||
$toast.error(
|
||||
t('dialog.subscribeEdit.updateFailed', {
|
||||
name: displayName,
|
||||
message: result.message ?? t('subscribe.requestFailed'),
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
$toast.error(
|
||||
t('dialog.subscribeEdit.updateFailed', {
|
||||
name: displayName,
|
||||
message: getRequestErrorMessage(e, t('subscribe.requestFailed')),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,19 +210,33 @@ async function updateSubscribeInfo() {
|
||||
async function saveDefaultSubscribeConfig() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
const typeName = getDefaultSubscribeTypeName()
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)
|
||||
if (result.success) $toast.success(`${props.type}订阅默认规则保存成功`)
|
||||
else $toast.error(`${props.type}订阅默认规则保存失败!`)
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
if (result.success) {
|
||||
$toast.success(t('dialog.subscribeEdit.defaultSaveSuccess', { type: typeName }))
|
||||
// 通知父组件刷新
|
||||
emit('save', subscribeForm.value)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('dialog.subscribeEdit.defaultSaveFailed', {
|
||||
type: typeName,
|
||||
message: result.message ?? t('subscribe.requestFailed'),
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(
|
||||
t('dialog.subscribeEdit.defaultSaveFailed', {
|
||||
type: typeName,
|
||||
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,16 +305,28 @@ async function removeSubscribe() {
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
const displayName = getSubscribeDisplayName()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(`订阅 ${getSubscribeDisplayName()} 已取消!`)
|
||||
$toast.success(`${displayName} ${t('subscribe.cancelSuccess')}`)
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
} else {
|
||||
$toast.error(
|
||||
`${displayName} ${t('subscribe.cancelFailed', {
|
||||
message: result.message ?? t('subscribe.requestFailed'),
|
||||
})}`,
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
$toast.error(
|
||||
`${displayName} ${t('subscribe.cancelFailed', {
|
||||
message: getRequestErrorMessage(e, t('subscribe.requestFailed')),
|
||||
})}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,8 +344,10 @@ async function loadDownloadDirectories() {
|
||||
|
||||
// 保存目录下拉框
|
||||
const targetDirectories = computed(() => {
|
||||
// 去重后的下载目录
|
||||
return downloadDirectories.value.map(item => item.download_path)
|
||||
const paths = downloadDirectories.value
|
||||
.map(item => item.download_path?.trim())
|
||||
.filter((path): path is string => Boolean(path))
|
||||
return [...new Set(paths)]
|
||||
})
|
||||
|
||||
// 仅电视剧订阅支持全集洗版,电影保持原有洗版逻辑
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ const emit = defineEmits(['subscribe', 'close'])
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaInfo>,
|
||||
selectedSeason: Number,
|
||||
initialEpisodeGroup: String,
|
||||
subscribedSeasons: Array as PropType<number[]>,
|
||||
subscribedSeasonModes: Object as PropType<SeasonSubscribeModes>,
|
||||
defaultSubscribeMode: String as PropType<SubscribeMode>,
|
||||
@@ -64,7 +65,14 @@ const isRefreshed = ref(false)
|
||||
const episodeGroups = ref<{ [key: string]: any }[]>([])
|
||||
|
||||
// 当前选择剧集组
|
||||
const episodeGroup = ref('')
|
||||
const episodeGroup = ref(props.initialEpisodeGroup ?? '')
|
||||
|
||||
// 剧集组横向轨道
|
||||
const episodeGroupRail = ref<HTMLElement | null>(null)
|
||||
|
||||
// 剧集组轨道左右滚动状态
|
||||
const canScrollEpisodeGroupsBackward = ref(false)
|
||||
const canScrollEpisodeGroupsForward = ref(false)
|
||||
|
||||
const subscribeModeOptions = computed<SubscribeModeOption[]>(() => [
|
||||
{
|
||||
@@ -84,12 +92,14 @@ const subscribeModeOptions = computed<SubscribeModeOption[]>(() => [
|
||||
},
|
||||
])
|
||||
|
||||
// 获取订阅模式的主题色。
|
||||
function getSubscribeModeColor(mode: SubscribeMode) {
|
||||
if (mode === 'normal') return 'primary'
|
||||
if (mode === 'best_version') return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// 校验弹窗输入是否为支持的订阅模式。
|
||||
function isSubscribeMode(value: unknown): value is SubscribeMode {
|
||||
return value === 'normal' || value === 'best_version' || value === 'best_version_full'
|
||||
}
|
||||
@@ -170,6 +180,8 @@ async function getEpisodeGroups() {
|
||||
episodeGroups.value = await api.get(`media/groups/${props.media?.tmdb_id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
nextTick(updateEpisodeGroupScrollState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,14 +277,42 @@ function getYear(airDate: string) {
|
||||
return date.getFullYear()
|
||||
}
|
||||
|
||||
// 切换当前剧集组并清空上一组的派生数据。
|
||||
function setEpisodeGroup(value: string) {
|
||||
if (episodeGroup.value === value) return
|
||||
|
||||
seasonsNotExisted.value = {}
|
||||
seasonInfos.value = []
|
||||
episodeGroup.value = value
|
||||
nextTick(updateEpisodeGroupScrollState)
|
||||
}
|
||||
|
||||
// 刷新剧集组横向轨道的左右滚动按钮状态。
|
||||
function updateEpisodeGroupScrollState() {
|
||||
const rail = episodeGroupRail.value
|
||||
if (!rail) {
|
||||
canScrollEpisodeGroupsBackward.value = false
|
||||
canScrollEpisodeGroupsForward.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const maxScrollLeft = Math.max(rail.scrollWidth - rail.clientWidth, 0)
|
||||
canScrollEpisodeGroupsBackward.value = rail.scrollLeft > 4
|
||||
canScrollEpisodeGroupsForward.value = rail.scrollLeft < maxScrollLeft - 4
|
||||
}
|
||||
|
||||
// 按一屏内可辨识的距离横向滚动剧集组轨道。
|
||||
function scrollEpisodeGroups(direction: 'backward' | 'forward') {
|
||||
const rail = episodeGroupRail.value
|
||||
if (!rail) return
|
||||
|
||||
rail.scrollBy({
|
||||
behavior: 'smooth',
|
||||
left: direction === 'backward' ? -Math.max(rail.clientWidth * 0.72, 240) : Math.max(rail.clientWidth * 0.72, 240),
|
||||
})
|
||||
}
|
||||
|
||||
// 提交当前剧集组下选中的季及其订阅模式。
|
||||
function subscribeSeasons() {
|
||||
const selectedSeasons = seasonInfos.value.filter(item => {
|
||||
const seasonNumber = item.season_number ?? null
|
||||
@@ -289,6 +329,7 @@ function subscribeSeasons() {
|
||||
)
|
||||
}
|
||||
|
||||
// 写入指定季的订阅模式。
|
||||
function setSeasonMode(season: number, mode: SubscribeMode) {
|
||||
seasonModes.value = {
|
||||
...seasonModes.value,
|
||||
@@ -296,22 +337,26 @@ function setSeasonMode(season: number, mode: SubscribeMode) {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理用户手动切换指定季的订阅模式。
|
||||
function updateSeasonMode(season: number, mode: unknown) {
|
||||
if (!isSubscribeMode(mode)) return
|
||||
manuallySelectedModeSeasons.value.add(season)
|
||||
setSeasonMode(season, mode)
|
||||
}
|
||||
|
||||
// 根据入库状态和系统配置计算指定季的默认订阅模式。
|
||||
function getDefaultSeasonMode(season: number) {
|
||||
if (!seasonsNotExisted.value[season]) return 'best_version_full'
|
||||
|
||||
return props.defaultSubscribeMode ?? 'normal'
|
||||
}
|
||||
|
||||
// 确保指定季已初始化订阅模式。
|
||||
function ensureSeasonMode(season: number) {
|
||||
if (!seasonModes.value[season]) setSeasonMode(season, props.subscribedSeasonModes?.[season] ?? getDefaultSeasonMode(season))
|
||||
}
|
||||
|
||||
// 在入库状态刷新后同步尚未手动修改的默认模式。
|
||||
function syncDefaultSeasonModes() {
|
||||
seasonsSelected.value.forEach(season => {
|
||||
if (subscribedSeasonSet.value.has(season) || manuallySelectedModeSeasons.value.has(season)) return
|
||||
@@ -319,14 +364,17 @@ function syncDefaultSeasonModes() {
|
||||
})
|
||||
}
|
||||
|
||||
// 判断指定季是否已有订阅。
|
||||
function isSeasonSubscribed(season: number) {
|
||||
return subscribedSeasonSet.value.has(season)
|
||||
}
|
||||
|
||||
// 判断指定季是否在本次提交选择中。
|
||||
function isSeasonSelected(season: number) {
|
||||
return selectedSeasonSet.value.has(season)
|
||||
}
|
||||
|
||||
// 设置指定季的选择状态并同步其订阅模式。
|
||||
function setSeasonSelected(season: number, selected: boolean | null) {
|
||||
const nextSeasons = new Set(seasonsSelected.value)
|
||||
if (selected) {
|
||||
@@ -338,10 +386,12 @@ function setSeasonSelected(season: number, selected: boolean | null) {
|
||||
seasonsSelected.value = [...nextSeasons].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
// 切换指定季的选择状态。
|
||||
function toggleSeasonSelected(season: number) {
|
||||
setSeasonSelected(season, !isSeasonSelected(season))
|
||||
}
|
||||
|
||||
// 将入口预选季和已有订阅同步到当前剧集组的可见季列表。
|
||||
function syncSelectedSeason() {
|
||||
if (!seasonInfos.value.length) return
|
||||
|
||||
@@ -380,11 +430,19 @@ watch(() => props.subscribedSeasons, syncSelectedSeason)
|
||||
|
||||
watch(() => props.subscribedSeasonModes, syncSelectedSeason)
|
||||
|
||||
watch(episodeGroupOptions, () => nextTick(updateEpisodeGroupScrollState), { flush: 'post' })
|
||||
|
||||
onMounted(async () => {
|
||||
getMediaSeasons()
|
||||
window.addEventListener('resize', updateEpisodeGroupScrollState)
|
||||
// 自定义剧集组由 watchEffect 首次加载,避免默认季数据异步覆盖它。
|
||||
if (!episodeGroup.value) getMediaSeasons()
|
||||
getEpisodeGroups()
|
||||
checkSeasonsNotExists()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateEpisodeGroupScrollState)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -408,20 +466,44 @@ onMounted(async () => {
|
||||
<div class="subscribe-season-group-label">
|
||||
{{ t('dialog.subscribeSeason.selectGroup') }}
|
||||
</div>
|
||||
<div class="subscribe-season-group-options">
|
||||
<div class="subscribe-season-group-rail-shell">
|
||||
<button
|
||||
v-for="group in episodeGroupOptions"
|
||||
:key="group.value || 'default'"
|
||||
v-if="mdAndUp && canScrollEpisodeGroupsBackward"
|
||||
type="button"
|
||||
class="subscribe-season-group-option"
|
||||
:class="{ 'subscribe-season-group-option--active': episodeGroup === group.value }"
|
||||
@click="setEpisodeGroup(group.value)"
|
||||
class="subscribe-season-group-nav subscribe-season-group-nav--backward"
|
||||
:aria-label="t('media.episodeGroups.previous')"
|
||||
@click="scrollEpisodeGroups('backward')"
|
||||
>
|
||||
<VIcon :icon="group.icon" size="small" />
|
||||
<span class="subscribe-season-group-text">
|
||||
<span class="subscribe-season-group-title">{{ group.title }}</span>
|
||||
<span class="subscribe-season-group-subtitle">{{ group.subtitle }}</span>
|
||||
</span>
|
||||
<VIcon icon="mdi-chevron-left" />
|
||||
</button>
|
||||
<div
|
||||
ref="episodeGroupRail"
|
||||
class="subscribe-season-group-options"
|
||||
@scroll.passive="updateEpisodeGroupScrollState"
|
||||
>
|
||||
<button
|
||||
v-for="group in episodeGroupOptions"
|
||||
:key="group.value || 'default'"
|
||||
type="button"
|
||||
class="subscribe-season-group-option"
|
||||
:class="{ 'subscribe-season-group-option--active': episodeGroup === group.value }"
|
||||
@click="setEpisodeGroup(group.value)"
|
||||
>
|
||||
<VIcon :icon="group.icon" size="small" />
|
||||
<span class="subscribe-season-group-text">
|
||||
<span class="subscribe-season-group-title">{{ group.title }}</span>
|
||||
<span class="subscribe-season-group-subtitle">{{ group.subtitle }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="mdAndUp && canScrollEpisodeGroupsForward"
|
||||
type="button"
|
||||
class="subscribe-season-group-nav subscribe-season-group-nav--forward"
|
||||
:aria-label="t('media.episodeGroups.next')"
|
||||
@click="scrollEpisodeGroups('forward')"
|
||||
>
|
||||
<VIcon icon="mdi-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -561,27 +643,39 @@ onMounted(async () => {
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-season-group-rail-shell {
|
||||
position: relative;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.subscribe-season-group-options {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-block: 0.125rem 0.375rem;
|
||||
scrollbar-width: thin;
|
||||
scroll-behavior: smooth;
|
||||
scroll-snap-type: inline proximity;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.subscribe-season-group-options::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subscribe-season-group-option {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
flex: 0 0 12rem;
|
||||
align-items: center;
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter, blur(var(--transparent-blur-light, 6px)));
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
background: rgba(var(--v-theme-surface), 0.82);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
gap: 0.5rem;
|
||||
max-inline-size: 16rem;
|
||||
min-inline-size: 11rem;
|
||||
min-inline-size: 0;
|
||||
padding-block: 0.5rem;
|
||||
padding-inline: 0.75rem;
|
||||
scroll-snap-align: start;
|
||||
text-align: start;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
@@ -591,15 +685,26 @@ onMounted(async () => {
|
||||
|
||||
.subscribe-season-group-option:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.45);
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
background: rgba(var(--v-theme-surface), 0.9);
|
||||
box-shadow: 0 6px 18px rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.subscribe-season-group-option--active {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background: rgba(var(--v-theme-primary), 0.14);
|
||||
background: rgba(var(--v-theme-primary), 0.18);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.subscribe-season-group-option--active:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.24);
|
||||
}
|
||||
|
||||
.subscribe-season-group-option:focus-visible,
|
||||
.subscribe-season-group-nav:focus-visible {
|
||||
outline: 2px solid rgba(var(--v-theme-primary), 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.subscribe-season-group-text {
|
||||
display: grid;
|
||||
min-inline-size: 0;
|
||||
@@ -628,6 +733,42 @@ onMounted(async () => {
|
||||
color: rgba(var(--v-theme-primary), 0.82);
|
||||
}
|
||||
|
||||
.subscribe-season-group-nav {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter, blur(var(--transparent-blur-light, 6px)));
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 9999px;
|
||||
background: rgba(var(--v-theme-surface), 0.96);
|
||||
block-size: 2.5rem;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
inline-size: 2.5rem;
|
||||
inset-block-start: 50%;
|
||||
transform: translateY(-55%);
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
}
|
||||
|
||||
.subscribe-season-group-nav:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.45);
|
||||
background: rgba(var(--v-theme-surface), 0.96);
|
||||
box-shadow: 0 6px 18px rgba(var(--v-theme-on-surface), 0.12);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.subscribe-season-group-nav--backward {
|
||||
inset-inline-start: -0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-season-group-nav--forward {
|
||||
inset-inline-end: -0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-season-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
@@ -690,8 +831,13 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.subscribe-season-group-option {
|
||||
max-inline-size: 12rem;
|
||||
min-inline-size: 9.5rem;
|
||||
flex-basis: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.subscribe-season-group-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useTransparencySettings } from '@/composables/useTransparencySettings'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -38,15 +42,17 @@ const {
|
||||
onBackgroundBlurChange,
|
||||
onBackgroundPosterOpacityChange,
|
||||
onBlurChange,
|
||||
onGlassQualityChange,
|
||||
onOpacityChange,
|
||||
resetTransparencySettings,
|
||||
transparencyBlur,
|
||||
transparencyGlassQuality,
|
||||
transparencyOpacity,
|
||||
} = useTransparencySettings()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="30rem">
|
||||
<VDialog v-if="visible" v-model="visible" max-width="30rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
@@ -118,6 +124,34 @@ const {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-body-2 d-block mb-2">{{ t('theme.transparencyGlassQuality') }}</span>
|
||||
<VBtnToggle
|
||||
v-model="transparencyGlassQuality"
|
||||
mandatory
|
||||
divided
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
class="w-full"
|
||||
@update:model-value="onGlassQualityChange"
|
||||
>
|
||||
<VBtn value="lightweight" class="flex-1">
|
||||
{{ t('theme.transparencyGlassQualityLightweight') }}
|
||||
</VBtn>
|
||||
<VBtn value="realtime" class="flex-1">
|
||||
{{ t('theme.transparencyGlassQualityRealtime') }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<p class="text-caption text-medium-emphasis mt-2 mb-0">
|
||||
{{
|
||||
transparencyGlassQuality === 'realtime'
|
||||
? t('theme.transparencyGlassQualityRealtimeHint')
|
||||
: t('theme.transparencyGlassQualityLightweightHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-body-2 d-block mb-2">{{ t('common.preset') }}</span>
|
||||
<VBtnGroup density="compact" variant="outlined" class="w-full">
|
||||
|
||||
@@ -7,6 +7,14 @@ import { useDisplay } from 'vuetify'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
USER_PERMISSION_FEATURES,
|
||||
buildDefaultFeaturePermissions,
|
||||
normalizeUserPermissions,
|
||||
type UserPermissionCategoryKey,
|
||||
type UserPermissionFeatureKey,
|
||||
type UserPermissions,
|
||||
} from '@/utils/permission'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
@@ -65,14 +73,6 @@ interface ExtendedUser extends User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 权限类型定义
|
||||
interface UserPermissions {
|
||||
discovery: boolean // 发现权限
|
||||
search: boolean // 搜索权限
|
||||
subscribe: boolean // 订阅权限
|
||||
manage: boolean // 管理权限
|
||||
}
|
||||
|
||||
// 用户编辑表单数据
|
||||
const userForm = ref<ExtendedUser>({
|
||||
id: 0,
|
||||
@@ -88,6 +88,7 @@ const userForm = ref<ExtendedUser>({
|
||||
search: true,
|
||||
subscribe: true,
|
||||
manage: false,
|
||||
features: buildDefaultFeaturePermissions(),
|
||||
},
|
||||
settings: {
|
||||
wechat_userid: null,
|
||||
@@ -127,34 +128,115 @@ const permissionOptions = [
|
||||
description: t('dialog.userAddEdit.permissions.manageDesc'),
|
||||
icon: 'mdi-cog-outline',
|
||||
},
|
||||
]
|
||||
] as const
|
||||
|
||||
const activePermissionCategory = ref<UserPermissionCategoryKey>('discovery')
|
||||
|
||||
// 权限状态计算属性
|
||||
const userPermissions = computed({
|
||||
get: () => {
|
||||
const permissions = userForm.value.permissions as UserPermissions
|
||||
return {
|
||||
discovery: permissions?.discovery ?? true,
|
||||
search: permissions?.search ?? true,
|
||||
subscribe: permissions?.subscribe ?? true,
|
||||
manage: permissions?.manage ?? false,
|
||||
}
|
||||
return normalizeUserPermissions(userForm.value.permissions as Partial<UserPermissions>)
|
||||
},
|
||||
set: (value: UserPermissions) => {
|
||||
userForm.value.permissions = value
|
||||
},
|
||||
})
|
||||
|
||||
// 切换权限状态
|
||||
function togglePermission(key: keyof UserPermissions) {
|
||||
const permissionFeatureOptions = computed(() =>
|
||||
USER_PERMISSION_FEATURES.map(feature => ({
|
||||
...feature,
|
||||
title: t(feature.titleKey),
|
||||
description: t(feature.descriptionKey),
|
||||
})),
|
||||
)
|
||||
|
||||
const activePermissionOption = computed(() =>
|
||||
permissionOptions.find(option => option.key === activePermissionCategory.value) ?? permissionOptions[0],
|
||||
)
|
||||
|
||||
const activePermissionFeatures = computed(() =>
|
||||
permissionFeatureOptions.value.filter(feature => feature.permission === activePermissionCategory.value),
|
||||
)
|
||||
|
||||
const enabledCategoryCount = computed(
|
||||
() => permissionOptions.filter(option => userPermissions.value[option.key]).length,
|
||||
)
|
||||
|
||||
const enabledFeatureCount = computed(
|
||||
() =>
|
||||
permissionFeatureOptions.value.filter(
|
||||
feature => userPermissions.value[feature.permission] && isFeatureEnabled(feature.key),
|
||||
).length,
|
||||
)
|
||||
|
||||
/** 切换当前查看的权限分类。 */
|
||||
function selectPermissionCategory(key: UserPermissionCategoryKey) {
|
||||
activePermissionCategory.value = key
|
||||
}
|
||||
|
||||
/** 切换权限分类启用状态,并聚焦到该分类的功能列表。 */
|
||||
function togglePermission(key: UserPermissionCategoryKey) {
|
||||
const currentPermissions = userPermissions.value
|
||||
userPermissions.value = {
|
||||
...currentPermissions,
|
||||
[key]: !currentPermissions[key],
|
||||
}
|
||||
activePermissionCategory.value = key
|
||||
}
|
||||
|
||||
// 更新头像
|
||||
/** 判断功能项是否已启用,缺省功能按启用处理以兼容旧权限数据。 */
|
||||
function isFeatureEnabled(key: UserPermissionFeatureKey) {
|
||||
return userPermissions.value.features?.[key] !== false
|
||||
}
|
||||
|
||||
/** 切换单个功能项的启用状态。 */
|
||||
function togglePermissionFeature(key: UserPermissionFeatureKey) {
|
||||
userPermissions.value = {
|
||||
...userPermissions.value,
|
||||
features: {
|
||||
...(userPermissions.value.features ?? {}),
|
||||
[key]: !isFeatureEnabled(key),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 统计指定分类下已经勾选的功能数量。 */
|
||||
function getEnabledFeatureCount(permission: UserPermissionCategoryKey) {
|
||||
return permissionFeatureOptions.value.filter(
|
||||
feature => feature.permission === permission && isFeatureEnabled(feature.key),
|
||||
).length
|
||||
}
|
||||
|
||||
/** 返回指定分类下的功能总数。 */
|
||||
function getFeatureTotalCount(permission: UserPermissionCategoryKey) {
|
||||
return permissionFeatureOptions.value.filter(feature => feature.permission === permission).length
|
||||
}
|
||||
|
||||
/** 判断指定分类是否处于功能部分勾选状态。 */
|
||||
function isPermissionPartiallySelected(permission: UserPermissionCategoryKey) {
|
||||
const enabledCount = getEnabledFeatureCount(permission)
|
||||
const totalCount = getFeatureTotalCount(permission)
|
||||
return enabledCount > 0 && enabledCount < totalCount
|
||||
}
|
||||
|
||||
/** 批量设置当前分类下的所有功能项。 */
|
||||
function setCategoryFeatures(permission: UserPermissionCategoryKey, enabled: boolean) {
|
||||
const nextFeatures = { ...(userPermissions.value.features ?? {}) }
|
||||
for (const feature of permissionFeatureOptions.value.filter(item => item.permission === permission)) {
|
||||
nextFeatures[feature.key] = enabled
|
||||
}
|
||||
userPermissions.value = {
|
||||
...userPermissions.value,
|
||||
features: nextFeatures,
|
||||
}
|
||||
}
|
||||
|
||||
/** 将当前分类下的功能项恢复为默认全选状态。 */
|
||||
function resetCategoryFeatures(permission: UserPermissionCategoryKey) {
|
||||
setCategoryFeatures(permission, true)
|
||||
}
|
||||
|
||||
/** 校验并读取用户上传的新头像。 */
|
||||
function changeAvatar(file: Event) {
|
||||
const fileReader = new FileReader()
|
||||
const { files } = file.target as HTMLInputElement
|
||||
@@ -182,19 +264,19 @@ function changeAvatar(file: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// 重置默认头像
|
||||
/** 将头像恢复为系统默认头像。 */
|
||||
function resetDefaultAvatar() {
|
||||
currentAvatar.value = avatar1
|
||||
$toast.success(t('dialog.userAddEdit.resetAvatarSuccess'))
|
||||
}
|
||||
|
||||
// 还原当前头像
|
||||
/** 还原为用户当前已保存的头像。 */
|
||||
function restoreCurrentAvatar() {
|
||||
currentAvatar.value = userForm.value.avatar
|
||||
$toast.success(t('dialog.userAddEdit.restoreAvatarSuccess'))
|
||||
}
|
||||
|
||||
// 查询用户信息
|
||||
/** 从接口查询当前编辑用户的信息并回填表单。 */
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
userForm.value = await api.get(`user/${props.username}`)
|
||||
@@ -210,7 +292,7 @@ async function fetchUserInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API 新增用户
|
||||
/** 调用接口创建新用户。 */
|
||||
async function addUser() {
|
||||
if (isAdding.value) {
|
||||
$toast.error(t('dialog.userAddEdit.creatingUser', { name: userForm.value.name }))
|
||||
@@ -256,7 +338,7 @@ async function addUser() {
|
||||
isAdding.value = false
|
||||
}
|
||||
|
||||
// 调用API更新用户信息
|
||||
/** 调用接口更新当前用户信息。 */
|
||||
async function updateUser() {
|
||||
if (isUpdating.value) {
|
||||
$toast.error(t('dialog.userAddEdit.updatingUser', { name: userForm.value.name }))
|
||||
@@ -367,7 +449,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable max-width="64rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem :class="props.oper === 'add' ? 'py-3' : 'py-2'">
|
||||
<template #prepend>
|
||||
@@ -572,43 +654,144 @@ onMounted(() => {
|
||||
<span>{{ t('dialog.userAddEdit.permissions.title') }}</span>
|
||||
</VDivider>
|
||||
<!-- 权限设置 -->
|
||||
<div v-if="canControl">
|
||||
<VRow>
|
||||
<VCol v-for="option in permissionOptions" :key="option.key" cols="6">
|
||||
<VCard
|
||||
:color="userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface'"
|
||||
:variant="userPermissions[option.key as keyof UserPermissions] ? 'tonal' : 'outlined'"
|
||||
class="cursor-pointer transition-all h-full"
|
||||
@click="togglePermission(option.key as keyof UserPermissions)"
|
||||
hover
|
||||
<div v-if="canControl" class="user-permission-editor">
|
||||
<div class="permission-section-header">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
{{ t('dialog.userAddEdit.permissions.categoryTitle') }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ t('dialog.userAddEdit.permissions.categoryHint') }}
|
||||
</div>
|
||||
</div>
|
||||
<VChip size="small" color="primary" variant="tonal">
|
||||
{{
|
||||
t('dialog.userAddEdit.permissions.summary', {
|
||||
categories: enabledCategoryCount,
|
||||
features: enabledFeatureCount,
|
||||
})
|
||||
}}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="permission-category-grid">
|
||||
<div
|
||||
v-for="option in permissionOptions"
|
||||
:key="option.key"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="permission-category-option"
|
||||
:class="{
|
||||
'is-active': activePermissionCategory === option.key,
|
||||
'is-enabled': userPermissions[option.key],
|
||||
'is-partial': isPermissionPartiallySelected(option.key),
|
||||
}"
|
||||
@click="selectPermissionCategory(option.key)"
|
||||
@keydown.enter="selectPermissionCategory(option.key)"
|
||||
@keydown.space.prevent="selectPermissionCategory(option.key)"
|
||||
>
|
||||
<span class="permission-category-option__icon">
|
||||
<VIcon :icon="option.icon" size="20" />
|
||||
</span>
|
||||
<span class="permission-category-option__body">
|
||||
<span class="permission-category-option__title-row">
|
||||
<span class="permission-category-option__title">{{ option.title }}</span>
|
||||
<span class="permission-category-option__count">
|
||||
{{ getEnabledFeatureCount(option.key) }}/{{ getFeatureTotalCount(option.key) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="permission-category-option__desc">{{ option.description }}</span>
|
||||
</span>
|
||||
<VBtn
|
||||
:icon="userPermissions[option.key] ? 'mdi-check-circle' : 'mdi-circle-outline'"
|
||||
:color="userPermissions[option.key] ? 'primary' : 'secondary'"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="permission-category-option__toggle"
|
||||
@click.stop="togglePermission(option.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="permission-feature-panel">
|
||||
<div class="permission-feature-panel__header">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
{{ activePermissionOption.title }}{{ t('dialog.userAddEdit.permissions.featureTitleSuffix') }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{
|
||||
userPermissions[activePermissionCategory]
|
||||
? t('dialog.userAddEdit.permissions.featureHint')
|
||||
: t('dialog.userAddEdit.permissions.disabledCategoryHint')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="permission-feature-panel__actions">
|
||||
<VBtn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:disabled="!userPermissions[activePermissionCategory]"
|
||||
@click="setCategoryFeatures(activePermissionCategory, true)"
|
||||
>
|
||||
{{ t('dialog.userAddEdit.permissions.selectAll') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
:disabled="!userPermissions[activePermissionCategory]"
|
||||
@click="setCategoryFeatures(activePermissionCategory, false)"
|
||||
>
|
||||
{{ t('dialog.userAddEdit.permissions.clear') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
:disabled="!userPermissions[activePermissionCategory]"
|
||||
@click="resetCategoryFeatures(activePermissionCategory)"
|
||||
>
|
||||
{{ t('dialog.userAddEdit.permissions.default') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="permission-feature-list">
|
||||
<div
|
||||
v-for="feature in activePermissionFeatures"
|
||||
:key="feature.key"
|
||||
role="checkbox"
|
||||
:aria-checked="isFeatureEnabled(feature.key)"
|
||||
:aria-disabled="!userPermissions[activePermissionCategory]"
|
||||
:tabindex="userPermissions[activePermissionCategory] ? 0 : -1"
|
||||
class="permission-feature-item"
|
||||
:class="{
|
||||
'is-enabled': isFeatureEnabled(feature.key),
|
||||
'is-disabled': !userPermissions[activePermissionCategory],
|
||||
}"
|
||||
@click="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
|
||||
@keydown.enter="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
|
||||
@keydown.space.prevent="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
|
||||
>
|
||||
<VCardText class="d-flex align-center pa-4">
|
||||
<VAvatar
|
||||
:color="userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface-variant'"
|
||||
size="40"
|
||||
class="me-3"
|
||||
>
|
||||
<VIcon :icon="option.icon" />
|
||||
</VAvatar>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-subtitle-1 font-weight-medium d-flex align-center">
|
||||
{{ option.title }}
|
||||
<VIcon
|
||||
v-if="userPermissions[option.key as keyof UserPermissions]"
|
||||
icon="mdi-check-circle"
|
||||
color="primary"
|
||||
size="small"
|
||||
class="ms-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VCheckboxBtn
|
||||
:model-value="isFeatureEnabled(feature.key)"
|
||||
:disabled="!userPermissions[activePermissionCategory]"
|
||||
color="primary"
|
||||
density="compact"
|
||||
class="permission-feature-item__check"
|
||||
@click.stop
|
||||
@update:model-value="togglePermissionFeature(feature.key)"
|
||||
/>
|
||||
<span class="permission-feature-item__icon">
|
||||
<VIcon :icon="feature.icon" size="20" />
|
||||
</span>
|
||||
<span class="permission-feature-item__body">
|
||||
<span class="permission-feature-item__title">{{ feature.title }}</span>
|
||||
<span class="permission-feature-item__desc">{{ feature.description }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
@@ -642,3 +825,273 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-permission-editor {
|
||||
--permission-editor-radius: min(var(--app-surface-radius, 8px), 8px);
|
||||
--permission-editor-border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
--permission-editor-muted-border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
--permission-editor-panel-bg: rgb(var(--v-theme-surface));
|
||||
--permission-editor-hover-bg: rgba(var(--v-theme-primary), 0.04);
|
||||
--permission-editor-active-bg: rgba(var(--v-theme-primary), 0.1);
|
||||
--permission-editor-selected-bg: rgba(var(--v-theme-primary), 0.08);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.permission-section-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.permission-category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15.5rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-category-option,
|
||||
.permission-feature-item {
|
||||
border: var(--permission-editor-muted-border);
|
||||
border-radius: var(--permission-editor-radius);
|
||||
background: var(--permission-editor-panel-bg);
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.permission-category-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: flex-start;
|
||||
min-block-size: 5.75rem;
|
||||
padding: 0.875rem;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.permission-category-option:hover,
|
||||
.permission-feature-item:hover:not(.is-disabled) {
|
||||
border-color: rgba(var(--v-theme-primary), 0.36);
|
||||
background: var(--permission-editor-hover-bg);
|
||||
}
|
||||
|
||||
.permission-category-option:focus-visible,
|
||||
.permission-feature-item:focus-visible {
|
||||
outline: 2px solid rgba(var(--v-theme-primary), 0.72);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.permission-category-option.is-active {
|
||||
border-color: rgba(var(--v-theme-primary), 0.72);
|
||||
background: var(--permission-editor-active-bg);
|
||||
}
|
||||
|
||||
.permission-category-option.is-enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.34);
|
||||
}
|
||||
|
||||
.permission-category-option.is-partial .permission-category-option__count {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
background: rgba(var(--v-theme-warning), 0.12);
|
||||
}
|
||||
|
||||
.permission-category-option__icon,
|
||||
.permission-feature-item__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--app-control-radius, 6px);
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.permission-category-option__icon {
|
||||
inline-size: 2.25rem;
|
||||
block-size: 2.25rem;
|
||||
margin-inline-end: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-category-option__body,
|
||||
.permission-feature-item__body {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.permission-category-option__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.permission-category-option__title,
|
||||
.permission-feature-item__title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.permission-category-option__count {
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--app-control-radius, 6px);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.permission-category-option__desc,
|
||||
.permission-feature-item__desc,
|
||||
.permission-feature-item__scope {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-category-option__desc,
|
||||
.permission-feature-item__desc {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.permission-category-option__toggle {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.permission-feature-panel {
|
||||
overflow: hidden;
|
||||
border: var(--permission-editor-border);
|
||||
border-radius: var(--permission-editor-radius);
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.permission-feature-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-block-end: var(--permission-editor-muted-border);
|
||||
}
|
||||
|
||||
.permission-feature-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.permission-feature-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.625rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.permission-feature-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
align-items: flex-start;
|
||||
min-block-size: 4rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.permission-feature-item.is-disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.permission-feature-item.is-disabled .permission-feature-item__icon {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.permission-feature-item.is-disabled .permission-feature-item__title,
|
||||
.permission-feature-item.is-disabled .permission-feature-item__desc {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
}
|
||||
|
||||
.permission-feature-item.is-enabled {
|
||||
background: var(--permission-editor-selected-bg);
|
||||
}
|
||||
|
||||
.permission-feature-item__check {
|
||||
margin-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.permission-feature-item__icon {
|
||||
inline-size: 2rem;
|
||||
block-size: 2rem;
|
||||
margin-inline-end: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-feature-item__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.permission-feature-item__scope {
|
||||
max-inline-size: 8rem;
|
||||
margin-inline-start: 1rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.permission-category-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.permission-feature-list {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.permission-section-header,
|
||||
.permission-feature-panel__header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.permission-feature-panel__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.user-permission-editor {
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.permission-category-option {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-block-size: 3.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-category-option__icon,
|
||||
.permission-category-option__desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.permission-category-option__toggle {
|
||||
margin-inline-start: 0.5rem;
|
||||
}
|
||||
|
||||
.permission-feature-item {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.permission-feature-item__icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
451
src/components/dialog/__tests__/SubscribeEditDialog.spec.ts
Normal file
451
src/components/dialog/__tests__/SubscribeEditDialog.spec.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {
|
||||
createSubscribe,
|
||||
createSubscribeDirectory,
|
||||
createSubscribeDownloader,
|
||||
createSubscribeRuleGroup,
|
||||
createSubscribeSite,
|
||||
} from '@tests/support/factories/subscribe'
|
||||
import {
|
||||
defaultSubscribeConfigHandler,
|
||||
deleteSubscribeByIdHandler,
|
||||
saveDefaultSubscribeConfigHandler,
|
||||
subscribeApiUrls,
|
||||
subscribeDetailsHandler,
|
||||
subscribeDialogOptionHandlers,
|
||||
type SubscribeDialogOptions,
|
||||
type SubscribeMediaType,
|
||||
updateSubscribeHandler,
|
||||
} 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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
confirm: 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,
|
||||
}))
|
||||
|
||||
interface DialogProps {
|
||||
default?: boolean
|
||||
subid?: number
|
||||
type?: SubscribeMediaType
|
||||
}
|
||||
|
||||
async function renderDialog(props: DialogProps, superUser = true) {
|
||||
const events = {
|
||||
close: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
save: vi.fn(),
|
||||
}
|
||||
const result = await renderWithProviders(SubscribeEditDialog, {
|
||||
initialState: {
|
||||
user: {
|
||||
superUser,
|
||||
userName: superUser ? 'admin' : 'member',
|
||||
},
|
||||
},
|
||||
props: {
|
||||
modelValue: true,
|
||||
...props,
|
||||
onClose: events.close,
|
||||
onRemove: events.remove,
|
||||
onSave: events.save,
|
||||
},
|
||||
global: {
|
||||
components: {
|
||||
VDialogCloseBtn: DialogCloseBtn,
|
||||
},
|
||||
},
|
||||
})
|
||||
return { ...result, events }
|
||||
}
|
||||
|
||||
function useDialogOptions(options: SubscribeDialogOptions = {}) {
|
||||
server.use(...subscribeDialogOptionHandlers(options))
|
||||
}
|
||||
|
||||
describe('SubscribeEditDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.confirm.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('loads a TV subscription, normalizes flags, and exposes episode groups', async () => {
|
||||
const record = createSubscribe({
|
||||
best_version: 1,
|
||||
best_version_full: 1,
|
||||
id: 801,
|
||||
name: '季度测试剧',
|
||||
search_imdbid: 0,
|
||||
season: 2,
|
||||
tmdbid: 8010,
|
||||
type: '电视剧',
|
||||
})
|
||||
const episodeGroupsRequested = vi.fn()
|
||||
server.use(subscribeDetailsHandler(801, record))
|
||||
useDialogOptions({
|
||||
episodeGroups: [{ episode_count: 24, group_count: 2, id: 99, name: '官方特别排序' }],
|
||||
onEpisodeGroups: episodeGroupsRequested,
|
||||
tmdbId: 8010,
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
await renderDialog({ subid: 801 })
|
||||
|
||||
expect(await screen.findByText('季度测试剧 S02')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('洗版')).toBeChecked()
|
||||
expect(screen.getByLabelText('全集洗版')).toBeChecked()
|
||||
expect(screen.getByLabelText('使用 ImdbID 搜索')).not.toBeChecked()
|
||||
await waitFor(() => expect(episodeGroupsRequested).toHaveBeenCalledOnce())
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '进阶' }))
|
||||
await user.click(screen.getByLabelText('指定剧集组'))
|
||||
expect(await screen.findByText('官方特别排序')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 季 • 24 集')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps movie titles free of season suffixes and skips episode groups', async () => {
|
||||
const record = createSubscribe({ id: 802, name: '电影测试项', season: undefined, tmdbid: 8020, type: '电影' })
|
||||
const episodeGroupsRequested = vi.fn()
|
||||
server.use(subscribeDetailsHandler(802, record))
|
||||
useDialogOptions({ onEpisodeGroups: episodeGroupsRequested, tmdbId: 8020 })
|
||||
await renderDialog({ subid: 802 })
|
||||
|
||||
expect(await screen.findByText('电影测试项')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/电影测试项 S\d+/)).not.toBeInTheDocument()
|
||||
expect(episodeGroupsRequested).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows enabled sites and stable downloader, directory, and rule options', async () => {
|
||||
const activeSite = createSubscribeSite({ id: 1, is_active: true, name: '启用站点' })
|
||||
const inactiveSite = createSubscribeSite({ id: 2, is_active: false, name: '停用站点' })
|
||||
const requests = {
|
||||
directories: vi.fn(),
|
||||
downloaders: vi.fn(),
|
||||
rules: vi.fn(),
|
||||
sites: vi.fn(),
|
||||
}
|
||||
server.use(defaultSubscribeConfigHandler('电影', createSubscribe({ id: 0, type: '电影' })))
|
||||
useDialogOptions({
|
||||
directories: [
|
||||
createSubscribeDirectory({ download_path: '/downloads', name: '目录一' }),
|
||||
createSubscribeDirectory({ download_path: '/downloads', name: '目录二' }),
|
||||
createSubscribeDirectory({ download_path: undefined, name: '空目录' }),
|
||||
],
|
||||
downloaders: [createSubscribeDownloader({ name: '下载器 A' })],
|
||||
filterRuleGroups: [createSubscribeRuleGroup({ name: '高优先级' })],
|
||||
onDirectories: requests.directories,
|
||||
onDownloaders: requests.downloaders,
|
||||
onFilterRuleGroups: requests.rules,
|
||||
onSites: requests.sites,
|
||||
sites: [activeSite, inactiveSite],
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
await renderDialog({ default: true, type: '电影' })
|
||||
await waitFor(() => expect(requests.sites).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(requests.downloaders).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(requests.directories).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(requests.rules).toHaveBeenCalledOnce())
|
||||
|
||||
await user.click(screen.getByLabelText('订阅站点'))
|
||||
expect(await screen.findByText('启用站点')).toBeInTheDocument()
|
||||
expect(screen.queryByText('停用站点')).not.toBeInTheDocument()
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
await user.click(screen.getByLabelText('下载器'))
|
||||
expect(await screen.findByText('下载器 A')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('默认').length).toBeGreaterThanOrEqual(1)
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
await user.click(screen.getByLabelText('保存路径'))
|
||||
expect(await screen.findAllByText('/downloads')).toHaveLength(1)
|
||||
expect(screen.queryByText('undefined')).not.toBeInTheDocument()
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '进阶' }))
|
||||
await user.click(screen.getByLabelText('优先级规则组'))
|
||||
expect(await screen.findByText('高优先级')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('allows non-admin users to read public defaults but not private rules or save them', async () => {
|
||||
const configRequested = vi.fn()
|
||||
const rulesRequested = vi.fn()
|
||||
const saved = vi.fn()
|
||||
server.use(
|
||||
defaultSubscribeConfigHandler('电视剧', createSubscribe({ id: 0, type: '电视剧' }), 200, configRequested),
|
||||
saveDefaultSubscribeConfigHandler('电视剧', { success: true }, 200, saved),
|
||||
)
|
||||
useDialogOptions({ onFilterRuleGroups: rulesRequested })
|
||||
const { events } = await renderDialog({ default: true, type: '电视剧' }, false)
|
||||
|
||||
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
|
||||
expect(rulesRequested).not.toHaveBeenCalled()
|
||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
expect(saved).not.toHaveBeenCalled()
|
||||
expect(events.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['电影', '电视剧'] as const)('loads and saves %s default configuration as an administrator', async type => {
|
||||
const configRequested = vi.fn()
|
||||
const saved = vi.fn()
|
||||
server.use(
|
||||
defaultSubscribeConfigHandler(type, createSubscribe({ id: 0, show_edit_dialog: false, type }), 200, configRequested),
|
||||
saveDefaultSubscribeConfigHandler(type, { success: true }, 200, saved),
|
||||
)
|
||||
useDialogOptions()
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog({ default: true, type })
|
||||
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(screen.getByLabelText('订阅时编辑更多规则')).not.toBeChecked())
|
||||
|
||||
await user.click(screen.getByLabelText('订阅时编辑更多规则'))
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
|
||||
expect(saved.mock.calls[0][0]).toMatchObject({ show_edit_dialog: true, type })
|
||||
expect(events.save).toHaveBeenCalledOnce()
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${type}订阅默认规则保存成功`)
|
||||
})
|
||||
|
||||
it('submits the complete TV editing form and exposes the close action', async () => {
|
||||
const record = createSubscribe({
|
||||
best_version: 1,
|
||||
best_version_full: 0,
|
||||
id: 809,
|
||||
name: '完整表单测试剧',
|
||||
search_imdbid: 0,
|
||||
season: 1,
|
||||
tmdbid: 8090,
|
||||
type: '电视剧',
|
||||
})
|
||||
const updated = vi.fn()
|
||||
server.use(subscribeDetailsHandler(809, record), updateSubscribeHandler({ success: true }, 200, updated))
|
||||
useDialogOptions({
|
||||
directories: [createSubscribeDirectory({ download_path: '/完整目录' })],
|
||||
downloaders: [createSubscribeDownloader({ name: '完整下载器' })],
|
||||
episodeGroups: [{ episode_count: 12, group_count: 1, id: 8091, name: '完整剧集组' }],
|
||||
filterRuleGroups: [createSubscribeRuleGroup({ name: '完整规则组' })],
|
||||
sites: [createSubscribeSite({ id: 8092, name: '完整站点' })],
|
||||
tmdbId: 8090,
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog({ subid: 809 })
|
||||
await screen.findByText('完整表单测试剧 S01')
|
||||
|
||||
const chooseOption = async (label: string, option: string) => {
|
||||
await user.click(screen.getByLabelText(label))
|
||||
await user.click(await screen.findByText(option, {}, { timeout: 2_000 }))
|
||||
}
|
||||
|
||||
await user.type(screen.getByLabelText('总集数'), '24')
|
||||
await user.type(screen.getByLabelText('开始集数'), '2')
|
||||
await chooseOption('质量', 'Remux')
|
||||
await chooseOption('分辨率', '1080p')
|
||||
await chooseOption('特效', 'HDR')
|
||||
await chooseOption('订阅站点', '完整站点')
|
||||
await user.keyboard('{Escape}')
|
||||
await chooseOption('下载器', '完整下载器')
|
||||
await chooseOption('保存路径', '/完整目录')
|
||||
await user.click(screen.getByLabelText('全集洗版'))
|
||||
await user.click(screen.getByLabelText('使用 ImdbID 搜索'))
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '进阶' }))
|
||||
await user.type(screen.getByLabelText('包含(关键字、正则式)'), '国语')
|
||||
await user.type(screen.getByLabelText('排除(关键字、正则式)'), '预告')
|
||||
await chooseOption('优先级规则组', '完整规则组')
|
||||
await user.keyboard('{Escape}')
|
||||
await chooseOption('指定剧集组', '完整剧集组')
|
||||
await chooseOption('指定季', '第 2 季')
|
||||
await user.type(screen.getByLabelText('自定义类别'), '纪录片')
|
||||
await user.type(screen.getByLabelText('自定义识别词'), '测试词 => 正式词')
|
||||
|
||||
const closeButton = document.querySelector<HTMLButtonElement>('.v-card-item button')
|
||||
expect(closeButton).not.toBeNull()
|
||||
await user.click(closeButton as HTMLButtonElement)
|
||||
expect(events.close).toHaveBeenCalledOnce()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
|
||||
expect(updated.mock.calls[0][0]).toMatchObject({
|
||||
best_version_full: true,
|
||||
custom_words: '测试词 => 正式词',
|
||||
downloader: '完整下载器',
|
||||
effect: '[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+',
|
||||
episode_group: 8091,
|
||||
exclude: '预告',
|
||||
filter_groups: ['完整规则组'],
|
||||
include: '国语',
|
||||
media_category: '纪录片',
|
||||
quality: 'Remux',
|
||||
resolution: '1080[pi]|x1080',
|
||||
save_path: '/完整目录',
|
||||
search_imdbid: true,
|
||||
season: 2,
|
||||
sites: [8092],
|
||||
start_episode: '2',
|
||||
total_episode: '24',
|
||||
})
|
||||
expect(events.save).toHaveBeenCalledWith(expect.objectContaining({ season: 2 }))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['business failure', 200, { message: 'rejected', success: false }, '电影订阅默认规则保存失败:rejected!'],
|
||||
['HTTP failure', 500, { message: 'server down', success: false }, '电影订阅默认规则保存失败:server down!'],
|
||||
])('keeps a default dialog open after a %s', async (_case, status, response, expectedMessage) => {
|
||||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
server.use(
|
||||
defaultSubscribeConfigHandler('电影', createSubscribe({ id: 0, type: '电影' })),
|
||||
saveDefaultSubscribeConfigHandler('电影', response, status),
|
||||
)
|
||||
useDialogOptions()
|
||||
const { events } = await renderDialog({ default: true, type: '电影' })
|
||||
await screen.findByText('电影')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage))
|
||||
expect(events.save).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument()
|
||||
consoleLog.mockRestore()
|
||||
})
|
||||
|
||||
it('updates an edited subscription and clears full-season mode when versioning is disabled', async () => {
|
||||
const record = createSubscribe({
|
||||
best_version: 1,
|
||||
best_version_full: 1,
|
||||
id: 803,
|
||||
keyword: '旧关键词',
|
||||
name: '编辑测试剧',
|
||||
season: 1,
|
||||
tmdbid: 8030,
|
||||
type: '电视剧',
|
||||
})
|
||||
const updated = vi.fn()
|
||||
server.use(subscribeDetailsHandler(803, record), updateSubscribeHandler({ success: true }, 200, updated))
|
||||
useDialogOptions({ tmdbId: 8030 })
|
||||
const user = userEvent.setup()
|
||||
const { events } = await renderDialog({ subid: 803 })
|
||||
await screen.findByText('编辑测试剧 S01')
|
||||
|
||||
const keyword = screen.getByLabelText('搜索关键词')
|
||||
await user.clear(keyword)
|
||||
await user.type(keyword, '新关键词')
|
||||
await user.click(screen.getByLabelText('洗版'))
|
||||
await waitFor(() => expect(screen.queryByLabelText('全集洗版')).not.toBeInTheDocument())
|
||||
await user.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
|
||||
expect(updated.mock.calls[0][0]).toMatchObject({
|
||||
best_version: false,
|
||||
best_version_full: false,
|
||||
id: 803,
|
||||
keyword: '新关键词',
|
||||
})
|
||||
expect(events.save).toHaveBeenCalledWith(expect.objectContaining({ id: 803, keyword: '新关键词' }))
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('编辑测试剧 S01 更新成功!')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['business failure', 200, { message: 'invalid', success: false }, '失败编辑项 更新失败:invalid!'],
|
||||
['HTTP failure', 500, { message: 'server down', success: false }, '失败编辑项 更新失败:server down!'],
|
||||
])('keeps an edit dialog usable after an update %s', async (_case, status, response, expectedMessage) => {
|
||||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const record = createSubscribe({ id: 804, name: '失败编辑项', tmdbid: 8040 })
|
||||
server.use(subscribeDetailsHandler(804, record), updateSubscribeHandler(response, status))
|
||||
useDialogOptions({ tmdbId: 8040 })
|
||||
const { events } = await renderDialog({ subid: 804 })
|
||||
await screen.findByText('失败编辑项')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage))
|
||||
expect(events.save).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument()
|
||||
consoleLog.mockRestore()
|
||||
})
|
||||
|
||||
it('does not delete when confirmation is cancelled', async () => {
|
||||
const record = createSubscribe({ id: 805, name: '保留订阅', tmdbid: 8050 })
|
||||
const deleted = vi.fn()
|
||||
server.use(subscribeDetailsHandler(805, record), deleteSubscribeByIdHandler(805, { success: true }, 200, deleted))
|
||||
useDialogOptions({ tmdbId: 8050 })
|
||||
mocks.confirm.mockResolvedValue(false)
|
||||
const { events } = await renderDialog({ subid: 805 })
|
||||
await screen.findByText('保留订阅')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '取消订阅' }))
|
||||
|
||||
expect(deleted).not.toHaveBeenCalled()
|
||||
expect(events.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits remove only after a successful deletion', async () => {
|
||||
const record = createSubscribe({ id: 806, name: '删除订阅', tmdbid: 8060 })
|
||||
const deleted = vi.fn()
|
||||
server.use(subscribeDetailsHandler(806, record), deleteSubscribeByIdHandler(806, { success: true }, 200, deleted))
|
||||
useDialogOptions({ tmdbId: 8060 })
|
||||
const { events } = await renderDialog({ subid: 806 })
|
||||
await screen.findByText('删除订阅')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '取消订阅' }))
|
||||
|
||||
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
|
||||
expect(events.remove).toHaveBeenCalledOnce()
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('删除订阅 已取消订阅!')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['business failure', 200, { message: 'not allowed', success: false }, '删除失败项 取消订阅失败:not allowed!'],
|
||||
['HTTP failure', 500, { message: 'server down', success: false }, '删除失败项 取消订阅失败:server down!'],
|
||||
])('keeps the subscription after a delete %s', async (_case, status, response, expectedMessage) => {
|
||||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const record = createSubscribe({ id: 807, name: '删除失败项', tmdbid: 8070 })
|
||||
server.use(subscribeDetailsHandler(807, record), deleteSubscribeByIdHandler(807, response, status))
|
||||
useDialogOptions({ tmdbId: 8070 })
|
||||
const { events } = await renderDialog({ subid: 807 })
|
||||
await screen.findByText('删除失败项')
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '取消订阅' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage))
|
||||
expect(events.remove).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('删除失败项')).toBeInTheDocument()
|
||||
consoleLog.mockRestore()
|
||||
})
|
||||
|
||||
it('remains editable when an auxiliary options request fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const record = createSubscribe({ id: 808, keyword: '仍可编辑', name: '部分失败项', tmdbid: 8080 })
|
||||
const updated = vi.fn()
|
||||
server.use(subscribeDetailsHandler(808, record), updateSubscribeHandler({ success: true }, 200, updated))
|
||||
useDialogOptions({ tmdbId: 8080 })
|
||||
server.use(
|
||||
http.get(subscribeApiUrls.downloaders, () =>
|
||||
HttpResponse.json({ message: 'unavailable', success: false }, { status: 500 }),
|
||||
),
|
||||
)
|
||||
await renderDialog({ subid: 808 })
|
||||
|
||||
expect(await screen.findByDisplayValue('仍可编辑')).toBeInTheDocument()
|
||||
await waitFor(() => expect(consoleError).toHaveBeenCalled())
|
||||
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,11 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '* * * * *',
|
||||
},
|
||||
/** 是否允许直接清空 CRON。 */
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -39,7 +44,6 @@ function updateModelValue(value: string) {
|
||||
:modelValue="innerValue"
|
||||
@update:modelValue="updateModelValue"
|
||||
v-bind="{ ...menuprops, ...propsWithoutModelValue }"
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
</CronInput>
|
||||
|
||||
@@ -30,6 +30,7 @@ const propsWithoutModelValue = computed(() => {
|
||||
return { ...rest, ...attrs }
|
||||
})
|
||||
|
||||
/** 同步路径输入值并向父组件派发更新。 */
|
||||
function updateModelValue(value: string) {
|
||||
innerValue.value = value
|
||||
emit('update:modelValue', value)
|
||||
|
||||
@@ -728,7 +728,7 @@ async function batchScrape() {
|
||||
function handleProgressMessage(event: MessageEvent) {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (progress) {
|
||||
progressText.value = progress.text
|
||||
progressText.value = progress.text_i18n || progress.text
|
||||
progressValue.value = progress.value
|
||||
progressDialogController?.updateProps({ text: progressText.value, value: progressValue.value })
|
||||
}
|
||||
|
||||
@@ -722,7 +722,7 @@ onMounted(() => {
|
||||
.filter-buttons-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filter-btn-mobile {
|
||||
@@ -732,10 +732,23 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 8px;
|
||||
block-size: auto;
|
||||
min-block-size: 48px;
|
||||
block-size: 56px;
|
||||
min-block-size: 56px;
|
||||
min-inline-size: 0;
|
||||
padding-block: 4px;
|
||||
padding-inline: 0;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.filter-btn-mobile :deep(.v-btn__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 100%;
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
line-height: 1.15;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
@@ -744,20 +757,20 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.15;
|
||||
max-inline-size: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.filter-buttons-grid {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
overflow: hidden;
|
||||
max-inline-size: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { locale } = useI18n()
|
||||
|
||||
const menu = ref(false)
|
||||
const currentCron = ref(props.modelValue)
|
||||
@@ -14,6 +15,27 @@ const menuRoot = ref<HTMLElement>()
|
||||
const instance = getCurrentInstance()
|
||||
const menuContentClass = `cron-input-menu-${instance?.uid ?? 'default'}`
|
||||
const menuContentSelector = `.${menuContentClass}`
|
||||
const normalizedLocale = computed(() => locale.value.toLowerCase().replace(/_/g, '-'))
|
||||
const cronLocale = computed(() => normalizedLocale.value.startsWith('zh') ? 'zh-cn' : 'en')
|
||||
// vue-js-cron 没有内置繁体中文,只需覆盖简体词典中会显示的 Cron 专有字词。
|
||||
const cronCustomLocale = computed(() => normalizedLocale.value === 'zh-tw' ? {
|
||||
'*': {
|
||||
day: {
|
||||
value: { text: '{{value.alt}}號' },
|
||||
range: { text: '{{start.alt}}號-{{end.alt}}號' },
|
||||
},
|
||||
dayOfWeek: { empty: { text: '一週的每一天' } },
|
||||
hour: { empty: { text: '每小時' } },
|
||||
minute: { empty: { text: '每分鐘' } },
|
||||
},
|
||||
hour: {
|
||||
text: '小時',
|
||||
minute: { '*': { suffix: '分鐘' } },
|
||||
},
|
||||
week: { text: '週' },
|
||||
'q-minute': { text: '分鐘' },
|
||||
'q-hour': { text: '小時' },
|
||||
} : undefined)
|
||||
|
||||
function isCronMenuTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof Element)) return false
|
||||
@@ -72,7 +94,14 @@ watch(
|
||||
</template>
|
||||
<VList>
|
||||
<VListItem>
|
||||
<VCronVuetify v-model="currentCron" locale="zh-CN" :chip-props="{ color: 'success' }" class="mt-1" />
|
||||
<VCronVuetify
|
||||
:key="locale"
|
||||
v-model="currentCron"
|
||||
:chip-props="{ color: 'success' }"
|
||||
class="mt-1"
|
||||
:custom-locale="cronCustomLocale"
|
||||
:locale="cronLocale"
|
||||
/>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
|
||||
@@ -24,6 +24,7 @@ const asyncDashboardOptions = {
|
||||
const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader> = {
|
||||
storage: () => import('@/views/dashboard/AnalyticsStorage.vue'),
|
||||
mediaStatistic: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'),
|
||||
mediaRecommend: () => import('@/views/dashboard/MediaRecommend.vue'),
|
||||
weeklyOverview: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'),
|
||||
speed: () => import('@/views/dashboard/AnalyticsSpeed.vue'),
|
||||
scheduler: () => import('@/views/dashboard/AnalyticsScheduler.vue'),
|
||||
@@ -33,6 +34,9 @@ const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader>
|
||||
library: () => import('@/views/dashboard/MediaServerLibrary.vue'),
|
||||
playing: () => import('@/views/dashboard/MediaServerPlaying.vue'),
|
||||
latest: () => import('@/views/dashboard/MediaServerLatest.vue'),
|
||||
recentImports: () => import('@/views/dashboard/DashboardRecentImports.vue'),
|
||||
quickActions: () => import('@/views/dashboard/DashboardQuickActions.vue'),
|
||||
systemInfo: () => import('@/views/dashboard/DashboardSystemInfo.vue'),
|
||||
}
|
||||
|
||||
const builtInDashboardComponentPromises = new Map<string, Promise<any>>()
|
||||
@@ -65,6 +69,7 @@ function createAsyncDashboardComponent(id: string) {
|
||||
// 内置仪表盘按需加载,关闭的卡片不再挤进 dashboard 首屏 chunk。
|
||||
const AnalyticsStorage = createAsyncDashboardComponent('storage')
|
||||
const AnalyticsMediaStatistic = createAsyncDashboardComponent('mediaStatistic')
|
||||
const MediaRecommend = createAsyncDashboardComponent('mediaRecommend')
|
||||
const AnalyticsWeeklyOverview = createAsyncDashboardComponent('weeklyOverview')
|
||||
const AnalyticsSpeed = createAsyncDashboardComponent('speed')
|
||||
const AnalyticsScheduler = createAsyncDashboardComponent('scheduler')
|
||||
@@ -74,6 +79,9 @@ const AnalyticsNetwork = createAsyncDashboardComponent('network')
|
||||
const MediaServerLibrary = createAsyncDashboardComponent('library')
|
||||
const MediaServerPlaying = createAsyncDashboardComponent('playing')
|
||||
const MediaServerLatest = createAsyncDashboardComponent('latest')
|
||||
const DashboardRecentImports = createAsyncDashboardComponent('recentImports')
|
||||
const DashboardQuickActions = createAsyncDashboardComponent('quickActions')
|
||||
const DashboardSystemInfo = createAsyncDashboardComponent('systemInfo')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -196,6 +204,7 @@ onUnmounted(() => {
|
||||
<!-- 系统内置的仪表板 -->
|
||||
<AnalyticsStorage v-if="config?.id === 'storage'" />
|
||||
<AnalyticsMediaStatistic v-else-if="config?.id === 'mediaStatistic'" />
|
||||
<MediaRecommend v-else-if="config?.id === 'mediaRecommend'" />
|
||||
<AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" />
|
||||
<AnalyticsSpeed v-else-if="config?.id === 'speed'" :allowRefresh="props.allowRefresh" />
|
||||
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" />
|
||||
@@ -205,6 +214,9 @@ onUnmounted(() => {
|
||||
<MediaServerLibrary v-else-if="config?.id === 'library'" />
|
||||
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
|
||||
<MediaServerLatest v-else-if="config?.id === 'latest'" />
|
||||
<DashboardRecentImports v-else-if="config?.id === 'recentImports'" />
|
||||
<DashboardQuickActions v-else-if="config?.id === 'quickActions'" />
|
||||
<DashboardSystemInfo v-else-if="config?.id === 'systemInfo'" :allow-refresh="props.allowRefresh" />
|
||||
<!-- 插件仪表板 -->
|
||||
<template v-else-if="!isNullOrEmptyObject(props.config)">
|
||||
<!-- Vue 渲染模式 -->
|
||||
|
||||
@@ -8,15 +8,22 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
interface TmdbItem {
|
||||
// 媒体标题
|
||||
title: string
|
||||
// 媒体简介,包含类型标签
|
||||
overview: string
|
||||
// TMDB ID
|
||||
tmdbid: number
|
||||
// 豆瓣 ID
|
||||
doubanid: string
|
||||
// 海报地址
|
||||
poster: string
|
||||
// 媒体类型
|
||||
type?: string
|
||||
}
|
||||
|
||||
// update:modelValue 事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
const emit = defineEmits(['update:modelValue', 'select', 'close'])
|
||||
|
||||
const items = ref<TmdbItem[]>([])
|
||||
|
||||
@@ -29,9 +36,10 @@ const loading = ref(false)
|
||||
// ref
|
||||
const inputKeyword = ref<HTMLElement | null>(null)
|
||||
|
||||
// 选中条目
|
||||
// 选中条目并通知父组件同步额外媒体信息。
|
||||
function selectMedia(item: TmdbItem) {
|
||||
emit('update:modelValue', item.tmdbid || item.doubanid)
|
||||
emit('select', item)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -66,6 +74,7 @@ async function searchMedias() {
|
||||
tmdbid: item.tmdb_id || 0,
|
||||
doubanid: item.douban_id || '',
|
||||
poster: getW500Image(item.poster_path),
|
||||
type: item.type,
|
||||
title: `${item.title}(${item.year})`,
|
||||
overview: `<span class="text-primary">${item.type}</span> ${item.overview}`,
|
||||
})
|
||||
|
||||
647
src/components/misc/MetalLogo3D.vue
Normal file
647
src/components/misc/MetalLogo3D.vue
Normal file
@@ -0,0 +1,647 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import * as THREE from 'three'
|
||||
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
|
||||
import logoUrl from '@images/logo.png'
|
||||
|
||||
type LogoPoint = readonly [number, number]
|
||||
|
||||
interface LogoFacetDefinition {
|
||||
points: readonly LogoPoint[]
|
||||
color: number
|
||||
cornerRadius?: number
|
||||
}
|
||||
|
||||
interface LogoPieceDefinition {
|
||||
points: readonly LogoPoint[]
|
||||
faceColor: number
|
||||
sideColor: number
|
||||
depth: number
|
||||
offsetZ: number
|
||||
cornerRadius: number
|
||||
facets: readonly LogoFacetDefinition[]
|
||||
}
|
||||
|
||||
const LOGO_VIEWBOX_CENTER = 96
|
||||
const LOGO_COORDINATE_SCALE = 1 / 80
|
||||
const LOGO_BEVEL_SIZE = 0.08 // 倒角水平扩张尺寸,增大以捕获更宽的高光带
|
||||
const LOGO_BEVEL_THICKNESS = 0.06 // 倒角绝对厚度
|
||||
const AUTO_ROTATION_SPEED = 0.3
|
||||
const MAX_TILT = 0.4
|
||||
const INITIAL_ROTATION_X = -0.09
|
||||
const INITIAL_ROTATION_Y = -0.16
|
||||
const LOGO_BASE_Y = 0.1
|
||||
|
||||
const LOGO_PIECES: readonly LogoPieceDefinition[] = [
|
||||
{
|
||||
points: [
|
||||
[96, 15],
|
||||
[24, 57],
|
||||
[24, 133],
|
||||
[48, 147],
|
||||
[48, 76],
|
||||
[96, 48],
|
||||
[120, 62],
|
||||
[120, 35],
|
||||
],
|
||||
faceColor: 0x9652e6,
|
||||
sideColor: 0x5c27ae,
|
||||
depth: 0.2,
|
||||
offsetZ: 0,
|
||||
cornerRadius: 4.8,
|
||||
facets: [
|
||||
{
|
||||
points: [
|
||||
[96, 19],
|
||||
[29, 58],
|
||||
[48, 72],
|
||||
[96, 44],
|
||||
[116, 56],
|
||||
[116, 38],
|
||||
],
|
||||
color: 0xb978ff,
|
||||
cornerRadius: 2.4,
|
||||
},
|
||||
{
|
||||
points: [
|
||||
[29, 61],
|
||||
[29, 130],
|
||||
[44, 139],
|
||||
[44, 78],
|
||||
],
|
||||
color: 0x7030ca,
|
||||
cornerRadius: 2.2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
points: [
|
||||
[144, 43],
|
||||
[168, 57],
|
||||
[168, 134],
|
||||
[96, 176],
|
||||
[72, 162],
|
||||
[72, 135],
|
||||
[96, 149],
|
||||
[144, 121],
|
||||
],
|
||||
faceColor: 0x8140d5,
|
||||
sideColor: 0x54229f,
|
||||
depth: 0.21,
|
||||
offsetZ: 0.006,
|
||||
cornerRadius: 4.8,
|
||||
facets: [
|
||||
{
|
||||
points: [
|
||||
[148, 49],
|
||||
[163, 59],
|
||||
[163, 130],
|
||||
[148, 121],
|
||||
],
|
||||
color: 0xa15cef,
|
||||
cornerRadius: 2.2,
|
||||
},
|
||||
{
|
||||
points: [
|
||||
[162, 134],
|
||||
[96, 171],
|
||||
[77, 159],
|
||||
[77, 141],
|
||||
[96, 153],
|
||||
[144, 125],
|
||||
],
|
||||
color: 0x722bd0,
|
||||
cornerRadius: 2.4,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
points: [
|
||||
[76, 64],
|
||||
[136, 96],
|
||||
[76, 128],
|
||||
],
|
||||
faceColor: 0x9a50eb,
|
||||
sideColor: 0x622cb4,
|
||||
depth: 0.23,
|
||||
offsetZ: 0.026,
|
||||
cornerRadius: 3.6,
|
||||
facets: [
|
||||
{
|
||||
points: [
|
||||
[80, 70],
|
||||
[130, 96],
|
||||
[80, 94],
|
||||
],
|
||||
color: 0xb978ff,
|
||||
cornerRadius: 1.8,
|
||||
},
|
||||
{
|
||||
points: [
|
||||
[80, 98],
|
||||
[130, 96],
|
||||
[80, 122],
|
||||
],
|
||||
color: 0x6f29d1,
|
||||
cornerRadius: 1.8,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const rootRef = ref<HTMLDivElement | null>(null)
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const isReady = ref(false)
|
||||
const hasWebGLError = ref(false)
|
||||
const isDragging = ref(false)
|
||||
|
||||
let renderer: THREE.WebGLRenderer | null = null
|
||||
let scene: THREE.Scene | null = null
|
||||
let camera: THREE.PerspectiveCamera | null = null
|
||||
let logoGroup: THREE.Group | null = null
|
||||
let environmentTexture: THREE.Texture | null = null
|
||||
let glowTexture: THREE.CanvasTexture | null = null
|
||||
let glowMaterial: THREE.SpriteMaterial | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let intersectionObserver: IntersectionObserver | null = null
|
||||
let reducedMotionQuery: MediaQueryList | null = null
|
||||
let animationFrameId = 0
|
||||
let previousFrameTime = 0
|
||||
let targetRotationX = INITIAL_ROTATION_X
|
||||
let targetRotationY = INITIAL_ROTATION_Y
|
||||
let dragVelocityY = 0
|
||||
let lastPointerX = 0
|
||||
let lastPointerY = 0
|
||||
let isIntersecting = true
|
||||
let prefersReducedMotion = false
|
||||
|
||||
/** 将原 Logo 的二维坐标转换到以画布中心为原点的 Three.js 坐标系。 */
|
||||
function convertLogoPoint([sourceX, sourceY]: LogoPoint) {
|
||||
return new THREE.Vector2(
|
||||
(sourceX - LOGO_VIEWBOX_CENTER) * LOGO_COORDINATE_SCALE,
|
||||
(LOGO_VIEWBOX_CENTER - sourceY) * LOGO_COORDINATE_SCALE,
|
||||
)
|
||||
}
|
||||
|
||||
/** 根据多边形轮廓生成带圆角的二维 Logo 形状。 */
|
||||
function createRoundedLogoShape(points: readonly LogoPoint[], sourceCornerRadius: number) {
|
||||
const vertices = points.map(convertLogoPoint)
|
||||
const cornerRadius = sourceCornerRadius * LOGO_COORDINATE_SCALE
|
||||
const corners = vertices.map((current, index) => {
|
||||
const previous = vertices[(index - 1 + vertices.length) % vertices.length]
|
||||
const next = vertices[(index + 1) % vertices.length]
|
||||
const incoming = previous.clone().sub(current)
|
||||
const outgoing = next.clone().sub(current)
|
||||
const entryDistance = Math.min(cornerRadius, incoming.length() * 0.32)
|
||||
const exitDistance = Math.min(cornerRadius, outgoing.length() * 0.32)
|
||||
|
||||
return {
|
||||
current,
|
||||
entry: current.clone().add(incoming.normalize().multiplyScalar(entryDistance)),
|
||||
exit: current.clone().add(outgoing.normalize().multiplyScalar(exitDistance)),
|
||||
}
|
||||
})
|
||||
const shape = new THREE.Shape()
|
||||
shape.moveTo(corners[0].exit.x, corners[0].exit.y)
|
||||
|
||||
for (let step = 1; step <= corners.length; step += 1) {
|
||||
const corner = corners[step % corners.length]
|
||||
shape.lineTo(corner.entry.x, corner.entry.y)
|
||||
shape.quadraticCurveTo(corner.current.x, corner.current.y, corner.exit.x, corner.exit.y)
|
||||
}
|
||||
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
/** 创建紫色哑光金属的正面材质:去除清漆与虹彩等“油润感”因素,仅依靠适中的粗糙度与环境反射呈现哑光滞面金属质感。 */
|
||||
function createFaceMaterial(color: number) {
|
||||
return new THREE.MeshPhysicalMaterial({
|
||||
color,
|
||||
metalness: 1.0, // 物理纯金属
|
||||
roughness: 0.32, // 哑光滞面,避免镜面般的过亮高光
|
||||
envMapIntensity: 1.6, // 适度的环境反射强度,避免出现过曝的大面积亮班
|
||||
})
|
||||
}
|
||||
|
||||
/** 创建偏深紫的挤出侧面材质,比正面稍粗糙,与光洁正面形成自然层次对比。 */
|
||||
function createSideMaterial(color: number) {
|
||||
return new THREE.MeshPhysicalMaterial({
|
||||
color,
|
||||
metalness: 0.95, // 纯粹侧边金属
|
||||
roughness: 0.42, // 侧面比正面更哑光,提升层次感
|
||||
envMapIntensity: 1.8, // 增强侧面在旋转时对环境光的敏感度
|
||||
})
|
||||
}
|
||||
|
||||
/** 在挤出主体正面叠加略微内收的金属折面,复现设计图中的明暗分区。 */
|
||||
function createFacetMesh(definition: LogoFacetDefinition, frontZ: number) {
|
||||
const geometry = new THREE.ShapeGeometry(createRoundedLogoShape(definition.points, definition.cornerRadius ?? 1.8), 8)
|
||||
geometry.translate(0, 0, frontZ)
|
||||
const material = createFaceMaterial(definition.color)
|
||||
material.polygonOffset = true
|
||||
material.polygonOffsetFactor = -1
|
||||
material.polygonOffsetUnits = -1
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
mesh.renderOrder = 2
|
||||
return mesh
|
||||
}
|
||||
|
||||
/** 创建一段带厚挤出、宽倒角和分区高光的紫色金属 Logo。 */
|
||||
function createLogoPiece(definition: LogoPieceDefinition) {
|
||||
const pieceGroup = new THREE.Group()
|
||||
const geometry = new THREE.ExtrudeGeometry(createRoundedLogoShape(definition.points, definition.cornerRadius), {
|
||||
depth: definition.depth,
|
||||
steps: 1,
|
||||
curveSegments: 12, // 提升折点平滑度
|
||||
bevelEnabled: true,
|
||||
bevelSegments: 12, // 大幅度提升倒角分段,打造极其圆润圆滑的边缘过渡
|
||||
bevelSize: LOGO_BEVEL_SIZE,
|
||||
bevelThickness: LOGO_BEVEL_THICKNESS,
|
||||
bevelOffset: -0.016, // 微调倒角向内偏移,控制体积膨胀感
|
||||
})
|
||||
geometry.translate(0, 0, definition.offsetZ - definition.depth / 2)
|
||||
geometry.computeVertexNormals()
|
||||
|
||||
const body = new THREE.Mesh(geometry, [
|
||||
createFaceMaterial(definition.faceColor),
|
||||
createSideMaterial(definition.sideColor),
|
||||
])
|
||||
body.renderOrder = 1
|
||||
pieceGroup.add(body)
|
||||
|
||||
const frontZ = definition.offsetZ + definition.depth / 2 + LOGO_BEVEL_THICKNESS + 0.004
|
||||
definition.facets.forEach(facet => pieceGroup.add(createFacetMesh(facet, frontZ)))
|
||||
return pieceGroup
|
||||
}
|
||||
|
||||
/** 组合断开六边形折带与双折面播放符号,形成完整 MoviePilot Logo。 */
|
||||
function createLogoModel() {
|
||||
const group = new THREE.Group()
|
||||
LOGO_PIECES.forEach(definition => group.add(createLogoPiece(definition)))
|
||||
group.position.y = LOGO_BASE_Y
|
||||
group.rotation.set(targetRotationX, targetRotationY, 0)
|
||||
return group
|
||||
}
|
||||
|
||||
/** 生成透明椭圆光斑纹理,作为 Logo 下方的紫色悬浮投影。 */
|
||||
function createGroundGlowTexture() {
|
||||
const glowCanvas = document.createElement('canvas')
|
||||
glowCanvas.width = 256
|
||||
glowCanvas.height = 256
|
||||
const context = glowCanvas.getContext('2d')
|
||||
if (!context) return null
|
||||
|
||||
const gradient = context.createRadialGradient(128, 128, 0, 128, 128, 128)
|
||||
gradient.addColorStop(0, 'rgba(177, 116, 255, 0.55)')
|
||||
gradient.addColorStop(0.34, 'rgba(119, 48, 255, 0.28)')
|
||||
gradient.addColorStop(1, 'rgba(65, 15, 132, 0)')
|
||||
context.fillStyle = gradient
|
||||
context.fillRect(0, 0, 256, 256)
|
||||
|
||||
const texture = new THREE.CanvasTexture(glowCanvas)
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
return texture
|
||||
}
|
||||
|
||||
/** 在场景中添加始终位于模型下方的柔和紫色光斑。 */
|
||||
function addGroundGlow(activeScene: THREE.Scene) {
|
||||
glowTexture = createGroundGlowTexture()
|
||||
if (!glowTexture) return
|
||||
|
||||
glowMaterial = new THREE.SpriteMaterial({
|
||||
map: glowTexture,
|
||||
color: 0xb06dff,
|
||||
opacity: 0.58,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
})
|
||||
const glow = new THREE.Sprite(glowMaterial)
|
||||
glow.position.set(0, -1.08, -0.7)
|
||||
glow.scale.set(2.25, 0.34, 1)
|
||||
glow.renderOrder = 0
|
||||
activeScene.add(glow)
|
||||
}
|
||||
|
||||
/** 配置突出紫色镜面、银紫倒角与背部轮廓的摄影棚布光。 */
|
||||
function configureLighting(activeRenderer: THREE.WebGLRenderer, activeScene: THREE.Scene) {
|
||||
const pmremGenerator = new THREE.PMREMGenerator(activeRenderer)
|
||||
const roomEnvironment = new RoomEnvironment()
|
||||
environmentTexture = pmremGenerator.fromScene(roomEnvironment, 0.035).texture
|
||||
activeScene.environment = environmentTexture
|
||||
activeScene.environmentIntensity = 1.15
|
||||
roomEnvironment.dispose()
|
||||
pmremGenerator.dispose()
|
||||
|
||||
const keyLight = new THREE.DirectionalLight(0xfff8ef, 4.6)
|
||||
keyLight.position.set(-3.6, 4.7, 5.4)
|
||||
activeScene.add(keyLight)
|
||||
|
||||
const coolFillLight = new THREE.DirectionalLight(0x9bc7ff, 1.35)
|
||||
coolFillLight.position.set(-4.4, -1.2, 3.2)
|
||||
activeScene.add(coolFillLight)
|
||||
|
||||
const rimLight = new THREE.DirectionalLight(0xe6b7ff, 4.1)
|
||||
rimLight.position.set(3.8, 2.8, -4.8)
|
||||
activeScene.add(rimLight)
|
||||
|
||||
const violetBounceLight = new THREE.PointLight(0x6422c9, 3.2, 7, 2)
|
||||
violetBounceLight.position.set(2.5, -2.4, 2.4)
|
||||
activeScene.add(violetBounceLight)
|
||||
|
||||
activeScene.add(new THREE.HemisphereLight(0xe9f2ff, 0x260441, 0.62))
|
||||
}
|
||||
|
||||
/** 按容器实际尺寸与设备像素比同步渲染器。 */
|
||||
function resizeRenderer() {
|
||||
if (!renderer || !camera || !rootRef.value) return
|
||||
|
||||
const { width, height } = rootRef.value.getBoundingClientRect()
|
||||
if (!width || !height) return
|
||||
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||
renderer.setSize(width, height, false)
|
||||
camera.aspect = width / height
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
/** 逐帧更新完整转台旋转、拖拽惯性与轻微悬浮位移。 */
|
||||
function renderFrame(frameTime: number) {
|
||||
animationFrameId = window.requestAnimationFrame(renderFrame)
|
||||
if (!renderer || !scene || !camera || !logoGroup || !isIntersecting) return
|
||||
|
||||
const delta = previousFrameTime ? Math.min((frameTime - previousFrameTime) / 1000, 0.05) : 0
|
||||
previousFrameTime = frameTime
|
||||
|
||||
if (!isDragging.value && !prefersReducedMotion) {
|
||||
targetRotationY += (AUTO_ROTATION_SPEED + dragVelocityY) * delta
|
||||
dragVelocityY *= Math.exp(-4.2 * delta)
|
||||
}
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
logoGroup.rotation.set(targetRotationX, targetRotationY, 0)
|
||||
logoGroup.position.y = LOGO_BASE_Y
|
||||
} else {
|
||||
const easing = 1 - Math.exp(-12 * delta)
|
||||
logoGroup.rotation.x += (targetRotationX - logoGroup.rotation.x) * easing
|
||||
logoGroup.rotation.y += (targetRotationY - logoGroup.rotation.y) * easing
|
||||
logoGroup.position.y = LOGO_BASE_Y + Math.sin(frameTime * 0.0011) * 0.018
|
||||
}
|
||||
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
|
||||
/** 初始化 Three.js 场景;不支持 WebGL 时切换到静态 Logo。 */
|
||||
function initializeScene() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
powerPreference: 'high-performance',
|
||||
})
|
||||
renderer.setClearColor(0x000000, 0)
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
renderer.toneMappingExposure = 1.12
|
||||
|
||||
scene = new THREE.Scene()
|
||||
camera = new THREE.PerspectiveCamera(28, 1, 0.1, 100)
|
||||
camera.position.set(0, 0.02, 5.05)
|
||||
camera.lookAt(0, 0, 0)
|
||||
configureLighting(renderer, scene)
|
||||
addGroundGlow(scene)
|
||||
|
||||
logoGroup = createLogoModel()
|
||||
scene.add(logoGroup)
|
||||
resizeRenderer()
|
||||
renderer.render(scene, camera)
|
||||
isReady.value = true
|
||||
animationFrameId = window.requestAnimationFrame(renderFrame)
|
||||
} catch (error) {
|
||||
console.warn('无法初始化登录页 3D Logo,已回退到静态图标。', error)
|
||||
hasWebGLError.value = true
|
||||
isReady.value = false
|
||||
disposeScene()
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理拖拽开始并捕获指针,保证触屏滑动连续。 */
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!isReady.value) return
|
||||
isDragging.value = true
|
||||
dragVelocityY = 0
|
||||
lastPointerX = event.clientX
|
||||
lastPointerY = event.clientY
|
||||
rootRef.value?.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
/** 根据指针位移更新 Logo 水平旋转与受限俯仰角。 */
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!isDragging.value) return
|
||||
|
||||
const deltaX = event.clientX - lastPointerX
|
||||
const deltaY = event.clientY - lastPointerY
|
||||
targetRotationY += deltaX * 0.012
|
||||
targetRotationX = THREE.MathUtils.clamp(targetRotationX + deltaY * 0.008, -MAX_TILT, MAX_TILT)
|
||||
dragVelocityY = THREE.MathUtils.clamp(deltaX * 0.08, -2.4, 2.4)
|
||||
lastPointerX = event.clientX
|
||||
lastPointerY = event.clientY
|
||||
}
|
||||
|
||||
/** 结束指针拖拽并释放捕获。 */
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
if (rootRef.value?.hasPointerCapture(event.pointerId)) rootRef.value.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
/** 支持方向键旋转 Logo,提供无鼠标交互能力。 */
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const rotationStep = Math.PI / 10
|
||||
if (event.key === 'ArrowLeft') targetRotationY -= rotationStep
|
||||
else if (event.key === 'ArrowRight') targetRotationY += rotationStep
|
||||
else if (event.key === 'ArrowUp') targetRotationX = Math.max(targetRotationX - rotationStep / 2, -MAX_TILT)
|
||||
else if (event.key === 'ArrowDown') targetRotationX = Math.min(targetRotationX + rotationStep / 2, MAX_TILT)
|
||||
else return
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
/** 同步系统减少动态偏好,关闭自动旋转但保留手动交互。 */
|
||||
function handleReducedMotionChange(event?: MediaQueryListEvent) {
|
||||
prefersReducedMotion = event?.matches ?? reducedMotionQuery?.matches ?? false
|
||||
}
|
||||
|
||||
/** 仅在组件进入视口时持续渲染,降低后台 GPU 占用。 */
|
||||
function handleIntersection(entries: IntersectionObserverEntry[]) {
|
||||
isIntersecting = entries[0]?.isIntersecting ?? true
|
||||
if (isIntersecting) previousFrameTime = performance.now()
|
||||
}
|
||||
|
||||
/** WebGL 上下文丢失时停止渲染并显示静态回退 Logo。 */
|
||||
function handleContextLost(event: Event) {
|
||||
event.preventDefault()
|
||||
if (animationFrameId) window.cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = 0
|
||||
hasWebGLError.value = true
|
||||
isReady.value = false
|
||||
}
|
||||
|
||||
/** 释放指定 3D 对象树中的几何体、材质与贴图资源。 */
|
||||
function disposeObjectResources(root: THREE.Object3D) {
|
||||
const disposedGeometries = new Set<THREE.BufferGeometry>()
|
||||
const disposedMaterials = new Set<THREE.Material>()
|
||||
const disposedTextures = new Set<THREE.Texture>()
|
||||
|
||||
root.traverse(object => {
|
||||
if (!(object instanceof THREE.Mesh)) return
|
||||
|
||||
if (!disposedGeometries.has(object.geometry)) {
|
||||
object.geometry.dispose()
|
||||
disposedGeometries.add(object.geometry)
|
||||
}
|
||||
|
||||
const materials = Array.isArray(object.material) ? object.material : [object.material]
|
||||
materials.forEach(material => {
|
||||
if (disposedMaterials.has(material)) return
|
||||
const mappedMaterial = material as THREE.Material & { map?: THREE.Texture | null }
|
||||
if (mappedMaterial.map && !disposedTextures.has(mappedMaterial.map)) {
|
||||
mappedMaterial.map.dispose()
|
||||
disposedTextures.add(mappedMaterial.map)
|
||||
}
|
||||
material.dispose()
|
||||
disposedMaterials.add(material)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 释放场景中的几何体、材质、环境贴图和 WebGL 上下文。 */
|
||||
function disposeScene() {
|
||||
if (animationFrameId) window.cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = 0
|
||||
|
||||
if (scene) disposeObjectResources(scene)
|
||||
environmentTexture?.dispose()
|
||||
glowMaterial?.dispose()
|
||||
glowTexture?.dispose()
|
||||
renderer?.dispose()
|
||||
scene = null
|
||||
camera = null
|
||||
logoGroup = null
|
||||
renderer = null
|
||||
environmentTexture = null
|
||||
glowTexture = null
|
||||
glowMaterial = null
|
||||
}
|
||||
|
||||
/** 注册尺寸、可见性和动态偏好监听并启动场景。 */
|
||||
function handleMounted() {
|
||||
reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
handleReducedMotionChange()
|
||||
reducedMotionQuery.addEventListener('change', handleReducedMotionChange)
|
||||
|
||||
if (rootRef.value) {
|
||||
resizeObserver = new ResizeObserver(resizeRenderer)
|
||||
resizeObserver.observe(rootRef.value)
|
||||
intersectionObserver = new IntersectionObserver(handleIntersection, { threshold: 0.05 })
|
||||
intersectionObserver.observe(rootRef.value)
|
||||
}
|
||||
|
||||
canvasRef.value?.addEventListener('webglcontextlost', handleContextLost)
|
||||
initializeScene()
|
||||
}
|
||||
|
||||
/** 移除监听器并完整销毁 Three.js 场景。 */
|
||||
function handleBeforeUnmount() {
|
||||
resizeObserver?.disconnect()
|
||||
intersectionObserver?.disconnect()
|
||||
reducedMotionQuery?.removeEventListener('change', handleReducedMotionChange)
|
||||
canvasRef.value?.removeEventListener('webglcontextlost', handleContextLost)
|
||||
disposeScene()
|
||||
}
|
||||
|
||||
onMounted(handleMounted)
|
||||
onBeforeUnmount(handleBeforeUnmount)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="metal-logo-3d"
|
||||
:class="{ 'metal-logo-3d--dragging': isDragging, 'metal-logo-3d--ready': isReady }"
|
||||
role="img"
|
||||
tabindex="0"
|
||||
aria-label="MoviePilot 3D metal logo"
|
||||
@keydown="handleKeydown"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointercancel="handlePointerUp"
|
||||
>
|
||||
<canvas ref="canvasRef" class="metal-logo-3d__canvas" aria-hidden="true" />
|
||||
<img v-if="hasWebGLError" :src="logoUrl" class="metal-logo-3d__fallback" alt="MoviePilot" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.metal-logo-3d {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
block-size: 112px;
|
||||
cursor: grab;
|
||||
inline-size: 112px;
|
||||
outline: none;
|
||||
touch-action: none;
|
||||
|
||||
&:focus-visible {
|
||||
border-radius: 8px;
|
||||
outline: 2px solid rgba(var(--v-theme-primary), 0.78);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.metal-logo-3d--dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.metal-logo-3d__canvas,
|
||||
.metal-logo-3d__fallback {
|
||||
display: block;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.metal-logo-3d__canvas {
|
||||
filter: drop-shadow(0 9px 9px rgba(16, 6, 34, 24%)) drop-shadow(0 0 7px rgba(132, 70, 255, 16%));
|
||||
opacity: 0;
|
||||
transition: opacity 350ms ease;
|
||||
}
|
||||
|
||||
.metal-logo-3d--ready .metal-logo-3d__canvas {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.metal-logo-3d__fallback {
|
||||
padding: 12px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (width <= 480px) {
|
||||
.metal-logo-3d {
|
||||
block-size: 104px;
|
||||
inline-size: 104px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.metal-logo-3d__canvas {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3538
src/components/misc/OpticalLogoLab.vue
Normal file
3538
src/components/misc/OpticalLogoLab.vue
Normal file
File diff suppressed because it is too large
Load Diff
247
src/components/misc/PrismaticLogo.vue
Normal file
247
src/components/misc/PrismaticLogo.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import logoUrl from '@images/logo.svg'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
animate?: boolean
|
||||
intensity?: number
|
||||
}>(),
|
||||
{
|
||||
animate: true,
|
||||
intensity: 45,
|
||||
},
|
||||
)
|
||||
|
||||
const rootRef = ref<HTMLSpanElement | null>(null)
|
||||
const logoMaskStyle = computed(() => ({
|
||||
'--logo-mask': `url("${logoUrl}")`,
|
||||
'--prism-intensity': Math.min(1, Math.max(0, props.intensity / 100)),
|
||||
}))
|
||||
|
||||
let pointerFrame: number | null = null
|
||||
let pendingPointerX = 0.5
|
||||
let pendingPointerY = 0.42
|
||||
|
||||
/** 将指针位置映射为同一套棱镜反射和轻微空间倾角。 */
|
||||
function renderPointerResponse() {
|
||||
pointerFrame = null
|
||||
const root = rootRef.value
|
||||
if (!root) return
|
||||
|
||||
root.style.setProperty('--logo-light-x', `${(pendingPointerX * 100).toFixed(2)}%`)
|
||||
root.style.setProperty('--logo-light-y', `${(pendingPointerY * 100).toFixed(2)}%`)
|
||||
root.style.setProperty('--logo-tilt-x', `${((0.5 - pendingPointerY) * 7).toFixed(2)}deg`)
|
||||
root.style.setProperty('--logo-tilt-y', `${((pendingPointerX - 0.5) * 9).toFixed(2)}deg`)
|
||||
}
|
||||
|
||||
function queuePointerResponse() {
|
||||
if (pointerFrame === null) pointerFrame = window.requestAnimationFrame(renderPointerResponse)
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch') return
|
||||
const bounds = rootRef.value?.getBoundingClientRect()
|
||||
if (!bounds?.width || !bounds.height) return
|
||||
|
||||
pendingPointerX = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width))
|
||||
pendingPointerY = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height))
|
||||
queuePointerResponse()
|
||||
}
|
||||
|
||||
function resetPointerResponse() {
|
||||
pendingPointerX = 0.5
|
||||
pendingPointerY = 0.42
|
||||
queuePointerResponse()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pointerFrame !== null) window.cancelAnimationFrame(pointerFrame)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
ref="rootRef"
|
||||
class="prismatic-logo"
|
||||
:class="{ 'prismatic-logo--animated': props.animate }"
|
||||
:style="logoMaskStyle"
|
||||
role="img"
|
||||
aria-label="MoviePilot"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerleave="resetPointerResponse"
|
||||
>
|
||||
<img :src="logoUrl" class="prismatic-logo__base" alt="" draggable="false" aria-hidden="true" />
|
||||
<span class="prismatic-logo__spectrum" aria-hidden="true" />
|
||||
<span class="prismatic-logo__specular" aria-hidden="true" />
|
||||
<span class="prismatic-logo__reveal" aria-hidden="true" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.prismatic-logo {
|
||||
--logo-light-x: 50%;
|
||||
--logo-light-y: 42%;
|
||||
--logo-tilt-x: 0deg;
|
||||
--logo-tilt-y: 0deg;
|
||||
|
||||
position: relative;
|
||||
display: grid;
|
||||
isolation: isolate;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
place-items: center;
|
||||
transform: perspective(520px) rotateX(var(--logo-tilt-x)) rotateY(var(--logo-tilt-y));
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.prismatic-logo--animated {
|
||||
animation: prismatic-logo-enter 620ms cubic-bezier(0.16, 1, 0.3, 1) 80ms backwards;
|
||||
}
|
||||
|
||||
.prismatic-logo::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(var(--v-theme-primary), 0.34),
|
||||
rgba(var(--v-theme-primary), 0.1) 44%,
|
||||
transparent 72%
|
||||
);
|
||||
content: '';
|
||||
filter: blur(13px);
|
||||
inset: 17%;
|
||||
opacity: calc(0.36 + var(--prism-intensity) * 0.56);
|
||||
transform: translate3d(0, 8px, -16px) scaleX(1.18);
|
||||
}
|
||||
|
||||
.prismatic-logo__base,
|
||||
.prismatic-logo__spectrum,
|
||||
.prismatic-logo__specular,
|
||||
.prismatic-logo__reveal {
|
||||
position: absolute;
|
||||
display: block;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.prismatic-logo__base {
|
||||
filter:
|
||||
drop-shadow(0 8px 12px rgba(24, 8, 52, 0.3))
|
||||
drop-shadow(0 0 10px rgba(139, 92, 246, 0.22));
|
||||
object-fit: contain;
|
||||
padding: 7px;
|
||||
transform: translateZ(10px);
|
||||
}
|
||||
|
||||
.prismatic-logo__spectrum,
|
||||
.prismatic-logo__specular,
|
||||
.prismatic-logo__reveal {
|
||||
-webkit-mask: var(--logo-mask) center / calc(100% - 14px) calc(100% - 14px) no-repeat;
|
||||
mask: var(--logo-mask) center / calc(100% - 14px) calc(100% - 14px) no-repeat;
|
||||
}
|
||||
|
||||
.prismatic-logo__spectrum {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at var(--logo-light-x) var(--logo-light-y),
|
||||
rgba(255, 255, 255, 0.95),
|
||||
rgba(210, 188, 255, 0.66) 13%,
|
||||
transparent 36%
|
||||
),
|
||||
conic-gradient(
|
||||
from 218deg at var(--logo-light-x) var(--logo-light-y),
|
||||
rgba(255, 105, 210, 0.72),
|
||||
rgba(117, 212, 255, 0.72),
|
||||
rgba(177, 139, 255, 0.82),
|
||||
rgba(255, 214, 246, 0.7),
|
||||
rgba(255, 105, 210, 0.72)
|
||||
);
|
||||
mix-blend-mode: screen;
|
||||
opacity: calc(0.22 + var(--prism-intensity) * 0.58);
|
||||
transform: translateZ(14px);
|
||||
}
|
||||
|
||||
.prismatic-logo__specular {
|
||||
background: radial-gradient(
|
||||
ellipse 28% 20% at var(--logo-light-x) var(--logo-light-y),
|
||||
rgba(255, 255, 255, 0.96),
|
||||
rgba(232, 219, 255, 0.52) 28%,
|
||||
transparent 72%
|
||||
);
|
||||
mix-blend-mode: screen;
|
||||
opacity: calc(0.3 + var(--prism-intensity) * 0.66);
|
||||
transform: translateZ(18px);
|
||||
}
|
||||
|
||||
.prismatic-logo__reveal {
|
||||
background: linear-gradient(
|
||||
112deg,
|
||||
transparent 30%,
|
||||
rgba(255, 255, 255, 0.18) 39%,
|
||||
rgba(255, 255, 255, 0.98) 48%,
|
||||
rgba(125, 211, 252, 0.62) 54%,
|
||||
transparent 66%
|
||||
);
|
||||
background-position: 100% 50%;
|
||||
background-size: 300% 100%;
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0;
|
||||
transform: translateZ(20px);
|
||||
}
|
||||
|
||||
.prismatic-logo--animated .prismatic-logo__reveal {
|
||||
animation: prismatic-logo-reveal 840ms cubic-bezier(0.2, 0.76, 0.18, 1) 160ms both;
|
||||
}
|
||||
|
||||
@keyframes prismatic-logo-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: perspective(520px) translateY(10px) scale(0.82) rotateX(-8deg) rotateY(10deg);
|
||||
}
|
||||
|
||||
62% {
|
||||
opacity: 1;
|
||||
transform: perspective(520px) translateY(-2px) scale(1.025) rotateX(1deg) rotateY(-1deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: perspective(520px) translateY(0) scale(1) rotateX(0) rotateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes prismatic-logo-reveal {
|
||||
0% {
|
||||
background-position: 100% 50%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
24% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.prismatic-logo,
|
||||
.prismatic-logo__reveal {
|
||||
animation: none;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.prismatic-logo__reveal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -60,6 +60,9 @@ const trailingSpaceWidth = computed(() => {
|
||||
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取容器宽度不可用时的兜底视口宽度。
|
||||
*/
|
||||
function getFallbackViewportWidth() {
|
||||
if (typeof window === 'undefined') {
|
||||
return itemStep.value * Math.max(props.overscanItems, 1)
|
||||
@@ -69,6 +72,9 @@ function getFallbackViewportWidth() {
|
||||
return Math.max(window.innerWidth, itemStep.value * Math.max(props.overscanItems, 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析虚拟列表项的稳定 key。
|
||||
*/
|
||||
function resolveItemKey(item: any, index: number) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, startIndex.value + index)
|
||||
@@ -77,6 +83,9 @@ function resolveItemKey(item: any, index: number) {
|
||||
return startIndex.value + index
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置滚动状态指示计时器。
|
||||
*/
|
||||
function resetScrollIndicatorTimer() {
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
@@ -88,6 +97,9 @@ function resetScrollIndicatorTimer() {
|
||||
}, scrollTimeoutDuration)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前滚动位置更新虚拟渲染范围。
|
||||
*/
|
||||
function updateVisibleRange() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) {
|
||||
@@ -113,6 +125,9 @@ function updateVisibleRange() {
|
||||
endIndex.value = Math.max(firstVisible + 1, lastVisible)
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步左右导航按钮的可用状态。
|
||||
*/
|
||||
function updateDisabledState() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
@@ -130,11 +145,17 @@ function updateDisabledState() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步虚拟列表布局与导航状态。
|
||||
*/
|
||||
function syncLayoutState() {
|
||||
updateVisibleRange()
|
||||
updateDisabledState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前可视范围向左或向右滚动一屏。
|
||||
*/
|
||||
function slideNext(next: boolean) {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
@@ -159,6 +180,9 @@ function slideNext(next: boolean) {
|
||||
resetScrollIndicatorTimer()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理内容滚动并刷新滚动指示状态。
|
||||
*/
|
||||
function handleContentScroll() {
|
||||
syncLayoutState()
|
||||
resetScrollIndicatorTimer()
|
||||
@@ -257,28 +281,20 @@ watch(
|
||||
<VBtn
|
||||
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-left"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
variant="tonal"
|
||||
icon="mdi-chevron-left"
|
||||
color="white"
|
||||
@click.stop="slideNext(false)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
/>
|
||||
|
||||
<VBtn
|
||||
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-right"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
variant="tonal"
|
||||
icon="mdi-chevron-right"
|
||||
color="white"
|
||||
@click.stop="slideNext(true)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -402,17 +418,18 @@ watch(
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 14%);
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(var(--v-theme-background), 0.3);
|
||||
block-size: 36px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
|
||||
background: rgba(8, 18, 28, 52%) !important;
|
||||
block-size: 40px;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 22%);
|
||||
color: rgb(255, 255, 255);
|
||||
cursor: pointer;
|
||||
inline-size: 36px;
|
||||
inline-size: 40px;
|
||||
inset-block-start: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||
transform: translateY(-50%);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
@@ -421,21 +438,22 @@ watch(
|
||||
box-shadow 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
|
||||
svg {
|
||||
block-size: 22px;
|
||||
fill: currentcolor;
|
||||
filter: none;
|
||||
inline-size: 22px;
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
:deep(.v-icon) {
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 55%));
|
||||
font-size: 28px;
|
||||
opacity: 1;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
border-color: rgba(255, 255, 255, 28%);
|
||||
background: rgba(8, 18, 28, 68%) !important;
|
||||
box-shadow: 0 10px 26px rgba(0, 0, 0, 28%);
|
||||
color: rgb(255, 255, 255);
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
:deep(.v-icon) {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ function clearThemeCustomizerOpenState() {
|
||||
syncThemeCustomizerOpenState(false)
|
||||
}
|
||||
|
||||
// 处理固定面板的全局 Esc 关闭快捷键。
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
// 固定侧栏不再依赖 Vuetify overlay,手动补上常见的 Esc 关闭行为。
|
||||
if (event.key === 'Escape') emit('close')
|
||||
@@ -146,16 +147,19 @@ const showSemiDarkMenuOption = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 打开原生颜色选择器。
|
||||
function openColorPicker() {
|
||||
customColorInput.value?.click()
|
||||
}
|
||||
|
||||
// 处理原生颜色选择器的自定义主色输入。
|
||||
function handleCustomColorInput(event: Event) {
|
||||
const color = (event.target as HTMLInputElement).value
|
||||
|
||||
setPrimaryColor(color)
|
||||
}
|
||||
|
||||
// 切换布局类型,App 模式下保留移动端固定布局。
|
||||
function handleLayoutChange(layout: ThemeCustomizerLayout) {
|
||||
// App 模式固定使用移动端导航,避免切换桌面布局后破坏底部导航体验。
|
||||
if (appMode.value) return
|
||||
@@ -176,6 +180,7 @@ function handleShadowSliderChange(value: unknown) {
|
||||
if (themeCustomizerShadowLevels.includes(shadow)) setShadow(shadow)
|
||||
}
|
||||
|
||||
// 重置主题定制设置,App 模式只恢复可调整的外观项。
|
||||
async function handleResetSettings() {
|
||||
if (!appMode.value) {
|
||||
await resetSettings()
|
||||
@@ -217,7 +222,7 @@ async function handleResetSettings() {
|
||||
|
||||
<VDivider />
|
||||
|
||||
<PerfectScrollbar class="theme-customizer-body" :options="{ wheelPropagation: false }">
|
||||
<div class="theme-customizer-body">
|
||||
<section class="theme-customizer-section">
|
||||
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.primaryColor') }}</h3>
|
||||
<div class="theme-customizer-color-grid">
|
||||
@@ -277,14 +282,15 @@ async function handleResetSettings() {
|
||||
:class="{ 'is-active': settings.skin === skin.value }"
|
||||
@click="setSkin(skin.value)"
|
||||
>
|
||||
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${skin.value}`">
|
||||
<span class="mini-sidebar">
|
||||
<i />
|
||||
<i />
|
||||
<span class="theme-customizer-border-scene" :class="`theme-customizer-border-scene--${skin.value}`">
|
||||
<span class="theme-customizer-border-scene__card">
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<span class="mini-content">
|
||||
<span class="theme-customizer-border-scene__dialog">
|
||||
<i />
|
||||
</span>
|
||||
<span class="theme-customizer-border-scene__menu">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
@@ -400,7 +406,7 @@ async function handleResetSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PerfectScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -438,11 +444,17 @@ async function handleResetSettings() {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
block-size: 100vh;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
@supports (block-size: 100dvh) {
|
||||
.theme-customizer-panel {
|
||||
block-size: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-panel--dialog {
|
||||
overflow: hidden;
|
||||
block-size: 100%;
|
||||
@@ -487,18 +499,15 @@ async function handleResetSettings() {
|
||||
.theme-customizer-body {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-ms-overflow-style: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.ps__rail-x),
|
||||
:deep(.ps__rail-y) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-section {
|
||||
@@ -641,6 +650,7 @@ async function handleResetSettings() {
|
||||
box-shadow: none !important;
|
||||
|
||||
.theme-customizer-mini-layout,
|
||||
.theme-customizer-border-scene,
|
||||
.theme-customizer-radius-scene {
|
||||
border-width: 2px;
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
@@ -696,6 +706,72 @@ async function handleResetSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-border-scene {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--v-theme-on-surface), 0.02), rgba(var(--v-theme-on-surface), 0.05)),
|
||||
rgb(var(--v-theme-surface));
|
||||
block-size: 74px;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 92px;
|
||||
}
|
||||
|
||||
.theme-customizer-border-scene__card,
|
||||
.theme-customizer-border-scene__dialog,
|
||||
.theme-customizer-border-scene__menu {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0 solid rgba(var(--v-theme-on-surface), 0.14);
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--v-theme-surface), 0.92);
|
||||
gap: 5px;
|
||||
padding: 7px;
|
||||
box-shadow: var(--app-elevation-0);
|
||||
}
|
||||
|
||||
.theme-customizer-border-scene__card {
|
||||
block-size: 36px;
|
||||
inline-size: 46%;
|
||||
inset-block-start: 12px;
|
||||
inset-inline-start: 10px;
|
||||
}
|
||||
|
||||
.theme-customizer-border-scene__dialog {
|
||||
block-size: 42px;
|
||||
inline-size: 38%;
|
||||
inset-block-start: 18px;
|
||||
inset-inline-end: 13px;
|
||||
}
|
||||
|
||||
.theme-customizer-border-scene__menu {
|
||||
block-size: 30px;
|
||||
inline-size: 30%;
|
||||
inset-block-end: 10px;
|
||||
inset-inline-start: 34%;
|
||||
}
|
||||
|
||||
.theme-customizer-border-scene__card i,
|
||||
.theme-customizer-border-scene__dialog i,
|
||||
.theme-customizer-border-scene__menu i {
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.1);
|
||||
block-size: 5px;
|
||||
}
|
||||
|
||||
.theme-customizer-border-scene--bordered {
|
||||
.theme-customizer-border-scene__card,
|
||||
.theme-customizer-border-scene__dialog,
|
||||
.theme-customizer-border-scene__menu {
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.mini-sidebar,
|
||||
.mini-content {
|
||||
display: flex;
|
||||
@@ -721,14 +797,6 @@ async function handleResetSettings() {
|
||||
block-size: 18px;
|
||||
}
|
||||
|
||||
.theme-customizer-mini-layout--bordered {
|
||||
.mini-content i,
|
||||
.mini-sidebar i {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
598
src/composables/__tests__/useMediaSubscribe.spec.ts
Normal file
598
src/composables/__tests__/useMediaSubscribe.spec.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
import type { MediaInfo, MediaSeason, Subscribe } from '@/api/types'
|
||||
import {
|
||||
getMediaSubscribeId,
|
||||
getSubscribeMode,
|
||||
type SeasonSubscribeModes,
|
||||
type SubscribeMode,
|
||||
useMediaSubscribe,
|
||||
} from '@/composables/useMediaSubscribe'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import {
|
||||
createSubscribe,
|
||||
createSubscribeMovie,
|
||||
createSubscribeTv,
|
||||
} from '@tests/support/factories/subscribe'
|
||||
import {
|
||||
createSubscribeHandler,
|
||||
defaultSubscribeConfigHandler,
|
||||
deleteSubscribeByMediaHandler,
|
||||
querySubscribeByMediaHandler,
|
||||
updateSubscribeHandler,
|
||||
} from '@tests/support/msw/handlers/subscribe'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
cacheStatus: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
doneProgress: vi.fn(),
|
||||
onEditRemove: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
startProgress: 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('@/api/nprogress', () => ({
|
||||
configureNProgress: vi.fn(),
|
||||
doneNProgress: mocks.doneProgress,
|
||||
startNProgress: mocks.startProgress,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/mediaStatusCache', () => ({
|
||||
setCachedMediaSubscribeStatus: (...args: unknown[]) => mocks.cacheStatus(...args),
|
||||
}))
|
||||
|
||||
interface MultiSeasonInput {
|
||||
modes?: SubscribeMode | SeasonSubscribeModes
|
||||
seasons?: MediaSeason[]
|
||||
visible?: number[]
|
||||
}
|
||||
|
||||
interface HarnessOptions {
|
||||
actionSeason?: number | null
|
||||
canSubscribe?: boolean
|
||||
isExists?: boolean
|
||||
isSubscribed?: boolean
|
||||
media?: MediaInfo
|
||||
modes?: SeasonSubscribeModes
|
||||
multi?: MultiSeasonInput
|
||||
seasonsMap?: Record<number, boolean>
|
||||
subscribedSeasons?: number[]
|
||||
useSeasonMap?: boolean
|
||||
}
|
||||
|
||||
async function renderSubscribeHarness(options: HarnessOptions = {}) {
|
||||
const media = options.media
|
||||
const actionSeason = options.actionSeason ?? (media?.type === '电视剧' ? media.season ?? 1 : null)
|
||||
const Harness = defineComponent({
|
||||
name: 'MediaSubscribeHarness',
|
||||
setup() {
|
||||
const isSubscribed = ref(options.isSubscribed ?? false)
|
||||
const seasonsSubscribed = ref<Record<number, boolean>>({ ...(options.seasonsMap ?? {}) })
|
||||
const subscribedSeasons = ref([...(options.subscribedSeasons ?? [])])
|
||||
const subscribedSeasonModes = ref<SeasonSubscribeModes>({ ...(options.modes ?? {}) })
|
||||
const checkResult = ref('idle')
|
||||
const actions = useMediaSubscribe({
|
||||
canSubscribe: () => options.canSubscribe ?? true,
|
||||
getSubscribeStatusKey: season => `status:${season ?? 'all'}`,
|
||||
isExists: () => options.isExists ?? false,
|
||||
isSubscribed,
|
||||
media: () => media,
|
||||
onEditRemove: mocks.onEditRemove,
|
||||
primarySeason: () => media?.season ?? null,
|
||||
seasonsSubscribed: options.useSeasonMap ? seasonsSubscribed : undefined,
|
||||
subscribedSeasonModes,
|
||||
subscribedSeasons,
|
||||
})
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
checkResult.value = (await actions.checkSubscribe(actionSeason)) ? 'subscribed' : 'missing'
|
||||
} catch {
|
||||
checkResult.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function alignSeasons() {
|
||||
actions.subscribeSeasons(
|
||||
options.multi?.seasons ?? [],
|
||||
{},
|
||||
'episode-group-1',
|
||||
options.multi?.modes ?? 'normal',
|
||||
options.multi?.visible ?? [],
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
addBestFull: () => actions.addSubscribe(actionSeason, { best_version: 1, best_version_full: 1 }),
|
||||
addNormal: () => actions.addSubscribe(actionSeason),
|
||||
alignSeasons,
|
||||
check,
|
||||
checkResult,
|
||||
handlePrimary: () => actions.handleSubscribe(),
|
||||
handleSeason: () => actions.handleSubscribe(actionSeason, 'episode-group-entry'),
|
||||
isSubscribed,
|
||||
modes: subscribedSeasonModes,
|
||||
openSeason: () => actions.openSubscribeSeasonDialog(actionSeason, 'episode-group-entry'),
|
||||
remove: () => actions.removeSubscribe(actionSeason),
|
||||
seasons: subscribedSeasons,
|
||||
seasonsMap: seasonsSubscribed,
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<button type="button" @click="handlePrimary">primary</button>
|
||||
<button type="button" @click="handleSeason">season</button>
|
||||
<button type="button" @click="addNormal">add-normal</button>
|
||||
<button type="button" @click="addBestFull">add-best-full</button>
|
||||
<button type="button" @click="remove">remove</button>
|
||||
<button type="button" @click="check">check</button>
|
||||
<button type="button" @click="openSeason">open-season</button>
|
||||
<button type="button" @click="alignSeasons">align-seasons</button>
|
||||
<output data-testid="subscribed">{{ String(isSubscribed) }}</output>
|
||||
<output data-testid="seasons">{{ JSON.stringify(seasons) }}</output>
|
||||
<output data-testid="season-map">{{ JSON.stringify(seasonsMap) }}</output>
|
||||
<output data-testid="modes">{{ JSON.stringify(modes) }}</output>
|
||||
<output data-testid="check-result">{{ checkResult }}</output>
|
||||
`,
|
||||
})
|
||||
|
||||
return renderWithProviders(Harness, {
|
||||
initialState: {
|
||||
user: {
|
||||
superUser: false,
|
||||
userName: 'tester',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getDialogCall(index = 0) {
|
||||
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
|
||||
unknown,
|
||||
Record<string, unknown>,
|
||||
Record<string, (...args: any[]) => unknown>,
|
||||
Record<string, unknown>,
|
||||
]
|
||||
return { events, options, props }
|
||||
}
|
||||
|
||||
describe('media subscribe identifiers and modes', () => {
|
||||
it.each([
|
||||
['TMDB before all fallback identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: 10 }, 'tmdb:10'],
|
||||
['Douban before Bangumi and generic identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: undefined }, 'douban:20'],
|
||||
['Bangumi before a generic identifier', { bangumi_id: '30', douban_id: undefined, tmdb_id: undefined }, 'bangumi:30'],
|
||||
[
|
||||
'generic identifiers when provider ids are absent',
|
||||
{ bangumi_id: undefined, douban_id: undefined, media_id: 'abc', mediaid_prefix: 'custom', tmdb_id: undefined },
|
||||
'custom:abc',
|
||||
],
|
||||
])('uses %s', (_case, overrides, expected) => {
|
||||
expect(getMediaSubscribeId(createSubscribeMovie(overrides))).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ best_version: false, best_version_full: true }, 'normal'],
|
||||
[{ best_version: 0, best_version_full: 1 }, 'normal'],
|
||||
[{ best_version: '0', best_version_full: '1' }, 'normal'],
|
||||
[{ best_version: true, best_version_full: false }, 'best_version'],
|
||||
[{ best_version: 1, best_version_full: 0 }, 'best_version'],
|
||||
[{ best_version: '1', best_version_full: '1' }, 'best_version_full'],
|
||||
] as const)('normalizes compatible mode flags %#', (subscribe, expected) => {
|
||||
expect(getSubscribeMode(subscribe)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMediaSubscribe entry flows', () => {
|
||||
beforeEach(() => {
|
||||
mocks.confirm.mockResolvedValue(true)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
})
|
||||
|
||||
it('creates a normal movie subscription and synchronizes public state', async () => {
|
||||
const media = createSubscribeMovie({ title: '普通电影', tmdb_id: 101, year: '2025' })
|
||||
const created = vi.fn()
|
||||
server.use(
|
||||
createSubscribeHandler({ data: { id: 501 }, success: true }, 200, created),
|
||||
defaultSubscribeConfigHandler('电影', { show_edit_dialog: false }),
|
||||
)
|
||||
await renderSubscribeHarness({ media })
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
|
||||
|
||||
await waitFor(() => expect(created).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
|
||||
expect(created).toHaveBeenCalledWith({
|
||||
bangumiid: undefined,
|
||||
doubanid: undefined,
|
||||
episode_group: '',
|
||||
mediaid: '',
|
||||
name: '普通电影',
|
||||
season: null,
|
||||
tmdbid: 101,
|
||||
type: '电影',
|
||||
year: '2025',
|
||||
})
|
||||
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
|
||||
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', true)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalled()
|
||||
expect(mocks.startProgress).toHaveBeenCalledOnce()
|
||||
expect(mocks.doneProgress).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a successful creation successful when default configuration loading fails', async () => {
|
||||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const media = createSubscribeMovie({ title: '辅助查询失败电影', tmdb_id: 111 })
|
||||
const created = vi.fn()
|
||||
const configQueried = vi.fn()
|
||||
server.use(
|
||||
createSubscribeHandler({ data: { id: 511 }, success: true }, 200, created),
|
||||
defaultSubscribeConfigHandler('电影', {}, 500, configQueried),
|
||||
)
|
||||
await renderSubscribeHarness({ media })
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
|
||||
|
||||
await waitFor(() => expect(created).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(configQueried).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
|
||||
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
|
||||
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', true)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledOnce()
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
consoleLog.mockRestore()
|
||||
})
|
||||
|
||||
it('opens the mode chooser for an existing movie and creates the selected mode', async () => {
|
||||
const media = createSubscribeMovie({ title: '已入库电影', tmdb_id: 102 })
|
||||
const created = vi.fn()
|
||||
server.use(
|
||||
createSubscribeHandler({ data: { id: 502 }, success: true }, 200, created),
|
||||
defaultSubscribeConfigHandler('电影', { show_edit_dialog: false }),
|
||||
)
|
||||
await renderSubscribeHarness({ isExists: true, media })
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
|
||||
const modeDialog = getDialogCall()
|
||||
expect(modeDialog.props).toMatchObject({ modes: ['normal', 'best_version'], type: '电影' })
|
||||
|
||||
modeDialog.events.choose('best_version')
|
||||
await waitFor(() => expect(created).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
|
||||
expect(created.mock.calls[0][0]).toMatchObject({ best_version: 1, best_version_full: 0, season: null })
|
||||
})
|
||||
|
||||
it('opens the TV season chooser with current state and the default mode', async () => {
|
||||
const media = createSubscribeTv({ season: 2, title: '季选择剧集', tmdb_id: 103 })
|
||||
server.use(defaultSubscribeConfigHandler('电视剧', { best_version: '1', best_version_full: '1' }))
|
||||
await renderSubscribeHarness({
|
||||
media,
|
||||
modes: { 1: 'normal', 2: 'best_version' },
|
||||
subscribedSeasons: [1, 2],
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
|
||||
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const dialog = getDialogCall()
|
||||
expect(dialog.props).toMatchObject({
|
||||
defaultSubscribeMode: 'best_version_full',
|
||||
selectedSeason: undefined,
|
||||
subscribedSeasonModes: { 1: 'normal', 2: 'best_version' },
|
||||
subscribedSeasons: [1, 2],
|
||||
})
|
||||
expect(dialog.options).toEqual({ closeOn: ['close', 'subscribe'] })
|
||||
})
|
||||
|
||||
it('opens the season chooser from an unsubscribed TV season entry', async () => {
|
||||
const media = createSubscribeTv({ season: 2, title: '单季入口剧集', tmdb_id: 1031 })
|
||||
server.use(defaultSubscribeConfigHandler('电视剧', { best_version: 0 }))
|
||||
await renderSubscribeHarness({ actionSeason: 2, media })
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'season' }))
|
||||
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const dialog = getDialogCall()
|
||||
expect(dialog.props).toMatchObject({
|
||||
initialEpisodeGroup: 'episode-group-entry',
|
||||
selectedSeason: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels a subscribed movie from the primary entry', async () => {
|
||||
const deleted = vi.fn()
|
||||
server.use(deleteSubscribeByMediaHandler('tmdb:1032', { success: true }, 200, url => deleted(url)))
|
||||
await renderSubscribeHarness({
|
||||
isSubscribed: true,
|
||||
media: createSubscribeMovie({ title: '主入口取消电影', tmdb_id: 1032 }),
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
|
||||
|
||||
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
|
||||
expect((deleted.mock.calls[0][0] as URL).searchParams.has('season')).toBe(false)
|
||||
expect(screen.getByTestId('subscribed')).toHaveTextContent('false')
|
||||
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', false)
|
||||
})
|
||||
|
||||
it('cancels a subscribed TV season only after confirmation', async () => {
|
||||
const media = createSubscribeTv({ season: 2, title: '取消季剧集', tmdb_id: 104 })
|
||||
const deleted = vi.fn()
|
||||
server.use(deleteSubscribeByMediaHandler('tmdb:104', { success: true }, 200, url => deleted(url)))
|
||||
await renderSubscribeHarness({
|
||||
isSubscribed: true,
|
||||
media,
|
||||
modes: { 1: 'normal', 2: 'best_version' },
|
||||
seasonsMap: { 1: true, 2: true },
|
||||
subscribedSeasons: [1, 2],
|
||||
useSeasonMap: true,
|
||||
})
|
||||
mocks.confirm.mockResolvedValueOnce(false)
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'season' }))
|
||||
expect(deleted).not.toHaveBeenCalled()
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'season' }))
|
||||
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
|
||||
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
|
||||
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false')
|
||||
expect(screen.getByTestId('modes')).not.toHaveTextContent('"2"')
|
||||
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
|
||||
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:2', false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'Douban',
|
||||
media: createSubscribeTv({ douban_id: 'db-1', tmdb_id: undefined }),
|
||||
mediaId: 'douban:db-1',
|
||||
record: createSubscribe({ doubanid: 'db-1', season: 2, tmdbid: 0, type: '电视剧' }),
|
||||
},
|
||||
{
|
||||
label: 'Bangumi',
|
||||
media: createSubscribeTv({ bangumi_id: '42', tmdb_id: undefined }),
|
||||
mediaId: 'bangumi:42',
|
||||
record: createSubscribe({ bangumiid: 42 as unknown as string, season: 2, tmdbid: 0, type: '电视剧' }),
|
||||
},
|
||||
{
|
||||
label: 'generic provider',
|
||||
media: createSubscribeTv({ media_id: 'series-1', mediaid_prefix: 'custom', tmdb_id: undefined }),
|
||||
mediaId: 'custom:series-1',
|
||||
record: createSubscribe({ mediaid: 'custom:series-1', season: 2, tmdbid: 0, type: '电视剧' }),
|
||||
},
|
||||
])('queries $label subscriptions through the media endpoint', async ({ media, mediaId, record }) => {
|
||||
const queried = vi.fn()
|
||||
server.use(querySubscribeByMediaHandler(mediaId, record, 200, url => queried(url)))
|
||||
await renderSubscribeHarness({ actionSeason: 2, media })
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('subscribed'))
|
||||
expect(queried).toHaveBeenCalledOnce()
|
||||
expect((queried.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
|
||||
})
|
||||
|
||||
it('cancels a non-TMDB season through the media endpoint', async () => {
|
||||
const media = createSubscribeTv({ douban_id: 'db-delete', tmdb_id: undefined })
|
||||
const deleted = vi.fn()
|
||||
server.use(deleteSubscribeByMediaHandler('douban:db-delete', { success: true }, 200, url => deleted(url)))
|
||||
await renderSubscribeHarness({
|
||||
actionSeason: 2,
|
||||
isSubscribed: true,
|
||||
media,
|
||||
seasonsMap: { 2: true },
|
||||
useSeasonMap: true,
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'remove' }))
|
||||
|
||||
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
|
||||
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
|
||||
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false')
|
||||
})
|
||||
|
||||
it('aligns visible seasons while preserving hidden subscriptions', async () => {
|
||||
const media = createSubscribeTv({ title: '多季剧集', tmdb_id: 105 })
|
||||
const deleted = vi.fn()
|
||||
const queried = vi.fn()
|
||||
const updated = vi.fn()
|
||||
const created = vi.fn()
|
||||
server.use(
|
||||
deleteSubscribeByMediaHandler('tmdb:105', { success: true }, 200, url => deleted(url)),
|
||||
querySubscribeByMediaHandler(
|
||||
'tmdb:105',
|
||||
createSubscribe({ id: 605, season: 2, tmdbid: 105, type: '电视剧' }),
|
||||
200,
|
||||
url => queried(url),
|
||||
),
|
||||
updateSubscribeHandler({ success: true }, 200, updated),
|
||||
createSubscribeHandler({ data: { id: 606 }, success: true }, 200, created),
|
||||
)
|
||||
await renderSubscribeHarness({
|
||||
isSubscribed: true,
|
||||
media,
|
||||
modes: { 1: 'normal', 2: 'normal', 4: 'best_version' },
|
||||
multi: {
|
||||
modes: { 2: 'best_version', 3: 'best_version_full' },
|
||||
seasons: [{ season_number: 2 }, { season_number: 3 }],
|
||||
visible: [1, 2, 3],
|
||||
},
|
||||
subscribedSeasons: [1, 2, 4],
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'align-seasons' }))
|
||||
|
||||
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(created).toHaveBeenCalledOnce())
|
||||
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('1')
|
||||
expect((queried.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
|
||||
expect(updated.mock.calls[0][0]).toMatchObject({ best_version: 1, best_version_full: 0, id: 605, season: 2 })
|
||||
expect(created.mock.calls[0][0]).toMatchObject({
|
||||
best_version: 1,
|
||||
best_version_full: 1,
|
||||
episode_group: 'episode-group-1',
|
||||
season: 3,
|
||||
})
|
||||
await waitFor(() => expect(screen.getByTestId('seasons')).toHaveTextContent('[2,3,4]'))
|
||||
expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"')
|
||||
expect(screen.getByTestId('modes')).toHaveTextContent('"3":"best_version_full"')
|
||||
expect(screen.getByTestId('modes')).toHaveTextContent('"4":"best_version"')
|
||||
})
|
||||
|
||||
it('synchronizes the created season after edit save and remove events', async () => {
|
||||
const media = createSubscribeTv({ season: 2, title: '编辑后同步', tmdb_id: 106 })
|
||||
server.use(
|
||||
createSubscribeHandler({ data: { id: 701 }, success: true }),
|
||||
defaultSubscribeConfigHandler('电视剧', { show_edit_dialog: true }),
|
||||
)
|
||||
await renderSubscribeHarness({
|
||||
isSubscribed: true,
|
||||
media,
|
||||
modes: { 1: 'normal' },
|
||||
subscribedSeasons: [1],
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'add-best-full' }))
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
expect(screen.getByTestId('seasons')).toHaveTextContent('[1,2]')
|
||||
const editDialog = getDialogCall()
|
||||
expect(editDialog.props).toEqual({ subid: 701 })
|
||||
|
||||
editDialog.events.save(createSubscribe({ best_version: 1, best_version_full: 0, id: 701, season: 2, type: '电视剧' }))
|
||||
await waitFor(() => expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"'))
|
||||
|
||||
editDialog.events.remove()
|
||||
await waitFor(() => expect(screen.getByTestId('seasons')).toHaveTextContent('[1]'))
|
||||
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('modes')).not.toHaveTextContent('"2"')
|
||||
expect(mocks.cacheStatus).toHaveBeenLastCalledWith('status:2', false)
|
||||
expect(mocks.onEditRemove).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['business failure', 200, { message: 'duplicate', success: false }],
|
||||
['HTTP failure', 500, { message: 'server down', success: false }],
|
||||
])('keeps state unchanged when create returns a %s', async (_case, status, response) => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
server.use(createSubscribeHandler(response, status))
|
||||
await renderSubscribeHarness({ media: createSubscribeMovie({ tmdb_id: 107 }) })
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'add-normal' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
|
||||
expect(screen.getByTestId('subscribed')).toHaveTextContent('false')
|
||||
expect(mocks.cacheStatus).not.toHaveBeenCalled()
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
expect(mocks.doneProgress).toHaveBeenCalledOnce()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['business failure', 200, { message: 'delete rejected', success: false }],
|
||||
['HTTP failure', 500, { message: 'server down', success: false }],
|
||||
])('keeps subscription state when removal returns a %s', async (_case, status, response) => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const deleted = vi.fn()
|
||||
server.use(deleteSubscribeByMediaHandler('tmdb:108', response, status, url => deleted(url)))
|
||||
await renderSubscribeHarness({
|
||||
actionSeason: 2,
|
||||
isSubscribed: true,
|
||||
media: createSubscribeTv({ season: 2, tmdb_id: 108 }),
|
||||
modes: { 1: 'normal', 2: 'best_version' },
|
||||
seasonsMap: { 1: true, 2: true },
|
||||
subscribedSeasons: [1, 2],
|
||||
useSeasonMap: true,
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'remove' }))
|
||||
|
||||
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
|
||||
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
|
||||
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('seasons')).toHaveTextContent('[1,2]')
|
||||
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":true')
|
||||
expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"')
|
||||
expect(mocks.cacheStatus).not.toHaveBeenCalled()
|
||||
expect(mocks.doneProgress).toHaveBeenCalledOnce()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['business failure', 200, { message: 'update rejected', success: false }],
|
||||
['HTTP failure', 500, { message: 'server down', success: false }],
|
||||
])('keeps the subscribed mode when an update returns a %s', async (_case, status, response) => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const media = createSubscribeTv({ title: '模式更新失败剧集', tmdb_id: 110 })
|
||||
const updated = vi.fn()
|
||||
server.use(
|
||||
querySubscribeByMediaHandler(
|
||||
'tmdb:110',
|
||||
createSubscribe({ id: 710, season: 2, tmdbid: 110, type: '电视剧' }),
|
||||
),
|
||||
updateSubscribeHandler(response, status, updated),
|
||||
)
|
||||
await renderSubscribeHarness({
|
||||
isSubscribed: true,
|
||||
media,
|
||||
modes: { 2: 'normal' },
|
||||
multi: {
|
||||
modes: { 2: 'best_version' },
|
||||
seasons: [{ season_number: 2 }],
|
||||
visible: [2],
|
||||
},
|
||||
subscribedSeasons: [2],
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'align-seasons' }))
|
||||
|
||||
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
|
||||
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('seasons')).toHaveTextContent('[2]')
|
||||
expect(screen.getByTestId('modes')).toHaveTextContent('"2":"normal"')
|
||||
expect(mocks.cacheStatus).not.toHaveBeenCalled()
|
||||
expect(mocks.doneProgress).toHaveBeenCalledOnce()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('maps a 404 query to missing and propagates other HTTP errors', async () => {
|
||||
const media = createSubscribeMovie({ tmdb_id: 109 })
|
||||
server.use(querySubscribeByMediaHandler('tmdb:109', {}, 404))
|
||||
await renderSubscribeHarness({ media })
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
|
||||
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('missing'))
|
||||
|
||||
server.use(querySubscribeByMediaHandler('tmdb:109', {}, 500))
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
|
||||
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('error'))
|
||||
})
|
||||
|
||||
it('does nothing when the current media is unavailable', async () => {
|
||||
await renderSubscribeHarness()
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'add-normal' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'open-season' }))
|
||||
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
expect(mocks.startProgress).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,26 @@
|
||||
import { getCurrentInstance, onMounted, onUnmounted, ref, type Ref } from 'vue'
|
||||
import { sseManagerSingleton, type SSEManagerOptions } from '@/utils/sseManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
|
||||
type UseSSEOptions = Partial<SSEManagerOptions> & {
|
||||
connectDelay?: number
|
||||
}
|
||||
|
||||
/** 为 SSE 请求补充当前前端语言,弥补 EventSource 不能设置自定义请求头的问题。 */
|
||||
function appendLocaleParam(url: string) {
|
||||
const locale = getCurrentLocale()
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url, window.location.origin)
|
||||
parsedUrl.searchParams.set('locale', locale)
|
||||
return parsedUrl.toString()
|
||||
} catch {
|
||||
const separator = url.includes('?') ? '&' : '?'
|
||||
return `${url}${separator}locale=${encodeURIComponent(locale)}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台任务组合函数
|
||||
* 统一管理SSE连接和定时器,减少后台常驻活动。
|
||||
@@ -180,12 +195,15 @@ export function useBackground() {
|
||||
listenerId: string,
|
||||
isActive: Ref<boolean>,
|
||||
) => {
|
||||
const getManager = () =>
|
||||
sseManagerSingleton.getIndependentManager(url, listenerId, {
|
||||
let managerUrl = ''
|
||||
const getManager = () => {
|
||||
managerUrl = appendLocaleParam(url)
|
||||
return sseManagerSingleton.getIndependentManager(managerUrl, listenerId, {
|
||||
backgroundCloseDelay: 1000, // 进度SSE更快关闭
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectAttempts: 5,
|
||||
})
|
||||
}
|
||||
|
||||
let manager: ReturnType<typeof getManager> | null = null
|
||||
let isListening = false
|
||||
@@ -207,8 +225,9 @@ export function useBackground() {
|
||||
manager.removeMessageListener(listenerId)
|
||||
|
||||
if (destroyManager) {
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
sseManagerSingleton.closeIndependentManager(managerUrl || appendLocaleParam(url), listenerId)
|
||||
manager = null
|
||||
managerUrl = ''
|
||||
}
|
||||
|
||||
isListening = false
|
||||
|
||||
167
src/composables/useDashboardMediaGridCapacity.ts
Normal file
167
src/composables/useDashboardMediaGridCapacity.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
toValue,
|
||||
watch,
|
||||
type ComputedRef,
|
||||
type MaybeRefOrGetter,
|
||||
type Ref,
|
||||
} from 'vue'
|
||||
|
||||
interface DashboardMediaGridCapacityOptions {
|
||||
contentSelector?: string
|
||||
gap?: number
|
||||
horizontalPadding?: number
|
||||
maxCount?: number
|
||||
minItemWidth: number
|
||||
rows?: MaybeRefOrGetter<number>
|
||||
}
|
||||
|
||||
interface DashboardMediaGridCapacityState {
|
||||
columnCount: ComputedRef<number>
|
||||
containerRef: Ref<HTMLElement | null>
|
||||
itemCount: ComputedRef<number>
|
||||
refreshCapacity: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_DASHBOARD_MEDIA_GRID_GAP = 16
|
||||
const DEFAULT_DASHBOARD_MEDIA_GRID_ROWS = 2
|
||||
|
||||
/**
|
||||
* 根据仪表盘媒体卡片容器宽度和响应式行数计算请求数量。
|
||||
*
|
||||
* @param options 网格尺寸参数,需要与实际 ProgressiveCardGrid 参数保持一致
|
||||
* @returns 容器引用、列数、请求数量和手动刷新方法
|
||||
*/
|
||||
export function useDashboardMediaGridCapacity(options: DashboardMediaGridCapacityOptions): DashboardMediaGridCapacityState {
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const measuredWidth = ref(0)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let frameId: number | null = null
|
||||
|
||||
const safeGap = computed(() => Math.max(0, options.gap ?? DEFAULT_DASHBOARD_MEDIA_GRID_GAP))
|
||||
const safeHorizontalPadding = computed(() => Math.max(0, options.horizontalPadding ?? 0))
|
||||
const safeMaxCount = computed(() => Math.max(0, Math.floor(options.maxCount ?? Number.POSITIVE_INFINITY)))
|
||||
const safeMinItemWidth = computed(() => Math.max(1, options.minItemWidth))
|
||||
const safeRows = computed(() => {
|
||||
const rows = options.rows === undefined ? DEFAULT_DASHBOARD_MEDIA_GRID_ROWS : toValue(options.rows)
|
||||
|
||||
return Math.max(1, Math.floor(rows))
|
||||
})
|
||||
|
||||
const columnCount = computed(() => {
|
||||
if (measuredWidth.value <= 0) return 0
|
||||
|
||||
return Math.max(1, Math.floor((measuredWidth.value + safeGap.value) / (safeMinItemWidth.value + safeGap.value)))
|
||||
})
|
||||
|
||||
const itemCount = computed(() => {
|
||||
if (columnCount.value <= 0) return 0
|
||||
|
||||
const count = columnCount.value * safeRows.value
|
||||
|
||||
return safeMaxCount.value > 0 ? Math.min(count, safeMaxCount.value) : count
|
||||
})
|
||||
|
||||
/**
|
||||
* 读取元素扣除内边距后的内容宽度。
|
||||
*
|
||||
* @param element 需要测量的元素
|
||||
*/
|
||||
function measureContentBoxWidth(element: HTMLElement) {
|
||||
const style = window.getComputedStyle(element)
|
||||
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0
|
||||
const paddingRight = Number.parseFloat(style.paddingRight) || 0
|
||||
|
||||
return Math.max(0, element.clientWidth - paddingLeft - paddingRight)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取容器可用于网格布局的实际宽度。
|
||||
*/
|
||||
function measureContainerWidth() {
|
||||
const element = containerRef.value
|
||||
|
||||
if (!element) return 0
|
||||
|
||||
const contentElement = options.contentSelector
|
||||
? element.querySelector<HTMLElement>(options.contentSelector)
|
||||
: null
|
||||
if (contentElement) {
|
||||
return measureContentBoxWidth(contentElement)
|
||||
}
|
||||
|
||||
const elementWidth = element.getBoundingClientRect().width || element.clientWidth
|
||||
|
||||
return Math.max(0, elementWidth - safeHorizontalPadding.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步当前容器宽度到响应式状态。
|
||||
*/
|
||||
function refreshCapacity() {
|
||||
measuredWidth.value = measureContainerWidth()
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并同一帧内的尺寸变更,避免拖拽调整仪表盘时重复计算。
|
||||
*/
|
||||
function queueRefreshCapacity() {
|
||||
if (typeof window === 'undefined' || frameId !== null) return
|
||||
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
frameId = null
|
||||
refreshCapacity()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新绑定当前容器的 ResizeObserver。
|
||||
*/
|
||||
function observeContainer() {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined' && containerRef.value) {
|
||||
resizeObserver = new ResizeObserver(queueRefreshCapacity)
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
|
||||
queueRefreshCapacity()
|
||||
}
|
||||
|
||||
watch(containerRef, observeContainer, { flush: 'post' })
|
||||
|
||||
onMounted(() => {
|
||||
observeContainer()
|
||||
window.addEventListener('resize', queueRefreshCapacity, { passive: true })
|
||||
void nextTick(refreshCapacity)
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void nextTick(refreshCapacity)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', queueRefreshCapacity)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId)
|
||||
frameId = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
columnCount,
|
||||
containerRef,
|
||||
itemCount,
|
||||
refreshCapacity,
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
} from 'vue'
|
||||
import type { UserPermissionKey } from '@/utils/permission'
|
||||
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
|
||||
|
||||
// 声明全局变量类型
|
||||
declare global {
|
||||
@@ -31,6 +31,7 @@ export interface DynamicButtonMenuItem {
|
||||
icon?: string
|
||||
color?: string
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
disabled?: boolean
|
||||
action: () => void
|
||||
}
|
||||
@@ -61,11 +62,12 @@ export function useDynamicButton(options: {
|
||||
onClick?: () => void
|
||||
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
show?: MaybeRefValue<boolean>
|
||||
autoRegister?: boolean // 是否自动注册,默认为true
|
||||
}) {
|
||||
// 提取配置
|
||||
const { icon, onClick, menuItems, permission, show, autoRegister = true } = options
|
||||
const { icon, onClick, menuItems, permission, feature, show, autoRegister = true } = options
|
||||
|
||||
// 动态按钮相关
|
||||
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
|
||||
@@ -79,6 +81,7 @@ export function useDynamicButton(options: {
|
||||
const resolvedShow = computed(() => resolveMaybeRef(show, true))
|
||||
const resolvedMenuItems = computed(() => resolveMaybeRef(menuItems))
|
||||
|
||||
/** 根据当前响应式配置生成可注册的动态按钮对象。 */
|
||||
function buildDynamicButton() {
|
||||
const buttonMenuItems = resolvedMenuItems.value
|
||||
|
||||
@@ -86,12 +89,13 @@ export function useDynamicButton(options: {
|
||||
icon: resolvedIcon.value,
|
||||
action: onClick || (() => {}),
|
||||
permission,
|
||||
feature,
|
||||
show: resolvedShow.value,
|
||||
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 注册动态按钮
|
||||
/** 在当前页面激活时注册动态按钮。 */
|
||||
function setupDynamicButton() {
|
||||
if (!componentActive.value) return
|
||||
|
||||
@@ -133,7 +137,7 @@ export function useDynamicButton(options: {
|
||||
})
|
||||
}
|
||||
|
||||
// 取消注册动态按钮
|
||||
/** 清理当前页面注册过的动态按钮。 */
|
||||
function cleanupDynamicButton() {
|
||||
if (unregisterDynamicButton && dynamicButtonRegistered.value) {
|
||||
unregisterDynamicButton()
|
||||
@@ -147,7 +151,7 @@ export function useDynamicButton(options: {
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法:手动打开对话框
|
||||
/** 手动触发动态按钮主操作。 */
|
||||
function openDialog() {
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { useTabStateRestore } from '@/composables/useStateRestore'
|
||||
import type { UserPermissionKey } from '@/utils/permission'
|
||||
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
|
||||
|
||||
// 动态标签页相关类型
|
||||
interface DynamicHeaderTabButton {
|
||||
@@ -11,6 +11,7 @@ interface DynamicHeaderTabButton {
|
||||
class?: string
|
||||
action?: () => void
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
show?: boolean | ComputedRef<boolean>
|
||||
loading?: boolean | ComputedRef<boolean>
|
||||
dataAttr?: string // 用于VMenu定位的data属性
|
||||
@@ -20,6 +21,8 @@ interface DynamicHeaderTabItem {
|
||||
title: string
|
||||
icon?: string
|
||||
tab: string
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
}
|
||||
|
||||
interface DynamicHeaderTabConfig {
|
||||
@@ -30,6 +33,7 @@ interface DynamicHeaderTabConfig {
|
||||
onUpdateModelValue?: (value: string) => void
|
||||
}
|
||||
|
||||
/** 提供页面动态头部标签的注册、状态恢复和注销能力。 */
|
||||
export function useDynamicHeaderTab() {
|
||||
const route = useRoute()
|
||||
|
||||
@@ -37,7 +41,7 @@ export function useDynamicHeaderTab() {
|
||||
const registerDynamicHeaderTab = inject<(tab: DynamicHeaderTabConfig) => void>('registerDynamicHeaderTab')
|
||||
const unregisterDynamicHeaderTab = inject<() => void>('unregisterDynamicHeaderTab')
|
||||
|
||||
// 注册动态标签页
|
||||
/** 注册当前页面的动态头部标签配置。 */
|
||||
const registerHeaderTab = (config: {
|
||||
items: DynamicHeaderTabItem[] | ComputedRef<DynamicHeaderTabItem[]> | Ref<DynamicHeaderTabItem[]>
|
||||
modelValue: Ref<string>
|
||||
@@ -184,7 +188,7 @@ export function useDynamicHeaderTab() {
|
||||
})
|
||||
}
|
||||
|
||||
// 取消注册
|
||||
/** 取消当前页面注册的动态头部标签。 */
|
||||
const unregisterHeaderTab = () => {
|
||||
if (unregisterDynamicHeaderTab) {
|
||||
unregisterDynamicHeaderTab()
|
||||
|
||||
@@ -49,6 +49,7 @@ const SubscribeSeasonDialog = defineAsyncComponent(() => import('@/components/di
|
||||
|
||||
export type SeasonSubscribeModes = Record<number, SubscribeMode>
|
||||
|
||||
// 生成跨媒体源稳定的订阅媒体标识。
|
||||
export function getMediaSubscribeId(media?: MediaInfo) {
|
||||
if (media?.tmdb_id) return `tmdb:${media.tmdb_id}`
|
||||
if (media?.douban_id) return `douban:${media.douban_id}`
|
||||
@@ -56,6 +57,7 @@ export function getMediaSubscribeId(media?: MediaInfo) {
|
||||
return `${media?.mediaid_prefix}:${media?.media_id}`
|
||||
}
|
||||
|
||||
// 将订阅模式转换为后端订阅字段。
|
||||
function getSubscribePayload(mode: SubscribeMode): SubscribePayload {
|
||||
return {
|
||||
best_version: mode === 'normal' ? 0 : 1,
|
||||
@@ -63,16 +65,19 @@ function getSubscribePayload(mode: SubscribeMode): SubscribePayload {
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容布尔值和数字、字符串形式的开关值。
|
||||
function isEnabledFlag(value: unknown) {
|
||||
return value === true || value === 1 || value === '1'
|
||||
}
|
||||
|
||||
// 从订阅字段解析统一的订阅模式。
|
||||
export function getSubscribeMode(subscribe: { best_version?: unknown; best_version_full?: unknown }): SubscribeMode {
|
||||
if (!isEnabledFlag(subscribe.best_version)) return 'normal'
|
||||
|
||||
return isEnabledFlag(subscribe.best_version_full) ? 'best_version_full' : 'best_version'
|
||||
}
|
||||
|
||||
// 从默认订阅配置解析订阅模式。
|
||||
function getSubscribeConfigMode(config?: SubscribeConfig): SubscribeMode {
|
||||
return getSubscribeMode({
|
||||
best_version: config?.best_version,
|
||||
@@ -80,30 +85,46 @@ function getSubscribeConfigMode(config?: SubscribeConfig): SubscribeMode {
|
||||
})
|
||||
}
|
||||
|
||||
// 获取订阅模式的本地化名称。
|
||||
function getModeName(t: ReturnType<typeof useI18n>['t'], mode: SubscribeMode) {
|
||||
if (mode === 'normal') return t('dialog.subscribeMode.normal')
|
||||
if (mode === 'best_version') return t('dialog.subscribeMode.bestVersionEpisode')
|
||||
return t('dialog.subscribeMode.bestVersionFull')
|
||||
}
|
||||
|
||||
// 从变更请求异常中提取可展示消息,并为非标准错误提供稳定兜底。
|
||||
function getRequestErrorMessage(error: unknown, fallback: string) {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const responseMessage = (error as { response?: { data?: { message?: unknown } } }).response?.data?.message
|
||||
if (typeof responseMessage === 'string' && responseMessage) return responseMessage
|
||||
}
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
return fallback
|
||||
}
|
||||
|
||||
// 封装媒体卡片与详情页共用的订阅交互。
|
||||
export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
const { t } = useI18n()
|
||||
const $toast = useToast()
|
||||
const createConfirm = useConfirm()
|
||||
const episodeGroup = ref('')
|
||||
|
||||
// 获取调用方当前媒体,避免在异步流程中持有旧对象。
|
||||
function currentMedia() {
|
||||
return options.media()
|
||||
}
|
||||
|
||||
// 获取当前媒体的统一订阅标识。
|
||||
function getMediaId() {
|
||||
return getMediaSubscribeId(currentMedia())
|
||||
}
|
||||
|
||||
// 获取主订阅入口默认对应的季号。
|
||||
function getPrimarySeason() {
|
||||
return options.primarySeason?.() ?? currentMedia()?.season ?? null
|
||||
}
|
||||
|
||||
// 同步调用方状态和订阅状态缓存。
|
||||
function updateSubscribeStatus(season: number | null, subscribed: boolean, mode: SubscribeMode = 'normal') {
|
||||
const media = currentMedia()
|
||||
|
||||
@@ -143,23 +164,27 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function openSubscribeEditDialog(subid: number) {
|
||||
// 打开已创建订阅的编辑弹窗。
|
||||
function openSubscribeEditDialog(subid: number, season: number | null, mode: SubscribeMode) {
|
||||
openSharedDialog(
|
||||
SubscribeEditDialog,
|
||||
{ subid },
|
||||
{
|
||||
save: (subscribe?: Subscribe) => {
|
||||
const savedSeason = currentMedia()?.type === '电影' ? null : (subscribe?.season ?? season)
|
||||
if (savedSeason !== season) updateSubscribeStatus(season, false)
|
||||
updateSubscribeStatus(savedSeason, true, subscribe ? getSubscribeMode(subscribe) : mode)
|
||||
},
|
||||
remove: () => {
|
||||
if (options.onEditRemove) {
|
||||
options.onEditRemove()
|
||||
} else if (options.isSubscribed) {
|
||||
options.isSubscribed.value = false
|
||||
}
|
||||
updateSubscribeStatus(season, false)
|
||||
options.onEditRemove?.()
|
||||
},
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开订阅模式选择弹窗并转换选择结果。
|
||||
function openSubscribeModeDialog(
|
||||
modes: SubscribeMode[],
|
||||
choose: (payload: SubscribePayload, mode: SubscribeMode) => void,
|
||||
@@ -174,7 +199,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
)
|
||||
}
|
||||
|
||||
async function openSubscribeSeasonDialog(selectedSeason?: number | null) {
|
||||
// 打开季订阅弹窗,并保留发起入口当前使用的剧集组。
|
||||
async function openSubscribeSeasonDialog(selectedSeason?: number | null, initialEpisodeGroup = '') {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
const defaultSubscribeConfig = await queryDefaultSubscribeConfig()
|
||||
@@ -184,6 +210,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
{
|
||||
media,
|
||||
selectedSeason,
|
||||
initialEpisodeGroup,
|
||||
subscribedSeasons: options.subscribedSeasons?.value ?? [],
|
||||
subscribedSeasonModes: options.subscribedSeasonModes?.value ?? {},
|
||||
defaultSubscribeMode: getSubscribeConfigMode(defaultSubscribeConfig),
|
||||
@@ -195,6 +222,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
)
|
||||
}
|
||||
|
||||
// 查询系统默认订阅配置。
|
||||
async function queryDefaultSubscribeConfig(): Promise<SubscribeConfig | undefined> {
|
||||
if (!options.canSubscribe()) return undefined
|
||||
|
||||
@@ -214,6 +242,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 展示订阅新增结果通知。
|
||||
function showSubscribeAddToast(
|
||||
result: boolean,
|
||||
title: string,
|
||||
@@ -229,6 +258,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
else $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message })}`)
|
||||
}
|
||||
|
||||
// 创建指定季和模式的订阅。
|
||||
async function addSubscribe(
|
||||
season: number | null = null,
|
||||
payload: SubscribePayload = {},
|
||||
@@ -252,21 +282,39 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
|
||||
if (result.success) updateSubscribeStatus(media.type === '电影' ? null : season, true, getSubscribeMode(payload))
|
||||
const subscribeSeason = media.type === '电影' ? null : season
|
||||
const subscribeMode = getSubscribeMode(payload)
|
||||
if (result.success) updateSubscribeStatus(subscribeSeason, true, subscribeMode)
|
||||
|
||||
showSubscribeAddToast(result.success, media.title ?? '', season, result.message, payload.best_version ?? 0)
|
||||
showSubscribeAddToast(
|
||||
result.success,
|
||||
media.title ?? '',
|
||||
season,
|
||||
result.message ?? t('subscribe.requestFailed'),
|
||||
payload.best_version ?? 0,
|
||||
)
|
||||
|
||||
if (result.success && (addOptions.openEditDialog ?? true)) {
|
||||
const subscribeConfig = await queryDefaultSubscribeConfig()
|
||||
if (subscribeConfig?.show_edit_dialog) openSubscribeEditDialog(result.data.id)
|
||||
if (subscribeConfig?.show_edit_dialog && result.data?.id) {
|
||||
openSubscribeEditDialog(result.data.id, subscribeSeason, subscribeMode)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showSubscribeAddToast(
|
||||
false,
|
||||
media.title ?? '',
|
||||
season,
|
||||
getRequestErrorMessage(error, t('subscribe.requestFailed')),
|
||||
payload.best_version ?? 0,
|
||||
)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 删除指定季的订阅。
|
||||
async function removeSubscribe(season: number | null = null, removeOptions: RemoveSubscribeOptions = {}) {
|
||||
if (removeOptions.confirm ?? true) {
|
||||
const confirmed = await createConfirm({
|
||||
@@ -278,6 +326,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
let title = media.title ?? ''
|
||||
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
@@ -286,22 +336,26 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
season: media.type === '电影' ? null : season,
|
||||
},
|
||||
})
|
||||
let title = media.title ?? ''
|
||||
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
if (result.success) {
|
||||
updateSubscribeStatus(media.type === '电影' ? null : season, false)
|
||||
$toast.success(`${title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
$toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message ?? t('subscribe.requestFailed') })}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(
|
||||
`${title} ${t('subscribe.cancelFailed', {
|
||||
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
|
||||
})}`,
|
||||
)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 检查当前媒体指定季是否已订阅。
|
||||
async function checkSubscribe(season: number | null = null) {
|
||||
try {
|
||||
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
|
||||
@@ -319,6 +373,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// 查询当前媒体指定季的订阅记录。
|
||||
async function querySubscribe(season: number | null = null) {
|
||||
try {
|
||||
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
|
||||
@@ -336,9 +391,11 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新已有单季订阅的模式。
|
||||
async function updateSubscribeMode(season: number, mode: SubscribeMode) {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
const title = `${media.title ?? ''} ${formatSeason(season.toString())}`
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
@@ -353,30 +410,42 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
...subscribe,
|
||||
...payload,
|
||||
})
|
||||
const title = `${media.title ?? ''} ${formatSeason(season.toString())}`
|
||||
|
||||
if (result.success) {
|
||||
updateSubscribeStatus(season, true, mode)
|
||||
$toast.success(`${title} ${t('subscribe.modeUpdateSuccess', { mode: getModeName(t, mode) })}`)
|
||||
} else {
|
||||
$toast.error(`${title} ${t('subscribe.addFailed', { name: getModeName(t, mode), message: result.message })}`)
|
||||
$toast.error(
|
||||
`${title} ${t('subscribe.addFailed', {
|
||||
name: getModeName(t, mode),
|
||||
message: result.message ?? t('subscribe.requestFailed'),
|
||||
})}`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(
|
||||
`${title} ${t('subscribe.addFailed', {
|
||||
name: getModeName(t, mode),
|
||||
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
|
||||
})}`,
|
||||
)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSeasonSubscribe(season: number) {
|
||||
// 处理单季订阅入口,未订阅时将当前剧集组带入季选择弹窗。
|
||||
function handleSeasonSubscribe(season: number, initialEpisodeGroup = '') {
|
||||
if (options.seasonsSubscribed?.value[season]) {
|
||||
removeSubscribe(season)
|
||||
return
|
||||
}
|
||||
|
||||
openSubscribeSeasonDialog(season)
|
||||
openSubscribeSeasonDialog(season, initialEpisodeGroup)
|
||||
}
|
||||
|
||||
// 处理媒体主订阅入口,电视剧统一进入季选择弹窗。
|
||||
function handlePrimarySubscribe() {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
@@ -401,15 +470,17 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
addSubscribe(null)
|
||||
}
|
||||
|
||||
function handleSubscribe(season?: number | null) {
|
||||
// 根据是否指定季号分发主订阅或单季订阅操作。
|
||||
function handleSubscribe(season?: number | null, initialEpisodeGroup = '') {
|
||||
if (season !== undefined && season !== null) {
|
||||
handleSeasonSubscribe(season)
|
||||
handleSeasonSubscribe(season, initialEpisodeGroup)
|
||||
return
|
||||
}
|
||||
|
||||
handlePrimarySubscribe()
|
||||
}
|
||||
|
||||
// 批量对齐弹窗中选择的季、订阅模式和当前订阅状态。
|
||||
function subscribeSeasons(
|
||||
seasons: MediaSeason[] = [],
|
||||
seasonExistsStates: { [key: number]: number } = {},
|
||||
|
||||
@@ -1,86 +1,82 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
|
||||
// 全局状态
|
||||
const isAppOffline = ref(false)
|
||||
const appOfflineReason = ref('')
|
||||
const consecutiveNetworkErrors = ref(0)
|
||||
const MAX_CONSECUTIVE_ERRORS = 3
|
||||
export type ConnectionStatus = 'online' | 'checking' | 'offline'
|
||||
export type ConnectionFailureReason = 'browser-offline' | 'network-error' | 'timeout' | 'server-unreachable'
|
||||
|
||||
// 全局离线状态管理
|
||||
const browserOnline = useOnline()
|
||||
const connectionStatus = ref<ConnectionStatus>('online')
|
||||
const connectionReason = ref<ConnectionFailureReason | null>(null)
|
||||
const connectionCheckRequestId = ref(0)
|
||||
const serverSuccessSequence = ref(0)
|
||||
|
||||
/** 管理 MoviePilot 服务的全局连接状态。 */
|
||||
export function useGlobalOfflineStatus() {
|
||||
const isOnline = useOnline()
|
||||
const isOnline = computed(() => connectionStatus.value === 'online')
|
||||
const isChecking = computed(() => connectionStatus.value === 'checking')
|
||||
const isOffline = computed(() => connectionStatus.value === 'offline')
|
||||
const canPerformNetworkAction = computed(() => connectionStatus.value !== 'offline')
|
||||
|
||||
// 综合离线状态(网络离线 或 应用离线)
|
||||
const isOffline = computed(() => !isOnline.value || isAppOffline.value)
|
||||
|
||||
// 是否可以执行网络操作
|
||||
const canPerformNetworkAction = computed(() => isOnline.value && !isAppOffline.value)
|
||||
|
||||
// 设置应用离线状态
|
||||
const setAppOffline = (offline: boolean, reason?: string) => {
|
||||
isAppOffline.value = offline
|
||||
appOfflineReason.value = reason || ''
|
||||
|
||||
// 如果设置为在线状态,重置连续错误计数
|
||||
if (!offline) {
|
||||
consecutiveNetworkErrors.value = 0
|
||||
}
|
||||
/** 记录任意 MoviePilot API 成功响应并恢复在线状态。 */
|
||||
function markServerOnline() {
|
||||
connectionStatus.value = 'online'
|
||||
connectionReason.value = null
|
||||
serverSuccessSequence.value += 1
|
||||
}
|
||||
|
||||
// 记录网络错误
|
||||
const recordNetworkError = (reason?: string) => {
|
||||
consecutiveNetworkErrors.value++
|
||||
|
||||
// 只有连续出现三次网络错误时才设置为离线模式
|
||||
if (consecutiveNetworkErrors.value >= MAX_CONSECUTIVE_ERRORS) {
|
||||
setAppOffline(true, reason || `连续${MAX_CONSECUTIVE_ERRORS}次网络错误`)
|
||||
}
|
||||
/** 将连接状态标记为待确认,但不直接阻断页面操作。 */
|
||||
function markConnectionChecking(reason?: ConnectionFailureReason) {
|
||||
connectionStatus.value = 'checking'
|
||||
if (reason) connectionReason.value = reason
|
||||
}
|
||||
|
||||
// 重置连续错误计数
|
||||
const resetConsecutiveErrors = () => {
|
||||
consecutiveNetworkErrors.value = 0
|
||||
/** 在权威探测失败后标记 MoviePilot 服务不可达。 */
|
||||
function markServerOffline(reason: ConnectionFailureReason = 'server-unreachable') {
|
||||
connectionStatus.value = 'offline'
|
||||
connectionReason.value = reason
|
||||
}
|
||||
|
||||
// 获取离线消息
|
||||
const getOfflineMessage = () => {
|
||||
if (!isOnline.value) {
|
||||
return appOfflineReason.value
|
||||
}
|
||||
if (isAppOffline.value) {
|
||||
return appOfflineReason.value
|
||||
}
|
||||
return ''
|
||||
/** 将普通请求的网络错误降级为待确认状态,并请求一次去重后的服务探测。 */
|
||||
function reportNetworkError(reason: ConnectionFailureReason = 'network-error') {
|
||||
markConnectionChecking(reason)
|
||||
connectionCheckRequestId.value += 1
|
||||
}
|
||||
|
||||
/** 请求立即检查 MoviePilot 服务连接。 */
|
||||
function requestConnectionCheck(reason?: ConnectionFailureReason) {
|
||||
markConnectionChecking(reason)
|
||||
connectionCheckRequestId.value += 1
|
||||
}
|
||||
|
||||
return {
|
||||
browserOnline,
|
||||
connectionStatus,
|
||||
connectionReason,
|
||||
connectionCheckRequestId,
|
||||
serverSuccessSequence,
|
||||
isOnline,
|
||||
isChecking,
|
||||
isOffline,
|
||||
canPerformNetworkAction,
|
||||
setAppOffline,
|
||||
recordNetworkError,
|
||||
resetConsecutiveErrors,
|
||||
getOfflineMessage,
|
||||
consecutiveNetworkErrors: computed(() => consecutiveNetworkErrors.value),
|
||||
markServerOnline,
|
||||
markConnectionChecking,
|
||||
markServerOffline,
|
||||
reportNetworkError,
|
||||
requestConnectionCheck,
|
||||
}
|
||||
}
|
||||
|
||||
// 单个组件的离线状态
|
||||
export function useOfflineStatus(initialMessage?: string) {
|
||||
const { isOnline, isOffline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
|
||||
|
||||
const message = computed(() => {
|
||||
if (initialMessage) {
|
||||
return initialMessage
|
||||
}
|
||||
return getOfflineMessage()
|
||||
})
|
||||
/** 为单个组件提供 MoviePilot 服务连接状态。 */
|
||||
export function useOfflineStatus() {
|
||||
const status = useGlobalOfflineStatus()
|
||||
|
||||
return {
|
||||
isOnline,
|
||||
isOffline,
|
||||
canPerformNetworkAction,
|
||||
message,
|
||||
browserOnline: status.browserOnline,
|
||||
isOnline: status.isOnline,
|
||||
isChecking: status.isChecking,
|
||||
isOffline: status.isOffline,
|
||||
canPerformNetworkAction: status.canPerformNetworkAction,
|
||||
connectionReason: status.connectionReason,
|
||||
requestConnectionCheck: status.requestConnectionCheck,
|
||||
}
|
||||
}
|
||||
|
||||
106
src/composables/useScheduleProgress.ts
Normal file
106
src/composables/useScheduleProgress.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, ScheduleInfo, ScheduleProgress } from '@/api/types'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const SCHEDULE_PROGRESS_REFRESH_INTERVAL = 1000
|
||||
const SCHEDULE_RUNNING_STATUS = '正在运行'
|
||||
const SCHEDULE_WAITING_STATUS = '等待'
|
||||
|
||||
/** 判断定时服务是否仍在运行,兼容列表状态与进度详情两种后端信号。 */
|
||||
export function isScheduleRunning(schedule: ScheduleInfo) {
|
||||
return (
|
||||
schedule.status === SCHEDULE_RUNNING_STATUS ||
|
||||
schedule.progress_enable === true ||
|
||||
schedule.progress_detail?.enable === true ||
|
||||
schedule.progress_detail?.status === 'running'
|
||||
)
|
||||
}
|
||||
|
||||
/** 判断定时服务是否处于等待执行状态。 */
|
||||
export function isScheduleWaiting(schedule: ScheduleInfo) {
|
||||
return !isScheduleRunning(schedule) && schedule.status === SCHEDULE_WAITING_STATUS
|
||||
}
|
||||
|
||||
/** 获取定时服务展示名称,优先使用后端按当前语言生成的文本。 */
|
||||
export function getScheduleName(schedule: ScheduleInfo) {
|
||||
return schedule.name_i18n || schedule.name || ''
|
||||
}
|
||||
|
||||
/** 获取定时服务提供者展示名称,优先使用后端按当前语言生成的文本。 */
|
||||
export function getScheduleProvider(schedule: ScheduleInfo) {
|
||||
return schedule.provider_i18n || schedule.provider || ''
|
||||
}
|
||||
|
||||
/** 获取定时服务状态展示文本,优先使用后端按当前语言生成的文本。 */
|
||||
export function getScheduleStatusText(schedule: ScheduleInfo) {
|
||||
return schedule.status_i18n || schedule.status || ''
|
||||
}
|
||||
|
||||
/** 获取定时服务下次运行展示文本,优先使用后端按当前语言生成的文本。 */
|
||||
export function getScheduleNextRunText(schedule: ScheduleInfo) {
|
||||
return schedule.next_run_i18n || schedule.next_run || ''
|
||||
}
|
||||
|
||||
/** 为定时服务列表提供仅针对运行中任务的实时进度轮询。 */
|
||||
export function useScheduleProgress(schedules: Ref<ScheduleInfo[]>, refreshId: string) {
|
||||
const { useDataRefresh } = useBackground()
|
||||
const progressById = ref<Record<string, ScheduleProgress>>({})
|
||||
|
||||
/** 请求指定运行中任务的最新进度。 */
|
||||
async function loadScheduleProgress(schedule: ScheduleInfo) {
|
||||
const response = (await api.get(
|
||||
`dashboard/schedule/${encodeURIComponent(schedule.id)}/progress`,
|
||||
)) as ApiResponse<ScheduleProgress>
|
||||
|
||||
return response.success ? response.data : undefined
|
||||
}
|
||||
|
||||
/** 刷新所有运行中任务的进度,并清理已经停止任务的缓存。 */
|
||||
async function refreshRunningProgress() {
|
||||
const runningSchedules = schedules.value.filter(schedule => schedule.id && isScheduleRunning(schedule))
|
||||
const runningIds = new Set(runningSchedules.map(schedule => schedule.id))
|
||||
const nextProgress = Object.fromEntries(Object.entries(progressById.value).filter(([id]) => runningIds.has(id)))
|
||||
|
||||
if (!runningSchedules.length) {
|
||||
progressById.value = {}
|
||||
return
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(runningSchedules.map(loadScheduleProgress))
|
||||
const currentRunningIds = new Set(
|
||||
schedules.value.filter(schedule => schedule.id && isScheduleRunning(schedule)).map(schedule => schedule.id),
|
||||
)
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const schedule = runningSchedules[index]
|
||||
if (result.status === 'fulfilled' && result.value && currentRunningIds.has(schedule.id)) {
|
||||
nextProgress[schedule.id] = result.value
|
||||
}
|
||||
})
|
||||
|
||||
progressById.value = Object.fromEntries(Object.entries(nextProgress).filter(([id]) => currentRunningIds.has(id)))
|
||||
}
|
||||
|
||||
/** 获取任务当前应展示的百分比,并限制在合法范围内。 */
|
||||
function getScheduleProgressValue(schedule: ScheduleInfo) {
|
||||
const value = Number(progressById.value[schedule.id]?.value ?? schedule.progress ?? 0)
|
||||
|
||||
return Math.min(Math.max(value, 0), 100)
|
||||
}
|
||||
|
||||
/** 获取任务当前应展示的进度说明。 */
|
||||
function getScheduleProgressText(schedule: ScheduleInfo) {
|
||||
const progress = progressById.value[schedule.id]
|
||||
|
||||
return progress?.text_i18n || progress?.text || schedule.progress_text_i18n || schedule.progress_text || ''
|
||||
}
|
||||
|
||||
useDataRefresh(refreshId, refreshRunningProgress, SCHEDULE_PROGRESS_REFRESH_INTERVAL, true)
|
||||
|
||||
return {
|
||||
getScheduleProgressText,
|
||||
getScheduleProgressValue,
|
||||
refreshRunningProgress,
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ export interface WizardData {
|
||||
baseUrlPreset: string
|
||||
maxContextTokens: number
|
||||
userAgent: string
|
||||
temperature: number
|
||||
audioInputProvider: string
|
||||
audioInputApiKey: string
|
||||
audioInputBaseUrl: string
|
||||
@@ -253,6 +254,7 @@ const wizardData = ref<WizardData>({
|
||||
baseUrlPreset: '',
|
||||
maxContextTokens: 64,
|
||||
userAgent: '',
|
||||
temperature: 0.3,
|
||||
audioInputProvider: 'openai',
|
||||
audioInputApiKey: '',
|
||||
audioInputBaseUrl: '',
|
||||
@@ -1436,6 +1438,7 @@ export function useSetupWizard() {
|
||||
// 保存智能助手设置
|
||||
async function saveAgentSettings() {
|
||||
try {
|
||||
const agentTemperature = Number(wizardData.value.agent.temperature ?? 0.3)
|
||||
const agentSettings = {
|
||||
AI_AGENT_ENABLE: wizardData.value.agent.enabled,
|
||||
AI_AGENT_GLOBAL: wizardData.value.agent.enabled ? wizardData.value.agent.global : false,
|
||||
@@ -1452,6 +1455,7 @@ export function useSetupWizard() {
|
||||
LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null,
|
||||
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
|
||||
LLM_USER_AGENT: wizardData.value.agent.userAgent || null,
|
||||
LLM_TEMPERATURE: Number.isFinite(agentTemperature) ? agentTemperature : 0.3,
|
||||
AUDIO_INPUT_PROVIDER: wizardData.value.agent.audioInputProvider || 'openai',
|
||||
AUDIO_INPUT_API_KEY: wizardData.value.agent.audioInputApiKey || null,
|
||||
AUDIO_INPUT_BASE_URL: wizardData.value.agent.audioInputBaseUrl || null,
|
||||
@@ -1567,6 +1571,8 @@ export function useSetupWizard() {
|
||||
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
|
||||
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
|
||||
wizardData.value.agent.userAgent = result.data.LLM_USER_AGENT || ''
|
||||
const agentTemperature = Number(result.data.LLM_TEMPERATURE ?? 0.3)
|
||||
wizardData.value.agent.temperature = Number.isFinite(agentTemperature) ? agentTemperature : 0.3
|
||||
wizardData.value.agent.audioInputProvider = result.data.AUDIO_INPUT_PROVIDER || 'openai'
|
||||
wizardData.value.agent.audioInputApiKey = result.data.AUDIO_INPUT_API_KEY || ''
|
||||
wizardData.value.agent.audioInputBaseUrl = result.data.AUDIO_INPUT_BASE_URL || ''
|
||||
|
||||
158
src/composables/useShortcutTools.ts
Normal file
158
src/composables/useShortcutTools.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { Component } from 'vue'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useUserStore } from '@/stores'
|
||||
import {
|
||||
buildUserPermissionContext,
|
||||
filterItemsByPermission,
|
||||
hasItemPermission,
|
||||
type PermissionProtectedItem,
|
||||
} from '@/utils/permission'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const NameTestView = defineAsyncComponent(() => import('@/views/system/NameTestView.vue'))
|
||||
const NetTestView = defineAsyncComponent(() => import('@/views/system/NetTestView.vue'))
|
||||
const RuleTestView = defineAsyncComponent(() => import('@/views/system/RuleTestView.vue'))
|
||||
const ModuleTestView = defineAsyncComponent(() => import('@/views/system/ModuleTestView.vue'))
|
||||
const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vue'))
|
||||
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
|
||||
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
|
||||
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue'))
|
||||
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
|
||||
|
||||
// 定时服务在捷径与仪表板中共用的图标,避免两个入口的视觉语义漂移。
|
||||
export const SCHEDULER_SHORTCUT_ICON = 'mdi-list-box'
|
||||
|
||||
export type ShortcutToolItem = PermissionProtectedItem & {
|
||||
bodyClass?: string
|
||||
cardClass?: string
|
||||
component?: Component
|
||||
customDialog?: Component
|
||||
dialog: string
|
||||
dialogSubtitle?: string
|
||||
icon: string
|
||||
maxWidth?: string
|
||||
subtitle: string
|
||||
title: string
|
||||
titleText?: string
|
||||
}
|
||||
|
||||
/** 提供顶部捷径与仪表板共用的工具定义和打开逻辑。 */
|
||||
export function useShortcutTools() {
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
|
||||
const shortcuts: ShortcutToolItem[] = [
|
||||
{
|
||||
title: t('shortcut.recognition.title'),
|
||||
subtitle: t('shortcut.recognition.subtitle'),
|
||||
icon: 'mdi-text-recognition',
|
||||
dialog: 'nameTest',
|
||||
component: NameTestView,
|
||||
maxWidth: '65rem',
|
||||
titleText: t('shortcut.recognition.title'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.rule.title'),
|
||||
subtitle: t('shortcut.rule.subtitle'),
|
||||
icon: 'mdi-filter-cog',
|
||||
dialog: 'ruleTest',
|
||||
component: RuleTestView,
|
||||
maxWidth: '65rem',
|
||||
titleText: t('shortcut.rule.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.log.title'),
|
||||
subtitle: t('shortcut.log.subtitle'),
|
||||
icon: 'mdi-file-document',
|
||||
dialog: 'logging',
|
||||
customDialog: ShortcutLogDialog,
|
||||
},
|
||||
{
|
||||
title: t('shortcut.network.title'),
|
||||
subtitle: t('shortcut.network.subtitle'),
|
||||
icon: 'mdi-network',
|
||||
dialog: 'netTest',
|
||||
component: NetTestView,
|
||||
titleText: t('shortcut.network.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.words.title'),
|
||||
subtitle: t('shortcut.words.subtitle'),
|
||||
icon: 'mdi-file-word-box',
|
||||
dialog: 'words',
|
||||
bodyClass: 'words-shortcut-dialog-body pa-0',
|
||||
cardClass: 'words-shortcut-dialog-card',
|
||||
component: WordsView,
|
||||
maxWidth: '60rem',
|
||||
titleText: t('shortcut.words.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.cache.title'),
|
||||
subtitle: t('shortcut.cache.subtitle'),
|
||||
icon: 'mdi-database',
|
||||
dialog: 'cache',
|
||||
bodyClass: 'cache-shortcut-dialog-body',
|
||||
cardClass: 'cache-shortcut-dialog-card',
|
||||
component: CacheView,
|
||||
maxWidth: '90rem',
|
||||
titleText: t('shortcut.cache.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.scheduler.title'),
|
||||
subtitle: t('shortcut.scheduler.subtitle'),
|
||||
icon: SCHEDULER_SHORTCUT_ICON,
|
||||
dialog: 'scheduler',
|
||||
bodyClass: 'scheduler-shortcut-dialog-body pa-0',
|
||||
cardClass: 'scheduler-shortcut-dialog-card',
|
||||
component: AccountSettingService,
|
||||
maxWidth: '60rem',
|
||||
titleText: t('shortcut.scheduler.subtitle'),
|
||||
dialogSubtitle: t('setting.scheduler.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.system.title'),
|
||||
subtitle: t('shortcut.system.subtitle'),
|
||||
icon: 'mdi-cog',
|
||||
dialog: 'systemTest',
|
||||
bodyClass: 'system-health-dialog-body pa-0',
|
||||
cardClass: 'system-health-dialog-card',
|
||||
component: ModuleTestView,
|
||||
titleText: t('shortcut.system.subtitle'),
|
||||
},
|
||||
].map(item => ({ ...item, permission: 'admin' }))
|
||||
|
||||
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
|
||||
|
||||
/** 打开工具对应的共享弹窗。 */
|
||||
function openShortcutDialog(item: ShortcutToolItem) {
|
||||
if (!hasItemPermission(item, userPermissions.value)) return
|
||||
|
||||
if (item.customDialog) {
|
||||
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||
return
|
||||
}
|
||||
|
||||
if (!item.component) return
|
||||
|
||||
openSharedDialog(
|
||||
ShortcutToolDialog,
|
||||
{
|
||||
bodyClass: item.bodyClass,
|
||||
cardClass: item.cardClass,
|
||||
icon: item.icon,
|
||||
maxWidth: item.maxWidth ?? '35rem',
|
||||
subtitle: item.dialogSubtitle,
|
||||
title: item.titleText ?? item.title,
|
||||
view: item.component,
|
||||
},
|
||||
{},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
openShortcutDialog,
|
||||
visibleShortcuts,
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,36 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export type TransparencyGlassQuality = 'lightweight' | 'realtime'
|
||||
|
||||
export interface TransparencySettings {
|
||||
backgroundBlur: number
|
||||
backgroundPosterOpacity: number
|
||||
blur: number
|
||||
glassQuality: TransparencyGlassQuality
|
||||
level: string
|
||||
opacity: number
|
||||
}
|
||||
|
||||
export const transparencyPresets = {
|
||||
low: { opacity: 0.1, blur: 5 },
|
||||
low: { opacity: 0.6, blur: 5 },
|
||||
medium: { opacity: 0.3, blur: 10 },
|
||||
high: { opacity: 0.6, blur: 15 },
|
||||
high: { opacity: 0.1, blur: 15 },
|
||||
}
|
||||
|
||||
export const TRANSPARENCY_SETTINGS_CHANGED_EVENT = 'transparency-settings-changed'
|
||||
|
||||
/** 将数值限制在指定范围内。 */
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
/** 读取玻璃质量档位,未知值回落到默认的轻量玻璃路径。 */
|
||||
function readGlassQuality(): TransparencyGlassQuality {
|
||||
const storedQuality = localStorage.getItem('transparency-glass-quality')
|
||||
|
||||
return storedQuality === 'realtime' ? 'realtime' : 'lightweight'
|
||||
}
|
||||
|
||||
/** 从本地存储读取透明主题设置。 */
|
||||
export function readTransparencySettings(): TransparencySettings {
|
||||
return {
|
||||
@@ -26,6 +38,7 @@ export function readTransparencySettings(): TransparencySettings {
|
||||
blur: parseFloat(localStorage.getItem('transparency-blur') || '10'),
|
||||
backgroundPosterOpacity: parseFloat(localStorage.getItem('transparency-background-poster-opacity') || '0'),
|
||||
backgroundBlur: parseFloat(localStorage.getItem('transparency-background-blur') || '16'),
|
||||
glassQuality: readGlassQuality(),
|
||||
level: localStorage.getItem('transparency-level') || 'medium',
|
||||
}
|
||||
}
|
||||
@@ -39,6 +52,7 @@ export function applyTransparencySettings(settings: TransparencySettings) {
|
||||
? clamp(settings.backgroundPosterOpacity, 0, 1)
|
||||
: 0,
|
||||
backgroundBlur: Number.isFinite(settings.backgroundBlur) ? clamp(settings.backgroundBlur, 0, 30) : 16,
|
||||
glassQuality: settings.glassQuality === 'realtime' ? 'realtime' : 'lightweight',
|
||||
level: settings.level,
|
||||
}
|
||||
|
||||
@@ -51,13 +65,20 @@ export function applyTransparencySettings(settings: TransparencySettings) {
|
||||
root.style.setProperty('--transparent-blur-heavy', `${normalized.blur * 1.6}px`)
|
||||
root.style.setProperty('--transparent-background-poster-opacity', (1 - normalized.backgroundPosterOpacity).toString())
|
||||
root.style.setProperty('--transparent-background-blur', `${normalized.backgroundBlur}px`)
|
||||
root.classList.toggle('transparent-blur-disabled', normalized.blur <= 0)
|
||||
root.classList.toggle('transparent-background-blur-disabled', normalized.backgroundBlur <= 0)
|
||||
root.classList.toggle('transparent-glass-lightweight', normalized.glassQuality === 'lightweight')
|
||||
root.classList.toggle('transparent-glass-realtime', normalized.glassQuality === 'realtime')
|
||||
|
||||
localStorage.setItem('transparency-opacity', normalized.opacity.toString())
|
||||
localStorage.setItem('transparency-blur', normalized.blur.toString())
|
||||
localStorage.setItem('transparency-background-poster-opacity', normalized.backgroundPosterOpacity.toString())
|
||||
localStorage.setItem('transparency-background-blur', normalized.backgroundBlur.toString())
|
||||
localStorage.setItem('transparency-glass-quality', normalized.glassQuality)
|
||||
localStorage.setItem('transparency-level', normalized.level)
|
||||
|
||||
window.dispatchEvent(new CustomEvent<TransparencySettings>(TRANSPARENCY_SETTINGS_CHANGED_EVENT, { detail: normalized }))
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -73,6 +94,7 @@ export function useTransparencySettings() {
|
||||
const transparencyBlur = ref(storedSettings.blur)
|
||||
const backgroundPosterOpacity = ref(storedSettings.backgroundPosterOpacity)
|
||||
const backgroundBlur = ref(storedSettings.backgroundBlur)
|
||||
const transparencyGlassQuality = ref<TransparencyGlassQuality>(storedSettings.glassQuality)
|
||||
const transparencyLevel = ref(storedSettings.level)
|
||||
|
||||
const currentPresetLevel = computed(() => {
|
||||
@@ -95,6 +117,7 @@ export function useTransparencySettings() {
|
||||
blur: transparencyBlur.value,
|
||||
backgroundPosterOpacity: backgroundPosterOpacity.value,
|
||||
backgroundBlur: backgroundBlur.value,
|
||||
glassQuality: transparencyGlassQuality.value,
|
||||
level: transparencyLevel.value,
|
||||
})
|
||||
|
||||
@@ -102,6 +125,7 @@ export function useTransparencySettings() {
|
||||
transparencyBlur.value = normalized.blur
|
||||
backgroundPosterOpacity.value = normalized.backgroundPosterOpacity
|
||||
backgroundBlur.value = normalized.backgroundBlur
|
||||
transparencyGlassQuality.value = normalized.glassQuality
|
||||
transparencyLevel.value = normalized.level
|
||||
}
|
||||
|
||||
@@ -149,12 +173,18 @@ export function useTransparencySettings() {
|
||||
syncTransparencySettings()
|
||||
}
|
||||
|
||||
/** 处理玻璃质量档位变化。 */
|
||||
function onGlassQualityChange() {
|
||||
syncTransparencySettings()
|
||||
}
|
||||
|
||||
/** 重置透明主题设置为默认值。 */
|
||||
function resetTransparencySettings() {
|
||||
transparencyOpacity.value = transparencyPresets.medium.opacity
|
||||
transparencyBlur.value = transparencyPresets.medium.blur
|
||||
backgroundPosterOpacity.value = 0
|
||||
backgroundBlur.value = 16
|
||||
transparencyGlassQuality.value = 'lightweight'
|
||||
transparencyLevel.value = 'medium'
|
||||
syncTransparencySettings()
|
||||
}
|
||||
@@ -167,10 +197,12 @@ export function useTransparencySettings() {
|
||||
onBackgroundBlurChange,
|
||||
onBackgroundPosterOpacityChange,
|
||||
onBlurChange,
|
||||
onGlassQualityChange,
|
||||
onOpacityChange,
|
||||
resetTransparencySettings,
|
||||
syncTransparencySettings,
|
||||
transparencyBlur,
|
||||
transparencyGlassQuality,
|
||||
transparencyOpacity,
|
||||
transparencyLevel,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@ import DefaultLayout from './default/components/DefaultLayout.vue'
|
||||
const route = useRoute()
|
||||
|
||||
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
|
||||
const routeCacheKey = computed(() => route.meta.keepAliveKey?.toString() || route.path)
|
||||
const routeCacheKey = computed(() => {
|
||||
if (route.meta.keepAliveKey) return route.meta.keepAliveKey.toString()
|
||||
|
||||
// 部分列表页的 query 会参与接口参数,缓存 key 需要保留完整路由避免串用旧数据。
|
||||
if (route.meta.keepAliveByFullPath) return route.fullPath
|
||||
|
||||
return route.path
|
||||
})
|
||||
|
||||
// 页面过渡按实际页面身份触发;keep-alive 页面避免 query 变化时反复入场。
|
||||
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
filterMenusByPermission,
|
||||
hasItemPermission,
|
||||
hasPermission,
|
||||
type UserPermissionFeatureKey,
|
||||
type UserPermissionKey,
|
||||
} from '@/utils/permission'
|
||||
import { usePullDownGesture } from '@/composables/usePullDownGesture'
|
||||
@@ -107,7 +108,7 @@ const mainContentPaddingTop = computed(() => {
|
||||
const showPluginQuickAccess = ref(false)
|
||||
|
||||
// 离线状态管理
|
||||
const { setAppOffline, isOffline } = useGlobalOfflineStatus()
|
||||
const { isOffline } = useGlobalOfflineStatus()
|
||||
|
||||
// 动态标签页相关
|
||||
// 定义动态标签页类型
|
||||
@@ -119,6 +120,7 @@ interface DynamicHeaderTabButton {
|
||||
class?: string
|
||||
action?: () => void
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
show?: boolean | ComputedRef<boolean>
|
||||
loading?: boolean | ComputedRef<boolean>
|
||||
dataAttr?: string
|
||||
@@ -128,6 +130,8 @@ interface DynamicHeaderTabItem {
|
||||
title: string
|
||||
icon?: string
|
||||
tab: string
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
}
|
||||
|
||||
interface DynamicHeaderTab {
|
||||
@@ -143,7 +147,7 @@ const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
|
||||
const openHorizontalNavGroup = ref<string | null>(null)
|
||||
const pendingHorizontalTab = ref<{ path: string; tab: string } | null>(null)
|
||||
|
||||
// 提供一个方法让其他组件注册动态标签页
|
||||
/** 注册页面动态标签页,并在页面可用后应用待切换的水平导航标签。 */
|
||||
const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
|
||||
// 保存注册标签页的路由路径
|
||||
tab.routePath = route.path
|
||||
@@ -152,14 +156,16 @@ const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
|
||||
applyPendingHorizontalTab()
|
||||
}
|
||||
|
||||
// 提供一个方法让其他组件取消注册动态标签页
|
||||
/** 取消当前页面注册的动态标签页。 */
|
||||
const unregisterDynamicHeaderTab = () => {
|
||||
dynamicHeaderTab.value = null
|
||||
}
|
||||
|
||||
// 标签页值更新处理
|
||||
/** 更新当前动态标签页选中值,并通知注册页面同步状态。 */
|
||||
const handleTabChange = (newValue: string) => {
|
||||
if (dynamicHeaderTab.value) {
|
||||
if (!visibleDynamicHeaderTabItems.value.some(item => item.tab === newValue)) return
|
||||
|
||||
dynamicHeaderTab.value.modelValue = newValue
|
||||
// 通知注册的页面更新值
|
||||
if (dynamicHeaderTab.value.onUpdateModelValue) {
|
||||
@@ -193,13 +199,15 @@ watch(
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
// 当前路由是否注册了动态标签页。
|
||||
const hasDynamicHeaderTab = computed(() => {
|
||||
return (
|
||||
dynamicHeaderTab.value && dynamicHeaderTab.value.items.length > 0 && dynamicHeaderTab.value.routePath === route.path
|
||||
)
|
||||
const visibleDynamicHeaderTabItems = computed(() => {
|
||||
if (!dynamicHeaderTab.value || dynamicHeaderTab.value.routePath !== route.path) return []
|
||||
|
||||
return filterItemsByPermission(dynamicHeaderTab.value.items, userPermissions.value)
|
||||
})
|
||||
|
||||
// 当前路由是否注册了动态标签页。
|
||||
const hasDynamicHeaderTab = computed(() => visibleDynamicHeaderTabItems.value.length > 0)
|
||||
|
||||
// 水平布局下动态标签页会并入顶部导航三级菜单,不再额外显示标签页栏。
|
||||
const showDynamicHeaderTab = computed(() => hasDynamicHeaderTab.value && !showHorizontalThemeNav.value)
|
||||
|
||||
@@ -227,18 +235,7 @@ onUnmounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 监听Service Worker消息
|
||||
const handleServiceWorkerMessage = (event: MessageEvent) => {
|
||||
if (event.data && event.data.type === 'OFFLINE_STATUS') {
|
||||
if (event.data.offline) {
|
||||
setAppOffline(true, t('common.serverConnectionFailed'))
|
||||
} else {
|
||||
setAppOffline(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否可以使用下拉手势
|
||||
/** 判断当前页面状态是否允许使用主界面下拉快捷入口手势。 */
|
||||
const canUsePullGesture = () => {
|
||||
// 检查是否在dashboard页面
|
||||
const isDashboard = route.path === '/dashboard' || route.path === '/'
|
||||
@@ -279,7 +276,7 @@ const {
|
||||
},
|
||||
})
|
||||
|
||||
// 根据分类获取菜单列表
|
||||
/** 根据菜单分组标题获取当前用户可见的菜单项。 */
|
||||
const getMenuList = (header: string) => {
|
||||
// 使用国际化菜单
|
||||
const menus = getNavMenus(t)
|
||||
@@ -287,19 +284,22 @@ const getMenuList = (header: string) => {
|
||||
return filteredMenus.filter((item: NavMenu) => item.header === header)
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
/** 返回浏览历史中的上一页。 */
|
||||
function goBack() {
|
||||
history.back()
|
||||
}
|
||||
|
||||
/** 同步主题定制器变更后的布局模式。 */
|
||||
function handleThemeCustomizerChange(event: Event) {
|
||||
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
|
||||
}
|
||||
|
||||
/** 打开主题定制器面板。 */
|
||||
function handleThemeCustomizerOpen() {
|
||||
showThemeCustomizer.value = true
|
||||
}
|
||||
|
||||
/** 判断水平导航菜单项是否匹配当前路由。 */
|
||||
function isHorizontalNavActive(item: NavMenu) {
|
||||
const targetPath = normalizeMenuPath(item.to)
|
||||
if (!targetPath) return false
|
||||
@@ -309,18 +309,22 @@ function isHorizontalNavActive(item: NavMenu) {
|
||||
return currentPath === targetPath || currentPath.startsWith(`${targetPath}/`)
|
||||
}
|
||||
|
||||
/** 判断水平导航分组内是否存在当前路由激活项。 */
|
||||
function isHorizontalNavGroupActive(group: { items: NavMenu[] }) {
|
||||
return group.items.some(isHorizontalNavActive)
|
||||
}
|
||||
|
||||
/** 判断菜单项是否存在可在水平导航中展示的动态标签。 */
|
||||
function hasHorizontalDynamicTabs(item: NavMenu) {
|
||||
return showHorizontalThemeNav.value && getHorizontalNavTabs(item).length > 0
|
||||
}
|
||||
|
||||
/** 判断水平导航动态标签是否为当前选中项。 */
|
||||
function isHorizontalDynamicTabActive(tab: DynamicHeaderTabItem) {
|
||||
return dynamicHeaderTab.value?.modelValue === tab.tab
|
||||
}
|
||||
|
||||
/** 切换水平导航中的动态标签,必要时先跳转到目标页面。 */
|
||||
async function handleHorizontalDynamicTabSelect(item: NavMenu, tab: DynamicHeaderTabItem) {
|
||||
const targetPath = normalizeMenuPath(item.to)
|
||||
const currentPath = normalizeMenuPath(route.path)
|
||||
@@ -336,28 +340,34 @@ async function handleHorizontalDynamicTabSelect(item: NavMenu, tab: DynamicHeade
|
||||
openHorizontalNavGroup.value = null
|
||||
}
|
||||
|
||||
/** 关闭当前展开的水平导航分组菜单。 */
|
||||
function closeHorizontalNavGroup() {
|
||||
openHorizontalNavGroup.value = null
|
||||
}
|
||||
|
||||
/** 读取可能是 Ref 的值,空值时回落到默认值。 */
|
||||
function resolveMaybeRefValue<T>(value: T | ComputedRef<T> | undefined, fallback: T): T {
|
||||
return isRef(value) ? value.value : (value ?? fallback)
|
||||
}
|
||||
|
||||
/** 解析动态头部按钮颜色。 */
|
||||
function resolveHeaderButtonColor(button: DynamicHeaderTabButton) {
|
||||
return resolveMaybeRefValue(button.color, 'gray')
|
||||
}
|
||||
|
||||
/** 解析动态头部按钮加载状态。 */
|
||||
function resolveHeaderButtonLoading(button: DynamicHeaderTabButton) {
|
||||
return resolveMaybeRefValue(button.loading, false)
|
||||
}
|
||||
|
||||
/** 校验权限后执行动态头部按钮动作。 */
|
||||
function handleHeaderButtonClick(button: DynamicHeaderTabButton) {
|
||||
if (!hasItemPermission(button, userPermissions.value)) return
|
||||
|
||||
button.action?.()
|
||||
}
|
||||
|
||||
/** 获取水平导航动态标签图标,不可渲染时使用默认圆点图标。 */
|
||||
function getHorizontalTabIcon(tab: DynamicHeaderTabItem) {
|
||||
const icon = tab.icon?.trim()
|
||||
|
||||
@@ -370,45 +380,49 @@ function getHorizontalTabIcon(tab: DynamicHeaderTabItem) {
|
||||
return icon
|
||||
}
|
||||
|
||||
/** 标准化菜单路径,移除末尾斜杠并保留根路径。 */
|
||||
function normalizeMenuPath(value: unknown) {
|
||||
if (typeof value !== 'string') return ''
|
||||
|
||||
return value.replace(/\/$/, '') || '/'
|
||||
}
|
||||
|
||||
/** 获取水平导航菜单项可展示的标签列表。 */
|
||||
function getHorizontalNavTabs(item: NavMenu): DynamicHeaderTabItem[] {
|
||||
const targetPath = normalizeMenuPath(item.to)
|
||||
|
||||
if (targetPath && isHorizontalNavActive(item) && hasDynamicHeaderTab.value) {
|
||||
return dynamicHeaderTab.value?.items ?? []
|
||||
return visibleDynamicHeaderTabItems.value
|
||||
}
|
||||
|
||||
return item.tabs ?? []
|
||||
return filterItemsByPermission(item.tabs ?? [], userPermissions.value)
|
||||
}
|
||||
|
||||
/** 在目标页面注册动态标签后应用此前暂存的标签切换。 */
|
||||
function applyPendingHorizontalTab() {
|
||||
if (!pendingHorizontalTab.value || !hasDynamicHeaderTab.value) return
|
||||
|
||||
const pending = pendingHorizontalTab.value
|
||||
if (normalizeMenuPath(route.path) !== pending.path) return
|
||||
|
||||
const tabExists = dynamicHeaderTab.value?.items.some(item => item.tab === pending.tab)
|
||||
const tabExists = visibleDynamicHeaderTabItems.value.some(item => item.tab === pending.tab)
|
||||
if (!tabExists) return
|
||||
|
||||
handleTabChange(pending.tab)
|
||||
pendingHorizontalTab.value = null
|
||||
}
|
||||
|
||||
// 关闭插件快速访问
|
||||
/** 关闭插件快速访问面板。 */
|
||||
function handleClosePluginQuickAccess() {
|
||||
showPluginQuickAccess.value = false
|
||||
}
|
||||
|
||||
// 点击插件后关闭
|
||||
/** 点击插件入口后关闭插件快速访问面板。 */
|
||||
function handlePluginClick() {
|
||||
showPluginQuickAccess.value = false
|
||||
}
|
||||
|
||||
/** 将插件侧边栏菜单合并到对应的导航分组中。 */
|
||||
function appendPluginSidebarMenus() {
|
||||
for (const { navMenu, section } of filterPluginSidebarNavEntries(
|
||||
pluginSidebarNavStore.items,
|
||||
@@ -451,18 +465,10 @@ onMounted(async () => {
|
||||
await pluginSidebarNavStore.ensureSidebarNav()
|
||||
appendPluginSidebarMenus()
|
||||
|
||||
// 监听Service Worker消息
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
|
||||
}
|
||||
|
||||
// 组件卸载时清理监听
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -474,15 +480,15 @@ onMounted(async () => {
|
||||
<!-- 👉 Pull Down Indicator -->
|
||||
<div
|
||||
v-if="appMode && showPullIndicator"
|
||||
class="pull-indicator"
|
||||
class="app-pull-indicator"
|
||||
:style="{
|
||||
'--pull-indicator-navbar-extra-height': navbarExtraHeight,
|
||||
'--app-pull-indicator-navbar-extra-height': navbarExtraHeight,
|
||||
opacity: indicatorOpacity,
|
||||
transform: indicatorTransform,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="indicator-icon"
|
||||
class="app-pull-indicator__icon"
|
||||
:style="{
|
||||
transform: `scale(${
|
||||
1 + Math.min((pullDistance - PULL_CONFIG.SHOW_INDICATOR) / PULL_CONFIG.MAX_PULL_DISTANCE, 0.5) * 0.3
|
||||
@@ -523,8 +529,8 @@ onMounted(async () => {
|
||||
class="theme-navbar-actions d-flex align-center"
|
||||
:class="{ 'theme-navbar-actions--horizontal': showHorizontalThemeNav }"
|
||||
>
|
||||
<!-- 👉 Horizontal Search Icon -->
|
||||
<SearchBar v-if="showHorizontalThemeNav" icon-only />
|
||||
<!-- 👉 Horizontal Search Bar -->
|
||||
<SearchBar v-if="showHorizontalThemeNav" />
|
||||
<!-- 👉 Shortcuts -->
|
||||
<ShortcutBar v-if="canAdmin" />
|
||||
<!-- 👉 Notification -->
|
||||
@@ -675,7 +681,7 @@ onMounted(async () => {
|
||||
<template #dynamic-header-tab>
|
||||
<div v-if="showDynamicHeaderTab">
|
||||
<HeaderTab
|
||||
:items="dynamicHeaderTab!.items"
|
||||
:items="visibleDynamicHeaderTabItems"
|
||||
:model-value="dynamicHeaderTab!.modelValue"
|
||||
@update:model-value="handleTabChange"
|
||||
>
|
||||
@@ -847,72 +853,4 @@ onMounted(async () => {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.pull-indicator {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(20px);
|
||||
background: rgba(var(--v-theme-surface), 0.3);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 10%),
|
||||
0 1px 3px rgba(0, 0, 0, 6%);
|
||||
inset-block-start: calc(
|
||||
env(safe-area-inset-top, 0px) + 4rem + var(--pull-indicator-navbar-extra-height, 0rem) + 0.75rem
|
||||
);
|
||||
inset-inline-start: 50%;
|
||||
pointer-events: none;
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
block-size: 40px;
|
||||
inline-size: 40px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 透明主题适配 */
|
||||
html[class*='transparent'] .pull-indicator,
|
||||
html[class*='mica'] .pull-indicator,
|
||||
html[class*='acrylic'] .pull-indicator {
|
||||
border: 1px solid rgba(255, 255, 255, 20%);
|
||||
background: rgba(255, 255, 255, 95%);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 12%),
|
||||
0 4px 16px rgba(0, 0, 0, 8%);
|
||||
}
|
||||
|
||||
html[class*='transparent'] .indicator-icon,
|
||||
html[class*='mica'] .indicator-icon,
|
||||
html[class*='acrylic'] .indicator-icon {
|
||||
background: rgba(var(--v-theme-primary), 0.12);
|
||||
}
|
||||
|
||||
html[data-theme='dark'][class*='transparent'] .pull-indicator,
|
||||
html[data-theme='dark'][class*='mica'] .pull-indicator,
|
||||
html[data-theme='dark'][class*='acrylic'] .pull-indicator {
|
||||
border: 1px solid rgba(255, 255, 255, 10%);
|
||||
background: rgba(18, 18, 18, 95%);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 30%),
|
||||
0 4px 16px rgba(0, 0, 0, 20%);
|
||||
}
|
||||
|
||||
html[data-theme='dark'][class*='transparent'] .indicator-icon,
|
||||
html[data-theme='dark'][class*='mica'] .indicator-icon,
|
||||
html[data-theme='dark'][class*='acrylic'] .indicator-icon {
|
||||
background: rgba(var(--v-theme-primary), 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -119,6 +119,7 @@ interface DynamicButton {
|
||||
icon: string
|
||||
action: () => void
|
||||
permission?: DynamicButtonMenuItem['permission']
|
||||
feature?: DynamicButtonMenuItem['feature']
|
||||
show: boolean
|
||||
routePath?: string // 添加路径属性,用于标识哪个路由注册的
|
||||
menuItems?: DynamicButtonMenuItem[]
|
||||
|
||||
@@ -1,66 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
const OfflineStatusDialog = defineAsyncComponent(() => import('@/components/dialog/OfflineStatusDialog.vue'))
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { connectionStatus, connectionReason } = useGlobalOfflineStatus()
|
||||
const shownConnectionPromptKeys = new Set<string>()
|
||||
|
||||
interface Props {
|
||||
type?: 'offline' | 'online'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'offline',
|
||||
const isChecking = computed(() => connectionStatus.value === 'checking')
|
||||
const statusTitle = computed(() => (isChecking.value ? t('app.connectionChecking') : t('app.serviceUnavailable')))
|
||||
const statusMessage = computed(() => {
|
||||
if (connectionReason.value === 'browser-offline') return t('app.browserOfflineMessage')
|
||||
if (connectionReason.value === 'timeout') return t('app.serviceTimeoutMessage')
|
||||
if (isChecking.value) return t('app.connectionCheckingMessage')
|
||||
return t('app.serviceUnavailableMessage')
|
||||
})
|
||||
|
||||
const { canPerformNetworkAction } = useGlobalOfflineStatus()
|
||||
let offlineDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
/** 拼接离线状态提示文案,供 Toast 或 Agent 助手气泡展示。 */
|
||||
function buildConnectionPromptMessage() {
|
||||
return `${statusTitle.value}:${statusMessage.value}`
|
||||
}
|
||||
|
||||
/** 打开离线状态共享弹窗。 */
|
||||
function showOfflineDialog() {
|
||||
if (offlineDialogController) {
|
||||
offlineDialogController.updateProps({ type: props.type })
|
||||
/** 根据当前连接状态选择 Toast 级别,Agent 助手可用时会由全局 Toast 路由接管。 */
|
||||
function showConnectionPrompt() {
|
||||
const message = buildConnectionPromptMessage()
|
||||
const options = {
|
||||
timeout: isChecking.value ? 5000 : 7000,
|
||||
}
|
||||
|
||||
if (isChecking.value) {
|
||||
toast.warning(message, options)
|
||||
return
|
||||
}
|
||||
|
||||
offlineDialogController = openSharedDialog(
|
||||
OfflineStatusDialog,
|
||||
{
|
||||
type: props.type,
|
||||
},
|
||||
{},
|
||||
{ closeOn: false },
|
||||
)
|
||||
toast.error(message, options)
|
||||
}
|
||||
|
||||
/** 关闭离线状态共享弹窗。 */
|
||||
function closeOfflineDialog() {
|
||||
offlineDialogController?.close()
|
||||
offlineDialogController = null
|
||||
/** 在同一轮连接异常内按状态去重提示,并在恢复在线后允许下一轮提示重新出现。 */
|
||||
function handleConnectionStatusChange() {
|
||||
if (connectionStatus.value === 'online') {
|
||||
shownConnectionPromptKeys.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const promptKey =
|
||||
connectionStatus.value === 'checking'
|
||||
? connectionStatus.value
|
||||
: `${connectionStatus.value}:${connectionReason.value || 'unknown'}`
|
||||
|
||||
if (shownConnectionPromptKeys.has(promptKey)) return
|
||||
|
||||
shownConnectionPromptKeys.add(promptKey)
|
||||
showConnectionPrompt()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => canPerformNetworkAction.value,
|
||||
canPerform => {
|
||||
if (canPerform) {
|
||||
closeOfflineDialog()
|
||||
return
|
||||
}
|
||||
|
||||
showOfflineDialog()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.type,
|
||||
() => {
|
||||
offlineDialogController?.updateProps({ type: props.type })
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
closeOfflineDialog()
|
||||
watch([connectionStatus, connectionReason], handleConnectionStatusChange, {
|
||||
flush: 'post',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template></template>
|
||||
<template>
|
||||
<span class="d-none" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
@@ -1,109 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import * as Mousetrap from 'mousetrap'
|
||||
import SearchBarDialog from '@/components/dialog/SearchBarDialog.vue'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
iconOnly?: boolean
|
||||
}>(),
|
||||
{
|
||||
iconOnly: false,
|
||||
},
|
||||
)
|
||||
const searchOpen = ref(false)
|
||||
const searchBar = ref<InstanceType<typeof SearchBarDialog> | null>(null)
|
||||
|
||||
const display = useDisplay()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 注册快捷键
|
||||
Mousetrap.bind(['command+k', 'ctrl+k'], openSearchDialog)
|
||||
|
||||
/** 打开全局共享搜索弹窗。 */
|
||||
function openSearchDialog() {
|
||||
openSharedDialog(SearchBarDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||
/** 打开全局搜索,并在桌面端聚焦常驻搜索输入框。 */
|
||||
function openSearch() {
|
||||
searchOpen.value = true
|
||||
nextTick(() => searchBar.value?.focusSearchInput())
|
||||
return false
|
||||
}
|
||||
|
||||
/** 检测操作系统是否是 Mac。 */
|
||||
function isMac() {
|
||||
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
/** 关闭当前搜索浮层。 */
|
||||
function closeSearch() {
|
||||
searchOpen.value = false
|
||||
}
|
||||
// 计算属性:根据操作系统显示不同的按键提示
|
||||
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
||||
const showIconOnly = computed(() => props.iconOnly || !display.mdAndUp.value)
|
||||
|
||||
onMounted(() => {
|
||||
Mousetrap.bind(['command+k', 'ctrl+k'], openSearch)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
Mousetrap.unbind(['command+k', 'ctrl+k'])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 小屏或水平导航右侧工具区:仅显示搜索图标。 -->
|
||||
<IconBtn
|
||||
v-if="showIconOnly"
|
||||
class="search-icon-trigger"
|
||||
:aria-label="t('dialog.searchBar.openSearch')"
|
||||
@click="openSearchDialog"
|
||||
>
|
||||
<VIcon class="search-icon-trigger__icon" icon="mdi-magnify" />
|
||||
</IconBtn>
|
||||
|
||||
<!-- 中屏及以上:与用户头像同尺寸的搜索入口。 -->
|
||||
<VTooltip v-else :text="`${t('dialog.searchBar.openSearch')} (${metaKey})`">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<button
|
||||
v-bind="tooltipProps"
|
||||
class="search-trigger"
|
||||
type="button"
|
||||
:aria-label="t('dialog.searchBar.openSearch')"
|
||||
@click="openSearchDialog"
|
||||
>
|
||||
<VIcon icon="mdi-magnify" size="24" class="search-trigger-icon" />
|
||||
</button>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<SearchBarDialog ref="searchBar" v-model="searchOpen" show-activator @close="closeSearch" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--v-theme-surface), 0.44);
|
||||
block-size: 40px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
inline-size: 40px;
|
||||
padding: 0;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.search-trigger:hover {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.18);
|
||||
background-color: rgba(var(--v-theme-surface), 0.62);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 6%);
|
||||
}
|
||||
|
||||
.search-trigger-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
html[data-theme='transparent'] .search-trigger,
|
||||
.v-theme--transparent .search-trigger {
|
||||
backdrop-filter: none;
|
||||
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
|
||||
}
|
||||
|
||||
.search-icon-trigger {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.search-icon-trigger__icon {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user