Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
777e30d137 | ||
|
|
ed5d955ae7 | ||
|
|
bcc5543f4c | ||
|
|
eedf038a70 | ||
|
|
88f81ecce4 | ||
|
|
9e35378ab4 | ||
|
|
36b901f717 | ||
|
|
cfbbd89b1b | ||
|
|
5acf6ee84c | ||
|
|
985dafe45f | ||
|
|
21558372d5 | ||
|
|
53f4ba23be | ||
|
|
9b3388f45f | ||
|
|
eaf6b5427c | ||
|
|
39395ca6b6 | ||
|
|
38fe17ab99 | ||
|
|
c957264b72 | ||
|
|
41a36e2279 | ||
|
|
246b8ff54b | ||
|
|
3f414afff4 | ||
|
|
7f2bd5b3ae | ||
|
|
2c2f55b6d7 | ||
|
|
a1802f7f93 | ||
|
|
b85d412308 | ||
|
|
999f8bf18c | ||
|
|
544acc0758 | ||
|
|
e67edd9feb | ||
|
|
bf77c8ee04 | ||
|
|
6d5550f198 | ||
|
|
097beb6f63 | ||
|
|
55e3a8cba2 | ||
|
|
ca2f3da7a5 | ||
|
|
32e4a9b817 | ||
|
|
7e3e95fbcd | ||
|
|
4362e76ca0 | ||
|
|
f761c59637 | ||
|
|
d29a008e13 | ||
|
|
715da1929b | ||
|
|
6104fb861e | ||
|
|
01e7dcee56 | ||
|
|
9ef45918b8 | ||
|
|
502a593985 | ||
|
|
287cbc76ae | ||
|
|
90d626e7c6 | ||
|
|
8a3b99ae51 | ||
|
|
e5339d524b | ||
|
|
e218508c2d | ||
|
|
e7bfc4ec1c | ||
|
|
5c63fe2313 | ||
|
|
8d86bd6e92 | ||
|
|
8a4ca2482c | ||
|
|
3daa031a04 | ||
|
|
15ef332ea2 | ||
|
|
57448ca609 | ||
|
|
0f7c95cfaf | ||
|
|
3886909e9e | ||
|
|
0f8a817413 | ||
|
|
eb47545b76 | ||
|
|
6fc123e0f6 | ||
|
|
5bdef2ba8f | ||
|
|
42ca5b8f94 | ||
|
|
ba48a25982 | ||
|
|
76c524eb9b | ||
|
|
48f8e8fc71 | ||
|
|
2a0f15c1bd | ||
|
|
e94ade364e | ||
|
|
27f6c33782 | ||
|
|
b3e596c761 | ||
|
|
0ee9e466c3 | ||
|
|
5325d813b4 | ||
|
|
dc3f0abf9b | ||
|
|
e63fc9a8bd | ||
|
|
eb5366f129 | ||
|
|
77c4a0f84c | ||
|
|
1bf6c1f1d8 | ||
|
|
31caf8f5f8 | ||
|
|
787076355b | ||
|
|
4da764ddef | ||
|
|
590755169f | ||
|
|
f7ef9dd116 | ||
|
|
4bc16eb220 | ||
|
|
032bda61f6 | ||
|
|
91ce2c8aee | ||
|
|
9b230d14b4 | ||
|
|
6201e01914 | ||
|
|
441f2ecc06 | ||
|
|
1ed69ccc64 | ||
|
|
80519e6839 | ||
|
|
b4be18e71f | ||
|
|
4a196731f1 | ||
|
|
b32a4e7c6a | ||
|
|
dc8becb41a | ||
|
|
d2896d5936 | ||
|
|
0315481eb6 | ||
|
|
ca955b48e2 | ||
|
|
19e93edca0 | ||
|
|
d039efbefe | ||
|
|
103472404f | ||
|
|
4405a69d59 | ||
|
|
c5f3a4db44 | ||
|
|
b66bc353b3 | ||
|
|
17cdfe6885 | ||
|
|
5cf0dc116a | ||
|
|
c1bb54a31e | ||
|
|
0fb5bbebc6 | ||
|
|
9f375e8413 | ||
|
|
1d7cfbfed8 | ||
|
|
0cc254c07d | ||
|
|
65d93b70e4 | ||
|
|
8dc36a2007 | ||
|
|
42ba550bb4 | ||
|
|
1aa8c9122d | ||
|
|
225d1d2ec0 | ||
|
|
00c0c86345 | ||
|
|
eee83542df |
8
.github/workflows/test.yml
vendored
@@ -4,16 +4,20 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- v2
|
||||
push:
|
||||
branches:
|
||||
- v2
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-tests-${{ github.event.pull_request.number }}
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
format:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -58,7 +62,7 @@ jobs:
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
|
||||
unit-tests:
|
||||
typecheck-and-coverage:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
|
||||
@@ -80,7 +80,7 @@ yarn build
|
||||
|
||||
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job:
|
||||
|
||||
1. lint 与既有 `unit-tests` 使用不同 job,避免改变现有测试 check 的名称和职责。
|
||||
1. lint 与 `typecheck-and-coverage` 使用不同 job,保持静态检查和测试覆盖率职责独立。
|
||||
2. 初始阶段作为普通 check 运行,不立即配置 required check。
|
||||
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。
|
||||
4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
|
||||
|
||||
@@ -135,12 +135,16 @@ export default defineConfig({
|
||||
// 自定义事件,用于通知主应用刷新数据
|
||||
const emit = defineEmits(['action', 'switch', 'close'])
|
||||
|
||||
// 接收API对象
|
||||
// 接收主应用能力
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 页面逻辑代码...
|
||||
@@ -175,7 +179,7 @@ function notifyClose() {
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 接收初始配置和API对象
|
||||
// 接收初始配置和主应用能力
|
||||
const props = defineProps({
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
@@ -185,6 +189,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 配置数据
|
||||
@@ -230,7 +238,7 @@ function notifyClose() {
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 接收配置和刷新控制
|
||||
// 接收配置、刷新控制和主应用能力
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
@@ -240,6 +248,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 仪表板逻辑...
|
||||
@@ -272,16 +284,18 @@ const props = defineProps({
|
||||
|
||||
主应用传入的 props:
|
||||
|
||||
| 属性 | 说明 |
|
||||
| ---------- | ----------------------------------------------------- |
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
| 属性 | 说明 |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `nativeSubscribe` | 打开主应用原生订阅交互 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
nativeSubscribe: { type: Function, default: null },
|
||||
navKey: { type: String, default: 'main' },
|
||||
pluginId: { type: String, default: '' },
|
||||
})
|
||||
@@ -296,7 +310,68 @@ const emit = defineEmits(['action'])
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5.5 调用主应用 Toast
|
||||
### 5.5 主应用宿主能力
|
||||
|
||||
登录后的联邦组件宿主会向插件开放以下能力:
|
||||
|
||||
| 能力 | Page | Config | Dashboard | AppPage | 调用方式 |
|
||||
| ---------------- | ---- | ------ | --------- | ------- | ---------------------------------------------------------------- |
|
||||
| 认证 API | ✓ | ✓ | ✓ | ✓ | `api` prop |
|
||||
| 原生订阅交互 | ✓ | ✓ | ✓ | ✓ | `nativeSubscribe` prop 或 `inject('moviepilot:nativeSubscribe')` |
|
||||
| 主应用统一 Toast | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:toast')` |
|
||||
|
||||
`nativeSubscribe` 和 Toast 都由主应用宿主提供。插件不应复制主程序订阅弹窗,也不应自行创建另一套 Toast 容器。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。
|
||||
|
||||
### 5.6 玻璃光学表面
|
||||
|
||||
主应用的 `Page`、`Config` 与 `AppPage` 宿主在玻璃主题下默认采用 `static-material` 光学模式:保留壁纸透射、材质色调和方向反射,但不响应指针流场、局部折射、拖尾或动态焦散。插件列表与 `Dashboard` 继续使用完整动态光学。视觉型插件可以在自己控制的 DOM 区域显式恢复完整动态光学:
|
||||
|
||||
```html
|
||||
<div data-glass-optical-surface data-glass-optical-mode="dynamic">
|
||||
<!-- 插件自己的视觉内容 -->
|
||||
</div>
|
||||
```
|
||||
|
||||
使用时需同时声明 `data-glass-optical-surface` 和 `data-glass-optical-mode="dynamic"`。模式会从最近的祖先容器继承,因此显式声明的动态子表面不会沿用宿主的静态模式。该合同适用于插件在 `Page`、`Config` 或 `AppPage` 中自行渲染并控制的区域;主应用生成的插件列表、插件市场卡片、`Dashboard` 及其他宿主 DOM 不属于插件的修改边界。
|
||||
|
||||
动态模式只在主应用启用玻璃主题和实时光学能力时生效。其他主题、降低动态效果或光学能力不可用时,插件必须保持内容与交互正常,不应依赖动态光学表达业务状态或必要反馈。
|
||||
|
||||
### 5.7 调用主应用原生订阅
|
||||
|
||||
`Page`、`Config`、`Dashboard` 与 `AppPage` 都会收到 `nativeSubscribe(mediaInfo)` prop。插件传入媒体信息后,电视剧会打开主应用的选季抽屉,电影会进入现有电影订阅流程。宿主也会用 `moviepilot:nativeSubscribe` 键提供同一个方法,深层子组件可以使用 `inject`,无需逐层传递 prop。
|
||||
|
||||
媒体信息必须包含:
|
||||
|
||||
- `type`:`电影` / `电视剧`,也兼容 `movie` / `tv`;
|
||||
- `title`;
|
||||
- 至少一个有效媒体标识:`tmdb_id` / `tmdbid`、`douban_id` / `doubanid`、`bangumi_id` / `bangumiid`、`anilist_id` / `anilistid`,或者 `media_id` 与 `mediaid_prefix` / `source` / `media_source` 的组合。
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
type NativeSubscribeResult =
|
||||
{ success: true } | { success: false; code: 'INVALID_MEDIA' | 'PERMISSION_DENIED'; message: string }
|
||||
|
||||
const props = defineProps<{
|
||||
nativeSubscribe?: (mediaInfo: Record<string, unknown>) => Promise<NativeSubscribeResult>
|
||||
}>()
|
||||
|
||||
const nativeSubscribe = inject('moviepilot:nativeSubscribe', props.nativeSubscribe)
|
||||
|
||||
/** 使用主应用订阅交互,宿主不接受时保留插件自己的 fallback。 */
|
||||
async function subscribeMedia(mediaInfo: Record<string, unknown>) {
|
||||
const result = await nativeSubscribe?.(mediaInfo)
|
||||
if (!result?.success) {
|
||||
// 插件可在这里执行自己的 fallback;宿主已同时显示明确错误提示。
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
`success: true` 表示主应用已接受调用并启动原生交互,不表示用户已经完成订阅。字段无效或当前用户没有订阅权限时返回 `success: false`,插件可以依据 `code` 执行 fallback。
|
||||
|
||||
### 5.8 调用主应用 Toast
|
||||
|
||||
`Page`、`Config`、`Dashboard` 与 `AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例,不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器:
|
||||
|
||||
@@ -304,7 +379,14 @@ const emit = defineEmits(['action'])
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
const toast = inject<any>('moviepilot:toast', null)
|
||||
interface HostToast {
|
||||
error(message: string): unknown
|
||||
info(message: string): unknown
|
||||
success(message: string): unknown
|
||||
warning(message: string): unknown
|
||||
}
|
||||
|
||||
const toast = inject<HostToast | null>('moviepilot:toast', null)
|
||||
|
||||
// 保存完成后调用主应用的统一通知。
|
||||
function saveComplete() {
|
||||
|
||||
@@ -92,4 +92,4 @@ yarn lint
|
||||
yarn build
|
||||
```
|
||||
|
||||
Pull Request 工作流使用 Node 24 LTS 和 frozen lockfile。`unit-tests` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查,当前处于普通 check 观察阶段,不改变既有 required checks。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
|
||||
`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 或格式问题。
|
||||
|
||||
@@ -54,9 +54,6 @@
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"no-useless-catch": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -118,11 +115,6 @@
|
||||
"count": 27
|
||||
}
|
||||
},
|
||||
"src/components/cards/DownloadingCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/FilterRuleCard.vue": {
|
||||
"vue/no-mutating-props": {
|
||||
"count": 1
|
||||
@@ -190,16 +182,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/cards/TorrentCard.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/TorrentItem.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/UserCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -221,19 +203,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/AddDownloadDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/AddSubtitleDownloadDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/dialog/AgentMcpSettingsDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -346,17 +315,12 @@
|
||||
},
|
||||
"src/components/dialog/ReorganizeDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 14
|
||||
"count": 10
|
||||
},
|
||||
"sonarjs/super-linear-regex": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SearchBarDialog.vue": {
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/SharedDialogHost.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
@@ -421,17 +385,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/TransferQueueDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"no-useless-assignment": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-dead-store": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/U115AuthDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
@@ -485,11 +438,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/filebrowser/FileBrowser.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/filebrowser/FileList.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 6
|
||||
@@ -497,21 +445,10 @@
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/filebrowser/FileToolbar.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/input/PathInput.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
@@ -810,9 +747,6 @@
|
||||
}
|
||||
},
|
||||
"src/pages/login.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 10
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -1029,19 +963,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/views/reorganize/DownloadingListView.vue": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/views/reorganize/FileBrowserView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/reorganize/TransferHistoryView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 11
|
||||
@@ -1052,7 +973,7 @@
|
||||
},
|
||||
"src/views/setting/AccountSettingDirectory.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 10
|
||||
"count": 9
|
||||
},
|
||||
"no-prototype-builtins": {
|
||||
"count": 1
|
||||
|
||||
1638
index.html
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.14.6",
|
||||
"version": "2.15.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"prebuild": "npm run build:icons",
|
||||
"dev:pwa": "vite --host --port 5174",
|
||||
"prebuild": "npm run build:icons && npm run generate:pwa-splash",
|
||||
"build": "vite build",
|
||||
"generate:pwa-splash": "node scripts/generate-pwa-splash.mjs",
|
||||
"preview": "vite preview --port 5050",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
@@ -130,6 +132,7 @@
|
||||
"postcss": "^8.5.1",
|
||||
"postcss-html": "^1.5.0",
|
||||
"prettier": "^3.9.5",
|
||||
"sharp": "^0.33.5",
|
||||
"stylelint": "^16.13.2",
|
||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||
"stylelint-config-standard-scss": "^14.0.0",
|
||||
|
||||
BIN
public/splash/apple-splash-1125-2436.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/splash/apple-splash-1136-640.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/splash/apple-splash-1170-2532.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-1179-2556.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-1206-2622.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-1242-2208.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-1242-2688.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-1260-2736.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-1284-2778.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-1290-2796.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-1320-2868.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/splash/apple-splash-1334-750.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/splash/apple-splash-1488-2266.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/splash/apple-splash-1536-2048.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/splash/apple-splash-1620-2160.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/splash/apple-splash-1640-2360.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-1668-2224.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-1668-2388.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-1792-828.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/splash/apple-splash-2048-1536.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/splash/apple-splash-2048-2732.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/splash/apple-splash-2160-1620.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2208-1242.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2224-1668.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/splash/apple-splash-2266-1488.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2360-1640.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-2388-1668.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-2436-1125.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2532-1170.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-2556-1179.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-2622-1206.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-2688-1242.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-2732-2048.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/splash/apple-splash-2736-1260.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-2778-1284.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-2796-1290.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-2868-1320.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/splash/apple-splash-640-1136.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/splash/apple-splash-750-1334.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/splash/apple-splash-828-1792.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 16 KiB |
68
scripts/generate-pwa-splash.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
import appleSplashSpecs from './pwa-splash-specs.json' with { type: 'json' }
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const logoPath = path.join(projectRoot, 'public', 'logo.svg')
|
||||
const outputDirectory = path.join(projectRoot, 'public', 'splash')
|
||||
const background = '#0E1116'
|
||||
|
||||
async function createSplash(width, height, scaleFactor, outputPath, format) {
|
||||
// Match the DOM loader's `min(160px, 36vw)` in CSS pixels, then convert it
|
||||
// to physical pixels for the selected Apple launch image.
|
||||
const logoSize = Math.round(Math.min(160, (width / scaleFactor) * 0.36) * scaleFactor)
|
||||
const logo = await sharp(logoPath).resize(logoSize, logoSize, { fit: 'contain' }).png().toBuffer()
|
||||
const image = sharp({
|
||||
create: {
|
||||
width,
|
||||
height,
|
||||
channels: 4,
|
||||
background,
|
||||
},
|
||||
}).composite([
|
||||
{
|
||||
input: logo,
|
||||
left: Math.round((width - logoSize) / 2),
|
||||
top: Math.round((height - logoSize) / 2),
|
||||
},
|
||||
])
|
||||
|
||||
if (format === 'png') {
|
||||
await image.png({ compressionLevel: 9 }).toFile(outputPath)
|
||||
return
|
||||
}
|
||||
|
||||
await image.flatten({ background }).jpeg({ quality: 88, progressive: true }).toFile(outputPath)
|
||||
}
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true })
|
||||
|
||||
for (const { width: portraitWidth, height: portraitHeight, scaleFactor } of appleSplashSpecs) {
|
||||
const landscapeWidth = portraitHeight
|
||||
const landscapeHeight = portraitWidth
|
||||
|
||||
await createSplash(
|
||||
portraitWidth,
|
||||
portraitHeight,
|
||||
scaleFactor,
|
||||
path.join(outputDirectory, `apple-splash-${portraitWidth}-${portraitHeight}.jpg`),
|
||||
'jpg',
|
||||
)
|
||||
await createSplash(
|
||||
landscapeWidth,
|
||||
landscapeHeight,
|
||||
scaleFactor,
|
||||
path.join(outputDirectory, `apple-splash-${landscapeWidth}-${landscapeHeight}.jpg`),
|
||||
'jpg',
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the previous fallback filename for older deployments and bookmarked
|
||||
// entries that may still reference it. Its palette matches the new assets.
|
||||
await createSplash(750, 1334, 2, path.join(outputDirectory, 'apple-splash.png'), 'png')
|
||||
|
||||
console.log(
|
||||
`Generated ${appleSplashSpecs.length * 2 + 1} PWA splash assets in ${path.relative(projectRoot, outputDirectory)}`,
|
||||
)
|
||||
294
scripts/pwa-development.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const PWA_DEVELOPMENT_SCRIPT = 'dev:pwa'
|
||||
export const DEV_SW_CLEANUP_PATH = '/__moviepilot_dev_sw_cleanup__'
|
||||
|
||||
const devEntryScriptTag = '<script type="module" src="/src/main.ts"></script>'
|
||||
const devEntryScriptUrl = '/src/main.ts'
|
||||
const moviePilotWorkerScripts = ['dev-sw.js?dev-sw', 'service-worker.js']
|
||||
const moviePilotIdentityMessage = 'GET_UNREAD_COUNT'
|
||||
const moviePilotIdentityTimeoutMs = 1500
|
||||
const moviePilotIdentityAttempts = 2
|
||||
|
||||
/** 普通 development mode 仅在显式 dev:pwa 脚本中启用开发 Service Worker。 */
|
||||
export const isPwaDevelopmentEnabled = (mode: string, lifecycleEvent?: string) =>
|
||||
mode === 'development' && lifecycleEvent === PWA_DEVELOPMENT_SCRIPT
|
||||
|
||||
/** 普通开发服务器才执行历史 Service Worker 清理,production preview 保持构建产物行为。 */
|
||||
export const shouldEnableDevServiceWorkerCleanup = (
|
||||
command: 'build' | 'serve',
|
||||
mode: string,
|
||||
isPreview: boolean | undefined,
|
||||
lifecycleEvent?: string,
|
||||
) =>
|
||||
command === 'serve' && mode === 'development' && isPreview !== true && !isPwaDevelopmentEnabled(mode, lifecycleEvent)
|
||||
|
||||
/** 解析当前页面所属的应用根目录,兼容根路径和子路径部署。 */
|
||||
export function resolveDevAppScope(pageUrl: string): URL {
|
||||
return new URL('./', new URL(pageUrl))
|
||||
}
|
||||
|
||||
/** Worker 的 scope 和脚本 URL 都必须精确落在当前应用根目录。 */
|
||||
export function isManagedServiceWorkerRegistration(
|
||||
scriptUrl: string,
|
||||
registrationScope: string,
|
||||
pageUrl: string,
|
||||
): boolean {
|
||||
const appScope = resolveDevAppScope(pageUrl)
|
||||
const scope = new URL(registrationScope)
|
||||
const script = new URL(scriptUrl)
|
||||
|
||||
return (
|
||||
scope.href === appScope.href &&
|
||||
moviePilotWorkerScripts.some(workerScript => script.href === new URL(workerScript, appScope).href)
|
||||
)
|
||||
}
|
||||
|
||||
/** 复用现有轻量消息协议确认 Worker 确由 MoviePilot 提供。 */
|
||||
export function isMoviePilotServiceWorkerIdentityResponse(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
|
||||
return typeof (value as { count?: unknown }).count === 'number'
|
||||
}
|
||||
|
||||
/** 身份探测允许有限次重试;持续失败时保持现有注册,避免误清理未知 Worker。 */
|
||||
export async function retryMoviePilotIdentityVerification(
|
||||
verifyOnce: () => Promise<boolean>,
|
||||
attempts: number,
|
||||
): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
if (await verifyOnce()) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 删除当前 origin 的全部 Cache Storage;调用方必须先确认该 dev origin 由 MoviePilot Worker 管理。 */
|
||||
export async function deleteCurrentOriginCaches(cacheStorage: Pick<CacheStorage, 'delete' | 'keys'>): Promise<void> {
|
||||
const cacheNames = await cacheStorage.keys()
|
||||
await Promise.all(cacheNames.map(cacheName => cacheStorage.delete(cacheName)))
|
||||
}
|
||||
|
||||
/** 清理完成后只允许返回当前 origin,避免开发中间页形成开放重定向。 */
|
||||
export function resolveDevCleanupReturnUrl(requested: string | null, origin: string): URL {
|
||||
if (!requested) return new URL('/', origin)
|
||||
const target = new URL(requested, origin)
|
||||
return target.origin === origin ? target : new URL('/', origin)
|
||||
}
|
||||
|
||||
/**
|
||||
* 普通开发服务器不应被历史 Service Worker 控制;受控页面先进入独立清理页,避免旧缓存模块抢先执行。
|
||||
*/
|
||||
export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
||||
const workerScripts = JSON.stringify(moviePilotWorkerScripts)
|
||||
const identityMessage = JSON.stringify(moviePilotIdentityMessage)
|
||||
const identityTimeoutMs = JSON.stringify(moviePilotIdentityTimeoutMs)
|
||||
const identityAttempts = JSON.stringify(moviePilotIdentityAttempts)
|
||||
const retryIdentityVerification = retryMoviePilotIdentityVerification.toString()
|
||||
const deleteOriginCaches = deleteCurrentOriginCaches.toString()
|
||||
const entryScriptUrl = JSON.stringify(devEntryScriptUrl)
|
||||
const cleanupPath = JSON.stringify(DEV_SW_CLEANUP_PATH)
|
||||
const cleanupAttemptKeyPrefix = JSON.stringify('moviepilot:dev-sw-cleanup')
|
||||
|
||||
const redirectScript = `
|
||||
(() => {
|
||||
const entryScriptUrl = ${entryScriptUrl}
|
||||
const deleteCurrentOriginCaches = ${deleteOriginCaches}
|
||||
let appStarted = false
|
||||
const startApp = () => {
|
||||
if (appStarted) return
|
||||
appStarted = true
|
||||
const entry = document.createElement('script')
|
||||
entry.type = 'module'
|
||||
entry.src = entryScriptUrl
|
||||
document.head.appendChild(entry)
|
||||
}
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
startApp()
|
||||
return
|
||||
}
|
||||
|
||||
const workerScripts = ${workerScripts}
|
||||
const identityMessage = ${identityMessage}
|
||||
const identityTimeoutMs = ${identityTimeoutMs}
|
||||
const identityAttempts = ${identityAttempts}
|
||||
const retryIdentityVerification = ${retryIdentityVerification}
|
||||
const cleanupPath = ${cleanupPath}
|
||||
const appScope = new URL('./', location.href)
|
||||
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
|
||||
const cleanupState = sessionStorage.getItem(cleanupAttemptKey)
|
||||
const getCandidateWorker = registration => {
|
||||
if (new URL(registration.scope).href !== appScope.href) return null
|
||||
return [registration.active, registration.waiting, registration.installing].find(worker => {
|
||||
if (!worker) return false
|
||||
const scriptUrl = new URL(worker.scriptURL).href
|
||||
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
|
||||
}) || null
|
||||
}
|
||||
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
|
||||
const channel = new MessageChannel()
|
||||
const finish = result => {
|
||||
window.clearTimeout(timeout)
|
||||
channel.port1.close()
|
||||
resolve(result)
|
||||
}
|
||||
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
|
||||
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
|
||||
try {
|
||||
worker.postMessage({ type: identityMessage }, [channel.port2])
|
||||
} catch {
|
||||
finish(false)
|
||||
}
|
||||
})
|
||||
const verifyMoviePilotWorker = worker =>
|
||||
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
|
||||
const hasVerifiedRegistration = async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
for (const registration of registrations) {
|
||||
const worker = getCandidateWorker(registration)
|
||||
if (worker && await verifyMoviePilotWorker(worker)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const redirectToCleanup = () => {
|
||||
sessionStorage.setItem(cleanupAttemptKey, 'pending')
|
||||
const target = new URL(cleanupPath.slice(1), appScope)
|
||||
target.searchParams.set('return', location.href)
|
||||
location.replace(target.href)
|
||||
}
|
||||
|
||||
// unregister 不会立即解除当前 document 的 controller;应用模块加载前需再导航一次以脱离旧 Worker。
|
||||
if (cleanupState === 'complete') {
|
||||
void (async () => {
|
||||
// 旧 Worker 可能在注销后的首次导航中重新创建缓存;脱离控制后再清理一次。
|
||||
if ('caches' in window) await deleteCurrentOriginCaches(caches)
|
||||
sessionStorage.removeItem(cleanupAttemptKey)
|
||||
location.reload()
|
||||
})().catch(error => {
|
||||
console.error('[PWA] Failed to finish stale development cache cleanup', error)
|
||||
document.body.textContent = 'Failed to finish stale development cache cleanup. Reload to retry.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
void hasVerifiedRegistration().then(hasRegistration => {
|
||||
if (hasRegistration) {
|
||||
redirectToCleanup()
|
||||
return
|
||||
}
|
||||
sessionStorage.removeItem(cleanupAttemptKey)
|
||||
startApp()
|
||||
}).catch(error => {
|
||||
console.warn('[PWA] Failed to inspect historical Service Worker state', error)
|
||||
startApp()
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
const cleanupDocument = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="UTF-8"><title>MoviePilot Dev Cleanup</title></head>
|
||||
<body>
|
||||
<script>
|
||||
(() => {
|
||||
const workerScripts = ${workerScripts}
|
||||
const identityMessage = ${identityMessage}
|
||||
const identityTimeoutMs = ${identityTimeoutMs}
|
||||
const identityAttempts = ${identityAttempts}
|
||||
const retryIdentityVerification = ${retryIdentityVerification}
|
||||
const deleteCurrentOriginCaches = ${deleteOriginCaches}
|
||||
const appScope = new URL('./', location.href)
|
||||
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
|
||||
const resolveReturnUrl = () => {
|
||||
const requested = new URLSearchParams(location.search).get('return')
|
||||
if (!requested) return appScope
|
||||
const target = new URL(requested, appScope)
|
||||
return target.href.startsWith(appScope.href) ? target : appScope
|
||||
}
|
||||
const returnUrl = resolveReturnUrl()
|
||||
const getCandidateWorker = registration => {
|
||||
if (new URL(registration.scope).href !== appScope.href) return null
|
||||
return [registration.active, registration.waiting, registration.installing].find(worker => {
|
||||
if (!worker) return false
|
||||
const scriptUrl = new URL(worker.scriptURL).href
|
||||
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
|
||||
}) || null
|
||||
}
|
||||
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
|
||||
const channel = new MessageChannel()
|
||||
const finish = result => {
|
||||
window.clearTimeout(timeout)
|
||||
channel.port1.close()
|
||||
resolve(result)
|
||||
}
|
||||
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
|
||||
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
|
||||
try {
|
||||
worker.postMessage({ type: identityMessage }, [channel.port2])
|
||||
} catch {
|
||||
finish(false)
|
||||
}
|
||||
})
|
||||
const verifyMoviePilotWorker = worker =>
|
||||
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
|
||||
const cleanup = async () => {
|
||||
if (sessionStorage.getItem(cleanupAttemptKey) !== 'pending') {
|
||||
throw new Error('Missing Service Worker cleanup context for current application scope')
|
||||
}
|
||||
const registrations = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistrations() : []
|
||||
const managedRegistrations = []
|
||||
for (const registration of registrations) {
|
||||
const worker = getCandidateWorker(registration)
|
||||
if (worker && await verifyMoviePilotWorker(worker)) managedRegistrations.push(registration)
|
||||
}
|
||||
if (!managedRegistrations.length) throw new Error('MoviePilot Service Worker identity verification failed')
|
||||
await Promise.allSettled(managedRegistrations.map(registration => registration.unregister()))
|
||||
|
||||
// Vite dev server 使用独立 origin;仅在确认 MoviePilot Worker 后清除其遗留模块响应。
|
||||
if ('caches' in window) await deleteCurrentOriginCaches(caches)
|
||||
|
||||
// 回跳入口后由 head-prepend 脚本完成第二次导航,避免同一 client 继续复用旧模块响应。
|
||||
sessionStorage.setItem(cleanupAttemptKey, 'complete')
|
||||
location.replace(returnUrl.href)
|
||||
}
|
||||
|
||||
void cleanup().catch(error => {
|
||||
console.error('[PWA] Failed to clean stale development Service Worker state', error)
|
||||
document.body.textContent = 'Failed to clean stale development Service Worker state. Reload to retry.'
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return {
|
||||
name: 'moviepilot:dev-service-worker-cleanup',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const pathname = new URL(request.url || '/', 'http://localhost').pathname
|
||||
if (!pathname.endsWith(DEV_SW_CLEANUP_PATH)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
response.statusCode = 200
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
response.setHeader('Cache-Control', 'no-store')
|
||||
response.end(cleanupDocument)
|
||||
})
|
||||
},
|
||||
transformIndexHtml: {
|
||||
order: 'pre',
|
||||
handler(html) {
|
||||
if (!html.includes(devEntryScriptTag)) {
|
||||
throw new Error(`Expected development entry tag: ${devEntryScriptTag}`)
|
||||
}
|
||||
|
||||
return {
|
||||
html: html.replace(devEntryScriptTag, ''),
|
||||
tags: [{ tag: 'script', children: redirectScript, injectTo: 'head-prepend' }],
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
22
scripts/pwa-splash-specs.json
Normal file
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{ "width": 2048, "height": 2732, "scaleFactor": 2 },
|
||||
{ "width": 1668, "height": 2388, "scaleFactor": 2 },
|
||||
{ "width": 1536, "height": 2048, "scaleFactor": 2 },
|
||||
{ "width": 1640, "height": 2360, "scaleFactor": 2 },
|
||||
{ "width": 1668, "height": 2224, "scaleFactor": 2 },
|
||||
{ "width": 1620, "height": 2160, "scaleFactor": 2 },
|
||||
{ "width": 1488, "height": 2266, "scaleFactor": 2 },
|
||||
{ "width": 1320, "height": 2868, "scaleFactor": 3 },
|
||||
{ "width": 1206, "height": 2622, "scaleFactor": 3 },
|
||||
{ "width": 1260, "height": 2736, "scaleFactor": 3 },
|
||||
{ "width": 1290, "height": 2796, "scaleFactor": 3 },
|
||||
{ "width": 1179, "height": 2556, "scaleFactor": 3 },
|
||||
{ "width": 1170, "height": 2532, "scaleFactor": 3 },
|
||||
{ "width": 1284, "height": 2778, "scaleFactor": 3 },
|
||||
{ "width": 1125, "height": 2436, "scaleFactor": 3 },
|
||||
{ "width": 1242, "height": 2688, "scaleFactor": 3 },
|
||||
{ "width": 828, "height": 1792, "scaleFactor": 2 },
|
||||
{ "width": 1242, "height": 2208, "scaleFactor": 3 },
|
||||
{ "width": 750, "height": 1334, "scaleFactor": 2 },
|
||||
{ "width": 640, "height": 1136, "scaleFactor": 2 }
|
||||
]
|
||||
@@ -96,7 +96,8 @@
|
||||
|
||||
// 👉 Nav Link
|
||||
.nav-link {
|
||||
overflow: hidden;
|
||||
// 交互项已预留下边距承接激活阴影,父级不能再次裁切这段视觉溢出。
|
||||
overflow: visible;
|
||||
|
||||
> :first-child {
|
||||
@extend %vertical-nav-item;
|
||||
|
||||
@@ -34,10 +34,15 @@ $ps-track-size: 0.5rem;
|
||||
inline-size: $ps-hover-size;
|
||||
}
|
||||
|
||||
// fix bug
|
||||
@media(hover: none) {
|
||||
.ps > .ps__rail-x,
|
||||
.ps > .ps__rail-y {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
// 滚动条只反馈实际滚动状态,鼠标经过容器或键盘聚焦不应让轨道常驻。
|
||||
.ps:hover > .ps__rail-x,
|
||||
.ps:hover > .ps__rail-y,
|
||||
.ps--focus > .ps__rail-x,
|
||||
.ps--focus > .ps__rail-y {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.ps.ps--scrolling-x > .ps__rail-x,
|
||||
.ps.ps--scrolling-y > .ps__rail-y {
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
|
||||
53
src/@core/utils/__tests__/image.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('colorthief', () => ({
|
||||
default: class ColorThief {},
|
||||
}))
|
||||
|
||||
import { preloadCorsImage } from '@/@core/utils/image'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
describe('preloadCorsImage', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('uses a CORS-clean cached response without reloading it', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
|
||||
ok: true,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(preloadCorsImage('https://image.example/wallpaper.jpg')).resolves.toBe(true)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL('https://image.example/wallpaper.jpg'),
|
||||
expect.objectContaining({ cache: 'force-cache', credentials: 'omit', mode: 'cors' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('reloads a response when an earlier non-CORS cache entry blocks the first request', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
.mockResolvedValueOnce({
|
||||
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
|
||||
ok: true,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(preloadCorsImage('/wallpaper.jpg')).resolves.toBe(true)
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
new URL('/wallpaper.jpg', window.location.href),
|
||||
expect.objectContaining({ cache: 'reload', credentials: 'same-origin', mode: 'cors' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when the source cannot be read with CORS', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
|
||||
await expect(preloadCorsImage('https://image.example/wallpaper.jpg')).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
27
src/@core/utils/corsImage.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** 建立带 CORS 响应头的浏览器缓存,必要时修复旧的非 CORS 缓存条目。 */
|
||||
export async function preloadCorsImage(url: string): Promise<boolean> {
|
||||
const request = async (cache: RequestCache) => {
|
||||
const source = new URL(url, window.location.href)
|
||||
const response = await fetch(source, {
|
||||
cache,
|
||||
credentials: source.origin === window.location.origin ? 'same-origin' : 'omit',
|
||||
mode: 'cors',
|
||||
})
|
||||
if (!response.ok) return false
|
||||
|
||||
await response.blob()
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
if (await request('force-cache')) return true
|
||||
} catch {
|
||||
// 缓存中的非 CORS 响应可能使首次读取失败,重新验证后再决定是否回退。
|
||||
}
|
||||
|
||||
try {
|
||||
return await request('reload')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import ColorThief from 'colorthief'
|
||||
export { preloadCorsImage } from './corsImage'
|
||||
|
||||
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
|
||||
const DOMINANT_COLOR_CACHE_LIMIT = 100
|
||||
@@ -70,22 +71,29 @@ export async function getDominantColor(
|
||||
export async function preloadImage(url: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image()
|
||||
let settled = false
|
||||
const finish = (available: boolean) => {
|
||||
if (settled) return
|
||||
|
||||
img.onload = () => resolve(true)
|
||||
img.onerror = () => resolve(false)
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
resolve(available)
|
||||
}
|
||||
|
||||
img.onload = () => finish(true)
|
||||
img.onerror = () => finish(false)
|
||||
|
||||
// 设置超时,防止图片长时间加载
|
||||
const timeout = setTimeout(() => {
|
||||
img.src = ''
|
||||
resolve(false)
|
||||
finish(false)
|
||||
}, 5000) // 5秒超时
|
||||
|
||||
img.src = url
|
||||
|
||||
// 如果图片已经缓存,onload可能不会触发
|
||||
if (img.complete) {
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
finish(img.naturalWidth > 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,4 +3,9 @@ export function saveLocalTheme(name: string, theme: any) {
|
||||
localStorage.setItem('theme', name)
|
||||
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
|
||||
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)
|
||||
|
||||
// 自动主题下次恢复时需要一个稳定的首帧明暗结果,避免媒体查询短暂返回浅色。
|
||||
if (name === 'auto') {
|
||||
localStorage.setItem('materio-initial-resolved-theme', theme.current.value.dark ? 'dark' : 'light')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ function handleNavScroll(evt: Event) {
|
||||
<RouterLink to="/" class="app-logo d-flex align-center app-title-wrapper">
|
||||
<ThemeLogoMark />
|
||||
|
||||
<h1 class="font-weight-bold leading-normal text-xl">
|
||||
MOVIEPILOT <span class="text-sm text-gray-500">v2</span>
|
||||
<h1 class="leading-normal text-xl">
|
||||
<span class="moviepilot-wordmark">MOVIEPILOT</span> <span class="text-sm text-gray-500">v2</span>
|
||||
</h1>
|
||||
</RouterLink>
|
||||
</slot>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
||||
import {
|
||||
readThemeCustomizerSettings,
|
||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||
type ThemeCustomizerSettings,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import { useGlassFixedShellBackplate } from '@/composables/useGlassFixedShellBackplate'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
export default defineComponent({
|
||||
@@ -17,6 +19,7 @@ export default defineComponent({
|
||||
const route = useRoute()
|
||||
const { mdAndDown } = useDisplay()
|
||||
const { appMode } = usePWA()
|
||||
const fixedShellBackplate = useGlassFixedShellBackplate()
|
||||
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
||||
const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value)
|
||||
const isCollapsedLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'collapsed')
|
||||
@@ -73,6 +76,8 @@ export default defineComponent({
|
||||
})
|
||||
|
||||
return () => {
|
||||
const hasFixedShellBackplate = fixedShellBackplate.layers.value.length > 0
|
||||
|
||||
// 👉 Vertical nav
|
||||
const verticalNav = h(
|
||||
VerticalNav,
|
||||
@@ -85,6 +90,15 @@ export default defineComponent({
|
||||
},
|
||||
)
|
||||
|
||||
const fixedShellBackplateNode = hasFixedShellBackplate
|
||||
? h(GlassFixedShellBackplate, {
|
||||
isOverlayNav: mdAndDown.value,
|
||||
isOverlayNavActive: isOverlayNavActive.value,
|
||||
layers: fixedShellBackplate.layers.value,
|
||||
transitionDurationMs: fixedShellBackplate.transitionDurationMs,
|
||||
})
|
||||
: null
|
||||
|
||||
// 👉 Navbar
|
||||
const navbar = h(
|
||||
'header',
|
||||
@@ -145,11 +159,17 @@ export default defineComponent({
|
||||
isCollapsedLayout.value && 'layout-vertical-nav-collapsed',
|
||||
isHorizontalLayout.value && 'layout-horizontal-nav-active',
|
||||
isHorizontalLayout.value && isNavbarScrolled && 'layout-horizontal-nav-scrolled',
|
||||
hasFixedShellBackplate && 'layout-fixed-shell-backplate-active',
|
||||
route.meta.layoutWrapperClasses,
|
||||
!isHorizontalLayout.value && isNavbarScrolled && 'window-scrolled',
|
||||
],
|
||||
},
|
||||
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
|
||||
[
|
||||
fixedShellBackplateNode,
|
||||
verticalNav,
|
||||
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
|
||||
layoutOverlay,
|
||||
],
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
857
src/App.vue
@@ -70,7 +70,7 @@ function registerJinja2Mode() {
|
||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||
const operators = 'and|in|is|not|or'
|
||||
const contextVariables =
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
@@ -282,7 +282,7 @@ function registerJinja2Mode() {
|
||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||
const operators = 'and|in|is|not|or'
|
||||
const contextVariables =
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | (string & {})
|
||||
|
||||
// 手动刮削选项
|
||||
export interface ManualScrapeOptions {
|
||||
// 媒体数据源
|
||||
media_source: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 媒体类型
|
||||
type_name?: string
|
||||
}
|
||||
|
||||
// 订阅
|
||||
export interface Subscribe {
|
||||
// 订阅ID
|
||||
@@ -15,7 +27,13 @@ export interface Subscribe {
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: string
|
||||
bangumiid?: number
|
||||
// AniList ID
|
||||
anilistid?: number
|
||||
// 主媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 其它媒体ID
|
||||
mediaid?: string
|
||||
// 季号
|
||||
@@ -116,6 +134,12 @@ export interface SubscribeShare {
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: number
|
||||
// AniList ID
|
||||
anilistid?: number
|
||||
// 主媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 季号
|
||||
season?: number
|
||||
// 海报
|
||||
@@ -218,6 +242,14 @@ export interface TransferHistory {
|
||||
tvdbid?: number
|
||||
// 豆瓣ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: number
|
||||
// AniList ID
|
||||
anilistid?: number
|
||||
// 媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string
|
||||
// 季Sxx
|
||||
seasons?: string
|
||||
// 集Exx
|
||||
@@ -238,7 +270,7 @@ export interface TransferHistory {
|
||||
|
||||
// 媒体信息
|
||||
export interface MediaInfo {
|
||||
// 来源:themoviedb、douban、bangumi
|
||||
// 来源:themoviedb、douban、bangumi、anilist
|
||||
source?: string
|
||||
// 类型 电影、电视剧、合集
|
||||
type?: string
|
||||
@@ -259,7 +291,11 @@ export interface MediaInfo {
|
||||
// 豆瓣ID
|
||||
douban_id?: string
|
||||
// Bangumi ID
|
||||
bangumi_id?: string
|
||||
bangumi_id?: string | number
|
||||
// AniList ID
|
||||
anilist_id?: number
|
||||
// AniDB ID
|
||||
anidb_id?: number
|
||||
// 合集ID
|
||||
collection_id?: number
|
||||
// 其它媒体ID前缀
|
||||
@@ -416,7 +452,7 @@ export interface TmdbEpisode {
|
||||
|
||||
// TMDB人物信息
|
||||
export interface Person {
|
||||
// 来源:themoviedb、douban、bangumi
|
||||
// 来源:themoviedb、douban、bangumi、anilist
|
||||
source?: string
|
||||
// ID
|
||||
id?: number
|
||||
@@ -670,6 +706,20 @@ export interface Plugin {
|
||||
add_time?: number
|
||||
// 页面打开状态
|
||||
page_open?: boolean
|
||||
// 平均评分
|
||||
average_rating?: number
|
||||
// 评分人数
|
||||
rating_count?: number
|
||||
// 当前安装实例评分
|
||||
user_rating?: number | null
|
||||
}
|
||||
|
||||
// 插件评分结果
|
||||
export interface PluginRating {
|
||||
plugin_id: string
|
||||
average_rating: number
|
||||
rating_count: number
|
||||
user_rating?: number | null
|
||||
}
|
||||
|
||||
// 插件 Release 可安装版本
|
||||
@@ -964,7 +1014,7 @@ export interface User {
|
||||
is_superuser: boolean
|
||||
// 头像
|
||||
avatar: string
|
||||
// 是否开启双重验证
|
||||
// 是否开启二次验证
|
||||
is_otp: boolean
|
||||
// 用户权限 json
|
||||
permissions: { [key: string]: any }
|
||||
@@ -1353,6 +1403,8 @@ export interface MediaServerConf {
|
||||
enabled: boolean
|
||||
// 同步媒体体库列表
|
||||
sync_libraries?: string[]
|
||||
// 自动同步间隔(小时),为空时使用旧全局配置
|
||||
sync_interval?: number | null
|
||||
}
|
||||
|
||||
// 文件整理目录配置
|
||||
@@ -1513,6 +1565,14 @@ export interface TransferForm {
|
||||
tmdbid?: number
|
||||
// 豆瓣 ID
|
||||
doubanid?: string
|
||||
// Bangumi ID
|
||||
bangumiid?: number
|
||||
// AniList ID
|
||||
anilistid?: number
|
||||
// 媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 数据源原生ID
|
||||
media_id?: string | null
|
||||
// 季号
|
||||
season?: number
|
||||
// 类型
|
||||
@@ -1541,6 +1601,8 @@ export interface TransferForm {
|
||||
episode_group?: string | null
|
||||
// 预览模式
|
||||
preview?: boolean
|
||||
// 清理已有成功记录后重新整理
|
||||
reorganize: boolean
|
||||
}
|
||||
|
||||
// 手动整理请求
|
||||
@@ -1567,6 +1629,14 @@ export interface ManualTransferTargetPathData {
|
||||
library_category_folder?: boolean | null
|
||||
}
|
||||
|
||||
// 手动整理命中的成功历史摘要
|
||||
export interface ManualTransferHistoryInfo {
|
||||
// 是否应执行重新整理
|
||||
reorganize: boolean
|
||||
// 命中的成功历史数量
|
||||
history_count: number
|
||||
}
|
||||
|
||||
// 手动整理预览统计
|
||||
export interface ManualTransferPreviewSummary {
|
||||
// 总数
|
||||
@@ -1783,6 +1853,10 @@ export interface RecognitionCacheData {
|
||||
recognized: number
|
||||
// 未识别数量
|
||||
unrecognized: number
|
||||
// 共享识别成功命中数量
|
||||
shared_recognized: number
|
||||
// 是否开启共享识别
|
||||
shared_recognize_enabled: boolean
|
||||
// 缓存数据
|
||||
data: RecognitionCacheItem[]
|
||||
}
|
||||
|
||||
@@ -35,10 +35,13 @@ interface AgentAssistantEntryBubbleInput {
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
active?: boolean
|
||||
/** 是否允许入口的随机动作、指针跟随和自动贴边,不影响 thinking 等业务状态。 */
|
||||
motionActive?: boolean
|
||||
thinking?: boolean
|
||||
}>(),
|
||||
{
|
||||
active: true,
|
||||
motionActive: true,
|
||||
thinking: false,
|
||||
},
|
||||
)
|
||||
@@ -214,7 +217,7 @@ const {
|
||||
playAction: playAgentPetAction,
|
||||
scheduleRandomAction: scheduleFabRandomAction,
|
||||
} = useAgentPetMachine({
|
||||
active: () => props.active,
|
||||
active: () => props.active && props.motionActive,
|
||||
docked: fabDocked,
|
||||
dragging: fabDragging,
|
||||
pressed: fabPressed,
|
||||
@@ -237,8 +240,7 @@ function getViewportSize() {
|
||||
|
||||
// 取布局视口和可见视口的较小值,避免两者短暂不同步时把入口计算到屏幕外。
|
||||
return {
|
||||
height:
|
||||
visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight,
|
||||
height: visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight,
|
||||
width: visualWidth > 0 && layoutWidth > 0 ? Math.min(visualWidth, layoutWidth) : visualWidth || layoutWidth,
|
||||
}
|
||||
}
|
||||
@@ -826,17 +828,18 @@ function updateFabPointerFromPoint(point: FabPointerPoint) {
|
||||
|
||||
// 使用 requestAnimationFrame 合并高频指针事件,降低全局跟随的渲染开销。
|
||||
function queueFabPointerUpdate(clientX: number, clientY: number) {
|
||||
if (!props.active) return
|
||||
if (!props.active || !props.motionActive) return
|
||||
|
||||
fabPendingPointerPoint = { clientX, clientY }
|
||||
if (fabPointerFrame) return
|
||||
|
||||
fabPointerFrame = window.requestAnimationFrame(() => {
|
||||
fabPointerFrame = 0
|
||||
if (!fabPendingPointerPoint) return
|
||||
|
||||
updateFabPointerFromPoint(fabPendingPointerPoint)
|
||||
const point = fabPendingPointerPoint
|
||||
fabPendingPointerPoint = null
|
||||
if (!point || !props.active || !props.motionActive) return
|
||||
|
||||
updateFabPointerFromPoint(point)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -850,22 +853,6 @@ function updateFabPointer(event: PointerEvent) {
|
||||
queueFabPointerUpdate(event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
// 重置机器人按压状态和眼神跟随位移。
|
||||
function resetFabPointer() {
|
||||
fabPressed.value = false
|
||||
fabPointerStyle.value = {
|
||||
'--agent-assistant-body-x': '0px',
|
||||
'--agent-assistant-body-y': '0px',
|
||||
'--agent-assistant-eye-x': '0px',
|
||||
'--agent-assistant-eye-y': '0px',
|
||||
'--agent-assistant-head-x': '0px',
|
||||
'--agent-assistant-head-y': '0px',
|
||||
'--agent-assistant-pointer-x': '0px',
|
||||
'--agent-assistant-pointer-y': '0px',
|
||||
'--agent-assistant-robot-tilt': '0deg',
|
||||
}
|
||||
}
|
||||
|
||||
// 清理入口自动贴边计时器。
|
||||
function clearFabIdleTimer() {
|
||||
if (fabIdleTimer === null) return
|
||||
@@ -895,11 +882,20 @@ function suppressNextFabClick() {
|
||||
// 在入口靠近右侧边缘且空闲时安排自动贴边收起。
|
||||
function scheduleFabAutoDock() {
|
||||
clearFabIdleTimer()
|
||||
if (fabDocked.value || hasKeepOpenFabBubbles.value || fabRandomAction.value || !shouldFabAutoDock()) return
|
||||
if (
|
||||
!props.active ||
|
||||
!props.motionActive ||
|
||||
fabDocked.value ||
|
||||
hasKeepOpenFabBubbles.value ||
|
||||
fabRandomAction.value ||
|
||||
!shouldFabAutoDock()
|
||||
)
|
||||
return
|
||||
|
||||
fabIdleTimer = window.setTimeout(() => {
|
||||
fabIdleTimer = null
|
||||
if (fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock()) return
|
||||
if (!props.active || !props.motionActive || fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock())
|
||||
return
|
||||
|
||||
if (fabRandomAction.value) {
|
||||
scheduleFabAutoDock()
|
||||
@@ -915,14 +911,36 @@ function pauseFabAutoDock() {
|
||||
clearFabIdleTimer()
|
||||
}
|
||||
|
||||
// 取消挂起的全局指针帧并移除监听器。
|
||||
function teardownFabPointerTracking() {
|
||||
// 取消挂起的全局指针帧,避免状态切换后迟到回调重新写入位移。
|
||||
function cancelFabPointerUpdate() {
|
||||
if (fabPointerFrame) {
|
||||
window.cancelAnimationFrame(fabPointerFrame)
|
||||
fabPointerFrame = 0
|
||||
}
|
||||
|
||||
fabPendingPointerPoint = null
|
||||
}
|
||||
|
||||
// 重置机器人按压状态和眼神跟随位移。
|
||||
function resetFabPointer() {
|
||||
cancelFabPointerUpdate()
|
||||
fabPressed.value = false
|
||||
fabPointerStyle.value = {
|
||||
'--agent-assistant-body-x': '0px',
|
||||
'--agent-assistant-body-y': '0px',
|
||||
'--agent-assistant-eye-x': '0px',
|
||||
'--agent-assistant-eye-y': '0px',
|
||||
'--agent-assistant-head-x': '0px',
|
||||
'--agent-assistant-head-y': '0px',
|
||||
'--agent-assistant-pointer-x': '0px',
|
||||
'--agent-assistant-pointer-y': '0px',
|
||||
'--agent-assistant-robot-tilt': '0deg',
|
||||
}
|
||||
}
|
||||
|
||||
// 取消挂起的全局指针帧并移除监听器。
|
||||
function teardownFabPointerTracking() {
|
||||
cancelFabPointerUpdate()
|
||||
window.removeEventListener('pointermove', handleGlobalFabPointer)
|
||||
window.removeEventListener('pointerdown', handleGlobalFabPointer)
|
||||
}
|
||||
@@ -1432,6 +1450,19 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.motionActive,
|
||||
motionActive => {
|
||||
if (motionActive) {
|
||||
if (props.active && shouldFabAutoDock()) scheduleFabAutoDock()
|
||||
return
|
||||
}
|
||||
|
||||
clearFabIdleTimer()
|
||||
resetFabPointer()
|
||||
},
|
||||
)
|
||||
|
||||
onScopeDispose(clearFabIdleTimer)
|
||||
onScopeDispose(clearFabSuppressNextClickTimer)
|
||||
onScopeDispose(resetFabBubbles)
|
||||
@@ -1542,17 +1573,18 @@ defineExpose({
|
||||
/* 保持机器人和提示气泡高于 Vuetify 弹窗(2400)及全局 Toast(2500)。 */
|
||||
z-index: 2600;
|
||||
|
||||
--agent-assistant-robot-outline: #5b00c5;
|
||||
--agent-assistant-robot-outline-soft: #7432df;
|
||||
--agent-assistant-robot-shell-start: #d3bbff;
|
||||
--agent-assistant-robot-shell-mid: #a576ff;
|
||||
--agent-assistant-robot-shell-end: #8d51f9;
|
||||
--agent-assistant-robot-face-start: #24124e;
|
||||
--agent-assistant-robot-face-end: #100525;
|
||||
--agent-assistant-robot-eye: #f1dcff;
|
||||
--agent-assistant-robot-play: #fff;
|
||||
--agent-assistant-robot-shadow: rgba(54, 0, 126, 28%);
|
||||
--agent-assistant-robot-shadow-strong: rgba(54, 0, 126, 34%);
|
||||
--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%);
|
||||
--agent-assistant-robot-shell-mid: color-mix(in srgb, rgb(var(--v-theme-primary)) 74%, white 26%);
|
||||
--agent-assistant-robot-shell-end: rgb(var(--v-theme-primary));
|
||||
--agent-assistant-robot-face-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 30%, #080a12 70%);
|
||||
--agent-assistant-robot-face-end: color-mix(in srgb, rgb(var(--v-theme-primary)) 14%, #03050a 86%);
|
||||
--agent-assistant-robot-eye: color-mix(in srgb, rgb(var(--v-theme-primary)) 12%, white 88%);
|
||||
--agent-assistant-robot-play: rgb(var(--v-theme-on-primary));
|
||||
--agent-assistant-robot-shadow: color-mix(in srgb, rgb(var(--v-theme-primary)) 28%, transparent);
|
||||
--agent-assistant-robot-shadow-strong: color-mix(in srgb, rgb(var(--v-theme-primary)) 36%, transparent);
|
||||
--agent-assistant-robot-shade: color-mix(in srgb, rgb(var(--v-theme-primary)) 25%, transparent);
|
||||
--agent-assistant-bubble-bg: rgba(var(--v-theme-surface), 0.92);
|
||||
--agent-assistant-bot-scale: 1;
|
||||
--agent-assistant-bot-pressed-scale: 0.96;
|
||||
@@ -1752,12 +1784,8 @@ defineExpose({
|
||||
|
||||
.agent-assistant-fab__bubbles--arrow-notification::before {
|
||||
--agent-assistant-bubble-arrow-border: rgba(var(--v-theme-primary), 0.22);
|
||||
--agent-assistant-bubble-arrow-bg: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--v-theme-primary), 0.1),
|
||||
transparent 48%
|
||||
),
|
||||
rgba(var(--v-theme-surface), 0.94);
|
||||
--agent-assistant-bubble-arrow-bg:
|
||||
linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1), transparent 48%), rgba(var(--v-theme-surface), 0.94);
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubbles--arrow-success::before {
|
||||
@@ -1778,11 +1806,8 @@ defineExpose({
|
||||
|
||||
.agent-assistant-fab__bubbles--arrow-toast::before {
|
||||
--agent-assistant-bubble-arrow-border: rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.3);
|
||||
--agent-assistant-bubble-arrow-bg: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12),
|
||||
transparent 54%
|
||||
),
|
||||
--agent-assistant-bubble-arrow-bg:
|
||||
linear-gradient(135deg, rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12), transparent 54%),
|
||||
rgba(var(--v-theme-surface), 0.95);
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,16 @@ interface AgentStreamMessageOptions {
|
||||
originalChatId?: string
|
||||
}
|
||||
|
||||
interface AgentPendingStreamRecovery {
|
||||
sessionId: string
|
||||
startedAt: number
|
||||
attempts: number
|
||||
}
|
||||
|
||||
interface AgentStreamReadResult {
|
||||
receivedTerminalEvent: boolean
|
||||
}
|
||||
|
||||
interface AgentSlashCommand {
|
||||
command: string
|
||||
description: string
|
||||
@@ -158,9 +168,12 @@ const userStore = useUserStore()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
/** 是否允许面板的装饰性动效,不影响消息流、thinking 或输入反馈。 */
|
||||
motionActive?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: false,
|
||||
motionActive: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -201,6 +214,7 @@ const historyHasMore = ref(true)
|
||||
const slashCommands = ref<AgentSlashCommand[]>([])
|
||||
const slashCommandsLoading = ref(false)
|
||||
const slashCommandsLoaded = ref(false)
|
||||
const pendingStreamRecovery = ref<AgentPendingStreamRecovery | null>(null)
|
||||
let abortController: AbortController | null = null
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let mediaRecorderStream: MediaStream | null = null
|
||||
@@ -210,14 +224,9 @@ let messageScrollFrame: number | null = null
|
||||
let pendingMessageScrollToBottom = false
|
||||
let streamPersistTimer: number | null = null
|
||||
let userAbortRequested = false
|
||||
let streamRecoveryAbortRequested = false
|
||||
let streamRecoveryTimer: number | null = null
|
||||
let pendingStreamRecovery:
|
||||
| {
|
||||
sessionId: string
|
||||
startedAt: number
|
||||
attempts: number
|
||||
}
|
||||
| null = null
|
||||
let activeStreamStartedAt = 0
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
@@ -233,11 +242,12 @@ md.use(mdLinkAttributes, {
|
||||
},
|
||||
})
|
||||
|
||||
// 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。
|
||||
const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
|
||||
const canSend = computed(
|
||||
() =>
|
||||
(inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !sending.value && !recording.value,
|
||||
() => (inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !isBusy.value && !recording.value,
|
||||
)
|
||||
const canRecord = computed(() => !sending.value && !recording.value)
|
||||
const canRecord = computed(() => !isBusy.value && !recording.value)
|
||||
// 获取当前输入对应的斜杠命令查询词。
|
||||
const slashCommandQuery = computed(() => {
|
||||
const text = inputText.value.trimStart()
|
||||
@@ -250,24 +260,23 @@ const filteredSlashCommands = computed(() => {
|
||||
const query = slashCommandQuery.value
|
||||
if (!query) return slashCommands.value
|
||||
|
||||
return slashCommands.value
|
||||
.filter(command => {
|
||||
const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase()
|
||||
return slashCommands.value.filter(command => {
|
||||
const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase()
|
||||
|
||||
return haystack.includes(query)
|
||||
})
|
||||
return haystack.includes(query)
|
||||
})
|
||||
})
|
||||
// 判断是否展示命令建议浮层。
|
||||
const showSlashCommandMenu = computed(
|
||||
() =>
|
||||
inputText.value.trimStart().startsWith('/') &&
|
||||
!sending.value &&
|
||||
!isBusy.value &&
|
||||
!recording.value &&
|
||||
(filteredSlashCommands.value.length > 0 || slashCommandsLoading.value),
|
||||
)
|
||||
// 根据智能体处理状态切换输入框背景提示。
|
||||
const inputPlaceholder = computed(() =>
|
||||
sending.value ? t('agentAssistant.processingPlaceholder') : t('agentAssistant.placeholder'),
|
||||
isBusy.value ? t('agentAssistant.processingPlaceholder') : t('agentAssistant.placeholder'),
|
||||
)
|
||||
const recordingTimeText = computed(() => {
|
||||
const seconds = Math.max(0, recordingDuration.value)
|
||||
@@ -776,32 +785,82 @@ function clearStreamRecoveryTimer() {
|
||||
streamRecoveryTimer = null
|
||||
}
|
||||
|
||||
// 记录当前流的恢复上下文,并立即持久化,供 PWA 后台冻结或重载后继续轮询。
|
||||
function beginStreamRecovery(targetSessionId: string, startedAt: number) {
|
||||
const current = pendingStreamRecovery.value
|
||||
pendingStreamRecovery.value = {
|
||||
sessionId: targetSessionId,
|
||||
startedAt,
|
||||
attempts: current?.sessionId === targetSessionId ? current.attempts : 0,
|
||||
}
|
||||
persistState({ syncHistory: false })
|
||||
}
|
||||
|
||||
// 判断服务端正式会话 ID 或客户端会话 ID 是否属于当前恢复任务。
|
||||
function matchesStreamRecoverySession(session: AgentSessionHistoryItem, targetSessionId: string) {
|
||||
const requestedIds = new Set([sessionId.value, targetSessionId].filter(Boolean))
|
||||
|
||||
return [session.sessionId, session.clientSessionId].some(id => Boolean(id && requestedIds.has(id)))
|
||||
}
|
||||
|
||||
// 将无法继续恢复的流式占位转换成明确错误,避免界面永久停留在空白或加载态。
|
||||
function failStreamRecovery() {
|
||||
pendingStreamRecovery.value = null
|
||||
const assistantMessage = [...messages.value]
|
||||
.reverse()
|
||||
.find(message => message.role === 'assistant' && message.status === 'streaming')
|
||||
if (assistantMessage) {
|
||||
assistantMessage.status = 'error'
|
||||
assistantMessage.content ||= t('agentAssistant.recoveryFailed')
|
||||
markToolsDone(assistantMessage)
|
||||
refreshMessageList()
|
||||
} else {
|
||||
streamError.value = t('agentAssistant.recoveryFailed')
|
||||
}
|
||||
persistState()
|
||||
}
|
||||
|
||||
// 从服务端拉取当前会话展示快照,用于移动端后台断开 SSE 后恢复最终结果。
|
||||
async function restoreCurrentSessionFromServer(targetSessionId: string, startedAt: number) {
|
||||
const session = await loadServerHistorySession(targetSessionId)
|
||||
const activeSessionIds = new Set([sessionId.value, targetSessionId, session.clientSessionId].filter(Boolean))
|
||||
if (!activeSessionIds.has(session.sessionId)) return { restored: false, pending: false }
|
||||
const activeRecovery = pendingStreamRecovery.value
|
||||
if (!activeRecovery || activeRecovery.sessionId !== targetSessionId) return { restored: false, pending: false }
|
||||
if (!matchesStreamRecoverySession(session, targetSessionId)) return { restored: false, pending: false }
|
||||
if (session.updatedAt < startedAt - 1000) return { restored: false, pending: Boolean(session.isProcessing) }
|
||||
if (!session.messages.length) return { restored: false, pending: Boolean(session.isProcessing) }
|
||||
if (session.isProcessing) return { restored: false, pending: true }
|
||||
if (!session.messages.length) return { restored: false, pending: false }
|
||||
|
||||
messages.value = normalizeStoredMessages(session.messages)
|
||||
pendingStreamRecovery = null
|
||||
const restoredMessages = normalizeStoredMessages(session.messages)
|
||||
restoredMessages.forEach(message => {
|
||||
if (message.status !== 'streaming') return
|
||||
|
||||
message.status = 'done'
|
||||
markToolsDone(message)
|
||||
})
|
||||
messages.value = restoredMessages
|
||||
sessionId.value = session.sessionId
|
||||
pendingStreamRecovery.value = null
|
||||
clearStreamRecoveryTimer()
|
||||
persistState({ syncHistory: false })
|
||||
scrollToBottom()
|
||||
if (abortController) {
|
||||
streamRecoveryAbortRequested = true
|
||||
abortController.abort()
|
||||
}
|
||||
return { restored: true, pending: false }
|
||||
}
|
||||
|
||||
// WebAgent SSE 断流后,后台任务仍会完成并保存快照;前端回到前台后轮询拉取。
|
||||
function scheduleStreamRecovery(delay = 1200) {
|
||||
if (!pendingStreamRecovery || typeof window === 'undefined') return
|
||||
if (!pendingStreamRecovery.value || typeof window === 'undefined') return
|
||||
|
||||
clearStreamRecoveryTimer()
|
||||
streamRecoveryTimer = window.setTimeout(async () => {
|
||||
streamRecoveryTimer = null
|
||||
if (!pendingStreamRecovery) return
|
||||
if (!pendingStreamRecovery.value) return
|
||||
if (document.visibilityState === 'hidden') return
|
||||
|
||||
const recovery = pendingStreamRecovery
|
||||
const recovery = pendingStreamRecovery.value
|
||||
try {
|
||||
const result = await restoreCurrentSessionFromServer(recovery.sessionId, recovery.startedAt)
|
||||
if (result.restored) return
|
||||
@@ -813,17 +872,43 @@ function scheduleStreamRecovery(delay = 1200) {
|
||||
// 会话快照可能还未写入,继续按退避间隔等待。
|
||||
}
|
||||
|
||||
if (!pendingStreamRecovery || pendingStreamRecovery.sessionId !== recovery.sessionId) return
|
||||
pendingStreamRecovery.attempts += 1
|
||||
if (pendingStreamRecovery.attempts > 8) {
|
||||
pendingStreamRecovery = null
|
||||
if (!pendingStreamRecovery.value || pendingStreamRecovery.value.sessionId !== recovery.sessionId) return
|
||||
pendingStreamRecovery.value.attempts += 1
|
||||
if (pendingStreamRecovery.value.attempts > 8) {
|
||||
failStreamRecovery()
|
||||
return
|
||||
}
|
||||
|
||||
scheduleStreamRecovery(Math.min(8000, 1200 + pendingStreamRecovery.attempts * 900))
|
||||
persistState({ syncHistory: false })
|
||||
scheduleStreamRecovery(Math.min(8000, 1200 + pendingStreamRecovery.value.attempts * 900))
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// 从持久化元数据或最后一条 streaming 助手消息重建后台恢复任务。
|
||||
function restorePendingStreamRecovery(value: unknown) {
|
||||
const stored = value && typeof value === 'object' ? (value as Record<string, unknown>) : null
|
||||
const latestAssistantMessage = messages.value.at(-1)
|
||||
const recoverableAssistantMessage =
|
||||
latestAssistantMessage?.role === 'assistant' &&
|
||||
(latestAssistantMessage.status === 'streaming' ||
|
||||
(latestAssistantMessage.status === 'done' && isEmptyAssistantMessage(latestAssistantMessage)))
|
||||
? latestAssistantMessage
|
||||
: undefined
|
||||
const recoverySessionId = stringifyChoiceField(stored?.sessionId) || sessionId.value
|
||||
const startedAt = Number(stored?.startedAt) || recoverableAssistantMessage?.createdAt || 0
|
||||
if (!recoverySessionId || !startedAt || (!stored && !recoverableAssistantMessage)) {
|
||||
pendingStreamRecovery.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (recoverableAssistantMessage) recoverableAssistantMessage.status = 'streaming'
|
||||
pendingStreamRecovery.value = {
|
||||
sessionId: recoverySessionId,
|
||||
startedAt,
|
||||
attempts: Math.max(0, Number(stored?.attempts) || 0),
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复当前会话状态,优先使用本地状态,缺失时使用最近历史。
|
||||
function restoreState() {
|
||||
try {
|
||||
@@ -843,9 +928,11 @@ function restoreState() {
|
||||
const state = JSON.parse(raw)
|
||||
sessionId.value = state.sessionId || createSessionId()
|
||||
messages.value = normalizeStoredMessages(state.messages)
|
||||
restorePendingStreamRecovery(state.streamRecovery)
|
||||
upsertCurrentSessionHistory()
|
||||
} catch (error) {
|
||||
sessionId.value = createSessionId()
|
||||
pendingStreamRecovery.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,6 +1046,7 @@ function persistState(options: { syncHistory?: boolean } = {}) {
|
||||
JSON.stringify({
|
||||
sessionId: sessionId.value,
|
||||
messages: messages.value.slice(-MAX_PERSISTED_MESSAGES),
|
||||
streamRecovery: pendingStreamRecovery.value,
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -1223,7 +1311,10 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
||||
markToolsDone(assistantMessage)
|
||||
break
|
||||
case 'start':
|
||||
if (event.session_id) sessionId.value = event.session_id
|
||||
if (event.session_id) {
|
||||
sessionId.value = event.session_id
|
||||
if (pendingStreamRecovery.value) pendingStreamRecovery.value.sessionId = event.session_id
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
@@ -1248,7 +1339,7 @@ function parseSseBlock(block: string) {
|
||||
}
|
||||
|
||||
// 读取并应用智能助手 SSE 响应流。
|
||||
async function readAgentStream(response: Response, assistantMessage: AgentChatMessage) {
|
||||
async function readAgentStream(response: Response, assistantMessage: AgentChatMessage): Promise<AgentStreamReadResult> {
|
||||
if (!response.body) {
|
||||
throw new Error(t('agentAssistant.noStream'))
|
||||
}
|
||||
@@ -1256,6 +1347,15 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ''
|
||||
let receivedTerminalEvent = false
|
||||
|
||||
// 应用事件并记录服务端是否明确结束本轮流,区分正常完成与无异常的后台断流。
|
||||
const consumeEvent = (event: AgentStreamEvent | null) => {
|
||||
if (!event) return
|
||||
|
||||
applyStreamEvent(event, assistantMessage)
|
||||
if (event.type === 'done' || event.type === 'error') receivedTerminalEvent = true
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
@@ -1266,16 +1366,16 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe
|
||||
buffer = blocks.pop() || ''
|
||||
|
||||
for (const block of blocks) {
|
||||
const event = parseSseBlock(block)
|
||||
if (event) applyStreamEvent(event, assistantMessage)
|
||||
consumeEvent(parseSseBlock(block))
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode()
|
||||
if (buffer.trim()) {
|
||||
const event = parseSseBlock(buffer)
|
||||
if (event) applyStreamEvent(event, assistantMessage)
|
||||
consumeEvent(parseSseBlock(buffer))
|
||||
}
|
||||
|
||||
return { receivedTerminalEvent }
|
||||
}
|
||||
|
||||
// 移动端浏览器退到后台时,SSE/fetch 可能以 TypeError: Load failed 等形式被动断开。
|
||||
@@ -1467,7 +1567,9 @@ async function streamAgentMessage(
|
||||
|
||||
abortController = new AbortController()
|
||||
userAbortRequested = false
|
||||
streamRecoveryAbortRequested = false
|
||||
const streamStartedAt = Date.now()
|
||||
activeStreamStartedAt = streamStartedAt
|
||||
let shouldFollowBottomAfterStream = true
|
||||
let shouldSaveClientSnapshot = true
|
||||
|
||||
@@ -1497,8 +1599,18 @@ async function streamAgentMessage(
|
||||
throw new Error(await resolveAgentResponseErrorMessage(response))
|
||||
}
|
||||
|
||||
await readAgentStream(response, assistantMessage)
|
||||
const streamResult = await readAgentStream(response, assistantMessage)
|
||||
shouldFollowBottomAfterStream = isMessageScrollerNearBottom()
|
||||
if (!streamResult.receivedTerminalEvent) {
|
||||
shouldSaveClientSnapshot = false
|
||||
beginStreamRecovery(sessionId.value, streamStartedAt)
|
||||
refreshMessageList()
|
||||
if (document.visibilityState === 'visible') scheduleStreamRecovery(0)
|
||||
return
|
||||
}
|
||||
|
||||
pendingStreamRecovery.value = null
|
||||
clearStreamRecoveryTimer()
|
||||
if (isEmptyAssistantMessage(assistantMessage)) {
|
||||
messages.value = messages.value.filter(message => message.id !== assistantMessage.id)
|
||||
refreshMessageList()
|
||||
@@ -1510,6 +1622,8 @@ async function streamAgentMessage(
|
||||
refreshMessageList()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError' && streamRecoveryAbortRequested) return
|
||||
|
||||
if (error?.name === 'AbortError' && userAbortRequested) {
|
||||
assistantMessage.status = 'done'
|
||||
markToolsDone(assistantMessage)
|
||||
@@ -1519,13 +1633,8 @@ async function streamAgentMessage(
|
||||
|
||||
if (isRecoverableStreamDisconnect(error)) {
|
||||
shouldSaveClientSnapshot = false
|
||||
pendingStreamRecovery = {
|
||||
sessionId: sessionId.value,
|
||||
startedAt: streamStartedAt,
|
||||
attempts: 0,
|
||||
}
|
||||
assistantMessage.status = 'done'
|
||||
markToolsDone(assistantMessage)
|
||||
beginStreamRecovery(sessionId.value, streamStartedAt)
|
||||
assistantMessage.status = 'streaming'
|
||||
refreshMessageList()
|
||||
if (document.visibilityState === 'visible') scheduleStreamRecovery(1200)
|
||||
return
|
||||
@@ -1537,7 +1646,9 @@ async function streamAgentMessage(
|
||||
refreshMessageList()
|
||||
} finally {
|
||||
abortController = null
|
||||
activeStreamStartedAt = 0
|
||||
userAbortRequested = false
|
||||
streamRecoveryAbortRequested = false
|
||||
clearStreamPersistTimer()
|
||||
persistState()
|
||||
if (shouldSaveClientSnapshot) {
|
||||
@@ -1556,7 +1667,7 @@ async function streamAgentMessage(
|
||||
async function sendMessage() {
|
||||
const text = inputText.value.trim()
|
||||
const attachments = [...pendingAttachments.value]
|
||||
if ((!text && !attachments.length) || sending.value) return
|
||||
if ((!text && !attachments.length) || isBusy.value) return
|
||||
|
||||
streamError.value = ''
|
||||
inputText.value = ''
|
||||
@@ -1711,7 +1822,7 @@ function toggleVoiceRecording() {
|
||||
|
||||
// 处理选择按钮点击,保存可读选择描述并把真实值发给 Agent。
|
||||
async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceCard, button: AgentChoiceButton) {
|
||||
if (sending.value || choice.status !== 'pending') return
|
||||
if (isBusy.value || choice.status !== 'pending') return
|
||||
|
||||
sending.value = true
|
||||
streamError.value = ''
|
||||
@@ -1787,8 +1898,21 @@ async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceC
|
||||
// 中止当前流式回复。
|
||||
function stopGeneration() {
|
||||
userAbortRequested = true
|
||||
pendingStreamRecovery = null
|
||||
pendingStreamRecovery.value = null
|
||||
clearStreamRecoveryTimer()
|
||||
const assistantMessage = [...messages.value]
|
||||
.reverse()
|
||||
.find(message => message.role === 'assistant' && message.status === 'streaming')
|
||||
if (assistantMessage) {
|
||||
if (isEmptyAssistantMessage(assistantMessage)) {
|
||||
messages.value = messages.value.filter(message => message.id !== assistantMessage.id)
|
||||
} else {
|
||||
assistantMessage.status = 'done'
|
||||
markToolsDone(assistantMessage)
|
||||
}
|
||||
refreshMessageList()
|
||||
}
|
||||
persistState()
|
||||
if (sessionId.value) {
|
||||
fetchAgentApi(`message/agent/sessions/${encodeURIComponent(sessionId.value)}/stop`, {
|
||||
method: 'POST',
|
||||
@@ -1813,7 +1937,7 @@ function startNewSession() {
|
||||
|
||||
// 从历史列表恢复指定会话,同时把它设为当前本地会话。
|
||||
async function loadHistorySession(targetSessionId: string) {
|
||||
if (sending.value) return
|
||||
if (isBusy.value) return
|
||||
|
||||
let historySession = historySessions.value.find(item => item.sessionId === targetSessionId)
|
||||
if (!historySession) return
|
||||
@@ -1837,7 +1961,7 @@ async function loadHistorySession(targetSessionId: string) {
|
||||
|
||||
// 删除指定历史会话;若删除的是当前会话,则切换到新的空会话。
|
||||
async function deleteHistorySession(targetSessionId: string) {
|
||||
if (sending.value && targetSessionId === sessionId.value) return
|
||||
if (isBusy.value && targetSessionId === sessionId.value) return
|
||||
|
||||
try {
|
||||
await fetchAgentApi(`message/agent/sessions/${encodeURIComponent(targetSessionId)}`, {
|
||||
@@ -1906,8 +2030,23 @@ function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && isOpen.value) closeDrawer()
|
||||
}
|
||||
|
||||
// 页面从后台恢复时尝试拉取 WebAgent 后台完成后的会话快照。
|
||||
// 页面进入后台时保存流式占位,恢复可见时尝试拉取 WebAgent 后台完成后的会话快照。
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
if (sending.value && activeStreamStartedAt && sessionId.value) {
|
||||
beginStreamRecovery(sessionId.value, activeStreamStartedAt)
|
||||
} else if (pendingStreamRecovery.value) {
|
||||
clearStreamPersistTimer()
|
||||
persistState({ syncHistory: false })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
scheduleStreamRecovery(0)
|
||||
}
|
||||
|
||||
// PWA 从页面缓存或系统挂起状态返回时补触发恢复,兼容未派发 visibilitychange 的浏览器。
|
||||
function handlePageShow() {
|
||||
if (document.visibilityState === 'visible') scheduleStreamRecovery(0)
|
||||
}
|
||||
|
||||
@@ -1927,14 +2066,16 @@ watch(isOpen, open => {
|
||||
if (open) scrollToBottom()
|
||||
})
|
||||
|
||||
watch(sending, value => emit('thinking-change', value), { immediate: true })
|
||||
watch(isBusy, value => emit('thinking-change', value), { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
restoreHistorySessions()
|
||||
restoreState()
|
||||
loadServerHistorySessions()
|
||||
if (pendingStreamRecovery.value && document.visibilityState === 'visible') scheduleStreamRecovery(0)
|
||||
syncInputHeight()
|
||||
window.addEventListener('keydown', handleGlobalKeydown)
|
||||
window.addEventListener('pageshow', handlePageShow)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
})
|
||||
|
||||
@@ -1948,6 +2089,7 @@ onScopeDispose(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||
window.removeEventListener('pageshow', handlePageShow)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
})
|
||||
</script>
|
||||
@@ -1956,6 +2098,7 @@ onScopeDispose(() => {
|
||||
<aside
|
||||
v-show="isOpen"
|
||||
class="agent-assistant-panel"
|
||||
:class="{ 'is-motion-paused': !props.motionActive, 'is-open': isOpen }"
|
||||
:style="drawerStyle"
|
||||
role="dialog"
|
||||
:aria-label="t('agentAssistant.title')"
|
||||
@@ -1979,7 +2122,7 @@ onScopeDispose(() => {
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-semibold">{{ t('agentAssistant.title') }}</div>
|
||||
<div class="agent-assistant-status">
|
||||
{{ sending ? t('agentAssistant.thinking') : t('agentAssistant.ready') }}
|
||||
{{ isBusy ? t('agentAssistant.thinking') : t('agentAssistant.ready') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2029,7 +2172,7 @@ onScopeDispose(() => {
|
||||
class="agent-assistant-history-item"
|
||||
:class="{ 'is-active': isCurrentHistorySession(historySession.sessionId) }"
|
||||
type="button"
|
||||
:disabled="sending"
|
||||
:disabled="isBusy"
|
||||
@click="loadHistorySession(historySession.sessionId)"
|
||||
>
|
||||
<span class="agent-assistant-history-item__content">
|
||||
@@ -2043,7 +2186,7 @@ onScopeDispose(() => {
|
||||
</span>
|
||||
<IconBtn
|
||||
size="x-small"
|
||||
:disabled="sending"
|
||||
:disabled="isBusy"
|
||||
:title="t('agentAssistant.deleteHistory')"
|
||||
:aria-label="t('agentAssistant.deleteHistory')"
|
||||
@click.stop="deleteHistorySession(historySession.sessionId)"
|
||||
@@ -2064,7 +2207,7 @@ onScopeDispose(() => {
|
||||
</VCard>
|
||||
</VMenu>
|
||||
<IconBtn
|
||||
:disabled="sending"
|
||||
:disabled="isBusy"
|
||||
:title="t('agentAssistant.newChat')"
|
||||
:aria-label="t('agentAssistant.newChat')"
|
||||
@click="startNewSession"
|
||||
@@ -2147,7 +2290,7 @@ onScopeDispose(() => {
|
||||
class="agent-assistant-choice__button"
|
||||
size="small"
|
||||
variant="flat"
|
||||
:disabled="sending || choice.status !== 'pending'"
|
||||
:disabled="isBusy || choice.status !== 'pending'"
|
||||
@click="handleChoiceClick(message, choice, button)"
|
||||
>
|
||||
{{ button.label }}
|
||||
@@ -2258,7 +2401,7 @@ onScopeDispose(() => {
|
||||
<IconBtn
|
||||
class="agent-assistant-surface-btn"
|
||||
size="x-small"
|
||||
:disabled="sending"
|
||||
:disabled="isBusy"
|
||||
:title="t('agentAssistant.removeAttachment')"
|
||||
:aria-label="t('agentAssistant.removeAttachment')"
|
||||
@click="removePendingAttachment(attachment.id)"
|
||||
@@ -2290,12 +2433,12 @@ onScopeDispose(() => {
|
||||
class="agent-assistant-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
:disabled="sending"
|
||||
:disabled="isBusy"
|
||||
@change="handleFileSelection"
|
||||
/>
|
||||
<IconBtn
|
||||
class="agent-assistant-attach agent-assistant-surface-btn"
|
||||
:disabled="sending || recording"
|
||||
:disabled="isBusy || recording"
|
||||
:title="t('agentAssistant.attachFile')"
|
||||
:aria-label="t('agentAssistant.attachFile')"
|
||||
@click="openFilePicker"
|
||||
@@ -2307,7 +2450,7 @@ onScopeDispose(() => {
|
||||
v-model="inputText"
|
||||
class="agent-assistant-textarea"
|
||||
rows="1"
|
||||
:disabled="sending || recording"
|
||||
:disabled="isBusy || recording"
|
||||
:placeholder="inputPlaceholder"
|
||||
@input="handleInputChange"
|
||||
@keydown="handleInputKeydown"
|
||||
@@ -2332,12 +2475,12 @@ onScopeDispose(() => {
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
class="agent-assistant-send agent-assistant-surface-btn"
|
||||
:disabled="!sending && !canSend"
|
||||
:title="sending ? t('agentAssistant.stop') : t('common.send')"
|
||||
:aria-label="sending ? t('agentAssistant.stop') : t('common.send')"
|
||||
@click="sending ? stopGeneration() : sendMessage()"
|
||||
:disabled="!isBusy && !canSend"
|
||||
:title="isBusy ? t('agentAssistant.stop') : t('common.send')"
|
||||
:aria-label="isBusy ? t('agentAssistant.stop') : t('common.send')"
|
||||
@click="isBusy ? stopGeneration() : sendMessage()"
|
||||
>
|
||||
<VIcon :icon="sending ? 'mdi-stop' : 'mdi-send'" />
|
||||
<VIcon :icon="isBusy ? 'mdi-stop' : 'mdi-send'" />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -2424,14 +2567,15 @@ onScopeDispose(() => {
|
||||
justify-content: center;
|
||||
border-radius: var(--app-control-radius);
|
||||
|
||||
--agent-assistant-mini-robot-outline: #5b00c5;
|
||||
--agent-assistant-mini-robot-outline-soft: #7432df;
|
||||
--agent-assistant-mini-robot-shell-start: #d3bbff;
|
||||
--agent-assistant-mini-robot-shell-mid: #a576ff;
|
||||
--agent-assistant-mini-robot-shell-end: #8d51f9;
|
||||
--agent-assistant-mini-robot-face-start: #24124e;
|
||||
--agent-assistant-mini-robot-face-end: #100525;
|
||||
--agent-assistant-mini-robot-eye: #f1dcff;
|
||||
--agent-assistant-mini-robot-outline: color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, #090510 28%);
|
||||
--agent-assistant-mini-robot-outline-soft: color-mix(in srgb, rgb(var(--v-theme-primary)) 84%, #090510 16%);
|
||||
--agent-assistant-mini-robot-shell-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 38%, white 62%);
|
||||
--agent-assistant-mini-robot-shell-mid: color-mix(in srgb, rgb(var(--v-theme-primary)) 74%, white 26%);
|
||||
--agent-assistant-mini-robot-shell-end: rgb(var(--v-theme-primary));
|
||||
--agent-assistant-mini-robot-face-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 30%, #080a12 70%);
|
||||
--agent-assistant-mini-robot-face-end: color-mix(in srgb, rgb(var(--v-theme-primary)) 14%, #03050a 86%);
|
||||
--agent-assistant-mini-robot-eye: color-mix(in srgb, rgb(var(--v-theme-primary)) 12%, white 88%);
|
||||
--agent-assistant-mini-robot-shade: color-mix(in srgb, rgb(var(--v-theme-primary)) 22%, transparent);
|
||||
|
||||
background: rgba(var(--v-theme-primary), 0.12);
|
||||
block-size: 2.5rem;
|
||||
@@ -2488,7 +2632,7 @@ onScopeDispose(() => {
|
||||
);
|
||||
block-size: 1.04rem;
|
||||
box-shadow:
|
||||
inset 0 -0.12rem 0 rgba(54, 0, 126, 22%),
|
||||
inset 0 -0.12rem 0 var(--agent-assistant-mini-robot-shade),
|
||||
inset 0.08rem 0.08rem 0 rgba(255, 255, 255, 22%);
|
||||
inline-size: 1.45rem;
|
||||
inset-block-start: 0.42rem;
|
||||
@@ -2515,13 +2659,16 @@ onScopeDispose(() => {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 0 0 999px 999px;
|
||||
animation: agent-fab-blink 4.8s ease-in-out infinite;
|
||||
block-size: 0.24rem;
|
||||
border-block-end: 0.1rem solid var(--agent-assistant-mini-robot-eye);
|
||||
inline-size: 0.22rem;
|
||||
inset-block-start: 0.16rem;
|
||||
}
|
||||
|
||||
.agent-assistant-panel.is-open:not(.is-motion-paused) .agent-assistant-mini-bot__eye {
|
||||
animation: agent-fab-blink 4.8s ease-in-out 1;
|
||||
}
|
||||
|
||||
.agent-assistant-mini-bot__eye--left {
|
||||
inset-inline-start: 0.22rem;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import AgentAssistantEntry from './AgentAssistantEntry.vue'
|
||||
import AgentAssistantPanel from './AgentAssistantPanel.vue'
|
||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||
|
||||
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
|
||||
|
||||
const panelOpen = ref(false)
|
||||
const thinking = ref(false)
|
||||
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
||||
const { allowsDecorativeMotion } = useAppActivityLifecycle()
|
||||
|
||||
// 打开 Agent 面板并清空入口预览气泡。
|
||||
function openPanel() {
|
||||
@@ -23,6 +25,17 @@ function handleAssistantPreview(value: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" />
|
||||
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" />
|
||||
<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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
71
src/components/agent/__tests__/AgentAssistantEntry.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentAssistantEntry from '@/components/agent/AgentAssistantEntry.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
describe('AgentAssistantEntry lifecycle motion', () => {
|
||||
let animationFrameCallbacks: Map<number, FrameRequestCallback>
|
||||
let nextAnimationFrameId: number
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
animationFrameCallbacks = new Map()
|
||||
nextAnimationFrameId = 1
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextAnimationFrameId++
|
||||
animationFrameCallbacks.set(id, callback)
|
||||
return id
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'cancelAnimationFrame',
|
||||
vi.fn((id: number) => {
|
||||
animationFrameCallbacks.delete(id)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cancels pointer frames and auto-dock timers when decorative motion stops', async () => {
|
||||
const wrapper = shallowMount(AgentAssistantEntry, {
|
||||
global: {
|
||||
stubs: {
|
||||
AgentPetStage: true,
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
active: true,
|
||||
motionActive: true,
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const pointerEvent = new Event('pointermove')
|
||||
Object.assign(pointerEvent, { clientX: 480, clientY: 320 })
|
||||
const frameCountBeforePointer = animationFrameCallbacks.size
|
||||
window.dispatchEvent(pointerEvent)
|
||||
const pointerFrameId = nextAnimationFrameId - 1
|
||||
|
||||
expect(animationFrameCallbacks.size).toBe(frameCountBeforePointer + 1)
|
||||
expect(vi.getTimerCount()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
await wrapper.setProps({ motionActive: false })
|
||||
await nextTick()
|
||||
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledWith(pointerFrameId)
|
||||
expect(animationFrameCallbacks.has(pointerFrameId)).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
240
src/components/agent/__tests__/AgentAssistantPanel.spec.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentAssistantPanel from '@/components/agent/AgentAssistantPanel.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('vuetify', () => ({
|
||||
useDisplay: () => ({ mdAndDown: { value: true } }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({ token: null }),
|
||||
useUserStore: () => ({ getUserName: 'Tester' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/plugins/i18n', () => ({
|
||||
getCurrentLocale: () => 'zh-CN',
|
||||
}))
|
||||
|
||||
interface MockServerSession {
|
||||
session_id: string
|
||||
client_session_id: string
|
||||
updated_at: string
|
||||
is_processing: boolean
|
||||
messages: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
// 构造符合 Agent 标准响应包装的 fetch 返回值。
|
||||
function createAgentResponse(data: unknown) {
|
||||
return {
|
||||
json: vi.fn().mockResolvedValue({ success: true, data }),
|
||||
ok: true,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
describe('AgentAssistantPanel stream recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
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'
|
||||
const serverSessionId = 'web-agent:server-session'
|
||||
const userMessage = {
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: '检查后台任务',
|
||||
createdAt: startedAt - 100,
|
||||
status: 'done',
|
||||
attachments: [],
|
||||
choices: [],
|
||||
tools: [],
|
||||
}
|
||||
const assistantPlaceholder = {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: startedAt,
|
||||
status: 'done',
|
||||
attachments: [],
|
||||
choices: [],
|
||||
tools: [],
|
||||
}
|
||||
const processingSession: MockServerSession = {
|
||||
session_id: serverSessionId,
|
||||
client_session_id: localSessionId,
|
||||
updated_at: new Date(startedAt + 1000).toISOString(),
|
||||
is_processing: true,
|
||||
messages: [userMessage],
|
||||
}
|
||||
const completedSession: MockServerSession = {
|
||||
...processingSession,
|
||||
updated_at: new Date(startedAt + 2000).toISOString(),
|
||||
is_processing: false,
|
||||
messages: [
|
||||
userMessage,
|
||||
{
|
||||
...assistantPlaceholder,
|
||||
content: '后台任务已经完成',
|
||||
createdAt: startedAt + 2000,
|
||||
status: 'done',
|
||||
},
|
||||
],
|
||||
}
|
||||
const detailResponses = [processingSession, completedSession]
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes(`/sessions/${localSessionId}`)) {
|
||||
return createAgentResponse(detailResponses.shift() || completedSession)
|
||||
}
|
||||
|
||||
return createAgentResponse([])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
localStorage.setItem(
|
||||
'moviepilot-agent-assistant-state',
|
||||
JSON.stringify({
|
||||
sessionId: localSessionId,
|
||||
messages: [userMessage, assistantPlaceholder],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.agent-assistant-typing').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('agentAssistant.thinking')
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input]) => String(input).includes(`/sessions/${localSessionId}`)),
|
||||
).toHaveLength(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1200)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.agent-assistant-typing').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('后台任务已经完成')
|
||||
expect(wrapper.text()).toContain('agentAssistant.ready')
|
||||
expect(JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')).toMatchObject({
|
||||
sessionId: serverSessionId,
|
||||
streamRecovery: null,
|
||||
})
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('persists an active stream in the background and restores it when the PWA becomes visible', async () => {
|
||||
let visibilityState: DocumentVisibilityState = 'visible'
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
const startedAt = Date.now()
|
||||
const localSessionId = 'web-local-active'
|
||||
const serverSessionId = 'web-agent:server-active'
|
||||
const userMessage = {
|
||||
id: 'user-active',
|
||||
role: 'user',
|
||||
content: '继续后台处理',
|
||||
createdAt: startedAt,
|
||||
status: 'done',
|
||||
attachments: [],
|
||||
choices: [],
|
||||
tools: [],
|
||||
}
|
||||
const completedSession: MockServerSession = {
|
||||
session_id: serverSessionId,
|
||||
client_session_id: localSessionId,
|
||||
updated_at: new Date(startedAt + 2000).toISOString(),
|
||||
is_processing: false,
|
||||
messages: [
|
||||
userMessage,
|
||||
{
|
||||
id: 'assistant-active',
|
||||
role: 'assistant',
|
||||
content: '前后台恢复成功',
|
||||
createdAt: startedAt + 2000,
|
||||
status: 'done',
|
||||
attachments: [],
|
||||
choices: [],
|
||||
tools: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
const encoder = new TextEncoder()
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/message/agent/stream')) {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'start', session_id: serverSessionId })}\n\n`),
|
||||
)
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
controller.error(new DOMException('Aborted', 'AbortError'))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(body, { status: 200 })
|
||||
}
|
||||
if (url.includes(`/sessions/${encodeURIComponent(serverSessionId)}`)) return createAgentResponse(completedSession)
|
||||
|
||||
return createAgentResponse([])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
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()
|
||||
|
||||
expect(wrapper.find('.agent-assistant-typing').exists()).toBe(true)
|
||||
visibilityState = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await flushPromises()
|
||||
|
||||
expect(JSON.parse(localStorage.getItem('moviepilot-agent-assistant-state') || '{}')).toMatchObject({
|
||||
sessionId: serverSessionId,
|
||||
streamRecovery: {
|
||||
sessionId: serverSessionId,
|
||||
},
|
||||
})
|
||||
|
||||
visibilityState = 'visible'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.agent-assistant-typing').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('前后台恢复成功')
|
||||
expect(wrapper.text()).toContain('agentAssistant.ready')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_PET_RANDOM_ACTION_MIN_DELAY } from '../agentPetActions'
|
||||
import { useAgentPetMachine } from '../useAgentPetMachine'
|
||||
|
||||
describe('useAgentPetMachine', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('runs finite random actions and clears the queue when decorative motion stops', async () => {
|
||||
const active = ref(true)
|
||||
const docked = ref(false)
|
||||
const dragging = ref(false)
|
||||
const pressed = ref(false)
|
||||
const thinking = ref(false)
|
||||
const scope = effectScope()
|
||||
const machine = scope.run(() =>
|
||||
useAgentPetMachine({
|
||||
active,
|
||||
docked,
|
||||
dragging,
|
||||
pressed,
|
||||
scheduleAutoDock: vi.fn(),
|
||||
shouldAutoDock: () => false,
|
||||
thinking,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(machine).toBeDefined()
|
||||
|
||||
machine?.scheduleRandomAction()
|
||||
vi.advanceTimersByTime(AGENT_PET_RANDOM_ACTION_MIN_DELAY)
|
||||
|
||||
expect(machine?.currentAction.value).toBe('wave')
|
||||
|
||||
active.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(machine?.currentAction.value).toBeNull()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
active.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(AGENT_PET_RANDOM_ACTION_MIN_DELAY)
|
||||
|
||||
expect(machine?.currentAction.value).not.toBeNull()
|
||||
|
||||
scope.stop()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,6 @@
|
||||
z-index: 3;
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
animation: agent-fab-antenna-idle 3.9s ease-in-out infinite;
|
||||
background: var(--agent-assistant-robot-outline);
|
||||
block-size: 0.66rem;
|
||||
inline-size: 0.18rem;
|
||||
@@ -58,7 +57,6 @@
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 11px;
|
||||
animation: agent-fab-head-idle 4.6s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-start) 0%,
|
||||
@@ -66,7 +64,7 @@
|
||||
);
|
||||
block-size: 2.05rem;
|
||||
box-shadow:
|
||||
inset 0 -0.2rem 0 rgba(54, 0, 126, 26%),
|
||||
inset 0 -0.2rem 0 var(--agent-assistant-robot-shade),
|
||||
inset 0.15rem 0.14rem 0 rgba(255, 255, 255, 24%);
|
||||
inline-size: 2.82rem;
|
||||
inset-block-start: 1.42rem;
|
||||
@@ -97,7 +95,6 @@
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 0 0 999px 999px;
|
||||
animation: agent-fab-blink 4.8s ease-in-out infinite;
|
||||
block-size: 0.42rem;
|
||||
border-block-end: 0.15rem solid var(--agent-assistant-robot-eye);
|
||||
inline-size: 0.42rem;
|
||||
@@ -136,7 +133,6 @@
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 0.65rem 0.65rem 0.55rem 0.55rem;
|
||||
animation: agent-fab-body-idle 4.2s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-mid) 0%,
|
||||
@@ -144,7 +140,7 @@
|
||||
);
|
||||
block-size: 1.34rem;
|
||||
box-shadow:
|
||||
inset 0 -0.18rem 0 rgba(54, 0, 126, 24%),
|
||||
inset 0 -0.18rem 0 var(--agent-assistant-robot-shade),
|
||||
inset 0.16rem 0.12rem 0 rgba(255, 255, 255, 24%);
|
||||
inline-size: 1.88rem;
|
||||
inset-block-start: 3.24rem;
|
||||
@@ -190,7 +186,7 @@
|
||||
var(--agent-assistant-robot-shell-mid) 0%,
|
||||
var(--agent-assistant-robot-outline-soft) 100%
|
||||
);
|
||||
box-shadow: inset 0 -0.12rem 0 rgba(54, 0, 126, 24%);
|
||||
box-shadow: inset 0 -0.12rem 0 var(--agent-assistant-robot-shade);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.22s ease;
|
||||
@@ -204,14 +200,12 @@
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-arm-left-idle 3.8s ease-in-out infinite;
|
||||
inset-inline-start: 0.9rem;
|
||||
transform: rotate(17deg);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-arm-right-idle 4.1s ease-in-out infinite;
|
||||
inset-inline-start: 3.08rem;
|
||||
transform: rotate(-17deg);
|
||||
transform-origin: top center;
|
||||
@@ -225,13 +219,11 @@
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-leg-left-idle 4.8s ease-in-out infinite;
|
||||
inset-inline-start: 1.48rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-leg-right-idle 4.8s ease-in-out 0.35s infinite;
|
||||
inset-inline-start: 2.46rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
@@ -257,8 +249,7 @@
|
||||
}
|
||||
|
||||
.agent-assistant-fab__trigger:focus-visible .agent-assistant-fab__bot {
|
||||
filter:
|
||||
drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
|
||||
filter: drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
|
||||
drop-shadow(0 0 0.34rem rgba(var(--v-theme-primary), 0.55));
|
||||
}
|
||||
|
||||
@@ -315,83 +306,6 @@
|
||||
transform: translate(0.34rem, 0.02rem) rotate(2deg) scale(0.82);
|
||||
}
|
||||
|
||||
@keyframes agent-fab-head-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-1.8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-body-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.04rem)) scaleY(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-antenna-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(15deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(12deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-11deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-core-pulse {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
193
src/components/auth/LoginMfaStep.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MfaMethod } from '@/types/auth'
|
||||
|
||||
interface Props {
|
||||
/** 当前验证步骤的错误信息。 */
|
||||
errorMessage: string
|
||||
/** 密码验证通过后服务端声明的可用方式。 */
|
||||
methods: MfaMethod[]
|
||||
/** OTP 提交状态。 */
|
||||
otpLoading: boolean
|
||||
/** 当前输入的 OTP。 */
|
||||
otpPassword: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'back'): void
|
||||
(event: 'otp'): void
|
||||
(event: 'update:otpPassword', value: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const hasOtp = computed(() => props.methods.includes('otp'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mfa-step" :class="{ 'mfa-step--unavailable': !hasOtp }" aria-labelledby="mfa-step-title">
|
||||
<header class="mfa-step__header">
|
||||
<VBtn
|
||||
data-testid="mfa-back"
|
||||
icon="mdi-arrow-left"
|
||||
size="small"
|
||||
variant="text"
|
||||
:aria-label="t('login.mfa.back')"
|
||||
:disabled="props.otpLoading"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<div>
|
||||
<h2 id="mfa-step-title" class="mfa-step__title">{{ t('login.secondaryVerification') }}</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form v-if="hasOtp" data-testid="mfa-otp-form" class="mfa-step__method" @submit.prevent="emit('otp')">
|
||||
<p class="mfa-step__description">{{ t('login.mfa.otpPrompt') }}</p>
|
||||
<div class="mfa-step__field">
|
||||
<VIcon icon="mdi-shield-key" class="mfa-step__field-icon" aria-hidden="true" />
|
||||
<input
|
||||
:value="props.otpPassword"
|
||||
class="mfa-step__input"
|
||||
type="text"
|
||||
name="otp"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
:placeholder="t('login.otpCode')"
|
||||
:aria-label="t('login.otpCode')"
|
||||
autofocus
|
||||
:disabled="props.otpLoading"
|
||||
@input="emit('update:otpPassword', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
<VBtn
|
||||
block
|
||||
type="submit"
|
||||
color="primary"
|
||||
class="mfa-step__submit"
|
||||
prepend-icon="mdi-login"
|
||||
:loading="props.otpLoading"
|
||||
:disabled="!props.otpPassword"
|
||||
>
|
||||
{{ t('login.loginWithOtp') }}
|
||||
</VBtn>
|
||||
</form>
|
||||
|
||||
<VAlert v-if="props.errorMessage" class="mfa-step__alert" type="error" variant="tonal" role="alert">
|
||||
{{ props.errorMessage }}
|
||||
</VAlert>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mfa-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mfa-step__header {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
margin-block-end: 20px;
|
||||
}
|
||||
|
||||
.mfa-step__title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mfa-step__method {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mfa-step__field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.38);
|
||||
min-block-size: 52px;
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--v-theme-surface), 0.13);
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
background 220ms ease;
|
||||
}
|
||||
|
||||
.mfa-step__field:focus-within {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.mfa-step__field-icon {
|
||||
position: absolute;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
inset-inline-start: 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mfa-step__input {
|
||||
border: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
block-size: 50px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font: inherit;
|
||||
inline-size: 100%;
|
||||
outline: none;
|
||||
padding-block: 0;
|
||||
padding-inline: 48px 16px;
|
||||
}
|
||||
|
||||
.mfa-step__input::placeholder {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mfa-step__input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: var(--v-disabled-opacity);
|
||||
}
|
||||
|
||||
.mfa-step__submit {
|
||||
flex: 0 0 48px !important;
|
||||
block-size: 48px !important;
|
||||
max-block-size: 48px !important;
|
||||
min-block-size: 48px !important;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mfa-step__description {
|
||||
margin: 0;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mfa-step__alert {
|
||||
margin-block-start: 18px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mfa-step--unavailable {
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.mfa-step--unavailable .mfa-step__header {
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
.mfa-step--unavailable .mfa-step__alert {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
</style>
|
||||
47
src/components/auth/__tests__/LoginMfaStep.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import LoginMfaStep from '@/components/auth/LoginMfaStep.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
const slotStub = { template: '<div><slot /></div>' }
|
||||
const buttonStub = {
|
||||
emits: ['click'],
|
||||
props: ['loading'],
|
||||
template: '<button :disabled="loading" @click="$emit(\'click\')"><slot /></button>',
|
||||
}
|
||||
|
||||
function mountStep(methods: Array<'otp'>) {
|
||||
return shallowMount(LoginMfaStep, {
|
||||
global: {
|
||||
stubs: {
|
||||
VAlert: slotStub,
|
||||
VBtn: buttonStub,
|
||||
VIcon: true,
|
||||
VTextField: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
errorMessage: '',
|
||||
methods,
|
||||
otpLoading: false,
|
||||
otpPassword: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('LoginMfaStep', () => {
|
||||
it('shows only the OTP form for an OTP-only account', () => {
|
||||
const wrapper = mountStep(['otp'])
|
||||
|
||||
expect(wrapper.find('[data-testid="mfa-otp-form"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows no authentication action when the server declares no supported method', () => {
|
||||
const wrapper = mountStep([])
|
||||
|
||||
expect(wrapper.find('[data-testid="mfa-otp-form"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
31
src/components/cache/RecognitionCachePanel.vue
vendored
@@ -45,6 +45,8 @@ const cacheData = ref<RecognitionCacheData>({
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
shared_recognized: 0,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
})
|
||||
const mobileVisibleCount = ref(MOBILE_CACHE_PAGE_SIZE)
|
||||
@@ -111,9 +113,18 @@ async function loadCacheData(showSuccess = false) {
|
||||
loading.value = true
|
||||
const response = (await api.get(recognitionCacheEndpoint.value)) as unknown as ApiResponse<RecognitionCacheData>
|
||||
if (requestId !== cacheLoadRequestId) return
|
||||
const responseData = response.data ?? { count: 0, recognized: 0, unrecognized: 0, data: [] }
|
||||
const responseData = response.data ?? {
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
shared_recognized: 0,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
}
|
||||
cacheData.value = {
|
||||
...responseData,
|
||||
shared_recognized: responseData.shared_recognized ?? 0,
|
||||
shared_recognize_enabled: responseData.shared_recognize_enabled ?? false,
|
||||
data: responseData.data.map(item => ({ ...item, recognition_id: getRecognitionId(item) })),
|
||||
}
|
||||
selectedItems.value = selectedItems.value.filter(key => cacheData.value.data.some(item => item.key === key))
|
||||
@@ -276,13 +287,20 @@ watch(recognitionSource, () => {
|
||||
<span>{{ t('setting.cache.recognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isMobile" class="cache-panel-stat cache-panel-stat--warning">
|
||||
<div v-if="!isMobile || cacheData.shared_recognize_enabled" class="cache-panel-stat cache-panel-stat--warning">
|
||||
<VIcon icon="mdi-help-circle-outline" size="22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.unrecognized }}</strong>
|
||||
<span>{{ t('setting.cache.unrecognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="cacheData.shared_recognize_enabled" class="cache-panel-stat cache-panel-stat--info">
|
||||
<VIcon icon="mdi-cloud-check-outline" :size="isMobile ? 32 : 22" />
|
||||
<div>
|
||||
<strong>{{ cacheData.shared_recognized }}</strong>
|
||||
<span>{{ t('setting.cache.sharedRecognized') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isMobile" class="cache-panel-actions">
|
||||
@@ -376,7 +394,7 @@ watch(recognitionSource, () => {
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<article class="recognition-cache-mobile-item">
|
||||
<div class="recognition-cache-poster">
|
||||
<div class="recognition-cache-poster rounded-md">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" size="28" />
|
||||
</div>
|
||||
@@ -432,7 +450,7 @@ watch(recognitionSource, () => {
|
||||
:loading-text="t('common.loadingText')"
|
||||
>
|
||||
<template #item.poster="{ item }">
|
||||
<div class="recognition-cache-table__poster">
|
||||
<div class="recognition-cache-table__poster rounded-md">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" />
|
||||
</div>
|
||||
@@ -550,6 +568,10 @@ watch(recognitionSource, () => {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
}
|
||||
|
||||
.cache-panel-stat--info {
|
||||
color: rgb(var(--v-theme-info));
|
||||
}
|
||||
|
||||
.cache-panel-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
@@ -590,7 +612,6 @@ watch(recognitionSource, () => {
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--app-control-radius);
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
color: rgba(var(--v-theme-on-surface), 0.36);
|
||||
}
|
||||
|
||||
82
src/components/cache/__tests__/RecognitionCachePanel.spec.ts
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
import RecognitionCachePanel from '@/components/cache/RecognitionCachePanel.vue'
|
||||
import { 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(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: mocks.apiGet,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({
|
||||
error: mocks.toastError,
|
||||
success: mocks.toastSuccess,
|
||||
}),
|
||||
}))
|
||||
|
||||
async function renderRecognitionCachePanel() {
|
||||
return renderWithProviders(RecognitionCachePanel, {
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: {
|
||||
GLOBAL_IMAGE_CACHE: false,
|
||||
RECOGNIZE_SOURCE: 'themoviedb',
|
||||
TMDB_IMAGE_DOMAIN: 'image.tmdb.org',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('RecognitionCachePanel shared recognition statistics', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
})
|
||||
|
||||
it('shows the persisted shared recognition count when sharing is enabled', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
count: 12,
|
||||
recognized: 9,
|
||||
unrecognized: 3,
|
||||
shared_recognized: 27,
|
||||
shared_recognize_enabled: true,
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
|
||||
await renderRecognitionCachePanel()
|
||||
|
||||
expect(await screen.findByText('共享识别')).toBeInTheDocument()
|
||||
expect(screen.getByText('27')).toBeInTheDocument()
|
||||
expect(screen.getByText('未识别')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides shared recognition statistics when sharing is disabled', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
count: 12,
|
||||
recognized: 9,
|
||||
unrecognized: 3,
|
||||
shared_recognized: 27,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
|
||||
await renderRecognitionCachePanel()
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('tmdb/cache'))
|
||||
|
||||
expect(screen.queryByText('共享识别')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { DownloadingInfo } from '@/api/types'
|
||||
import type { ApiResponse, DownloadingInfo } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
|
||||
// 输入参数
|
||||
@@ -35,19 +35,11 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 下载状态控制
|
||||
async function toggleDownload() {
|
||||
const operation = isDownloading.value ? 'stop' : 'start'
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`, {
|
||||
const result: ApiResponse<unknown> = await api.get(`download/${operation}/${props.info?.hash}`, {
|
||||
params: {
|
||||
name: props.downloaderName,
|
||||
},
|
||||
@@ -59,11 +51,13 @@ async function toggleDownload() {
|
||||
}
|
||||
}
|
||||
|
||||
// 删除下截
|
||||
// 删除下载任务
|
||||
async function deleteDownload() {
|
||||
try {
|
||||
await api.delete(`download/${props.info?.hash}`, { params: { name: props.downloaderName } })
|
||||
cardState.value = false
|
||||
const result: ApiResponse<unknown> = await api.delete(`download/${props.info?.hash}`, {
|
||||
params: { name: props.downloaderName },
|
||||
})
|
||||
if (result.success) cardState.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -83,53 +77,46 @@ async function deleteDownload() {
|
||||
}"
|
||||
min-height="150"
|
||||
>
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="props.info?.media.image"
|
||||
class="downloading-card-image"
|
||||
aspect-ratio="2/3"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
position="top"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none downloading-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<template #image>
|
||||
<VImg :src="props.info?.media.image" class="downloading-card-image" aspect-ratio="2/3" cover position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none downloading-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<VCardTitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.media.title || props.info?.name }}
|
||||
{{
|
||||
props.info?.media.episode
|
||||
? `${props.info?.media.season} ${props.info?.media.episode}`
|
||||
: props.info?.season_episode
|
||||
}}
|
||||
</VCardTitle>
|
||||
<div>
|
||||
<VCardTitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.media.title || props.info?.name }}
|
||||
{{
|
||||
props.info?.media.episode
|
||||
? `${props.info?.media.season} ${props.info?.media.episode}`
|
||||
: props.info?.season_episode
|
||||
}}
|
||||
</VCardTitle>
|
||||
|
||||
<VCardSubtitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.title }}
|
||||
</VCardSubtitle>
|
||||
<VCardSubtitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.title }}
|
||||
</VCardSubtitle>
|
||||
|
||||
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
|
||||
{{ getSpeedText() }}
|
||||
</VCardText>
|
||||
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
|
||||
{{ getSpeedText() }}
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="getPercentage() > 0" class="text-white">
|
||||
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
|
||||
</VCardText>
|
||||
<VCardText v-if="getPercentage() > 0" class="text-white">
|
||||
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-space-between">
|
||||
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
</div>
|
||||
<VCardActions class="justify-space-between">
|
||||
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -166,12 +166,17 @@ function getExistsStatusKey() {
|
||||
}
|
||||
|
||||
function isSameSubscribeMedia(subscribe: Subscribe) {
|
||||
const mediaId = getMediaId()
|
||||
if (subscribe.media_source && subscribe.media_id) {
|
||||
const prefix = subscribe.media_source === 'themoviedb' ? 'tmdb' : subscribe.media_source
|
||||
return mediaId === `${prefix}:${subscribe.media_id}`
|
||||
}
|
||||
if (subscribe.mediaid) return mediaId === subscribe.mediaid
|
||||
if (props.media?.tmdb_id && subscribe.tmdbid) return props.media.tmdb_id === subscribe.tmdbid
|
||||
if (props.media?.douban_id && subscribe.doubanid) return props.media.douban_id === subscribe.doubanid
|
||||
if (props.media?.bangumi_id && subscribe.bangumiid) return props.media.bangumi_id === subscribe.bangumiid
|
||||
|
||||
const mediaId = props.media?.media_id ? `${props.media.mediaid_prefix}:${props.media.media_id}` : ''
|
||||
return Boolean(mediaId && subscribe.mediaid === mediaId)
|
||||
if (props.media?.anilist_id && subscribe.anilistid) return props.media.anilist_id === subscribe.anilistid
|
||||
return false
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
@@ -459,6 +464,7 @@ onBeforeUnmount(() => {
|
||||
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
|
||||
'media-card--image-loaded': isImageLoaded,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="handleMediaCardClick(hover.isHovering)"
|
||||
@@ -535,7 +541,8 @@ onBeforeUnmount(() => {
|
||||
tile
|
||||
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
<VIcon v-if="props.media?.source === 'anilist'" color="#02a9ff" icon="mdi-alpha-a-circle" size="24" />
|
||||
<VImg v-else cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,11 @@ const props = defineProps({
|
||||
type: Array as PropType<MediaServerConf[]>,
|
||||
required: true,
|
||||
},
|
||||
// 旧版全局同步间隔,用作服务器未单独设置时的默认值
|
||||
defaultSyncInterval: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -56,6 +61,7 @@ function openMediaServerInfoDialog() {
|
||||
{
|
||||
mediaserver: props.mediaserver,
|
||||
mediaservers: props.mediaservers,
|
||||
defaultSyncInterval: props.defaultSyncInterval,
|
||||
},
|
||||
{
|
||||
change: (...args: unknown[]) => emit('change', ...args),
|
||||
|
||||
@@ -38,6 +38,9 @@ function getPersonImage() {
|
||||
} else if (personProps.person?.source === 'bangumi') {
|
||||
if (!personInfo.value?.images) return personIcon
|
||||
url = personInfo.value?.images?.medium
|
||||
} else if (personProps.person?.source === 'anilist') {
|
||||
if (!personInfo.value?.images) return personIcon
|
||||
url = personInfo.value?.images?.large || personInfo.value?.images?.medium
|
||||
} else {
|
||||
return personIcon
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ async function goPlay() {
|
||||
>
|
||||
<VImg
|
||||
:src="imageUrl"
|
||||
crossorigin="anonymous"
|
||||
class="playing-card__image"
|
||||
:class="{ 'playing-card__image--loaded': imageLoaded }"
|
||||
cover
|
||||
@@ -216,8 +217,7 @@ async function goPlay() {
|
||||
}
|
||||
|
||||
.playing-card__bottom-scrim {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
|
||||
background: linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
|
||||
}
|
||||
|
||||
.playing-card__percent,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useToast } from 'vue-toastification'
|
||||
@@ -35,8 +35,8 @@ const $toast = useToast()
|
||||
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值)
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
@@ -76,8 +76,8 @@ function closeInstallProgress() {
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图片中提取背景色
|
||||
backgroundColor.value = await getDominantColor(imageElement)
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
}
|
||||
|
||||
// 计算图标路径
|
||||
@@ -236,96 +236,119 @@ onUnmounted(() => {
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="showPluginDetail"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
class="plugin-card app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
>
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||
</VCardTitle>
|
||||
</VCardText>
|
||||
<div class="relative flex flex-row items-start px-2 justify-between grow">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div
|
||||
class="text-white text-sm px-2 py-1 text-shadow overflow-hidden ..."
|
||||
:class="{ 'line-clamp-3': !props.plugin?.plugin_label, 'line-clamp-2': props.plugin?.plugin_label }"
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="plugin-app-card__title text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
:class="{ 'plugin-app-card__title--with-rating': (props.plugin?.rating_count || 0) > 0 }"
|
||||
>
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
<!-- 插件标签 -->
|
||||
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2 mb-2">
|
||||
<VChip
|
||||
v-for="tag in pluginLabels"
|
||||
:key="tag"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
class="plugin-app-card__tag"
|
||||
tile
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||
</VCardTitle>
|
||||
</VCardText>
|
||||
<div class="relative flex flex-row items-start px-2 justify-between grow">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div
|
||||
class="text-white text-sm px-2 py-1 text-shadow overflow-hidden ..."
|
||||
:class="{ 'line-clamp-3': !props.plugin?.plugin_label, 'line-clamp-2': props.plugin?.plugin_label }"
|
||||
>
|
||||
{{ tag }}
|
||||
</VChip>
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
<!-- 插件标签 -->
|
||||
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2 mb-2">
|
||||
<VChip
|
||||
v-for="tag in pluginLabels"
|
||||
:key="tag"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
class="plugin-app-card__tag"
|
||||
tile
|
||||
>
|
||||
{{ tag }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center pb-3">
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center pb-3">
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText
|
||||
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
||||
>
|
||||
<div class="flex flex-nowrap items-center w-full pe-10">
|
||||
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
||||
<VIcon icon="mdi-github" class="me-1" />
|
||||
<a
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
<VCardText
|
||||
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
||||
>
|
||||
<div class="flex flex-nowrap items-center w-full pe-10">
|
||||
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
||||
<VIcon icon="mdi-github" class="me-1" />
|
||||
<a
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="props.count" class="ms-2 flex-shrink-0 download-count align-middle items-center">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="props.count" class="ms-2 flex-shrink-0 download-count align-middle items-center">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon size="small" icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<div
|
||||
v-if="(props.plugin?.rating_count || 0) > 0"
|
||||
class="plugin-app-card__rating"
|
||||
:aria-label="
|
||||
t('plugin.ratingSummary', {
|
||||
rating: Number(props.plugin?.average_rating || 0).toFixed(1),
|
||||
count: props.plugin?.rating_count || 0,
|
||||
})
|
||||
"
|
||||
:title="
|
||||
t('plugin.ratingSummary', {
|
||||
rating: Number(props.plugin?.average_rating || 0).toFixed(1),
|
||||
count: props.plugin?.rating_count || 0,
|
||||
})
|
||||
"
|
||||
>
|
||||
<VIcon icon="mdi-star" color="warning" size="16" />
|
||||
<span>{{ Number(props.plugin?.average_rating || 0).toFixed(1) }}</span>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon size="small" icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem v-for="(item, i) in dropdownItems" v-show="item.show" :key="i" @click="item.props.click">
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -346,12 +369,31 @@ onUnmounted(() => {
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-app-card__title--with-rating {
|
||||
padding-inline-end: 4rem !important;
|
||||
}
|
||||
|
||||
.plugin-app-card__tag {
|
||||
flex: 0 0 auto;
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.plugin-app-card__rating {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
inset-block-start: 0.625rem;
|
||||
inset-inline-end: 0.625rem;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 65%);
|
||||
}
|
||||
|
||||
.plugin-app-card__tag :deep(.v-chip__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -16,6 +16,7 @@ const PluginDataDialog = defineAsyncComponent(() => import('../dialog/PluginData
|
||||
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
|
||||
const PluginCloneDialog = defineAsyncComponent(() => import('../dialog/PluginCloneDialog.vue'))
|
||||
const PluginLogDialog = defineAsyncComponent(() => import('../dialog/PluginLogDialog.vue'))
|
||||
const PluginMarketDetailDialog = defineAsyncComponent(() => import('../dialog/PluginMarketDetailDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(() => import('../dialog/PluginVersionHistoryDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
@@ -40,8 +41,8 @@ const { t } = useI18n()
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值)
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
@@ -69,6 +70,7 @@ const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let marketDetailDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
||||
@@ -98,8 +100,8 @@ watch(
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图片中提取背景色
|
||||
backgroundColor.value = await getDominantColor(imageElement)
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
}
|
||||
|
||||
// 显示更新日志
|
||||
@@ -305,10 +307,7 @@ function hasRemoteRepoUrl(plugin?: Plugin) {
|
||||
function resolvePluginPageUrl(plugin?: Plugin) {
|
||||
if (!plugin) return ''
|
||||
|
||||
const repoUrl =
|
||||
hasRemoteRepoUrl(plugin)
|
||||
? normalizePluginRepoUrl(plugin.repo_url)
|
||||
: plugin.author_url
|
||||
const repoUrl = hasRemoteRepoUrl(plugin) ? normalizePluginRepoUrl(plugin.repo_url) : plugin.author_url
|
||||
|
||||
return repoUrl || plugin.author_url || ''
|
||||
}
|
||||
@@ -332,27 +331,49 @@ async function fetchMarketPlugin(pluginId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 访问插件项目主页
|
||||
async function visitPluginPage() {
|
||||
const popup = window.open('about:blank', '_blank')
|
||||
/** 读取已安装插件的最新市场详情,并保留本地运行状态字段。 */
|
||||
async function fetchInstalledPluginDetail() {
|
||||
let pluginDetail = props.plugin
|
||||
|
||||
if (popup) popup.opener = null
|
||||
if (!props.plugin?.id) return pluginDetail
|
||||
|
||||
try {
|
||||
if (props.plugin?.id) {
|
||||
const historyPlugin: Plugin = await api.get(`plugin/history/${props.plugin.id}`, {
|
||||
params: {
|
||||
force: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 历史接口可能只返回部分字段,合并原卡片数据避免丢失 author_url 兜底。
|
||||
pluginDetail = { ...(props.plugin || {}), ...(historyPlugin || {}) } as Plugin
|
||||
}
|
||||
const historyPlugin: Plugin = await api.get(`plugin/history/${props.plugin.id}`, {
|
||||
params: {
|
||||
force: false,
|
||||
},
|
||||
})
|
||||
pluginDetail = { ...(props.plugin || {}), ...(historyPlugin || {}), installed: true } as Plugin
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
return pluginDetail
|
||||
}
|
||||
|
||||
/** 使用插件市场详情弹窗展示已安装插件信息和评分入口。 */
|
||||
async function showPluginAbout() {
|
||||
const pluginDetail = await fetchInstalledPluginDetail()
|
||||
if (!pluginDetail) return
|
||||
|
||||
marketDetailDialogController?.close()
|
||||
marketDetailDialogController = openSharedDialog(
|
||||
PluginMarketDetailDialog,
|
||||
{
|
||||
plugin: pluginDetail,
|
||||
count: props.count,
|
||||
},
|
||||
{
|
||||
install: () => emit('save'),
|
||||
},
|
||||
{ closeOn: ['close', 'install', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 访问插件项目主页
|
||||
async function visitPluginPage() {
|
||||
const popup = window.open('about:blank', '_blank')
|
||||
let pluginDetail = await fetchInstalledPluginDetail()
|
||||
|
||||
if (popup) popup.opener = null
|
||||
|
||||
if (!hasRemoteRepoUrl(pluginDetail)) {
|
||||
const marketPlugin = await fetchMarketPlugin(props.plugin?.id)
|
||||
@@ -409,7 +430,13 @@ function showPluginClone() {
|
||||
}
|
||||
|
||||
// 执行插件分身
|
||||
async function executePluginClone(cloneForm: { suffix: string; name: string; description: string; version: string; icon: string }) {
|
||||
async function executePluginClone(cloneForm: {
|
||||
suffix: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
icon: string
|
||||
}) {
|
||||
if (!cloneForm.suffix.trim()) {
|
||||
$toast.error(t('plugin.suffixRequired'))
|
||||
return
|
||||
@@ -447,10 +474,21 @@ async function executePluginClone(cloneForm: { suffix: string; name: string; des
|
||||
onUnmounted(() => {
|
||||
closePluginProgress()
|
||||
cloneDialogController?.close()
|
||||
marketDetailDialogController?.close()
|
||||
versionHistoryDialogController?.close()
|
||||
})
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
title: t('plugin.about'),
|
||||
value: 10,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-information-outline',
|
||||
click: showPluginAbout,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('plugin.viewData'),
|
||||
value: 1,
|
||||
@@ -573,107 +611,104 @@ watch(
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="handleCardClick"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
class="plugin-card app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
>
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
<VBadge dot inline :color="props.plugin?.state ? 'success' : 'secondary'" />
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||
</VCardTitle>
|
||||
</VCardText>
|
||||
<div class="relative flex flex-row items-start px-2 justify-between grow">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div class="px-2 py-1 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
<VBadge dot inline :color="props.plugin?.state ? 'success' : 'secondary'" />
|
||||
{{ props.plugin?.plugin_name }}
|
||||
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
|
||||
</VCardTitle>
|
||||
</VCardText>
|
||||
<div class="relative flex flex-row items-start px-2 justify-between grow">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<div class="px-2 py-1 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex-shrink-0 self-center pb-3"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex-shrink-0 self-center pb-3"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="iconPath"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VCardText
|
||||
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
||||
>
|
||||
<div class="flex flex-nowrap items-center w-full pe-10">
|
||||
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
||||
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
|
||||
<template #default>
|
||||
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
|
||||
</template>
|
||||
</VImg>
|
||||
<span v-if="props.sortable" class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
<VCardText
|
||||
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
|
||||
>
|
||||
<div class="flex flex-nowrap items-center w-full pe-10">
|
||||
<div class="flex flex-nowrap max-w-40 items-center align-middle">
|
||||
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
|
||||
<template #default>
|
||||
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
|
||||
</template>
|
||||
</VImg>
|
||||
<span v-if="props.sortable" class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</span>
|
||||
<a
|
||||
v-else
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</div>
|
||||
<span v-if="props.count" class="ms-2 flex-shrink-0 download-count items-center align-middle">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</span>
|
||||
<a
|
||||
v-else
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</a>
|
||||
</div>
|
||||
<span v-if="props.count" class="ms-2 flex-shrink-0 download-count items-center align-middle">
|
||||
<VIcon size="small" icon="mdi-download" />
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</span>
|
||||
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, i) in dropdownItems"
|
||||
v-show="item.show"
|
||||
:key="i"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const getImgUrl = computed(() => {
|
||||
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)}`
|
||||
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
}
|
||||
return url
|
||||
})
|
||||
@@ -63,43 +63,44 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
|
||||
:class="{ 'poster-card-image--loaded': isImageLoaded }"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
<!-- 类型角标 -->
|
||||
<VChip
|
||||
v-show="isImageLoaded"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="poster-card-chip absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2 pb-5"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
@click.stop="goPlay(hover.isHovering)"
|
||||
>
|
||||
<span class="font-semibold text-sm">{{ props.media?.subtitle }}</span>
|
||||
<h1 class="mb-1 text-white font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
crossorigin="anonymous"
|
||||
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
|
||||
:class="{ 'poster-card-image--loaded': isImageLoaded }"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
<!-- 类型角标 -->
|
||||
<VChip
|
||||
v-show="isImageLoaded"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="poster-card-chip absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError"
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2 pb-5"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
@click.stop="goPlay(hover.isHovering)"
|
||||
>
|
||||
<span class="font-semibold text-sm">{{ props.media?.subtitle }}</span>
|
||||
<h1 class="mb-1 text-white font-bold text-lg line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -264,9 +264,14 @@ async function editSubscribeDialog() {
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.media_source && props.media?.media_id) {
|
||||
const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source
|
||||
return `${prefix}:${props.media.media_id}`
|
||||
}
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
||||
else if (props.media?.anilistid) return `anilist:${props.media?.anilistid}`
|
||||
else return props.media?.mediaid
|
||||
}
|
||||
|
||||
@@ -480,13 +485,7 @@ function handleCardClick() {
|
||||
|
||||
<template v-if="display.xs.value">
|
||||
<div class="subscribe-card-mobile-media">
|
||||
<VImg
|
||||
:src="backdropUrl || posterUrl"
|
||||
:aspect-ratio="2"
|
||||
cover
|
||||
position="top"
|
||||
@load="imageLoadHandler"
|
||||
>
|
||||
<VImg :src="backdropUrl || posterUrl" :aspect-ratio="2" cover position="top" @load="imageLoadHandler">
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="h-full w-full" />
|
||||
</template>
|
||||
@@ -535,12 +534,7 @@ function handleCardClick() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<IconBtn
|
||||
v-if="!props.sortable"
|
||||
class="subscribe-card-mobile-menu"
|
||||
size="small"
|
||||
@click.stop
|
||||
>
|
||||
<IconBtn v-if="!props.sortable" class="subscribe-card-mobile-menu" size="small" @click.stop>
|
||||
<VIcon icon="mdi-dots-horizontal" size="20" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
@@ -576,112 +570,114 @@ function handleCardClick() {
|
||||
</template>
|
||||
|
||||
<div v-else>
|
||||
<VCardText class="flex items-center pt-3 pb-2">
|
||||
<div
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
|
||||
v-if="imageLoaded"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ...">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="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"
|
||||
icon="mdi-progress-download"
|
||||
size="small"
|
||||
color="white"
|
||||
class="me-1"
|
||||
/>
|
||||
<IconBtn
|
||||
v-else-if="props.media?.total_episode"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-download"
|
||||
color="white"
|
||||
/>
|
||||
<!-- 守卫改用 total_episode:电视剧订阅可能不带 season 字段(旧数据或自定义来源),仍应展示集数进度 -->
|
||||
<div v-if="props.media?.total_episode" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
|
||||
{{ subscribeProgressText }}
|
||||
<VTooltip v-if="subscribeProgressTooltip" activator="parent" location="top">
|
||||
{{ subscribeProgressTooltip }}
|
||||
</VTooltip>
|
||||
</div>
|
||||
<VIcon
|
||||
v-if="props.media?.username && props.sortable"
|
||||
icon="mdi-account"
|
||||
size="small"
|
||||
color="white"
|
||||
class="flex-shrink-0 me-1"
|
||||
/>
|
||||
<IconBtn
|
||||
v-else-if="props.media?.username"
|
||||
icon="mdi-account"
|
||||
size="small"
|
||||
color="white"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<!-- 用户名过长时限制在卡片宽度内,并用省略号展示剩余内容 -->
|
||||
<span
|
||||
v-if="props.media?.username"
|
||||
class="min-w-0 truncate text-subtitle-2 text-white"
|
||||
:title="props.media?.username"
|
||||
<VCardText class="flex items-center pt-3 pb-2">
|
||||
<div
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
|
||||
v-if="imageLoaded"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<!-- 右下角元数据:暂停 / 待定时替换"x 天前"为状态文案 -->
|
||||
<VCardText
|
||||
v-if="rightBottomStateDisplay"
|
||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||
>
|
||||
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
|
||||
{{ rightBottomStateDisplay.label }}
|
||||
</VCardText>
|
||||
<VCardText
|
||||
v-else-if="lastUpdateText"
|
||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||
>
|
||||
<VIcon icon="mdi-download" class="me-1" />
|
||||
{{ lastUpdateText }}
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<!--
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
|
||||
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||
<div
|
||||
class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ..."
|
||||
>
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="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"
|
||||
icon="mdi-progress-download"
|
||||
size="small"
|
||||
color="white"
|
||||
class="me-1"
|
||||
/>
|
||||
<IconBtn
|
||||
v-else-if="props.media?.total_episode"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-download"
|
||||
color="white"
|
||||
/>
|
||||
<!-- 守卫改用 total_episode:电视剧订阅可能不带 season 字段(旧数据或自定义来源),仍应展示集数进度 -->
|
||||
<div v-if="props.media?.total_episode" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
|
||||
{{ subscribeProgressText }}
|
||||
<VTooltip v-if="subscribeProgressTooltip" activator="parent" location="top">
|
||||
{{ subscribeProgressTooltip }}
|
||||
</VTooltip>
|
||||
</div>
|
||||
<VIcon
|
||||
v-if="props.media?.username && props.sortable"
|
||||
icon="mdi-account"
|
||||
size="small"
|
||||
color="white"
|
||||
class="flex-shrink-0 me-1"
|
||||
/>
|
||||
<IconBtn
|
||||
v-else-if="props.media?.username"
|
||||
icon="mdi-account"
|
||||
size="small"
|
||||
color="white"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<!-- 用户名过长时限制在卡片宽度内,并用省略号展示剩余内容 -->
|
||||
<span
|
||||
v-if="props.media?.username"
|
||||
class="min-w-0 truncate text-subtitle-2 text-white"
|
||||
:title="props.media?.username"
|
||||
>
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<!-- 右下角元数据:暂停 / 待定时替换"x 天前"为状态文案 -->
|
||||
<VCardText
|
||||
v-if="rightBottomStateDisplay"
|
||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||
>
|
||||
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
|
||||
{{ rightBottomStateDisplay.label }}
|
||||
</VCardText>
|
||||
<VCardText
|
||||
v-else-if="lastUpdateText"
|
||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||
>
|
||||
<VIcon icon="mdi-download" class="me-1" />
|
||||
{{ lastUpdateText }}
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<!--
|
||||
分集洗版模式:底色保持深绿、buffer 段显示"已下载未洗版"为浅绿、model 段显示"已洗版完成"为亮绿,
|
||||
形成两段语义;其余订阅维持原有单段进度条
|
||||
-->
|
||||
<VProgressLinear
|
||||
v-if="isBestVersion && getBufferPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
:buffer-value="getBufferPercentage()"
|
||||
bg-color="success"
|
||||
bg-opacity="0.25"
|
||||
color="success"
|
||||
buffer-color="success"
|
||||
buffer-opacity="0.55"
|
||||
/>
|
||||
<VProgressLinear
|
||||
v-else-if="getPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
<VProgressLinear
|
||||
v-if="isBestVersion && getBufferPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
:buffer-value="getBufferPercentage()"
|
||||
bg-color="success"
|
||||
bg-opacity="0.25"
|
||||
color="success"
|
||||
buffer-color="success"
|
||||
buffer-opacity="0.55"
|
||||
/>
|
||||
<VProgressLinear
|
||||
v-else-if="getPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
@@ -47,9 +47,14 @@ const posterUrl = computed(() => {
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.media_source && props.media?.media_id) {
|
||||
const prefix = props.media.media_source === 'themoviedb' ? 'tmdb' : props.media.media_source
|
||||
return `${prefix}:${props.media.media_id}`
|
||||
}
|
||||
if (props.media?.tmdbid) return `tmdb:${props.media?.tmdbid}`
|
||||
else if (props.media?.doubanid) return `douban:${props.media?.doubanid}`
|
||||
else if (props.media?.bangumiid) return `bangumi:${props.media?.bangumiid}`
|
||||
else if (props.media?.anilistid) return `anilist:${props.media?.anilistid}`
|
||||
}
|
||||
|
||||
// 查看媒体详情
|
||||
@@ -102,61 +107,65 @@ function doDelete() {
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
:key="props.media?.id"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
min-height="150"
|
||||
@click="showForkSubscribe"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
<VCard
|
||||
:key="props.media?.id"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
min-height="150"
|
||||
@click="showForkSubscribe"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pa-3 pb-1 grow">
|
||||
<div class="h-auto w-16 flex-shrink-0 overflow-hidden rounded-md" v-if="imageLoaded">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 subscribe-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pa-3 pb-1 grow">
|
||||
<div class="h-auto w-16 flex-shrink-0 overflow-hidden rounded-md" v-if="imageLoaded">
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center pl-2 xl:pl-4">
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.share_title }}
|
||||
<div class="flex flex-col justify-center pl-2 xl:pl-4">
|
||||
<div
|
||||
class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.media?.share_title }}
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.media?.share_comment }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.share_comment }}
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap py-2">
|
||||
<div class="flex align-center">
|
||||
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
|
||||
<div class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.share_user }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
|
||||
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.count.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap py-2">
|
||||
<div class="flex align-center">
|
||||
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
|
||||
<div class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.share_user }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
|
||||
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
|
||||
{{ props.media?.count.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-calendar" class="me-1" />
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
|
||||
<VIcon icon="mdi-calendar" class="me-1" />
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,9 +76,12 @@ async function handleAddDownload(item: Context | null = null) {
|
||||
openSharedDialog(
|
||||
AddDownloadDialog,
|
||||
{
|
||||
title: `${downloadItem.value?.media_info?.title_year || downloadItem.value?.meta_info?.name} ${
|
||||
downloadItem.value?.meta_info?.season_episode
|
||||
}`,
|
||||
title: [
|
||||
downloadItem.value?.media_info?.title_year || downloadItem.value?.meta_info?.name,
|
||||
downloadItem.value?.meta_info?.season_episode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
media: downloadItem.value?.media_info,
|
||||
torrent: downloadItem.value?.torrent_info,
|
||||
},
|
||||
@@ -92,16 +95,10 @@ async function handleAddDownload(item: Context | null = null) {
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(item: Context | null = null) {
|
||||
if (item && !isNullOrEmptyObject(item) && !isNullOrEmptyObject(item.torrent_info)) {
|
||||
window.open(item.torrent_info.page_url, '_blank')
|
||||
return
|
||||
const pageUrl = item && !isNullOrEmptyObject(item) ? item.torrent_info?.page_url : torrent.value?.page_url
|
||||
if (pageUrl) {
|
||||
window.open(pageUrl, '_blank')
|
||||
}
|
||||
window.open(torrent.value?.page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile() {
|
||||
window.open(torrent.value?.enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 获取优惠类型样式
|
||||
|
||||
@@ -29,15 +29,16 @@ const siteIcon = ref('')
|
||||
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
if (!torrent?.value?.site) {
|
||||
async function getSiteIcon(site: number | undefined) {
|
||||
if (!site) {
|
||||
siteIcon.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = await getCachedSiteIcon(torrent.value.site, async () => {
|
||||
const icon = await getCachedSiteIcon(site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${torrent.value?.site}`)
|
||||
const response = await api.get(`site/icon/${site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
@@ -45,9 +46,15 @@ async function getSiteIcon() {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
// 只提交当前站点的响应,避免 Context 快速切换时旧请求覆盖新图标。
|
||||
if (torrent.value?.site === site) {
|
||||
siteIcon.value = icon
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
siteIcon.value = ''
|
||||
if (torrent.value?.site === site) {
|
||||
siteIcon.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,15 +67,6 @@ function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolum
|
||||
else return ''
|
||||
}
|
||||
|
||||
// 获取优惠标签类
|
||||
function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
|
||||
if (!downloadVolumeFactor) return 'chip-free'
|
||||
if (downloadVolumeFactor === 0) return 'chip-free'
|
||||
else if (downloadVolumeFactor < 1) return 'chip-discount'
|
||||
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'chip-bonus'
|
||||
else return ''
|
||||
}
|
||||
|
||||
// 询问并添加下载
|
||||
async function handleAddDownload() {
|
||||
// 打开下载对话框
|
||||
@@ -99,7 +97,9 @@ function addDownloadError(error: string) {
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail() {
|
||||
window.open(torrent.value?.page_url, '_blank')
|
||||
if (torrent.value?.page_url) {
|
||||
window.open(torrent.value.page_url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -108,7 +108,7 @@ watch(
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
getSiteIcon()
|
||||
getSiteIcon(value?.torrent_info?.site)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
139
src/components/cards/__tests__/DownloadingCard.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { DownloadingInfo } from '@/api/types'
|
||||
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { deleteDownloadHandler, downloadActionHandler } from '@tests/support/msw/handlers/download'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function downloading(overrides: Partial<DownloadingInfo> = {}): DownloadingInfo {
|
||||
return {
|
||||
dlspeed: '2 MiB',
|
||||
hash: 'hash-1',
|
||||
left_time: '1 小时',
|
||||
media: {
|
||||
episode: 'E02',
|
||||
image: 'https://images.example.com/poster.jpg',
|
||||
season: 'S01',
|
||||
title: '测试媒体',
|
||||
},
|
||||
name: 'fallback-name',
|
||||
progress: 40,
|
||||
season_episode: 'S01E02',
|
||||
size: 1024,
|
||||
state: 'downloading',
|
||||
title: '下载任务标题',
|
||||
upspeed: '1 MiB',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function renderCard(info = downloading(), downloaderName = 'qb-main') {
|
||||
return renderWithProviders(DownloadingCard, {
|
||||
props: { downloaderName, info },
|
||||
})
|
||||
}
|
||||
|
||||
function actionButtons(container: Element) {
|
||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>('.v-card-actions button')]
|
||||
expect(buttons).toHaveLength(2)
|
||||
return { deleteButton: buttons[1]!, toggleButton: buttons[0]! }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
describe('DownloadingCard display and pause state', () => {
|
||||
it('renders task metadata, progress, speed and the current download state', async () => {
|
||||
const { container } = await renderCard()
|
||||
|
||||
expect(screen.getByText(/测试媒体/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/S01 E02/)).toBeInTheDocument()
|
||||
expect(screen.getByText('下载任务标题')).toBeInTheDocument()
|
||||
expect(screen.getByText(/1 小时/)).toBeInTheDocument()
|
||||
expect(container.querySelector('.v-card-text .v-progress-linear')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the task name and season string when media recognition is incomplete', async () => {
|
||||
const { container } = await renderCard(
|
||||
downloading({
|
||||
media: {},
|
||||
name: '未识别任务',
|
||||
progress: 0,
|
||||
season_episode: 'S03E04',
|
||||
state: 'stopped',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.getByText(/未识别任务/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/S03E04/)).toBeInTheDocument()
|
||||
expect(container.querySelector('.v-card-text .v-progress-linear')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the current operation and downloader name, changing state only on business success', async () => {
|
||||
const stopRequested = vi.fn()
|
||||
const startRequested = vi.fn()
|
||||
server.use(
|
||||
downloadActionHandler('stop', 'hash-1', { success: false }, 200, stopRequested),
|
||||
downloadActionHandler('start', 'hash-1', { success: true }, 200, startRequested),
|
||||
)
|
||||
const { container, rerender } = await renderCard()
|
||||
const { toggleButton } = actionButtons(container)
|
||||
|
||||
await fireEvent.click(toggleButton)
|
||||
await waitFor(() => expect(stopRequested).toHaveBeenCalledOnce())
|
||||
expect(stopRequested.mock.calls[0][0].searchParams.get('name')).toBe('qb-main')
|
||||
await fireEvent.click(toggleButton)
|
||||
await waitFor(() => expect(stopRequested).toHaveBeenCalledTimes(2))
|
||||
|
||||
await rerender({ downloaderName: 'transmission', info: downloading({ state: 'stopped' }) })
|
||||
await fireEvent.click(toggleButton)
|
||||
await waitFor(() => expect(startRequested).toHaveBeenCalledOnce())
|
||||
expect(startRequested.mock.calls[0][0].searchParams.get('name')).toBe('transmission')
|
||||
await fireEvent.click(toggleButton)
|
||||
await waitFor(() => expect(stopRequested).toHaveBeenCalledTimes(3))
|
||||
})
|
||||
|
||||
it('keeps the current state when the pause request fails at the HTTP boundary', async () => {
|
||||
server.use(downloadActionHandler('stop', 'hash-1', { success: false }, 503))
|
||||
const { container } = await renderCard()
|
||||
|
||||
await fireEvent.click(actionButtons(container).toggleButton)
|
||||
|
||||
await waitFor(() => expect(console.error).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
|
||||
describe('DownloadingCard deletion', () => {
|
||||
it('keeps the card visible when HTTP 200 reports business failure', async () => {
|
||||
const requested = vi.fn()
|
||||
server.use(deleteDownloadHandler('hash-1', { success: false, message: '任务仍在运行' }, 200, requested))
|
||||
const { container } = await renderCard()
|
||||
|
||||
await fireEvent.click(actionButtons(container).deleteButton)
|
||||
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
expect(requested.mock.calls[0][0].searchParams.get('name')).toBe('qb-main')
|
||||
expect(container.querySelector('.downloading-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the card only after business success', async () => {
|
||||
server.use(deleteDownloadHandler('hash-1', { success: true }))
|
||||
const { container } = await renderCard()
|
||||
|
||||
await fireEvent.click(actionButtons(container).deleteButton)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.downloading-card')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('keeps the card visible when deletion fails at the HTTP boundary', async () => {
|
||||
server.use(deleteDownloadHandler('hash-1', { success: false }, 503))
|
||||
const { container } = await renderCard()
|
||||
|
||||
await fireEvent.click(actionButtons(container).deleteButton)
|
||||
|
||||
await waitFor(() => expect(console.error).toHaveBeenCalled())
|
||||
expect(container.querySelector('.downloading-card')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -461,6 +461,55 @@ describe('MediaCard', () => {
|
||||
expect(dialogProps).toMatchObject({ subscribedSeasons: [2] })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'structured TMDB identity',
|
||||
createMediaInfo({
|
||||
media_id: 'series-9554',
|
||||
mediaid_prefix: undefined,
|
||||
season: 2,
|
||||
source: 'themoviedb',
|
||||
tmdb_id: undefined,
|
||||
type: '电视剧',
|
||||
}),
|
||||
'tmdb:series-9554',
|
||||
[
|
||||
{ id: 93, media_id: 'series-9554', media_source: 'themoviedb', season: 4, type: '电视剧' },
|
||||
{ id: 94, media_id: 'other', media_source: 'themoviedb', season: 5, type: '电视剧' },
|
||||
],
|
||||
[4],
|
||||
],
|
||||
[
|
||||
'legacy AniList identity',
|
||||
createMediaInfo({ anilist_id: 154588, season: 2, source: 'anilist', tmdb_id: undefined, type: '电视剧' }),
|
||||
'anilist:154588',
|
||||
[
|
||||
{ anilistid: 154588, id: 95, season: 1, type: '电视剧' },
|
||||
{ anilistid: 154589, id: 96, season: 3, type: '电视剧' },
|
||||
],
|
||||
[1],
|
||||
],
|
||||
])('matches %s when collecting subscribed TV seasons', async (_label, media, mediaId, subscribes, expected) => {
|
||||
server.use(
|
||||
querySubscribeByMediaHandler(mediaId, { id: 93, season: 2 }),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }),
|
||||
subscribeListHandler(subscribes),
|
||||
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
|
||||
HttpResponse.json({ data: { value: {} }, success: true }),
|
||||
),
|
||||
)
|
||||
const { container } = await renderCard(media)
|
||||
getStatusObservers()[0]?.trigger()
|
||||
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
|
||||
|
||||
await fireEvent.mouseEnter(getHoverArea(container))
|
||||
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
|
||||
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
|
||||
expect(dialogProps).toMatchObject({ subscribedSeasons: expected })
|
||||
})
|
||||
|
||||
it('updates image badges on load and falls back after an image error', async () => {
|
||||
const media = createMediaInfo({
|
||||
poster_path: '/original/poster.jpg',
|
||||
@@ -499,6 +548,34 @@ describe('MediaCard', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the AniList source badge after the poster loads', async () => {
|
||||
const media = createMediaInfo({
|
||||
anilist_id: 154588,
|
||||
poster_path: '/original/anilist.jpg',
|
||||
source: 'anilist',
|
||||
tmdb_id: undefined,
|
||||
type: '电视剧',
|
||||
})
|
||||
const VImgStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['load'],
|
||||
props: { src: String },
|
||||
setup(_props, { emit, slots }) {
|
||||
return () =>
|
||||
h('div', [h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }), slots.default?.()])
|
||||
},
|
||||
})
|
||||
const { container } = await renderWithProviders(MediaCard, {
|
||||
props: { media, width: '9rem' },
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { VImg: VImgStub } },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.v-avatar .iconify--mdi')).not.toBeNull())
|
||||
})
|
||||
|
||||
it('hides search and subscribe actions when the user lacks both permissions', async () => {
|
||||
const { container } = await renderCard(createMediaInfo({ tmdb_id: 9601 }), {
|
||||
permissions: {
|
||||
|
||||
39
src/components/cards/__tests__/MediaServerImageCards.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
|
||||
import PosterCard from '@/components/cards/PosterCard.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/utils/appDeepLink', () => ({
|
||||
openMediaServerItem: vi.fn(),
|
||||
}))
|
||||
|
||||
const media: MediaServerPlayItem = {
|
||||
id: 'media-1',
|
||||
image: 'https://media.example.com/poster.jpg',
|
||||
title: 'Test media',
|
||||
}
|
||||
|
||||
const VImgStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
crossorigin: String,
|
||||
src: String,
|
||||
},
|
||||
setup: props => () => h('img', { crossorigin: props.crossorigin, src: props.src }),
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['PosterCard', PosterCard],
|
||||
['PlayingBackdropCard', PlayingBackdropCard],
|
||||
])('%s image request mode', (_name, component) => {
|
||||
it('loads the media server image anonymously', async () => {
|
||||
const { container } = await renderWithProviders(component, {
|
||||
global: { stubs: { VImg: VImgStub } },
|
||||
props: { media },
|
||||
})
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('crossorigin', 'anonymous')
|
||||
})
|
||||
})
|
||||
35
src/components/cards/__tests__/PluginAppCardRating.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'),
|
||||
}))
|
||||
|
||||
const plugin: Plugin = {
|
||||
id: 'DemoPlugin',
|
||||
plugin_name: '演示插件',
|
||||
plugin_desc: '用于测试市场卡片评分',
|
||||
plugin_version: '1.0.0',
|
||||
plugin_author: 'MoviePilot',
|
||||
installed: false,
|
||||
}
|
||||
|
||||
describe('PluginAppCard rating badge', () => {
|
||||
it('shows the top-right score only after the plugin has ratings', async () => {
|
||||
const unrated = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: { ...plugin, average_rating: 0, rating_count: 0 } },
|
||||
})
|
||||
expect(unrated.container.querySelector('.plugin-app-card__rating')).toBeNull()
|
||||
expect(unrated.container.querySelector('.plugin-app-card__title--with-rating')).toBeNull()
|
||||
unrated.unmount()
|
||||
|
||||
const rated = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: { ...plugin, average_rating: 4.3, rating_count: 12 } },
|
||||
})
|
||||
const badge = rated.container.querySelector('.plugin-app-card__rating')
|
||||
expect(badge).toHaveTextContent('4.3')
|
||||
expect(rated.container.querySelector('.plugin-app-card__title--with-rating')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
80
src/components/cards/__tests__/PluginCardAbout.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
closeDialog: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: mocks.apiGet,
|
||||
delete: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: mocks.openSharedDialog,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
const plugin: Plugin = {
|
||||
id: 'DemoPlugin',
|
||||
plugin_name: '演示插件',
|
||||
plugin_desc: '用于测试关于菜单',
|
||||
plugin_version: '1.0.0',
|
||||
plugin_author: 'MoviePilot',
|
||||
author_url: 'https://github.com/MoviePilot',
|
||||
installed: true,
|
||||
state: true,
|
||||
}
|
||||
|
||||
describe('PluginCard about menu', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset().mockResolvedValue({
|
||||
...plugin,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
})
|
||||
mocks.closeDialog.mockReset()
|
||||
mocks.openSharedDialog.mockReset().mockReturnValue({ close: mocks.closeDialog })
|
||||
})
|
||||
|
||||
it('loads installed plugin detail and opens the shared market detail dialog', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin, count: 24 },
|
||||
})
|
||||
|
||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||
expect(menuButton).not.toBeNull()
|
||||
await fireEvent.click(menuButton!)
|
||||
await fireEvent.click(await screen.findByText('关于'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/history/DemoPlugin', {
|
||||
params: { force: false },
|
||||
})
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
})
|
||||
const dialogProps = mocks.openSharedDialog.mock.calls[0][1]
|
||||
expect(dialogProps.plugin).toMatchObject({
|
||||
id: 'DemoPlugin',
|
||||
installed: true,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
})
|
||||
expect(dialogProps.count).toBe(24)
|
||||
})
|
||||
})
|
||||
@@ -164,7 +164,16 @@ describe('SubscribeCard display and progress', () => {
|
||||
['disabled flag', false, true, 3, '电视剧', 80, false, false],
|
||||
])(
|
||||
'normalizes %s for wash progress and badges',
|
||||
async (_case, bestVersion, bestVersionFull, completedEpisode, type, expectedProgress, expectedWash, expectedFull) => {
|
||||
async (
|
||||
_case,
|
||||
bestVersion,
|
||||
bestVersionFull,
|
||||
completedEpisode,
|
||||
type,
|
||||
expectedProgress,
|
||||
expectedWash,
|
||||
expectedFull,
|
||||
) => {
|
||||
const { container } = await renderCard({
|
||||
best_version: bestVersion,
|
||||
best_version_full: bestVersionFull,
|
||||
@@ -291,10 +300,20 @@ describe('SubscribeCard interaction boundaries', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['TMDB before all fallbacks', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'],
|
||||
['Douban before Bangumi', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'],
|
||||
['Bangumi before custom', { bangumiid: '33', doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'],
|
||||
['custom media ID last', { bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'custom:44'],
|
||||
['TMDB before all fallbacks', { bangumiid: 33, doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'],
|
||||
['Douban before Bangumi', { bangumiid: 33, doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'],
|
||||
['Bangumi before custom', { bangumiid: 33, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'],
|
||||
[
|
||||
'AniList before legacy custom',
|
||||
{ anilistid: 55, bangumiid: undefined, mediaid: 'custom:44', tmdbid: 0 },
|
||||
'anilist:55',
|
||||
],
|
||||
['selected primary identity', { media_id: '66', media_source: 'anilist', tmdbid: 11 }, 'anilist:66'],
|
||||
[
|
||||
'custom media ID last',
|
||||
{ bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 },
|
||||
'custom:44',
|
||||
],
|
||||
])('routes media details with %s', async (_case, identifiers, expectedMediaId) => {
|
||||
const { container, media } = await renderCard(identifiers)
|
||||
|
||||
@@ -364,30 +383,34 @@ describe('SubscribeCard item operations', () => {
|
||||
['confirmation cancellation', false, 200, { success: true }, null],
|
||||
['business failure', true, 200, { message: 'rejected', success: false }, '暂停失败:rejected'],
|
||||
['HTTP failure', true, 500, { message: 'server down', success: false }, '请求失败,请稍后重试'],
|
||||
] as const)(
|
||||
'keeps status unchanged after %s',
|
||||
async (_case, confirmed, status, response, expectedError) => {
|
||||
const requested = vi.fn()
|
||||
mocks.confirm.mockResolvedValue(confirmed)
|
||||
const { container, emitted, media } = await renderCard({ state: 'R' })
|
||||
server.use(updateSubscribeStatusHandler(media.id, response, status, requested))
|
||||
] as const)('keeps status unchanged after %s', async (_case, confirmed, status, response, expectedError) => {
|
||||
const requested = vi.fn()
|
||||
mocks.confirm.mockResolvedValue(confirmed)
|
||||
const { container, emitted, media } = await renderCard({ state: 'R' })
|
||||
server.use(updateSubscribeStatusHandler(media.id, response, status, requested))
|
||||
|
||||
await chooseMenuItem(container, '暂停')
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||
await chooseMenuItem(container, '暂停')
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||
|
||||
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
else expect(requested).not.toHaveBeenCalled()
|
||||
if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError))
|
||||
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||
expect(emitted('save') ?? []).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
else expect(requested).not.toHaveBeenCalled()
|
||||
if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError))
|
||||
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||
expect(emitted('save') ?? []).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['success', true, 200, { success: true }, 'success', '卡片测试媒体 重置成功!'],
|
||||
['confirmation cancellation', false, 200, { success: true }, null, null],
|
||||
['business failure', true, 200, { message: 'rejected', success: false }, 'error', '卡片测试媒体 重置失败:rejected'],
|
||||
[
|
||||
'business failure',
|
||||
true,
|
||||
200,
|
||||
{ message: 'rejected', success: false },
|
||||
'error',
|
||||
'卡片测试媒体 重置失败:rejected',
|
||||
],
|
||||
['HTTP failure', true, 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
|
||||
] as const)(
|
||||
'handles reset %s without speculative state',
|
||||
@@ -423,16 +446,19 @@ describe('SubscribeCard item operations', () => {
|
||||
it.each([
|
||||
['success', 200, { success: true }, true, null],
|
||||
['HTTP failure', 500, { message: 'server down', success: false }, false, '请求失败,请稍后重试'],
|
||||
] as const)('handles delete %s without a synthetic business-failure branch', async (_case, status, response, removed, error) => {
|
||||
const requested = vi.fn()
|
||||
const { container, emitted, media } = await renderCard()
|
||||
server.use(deleteSubscribeByIdHandler(media.id, response, status, requested))
|
||||
] as const)(
|
||||
'handles delete %s without a synthetic business-failure branch',
|
||||
async (_case, status, response, removed, error) => {
|
||||
const requested = vi.fn()
|
||||
const { container, emitted, media } = await renderCard()
|
||||
server.use(deleteSubscribeByIdHandler(media.id, response, status, requested))
|
||||
|
||||
await chooseMenuItem(container, '取消订阅')
|
||||
await chooseMenuItem(container, '取消订阅')
|
||||
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
|
||||
if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error))
|
||||
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
|
||||
if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error))
|
||||
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -127,6 +127,7 @@ describe('SubscribeShareCard', () => {
|
||||
['TMDB before Douban', { doubanid: '2202', tmdbid: 1101 }, 'tmdb:1101'],
|
||||
['Douban without TMDB', { doubanid: '2202', tmdbid: undefined }, 'douban:2202'],
|
||||
['Bangumi without TMDB or Douban', { bangumiid: 3303, doubanid: undefined, tmdbid: undefined }, 'bangumi:3303'],
|
||||
['AniList without other IDs', { anilistid: 4404, bangumiid: undefined, tmdbid: undefined }, 'anilist:4404'],
|
||||
] as const)('routes media details with %s while keeping the fork dialog closed', async (_case, ids, mediaid) => {
|
||||
const { container, media } = await renderCard(ids)
|
||||
const poster = await loadPoster(container)
|
||||
|
||||
346
src/components/cards/__tests__/TorrentCard.spec.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||
import type { Context, MediaInfo, MetaInfo, TorrentInfo } from '@/api/types'
|
||||
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
||||
import { downloadedTorrentMap } from '@/utils/torrentDownloadCache'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getCachedSiteIcon: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/siteIconCache', () => ({
|
||||
getCachedSiteIcon: (...args: unknown[]) => mocks.getCachedSiteIcon(...args),
|
||||
}))
|
||||
|
||||
const ImageStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
alt: String,
|
||||
src: String,
|
||||
},
|
||||
setup: props => () => h('img', { alt: props.alt, src: props.src }),
|
||||
})
|
||||
|
||||
interface ContextOverrides {
|
||||
media?: Partial<MediaInfo>
|
||||
meta?: Partial<MetaInfo>
|
||||
torrent?: Partial<TorrentInfo>
|
||||
}
|
||||
|
||||
function createContext(overrides: ContextOverrides = {}): Context {
|
||||
return {
|
||||
media_info: {
|
||||
episode_run_time: [],
|
||||
origin_country: [],
|
||||
title: '测试电影',
|
||||
title_year: '测试电影 (2026)',
|
||||
...overrides.media,
|
||||
},
|
||||
meta_info: {
|
||||
apply_words: [],
|
||||
audio_term: '',
|
||||
edition: '',
|
||||
episode: '',
|
||||
episode_list: [],
|
||||
episode_seq: '',
|
||||
episode_seqs: '',
|
||||
episodes: '',
|
||||
isfile: false,
|
||||
name: '测试电影',
|
||||
release_group: '',
|
||||
resource_term: '',
|
||||
sea: '',
|
||||
season: '',
|
||||
season_episode: '',
|
||||
season_list: [],
|
||||
season_seq: '',
|
||||
total_episode: 0,
|
||||
total_season: 0,
|
||||
type: '电影',
|
||||
video_term: '',
|
||||
web_source: '',
|
||||
...overrides.meta,
|
||||
},
|
||||
torrent_info: {
|
||||
category: '电影',
|
||||
downloadvolumefactor: 1,
|
||||
enclosure: 'https://downloads.example.com/test.torrent',
|
||||
freedate: '',
|
||||
freedate_diff: '',
|
||||
grabs: 3,
|
||||
hit_and_run: false,
|
||||
imdbid: 'tt1000001',
|
||||
labels: [],
|
||||
page_url: 'https://tracker.example.com/details/1001',
|
||||
peers: 2,
|
||||
pri_order: 0,
|
||||
seeders: 10,
|
||||
site: 101,
|
||||
site_name: '测试站',
|
||||
site_order: 0,
|
||||
site_proxy: false,
|
||||
size: 1024,
|
||||
title: 'Test.Movie.2026.1080p',
|
||||
uploadvolumefactor: 1,
|
||||
volume_factor: 'FREE',
|
||||
...overrides.torrent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function renderCard(context: Context, more: Context[] = []) {
|
||||
return renderWithProviders(TorrentCard, {
|
||||
global: { stubs: { VImg: ImageStub } },
|
||||
props: { more, torrent: context },
|
||||
})
|
||||
}
|
||||
|
||||
function getCard(container: Element) {
|
||||
const card = container.querySelector<HTMLElement>('.torrent-card')
|
||||
expect(card).not.toBeNull()
|
||||
return card as HTMLElement
|
||||
}
|
||||
|
||||
function getDetailButton(container: Element) {
|
||||
const button = container.querySelector<HTMLButtonElement>('.v-card-actions .v-btn--icon')
|
||||
expect(button).not.toBeNull()
|
||||
return button as HTMLButtonElement
|
||||
}
|
||||
|
||||
function getDialogCall(index = 0) {
|
||||
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
|
||||
unknown,
|
||||
Record<string, unknown>,
|
||||
Record<string, (...args: unknown[]) => void>,
|
||||
Record<string, unknown>,
|
||||
]
|
||||
return { events, options, props }
|
||||
}
|
||||
|
||||
describe('TorrentCard approved regressions', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(downloadedTorrentMap).forEach(url => delete downloadedTorrentMap[url])
|
||||
mocks.getCachedSiteIcon.mockImplementation((site: number) =>
|
||||
Promise.resolve(`https://images.example.com/site-${site}.png`),
|
||||
)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('omits a missing season and episode from the download title', async () => {
|
||||
const context = createContext({ meta: { season_episode: undefined } })
|
||||
const { container } = await renderCard(context)
|
||||
|
||||
await fireEvent.click(getCard(container))
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
|
||||
expect(mocks.openSharedDialog.mock.calls[0]?.[1]).toMatchObject({
|
||||
media: context.media_info,
|
||||
title: '测试电影 (2026)',
|
||||
torrent: context.torrent_info,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not open a blank detail page or trigger download when the URL is missing', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const context = createContext({ torrent: { page_url: undefined } })
|
||||
const { container } = await renderCard(context)
|
||||
|
||||
await fireEvent.click(getDetailButton(container))
|
||||
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TorrentCard display and interactions', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(downloadedTorrentMap).forEach(url => delete downloadedTorrentMap[url])
|
||||
mocks.getCachedSiteIcon.mockImplementation((site: number) =>
|
||||
Promise.resolve(`https://images.example.com/site-${site}.png`),
|
||||
)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('renders the current title, release metadata, tracker state, labels, size, and time', async () => {
|
||||
const context = createContext({
|
||||
media: { title: '可见媒体' },
|
||||
meta: {
|
||||
edition: 'IMAX',
|
||||
resource_pix: '2160p',
|
||||
resource_team: '测试组',
|
||||
season_episode: 'S02E03',
|
||||
subtitle: '国粤双语',
|
||||
video_encode: 'HEVC',
|
||||
web_source: 'Netflix',
|
||||
},
|
||||
torrent: {
|
||||
description: '种子描述',
|
||||
downloadvolumefactor: 0,
|
||||
freedate_diff: '剩余 1 天',
|
||||
hit_and_run: true,
|
||||
labels: ['国语', '杜比视界'],
|
||||
peers: 4,
|
||||
pubdate: '2026-07-29 12:00:00',
|
||||
seeders: 12,
|
||||
size: 1536,
|
||||
site_name: '高清测试站',
|
||||
title: 'Visible.Media.S02E03.2160p',
|
||||
uploadvolumefactor: 2,
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = await renderCard(context)
|
||||
|
||||
for (const text of [
|
||||
'可见媒体',
|
||||
'S02E03',
|
||||
'Visible.Media.S02E03.2160p',
|
||||
'国粤双语',
|
||||
'高清测试站',
|
||||
'Netflix',
|
||||
'IMAX',
|
||||
'2160p',
|
||||
'HEVC',
|
||||
'测试组',
|
||||
'国语',
|
||||
'杜比视界',
|
||||
'H&R',
|
||||
'剩余 1 天',
|
||||
formatFileSize(1536),
|
||||
formatDateDifference('2026-07-29 12:00:00'),
|
||||
]) {
|
||||
expect(screen.getByText(text)).toBeInTheDocument()
|
||||
}
|
||||
expect(container.querySelector('.discount-banner')).toHaveClass('bg-success')
|
||||
await waitFor(() => expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('site-101.png'))
|
||||
})
|
||||
|
||||
it('falls back to the recognized name and tracker initial when richer metadata and icon are unavailable', async () => {
|
||||
mocks.getCachedSiteIcon.mockRejectedValue(new Error('icon unavailable'))
|
||||
const context = createContext({
|
||||
media: { title: undefined },
|
||||
meta: { name: '识别名称', subtitle: undefined },
|
||||
torrent: { description: undefined, site_name: '备用站' },
|
||||
})
|
||||
|
||||
const { container } = await renderCard(context)
|
||||
|
||||
expect(screen.getByText('识别名称')).toBeInTheDocument()
|
||||
expect(screen.getByText('备')).toBeInTheDocument()
|
||||
expect(container.querySelector('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['discount', 0.5, 1, 'bg-orange'],
|
||||
['upload bonus', 1, 2, 'bg-purple'],
|
||||
['unclassified promotion', 2, 1, null],
|
||||
] as const)('projects the %s promotion style', async (_case, downloadFactor, uploadFactor, expectedClass) => {
|
||||
const { container } = await renderCard(
|
||||
createContext({
|
||||
torrent: {
|
||||
downloadvolumefactor: downloadFactor,
|
||||
uploadvolumefactor: uploadFactor,
|
||||
volume_factor: _case,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const banner = container.querySelector('.discount-banner')
|
||||
|
||||
expect(banner).toBeInTheDocument()
|
||||
if (expectedClass) expect(banner).toHaveClass(expectedClass)
|
||||
else expect(banner).not.toHaveClass('bg-success', 'bg-orange', 'bg-purple')
|
||||
})
|
||||
|
||||
it('opens the download dialog with the current Context and shares successful download state across cards', async () => {
|
||||
const context = createContext({ meta: { season_episode: 'S01E02' } })
|
||||
const Harness = {
|
||||
components: { TorrentCard },
|
||||
data: () => ({ context }),
|
||||
template: '<div><TorrentCard :torrent="context" /><TorrentCard :torrent="context" /></div>',
|
||||
}
|
||||
const { container } = await renderWithProviders(Harness, {
|
||||
global: { stubs: { VImg: ImageStub } },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('.torrent-card') as Element)
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const { events, options, props } = getDialogCall()
|
||||
|
||||
expect(props).toEqual({
|
||||
media: context.media_info,
|
||||
title: '测试电影 (2026) S01E02',
|
||||
torrent: context.torrent_info,
|
||||
})
|
||||
expect(options).toEqual({ closeOn: ['close', 'done', 'error'] })
|
||||
|
||||
events.done('')
|
||||
expect(container.querySelectorAll('.torrent-card.border-success')).toHaveLength(0)
|
||||
|
||||
events.done(context.torrent_info.enclosure)
|
||||
await waitFor(() => expect(container.querySelectorAll('.torrent-card.border-success')).toHaveLength(2))
|
||||
|
||||
events.error('下载失败')
|
||||
expect(console.error).toHaveBeenCalledWith('下载失败')
|
||||
})
|
||||
|
||||
it('opens more sources with candidate icons and keeps alternative download and detail Context intact', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const primary = createContext()
|
||||
const alternative = createContext({
|
||||
media: { title: '备选媒体', title_year: '备选媒体 (2025)' },
|
||||
meta: { name: '备选名称', season_episode: 'S03E04' },
|
||||
torrent: {
|
||||
enclosure: 'https://downloads.example.com/alternative.torrent',
|
||||
page_url: 'https://tracker.example.com/details/202',
|
||||
site: 202,
|
||||
site_name: '备选站',
|
||||
title: 'Alternative.Media.S03E04',
|
||||
},
|
||||
})
|
||||
const { container } = await renderCard(primary, [alternative])
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /更多来源/ }))
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
const moreDialog = getDialogCall()
|
||||
expect(moreDialog.props.items).toEqual([alternative])
|
||||
expect(moreDialog.options).toEqual({ closeOn: ['close', 'update:modelValue'] })
|
||||
await waitFor(() =>
|
||||
expect(moreDialog.props.siteIcons).toEqual(
|
||||
expect.objectContaining({ 202: 'https://images.example.com/site-202.png' }),
|
||||
),
|
||||
)
|
||||
|
||||
moreDialog.events.download(alternative)
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledTimes(2))
|
||||
expect(getDialogCall(1).props).toEqual({
|
||||
media: alternative.media_info,
|
||||
title: '备选媒体 (2025) S03E04',
|
||||
torrent: alternative.torrent_info,
|
||||
})
|
||||
|
||||
moreDialog.events.detail(alternative)
|
||||
expect(open).toHaveBeenCalledWith(alternative.torrent_info.page_url, '_blank')
|
||||
expect(container.querySelector('.torrent-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens an available primary detail URL without starting a download', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const context = createContext()
|
||||
const { container } = await renderCard(context)
|
||||
|
||||
await fireEvent.click(getDetailButton(container))
|
||||
|
||||
expect(open).toHaveBeenCalledWith(context.torrent_info.page_url, '_blank')
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
331
src/components/cards/__tests__/TorrentItem.spec.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||
import type { Context, MediaInfo, MetaInfo, TorrentInfo } from '@/api/types'
|
||||
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
||||
import { downloadedTorrentMap } from '@/utils/torrentDownloadCache'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getCachedSiteIcon: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/siteIconCache', () => ({
|
||||
getCachedSiteIcon: (...args: unknown[]) => mocks.getCachedSiteIcon(...args),
|
||||
}))
|
||||
|
||||
const ImageStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
alt: String,
|
||||
src: String,
|
||||
},
|
||||
setup: props => () => h('img', { alt: props.alt, src: props.src }),
|
||||
})
|
||||
|
||||
interface ContextOverrides {
|
||||
media?: Partial<MediaInfo>
|
||||
meta?: Partial<MetaInfo>
|
||||
torrent?: Partial<TorrentInfo>
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(done => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function createContext(overrides: ContextOverrides = {}): Context {
|
||||
return {
|
||||
media_info: {
|
||||
episode_run_time: [],
|
||||
origin_country: [],
|
||||
title: '测试电影',
|
||||
title_year: '测试电影 (2026)',
|
||||
...overrides.media,
|
||||
},
|
||||
meta_info: {
|
||||
apply_words: [],
|
||||
audio_term: '',
|
||||
edition: '',
|
||||
episode: '',
|
||||
episode_list: [],
|
||||
episode_seq: '',
|
||||
episode_seqs: '',
|
||||
episodes: '',
|
||||
isfile: false,
|
||||
name: '测试电影',
|
||||
release_group: '',
|
||||
resource_term: '',
|
||||
sea: '',
|
||||
season: '',
|
||||
season_episode: '',
|
||||
season_list: [],
|
||||
season_seq: '',
|
||||
total_episode: 0,
|
||||
total_season: 0,
|
||||
type: '电影',
|
||||
video_term: '',
|
||||
web_source: '',
|
||||
...overrides.meta,
|
||||
},
|
||||
torrent_info: {
|
||||
category: '电影',
|
||||
downloadvolumefactor: 1,
|
||||
enclosure: 'https://downloads.example.com/test.torrent',
|
||||
freedate: '',
|
||||
freedate_diff: '',
|
||||
grabs: 3,
|
||||
hit_and_run: false,
|
||||
imdbid: 'tt1000001',
|
||||
labels: [],
|
||||
page_url: 'https://tracker.example.com/details/1001',
|
||||
peers: 2,
|
||||
pri_order: 0,
|
||||
seeders: 10,
|
||||
site: 101,
|
||||
site_name: '测试站',
|
||||
site_order: 0,
|
||||
site_proxy: false,
|
||||
size: 1024,
|
||||
title: 'Test.Movie.2026.1080p',
|
||||
uploadvolumefactor: 1,
|
||||
volume_factor: 'FREE',
|
||||
...overrides.torrent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function renderItem(context: Context) {
|
||||
return renderWithProviders(TorrentItem, {
|
||||
global: { stubs: { VImg: ImageStub } },
|
||||
props: { torrent: context },
|
||||
})
|
||||
}
|
||||
|
||||
function getItem(container: Element) {
|
||||
const item = container.querySelector<HTMLElement>('.torrent-item')
|
||||
expect(item).not.toBeNull()
|
||||
return item as HTMLElement
|
||||
}
|
||||
|
||||
function getDetailButton(container: Element) {
|
||||
const button = container.querySelector<HTMLButtonElement>('.v-list-item__append .v-btn--icon')
|
||||
expect(button).not.toBeNull()
|
||||
return button as HTMLButtonElement
|
||||
}
|
||||
|
||||
function getDialogCall(index = 0) {
|
||||
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
|
||||
unknown,
|
||||
Record<string, unknown>,
|
||||
Record<string, (...args: unknown[]) => void>,
|
||||
Record<string, unknown>,
|
||||
]
|
||||
return { events, options, props }
|
||||
}
|
||||
|
||||
describe('TorrentItem approved regressions', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(downloadedTorrentMap).forEach(url => delete downloadedTorrentMap[url])
|
||||
mocks.getCachedSiteIcon.mockResolvedValue('https://images.example.com/site-101.png')
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('does not open a blank detail page or trigger download when the URL is missing', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const context = createContext({ torrent: { page_url: undefined } })
|
||||
const { container } = await renderItem(context)
|
||||
|
||||
await fireEvent.click(getDetailButton(container))
|
||||
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the current site icon when an older request resolves last', async () => {
|
||||
const oldIcon = createDeferred<string>()
|
||||
const currentIcon = createDeferred<string>()
|
||||
mocks.getCachedSiteIcon.mockImplementation((site: number) => (site === 101 ? oldIcon.promise : currentIcon.promise))
|
||||
const oldContext = createContext({ torrent: { site: 101, site_name: '旧站' } })
|
||||
const currentContext = createContext({ torrent: { site: 202, site_name: '当前站' } })
|
||||
|
||||
const { container, rerender } = await renderItem(oldContext)
|
||||
await waitFor(() => expect(mocks.getCachedSiteIcon).toHaveBeenCalledWith(101, expect.any(Function)))
|
||||
await rerender({ torrent: currentContext })
|
||||
await waitFor(() => expect(mocks.getCachedSiteIcon).toHaveBeenCalledWith(202, expect.any(Function)))
|
||||
|
||||
currentIcon.resolve('https://images.example.com/site-202.png')
|
||||
await waitFor(() => expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('site-202.png'))
|
||||
|
||||
oldIcon.resolve('https://images.example.com/site-101.png')
|
||||
await waitFor(() => expect(mocks.getCachedSiteIcon).toHaveBeenCalledTimes(2))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('site-202.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TorrentItem display and interactions', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(downloadedTorrentMap).forEach(url => delete downloadedTorrentMap[url])
|
||||
mocks.getCachedSiteIcon.mockImplementation((site: number) =>
|
||||
Promise.resolve(`https://images.example.com/site-${site}.png`),
|
||||
)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('renders the current title, release metadata, tracker state, labels, size, and time', async () => {
|
||||
const context = createContext({
|
||||
media: { title: '列表媒体' },
|
||||
meta: {
|
||||
edition: '导演剪辑版',
|
||||
resource_pix: '1080p',
|
||||
resource_team: '列表组',
|
||||
season_episode: 'S01E08',
|
||||
subtitle: '简繁字幕',
|
||||
video_encode: 'AV1',
|
||||
web_source: 'Apple TV+',
|
||||
},
|
||||
torrent: {
|
||||
downloadvolumefactor: 0.5,
|
||||
freedate_diff: '剩余 2 小时',
|
||||
hit_and_run: true,
|
||||
labels: ['国语', '高码率'],
|
||||
peers: 6,
|
||||
pubdate: '2026-07-29 18:00:00',
|
||||
seeders: 24,
|
||||
size: 2048,
|
||||
site_name: '列表测试站',
|
||||
title: 'List.Media.S01E08.1080p',
|
||||
uploadvolumefactor: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = await renderItem(context)
|
||||
|
||||
for (const text of [
|
||||
'列表媒体',
|
||||
'S01E08',
|
||||
'List.Media.S01E08.1080p',
|
||||
'简繁字幕',
|
||||
'Apple TV+',
|
||||
'导演剪辑版',
|
||||
'1080p',
|
||||
'AV1',
|
||||
'列表组',
|
||||
'国语',
|
||||
'高码率',
|
||||
'H&R',
|
||||
'剩余 2 小时',
|
||||
formatFileSize(2048),
|
||||
formatDateDifference('2026-07-29 18:00:00'),
|
||||
]) {
|
||||
expect(screen.getByText(text)).toBeInTheDocument()
|
||||
}
|
||||
expect(container.querySelector('.discount-banner')).toHaveClass('bg-orange')
|
||||
expect(container.querySelector('.torrent-item [title="列表测试站"]')).toBeInTheDocument()
|
||||
await waitFor(() => expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('site-101.png'))
|
||||
})
|
||||
|
||||
it('falls back to the recognized name, placeholder description, and tracker initial', async () => {
|
||||
mocks.getCachedSiteIcon.mockRejectedValue(new Error('icon unavailable'))
|
||||
const context = createContext({
|
||||
media: { title: undefined },
|
||||
meta: { name: '列表识别名称', subtitle: undefined },
|
||||
torrent: { description: undefined, site_name: '备用列表站' },
|
||||
})
|
||||
|
||||
const { container } = await renderItem(context)
|
||||
|
||||
expect(screen.getByText('列表识别名称')).toBeInTheDocument()
|
||||
expect(screen.getByText('暂无描述')).toBeInTheDocument()
|
||||
expect(screen.getByText('备')).toBeInTheDocument()
|
||||
expect(container.querySelector('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['free', 0, 1, 'bg-success'],
|
||||
['upload bonus', 1, 2, 'bg-purple'],
|
||||
['unclassified promotion', 2, 1, null],
|
||||
] as const)('projects the %s promotion style', async (_case, downloadFactor, uploadFactor, expectedClass) => {
|
||||
const { container } = await renderItem(
|
||||
createContext({
|
||||
torrent: {
|
||||
downloadvolumefactor: downloadFactor,
|
||||
uploadvolumefactor: uploadFactor,
|
||||
volume_factor: _case,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const banner = container.querySelector('.discount-banner')
|
||||
|
||||
expect(banner).toBeInTheDocument()
|
||||
if (expectedClass) expect(banner).toHaveClass(expectedClass)
|
||||
else expect(banner).not.toHaveClass('bg-success', 'bg-orange', 'bg-purple')
|
||||
})
|
||||
|
||||
it('opens the download dialog with the current Context and marks the item after success', async () => {
|
||||
const context = createContext({ meta: { season_episode: 'S04E05' } })
|
||||
const { container } = await renderItem(context)
|
||||
|
||||
await fireEvent.click(getItem(container))
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
const { events, options, props } = getDialogCall()
|
||||
|
||||
expect(props).toEqual({
|
||||
media: context.media_info,
|
||||
title: '测试电影 (2026) S04E05',
|
||||
torrent: context.torrent_info,
|
||||
})
|
||||
expect(options).toEqual({ closeOn: ['close', 'done', 'error'] })
|
||||
|
||||
events.done('')
|
||||
expect(getItem(container)).not.toHaveClass('border-success')
|
||||
|
||||
events.done(context.torrent_info.enclosure)
|
||||
await waitFor(() => expect(getItem(container)).toHaveClass('border-success'))
|
||||
|
||||
events.error('下载失败')
|
||||
expect(console.error).toHaveBeenCalledWith('下载失败')
|
||||
})
|
||||
|
||||
it('opens an available detail URL without starting a download', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const context = createContext()
|
||||
const { container } = await renderItem(context)
|
||||
|
||||
await fireEvent.click(getDetailButton(container))
|
||||
|
||||
expect(open).toHaveBeenCalledWith(context.torrent_info.page_url, '_blank')
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a resolved icon when the current Context has no site identity', async () => {
|
||||
const current = createContext({ torrent: { site: 101, site_name: '当前站' } })
|
||||
const withoutSite = createContext({ torrent: { site: undefined, site_name: '无站点' } })
|
||||
const { container, rerender } = await renderItem(current)
|
||||
await waitFor(() => expect(container.querySelector<HTMLImageElement>('img')?.src).toContain('site-101.png'))
|
||||
|
||||
await rerender({ torrent: withoutSite })
|
||||
|
||||
await waitFor(() => expect(container.querySelector('img')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('无')).toBeInTheDocument()
|
||||
expect(mocks.getCachedSiteIcon).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
75
src/components/common/PluginRatingDisplay.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
rating?: number
|
||||
count?: number
|
||||
iconSize?: number | string
|
||||
showCount?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rating: 0,
|
||||
count: 0,
|
||||
iconSize: 16,
|
||||
showCount: true,
|
||||
})
|
||||
|
||||
const displayRating = computed(() => {
|
||||
const normalizedRating = Number.isFinite(props.rating) ? props.rating : 0
|
||||
return Math.round(Math.min(5, Math.max(0, normalizedRating)) * 2) / 2
|
||||
})
|
||||
|
||||
const starIcons = computed(() =>
|
||||
Array.from({ length: 5 }, (_, index) => {
|
||||
const starValue = index + 1
|
||||
if (displayRating.value >= starValue) return 'mdi-star'
|
||||
if (displayRating.value >= starValue - 0.5) return 'mdi-star-half-full'
|
||||
return 'mdi-star-outline'
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugin-rating-display" :aria-label="`${Number(props.rating || 0).toFixed(1)} / 5`">
|
||||
<span class="plugin-rating-display__stars" aria-hidden="true">
|
||||
<VIcon
|
||||
v-for="(icon, index) in starIcons"
|
||||
:key="index"
|
||||
:icon="icon"
|
||||
:size="props.iconSize"
|
||||
:color="icon === 'mdi-star-outline' ? undefined : 'warning'"
|
||||
:data-rating-icon="icon"
|
||||
/>
|
||||
</span>
|
||||
<span class="plugin-rating-display__value">{{ Number(props.rating || 0).toFixed(1) }}</span>
|
||||
<span v-if="props.showCount" class="plugin-rating-display__count">({{ props.count || 0 }})</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-rating-display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-inline-size: 0;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-rating-display__stars {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.plugin-rating-display__value {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plugin-rating-display__count {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
18
src/components/common/__tests__/PluginRatingDisplay.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import PluginRatingDisplay from '@/components/common/PluginRatingDisplay.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('PluginRatingDisplay', () => {
|
||||
it('rounds the visual stars to half increments while keeping the exact score text', async () => {
|
||||
const { container } = await renderWithProviders(PluginRatingDisplay, {
|
||||
props: {
|
||||
rating: 4.3,
|
||||
count: 12,
|
||||
},
|
||||
})
|
||||
|
||||
expect(container.querySelectorAll('[data-rating-icon="mdi-star"]')).toHaveLength(4)
|
||||
expect(container.querySelectorAll('[data-rating-icon="mdi-star-half-full"]')).toHaveLength(1)
|
||||
expect(container.querySelector('[aria-label="4.3 / 5"]')).toHaveTextContent('4.3(12)')
|
||||
})
|
||||
})
|
||||
@@ -100,11 +100,20 @@ const versionStatisticLoading = ref(false)
|
||||
// 版本统计数据
|
||||
const versionStatistic = ref<any>({})
|
||||
|
||||
const MIN_VISIBLE_VERSION_INSTALLS = 2
|
||||
|
||||
/** 过滤安装实例过少的版本,避免在版本统计中展示孤立记录。 */
|
||||
function filterVersionStatistics(items: unknown) {
|
||||
if (!Array.isArray(items)) return []
|
||||
|
||||
return items.filter(item => Number(item?.count || 0) >= MIN_VISIBLE_VERSION_INSTALLS)
|
||||
}
|
||||
|
||||
// 后端版本统计
|
||||
const backendVersionStatistics = computed(() => versionStatistic.value?.backend_versions ?? [])
|
||||
const backendVersionStatistics = computed(() => filterVersionStatistics(versionStatistic.value?.backend_versions))
|
||||
|
||||
// 前端版本统计
|
||||
const frontendVersionStatistics = computed(() => versionStatistic.value?.frontend_versions ?? [])
|
||||
const frontendVersionStatistics = computed(() => filterVersionStatistics(versionStatistic.value?.frontend_versions))
|
||||
|
||||
// 活跃用户统计
|
||||
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import type {
|
||||
ApiResponse,
|
||||
DownloaderConf,
|
||||
MediaDataSource,
|
||||
MediaInfo,
|
||||
TorrentInfo,
|
||||
TransferDirectoryConf,
|
||||
} from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -18,7 +25,11 @@ const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
const mediaSource = ref<MediaDataSource>(
|
||||
['themoviedb', 'douban', 'bangumi', 'anilist'].includes(globalSettings.RECOGNIZE_SOURCE)
|
||||
? globalSettings.RECOGNIZE_SOURCE
|
||||
: 'themoviedb',
|
||||
)
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -40,7 +51,7 @@ const selectedDownloader = ref<string | null>(null)
|
||||
const selectedDirectory = ref<string | null>(null)
|
||||
|
||||
// 下载器
|
||||
const downloaders = ref<DownloaderConf[]>([])
|
||||
const downloaders = ref<Array<Pick<DownloaderConf, 'name' | 'type'>>>([])
|
||||
|
||||
// 所有目录设置
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
@@ -51,11 +62,19 @@ const loading = ref(false)
|
||||
// 是否显示高级选项
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// TMDB ID
|
||||
const tmdbid = ref<number | undefined>(undefined)
|
||||
// 当前数据源的原生媒体ID
|
||||
const mediaId = ref<string | undefined>(undefined)
|
||||
|
||||
// 豆瓣ID
|
||||
const doubanId = ref<string | undefined>(undefined)
|
||||
// 当前数据源对应的原生ID标签。
|
||||
const mediaIdLabel = computed(() => {
|
||||
const labels: Record<MediaDataSource, string> = {
|
||||
themoviedb: t('dialog.reorganize.tmdbId'),
|
||||
douban: t('dialog.reorganize.doubanId'),
|
||||
bangumi: t('dialog.reorganize.bangumiId'),
|
||||
anilist: t('dialog.reorganize.anilistId'),
|
||||
}
|
||||
return labels[mediaSource.value]
|
||||
})
|
||||
|
||||
// TMDB选择对话框
|
||||
const mediaSelectorDialog = ref(false)
|
||||
@@ -79,7 +98,10 @@ const dialogSubtitle = computed(() => {
|
||||
// 加载目录设置
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
const result = await api.get<
|
||||
ApiResponse<{ value?: TransferDirectoryConf[] }>,
|
||||
ApiResponse<{ value?: TransferDirectoryConf[] }>
|
||||
>('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -91,7 +113,8 @@ function convertToUri(item: TransferDirectoryConf) {
|
||||
if (!item.download_path) {
|
||||
return undefined
|
||||
}
|
||||
if (item.storage === 'local') {
|
||||
// storage 缺省是受支持的本地目录配置,不能生成 undefined/null 前缀。
|
||||
if (item.storage === undefined || item.storage === null || item.storage === 'local') {
|
||||
return item.download_path
|
||||
}
|
||||
return item.storage + ':' + item.download_path
|
||||
@@ -108,7 +131,10 @@ const targetDirectories = computed(() => {
|
||||
// 调用API查询下载器设置
|
||||
async function loadDownloaderSetting() {
|
||||
try {
|
||||
downloaders.value = await api.get('download/clients')
|
||||
downloaders.value = await api.get<
|
||||
Array<Pick<DownloaderConf, 'name' | 'type'>>,
|
||||
Array<Pick<DownloaderConf, 'name' | 'type'>>
|
||||
>('download/clients')
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -127,9 +153,14 @@ async function addDownload() {
|
||||
startNProgress()
|
||||
loading.value = true
|
||||
try {
|
||||
let result: { [key: string]: any }
|
||||
|
||||
const payload: any = {
|
||||
const payload: {
|
||||
downloader: string | null
|
||||
media_id?: string
|
||||
media_in?: MediaInfo
|
||||
media_source?: MediaDataSource
|
||||
save_path: string | null
|
||||
torrent_in: TorrentInfo | undefined
|
||||
} = {
|
||||
torrent_in: props.torrent,
|
||||
downloader: selectedDownloader.value,
|
||||
save_path: selectedDirectory.value,
|
||||
@@ -140,16 +171,14 @@ async function addDownload() {
|
||||
}
|
||||
|
||||
// 添加媒体ID辅助识别
|
||||
if (tmdbid.value) {
|
||||
payload.tmdbid = tmdbid.value
|
||||
}
|
||||
if (doubanId.value) {
|
||||
payload.doubanid = doubanId.value
|
||||
if (mediaId.value) {
|
||||
payload.media_source = mediaSource.value
|
||||
payload.media_id = mediaId.value
|
||||
}
|
||||
|
||||
const endpoint = props.media ? 'download/' : 'download/add'
|
||||
|
||||
result = await api.post(endpoint, payload)
|
||||
const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>(endpoint, payload)
|
||||
|
||||
if (result && result.success) {
|
||||
// 添加下载成功
|
||||
@@ -269,23 +298,9 @@ onMounted(() => {
|
||||
<VRow v-show="showAdvancedOptions" class="px-5">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
:label="t('dialog.reorganize.tmdbId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('dialog.reorganize.doubanId')"
|
||||
v-model="mediaId"
|
||||
class="app-responsive-input--keep-append-action"
|
||||
:label="mediaIdLabel"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
@@ -307,13 +322,7 @@ onMounted(() => {
|
||||
</VCard>
|
||||
<!-- 媒体ID选择器 -->
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
<MediaIdSelector v-model="mediaId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { SubtitleInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import type { ApiResponse, MediaDataSource, SubtitleInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
@@ -17,7 +17,11 @@ const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
const mediaSource = ref<MediaDataSource>(
|
||||
['themoviedb', 'douban', 'bangumi', 'anilist'].includes(globalSettings.RECOGNIZE_SOURCE)
|
||||
? globalSettings.RECOGNIZE_SOURCE
|
||||
: 'themoviedb',
|
||||
)
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -43,11 +47,19 @@ const loading = ref(false)
|
||||
// 是否显示高级选项
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// TMDB ID
|
||||
const tmdbid = ref<number | undefined>(undefined)
|
||||
// 当前数据源的原生媒体ID
|
||||
const mediaId = ref<string | undefined>(undefined)
|
||||
|
||||
// 豆瓣ID
|
||||
const doubanId = ref<string | undefined>(undefined)
|
||||
// 当前数据源对应的原生ID标签。
|
||||
const mediaIdLabel = computed(() => {
|
||||
const labels: Record<MediaDataSource, string> = {
|
||||
themoviedb: t('dialog.reorganize.tmdbId'),
|
||||
douban: t('dialog.reorganize.doubanId'),
|
||||
bangumi: t('dialog.reorganize.bangumiId'),
|
||||
anilist: t('dialog.reorganize.anilistId'),
|
||||
}
|
||||
return labels[mediaSource.value]
|
||||
})
|
||||
|
||||
// TMDB选择对话框
|
||||
const mediaSelectorDialog = ref(false)
|
||||
@@ -63,7 +75,10 @@ const buttonText = computed(() =>
|
||||
// 加载目录设置
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
const result = await api.get<
|
||||
ApiResponse<{ value?: TransferDirectoryConf[] }>,
|
||||
ApiResponse<{ value?: TransferDirectoryConf[] }>
|
||||
>('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -74,7 +89,8 @@ function convertToUri(item: TransferDirectoryConf) {
|
||||
if (!item.download_path) {
|
||||
return undefined
|
||||
}
|
||||
if (item.storage === 'local') {
|
||||
// storage 缺省是受支持的本地目录配置,不能生成 undefined/null 前缀。
|
||||
if (item.storage === undefined || item.storage === null || item.storage === 'local') {
|
||||
return item.download_path
|
||||
}
|
||||
return item.storage + ':' + item.download_path
|
||||
@@ -93,19 +109,22 @@ async function addSubtitleDownload() {
|
||||
startNProgress()
|
||||
loading.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
const payload: {
|
||||
media_id?: string
|
||||
media_source?: MediaDataSource
|
||||
save_path: string | null
|
||||
subtitle_in: SubtitleInfo | undefined
|
||||
} = {
|
||||
subtitle_in: props.subtitle,
|
||||
save_path: selectedDirectory.value,
|
||||
}
|
||||
|
||||
if (tmdbid.value) {
|
||||
payload.tmdbid = tmdbid.value
|
||||
}
|
||||
if (doubanId.value) {
|
||||
payload.doubanid = doubanId.value
|
||||
if (mediaId.value) {
|
||||
payload.media_source = mediaSource.value
|
||||
payload.media_id = mediaId.value
|
||||
}
|
||||
|
||||
const result: { [key: string]: any } = await api.post('download/subtitle', payload)
|
||||
const result = await api.post<ApiResponse<unknown>, ApiResponse<unknown>>('download/subtitle', payload)
|
||||
|
||||
if (result && result.success) {
|
||||
$toast.success(
|
||||
@@ -221,23 +240,9 @@ onMounted(() => {
|
||||
<VRow v-show="showAdvancedOptions" class="px-5">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
:label="t('dialog.reorganize.tmdbId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('dialog.reorganize.doubanId')"
|
||||
v-model="mediaId"
|
||||
class="app-responsive-input--keep-append-action"
|
||||
:label="mediaIdLabel"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
@@ -258,13 +263,7 @@ onMounted(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
<MediaIdSelector v-model="mediaId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MediaDataSource } from '@/api/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -20,12 +21,28 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'confirm', payload: { doubanId?: string; tmdbId?: number }): void
|
||||
(event: 'confirm', payload: { mediaSource?: MediaDataSource; mediaId?: string }): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const tmdbId = ref<number | undefined>()
|
||||
const doubanId = ref<string | undefined>()
|
||||
const mediaSource = ref<MediaDataSource>((props.recognizeSource as MediaDataSource) || 'themoviedb')
|
||||
const mediaId = ref<string>()
|
||||
const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [
|
||||
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
||||
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
|
||||
{ title: t('setting.cache.recognitionSource.bangumi'), value: 'bangumi' },
|
||||
{ title: t('setting.cache.recognitionSource.anilist'), value: 'anilist' },
|
||||
])
|
||||
|
||||
const mediaIdLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
themoviedb: t('setting.cache.reidentifyDialog.tmdbId'),
|
||||
douban: t('setting.cache.reidentifyDialog.doubanId'),
|
||||
bangumi: t('setting.cache.reidentifyDialog.bangumiId'),
|
||||
anilist: t('setting.cache.reidentifyDialog.anilistId'),
|
||||
}
|
||||
return labels[mediaSource.value] || t('setting.cache.reidentifyDialog.mediaId')
|
||||
})
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -38,8 +55,8 @@ const visible = computed({
|
||||
// 提交重新识别参数给缓存页执行接口调用。
|
||||
function submitReidentify() {
|
||||
emit('confirm', {
|
||||
doubanId: doubanId.value,
|
||||
tmdbId: tmdbId.value,
|
||||
mediaSource: mediaSource.value,
|
||||
mediaId: mediaId.value?.trim() || undefined,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -59,20 +76,20 @@ function submitReidentify() {
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="props.recognizeSource === 'themoviedb'"
|
||||
v-model="tmdbId"
|
||||
:label="t('setting.cache.reidentifyDialog.tmdbId')"
|
||||
:hint="t('setting.cache.reidentifyDialog.tmdbIdHint')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-id-card"
|
||||
<VSelect
|
||||
v-model="mediaSource"
|
||||
:items="mediaSourceItems"
|
||||
:label="t('setting.cache.reidentifyDialog.mediaSource')"
|
||||
:hint="t('setting.cache.reidentifyDialog.mediaSourceHint')"
|
||||
prepend-inner-icon="mdi-database-search"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('setting.cache.reidentifyDialog.doubanId')"
|
||||
:hint="t('setting.cache.reidentifyDialog.doubanIdHint')"
|
||||
v-model="mediaId"
|
||||
:label="mediaIdLabel"
|
||||
:hint="t('setting.cache.reidentifyDialog.mediaIdHint')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-id-card"
|
||||
persistent-hint
|
||||
|
||||