mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 03:27:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a4af66683 | ||
|
|
bb53a181a9 | ||
|
|
c5f7924484 | ||
|
|
cf5b95ec04 | ||
|
|
b62e8dc1a6 | ||
|
|
eb3b7b63b6 | ||
|
|
309ce3c92e | ||
|
|
ce0b0d6330 | ||
|
|
2a01a2ffe0 | ||
|
|
6ca606a864 | ||
|
|
73840e3480 | ||
|
|
98a9f87768 | ||
|
|
dd6f644f05 | ||
|
|
6db19ee6f7 | ||
|
|
d1d46353dd | ||
|
|
cfd08059a4 | ||
|
|
278a7587c1 | ||
|
|
b2c4a69173 | ||
|
|
a98271f8a1 | ||
|
|
eabe8f6951 | ||
|
|
e6b26cc831 | ||
|
|
e242b881de | ||
|
|
e23d9a6f41 | ||
|
|
f21274ebca | ||
|
|
139721d62a | ||
|
|
6bf9cf9d18 | ||
|
|
f080da1ebb | ||
|
|
4b70b98ffc | ||
|
|
fe442fda4b | ||
|
|
ede4fe94ce | ||
|
|
20fcd699f2 | ||
|
|
009ce6173b | ||
|
|
b9194ffb74 | ||
|
|
942a44cde6 | ||
|
|
3bf52231f7 | ||
|
|
109a0b1af5 | ||
|
|
777c4ec7e3 | ||
|
|
1064156062 | ||
|
|
693e6fb352 | ||
|
|
70bb6e1040 | ||
|
|
02e3314853 | ||
|
|
d48ee55bab | ||
|
|
d72bb75996 | ||
|
|
95674fa47a | ||
|
|
6afa2bdc8a | ||
|
|
7ea14bc919 | ||
|
|
1dfb51f3c4 | ||
|
|
871a9a072b | ||
|
|
0eb81b4ecb | ||
|
|
4814f53c4e | ||
|
|
0703a99e41 | ||
|
|
21b485ca84 | ||
|
|
fc6313b79e | ||
|
|
8badfb0da9 | ||
|
|
bfd61cd479 | ||
|
|
ac29818fd3 | ||
|
|
8d18f82e68 | ||
|
|
fd1bf8df7f | ||
|
|
6fd259427e |
@@ -1,10 +1,10 @@
|
||||
name: Build Moviepilot-Frontend v2
|
||||
name: Build Moviepilot-Frontend v3
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- v2
|
||||
- v3
|
||||
paths:
|
||||
- 'package.json'
|
||||
|
||||
|
||||
@@ -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,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.19;Node.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
@@ -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 或格式问题。
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.15.4",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
+3
-2
@@ -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;
|
||||
|
||||
|
||||
@@ -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> 显式指定比较起点。')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,7 +72,7 @@ function handleCancel() {
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
<VCardActions class="mx-auto">
|
||||
<VCardActions class="app-confirm-dialog-actions mx-auto">
|
||||
<VBtn variant="tonal" color="secondary" class="px-5" @click="handleCancel">
|
||||
{{ cancelText }}
|
||||
</VBtn>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
// 定义输入参数
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps({
|
||||
// 是否显示
|
||||
/** 覆盖关闭按钮默认的右上角定位 class。 */
|
||||
innerClass: String,
|
||||
})
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['click', 'update:modelValue'])
|
||||
// 按钮点击
|
||||
|
||||
function onClick() {
|
||||
emit('update:modelValue', false)
|
||||
emit('click')
|
||||
@@ -15,6 +16,7 @@ function onClick() {
|
||||
|
||||
<template>
|
||||
<IconBtn
|
||||
:aria-label="t('common.close')"
|
||||
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'"
|
||||
@click.stop="onClick"
|
||||
>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 ID,0 表示未识别
|
||||
tmdb_id?: number
|
||||
// 豆瓣 ID,0 表示未识别
|
||||
douban_id?: string | number
|
||||
// 当前识别数据源对应的统一 ID
|
||||
// 识别缓存对应的字符串 ID
|
||||
recognition_id?: string
|
||||
// 识别后的标题
|
||||
title: string
|
||||
|
||||
@@ -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)及全局 Toast(2500)。 */
|
||||
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%);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '音乐' },
|
||||
])
|
||||
|
||||
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { MediaServerLibrary } from '@/api/types'
|
||||
import plex from '@images/misc/plex.png'
|
||||
import emby from '@images/misc/emby.png'
|
||||
import jellyfin from '@images/misc/jellyfin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -12,6 +13,7 @@ const props = defineProps({
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// canvas
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
@@ -96,11 +98,10 @@ async function goPlay() {
|
||||
*/
|
||||
function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
if (!url || imageError.value) return getDefaultImage()
|
||||
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
if (use_cookies) {
|
||||
imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return imgurl
|
||||
return getProxyImageUrl(url, {
|
||||
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
useCookies: use_cookies,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,10 +116,10 @@ async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
|
||||
// 为所有图片添加system/img前缀
|
||||
for (let i = 0; i < IMAGES.length; i++) {
|
||||
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(IMAGES[i])}`
|
||||
if (use_cookies) {
|
||||
IMAGES[i] += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
IMAGES[i] = getProxyImageUrl(IMAGES[i], {
|
||||
useCache: globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE,
|
||||
useCookies: use_cookies,
|
||||
})
|
||||
}
|
||||
|
||||
// canvas
|
||||
@@ -213,33 +214,33 @@ onMounted(async () => {
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||
<VImg
|
||||
:src="imgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="library-card-image"
|
||||
:class="{ 'library-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="library-card-placeholder">
|
||||
<VSkeletonLoader class="library-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="library-card-shade" aria-hidden="true" />
|
||||
<div v-if="showCountCorner" class="library-card-count-corner">
|
||||
<span>{{ countLabel }}</span>
|
||||
</div>
|
||||
<div class="library-card-label">
|
||||
<span>{{ props.media?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||
<VImg
|
||||
:src="imgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="library-card-image"
|
||||
:class="{ 'library-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="library-card-placeholder">
|
||||
<VSkeletonLoader class="library-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="library-card-shade" aria-hidden="true" />
|
||||
<div v-if="showCountCorner" class="library-card-count-corner">
|
||||
<span>{{ countLabel }}</span>
|
||||
</div>
|
||||
<div class="library-card-label">
|
||||
<span>{{ props.media?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -310,7 +311,7 @@ onMounted(async () => {
|
||||
background: rgba(8, 13, 22, 72%);
|
||||
block-size: 100%;
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||
content: "";
|
||||
content: '';
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 100%;
|
||||
@@ -321,7 +322,7 @@ onMounted(async () => {
|
||||
background: rgba(var(--v-theme-primary), 62%);
|
||||
block-size: 100%;
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||
content: "";
|
||||
content: '';
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -6,7 +6,6 @@ let mediaCardIdSeed = 0
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { formatRating } from '@/@core/utils/formatters'
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
import { buildMusicDetailRoute, getMusicKey } from '@/utils/music'
|
||||
|
||||
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
|
||||
|
||||
@@ -62,6 +62,34 @@ const isImageLoaded = ref(false)
|
||||
// 图片加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 图片请求代际隔离复用卡片的迟到事件,避免旧海报改变新媒体的 renderer 资格。
|
||||
const imageRequestRevision = ref(0)
|
||||
|
||||
// renderer 只能在真实海报完成可见淡入后退出,资源 load 本身不代表像素已完全覆盖卡片。
|
||||
const hasCompletedPosterReveal = ref(false)
|
||||
const POSTER_REVEAL_FALLBACK_MS = 400
|
||||
let posterRevealFallbackTimer: number | null = null
|
||||
|
||||
/** 清理当前海报的视觉覆盖状态与兜底提交。 */
|
||||
function resetPosterRevealState() {
|
||||
hasCompletedPosterReveal.value = false
|
||||
if (posterRevealFallbackTimer === null) return
|
||||
|
||||
window.clearTimeout(posterRevealFallbackTimer)
|
||||
posterRevealFallbackTimer = null
|
||||
}
|
||||
|
||||
/** 仅允许当前真实海报请求完成 renderer 排除提交。 */
|
||||
function completePosterReveal(revision: number, usesFallback: boolean) {
|
||||
if (revision !== imageRequestRevision.value || usesFallback) return
|
||||
|
||||
if (posterRevealFallbackTimer !== null) {
|
||||
window.clearTimeout(posterRevealFallbackTimer)
|
||||
posterRevealFallbackTimer = null
|
||||
}
|
||||
hasCompletedPosterReveal.value = true
|
||||
}
|
||||
|
||||
// 当前订阅状态
|
||||
const isSubscribed = ref(false)
|
||||
|
||||
@@ -122,10 +150,11 @@ function openSearchSiteDialog() {
|
||||
)
|
||||
}
|
||||
|
||||
// 查询所有站点
|
||||
// 查询与当前媒体类型兼容的站点
|
||||
async function querySites() {
|
||||
try {
|
||||
const data: Site[] = await api.get('site/')
|
||||
const mediaType = props.media?.type === '电视剧' ? 'tv' : props.media?.type === '音乐' ? 'music' : 'movie'
|
||||
const data: Site[] = await api.get(`site/media/${mediaType}`)
|
||||
|
||||
// 过滤站点,只有启用的站点才显示
|
||||
allSites.value = data.filter(item => item.is_active)
|
||||
@@ -150,7 +179,8 @@ function getMediaId() {
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
|
||||
return `${getMediaId()}::${season ?? 'all'}`
|
||||
const identity = props.media?.type === '音乐' ? getMusicKey(props.media) : getMediaId()
|
||||
return `${identity}::${season ?? 'all'}`
|
||||
}
|
||||
|
||||
function getExistsStatusKey() {
|
||||
@@ -166,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_url(ListenBrainz/MusicBrainz 统计接口返回),回退到 poster_path
|
||||
const musicCover = props.media?.cover_url || props.media?.poster_path
|
||||
if (!musicCover) return ''
|
||||
return getDisplayImageUrl(musicCover, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
const url = props.media?.poster_path?.replace('original', 'w500')
|
||||
if (!url) return ''
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
const hasLoadedRealPoster = computed(
|
||||
() => Boolean(getImgUrl.value) && isImageLoaded.value && hasCompletedPosterReveal.value && !imageLoadError.value,
|
||||
)
|
||||
|
||||
/** 为当前图片实例绑定不可跨媒体复用的成功与失败回调。 */
|
||||
const imageRequest = computed(() => {
|
||||
const revision = imageRequestRevision.value
|
||||
const src = getImgUrl.value
|
||||
const usesFallback = !src
|
||||
|
||||
return {
|
||||
handleError: () => {
|
||||
if (revision !== imageRequestRevision.value || usesFallback) return
|
||||
|
||||
resetPosterRevealState()
|
||||
isImageLoaded.value = false
|
||||
imageLoadError.value = true
|
||||
imageRequestRevision.value += 1
|
||||
},
|
||||
handleLoad: () => {
|
||||
if (revision !== imageRequestRevision.value) return
|
||||
|
||||
resetPosterRevealState()
|
||||
isImageLoaded.value = true
|
||||
if (!usesFallback) {
|
||||
posterRevealFallbackTimer = window.setTimeout(
|
||||
() => completePosterReveal(revision, usesFallback),
|
||||
POSTER_REVEAL_FALLBACK_MS,
|
||||
)
|
||||
}
|
||||
},
|
||||
handleReveal: (event: TransitionEvent) => {
|
||||
if (
|
||||
event.propertyName !== 'opacity' ||
|
||||
!(event.target instanceof Element) ||
|
||||
!event.target.matches('.v-img__img')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
completePosterReveal(revision, usesFallback)
|
||||
},
|
||||
key: revision,
|
||||
src,
|
||||
}
|
||||
})
|
||||
|
||||
// 获取媒体类型文本
|
||||
function getMediaTypeText(type: string | undefined) {
|
||||
if (!type) return ''
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}`
|
||||
})
|
||||
|
||||
@@ -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,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)
|
||||
})
|
||||
|
||||
// 计算背景渐变
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
// 跳转播放
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停:降低不透明度表达"已停止活动"
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] = ''
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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(['音乐 周杰伦', '音乐 周杰伦 · 叶惠美'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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} 條',
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: '音乐',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user