Compare commits

..
59 Commits
Author SHA1 Message Date
jxxghpandInfinityPacer 6a4af66683 fix(glass): preserve content clarity across transparency (#656) (#657)
Co-authored-by: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com>
2026-08-10 12:49:05 +08:00
jxxghp bb53a181a9 feat(music): add audio quality controls 2026-08-10 10:23:02 +08:00
jxxghp c5f7924484 fix(music): label subscription entity types 2026-08-10 09:06:56 +08:00
jxxghp cf5b95ec04 fix(music): increase search result density 2026-08-10 08:43:14 +08:00
jxxghp b62e8dc1a6 Merge branch 'v2' into v3 2026-08-10 07:42:11 +08:00
InfinityPacer eb3b7b63b6 fix(glass): unify content surfaces and renderer eligibility (#653) 2026-08-10 06:54:29 +08:00
InfinityPacer 309ce3c92e fix(a11y): label dialog close buttons (#654) 2026-08-10 06:53:56 +08:00
jxxghp ce0b0d6330 fix(music): preserve subscription entity semantics 2026-08-09 23:30:31 +08:00
jxxghp 2a01a2ffe0 feat(discover): persist tab visibility settings 2026-08-09 23:27:41 +08:00
jxxghp 6ca606a864 fix(images): honor global cache across cards 2026-08-09 22:20:51 +08:00
jxxghp 73840e3480 feat(music): add lyrics scraping setting 2026-08-09 22:20:07 +08:00
jxxghp 98a9f87768 feat(music): support album subscription semantics 2026-08-09 20:43:33 +08:00
jxxghp dd6f644f05 fix: 避免音乐专辑副标题重复标题 2026-08-09 18:34:19 +08:00
jxxghp 6db19ee6f7 feat: 优化音乐卡片与搜索站点筛选 2026-08-09 18:12:29 +08:00
cosini d1d46353dd fix: 修复智能助手回答内容超出界面宽度的问题 (#652) 2026-08-09 17:58:07 +08:00
jxxghp cfd08059a4 feat: 优化正在下载卡片信息层级 2026-08-09 17:50:19 +08:00
jxxghp 278a7587c1 feat: 完善音乐识别结果弹窗 2026-08-09 17:05:36 +08:00
jxxghp b2c4a69173 feat: support fullscreen AI assistant 2026-08-09 16:36:04 +08:00
jxxghp a98271f8a1 style: 对齐 AList 存储前端格式 2026-08-09 14:53:13 +08:00
okatu-loli eabe8f6951 feat: 添加 AList 存储选项 2026-08-09 14:48:18 +08:00
千石 e6b26cc831 feat: 添加 AList 存储选项 (#645) 2026-08-09 14:40:54 +08:00
jxxghp e242b881de fix tests 2026-08-09 14:12:11 +08:00
jxxghp e23d9a6f41 feat: 调整站点资源对话框布局,优化输入框和选择框的列宽 2026-08-09 13:01:39 +08:00
jxxghp f21274ebca feat: 更新音乐页面加载状态显示,使用 LoadingBanner 替代 VSkeletonLoader 2026-08-09 12:56:05 +08:00
jxxghp 139721d62a fix: 修正订阅卡片占位符样式和媒体 ID 校验逻辑 2026-08-09 12:49:54 +08:00
LLL001a 6bf9cf9d18 feat: 系统设置增加音乐封面代理地址配置项 (#651) 2026-08-09 12:43:00 +08:00
jxxghp f080da1ebb feat: 更新多个组件的背景样式为透明度背景 2026-08-09 12:35:09 +08:00
jxxghp 4b70b98ffc feat: 为音乐类型添加图标支持 2026-08-09 11:56:54 +08:00
jxxghp fe442fda4b feat: 添加媒体占位图标并优化无封面处理逻辑 2026-08-09 11:40:08 +08:00
jxxghp ede4fe94ce fix: 修正搜索栏音乐入口测试断言文案
音乐搜索动作的描述文案已更新为 music.searchDescription(歌曲、专辑或艺术家),
同步修正过时的测试断言。
2026-08-09 10:58:29 +08:00
jxxghp 20fcd699f2 fix: 影视识别结果卡片不再渲染音乐专属标签
音乐标签(艺术家、专辑、发行日期、风格、时长等)增加 isMusic 保护,
避免电影/电视剧识别结果的 genres/release_date 等字段被误展示;
补充影视场景的回归测试。
2026-08-09 10:54:05 +08:00
jxxghp 009ce6173b feat: 音乐搜索与识别前端联动统一
- 音乐搜索从 music/search 切换为 media/search?type=music
- MediaInfoCard 支持音乐卡片(方形封面、时长、音乐实体类型、详情外链)
- 订阅与资源选择器适配音乐元数据
- 同步中英繁语言包
2026-08-09 10:18:42 +08:00
jxxghp b9194ffb74 更新 en-US.ts 2026-08-08 15:49:29 +08:00
jxxghp 942a44cde6 更新 zh-TW.ts 2026-08-08 15:48:54 +08:00
jxxghp 3bf52231f7 更新 zh-CN.ts 2026-08-08 15:48:21 +08:00
jxxghp 109a0b1af5 Delete unused files 2026-08-08 15:41:25 +08:00
jxxghpandtraeagent 777c4ec7e3 feat: 优化媒体卡片与版本标志 (#650)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-08-08 15:33:55 +08:00
jxxghpandtraeagent 1064156062 Trae/music subscribe card cover fallback (#648)
* feat: 优化音乐搜索与详情页面

Co-authored-by: traeagent <traeagent@users.noreply.github.com>

* feat: 优化音乐搜索与详情页面

Co-authored-by: traeagent <traeagent@users.noreply.github.com>

---------

Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-08-08 14:58:45 +08:00
jxxghpandtraeagent 693e6fb352 feat: 优化音乐搜索与详情页面 (#647)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-08-08 14:36:07 +08:00
jxxghp 70bb6e1040 feat(music): add MusicAlbumView, MusicArtistView, and MusicDetailView components
- Implement MusicAlbumView for displaying album details, including tracks and artist links.
- Create MusicArtistView to show artist information and their albums.
- Develop MusicDetailView for individual music tracks, integrating album details and related artists.
- Introduce MusicDetailLayout for consistent layout across music-related views.
- Add MusicArtistSlideView for displaying related artists in a slide format.
- Enhance user experience with loading states and error handling in NoDataFound component.
2026-08-08 12:56:22 +08:00
jxxghp 02e3314853 fix(v3): finish music discovery and detail flows 2026-08-07 23:46:54 +08:00
jxxghp d48ee55bab fix(v3): route music search through global search 2026-08-07 22:54:39 +08:00
jxxghp d72bb75996 chore(v3): prune stale eslint suppression 2026-08-07 22:16:40 +08:00
jxxghp 95674fa47a ci(v3): publish frontend from v3 2026-08-07 22:08:37 +08:00
jxxghp 6afa2bdc8a feat(v3): add music search and subscription UI 2026-08-07 21:50:22 +08:00
jxxghp 7ea14bc919 fix confirm dialog button text color 2026-08-07 17:33:02 +08:00
jxxghp 1dfb51f3c4 Improve agent input handling and tool summary grouping 2026-08-07 13:06:01 +08:00
jxxghp 871a9a072b fix(agent): preserve tool and content ordering 2026-08-07 13:01:47 +08:00
jxxghp 0eb81b4ecb perf(agent): smooth SSE streaming updates 2026-08-07 12:42:57 +08:00
jxxghp 4814f53c4e fix(download): place metadata chips below title 2026-08-07 07:35:07 +08:00
秋澪Akimio 0703a99e41 feat: words editor replacement highlight (#644) 2026-08-07 07:01:41 +08:00
jxxghp 21b485ca84 fix(api): type avatar upload response data 2026-08-06 23:36:34 +08:00
jxxghp fc6313b79e fix(agent): preserve streamed message order 2026-08-06 22:24:54 +08:00
jxxghp 8badfb0da9 fix(download): keep torrent size visible 2026-08-06 17:45:56 +08:00
jxxghp bfd61cd479 fix(api): read unified error messages 2026-08-06 17:31:00 +08:00
jxxghp ac29818fd3 Refine assistant layering and plugin source settings 2026-08-06 10:33:20 +08:00
jxxghp 8d18f82e68 Refine responsive input action visibility 2026-08-06 08:21:49 +08:00
jxxghp fd1bf8df7f test(site): cover chart tooltip formatters 2026-08-06 08:01:10 +08:00
jxxghp 6fd259427e refactor(cache): manage TMDB recognition cache only 2026-08-06 07:48:43 +08:00
155 changed files with 10687 additions and 1975 deletions
+2 -2
View File
@@ -1,10 +1,10 @@
name: Build Moviepilot-Frontend v2
name: Build Moviepilot-Frontend v3
on:
workflow_dispatch:
push:
branches:
- v2
- v3
paths:
- 'package.json'
+5 -5
View File
@@ -3,10 +3,10 @@ name: Frontend Tests
on:
pull_request:
branches:
- v2
- v3
push:
branches:
- v2
- v3
permissions:
contents: read
@@ -62,7 +62,7 @@ jobs:
- name: Lint
run: yarn lint
typecheck-and-coverage:
typecheck-and-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -82,5 +82,5 @@ jobs:
- name: Typecheck
run: yarn typecheck
- name: Unit tests with coverage
run: yarn test:coverage
- name: Unit tests
run: yarn test:run
+9 -9
View File
@@ -9,7 +9,7 @@
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
| Vitest | 单元测试、组件测试和覆盖率门槛 | 生产构建与真实浏览器行为 |
| Vitest | 单元测试、组件测试和按需覆盖率报告 | 生产构建与真实浏览器行为 |
| Vite | 生产构建和构建期集成验证 | 类型完整性和代码质量规则 |
ESLint 配置由仓库显式维护,不继承 Antfu 等覆盖面较大的个人风格预设。JavaScript、TypeScript 与 Vue 使用各自面向正确性的 recommended/essential 基线;SonarJS 不整包展开 recommended,而只显式启用安全和正确性的高信号规则,避免插件升级隐式扩大检查范围。
@@ -22,7 +22,7 @@ Prettier 独立运行,ESLint 中与格式重叠的规则保持关闭。任何
- 最低兼容目标为 Node.js 20.19Node.js 20.19、22 和 24 的开发环境可以安装依赖并运行项目命令。
- 推荐开发环境和 CI 主环境使用 Node.js 24。
- CI 若继续声明兼容 Node.js 20.19,应至少在该版本验证 frozen lockfile 安装、lint、typecheck 和 build完整覆盖率门禁在 Node.js 24 执行。
- CI 若继续声明兼容 Node.js 20.19,应至少在该版本验证 frozen lockfile 安装、lint、typecheck 和 build主测试 job 使用 Node.js 24 执行单元测试
- Yarn 1 与 `yarn.lock` 继续作为依赖安装事实源,CI 使用 `yarn --frozen-lockfile`
- Node 只用于依赖安装、开发、测试和前端构建;MoviePilot 正式 Docker 镜像使用预构建前端产物,不因开发工具链升级而增加 Node 运行时。
@@ -60,7 +60,7 @@ ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业
5. 只检查 JavaScript、TypeScript、Vue 与相关测试文件;Markdown、JSON、YAML、TOML、构建产物和生成文件不在首阶段扩展范围内。
6. 默认 lint 只读,并提供单独的 `lint:fix`
7. 对确有价值但存在存量的问题使用明确基线或分阶段启用,不通过全仓自动修复制造大面积混合变更。
8. 项目边界与新增问题门禁使用 Vitest 配置契约测试保护,并随现有 `test:coverage` CI 自动执行。
8. 项目边界与新增问题门禁使用 Vitest 配置契约测试保护,并随现有 `test:run` CI 自动执行。
`eslint-suppressions.json` 只冻结迁移时已经确认的文件、规则和数量。新增问题不得加入 baseline;修复存量问题后运行 `yarn lint:suppressions:prune`,并把裁剪结果与代码修复一同提交。日常开发和 CI 不得使用 `--suppress-all`
@@ -70,7 +70,7 @@ ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业
yarn --frozen-lockfile
yarn lint
yarn typecheck
yarn test:coverage
yarn test:run
yarn build
```
@@ -80,7 +80,7 @@ yarn build
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job
1. lint 与 `typecheck-and-coverage` 使用不同 job,保持静态检查和测试覆盖率职责独立。
1. lint 与 `typecheck-and-tests` 使用不同 job,保持静态检查和单元测试职责独立。
2. 初始阶段作为普通 check 运行,不立即配置 required check。
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。
4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
@@ -105,7 +105,7 @@ Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,
3. 仅把 Prettier 支持且由仓库管理的文件传给 `prettier --check`
4. 文件集合为空时正常通过。
本地直接运行 `yarn format``yarn format:check` 时,脚本按 `refs/remotes/upstream/v2``refs/remotes/origin/v2``refs/heads/v2` 的顺序选择与当前提交具有共同祖先的基线,并合并当前工作区与未跟踪文件。完整引用可避免同名标签造成歧义;CI 使用显式提交 SHA 保证输入可复现:
本地直接运行 `yarn format``yarn format:check` 时,脚本按 `refs/remotes/upstream/v3``refs/remotes/origin/v3``refs/heads/v3` 的顺序选择与当前提交具有共同祖先的基线,并合并当前工作区与未跟踪文件。完整引用可避免同名标签造成歧义;CI 使用显式提交 SHA 保证输入可复现:
```sh
yarn format:check --base <base-sha> --head <head-sha>
@@ -135,7 +135,7 @@ Prettier 3.9.5 基础设施接入时,全仓只读检查在 `v2` 基线报告 2
- 全仓 `yarn format:all:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
- 最近的活跃分支已合并或完成同步,避免集中格式变化制造冲突。
- `yarn lint``yarn typecheck``yarn test:coverage``yarn build` 在格式收敛后全部通过。
- `yarn lint``yarn typecheck``yarn test:run``yarn build` 在格式收敛后全部通过。
- Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。
- workflow 已以非 required 状态稳定运行多个 PR。
@@ -146,7 +146,7 @@ yarn --frozen-lockfile
yarn format:all:check
yarn lint
yarn typecheck
yarn test:coverage
yarn test:run
yarn build
```
@@ -159,7 +159,7 @@ PR-Agent、编辑器诊断和人工 review 可以补充判断,但不能替代
1. 只对本次新增或修改的文件执行 Prettier。
2. 运行全仓只读 lint。
3. 运行 typecheck。
4. 按影响面运行聚焦测试;提交 PR 前运行覆盖率门禁。
4. 按影响面运行聚焦测试;覆盖率报告按需本地运行,不作为 Actions 门禁。
5. 涉及构建配置、依赖、Vue SFC 或模块联邦时运行生产 build。
编辑器设置不得成为通过验证的前提。文档、Agent 指令和 PR 说明应引用 `yarn` 命令,而不是“在 VS Code 保存一次”或依赖某个 IDE 扩展完成修复。
+3 -3
View File
@@ -72,13 +72,13 @@ tests/
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
5. 核心覆盖范围发生变化时,同步更新 `vite.config.ts``coverage.include`
6. 提交前运行测试、覆盖率、类型检查、lint 和生产构建。
6. 提交前按影响面运行测试、类型检查、lint 和生产构建;覆盖率报告按需本地执行
## 配置边界
Vitest 收集 `src/**/__tests__/**/*.spec.ts``tests/config/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。配置契约测试随全量测试执行,但不加入业务源码覆盖率统计范围。
当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%Branches 不低于 75%。覆盖率报告写入 `coverage/`
当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护,覆盖率报告写入 `coverage/`。覆盖率配置保留给本地质量分析,不作为 GitHub Actions 的单元测试门禁
## 命令与 CI
@@ -92,4 +92,4 @@ yarn lint
yarn build
```
`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile,在面向 `v2` 的 Pull Request 和推送到 `v2` 时运行。`typecheck-and-coverage` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查。变更文件格式检查依赖 Pull Request 的 base/head SHA,因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile,在面向 `v3` 的 Pull Request 和推送到 `v3` 时运行。`typecheck-and-tests` job 执行类型检查和单元测试(`yarn test:run`),不要求覆盖率达标;覆盖率可按需在本地运行 `yarn test:coverage`独立的 `lint` job 执行全仓只读 ESLint 检查。变更文件格式检查依赖 Pull Request 的 base/head SHA,因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
+3 -8
View File
@@ -60,7 +60,7 @@
},
"src/ace-config.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 31
"count": 24
},
"no-useless-escape": {
"count": 4
@@ -162,11 +162,6 @@
"count": 1
}
},
"src/components/cards/__tests__/SubscribeCard.spec.ts": {
"sonarjs/super-linear-regex": {
"count": 2
}
},
"src/components/dialog/AboutDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 8
@@ -696,7 +691,7 @@
"count": 3
},
"sonarjs/no-duplicated-branches": {
"count": 3
"count": 2
}
},
"src/pages/setting.vue": {
@@ -1056,4 +1051,4 @@
"count": 1
}
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.15.4",
"version": "3.0.0",
"private": true,
"type": "module",
"bin": "dist/service.js",
+3 -2
View File
@@ -65,13 +65,14 @@ http {
root html;
}
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$|message/agent/stream$) {
# SSE MIME类型设置
default_type text/event-stream;
# 禁用缓存
add_header Cache-Control no-cache;
add_header Cache-Control "no-cache, no-transform";
add_header X-Accel-Buffering no;
gzip off;
proxy_buffering off;
proxy_cache off;
+3 -3
View File
@@ -5,7 +5,7 @@ import process from 'node:process'
import { fileURLToPath, pathToFileURL } from 'node:url'
import * as prettier from 'prettier'
const DEFAULT_BASE_REFS = ['refs/remotes/upstream/v2', 'refs/remotes/origin/v2', 'refs/heads/v2']
const DEFAULT_BASE_REFS = ['refs/remotes/upstream/v3', 'refs/remotes/origin/v3', 'refs/heads/v3']
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024
const PRETTIER_ARGUMENT_BUDGET = 24_000
const prettierCliPath = fileURLToPath(new URL('../node_modules/prettier/bin/prettier.cjs', import.meta.url))
@@ -47,14 +47,14 @@ function hasMergeBase(cwd, baseCommit, headCommit) {
return spawnSync('git', ['merge-base', baseCommit, headCommit], { cwd, stdio: 'ignore' }).status === 0
}
/** 为本地命令选择与当前提交具有共同祖先的 v2 基线。 */
/** 为本地命令选择与当前提交具有共同祖先的 v3 基线。 */
function resolveDefaultBase(cwd, headCommit) {
for (const ref of DEFAULT_BASE_REFS) {
const commit = tryResolveCommit(cwd, ref)
if (commit && hasMergeBase(cwd, commit, headCommit)) return commit
}
throw new Error('无法定位可比较的 v2 基线,请通过 --base <ref> 显式指定比较起点。')
throw new Error('无法定位可比较的 v3 基线,请通过 --base <ref> 显式指定比较起点。')
}
/**
+1 -1
View File
@@ -72,7 +72,7 @@ function handleCancel() {
</div>
</div>
</VCardItem>
<VCardActions class="mx-auto">
<VCardActions class="app-confirm-dialog-actions mx-auto">
<VBtn variant="tonal" color="secondary" class="px-5" @click="handleCancel">
{{ cancelText }}
</VBtn>
+6 -4
View File
@@ -1,12 +1,13 @@
<script lang="ts" setup>
// 定义输入参数
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
// 是否显示
/** 覆盖关闭按钮默认的右上角定位 class。 */
innerClass: String,
})
// 定义触发的自定义事件
const emit = defineEmits(['click', 'update:modelValue'])
// 按钮点击
function onClick() {
emit('update:modelValue', false)
emit('click')
@@ -15,6 +16,7 @@ function onClick() {
<template>
<IconBtn
:aria-label="t('common.close')"
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'"
@click.stop="onClick"
>
@@ -0,0 +1,40 @@
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import i18n from '@/plugins/i18n'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { afterEach, describe, expect, it } from 'vitest'
afterEach(() => {
i18n.global.locale.value = 'zh-CN'
})
describe('DialogCloseBtn', () => {
it('keeps its visual and event contracts while exposing a localized name', async () => {
const user = userEvent.setup()
const { container, emitted, rerender } = await renderWithProviders(DialogCloseBtn)
const button = screen.getByRole('button', { name: '关闭' })
expect(button).toHaveClass('absolute', 'right-3', 'top-3', 'z-10')
const icon = container.querySelector('svg.v-icon')
expect(icon).not.toBeNull()
expect(icon).toHaveAttribute('aria-hidden', 'true')
await waitFor(() =>
expect(icon?.querySelector('path')).toHaveAttribute(
'd',
'M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z',
),
)
i18n.global.locale.value = 'en-US'
await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toBe(button))
await user.click(button)
expect(emitted()['update:modelValue']).toEqual([[false]])
expect(emitted().click).toEqual([[]])
await rerender({ innerClass: 'dialog-close-custom' })
expect(button).toHaveClass('dialog-close-custom')
expect(button).not.toHaveClass('absolute', 'right-3', 'top-3', 'z-10')
})
})
+1 -1
View File
@@ -54,7 +54,7 @@ function handleNavScroll(evt: Event) {
<ThemeLogoMark />
<h1 class="leading-normal text-xl">
<span class="moviepilot-wordmark">MOVIEPILOT</span> <span class="text-sm text-gray-500">v2</span>
<span class="moviepilot-wordmark">MOVIEPILOT</span> <span class="text-sm text-gray-500">v3</span>
</h1>
</RouterLink>
</slot>
+120 -6
View File
@@ -7,17 +7,22 @@ interface AceToken {
value: string
}
interface WordListSyntaxMode {
interface WordListMode {
getTokenizer: () => {
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
}
}
const WordListSyntaxMode = ace.require('ace/mode/word_list_syntax').Mode as new () => WordListSyntaxMode
const tokenizer = new WordListSyntaxMode().getTokenizer()
const WordListMode = ace.require('ace/mode/word_list').Mode as new (options?: { syntax?: boolean }) => WordListMode
const syntaxTokenizer = new WordListMode({ syntax: true }).getTokenizer()
const plainTokenizer = new WordListMode().getTokenizer()
function tokenize(line: string) {
return tokenizer.getLineTokens(line, 'start').tokens
return syntaxTokenizer.getLineTokens(line, 'start').tokens
}
function tokenizePlain(line: string) {
return plainTokenizer.getLineTokens(line, 'start').tokens
}
describe('word list syntax mode', () => {
@@ -25,14 +30,103 @@ describe('word list syntax mode', () => {
expect(tokenize('屏蔽词')).toEqual([{ type: 'word_list_block', value: '屏蔽词' }])
})
it('separates replaced and replacement fields without parsing their content', () => {
it('parses valid replacement parameters', () => {
expect(tokenize('旧名.* => 新名 {[tmdbid=123;type=tv]}')).toEqual([
{ type: 'word_list_replaced', value: '旧名.*' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 {[tmdbid=123;type=tv]}' },
{ type: 'word_list_replacement', value: '新名 ' },
{ type: 'word_list_parameter_syntax', value: '{[' },
{ type: 'word_list_parameter_key', value: 'tmdbid' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '123' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'type' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: 'tv' },
{ type: 'word_list_parameter_syntax', value: ']}' },
])
})
it('marks invalid replacement parameter keys and values', () => {
expect(tokenize('旧名 => 新名 {[unknown=1;tmdbid=abc;type=anime;g=group;s=2;e=3]}')).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 ' },
{ type: 'word_list_parameter_syntax', value: '{[' },
{ type: 'invalid.word-list', value: 'unknown' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '1' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'tmdbid' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'invalid.word-list', value: 'abc' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'type' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'invalid.word-list', value: 'anime' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'g' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: 'group' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 's' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '2' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'e' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '3' },
{ type: 'word_list_parameter_syntax', value: ']}' },
])
})
it('marks an unknown replacement parameter key as invalid', () => {
expect(tokenize('旧名 => {[unknown=1]}')).toContainEqual({ type: 'invalid.word-list', value: 'unknown' })
})
it('marks visible syntax for missing or malformed replacement parameters', () => {
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123;broken;;]}')
.filter(token => token.type === 'invalid.word-list')
.map(token => token.value)
expect(invalidTokenValues).toEqual(['tmdbid=', '=123', 'broken;;'])
expect(tokenize('旧名 => {[]}')).toContainEqual({ type: 'invalid.word-list', value: '{[]}' })
})
it('marks the second semicolon when it creates an empty parameter', () => {
const semicolonTokens = tokenize('旧名 => {[tmdbid=1;;type=tv]}').filter(token => token.value.includes(';'))
expect(semicolonTokens).toEqual([{ type: 'invalid.word-list', value: ';;' }])
})
it('marks both sides of missing keys and values', () => {
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123]}')
.filter(token => token.type === 'invalid.word-list')
.map(token => token.value)
expect(invalidTokenValues).toEqual(['tmdbid=', '=123'])
})
it('treats an unclosed parameter block as ordinary replacement text', () => {
expect(tokenize('旧名 => 新名 {[tmdbid=1')).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 {[tmdbid=1' },
])
})
it('accepts every supported replacement parameter type', () => {
expect(
tokenize('旧名 => {[tmdbid=1;doubanid=2;bangumiid=3;anilistid=4;type=movie;g=group;s=5;e=6]}'),
).not.toContainEqual(expect.objectContaining({ type: 'invalid.word-list' }))
})
it('accepts season and episode number ranges', () => {
expect(tokenize('旧名 => {[s=1-2;e=3-5]}')).not.toContainEqual(
expect.objectContaining({ type: 'invalid.word-list' }),
)
})
it('separates front, back, and episode offset fields', () => {
expect(tokenize('第 <> 集 >> EP+1')).toEqual([
{ type: 'word_list_front', value: '第' },
@@ -59,4 +153,24 @@ describe('word list syntax mode', () => {
])
expect(tokenize('屏蔽词')[0].type).toBe('word_list_block')
})
it('highlights comment lines', () => {
expect(tokenize('# 这是一个注释')).toEqual([{ type: 'comment.word-list', value: '# 这是一个注释' }])
})
})
describe('word list plain mode', () => {
it('does not highlight comment lines', () => {
expect(tokenizePlain('# 这是一个注释')).toEqual([{ type: 'text', value: '# 这是一个注释' }])
})
it('does not highlight word list fields', () => {
expect(tokenizePlain('旧名 => 新名 && 第 <> 集 >> 2*EP-1')).toEqual([
{ type: 'text', value: '旧名 => 新名 && 第 <> 集 >> 2*EP-1' },
])
})
it('returns no tokens for empty lines', () => {
expect(tokenizePlain('')).toEqual([])
})
})
+232 -94
View File
@@ -530,6 +530,234 @@ function registerJinja2Mode() {
)
}
interface WordListToken {
type: string
value: string
}
const wordListReplacementParametersPattern = /\{\[([^\]]*)\]\}/g
const wordListUnsignedIntegerPattern = /^\d+$/
const wordListUnsignedIntegerOrRangePattern = /^\d+(?:-\d+)?$/
const wordListParameterTypes = {
tmdbid: 'uint',
doubanid: 'uint',
bangumiid: 'uint',
anilistid: 'uint',
type: 'media-type',
g: 'string',
s: 'uint-or-range',
e: 'uint-or-range',
} as const
function appendWordListToken(tokens: WordListToken[], type: string, value: string) {
if (!value) return
const previousToken = tokens.at(-1)
if (previousToken?.type === type) previousToken.value += value
else tokens.push({ type, value })
}
function isValidWordListParameterValue(key: keyof typeof wordListParameterTypes, value: string) {
switch (wordListParameterTypes[key]) {
case 'uint':
return wordListUnsignedIntegerPattern.test(value)
case 'uint-or-range':
return wordListUnsignedIntegerOrRangePattern.test(value)
case 'media-type':
return value === 'movie' || value === 'tv'
case 'string':
return value.length > 0
}
}
function tokenizeWordListParameters(parameters: string): WordListToken[] {
const tokens: WordListToken[] = []
const parameterList = parameters.split(';')
parameterList.forEach((parameter, index) => {
const isEmptyParameter = parameter.length === 0
if (index > 0) {
appendWordListToken(
tokens,
parameterList[index - 1] === '' || isEmptyParameter ? 'invalid.word-list' : 'word_list_parameter_syntax',
';',
)
}
if (isEmptyParameter) return
const equalsIndex = parameter.indexOf('=')
if (equalsIndex === -1) {
appendWordListToken(tokens, 'invalid.word-list', parameter)
return
}
const key = parameter.slice(0, equalsIndex)
const value = parameter.slice(equalsIndex + 1)
const hasKey = key.length > 0
const hasValue = value.length > 0
const isKnownKey = hasKey && Object.hasOwn(wordListParameterTypes, key)
appendWordListToken(tokens, isKnownKey && hasValue ? 'word_list_parameter_key' : 'invalid.word-list', key)
appendWordListToken(tokens, hasKey && hasValue ? 'word_list_parameter_syntax' : 'invalid.word-list', '=')
const isValidValue =
isKnownKey &&
!value.includes('=') &&
isValidWordListParameterValue(key as keyof typeof wordListParameterTypes, value)
appendWordListToken(
tokens,
isValidValue || (!isKnownKey && hasKey) ? 'word_list_parameter_value' : 'invalid.word-list',
value,
)
})
return tokens
}
function tokenizeWordListReplacement(replacement: string): WordListToken[] {
const tokens: WordListToken[] = []
let replacementStart = 0
for (const match of replacement.matchAll(wordListReplacementParametersPattern)) {
const parameterStart = match.index ?? 0
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart, parameterStart))
if (!match[1]) appendWordListToken(tokens, 'invalid.word-list', match[0])
else {
appendWordListToken(tokens, 'word_list_parameter_syntax', '{[')
tokenizeWordListParameters(match[1]).forEach(token => appendWordListToken(tokens, token.type, token.value))
appendWordListToken(tokens, 'word_list_parameter_syntax', ']}')
}
replacementStart = parameterStart + match[0].length
}
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart))
return tokens
}
function splitWordListField(value: string, operator: string) {
let operatorIndex = value.indexOf(operator)
while (operatorIndex !== -1) {
const operatorEnd = operatorIndex + operator.length
if (value[operatorIndex - 1] === ' ' && value[operatorEnd] === ' ') {
let operatorStart = operatorIndex
while (value[operatorStart - 1] === ' ') operatorStart -= 1
let operatorWithSpacesEnd = operatorEnd
while (value[operatorWithSpacesEnd] === ' ') operatorWithSpacesEnd += 1
return {
before: value.slice(0, operatorStart),
operator: value.slice(operatorStart, operatorWithSpacesEnd),
after: value.slice(operatorWithSpacesEnd),
}
}
operatorIndex = value.indexOf(operator, operatorEnd)
}
}
function getWordListLineWhitespace(value: string) {
const leadingSpaceLength = value.length - value.trimStart().length
const trailingSpaceLength = value.length - value.trimEnd().length
return {
leadingSpace: value.slice(0, leadingSpaceLength),
content: value.slice(leadingSpaceLength, value.length - trailingSpaceLength),
trailingSpace: value.slice(value.length - trailingSpaceLength),
}
}
function tokenizeWordListReplacementLine(value: string): WordListToken[] {
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
const replacementField = splitWordListField(content, '=>')
if (!replacementField) return [{ type: 'text', value }]
const tokens: WordListToken[] = []
appendWordListToken(tokens, 'text', leadingSpace)
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
tokenizeWordListReplacement(replacementField.after).forEach(token =>
appendWordListToken(tokens, token.type, token.value),
)
appendWordListToken(tokens, 'text', trailingSpace)
return tokens
}
function tokenizeCombinedWordListLine(value: string): WordListToken[] {
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
const replacementField = splitWordListField(content, '=>')
const frontField = replacementField && splitWordListField(replacementField.after, '&&')
const backField = frontField && splitWordListField(frontField.after, '<>')
const offsetField = backField && splitWordListField(backField.after, '>>')
if (!replacementField || !frontField || !backField || !offsetField) return [{ type: 'text', value }]
const tokens: WordListToken[] = []
appendWordListToken(tokens, 'text', leadingSpace)
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
tokenizeWordListReplacement(frontField.before).forEach(token => appendWordListToken(tokens, token.type, token.value))
appendWordListToken(tokens, 'keyword.operator.word-list', frontField.operator)
appendWordListToken(tokens, 'word_list_front', backField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', backField.operator)
appendWordListToken(tokens, 'word_list_back', offsetField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', offsetField.operator)
appendWordListToken(tokens, 'word_list_offset', offsetField.after)
appendWordListToken(tokens, 'text', trailingSpace)
return tokens
}
function buildWordListRules(syntax: boolean) {
if (!syntax) {
return {
start: [
{
token: 'empty_line',
regex: '^$',
},
{
defaultToken: 'text',
},
],
}
}
return {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
{
token: 'text',
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
onMatch: tokenizeCombinedWordListLine,
},
{
token: 'text',
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
onMatch: tokenizeWordListReplacementLine,
},
{
token: [
'text',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_block', 'text'],
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
},
],
}
}
function registerWordListMode() {
aceModule.define?.(
'ace/mode/word_list_highlight_rules',
@@ -538,15 +766,8 @@ function registerWordListMode() {
const oop = require('../lib/oop')
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
const WordListHighlightRules = function (this: any) {
this.$rules = {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
],
}
const WordListHighlightRules = function (this: any, options?: { syntax?: boolean }) {
this.$rules = buildWordListRules(options?.syntax === true)
this.normalizeRules()
}
@@ -564,9 +785,10 @@ function registerWordListMode() {
const TextMode = require('./text').Mode
const WordListHighlightRules = require('./word_list_highlight_rules').WordListHighlightRules
const Mode = function (this: any) {
const Mode = function (this: any, options?: { syntax?: boolean }) {
TextMode.call(this)
this.HighlightRules = WordListHighlightRules
this.$highlightRuleConfig = options || {}
}
oop.inherits(Mode, TextMode)
@@ -578,90 +800,6 @@ function registerWordListMode() {
exports.Mode = Mode
},
)
aceModule.define?.(
'ace/mode/word_list_syntax_highlight_rules',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
const WordListSyntaxHighlightRules = function (this: any) {
this.$rules = {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
{
token: [
'text',
'word_list_replaced',
'keyword.operator.word-list',
'word_list_replacement',
'keyword.operator.word-list',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_replaced', 'keyword.operator.word-list', 'word_list_replacement', 'text'],
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
},
{
token: [
'text',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_block', 'text'],
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
},
],
}
this.normalizeRules()
}
oop.inherits(WordListSyntaxHighlightRules, TextHighlightRules)
exports.WordListSyntaxHighlightRules = WordListSyntaxHighlightRules
},
)
aceModule.define?.(
'ace/mode/word_list_syntax',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_syntax_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextMode = require('./text').Mode
const WordListSyntaxHighlightRules = require('./word_list_syntax_highlight_rules').WordListSyntaxHighlightRules
const Mode = function (this: any) {
TextMode.call(this)
this.HighlightRules = WordListSyntaxHighlightRules
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.$id = 'ace/mode/word_list_syntax'
}).call(Mode.prototype)
exports.Mode = Mode
},
)
}
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
+115 -28
View File
@@ -26,6 +26,11 @@ export const storageAttributes = [
icon: 'mdi-server-network-outline',
remote: true,
},
{
type: 'alistgo',
icon: 'mdi-server-network-outline',
remote: true,
},
{
type: 'smb',
icon: 'mdi-folder-network-outline',
@@ -33,15 +38,21 @@ export const storageAttributes = [
},
]
export const storageIconDict = storageAttributes.reduce((dict, item) => {
dict[item.type] = item.icon
return dict
}, {} as Record<string, string>)
export const storageIconDict = storageAttributes.reduce(
(dict, item) => {
dict[item.type] = item.icon
return dict
},
{} as Record<string, string>,
)
export const storageRemoteDict = storageAttributes.reduce((dict, item) => {
dict[item.type] = item.remote
return dict
}, {} as Record<string, boolean>)
export const storageRemoteDict = storageAttributes.reduce(
(dict, item) => {
dict[item.type] = item.remote
return dict
},
{} as Record<string, boolean>,
)
export const downloaderOptions = [
{
@@ -58,10 +69,13 @@ export const downloaderOptions = [
},
]
export const downloaderDict = downloaderOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const downloaderDict = downloaderOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
export const mediaServerOptions = [
{
@@ -88,12 +102,19 @@ export const mediaServerOptions = [
value: 'ugreen',
title: i18n.global.t('setting.system.ugreen'),
},
{
value: 'navidrome',
title: i18n.global.t('setting.system.navidrome'),
},
]
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const mediaServerDict = mediaServerOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
export const innerFilterRules = [
{ title: i18n.global.t('filterRules.specSub'), value: ' SPECSUB ' },
@@ -104,6 +125,19 @@ export const innerFilterRules = [
{ title: i18n.global.t('filterRules.hkVoi'), value: ' HKVOI ' },
{ title: i18n.global.t('filterRules.notHkVoi'), value: ' !HKVOI ' },
{ title: i18n.global.t('filterRules.free'), value: ' FREE ' },
{ title: i18n.global.t('filterRules.audioHires'), value: ' HIRES ' },
{ title: i18n.global.t('filterRules.audioLossless'), value: ' LOSSLESS ' },
{ title: i18n.global.t('filterRules.audioFlac'), value: ' FLAC ' },
{ title: i18n.global.t('filterRules.audioAlac'), value: ' ALAC ' },
{ title: i18n.global.t('filterRules.audioApe'), value: ' APE ' },
{ title: i18n.global.t('filterRules.audioWav'), value: ' WAV ' },
{ title: i18n.global.t('filterRules.audioDsd'), value: ' DSD ' },
{ title: i18n.global.t('filterRules.audioMp3'), value: ' MP3 ' },
{ title: i18n.global.t('filterRules.audioAac'), value: ' AAC ' },
{ title: i18n.global.t('filterRules.audioOpus'), value: ' OPUS ' },
{ title: i18n.global.t('filterRules.bitrate320'), value: ' BITRATE320 ' },
{ title: i18n.global.t('filterRules.bitrate256'), value: ' BITRATE256 ' },
{ title: i18n.global.t('filterRules.bitrate192'), value: ' BITRATE192 ' },
{ title: i18n.global.t('filterRules.resolution4k'), value: ' 4K ' },
{ title: i18n.global.t('filterRules.resolution1080p'), value: ' 1080P ' },
{ title: i18n.global.t('filterRules.resolution720p'), value: ' 720P ' },
@@ -226,6 +260,46 @@ export const effectOptions = ref([
},
])
// 音乐音质等级选择框数据
export const audioQualityOptions = ref([
{ title: i18n.global.t('audioQualityOptions.all'), value: '' },
{ title: i18n.global.t('audioQualityOptions.hires'), value: 'hires' },
{ title: i18n.global.t('audioQualityOptions.lossless'), value: 'hires|lossless' },
{ title: i18n.global.t('audioQualityOptions.lossy'), value: 'lossy' },
])
// 音频格式选择框数据
export const audioFormatOptions = ref([
{ title: i18n.global.t('audioFormatOptions.all'), value: '' },
{ title: i18n.global.t('audioFormatOptions.lossless'), value: 'DSD|FLAC|ALAC|APE|WAV|AIFF|PCM' },
...['DSD', 'FLAC', 'ALAC', 'APE', 'WAV', 'AIFF', 'PCM', 'MP3', 'AAC', 'OGG', 'OPUS', 'WMA'].map(value => ({
title: value,
value,
})),
])
export const audioBitrateOptions = [
{ title: '128 kbps', value: 128000 },
{ title: '192 kbps', value: 192000 },
{ title: '256 kbps', value: 256000 },
{ title: '320 kbps', value: 320000 },
]
export const audioBitDepthOptions = [
{ title: '16-bit', value: 16 },
{ title: '24-bit', value: 24 },
{ title: '32-bit', value: 32 },
]
export const audioSampleRateOptions = [
{ title: '44.1 kHz', value: 44100 },
{ title: '48 kHz', value: 48000 },
{ title: '88.2 kHz', value: 88200 },
{ title: '96 kHz', value: 96000 },
{ title: '176.4 kHz', value: 176400 },
{ title: '192 kHz', value: 192000 },
]
// 媒体类型选项
export const mediaTypeOptions = [
{
@@ -236,6 +310,10 @@ export const mediaTypeOptions = [
title: i18n.global.t('mediaType.tv'),
value: '电视剧',
},
{
title: i18n.global.t('mediaType.music'),
value: '音乐',
},
{
title: i18n.global.t('mediaType.anime'),
value: '动漫',
@@ -251,10 +329,13 @@ export const mediaTypeOptions = [
]
// 媒体类型字典
export const mediaTypeDict = mediaTypeOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const mediaTypeDict = mediaTypeOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
// 通知开关选项
export const notificationSwitchOptions = [
@@ -297,10 +378,13 @@ export const notificationSwitchOptions = [
]
// 通知开关字典
export const notificationSwitchDict = notificationSwitchOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const notificationSwitchDict = notificationSwitchOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
// 操作步骤选项
export const actionStepOptions = [
@@ -367,7 +451,10 @@ export const actionStepOptions = [
]
// 操作步骤字典
export const actionStepDict = actionStepOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const actionStepDict = actionStepOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
+279 -13
View File
@@ -1,4 +1,4 @@
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | (string & {})
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz' | (string & {})
// 手动刮削选项
export interface ManualScrapeOptions {
@@ -18,7 +18,7 @@ export interface Subscribe {
name: string
// 订阅年份
year: string
// 订阅类型 电影/电视剧
// 订阅类型 电影/电视剧/音乐
type: string
// 搜索关键字
keyword?: string
@@ -34,6 +34,10 @@ export interface Subscribe {
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string
// 音乐实体类型:recording 单曲、album 专辑、artist 艺术家
music_type?: MusicEntityType
// 专辑总曲目数
total_tracks?: number
// 其它媒体ID
mediaid?: string
// 季号
@@ -58,6 +62,16 @@ export interface Subscribe {
resolution?: string
// 特效
effect?: string
// 音乐音质等级正则
audio_quality?: string
// 音频格式正则
audio_format?: string
// 最低码率(bps
min_bitrate?: number
// 最低位深(bit
min_bit_depth?: number
// 最低采样率(Hz
min_sample_rate?: number
// 总集数
total_episode?: number
// 开始集数
@@ -84,6 +98,14 @@ export interface Subscribe {
search_imdbid?: any
// 当前优先级
current_priority: number
// 当前音乐版本格式
current_audio_format?: string
// 当前音乐版本码率(bps
current_bitrate?: number
// 当前音乐版本位深(bit
current_bit_depth?: number
// 当前音乐版本采样率(Hz
current_sample_rate?: number
// 洗版时已下载剧集的优先级状态
episode_priority?: Record<string, number>
// 保存目录
@@ -140,6 +162,10 @@ export interface SubscribeShare {
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string
// 音乐实体类型:recording 单曲、album 专辑、artist 艺术家
music_type?: MusicEntityType
// 专辑总曲目数
total_tracks?: number
// 季号
season?: number
// 海报
@@ -264,6 +290,20 @@ export interface TransferHistory {
errmsg?: string
// 日期
date?: string
// 音乐实体类型
music_type?: MusicEntityType
// 专辑预期总曲目数
total_tracks?: number
// 实际音频格式
audio_format?: string
// 是否无损音频
audio_lossless?: boolean
// 实际位深(bit
bit_depth?: number
// 实际采样率(Hz
sample_rate?: number
// 实际码率(bps
bitrate?: number
// 源文件项
src_fileitem?: FileItem
}
@@ -330,14 +370,14 @@ export interface DownloadHistory {
// 媒体信息
export interface MediaInfo {
// 来源:themoviedb、douban、bangumi、anilist
// 来源:themoviedb、douban、bangumi、anilist、musicbrainz
source?: string
// 类型 电影、电视剧、合集
// 类型 电影、电视剧、音乐、合集
type?: string
// 媒体标题
title?: string
// 年份
year?: string
// 年份(音乐等数据源返回数字,订阅接口要求字符串)
year?: string | number
// 标题(年)
title_year?: string
// 季号
@@ -436,6 +476,193 @@ export interface MediaInfo {
names?: string[]
// 剧集组
episode_group?: string
// 音乐艺术家列表
artists?: string[]
// 音乐艺术家展示文本
artist?: string
// 音乐实体类型:recording 单曲、album 专辑、artist 艺术家
music_type?: MusicEntityType
// 音乐艺术家标准ID,顺序与 artists 一致
artist_ids?: string[]
// 专辑
album?: string
// 专辑艺术家
album_artist?: string
// 所属专辑标准ID
album_id?: string
// 专辑主类型:Album、EP、Single 等
album_type?: string
// 发行版本
version?: string
// 音轨号
track_number?: number
// 碟号
disc_number?: number
// 总音轨数
total_tracks?: number
// 音轨时长(秒)
duration?: number
// ISRC
isrc?: string
// 音乐封面
cover_url?: string
// ListenBrainz 收听次数
listen_count?: number
// 音频格式
audio_format?: string
// 是否无损音频
audio_lossless?: boolean
// 音质等级
audio_quality?: 'hires' | 'lossless' | 'lossy'
// 音质洗版分数
audio_quality_score?: number
// 格式化音频参数
audio_specs?: string
// 音频位深
bit_depth?: number
// 音频采样率
sample_rate?: number
// 音频码率
bitrate?: number
}
// 音乐可浏览实体类型
export type MusicEntityType = 'recording' | 'album' | 'artist'
// 音乐专辑下的单个发行版本
export interface MusicRelease {
// 发行版本标准ID
media_id?: string
// 发行版本名称
title?: string
// 发行日期
date?: string
// 发行年份
year?: number
// 发行地区
country?: string
// 发行状态:Official、Promotion 等
status?: string
// 包装形式
packaging?: string
// 介质格式:CD、Vinyl 等
formats?: string[]
// 音轨总数
track_count?: number
// 封面
cover_url?: string
}
// 音乐专辑详情
export interface MusicAlbumInfo {
// 类型,固定为音乐
type?: string
// 实体类型,固定为 album
music_type?: MusicEntityType
// 来源
source?: string
// 专辑标准ID
media_id?: string
// 专辑名称
title?: string
// 艺术家列表
artists?: string[]
// 艺术家展示文本
artist?: string
// 艺术家标准ID,顺序与 artists 一致
artist_ids?: string[]
// 专辑名称,与 title 一致,便于复用音乐展示组件
album?: string
// 专辑主类型
album_type?: string
// 专辑副类型
secondary_types?: string[]
// 首次发行年份
year?: number
// 首次发行日期
release_date?: string
// 音轨总数
total_tracks?: number
// 专辑总时长(秒)
duration?: number
// 封面
cover_url?: string
// 风格
genres?: string[]
// 标签
tags?: string[]
// 主类型与副类型组合文本
category?: string
// 10 分制评分
rating?: number
// 评分人数
rating_votes?: number
// 详情页面
detail_link?: string
// 专辑内的音乐
tracks?: MediaInfo[]
// 同一专辑的其它发行版本
releases?: MusicRelease[]
// 海报
poster_path?: string
// 背景图
backdrop_path?: string
// 摘要
overview?: string
}
// 音乐艺术家详情
export interface MusicArtistInfo {
// 类型,固定为音乐
type?: string
// 实体类型,固定为 artist
music_type?: MusicEntityType
// 来源
source?: string
// 艺术家标准ID
media_id?: string
// 艺术家名称
name?: string
// 名称,与 name 一致,便于复用通用展示组件
title?: string
// 排序名称
sort_name?: string
// 消歧义说明
disambiguation?: string
// 艺术家类型:Person、Group 等
artist_type?: string
// 性别
gender?: string
// 国家代码
country?: string
// 地区
area?: string
// 出道或成立日期
begin_date?: string
// 解散或去世日期
end_date?: string
// 是否已结束活动
ended?: boolean
// 活跃时间区间文本
life_span?: string
// 风格
genres?: string[]
// 标签
tags?: string[]
// 别名
aliases?: string[]
// 与当前艺术家的关系文本
relation?: string
// 艺术家图片
image_url?: string
// 详情页面
detail_link?: string
// 外部站点链接
external_links?: Record<string, string>
// 海报
poster_path?: string
// 摘要
overview?: string
}
// 季信息
@@ -983,7 +1210,25 @@ export interface MetaInfo {
// 识别的英文名
en_name?: string
// 年份
year?: string
year?: string | number
// 音乐艺术家列表
artists?: string[]
// 音乐艺术家展示文本
artist?: string
// 音乐专辑
album?: string
// 音乐专辑艺术家
album_artist?: string
// 音乐碟号
disc_number?: number
// 音乐音轨号
track_number?: number
// 音乐总音轨数
total_tracks?: number
// 音乐时长(秒)
duration?: number
// 音乐 ISRC
isrc?: string
// 总季数
total_season: number
// 识别的开始季 数字
@@ -1010,6 +1255,22 @@ export interface MetaInfo {
video_encode?: string
// 音频编码
audio_encode?: string
// 音频格式
audio_format?: string
// 是否无损音频
audio_lossless?: boolean
// 音质等级
audio_quality?: 'hires' | 'lossless' | 'lossy'
// 音质洗版分数
audio_quality_score?: number
// 格式化音频参数
audio_specs?: string
// 音频位深
bit_depth?: number
// 音频采样率
sample_rate?: number
// 音频码率
bitrate?: number
// 名称(自动中英文)
name: string
// SXX-SXX
@@ -1084,6 +1345,11 @@ export interface User {
nickname?: string
}
// 头像上传响应数据
export interface AvatarUploadData {
filename: string
}
// 通行密钥
export interface PassKey {
id: number
@@ -1110,8 +1376,10 @@ export interface MediaStatistic {
tv_count: number
// 电视剧总集数,未获取时为 null
episode_count: number | null
// 用户数量
user_count: number
// 音乐总数
music_count: number
// 用户数量,仪表板已改为展示音乐数量,仅保留接口字段兼容
user_count?: number
// 本月新增电影数量
movie_count_month: number
// 本月新增电视剧数量
@@ -1455,7 +1723,7 @@ export interface StorageConf {
export interface MediaServerConf {
// 名称
name: string
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen/navidrome
type: string
// 配置
config: { [key: string]: any }
@@ -1889,9 +2157,7 @@ export interface RecognitionCacheItem {
key: string
// TMDB ID0 表示未识别
tmdb_id?: number
// 豆瓣 ID0 表示未识别
douban_id?: string | number
// 当前识别数据源对应的统一 ID
// 识别缓存对应的字符串 ID
recognition_id?: string
// 识别后的标题
title: string
+14 -4
View File
@@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n'
import AgentPetStage from './pet/AgentPetStage.vue'
import type { AgentPetActionName, AgentPetIntent } from './pet/types'
import { useAgentPetMachine } from './pet/useAgentPetMachine'
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
interface AgentAssistantEntryBubble {
id: string
@@ -46,6 +47,8 @@ const props = withDefaults(
},
)
const ASSISTANT_PREVIEW_MAX_LENGTH = 480
const emit = defineEmits<{
open: []
}>()
@@ -163,6 +166,7 @@ const fabPositionStyle = computed(() => {
...fabPointerStyle.value,
'--agent-assistant-fab-x': `${position.x}px`,
'--agent-assistant-fab-y': `${position.y}px`,
zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.entry,
}
})
const fabBubblePlacement = ref<FabBubblePlacement>('top')
@@ -1012,6 +1016,15 @@ function scheduleFabBubbleRemoval(id: string, duration = FAB_NOTIFICATION_BUBBLE
function upsertFabBubble(bubble: AgentAssistantEntryBubble, options: { autoClose?: boolean; duration?: number } = {}) {
if (!props.active || !bubble.text) return
const existingIndex = fabBubbles.value.findIndex(item => item.id === bubble.id)
if (existingIndex >= 0) {
fabBubbles.value[existingIndex] = bubble
setFabDocked(false)
nextTick(scheduleFabBubblePositionUpdate)
if (options.autoClose) scheduleFabBubbleRemoval(bubble.id, options.duration)
return
}
const hadBubbles = hasFabBubbles.value
const wasDocked = fabDocked.value
const existingBubbles = fabBubbles.value.filter(item => item.id !== bubble.id)
@@ -1059,7 +1072,7 @@ function showAssistantReplyPreview(value: string) {
showBubble({
id: 'assistant-preview',
kind: 'assistant',
text: value,
text: value.slice(0, ASSISTANT_PREVIEW_MAX_LENGTH),
})
}
@@ -1570,9 +1583,6 @@ defineExpose({
.agent-assistant-fab {
position: fixed;
/* 保持机器人和提示气泡高于 Vuetify 弹窗(2400)及全局 Toast2500)。 */
z-index: 2600;
--agent-assistant-robot-outline: color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, #090510 28%);
--agent-assistant-robot-outline-soft: color-mix(in srgb, rgb(var(--v-theme-primary)) 84%, #090510 16%);
--agent-assistant-robot-shell-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 38%, white 62%);
+333 -84
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import MarkdownIt from 'markdown-it'
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'
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
import AgentMarkdownContent from './AgentMarkdownContent.vue'
type AgentMessageRole = 'user' | 'assistant'
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
@@ -19,6 +19,21 @@ interface AgentToolCall {
status: 'running' | 'done'
}
interface AgentMessageTextSegment {
type: 'text'
content: string
}
interface AgentMessageToolSegment {
type: 'tool'
toolIndex: number
}
type AgentMessageSegment = AgentMessageTextSegment | AgentMessageToolSegment
type AgentRenderableMessageSegment =
(AgentMessageTextSegment & { key: string }) | { type: 'tool'; key: string; tool: AgentToolCall }
interface AgentMessageAttachment {
kind: AgentAttachmentKind
url: string
@@ -65,6 +80,7 @@ interface AgentChatMessage {
createdAt: number
status: AgentMessageStatus
tools: AgentToolCall[]
segments: AgentMessageSegment[]
attachments: AgentMessageAttachment[]
choices: AgentChoiceCard[]
choice_selection?: AgentChoiceSelection
@@ -198,6 +214,7 @@ const messages = ref<AgentChatMessage[]>([])
const historySessions = ref<AgentSessionHistoryItem[]>([])
const sessionId = ref('')
const sending = ref(false)
const isComposing = ref(false)
const streamError = ref('')
const historyMenuOpen = ref(false)
const messageListRef = ref<HTMLElement | null>(null)
@@ -211,6 +228,7 @@ const historyLoading = ref(false)
const historyLoadingMore = ref(false)
const historyPage = ref(1)
const historyHasMore = ref(true)
const fullscreen = ref(false)
const slashCommands = ref<AgentSlashCommand[]>([])
const slashCommandsLoading = ref(false)
const slashCommandsLoaded = ref(false)
@@ -223,25 +241,16 @@ let recordingChunks: BlobPart[] = []
let messageScrollFrame: number | null = null
let pendingMessageScrollToBottom = false
let streamPersistTimer: number | null = null
let streamPersistLastRunAt = 0
let messageScrollerShouldFollow = true
let streamDeltaFrame: number | null = null
let pendingStreamDelta = ''
let pendingStreamDeltaMessage: AgentChatMessage | null = null
let userAbortRequested = false
let streamRecoveryAbortRequested = false
let streamRecoveryTimer: number | null = null
let activeStreamStartedAt = 0
const md = new MarkdownIt({
html: true,
breaks: true,
linkify: true,
typographer: true,
})
md.use(mdLinkAttributes, {
attrs: {
target: '_blank',
rel: 'noopener noreferrer',
},
})
// 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。
const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
const canSend = computed(
@@ -285,8 +294,10 @@ const recordingTimeText = computed(() => {
return `${minutes}:${String(remainSeconds).padStart(2, '0')}`
})
// 窄屏下直接全屏,避免聊天内容被压成半屏窄栏
const drawerWidth = computed(() => (display.mdAndDown.value ? '100vw' : '30rem'))
// 窄屏下直接全屏,桌面端则允许用户按需扩展对话区域
const drawerWidth = computed(() => (display.mdAndDown.value || fullscreen.value ? '100vw' : '30rem'))
// 仅桌面宽屏展示全屏开关,窄屏已默认占满视口。
const canToggleFullscreen = computed(() => !display.mdAndDown.value)
const hasMessages = computed(() => messages.value.length > 0)
const hasHistorySessions = computed(() => historySessions.value.length > 0)
const currentUserName = computed(() => userStore.getUserName || t('common.user'))
@@ -296,6 +307,7 @@ const isOpen = computed({
})
const drawerStyle = computed(() => ({
'--agent-assistant-panel-width': drawerWidth.value,
zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.panel,
}))
// 创建前端展示用的临时 ID。
@@ -515,26 +527,57 @@ function normalizeChoiceSelectionMessages(sessionMessages: AgentChatMessage[]) {
return sessionMessages
}
// 规范化历史消息,补齐附件、工具和选择项等可选数组
// 规范化消息的有序片段;旧历史按原来的工具在前、文本在后布局回退
function normalizeMessageSegments(value: unknown, content: string, tools: AgentToolCall[]) {
const normalizedSegments: AgentMessageSegment[] = []
if (Array.isArray(value)) {
value.forEach(rawSegment => {
if (!rawSegment || typeof rawSegment !== 'object' || Array.isArray(rawSegment)) return
const segment = rawSegment as Record<string, unknown>
if (segment.type === 'text' && typeof segment.content === 'string' && segment.content.trim()) {
normalizedSegments.push({ type: 'text', content: segment.content })
return
}
const toolIndex = Number(segment.toolIndex ?? segment.tool_index)
if (segment.type === 'tool' && Number.isInteger(toolIndex) && toolIndex >= 0 && toolIndex < tools.length) {
normalizedSegments.push({ type: 'tool', toolIndex })
}
})
}
if (normalizedSegments.length) return normalizedSegments
tools.forEach((_tool, toolIndex) => normalizedSegments.push({ type: 'tool', toolIndex }))
if (content.trim()) normalizedSegments.push({ type: 'text', content })
return normalizedSegments
}
// 规范化历史消息,补齐附件、工具、有序片段和选择项等可选数组。
function normalizeStoredMessages(value: unknown) {
if (!Array.isArray(value)) return []
const normalizedMessages = value.slice(-MAX_PERSISTED_MESSAGES).map(rawMessage => {
const message = rawMessage && typeof rawMessage === 'object' ? (rawMessage as Record<string, unknown>) : {}
const role = message.role === 'assistant' ? 'assistant' : 'user'
const content = typeof message.content === 'string' ? message.content : stringifyChoiceField(message.content)
const tools = Array.isArray(message.tools) ? (message.tools as AgentToolCall[]) : []
return {
...message,
id: stringifyChoiceField(message.id) || createId(role),
role,
content: typeof message.content === 'string' ? message.content : stringifyChoiceField(message.content),
content,
createdAt: Number(message.createdAt) || Number(message.created_at) || Date.now(),
status: normalizeMessageStatus(message.status),
attachments: Array.isArray(message.attachments) ? message.attachments : [],
choices: Array.isArray(message.choices)
? (message.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
: [],
tools: Array.isArray(message.tools) ? message.tools : [],
tools,
segments: normalizeMessageSegments(message.segments, content, tools),
choice_selection: normalizeChoiceSelection(message.choice_selection || message.choiceSelection),
} as AgentChatMessage
})
@@ -811,7 +854,7 @@ function failStreamRecovery() {
.find(message => message.role === 'assistant' && message.status === 'streaming')
if (assistantMessage) {
assistantMessage.status = 'error'
assistantMessage.content ||= t('agentAssistant.recoveryFailed')
if (!assistantMessage.content) appendAssistantTextSegment(assistantMessage, t('agentAssistant.recoveryFailed'))
markToolsDone(assistantMessage)
refreshMessageList()
} else {
@@ -1056,12 +1099,6 @@ function persistState(options: { syncHistory?: boolean } = {}) {
if (syncHistory) upsertCurrentSessionHistory()
}
// 渲染助手消息中的 Markdown 文本。
function renderMarkdown(value: string) {
if (!value) return ''
return md.render(value)
}
// 拼接后端 API 地址。
function resolveApiUrl(path: string) {
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/'
@@ -1106,6 +1143,11 @@ function isMessageScrollerNearBottom() {
return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= MESSAGE_SCROLL_FOLLOW_THRESHOLD
}
// 只在滚动事件中更新自动跟随意图,避免每个流式事件触发布局读取。
function handleMessageScrollerScroll() {
messageScrollerShouldFollow = isMessageScrollerNearBottom()
}
// 合并滚动更新请求,降低流式输出时的布局测量频率。
function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
const { toBottom = false } = options
@@ -1125,6 +1167,7 @@ function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
// 将消息列表滚动到底部。
function scrollToBottom(options: { smooth?: boolean } = {}) {
const { smooth = false } = options
messageScrollerShouldFollow = true
nextTick(() => {
const scroller = getMessageScrollerElement()
if (!scroller) return
@@ -1168,13 +1211,17 @@ function clearMessageScrollFrame() {
pendingMessageScrollToBottom = false
}
// 延迟持久化流式消息,避免每个 token 都写入本地存储
// 流式期间至多每秒保存一次轻量当前态,终态再同步完整历史
function scheduleStreamPersist() {
clearStreamPersistTimer()
if (streamPersistTimer !== null) return
const elapsed = Date.now() - streamPersistLastRunAt
const delay = Math.max(0, STREAM_STATE_PERSIST_DELAY - elapsed)
streamPersistTimer = window.setTimeout(() => {
persistState()
streamPersistTimer = null
}, STREAM_STATE_PERSIST_DELAY)
streamPersistLastRunAt = Date.now()
persistState({ syncHistory: false })
}, delay)
}
// 同步输入框高度,使多行输入不撑破底部布局。
@@ -1210,6 +1257,7 @@ function addMessage(
attachments,
choices: [],
tools: [],
segments: role === 'assistant' && content ? [{ type: 'text', content }] : [],
choice_selection: choiceSelection,
}
messages.value.push(message)
@@ -1225,6 +1273,45 @@ function normalizeToolMessage(message: string) {
return message.replace(/^=>\s*/, '').trim()
}
// 解析非啰嗦模式的工具汇总,供相邻工具状态按类别累计次数。
function parseToolSummary(message: string) {
const summaryMatch = message.match(/^(.+)$/)
if (!summaryMatch) return null
const parts = summaryMatch[1].split('').map(part => {
const countMatch = part.trim().match(/^(.*?\D)(\d+)(\D.*)$/)
if (!countMatch) return null
return {
prefix: countMatch[1],
count: Number(countMatch[2]),
suffix: countMatch[3],
}
})
return parts.every(Boolean) ? (parts as Array<{ prefix: string; count: number; suffix: string }>) : null
}
// 仅合并相邻的非啰嗦工具汇总;正文或具体工具提示会自然终止当前聚合组。
function mergeToolSummaries(currentMessage: string, nextMessage: string) {
const currentParts = parseToolSummary(currentMessage)
const nextParts = parseToolSummary(nextMessage)
if (!currentParts || !nextParts) return null
const mergedParts = currentParts.map(part => ({ ...part }))
const partIndexes = new Map(mergedParts.map((part, index) => [`${part.prefix}\u0000${part.suffix}`, index]))
nextParts.forEach(part => {
const key = `${part.prefix}\u0000${part.suffix}`
const existingIndex = partIndexes.get(key)
if (existingIndex === undefined) {
partIndexes.set(key, mergedParts.length)
mergedParts.push({ ...part })
return
}
mergedParts[existingIndex].count += part.count
})
return `${mergedParts.map(part => `${part.prefix}${part.count}${part.suffix}`).join('')}`
}
// 将当前消息里的运行中工具标记为完成。
function markToolsDone(message: AgentChatMessage) {
message.tools.forEach(tool => {
@@ -1232,11 +1319,58 @@ function markToolsDone(message: AgentChatMessage) {
})
}
// 追加助手文本,并只合并紧邻的文本片段以保留工具事件边界。
function appendAssistantTextSegment(message: AgentChatMessage, content: string) {
if (!content) return
message.content += content
const lastSegment = message.segments.at(-1)
if (lastSegment?.type === 'text') {
lastSegment.content += content
} else if (content.trim()) {
message.segments.push({ type: 'text', content })
}
}
// 替换助手文本但保留工具片段,用于无法继续流式处理时显示错误。
function replaceAssistantTextSegments(message: AgentChatMessage, content: string) {
message.content = content
message.segments = message.segments.filter(segment => segment.type === 'tool')
if (content.trim()) message.segments.push({ type: 'text', content })
}
// 按 SSE 事件顺序渲染文本与工具,只跳过无法产生可见内容的空白文本。
function getRenderableMessageSegments(message: AgentChatMessage): AgentRenderableMessageSegment[] {
return message.segments.reduce<AgentRenderableMessageSegment[]>((renderableSegments, segment, index) => {
if (segment.type === 'text') {
if (segment.content.trim()) renderableSegments.push({ ...segment, key: `text-${index}` })
return renderableSegments
}
const tool = message.tools[segment.toolIndex]
if (tool) {
const previousSegment = renderableSegments.at(-1)
const mergedMessage =
previousSegment?.type === 'tool' ? mergeToolSummaries(previousSegment.tool.message, tool.message) : null
if (previousSegment?.type === 'tool' && mergedMessage) {
previousSegment.tool = {
...previousSegment.tool,
message: mergedMessage,
status: previousSegment.tool.status === 'running' || tool.status === 'running' ? 'running' : 'done',
}
} else {
renderableSegments.push({ type: 'tool', key: `tool-${tool.id}`, tool })
}
}
return renderableSegments
}, [])
}
// 判断消息是否没有任何可展示内容,可用于清理编辑回调产生的占位回复。
function isEmptyAssistantMessage(message: AgentChatMessage) {
return (
message.role === 'assistant' &&
!message.content &&
!message.content.trim() &&
message.attachments.length === 0 &&
message.choices.length === 0 &&
message.tools.length === 0
@@ -1255,6 +1389,7 @@ function applyMessageUpdate(event: AgentStreamEvent) {
message.content = typeof target?.content === 'string' ? target.content : ''
message.attachments = Array.isArray(target?.attachments) ? target.attachments : []
message.tools = Array.isArray(target?.tools) ? target.tools : []
message.segments = normalizeMessageSegments(target?.segments, message.content, message.tools)
message.choices = Array.isArray(target?.choices)
? (target.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
: []
@@ -1266,11 +1401,9 @@ function applyMessageUpdate(event: AgentStreamEvent) {
// 将单个 SSE 事件应用到正在流式输出的助手消息。
function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
const shouldFollowBottom = isMessageScrollerNearBottom()
switch (event.type) {
case 'delta':
assistantMessage.content += event.content || ''
appendAssistantTextSegment(assistantMessage, event.content || '')
emit('assistant-preview', assistantMessage.content)
break
case 'tool':
@@ -1280,6 +1413,7 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
message: normalizeToolMessage(event.message || ''),
status: 'running',
})
assistantMessage.segments.push({ type: 'tool', toolIndex: assistantMessage.tools.length - 1 })
break
case 'attachment':
if (event.attachment?.url) {
@@ -1306,7 +1440,9 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
case 'error':
assistantMessage.status = 'error'
// 后端流式错误已经以 AI 消息展示,避免底部提示条重复且持续占位。
assistantMessage.content ||= event.message_i18n || event.message || t('agentAssistant.error')
if (!assistantMessage.content) {
appendAssistantTextSegment(assistantMessage, event.message_i18n || event.message || t('agentAssistant.error'))
}
emit('assistant-preview', assistantMessage.content)
markToolsDone(assistantMessage)
break
@@ -1322,10 +1458,54 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
scheduleStreamPersist()
nextTick(() => {
scheduleMessageScrollerUpdate({ toBottom: shouldFollowBottom })
scheduleMessageScrollerUpdate({ toBottom: messageScrollerShouldFollow })
})
}
// 将同一条助手消息的连续文本增量合并到一个动画帧,语义事件到来前会同步冲刷。
function flushPendingStreamDelta() {
if (streamDeltaFrame !== null) {
window.cancelAnimationFrame(streamDeltaFrame)
streamDeltaFrame = null
}
if (!pendingStreamDeltaMessage || !pendingStreamDelta) return
const assistantMessage = pendingStreamDeltaMessage
const content = pendingStreamDelta
pendingStreamDeltaMessage = null
pendingStreamDelta = ''
applyStreamEvent({ type: 'delta', content }, assistantMessage)
}
function clearPendingStreamDelta() {
if (streamDeltaFrame !== null) window.cancelAnimationFrame(streamDeltaFrame)
streamDeltaFrame = null
pendingStreamDeltaMessage = null
pendingStreamDelta = ''
}
function schedulePendingStreamDeltaFlush() {
if (streamDeltaFrame !== null) return
streamDeltaFrame = window.requestAnimationFrame(() => {
streamDeltaFrame = null
flushPendingStreamDelta()
})
}
function queueStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
if (event.type !== 'delta') {
flushPendingStreamDelta()
applyStreamEvent(event, assistantMessage)
return
}
if (pendingStreamDeltaMessage && pendingStreamDeltaMessage !== assistantMessage) flushPendingStreamDelta()
pendingStreamDeltaMessage = assistantMessage
pendingStreamDelta += event.content || ''
schedulePendingStreamDeltaFlush()
}
// 解析一个 SSE 数据块。
function parseSseBlock(block: string) {
const data = block
@@ -1353,26 +1533,30 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe
const consumeEvent = (event: AgentStreamEvent | null) => {
if (!event) return
applyStreamEvent(event, assistantMessage)
queueStreamEvent(event, assistantMessage)
if (event.type === 'done' || event.type === 'error') receivedTerminalEvent = true
}
while (true) {
const { value, done } = await reader.read()
if (done) break
try {
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const blocks = buffer.split(/\n\n/)
buffer = blocks.pop() || ''
buffer += decoder.decode(value, { stream: true })
const blocks = buffer.split(/\r?\n\r?\n/)
buffer = blocks.pop() || ''
for (const block of blocks) {
consumeEvent(parseSseBlock(block))
for (const block of blocks) {
consumeEvent(parseSseBlock(block))
}
}
}
buffer += decoder.decode()
if (buffer.trim()) {
consumeEvent(parseSseBlock(buffer))
buffer += decoder.decode()
if (buffer.trim()) {
consumeEvent(parseSseBlock(buffer))
}
} finally {
flushPendingStreamDelta()
}
return { receivedTerminalEvent }
@@ -1641,7 +1825,7 @@ async function streamAgentMessage(
}
assistantMessage.status = 'error'
assistantMessage.content = error?.message || t('agentAssistant.error')
replaceAssistantTextSegments(assistantMessage, error?.message || t('agentAssistant.error'))
markToolsDone(assistantMessage)
refreshMessageList()
} finally {
@@ -1999,6 +2183,11 @@ function closeDrawer() {
isOpen.value = false
}
// 在桌面侧栏与全屏对话模式之间切换。
function toggleFullscreen() {
fullscreen.value = !fullscreen.value
}
// 同步面板打开状态到全局 DOM,供悬浮入口避让面板宽度。
function syncAgentAssistantOpenState(isOpen: boolean) {
if (typeof document === 'undefined') return
@@ -2052,11 +2241,21 @@ function handlePageShow() {
// 处理输入框回车发送。
function handleInputKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || event.shiftKey) return
if (event.key !== 'Enter' || event.shiftKey || isComposing.value || event.isComposing || event.keyCode === 229) return
event.preventDefault()
sendMessage()
}
// 开始输入法组合输入,避免回车确认候选词时误发送消息。
function handleCompositionStart() {
isComposing.value = true
}
// 结束输入法组合输入,恢复回车发送能力。
function handleCompositionEnd() {
isComposing.value = false
}
watch(isOpen, syncAgentAssistantOpenState, { immediate: true })
watch(drawerWidth, () => {
if (isOpen.value) syncAgentAssistantOpenState(true)
@@ -2084,6 +2283,7 @@ onScopeDispose(clearPendingAttachments)
onScopeDispose(cancelVoiceRecording)
onScopeDispose(clearMessageScrollFrame)
onScopeDispose(clearStreamPersistTimer)
onScopeDispose(clearPendingStreamDelta)
onScopeDispose(clearStreamRecoveryTimer)
onScopeDispose(() => {
if (typeof window === 'undefined') return
@@ -2098,7 +2298,11 @@ onScopeDispose(() => {
<aside
v-show="isOpen"
class="agent-assistant-panel"
:class="{ 'is-motion-paused': !props.motionActive, 'is-open': isOpen }"
:class="{
'is-fullscreen': fullscreen,
'is-motion-paused': !props.motionActive,
'is-open': isOpen,
}"
:style="drawerStyle"
role="dialog"
:aria-label="t('agentAssistant.title')"
@@ -2130,11 +2334,11 @@ onScopeDispose(() => {
<VMenu
v-model="historyMenuOpen"
:close-on-content-click="false"
content-class="agent-assistant-history-overlay"
location="bottom end"
offset="8"
max-width="360"
:z-index="2603"
:style="{ zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.overlay }"
:z-index="AGENT_ASSISTANT_LAYER_Z_INDEX.overlay"
>
<template #activator="{ props }">
<IconBtn v-bind="props" :title="t('agentAssistant.history')" :aria-label="t('agentAssistant.history')">
@@ -2214,6 +2418,16 @@ onScopeDispose(() => {
>
<VIcon icon="mdi-message-plus-outline" />
</IconBtn>
<IconBtn
v-if="canToggleFullscreen"
class="agent-assistant-fullscreen-toggle"
:title="t(fullscreen ? 'agentAssistant.exitFullscreen' : 'agentAssistant.enterFullscreen')"
:aria-label="t(fullscreen ? 'agentAssistant.exitFullscreen' : 'agentAssistant.enterFullscreen')"
:aria-pressed="fullscreen"
@click="toggleFullscreen"
>
<VIcon :icon="fullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'" />
</IconBtn>
<IconBtn :title="t('common.close')" :aria-label="t('common.close')" @click="closeDrawer">
<VIcon icon="mdi-close" />
</IconBtn>
@@ -2224,6 +2438,7 @@ onScopeDispose(() => {
ref="messageListRef"
class="agent-assistant-messages"
:class="{ 'agent-assistant-messages--has-content': hasMessages }"
@scroll.passive="handleMessageScrollerScroll"
>
<div class="agent-assistant-messages__content">
<div v-if="!hasMessages" class="agent-assistant-empty">
@@ -2245,31 +2460,41 @@ onScopeDispose(() => {
<span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
</div>
<div v-if="message.tools.length" class="agent-assistant-tools">
<div v-for="tool in message.tools" :key="tool.id" class="agent-assistant-tool">
<VIcon
:icon="
tool.status === 'running' && message.status === 'streaming'
? 'line-md:loading-twotone-loop'
: 'mdi-check-circle-outline'
"
size="16"
<div
v-if="message.role === 'assistant' && (message.tools.length || message.content.trim())"
class="agent-assistant-segments"
>
<template v-for="segment in getRenderableMessageSegments(message)" :key="segment.key">
<AgentMarkdownContent
v-if="segment.type === 'text'"
:content="segment.content"
:streaming="message.status === 'streaming'"
/>
<span>{{ tool.message }}</span>
</div>
<div v-else class="agent-assistant-tool">
<VIcon
:icon="
segment.tool.status === 'running' && message.status === 'streaming'
? 'line-md:loading-twotone-loop'
: 'mdi-check-circle-outline'
"
size="16"
/>
<span>{{ segment.tool.message }}</span>
</div>
</template>
</div>
<div
v-if="message.content"
class="agent-assistant-message__bubble markdown-body"
v-html="renderMarkdown(message.content)"
<AgentMarkdownContent
v-else-if="message.content"
:content="message.content"
:streaming="message.status === 'streaming'"
/>
<div v-if="message.choices.length" class="agent-assistant-choices">
<div v-for="choice in message.choices" :key="choice.id" class="agent-assistant-choice">
<div class="agent-assistant-choice__bubble">
<div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div>
<div class="agent-assistant-choice__prompt markdown-body" v-html="renderMarkdown(choice.prompt)" />
<AgentMarkdownContent :content="choice.prompt" variant="choice" />
<div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected">
<VIcon icon="mdi-check-circle-outline" size="16" />
<span>{{
@@ -2363,6 +2588,7 @@ onScopeDispose(() => {
<div
v-if="
!message.content &&
!message.segments.length &&
!message.attachments.length &&
!message.choices.length &&
message.status === 'streaming'
@@ -2454,6 +2680,8 @@ onScopeDispose(() => {
:placeholder="inputPlaceholder"
@input="handleInputChange"
@keydown="handleInputKeydown"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
<IconBtn
class="agent-assistant-record agent-assistant-surface-btn"
@@ -2488,21 +2716,12 @@ onScopeDispose(() => {
</aside>
</template>
<style lang="scss">
.agent-assistant-history-overlay {
z-index: 2603 !important;
}
</style>
<style lang="scss" scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
/* stylelint-disable no-descending-specificity */
.agent-assistant-panel {
position: fixed;
/* Agent 会话层保持高于入口(2600)和业务弹窗,同时低于自身弹出菜单。 */
z-index: 2601;
overflow: hidden;
background: rgb(var(--v-theme-surface));
@@ -2518,6 +2737,27 @@ onScopeDispose(() => {
overscroll-behavior: contain;
}
.agent-assistant-panel.is-fullscreen {
border-inline-start: 0;
box-shadow: none;
.agent-assistant-message__bubble {
max-inline-size: min(100%, 64rem);
}
.agent-assistant-segments,
.agent-assistant-choices,
.agent-assistant-attachments {
inline-size: min(100%, 64rem);
}
.agent-assistant-composer {
inline-size: min(calc(100% - 2rem), 72rem);
inset-inline: 0;
margin-inline: auto;
}
}
@supports (block-size: 100lvh) {
.agent-assistant-panel {
block-size: 100lvh !important;
@@ -2853,6 +3093,11 @@ onScopeDispose(() => {
min-block-size: 100%;
}
.agent-assistant-panel.is-fullscreen .agent-assistant-messages__content {
inline-size: min(100%, 72rem);
margin-inline: auto;
}
/* 只有消息态预留输入框空间,避免 iOS 空态被 padding 撑出不可滚动的滚动条。 */
.agent-assistant-messages--has-content {
padding-block-end: calc(env(safe-area-inset-bottom, 0px) + 8.75rem);
@@ -2926,6 +3171,7 @@ onScopeDispose(() => {
line-height: 1.55;
max-inline-size: min(100%, 34rem);
min-inline-size: 0;
overflow-wrap: anywhere;
padding-block: 0.75rem;
padding-inline: 0.85rem;
}
@@ -2941,11 +3187,14 @@ onScopeDispose(() => {
background: var(--agent-assistant-assistant-bg);
}
.agent-assistant-tools {
.agent-assistant-segments {
display: grid;
gap: 0.4rem;
gap: 0.5rem;
inline-size: min(100%, 34rem);
margin-block-end: 0.5rem;
}
.agent-assistant-segments .agent-assistant-message__bubble {
inline-size: 100%;
}
.agent-assistant-tool {
+56 -15
View File
@@ -2,6 +2,7 @@
import AgentAssistantEntry from './AgentAssistantEntry.vue'
import AgentAssistantPanel from './AgentAssistantPanel.vue'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
import { useTheme } from 'vuetify'
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
@@ -9,33 +10,73 @@ const panelOpen = ref(false)
const thinking = ref(false)
const entryRef = ref<AgentAssistantEntryRef | null>(null)
const { allowsDecorativeMotion } = useAppActivityLifecycle()
const { themeClasses } = useTheme()
const ASSISTANT_PREVIEW_INTERVAL = 125
let assistantPreviewTimer: number | null = null
let assistantPreviewPendingValue = ''
let assistantPreviewLastShownAt = 0
let assistantPreviewHasShown = false
function clearAssistantPreviewTimer() {
if (assistantPreviewTimer === null) return
window.clearTimeout(assistantPreviewTimer)
assistantPreviewTimer = null
}
function showPendingAssistantPreview() {
assistantPreviewTimer = null
if (panelOpen.value || !assistantPreviewPendingValue) return
entryRef.value?.showAssistantReplyPreview(assistantPreviewPendingValue)
assistantPreviewLastShownAt = performance.now()
assistantPreviewHasShown = true
}
// 打开 Agent 面板并清空入口预览气泡。
function openPanel() {
panelOpen.value = true
assistantPreviewPendingValue = ''
clearAssistantPreviewTimer()
entryRef.value?.clearBubbles()
}
// 面板关闭时展示助手回复预览
// 面板关闭时限制预览更新频率,避免每个流式 token 都触发气泡布局
function handleAssistantPreview(value: string) {
if (panelOpen.value) return
entryRef.value?.showAssistantReplyPreview(value)
assistantPreviewPendingValue = value
const elapsed = performance.now() - assistantPreviewLastShownAt
if (!assistantPreviewHasShown || elapsed >= ASSISTANT_PREVIEW_INTERVAL) {
clearAssistantPreviewTimer()
showPendingAssistantPreview()
return
}
if (assistantPreviewTimer !== null) return
assistantPreviewTimer = window.setTimeout(showPendingAssistantPreview, ASSISTANT_PREVIEW_INTERVAL - elapsed)
}
onScopeDispose(clearAssistantPreviewTimer)
</script>
<template>
<AgentAssistantEntry
ref="entryRef"
:active="!panelOpen"
:motion-active="allowsDecorativeMotion"
:thinking="thinking"
@open="openPanel"
/>
<AgentAssistantPanel
v-model="panelOpen"
:motion-active="allowsDecorativeMotion"
@assistant-preview="handleAssistantPreview"
@thinking-change="thinking = $event"
/>
<!-- 脱离 .v-application 的层叠上下文确保弹窗打开时入口消息气泡和面板仍在最上层 -->
<Teleport to="body">
<div class="agent-assistant-layer" :class="themeClasses">
<AgentAssistantEntry
ref="entryRef"
:active="!panelOpen"
:motion-active="allowsDecorativeMotion"
:thinking="thinking"
@open="openPanel"
/>
<AgentAssistantPanel
v-model="panelOpen"
:motion-active="allowsDecorativeMotion"
@assistant-preview="handleAssistantPreview"
@thinking-change="thinking = $event"
/>
</div>
</Teleport>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { renderAgentMarkdown } from '@/utils/agentMarkdown'
const props = withDefaults(
defineProps<{
content: string
streaming?: boolean
variant?: 'choice' | 'message'
}>(),
{
streaming: false,
variant: 'message',
},
)
const STREAM_MARKDOWN_RENDER_INTERVAL = 96
const renderedHtml = shallowRef('')
let renderTimer: number | null = null
let lastRenderedAt = 0
let hasRendered = false
function clearRenderTimer() {
if (renderTimer === null) return
window.clearTimeout(renderTimer)
renderTimer = null
}
// 流式阶段限制 Markdown 全量解析频率;结束时立即渲染最终内容。
function renderContent(immediate = false) {
const now = performance.now()
const elapsed = now - lastRenderedAt
if (immediate || !hasRendered || elapsed >= STREAM_MARKDOWN_RENDER_INTERVAL) {
clearRenderTimer()
renderedHtml.value = renderAgentMarkdown(props.content)
lastRenderedAt = now
hasRendered = true
return
}
if (renderTimer !== null) return
renderTimer = window.setTimeout(() => {
renderTimer = null
renderedHtml.value = renderAgentMarkdown(props.content)
lastRenderedAt = performance.now()
hasRendered = true
}, STREAM_MARKDOWN_RENDER_INTERVAL - elapsed)
}
watch(
() => props.content,
() => renderContent(!props.streaming),
{ immediate: true },
)
watch(
() => props.streaming,
streaming => {
if (!streaming) renderContent(true)
},
)
onScopeDispose(clearRenderTimer)
</script>
<template>
<div
v-if="renderedHtml"
class="markdown-body"
:class="variant === 'choice' ? 'agent-assistant-choice__prompt' : 'agent-assistant-message__bubble'"
v-html="renderedHtml"
/>
</template>
@@ -68,4 +68,44 @@ describe('AgentAssistantEntry lifecycle motion', () => {
wrapper.unmount()
})
it('updates an existing assistant preview without recreating its resize observer', async () => {
const observe = vi.fn()
const disconnect = vi.fn()
const resizeObserverConstructor = vi.fn()
vi.stubGlobal(
'ResizeObserver',
class {
constructor() {
resizeObserverConstructor()
}
observe = observe
disconnect = disconnect
},
)
const wrapper = shallowMount(AgentAssistantEntry, {
global: {
stubs: {
AgentPetStage: true,
VIcon: true,
},
},
props: {
active: true,
motionActive: true,
},
})
wrapper.vm.showAssistantReplyPreview('第一段')
await nextTick()
expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
wrapper.vm.showAssistantReplyPreview('第二段')
await nextTick()
expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
expect(wrapper.find('.agent-assistant-fab__bubble').text()).toContain('第二段')
wrapper.unmount()
})
})
@@ -2,12 +2,14 @@ import { flushPromises, shallowMount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AgentAssistantPanel from '@/components/agent/AgentAssistantPanel.vue'
const displayState = vi.hoisted(() => ({ mdAndDown: { value: true } }))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vuetify', () => ({
useDisplay: () => ({ mdAndDown: { value: true } }),
useDisplay: () => displayState,
}))
vi.mock('@/stores', () => ({
@@ -27,6 +29,12 @@ interface MockServerSession {
messages: Array<Record<string, unknown>>
}
const agentMarkdownContentStub = {
props: ['content', 'variant'],
template:
"<div :class=\"variant === 'choice' ? 'agent-assistant-choice__prompt' : 'agent-assistant-message__bubble'\">{{ content }}</div>",
}
// 构造符合 Agent 标准响应包装的 fetch 返回值。
function createAgentResponse(data: unknown) {
return {
@@ -41,9 +49,77 @@ describe('AgentAssistantPanel stream recovery', () => {
})
afterEach(() => {
displayState.mdAndDown.value = true
vi.useRealTimers()
})
it('toggles the desktop assistant between the side panel and fullscreen modes', async () => {
displayState.mdAndDown.value = false
vi.stubGlobal(
'fetch',
vi.fn(async () => createAgentResponse([])),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await flushPromises()
const panel = wrapper.get('.agent-assistant-panel')
const fullscreenToggle = wrapper.get('.agent-assistant-fullscreen-toggle')
expect(panel.classes()).not.toContain('is-fullscreen')
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 30rem')
expect(fullscreenToggle.attributes('title')).toBe('agentAssistant.enterFullscreen')
expect(fullscreenToggle.attributes('aria-pressed')).toBe('false')
await fullscreenToggle.trigger('click')
expect(panel.classes()).toContain('is-fullscreen')
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 100vw')
expect(fullscreenToggle.attributes('title')).toBe('agentAssistant.exitFullscreen')
expect(fullscreenToggle.attributes('aria-pressed')).toBe('true')
await fullscreenToggle.trigger('click')
expect(panel.classes()).not.toContain('is-fullscreen')
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 30rem')
wrapper.unmount()
})
it('keeps the narrow assistant fullscreen without a redundant toggle', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => createAgentResponse([])),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await flushPromises()
expect(wrapper.get('.agent-assistant-panel').attributes('style')).toContain('--agent-assistant-panel-width: 100vw')
expect(wrapper.find('.agent-assistant-fullscreen-toggle').exists()).toBe(false)
wrapper.unmount()
})
it('restores a legacy empty message to loading and waits for the final reply after a PWA reload', async () => {
const startedAt = Date.now() - 5000
const localSessionId = 'web-local-session'
@@ -111,6 +187,7 @@ describe('AgentAssistantPanel stream recovery', () => {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
@@ -203,6 +280,7 @@ describe('AgentAssistantPanel stream recovery', () => {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
@@ -237,4 +315,238 @@ describe('AgentAssistantPanel stream recovery', () => {
wrapper.unmount()
})
it('does not send when Enter confirms an IME composition', async () => {
const fetchMock = vi.fn(async () => createAgentResponse([]))
vi.stubGlobal('fetch', fetchMock)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
const textarea = wrapper.find('textarea')
await flushPromises()
fetchMock.mockClear()
await textarea.setValue('搜索电影')
await textarea.trigger('compositionstart')
await textarea.trigger('keydown', { key: 'Enter', isComposing: true })
await textarea.trigger('compositionend')
await flushPromises()
expect(fetchMock).not.toHaveBeenCalled()
await textarea.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(fetchMock).toHaveBeenCalled()
wrapper.unmount()
})
it('renders interleaved assistant text and tool events in their SSE order', async () => {
const serverSessionId = 'web-agent:ordered-segments'
const streamEvents = [
{ type: 'start', session_id: serverSessionId },
{ type: 'delta', content: '先检查服务器。' },
{ type: 'tool', message: '(执行了 1 条命令)' },
{ type: 'delta', content: '检查完成,没有发现错误。' },
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
const textarea = wrapper.find('textarea')
await textarea.setValue('检查服务器')
await textarea.trigger('keydown', { key: 'Enter' })
await flushPromises()
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
expect(renderedSegments).toHaveLength(3)
expect(renderedSegments[0].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[0].text()).toContain('先检查服务器。')
expect(renderedSegments[1].classes()).toContain('agent-assistant-tool')
expect(renderedSegments[1].text()).toContain('执行了 1 条命令')
expect(renderedSegments[2].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[2].text()).toContain('检查完成,没有发现错误。')
const saveCall = fetchMock.mock.calls.find(([input]) => String(input).includes('/display'))
const savedMessages = JSON.parse(String(saveCall?.[1]?.body || '{}')).messages as Array<Record<string, unknown>>
expect(savedMessages.at(-1)).toMatchObject({
content: '先检查服务器。检查完成,没有发现错误。',
segments: [
{ type: 'text', content: '先检查服务器。' },
{ type: 'tool', toolIndex: 0 },
{ type: 'text', content: '检查完成,没有发现错误。' },
],
})
wrapper.unmount()
})
it('does not render an empty text bubble for trailing whitespace after a tool summary', async () => {
const streamEvents = [
{ type: 'start', session_id: 'web-agent:trailing-whitespace' },
{ type: 'delta', content: '最终结论' },
{ type: 'tool', message: '(查询了 1 次数据)' },
{ type: 'delta', content: '\n\n' },
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
}),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await wrapper.find('textarea').setValue('检查配置')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
expect(renderedSegments).toHaveLength(2)
expect(renderedSegments[0].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[0].text()).toBe('最终结论')
expect(renderedSegments[1].classes()).toContain('agent-assistant-tool')
wrapper.unmount()
})
it('aggregates adjacent non-verbose tools and starts a new group after text', async () => {
const streamEvents = [
{ type: 'start', session_id: 'web-agent:tool-groups' },
{ type: 'tool', message: '(查询了 1 次数据)' },
{ type: 'tool', message: '(查看了 1 个目录)' },
{ type: 'tool', message: '(查询了 1 次数据)' },
{ type: 'delta', content: '继续分析。' },
{ type: 'tool', message: '(读取了 1 个文件)' },
{ type: 'tool', message: '(读取了 1 个文件)' },
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
}),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await wrapper.find('textarea').setValue('分析插件')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
expect(renderedSegments).toHaveLength(3)
expect(renderedSegments[0].text()).toContain('查询了 2 次数据,查看了 1 个目录')
expect(renderedSegments[1].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[1].text()).toBe('继续分析。')
expect(renderedSegments[2].text()).toContain('读取了 2 个文件')
wrapper.unmount()
})
it('coalesces consecutive text deltas into one UI update before a terminal event', async () => {
const streamEvents = [
{ type: 'start', session_id: 'web-agent:coalesced' },
...Array.from({ length: 100 }, (_item, index) => ({ type: 'delta', content: String(index % 10) })),
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
}),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await wrapper.find('textarea').setValue('测试突发事件')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.emitted('assistant-preview')).toHaveLength(1)
expect(wrapper.find('.agent-assistant-message--assistant .agent-assistant-message__bubble').text()).toBe(
Array.from({ length: 100 }, (_item, index) => String(index % 10)).join(''),
)
wrapper.unmount()
})
})
@@ -0,0 +1,94 @@
import { mount } from '@vue/test-utils'
import { defineComponent, h, nextTick, ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
vi.mock('vuetify', () => ({
useTheme: () => ({ themeClasses: ref('v-theme--test') }),
}))
vi.mock('@/composables/useAppActivityLifecycle', () => ({
useAppActivityLifecycle: () => ({ allowsDecorativeMotion: ref(true) }),
}))
describe('AgentAssistantWidget layering', () => {
afterEach(() => {
vi.useRealTimers()
document.body
.querySelectorAll('.agent-assistant-layer, .agent-assistant-test-host')
.forEach(element => element.remove())
})
it('teleports the assistant outside the application stacking context while preserving the active theme', () => {
const host = document.createElement('div')
host.className = 'agent-assistant-test-host v-application'
document.body.append(host)
const wrapper = mount(AgentAssistantWidget, {
attachTo: host,
global: {
stubs: {
AgentAssistantEntry: { template: '<div data-agent-assistant-entry />' },
AgentAssistantPanel: { template: '<div data-agent-assistant-panel />' },
},
},
})
const layer = document.body.querySelector(':scope > .agent-assistant-layer')
expect(layer).not.toBeNull()
expect(host.contains(layer)).toBe(false)
expect(layer).toHaveClass('v-theme--test')
expect(layer?.querySelector('[data-agent-assistant-entry]')).not.toBeNull()
expect(layer?.querySelector('[data-agent-assistant-panel]')).not.toBeNull()
wrapper.unmount()
})
it('reserves the highest CSS stacking levels in assistant display order', () => {
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.entry).toBe(2_147_483_645)
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.panel).toBe(2_147_483_646)
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.overlay).toBe(2_147_483_647)
})
it('limits closed-panel assistant preview updates and keeps the latest text', async () => {
vi.useFakeTimers()
const showAssistantReplyPreview = vi.fn()
const entryStub = defineComponent({
setup(_props, { expose }) {
expose({ clearBubbles: vi.fn(), showAssistantReplyPreview })
return () => h('div', { 'data-agent-assistant-entry': '' })
},
})
const panelStub = defineComponent({
emits: ['assistant-preview', 'thinking-change', 'update:modelValue'],
setup() {
return () => h('div', { 'data-agent-assistant-panel': '' })
},
})
const wrapper = mount(AgentAssistantWidget, {
global: {
stubs: {
AgentAssistantEntry: entryStub,
AgentAssistantPanel: panelStub,
},
},
})
const panel = wrapper.findComponent(panelStub)
panel.vm.$emit('assistant-preview', '第一段')
panel.vm.$emit('assistant-preview', '第二段')
panel.vm.$emit('assistant-preview', '最终预览')
await nextTick()
expect(showAssistantReplyPreview).toHaveBeenCalledTimes(1)
expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('第一段')
await vi.advanceTimersByTimeAsync(125)
expect(showAssistantReplyPreview).toHaveBeenCalledTimes(2)
expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('最终预览')
wrapper.unmount()
})
})
@@ -0,0 +1,46 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AgentMarkdownContent from '@/components/agent/AgentMarkdownContent.vue'
describe('AgentMarkdownContent', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('throttles streaming Markdown and renders the final content immediately', async () => {
const wrapper = mount(AgentMarkdownContent, {
props: { content: '**开始**', streaming: true },
})
expect(wrapper.html()).toContain('<strong>开始</strong>')
await wrapper.setProps({ content: '**开始继续**' })
expect(wrapper.html()).not.toContain('<strong>开始继续</strong>')
await vi.advanceTimersByTimeAsync(96)
expect(wrapper.html()).toContain('<strong>开始继续</strong>')
await wrapper.setProps({ content: '**最终结果**', streaming: false })
expect(wrapper.html()).toContain('<strong>最终结果</strong>')
})
it('escapes raw HTML from Agent output', () => {
const wrapper = mount(AgentMarkdownContent, {
props: { content: '<img src=x onerror="alert(1)">' },
})
expect(wrapper.html()).not.toContain('<img')
expect(wrapper.text()).toContain('<img src=x onerror=')
})
it('does not render a bubble for whitespace-only Markdown', () => {
const wrapper = mount(AgentMarkdownContent, {
props: { content: '\n\n' },
})
expect(wrapper.find('.agent-assistant-message__bubble').exists()).toBe(false)
})
})
+11 -29
View File
@@ -11,9 +11,9 @@ import { getDisplayImageUrl } from '@/utils/imageUtils'
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
type RecognitionCacheSource = 'tmdb' | 'douban'
const MOBILE_CACHE_PAGE_SIZE = 20
const RECOGNITION_CACHE_ENDPOINT = 'tmdb/cache'
const { t } = useI18n()
const display = useDisplay()
@@ -22,18 +22,8 @@ const $toast = useToast()
const globalSettingsStore = useGlobalSettingsStore()
const isMobile = computed(() => display.smAndDown.value)
const recognitionSource = computed<RecognitionCacheSource>(() =>
globalSettingsStore.globalSettings.RECOGNIZE_SOURCE === 'douban' ? 'douban' : 'tmdb',
)
const recognitionSourceName = computed(() =>
recognitionSource.value === 'douban'
? t('setting.cache.recognitionSource.douban')
: t('setting.cache.recognitionSource.themoviedb'),
)
const recognitionIdLabel = computed(() =>
recognitionSource.value === 'douban' ? t('setting.cache.doubanId') : t('setting.cache.tmdbId'),
)
const recognitionCacheEndpoint = computed(() => `${recognitionSource.value}/cache`)
const recognitionSourceName = computed(() => t('setting.cache.recognitionSource.themoviedb'))
const recognitionIdLabel = computed(() => t('setting.cache.tmdbId'))
const recognitionFilterPlaceholder = computed(() =>
t('setting.cache.filterRecognitionCache', { source: recognitionSourceName.value }),
)
@@ -106,12 +96,12 @@ function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) =>
done(mobileHasMore.value ? 'ok' : 'empty')
}
/** 加载当前识别数据源对应的缓存列表。 */
/** 加载 TMDB 主识别缓存列表。 */
async function loadCacheData(showSuccess = false) {
const requestId = ++cacheLoadRequestId
try {
loading.value = true
const response = (await api.get(recognitionCacheEndpoint.value)) as unknown as ApiResponse<RecognitionCacheData>
const response = (await api.get(RECOGNITION_CACHE_ENDPOINT)) as unknown as ApiResponse<RecognitionCacheData>
if (requestId !== cacheLoadRequestId) return
const responseData = response.data ?? {
count: 0,
@@ -139,7 +129,7 @@ async function loadCacheData(showSuccess = false) {
}
}
/** 清空当前识别数据源的全部识别缓存。 */
/** 清空全部 TMDB 主识别缓存。 */
async function clearAllCache() {
const confirmed = await createConfirm({
type: 'warn',
@@ -150,7 +140,7 @@ async function clearAllCache() {
try {
loading.value = true
const response = (await api.delete(recognitionCacheEndpoint.value)) as unknown as ApiResponse
const response = (await api.delete(RECOGNITION_CACHE_ENDPOINT)) as unknown as ApiResponse
if (!response.success) throw new Error(response.message)
$toast.success(response.message || t('setting.cache.clearSuccess'))
await loadCacheData()
@@ -163,10 +153,10 @@ async function clearAllCache() {
}
}
/** 请求当前识别数据源接口删除指定识别缓存。 */
/** 请求 TMDB 接口删除指定识别缓存。 */
async function deleteCacheItem(key: string) {
const response = (await api.delete(
`${recognitionCacheEndpoint.value}/${encodeURIComponent(key)}`,
`${RECOGNITION_CACHE_ENDPOINT}/${encodeURIComponent(key)}`,
)) as unknown as ApiResponse
if (!response.success) throw new Error(response.message)
}
@@ -217,10 +207,9 @@ function getPosterUrl(item: RecognitionCacheItem): string {
return getDisplayImageUrl(sourceUrl, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
}
/** 获取当前识别数据源对应的媒体 ID。 */
/** 获取识别缓存对应的 TMDB ID。 */
function getRecognitionId(item: RecognitionCacheItem): string {
const recognitionId = recognitionSource.value === 'douban' ? item.douban_id : item.tmdb_id
return recognitionId ? String(recognitionId) : ''
return item.tmdb_id ? String(item.tmdb_id) : ''
}
/** 判断识别缓存条目是否包含有效媒体 ID。 */
@@ -260,13 +249,6 @@ onMounted(() => {
watch([searchFilter, statusFilter], () => {
resetMobilePagination()
})
watch(recognitionSource, () => {
searchFilter.value = ''
statusFilter.value = 'all'
selectedItems.value = []
void loadCacheData()
})
</script>
<template>
@@ -22,13 +22,13 @@ vi.mock('vue-toastification', () => ({
}),
}))
async function renderRecognitionCachePanel() {
async function renderRecognitionCachePanel(recognitionSource = 'themoviedb') {
return renderWithProviders(RecognitionCachePanel, {
initialState: {
globalSettings: {
data: {
GLOBAL_IMAGE_CACHE: false,
RECOGNIZE_SOURCE: 'themoviedb',
RECOGNIZE_SOURCE: recognitionSource,
TMDB_IMAGE_DOMAIN: 'image.tmdb.org',
},
},
@@ -79,4 +79,21 @@ describe('RecognitionCachePanel shared recognition statistics', () => {
expect(screen.queryByText('共享识别')).not.toBeInTheDocument()
})
it('loads TMDB cache even when Douban is selected as the recognition source', async () => {
mocks.apiGet.mockResolvedValue({
data: {
count: 0,
recognized: 0,
unrecognized: 0,
shared_recognized: 0,
shared_recognize_enabled: false,
data: [],
},
})
await renderRecognitionCachePanel('douban')
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('tmdb/cache'))
})
})
+43 -42
View File
@@ -2,12 +2,15 @@
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { openMediaServerItem } from '@/utils/appDeepLink'
import { useGlobalSettingsStore } from '@/stores'
import { getProxyImageUrl } from '@/utils/imageUtils'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
width: String,
height: String,
})
const globalSettingsStore = useGlobalSettingsStore()
// 图片是否加载完成
const imageLoaded = ref(false)
@@ -40,12 +43,10 @@ async function goPlay() {
const getImgUrl = computed(() => {
const image = props.media?.image || ''
if (!image || imageLoadError.value) return noImage
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
const use_cookies = props.media?.use_cookies
if (use_cookies) {
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
}
return url
return getProxyImageUrl(image, {
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
useCookies: props.media?.use_cookies,
})
})
</script>
@@ -63,43 +64,43 @@ const getImgUrl = computed(() => {
}"
@click="goPlay"
>
<template #image>
<VImg
:src="getImgUrl"
aspect-ratio="2/3"
class="backdrop-card-image"
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
cover
@load="imageLoadHandler"
@error="imageErrorHandler"
>
<template #placeholder>
<div class="backdrop-card-placeholder">
<VSkeletonLoader class="backdrop-card-skeleton" />
</div>
</template>
<template #default>
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
>
<h1
class="mb-1 text-white text-shadow font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ..."
<template #image>
<VImg
:src="getImgUrl"
aspect-ratio="2/3"
class="backdrop-card-image"
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
cover
@load="imageLoadHandler"
@error="imageErrorHandler"
>
<template #placeholder>
<div class="backdrop-card-placeholder">
<VSkeletonLoader class="backdrop-card-skeleton" />
</div>
</template>
<template #default>
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
>
{{ props.media?.title }}
</h1>
<span class="text-shadow text-sm">{{ props.media?.subtitle }}</span>
</VCardText>
</template>
</VImg>
</template>
<div class="w-full absolute bottom-0">
<VProgressLinear
v-if="props.media?.percent"
:model-value="props.media?.percent"
bg-color="success"
color="success"
/>
</div>
<h1
class="mb-1 text-white text-shadow font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ..."
>
{{ props.media?.title }}
</h1>
<span class="text-shadow text-sm">{{ props.media?.subtitle }}</span>
</VCardText>
</template>
</VImg>
</template>
<div class="w-full absolute bottom-0">
<VProgressLinear
v-if="props.media?.percent"
:model-value="props.media?.percent"
bg-color="success"
color="success"
/>
</div>
</VCard>
</div>
</template>
+2
View File
@@ -12,6 +12,7 @@ const STORAGE_ACCENT_COLOR_MAP = {
u115: '#17B26A',
rclone: '#6675FF',
alist: '#12B8D7',
alistgo: '#1BA0D8',
smb: '#3B82F6',
}
@@ -47,6 +48,7 @@ const typeItems = computed(() => [
{ title: t('common.all'), value: '' },
{ title: t('mediaType.movie'), value: '电影' },
{ title: t('mediaType.tv'), value: '电视剧' },
{ title: t('mediaType.music'), value: '音乐' },
])
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
+143 -69
View File
@@ -2,6 +2,8 @@
import api from '@/api'
import type { ApiResponse, DownloadingInfo } from '@/api/types'
import { formatFileSize } from '@/@core/utils/formatters'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { useI18n } from 'vue-i18n'
/** 卡片使用的下载任务信息,兼容接口已经返回但公共类型尚未声明的来源站点。 */
@@ -17,6 +19,7 @@ const props = defineProps({
})
const { t } = useI18n()
const globalSettingsStore = useGlobalSettingsStore()
// 卡片在删除成功后就地隐藏,等待外层轮询同步任务列表。
const cardState = ref(true)
@@ -31,7 +34,10 @@ watch(
},
)
const hasPosterImage = computed(() => Boolean(media.value.poster && !imageLoadError.value))
const posterUrl = computed(() =>
getDisplayImageUrl(media.value.poster || '', globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
)
const hasPosterImage = computed(() => Boolean(posterUrl.value && !imageLoadError.value))
const mediaTitle = computed(() => media.value.title || props.info?.name || props.info?.title || t('common.unknown'))
@@ -46,6 +52,7 @@ const mediaTypeText = computed(() => {
const type = String(media.value.type || '').trim()
if (type === '电影' || type.toLowerCase() === 'movie') return t('mediaType.movie')
if (type === '电视剧' || type.toLowerCase() === 'tv') return t('mediaType.tv')
if (type === '音乐' || type.toLowerCase() === 'music') return t('mediaType.music')
if (type) return type
if (media.value.season || media.value.episode || props.info?.season_episode) return t('mediaType.tv')
return media.value.title ? t('mediaType.movie') : ''
@@ -57,6 +64,7 @@ const mediaTypeIcon = computed(() => {
.toLowerCase()
if (type === '电影' || type === 'movie') return 'mdi-movie-outline'
if (type === '电视剧' || type === 'tv') return 'mdi-television-classic'
if (type === '音乐' || type === 'music') return 'mdi-music-note'
return 'mdi-play-box-outline'
})
@@ -94,6 +102,8 @@ function formatSpeed(speed?: string) {
const downloadSpeedText = computed(() => formatSpeed(props.info?.dlspeed))
const uploadSpeedText = computed(() => formatSpeed(props.info?.upspeed))
const hasDownloadSpeed = computed(() => Number.parseFloat(props.info?.dlspeed || '') > 0)
const hasUploadSpeed = computed(() => Number.parseFloat(props.info?.upspeed || '') > 0)
// 下载状态跟随轮询数据变化,操作成功时也会立即响应。
const isDownloading = ref(props.info?.state === 'downloading')
@@ -160,7 +170,7 @@ async function deleteDownload() {
>
<div v-if="hasPosterImage" class="downloading-card__poster">
<VImg
:src="media.poster"
:src="posterUrl"
class="downloading-card__image"
cover
position="center"
@@ -174,24 +184,6 @@ async function deleteDownload() {
</div>
<VCardText class="downloading-card__body">
<div class="downloading-card__chips">
<VChip
v-if="mediaTypeText"
:prepend-icon="mediaTypeIcon"
color="primary"
size="x-small"
variant="tonal"
>
{{ mediaTypeText }}
</VChip>
<VChip v-if="sourceSiteText" prepend-icon="mdi-web" size="x-small" variant="tonal">
{{ sourceSiteText }}
</VChip>
<VChip v-else prepend-icon="mdi-harddisk" size="x-small" variant="tonal">
{{ sizeText }}
</VChip>
</div>
<div class="downloading-card__heading">
<div class="downloading-card__title" :title="mediaTitle">
<span>{{ mediaTitle }}</span>
@@ -202,19 +194,43 @@ async function deleteDownload() {
</div>
</div>
<div v-if="progressValue > 0" class="downloading-card__progress">
<div class="downloading-card__progress-label">
<span>
{{ isDownloading ? t('common.download') : t('common.pause') }}
<span class="downloading-card__progress-separator">·</span>
{{ remainingTimeText }}
<div class="downloading-card__meta">
<span v-if="mediaTypeText" class="downloading-card__meta-item downloading-card__meta-type">
<VIcon :icon="mediaTypeIcon" size="14" />
{{ mediaTypeText }}
</span>
<span v-if="mediaTypeText" aria-hidden="true" class="downloading-card__meta-separator">·</span>
<span class="downloading-card__meta-item downloading-card__meta-size">{{ sizeText }}</span>
<template v-if="sourceSiteText">
<span aria-hidden="true" class="downloading-card__meta-separator">·</span>
<span class="downloading-card__meta-item downloading-card__meta-source" :title="sourceSiteText">
{{ sourceSiteText }}
</span>
</template>
</div>
<div
v-if="progressValue > 0"
class="downloading-card__progress"
:class="isDownloading ? 'downloading-card__progress--active' : 'downloading-card__progress--paused'"
>
<div class="downloading-card__progress-label">
<div class="downloading-card__progress-copy">
<span class="downloading-card__progress-state">
<VIcon :icon="isDownloading ? 'mdi-download' : 'mdi-pause'" size="14" />
{{ isDownloading ? t('common.download') : t('common.pause') }}
</span>
<span class="downloading-card__progress-remaining">
<span aria-hidden="true" class="downloading-card__progress-separator">·</span>
{{ remainingTimeText }}
</span>
</div>
<strong>{{ progressText }}</strong>
</div>
<VProgressLinear
:aria-label="t('common.download')"
:aria-label="isDownloading ? t('common.download') : t('common.pause')"
:model-value="progressValue"
:color="isDownloading ? 'success' : 'warning'"
:color="isDownloading ? 'info' : 'warning'"
bg-color="surface-variant"
height="6"
rounded
@@ -223,11 +239,17 @@ async function deleteDownload() {
<div class="downloading-card__footer">
<div class="downloading-card__speeds">
<div class="downloading-card__speed downloading-card__speed--download">
<div
class="downloading-card__speed downloading-card__speed--download"
:class="{ 'downloading-card__speed--idle': !hasDownloadSpeed }"
>
<VIcon icon="mdi-arrow-down" size="16" />
<strong :title="downloadSpeedText">{{ downloadSpeedText }}</strong>
</div>
<div class="downloading-card__speed downloading-card__speed--upload">
<div
class="downloading-card__speed downloading-card__speed--upload"
:class="{ 'downloading-card__speed--idle': !hasUploadSpeed }"
>
<VIcon icon="mdi-arrow-up" size="16" />
<strong :title="uploadSpeedText">{{ uploadSpeedText }}</strong>
</div>
@@ -239,7 +261,7 @@ async function deleteDownload() {
:disabled="pendingAction === 'delete'"
icon
:loading="pendingAction === 'toggle'"
color="primary"
:color="isDownloading ? 'info' : 'warning'"
size="small"
variant="tonal"
@click="toggleDownload"
@@ -251,9 +273,10 @@ async function deleteDownload() {
</VBtn>
<VBtn
:aria-label="t('common.delete')"
class="downloading-card__delete-action"
color="on-surface"
:disabled="pendingAction === 'toggle'"
:loading="pendingAction === 'delete'"
color="error"
icon
size="small"
variant="text"
@@ -316,25 +339,43 @@ async function deleteDownload() {
display: flex;
min-inline-size: 0;
flex-direction: column;
gap: 0.55rem;
padding: 0.875rem !important;
gap: 0.7rem;
padding: 1rem !important;
}
.downloading-card__chips {
.downloading-card__meta {
display: flex;
min-inline-size: 0;
align-items: center;
gap: 0.4rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.72rem;
gap: 0.35rem;
line-height: 1.35;
white-space: nowrap;
}
.downloading-card__chips :deep(.v-chip) {
max-inline-size: calc(50% - 0.2rem);
.downloading-card__meta-item {
display: inline-flex;
min-inline-size: 0;
align-items: center;
gap: 0.2rem;
}
.downloading-card__chips :deep(.v-chip__content) {
.downloading-card__meta-type,
.downloading-card__meta-size,
.downloading-card__meta-separator {
flex: 0 0 auto;
}
.downloading-card__meta-type {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-weight: 600;
}
.downloading-card__meta-source {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.downloading-card__heading {
@@ -363,20 +404,24 @@ async function deleteDownload() {
}
.downloading-card__torrent-title {
display: -webkit-box;
display: block;
overflow: hidden;
margin-block-start: 0.25rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-height: 1.4;
overflow-wrap: anywhere;
white-space: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.downloading-card__progress {
min-inline-size: 0;
--downloading-card-status-color: rgb(var(--v-theme-info));
}
.downloading-card__progress--paused {
--downloading-card-status-color: rgb(var(--v-theme-warning));
}
.downloading-card__progress-label {
@@ -384,13 +429,29 @@ async function deleteDownload() {
min-inline-size: 0;
align-items: center;
justify-content: space-between;
margin-block-end: 0.4rem;
margin-block-end: 0.35rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.7rem;
font-size: 0.72rem;
gap: 0.5rem;
}
.downloading-card__progress-label > span {
.downloading-card__progress-copy {
display: flex;
min-inline-size: 0;
align-items: center;
gap: 0.15rem;
}
.downloading-card__progress-state {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
color: var(--downloading-card-status-color);
font-weight: 650;
gap: 0.2rem;
}
.downloading-card__progress-remaining {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -398,9 +459,10 @@ async function deleteDownload() {
.downloading-card__progress-label strong {
flex: 0 0 auto;
color: rgb(var(--v-theme-on-surface));
color: var(--downloading-card-status-color);
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
font-size: 0.82rem;
font-weight: 700;
}
.downloading-card__progress-separator {
@@ -436,10 +498,10 @@ async function deleteDownload() {
.downloading-card__speed strong {
overflow: hidden;
color: rgb(var(--v-theme-on-surface));
font-size: 0.7rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
font-weight: 650;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -448,16 +510,35 @@ async function deleteDownload() {
color: rgb(var(--v-theme-info));
}
.downloading-card__speed--download strong {
color: rgb(var(--v-theme-on-surface));
font-weight: 700;
}
.downloading-card__speed--upload .v-icon {
color: rgb(var(--v-theme-success));
}
.downloading-card__speed--idle {
opacity: 0.52;
}
.downloading-card__actions {
display: flex;
flex: 0 0 auto;
gap: 0.1rem;
}
.downloading-card__delete-action {
opacity: var(--v-medium-emphasis-opacity);
}
.downloading-card__delete-action:hover,
.downloading-card__delete-action:focus-visible {
color: rgb(var(--v-theme-error)) !important;
opacity: 1;
}
@container (width <= 25rem) {
.downloading-card {
min-block-size: 11rem;
@@ -469,12 +550,8 @@ async function deleteDownload() {
}
.downloading-card__body {
gap: 0.45rem;
padding: 0.65rem !important;
}
.downloading-card__chips {
gap: 0.3rem;
gap: 0.55rem;
padding: 0.75rem !important;
}
.downloading-card__title {
@@ -482,7 +559,12 @@ async function deleteDownload() {
}
.downloading-card__torrent-title {
font-size: 0.7rem;
font-size: 0.69rem;
}
.downloading-card__meta,
.downloading-card__progress-label {
font-size: 0.69rem;
}
.downloading-card__speeds {
@@ -496,15 +578,7 @@ async function deleteDownload() {
@container (width <= 21rem) {
.downloading-card__body {
padding-inline: 0.65rem !important;
}
.downloading-card__chips :deep(.v-chip) {
max-inline-size: 100%;
}
.downloading-card__chips :deep(.v-chip:first-child:last-child) {
display: inline-flex;
padding-inline: 0.75rem !important;
}
.downloading-card__actions :deep(.v-btn) {
+40 -39
View File
@@ -3,8 +3,9 @@ import type { MediaServerLibrary } from '@/api/types'
import plex from '@images/misc/plex.png'
import emby from '@images/misc/emby.png'
import jellyfin from '@images/misc/jellyfin.png'
import { getLogoUrl } from '@/utils/imageUtils'
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
import { openMediaServerItem } from '@/utils/appDeepLink'
import { useGlobalSettingsStore } from '@/stores'
// 输入参数
const props = defineProps({
@@ -12,6 +13,7 @@ const props = defineProps({
width: String,
height: String,
})
const globalSettingsStore = useGlobalSettingsStore()
// canvas
const canvasRef = ref<HTMLCanvasElement>()
@@ -96,11 +98,10 @@ async function goPlay() {
*/
function getImgUrl(url: string, use_cookies?: boolean) {
if (!url || imageError.value) return getDefaultImage()
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
if (use_cookies) {
imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`
}
return imgurl
return getProxyImageUrl(url, {
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
useCookies: use_cookies,
})
}
/**
@@ -115,10 +116,10 @@ async function drawImages(imageList: string[], use_cookies?: boolean) {
// 为所有图片添加system/img前缀
for (let i = 0; i < IMAGES.length; i++) {
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(IMAGES[i])}`
if (use_cookies) {
IMAGES[i] += `&use_cookies=${encodeURIComponent(use_cookies)}`
}
IMAGES[i] = getProxyImageUrl(IMAGES[i], {
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
useCookies: use_cookies,
})
}
// canvas
@@ -213,33 +214,33 @@ onMounted(async () => {
}"
@click="goPlay"
>
<template #image>
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
<VImg
:src="imgUrl"
aspect-ratio="2/3"
class="library-card-image"
:class="{ 'library-card-image--loaded': imageLoaded }"
cover
@load="imageLoadHandler"
@error="imageErrorHandler"
>
<template #placeholder>
<div class="library-card-placeholder">
<VSkeletonLoader class="library-card-skeleton" />
</div>
</template>
<template #default>
<div class="library-card-shade" aria-hidden="true" />
<div v-if="showCountCorner" class="library-card-count-corner">
<span>{{ countLabel }}</span>
</div>
<div class="library-card-label">
<span>{{ props.media?.name }}</span>
</div>
</template>
</VImg>
</template>
<template #image>
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
<VImg
:src="imgUrl"
aspect-ratio="2/3"
class="library-card-image"
:class="{ 'library-card-image--loaded': imageLoaded }"
cover
@load="imageLoadHandler"
@error="imageErrorHandler"
>
<template #placeholder>
<div class="library-card-placeholder">
<VSkeletonLoader class="library-card-skeleton" />
</div>
</template>
<template #default>
<div class="library-card-shade" aria-hidden="true" />
<div v-if="showCountCorner" class="library-card-count-corner">
<span>{{ countLabel }}</span>
</div>
<div class="library-card-label">
<span>{{ props.media?.name }}</span>
</div>
</template>
</VImg>
</template>
</VCard>
</div>
</template>
@@ -310,7 +311,7 @@ onMounted(async () => {
background: rgba(8, 13, 22, 72%);
block-size: 100%;
clip-path: polygon(100% 0, 100% 100%, 0 0);
content: "";
content: '';
inset-block-start: 0;
inset-inline-end: 0;
inline-size: 100%;
@@ -321,7 +322,7 @@ onMounted(async () => {
background: rgba(var(--v-theme-primary), 62%);
block-size: 100%;
clip-path: polygon(100% 0, 100% 100%, 0 0);
content: "";
content: '';
inset-block-start: 0;
inset-inline-end: 0;
inline-size: 100%;
+147 -12
View File
@@ -6,7 +6,6 @@ let mediaCardIdSeed = 0
</script>
<script lang="ts" setup>
import noImage from '@images/no-image.jpeg'
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
import api from '@/api'
import { formatRating } from '@/@core/utils/formatters'
@@ -27,6 +26,7 @@ import {
getCachedMediaSubscribeStatus,
setCachedMediaExistsStatus,
} from '@/utils/mediaStatusCache'
import { buildMusicDetailRoute, getMusicKey } from '@/utils/music'
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
@@ -62,6 +62,34 @@ const isImageLoaded = ref(false)
// 图片加载失败
const imageLoadError = ref(false)
// 图片请求代际隔离复用卡片的迟到事件,避免旧海报改变新媒体的 renderer 资格。
const imageRequestRevision = ref(0)
// renderer 只能在真实海报完成可见淡入后退出,资源 load 本身不代表像素已完全覆盖卡片。
const hasCompletedPosterReveal = ref(false)
const POSTER_REVEAL_FALLBACK_MS = 400
let posterRevealFallbackTimer: number | null = null
/** 清理当前海报的视觉覆盖状态与兜底提交。 */
function resetPosterRevealState() {
hasCompletedPosterReveal.value = false
if (posterRevealFallbackTimer === null) return
window.clearTimeout(posterRevealFallbackTimer)
posterRevealFallbackTimer = null
}
/** 仅允许当前真实海报请求完成 renderer 排除提交。 */
function completePosterReveal(revision: number, usesFallback: boolean) {
if (revision !== imageRequestRevision.value || usesFallback) return
if (posterRevealFallbackTimer !== null) {
window.clearTimeout(posterRevealFallbackTimer)
posterRevealFallbackTimer = null
}
hasCompletedPosterReveal.value = true
}
// 当前订阅状态
const isSubscribed = ref(false)
@@ -122,10 +150,11 @@ function openSearchSiteDialog() {
)
}
// 查询所有站点
// 查询与当前媒体类型兼容的站点
async function querySites() {
try {
const data: Site[] = await api.get('site/')
const mediaType = props.media?.type === '电视剧' ? 'tv' : props.media?.type === '音乐' ? 'music' : 'movie'
const data: Site[] = await api.get(`site/media/${mediaType}`)
// 过滤站点,只有启用的站点才显示
allSites.value = data.filter(item => item.is_active)
@@ -150,7 +179,8 @@ function getMediaId() {
}
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
return `${getMediaId()}::${season ?? 'all'}`
const identity = props.media?.type === '音乐' ? getMusicKey(props.media) : getMediaId()
return `${identity}::${season ?? 'all'}`
}
function getExistsStatusKey() {
@@ -166,6 +196,11 @@ function getExistsStatusKey() {
}
function isSameSubscribeMedia(subscribe: Subscribe) {
if (props.media?.type === '音乐') {
const expectedMusicType = props.media.music_type ?? 'recording'
const subscribeMusicType = subscribe.music_type ?? 'recording'
if (subscribeMusicType !== expectedMusicType) return false
}
const mediaId = getMediaId()
if (subscribe.media_source && subscribe.media_id) {
const prefix = subscribe.media_source === 'themoviedb' ? 'tmdb' : subscribe.media_source
@@ -183,6 +218,7 @@ function isSameSubscribeMedia(subscribe: Subscribe) {
function getChipColor(type: string) {
if (type === '电影') return 'border-blue-500 bg-blue-600'
else if (type === '电视剧') return ' bg-indigo-500 border-indigo-600'
else if (type === '音乐') return 'border-pink-500 bg-pink-600'
else return 'border-purple-600 bg-purple-600'
}
@@ -230,6 +266,7 @@ async function querySubscribedSeasons() {
// 查询当前媒体是否已入库
async function handleCheckExists() {
if (props.media?.type === '音乐') return
try {
const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
@@ -268,7 +305,9 @@ function goMediaDetail(isHovering = false) {
if (isHovering) {
resetMediaCardDetailState()
if (props.media?.collection_id) {
if (props.media?.type === '音乐') {
router.push(buildMusicDetailRoute(props.media))
} else if (props.media?.collection_id) {
// 跳转到合集列表
router.push({
path: `/browse/tmdb/collection/${props.media?.collection_id}`,
@@ -392,13 +431,80 @@ function setupIntersectionObserver() {
}
}
// 媒体占位图标:电影/电视剧/音乐各自使用对应图标,缺失封面时统一渲染图标 + 底色占位
const placeholderIcon = computed(() => {
switch (props.media?.type) {
case '音乐':
return 'mdi-album'
case '电视剧':
return 'mdi-television-classic'
case '电影':
default:
return 'mdi-movie-open-outline'
}
})
// 计算图片地址
const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
if (imageLoadError.value) return ''
if (props.media?.type === '音乐') {
// 音乐封面优先使用 cover_urlListenBrainz/MusicBrainz 统计接口返回),回退到 poster_path
const musicCover = props.media?.cover_url || props.media?.poster_path
if (!musicCover) return ''
return getDisplayImageUrl(musicCover, globalSettings.GLOBAL_IMAGE_CACHE)
}
const url = props.media?.poster_path?.replace('original', 'w500')
if (!url) return ''
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
})
const hasLoadedRealPoster = computed(
() => Boolean(getImgUrl.value) && isImageLoaded.value && hasCompletedPosterReveal.value && !imageLoadError.value,
)
/** 为当前图片实例绑定不可跨媒体复用的成功与失败回调。 */
const imageRequest = computed(() => {
const revision = imageRequestRevision.value
const src = getImgUrl.value
const usesFallback = !src
return {
handleError: () => {
if (revision !== imageRequestRevision.value || usesFallback) return
resetPosterRevealState()
isImageLoaded.value = false
imageLoadError.value = true
imageRequestRevision.value += 1
},
handleLoad: () => {
if (revision !== imageRequestRevision.value) return
resetPosterRevealState()
isImageLoaded.value = true
if (!usesFallback) {
posterRevealFallbackTimer = window.setTimeout(
() => completePosterReveal(revision, usesFallback),
POSTER_REVEAL_FALLBACK_MS,
)
}
},
handleReveal: (event: TransitionEvent) => {
if (
event.propertyName !== 'opacity' ||
!(event.target instanceof Element) ||
!event.target.matches('.v-img__img')
) {
return
}
completePosterReveal(revision, usesFallback)
},
key: revision,
src,
}
})
// 获取媒体类型文本
function getMediaTypeText(type: string | undefined) {
if (!type) return ''
@@ -425,13 +531,21 @@ watch(isSubscribed, subscribed => {
})
watch(
() => props.media,
() => {
[() => props.media, () => props.media?.poster_path, () => props.media?.cover_url],
([media], [previousMedia]) => {
imageRequestRevision.value += 1
resetPosterRevealState()
isImageLoaded.value = false
imageLoadError.value = false
// 海报补全只重置图片代际;详情和订阅状态绑定媒体对象身份。
if (media === previousMedia) return
resetMediaCardDetailState()
subscribedSeasons.value = []
subscribedSeasonModes.value = {}
subscribedSeasonsLoaded.value = false
},
{ flush: 'sync' },
)
onMounted(() => {
@@ -445,6 +559,7 @@ onActivated(resetMediaCardDetailState)
onDeactivated(resetMediaCardDetailState)
onBeforeUnmount(() => {
resetPosterRevealState()
resetMediaCardDetailState()
document.removeEventListener('pointerdown', handleDocumentPointerDown)
observer.value?.disconnect()
@@ -461,6 +576,7 @@ onBeforeUnmount(() => {
:height="props.height"
:width="props.width"
:ripple="false"
:data-glass-optical-mode="hasLoadedRealPoster ? 'excluded' : undefined"
class="app-hover-lift-card outline-none ring-gray-500 media-card"
:class="{
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
@@ -469,13 +585,19 @@ onBeforeUnmount(() => {
}"
@click.stop="handleMediaCardClick(hover.isHovering)"
>
<div v-if="!getImgUrl" class="media-card-placeholder d-flex align-center justify-center">
<VIcon :icon="placeholderIcon" size="64" color="medium-emphasis" />
</div>
<VImg
:key="imageRequest.key"
v-else
aspect-ratio="2/3"
:src="getImgUrl"
:src="imageRequest.src"
class="object-cover aspect-w-2 aspect-h-3"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
@load="imageRequest.handleLoad"
@error="imageRequest.handleError"
@transitionend="imageRequest.handleReveal"
>
<template #placeholder>
<div class="w-full h-full">
@@ -542,6 +664,12 @@ onBeforeUnmount(() => {
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
>
<VIcon v-if="props.media?.source === 'anilist'" color="#02a9ff" icon="mdi-alpha-a-circle" size="24" />
<VIcon
v-else-if="props.media?.source === 'musicbrainz'"
color="#eb743b"
icon="mdi-music-circle"
size="24"
/>
<VImg v-else cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
</VAvatar>
</VCard>
@@ -554,6 +682,13 @@ onBeforeUnmount(() => {
inline-size: 100%;
}
.media-card-placeholder {
aspect-ratio: 2 / 3;
background: rgba(var(--v-theme-on-surface), 0.08);
block-size: 100%;
inline-size: 100%;
}
.media-card-title {
font-size: 1.125rem;
line-height: 1.25rem;
+260 -39
View File
@@ -2,12 +2,27 @@
import type { PropType } from 'vue'
import type { Context } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { formatMusicDuration, getMusicAudioSpecItems } from '@/utils/music'
const { t } = useI18n()
const globalSettingsStore = useGlobalSettingsStore()
// 输入参数
defineProps({
const props = defineProps({
context: Object as PropType<Context>,
})
// 音乐元数据使用 title,影视元数据使用 name,识别结果卡片统一兼容两种字段。
const recognizedName = computed(() => props.context?.meta_info?.name || props.context?.meta_info?.title)
// 是否为音乐识别结果
const isMusic = computed(() => props.context?.media_info?.type === '音乐' || props.context?.meta_info?.type === '音乐')
// 音乐封面加载失败时保留方形唱片占位,避免弹窗信息结构塌缩。
const musicCoverLoadError = ref(false)
// TMDB图片转换为w500大小
function getW500Image(url = '') {
if (!url) return ''
@@ -21,21 +36,132 @@ function openTmdbPage(type: string, tmdbId: number) {
const url = `https://www.themoviedb.org/${type === '电影' ? 'movie' : 'tv'}/${tmdbId}`
window.open(url, '_blank')
}
// 音乐封面优先使用方形封面,仅 W500 图片处理对TMDB类 URL 生效
const rawMusicCover = computed(() =>
getW500Image(props.context?.media_info?.cover_url || props.context?.media_info?.poster_path || ''),
)
const musicCover = computed(() =>
getDisplayImageUrl(rawMusicCover.value, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
)
// 影视识别海报与音乐封面使用同一全局图片缓存开关。
const mediaPoster = computed(() =>
getDisplayImageUrl(
getW500Image(props.context?.media_info?.poster_path || ''),
globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
),
)
// 音乐艺术家优先使用标准识别结果,远端未命中时回退到文件标签。
const musicArtist = computed(() => {
const mediaInfo = props.context?.media_info
const metaInfo = props.context?.meta_info
return (
mediaInfo?.artist || mediaInfo?.artists?.join(' / ') || metaInfo?.artist || metaInfo?.artists?.join(' / ') || ''
)
})
// 专辑艺术家只在与歌曲艺术家不同时单独展示。
const musicAlbumArtist = computed(() => {
const albumArtist = props.context?.media_info?.album_artist || props.context?.meta_info?.album_artist
return albumArtist && albumArtist !== musicArtist.value ? albumArtist : ''
})
// 音乐专辑优先使用标准识别结果,远端未命中时回退到文件标签。
const musicAlbum = computed(() => props.context?.media_info?.album || props.context?.meta_info?.album || '')
// 音乐实体类型转换为面向用户的本地化名称。
const musicTypeLabel = computed(() => {
const labels = {
album: t('music.entityAlbum'),
artist: t('music.entityArtist'),
recording: t('music.entityRecording'),
}
const musicType = props.context?.media_info?.music_type
return musicType ? labels[musicType] : ''
})
// 音乐标题下方汇总发行时间、时长和曲序,信息密度与影视识别结果保持一致。
const musicSummary = computed(() => {
const mediaInfo = props.context?.media_info
const metaInfo = props.context?.meta_info
const values: string[] = []
const releaseDate = mediaInfo?.release_date || mediaInfo?.year || metaInfo?.year
if (releaseDate) values.push(String(releaseDate))
const duration = formatMusicDuration(mediaInfo?.duration || metaInfo?.duration)
if (duration) values.push(duration)
const trackNumber = mediaInfo?.track_number || metaInfo?.track_number
const totalTracks = mediaInfo?.total_tracks || metaInfo?.total_tracks
if (trackNumber) {
const track = totalTracks ? `${trackNumber}/${totalTracks}` : String(trackNumber)
values.push(`${t('music.track')} ${track}`)
}
return values.join(' · ')
})
// 本地文件实际参数优先,资源标题识别场景回退到标准音乐信息。
const musicAudioChips = computed(() => {
const metaInfo = props.context?.meta_info
const mediaInfo = props.context?.media_info
return getMusicAudioSpecItems({
audio_format: metaInfo?.audio_format || mediaInfo?.audio_format,
bit_depth: metaInfo?.bit_depth || mediaInfo?.bit_depth,
sample_rate: metaInfo?.sample_rate || mediaInfo?.sample_rate,
bitrate: metaInfo?.bitrate || mediaInfo?.bitrate,
})
})
// 音乐详情外链
const musicLink = computed(() => props.context?.media_info?.detail_link || '')
// 打开音乐详情外链
function openMusicDetail() {
if (musicLink.value) window.open(musicLink.value, '_blank')
}
watch(musicCover, () => {
musicCoverLoadError.value = false
})
</script>
<template>
<div v-show="context">
<VCol>
<div
v-if="context?.meta_info?.name"
class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
>
<div v-if="context?.media_info?.poster_path" class="ma-auto">
<div v-if="recognizedName" class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
<div v-if="isMusic" class="ma-auto recognized-music-cover">
<VImg
v-if="musicCover && !musicCoverLoadError"
width="10rem"
aspect-ratio="1"
class="object-cover rounded-lg ring-1 ring-gray-500"
:src="musicCover"
cover
eager
@error="musicCoverLoadError = true"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover" />
</div>
</template>
</VImg>
<VSheet
v-else
width="10rem"
height="10rem"
rounded="lg"
class="d-flex align-center justify-center music-cover-placeholder"
>
<VIcon icon="mdi-album" size="64" color="medium-emphasis" />
</VSheet>
</div>
<div v-else-if="mediaPoster" class="ma-auto">
<VImg
width="10rem"
aspect-ratio="2/3"
class="object-cover aspect-w-2 aspect-h-3 rounded-lg ring-1 ring-gray-500"
:src="getW500Image(context?.media_info?.poster_path)"
:src="mediaPoster"
cover
>
<template #placeholder>
@@ -48,12 +174,26 @@ function openTmdbPage(type: string, tmdbId: number) {
<div class="flex-grow">
<VCardItem class="pb-1">
<div class="text-center text-md-left text-h6 font-weight-bold line-clamp-2 overflow-hidden text-ellipsis">
{{ context?.media_info?.title || context?.meta_info?.name }}
{{ context?.media_info?.title || recognizedName }}
<span v-if="context?.meta_info?.season_episode" class="text-sm text-medium-emphasis align-top">
{{ context?.meta_info?.season_episode }}
</span>
</div>
<VCardSubtitle class="text-center text-md-left">
<div v-if="isMusic" class="music-recognition-summary text-center text-md-left">
<div v-if="musicArtist" class="text-body-1 font-weight-medium text-high-emphasis">
{{ musicArtist }}
</div>
<div v-if="musicAlbum" class="text-body-2 text-medium-emphasis">
{{ t('music.album') }}{{ musicAlbum }}
</div>
<div v-if="musicAlbumArtist" class="text-body-2 text-medium-emphasis">
{{ t('music.albumArtist') }}{{ musicAlbumArtist }}
</div>
<div v-if="musicSummary" class="text-body-2 text-medium-emphasis">
{{ musicSummary }}
</div>
</div>
<VCardSubtitle v-else class="text-center text-md-left">
{{ context?.media_info?.year || context?.meta_info?.year }}
</VCardSubtitle>
</VCardItem>
@@ -74,50 +214,115 @@ function openTmdbPage(type: string, tmdbId: number) {
>
{{ context?.media_info?.type || context?.meta_info?.type }}
</VChip>
<!-- 音乐实体类型 -->
<VChip
v-if="isMusic && context?.media_info?.music_type"
variant="elevated"
class="me-1 mb-1 text-white bg-blue-500"
>
{{ musicTypeLabel }}
</VChip>
<!-- 音乐分类 -->
<VChip
v-if="isMusic && (context?.media_info?.category || context?.media_info?.album_type)"
variant="elevated"
class="me-1 mb-1 text-white bg-purple-500"
>
{{ context?.media_info?.category || context?.media_info?.album_type }}
</VChip>
<!-- 风格 -->
<VChip
v-for="genre in isMusic ? context?.media_info?.genres : []"
:key="genre"
variant="elevated"
class="me-1 mb-1 text-white bg-purple-500"
>
{{ genre }}
</VChip>
<!-- 音频技术参数 -->
<VChip
v-for="audioChip in isMusic ? musicAudioChips : []"
:key="audioChip"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ audioChip }}
</VChip>
<!-- ISRC -->
<VChip
v-if="isMusic && context?.media_info?.isrc"
variant="elevated"
class="me-1 mb-1 text-white bg-red-500"
>
{{ context?.media_info?.isrc }}
</VChip>
<!-- MusicBrainz 外链 -->
<VChip
v-if="isMusic && musicLink"
variant="elevated"
class="me-1 mb-1 text-white bg-green-500"
@click="openMusicDetail"
>
详情
</VChip>
<!-- 二级分类 -->
<VChip v-if="context?.media_info?.category" variant="elevated" class="me-1 mb-1 text-white bg-blue-500">
<VChip
v-if="!isMusic && context?.media_info?.category"
variant="elevated"
class="me-1 mb-1 text-white bg-blue-500"
>
{{ context?.media_info?.category }}
</VChip>
<!-- TMDBID -->
<VChip
v-if="context?.media_info?.tmdb_id"
v-if="!isMusic && context?.media_info?.tmdb_id"
variant="elevated"
class="me-1 mb-1 text-white bg-green-500"
@click="openTmdbPage(context?.media_info?.type || '', context?.media_info?.tmdb_id)"
>
{{ context?.media_info?.tmdb_id }}
</VChip>
<!-- meta_info -->
<VChip v-if="context?.meta_info?.web_source" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
{{ context?.meta_info?.web_source }}
</VChip>
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
{{ context?.meta_info?.edition }}
</VChip>
<VChip v-if="context?.meta_info?.resource_pix" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
{{ context?.meta_info?.resource_pix }}
</VChip>
<VChip
v-if="context?.meta_info?.video_encode"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ context?.meta_info?.video_encode }}
</VChip>
<VChip
v-if="context?.meta_info?.audio_encode"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ context?.meta_info?.audio_encode }}
</VChip>
<VChip v-if="context?.meta_info?.resource_team" variant="elevated" class="me-1 mb-1 text-white bg-cyan-500">
{{ context?.meta_info?.resource_team }}
</VChip>
<!-- meta_info音乐不显示影视资源信息 -->
<template v-if="!isMusic">
<VChip
v-if="context?.meta_info?.web_source"
variant="elevated"
class="me-1 mb-1 text-white bg-purple-500"
>
{{ context?.meta_info?.web_source }}
</VChip>
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
{{ context?.meta_info?.edition }}
</VChip>
<VChip v-if="context?.meta_info?.resource_pix" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
{{ context?.meta_info?.resource_pix }}
</VChip>
<VChip
v-if="context?.meta_info?.video_encode"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ context?.meta_info?.video_encode }}
</VChip>
<VChip
v-if="context?.meta_info?.audio_encode"
variant="elevated"
class="me-1 mb-1 text-white bg-orange-500"
>
{{ context?.meta_info?.audio_encode }}
</VChip>
<VChip
v-if="context?.meta_info?.resource_team"
variant="elevated"
class="me-1 mb-1 text-white bg-cyan-500"
>
{{ context?.meta_info?.resource_team }}
</VChip>
</template>
</VCardItem>
</div>
</div>
<VAlert v-if="!context?.meta_info?.name" icon="mdi-alert-circle-outline"> 识别失败无法识别到有效信息 </VAlert>
<VAlert v-if="!recognizedName" icon="mdi-alert-circle-outline"> 识别失败无法识别到有效信息 </VAlert>
</VCol>
<VExpansionPanels v-show="!isNullOrEmptyObject(context?.meta_info.apply_words)">
<VExpansionPanel>
@@ -141,3 +346,19 @@ function openTmdbPage(type: string, tmdbId: number) {
</VExpansionPanels>
</div>
</template>
<style scoped>
.recognized-music-cover {
flex: 0 0 auto;
}
.music-cover-placeholder {
background: rgba(var(--v-theme-on-surface), 0.08);
}
.music-recognition-summary {
display: grid;
gap: 0.2rem;
margin-block-start: 0.35rem;
}
</style>
+6 -5
View File
@@ -48,8 +48,8 @@ const infoItems = ref([
amount: '0',
},
{
avatar: 'mdi-account',
title: t('common.user'),
avatar: 'mdi-music-box-multiple',
title: t('mediaType.music'),
amount: '0',
},
])
@@ -87,6 +87,7 @@ const getIcon = computed(() => {
case 'plex':
return getLogoUrl('plex')
default:
// Navidrome 等未内置图标的服务器统一回退到通用媒体服务器图标
return getLogoUrl('mediaserver')
}
})
@@ -118,9 +119,9 @@ async function loadMediaStatistic() {
amount: res.tv_count.toLocaleString(),
},
{
avatar: 'mdi-account',
title: t('common.user'),
amount: res.user_count.toLocaleString(),
avatar: 'mdi-music-box-multiple',
title: t('mediaType.music'),
amount: (res.music_count || 0).toLocaleString(),
},
]
}
+76
View File
@@ -0,0 +1,76 @@
<script lang="ts" setup>
import type { MusicArtistInfo } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { buildMusicArtistRoute, getMusicArtistSubtitle } from '@/utils/music'
const router = useRouter()
const globalSettingsStore = useGlobalSettingsStore()
const props = defineProps({
artist: Object as PropType<MusicArtistInfo>,
width: String,
})
// 艺术家图片加载失败后回退到占位图标
const imageLoadError = ref(false)
const rawImageUrl = computed(() => props.artist?.image_url || props.artist?.poster_path || '')
const imageUrl = computed(() =>
getDisplayImageUrl(rawImageUrl.value, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
)
const showImage = computed(() => Boolean(imageUrl.value) && !imageLoadError.value)
const subtitle = computed(() => getMusicArtistSubtitle(props.artist))
/** 打开艺术家详情页。 */
function goArtistDetail() {
if (!props.artist?.media_id) return
router.push(buildMusicArtistRoute(props.artist.media_id, props.artist.name, props.artist.source))
}
</script>
<template>
<VHover>
<template #default="hover">
<div v-bind="hover.props" class="music-artist-card-hover-area">
<VCard
:width="props.width"
class="app-hover-lift-card"
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
@click.stop="goArtistDetail"
>
<div class="music-artist-card relative cursor-pointer">
<div style="padding-block-end: 150%">
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
<VAvatar size="100" class="music-artist-card-avatar">
<VImg v-if="showImage" :src="imageUrl" cover @error="imageLoadError = true" />
<VIcon v-else icon="mdi-account-music" size="48" color="medium-emphasis" />
</VAvatar>
</div>
<div class="w-full truncate text-center font-bold">{{ props.artist?.name }}</div>
<div class="overflow-hidden whitespace-normal text-center text-sm text-ellipsis line-clamp-2">
{{ subtitle }}
</div>
</div>
</div>
</div>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
.music-artist-card-hover-area {
inline-size: 100%;
}
.music-artist-card-avatar {
background: rgba(var(--v-theme-on-surface), 0.08);
}
.music-artist-card {
background-image: linear-gradient(45deg, rgba(var(--v-theme-background), 0.3), rgba(var(--v-theme-surface), 0.3) 60%);
}
</style>
+450
View File
@@ -0,0 +1,450 @@
<script lang="ts" setup>
import type { MediaInfo } from '@/api/types'
import { useGlobalSettingsStore, useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { useMediaSubscribe } from '@/composables/useMediaSubscribe'
import { getCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache'
import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch'
import {
buildMusicAlbumRoute,
buildMusicArtistRoute,
buildMusicDetailRoute,
buildMusicResourceRoute,
formatMusicDuration,
formatMusicAudioSpecs,
getMusicArtistLinks,
getMusicKey,
} from '@/utils/music'
const { t } = useI18n()
const router = useRouter()
const props = defineProps({
music: Object as PropType<MediaInfo>,
})
const userStore = useUserStore()
const globalSettingsStore = useGlobalSettingsStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
const isActionableMedia = computed(() => props.music?.music_type !== 'artist')
// 封面加载失败后改用专辑占位图标
const imageLoadError = ref(false)
// 当前订阅状态
const isSubscribed = ref(false)
// 可点击跳转的艺术家
const artistLinks = computed(() => getMusicArtistLinks(props.music))
// 音乐实体标签和图标
const entityMeta = computed(() => {
const entities = {
album: { icon: 'mdi-album', label: t('music.entityAlbum') },
artist: { icon: 'mdi-account-music', label: t('music.entityArtist') },
recording: { icon: 'mdi-music-note', label: t('music.entityRecording') },
}
return entities[props.music?.music_type || 'recording']
})
// 卡片上展示的元数据,只保留 MusicBrainz 实际返回的字段
const metaItems = computed(() => {
const items: { hideOnNarrow?: boolean; icon: string; label: string }[] = []
const category = props.music?.category || props.music?.album_type
if (category) items.push({ hideOnNarrow: true, icon: 'mdi-label-outline', label: category })
const releaseDate = props.music?.release_date || props.music?.year?.toString()
if (releaseDate) items.push({ icon: 'mdi-calendar-blank-outline', label: releaseDate })
const duration = formatMusicDuration(props.music?.duration)
if (duration) items.push({ icon: 'mdi-clock-outline', label: duration })
const audioSpecs = formatMusicAudioSpecs(props.music)
if (audioSpecs) items.push({ icon: 'mdi-waveform', label: audioSpecs })
if (props.music?.track_number)
items.push({
hideOnNarrow: true,
icon: 'mdi-counter',
label: t('music.trackNumber', { number: props.music.track_number }),
})
if (props.music?.listen_count)
items.push({
hideOnNarrow: true,
icon: 'mdi-chart-line',
label: t('music.listenCountValue', { count: props.music.listen_count.toLocaleString() }),
})
return items
})
const rawCoverUrl = computed(() => props.music?.cover_url || props.music?.poster_path || '')
const coverUrl = computed(() =>
getDisplayImageUrl(rawCoverUrl.value, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
)
const showCover = computed(() => Boolean(coverUrl.value) && !imageLoadError.value)
/** 生成订阅状态缓存键。 */
function getSubscribeStatusKey() {
return `${props.music ? getMusicKey(props.music) : ''}::all`
}
const subscribeActions = useMediaSubscribe({
media: () => props.music,
canSubscribe: () => canSubscribe.value,
isSubscribed,
getSubscribeStatusKey,
})
const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
props.music ? buildMusicResourceRoute(props.music, sites) : undefined,
)
/** 查询当前音乐是否已订阅,用于决定心形图标是实心还是空心。 */
async function checkSubscribeStatus() {
if (!isActionableMedia.value || !canSubscribe.value || !props.music?.media_id) return
try {
isSubscribed.value = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(), () =>
subscribeActions.checkSubscribe(null),
)
} catch (error) {
console.error(error)
}
}
/** 打开音乐详情页。 */
function goDetail() {
if (!props.music) return
router.push(buildMusicDetailRoute(props.music))
}
/** 打开所属专辑详情页。 */
function goAlbum() {
if (!props.music?.album_id) return
router.push(buildMusicAlbumRoute(props.music.album_id, props.music.album, props.music.source))
}
/** 打开艺术家详情页。 */
function goArtist(artistId?: string, name?: string) {
if (!artistId) return
router.push(buildMusicArtistRoute(artistId, name, props.music?.source))
}
watch(
() => coverUrl.value,
() => {
imageLoadError.value = false
},
)
watch(() => props.music?.media_id, checkSubscribeStatus)
onMounted(checkSubscribeStatus)
</script>
<template>
<VHover>
<template #default="hover">
<div v-bind="hover.props" class="music-card-hover-area h-100">
<VCard
class="music-card app-hover-lift-card h-100 cursor-pointer"
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
@click="goDetail"
>
<div class="music-card-content">
<div class="music-card-cover-shell">
<VImg v-if="showCover" :src="coverUrl" cover class="music-card-cover" @error="imageLoadError = true">
<template #placeholder>
<VSkeletonLoader class="h-100" />
</template>
</VImg>
<VIcon v-else :icon="entityMeta.icon" size="44" color="medium-emphasis" />
<VChip :prepend-icon="entityMeta.icon" size="x-small" variant="flat" class="music-card-entity">
{{ entityMeta.label }}
</VChip>
</div>
<div class="music-card-body">
<div class="music-card-heading">
<div class="music-card-title" :title="props.music?.title">{{ props.music?.title }}</div>
<VChip v-if="props.music?.version" size="x-small" variant="tonal" class="music-card-version">
{{ props.music.version }}
</VChip>
</div>
<div v-if="props.music?.music_type !== 'artist'" class="music-card-supporting text-medium-emphasis">
<VIcon icon="mdi-account-music" size="16" />
<div class="music-card-artists">
<template v-if="artistLinks.length">
<template v-for="(artist, index) in artistLinks" :key="`${artist.name}-${index}`">
<span v-if="index > 0"> / </span>
<a
v-if="artist.id"
class="music-card-link"
role="link"
tabindex="0"
@click.stop="goArtist(artist.id, artist.name)"
@keydown.enter.stop="goArtist(artist.id, artist.name)"
>{{ artist.name }}</a
>
<span v-else>{{ artist.name }}</span>
</template>
</template>
<span v-else>{{ t('common.unknown') }}</span>
</div>
</div>
<div
v-if="props.music?.album && props.music?.music_type !== 'album'"
class="music-card-supporting text-medium-emphasis"
>
<VIcon icon="mdi-album" size="16" />
<div class="music-card-album">
<span>{{ t('music.album') }}</span>
<a
v-if="props.music.album_id"
class="music-card-link"
role="link"
tabindex="0"
@click.stop="goAlbum"
@keydown.enter.stop="goAlbum"
>{{ props.music.album }}</a
>
<span v-else>{{ props.music.album }}</span>
</div>
</div>
<div class="music-card-footer">
<div class="music-card-meta">
<VChip
v-for="item in metaItems"
:key="`${item.icon}-${item.label}`"
:prepend-icon="item.icon"
:class="{ 'music-card-meta-item--narrow-optional': item.hideOnNarrow }"
size="x-small"
variant="tonal"
>
{{ item.label }}
</VChip>
</div>
<div class="music-card-actions">
<IconBtn
v-if="canSubscribe && isActionableMedia"
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
:color="isSubscribed ? 'error' : 'medium-emphasis'"
:aria-label="isSubscribed ? t('music.unsubscribe') : t('music.subscribe')"
:title="isSubscribed ? t('music.unsubscribe') : t('music.subscribe')"
size="small"
variant="tonal"
@click.stop="subscribeActions.handleSubscribe()"
/>
<IconBtn
v-if="canSearch && isActionableMedia"
icon="mdi-magnify"
color="primary"
:aria-label="t('music.searchResources')"
:title="t('music.searchResources')"
size="small"
variant="tonal"
@click.stop="openMusicSiteSearch"
/>
</div>
</div>
</div>
</div>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
.music-card-hover-area {
inline-size: 100%;
}
.music-card {
min-block-size: 144px;
overflow: hidden;
}
.music-card-content {
display: grid;
block-size: 100%;
gap: 1rem;
grid-template-columns: 112px minmax(0, 1fr);
padding: 1rem;
}
.music-card-cover-shell {
position: relative;
display: flex;
overflow: hidden;
align-items: center;
align-self: start;
justify-content: center;
aspect-ratio: 1;
background: rgba(var(--v-theme-on-surface), 0.06);
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 8px;
inline-size: 112px;
}
.music-card-cover {
block-size: 100%;
inline-size: 100%;
transition: transform 0.3s ease;
}
.music-card.app-hover-lift-card--hovering .music-card-cover {
transform: scale(1.035);
}
.music-card-entity {
position: absolute;
z-index: 1;
background: rgba(var(--v-theme-surface), 0.88) !important;
color: rgb(var(--v-theme-on-surface)) !important;
inset-block-end: 0.5rem;
inset-inline-start: 0.5rem;
max-inline-size: calc(100% - 1rem);
}
.music-card-body {
display: flex;
flex-direction: column;
min-inline-size: 0;
}
.music-card-heading {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.music-card-title {
overflow: hidden;
display: -webkit-box;
color: rgb(var(--v-theme-on-surface));
font-size: 1rem;
font-weight: 600;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
letter-spacing: 0;
line-height: 1.4;
}
.music-card-version {
flex: 0 0 auto;
margin-block-start: 0.125rem;
}
.music-card-supporting {
display: flex;
align-items: center;
font-size: 0.8125rem;
gap: 0.375rem;
line-height: 1.35;
margin-block-start: 0.35rem;
}
.music-card-artists,
.music-card-album {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.music-card-link {
color: inherit;
cursor: pointer;
font-weight: 500;
text-decoration: none;
transition: color 0.2s ease;
}
.music-card-link:hover {
color: rgb(var(--v-theme-primary));
}
.music-card-footer {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
margin-block-start: auto;
padding-block-start: 0.65rem;
}
.music-card-meta,
.music-card-actions {
display: flex;
align-items: center;
}
.music-card-meta {
flex: 1 1 auto;
flex-wrap: wrap;
gap: 0.3rem;
min-inline-size: 0;
}
.music-card-actions {
flex: 0 0 auto;
gap: 0.25rem;
}
@media (width <= 600px) {
.music-card {
min-block-size: 120px;
}
.music-card-content {
gap: 0.75rem;
grid-template-columns: 88px minmax(0, 1fr);
padding: 0.75rem;
}
.music-card-cover-shell {
inline-size: 88px;
}
.music-card-body {
position: relative;
}
.music-card-heading {
align-items: center;
min-block-size: 40px;
padding-inline-end: 5.5rem;
}
.music-card-entity {
inset-block-end: 0.375rem;
inset-inline-start: 0.375rem;
max-inline-size: calc(100% - 0.75rem);
}
.music-card-footer {
display: block;
padding-block-start: 0.5rem;
}
.music-card-actions {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
}
.music-card-actions :deep(.v-btn) {
block-size: 40px;
inline-size: 40px;
}
}
@media (width <= 360px) {
.music-card-meta-item--narrow-optional {
display: none;
}
}
</style>
+7 -7
View File
@@ -2,6 +2,8 @@
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { openMediaServerItem } from '@/utils/appDeepLink'
import { useGlobalSettingsStore } from '@/stores'
import { getProxyImageUrl } from '@/utils/imageUtils'
// 输入参数
const props = defineProps({
@@ -9,6 +11,7 @@ const props = defineProps({
width: String,
height: String,
})
const globalSettingsStore = useGlobalSettingsStore()
// 图片是否加载完成
const imageLoaded = ref(false)
@@ -52,13 +55,10 @@ 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
return getProxyImageUrl(image, {
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
useCookies: props.media?.use_cookies,
})
})
/**
+7 -4
View File
@@ -1,7 +1,8 @@
<script lang="ts" setup>
import api from '@/api'
import type { ApiResponse, Plugin } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
import { useGlobalSettingsStore } from '@/stores'
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
import { isNullOrEmptyObject } from '@/@core/utils'
import { formatDownloadCount } from '@/@core/utils/formatters'
@@ -23,6 +24,7 @@ const props = defineProps({
height: String,
count: Number,
})
const globalSettingsStore = useGlobalSettingsStore()
// 定义触发的自定义事件
const emit = defineEmits(['install'])
@@ -81,9 +83,10 @@ const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) return getLogoUrl('plugin')
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.plugin_icon,
)}&cache=true`
return getProxyImageUrl(props.plugin.plugin_icon, {
proxy: true,
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
})
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
+12 -7
View File
@@ -3,13 +3,14 @@ import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import api from '@/api'
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
import { formatDownloadCount } from '@/@core/utils/formatters'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
import { useGlobalSettingsStore } from '@/stores'
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
@@ -32,6 +33,7 @@ const props = defineProps({
default: false,
},
})
const globalSettingsStore = useGlobalSettingsStore()
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save', 'actionDone', 'rating'])
@@ -193,19 +195,22 @@ const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) return getLogoUrl('plugin')
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.plugin_icon,
)}&cache=true`
return getProxyImageUrl(props.plugin.plugin_icon, {
proxy: true,
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
})
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
// 插件作者头像路径
const authorPath: Ref<string> = computed(() => {
if (!props.plugin?.author_url) return ''
// 网络图片则使用代理后返回
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.author_url + '.png',
)}&cache=true`
return getProxyImageUrl(`${props.plugin.author_url}.png`, {
proxy: true,
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
})
})
// 重置插件
+4 -1
View File
@@ -4,6 +4,8 @@ import { useConfirm } from '@/composables/useConfirm'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const PluginFolderRenameDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderRenameDialog.vue'))
const PluginFolderSettingsDialog = defineAsyncComponent(
@@ -36,6 +38,7 @@ const props = defineProps({
default: false,
},
})
const globalSettingsStore = useGlobalSettingsStore()
// 定义触发的自定义事件
const emit = defineEmits(['open', 'delete', 'rename', 'update-config'])
@@ -66,7 +69,7 @@ const defaultGradient =
// 计算背景图片
const backgroundImage = computed(() => {
return props.folderConfig.background
return getDisplayImageUrl(props.folderConfig.background || '', globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
})
// 计算背景渐变
+7 -6
View File
@@ -3,6 +3,8 @@ import type { PropType } from 'vue'
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { openMediaServerItem } from '@/utils/appDeepLink'
import { useGlobalSettingsStore } from '@/stores'
import { getProxyImageUrl } from '@/utils/imageUtils'
// 输入参数
const props = defineProps({
@@ -10,6 +12,7 @@ const props = defineProps({
width: String,
height: String,
})
const globalSettingsStore = useGlobalSettingsStore()
// 图片加载状态
const isImageLoaded = ref(false)
@@ -34,12 +37,10 @@ function getChipColor(type: string) {
const getImgUrl = computed(() => {
if (imageLoadError.value) return noImage
const image = props.media?.image || ''
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
const use_cookies = props.media?.use_cookies
if (use_cookies) {
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
}
return url
return getProxyImageUrl(image, {
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
useCookies: props.media?.use_cookies,
})
})
// 跳转播放
+5 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { getLogoUrl } from '@/utils/imageUtils'
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
import { useGlobalSettingsStore } from '@/stores'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import api from '@/api'
@@ -33,6 +34,7 @@ const cardProps = defineProps({
default: false,
},
})
const globalSettingsStore = useGlobalSettingsStore()
// 定义触发的自定义事件
const emit = defineEmits(['update', 'remove', 'refresh-stats'])
@@ -62,11 +64,12 @@ async function getSiteIcon() {
}
try {
siteIcon.value = await getCachedSiteIcon(siteId, async () => {
const icon = await getCachedSiteIcon(siteId, async () => {
const response = await api.get(`site/icon/${siteId}`)
return response?.data?.icon || defaultSiteIcon
})
siteIcon.value = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
} catch (error) {
siteIcon.value = defaultSiteIcon
console.error(error)
+10 -3
View File
@@ -6,6 +6,7 @@ import alipan_png from '@images/misc/alipan.webp'
import u115_png from '@images/misc/u115.png'
import rclone_png from '@images/misc/rclone.png'
import alist_png from '@images/misc/openlist.svg'
import alistgo_png from '@images/misc/alist.svg'
import custom_png from '@images/misc/database.png'
import smb_png from '@images/misc/smb.png'
import api from '@/api'
@@ -58,6 +59,7 @@ function openStorageDialog() {
u115: U115AuthDialog,
rclone: RcloneConfigDialog,
alist: AlistConfigDialog,
alistgo: AlistConfigDialog,
smb: SmbConfigDialog,
}
@@ -67,9 +69,12 @@ function openStorageDialog() {
}
const dialog = dialogMap[props.storage.type] || StorageCustomConfigDialog
const dialogProps = dialog === StorageCustomConfigDialog
? { storage: props.storage }
: { conf: props.storage.config || {} }
const dialogProps =
dialog === StorageCustomConfigDialog
? { storage: props.storage }
: dialog === AlistConfigDialog
? { conf: props.storage.config || {}, type: props.storage.type }
: { conf: props.storage.config || {} }
openSharedDialog(
dialog,
@@ -94,6 +99,8 @@ const getIcon = computed(() => {
return rclone_png
case 'alist':
return alist_png
case 'alistgo':
return alistgo_png
case 'smb':
return smb_png
default:
+156 -10
View File
@@ -11,6 +11,7 @@ import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { buildMusicDetailRoute, formatMusicAudioSpecs, formatMusicBitrate } from '@/utils/music'
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
@@ -56,6 +57,10 @@ const $toast = useToast()
// 图片是否加载完成
const imageLoaded = ref(false)
// 背景图或海报加载失败时使用统一占位图,避免订阅卡片留下空白图片区。
const backdropLoadError = ref(false)
const posterLoadError = ref(false)
// 当前的订阅状态
const subscribeState = ref<string>(props.media?.state ?? 'P')
@@ -131,6 +136,44 @@ const subscribeProgressText = computed(() => {
return `${downloadedEpisode.value} / ${total}`
})
// 音乐订阅始终展示实体类型;旧数据缺少 music_type 时按既有单曲语义兼容。
const musicSubscribeMeta = computed(() => {
if (props.media?.type !== '音乐') return null
const currentSpecs = formatMusicAudioSpecs({
audio_format: props.media.current_audio_format,
bit_depth: props.media.current_bit_depth,
sample_rate: props.media.current_sample_rate,
bitrate: props.media.current_bitrate,
})
const selectedQuality = {
hires: t('music.audioQualityHires'),
'hires|lossless': t('music.audioQualityLossless'),
lossy: t('music.audioQualityLossy'),
}[props.media.audio_quality || '']
const selectedFormat = props.media.audio_format
? props.media.audio_format === 'DSD|FLAC|ALAC|APE|WAV|AIFF|PCM'
? t('music.audioFormatLossless')
: props.media.audio_format.replaceAll('|', '/')
: ''
const selectedBitrate = props.media.min_bitrate ? `${formatMusicBitrate(props.media.min_bitrate)}` : ''
const qualityText = currentSpecs || [selectedQuality, selectedFormat, selectedBitrate].filter(Boolean).join(' · ')
if (props.media.music_type === 'album') {
const trackCount = props.media.total_tracks
const entityText = trackCount
? `${t('music.entityAlbum')} · ${t('music.trackCount', { count: trackCount })}`
: t('music.entityAlbum')
return {
icon: 'mdi-album',
text: [entityText, qualityText].filter(Boolean).join(' · '),
}
}
return {
icon: 'mdi-music-note',
text: [t('music.entityRecording'), qualityText].filter(Boolean).join(' · '),
}
})
// 订阅卡片 hover 文案:
// - 普通订阅:「已下载 X · 共 Y 集」
// - 洗版订阅:「已下载 X · 已洗版 N · 共 Y 集」
@@ -154,6 +197,17 @@ function imageLoadHandler() {
imageLoaded.value = true
}
// 背景图加载失败后直接切换占位图,避免同一失效地址在 poster fallback 中重复请求。
function backdropErrorHandler() {
backdropLoadError.value = true
imageLoaded.value = true
}
// 海报加载失败后使用占位图,保留卡片布局和可点击区域。
function posterErrorHandler() {
posterLoadError.value = true
}
// 进度条 model 段百分比:洗版订阅表示"已洗版"占比(亮段),普通订阅表示"已下载"占比
function getPercentage() {
const total = props.media?.total_episode || 0
@@ -283,6 +337,10 @@ function getMediaId() {
// 查看媒体详情
async function viewMediaDetail() {
if (props.media?.type === '音乐') {
router.push(buildMusicDetailRoute(props.media))
return
}
router.push({
path: '/media',
query: {
@@ -360,6 +418,7 @@ const dropdownItems = computed(() => [
prependIcon: 'mdi-file-document-outline',
click: viewSubscribeFiles,
},
show: props.media?.type !== '音乐',
},
{
title: t('common.unsubscribe'),
@@ -389,18 +448,57 @@ watch(
},
)
// 切换订阅记录时重新尝试加载图片,避免复用卡片组件后沿用旧的失败状态。
watch(
() => [props.media?.id, props.media?.backdrop, props.media?.poster],
() => {
imageLoaded.value = false
backdropLoadError.value = false
posterLoadError.value = false
},
)
// 媒体占位图标:电影/电视剧/音乐各自使用对应图标,缺失封面时统一渲染图标 + 底色占位
const placeholderIcon = computed(() => {
switch (props.media?.type) {
case '音乐':
return 'mdi-album'
case '电视剧':
return 'mdi-television-classic'
case '电影':
default:
return 'mdi-movie-open-outline'
}
})
// 计算backdrop图片地址
const backdropUrl = computed(() => {
if (backdropLoadError.value) return ''
const url = props.media?.backdrop || props.media?.poster
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
if (!url) return ''
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
})
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
if (posterLoadError.value) return ''
const url = props.media?.poster || props.media?.backdrop
if (!url) return ''
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
})
// 缺失封面时展示媒体占位背景(图标 + 底色),对齐音乐媒体卡片
const showPlaceholder = computed(() => !backdropUrl.value)
// 占位背景出现时同步标记图片已加载,让卡片正文与徽标正常渲染
watch(
showPlaceholder,
show => {
if (show) imageLoaded.value = true
},
{ immediate: true },
)
// 订阅编辑保存
function onSubscribeEditSave() {
emit('save')
@@ -478,7 +576,22 @@ function handleCardClick() {
</IconBtn>
</div>
<template #image v-if="display.smAndUp.value">
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<div
v-if="showPlaceholder"
class="subscribe-card-placeholder subscribe-card-placeholder--cover d-flex align-center justify-center relative"
>
<VIcon :icon="placeholderIcon" size="64" color="medium-emphasis" />
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</div>
<VImg
v-else
:src="backdropUrl || posterUrl"
aspect-ratio="3/2"
cover
@load="imageLoadHandler"
@error="backdropErrorHandler"
position="top"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
@@ -492,12 +605,21 @@ function handleCardClick() {
<template v-if="display.xs.value">
<div class="subscribe-card-mobile-media">
<div
v-if="showPlaceholder"
class="subscribe-card-placeholder d-flex align-center justify-center relative"
>
<VIcon :icon="placeholderIcon" size="64" color="medium-emphasis" />
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</div>
<VImg
v-else
:src="backdropUrl || posterUrl"
:aspect-ratio="16 / 9"
cover
position="top"
@load="imageLoadHandler"
@error="backdropErrorHandler"
>
<template #placeholder>
<VSkeletonLoader class="h-full w-full" />
@@ -550,8 +672,11 @@ function handleCardClick() {
:data-subscribe-state-icon="compactStateDisplay.icon"
size="16"
/>
<span v-if="subscribeProgressText" class="subscribe-card-mobile-progress-text">
{{ subscribeProgressText }}
<span
v-if="subscribeProgressText || musicSubscribeMeta"
class="subscribe-card-mobile-progress-text"
>
{{ subscribeProgressText || musicSubscribeMeta?.text }}
</span>
</div>
@@ -591,13 +716,13 @@ function handleCardClick() {
</template>
<div v-else>
<VCardText class="flex items-center pt-3 pb-2">
<VCardText class="flex flex-1 items-center pt-3 pb-9">
<div
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
v-if="imageLoaded"
v-if="imageLoaded && posterUrl"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
>
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
<VImg :src="posterUrl" aspect-ratio="2/3" cover @error="posterErrorHandler">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
@@ -615,7 +740,9 @@ function handleCardClick() {
</div>
</div>
</VCardText>
<VCardText class="flex min-w-0 justify-space-between align-center flex-wrap px-3">
<VCardText
class="absolute inset-x-0 bottom-2 z-10 flex min-w-0 justify-space-between align-center flex-wrap px-3"
>
<div class="flex min-w-0 max-w-full align-center">
<VIcon
v-if="props.media?.total_episode && props.sortable"
@@ -638,6 +765,13 @@ function handleCardClick() {
{{ subscribeProgressTooltip }}
</VTooltip>
</div>
<div
v-else-if="musicSubscribeMeta"
class="flex flex-shrink-0 align-center text-subtitle-2 me-2 text-white"
>
<VIcon :icon="musicSubscribeMeta.icon" size="small" class="me-1" />
{{ musicSubscribeMeta.text }}
</div>
<VIcon
v-if="props.media?.username && props.sortable"
icon="mdi-account"
@@ -890,6 +1024,18 @@ function handleCardClick() {
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
/* 缺失封面时的媒体占位背景(图标 + 底色),对齐音乐媒体卡片 */
.subscribe-card-placeholder {
block-size: 100%;
inline-size: 100%;
background: rgba(var(--v-theme-on-surface), 0.08);
}
/* 桌面版占位与图片同高,避免无图时卡片整体塌陷上浮 */
.subscribe-card-placeholder--cover {
aspect-ratio: 3 / 2;
}
/**
* 暂停:降低不透明度表达"已停止活动"
*/
+27 -6
View File
@@ -7,6 +7,8 @@ import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
@@ -18,6 +20,7 @@ const props = defineProps({
subtitle: Object as PropType<SubtitleInfo>,
width: String,
})
const globalSettingsStore = useGlobalSettingsStore()
//
const subtitle = ref(props.subtitle)
@@ -25,7 +28,9 @@ const subtitle = ref(props.subtitle)
//
const siteIcon = ref('')
const isDownloaded = computed(() => Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]))
const isDownloaded = computed(() =>
Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]),
)
//
async function getSiteIcon() {
@@ -35,7 +40,7 @@ async function getSiteIcon() {
}
try {
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
const icon = await getCachedSiteIcon(subtitle.value.site, async () => {
try {
const response = await api.get(`site/icon/${subtitle.value?.site}`)
@@ -45,12 +50,18 @@ async function getSiteIcon() {
return ''
}
})
siteIcon.value = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
} catch (error) {
console.error('Failed to load site icon:', error)
siteIcon.value = ''
}
}
//
const languageIconUrl = computed(() =>
getDisplayImageUrl(subtitle.value?.language_icon || '', globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
)
//
function addDownloadSuccess(url: string) {
markSubtitleDownloaded(url)
@@ -133,8 +144,8 @@ watch(
</VChip>
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
<VImg
v-if="subtitle?.language_icon"
:src="subtitle.language_icon"
v-if="languageIconUrl"
:src="languageIconUrl"
:alt="subtitle.language"
width="14"
height="14"
@@ -163,7 +174,10 @@ watch(
</div>
<div class="d-flex flex-wrap align-center gap-2 mb-2">
<span v-if="subtitle?.pubdate || subtitle?.date_elapsed" class="d-flex align-center text-sm text-medium-emphasis">
<span
v-if="subtitle?.pubdate || subtitle?.date_elapsed"
class="d-flex align-center text-sm text-medium-emphasis"
>
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
</span>
@@ -195,7 +209,14 @@ watch(
<VBtn v-if="subtitle?.report_url" icon size="small" variant="text" color="warning" @click.stop="openReportPage">
<VIcon icon="mdi-alert-outline"></VIcon>
</VBtn>
<VBtn v-if="subtitle?.page_url" icon size="small" variant="text" color="primary" @click.stop="openSubtitleDetail">
<VBtn
v-if="subtitle?.page_url"
icon
size="small"
variant="text"
color="primary"
@click.stop="openSubtitleDetail"
>
<VIcon icon="mdi-information-outline"></VIcon>
</VBtn>
</VCardActions>
+24 -6
View File
@@ -7,6 +7,8 @@ import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
@@ -17,6 +19,7 @@ const { t } = useI18n()
const props = defineProps({
subtitle: Object as PropType<SubtitleInfo>,
})
const globalSettingsStore = useGlobalSettingsStore()
//
const subtitle = ref(props.subtitle)
@@ -24,7 +27,9 @@ const subtitle = ref(props.subtitle)
//
const siteIcon = ref('')
const isDownloaded = computed(() => Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]))
const isDownloaded = computed(() =>
Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]),
)
//
async function getSiteIcon() {
@@ -34,7 +39,7 @@ async function getSiteIcon() {
}
try {
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
const icon = await getCachedSiteIcon(subtitle.value.site, async () => {
try {
const response = await api.get(`site/icon/${subtitle.value?.site}`)
@@ -44,12 +49,18 @@ async function getSiteIcon() {
return ''
}
})
siteIcon.value = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
} catch (error) {
console.error('Failed to load site icon:', error)
siteIcon.value = ''
}
}
//
const languageIconUrl = computed(() =>
getDisplayImageUrl(subtitle.value?.language_icon || '', globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
)
//
async function handleAddDownload() {
openSharedDialog(
@@ -135,8 +146,8 @@ watch(
</VChip>
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
<VImg
v-if="subtitle?.language_icon"
:src="subtitle.language_icon"
v-if="languageIconUrl"
:src="languageIconUrl"
:alt="subtitle.language"
width="14"
height="14"
@@ -153,12 +164,19 @@ watch(
{{ subtitle?.title }}
</div>
<div v-if="subtitle?.description" class="text-body-2 text-medium-emphasis mb-2 break-all" :title="subtitle.description">
<div
v-if="subtitle?.description"
class="text-body-2 text-medium-emphasis mb-2 break-all"
:title="subtitle.description"
>
{{ subtitle.description }}
</div>
<div class="d-flex flex-wrap gap-2 mb-2">
<span v-if="subtitle?.pubdate || subtitle?.date_elapsed" class="d-flex align-center text-sm text-medium-emphasis">
<span
v-if="subtitle?.pubdate || subtitle?.date_elapsed"
class="d-flex align-center text-sm text-medium-emphasis"
>
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
</span>
+5 -1
View File
@@ -7,6 +7,8 @@ import { isNullOrEmptyObject } from '@/@core/utils'
import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
const TorrentMoreSourcesDialog = defineAsyncComponent(() => import('../dialog/TorrentMoreSourcesDialog.vue'))
@@ -18,6 +20,7 @@ const props = defineProps({
width: String,
height: String,
})
const globalSettingsStore = useGlobalSettingsStore()
//
const torrent = ref(props.torrent?.torrent_info)
@@ -51,7 +54,7 @@ async function getSiteIcon(site: number | undefined) {
if (!site) return
try {
siteIcons.value[site] = await getCachedSiteIcon(site, async () => {
const icon = await getCachedSiteIcon(site, async () => {
try {
const response = await api.get(`site/icon/${site}`)
@@ -61,6 +64,7 @@ async function getSiteIcon(site: number | undefined) {
return ''
}
})
siteIcons.value[site] = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
} catch (error) {
console.error(error)
siteIcons.value[site] = ''
+4 -1
View File
@@ -6,6 +6,8 @@ import type { Context } from '@/api/types'
import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
@@ -13,6 +15,7 @@ const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownlo
const props = defineProps({
torrent: Object as PropType<Context>,
})
const globalSettingsStore = useGlobalSettingsStore()
//
const torrent = ref(props.torrent?.torrent_info)
@@ -48,7 +51,7 @@ async function getSiteIcon(site: number | undefined) {
})
// Context
if (torrent.value?.site === site) {
siteIcon.value = icon
siteIcon.value = getDisplayImageUrl(icon, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
}
} catch (error) {
console.error('Failed to load site icon:', error)
+168 -164
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import api from '@/api'
import { Subscribe, User } from '@/api/types'
import { useUserStore } from '@/stores'
import { useGlobalSettingsStore, useUserStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import avatar1 from '@images/avatars/avatar-1.png'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
@@ -35,6 +36,7 @@ const props = defineProps({
const display = useDisplay()
const isMobile = computed(() => display.mdAndDown.value)
const globalSettingsStore = useGlobalSettingsStore()
// ID
const currentLoginUserId = computed(() => useUserStore().userID)
@@ -64,6 +66,11 @@ const displayName = computed(() => {
return nickname || props.user.name
})
// data URL
const avatarUrl = computed(() =>
getDisplayImageUrl(props.user.avatar || avatar1, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE),
)
//
async function fetchSubscriptions() {
try {
@@ -130,180 +137,177 @@ onMounted(() => {
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div class="user-card-hover-area h-full">
<VCard
:class="[
'app-hover-lift-card',
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
]"
:class="['app-hover-lift-card', !props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '']"
class="user-card flex flex-column h-full"
@click="editUser"
>
<div class="user-card__body flex-grow flex-grow-1">
<!-- 用户头像和基本信息 -->
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
<template v-slot:prepend>
<div class="position-relative mr-4">
<VAvatar
size="72"
rounded="lg"
:class="[
user.is_superuser ? 'admin-avatar' : 'border-4 bg-surface',
!user.is_active ? 'grayscale-50 opacity-90' : '',
]"
:style="user.is_superuser ? 'border: 4px solid rgba(var(--v-theme-warning), 0.3);' : ''"
>
<VImg :src="user.avatar || avatar1" :alt="user.name" />
<div
v-if="!user.is_active"
class="position-absolute d-flex align-center justify-center rounded-lg bg-surface-variant opacity-20"
style="inset: 0"
>
<VIcon icon="mdi-account-lock" color="white" />
</div>
</VAvatar>
<div v-if="user.is_superuser" class="admin-crown">
<VIcon icon="mdi-crown" color="warning" />
</div>
</div>
</template>
<VCardTitle class="pa-0 d-flex flex-column">
<div class="d-flex flex-column mb-1">
<div class="d-flex align-center">
<span
<div class="user-card__body flex-grow flex-grow-1">
<!-- 用户头像和基本信息 -->
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
<template v-slot:prepend>
<div class="position-relative mr-4">
<VAvatar
size="72"
rounded="lg"
:class="[
'text-h6 font-weight-bold truncate',
user.is_superuser ? 'text-warning' : '',
!user.is_active ? 'text-medium-emphasis' : '',
user.is_superuser ? 'admin-avatar' : 'border-4 bg-surface',
!user.is_active ? 'grayscale-50 opacity-90' : '',
]"
:style="user.is_superuser ? 'border: 4px solid rgba(var(--v-theme-warning), 0.3);' : ''"
>
{{ displayName }}
<VIcon
v-if="user.nickname || user.settings?.nickname"
icon="mdi-format-quote-close"
size="x-small"
color="info"
class="animate-pulse"
/>
</span>
</div>
<div class="d-flex flex-wrap gap-1 overflow-auto">
<VChip v-if="user.is_superuser" size="x-small" color="error" variant="outlined" label>{{
t('user.admin')
}}</VChip>
<VChip v-else size="x-small" label>{{ t('user.normal') }}</VChip>
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" label>
{{ user.is_active ? t('user.active') : t('user.inactive') }}
</VChip>
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" label>2FA</VChip>
</div>
</div>
<!-- 移动端订阅数据信息 -->
<div v-if="isMobile" class="d-flex gap-5 mt-2">
<div class="d-flex align-center">
<VIcon size="x-small" icon="mdi-movie-outline" color="primary" class="mr-1" />
<span class="text-body-2">{{ movieSubscriptions }}</span>
</div>
<div class="d-flex align-center">
<VIcon size="x-small" icon="mdi-television-classic" color="primary" class="mr-1" />
<span class="text-body-2">{{ tvShowSubscriptions }}</span>
</div>
</div>
</VCardTitle>
<!-- 头部操作按钮 -->
<template v-slot:append>
<div :class="['d-flex', isMobile ? 'position-absolute top-2 right-2' : '']">
<VBtn
icon
size="small"
:color="user.is_superuser ? 'warning' : 'primary'"
variant="text"
class="opacity-70 hover:opacity-100 transition-opacity"
@click.stop="editUser"
>
<VIcon icon="mdi-pencil" />
</VBtn>
<VBtn
v-if="props.user.id != currentLoginUserId && currentUserIsSuperuser"
icon
size="small"
color="error"
variant="text"
class="opacity-70 hover:opacity-100 transition-opacity"
@click.stop="removeUser"
>
<VIcon icon="mdi-delete" />
</VBtn>
</div>
</template>
</VCardItem>
<!-- 权限显示 -->
<div v-if="!user.is_superuser && user.permissions" class="d-flex flex-wrap gap-1 px-7 pb-3">
<VChip v-if="user.permissions.discovery" size="x-small" color="purple" variant="outlined" label>
{{ t('dialog.userAddEdit.permissions.discovery') }}
</VChip>
<VChip v-if="user.permissions.search" size="x-small" color="blue" variant="outlined" label>
{{ t('dialog.userAddEdit.permissions.search') }}
</VChip>
<VChip v-if="user.permissions.subscribe" size="x-small" color="green" variant="outlined" label>
{{ t('dialog.userAddEdit.permissions.subscribe') }}
</VChip>
<VChip v-if="user.permissions.manage" size="x-small" color="orange" variant="outlined" label>
{{ t('dialog.userAddEdit.permissions.manage') }}
</VChip>
</div>
</div>
<!-- 独立的邮箱显示 -->
<VDivider class="mx-4" />
<div class="user-card__footer">
<VCardText class="d-flex align-center py-2 px-4 text-medium-emphasis">
<VIcon icon="mdi-email-outline" size="small" color="primary" class="mr-2 opacity-70" />
<span class="text-body-2 truncate">{{ user.email || t('user.noEmail') }}</span>
</VCardText>
<!-- PC端显示订阅统计信息 -->
<VCardText v-if="!isMobile" class="px-4 pt-0 pb-4">
<div rounded="lg" class="d-flex justify-space-around">
<div class="d-flex align-center gap-3">
<VAvatar
tile
rounded="lg"
size="large"
class="mr-1"
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
>
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-movie-outline" size="20" />
<VImg :src="avatarUrl" :alt="user.name" />
<div
v-if="!user.is_active"
class="position-absolute d-flex align-center justify-center rounded-lg bg-surface-variant opacity-20"
style="inset: 0"
>
<VIcon icon="mdi-account-lock" color="white" />
</div>
</VAvatar>
<div v-if="user.is_superuser" class="admin-crown">
<VIcon icon="mdi-crown" color="warning" />
</div>
</VAvatar>
<div class="d-flex flex-column">
<span class="text-lg text-medium-emphasis font-weight-bold">{{ movieSubscriptions }}</span>
<span class="text-caption text-medium-emphasis">{{ t('user.movieSubscriptions') }}</span>
</div>
</div>
<div class="d-flex align-center gap-3">
<VAvatar
tile
rounded="lg"
size="large"
class="mr-1"
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
>
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-television-classic" />
</template>
<VCardTitle class="pa-0 d-flex flex-column">
<div class="d-flex flex-column mb-1">
<div class="d-flex align-center">
<span
:class="[
'text-h6 font-weight-bold truncate',
user.is_superuser ? 'text-warning' : '',
!user.is_active ? 'text-medium-emphasis' : '',
]"
>
{{ displayName }}
<VIcon
v-if="user.nickname || user.settings?.nickname"
icon="mdi-format-quote-close"
size="x-small"
color="info"
class="animate-pulse"
/>
</span>
</div>
<div class="d-flex flex-wrap gap-1 overflow-auto">
<VChip v-if="user.is_superuser" size="x-small" color="error" variant="outlined" label>{{
t('user.admin')
}}</VChip>
<VChip v-else size="x-small" label>{{ t('user.normal') }}</VChip>
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" label>
{{ user.is_active ? t('user.active') : t('user.inactive') }}
</VChip>
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" label>2FA</VChip>
</div>
</VAvatar>
<div class="d-flex flex-column">
<span class="text-lg text-medium-emphasis">{{ tvShowSubscriptions }}</span>
<span class="text-caption text-medium-emphasis">{{ t('user.tvSubscriptions') }}</span>
</div>
</div>
<!-- 移动端订阅数据信息 -->
<div v-if="isMobile" class="d-flex gap-5 mt-2">
<div class="d-flex align-center">
<VIcon size="x-small" icon="mdi-movie-outline" color="primary" class="mr-1" />
<span class="text-body-2">{{ movieSubscriptions }}</span>
</div>
<div class="d-flex align-center">
<VIcon size="x-small" icon="mdi-television-classic" color="primary" class="mr-1" />
<span class="text-body-2">{{ tvShowSubscriptions }}</span>
</div>
</div>
</VCardTitle>
<!-- 头部操作按钮 -->
<template v-slot:append>
<div :class="['d-flex', isMobile ? 'position-absolute top-2 right-2' : '']">
<VBtn
icon
size="small"
:color="user.is_superuser ? 'warning' : 'primary'"
variant="text"
class="opacity-70 hover:opacity-100 transition-opacity"
@click.stop="editUser"
>
<VIcon icon="mdi-pencil" />
</VBtn>
<VBtn
v-if="props.user.id != currentLoginUserId && currentUserIsSuperuser"
icon
size="small"
color="error"
variant="text"
class="opacity-70 hover:opacity-100 transition-opacity"
@click.stop="removeUser"
>
<VIcon icon="mdi-delete" />
</VBtn>
</div>
</template>
</VCardItem>
<!-- 权限显示 -->
<div v-if="!user.is_superuser && user.permissions" class="d-flex flex-wrap gap-1 px-7 pb-3">
<VChip v-if="user.permissions.discovery" size="x-small" color="purple" variant="outlined" label>
{{ t('dialog.userAddEdit.permissions.discovery') }}
</VChip>
<VChip v-if="user.permissions.search" size="x-small" color="blue" variant="outlined" label>
{{ t('dialog.userAddEdit.permissions.search') }}
</VChip>
<VChip v-if="user.permissions.subscribe" size="x-small" color="green" variant="outlined" label>
{{ t('dialog.userAddEdit.permissions.subscribe') }}
</VChip>
<VChip v-if="user.permissions.manage" size="x-small" color="orange" variant="outlined" label>
{{ t('dialog.userAddEdit.permissions.manage') }}
</VChip>
</div>
</VCardText>
</div>
</div>
<!-- 独立的邮箱显示 -->
<VDivider class="mx-4" />
<div class="user-card__footer">
<VCardText class="d-flex align-center py-2 px-4 text-medium-emphasis">
<VIcon icon="mdi-email-outline" size="small" color="primary" class="mr-2 opacity-70" />
<span class="text-body-2 truncate">{{ user.email || t('user.noEmail') }}</span>
</VCardText>
<!-- PC端显示订阅统计信息 -->
<VCardText v-if="!isMobile" class="px-4 pt-0 pb-4">
<div rounded="lg" class="d-flex justify-space-around">
<div class="d-flex align-center gap-3">
<VAvatar
tile
rounded="lg"
size="large"
class="mr-1"
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
>
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-movie-outline" size="20" />
</div>
</VAvatar>
<div class="d-flex flex-column">
<span class="text-lg text-medium-emphasis font-weight-bold">{{ movieSubscriptions }}</span>
<span class="text-caption text-medium-emphasis">{{ t('user.movieSubscriptions') }}</span>
</div>
</div>
<div class="d-flex align-center gap-3">
<VAvatar
tile
rounded="lg"
size="large"
class="mr-1"
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
>
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-television-classic" />
</div>
</VAvatar>
<div class="d-flex flex-column">
<span class="text-lg text-medium-emphasis">{{ tvShowSubscriptions }}</span>
<span class="text-caption text-medium-emphasis">{{ t('user.tvSubscriptions') }}</span>
</div>
</div>
</div>
</VCardText>
</div>
</VCard>
</div>
</template>
@@ -37,8 +37,15 @@ function downloading(overrides: Partial<DownloadingCardInfo> = {}): DownloadingC
}
/** 使用生产插件和指定下载器渲染下载任务卡片。 */
async function renderCard(info = downloading(), downloaderName = 'qb-main') {
async function renderCard(info = downloading(), downloaderName = 'qb-main', globalImageCache = false) {
return renderWithProviders(DownloadingCard, {
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
initialized: true,
loading: false,
},
},
props: { downloaderName, info },
})
}
@@ -63,6 +70,7 @@ describe('DownloadingCard display and pause state', () => {
expect(screen.getByText('下载任务标题')).toBeInTheDocument()
expect(screen.getByText(/1 小时/)).toBeInTheDocument()
expect(container.querySelector('.v-card-text .v-progress-linear')).toBeInTheDocument()
expect(container.querySelector('.downloading-card__progress')).toHaveClass('downloading-card__progress--active')
})
it('falls back to the task name and season string when media recognition is incomplete', async () => {
@@ -93,12 +101,16 @@ describe('DownloadingCard display and pause state', () => {
}),
)
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电影')
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent('电影')
expect(container.querySelector('.downloading-card__progress')).toHaveTextContent('100%')
expect(container.querySelector('.downloading-card__progress')).toHaveTextContent('--')
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('3 MiB/s')
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('0 B/s')
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('0.00 B')
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent('0.00 B')
expect(container.querySelector('.downloading-card__speed--download')).not.toHaveClass(
'downloading-card__speed--idle',
)
expect(container.querySelector('.downloading-card__speed--upload')).toHaveClass('downloading-card__speed--idle')
for (const [type, label] of [
['电影', '电影'],
@@ -110,7 +122,7 @@ describe('DownloadingCard display and pause state', () => {
downloaderName: 'qb-main',
info: downloading({ media: { title: `${type}标题`, type }, progress: -1 }),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent(label)
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent(label)
expect(container.querySelector('.downloading-card__progress')).not.toBeInTheDocument()
}
@@ -124,32 +136,55 @@ describe('DownloadingCard display and pause state', () => {
upspeed: '1 MiB/s',
}),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电影')
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent('电影')
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('0 B/s')
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('1 MiB/s')
expect(container.querySelector('.downloading-card__speed--download')).toHaveClass('downloading-card__speed--idle')
expect(container.querySelector('.downloading-card__speed--upload')).not.toHaveClass('downloading-card__speed--idle')
await rerender({
downloaderName: 'qb-main',
info: downloading({ media: { season: 'S02' }, name: '', season_episode: undefined, title: '任务标题回退' }),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电视剧')
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent('电视剧')
expect(container.querySelector('.downloading-card__title')).toHaveTextContent('任务标题回退')
await rerender({ downloaderName: '', info: undefined })
expect(container.querySelector('.downloading-card__title')).toHaveTextContent('未知')
expect(container.querySelectorAll('.downloading-card__chips .v-chip')).toHaveLength(1)
expect(container.querySelectorAll('.downloading-card__meta-item')).toHaveLength(1)
})
it('prefers explicit site names and safely reduces tracker URLs to hostnames', async () => {
it('uses download state as the progress emphasis', async () => {
const { container, rerender } = await renderCard()
expect(container.querySelector('.downloading-card__progress')).toHaveClass('downloading-card__progress--active')
expect(screen.getByRole('progressbar', { name: '下载' })).toBeInTheDocument()
await rerender({
downloaderName: 'qb-main',
info: downloading({ state: 'stopped' }),
})
expect(container.querySelector('.downloading-card__progress')).toHaveClass('downloading-card__progress--paused')
expect(screen.getByRole('progressbar', { name: '暂停' })).toBeInTheDocument()
})
it('keeps torrent size visible while resolving explicit site names and tracker hostnames', async () => {
const { container, rerender } = await renderCard(downloading({ site_name: ' M-Team ' }))
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('M-Team')
const heading = container.querySelector('.downloading-card__heading')!
const metaContainer = container.querySelector('.downloading-card__meta')!
const metaItems = [...metaContainer.querySelectorAll('.downloading-card__meta-item')]
expect(heading.nextElementSibling).toBe(metaContainer)
expect(metaItems.map(item => item.textContent?.trim())).toEqual(['电视剧', '1.00 KB', 'M-Team'])
expect(metaContainer.querySelector('.v-chip')).not.toBeInTheDocument()
await rerender({
downloaderName: 'qb-main',
info: downloading({ media: { ...downloading().media, site_name: '媒体站点' }, site_name: undefined }),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('媒体站点')
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent('媒体站点')
await rerender({
downloaderName: 'qb-main',
@@ -159,7 +194,7 @@ describe('DownloadingCard display and pause state', () => {
trackers: ['', 'not-a-url', 'https://www.tracker.example.com/announce?passkey=secret'],
}),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('tracker.example.com')
expect(container.querySelector('.downloading-card__meta')).toHaveTextContent('tracker.example.com')
expect(container).not.toHaveTextContent('passkey')
expect(container).not.toHaveTextContent('secret')
})
@@ -178,6 +213,30 @@ describe('DownloadingCard display and pause state', () => {
expect(container.querySelector('.downloading-card__image')).not.toBeInTheDocument()
})
it('uses the global backend cache for recognized poster images', async () => {
const ImageStub = defineComponent({
name: 'VImg',
inheritAttrs: false,
props: { src: String },
setup: props => () => h('img', { src: props.src }),
})
const { container } = await renderWithProviders(DownloadingCard, {
global: { stubs: { VImg: ImageStub } },
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: true },
initialized: true,
loading: false,
},
},
props: { downloaderName: 'qb-main', info: downloading() },
})
const image = container.querySelector<HTMLImageElement>('img')
expect(image?.src).toContain('system/cache/image?url=')
expect(image?.src).toContain(encodeURIComponent(downloading().media.poster))
})
it('hides a failed poster and retries when the task receives a new poster', async () => {
const VImgStub = defineComponent({
name: 'VImg',
+214 -32
View File
@@ -8,7 +8,7 @@ import { querySubscribeByMediaHandler, subscribeListHandler } from '@tests/suppo
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { defineComponent, h } from 'vue'
import { defineComponent, h, reactive, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
@@ -27,7 +27,8 @@ vi.mock('@/router', () => ({
}))
const API_BASE_URL = 'http://localhost/api/v1/'
const siteListUrl = new URL('site/', API_BASE_URL).href
const movieSiteListUrl = new URL('site/media/movie', API_BASE_URL).href
const tvSiteListUrl = new URL('site/media/tv', API_BASE_URL).href
const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_URL).href
let intersectionObservers: IntersectionObserverMock[] = []
@@ -89,6 +90,50 @@ interface RenderCardOptions {
superUser?: boolean
}
interface ControlledImageRequest {
/** 模拟当前 VImg 请求失败。 */
fail: () => void
/** 模拟当前 VImg 请求成功。 */
load: () => void
/** 模拟当前 VImg 的 opacity 淡入完成。 */
reveal: () => void
/** 当前 VImg 实例发起的图片地址。 */
src: string
}
/** 创建可保留旧实例回调的图片替身,用于验证媒体复用时的迟到事件隔离。 */
function createControlledImageStub(requests: ControlledImageRequest[]) {
return defineComponent({
name: 'VImg',
emits: ['error', 'load'],
props: { src: String },
setup(props, { emit, slots }) {
const src = props.src ?? ''
const imageElement = ref<HTMLImageElement | null>(null)
const request = {
fail: () => emit('error', src),
load: () => emit('load', src),
reveal: () => {
const event = new Event('transitionend', { bubbles: true }) as TransitionEvent
Object.defineProperty(event, 'propertyName', { value: 'opacity' })
imageElement.value?.dispatchEvent(event)
},
src,
}
requests.push(request)
return () =>
h('div', { 'data-src': src }, [
h('img', {
ref: imageElement,
class: 'v-img__img',
}),
slots.default?.(),
])
},
})
}
/** 使用指定媒体信息和用户权限渲染媒体卡片。 */
async function renderCard(media: MediaInfo, options: RenderCardOptions = {}) {
return renderWithProviders(MediaCard, {
@@ -142,9 +187,13 @@ function getStatusObservers() {
}
/** 安装站点列表及已选站点的搜索请求处理器。 */
function installSearchHandlers(sites: Record<string, unknown>[], selected: number[]) {
function installSearchHandlers(
sites: Record<string, unknown>[],
selected: number[],
mediaType: 'movie' | 'tv' = 'movie',
) {
server.use(
http.get(siteListUrl, () => HttpResponse.json(sites)),
http.get(mediaType === 'tv' ? tvSiteListUrl : movieSiteListUrl, () => HttpResponse.json(sites)),
http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })),
)
}
@@ -304,8 +353,63 @@ describe('MediaCard', () => {
await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith({ path, query }))
})
it('opens music detail and skips media-library existence checks', async () => {
const media = createMediaInfo({
artist: '周杰伦',
media_id: 'recording-1',
mediaid_prefix: 'musicbrainz',
source: 'musicbrainz',
title: '晴天',
tmdb_id: undefined,
type: '音乐',
})
const subscribeRequest = vi.fn<(url: URL) => void>()
const existsRequest = vi.fn<(url: URL) => void>()
server.use(
querySubscribeByMediaHandler('musicbrainz:recording-1', {}, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest),
)
const { container } = await renderCard(media)
getStatusObservers()[0]?.trigger()
await waitFor(() => expect(subscribeRequest).toHaveBeenCalledOnce())
expect((subscribeRequest.mock.calls[0][0] as URL).searchParams.get('music_type')).toBe('recording')
expect(existsRequest).not.toHaveBeenCalled()
await fireEvent.mouseEnter(getHoverArea(container))
await waitFor(() => expect(getCard(container)).toHaveClass('app-hover-lift-card--hovering'))
await fireEvent.click(getCard(container))
await waitFor(() =>
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/music/detail',
query: {
source: 'musicbrainz',
mediaid: 'recording-1',
title: '晴天',
},
}),
)
})
it('uses an album placeholder instead of the movie fallback image for music without a cover', async () => {
const media = createMediaInfo({
media_id: 'recording-2',
source: 'musicbrainz',
poster_path: undefined,
title: '无封面歌曲',
tmdb_id: undefined,
type: '音乐',
})
const { container } = await renderCard(media)
expect(container.querySelector('.media-card-placeholder .v-icon')).not.toBeNull()
expect(container.querySelector('img[src*="no-image"]')).toBeNull()
})
it('routes directly to resource search when no active sites are available', async () => {
installSearchHandlers([], [3, 5])
installSearchHandlers([], [3, 5], 'tv')
const media = createMediaInfo({ season: 4, title: '直接搜索剧集', tmdb_id: 9501, type: '电视剧' })
const { container } = await renderCard(media)
@@ -331,7 +435,7 @@ describe('MediaCard', () => {
it('falls back to global search when site settings cannot provide active selections', async () => {
server.use(
http.get(siteListUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
http.get(movieSiteListUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
http.get(selectedSitesUrl, () => HttpResponse.json({ success: true })),
)
const media = createMediaInfo({ title: '站点失败搜索', tmdb_id: 9503 })
@@ -390,7 +494,7 @@ describe('MediaCard', () => {
it('opens active sites with an empty selection when the saved setting fails', async () => {
server.use(
http.get(siteListUrl, () =>
http.get(movieSiteListUrl, () =>
HttpResponse.json([
{
domain: 'fallback.example',
@@ -414,17 +518,22 @@ describe('MediaCard', () => {
expect(dialogProps.selected).toEqual([])
})
it('loads matching TV seasons before opening the subscription dialog', async () => {
const media = createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' })
it('preserves loaded TV seasons when the same media receives a new poster', async () => {
const media = reactive(createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' }))
const subscribeListRequest = vi.fn<(url: URL) => void>()
server.use(
querySubscribeByMediaHandler('tmdb:9551', { id: 81, season: 2 }),
mediaExistsHandler({ data: { item: {} }, success: false }),
subscribeListHandler([
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
{ id: 84, tmdbid: 9551, type: '电' },
]),
subscribeListHandler(
[
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
{ id: 84, tmdbid: 9551, type: '电影' },
],
200,
subscribeListRequest,
),
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: { best_version: 0 } }, success: true }),
),
@@ -443,6 +552,19 @@ describe('MediaCard', () => {
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
subscribedSeasons: [1, 3],
})
expect(subscribeListRequest).toHaveBeenCalledOnce()
media.poster_path = '/original/updated.jpg'
mocks.openSharedDialog.mockClear()
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
expect(subscribeListRequest).toHaveBeenCalledOnce()
const [, updatedDialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
expect(updatedDialogProps).toMatchObject({
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
subscribedSeasons: [1, 3],
})
})
it('matches custom media IDs when collecting subscribed TV seasons', async () => {
@@ -525,7 +647,7 @@ describe('MediaCard', () => {
expect(dialogProps).toMatchObject({ subscribedSeasons: expected })
})
it('updates image badges on load and falls back after an image error', async () => {
it('updates image badges on load and shows the typed placeholder after an image error', async () => {
const media = createMediaInfo({
poster_path: '/original/poster.jpg',
source: 'themoviedb',
@@ -533,35 +655,95 @@ describe('MediaCard', () => {
type: '电视剧',
vote_average: 8.6,
})
const VImgStub = defineComponent({
name: 'VImg',
emits: ['error', 'load'],
props: { src: String },
/** 渲染可主动触发图片成功和失败事件的测试替身。 */
setup(props, { emit, slots }) {
return () =>
h('div', { 'data-src': props.src }, [
h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }),
h('button', { 'aria-label': '图片加载失败', onClick: () => emit('error') }),
slots.default?.(),
])
},
})
const requests: ControlledImageRequest[] = []
const VImgStub = createControlledImageStub(requests)
const { container } = await renderWithProviders(MediaCard, {
props: { media, width: '9rem' },
initialState: { user: { superUser: true } },
global: { stubs: { VImg: VImgStub } },
})
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
requests[0].load()
await waitFor(() => expect(container.querySelector('.media-card')).toHaveClass('ring-1'))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
requests[0].reveal()
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
expect(container).toHaveTextContent('TV')
expect(container).toHaveTextContent('8.6')
await fireEvent.click(container.querySelector('[aria-label="图片加载失败"]') as HTMLElement)
requests[0].fail()
await waitFor(() =>
expect(container.querySelector('.media-card-title')?.parentElement).not.toHaveStyle({ display: 'none' }),
)
await waitFor(() => expect(container.querySelector('.media-card-placeholder')).not.toBeNull())
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
expect(getCard(container)).not.toHaveClass('ring-1')
expect(requests.some(request => request.src.includes('no-image'))).toBe(false)
})
it('keeps placeholder-only cards inside the renderer without issuing an image request', async () => {
const requests: ControlledImageRequest[] = []
const { container } = await renderWithProviders(MediaCard, {
props: { media: createMediaInfo({ poster_path: undefined, tmdb_id: 9553 }), width: '9rem' },
initialState: { user: { superUser: true } },
global: { stubs: { VImg: createControlledImageStub(requests) } },
})
expect(requests).toHaveLength(0)
expect(container.querySelector('.media-card-placeholder')).not.toBeNull()
expect(getCard(container)).not.toHaveClass('ring-1')
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
})
it('excludes music cards from the renderer after the cover finishes revealing', async () => {
const requests: ControlledImageRequest[] = []
const { container } = await renderWithProviders(MediaCard, {
props: {
media: createMediaInfo({ cover_url: 'https://example.com/cover.jpg', poster_path: undefined, type: '音乐' }),
width: '9rem',
},
initialState: { user: { superUser: true } },
global: { stubs: { VImg: createControlledImageStub(requests) } },
})
expect(requests).toHaveLength(1)
requests[0].load()
requests[0].reveal()
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
})
it('ignores a previous poster load after the card is reused for another media item', async () => {
const requests: ControlledImageRequest[] = []
const mediaA = createMediaInfo({ poster_path: '/original/a.jpg', title: '媒体 A', tmdb_id: 9554 })
const mediaB = createMediaInfo({ poster_path: '/original/b.jpg', title: '媒体 B', tmdb_id: 9555 })
const { container, rerender } = await renderWithProviders(MediaCard, {
props: { media: mediaA, width: '9rem' },
initialState: { user: { superUser: true } },
global: { stubs: { VImg: createControlledImageStub(requests) } },
})
requests[0].load()
await waitFor(() => expect(getCard(container)).toHaveClass('media-card--image-loaded'))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
await rerender({ media: mediaB, width: '9rem' })
await waitFor(() => expect(requests.some(request => request.src.includes('/w500/b.jpg'))).toBe(true))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
requests[0].reveal()
await Promise.resolve()
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
const currentRequest = requests.find(request => request.src.includes('/w500/b.jpg'))
currentRequest?.load()
await waitFor(() => expect(getCard(container)).toHaveClass('media-card--image-loaded'))
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
currentRequest?.reveal()
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
})
it('renders the AniList source badge after the poster loads', async () => {
@@ -0,0 +1,110 @@
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import { screen } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { describe, expect, it } from 'vitest'
describe('MediaInfoCard', () => {
it('renders music recognition results whose meta identity is stored in title', async () => {
await renderWithProviders(MediaInfoCard, {
props: {
context: {
meta_info: {
album: '完美的一天',
artists: ['孙燕姿'],
audio_format: 'FLAC',
bit_depth: 24,
bitrate: 1411200,
duration: 221,
sample_rate: 48000,
title: '眼泪成诗',
total_tracks: 10,
track_number: 2,
type: '音乐',
},
media_info: {
album: '完美的一天',
artist: '孙燕姿',
category: 'Album',
cover_url: 'https://coverartarchive.org/release-group/album-1/front-500',
duration: 221,
genres: ['华语流行'],
isrc: 'TWA530505002',
music_type: 'recording',
release_date: '2005-10-07',
title: '眼泪成诗',
total_tracks: 10,
track_number: 2,
type: '音乐',
},
},
},
})
expect(screen.getByText('眼泪成诗')).toBeInTheDocument()
expect(screen.getByText('孙燕姿')).toBeInTheDocument()
expect(screen.getByText('专辑:完美的一天')).toBeInTheDocument()
expect(screen.getByText('2005-10-07 · 3:41 · 音轨 2/10')).toBeInTheDocument()
expect(screen.getByText('单曲')).toBeInTheDocument()
expect(screen.getByText('Album')).toBeInTheDocument()
expect(screen.getByText('华语流行')).toBeInTheDocument()
expect(screen.getByText('FLAC')).toBeInTheDocument()
expect(screen.getByText('24-bit')).toBeInTheDocument()
expect(screen.getByText('48 kHz')).toBeInTheDocument()
expect(screen.getByText('1,411 kbps')).toBeInTheDocument()
expect(screen.getByText('TWA530505002')).toBeInTheDocument()
expect(document.querySelector('.v-img__img')).toHaveAttribute(
'src',
'https://coverartarchive.org/release-group/album-1/front-500',
)
expect(screen.queryByText('识别失败,无法识别到有效信息!')).not.toBeInTheDocument()
})
it('does not render music tags for movie contexts', async () => {
await renderWithProviders(MediaInfoCard, {
props: {
context: {
meta_info: {
name: '流浪地球',
type: '电影',
},
media_info: {
title: '流浪地球',
type: '电影',
release_date: '2019-02-05',
genres: ['科幻', '冒险'],
year: 2019,
},
},
},
})
// 影视结果不应出现音乐专属标签:日期、风格、艺术家等
expect(screen.getByText('流浪地球')).toBeInTheDocument()
expect(screen.queryByText('2019-02-05')).not.toBeInTheDocument()
expect(screen.queryByText('科幻')).not.toBeInTheDocument()
expect(screen.queryByText('冒险')).not.toBeInTheDocument()
})
it('uses the global backend cache for recognized music covers', async () => {
const cover = 'https://coverartarchive.org/release-group/album-2/front-500'
const { container } = await renderWithProviders(MediaInfoCard, {
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: true },
initialized: true,
loading: false,
},
},
props: {
context: {
meta_info: { title: '缓存测试歌曲', type: '音乐' },
media_info: { cover_url: cover, title: '缓存测试歌曲', type: '音乐' },
},
},
})
const image = container.querySelector<HTMLImageElement>('.v-img__img')
expect(image?.src).toContain('system/cache/image?url=')
expect(image?.src).toContain(encodeURIComponent(cover))
})
})
@@ -1,4 +1,5 @@
import type { MediaServerPlayItem } from '@/api/types'
import BackdropCard from '@/components/cards/BackdropCard.vue'
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
import PosterCard from '@/components/cards/PosterCard.vue'
import { renderWithProviders } from '@tests/support/render'
@@ -36,4 +37,44 @@ describe.each([
expect(container.querySelector('img')).toHaveAttribute('crossorigin', 'anonymous')
})
it('passes the global cache switch to the required backend proxy', async () => {
const { container } = await renderWithProviders(component, {
global: { stubs: { VImg: VImgStub } },
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: true },
initialized: true,
loading: false,
},
},
props: { media: { ...media, use_cookies: true } },
})
const image = container.querySelector<HTMLImageElement>('img')
expect(image?.src).toContain('system/img/0?imgurl=')
expect(image?.src).toContain('&cache=true')
expect(image?.src).toContain('&use_cookies=true')
})
})
describe('BackdropCard image request mode', () => {
it('passes the global cache switch to the required backend proxy', async () => {
const { container } = await renderWithProviders(BackdropCard, {
global: { stubs: { VImg: VImgStub } },
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: true },
initialized: true,
loading: false,
},
},
props: { media: { ...media, use_cookies: true } },
})
const image = container.querySelector<HTMLImageElement>('img')
expect(image?.src).toContain('system/img/0?imgurl=')
expect(image?.src).toContain('&cache=true')
expect(image?.src).toContain('&use_cookies=true')
})
})
@@ -0,0 +1,92 @@
import type { MediaInfo, MusicArtistInfo } from '@/api/types'
import MusicArtistCard from '@/components/cards/MusicArtistCard.vue'
import MusicCard from '@/components/cards/MusicCard.vue'
import MusicDetailLayout from '@/views/discover/MusicDetailLayout.vue'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h } from 'vue'
import { describe, expect, it } from 'vitest'
const ImageStub = defineComponent({
name: 'VImg',
inheritAttrs: false,
props: { src: String },
setup:
(props, { slots }) =>
() =>
h('img', { src: props.src }, slots.default?.()),
})
const cachedImageState = {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: true },
initialized: true,
loading: false,
},
user: {
permissions: { search: false, subscribe: false },
superUser: false,
},
}
/** 断言组件内所有远程音乐图片都已转换为后端全局缓存地址。 */
function expectCachedImages(container: Element, source: string) {
const images = [...container.querySelectorAll<HTMLImageElement>('img')]
expect(images.length).toBeGreaterThan(0)
for (const image of images) {
expect(image.src).toContain('system/cache/image?url=')
expect(image.src).toContain(encodeURIComponent(source))
}
}
describe('music image cache integration', () => {
it('caches recording and album covers in the dedicated music card', async () => {
const cover = 'https://coverartarchive.org/release-group/album-1/front-500'
const music = {
cover_url: cover,
media_id: 'recording-1',
music_type: 'recording',
source: 'musicbrainz',
title: '测试单曲',
type: '音乐',
} as MediaInfo
const { container } = await renderWithProviders(MusicCard, {
global: { stubs: { VImg: ImageStub } },
initialState: cachedImageState,
props: { music },
})
expectCachedImages(container, cover)
})
it('caches artist portraits in artist cards', async () => {
const portrait = 'https://images.example.com/artists/artist-1.jpg'
const artist = {
image_url: portrait,
media_id: 'artist-1',
music_type: 'artist',
name: '测试艺术家',
source: 'musicbrainz',
type: '音乐',
} as MusicArtistInfo
const { container } = await renderWithProviders(MusicArtistCard, {
global: { stubs: { VImg: ImageStub } },
initialState: cachedImageState,
props: { artist },
})
expectCachedImages(container, portrait)
})
it('caches both background and foreground images in the shared music detail layout', async () => {
const cover = 'https://coverartarchive.org/release-group/album-2/front-500'
const { container } = await renderWithProviders(MusicDetailLayout, {
global: { stubs: { VImg: ImageStub } },
initialState: cachedImageState,
props: { cover, title: '测试专辑' },
})
expectCachedImages(container, cover)
})
})
@@ -96,9 +96,20 @@ const defaultFolderConfig = {
showIcon: true,
}
async function renderFolder(sortable = false, folderConfig: Record<string, unknown> = defaultFolderConfig) {
async function renderFolder(
sortable = false,
folderConfig: Record<string, unknown> = defaultFolderConfig,
globalImageCache = false,
) {
return renderWithProviders(PluginFolderCard, {
global: { stubs: passthroughStubs },
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
initialized: true,
loading: false,
},
},
props: {
folderConfig,
folderName: '媒体工具',
@@ -142,8 +153,8 @@ describe('PluginFolderCard', () => {
expect(defaults.container.querySelector('.plugin-folder-card__bg')).toBeInTheDocument()
defaults.unmount()
const image = await renderFolder(false, { background: 'https://example.com/folder.jpg', showIcon: false })
expect(image.container.querySelector('v-img-stub[src="https://example.com/folder.jpg"]')).toBeInTheDocument()
const image = await renderFolder(false, { background: 'https://example.com/folder.jpg', showIcon: false }, true)
expect(image.container.querySelector('v-img-stub')?.getAttribute('src')).toContain('system/cache/image?url=')
expect(image.container.querySelector('.plugin-folder-card__icon-container')).not.toBeInTheDocument()
})
@@ -138,7 +138,89 @@ describe('SubscribeCard display and progress', () => {
expect((image as HTMLImageElement).src).toContain('system/cache/image?url=')
expect((image as HTMLImageElement).src).toContain(encodeURIComponent(media.backdrop || ''))
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
expect(screen.queryByText(/^\d{1,4} \/ \d{1,4}$/)).not.toBeInTheDocument()
})
it('uses the poster as the background fallback and replaces failed images with the placeholder', async () => {
const { container, media } = await renderCard({ backdrop: undefined })
const image = container.querySelector<HTMLImageElement>('img')
expect(image).not.toBeNull()
expect((image as HTMLImageElement).src).toContain(media.poster || '')
await fireEvent.error(image as HTMLImageElement)
await waitFor(() => expect(container.querySelector('.subscribe-card-placeholder')).toBeInTheDocument())
})
it('falls back from backdrop to poster for music subscriptions, then to the album placeholder', async () => {
// 仅海报:背景图回退到海报
const { container: posterOnly } = await renderCard(
{
backdrop: undefined,
poster: 'https://images.example.com/music-poster.jpg',
type: '音乐',
},
{},
true,
)
const posterOnlyImage = posterOnly.querySelector<HTMLImageElement>('img')
expect(posterOnlyImage).not.toBeNull()
expect((posterOnlyImage as HTMLImageElement).src).toContain('system/cache/image?url=')
expect((posterOnlyImage as HTMLImageElement).src).toContain(
encodeURIComponent('https://images.example.com/music-poster.jpg'),
)
// 背景图与海报都缺失:渲染与音乐媒体卡片一致的胶片占位背景
const { container } = await renderCard({ backdrop: undefined, poster: undefined, type: '音乐' })
const placeholder = container.querySelector('.subscribe-card-placeholder')
expect(placeholder).toBeInTheDocument()
expect(placeholder?.querySelector('.v-icon, [class*="mdi-album"]')).not.toBeNull()
expect(container.querySelector('img')).toBeNull()
})
it.each([480, 1024])('shows whole-album track count without fake episode progress at %ipx', async width => {
setViewport(width)
await renderCard({
music_type: 'album',
name: '叶惠美',
total_episode: undefined,
total_tracks: 11,
type: '音乐',
})
expect(screen.getByText('专辑 · 11 首')).toBeInTheDocument()
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.queryByText(/^\d{1,4} \/ \d{1,4}$/)).not.toBeInTheDocument()
})
it('shows the current music quality on a music subscription card', async () => {
await renderCard({
current_audio_format: 'FLAC',
current_bit_depth: 24,
current_bitrate: 2304000,
current_sample_rate: 96000,
music_type: 'album',
name: '高解析专辑',
total_tracks: 11,
type: '音乐',
})
expect(screen.getByText('专辑 · 11 首 · FLAC · 24-bit · 96 kHz · 2,304 kbps')).toBeInTheDocument()
})
it.each([480, 1024])('identifies recording subscriptions at %ipx', async width => {
setViewport(width)
await renderCard({ music_type: 'recording', name: '晴天', total_tracks: undefined, type: '音乐' })
expect(screen.getByText('单曲')).toBeInTheDocument()
expect(screen.queryByText(/首$/)).not.toBeInTheDocument()
})
it('keeps the album identity visible when a legacy subscription has no track count', async () => {
await renderCard({ music_type: 'album', name: '旧专辑', total_tracks: undefined, type: '音乐' })
expect(screen.getByText('专辑')).toBeInTheDocument()
})
it.each([
@@ -150,7 +232,7 @@ describe('SubscribeCard display and progress', () => {
await renderCard({ lack_episode: lackEpisode, season: 2, total_episode: totalEpisode, type: '电视剧' })
if (expectedText) expect(screen.getByText(expectedText)).toBeInTheDocument()
else expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
else expect(screen.queryByText(/^\d{1,4} \/ \d{1,4}$/)).not.toBeInTheDocument()
if (expectedProgress) expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', expectedProgress)
else expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
+29 -10
View File
@@ -14,7 +14,7 @@ import { formatFileSize } from '@/@core/utils/formatters'
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
import { useI18n } from 'vue-i18n'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import { numberValidator } from '@/@validators'
import { isValidMediaSourceId } from '@/utils/mediaId'
import { useGlobalSettingsStore } from '@/stores'
//
@@ -25,19 +25,25 @@ const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
//
const mediaSource = ref<MediaDataSource>(
['themoviedb', 'douban', 'bangumi', 'anilist'].includes(globalSettings.RECOGNIZE_SOURCE)
? globalSettings.RECOGNIZE_SOURCE
: 'themoviedb',
)
//
const props = defineProps({
title: String,
media: Object as PropType<MediaInfo>,
torrent: Object as PropType<TorrentInfo>,
})
//
const SUPPORTED_MEDIA_SOURCES: MediaDataSource[] = ['themoviedb', 'douban', 'bangumi', 'anilist', 'musicbrainz']
// 使使
const mediaSource = computed<MediaDataSource>(() => {
const source = props.media?.source as MediaDataSource | undefined
if (source && SUPPORTED_MEDIA_SOURCES.includes(source)) return source
if (SUPPORTED_MEDIA_SOURCES.includes(globalSettings.RECOGNIZE_SOURCE as MediaDataSource)) {
return globalSettings.RECOGNIZE_SOURCE as MediaDataSource
}
return 'themoviedb'
})
//
const emit = defineEmits(['done', 'error', 'close'])
@@ -65,6 +71,17 @@ const showAdvancedOptions = ref(false)
// ID
const mediaId = ref<string | undefined>(undefined)
// MusicBrainz ID
watch(
() => props.media,
media => {
if (media && media.source === 'musicbrainz' && media.media_id) {
mediaId.value = media.media_id
}
},
{ immediate: true },
)
// ID
const mediaIdLabel = computed(() => {
const labels: Record<MediaDataSource, string> = {
@@ -72,6 +89,7 @@ const mediaIdLabel = computed(() => {
douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'),
anilist: t('dialog.reorganize.anilistId'),
musicbrainz: 'MusicBrainz ID',
}
return labels[mediaSource.value]
})
@@ -299,10 +317,11 @@ onMounted(() => {
<VCol cols="12">
<VTextField
v-model="mediaId"
class="app-responsive-input--keep-append-action"
:label="mediaIdLabel"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
:rules="[
(value: any) => isValidMediaSourceId(value, mediaSource) || t('dialog.reorganize.mediaIdInvalid'),
]"
append-inner-icon="mdi-magnify"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
@@ -57,6 +57,7 @@ const mediaIdLabel = computed(() => {
douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'),
anilist: t('dialog.reorganize.anilistId'),
musicbrainz: 'MusicBrainz ID',
}
return labels[mediaSource.value]
})
@@ -241,7 +242,6 @@ onMounted(() => {
<VCol cols="12">
<VTextField
v-model="mediaId"
class="app-responsive-input--keep-append-action"
:label="mediaIdLabel"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
+9 -5
View File
@@ -15,6 +15,10 @@ const props = defineProps({
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
type: {
type: String,
default: 'alist',
},
})
//
@@ -29,7 +33,7 @@ async function handleDone() {
//
async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/alist')
const result: { [key: string]: any } = await api.get(`/storage/reset/${props.type}`)
if (result.success) {
//
handleDone()
@@ -62,7 +66,7 @@ const sourceItems = [
// alist
async function savaAlistConfig() {
try {
await api.post(`storage/save/alist`, props.conf)
await api.post(`storage/save/${props.type}`, props.conf)
} catch (e) {
console.error(e)
}
@@ -78,7 +82,7 @@ async function savaAlistConfig() {
<VIcon icon="mdi-cog-outline" class="me-2" />
</template>
<VCardTitle>
{{ t('dialog.alistConfig.title') }}
{{ t(`dialog.${props.type}Config.title`) }}
</VCardTitle>
</VCardItem>
<VDivider />
@@ -87,8 +91,8 @@ async function savaAlistConfig() {
<VCol cols="12">
<VTextField
v-model="props.conf.url"
:hint="t('dialog.alistConfig.serverUrl')"
:label="t('dialog.alistConfig.serverUrl')"
:hint="t(`dialog.${props.type}Config.serverUrl`)"
:label="t(`dialog.${props.type}Config.serverUrl`)"
persistent-hint
prepend-inner-icon="mdi-server"
/>
@@ -9,6 +9,7 @@ const display = useDisplay()
const props = withDefaults(
defineProps<{
enabled: Record<string, boolean>
modelValue?: boolean
tabs: DiscoverSource[]
}>(),
@@ -19,11 +20,12 @@ const props = withDefaults(
const emit = defineEmits<{
(event: 'close'): void
(event: 'save', tabs: DiscoverSource[]): void
(event: 'save', payload: { enabled: Record<string, boolean>; tabs: DiscoverSource[] }): void
(event: 'update:modelValue', value: boolean): void
}>()
const localTabs = ref<DiscoverSource[]>([])
const localEnabled = ref<Record<string, boolean>>({})
const visible = computed({
get: () => props.modelValue,
@@ -34,21 +36,39 @@ const visible = computed({
})
watch(
() => props.tabs,
[() => props.tabs, () => props.enabled],
() => {
resetLocalTabs()
resetLocalSettings()
},
{ deep: true, immediate: true },
)
//
function resetLocalTabs() {
//
function resetLocalSettings() {
localTabs.value = props.tabs.map(item => ({ ...item }))
localEnabled.value = Object.fromEntries(
props.tabs.map(item => [item.mediaid_prefix, props.enabled[item.mediaid_prefix] !== false]),
)
}
//
function submitOrder() {
emit('save', localTabs.value)
//
function toggleTab(tab: DiscoverSource) {
localEnabled.value[tab.mediaid_prefix] = !localEnabled.value[tab.mediaid_prefix]
}
//
function setAllTabs(enabled: boolean) {
localTabs.value.forEach(tab => {
localEnabled.value[tab.mediaid_prefix] = enabled
})
}
//
function submitSettings() {
emit('save', {
enabled: { ...localEnabled.value },
tabs: localTabs.value,
})
}
</script>
@@ -64,14 +84,14 @@ function submitOrder() {
<VCard class="settings-card">
<VCardItem class="settings-card-header">
<VCardTitle>
<VIcon icon="mdi-order-alphabetical-ascending" size="small" class="me-2" />
{{ t('discover.setTabOrder') }}
<VIcon icon="mdi-tune" size="small" class="me-2" />
{{ t('discover.customizeTabs') }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText>
<p class="settings-hint">{{ t('discover.dragToReorder') }}</p>
<p class="settings-hint">{{ t('discover.configureTabsHint') }}</p>
<draggable
v-model="localTabs"
handle=".cursor-move"
@@ -81,18 +101,34 @@ function submitOrder() {
:component-data="{ 'class': 'settings-grid' }"
>
<template #item="{ element }">
<VCard variant="text" class="setting-item enabled">
<div class="setting-item-inner">
<div class="setting-item" :class="{ 'enabled': localEnabled[element.mediaid_prefix] }">
<button
type="button"
class="setting-toggle"
:aria-pressed="Boolean(localEnabled[element.mediaid_prefix])"
@click="toggleTab(element)"
>
<VIcon
:icon="localEnabled[element.mediaid_prefix] ? 'mdi-check-circle' : 'mdi-circle-outline'"
:color="localEnabled[element.mediaid_prefix] ? 'primary' : undefined"
size="small"
/>
<span class="setting-label">{{ element.name }}</span>
<VIcon icon="mdi-drag" class="drag-icon cursor-move" />
</div>
</VCard>
</button>
<VIcon icon="mdi-drag-vertical" class="drag-icon cursor-move" aria-hidden="true" />
</div>
</template>
</draggable>
</VCardText>
<VCardActions class="app-dialog-actions">
<VBtn color="success" variant="tonal" @click="setAllTabs(true)">
{{ t('discover.selectAll') }}
</VBtn>
<VBtn color="warning" variant="tonal" @click="setAllTabs(false)">
{{ t('discover.selectNone') }}
</VBtn>
<VSpacer />
<VBtn color="primary" variant="flat" class="px-5" @click="submitOrder">
<VBtn color="primary" variant="flat" class="px-5" @click="submitSettings">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
@@ -122,28 +158,16 @@ function submitOrder() {
}
.setting-item {
position: relative;
overflow: hidden;
display: flex;
align-items: stretch;
min-block-size: 48px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 10px;
border-radius: 8px;
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: rgb(var(--v-theme-primary));
block-size: 100%;
content: '';
inline-size: 3px;
inset-block-start: 0;
inset-inline-start: 0;
opacity: 0;
transition: opacity 0.2s ease;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
transform 0.2s ease;
}
.setting-item.enabled {
@@ -151,24 +175,35 @@ function submitOrder() {
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 {
.setting-toggle {
appearance: none;
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-inline-size: 0;
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
padding-block: 10px;
padding-inline: 12px 6px;
text-align: start;
}
.setting-toggle:focus-visible {
border-radius: 7px;
outline: 3px solid rgba(var(--v-theme-primary), 0.28);
outline-offset: 2px;
}
.setting-label {
@@ -185,11 +220,18 @@ function submitOrder() {
}
.drag-icon {
align-self: center;
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.52);
cursor: grab;
margin-inline-end: 10px;
transition: color 0.2s ease;
}
.drag-icon:active {
cursor: grabbing;
}
.setting-item:hover .drag-icon {
color: rgb(var(--v-theme-primary));
}
@@ -496,6 +496,92 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'navidrome'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
:hint="t('mediaserver.usernameHint')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
:hint="t('mediaserver.passwordHint')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="mediaServerInfo.sync_interval"
type="number"
min="0"
step="1"
clearable
:label="t('mediaserver.syncInterval')"
:hint="t('mediaserver.syncIntervalHint', { interval: props.defaultSyncInterval ?? 6 })"
persistent-hint
suffix="h"
prepend-inner-icon="mdi-sync"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'ugreen'">
<VCol cols="12" md="6">
<VTextField
+1 -1
View File
@@ -151,7 +151,7 @@ async function registerPassKey() {
} else if (error.message?.includes('start failed')) {
$toast.error(t('login.passkeyLoginStartFailed'))
} else if (error.response) {
$toast.error(error.response.data?.detail || t('profile.passkeyRegisterFailed'))
$toast.error(error.response.data?.message || error.response.data?.detail || t('profile.passkeyRegisterFailed'))
} else {
$toast.error(error.message || t('profile.passkeyRegisterFailed'))
}
@@ -24,7 +24,7 @@ const repoText = ref('')
const newRepoUrl = ref('')
const editingIndex = ref<number | null>(null)
const editingUrl = ref('')
const syncingWiki = ref(false)
const syncingSources = ref(false)
const emit = defineEmits(['save', 'close'])
@@ -139,10 +139,10 @@ async function saveHandle() {
}
}
/** 从 Wiki 同步公开插件仓库清单并写入配置。 */
async function syncWikiRepos() {
/** 同步公开插件清单并写入配置。 */
async function syncPluginSources() {
try {
syncingWiki.value = true
syncingSources.value = true
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})
if (result.success) {
@@ -164,7 +164,7 @@ async function syncWikiRepos() {
console.log(error)
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: error instanceof Error ? error.message : '' }))
} finally {
syncingWiki.value = false
syncingSources.value = false
}
}
@@ -483,11 +483,11 @@ onMounted(() => {
color="success"
variant="tonal"
prepend-icon="mdi-cloud-sync-outline"
:loading="syncingWiki"
:disabled="syncingWiki"
@click="syncWikiRepos"
:loading="syncingSources"
:disabled="syncingSources"
@click="syncPluginSources"
>
{{ t('dialog.pluginMarketSetting.syncWiki') }}
{{ t('dialog.pluginMarketSetting.syncSources') }}
</VBtn>
<VSpacer />
<VBtn
@@ -506,6 +506,8 @@ onMounted(() => {
</template>
<style scoped lang="scss">
/* stylelint-disable selector-pseudo-class-no-unknown */
.plugin-market-dialog-card {
display: flex;
flex-direction: column;
@@ -570,9 +572,12 @@ onMounted(() => {
.plugin-market-mode-switch {
display: inline-flex;
padding: 0.125rem;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 0.375rem;
background: rgba(var(--v-theme-surface), 0.72);
border: var(--app-grouped-list-border);
border-radius: var(--app-control-radius);
-webkit-backdrop-filter: var(--app-grouped-list-backdrop-filter);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
gap: 0.125rem;
}
@@ -582,7 +587,7 @@ onMounted(() => {
justify-content: center;
padding: 0;
border: 0;
border-radius: 0.375rem;
border-radius: var(--app-control-radius);
background: transparent;
block-size: 2.25rem;
color: rgba(var(--v-theme-on-surface), 0.68);
@@ -594,7 +599,7 @@ onMounted(() => {
color 0.16s ease;
&:hover {
background: rgba(var(--v-theme-primary), 0.07);
background: var(--app-grouped-list-hover-background);
color: rgb(var(--v-theme-on-surface));
}
@@ -604,7 +609,7 @@ onMounted(() => {
}
&.is-active {
background: rgba(var(--v-theme-primary), 0.12);
background: var(--app-grouped-list-active-background);
color: rgb(var(--v-theme-primary));
}
}
@@ -624,13 +629,23 @@ onMounted(() => {
.plugin-market-list-wrap {
flex: 1;
background: rgba(var(--v-theme-surface), 0.72);
border: var(--app-grouped-list-border);
border-radius: var(--app-grouped-list-radius);
-webkit-backdrop-filter: var(--app-grouped-list-backdrop-filter);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
min-block-size: 0;
overflow-y: auto;
}
.plugin-market-repo-list {
background: transparent;
border: 0;
border-radius: inherit !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
background: transparent !important;
box-shadow: none !important;
}
.plugin-market-repo-item {
@@ -681,7 +696,12 @@ onMounted(() => {
display: flex;
overflow: hidden;
flex: 1;
background: rgba(var(--v-theme-surface), 0.72);
border: var(--app-grouped-list-border);
border-radius: var(--app-grouped-list-radius);
-webkit-backdrop-filter: var(--app-grouped-list-backdrop-filter);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
min-block-size: 0;
transition:
border-color 0.2s ease,
+26 -2
View File
@@ -23,6 +23,7 @@ import ProgressDialog from './ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
import { isValidMediaSourceId } from '@/utils/mediaId'
//
const { t } = useI18n()
@@ -48,6 +49,7 @@ const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>((
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
{ title: 'MusicBrainz', value: 'musicbrainz' },
])
// 退TheMovieDb
@@ -153,6 +155,9 @@ function resolveTransferMediaType(type?: string) {
const tvTypes = ['电视剧', 'tv', 'series']
if (tvTypes.includes(normalizedType)) return '电视剧'
const musicTypes = ['音乐', 'music']
if (musicTypes.includes(normalizedType)) return '音乐'
return undefined
}
@@ -347,6 +352,7 @@ const mediaIdLabel = computed(() => {
douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'),
anilist: t('dialog.reorganize.anilistId'),
musicbrainz: 'MusicBrainz ID',
}
return labels[mediaSource.value]
})
@@ -463,6 +469,16 @@ watch([() => transferForm.type_name, () => mediaSource.value], ([typeName, sourc
episodeGroups.value = []
})
// 使 MusicBrainz
watch(
() => transferForm.type_name,
typeName => {
if (typeName === '音乐' && transferForm.media_source !== 'musicbrainz') {
transferForm.media_source = 'musicbrainz'
}
},
)
// ID
watch(
() => transferForm.media_source,
@@ -471,9 +487,17 @@ watch(
transferForm.media_id = null
mediaSelectorDialog.value = false
}
if (source === 'musicbrainz' && transferForm.type_name !== '音乐') {
transferForm.type_name = '音乐'
}
},
)
/** 按当前来源校验手动整理使用的原生媒体 ID。 */
function validateMediaId(value?: string | number | null) {
return isValidMediaSourceId(value, mediaSource.value) || t('dialog.reorganize.mediaIdInvalid')
}
watch(
() => transferForm.episode_group,
episodeGroup => {
@@ -1495,6 +1519,7 @@ onUnmounted(() => {
{ title: t('dialog.reorganize.auto'), value: '' },
{ title: t('dialog.reorganize.movie'), value: '电影' },
{ title: t('dialog.reorganize.tv'), value: '电视剧' },
{ title: t('mediaType.music'), value: '音乐' },
]"
:hint="t('dialog.reorganize.mediaTypeHint')"
persistent-hint
@@ -1514,11 +1539,10 @@ onUnmounted(() => {
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.media_id"
class="app-responsive-input--keep-append-action"
:disabled="transferForm.type_name === ''"
:label="mediaIdLabel"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
:rules="[validateMediaId]"
append-inner-icon="mdi-magnify"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
+17 -4
View File
@@ -1,9 +1,9 @@
<script lang="ts" setup>
import { numberValidator } from '@/@validators'
import type { FileItem, ManualScrapeOptions, MediaDataSource, MediaInfo } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import { isValidMediaSourceId } from '@/utils/mediaId'
const { t } = useI18n()
@@ -29,6 +29,7 @@ const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>((
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
{ title: 'MusicBrainz', value: 'musicbrainz' },
])
const globalSettingsStore = useGlobalSettingsStore()
@@ -55,13 +56,14 @@ const mediaIdLabel = computed(() => {
douban: t('dialog.reorganize.doubanId'),
bangumi: t('dialog.reorganize.bangumiId'),
anilist: t('dialog.reorganize.anilistId'),
musicbrainz: 'MusicBrainz ID',
}
return labels[mediaSource.value]
})
const canSubmit = computed(() => {
const normalizedMediaId = mediaId.value?.trim()
return !normalizedMediaId || /^\d+$/.test(normalizedMediaId)
return isValidMediaSourceId(normalizedMediaId, mediaSource.value)
})
// 退 TheMovieDb
@@ -75,9 +77,15 @@ function resolveMediaType(type?: string) {
const normalizedType = type?.trim().toLowerCase()
if (['电影', 'movie'].includes(normalizedType ?? '')) return '电影'
if (['电视剧', 'tv', 'series'].includes(normalizedType ?? '')) return '电视剧'
if (['音乐', 'music'].includes(normalizedType ?? '')) return '音乐'
return undefined
}
/** 按当前刮削来源校验原生媒体 ID。 */
function validateMediaId(value?: string | null) {
return isValidMediaSourceId(value, mediaSource.value) || t('dialog.reorganize.mediaIdInvalid')
}
//
function handleMediaSelected(item: Pick<MediaInfo, 'type'>) {
mediaType.value = resolveMediaType(item.type) ?? mediaType.value
@@ -103,6 +111,11 @@ function submitScrape() {
watch(mediaSource, () => {
mediaId.value = null
mediaSelectorDialog.value = false
if (mediaSource.value === 'musicbrainz') mediaType.value = '音乐'
})
watch(mediaType, type => {
if (type === '音乐' && mediaSource.value !== 'musicbrainz') mediaSource.value = 'musicbrainz'
})
</script>
@@ -128,6 +141,7 @@ watch(mediaSource, () => {
{ title: t('dialog.reorganize.auto'), value: '' },
{ title: t('dialog.reorganize.movie'), value: '电影' },
{ title: t('dialog.reorganize.tv'), value: '电视剧' },
{ title: t('mediaType.music'), value: '音乐' },
]"
:hint="t('dialog.reorganize.mediaTypeHint')"
persistent-hint
@@ -147,11 +161,10 @@ watch(mediaSource, () => {
<VCol cols="12" md="4">
<VTextField
v-model="mediaId"
class="app-responsive-input--keep-append-action"
:disabled="mediaType === ''"
:label="mediaIdLabel"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
:rules="[validateMediaId]"
append-inner-icon="mdi-magnify"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
+37 -3
View File
@@ -96,8 +96,8 @@ const searchOverlayProps = computed(() =>
//
const searchWord = ref<string | null>(null)
type MediaSearchSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist'
type MediaSearchType = 'media' | 'collection' | 'person'
type MediaSearchSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz'
type MediaSearchType = 'media' | 'music' | 'collection' | 'person'
interface MediaSearchSourceOption {
label: string
@@ -115,6 +115,7 @@ interface MediaSearchAction {
// 使 TheMovieDB
const selectedMediaSearchSources = reactive<Record<MediaSearchType, MediaSearchSource>>({
media: 'themoviedb',
music: 'musicbrainz',
collection: 'themoviedb',
person: 'themoviedb',
})
@@ -141,9 +142,15 @@ const mediaSearchSourceOptions = computed<Record<MediaSearchType, MediaSearchSou
name: t('discoverTabs.anilist'),
value: 'anilist' as const,
}
const musicbrainz = {
label: 'MusicBrainz',
name: 'MusicBrainz',
value: 'musicbrainz' as const,
}
return {
media: [themoviedb, douban, bangumi, anilist],
music: [musicbrainz],
collection: [themoviedb],
person: [themoviedb, douban],
}
@@ -158,6 +165,12 @@ const mediaSearchActions = computed(() => {
title: `${t('recommend.categoryMovie')}${t('recommend.categoryTV')}`,
description: t('resource.title'),
},
{
type: 'music',
icon: 'mdi-music-note-outline',
title: t('mediaType.music'),
description: t('music.searchDescription'),
},
{
type: 'collection',
icon: 'mdi-movie-filter',
@@ -389,6 +402,14 @@ function searchSubtitle() {
function searchMedia(searchType: MediaSearchType) {
if (!searchWord.value || !hasDiscoveryPermission.value) return
saveRecentSearches(searchWord.value)
if (searchType === 'music') {
router.push({
path: '/music',
query: { query: searchWord.value },
})
closeSearch()
return
}
router.push({
path: '/browse/media/search',
query: {
@@ -453,6 +474,13 @@ function goSubscribe(subscribe: Subscribe) {
id: subscribe.id,
},
})
} else if (subscribe.type === '音乐') {
router.push({
path: '/subscribe/music',
query: {
id: subscribe.id,
},
})
} else {
router.push({
path: '/subscribe/tv',
@@ -669,7 +697,13 @@ onMounted(() => {
<template #prepend>
<div class="result-icon-wrapper">
<VIcon
:icon="subscribe.type === '电影' ? 'mdi-movie-roll' : 'mdi-television-classic'"
:icon="
subscribe.type === '电影'
? 'mdi-movie-roll'
: subscribe.type === '音乐'
? 'mdi-music-note'
: 'mdi-television-classic'
"
size="small"
color="medium-emphasis"
/>
@@ -69,10 +69,9 @@ async function updateSiteCookie() {
}
} catch (error: any) {
console.error(error)
const detail = error?.response?.data?.detail
const message =
error?.response?.data?.message ||
(typeof detail === 'string' ? detail : error?.message) ||
(typeof error?.response?.data?.detail === 'string' ? error.response.data.detail : error?.message) ||
t('dialog.siteCookieUpdate.requestFailed')
$toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message }))
} finally {
+23 -21
View File
@@ -66,6 +66,9 @@ const categoryOptions = computed(() => {
})
})
//
const hasSiteCategory = computed(() => (siteCategoryList.value?.length ?? 0) > 0)
//
const resourceTotalItems = computed(() => resourceDataList.value.length)
@@ -248,7 +251,7 @@ onMounted(() => {
<VSheet class="site-resource-filter-panel">
<div class="site-resource-filter-panel__inner">
<VRow class="site-resource-filter-row">
<VCol cols="12" md="4">
<VCol cols="12" md="5">
<VTextField
v-model="keyword"
class="site-resource-filter-input"
@@ -278,9 +281,12 @@ onMounted(() => {
clearable
prepend-inner-icon="mdi-folder"
hide-details
:disabled="!hasSiteCategory"
:hint="hasSiteCategory ? '' : t('dialog.siteResource.noCategory')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="3" class="d-flex align-center">
<VCol cols="12" md="2" class="d-flex align-center">
<VBtn
color="primary"
variant="flat"
@@ -295,15 +301,6 @@ onMounted(() => {
</VBtn>
</VCol>
</VRow>
<div v-if="resourceTotalItems > 0" class="d-flex justify-space-between align-center flex-wrap gap-2 mt-3">
<div class="text-body-2 text-medium-emphasis">
{{ resultSummaryText }}
</div>
<VChip size="small" color="primary" variant="tonal" class="site-resource-result-chip">
{{ resourceTotalItems }}
</VChip>
</div>
</div>
</VSheet>
</template>
@@ -360,6 +357,9 @@ onMounted(() => {
clearable
prepend-inner-icon="mdi-folder"
hide-details
:disabled="!hasSiteCategory"
:hint="hasSiteCategory ? '' : t('dialog.siteResource.noCategory')"
persistent-hint
/>
</VCol>
<VCol cols="12" class="d-flex gap-2">
@@ -684,8 +684,8 @@ onMounted(() => {
<style lang="scss" scoped>
.site-resource-dialog {
display: flex;
flex-direction: column;
overflow: hidden;
flex-direction: column;
}
.site-resource-filter-row {
@@ -699,7 +699,8 @@ onMounted(() => {
}
.site-resource-filter-panel__inner {
padding: 0.75rem 0.85rem;
padding-block: 0.75rem;
padding-inline: 0.85rem;
}
.site-resource-filter-input :deep(.v-field) {
@@ -742,9 +743,9 @@ onMounted(() => {
}
.site-resource-content {
overflow: hidden;
flex: 1 1 auto;
min-block-size: 0;
overflow: hidden;
}
.site-resource-table {
@@ -772,8 +773,8 @@ onMounted(() => {
}
.site-resource-mobile {
overflow-y: auto;
block-size: 100%;
overflow-y: auto;
}
.site-resource-mobile__list {
@@ -801,9 +802,9 @@ onMounted(() => {
.site-resource-card__summary {
display: grid;
align-items: center;
gap: 0.35rem;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(2.5rem, 0.62fr) minmax(2.5rem, 0.62fr);
align-items: center;
}
.site-resource-card__stat {
@@ -811,12 +812,12 @@ onMounted(() => {
overflow: hidden;
align-items: center;
justify-content: center;
gap: 0.22rem;
border-radius: 6px;
background: rgba(var(--v-theme-on-surface), 0.05);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 0.74rem;
font-weight: 600;
gap: 0.22rem;
line-height: 1;
min-block-size: 1.65rem;
min-inline-size: 0;
@@ -854,21 +855,21 @@ onMounted(() => {
}
.site-resource-card__chips {
max-block-size: 4.75rem;
overflow: hidden;
max-block-size: 4.75rem;
}
.site-resource-card__actions {
display: grid;
align-items: center;
gap: 0.45rem;
grid-template-columns: minmax(0, 1fr) 2.5rem 2.5rem;
align-items: center;
}
.site-resource-card__download-btn {
box-shadow: 0 6px 16px rgba(var(--v-theme-primary), 0.17);
min-block-size: 2.5rem;
min-inline-size: 0;
box-shadow: 0 6px 16px rgba(var(--v-theme-primary), 0.17);
}
.site-resource-card__download-btn :deep(.v-btn__content) {
@@ -899,7 +900,8 @@ onMounted(() => {
}
.site-resource-filter-panel__inner {
padding: 0.7rem 0.75rem;
padding-block: 0.7rem;
padding-inline: 0.75rem;
}
.site-resource-mobile-search {
+85 -7
View File
@@ -6,7 +6,16 @@ import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirector
import { useDisplay } from 'vuetify'
import { useConfirm } from '@/composables/useConfirm'
import { useI18n } from 'vue-i18n'
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
import {
qualityOptions,
resolutionOptions,
effectOptions,
audioQualityOptions,
audioFormatOptions,
audioBitrateOptions,
audioBitDepthOptions,
audioSampleRateOptions,
} from '@/api/constants'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { formatSeason } from '@/@core/utils/formatters'
@@ -118,6 +127,7 @@ function getSubscribeDisplayName() {
function getDefaultSubscribeTypeName() {
if (props.type === '电影') return t('mediaType.movie')
if (props.type === '电视剧') return t('mediaType.tv')
if (props.type === '音乐') return t('mediaType.music')
return props.type ?? ''
}
@@ -216,7 +226,8 @@ async function saveDefaultSubscribeConfig() {
try {
let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
else if (props.type === '电视剧') subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultMusicSubscribeConfig'
const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)
if (result.success) {
@@ -247,7 +258,8 @@ async function queryDefaultSubscribeConfig() {
try {
let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
else if (props.type === '电视剧') subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
else subscribe_config_url = 'system/setting/public/DefaultMusicSubscribeConfig'
const result: { [key: string]: any } = await api.get(subscribe_config_url)
@@ -354,6 +366,7 @@ const targetDirectories = computed(() => {
//
const isTvSubscribe = computed(() => props.type === '电视剧' || subscribeForm.value.type === '电视剧')
const isMusicSubscribe = computed(() => props.type === '音乐' || subscribeForm.value.type === '音乐')
watch(
() => subscribeForm.value.best_version,
@@ -434,7 +447,7 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow>
<VRow v-if="!isMusicSubscribe">
<VCol cols="12" md="4">
<VAutocomplete
v-model="subscribeForm.quality"
@@ -466,6 +479,67 @@ onMounted(() => {
/>
</VCol>
</VRow>
<template v-else>
<VRow>
<VCol cols="12" md="4">
<VAutocomplete
v-model="subscribeForm.audio_quality"
:label="t('dialog.subscribeEdit.audioQuality')"
:items="audioQualityOptions"
:hint="t('dialog.subscribeEdit.audioQualityHint')"
clearable
persistent-hint
prepend-inner-icon="mdi-waveform"
/>
</VCol>
<VCol cols="12" md="4">
<VAutocomplete
v-model="subscribeForm.audio_format"
:label="t('dialog.subscribeEdit.audioFormat')"
:items="audioFormatOptions"
:hint="t('dialog.subscribeEdit.audioFormatHint')"
clearable
persistent-hint
prepend-inner-icon="mdi-file-music-outline"
/>
</VCol>
<VCol cols="12" md="4">
<VAutocomplete
v-model="subscribeForm.min_bitrate"
:label="t('dialog.subscribeEdit.minBitrate')"
:items="audioBitrateOptions"
:hint="t('dialog.subscribeEdit.minBitrateHint')"
clearable
persistent-hint
prepend-inner-icon="mdi-speedometer"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VAutocomplete
v-model="subscribeForm.min_bit_depth"
:label="t('dialog.subscribeEdit.minBitDepth')"
:items="audioBitDepthOptions"
:hint="t('dialog.subscribeEdit.minBitDepthHint')"
clearable
persistent-hint
prepend-inner-icon="mdi-numeric"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="subscribeForm.min_sample_rate"
:label="t('dialog.subscribeEdit.minSampleRate')"
:items="audioSampleRateOptions"
:hint="t('dialog.subscribeEdit.minSampleRateHint')"
clearable
persistent-hint
prepend-inner-icon="mdi-sine-wave"
/>
</VCol>
</VRow>
</template>
<VRow>
<VCol cols="12">
<VAutocomplete
@@ -508,7 +582,11 @@ onMounted(() => {
<VSwitch
v-model="subscribeForm.best_version"
:label="t('dialog.subscribeEdit.bestVersion')"
:hint="t('dialog.subscribeEdit.bestVersionHint')"
:hint="
isMusicSubscribe
? t('dialog.subscribeEdit.musicBestVersionHint')
: t('dialog.subscribeEdit.bestVersionHint')
"
persistent-hint
/>
</VCol>
@@ -520,7 +598,7 @@ onMounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VCol v-if="!isMusicSubscribe" cols="12" md="4">
<VSwitch
v-model="subscribeForm.search_imdbid"
:label="t('dialog.subscribeEdit.searchImdbid')"
@@ -606,7 +684,7 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow v-if="!props.default">
<VRow v-if="!props.default && !isMusicSubscribe">
<VCol cols="12">
<VTextarea
v-model="subscribeForm.custom_words"
@@ -55,12 +55,16 @@ function createSource(name: string, prefix: string): DiscoverSource {
}
}
async function renderDialog(tabs: DiscoverSource[]) {
async function renderDialog(
tabs: DiscoverSource[],
enabled = Object.fromEntries(tabs.map(tab => [tab.mediaid_prefix, true])),
) {
const close = vi.fn()
const save = vi.fn()
const updateModelValue = vi.fn()
const result = await renderWithProviders(DiscoverTabOrderDialog, {
props: {
enabled,
modelValue: true,
onClose: close,
onSave: save,
@@ -89,7 +93,7 @@ describe('DiscoverTabOrderDialog', () => {
expect(tabs).toEqual(originalOrder)
expect(save).toHaveBeenCalledOnce()
const savedTabs = save.mock.calls[0][0] as DiscoverSource[]
const savedTabs = (save.mock.calls[0][0] as { tabs: DiscoverSource[] }).tabs
expect(savedTabs.map(item => item.mediaid_prefix)).toEqual(['source-b', 'source-a'])
expect(savedTabs[0]).not.toBe(tabs[1])
expect(savedTabs[0].filter_params).toStrictEqual(tabs[1].filter_params)
@@ -104,15 +108,35 @@ describe('DiscoverTabOrderDialog', () => {
await user.click(screen.getByRole('button', { name: '反转顺序' }))
await rerender({
enabled: { 'source-c': true, 'source-d': true },
modelValue: true,
tabs: [createSource('来源丙', 'source-c'), createSource('来源丁', 'source-d')],
})
await user.click(screen.getByRole('button', { name: '保存' }))
const savedTabs = save.mock.calls[0][0] as DiscoverSource[]
const savedTabs = (save.mock.calls[0][0] as { tabs: DiscoverSource[] }).tabs
expect(savedTabs.map(item => item.mediaid_prefix)).toEqual(['source-c', 'source-d'])
})
it('toggles individual tabs and supports the same bulk choices as recommendation settings', async () => {
const tabs = [createSource('来源甲', 'source-a'), createSource('来源乙', 'source-b')]
const user = userEvent.setup()
const { save } = await renderDialog(tabs, { 'source-a': true, 'source-b': false })
expect(screen.getByRole('button', { name: '来源甲' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: '来源乙' })).toHaveAttribute('aria-pressed', 'false')
await user.click(screen.getByRole('button', { name: '来源甲' }))
await user.click(screen.getByRole('button', { name: '全选' }))
await user.click(screen.getByRole('button', { name: '来源乙' }))
await user.click(screen.getByRole('button', { name: '保存' }))
expect(save).toHaveBeenCalledWith({
enabled: { 'source-a': true, 'source-b': false },
tabs: expect.any(Array),
})
})
it('emits both model closure and close from the close control', async () => {
const user = userEvent.setup()
const { close, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')])
@@ -124,13 +148,17 @@ describe('DiscoverTabOrderDialog', () => {
expect(close).toHaveBeenCalledOnce()
})
it('emits only the current local order when saving', async () => {
it('emits only the current local settings when saving', async () => {
const user = userEvent.setup()
const { close, save, updateModelValue } = await renderDialog([createSource('来源甲', 'source-a')])
await user.click(screen.getByRole('button', { name: '保存' }))
expect(save).toHaveBeenCalledOnce()
expect(save).toHaveBeenCalledWith({
enabled: { 'source-a': true },
tabs: [expect.objectContaining({ mediaid_prefix: 'source-a' })],
})
expect(close).not.toHaveBeenCalled()
expect(updateModelValue).not.toHaveBeenCalled()
})
@@ -0,0 +1,38 @@
import { readFileSync } from 'node:fs'
import { cwd } from 'node:process'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const dialogSource = readFileSync(resolve(cwd(), 'src/components/dialog/PluginMarketSettingDialog.vue'), 'utf8')
function getStyleRule(selector: string) {
const ruleStart = dialogSource.indexOf(`${selector} {`)
const ruleEnd = dialogSource.indexOf('\n}', ruleStart)
expect(ruleStart).toBeGreaterThanOrEqual(0)
expect(ruleEnd).toBeGreaterThan(ruleStart)
return dialogSource.slice(ruleStart, ruleEnd)
}
describe('PluginMarketSettingDialog theme surfaces', () => {
it('uses shared theme tokens for the view switch and editor containers', () => {
const modeSwitchRule = getStyleRule('.plugin-market-mode-switch')
const listWrapRule = getStyleRule('.plugin-market-list-wrap')
const textareaRule = getStyleRule('.plugin-market-textarea-field')
expect(modeSwitchRule).toContain('border: var(--app-grouped-list-border)')
expect(modeSwitchRule).toContain('backdrop-filter: var(--app-grouped-list-backdrop-filter)')
expect(modeSwitchRule).toContain('background: var(--app-grouped-list-background)')
expect(dialogSource).toContain('background: var(--app-grouped-list-hover-background)')
expect(dialogSource).toContain('background: var(--app-grouped-list-active-background)')
expect(listWrapRule).toContain('border-radius: var(--app-grouped-list-radius)')
expect(listWrapRule).toContain('background: var(--app-grouped-list-background)')
expect(textareaRule).toContain('background: var(--app-grouped-list-background)')
})
it('uses plugin-source wording instead of exposing the Wiki implementation detail', () => {
expect(dialogSource).toContain("t('dialog.pluginMarketSetting.syncSources')")
expect(dialogSource).not.toContain("t('dialog.pluginMarketSetting.syncWiki')")
})
})
@@ -76,4 +76,22 @@ describe('ScrapeDialog', () => {
expect(screen.getByText('共 2 项')).toBeInTheDocument()
})
it('supports MusicBrainz UUIDs when scraping music', async () => {
const user = userEvent.setup()
const { events } = await renderDialog('themoviedb', [
{ name: '晴天.flac', path: '/music/晴天.flac', storage: 'local', type: 'file' },
])
await user.click(screen.getByLabelText('类型'))
await user.click(await screen.findByRole('option', { name: '音乐' }))
await user.type(screen.getByLabelText('MusicBrainz ID'), '977e6978-139d-425c-bb98-6b0c62d1e45e')
await user.click(screen.getByRole('button', { name: '确认' }))
expect(events.scrape).toHaveBeenCalledWith({
media_source: 'musicbrainz',
media_id: '977e6978-139d-425c-bb98-6b0c62d1e45e',
type_name: '音乐',
})
})
})
@@ -3,8 +3,17 @@ import { DEFAULT_PERMISSIONS } from '@/utils/permission'
import { screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent } from 'vue'
import { beforeEach, describe, expect, it } from 'vitest'
const IconStub = defineComponent({
name: 'VIcon',
props: {
icon: String,
},
template: '<i :data-icon="icon" />',
})
async function renderSearchBar() {
return renderWithProviders(SearchBarDialog, {
props: {
@@ -24,6 +33,11 @@ async function renderSearchBar() {
superUser: false,
},
},
global: {
stubs: {
VIcon: IconStub,
},
},
})
}
@@ -92,6 +106,18 @@ describe('SearchBarDialog media source selection', () => {
})
})
it('renders the bundled music icon in the music search action', async () => {
const user = userEvent.setup()
await renderSearchBar()
const input = await screen.findByPlaceholderText('搜索电影、剧集以及更多...')
await user.type(input, '晴天')
expect(getSearchItem('音乐').querySelector('[data-icon="mdi-music-note-outline"]')).not.toBeNull()
expect(getSearchItem('音乐')).toHaveTextContent('歌曲、专辑或艺术家')
expect(getSearchItem('音乐')).not.toHaveTextContent('搜索音乐元数据,并进入站点资源搜索、下载和订阅流程')
})
it('searches actors with the selected supported source', async () => {
const user = userEvent.setup()
const { router } = await renderSearchBar()
@@ -220,7 +220,9 @@ describe('SiteImportDialog', () => {
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
expect(await screen.findByText('导入过程中出现 1 个错误')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
const resultCloseButton = screen.getByText('关闭').closest('button')
expect(resultCloseButton).not.toBeNull()
await fireEvent.click(resultCloseButton!)
expect(events.update).toHaveBeenCalledWith(false)
expect(events.importSuccess).not.toHaveBeenCalled()
})
@@ -443,7 +443,8 @@ describe('SiteResourceDialog', () => {
await waitFor(() => expect(screen.queryByLabelText('搜索关键字')).not.toBeInTheDocument())
})
it('emits close and formats the result summary in English', async () => {
it('emits close and formats the mobile result summary in English', async () => {
setViewport(390)
server.use(
siteCategoriesHandler(501, []),
siteResourcesHandler(501, [createTorrentInfo({ title: 'Language resource' })]),
@@ -148,6 +148,15 @@ describe('SiteUserDataDialog projections', () => {
expect((history.options.xaxis as { categories: string[] }).categories).toEqual(['2026-07-17', '2026-07-19'])
expect((history.options.theme as { mode: string }).mode).toBe('light')
expect((history.options.chart as { background: string; foreColor: string }).background).toBeTruthy()
expect((history.options.tooltip as { x: { formatter: (value: string) => string } }).x.formatter('2026-07-19')).toBe(
new Date('2026-07-19').toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
}),
)
expect((history.options.tooltip as { y: { formatter: (value: number) => string } }).y.formatter(1234)).toBe(
`${(1234).toLocaleString()} GB`,
)
expect(
(history.options.xaxis as { labels: { formatter: (value: string) => string } }).labels.formatter('2026-07-19'),
).toBe(
@@ -174,6 +183,9 @@ describe('SiteUserDataDialog projections', () => {
expect((seeding.options.tooltip as { x: { formatter: (value: number) => string } }).x.formatter(1234)).toBe(
`数量:${(1234).toLocaleString()}`,
)
expect((seeding.options.tooltip as { y: { formatter: (value: number) => string } }).y.formatter(2048)).toBe(
`${(2048).toLocaleString()} GB`,
)
expect((seeding.options.xaxis as { labels: { formatter: (value: number) => string } }).labels.formatter(1.6)).toBe(
'2',
)
@@ -219,32 +219,80 @@ describe('SubscribeEditDialog', () => {
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())
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 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('loads and submits music quality filters and quality upgrades', async () => {
const record = createSubscribe({
audio_format: 'FLAC',
audio_quality: 'hires',
best_version: 1,
current_audio_format: 'MP3',
current_bitrate: 320000,
id: 811,
min_bit_depth: 24,
min_bitrate: 320000,
min_sample_rate: 96000,
music_type: 'album',
name: '音乐音质测试专辑',
tmdbid: 0,
type: '音乐',
})
const updated = vi.fn()
server.use(subscribeDetailsHandler(811, record), updateSubscribeHandler({ success: true }, 200, updated))
useDialogOptions()
await renderDialog({ subid: 811 })
expect(await screen.findByText('音乐音质测试专辑')).toBeInTheDocument()
expect(screen.getByLabelText('音质等级')).toBeInTheDocument()
expect(screen.getByLabelText('音频格式')).toBeInTheDocument()
expect(screen.getByLabelText('最低码率')).toBeInTheDocument()
expect(screen.getByLabelText('最低位深')).toBeInTheDocument()
expect(screen.getByLabelText('最低采样率')).toBeInTheDocument()
expect(screen.getByLabelText('洗版')).toBeChecked()
expect(screen.queryByLabelText('使用 ImdbID 搜索')).not.toBeInTheDocument()
expect(screen.queryByLabelText('质量')).not.toBeInTheDocument()
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}订阅默认规则保存成功`)
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
expect(updated.mock.calls[0][0]).toMatchObject({
audio_format: 'FLAC',
audio_quality: 'hires',
best_version: true,
min_bit_depth: 24,
min_bitrate: 320000,
min_sample_rate: 96000,
type: '音乐',
})
})
it('submits the complete TV editing form and exposes the close action', async () => {
+1 -1
View File
@@ -704,7 +704,7 @@ async function recognize(path: string) {
//
closeProgressDialog()
if (!nameTestResult.value) $toast.error(t('file.recognizeFailed', { path }))
if (nameTestResult.value?.meta_info?.name) {
if (nameTestResult.value?.meta_info?.name || nameTestResult.value?.meta_info?.title) {
openSharedDialog(MediaInfoDialog, { context: nameTestResult.value }, {}, { closeOn: ['close'] })
}
} catch (error) {
@@ -214,6 +214,34 @@ describe('FileList list state', () => {
expect(renderedNames[1]).toContain('a-file.mkv')
})
it('opens recognized music details when audio metadata uses title instead of name', async () => {
const audio = createItem({
extension: 'flac',
name: '晴天.flac',
path: '/music/晴天.flac',
type: 'file',
})
mocks.apiGet.mockResolvedValueOnce({
meta_info: { artists: ['周杰伦'], title: '晴天', type: '音乐' },
media_info: { artist: '周杰伦', title: '晴天', type: '音乐' },
})
await renderList(() => Promise.resolve([]), { item: audio })
await fireEvent.click(getSlotIconButton('mdi-text-recognition'))
await waitFor(() =>
expect(mocks.openSharedDialog.mock.calls.at(-1)?.[1]).toMatchObject({
context: {
meta_info: { title: '晴天', type: '音乐' },
media_info: { title: '晴天', type: '音乐' },
},
}),
)
expect(mocks.apiGet).toHaveBeenCalledWith('media/recognize_file', {
params: { path: '/music/晴天.flac' },
})
})
it('filters by substring, wildcard and case sensitivity', async () => {
await renderList(() =>
Promise.resolve([
+7 -2
View File
@@ -61,6 +61,7 @@ async function searchMedias() {
const result: MediaInfo[] = await api.get('media/search', {
params: {
title: searchKeyword,
type: props.type === 'musicbrainz' ? 'music' : 'media',
page: 1,
count: 20,
source: props.type,
@@ -79,12 +80,16 @@ async function searchMedias() {
item.bangumi_id?.toString() ||
item.anilist_id?.toString()
if (!mediaId) continue
const musicAlbum = item.music_type === 'album' || item.album === item.title ? undefined : item.album
items.value.push({
id: mediaId,
poster: getW500Image(item.poster_path),
poster: getW500Image(item.cover_url || item.poster_path),
type: item.type,
title: item.year ? `${item.title}${item.year}` : item.title || '',
overview: `<span class="text-primary">${item.type}</span> ${item.overview}`,
overview:
item.type === '音乐'
? `<span class="text-primary">${item.type}</span> ${[item.artist, musicAlbum].filter(Boolean).join(' · ')}`
: `<span class="text-primary">${item.type}</span> ${item.overview || ''}`,
})
}
} catch (e) {
@@ -56,4 +56,47 @@ describe('MediaIdSelector layout', () => {
const results = container.querySelector('.media-id-selector__results')
expect(results).toBeInstanceOf(HTMLElement)
})
it('omits the repeated album title for albums while keeping it for recordings', async () => {
mocks.apiGet.mockResolvedValue([
{
album: '叶惠美',
artist: '周杰伦',
media_id: 'album-1',
music_type: 'album',
title: '叶惠美',
type: '音乐',
year: 2003,
},
{
album: '叶惠美',
artist: '周杰伦',
media_id: 'recording-1',
music_type: 'recording',
title: '以父之名',
type: '音乐',
},
])
const { container } = await renderWithProviders(MediaIdSelector, {
global: {
stubs: {
VDialogCloseBtn: {
props: ['innerClass'],
template: '<button type="button" :class="innerClass"><slot /></button>',
},
},
},
})
const input = screen.getByPlaceholderText('输入媒体名称')
await fireEvent.update(input, '周杰伦')
await fireEvent.keyDown(input, { key: 'Enter' })
expect(await screen.findByText('叶惠美(2003')).toBeInTheDocument()
const subtitles = Array.from(container.querySelectorAll('.v-list-item-subtitle')).map(item =>
item.textContent?.trim(),
)
expect(subtitles).toEqual(['音乐 周杰伦', '音乐 周杰伦 · 叶惠美'])
})
})
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts" setup>
import type { MediaInfo } from '@/api/types'
import { buildMusicDetailRoute, formatMusicDuration, getMusicArtistLinks } from '@/utils/music'
const { t } = useI18n()
const router = useRouter()
const props = defineProps({
//
tracks: {
type: Array as PropType<MediaInfo[]>,
default: () => [],
},
// ID
activeMediaId: String,
//
hideArtists: {
type: Boolean,
default: false,
},
})
//
const discs = computed(() => {
const groups = new Map<number, MediaInfo[]>()
props.tracks.forEach(track => {
const disc = track.disc_number || 1
const existing = groups.get(disc)
if (existing) existing.push(track)
else groups.set(disc, [track])
})
return [...groups.entries()].map(([disc, tracks]) => ({ disc, tracks }))
})
const showDiscTitles = computed(() => discs.value.length > 1)
/** 打开曲目对应的音乐详情页。 */
function goTrack(track: MediaInfo) {
if (!track.media_id) return
router.push(buildMusicDetailRoute(track))
}
/** 返回曲目上展示的艺术家文本。 */
function getTrackArtists(track: MediaInfo) {
return getMusicArtistLinks(track)
.map(artist => artist.name)
.join(' / ')
}
</script>
<template>
<div class="music-track-list">
<div v-for="group in discs" :key="group.disc">
<div v-if="showDiscTitles" class="music-track-disc text-caption text-medium-emphasis">
{{ t('music.discNumber', { number: group.disc }) }}
</div>
<VList density="compact" bg-color="transparent">
<VListItem
v-for="track in group.tracks"
:key="track.media_id || track.title"
class="music-track-item"
:class="{ 'music-track-item--active': track.media_id === props.activeMediaId }"
@click="goTrack(track)"
>
<template #prepend>
<span class="music-track-number text-caption text-medium-emphasis">
{{ track.track_number || '-' }}
</span>
</template>
<VListItemTitle class="music-track-title">{{ track.title }}</VListItemTitle>
<VListItemSubtitle v-if="!props.hideArtists && getTrackArtists(track)" class="text-caption">
{{ getTrackArtists(track) }}
</VListItemSubtitle>
<template #append>
<span class="text-caption text-medium-emphasis">{{ formatMusicDuration(track.duration) }}</span>
</template>
</VListItem>
</VList>
</div>
</div>
</template>
<style scoped>
.music-track-disc {
font-weight: 700;
margin-block-start: 0.75rem;
text-transform: uppercase;
}
.music-track-item {
border-radius: var(--app-control-radius);
cursor: pointer;
}
.music-track-item--active {
background: rgba(var(--v-theme-primary), 0.12);
}
.music-track-number {
display: inline-block;
inline-size: 2rem;
text-align: end;
margin-inline-end: 0.75rem;
}
.music-track-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+1 -1
View File
@@ -232,7 +232,7 @@ watch(
</script>
<template>
<div class="slider-container" :class="{ 'is-scrolling': isScrolling }">
<div v-show="loading || items.length > 0" class="slider-container" :class="{ 'is-scrolling': isScrolling }">
<div class="slider-header">
<slot name="title">
<SlideViewTitle />
@@ -27,6 +27,10 @@ const typeOptions = ref([
title: t('mediaType.tv'),
value: '电视剧',
},
{
title: t('mediaType.music'),
value: '音乐',
},
])
//
@@ -26,6 +26,10 @@ const typeOptions = ref([
title: t('mediaType.tv'),
value: '电视剧',
},
{
title: t('mediaType.music'),
value: '音乐',
},
])
//
@@ -148,6 +148,28 @@ function createTouchList(points: Array<{ clientX: number; clientY: number; ident
}) as unknown as TouchList
}
/** 构造浏览器在 detached 子树交付前保留的 child-list 移除记录。 */
function createRemovalRecord(target: Element, removedNodes: Element[]): MutationRecord {
const toNodeList = (nodes: Node[]) =>
Object.assign(nodes, {
item(index: number) {
return nodes[index] ?? null
},
}) as unknown as NodeList
return {
addedNodes: toNodeList([]),
attributeName: null,
attributeNamespace: null,
nextSibling: null,
oldValue: null,
previousSibling: null,
removedNodes: toNodeList(removedNodes),
target,
type: 'childList',
}
}
function dispatchTouchEvent(
type: 'touchcancel' | 'touchend' | 'touchmove' | 'touchstart',
touches: Array<{ clientX: number; clientY: number; identifier: number }>,
@@ -454,6 +476,24 @@ describe('glass optical surface discovery', () => {
expect(resolveGlassOpticalSurfaceMode(overridden)).toBe('dynamic')
})
it('excludes direct surfaces and descendants even when a child requests dynamic mode', () => {
const direct = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 })
direct.dataset.glassOpticalMode = 'excluded'
const excludedContainer = document.createElement('section')
excludedContainer.dataset.glassOpticalMode = 'excluded'
const overridden = document.createElement('article')
overridden.className = 'app-hover-lift-card'
overridden.dataset.glassOpticalMode = 'dynamic'
setOpticalSurfaceBounds(overridden, { height: 220, width: 150, x: 200, y: 96 })
excludedContainer.append(overridden)
document.body.append(excludedContainer)
expect(containsGlassOpticalSurface(direct)).toBe(false)
expect(containsGlassOpticalSurface(excludedContainer)).toBe(false)
expect(resolveGlassOpticalSurfaceMode(overridden)).toBe('dynamic')
expect(collectGlassOpticalRects(390, 844, 'clear')).toEqual([])
})
it('discovers the shared interactive card contract used across routes', () => {
const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 })
surface.style.borderTopLeftRadius = '20px'
@@ -1998,6 +2038,458 @@ describe('glass optical surface discovery', () => {
scope.stop()
})
it('keeps a parent material static when all nested interaction clips are excluded', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const pageContent = document.createElement('main')
pageContent.className = 'app-wrapper layout-page-content'
const outerSurface = document.createElement('section')
outerSurface.className = 'v-card'
setOpticalSurfaceBounds(outerSurface, { height: 420, width: 900, x: 40, y: 80 })
const nestedCards = [80, 400].map(x => {
const nestedCard = document.createElement('article')
nestedCard.className = 'app-hover-lift-card'
nestedCard.dataset.glassOpticalMode = 'excluded'
setOpticalSurfaceBounds(nestedCard, { height: 160, width: 280, x, y: 140 })
outerSurface.append(nestedCard)
return nestedCard
})
pageContent.append(outerSurface)
document.body.append(pageContent)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/search'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
await vi.waitFor(() => expect(render).toHaveBeenCalled())
const getUniforms = () => {
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uInteractionRectCount: { value: number }
uRectCount: { value: number }
uSurfaceDynamics: { value: number[] }
}
}
}>
}
return scene.children[0].material.uniforms
}
expect(getUniforms().uRectCount.value).toBe(1)
expect(getUniforms().uInteractionRectCount.value).toBe(0)
expect(getUniforms().uSurfaceDynamics.value[0]).toBe(0)
nestedCards[0].removeAttribute('data-glass-optical-mode')
await vi.waitFor(() => expect(getUniforms().uInteractionRectCount.value).toBe(1))
expect(getUniforms().uSurfaceDynamics.value[0]).toBe(1)
scope.stop()
})
it('refreshes excluded interaction clip membership across direct and nested mutations', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const pageContent = document.createElement('main')
pageContent.className = 'app-wrapper layout-page-content'
const surfaces = [40, 520].map(x => {
const surface = document.createElement('section')
surface.className = 'v-card'
setOpticalSurfaceBounds(surface, { height: 420, width: 400, x, y: 80 })
pageContent.append(surface)
return surface
})
document.body.append(pageContent)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/search'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
await vi.waitFor(() => expect(render).toHaveBeenCalled())
const getInteractionState = () => {
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uInteractionRectCount: { value: number }
uInteractionRects: { value: Array<{ toArray: () => number[] }> }
uRectCount: { value: number }
uSurfaceDynamics: { value: number[] }
}
}
}>
}
const uniforms = scene.children[0].material.uniforms
return {
interactionCount: uniforms.uInteractionRectCount.value,
interactionXs: uniforms.uInteractionRects.value
.slice(0, uniforms.uInteractionRectCount.value)
.map(rect => rect.toArray()[0])
.sort((left, right) => left - right),
surfaceCount: uniforms.uRectCount.value,
surfaceDynamics: uniforms.uSurfaceDynamics.value.slice(0, 2).sort((left, right) => left - right),
}
}
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 2,
interactionXs: [40 / 1200, 520 / 1200],
surfaceCount: 2,
surfaceDynamics: [1, 1],
}),
)
const excludedClip = document.createElement('article')
excludedClip.className = 'app-hover-lift-card'
excludedClip.dataset.glassOpticalMode = 'excluded'
surfaces[0].append(excludedClip)
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 1,
interactionXs: [520 / 1200],
surfaceCount: 2,
surfaceDynamics: [0, 1],
}),
)
surfaces[1].append(excludedClip)
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 1,
interactionXs: [40 / 1200],
surfaceCount: 2,
surfaceDynamics: [0, 1],
}),
)
excludedClip.remove()
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 2,
interactionXs: [40 / 1200, 520 / 1200],
surfaceCount: 2,
surfaceDynamics: [1, 1],
}),
)
const excludedContainer = document.createElement('div')
excludedContainer.dataset.glassOpticalMode = 'excluded'
surfaces[0].append(excludedContainer)
const nestedExcludedClip = document.createElement('article')
nestedExcludedClip.className = 'app-hover-lift-card'
excludedContainer.append(nestedExcludedClip)
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 1,
interactionXs: [520 / 1200],
surfaceCount: 2,
surfaceDynamics: [0, 1],
}),
)
nestedExcludedClip.remove()
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 2,
interactionXs: [40 / 1200, 520 / 1200],
surfaceCount: 2,
surfaceDynamics: [1, 1],
}),
)
excludedContainer.append(nestedExcludedClip)
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 1,
interactionXs: [520 / 1200],
surfaceCount: 2,
surfaceDynamics: [0, 1],
}),
)
nestedExcludedClip.remove()
excludedContainer.remove()
await vi.waitFor(() =>
expect(getInteractionState()).toEqual({
interactionCount: 2,
interactionXs: [40 / 1200, 520 / 1200],
surfaceCount: 2,
surfaceDynamics: [1, 1],
}),
)
scope.stop()
})
it('propagates a managed owner through deeply nested removals in one observer batch', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const mutationObservers: Array<MutationObserver & { trigger: (records: MutationRecord[]) => void }> = []
class MutationObserverMock implements MutationObserver {
constructor(private readonly callback: MutationCallback) {
mutationObservers.push(this)
}
disconnect() {}
observe() {}
takeRecords() {
return []
}
trigger(records: MutationRecord[]) {
this.callback(records, this)
}
}
vi.stubGlobal('MutationObserver', MutationObserverMock)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const pageContent = document.createElement('main')
pageContent.className = 'app-wrapper layout-page-content'
const surface = document.createElement('section')
surface.className = 'v-card'
setOpticalSurfaceBounds(surface, { height: 420, width: 900, x: 40, y: 80 })
const outerExcludedContainer = document.createElement('div')
outerExcludedContainer.dataset.glassOpticalMode = 'excluded'
const innerExcludedContainer = document.createElement('div')
innerExcludedContainer.dataset.glassOpticalMode = 'excluded'
const deeplyNestedClip = document.createElement('article')
deeplyNestedClip.className = 'app-hover-lift-card'
innerExcludedContainer.append(deeplyNestedClip)
outerExcludedContainer.append(innerExcludedContainer)
surface.append(outerExcludedContainer)
pageContent.append(surface)
document.body.append(pageContent)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/search'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
await vi.waitFor(() => expect(render).toHaveBeenCalled())
const getInteractionState = () => {
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uInteractionRectCount: { value: number }
uSurfaceDynamics: { value: number[] }
}
}
}>
}
const uniforms = scene.children[0].material.uniforms
return {
interactionCount: uniforms.uInteractionRectCount.value,
surfaceDynamics: uniforms.uSurfaceDynamics.value[0],
}
}
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
await vi.waitFor(() => expect(getInteractionState()).toEqual({ interactionCount: 0, surfaceDynamics: 0 }))
outerExcludedContainer.remove()
innerExcludedContainer.remove()
deeplyNestedClip.remove()
mutationObservers[0].trigger([
createRemovalRecord(surface, [outerExcludedContainer]),
createRemovalRecord(outerExcludedContainer, [innerExcludedContainer]),
createRemovalRecord(innerExcludedContainer, [deeplyNestedClip]),
])
await vi.waitFor(() => expect(getInteractionState()).toEqual({ interactionCount: 1, surfaceDynamics: 1 }))
scope.stop()
})
it('removes and restores an active surface and interaction clip when exclusion changes', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const appWrapper = document.createElement('main')
appWrapper.className = 'app-wrapper'
const surface = document.createElement('article')
surface.className = 'app-hover-lift-card'
setOpticalSurfaceBounds(surface, { height: 180, width: 360, x: 100, y: 140 })
appWrapper.append(surface)
document.body.append(appWrapper)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/search'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
await vi.waitFor(() => expect(render).toHaveBeenCalled())
const getCounts = () => {
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uInteractionRectCount: { value: number }
uRectCount: { value: number }
}
}
}>
}
const uniforms = scene.children[0].material.uniforms
return [uniforms.uRectCount.value, uniforms.uInteractionRectCount.value]
}
expect(getCounts()).toEqual([1, 1])
surface.dataset.glassOpticalMode = 'excluded'
await vi.waitFor(() => expect(getCounts()).toEqual([0, 0]))
surface.removeAttribute('data-glass-optical-mode')
await vi.waitFor(() => expect(getCounts()).toEqual([1, 1]))
scope.stop()
})
it('ignores child-list churn inside an excluded surface after removed nodes lose their ancestor', async () => {
const appWrapper = document.createElement('main')
appWrapper.className = 'app-wrapper'
const surface = document.createElement('article')
surface.className = 'app-hover-lift-card'
surface.dataset.glassOpticalMode = 'excluded'
const imageContent = document.createElement('div')
surface.append(imageContent)
appWrapper.append(surface)
document.body.append(appWrapper)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
dynamicsActive: ref(false),
quality: ref('balanced'),
routeKey: ref('/search'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const querySelectorAll = vi.spyOn(document, 'querySelectorAll')
imageContent.remove()
await nextTick()
await new Promise(resolve => requestAnimationFrame(resolve))
expect(querySelectorAll).not.toHaveBeenCalled()
scope.stop()
})
it('removes and restores a managed surface when it moves across an excluded boundary', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const appWrapper = document.createElement('main')
appWrapper.className = 'app-wrapper'
const eligibleParent = document.createElement('section')
const excludedParent = document.createElement('section')
excludedParent.dataset.glassOpticalMode = 'excluded'
const surface = document.createElement('article')
surface.className = 'app-hover-lift-card'
setOpticalSurfaceBounds(surface, { height: 180, width: 360, x: 100, y: 140 })
eligibleParent.append(surface)
appWrapper.append(eligibleParent, excludedParent)
document.body.append(appWrapper)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
quality: ref('balanced'),
routeKey: ref('/search'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const getCounts = () => {
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uInteractionRectCount: { value: number }
uRectCount: { value: number }
}
}
}>
}
const uniforms = scene.children[0].material.uniforms
return [uniforms.uRectCount.value, uniforms.uInteractionRectCount.value]
}
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
await vi.waitFor(() => expect(getCounts()).toEqual([1, 1]))
const querySelectorAll = vi.spyOn(document, 'querySelectorAll')
excludedParent.append(surface)
expect(surface.closest('[data-glass-optical-mode="excluded"]')).toBe(excludedParent)
await vi.waitFor(() => expect(querySelectorAll).toHaveBeenCalled())
await vi.waitFor(() => expect(getCounts()).toEqual([0, 0]))
eligibleParent.append(surface)
await vi.waitFor(() => expect(getCounts()).toEqual([1, 1]))
scope.stop()
})
it('keeps an explicit optical boundary as the interaction clip without allocating nested slots', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
@@ -9,6 +9,7 @@ import {
} from '@/composables/useMediaSubscribe'
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createMediaInfo } from '@tests/support/factories/media'
import { createSubscribe, createSubscribeMovie, createSubscribeTv } from '@tests/support/factories/subscribe'
import {
createSubscribeHandler,
@@ -261,6 +262,90 @@ describe('useMediaSubscribe entry flows', () => {
expect(mocks.doneProgress).toHaveBeenCalledOnce()
})
it('creates an album subscription with its entity type and complete track count', async () => {
const media = createMediaInfo({
media_id: 'release-group-1',
music_type: 'album',
source: 'musicbrainz',
title: '叶惠美',
tmdb_id: undefined,
total_tracks: 11,
type: '音乐',
year: '2003',
})
const created = vi.fn()
server.use(
createSubscribeHandler({ data: { id: 502 }, 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())
expect(created.mock.calls[0][0]).toMatchObject({
media_id: 'release-group-1',
media_source: 'musicbrainz',
mediaid: 'musicbrainz:release-group-1',
music_type: 'album',
name: '叶惠美',
season: null,
total_tracks: 11,
type: '音乐',
year: '2003',
})
})
it('creates a recording subscription without its album track count', async () => {
const media = createMediaInfo({
media_id: 'recording-1',
music_type: 'recording',
source: 'musicbrainz',
title: '晴天',
tmdb_id: undefined,
total_tracks: 11,
type: '音乐',
year: '2003',
})
const created = vi.fn()
server.use(
createSubscribeHandler({ data: { id: 503 }, 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())
expect(created.mock.calls[0][0]).toMatchObject({
media_id: 'recording-1',
media_source: 'musicbrainz',
music_type: 'recording',
name: '晴天',
type: '音乐',
})
expect(created.mock.calls[0][0]).not.toHaveProperty('total_tracks')
})
it('does not create a subscription for an artist browsing entity', async () => {
const media = createMediaInfo({
media_id: 'artist-1',
music_type: 'artist',
source: 'musicbrainz',
title: '周杰伦',
tmdb_id: undefined,
type: '音乐',
})
const created = vi.fn()
server.use(createSubscribeHandler({ data: { id: 503 }, success: true }, 200, created))
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
expect(created).not.toHaveBeenCalled()
expect(mocks.startProgress).not.toHaveBeenCalled()
})
it('creates an AniList subscription without promoting its auxiliary TMDB ID', async () => {
const media = createSubscribeTv({
anilist_id: 154587,
@@ -466,6 +551,37 @@ describe('useMediaSubscribe entry flows', () => {
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false')
})
it('queries and cancels music subscriptions with their entity type', async () => {
const media = createMediaInfo({
media_id: 'release-group-1',
music_type: 'album',
source: 'musicbrainz',
title: '叶惠美',
tmdb_id: undefined,
type: '音乐',
})
const queried = vi.fn()
const deleted = vi.fn()
server.use(
querySubscribeByMediaHandler(
'musicbrainz:release-group-1',
createSubscribe({ id: 801, music_type: 'album', type: '音乐' }),
200,
url => queried(url),
),
deleteSubscribeByMediaHandler('musicbrainz:release-group-1', { success: true }, 200, url => deleted(url)),
)
await renderSubscribeHarness({ isSubscribed: true, media })
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('subscribed'))
expect((queried.mock.calls[0][0] as URL).searchParams.get('music_type')).toBe('album')
await fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('music_type')).toBe('album')
})
it('aligns visible seasons while preserving hidden subscriptions', async () => {
const media = createSubscribeTv({ title: '多季剧集', tmdb_id: 105 })
const deleted = vi.fn()
@@ -210,6 +210,27 @@ describe('useThemeCustomizer glass settings', () => {
expect(Number(document.body.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(0.48)
expect(Number(document.documentElement.style.getPropertyValue('--glass-surface-density'))).toBeCloseTo(0.72)
expect(Number(document.body.style.getPropertyValue('--glass-tint-density'))).toBeCloseTo(0.65)
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
expect(document.body.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
})
it('keeps overlay clarity synchronized across preview, cancel, and commit', () => {
persistPartialThemeCustomizerSettings({ glassTransparencyStrength: 50 })
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
previewGlassSettings({ glassTransparencyStrength: 100 })
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('5px')
expect(document.body.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('5px')
cancelGlassPreview()
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
expect(document.body.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
previewGlassSettings({ glassTransparencyStrength: 20 })
commitGlassPreview()
expect(readThemeCustomizerSettings().glassTransparencyStrength).toBe(20)
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('7.8px')
expect(document.body.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('7.8px')
})
it('previews glass settings without persisting them', () => {
+119 -15
View File
@@ -442,8 +442,19 @@ const SURFACE_SELECTORS = [
const SURFACE_SELECTOR_QUERY = SURFACE_SELECTORS.map(({ selector }) => selector).join(',')
const INTERACTION_CLIP_SELECTOR = '.app-hover-lift-card'
const OPTICAL_BOUNDARY_SELECTOR = '[data-glass-optical-boundary]'
const OPTICAL_EXCLUSION_SELECTOR = '[data-glass-optical-mode="excluded"]'
const INTERACTION_CLIP_OVERSCAN_PX = 96
/** 排除合同覆盖整个子树;后代不能用 dynamic 声明重新加入 renderer。 */
function isGlassOpticalElementExcluded(element: Element) {
return Boolean(element.closest(OPTICAL_EXCLUSION_SELECTOR))
}
/** overlay 与显式排除子树都不参与壁纸光学表面或交互裁剪。 */
function isGlassOpticalElementEligible(element: Element) {
return !element.closest('.v-overlay') && !isGlassOpticalElementExcluded(element)
}
/** 登录卡片随文档弹性合成,其余固定表面继续使用 viewport 坐标。 */
function getSurfacePresentationSpace(
selector: (typeof SURFACE_SELECTORS)[number]['selector'],
@@ -455,9 +466,16 @@ function getSurfacePresentationSpace(
/** 判断新增或移除的 DOM 子树是否会改变光学表面集合。 */
export function containsGlassOpticalSurface(node: Node) {
if (!(node instanceof Element)) return false
if (node.matches(SURFACE_SELECTOR_QUERY) && !node.closest('.v-overlay')) return true
if (node.matches(SURFACE_SELECTOR_QUERY) && isGlassOpticalElementEligible(node)) return true
return Array.from(node.querySelectorAll(SURFACE_SELECTOR_QUERY)).some(element => !element.closest('.v-overlay'))
return Array.from(node.querySelectorAll(SURFACE_SELECTOR_QUERY)).some(isGlassOpticalElementEligible)
}
/** 判断 DOM 子树是否包含会约束父表面动态输出的交互裁剪。 */
function containsGlassInteractionClip(node: Node) {
if (!(node instanceof Element)) return false
return node.matches(INTERACTION_CLIP_SELECTOR) || Boolean(node.querySelector(INTERACTION_CLIP_SELECTOR))
}
const VERTEX_SHADER = `
@@ -1100,7 +1118,7 @@ function collectGlassOpticalSurfaceDescriptors(
for (const element of document.querySelectorAll<HTMLElement>(selector)) {
if (seen.has(element)) continue
seen.add(element)
if (element.closest('.v-overlay')) continue
if (!isGlassOpticalElementEligible(element)) continue
collectedElements?.push(element)
const bounds = element.getBoundingClientRect()
@@ -1291,8 +1309,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let surfaceRegistry: GlassOpticalSurfaceDescriptor[] = []
let availableSurfaces: GlassOpticalSurfaceDescriptor[] = []
let surfaceSlots: GlassOpticalSurfaceSlot<HTMLElement>[] = []
let interactionClips: GlassOpticalSurfaceDescriptor[] = []
let interactionClips: GlassInteractionClipDescriptor[] = []
let interactionClipRegistry: GlassInteractionClipDescriptor[] = []
let interactionClipConstrainedOwners = new Set<HTMLElement>()
let interactionClipMembershipDirty = true
let activeSurface: HTMLElement | null = null
let activeInteractionClip: HTMLElement | null = null
@@ -1869,6 +1888,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const uniformRadii = resources.uniforms.uRadii.value
const uniformWeights = resources.uniforms.uSurfaceWeights.value
const uniformDynamics = resources.uniforms.uSurfaceDynamics.value
const ownersWithVisibleInteractionClips = new Set(interactionClips.map(clip => clip.owner))
const transitionWeights = outgoingSurface
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
: { incoming: 1, outgoing: 0 }
@@ -1894,7 +1914,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
? 1
: 0
uniformWeights[index] = surfaceWeight * pagePresentationWeight
uniformDynamics[index] = slot?.mode === 'static-material' ? 0 : 1
const nestedInteractionAvailable =
!slot || !interactionClipConstrainedOwners.has(slot.key) || ownersWithVisibleInteractionClips.has(slot.key)
uniformDynamics[index] = slot?.mode === 'static-material' || !nestedInteractionAvailable ? 0 : 1
}
resources.uniforms.uRectCount.value = normalized.length
@@ -1907,13 +1929,21 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
function refreshInteractionClipRegistry() {
const seen = new Set<HTMLElement>()
const candidates: GlassInteractionClipDescriptor[] = []
const constrainedOwners = new Set<HTMLElement>()
const append = (
element: HTMLElement,
owner: HTMLElement,
mode: GlassOpticalSurfaceMode,
committedRect?: GlassOpticalRect,
) => {
if (seen.has(element) || !element.isConnected || resolveGlassOpticalSurfaceMode(element) !== mode) return
if (
seen.has(element) ||
!element.isConnected ||
!isGlassOpticalElementEligible(element) ||
resolveGlassOpticalSurfaceMode(element) !== mode
) {
return
}
const rect = committedRect ?? getElementPresentationRect(element)
@@ -1935,6 +1965,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
if (surfaceIsClip) append(surface.key, surface.key, mode, surface.rect)
const nestedClips = [...surface.key.querySelectorAll<HTMLElement>(INTERACTION_CLIP_SELECTOR)]
if (nestedClips.length > 0) {
constrainedOwners.add(surface.key)
nestedClips.forEach(clip => append(clip, surface.key, mode))
} else if (!surfaceIsClip) {
append(surface.key, surface.key, mode, surface.rect)
@@ -1942,6 +1973,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
}
interactionClipRegistry = candidates
interactionClipConstrainedOwners = constrainedOwners
interactionClipMembershipDirty = false
}
@@ -1952,6 +1984,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
if (
!clip.key.isConnected ||
!clip.owner.isConnected ||
!isGlassOpticalElementEligible(clip.key) ||
!isGlassOpticalElementEligible(clip.owner) ||
resolveGlassOpticalSurfaceMode(clip.key) !== clip.mode ||
!committedSurfaces.has(clip.owner)
) {
@@ -2030,6 +2064,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
activeInteractionClip = null
}
if (outgoingSurface && !availableKeys.has(outgoingSurface)) outgoingSurface = null
const interactionClipKeys = new Set(interactionClipRegistry.map(clip => clip.key))
if (activeInteractionClip && !interactionClipKeys.has(activeInteractionClip)) activeInteractionClip = null
const maxCount = viewportWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
surfaceSlots = reconcileGlassOpticalSurfaceSlots(
surfaceSlots,
@@ -2242,7 +2278,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const surface = target.matches(SURFACE_SELECTOR_QUERY)
? target
: target.closest<HTMLElement>(SURFACE_SELECTOR_QUERY)
if (!surface) return null
if (!surface || !isGlassOpticalElementEligible(surface)) return null
return SURFACE_SELECTORS.some(
({ selector, space }) =>
@@ -2482,13 +2518,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
function findInteractionTarget(x: number, y: number) {
if (availableSurfaces.length === 0) updateSurfaceUniforms()
const surface = availableSurfaces.find(candidate => rectContainsPoint(candidate.rect, x, y))
const surface = availableSurfaces.find(
candidate => isGlassOpticalElementEligible(candidate.key) && rectContainsPoint(candidate.rect, x, y),
)
if (!surface) return null
const matchingClips = interactionClipRegistry
.filter(
candidate =>
candidate.owner === surface.key &&
isGlassOpticalElementEligible(candidate.key) &&
isInteractionClipRenderable(candidate.rect) &&
rectContainsPoint(candidate.rect, x, y),
)
@@ -2689,6 +2728,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
clientY: number,
timestamp: number,
velocityOverride?: { x: number; y: number },
target?: EventTarget | null,
) {
if (
!hasDynamicCapability() ||
@@ -2696,6 +2736,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
) {
return
}
if (target instanceof Element && isGlassOpticalElementExcluded(target)) return
const viewportWidth = Math.max(window.innerWidth, 1)
const viewportHeight = Math.max(window.innerHeight, 1)
@@ -2803,7 +2844,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
function handlePointerMove(event: PointerEvent) {
if (event.pointerType === 'touch') return
applyInteraction(event.clientX, event.clientY, event.timeStamp || performance.now())
applyInteraction(event.clientX, event.clientY, event.timeStamp || performance.now(), undefined, event.target)
}
function findTouch(touches: TouchList, identifier: number | null) {
@@ -2818,6 +2859,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
/** 被动跟踪真实触点;不阻止页面滚动,也不恢复按压放大语义。 */
function handleTouchStart(event: TouchEvent) {
if (activeTouchIdentifier !== null) return
if (event.target instanceof Element && isGlassOpticalElementExcluded(event.target)) return
const touch = findTouch(event.changedTouches, null)
if (!touch) return
@@ -2854,7 +2896,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
if (!touch) return
const timestamp = event.timeStamp || performance.now()
applyInteraction(touch.clientX, touch.clientY, timestamp)
applyInteraction(touch.clientX, touch.clientY, timestamp, undefined, event.target)
}
function handleTouchEnd(event: TouchEvent) {
@@ -3105,11 +3147,72 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
/** 只让会改变目标表面集合或圆角几何的 DOM 变更触发重扫。 */
function mutationTouchesOpticalSurface(mutations: MutationRecord[]) {
return mutations.some(
mutation =>
mutation.type === 'attributes' ||
[...mutation.addedNodes, ...mutation.removedNodes].some(containsGlassOpticalSurface),
)
const removalRecords = mutations.flatMap(mutation => {
if (mutation.type !== 'childList' || mutation.removedNodes.length === 0 || !(mutation.target instanceof Element))
return []
return [
{
removedElements: [...mutation.removedNodes].filter((node): node is Element => node instanceof Element),
target: mutation.target,
},
]
})
const managedRemovalTargets = new Set<Element>()
const removedElementsFromManagedSurfaces = new Set<Element>()
let removalGraphExpanded = removalRecords.length > 0
// MutationRecord 保留节点身份,但回调时的最终 DOM 已丢失中间祖先关系,需要沿同批次移除边传递 owner。
while (removalGraphExpanded) {
removalGraphExpanded = false
for (const record of removalRecords) {
const belongsToManagedSurface =
managedRemovalTargets.has(record.target) ||
surfaceRegistry.some(surface => surface.key === record.target || surface.key.contains(record.target)) ||
[...removedElementsFromManagedSurfaces].some(
element => element === record.target || element.contains(record.target),
)
if (!belongsToManagedSurface) continue
if (!managedRemovalTargets.has(record.target)) {
managedRemovalTargets.add(record.target)
removalGraphExpanded = true
}
for (const element of record.removedElements) {
if (removedElementsFromManagedSurfaces.has(element)) continue
removedElementsFromManagedSurfaces.add(element)
removalGraphExpanded = true
}
}
}
return mutations.some(mutation => {
if (mutation.type === 'attributes') return true
const removedManagedSurface = [...mutation.removedNodes].some(
node =>
node instanceof Element &&
(surfaceRegistry.some(surface => surface.key === node || node.contains(surface.key)) ||
interactionClipRegistry.some(clip => clip.key === node || node.contains(clip.key))),
)
if (removedManagedSurface) return true
const changedNodes = [...mutation.addedNodes, ...mutation.removedNodes]
// 新增节点按提交后的祖先资格判断;排除子树内部的图片 DOM 变化不需要重扫。
if (mutation.target instanceof Element && !isGlassOpticalElementEligible(mutation.target)) {
const belongsToManagedSurface = surfaceRegistry.some(
surface => surface.key === mutation.target || surface.key.contains(mutation.target),
)
const removedFromManagedSurface = managedRemovalTargets.has(mutation.target)
return (belongsToManagedSurface || removedFromManagedSurface) && changedNodes.some(containsGlassInteractionClip)
}
return changedNodes.some(node => containsGlassOpticalSurface(node) || containsGlassInteractionClip(node))
})
}
function setupObservers() {
@@ -3262,6 +3365,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
surfaceSlots = []
interactionClips = []
interactionClipRegistry = []
interactionClipConstrainedOwners = new Set<HTMLElement>()
interactionClipMembershipDirty = true
activeSurface = null
activeInteractionClip = null
+22 -7
View File
@@ -100,6 +100,11 @@ export function getMediaSubscribeId(media?: MediaInfo) {
return getMediaSubscribeIdentity(media)?.mediaKey ?? ''
}
/** 返回订阅 API 使用的音乐实体;旧音乐对象缺省时按单曲兼容。 */
function getMusicSubscribeType(media?: MediaInfo) {
return media?.type === '音乐' ? (media.music_type ?? 'recording') : undefined
}
// 将订阅模式转换为后端订阅字段。
function getSubscribePayload(mode: SubscribeMode): SubscribePayload {
return {
@@ -271,10 +276,12 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
try {
const media = currentMedia()
const subscribeConfigUrl =
media?.type === '电影'
? 'system/setting/public/DefaultMovieSubscribeConfig'
: 'system/setting/public/DefaultTvSubscribeConfig'
const subscribeConfigUrl = {
: 'system/setting/public/DefaultMovieSubscribeConfig',
: 'system/setting/public/DefaultTvSubscribeConfig',
: 'system/setting/public/DefaultMusicSubscribeConfig',
}[media?.type || '']
if (!subscribeConfigUrl) return undefined
const result: { [key: string]: any } = await api.get(subscribeConfigUrl)
return result.data?.value
@@ -308,7 +315,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
addOptions: AddSubscribeOptions = {},
) {
const media = currentMedia()
if (!media) return
// 艺术家仅用于继续浏览,其下作品必须按单曲或专辑分别订阅。
if (!media || media.music_type === 'artist') return
const identity = getMediaSubscribeIdentity(media)
startNProgress()
@@ -316,7 +324,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
const result: { [key: string]: any } = await api.post('subscribe/', {
name: media.title,
type: media.type,
year: media.year,
// 后端的订阅模型 year 为字符串,音乐的 year 是数字,需统一转字符串避免 422
year: media.year?.toString() ?? '',
tmdbid: media.tmdb_id,
doubanid: media.douban_id,
bangumiid: media.bangumi_id,
@@ -324,6 +333,9 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
media_source: identity?.source,
media_id: identity?.mediaId,
mediaid: identity?.mediaKey ?? '',
// 专辑订阅必须保留实体类型和曲目总数,后端据此校验整专资源并决定何时完成订阅。
music_type: getMusicSubscribeType(media),
total_tracks: getMusicSubscribeType(media) === 'album' ? media.total_tracks : undefined,
season: media.type === '电影' ? null : season,
...payload,
episode_group: episodeGroup.value,
@@ -381,6 +393,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
const result: { [key: string]: any } = await api.delete(`subscribe/media/${getMediaId()}`, {
params: {
season: media.type === '电影' ? null : season,
music_type: getMusicSubscribeType(media),
},
})
@@ -411,6 +424,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
params: {
season,
title: currentMedia()?.title,
music_type: getMusicSubscribeType(currentMedia()),
},
})
@@ -429,6 +443,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
params: {
season,
title: currentMedia()?.title,
music_type: getMusicSubscribeType(currentMedia()),
},
})
@@ -497,7 +512,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
// 处理媒体主订阅入口,电视剧统一进入季选择弹窗。
function handlePrimarySubscribe() {
const media = currentMedia()
if (!media) return
if (!media || media.music_type === 'artist') return
const season = media.type === '电影' ? null : getPrimarySeason()
+83
View File
@@ -0,0 +1,83 @@
import api from '@/api'
import type { Site } from '@/api/types'
import { openSharedDialog } from '@/composables/useSharedDialog'
import type { RouteLocationRaw } from 'vue-router'
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
type MusicResourceRouteBuilder = (sites: number[]) => RouteLocationRaw | undefined
/**
*
*
* @param buildRoute
*/
export function useMusicSiteSearch(buildRoute: MusicResourceRouteBuilder) {
const router = useRouter()
const musicSites = ref<Site[]>([])
const selectedSites = ref<number[]>([])
let dialogController: ReturnType<typeof openSharedDialog> | undefined
/** 查询已配置且支持音乐搜索的启用站点。 */
async function queryMusicSites() {
musicSites.value = []
try {
const sites: Site[] = await api.get('site/media/music')
musicSites.value = sites.filter(site => site.is_active)
} catch (error) {
console.error(error)
}
}
/** 查询用户默认选择的索引站点。 */
async function querySelectedSites() {
selectedSites.value = []
try {
const result: { data?: { value?: number[] } } = await api.get('system/setting/public/IndexerSites')
selectedSites.value = result.data?.value ?? []
} catch (error) {
console.error(error)
}
}
/** 重新加载音乐站点,并同步更新已打开对话框的属性。 */
async function reloadMusicSites() {
await queryMusicSites()
dialogController?.updateProps({
sites: musicSites.value,
selected: selectedSites.value,
})
}
/** 使用用户确认的站点进入音乐资源搜索页。 */
function searchSelectedSites(sites: number[]) {
if (!sites.length) return
selectedSites.value = sites
const target = buildRoute(sites)
if (target) router.push(target)
}
/** 打开只包含音乐站点的站点选择对话框。 */
function openMusicSiteDialog() {
dialogController = openSharedDialog(
SearchSiteDialog,
{
sites: musicSites.value,
selected: selectedSites.value,
},
{
reload: reloadMusicSites,
search: searchSelectedSites,
},
{ closeOn: ['close', 'search'] },
)
}
/** 加载音乐站点和默认选择后打开站点选择对话框。 */
async function openMusicSiteSearch() {
await Promise.all([queryMusicSites(), querySelectedSites()])
openMusicSiteDialog()
}
return { openMusicSiteSearch }
}
+3
View File
@@ -8,6 +8,7 @@ import {
GLASS_OPTICAL_STRENGTH_MAX,
getGlassCssFrostBlur,
getGlassMaterialResponse,
getGlassOverlayClarityBlur,
getGlassOpticalCssTransmissionBrightness,
getGlassOpticalPresetKey,
getGlassOpticalPresetParameters,
@@ -480,6 +481,7 @@ export function applyThemeCustomizerRootSettings(
const materialResponse = getGlassMaterialResponse(settings.glassAppearance, settings.glassTransparencyStrength)
const frostBlur = getGlassCssFrostBlur(settings.glassTransparencyStrength)
const overlayClarityBlur = getGlassOverlayClarityBlur(settings.glassTransparencyStrength)
const applyGlassResponse = (element: HTMLElement) => {
element.style.setProperty('--glass-background-visibility', String(materialResponse.backgroundVisibility))
element.style.setProperty('--glass-frost-blur-scale', String(materialResponse.frostBlurScale))
@@ -488,6 +490,7 @@ export function applyThemeCustomizerRootSettings(
element.style.setProperty('--glass-tint-density', String(materialResponse.tintDensity))
element.style.setProperty('--glass-blur-surface', `${frostBlur.surface}px`)
element.style.setProperty('--glass-blur-raised', `${frostBlur.raised}px`)
element.style.setProperty('--glass-overlay-clarity-blur', `${overlayClarityBlur}px`)
}
document.documentElement.setAttribute('data-glass-appearance', settings.glassAppearance)
+8
View File
@@ -0,0 +1,8 @@
// Agent 助手需要覆盖应用内所有普通浮层,因此从 CSS 32 位层级上限向下预留内部顺序。
const MAX_CSS_Z_INDEX = 2_147_483_647
export const AGENT_ASSISTANT_LAYER_Z_INDEX = {
entry: MAX_CSS_Z_INDEX - 2,
panel: MAX_CSS_Z_INDEX - 1,
overlay: MAX_CSS_Z_INDEX,
} as const
+7 -3
View File
@@ -4,7 +4,12 @@ import { useDisplay } from 'vuetify'
import { NavMenu } from '@/@layouts/types'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, filterItemsByPermission, filterMenusByPermission, hasItemPermission } from '@/utils/permission'
import {
buildUserPermissionContext,
filterItemsByPermission,
filterMenusByPermission,
hasItemPermission,
} from '@/utils/permission'
import { useLaunchLoading } from '@/composables/useLaunchLoading'
import { usePWA } from '@/composables/usePWA'
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
@@ -327,8 +332,7 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
pointer-events: none;
}
// body Agent .v-application
// z-index
//
html[data-theme-customizer-open='true'],
html[data-agent-assistant-open='true'] {
.footer-nav-container {
+153 -8
View File
@@ -84,6 +84,7 @@ export default {
mediaType: {
movie: 'Movie',
tv: 'TV Show',
music: 'Music',
anime: 'Anime',
collection: 'Collection',
unknown: 'Unknown',
@@ -127,6 +128,16 @@ export default {
h265: 'H265',
h264: 'H264',
},
audioQualityOptions: {
all: 'All Audio Qualities',
hires: 'Hi-Res Lossless',
lossless: 'Lossless (including Hi-Res)',
lossy: 'Lossy',
},
audioFormatOptions: {
all: 'All Formats',
lossless: 'All Lossless Formats',
},
resolutionOptions: {
all: 'All',
'4k': '4K',
@@ -386,6 +397,7 @@ export default {
download: 'Download',
movieSubscribe: 'Movie Subscription',
tvSubscribe: 'TV Subscription',
musicSubscribe: 'Music Subscription',
history: 'History',
transfer: 'Organize',
rename: 'Rename',
@@ -397,6 +409,7 @@ export default {
explore: 'Explore',
movie: 'Movies',
tv: 'TV Shows',
music: 'Music',
workflow: 'Workflow',
calendar: 'Calendar',
downloadManager: 'Download Manager',
@@ -407,6 +420,82 @@ export default {
userManager: 'User Management',
settings: 'Settings',
},
music: {
title: 'Music Search',
searchFromGlobal: 'Use the global search at the top to look up a song, album or artist',
searchDescription: 'Songs, albums or artists',
noResults: 'No matching music found',
searchResources: 'Search Resources',
subscribe: 'Subscribe',
unsubscribe: 'Unsubscribe',
album: 'Album',
albumArtist: 'Album Artist',
albumType: 'Album Type',
albumTracks: 'Tracks in This Album',
albumNotFound: 'Album not found',
albums: 'Albums',
eps: 'EPs',
singles: 'Singles',
tracks: 'Tracks',
track: 'Track',
trackTotal: 'Tracks',
trackNumber: 'Track {number}',
trackCount: '{count} tracks',
discNumber: 'Disc {number}',
releases: 'Releases',
releaseDate: 'Release Date',
duration: 'Duration',
ratingVotes: 'Rating Votes',
listenCount: 'Listens',
listenCountValue: '{count} listens',
relatedArtists: 'Related Artists',
artistAlbums: "Artist's Albums",
artistType: 'Artist Type',
artistNotFound: 'Artist not found',
entityRecording: 'Track',
entityAlbum: 'Album',
entityArtist: 'Artist',
audioQualityHires: 'Hi-Res',
audioQualityLossless: 'Lossless',
audioQualityLossy: 'Lossy',
audioFormatLossless: 'Lossless Formats',
gender: 'Gender',
area: 'Area',
lifeSpan: 'Active Period',
aliases: 'Aliases',
filter: {
mode: 'Mode',
modeChart: 'Top Charts',
modeFresh: 'Fresh Releases',
entity: 'Chart',
entityRecording: 'Top Tracks',
entityAlbum: 'Top Albums',
period: 'Period',
sort: 'Sort',
cover: 'Cover',
scope: 'Scope',
days: 'Time Window',
daysValue: 'Last {days} days',
thisWeek: 'This Week',
thisMonth: 'This Month',
thisYear: 'This Year',
lastWeek: 'Last Week',
lastMonth: 'Last Month',
lastQuarter: 'Last Quarter',
lastHalfYear: 'Last Half Year',
lastYear: 'Last Year',
allTime: 'All Time',
mostListened: 'Most Listened',
leastListened: 'Least Listened',
sortReleaseDate: 'Release Date',
sortArtist: 'Artist',
sortReleaseName: 'Album Name',
released: 'Released',
upcoming: 'Upcoming',
all: 'All',
withCover: 'With Cover Only',
},
},
settingTabs: {
system: {
title: 'System',
@@ -461,6 +550,9 @@ export default {
popular: 'Popular Subscriptions',
share: 'Subscription Shares',
},
music: {
mysub: 'My Subscriptions',
},
},
workflowTabs: {
list: 'My Workflows',
@@ -475,6 +567,7 @@ export default {
douban: 'Douban',
bangumi: 'Bangumi',
anilist: 'AniList',
music: 'Music',
},
user: {
admin: 'Admin',
@@ -771,6 +864,8 @@ export default {
ready: 'Ready',
thinking: 'Thinking',
newChat: 'New Chat',
enterFullscreen: 'Enter fullscreen',
exitFullscreen: 'Exit fullscreen',
history: 'Chat History',
historyLoading: 'Loading chat history...',
historyLoadFailed: 'Failed to load chat history',
@@ -782,7 +877,7 @@ export default {
emptyTitle: 'What should we handle today?',
emptySubtitle: 'Ask about sites, subscriptions, downloads, or organization tasks.',
placeholder: 'Ask MoviePilot, Type / for commands',
processingPlaceholder: 'MoviePilot is working, please wait...',
processingPlaceholder: 'Processing...',
commandLoading: 'Loading commands...',
commandLoadFailed: 'Failed to load commands',
stop: 'Stop generating',
@@ -1066,6 +1161,7 @@ export default {
episodes: 'Episodes',
users: 'Users',
activeUsers: 'Active users',
musicLibrary: 'Music library',
noSchedulers: 'No Background Services',
transferQueue: 'Transfer Queue',
transferProgress: '{completed} / {total} files',
@@ -1270,12 +1366,14 @@ export default {
categoryMovie: 'Movies',
categoryTV: 'TV Shows',
categoryAnime: 'Anime',
categoryMusic: 'Music',
categoryRankings: 'Rankings',
trendingNow: 'Trending Now',
nowShowing: 'Now Showing',
bangumiDaily: 'Bangumi Daily Release',
anilistTrendingNow: 'AniList TRENDING NOW',
anilistPopularThisSeason: 'AniList POPULAR THIS SEASON',
listenBrainzWeekly: 'Weekly Popular Music',
tmdbHotMovies: 'TMDB Hot Movies',
tmdbHotTVShows: 'TMDB Hot TV Shows',
doubanHotMovies: 'Douban Hot Movies',
@@ -1294,8 +1392,11 @@ export default {
selectNone: 'Select None',
},
discover: {
setTabOrder: 'Set Tab Order',
dragToReorder: 'Drag to reorder tabs',
customizeTabs: 'Customize Explore Tabs',
configureTabsHint: 'Drag to reorder tabs and choose which tabs to display',
selectAll: 'Select All',
selectNone: 'Select None',
saveSettingsFailed: 'Failed to save explore tab settings. Please try again later.',
},
downloading: {
noDownloader: 'No Downloader',
@@ -1401,6 +1502,7 @@ export default {
u115: '115 Cloud',
rclone: 'RClone',
alist: 'OpenList',
alistgo: 'AList',
smb: 'SMB Network Share',
custom: 'Custom',
},
@@ -1413,6 +1515,19 @@ export default {
hkVoi: 'Cantonese Dubbing',
notHkVoi: 'Exclude: Cantonese Dubbing',
free: 'Promotion: Free',
audioHires: 'Audio Quality: Hi-Res',
audioLossless: 'Audio Quality: Lossless',
audioFlac: 'Audio Format: FLAC',
audioAlac: 'Audio Format: ALAC',
audioApe: 'Audio Format: APE',
audioWav: 'Audio Format: WAV',
audioDsd: 'Audio Format: DSD',
audioMp3: 'Audio Format: MP3',
audioAac: 'Audio Format: AAC',
audioOpus: 'Audio Format: OPUS',
bitrate320: 'Minimum Bitrate: 320 kbps',
bitrate256: 'Minimum Bitrate: 256 kbps',
bitrate192: 'Minimum Bitrate: 192 kbps',
resolution4k: 'Resolution: 4K',
resolution1080p: 'Resolution: 1080P',
resolution720p: 'Resolution: 720P',
@@ -1997,6 +2112,7 @@ export default {
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: 'Ugreen',
navidrome: 'Navidrome Music Server',
reloadSuccess: 'System configuration has taken effect',
reloadFailed: 'Failed to reload system!',
auxAuthEnable: 'User Auxiliary Authentication',
@@ -2030,11 +2146,15 @@ export default {
tmdbImageDomainPlaceholder: 'image.tmdb.org',
tmdbImageDomainHint: 'Customize TheMovieDb image service domain or proxy address',
tmdbImageDomainRequired: 'Please enter image service domain',
musicCoverProxy: 'Music Cover Proxy Address',
musicCoverProxyPlaceholder: 'https://your-proxy.example.com',
musicCoverProxyHint:
'Customize music cover proxy address to fix cover not showing when coverartarchive.org is inaccessible. Leave empty to use official address',
tmdbLocale: 'TMDB Metadata Language',
tmdbLocalePlaceholder: 'en',
tmdbLocaleHint: 'Customize TheMovieDb metadata language',
metaCacheExpire: 'Media Metadata Cache Expiration Time',
metaCacheExpireHint: 'Recognition metadata local cache time, use built-in default value when set to 0',
metaCacheExpire: 'Per-item Media Metadata Cache TTL',
metaCacheExpireHint: 'Independent TTL for each recognition metadata item; use the built-in default when set to 0',
metaCacheExpireRequired: 'Please enter metadata cache time',
metaCacheExpireMin: 'Metadata cache time must be greater than or equal to 0',
scrapFollowTmdb: 'Follow TMDB Recognition',
@@ -2193,6 +2313,10 @@ export default {
seasonThumb: 'Thumb',
episodeNfo: 'NFO',
episodeThumb: 'Thumb',
music: 'Music',
musicNfo: 'Audio Tags',
musicPoster: 'Cover Art',
musicLyrics: 'Lyrics',
scrapingSwitchSaveFailed: 'Scraping switch settings save failed: {message}',
scrapingSwitchSaveError: 'Scraping switch settings save failed',
policy: {
@@ -2446,6 +2570,8 @@ export default {
'Using Jinja2 syntax, format reference: https://jinja.palletsprojects.com/en/3.0.x/templates',
tvRenameFormat: 'TV Show Rename Format',
tvRenameFormatHint: 'Using Jinja2 syntax, format reference: https://jinja.palletsprojects.com/en/3.0.x/templates',
musicRenameFormat: 'Music Rename Format',
musicRenameFormatHint: 'Uses Jinja2 syntax with music fields such as artist, album, track, and disc',
saveSuccess: 'Storage settings saved successfully',
saveFailed: 'Failed to save storage settings!',
directorySaveSuccess: 'Directory settings saved successfully',
@@ -2754,6 +2880,7 @@ export default {
resourceSearch: 'Resource Search',
movieSubscribe: 'Movie Subscriptions',
tvSubscribe: 'TV Subscriptions',
musicSubscribe: 'Music Subscriptions',
calendar: 'Subscription Calendar',
subscribeShare: 'Subscription Share',
workflow: 'Workflow',
@@ -2768,6 +2895,7 @@ export default {
resourceSearch: 'Site resource search and result page',
movieSubscribe: 'Movie subscription list and popular movies',
tvSubscribe: 'TV subscription list, popular shows, and share tab',
musicSubscribe: 'Music subscriptions and automatic resource search',
calendar: 'Subscription schedule and calendar view',
subscribeShare: 'Subscription share list access',
workflow: 'Workflow list and shared workflows',
@@ -2916,6 +3044,10 @@ export default {
complete: 'Complete',
reset: 'Reset',
},
alistgoConfig: {
title: 'AList Configuration',
serverUrl: 'AList server address',
},
smbConfig: {
title: 'SMB Network Share Configuration',
host: 'SMB Server Address',
@@ -3061,9 +3193,9 @@ export default {
invalidText: 'There are {count} invalid URLs in the text. Fix them before saving.',
invalidTextIgnored: '{count} invalid URLs ignored',
duplicateTextIgnored: 'Duplicate URLs will be removed automatically when saving.',
syncWiki: 'Sync Wiki',
syncSuccess: 'Plugin repositories synced from Wiki. {added} added, {total} total.',
syncFailed: 'Failed to sync Wiki: {message}!',
syncSources: 'Sync Plugin Sources',
syncSuccess: 'Plugin sources synced. {added} added, {total} total.',
syncFailed: 'Failed to sync plugin sources: {message}!',
close: 'Close',
save: 'Save',
saveSuccess: 'Plugin repository saved successfully',
@@ -3161,6 +3293,7 @@ export default {
anilistId: 'AniList ID',
mediaIdHint: 'Query media ID by name, leave empty for auto recognition',
mediaIdPlaceholder: 'Leave empty for auto recognition',
mediaIdInvalid: 'Invalid media ID format',
mediaSearchInput: 'Search Media',
mediaSearchPlaceholder: 'Enter a media title',
episodeGroup: 'Episode Group',
@@ -3272,6 +3405,16 @@ export default {
resolutionHint: 'Subscription resource resolution',
effect: 'Effects',
effectHint: 'Subscription resource effects',
audioQuality: 'Audio Quality',
audioQualityHint: 'Filter by Hi-Res, lossless, or lossy tier',
audioFormat: 'Audio Format',
audioFormatHint: 'Filter by FLAC, ALAC, DSD, MP3, and other formats',
minBitrate: 'Minimum Bitrate',
minBitrateHint: 'Resource titles must declare at least this bitrate',
minBitDepth: 'Minimum Bit Depth',
minBitDepthHint: 'Resource titles must declare at least this bit depth',
minSampleRate: 'Minimum Sample Rate',
minSampleRateHint: 'Resource titles must declare at least this sample rate',
subscribeSites: 'Subscription Sites',
subscribeSitesHint: 'Range of sites for subscription, use system settings if none selected',
downloader: 'Downloader',
@@ -3280,6 +3423,7 @@ export default {
savePathHint: 'Specify download save path for this subscription, leave empty to use default download directory',
bestVersion: 'Version Upgrade',
bestVersionHint: 'Perform version upgrade subscription based on upgrade priorities',
musicBestVersionHint: 'Keep downloading higher-scoring audio versions until top-tier Hi-Res is reached',
bestVersionFull: 'Full Season Upgrade',
bestVersionFullHint: 'Only download full-season packs and do not split packs by episode',
searchImdbid: 'Search Using ImdbID',
@@ -3397,6 +3541,7 @@ export default {
browseTitle: 'Browse - {name}',
searchKeyword: 'Search Keyword',
resourceCategory: 'Resource Category',
noCategory: 'This site has no resource categories configured',
search: 'Search',
itemsPerPage: 'Items Per Page',
noData: 'No Data',
+153 -8
View File
@@ -82,6 +82,7 @@ export default {
mediaType: {
movie: '电影',
tv: '电视剧',
music: '音乐',
anime: '动漫',
collection: '合集',
unknown: '未知',
@@ -125,6 +126,16 @@ export default {
h265: 'H265',
h264: 'H264',
},
audioQualityOptions: {
all: '全部音质',
hires: 'Hi-Res 高解析无损',
lossless: '无损(含 Hi-Res',
lossy: '有损',
},
audioFormatOptions: {
all: '全部格式',
lossless: '全部无损格式',
},
resolutionOptions: {
all: '全部',
'4k': '4k',
@@ -378,6 +389,7 @@ export default {
download: '下载',
movieSubscribe: '电影订阅',
tvSubscribe: '电视剧订阅',
musicSubscribe: '音乐订阅',
history: '历史记录',
transfer: '整理',
rename: '重命名',
@@ -389,6 +401,7 @@ export default {
explore: '探索',
movie: '电影',
tv: '电视剧',
music: '音乐',
workflow: '工作流',
calendar: '日历',
downloadManager: '下载管理',
@@ -399,6 +412,82 @@ export default {
userManager: '用户管理',
settings: '设定',
},
music: {
title: '音乐搜索',
searchFromGlobal: '在顶部全局搜索中输入歌曲、专辑或艺术家进入音乐搜索',
searchDescription: '歌曲、专辑或艺术家',
noResults: '没有找到匹配的音乐',
searchResources: '搜索资源',
subscribe: '订阅',
unsubscribe: '取消订阅',
album: '专辑',
albumArtist: '专辑艺术家',
albumType: '专辑类型',
albumTracks: '专辑内的音乐',
albumNotFound: '没有找到该专辑',
albums: '专辑',
eps: 'EP',
singles: '单曲',
tracks: '曲目',
track: '音轨',
trackTotal: '曲目数',
trackNumber: '第 {number} 首',
trackCount: '{count} 首',
discNumber: '碟 {number}',
releases: '发行版本',
releaseDate: '发行日期',
duration: '时长',
ratingVotes: '评分人数',
listenCount: '收听次数',
listenCountValue: '收听 {count} 次',
relatedArtists: '关联艺术家',
artistAlbums: '艺术家的专辑',
artistType: '艺术家类型',
artistNotFound: '没有找到该艺术家',
entityRecording: '单曲',
entityAlbum: '专辑',
entityArtist: '艺术家',
audioQualityHires: 'Hi-Res',
audioQualityLossless: '无损',
audioQualityLossy: '有损',
audioFormatLossless: '无损格式',
gender: '性别',
area: '地区',
lifeSpan: '活跃时间',
aliases: '别名',
filter: {
mode: '模式',
modeChart: '热门榜单',
modeFresh: '新发行',
entity: '榜单',
entityRecording: '热门单曲',
entityAlbum: '热门专辑',
period: '周期',
sort: '排序',
cover: '封面',
scope: '范围',
days: '时间窗口',
daysValue: '近 {days} 天',
thisWeek: '本周',
thisMonth: '本月',
thisYear: '今年',
lastWeek: '过去一周',
lastMonth: '过去一月',
lastQuarter: '过去一季',
lastHalfYear: '过去半年',
lastYear: '过去一年',
allTime: '全部时间',
mostListened: '收听最多',
leastListened: '收听最少',
sortReleaseDate: '发行日期',
sortArtist: '艺术家',
sortReleaseName: '专辑名称',
released: '已发行',
upcoming: '即将发行',
all: '全部',
withCover: '仅有封面',
},
},
settingTabs: {
system: {
title: '系统',
@@ -452,6 +541,9 @@ export default {
popular: '热门订阅',
share: '订阅分享',
},
music: {
mysub: '我的订阅',
},
},
workflowTabs: {
list: '我的工作流',
@@ -466,6 +558,7 @@ export default {
douban: '豆瓣',
bangumi: 'Bangumi',
anilist: 'AniList',
music: '音乐',
},
user: {
admin: '管理员',
@@ -760,6 +853,8 @@ export default {
ready: '随时待命',
thinking: '思考中',
newChat: '新会话',
enterFullscreen: '全屏显示',
exitFullscreen: '退出全屏',
history: '历史会话',
historyLoading: '正在加载历史会话...',
historyLoadFailed: '历史会话加载失败',
@@ -771,7 +866,7 @@ export default {
emptyTitle: '今天想处理什么?',
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
placeholder: '询问 MoviePilot,输入 / 使用命令',
processingPlaceholder: '智能体正在处理,请稍候...',
processingPlaceholder: '处理中...',
commandLoading: '正在加载命令...',
commandLoadFailed: '命令列表加载失败',
stop: '停止生成',
@@ -1057,6 +1152,7 @@ export default {
episodes: '剧集',
users: '用户',
activeUsers: '活跃用户',
musicLibrary: '音乐媒体库',
noSchedulers: '没有后台服务',
transferQueue: '整理队列',
transferProgress: '{completed} / {total} 个文件',
@@ -1260,12 +1356,14 @@ export default {
categoryMovie: '电影',
categoryTV: '电视剧',
categoryAnime: '动漫',
categoryMusic: '音乐',
categoryRankings: '榜单',
trendingNow: '流行趋势',
nowShowing: '正在热映',
bangumiDaily: 'Bangumi每日放送',
anilistTrendingNow: 'AniList 当前趋势',
anilistPopularThisSeason: 'AniList 本季热门',
listenBrainzWeekly: '本周热门音乐',
tmdbHotMovies: 'TMDB热门电影',
tmdbHotTVShows: 'TMDB热门电视剧',
doubanHotMovies: '豆瓣热门电影',
@@ -1284,8 +1382,11 @@ export default {
selectNone: '全不选',
},
discover: {
setTabOrder: '设置标签顺序',
dragToReorder: '拖动对标签页进行排序',
customizeTabs: '自定义探索标签',
configureTabsHint: '拖动调整标签顺序,并选择要显示的标签',
selectAll: '全选',
selectNone: '全不选',
saveSettingsFailed: '探索标签设置保存失败,请稍后重试',
},
downloading: {
noDownloader: '没有下载器',
@@ -1391,6 +1492,7 @@ export default {
u115: '115网盘',
rclone: 'RClone',
alist: 'OpenList',
alistgo: 'AList',
smb: 'SMB网络共享',
custom: '自定义',
},
@@ -1403,6 +1505,19 @@ export default {
hkVoi: '粤语配音',
notHkVoi: '排除: 粤语配音',
free: '促销: 免费',
audioHires: '音质: Hi-Res',
audioLossless: '音质: 无损',
audioFlac: '音频格式: FLAC',
audioAlac: '音频格式: ALAC',
audioApe: '音频格式: APE',
audioWav: '音频格式: WAV',
audioDsd: '音频格式: DSD',
audioMp3: '音频格式: MP3',
audioAac: '音频格式: AAC',
audioOpus: '音频格式: OPUS',
bitrate320: '最低码率: 320 kbps',
bitrate256: '最低码率: 256 kbps',
bitrate192: '最低码率: 192 kbps',
resolution4k: '分辨率: 4K',
resolution1080p: '分辨率: 1080P',
resolution720p: '分辨率: 720P',
@@ -1975,6 +2090,7 @@ export default {
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '绿联影视',
navidrome: 'Navidrome 音乐服务器',
reloadSuccess: '系统配置已生效',
reloadFailed: '重载系统失败!',
auxAuthEnable: '用户辅助认证',
@@ -2005,11 +2121,15 @@ export default {
tmdbImageDomainPlaceholder: 'image.tmdb.org',
tmdbImageDomainHint: '自定义 TheMovieDb 图片服务域名或代理地址',
tmdbImageDomainRequired: '请输入图片服务域名',
musicCoverProxy: '音乐封面代理地址',
musicCoverProxyPlaceholder: 'https://your-proxy.example.com',
musicCoverProxyHint:
'自定义音乐封面代理地址,用于解决 coverartarchive.org 无法访问导致的封面不显示问题,留空则使用官方地址',
tmdbLocale: 'TMDB 元数据语言',
tmdbLocalePlaceholder: 'zh',
tmdbLocaleHint: '自定义 TheMovieDb 元数据语言',
metaCacheExpire: '媒体元数据缓存过期时间',
metaCacheExpireHint: '识别元数据本地缓存时间,为 0 时使用内置默认值',
metaCacheExpire: '单条媒体元数据缓存有效期',
metaCacheExpireHint: '每条识别元数据的独立有效期,为 0 时使用内置默认值',
metaCacheExpireRequired: '请输入元数据缓存时间',
metaCacheExpireMin: '元数据缓存时间必须大于等于0',
scrapFollowTmdb: '跟随TMDB识别整理',
@@ -2156,6 +2276,10 @@ export default {
seasonThumb: '缩略图',
episodeNfo: 'NFO',
episodeThumb: '缩略图',
music: '音乐',
musicNfo: '音乐标签',
musicPoster: '封面',
musicLyrics: '歌词',
scrapingSwitchSaveFailed: '刮削开关设置保存失败:{message}',
scrapingSwitchSaveError: '刮削开关设置保存失败',
policy: {
@@ -2398,6 +2522,8 @@ export default {
movieRenameFormatHint: '使用Jinja2语法,格式参考:https://jinja.palletsprojects.com/en/3.0.x/templates',
tvRenameFormat: '电视剧重命名格式',
tvRenameFormatHint: '使用Jinja2语法,格式参考:https://jinja.palletsprojects.com/en/3.0.x/templates',
musicRenameFormat: '音乐重命名格式',
musicRenameFormatHint: '使用Jinja2语法,可使用 artist、album、track、disc 等音乐字段',
saveSuccess: '存储设置保存成功',
saveFailed: '存储设置保存失败!',
directorySaveSuccess: '目录设置保存成功',
@@ -2703,6 +2829,7 @@ export default {
resourceSearch: '资源搜索',
movieSubscribe: '电影订阅',
tvSubscribe: '电视剧订阅',
musicSubscribe: '音乐订阅',
calendar: '订阅日历',
subscribeShare: '订阅分享',
workflow: '工作流',
@@ -2717,6 +2844,7 @@ export default {
resourceSearch: '站点资源搜索与资源结果页',
movieSubscribe: '电影订阅列表和热门电影',
tvSubscribe: '电视剧订阅列表、热门剧集和分享标签',
musicSubscribe: '音乐订阅列表与自动资源搜索',
calendar: '订阅排期与日历视图',
subscribeShare: '订阅分享列表访问',
workflow: '工作流列表与分享入口',
@@ -2861,6 +2989,10 @@ export default {
complete: '完成',
reset: '重置',
},
alistgoConfig: {
title: 'AList配置',
serverUrl: 'AList服务地址',
},
smbConfig: {
title: 'SMB网络共享配置',
host: 'SMB服务器地址',
@@ -3006,9 +3138,9 @@ export default {
invalidText: '文本中有 {count} 个无效地址,请修正后保存。',
invalidTextIgnored: '已忽略 {count} 个无效地址',
duplicateTextIgnored: '重复地址会在保存时自动去重。',
syncWiki: '同步 Wiki',
syncSuccess: '已从 Wiki 同步插件仓库,新增 {added} 个,共 {total} 个',
syncFailed: '同步 Wiki 失败:{message}',
syncSources: '同步插件源',
syncSuccess: '已同步插件,新增 {added} 个,共 {total} 个',
syncFailed: '同步插件源失败:{message}',
close: '关闭',
save: '保存',
saveSuccess: '插件仓库保存成功',
@@ -3106,6 +3238,7 @@ export default {
anilistId: 'AniList编号',
mediaIdHint: '按名称查询媒体编号,留空自动识别',
mediaIdPlaceholder: '留空自动识别',
mediaIdInvalid: '媒体ID格式无效',
mediaSearchInput: '搜索媒体',
mediaSearchPlaceholder: '输入媒体名称',
episodeGroup: '剧集组',
@@ -3214,6 +3347,16 @@ export default {
resolutionHint: '订阅资源分辨率',
effect: '特效',
effectHint: '订阅资源特效',
audioQuality: '音质等级',
audioQualityHint: '按 Hi-Res、无损或有损等级筛选',
audioFormat: '音频格式',
audioFormatHint: '按 FLAC、ALAC、DSD、MP3 等格式筛选',
minBitrate: '最低码率',
minBitrateHint: '资源标题必须声明不低于该码率',
minBitDepth: '最低位深',
minBitDepthHint: '资源标题必须声明不低于该位深',
minSampleRate: '最低采样率',
minSampleRateHint: '资源标题必须声明不低于该采样率',
subscribeSites: '订阅站点',
subscribeSitesHint: '订阅的站点范围,不选使用系统设置',
downloader: '下载器',
@@ -3222,6 +3365,7 @@ export default {
savePathHint: '指定该订阅的下载保存路径,留空自动使用设定的下载目录',
bestVersion: '洗版',
bestVersionHint: '根据洗版优先级进行洗版订阅',
musicBestVersionHint: '持续下载音质分数更高的版本,达到顶级 Hi-Res 后完成',
bestVersionFull: '全集洗版',
bestVersionFullHint: '只下载覆盖全集的整包资源,不按单集拆包下载',
searchImdbid: '使用 ImdbID 搜索',
@@ -3339,6 +3483,7 @@ export default {
browseTitle: '浏览 - {name}',
searchKeyword: '搜索关键字',
resourceCategory: '资源分类',
noCategory: '该站点未配置资源分类',
search: '搜索',
itemsPerPage: '每页条数',
noData: '没有数据',
+153 -8
View File
@@ -82,6 +82,7 @@ export default {
mediaType: {
movie: '電影',
tv: '電視劇',
music: '音樂',
anime: '動漫',
collection: '合集',
unknown: '未知',
@@ -125,6 +126,16 @@ export default {
h265: 'H265',
h264: 'H264',
},
audioQualityOptions: {
all: '全部音質',
hires: 'Hi-Res 高解析無損',
lossless: '無損(含 Hi-Res',
lossy: '有損',
},
audioFormatOptions: {
all: '全部格式',
lossless: '全部無損格式',
},
resolutionOptions: {
all: '全部',
'4k': '4k',
@@ -378,6 +389,7 @@ export default {
download: '下載',
movieSubscribe: '電影訂閱',
tvSubscribe: '電視劇訂閱',
musicSubscribe: '音樂訂閱',
history: '歷史記錄',
transfer: '整理',
rename: '重命名',
@@ -389,6 +401,7 @@ export default {
explore: '探索',
movie: '電影',
tv: '電視劇',
music: '音樂',
workflow: '工作流',
calendar: '日曆',
downloadManager: '下載管理',
@@ -399,6 +412,82 @@ export default {
userManager: '用戶管理',
settings: '設定',
},
music: {
title: '音樂搜尋',
searchFromGlobal: '在頂部全域搜尋中輸入歌曲、專輯或藝術家進入音樂搜尋',
searchDescription: '歌曲、專輯或藝術家',
noResults: '沒有找到符合的音樂',
searchResources: '搜尋資源',
subscribe: '訂閱',
unsubscribe: '取消訂閱',
album: '專輯',
albumArtist: '專輯藝術家',
albumType: '專輯類型',
albumTracks: '專輯內的音樂',
albumNotFound: '沒有找到該專輯',
albums: '專輯',
eps: 'EP',
singles: '單曲',
tracks: '曲目',
track: '音軌',
trackTotal: '曲目數',
trackNumber: '第 {number} 首',
trackCount: '{count} 首',
discNumber: '碟 {number}',
releases: '發行版本',
releaseDate: '發行日期',
duration: '時長',
ratingVotes: '評分人數',
listenCount: '收聽次數',
listenCountValue: '收聽 {count} 次',
relatedArtists: '關聯藝術家',
artistAlbums: '藝術家的專輯',
artistType: '藝術家類型',
artistNotFound: '沒有找到該藝術家',
entityRecording: '單曲',
entityAlbum: '專輯',
entityArtist: '藝術家',
audioQualityHires: 'Hi-Res',
audioQualityLossless: '無損',
audioQualityLossy: '有損',
audioFormatLossless: '無損格式',
gender: '性別',
area: '地區',
lifeSpan: '活躍時間',
aliases: '別名',
filter: {
mode: '模式',
modeChart: '熱門榜單',
modeFresh: '新發行',
entity: '榜單',
entityRecording: '熱門單曲',
entityAlbum: '熱門專輯',
period: '週期',
sort: '排序',
cover: '封面',
scope: '範圍',
days: '時間視窗',
daysValue: '近 {days} 天',
thisWeek: '本週',
thisMonth: '本月',
thisYear: '今年',
lastWeek: '過去一週',
lastMonth: '過去一月',
lastQuarter: '過去一季',
lastHalfYear: '過去半年',
lastYear: '過去一年',
allTime: '全部時間',
mostListened: '收聽最多',
leastListened: '收聽最少',
sortReleaseDate: '發行日期',
sortArtist: '藝術家',
sortReleaseName: '專輯名稱',
released: '已發行',
upcoming: '即將發行',
all: '全部',
withCover: '僅有封面',
},
},
settingTabs: {
system: {
title: '系統',
@@ -452,6 +541,9 @@ export default {
popular: '熱門訂閱',
share: '訂閱分享',
},
music: {
mysub: '我的訂閱',
},
},
workflowTabs: {
list: '我的工作流',
@@ -466,6 +558,7 @@ export default {
douban: '豆瓣',
bangumi: 'Bangumi',
anilist: 'AniList',
music: '音樂',
},
user: {
admin: '管理員',
@@ -760,6 +853,8 @@ export default {
ready: '隨時待命',
thinking: '思考中',
newChat: '新會話',
enterFullscreen: '全屏顯示',
exitFullscreen: '退出全屏',
history: '歷史會話',
historyLoading: '正在載入歷史會話...',
historyLoadFailed: '歷史會話載入失敗',
@@ -771,7 +866,7 @@ export default {
emptyTitle: '今天想處理什麼?',
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
placeholder: '詢問 MoviePilot,輸入 / 使用命令',
processingPlaceholder: '智能體正在處理,請稍候...',
processingPlaceholder: '處理中...',
commandLoading: '正在載入命令...',
commandLoadFailed: '命令列表載入失敗',
stop: '停止生成',
@@ -1057,6 +1152,7 @@ export default {
episodes: '劇集',
users: '用戶',
activeUsers: '活躍用戶',
musicLibrary: '音樂媒體庫',
noSchedulers: '沒有後台服務',
transferQueue: '整理隊列',
transferProgress: '{completed} / {total} 個文件',
@@ -1258,12 +1354,14 @@ export default {
categoryMovie: '電影',
categoryTV: '電視劇',
categoryAnime: '動漫',
categoryMusic: '音樂',
categoryRankings: '榜單',
trendingNow: '流行趨勢',
nowShowing: '正在熱映',
bangumiDaily: 'Bangumi每日放送',
anilistTrendingNow: 'AniList 當前趨勢',
anilistPopularThisSeason: 'AniList 本季熱門',
listenBrainzWeekly: '本週熱門音樂',
tmdbHotMovies: 'TMDB熱門電影',
tmdbHotTVShows: 'TMDB熱門電視劇',
doubanHotMovies: '豆瓣熱門電影',
@@ -1282,8 +1380,11 @@ export default {
selectNone: '全不選',
},
discover: {
setTabOrder: '設置標籤順序',
dragToReorder: '拖動對標籤頁進行排序',
customizeTabs: '自定義探索標籤',
configureTabsHint: '拖動調整標籤順序,並選擇要顯示的標籤',
selectAll: '全選',
selectNone: '全不選',
saveSettingsFailed: '探索標籤設置保存失敗,請稍後重試',
},
downloading: {
noDownloader: '沒有下載器',
@@ -1389,6 +1490,7 @@ export default {
u115: '115網盤',
rclone: 'RClone',
alist: 'OpenList',
alistgo: 'AList',
smb: 'SMB網路共享',
custom: '自定義',
},
@@ -1402,6 +1504,19 @@ export default {
hkVoi: '粵語配音',
notHkVoi: '排除: 粵語配音',
free: '促銷: 免費',
audioHires: '音質: Hi-Res',
audioLossless: '音質: 無損',
audioFlac: '音訊格式: FLAC',
audioAlac: '音訊格式: ALAC',
audioApe: '音訊格式: APE',
audioWav: '音訊格式: WAV',
audioDsd: '音訊格式: DSD',
audioMp3: '音訊格式: MP3',
audioAac: '音訊格式: AAC',
audioOpus: '音訊格式: OPUS',
bitrate320: '最低碼率: 320 kbps',
bitrate256: '最低碼率: 256 kbps',
bitrate192: '最低碼率: 192 kbps',
resolution4k: '解析度: 4K',
resolution1080p: '解析度: 1080P',
resolution720p: '解析度: 720P',
@@ -1974,6 +2089,7 @@ export default {
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '綠聯影視',
navidrome: 'Navidrome 音樂伺服器',
reloadSuccess: '系統配置已生效',
reloadFailed: '重載系統失敗!',
auxAuthEnable: '用戶輔助認證',
@@ -2004,11 +2120,15 @@ export default {
tmdbImageDomainPlaceholder: 'image.tmdb.org',
tmdbImageDomainHint: '自定義 TheMovieDb 圖片服務域名或代理地址',
tmdbImageDomainRequired: '請輸入圖片服務域名',
musicCoverProxy: '音樂封面代理地址',
musicCoverProxyPlaceholder: 'https://your-proxy.example.com',
musicCoverProxyHint:
'自定義音樂封面代理地址,用於解決 coverartarchive.org 無法訪問導致的封面不顯示問題,留空則使用官方地址',
tmdbLocale: 'TMDB 元數據語言',
tmdbLocalePlaceholder: 'zh',
tmdbLocaleHint: '自定義 TheMovieDb 元數據語言',
metaCacheExpire: '媒體元數據緩存過期時間',
metaCacheExpireHint: '識別元數據本地緩存時間,為 0 時使用內置默認值',
metaCacheExpire: '單筆媒體元數據緩存有效期',
metaCacheExpireHint: '每筆識別元數據的獨立有效期,為 0 時使用內置默認值',
metaCacheExpireRequired: '請輸入元數據緩存時間',
metaCacheExpireMin: '元數據緩存時間必須大於等於0',
scrapFollowTmdb: '跟隨TMDB識別整理',
@@ -2155,6 +2275,10 @@ export default {
seasonThumb: '縮略圖',
episodeNfo: 'NFO',
episodeThumb: '縮略圖',
music: '音樂',
musicNfo: '音樂標籤',
musicPoster: '封面',
musicLyrics: '歌詞',
scrapingSwitchSaveFailed: '刮削開關設定保存失敗:{message}',
scrapingSwitchSaveError: '刮削開關設定保存失敗',
policy: {
@@ -2397,6 +2521,8 @@ export default {
movieRenameFormatHint: '使用Jinja2語法,格式參考:https://jinja.palletsprojects.com/en/3.0.x/templates',
tvRenameFormat: '電視劇重命名格式',
tvRenameFormatHint: '使用Jinja2語法,格式參考:https://jinja.palletsprojects.com/en/3.0.x/templates',
musicRenameFormat: '音樂重命名格式',
musicRenameFormatHint: '使用Jinja2語法,可使用 artist、album、track、disc 等音樂字段',
saveSuccess: '存儲設置保存成功',
saveFailed: '存儲設置保存失敗!',
directorySaveSuccess: '目錄設置保存成功',
@@ -2702,6 +2828,7 @@ export default {
resourceSearch: '資源搜索',
movieSubscribe: '電影訂閱',
tvSubscribe: '電視劇訂閱',
musicSubscribe: '音樂訂閱',
calendar: '訂閱日曆',
subscribeShare: '訂閱分享',
workflow: '工作流',
@@ -2716,6 +2843,7 @@ export default {
resourceSearch: '站點資源搜索與資源結果頁',
movieSubscribe: '電影訂閱列表和熱門電影',
tvSubscribe: '電視劇訂閱列表、熱門劇集和分享標籤',
musicSubscribe: '音樂訂閱列表與自動資源搜索',
calendar: '訂閱排期與日曆視圖',
subscribeShare: '訂閱分享列表存取',
workflow: '工作流列表與分享入口',
@@ -2860,6 +2988,10 @@ export default {
complete: '完成',
reset: '重置',
},
alistgoConfig: {
title: 'AList配置',
serverUrl: 'AList服務地址',
},
smbConfig: {
title: 'SMB網路共享配置',
host: 'SMB伺服器地址',
@@ -3005,9 +3137,9 @@ export default {
invalidText: '文字中有 {count} 個無效地址,請修正後儲存。',
invalidTextIgnored: '已忽略 {count} 個無效地址',
duplicateTextIgnored: '重複地址會在儲存時自動去重。',
syncWiki: '同步 Wiki',
syncSuccess: '已從 Wiki 同步插件倉庫,新增 {added} 個,共 {total} 個',
syncFailed: '同步 Wiki 失敗:{message}',
syncSources: '同步插件源',
syncSuccess: '已同步插件,新增 {added} 個,共 {total} 個',
syncFailed: '同步插件源失敗:{message}',
close: '關閉',
save: '儲存',
saveSuccess: '插件倉庫儲存成功',
@@ -3105,6 +3237,7 @@ export default {
anilistId: 'AniList編號',
mediaIdHint: '按名稱查詢媒體編號,留空自動識別',
mediaIdPlaceholder: '留空自動識別',
mediaIdInvalid: '媒體ID格式無效',
mediaSearchInput: '搜索媒體',
mediaSearchPlaceholder: '輸入媒體名稱',
episodeGroup: '劇集組',
@@ -3213,6 +3346,16 @@ export default {
resolutionHint: '訂閱資源分辨率',
effect: '特效',
effectHint: '訂閱資源特效',
audioQuality: '音質等級',
audioQualityHint: '按 Hi-Res、無損或有損等級篩選',
audioFormat: '音訊格式',
audioFormatHint: '按 FLAC、ALAC、DSD、MP3 等格式篩選',
minBitrate: '最低碼率',
minBitrateHint: '資源標題必須聲明不低於該碼率',
minBitDepth: '最低位深',
minBitDepthHint: '資源標題必須聲明不低於該位深',
minSampleRate: '最低採樣率',
minSampleRateHint: '資源標題必須聲明不低於該採樣率',
subscribeSites: '訂閱站點',
subscribeSitesHint: '訂閱的站點範圍,不選使用系統設置',
downloader: '下載器',
@@ -3221,6 +3364,7 @@ export default {
savePathHint: '指定該訂閱的下載儲存路徑,留空自動使用設定的下載目錄',
bestVersion: '洗版',
bestVersionHint: '根據洗版優先級進行洗版訂閱',
musicBestVersionHint: '持續下載音質分數更高的版本,達到頂級 Hi-Res 後完成',
bestVersionFull: '全集洗版',
bestVersionFullHint: '只下載覆蓋全集的整包資源,不按單集拆包下載',
searchImdbid: '使用 ImdbID 搜索',
@@ -3338,6 +3482,7 @@ export default {
browseTitle: '瀏覽 - {name}',
searchKeyword: '搜索關鍵字',
resourceCategory: '資源分類',
noCategory: '該站點未配置資源分類',
search: '搜索',
itemsPerPage: '每頁條數',
pageText: '{0}-{1} 共 {2} 條',
+7
View File
@@ -62,6 +62,13 @@ async function renderAppCenter(items: PluginSidebarNavItem[], permissions: Recor
}
describe('app center plugin navigation', () => {
it('keeps resource search and omits the standalone music search menu', async () => {
await renderAppCenter([])
expect(await screen.findByText('搜索结果')).toBeInTheDocument()
expect(screen.queryByText('音乐搜索')).not.toBeInTheDocument()
})
it('uses the shared category, admin and feature permission semantics', async () => {
await renderAppCenter(
[
+183 -43
View File
@@ -1,5 +1,6 @@
import DiscoverPage from '@/pages/discover.vue'
import type { DiscoverSource } from '@/api/types'
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
import { fireEvent, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import {
@@ -7,7 +8,7 @@ import {
discoverOrderConfigHandler,
discoverSourcesHandler,
saveDiscoverOrderHandler,
type DiscoverOrderItem,
type DiscoverTabConfigItem,
} from '@tests/support/msw/handlers/discover'
import { server } from '@tests/support/msw/server'
import { HttpResponse, http } from 'msw'
@@ -20,14 +21,19 @@ interface HeaderTabItem {
}
interface HeaderTabConfig {
appendButtons: Array<{ action: () => void }>
appendButtons?: Array<{ action: () => void }>
items: ComputedRef<HeaderTabItem[]> | Ref<HeaderTabItem[]> | HeaderTabItem[]
modelValue: Ref<string>
}
interface DiscoverTabSettingsPayload {
enabled: Record<string, boolean>
tabs: DiscoverSource[]
}
interface SharedDialogEvents {
close: () => void
save: (tabs: DiscoverSource[]) => Promise<void>
save: (settings: DiscoverTabSettingsPayload) => Promise<void>
'update:modelValue': (value: boolean) => void
}
@@ -41,16 +47,33 @@ const mocks = vi.hoisted(() => ({
controllers: [] as SharedDialogController[],
openSharedDialog: vi.fn(),
registerHeaderTab: vi.fn(),
toastError: vi.fn(),
useDynamicButton: vi.fn(),
}))
vi.mock('@/composables/useDynamicHeaderTab', () => ({
useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }),
}))
vi.mock('@/composables/useDynamicButton', () => ({
useDynamicButton: (options: unknown) => mocks.useDynamicButton(options),
}))
vi.mock('@/composables/usePWA', async () => {
const { ref } = await import('vue')
return {
usePWA: () => ({ appMode: ref(false) }),
}
})
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError }),
}))
const BuiltInViewStub = defineComponent({
name: 'BuiltInViewStub',
setup: () => () => h('section', '内置发现内容'),
@@ -91,10 +114,16 @@ function keepAliveHarness() {
})
}
async function renderDiscover() {
async function renderDiscover(options: { discovery?: boolean; superUser?: boolean } = {}) {
const componentError = vi.fn()
const result = await renderWithProviders(keepAliveHarness(), {
initialRoute: '/discover',
initialState: {
user: {
permissions: { ...DEFAULT_PERMISSIONS, discovery: options.discovery ?? true },
superUser: options.superUser ?? false,
},
},
global: {
config: {
errorHandler: componentError,
@@ -104,6 +133,7 @@ async function renderDiscover() {
BangumiView: BuiltInViewStub,
DoubanView: BuiltInViewStub,
ExtraSourceView: ExtraSourceViewStub,
MusicView: BuiltInViewStub,
TheMovieDbView: BuiltInViewStub,
VScrollToTopBtn: true,
},
@@ -127,11 +157,18 @@ function getDialogCall(index = 0) {
const call = mocks.openSharedDialog.mock.calls[index]
if (!call) throw new Error(`未找到第 ${index + 1} 个排序弹窗`)
return {
enabled: (call[1] as { enabled: Record<string, boolean> }).enabled,
events: call[2] as SharedDialogEvents,
tabs: (call[1] as { tabs: DiscoverSource[] }).tabs,
}
}
function getSettingsButton() {
const button = document.querySelector<HTMLButtonElement>('.compact-fab')
if (!button) throw new Error('未找到探索设置 FAB')
return button
}
async function reactivateDiscover() {
await fireEvent.click(document.querySelector('button') as HTMLButtonElement)
const buttons = Array.from(document.querySelectorAll('button'))
@@ -143,6 +180,7 @@ async function reactivateDiscover() {
describe('discover page', () => {
beforeEach(() => {
mocks.controllers.length = 0
server.use(discoverOrderConfigHandler(null))
mocks.openSharedDialog.mockImplementation(() => {
const controller: SharedDialogController = {
close: vi.fn(),
@@ -154,14 +192,11 @@ describe('discover page', () => {
})
})
it('uses local order, merges sources by prefix, and keeps unconfigured tabs stable', async () => {
it('falls back to legacy local order when the server has no config and keeps new tabs visible', async () => {
const configRequested = vi.fn()
localStorage.setItem(
'MP_DISCOVER_TAB_ORDER',
JSON.stringify([{ name: '豆瓣' }, { name: '自定义来源' }]),
)
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([{ name: '豆瓣' }, { name: '自定义来源' }]))
server.use(
discoverOrderConfigHandler([], 200, configRequested),
discoverOrderConfigHandler(null, 200, configRequested),
discoverSourcesHandler([
createSource('自定义来源', 'custom'),
createSource('重复自定义来源', 'custom'),
@@ -178,10 +213,18 @@ describe('discover page', () => {
'TheMovieDb',
'Bangumi',
'AniList',
'音乐',
]),
)
expect(configRequested).not.toHaveBeenCalled()
expect(getHeaderItems().map(item => item.tab)).toEqual(['douban', 'custom', 'themoviedb', 'bangumi', 'anilist'])
expect(configRequested).toHaveBeenCalledOnce()
expect(getHeaderItems().map(item => item.tab)).toEqual([
'douban',
'custom',
'themoviedb',
'bangumi',
'anilist',
'musicbrainz',
])
})
it('loads remote order when local order is absent and backfills localStorage', async () => {
@@ -201,10 +244,37 @@ describe('discover page', () => {
'TheMovieDb',
'豆瓣',
'AniList',
'音乐',
'自定义来源',
]),
)
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder))
expect(JSON.parse(localStorage.getItem('MP_DISCOVER_TAB_ORDER') ?? 'null')).toEqual(
remoteOrder.map(item => ({ enabled: true, name: item.name })),
)
})
it('uses the server config over a stale browser cache and hides disabled tabs', async () => {
localStorage.setItem(
'MP_DISCOVER_TAB_ORDER',
JSON.stringify([
{ enabled: true, mediaid_prefix: 'douban', name: '豆瓣' },
{ enabled: true, mediaid_prefix: 'themoviedb', name: 'TheMovieDb' },
]),
)
const remoteConfig: DiscoverTabConfigItem[] = [
{ enabled: false, mediaid_prefix: 'douban', name: '豆瓣' },
{ enabled: true, mediaid_prefix: 'musicbrainz', name: '音乐' },
{ enabled: true, mediaid_prefix: 'themoviedb', name: 'TheMovieDb' },
]
server.use(discoverOrderConfigHandler(remoteConfig), discoverSourcesHandler([]))
await renderDiscover()
await waitFor(() =>
expect(getHeaderItems().map(item => item.title)).toEqual(['音乐', 'TheMovieDb', 'Bangumi', 'AniList']),
)
expect(getHeaderConfig().modelValue.value).toBe('musicbrainz')
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteConfig))
})
it('falls back to remote order when local JSON is malformed', async () => {
@@ -216,23 +286,31 @@ describe('discover page', () => {
const { componentError } = await renderDiscover()
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣', 'AniList']))
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteOrder))
await waitFor(() =>
expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣', 'AniList', '音乐']),
)
expect(JSON.parse(localStorage.getItem('MP_DISCOVER_TAB_ORDER') ?? 'null')).toEqual(
remoteOrder.map(item => ({ enabled: true, name: item.name })),
)
expect(componentError).not.toHaveBeenCalled()
})
it('keeps built-in and extra sources usable when the order config request fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
server.use(
discoverOrderConfigHandler(null, 500),
discoverSourcesHandler([createSource('可用扩展源', 'available')]),
)
server.use(discoverOrderConfigHandler(null, 500), discoverSourcesHandler([createSource('可用扩展源', 'available')]))
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('可用扩展源'))
expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', 'AniList', '可用扩展源'])
expect(getHeaderItems().map(item => item.title)).toEqual([
'TheMovieDb',
'豆瓣',
'Bangumi',
'AniList',
'音乐',
'可用扩展源',
])
expect(getHeaderConfig().modelValue.value).toBe('themoviedb')
})
@@ -276,10 +354,7 @@ describe('discover page', () => {
it('removes a withdrawn source and falls back to the first sorted tab after reactivation', async () => {
let sources = [createSource('已撤销来源', 'withdrawn')]
const requested = vi.fn()
localStorage.setItem(
'MP_DISCOVER_TAB_ORDER',
JSON.stringify([{ name: '已撤销来源' }, { name: 'TheMovieDb' }]),
)
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([{ name: '已撤销来源' }, { name: 'TheMovieDb' }]))
server.use(
http.get(discoverApiUrls.sources, () => {
requested()
@@ -319,7 +394,14 @@ describe('discover page', () => {
await waitFor(() => expect(requested).toHaveBeenCalledTimes(requestsBeforeReactivation + 1))
expect(getHeaderItems().map(item => item.title)).toContain('缓存来源')
expect(getHeaderItems().map(item => item.title)).toEqual(['TheMovieDb', '豆瓣', 'Bangumi', 'AniList', '缓存来源'])
expect(getHeaderItems().map(item => item.title)).toEqual([
'TheMovieDb',
'豆瓣',
'Bangumi',
'AniList',
'音乐',
'缓存来源',
])
})
it('replaces the header metadata when a source with the same prefix changes', async () => {
@@ -344,37 +426,95 @@ describe('discover page', () => {
expect(getHeaderItems().map(item => item.title)).not.toContain('旧来源名称')
})
it('saves the exact visible order through the shared dialog boundary', async () => {
const savedOrders: DiscoverOrderItem[][] = []
it('registers the settings entry in the Footer area and renders the matching desktop FAB', async () => {
const sourcesRequested = vi.fn()
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(discoverSourcesHandler([], 200, sourcesRequested))
await renderDiscover()
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
expect(getHeaderConfig().appendButtons).toBeUndefined()
expect(mocks.useDynamicButton).toHaveBeenCalledWith(
expect.objectContaining({ icon: 'mdi-tune', permission: 'discovery' }),
)
expect(getSettingsButton()).toHaveAccessibleName('自定义探索标签')
})
it.each([
{ discovery: false, superUser: false, visible: false },
{ discovery: false, superUser: true, visible: true },
])('applies discovery permission to the desktop settings entry', async ({ discovery, superUser, visible }) => {
const sourcesRequested = vi.fn()
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(discoverSourcesHandler([], 200, sourcesRequested))
await renderDiscover({ discovery, superUser })
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
expect(Boolean(document.querySelector('.compact-fab'))).toBe(visible)
})
it('saves the exact order and visibility through the shared dialog boundary', async () => {
const savedConfigs: DiscoverTabConfigItem[][] = []
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(
discoverSourcesHandler([createSource('自定义来源', 'custom')]),
saveDiscoverOrderHandler(order => {
savedOrders.push(order)
saveDiscoverOrderHandler(config => {
savedConfigs.push(config)
}),
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems()).toHaveLength(5))
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('自定义来源'))
getHeaderConfig().appendButtons[0].action()
const { events, tabs } = getDialogCall()
const reorderedTabs = [tabs[4], tabs[1], tabs[0], tabs[3], tabs[2]]
await events.save(reorderedTabs)
getSettingsButton().click()
const { enabled, events, tabs } = getDialogCall()
const reorderedTabs = [tabs[4], tabs[1], tabs[0], tabs[3], tabs[2], tabs[5]]
const nextEnabled: Record<string, boolean> = { ...enabled, themoviedb: false }
await events.save({ enabled: nextEnabled, tabs: reorderedTabs })
const expectedOrder = reorderedTabs.map(item => ({ name: item.name }))
expect(savedOrders).toEqual([expectedOrder])
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(expectedOrder))
expect(getHeaderItems().map(item => item.title)).toEqual(reorderedTabs.map(item => item.name))
const expectedConfig = reorderedTabs.map(item => ({
enabled: nextEnabled[item.mediaid_prefix] !== false,
mediaid_prefix: item.mediaid_prefix,
name: item.name,
}))
expect(savedConfigs).toEqual([expectedConfig])
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(expectedConfig))
expect(getHeaderItems().map(item => item.title)).toEqual(
reorderedTabs.filter(item => nextEnabled[item.mediaid_prefix] !== false).map(item => item.name),
)
expect(getHeaderConfig().modelValue.value).toBe('musicbrainz')
expect(mocks.controllers[0].close).toHaveBeenCalledOnce()
})
it('closes the previous controller before opening another order dialog', async () => {
it('keeps the settings dialog open and leaves page state unchanged when server persistence fails', async () => {
const sourcesRequested = vi.fn()
vi.spyOn(console, 'error').mockImplementation(() => {})
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(
discoverSourcesHandler([], 200, sourcesRequested),
saveDiscoverOrderHandler(() => {}, 500),
)
await renderDiscover()
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
const originalTabs = getHeaderItems().map(item => item.title)
getSettingsButton().click()
const { enabled, events, tabs } = getDialogCall()
await events.save({ enabled: { ...enabled, themoviedb: false }, tabs })
expect(getHeaderItems().map(item => item.title)).toEqual(originalTabs)
expect(mocks.controllers[0].close).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('探索标签设置保存失败,请稍后重试')
})
it('closes the previous controller before opening another settings dialog', async () => {
localStorage.setItem('MP_DISCOVER_TAB_ORDER', JSON.stringify([]))
server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')]))
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
const action = getHeaderConfig().appendButtons[0].action
const action = () => getSettingsButton().click()
action()
action()
@@ -388,7 +528,7 @@ describe('discover page', () => {
server.use(discoverSourcesHandler([createSource('弹窗就绪来源', 'dialog-ready')]))
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
const action = getHeaderConfig().appendButtons[0].action
const action = () => getSettingsButton().click()
action()
getDialogCall(0).events.close()
@@ -420,11 +560,11 @@ describe('discover page', () => {
)
await renderDiscover()
await waitFor(() => expect(getHeaderItems().map(item => item.title)).toContain('弹窗就绪来源'))
const action = getHeaderConfig().appendButtons[0].action
const action = () => getSettingsButton().click()
action()
const firstDialog = getDialogCall(0)
const pendingSave = firstDialog.events.save(firstDialog.tabs)
const pendingSave = firstDialog.events.save({ enabled: firstDialog.enabled, tabs: firstDialog.tabs })
await waitFor(() => expect(saveStarted).toHaveBeenCalledOnce())
firstDialog.events.close()
action()
+200
View File
@@ -0,0 +1,200 @@
import MusicAlbumPage from '@/pages/music-album.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
post: (...args: unknown[]) => mocks.apiPost(...args),
delete: (...args: unknown[]) => mocks.apiDelete(...args),
},
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({
error: mocks.toastError,
success: mocks.toastSuccess,
}),
}))
const album = {
album_type: 'Album',
artist: 'Queen',
artists: ['Queen'],
artist_ids: ['artist-1'],
category: 'Album',
cover_url: 'https://coverartarchive.org/release-group/release-group-1/front-500',
duration: 2580,
genres: ['rock', 'art rock'],
media_id: 'release-group-1',
music_type: 'album',
rating: 8.5,
rating_votes: 44,
release_date: '1975-11-21',
releases: [
{
media_id: 'release-1',
title: 'A Night at the Opera',
date: '1975-11-21',
country: 'GB',
formats: ['12" Vinyl'],
track_count: 12,
},
],
source: 'musicbrainz',
title: 'A Night at the Opera',
total_tracks: 2,
tracks: [
{
media_id: 'recording-1',
title: 'Death on Two Legs',
track_number: 1,
disc_number: 1,
duration: 224,
source: 'musicbrainz',
},
{
media_id: 'recording-2',
title: 'Bohemian Rhapsody',
track_number: 2,
disc_number: 1,
duration: 355,
source: 'musicbrainz',
},
],
type: '音乐',
year: 1975,
}
const musicSite = { id: 13, is_active: true, name: '专辑站点', url: 'https://album-music.example' }
/** 按请求路径分派专辑详情与订阅状态查询。 */
function mockAlbumRequests(subscribed = false) {
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'music/album/release-group-1') return Promise.resolve(album)
if (path === 'site/media/music') return Promise.resolve([musicSite])
if (path === 'system/setting/public/IndexerSites') {
return Promise.resolve({ data: { value: [13] }, success: true })
}
if (path.startsWith('subscribe/media/')) {
return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } })
}
return Promise.resolve([])
})
mocks.apiPost.mockResolvedValue({ data: { id: 1 }, success: true })
}
/** 渲染专辑详情页,统一提供超级用户权限与路由身份。 */
function renderAlbumPage() {
return renderWithProviders(MusicAlbumPage, {
initialRoute: '/music/album?source=musicbrainz&mediaid=release-group-1',
initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: true } },
})
}
describe('music album page', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.apiDelete.mockReset()
mocks.openSharedDialog.mockReset()
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
mockAlbumRequests()
})
it('loads the album with its tracks and release variants', async () => {
await renderAlbumPage()
expect(await screen.findByRole('heading', { name: 'A Night at the Opera' })).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledWith('music/album/release-group-1', {
params: { source: 'musicbrainz' },
})
expect(screen.getByText('Bohemian Rhapsody')).toBeInTheDocument()
expect(screen.getByText('5:55')).toBeInTheDocument()
expect(screen.getByText('rock')).toBeInTheDocument()
expect(screen.getByText(/12" Vinyl/)).toBeInTheDocument()
})
it('opens the music detail page from a track row', async () => {
const { router } = await renderAlbumPage()
await fireEvent.click(await screen.findByText('Bohemian Rhapsody'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/detail'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'recording-2' })
})
it('opens the artist page from the header artist link', async () => {
const { router } = await renderAlbumPage()
await fireEvent.click(await screen.findByRole('link', { name: 'Queen' }))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/artist'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'artist-1' })
})
it('subscribes the whole album from the heart action', async () => {
await renderAlbumPage()
await fireEvent.click(await screen.findByRole('button', { name: '订阅' }))
await waitFor(() =>
expect(mocks.apiPost).toHaveBeenCalledWith(
'subscribe/',
expect.objectContaining({
media_id: 'release-group-1',
media_source: 'musicbrainz',
name: 'A Night at the Opera',
type: '音乐',
year: '1975',
}),
),
)
})
it('switches the heart action to unsubscribe when already subscribed', async () => {
mockAlbumRequests(true)
await renderAlbumPage()
expect(await screen.findByRole('button', { name: '取消订阅' })).toBeInTheDocument()
})
it('selects a music-capable site before searching album resources', async () => {
const { router } = await renderAlbumPage()
await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
unknown,
{ sites: Array<{ id: number }> },
{ search: (sites: number[]) => void },
]
expect(dialogProps.sites).toEqual([musicSite])
expect(router.currentRoute.value.path).toBe('/music/album')
dialogEvents.search([13])
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: 'musicbrainz:release-group-1',
sites: '13',
type: '音乐',
})
})
})
+122
View File
@@ -0,0 +1,122 @@
import MusicArtistPage from '@/pages/music-artist.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
openSharedDialog: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
},
}))
// 桩组件回显标题与“更多”链接,用于校验影视详情页对齐后的瀑布浏览入口
const MediaCardSlideViewStub = defineComponent({
props: ['apipath', 'title', 'linkurl'],
template: '<a v-if="linkurl" :href="linkurl">{{ title }}</a><span v-else>{{ title }}</span>',
})
const artist = {
aliases: ['皇后乐队'],
area: 'United Kingdom',
artist_type: 'Group',
begin_date: '1970-06-27',
country: 'GB',
detail_link: 'https://musicbrainz.org/artist/artist-1',
disambiguation: 'UK rock group',
external_links: { 'official homepage': 'http://www.queenonline.com/' },
genres: ['rock', 'glam rock'],
life_span: '1970-06-27',
media_id: 'artist-1',
music_type: 'artist',
name: 'Queen',
source: 'musicbrainz',
type: '音乐',
}
const musicSite = { id: 14, is_active: true, name: '艺术家站点', url: 'https://artist-music.example' }
/** 渲染艺术家详情页,统一提供超级用户权限与路由身份。 */
function renderArtistPage() {
return renderWithProviders(MusicArtistPage, {
initialRoute: '/music/artist?source=musicbrainz&mediaid=artist-1',
initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true } },
})
}
describe('music artist page', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.openSharedDialog.mockReset()
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'music/artist/artist-1') return Promise.resolve(artist)
if (path === 'site/media/music') return Promise.resolve([musicSite])
if (path === 'system/setting/public/IndexerSites') {
return Promise.resolve({ data: { value: [14] }, success: true })
}
return Promise.resolve([])
})
})
it('loads the artist profile with area, active period and aliases', async () => {
await renderArtistPage()
expect(await screen.findByRole('heading', { name: 'Queen' })).toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledWith('music/artist/artist-1', {
params: { source: 'musicbrainz' },
})
expect(screen.getByText('UK rock group')).toBeInTheDocument()
expect(screen.getAllByText('United Kingdom').length).toBeGreaterThan(0)
expect(screen.getByText('皇后乐队')).toBeInTheDocument()
expect(screen.getByText('rock')).toBeInTheDocument()
})
it('lists album, EP and single sections that link to the browse list', async () => {
await renderArtistPage()
await waitFor(() => expect(screen.getByText('专辑')).toBeInTheDocument())
expect(screen.getByText('EP')).toBeInTheDocument()
expect(screen.getByText('单曲')).toBeInTheDocument()
const albumsLink = screen.getByText('专辑').closest('a')
expect(albumsLink?.getAttribute('href')).toContain('/browse/music/artist/artist-1/albums')
expect(albumsLink?.getAttribute('href')).toContain('album_type=album')
})
it('exposes the MusicBrainz and official homepage links', async () => {
await renderArtistPage()
expect(await screen.findByText('MusicBrainz')).toBeInTheDocument()
expect(screen.getByText('official homepage')).toBeInTheDocument()
})
it('selects a music-capable site before searching artist resources', async () => {
const { router } = await renderArtistPage()
await fireEvent.click(await screen.findByRole('button', { name: '搜索资源' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
unknown,
{ sites: Array<{ id: number }> },
{ search: (sites: number[]) => void },
]
expect(dialogProps.sites).toEqual([musicSite])
expect(router.currentRoute.value.path).toBe('/music/artist')
dialogEvents.search([14])
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({ keyword: 'Queen', sites: '14', type: '音乐' })
})
})

Some files were not shown because too many files have changed in this diff Show More