Compare commits

...

161 Commits

Author SHA1 Message Date
InfinityPacer
b13674d0e7 test(plugin): cover market list state workflows (#631) 2026-08-03 16:40:48 +08:00
InfinityPacer
9a533cd103 test(plugin): cover plugin host surfaces (#630) 2026-08-03 15:21:36 +08:00
InfinityPacer
1298a5f72c test(plugin): cover dynamic sidebar navigation (#629) 2026-08-03 13:30:20 +08:00
InfinityPacer
b2c2cacc4c test(plugin): cover federation host lifecycle (#628) 2026-08-03 12:45:33 +08:00
jxxghp
d27b402b74 style(download): improve history item hierarchy 2026-08-03 11:54:41 +08:00
InfinityPacer
5ca6c520ae fix(pwa): recover CORS image cache reads (#627) 2026-08-03 10:07:00 +08:00
jxxghp
aa63782645 feat(download): add download history dialog 2026-08-03 08:45:35 +08:00
jxxghp
a7df9c432e 更新 package.json 2026-08-03 07:56:27 +08:00
InfinityPacer
422aba8d0e fix(layout): align scrollbar and navbar behavior (#626) 2026-08-03 07:02:18 +08:00
InfinityPacer
245a1f8a5c fix(dashboard): remeasure async card content (#625) 2026-08-02 07:42:13 +08:00
jxxghp
777e30d137 fix(agent): recover PWA background replies 2026-08-01 08:13:58 +08:00
jxxghp
ed5d955ae7 fix(glass): align toast bubbles with menu material 2026-08-01 07:51:25 +08:00
InfinityPacer
bcc5543f4c fix(glass): preserve explicit optical boundaries (#624) 2026-08-01 07:28:13 +08:00
InfinityPacer
eedf038a70 fix(glass): disable mobile motion dynamics (#623) 2026-08-01 07:27:58 +08:00
InfinityPacer
88f81ecce4 perf(list): prefetch infinite scroll pages (#622) 2026-08-01 07:27:36 +08:00
InfinityPacer
9e35378ab4 test(filebrowser): cover file list operations (#621) 2026-08-01 07:26:59 +08:00
InfinityPacer
36b901f717 test(subscribe): cover popular media deduplication (#620) 2026-07-31 20:32:52 +08:00
InfinityPacer
cfbbd89b1b perf(grid): stage virtual card mounting (#619) 2026-07-31 20:32:32 +08:00
InfinityPacer
5acf6ee84c test(filebrowser): cover shell workflows (#618) 2026-07-31 19:43:25 +08:00
InfinityPacer
985dafe45f fix(glass): align frosted fixed and scroll materials (#617) 2026-07-31 18:16:53 +08:00
InfinityPacer
21558372d5 test(transfer): cover queue dialog workflows (#616) 2026-07-31 18:16:35 +08:00
InfinityPacer
53f4ba23be test(reorganize): cover manual transfer workflows (#615)
* test(reorganize): cover manual transfer workflows

* fix(reorganize): show fallback transfer errors
2026-07-31 17:11:52 +08:00
InfinityPacer
9b3388f45f fix(router): keep route reveal timeline monotonic (#614) 2026-07-31 16:40:00 +08:00
InfinityPacer
eaf6b5427c test(history): cover transfer history workflows (#613) 2026-07-31 16:39:48 +08:00
InfinityPacer
39395ca6b6 test(download): cover downloading task workflows (#612) 2026-07-31 15:25:31 +08:00
jxxghp
38fe17ab99 Improve type safety in name test form and theme test 2026-07-31 15:14:38 +08:00
jxxghp
c957264b72 Enhance name recognition results with source metadata 2026-07-31 15:12:41 +08:00
InfinityPacer
41a36e2279 fix(glass): preserve material during route transitions (#611) 2026-07-31 14:35:08 +08:00
jxxghp
246b8ff54b feat(plugin): add ratings and about dialog 2026-07-31 13:41:29 +08:00
InfinityPacer
3f414afff4 test(download): cover download dialog workflows (#610) 2026-07-31 12:49:59 +08:00
InfinityPacer
7f2bd5b3ae test(utils): cover request and SSE lifecycle (#609) 2026-07-31 12:33:40 +08:00
InfinityPacer
2c2f55b6d7 test(resource): cover torrent card interactions (#608) 2026-07-31 12:20:05 +08:00
InfinityPacer
a1802f7f93 test(resource): cover search filtering and lifecycle (#607) 2026-07-31 10:59:35 +08:00
InfinityPacer
b85d412308 fix(glass): stabilize fixed shell and initial frosted material (#606) 2026-07-31 06:19:00 +08:00
InfinityPacer
999f8bf18c build: enable modern API for indented Sass (#605) 2026-07-31 06:18:36 +08:00
InfinityPacer
544acc0758 test: clean up expected console output (#604) 2026-07-31 06:17:52 +08:00
InfinityPacer
e67edd9feb fix(glass): scope fixed shell backplate (#603) 2026-07-30 22:47:22 +08:00
InfinityPacer
bf77c8ee04 fix(glass): stabilize frosted fixed shells (#602) 2026-07-30 21:42:06 +08:00
InfinityPacer
6d5550f198 fix(glass): preserve collapsed interaction clips (#601) 2026-07-30 21:01:15 +08:00
InfinityPacer
097beb6f63 perf(glass): reduce long-list interaction overhead (#600) 2026-07-30 18:58:49 +08:00
jxxghp
55e3a8cba2 feat: 显示共享媒体识别统计 2026-07-30 17:50:16 +08:00
jxxghp
ca2f3da7a5 feat(settings): configure mounted directory cleanup 2026-07-30 17:45:13 +08:00
InfinityPacer
32e4a9b817 fix(glass): support pointer input in Safari (#599) 2026-07-30 17:16:27 +08:00
jxxghp
7e3e95fbcd Theme agent robot colors dynamically 2026-07-30 16:42:43 +08:00
jxxghp
4362e76ca0 Refine glass navbar and file browser surface materials 2026-07-30 15:25:45 +08:00
jxxghp
f761c59637 Unify MoviePilot wordmark styling across layouts 2026-07-30 14:51:50 +08:00
InfinityPacer
d29a008e13 fix(glass): stabilize scroll material and shared dynamics (#598) 2026-07-30 14:46:09 +08:00
jxxghp
715da1929b Fix LLM provider fields and OpenAI-compatible protocol detection 2026-07-30 14:41:26 +08:00
jxxghp
6104fb861e feat: 后端修改前端智能助手设置 (#597) 2026-07-30 13:32:19 +08:00
jxxghp
01e7dcee56 fix(editor): unify Ace editor padding 2026-07-29 13:48:12 +08:00
jxxghp
9ef45918b8 fix WordsView 2026-07-29 13:19:56 +08:00
jxxghp
502a593985 更新 package.json 2026-07-29 11:19:32 +08:00
InfinityPacer
287cbc76ae feat(glass): refine material response and rendering lifecycle (#596) 2026-07-29 11:19:12 +08:00
jxxghp
90d626e7c6 chore(lint): 清理失效抑制项 2026-07-29 10:12:05 +08:00
jxxghp
8a3b99ae51 feat(pwa): 完善 iOS 启动与主题恢复 2026-07-29 10:06:14 +08:00
jxxghp
e5339d524b fix(cache): use fixed poster corner radius 2026-07-29 08:31:53 +08:00
jxxghp
e218508c2d fix(editor): Ace 输入框玻璃质感与圆角内边距对齐
- 玻璃主题下让 .ace_editor 与普通输入框一致的染色玻璃、描边、聚焦态
- 重命名格式编辑器改用全局圆角 token,并通过 renderer 补齐内边距

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 20:07:05 +08:00
jxxghp
e7bfc4ec1c fix(glass): 捷径卡片补齐玻璃描边与高光
弹层内嵌套卡片被通用折叠规则抹平 border 与 box-shadow,只补回背景会显得与其它玻璃卡片不一致,这里补齐完整表面并加上 hover 态。同时带上搜索弹窗的空状态间距与样式顺序整理。
2026-07-28 19:30:05 +08:00
jxxghp
5c63fe2313 feat(mediaserver): 飞牛影视增加访问码配置项
配置弹窗与初始化向导的飞牛表单增加访问码输入项,未开启时留空。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:09:17 +08:00
jxxghp
8d86bd6e92 feat(glass): 插件卡片改为染色玻璃
将插件卡片与市场卡片头部的实色背景改为变量驱动的染色层:
图标主色只作为透光色相,玻璃主题下叠加吸收遮罩与镜面高光,
并按 frosted/tinted 材质与透明度调整强度;文件夹卡片渐变底
同步降低不透明度让卡片本体玻璃透出。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:52:50 +08:00
InfinityPacer
8a4ca2482c fix(glass): stabilize optical rendering and page presentation (#595) 2026-07-28 10:41:41 +08:00
InfinityPacer
3daa031a04 fix(glass): stabilize optical surfaces and page transitions (#594) 2026-07-27 17:33:05 +08:00
jxxghp
15ef332ea2 fix(pwa): 面板打开时隐藏底部导航
底部导航 Teleport 到 body(z-index 1999),而主题定制器(2102)与
Agent 面板(2601)渲染在 VApp 内部。玻璃主题给 .v-application 设置了
position: relative + z-index: 1,形成层叠上下文,两个面板的数值被限制在
该层内部,无法压过 body 层的底部导航。

移动端这两个面板均为全屏,改为在根节点开关标记打开时隐藏底部导航。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:33:27 +08:00
InfinityPacer
57448ca609 fix(glass): load cross-origin wallpapers directly (#593) 2026-07-27 13:49:09 +08:00
InfinityPacer
0f7c95cfaf fix(glass): 收敛壁纸预加载与失效恢复 (#592)
* fix(glass): streamline wallpaper loading

* docs(glass): remove stale compatibility comment

* docs(glass): remove obsolete texture fallback note
2026-07-27 11:26:29 +08:00
InfinityPacer
3886909e9e feat(glass): unify login and app optical presentation (#591) 2026-07-27 10:26:46 +08:00
jxxghp
0f8a817413 fix tests 2026-07-27 10:20:30 +08:00
jxxghp
eb47545b76 fix #6199 2026-07-27 10:05:32 +08:00
jxxghp
6fc123e0f6 fix: simplify media key generation to prevent duplicates
- Refactor getMediaItemKey function with clear priority chain
- Replace complex nested ternary with readable if-else structure
- Ensure consistent key generation for same media items
- Prioritize tmdb_id > douban_id > bangumi_id > anilist_id > media_id

Fixes duplicate items in popular subscribe list
Related to jxxghp/MoviePilot#6199
2026-07-27 09:43:00 +08:00
jxxghp
5bdef2ba8f fix(transfer): 显示并确认重新整理 2026-07-26 08:58:49 +08:00
jxxghp
42ca5b8f94 fix(search): assemble batched SSE results (#6186) 2026-07-26 08:55:34 +08:00
InfinityPacer
ba48a25982 fix(glass): stabilize optical surfaces after scrolling (#590) 2026-07-25 19:14:52 +08:00
InfinityPacer
76c524eb9b fix(dashboard): show media empty and retry states (#589) 2026-07-25 12:57:28 +08:00
InfinityPacer
48f8e8fc71 fix(glass): reset renderer state on unmount (#588) 2026-07-25 10:44:51 +08:00
InfinityPacer
2a0f15c1bd test(subscribe): stabilize calendar and season coverage (#587) 2026-07-25 10:31:54 +08:00
InfinityPacer
e94ade364e test(subscribe): stabilize mobile calendar filter (#586) 2026-07-25 10:31:04 +08:00
InfinityPacer
27f6c33782 feat(glass): improve optical quality and controls (#585) 2026-07-25 10:30:29 +08:00
jxxghp
b3e596c761 fix(subscribe): preserve source identity across season flows 2026-07-25 08:51:30 +08:00
jxxghp
0ee9e466c3 fix(subscribe): improve season metadata display (#584) 2026-07-25 08:01:43 +08:00
jxxghp
5325d813b4 Restore clear theme navbar surface after scrolling 2026-07-24 18:57:29 +08:00
InfinityPacer
dc3f0abf9b fix(auth): separate passkey and two-step verification (#583) 2026-07-24 06:18:43 +08:00
InfinityPacer
e63fc9a8bd fix(theme): refine mobile glass controls (#582) 2026-07-24 06:18:08 +08:00
InfinityPacer
eb5366f129 fix(grid): refresh scroll target after overlay unlock (#581) 2026-07-23 23:18:25 +08:00
jxxghp
77c4a0f84c Add glass theme backdrop treatment to media details 2026-07-23 21:58:43 +08:00
InfinityPacer
1bf6c1f1d8 perf(glass): complete phase three optimization (#580) 2026-07-23 20:26:28 +08:00
jxxghp
31caf8f5f8 feat(plugin): expose native subscribe action (#502) 2026-07-23 17:29:36 +08:00
InfinityPacer
787076355b perf(app): add activity lifecycle management (#579) 2026-07-23 15:59:50 +08:00
jxxghp
4da764ddef Flatten glass-theme modal surfaces 2026-07-23 15:58:18 +08:00
InfinityPacer
590755169f feat(theme): refine glass and transparency settings (#578) 2026-07-23 14:06:46 +08:00
jxxghp
f7ef9dd116 Unify glass overlay material tokens across floating panels 2026-07-23 13:57:20 +08:00
jxxghp
4bc16eb220 Fix clear glass mobile overlays 2026-07-23 08:58:56 +08:00
jxxghp
032bda61f6 Fix mobile clear glass surfaces 2026-07-23 08:12:39 +08:00
jxxghp
91ce2c8aee Refine glass responsive surfaces 2026-07-23 07:51:49 +08:00
jxxghp
9b230d14b4 Lighten clear glass overlays 2026-07-23 07:19:00 +08:00
jxxghp
6201e01914 Fix glass overlay readability 2026-07-23 07:17:24 +08:00
jxxghp
441f2ecc06 Unify glass theme backdrop filters 2026-07-23 07:05:43 +08:00
InfinityPacer
1ed69ccc64 feat: add glass materials and quality profiles (#577) 2026-07-23 06:08:39 +08:00
zangse
80519e6839 fix(mobile): 修复移动端 TMDB 搜索图标不可见及弹窗交互问题 (#576) 2026-07-23 06:05:36 +08:00
zangse
b4be18e71f feat(calendar): add movie/TV series filter to subscription calendar (#575) 2026-07-23 06:04:55 +08:00
InfinityPacer
4a196731f1 perf(dashboard): restore additional cards from snapshots (#574) 2026-07-23 06:03:42 +08:00
jxxghp
b32a4e7c6a Optimize glass theme rendering performance 2026-07-22 22:48:25 +08:00
jxxghp
dc8becb41a Integrate logging and scheduler surfaces with glass theme 2026-07-22 22:39:11 +08:00
jxxghp
d2896d5936 Disable transitions that disrupt glass theme rendering 2026-07-22 22:13:13 +08:00
jxxghp
0315481eb6 Style mobile scheduler cards with glass theme 2026-07-22 21:18:47 +08:00
jxxghp
ca955b48e2 Fix glass theme shadows and slider overflow 2026-07-22 20:25:12 +08:00
jxxghp
19e93edca0 fix(glass-theme): adjust background filter and opacity for improved contrast 2026-07-22 20:10:17 +08:00
jxxghp
d039efbefe Brighten glass theme background visuals 2026-07-22 18:19:28 +08:00
InfinityPacer
103472404f perf(dashboard): restore media cards from snapshots (#573) 2026-07-22 17:55:54 +08:00
jxxghp
4405a69d59 fix: refine glass search field 2026-07-22 15:42:53 +08:00
jxxghp
c5f3a4db44 fix: honor selected glass theme color 2026-07-22 15:12:35 +08:00
jxxghp
b66bc353b3 fix: hide unsupported glass theme controls 2026-07-22 15:07:09 +08:00
jxxghp
17cdfe6885 feat: add glass theme 2026-07-22 15:00:11 +08:00
jxxghp
5cf0dc116a feat: add AniList browsing and source-aware search 2026-07-22 12:53:58 +08:00
InfinityPacer
c1bb54a31e ci(test): run frontend checks on v2 pushes (#572) 2026-07-22 06:59:27 +08:00
InfinityPacer
0fb5bbebc6 修复仪表盘首屏布局跳动与响应式错位 (#571) 2026-07-22 06:59:12 +08:00
jxxghp
9f375e8413 feat: 调整文件重命名输入框宽度 (#570)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-21 22:35:12 +08:00
jxxghp
1d7cfbfed8 fix: refine subscribe files dialog layout 2026-07-21 20:25:14 +08:00
jxxghp
0cc254c07d feat: propagate media source identities (#6129) 2026-07-21 20:21:17 +08:00
InfinityPacer
65d93b70e4 fix(pwa): coordinate service worker lifecycle (#569) 2026-07-21 17:30:08 +08:00
InfinityPacer
8dc36a2007 fix(pwa): isolate service worker development mode (#568) 2026-07-21 16:41:55 +08:00
jxxghp
42ba550bb4 feat: support manual media selection for file scraping 2026-07-21 12:55:14 +08:00
jxxghp
1aa8c9122d v2.15.0 2026-07-21 11:56:28 +08:00
jxxghp
225d1d2ec0 fix: complete media server field hints 2026-07-21 11:38:42 +08:00
jxxghp
00c0c86345 feat: support selectable anime metadata sources 2026-07-21 11:32:16 +08:00
jxxghp
eee83542df fix(scheduler): move mobile status to subtitle row 2026-07-21 11:25:15 +08:00
InfinityPacer
4da856fb6d fix(plugin): resolve remote entry URLs from document base (#566) 2026-07-20 15:05:58 +08:00
jxxghp
03126d32c1 feat(plugin): expose host toast to federated views 2026-07-20 12:35:14 +08:00
InfinityPacer
ad555b3b36 fix(site): preserve dialog failure states (#565) 2026-07-19 19:24:45 +08:00
InfinityPacer
7f8e6952df test(site): cover site user data dialog (#564) 2026-07-19 17:33:53 +08:00
InfinityPacer
2542339733 test(site): cover site resource dialog (#563) 2026-07-19 17:05:56 +08:00
InfinityPacer
0d094b01b5 test(site): cover site maintenance dialogs (#562) 2026-07-19 16:12:17 +08:00
InfinityPacer
47f007480f test(site): cover site card and icon cache (#561) 2026-07-19 12:39:53 +08:00
InfinityPacer
374a0866be test(site): cover site list flows (#560) 2026-07-19 08:57:44 +08:00
InfinityPacer
126a204b2a test(media): cover media detail flows (#559) 2026-07-18 20:46:38 +08:00
InfinityPacer
c491195b19 test(media): cover cards and virtual slides (#558) 2026-07-18 18:03:39 +08:00
InfinityPacer
395401e493 ci: check changed-file formatting (#557) 2026-07-18 16:48:25 +08:00
InfinityPacer
52479ebbd5 test(discover): cover source and media list flows (#556) 2026-07-18 15:41:40 +08:00
InfinityPacer
f6d8ab8aeb chore: add incremental Prettier tooling (#555) 2026-07-18 15:41:23 +08:00
jxxghp
67e78d0eb1 Fix transfer history mobile record layout 2026-07-18 11:45:12 +08:00
jxxghp
ef7f346e7a feat: 优化历史记录页面小屏幕显示 (#554)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-18 11:17:13 +08:00
jxxghp
707bfc87a1 feat: 优化历史记录页面小屏幕显示 (#553)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-18 10:58:06 +08:00
jxxghp
3416c879de feat: 优化历史记录页面小屏幕显示 (#552)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-18 10:42:08 +08:00
jxxghp
bbed5bf0ae feat: 优化历史记录页面小屏幕显示 (#551) 2026-07-18 10:24:46 +08:00
InfinityPacer
985874506e ci: add observational ESLint check (#550) 2026-07-18 10:24:00 +08:00
InfinityPacer
8434b75af3 chore: migrate frontend linting to ESLint 10 (#549) 2026-07-18 09:42:34 +08:00
InfinityPacer
8a1c10bbf7 test(recommend): cover source lifecycle and slides (#548) 2026-07-18 09:37:45 +08:00
jxxghp
e4e5bce234 更新 package.json 2026-07-18 08:06:59 +08:00
jxxghp
ae4f304cc2 feat: 优化小屏幕订阅日历界面 (#547) 2026-07-18 08:05:31 +08:00
秋澪Akimio
9a838647fe feat: 为词表编辑器增加可选行号与语法高亮 (#546) 2026-07-17 23:34:49 +08:00
InfinityPacer
435e9ecfdd test(subscribe): cover popular and share workflows (#545) 2026-07-17 19:54:24 +08:00
InfinityPacer
2b310c9a97 test(subscribe): cover season, files and history dialogs (#544) 2026-07-17 18:25:28 +08:00
InfinityPacer
7d2c98c5ae fix(layout): register cleanup hook synchronously (#543) 2026-07-17 18:25:05 +08:00
InfinityPacer
ff871e7049 feat(login): add hidden logo lab access (#542) 2026-07-17 16:47:11 +08:00
InfinityPacer
20f80d0f1f test(subscribe): cover subscription calendar (#541) 2026-07-17 16:14:24 +08:00
jxxghp
36bb99cf06 Improve plugin market filter menu layout 2026-07-17 15:45:41 +08:00
jxxghp
24c2e3b49c Fix login layout on short mobile screens 2026-07-17 14:47:19 +08:00
jxxghp
486a471c0c 更新 package.json 2026-07-17 14:18:55 +08:00
InfinityPacer
ce32da9176 fix(theme): preserve logo detail across theme colors (#540) 2026-07-17 14:18:35 +08:00
InfinityPacer
39dd1b9e00 feat: 让应用 Logo 跟随主题色 (#538)
* fix(login): show form with logo

* feat(theme): sync logos with primary color

* test(theme): cover favicon color sync

* chore(login): remove unused metal logo component

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-07-17 13:01:25 +08:00
InfinityPacer
807711cae8 test(subscribe): cover card actions (#539) 2026-07-17 12:57:28 +08:00
jxxghp
00feeb86ca Fix agent assistant layering and login card visibility 2026-07-17 12:53:01 +08:00
400 changed files with 61551 additions and 6953 deletions

View File

@@ -1,69 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: ['@antfu/eslint-config-vue', 'plugin:sonarjs/recommended'],
ignorePatterns: ['src/@iconify/*.js', 'node_modules', 'dist', '*.d.ts'],
plugins: ['regex'],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'sonarjs/no-duplicate-string': 'warn',
'vue/valid-v-slot': ['error', {
allowModifiers: true,
}],
// https://github.com/gmullerb/eslint-plugin-regex
'regex/invalid': [
'error',
[
{
regex: '@/assets/images',
replacement: '@images',
message: 'Use \'@images\' path alias for image imports',
},
{
regex: '@/styles',
replacement: '@styles',
message: 'Use \'@styles\' path alias for importing styles from \'src/styles\'',
},
// {
// id: 'Disallow icon of icon library',
// regex: 'tabler-\\w',
// message: 'Only \'mdi\' icons are allowed',
// },
{
regex: '@core/\\w',
message: 'You can\'t use @core when you are in @layouts module',
files: {
inspect: '@layouts/.*',
},
},
{
regex: 'useLayouts\\(',
message:
'`useLayouts` composable is only allowed in @layouts & @core directory. Please use `useThemeConfig` composable instead.',
files: {
inspect: '^(?!.*(@core|@layouts)).*',
},
},
],
// Ignore files
'.eslintrc.js',
],
},
settings: {
'import/resolver': {
node: {
extensions: ['.ts', '.js', '.tsx', '.jsx', '.mjs', '.png', '.jpg'],
},
typescript: {},
},
},
}

View File

@@ -4,16 +4,65 @@ 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:
unit-tests:
format:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '24'
cache: yarn
cache-dependency-path: yarn.lock
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Check changed-file formatting
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: yarn format:check --base "$BASE_SHA" --head "$HEAD_SHA"
lint:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '24'
cache: yarn
cache-dependency-path: yarn.lock
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Lint
run: yarn lint
typecheck-and-coverage:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:

1
.node-version Normal file
View File

@@ -0,0 +1 @@
24

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

View File

@@ -1,2 +1,8 @@
dist
node_modules
node_modules
# 自动生成文件的格式由各自生成器决定。
auto-imports.d.ts
components.d.ts
eslint-suppressions.json
/vite.config.*.timestamp-*.mjs

View File

@@ -3,7 +3,7 @@
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"bracketSameLine": false,
"jsxSingleQuote": true,
"printWidth": 120,
"proseWrap": "preserve",

View File

@@ -2,7 +2,7 @@
*中文 | [English](README_EN.md)*
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目,NodeJS版本>= `v20.12.1`
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目,最低支持 Node.js `20.19`,推荐使用 Node.js `24`
## 特性
@@ -49,6 +49,8 @@ yarn test:coverage
测试文件组织、共享测试设施、HTTP mock、覆盖率门禁和新增用例规范见[单元测试架构](docs/testing.md)。
ESLint、Prettier、Node 兼容范围及渐进式 CI 门禁见[前端代码质量工具链演进](docs/code-quality.md)。
### 静态运行
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件nginx配置参考 `public/nginx.conf`

179
docs/code-quality.md Normal file
View File

@@ -0,0 +1,179 @@
# 前端代码质量工具链演进
本文档定义 MoviePilot-Frontend 的代码质量工具链、运行入口和渐进式演进路线。目标是让人工开发者和自动化 Agent 都通过同一组项目命令完成格式化、静态检查、类型检查、测试和构建,并由 CI 在干净环境中复现结果;编辑器及其扩展只提供即时反馈,不作为项目行为的事实源。
## 工具职责
| 工具 | 职责 | 不负责 |
| ---------------------- | ------------------------------------------------------------ | -------------------------------------- |
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
| Vitest | 单元测试、组件测试和覆盖率门槛 | 生产构建与真实浏览器行为 |
| Vite | 生产构建和构建期集成验证 | 类型完整性和代码质量规则 |
ESLint 配置由仓库显式维护,不继承 Antfu 等覆盖面较大的个人风格预设。JavaScript、TypeScript 与 Vue 使用各自面向正确性的 recommended/essential 基线SonarJS 不整包展开 recommended而只显式启用安全和正确性的高信号规则避免插件升级隐式扩大检查范围。
历史规则迁移前必须核对当前源码和运行职责,不能仅因为旧配置出现过就写入 baseline。当前保留 Vuetify 点号 slot 兼容、`@layouts` 不反向依赖 `@core`、浏览器与 Node globals 分域及统一禁止 `debugger`。图片/样式 alias 强制只约束 import 拼写且已与当前实践脱节;`useLayouts` 目录限制所针对的 API 从未存在于源码;重复字符串属于高噪声风格治理;伪随机数限制会把界面效果误判为安全问题,因此这四类历史规则不再作为门禁。
Prettier 独立运行ESLint 中与格式重叠的规则保持关闭。任何编辑器都可以调用项目内 ESLint 和 Prettier但保存时是否自动执行不影响 CI 结果。
## Node 与依赖边界
- 最低兼容目标为 Node.js 20.19Node.js 20.19、22 和 24 的开发环境可以安装依赖并运行项目命令。
- 推荐开发环境和 CI 主环境使用 Node.js 24。
- CI 若继续声明兼容 Node.js 20.19,应至少在该版本验证 frozen lockfile 安装、lint、typecheck 和 build完整覆盖率门禁在 Node.js 24 执行。
- Yarn 1 与 `yarn.lock` 继续作为依赖安装事实源CI 使用 `yarn --frozen-lockfile`
- Node 只用于依赖安装、开发、测试和前端构建MoviePilot 正式 Docker 镜像使用预构建前端产物,不因开发工具链升级而增加 Node 运行时。
最低 Node 版本变化属于开发环境兼容性变更,必须独立说明并在 CI 验证。不得仅提高 `package.json``engines.node` 而不更新 README、版本文件和 workflow。
## 统一命令
工具链完成演进后,仓库提供以下入口:
```sh
yarn lint # 只读 ESLint 检查,不修改文件
yarn lint:fix # 显式执行 ESLint 安全修复
yarn lint:suppressions:prune # 修复存量问题后裁剪过期 baseline
yarn format # 格式化当前分支、暂存区、工作区和未跟踪文件中的受支持变更文件
yarn format:check # 检查相同变更文件,不修改文件
yarn format:all:check # 只读测量全仓格式收敛情况
yarn typecheck
yarn test:run
yarn test:coverage
yarn build
```
`yarn lint` 不得包含 `--fix`。CI 只运行只读命令,任何自动修改都由开发者或 Agent 在提交前显式执行。
## ESLint 演进:先恢复可执行门禁
ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业务代码修复。
第一阶段完成以下事项:
1. 使用 ESLint 10 flat config移除旧 `.eslintrc`
2. 不恢复已删除的 `@antfu/eslint-config-vue`
3. 显式组合 JavaScript、TypeScript、Vue 和经过审计的 SonarJS 规则。
4. 使用 ESLint 原生 AST 限制维护仍成立的模块边界,并删除只约束 import 拼写或已无现实对象的历史 regex 规则。
5. 只检查 JavaScript、TypeScript、Vue 与相关测试文件Markdown、JSON、YAML、TOML、构建产物和生成文件不在首阶段扩展范围内。
6. 默认 lint 只读,并提供单独的 `lint:fix`
7. 对确有价值但存在存量的问题使用明确基线或分阶段启用,不通过全仓自动修复制造大面积混合变更。
8. 项目边界与新增问题门禁使用 Vitest 配置契约测试保护,并随现有 `test:coverage` CI 自动执行。
`eslint-suppressions.json` 只冻结迁移时已经确认的文件、规则和数量。新增问题不得加入 baseline修复存量问题后运行 `yarn lint:suppressions:prune`,并把裁剪结果与代码修复一同提交。日常开发和 CI 不得使用 `--suppress-all`
迁移完成的最低验收为:
```sh
yarn --frozen-lockfile
yarn lint
yarn typecheck
yarn test:coverage
yarn build
```
若新配置导致大量与业务目标无关的格式、排序或额外文件类型问题,应停止扩大规则范围,先调整配置职责;不得以一次性自动修复数百个文件作为通过迁移的手段。
## ESLint CI先观察再强制
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job
1. lint 与 `typecheck-and-coverage` 使用不同 job保持静态检查和测试覆盖率职责独立。
2. 初始阶段作为普通 check 运行,不立即配置 required check。
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。
4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
5. 连续多个 PR 稳定通过后,再由维护者决定是否设为 required。
ESLint CI 必须检查全仓受管源码,而不是只检查 PR 变更文件。已有问题通过规则选择或受控基线管理,保证新问题不能借由“只检查改动行”绕过项目约束。
## Prettier 演进:改到即格式化
Prettier 采用与后端 `unittest → pytest` 相同的渐进原则:不为转换而转换,修改到存量文件时再纳入统一格式。
- **新文件**:必须通过当前 Prettier 配置。
- **修改文件**Agent 或开发者在提交前对本次触及的受支持文件运行 Prettier。
- **未修改存量文件**:暂时保持原样,不建立一次性全仓格式化任务。
- **大型或高冲突文件**:若格式化产生大量机械差异,格式调整与业务修改拆分为独立 commit必要时暂缓该文件并记录原因。
- **生成文件**:按生成器事实源管理,不直接用 Prettier 修补生成结果;需要统一格式时修改生成器或生成配置。
Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,并作为变更范围门禁运行。检查脚本应:
1. 从 PR base 与 head 计算文件集合。
2. 排除已删除文件、构建产物、覆盖率报告和生成文件。
3. 仅把 Prettier 支持且由仓库管理的文件传给 `prettier --check`
4. 文件集合为空时正常通过。
本地直接运行 `yarn format``yarn format:check` 时,脚本按 `refs/remotes/upstream/v2``refs/remotes/origin/v2``refs/heads/v2` 的顺序选择与当前提交具有共同祖先的基线并合并当前工作区与未跟踪文件。完整引用可避免同名标签造成歧义CI 使用显式提交 SHA 保证输入可复现:
```sh
yarn format:check --base <base-sha> --head <head-sha>
```
文件名通过 Git 的 NUL 分隔输出和 Node 参数数组传递,不经过 shell 字符串拼接重命名只检查新路径删除文件、ignore 文件和 Prettier 无法推断 parser 的文件不进入格式检查。
该门禁不要求未修改的存量文件通过 Prettier但所有进入 PR diff 的受支持文件都必须通过。Prettier 是文件级格式化工具因此修改存量文件时检查的是整个文件而不是仅检查变更行。Agent 或开发者应在提交前运行同一项目脚本完成格式化CI 只验证结果,不自动提交修改。
禁止在普通业务 PR 中直接运行 `prettier . --write` 或其他全仓格式写入命令。`yarn format` 只处理选择器返回的变更文件;单个存量文件被触及时仍可能整体重排,差异过大时应拆分 commit便于 reviewer 区分机械格式与业务逻辑。
具体交付拆分为:
1. Prettier 基础设施 PR安装项目 CLI定义配置、命令、变更文件选择脚本和回归测试不修改 workflow。
2. 变更文件门禁 PR复用已合并脚本接入 Pull Request workflow验证 fork PR、文件重命名、删除文件、空文件集和特殊文件名。初期作为普通 check 观察,稳定后再由维护者通过仓库规则独立配置为 required。
3. 全仓门禁 PR仅在存量收敛条件满足后择机实施不与变更文件门禁绑定。
## 全仓格式门禁的启用条件
不单独安排大爆炸式格式化阶段。存量通过日常“改到即格式化”逐步收敛,并定期执行全仓 `yarn format:all:check` 观察剩余范围。
Prettier 3.9.5 基础设施接入时,全仓只读检查在 `v2` 基线报告 203 个存量文件需要格式化。该数字是收敛起点而不是忽略白名单;变更文件仍必须完整通过检查,存量数量随日常修改逐步下降。
变更文件 CI 接入前在 `v2` 再次测量为 206 个文件,较基础设施合并后的 201 个文件出现回升。这说明仅提供本地命令不足以阻止新改动扩大存量;变更文件 check 用于守住新增 diff不因此要求在本阶段批量格式化已有文件。
变更文件 required check 不需要等待全仓收敛。只有同时满足以下条件,才把 Prettier 从变更文件 required check 切换为全仓 required check
- 全仓 `yarn format:all:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
- 最近的活跃分支已合并或完成同步,避免集中格式变化制造冲突。
- `yarn lint``yarn typecheck``yarn test:coverage``yarn build` 在格式收敛后全部通过。
- Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。
- workflow 已以非 required 状态稳定运行多个 PR。
最终 Pull Request 门禁目标为:
```sh
yarn --frozen-lockfile
yarn format:all:check
yarn lint
yarn typecheck
yarn test:coverage
yarn build
```
PR-Agent、编辑器诊断和人工 review 可以补充判断,但不能替代可重复执行的项目命令。
## Agent 与开发者提交规范
无论改动由 Agent 还是人工完成,提交前遵循同一顺序:
1. 只对本次新增或修改的文件执行 Prettier。
2. 运行全仓只读 lint。
3. 运行 typecheck。
4. 按影响面运行聚焦测试;提交 PR 前运行覆盖率门禁。
5. 涉及构建配置、依赖、Vue SFC 或模块联邦时运行生产 build。
编辑器设置不得成为通过验证的前提。文档、Agent 指令和 PR 说明应引用 `yarn` 命令,而不是“在 VS Code 保存一次”或依赖某个 IDE 扩展完成修复。
## 风险与回滚
| 风险 | 控制方式 | 回滚边界 |
| ---------------------------- | ------------------------------------------ | ------------------------------ |
| ESLint 自动修复改变语义 | 默认 lint 只读;基础设施迁移不批量 fix | 独立回退规则或依赖提交 |
| 格式化制造大面积冲突 | 改到即格式化;大型文件拆分机械 commit | 回退单文件格式 commit |
| 新规则让所有 PR 变红 | 先测量、再启用;使用受控基线 | 暂停单条规则,不回退整套工具链 |
| 检查范围扩展到生成物或文档 | 首阶段限制文件类型并显式 ignore | 收窄 flat config 文件匹配 |
| Node 最低版本阻断安装 | 最低保持 20.19Node 24 作为推荐和主 CI | 保持兼容 job版本提升独立处理 |
| 全仓 required check 配置过早 | 先强制变更文件,持续观察全仓剩余范围 | 保留变更文件门禁,暂缓全仓切换 |
| ESLint 与 Prettier 循环改写 | ESLint 关闭格式规则Prettier 单独负责格式 | 关闭冲突规则并增加回归样例 |
依赖迁移、规则迁移、存量代码修复、格式化和 GitHub required checks 应保持可独立评审、验证和回退,禁止合并成一个难以定位风险的大型变更。

View File

@@ -6,7 +6,6 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
关联阅读后端插件开发文档:[第三方插件开发说明](https://github.com/jxxghp/MoviePilot-Plugins/blob/main/README.md)
## 2. 技术要求
- Node.js 20+
@@ -18,13 +17,13 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
每个 Vue 联邦插件需要提供下列标准组件(`AppPage` 为可选,用于主界面侧栏全页入口):
| 组件名称 | 暴露名 | 文件名 | 用途 |
|---------|--------|--------|------|
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
| Config | `./Config` | Config.vue | 插件配置页面 |
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
| 组件名称 | 暴露名 | 文件名 | 用途 |
| --------- | ---------------- | ---------------------- | --------------------------------------------- |
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
| Config | `./Config` | Config.vue | 插件配置页面 |
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
主应用在侧栏全页路由中按 `nav_key` 解析暴露名(如 `AppPageSettings`),再回退 `AppPage``Page``nav_key``main` 时仅尝试 `AppPage``Page`
@@ -79,55 +78,52 @@ export default defineConfig({
singleton: true,
},
},
format: 'esm'
})
format: 'esm',
}),
],
build: {
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: true, // 改为true以便能分离样式文件
},
css: {
preprocessorOptions: {
scss: {
additionalData: '/* 覆盖vuetify样式 */',
}
},
},
postcss: {
plugins: [
{
postcssPlugin: 'internal:charset-removal',
AtRule: {
charset: (atRule) => {
charset: atRule => {
if (atRule.name === 'charset') {
atRule.remove();
atRule.remove()
}
}
}
},
},
},
{
postcssPlugin: 'vuetify-filter',
Root(root) {
// 过滤掉所有vuetify相关的CSS
root.walkRules(rule => {
if (rule.selector && (
rule.selector.includes('.v-') ||
rule.selector.includes('.mdi-'))) {
rule.remove();
if (rule.selector && (rule.selector.includes('.v-') || rule.selector.includes('.mdi-'))) {
rule.remove()
}
});
}
}
]
}
})
},
},
],
},
},
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001',
},
})
})
```
## 5. 组件开发规范
@@ -139,12 +135,16 @@ export default defineConfig({
// 自定义事件,用于通知主应用刷新数据
const emit = defineEmits(['action', 'switch', 'close'])
// 接收API对象
// 接收主应用能力
const props = defineProps({
api: {
type: Object,
default: () => {}
}
default: () => {},
},
nativeSubscribe: {
type: Function,
default: null,
},
})
// 页面逻辑代码...
@@ -179,20 +179,24 @@ function notifyClose() {
```vue
<script setup lang="ts">
// 接收初始配置和API对象
// 接收初始配置和主应用能力
const props = defineProps({
initialConfig: {
type: Object,
default: () => ({})
default: () => ({}),
},
api: {
type: Object,
default: () => {}
}
default: () => {},
},
nativeSubscribe: {
type: Function,
default: null,
},
})
// 配置数据
const config = ref({...props.initialConfig})
const config = ref({ ...props.initialConfig })
// 自定义事件,用于保存配置
const emit = defineEmits(['save', 'close', 'switch'])
@@ -217,7 +221,7 @@ function notifyClose() {
<div class="plugin-config">
<!-- 配置表单示例 -->
<v-text-field v-model="config.someField" label="配置项"></v-text-field>
<!-- 保存按钮示例 -->
<v-btn color="primary" @click="saveConfig">保存配置</v-btn>
@@ -234,16 +238,20 @@ function notifyClose() {
```vue
<script setup lang="ts">
// 接收配置刷新控制
// 接收配置刷新控制和主应用能力
const props = defineProps({
config: {
type: Object,
default: () => ({})
default: () => ({}),
},
allowRefresh: {
type: Boolean,
default: true
}
default: true,
},
nativeSubscribe: {
type: Function,
default: null,
},
})
// 仪表板逻辑...
@@ -276,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: '' },
})
@@ -300,18 +310,105 @@ const emit = defineEmits(['action'])
</template>
```
### 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 容器:
```vue
<script setup lang="ts">
import { inject } from 'vue'
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() {
toast?.success('保存成功')
}
</script>
```
可用方法与主项目 `vue-toastification` 一致,包括 `success``info``warning``error`。注入不存在时应静默降级,关键错误仍需保留页面内状态提示。
#### 后端:注册侧栏入口
插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致:
| 字段 | 说明 |
|------|------|
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
| `title` | 侧栏显示标题 |
| `icon` | MDI 图标名,如 `mdi-rss` |
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
| 字段 | 说明 |
| ------------ | ------------------------------------------------------------------------------------- |
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
| `title` | 侧栏显示标题 |
| `icon` | MDI 图标名,如 `mdi-rss` |
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
| `permission` | 可选:`subscribe` / `discovery` / `search` / `manage` / `admin`,与主应用菜单权限一致 |
| `order` | 可选:同组内排序,数值越小越靠前 |
| `order` | 可选:同组内排序,数值越小越靠前 |
```python
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
@@ -333,16 +430,16 @@ def get_sidebar_nav(self) -> List[Dict[str, Any]]:
前端加载远程组件的顺序为:
| `nav_key` | 依次尝试的联邦暴露名 |
|-----------|----------------------|
| `main` 或省略 | `./AppPage``./Page` |
| `nav_key` | 依次尝试的联邦暴露名 |
| -------------------------------- | ------------------------------------------------ |
| `main` 或省略 | `./AppPage``./Page` |
| 其它(如 `settings``my_tool` | `./AppPage{PascalCase}``./AppPage``./Page` |
`PascalCase` 规则:按 `-``_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings``my_tool``./AppPageMyTool`
**两种实现方式(二选一或混用):**
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
2. **多文件**:为某个入口单独暴露 `./AppPageSettings.vue` 等,主应用会优先加载对应模块,失败再回退到 `AppPage`
`vite.config` 多暴露示例:
@@ -365,8 +462,7 @@ yarn build
- 将生成的dist文件夹上传到插件后端目录下默认为`dist/assets`
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*``_plugin-vue_export-helper-*``remoteEntry.js`
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*``_plugin-vue_export-helper-*``remoteEntry.js`
- 在插件的后端python代码中实现以下方法来集成远程组件
@@ -380,7 +476,8 @@ def get_render_mode() -> Tuple[str, str]:
return "vue", "dist/assets"
```
- 需要在插件前端页面调用后端接口时通过传入的api模块发起调用后端api接口声明认证类型为`bear`
- 需要在插件前端页面调用后端接口时通过传入的api模块发起调用后端api接口声明认证类型为`bear`
```typescript
// 演示使用api模块调用插件接口
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
@@ -402,7 +499,6 @@ def get_api(self) -> List[Dict[str, Any]]:
]
```
## 7. 调试与排错
### 常见问题
@@ -461,7 +557,7 @@ yarn dev
## 9. 示例代码
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
- [模块联邦问题排查指南](./federation-troubleshooting.md) - 常见问题排查
## 10. 参考资料
@@ -471,4 +567,4 @@ yarn dev
---
如有问题请提交Issue。
如有问题请提交Issue。

View File

@@ -67,7 +67,7 @@ tests/
## 新增测试
1. 在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`
1. 业务测试在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`;工具链配置契约测试放在 `tests/config/`
2. 纯函数、store 和无渲染模块直接使用 VitestVue 组件使用标准渲染入口。
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
@@ -76,7 +76,7 @@ tests/
## 配置边界
Vitest 收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。
Vitest 收集 `src/**/__tests__/**/*.spec.ts``tests/config/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。配置契约测试随全量测试执行,但不加入业务源码覆盖率统计范围。
当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%Branches 不低于 75%。覆盖率报告写入 `coverage/`
@@ -86,9 +86,10 @@ Vitest 只收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue
yarn test # watch 模式
yarn test:run # 单次运行
yarn test:coverage # 单次运行并检查覆盖率
yarn test:lint-config # 聚焦执行 ESLint 配置契约测试
yarn typecheck
yarn lint
yarn build
```
Pull Request 测试工作流使用 Node 24 LTS 和 frozen lockfile依次执行类型检查和覆盖率门禁。现有 lint 基线问题按仓库当前维护约定单独处理,新增测试代码不得引入新的 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 或格式问题

1095
eslint-suppressions.json Normal file

File diff suppressed because it is too large Load Diff

195
eslint.config.js Normal file
View File

@@ -0,0 +1,195 @@
import js from '@eslint/js'
import sonarjs from 'eslint-plugin-sonarjs'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
const javascriptFiles = ['**/*.{js,mjs,cjs,jsx}']
const typescriptFiles = ['**/*.{ts,tsx,mts,cts}', '**/*.vue']
const managedScriptExtensions = 'js,mjs,cjs,jsx,ts,tsx,mts,cts'
const managedFiles = [...javascriptFiles, ...typescriptFiles]
const browserFiles = [
`src/**/*.{${managedScriptExtensions},vue}`,
`examples/**/src/**/*.{${managedScriptExtensions},vue}`,
]
const nodeFiles = ['**/*.config.{js,mjs,cjs,ts,mts,cts}', 'public/service.js', 'scripts/**/*.{js,mjs,cjs,ts,mts,cts}']
// TypeScript 关闭核心 no-undef显式禁止浏览器域中的 Node-only globals保证 JS、TS 与 Vue 使用同一运行时边界。
const browserGlobalNames = new Set(Object.keys(globals.browser))
const nodeOnlyGlobalRestrictions = Object.keys(globals.node)
.filter(name => !browserGlobalNames.has(name))
.sort()
.map(name => ({
name,
message: `'${name}' is only available in Node.js code.`,
}))
const restrictedCoreSourcePattern = '/(?:^|\\/)@core(?:\\/|$)/'
// @layouts 是 @core 的下游依赖;禁止反向引用,避免两层形成直接循环。
const layoutCoreImportRestrictions = [
'ImportDeclaration',
'ExportNamedDeclaration',
'ExportAllDeclaration',
'ImportExpression',
].map(nodeType => ({
selector: `${nodeType}[source.value=${restrictedCoreSourcePattern}]`,
message: "The '@layouts' module must not import from '@core'.",
}))
const nonLiteralLayoutDynamicImportRestriction = {
selector: "ImportExpression:not([source.type='Literal'])",
message: 'Dynamic imports in @layouts must use a literal path so module boundaries remain statically verifiable.',
}
const viteGlobCallSelectors = [
"CallExpression[callee.object.type='MetaProperty'][callee.object.meta.name='import'][callee.object.property.name='meta'][callee.property.name=/^glob(?:Eager)?$/]",
"CallExpression[callee.object.type='MetaProperty'][callee.object.meta.name='import'][callee.object.property.name='meta'][callee.property.value=/^glob(?:Eager)?$/]",
]
const layoutGlobRestrictions = viteGlobCallSelectors.map(callSelector => ({
selector: callSelector,
message:
'import.meta.glob is not allowed in @layouts because wildcard targets cannot be proven to stay within the module boundary.',
}))
const sonarRules = {
'sonarjs/array-callback-without-return': 'error',
'sonarjs/code-eval': 'error',
'sonarjs/empty-string-repetition': 'error',
'sonarjs/no-all-duplicated-branches': 'error',
'sonarjs/no-dead-store': 'error',
'sonarjs/no-duplicated-branches': 'error',
'sonarjs/no-element-overwrite': 'error',
'sonarjs/no-hardcoded-passwords': 'error',
'sonarjs/no-hardcoded-secrets': 'error',
'sonarjs/no-identical-conditions': 'error',
'sonarjs/no-identical-expressions': 'error',
'sonarjs/no-ignored-exceptions': 'error',
'sonarjs/no-unthrown-error': 'error',
'sonarjs/no-use-of-empty-return-value': 'error',
'sonarjs/reduce-initial-value': 'error',
'sonarjs/slow-regex': 'error',
'sonarjs/stateful-regex': 'error',
'sonarjs/super-linear-regex': 'error',
}
const typescriptConfigs = tseslint.configs.recommended.map(config => ({
...config,
files: typescriptFiles,
}))
const vueConfigs = pluginVue.configs['flat/essential'].map(config => ({
...config,
files: ['**/*.vue'],
}))
export default defineConfig([
globalIgnores([
'**/node_modules/**',
'**/dist/**',
'**/dev-dist/**',
'**/coverage/**',
'**/.worktrees/**',
'**/vite.config.*.timestamp-*.mjs',
'public/plugin_icon/**',
'src/@iconify/**',
'**/*.d.ts',
]),
{
...js.configs.recommended,
name: 'moviepilot/javascript',
files: managedFiles,
},
...typescriptConfigs,
...vueConfigs,
{
name: 'moviepilot/vue-contracts',
files: ['**/*.vue'],
rules: {
'vue/multi-word-component-names': 'off',
'vue/valid-v-slot': [
'error',
{
allowModifiers: true,
},
],
},
},
{
name: 'moviepilot/language-options',
files: managedFiles,
linterOptions: {
reportUnusedDisableDirectives: 'off',
},
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
parser: tseslint.parser,
},
},
},
{
name: 'moviepilot/browser-globals',
files: browserFiles,
languageOptions: {
globals: globals.browser,
},
rules: {
'no-restricted-globals': ['error', ...nodeOnlyGlobalRestrictions],
},
},
{
name: 'moviepilot/node-globals',
files: nodeFiles,
languageOptions: {
globals: globals.node,
},
// Node 配置可能位于浏览器源码目录;显式覆盖浏览器专用限制,避免 flat config 合并后误报。
rules: {
'no-restricted-globals': 'off',
},
},
{
name: 'moviepilot/sonarjs',
files: managedFiles,
plugins: {
sonarjs,
},
rules: sonarRules,
},
{
name: 'moviepilot/sonarjs-locales',
files: [`src/locales/**/*.{${managedScriptExtensions}}`],
rules: {
'sonarjs/no-hardcoded-passwords': 'off',
'sonarjs/no-hardcoded-secrets': 'off',
},
},
{
name: 'moviepilot/source-hygiene',
files: managedFiles,
rules: {
'no-debugger': 'error',
},
},
{
name: 'moviepilot/layout-boundary',
files: [`src/@layouts/**/*.{${managedScriptExtensions},vue}`],
rules: {
'no-restricted-syntax': [
'error',
...layoutCoreImportRestrictions,
...layoutGlobRestrictions,
nonLiteralLayoutDynamicImportRestriction,
],
},
},
])

1450
index.html

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,28 @@
{
"name": "moviepilot",
"version": "2.14.4",
"version": "2.15.3",
"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",
"test:coverage": "vitest run --coverage",
"test:lint-config": "vitest run tests/config/eslint-config.spec.ts",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix",
"lint:suppressions:prune": "eslint . --prune-suppressions",
"format": "node scripts/format-changed.mjs --write",
"format:check": "node scripts/format-changed.mjs --check",
"format:all:check": "prettier . --check",
"test:format-config": "vitest run tests/config/format-changed.spec.ts tests/config/frontend-workflow.spec.ts",
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
"postinstall": "npm run build:icons",
"pkg": "pkg . -t node18-win-x64 -o MoviePilot-Frontend.exe"
@@ -82,6 +91,7 @@
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@iconify-json/line-md": "^1.2.13",
"@iconify-json/lucide": "^1.2.85",
"@iconify-json/material-symbols": "^1.2.51",
@@ -107,26 +117,22 @@
"@types/qrcode": "^1.5.6",
"@types/three": "^0.185.1",
"@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitest/coverage-v8": "3.2.7",
"@vue/compiler-dom": "3.5.13",
"@vue/test-utils": "2.4.11",
"autoprefixer": "^10.4.14",
"eslint": "^9.18.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-regex": "^1.10.0",
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-vue": "^9.12.0",
"eslint": "^10.7.0",
"eslint-plugin-sonarjs": "^4.2.0",
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.3.0",
"jsdom": "26.1.0",
"msw": "2.15.0",
"postcss": "^8.5.1",
"postcss-html": "^1.5.0",
"prettier": "^3.9.5",
"sharp": "^0.33.5",
"stylelint": "^16.13.2",
"stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-config-standard-scss": "^14.0.0",
@@ -134,6 +140,7 @@
"terser": "^5.36.0",
"type-fest": "^4.15.0",
"typescript": "^5.0.4",
"typescript-eslint": "^8.64.0",
"unplugin-auto-import": "^19.0.0",
"unplugin-vue-components": "^28.0.0",
"unplugin-vue-define-options": "^1.5.3",
@@ -144,10 +151,14 @@
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.4",
"vitest": "3.2.7",
"vue-eslint-parser": "^10.4.1",
"vue-shepherd": "^4.1.0",
"vue-tsc": "^2.0.10",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0"
},
"packageManager": "yarn@1.22.18"
"packageManager": "yarn@1.22.18",
"engines": {
"node": ">=20.19"
}
}

View File

@@ -7,7 +7,7 @@
<link rel="icon" href="/favicon.ico">
<style>
:root {
--primary-color: #9155FD;
--primary-color: #8D51F9;
--surface-color: #FFFFFF;
--text-color: #333333;
--border-color: rgba(0, 0, 0, 0.12);
@@ -52,7 +52,7 @@
width: 120px;
height: 120px;
margin: 0 auto 32px;
background: rgba(145, 85, 253, 0.1);
background: rgba(141, 81, 249, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
@@ -100,7 +100,7 @@
gap: 8px;
margin-top: 24px;
padding: 8px 16px;
background: rgba(145, 85, 253, 0.1);
background: rgba(141, 81, 249, 0.1);
border-radius: 20px;
font-size: 0.875rem;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 16 KiB

208
scripts/format-changed.mjs Normal file
View File

@@ -0,0 +1,208 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, lstatSync } from 'node:fs'
import { resolve } from 'node:path'
import process from 'node:process'
import { fileURLToPath, pathToFileURL } from 'node:url'
import * as prettier from 'prettier'
const DEFAULT_BASE_REFS = ['refs/remotes/upstream/v2', 'refs/remotes/origin/v2', 'refs/heads/v2']
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024
const PRETTIER_ARGUMENT_BUDGET = 24_000
const prettierCliPath = fileURLToPath(new URL('../node_modules/prettier/bin/prettier.cjs', import.meta.url))
/** 解析 Git 的 NUL 分隔输出,保留文件名中的空格、换行和通配符字符。 */
function parseNullDelimited(output) {
return output.toString('utf8').split('\0').filter(Boolean)
}
/** 运行只返回仓库相对路径的 Git 查询。 */
function gitPaths(cwd, args) {
return parseNullDelimited(
execFileSync('git', args, {
cwd,
encoding: 'buffer',
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
}),
)
}
/** 尝试把引用解析为提交 SHA完整引用可避免分支与标签同名时产生歧义。 */
function tryResolveCommit(cwd, ref) {
const result = spawnSync('git', ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], {
cwd,
encoding: 'utf8',
})
return result.status === 0 ? result.stdout.trim() : undefined
}
/** 把外部引用解析为提交 SHA后续 Git diff 不再解释用户提供的选项样式字符串。 */
function resolveCommit(cwd, ref) {
const commit = tryResolveCommit(cwd, ref)
if (commit) return commit
throw new Error(`无法解析 Git 提交引用:${ref}`)
}
/** 判断两个提交能否进行三点比较。 */
function hasMergeBase(cwd, baseCommit, headCommit) {
return spawnSync('git', ['merge-base', baseCommit, headCommit], { cwd, stdio: 'ignore' }).status === 0
}
/** 为本地命令选择与当前提交具有共同祖先的 v2 基线。 */
function resolveDefaultBase(cwd, headCommit) {
for (const ref of DEFAULT_BASE_REFS) {
const commit = tryResolveCommit(cwd, ref)
if (commit && hasMergeBase(cwd, commit, headCommit)) return commit
}
throw new Error('无法定位可比较的 v2 基线,请通过 --base <ref> 显式指定比较起点。')
}
/**
* 收集当前分支相对基线的文件集合。
* 显式 base/head 模式只读取提交区间,默认本地模式还会合并暂存区、工作区和未跟踪文件。
*/
function collectChangedPaths({ cwd, base, head }) {
if (Boolean(base) !== Boolean(head)) {
throw new Error('--base 与 --head 必须一起使用。')
}
const explicitRange = Boolean(base)
const headCommit = resolveCommit(cwd, head ?? 'HEAD')
const baseCommit = base ? resolveCommit(cwd, base) : resolveDefaultBase(cwd, headCommit)
if (!hasMergeBase(cwd, baseCommit, headCommit)) {
throw new Error('Git 比较起点与终点没有共同祖先,无法执行三点比较。')
}
const paths = new Set(
gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', `${baseCommit}...${headCommit}`]),
)
if (!explicitRange) {
for (const filePath of gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', 'HEAD'])) {
paths.add(filePath)
}
for (const filePath of gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z'])) {
paths.add(filePath)
}
}
return [...paths]
.filter(filePath => {
const absolutePath = resolve(cwd, filePath)
if (!existsSync(absolutePath)) return false
try {
return lstatSync(absolutePath).isFile()
} catch {
return false
}
})
.sort()
}
/** 使用仓库 ignore 文件和 Prettier parser 推断筛出真正受格式化工具管理的文件。 */
async function selectPrettierFiles(filePaths, cwd) {
const ignorePaths = ['.gitignore', '.prettierignore'].map(filePath => resolve(cwd, filePath)).filter(existsSync)
const selected = []
for (const filePath of filePaths) {
const absolutePath = resolve(cwd, filePath)
const fileInfo = await prettier.getFileInfo(absolutePath)
if (!fileInfo.inferredParser) continue
let ignored = false
for (const ignorePath of ignorePaths) {
if ((await prettier.getFileInfo(absolutePath, { ignorePath })).ignored) {
ignored = true
break
}
}
if (!ignored) selected.push(filePath)
}
return selected
}
/** 按保守的参数长度预算拆批,避免大型 PR 超过操作系统命令行上限。 */
function batchPaths(filePaths) {
const batches = []
let batch = []
let length = 0
for (const filePath of filePaths) {
if (batch.length > 0 && length + filePath.length + 1 > PRETTIER_ARGUMENT_BUDGET) {
batches.push(batch)
batch = []
length = 0
}
batch.push(filePath)
length += filePath.length + 1
}
if (batch.length > 0) batches.push(batch)
return batches
}
/** 通过参数数组调用项目内 Prettier避免 shell 展开或拼接特殊文件名。 */
function runPrettier(filePaths, mode, cwd) {
if (filePaths.length === 0) {
console.log('没有需要 Prettier 处理的变更文件。')
return
}
for (const batch of batchPaths(filePaths)) {
const result = spawnSync(process.execPath, [prettierCliPath, mode, '--ignore-unknown', '--', ...batch], {
cwd,
stdio: 'inherit',
})
if (result.status !== 0) {
throw new Error(`Prettier ${mode} 失败,退出码:${result.status ?? 'unknown'}`)
}
}
}
/** 解析面向开发者和 CI 的稳定命令行契约。 */
function parseArguments(args) {
const options = { action: undefined, base: undefined, head: undefined }
for (let index = 0; index < args.length; index += 1) {
const argument = args[index]
if (['--check', '--write', '--list'].includes(argument)) {
if (options.action) throw new Error('只能指定 --check、--write 或 --list 中的一种操作。')
options.action = argument
continue
}
if (argument === '--base' || argument === '--head') {
const value = args[index + 1]
if (!value) throw new Error(`${argument} 缺少引用值。`)
options[argument.slice(2)] = value
index += 1
continue
}
throw new Error(`未知参数:${argument}`)
}
if (!options.action) throw new Error('必须指定 --check、--write 或 --list。')
return options
}
/** 执行变更文件选择与格式化;显式引用模式为 CI 提供确定输入。 */
async function main() {
const cwd = process.cwd()
const { action, base, head } = parseArguments(process.argv.slice(2))
const changedPaths = collectChangedPaths({ cwd, base, head })
const selectedPaths = await selectPrettierFiles(changedPaths, cwd)
if (action === '--list') {
process.stdout.write(`${JSON.stringify(selectedPaths)}\n`)
return
}
runPrettier(selectedPaths, action, cwd)
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
main().catch(error => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}

View 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
View 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' }],
}
},
},
}
}

View 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 }
]

View File

@@ -96,7 +96,8 @@
// 👉 Nav Link
.nav-link {
overflow: hidden;
// 交互项已预留下边距承接激活阴影,父级不能再次裁切这段视觉溢出。
overflow: visible;
> :first-child {
@extend %vertical-nav-item;

View File

@@ -33,11 +33,3 @@ $ps-track-size: 0.5rem;
.ps__rail-y:hover > .ps__thumb-y {
inline-size: $ps-hover-size;
}
// fix bug
@media(hover: none) {
.ps > .ps__rail-x,
.ps > .ps__rail-y {
opacity: 0.6;
}
}

View 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)
})
})

View 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
}
}

View File

@@ -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)
}
})
}

View File

@@ -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')
}
}

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { Component } from 'vue'
import { useDisplay } from 'vuetify'
import logo from '@images/logo.svg?raw'
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
interface Props {
tag?: string | Component
@@ -51,10 +51,10 @@ function handleNavScroll(evt: Event) {
<div class="nav-header">
<slot name="nav-header">
<RouterLink to="/" class="app-logo d-flex align-center app-title-wrapper">
<div class="d-flex" v-html="logo" />
<ThemeLogoMark />
<h1 class="font-weight-bold leading-normal text-xl">
MOVIEPILOT <span class="text-sm text-gray-500">v2</span>
<h1 class="leading-normal text-xl">
<span class="moviepilot-wordmark">MOVIEPILOT</span> <span class="text-sm text-gray-500">v2</span>
</h1>
</RouterLink>
</slot>

View File

@@ -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,
],
)
}
},
@@ -193,7 +213,7 @@ export default defineComponent({
// 单独提升顶栏图层可避免导航栏短暂上移到安全区下方。
backface-visibility: hidden;
block-size: var(--layout-navbar-block-size);
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
inline-size: calc(100% - variables.$layout-vertical-nav-width);
inset-block-start: 0;
transform: translate3d(0, 0, 0);
@@ -257,7 +277,7 @@ export default defineComponent({
}
&.layout-vertical-nav-collapsed .layout-navbar {
inline-size: calc(100vw - variables.$layout-vertical-nav-collapsed-width - 0.5rem);
inline-size: calc(100% - variables.$layout-vertical-nav-collapsed-width);
}
&.layout-vertical-nav-collapsed .layout-vertical-nav:not(.overlay-nav) {
@@ -273,16 +293,7 @@ export default defineComponent({
transform: none !important;
}
.app-logo > div {
display: flex;
overflow: hidden;
align-items: center;
justify-content: center;
block-size: 2.75rem;
inline-size: 2.75rem;
}
.app-logo svg {
.app-logo .theme-logo-mark {
block-size: 2.5rem;
inline-size: 2.5rem;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
import ace from 'ace-builds'
import { describe, expect, it } from 'vitest'
import '@/ace-config'
interface AceToken {
type: string
value: string
}
interface WordListSyntaxMode {
getTokenizer: () => {
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
}
}
const WordListSyntaxMode = ace.require('ace/mode/word_list_syntax').Mode as new () => WordListSyntaxMode
const tokenizer = new WordListSyntaxMode().getTokenizer()
function tokenize(line: string) {
return tokenizer.getLineTokens(line, 'start').tokens
}
describe('word list syntax mode', () => {
it('highlights a block word as one field', () => {
expect(tokenize('屏蔽词')).toEqual([{ type: 'word_list_block', value: '屏蔽词' }])
})
it('separates replaced and replacement fields without parsing their content', () => {
expect(tokenize('旧名.* => 新名 {[tmdbid=123;type=tv]}')).toEqual([
{ type: 'word_list_replaced', value: '旧名.*' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 {[tmdbid=123;type=tv]}' },
])
})
it('separates front, back, and episode offset fields', () => {
expect(tokenize('第 <> 集 >> EP+1')).toEqual([
{ type: 'word_list_front', value: '第' },
{ type: 'keyword.operator.word-list', value: ' <> ' },
{ type: 'word_list_back', value: '集' },
{ type: 'keyword.operator.word-list', value: ' >> ' },
{ type: 'word_list_offset', value: 'EP+1' },
])
})
it('uses all six field types in combined and standalone rules', () => {
const combinedTokens = tokenize('旧名 => 新名 && 第 <> 集 >> 2*EP-1')
expect(combinedTokens).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名' },
{ type: 'keyword.operator.word-list', value: ' && ' },
{ type: 'word_list_front', value: '第' },
{ type: 'keyword.operator.word-list', value: ' <> ' },
{ type: 'word_list_back', value: '集' },
{ type: 'keyword.operator.word-list', value: ' >> ' },
{ type: 'word_list_offset', value: '2*EP-1' },
])
expect(tokenize('屏蔽词')[0].type).toBe('word_list_block')
})
})

View File

@@ -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(
{
@@ -578,6 +578,90 @@ function registerWordListMode() {
exports.Mode = Mode
},
)
aceModule.define?.(
'ace/mode/word_list_syntax_highlight_rules',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
const WordListSyntaxHighlightRules = function (this: any) {
this.$rules = {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
{
token: [
'text',
'word_list_replaced',
'keyword.operator.word-list',
'word_list_replacement',
'keyword.operator.word-list',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_replaced', 'keyword.operator.word-list', 'word_list_replacement', 'text'],
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
},
{
token: [
'text',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_block', 'text'],
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
},
],
}
this.normalizeRules()
}
oop.inherits(WordListSyntaxHighlightRules, TextHighlightRules)
exports.WordListSyntaxHighlightRules = WordListSyntaxHighlightRules
},
)
aceModule.define?.(
'ace/mode/word_list_syntax',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_syntax_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextMode = require('./text').Mode
const WordListSyntaxHighlightRules = require('./word_list_syntax_highlight_rules').WordListSyntaxHighlightRules
const Mode = function (this: any) {
TextMode.call(this)
this.HighlightRules = WordListSyntaxHighlightRules
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.$id = 'ace/mode/word_list_syntax'
}).call(Mode.prototype)
exports.Mode = Mode
},
)
}
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)

View File

@@ -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
// 季号
@@ -114,6 +132,14 @@ export interface SubscribeShare {
tmdbid?: number
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: number
// AniList ID
anilistid?: number
// 主媒体数据源
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string
// 季号
season?: number
// 海报
@@ -216,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
@@ -234,9 +268,67 @@ export interface TransferHistory {
src_fileitem?: FileItem
}
// 下载历史记录
export interface DownloadHistory {
// ID
id: number
// 保存路径
path?: string
// 类型:电影、电视剧
type?: string
// 标题
title?: string
// 年份
year?: string
// TMDB ID
tmdbid?: number
// IMDB ID
imdbid?: string
// TVDB ID
tvdbid?: number
// 豆瓣 ID
doubanid?: string
// Bangumi ID
bangumiid?: number
// AniList ID
anilistid?: number
// 媒体数据源
media_source?: MediaDataSource
// 数据源原生 ID
media_id?: string
// 季 Sxx
seasons?: string
// 集 Exx
episodes?: string
// 海报或背景图
image?: string
// 下载器 Hash
download_hash?: string
// 种子名称
torrent_name?: string
// 种子描述
torrent_description?: string
// 站点
torrent_site?: string
// 下载用户 ID
userid?: string
// 下载用户名或插件名
username?: string
// 下载渠道
channel?: string
// 创建时间
date?: string
// 附加信息
note?: unknown
// 自定义媒体类别
media_category?: string
// 自定义剧集组
episode_group?: string
}
// 媒体信息
export interface MediaInfo {
// 来源themoviedb、douban、bangumi
// 来源themoviedb、douban、bangumi、anilist
source?: string
// 类型 电影、电视剧、合集
type?: string
@@ -257,7 +349,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前缀
@@ -414,7 +510,7 @@ export interface TmdbEpisode {
// TMDB人物信息
export interface Person {
// 来源themoviedb、douban、bangumi
// 来源themoviedb、douban、bangumi、anilist
source?: string
// ID
id?: number
@@ -668,6 +764,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 可安装版本
@@ -962,7 +1072,7 @@ export interface User {
is_superuser: boolean
// 头像
avatar: string
// 是否开启双重验证
// 是否开启二次验证
is_otp: boolean
// 用户权限 json
permissions: { [key: string]: any }
@@ -1351,6 +1461,8 @@ export interface MediaServerConf {
enabled: boolean
// 同步媒体体库列表
sync_libraries?: string[]
// 自动同步间隔(小时),为空时使用旧全局配置
sync_interval?: number | null
}
// 文件整理目录配置
@@ -1511,6 +1623,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
// 类型
@@ -1539,6 +1659,8 @@ export interface TransferForm {
episode_group?: string | null
// 预览模式
preview?: boolean
// 清理已有成功记录后重新整理
reorganize: boolean
}
// 手动整理请求
@@ -1565,6 +1687,14 @@ export interface ManualTransferTargetPathData {
library_category_folder?: boolean | null
}
// 手动整理命中的成功历史摘要
export interface ManualTransferHistoryInfo {
// 是否应执行重新整理
reorganize: boolean
// 命中的成功历史数量
history_count: number
}
// 手动整理预览统计
export interface ManualTransferPreviewSummary {
// 总数
@@ -1781,6 +1911,10 @@ export interface RecognitionCacheData {
recognized: number
// 未识别数量
unrecognized: number
// 共享识别成功命中数量
shared_recognized: number
// 是否开启共享识别
shared_recognize_enabled: boolean
// 缓存数据
data: RecognitionCacheItem[]
}

View File

@@ -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)
@@ -1539,20 +1570,21 @@ defineExpose({
.agent-assistant-fab {
position: fixed;
/* 保持高于菜单浮层,但低于 agent 会话面板2101)。 */
z-index: 2100;
/* 保持机器人和提示气泡高于 Vuetify 弹窗2400及全局 Toast2500)。 */
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);
}

View File

@@ -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,9 +2098,11 @@ 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')"
@focusin.stop
>
<div class="agent-assistant-shell">
<header class="agent-assistant-header">
@@ -1978,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>
@@ -1990,7 +2134,7 @@ onScopeDispose(() => {
location="bottom end"
offset="8"
max-width="360"
:z-index="2103"
:z-index="2603"
>
<template #activator="{ props }">
<IconBtn v-bind="props" :title="t('agentAssistant.history')" :aria-label="t('agentAssistant.history')">
@@ -2028,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">
@@ -2042,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)"
@@ -2063,7 +2207,7 @@ onScopeDispose(() => {
</VCard>
</VMenu>
<IconBtn
:disabled="sending"
:disabled="isBusy"
:title="t('agentAssistant.newChat')"
:aria-label="t('agentAssistant.newChat')"
@click="startNewSession"
@@ -2146,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 }}
@@ -2257,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)"
@@ -2289,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"
@@ -2306,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"
@@ -2331,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>
@@ -2346,7 +2490,7 @@ onScopeDispose(() => {
<style lang="scss">
.agent-assistant-history-overlay {
z-index: 2103 !important;
z-index: 2603 !important;
}
</style>
@@ -2356,7 +2500,9 @@ onScopeDispose(() => {
.agent-assistant-panel {
position: fixed;
z-index: 2101;
/* Agent 会话层保持高于入口2600和业务弹窗同时低于自身弹出菜单。 */
z-index: 2601;
overflow: hidden;
background: rgb(var(--v-theme-surface));
@@ -2421,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;
@@ -2485,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;
@@ -2512,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;
}

View File

@@ -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>

View 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()
})
})

View 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()
})
})

View File

@@ -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)
})
})

View File

@@ -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% {

View 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>

View 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)
})
})

View File

@@ -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);
}

View 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()
})
})

View File

@@ -5,7 +5,7 @@ import { nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { storageRemoteDict } from '@/api/constants'
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253'
const DEFAULT_DIRECTORY_ACCENT_RGB = '141, 81, 249'
const STORAGE_ACCENT_COLOR_MAP = {
local: '#FFB400',
alipan: '#00A7F2',

View File

@@ -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>

View File

@@ -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>

View File

@@ -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),

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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>

View File

@@ -6,7 +6,9 @@ import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
const PluginFolderRenameDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderRenameDialog.vue'))
const PluginFolderSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderSettingsDialog.vue'))
const PluginFolderSettingsDialog = defineAsyncComponent(
() => import('@/components/dialog/PluginFolderSettingsDialog.vue'),
)
// 文件夹配置接口
interface FolderConfig {
@@ -60,7 +62,7 @@ const defaultColor = '#2196F3'
const defaultIcon = 'mdi-folder'
// 默认渐变
const defaultGradient =
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8s) 100%)'
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)'
// 计算背景图片
const backgroundImage = computed(() => {
@@ -166,7 +168,6 @@ function showSettingDialog() {
// 保存设置
function saveSettings(config: FolderConfig) {
emit('update-config', props.folderName, config)
$toast.success(t('folder.folderSettingsSaved'))
}
onUnmounted(() => {
@@ -226,69 +227,72 @@ const dropdownItems = ref([
'plugin-folder-card--sortable': props.sortable,
}"
>
<template v-if="backgroundImage" #image>
<VImg :src="backgroundImage" cover position="top"> </VImg>
</template>
<template v-if="backgroundImage" #image>
<VImg :src="backgroundImage" cover position="top"> </VImg>
</template>
<!-- 背景遮罩当有背景图片时 -->
<div v-if="backgroundImage" class="plugin-folder-card__overlay" />
<!-- 背景遮罩当有背景图片时 -->
<div v-if="backgroundImage" class="plugin-folder-card__overlay" />
<!-- 背景渐变层 -->
<div v-else class="plugin-folder-card__bg" :style="{ background: backgroundGradient }" />
<!-- 背景渐变层 -->
<div v-else class="plugin-folder-card__bg" :style="{ background: backgroundGradient }" />
<!-- 卡片内容 -->
<div class="plugin-folder-card__content">
<!-- 主体内容 -->
<div class="plugin-folder-card__body" :class="{ 'plugin-folder-card__body--no-icon': !shouldShowIcon }">
<!-- 文件夹图标 -->
<div v-if="shouldShowIcon" class="plugin-folder-card__icon-container">
<VIcon
:icon="folderIcon"
:size="display.mobile ? 56 : 72"
:color="iconColor"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
/>
<!-- 卡片内容 -->
<div class="plugin-folder-card__content">
<!-- 主体内容 -->
<div class="plugin-folder-card__body" :class="{ 'plugin-folder-card__body--no-icon': !shouldShowIcon }">
<!-- 文件夹图标 -->
<div v-if="shouldShowIcon" class="plugin-folder-card__icon-container">
<VIcon
:icon="folderIcon"
:size="display.mobile ? 56 : 72"
:color="iconColor"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
/>
</div>
<!-- 文件夹信息 -->
<div
class="plugin-folder-card__info"
:class="{
'cursor-move': props.sortable && display.mdAndUp.value,
'plugin-folder-card__info--no-icon': !shouldShowIcon,
}"
>
<!-- 文件夹名称 -->
<h3 class="plugin-folder-card__name">
{{ props.folderName }}
</h3>
<!-- 插件数量 -->
<p class="plugin-folder-card__count">{{ t('folder.pluginCount', { count: props.pluginCount }) }}</p>
</div>
</div>
<!-- 文件夹信息 -->
<div
class="plugin-folder-card__info"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
>
<!-- 文件夹名称 -->
<h3 class="plugin-folder-card__name">
{{ props.folderName }}
</h3>
<!-- 插件数量 -->
<p class="plugin-folder-card__count">{{ t('folder.pluginCount', { count: props.pluginCount }) }}</p>
<!-- 更多菜单按钮 - 右下角 -->
<div v-if="!props.sortable" class="absolute top-0 right-0">
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
<template #activator="{ props: menuProps }">
<IconBtn v-bind="menuProps" @click.stop>
<VIcon size="small" icon="mdi-dots-vertical" class="text-white" />
</IconBtn>
</template>
<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" size="16" />
</template>
<VListItemTitle class="text-body-2">{{ item.title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</div>
</div>
<!-- 更多菜单按钮 - 右下角 -->
<div v-if="!props.sortable" class="absolute top-0 right-0">
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
<template #activator="{ props: menuProps }">
<IconBtn v-bind="menuProps" @click.stop>
<VIcon size="small" icon="mdi-dots-vertical" class="text-white" />
</IconBtn>
</template>
<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" size="16" />
</template>
<VListItemTitle class="text-body-2">{{ item.title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</div>
</div>
</VCard>
</div>
</template>

View File

@@ -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>

View File

@@ -4,7 +4,7 @@ import { getLogoUrl } from '@/utils/imageUtils'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import type { ApiResponse, Site, SiteStatistic, SiteUserData } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import { formatFileSize } from '@/@core/utils/formatters'
import { useConfirm } from '@/composables/useConfirm'
@@ -79,17 +79,17 @@ async function testSite() {
testButtonText.value = t('site.testing')
testButtonDisable.value = true
const result: { [key: string]: any } = await api.get(`site/test/${cardProps.site?.id}`)
const result = (await api.get(`site/test/${cardProps.site?.id}`)) as ApiResponse<unknown>
if (result.success) $toast.success(t('site.testSuccess', { name: cardProps.site?.name }))
else $toast.error(t('site.testFailed', { name: cardProps.site?.name, message: result.message }))
testButtonText.value = t('site.testConnectivity')
testButtonDisable.value = false
// 测试完成后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
} catch (error) {
console.error(error)
} finally {
testButtonText.value = t('site.testConnectivity')
testButtonDisable.value = false
}
}
@@ -166,7 +166,7 @@ async function deleteSiteInfo() {
if (!isConfirmed) return
try {
const result: { [key: string]: any } = await api.delete(`site/${cardProps.site?.id}`)
const result = (await api.delete(`site/${cardProps.site?.id}`)) as ApiResponse<unknown>
if (result.success) emit('remove')
else $toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: result.message }))
} catch (error) {
@@ -260,167 +260,182 @@ onMounted(() => {
:hover="!cardProps.sortable"
@click="handleCardClick"
>
<!-- 装饰性状态指示器 -->
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
<!-- 装饰性状态指示器 -->
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
<!-- 主体部分 -->
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
<!-- 顶部图标和站点名称 -->
<div class="mb-1 flex min-w-0 items-center gap-2">
<!-- 站点图标 -->
<VAvatar
tile
rounded="lg"
size="32"
class="shrink-0"
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
>
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-square" />
<!-- 主体部分 -->
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
<!-- 顶部图标和站点名称 -->
<div class="mb-1 flex min-w-0 items-center gap-2">
<!-- 站点图标 -->
<VAvatar
tile
rounded="lg"
size="32"
class="shrink-0"
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
>
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-square" />
</div>
</template>
</VImg>
</VAvatar>
<!-- 站点名称和特性图标 -->
<div class="flex min-w-0 flex-1 items-center gap-2">
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
<!-- 站点特性图标 -->
<div class="ml-auto flex shrink-0 items-center gap-2">
<div
v-if="cardProps.site?.limit_interval"
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
>
<VIcon
icon="mdi-speedometer"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div
v-if="cardProps.site?.proxy"
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
>
<VIcon
icon="mdi-network-outline"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div
v-if="cardProps.site?.render"
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
>
<VIcon
icon="mdi-apple-safari"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div
v-if="cardProps.site?.filter"
:class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'"
>
<VIcon
icon="mdi-filter-cog-outline"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
</template>
</VImg>
</VAvatar>
<!-- 站点名称和特性图标 -->
<div class="flex min-w-0 flex-1 items-center gap-2">
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
<!-- 站点特性图标 -->
<div class="ml-auto flex shrink-0 items-center gap-2">
<div v-if="cardProps.site?.limit_interval" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
<VIcon
icon="mdi-speedometer"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div v-if="cardProps.site?.proxy" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
<VIcon
icon="mdi-network-outline"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div v-if="cardProps.site?.render" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
<VIcon
icon="mdi-apple-safari"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div v-if="cardProps.site?.filter" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
<VIcon
icon="mdi-filter-cog-outline"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
</div>
</div>
</div>
<!-- 中间部分网址 -->
<div class="my-3">
<!-- 中间部分网址 -->
<div class="my-3">
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="handleSiteUrlClick">
{{ cardProps.site?.url }}
</div>
</div>
<!-- 底部数据统计 -->
<div class="flex-1 flex flex-col justify-end">
<!-- 更直观的上传下载数据条 -->
<div class="border-t mt-1.5 pt-1.5">
<!-- 上传数据 -->
<div class="flex items-center justify-between gap-3 mb-1.5">
<div class="text-sm text-medium-emphasis min-w-[70px]">
<VIcon icon="mdi-arrow-up" size="14" color="info" class="mr-1" />
<span>{{ formatFileSize(cardProps.data?.upload || 0) }}</span>
<!-- 底部数据统计 -->
<div class="flex-1 flex flex-col justify-end">
<!-- 更直观的上传下载数据条 -->
<div class="border-t mt-1.5 pt-1.5">
<!-- 上传数据 -->
<div class="flex items-center justify-between gap-3 mb-1.5">
<div class="text-sm text-medium-emphasis min-w-[70px]">
<VIcon icon="mdi-arrow-up" size="14" color="info" class="mr-1" />
<span>{{ formatFileSize(cardProps.data?.upload || 0) }}</span>
</div>
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
<VProgressLinear :model-value="getUploadPercent" color="info" height="4" rounded="lg" />
</div>
</div>
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
<VProgressLinear :model-value="getUploadPercent" color="info" height="4" rounded="lg" />
</div>
</div>
<!-- 下载数据 -->
<div class="flex items-center justify-between gap-3">
<div class="flex items-center text-[0.8rem] text-medium-emphasis min-w-[70px]">
<VIcon icon="mdi-arrow-down" size="14" color="success" class="mr-1" />
<span>{{ formatFileSize(cardProps.data?.download || 0) }}</span>
</div>
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
<VProgressLinear :model-value="getDownloadPercent" color="warning" height="4" rounded="lg" />
<!-- 下载数据 -->
<div class="flex items-center justify-between gap-3">
<div class="flex items-center text-[0.8rem] text-medium-emphasis min-w-[70px]">
<VIcon icon="mdi-arrow-down" size="14" color="success" class="mr-1" />
<span>{{ formatFileSize(cardProps.data?.download || 0) }}</span>
</div>
<div class="flex-grow h-1 rounded bg-on-surface/8 relative overflow-hidden">
<VProgressLinear :model-value="getDownloadPercent" color="warning" height="4" rounded="lg" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧操作按钮区 -->
<VSheet v-if="!cardProps.sortable" class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
<!-- 测试按钮 -->
<VBtn
icon
variant="text"
density="comfortable"
class="mb-1 relative flex items-center justify-center rounded-full mx-auto"
:disabled="testButtonDisable"
@click.stop="testSite"
size="36"
<!-- 右侧操作按钮区 -->
<VSheet
v-if="!cardProps.sortable"
class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1"
>
<div class="relative flex items-center justify-center w-full h-full">
<div
class="w-[20px] h-[20px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
:class="statColor"
></div>
</div>
<div
v-if="testButtonDisable"
class="absolute inset-0 flex flex-col items-center justify-center bg-surface/95 rounded-full shadow-md animate-fade-in"
<!-- 测试按钮 -->
<VBtn
icon
variant="text"
density="comfortable"
class="mb-1 relative flex items-center justify-center rounded-full mx-auto"
:disabled="testButtonDisable"
@click.stop="testSite"
size="36"
>
<div class="relative w-6 h-6">
<div class="spinner-circle"></div>
<div class="relative flex items-center justify-center w-full h-full">
<div
class="w-[20px] h-[20px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
:class="statColor"
></div>
</div>
</div>
</VBtn>
<div
v-if="testButtonDisable"
class="absolute inset-0 flex flex-col items-center justify-center bg-surface/95 rounded-full shadow-md animate-fade-in"
>
<div class="relative w-6 h-6">
<div class="spinner-circle"></div>
</div>
</div>
</VBtn>
<!-- 用户数据按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUserData" size="36">
<VIcon icon="mdi-chart-bell-curve" size="20" />
</VBtn>
<!-- 用户数据按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUserData" size="36">
<VIcon icon="mdi-chart-bell-curve" size="20" />
</VBtn>
<!-- 更新按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUpdate" size="36">
<VIcon icon="mdi-refresh" size="20" />
</VBtn>
<!-- 更新按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUpdate" size="36">
<VIcon icon="mdi-refresh" size="20" />
</VBtn>
<!-- 更多选项按钮 -->
<VBtn icon variant="text" class="mt-auto" size="36" @click.stop>
<VIcon icon="mdi-dots-vertical" size="20" />
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
<VList>
<VListItem @click="handleSiteEdit" base-color="info">
<template #prepend>
<VIcon icon="mdi-file-edit-outline" size="20" />
</template>
<VListItemTitle>{{ t('site.actions.edit') }}</VListItemTitle>
</VListItem>
<VListItem @click="deleteSiteInfo">
<template #prepend>
<VIcon icon="mdi-delete-outline" size="20" color="error" />
</template>
<VListItemTitle class="text-error">{{ t('site.deleteSite') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
</VSheet>
<!-- 更多选项按钮 -->
<VBtn icon variant="text" class="mt-auto" size="36" @click.stop>
<VIcon icon="mdi-dots-vertical" size="20" />
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
<VList>
<VListItem @click="handleSiteEdit" base-color="info">
<template #prepend>
<VIcon icon="mdi-file-edit-outline" size="20" />
</template>
<VListItemTitle>{{ t('site.actions.edit') }}</VListItemTitle>
</VListItem>
<VListItem @click="deleteSiteInfo">
<template #prepend>
<VIcon icon="mdi-delete-outline" size="20" color="error" />
</template>
<VListItemTitle class="text-error">{{ t('site.deleteSite') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
</VSheet>
</VCard>
</div>
</div>
@@ -442,7 +457,9 @@ onMounted(() => {
inset-block-start: 0;
inset-inline: 0;
opacity: 0.5;
transition: block-size 0.3s ease, opacity 0.3s ease;
transition:
block-size 0.3s ease,
opacity 0.3s ease;
}
.site-status-indicator.error {

View File

@@ -173,6 +173,7 @@ async function removeSubscribe() {
emit('remove')
}
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -184,7 +185,9 @@ async function searchSubscribe() {
// 提示
if (result.success) $toast.success(`${props.media?.name} 提交搜索请求成功!`)
else $toast.error(t('subscribe.requestFailed'))
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -211,6 +214,7 @@ async function toggleSubscribeStatus(state: 'R' | 'S') {
$toast.error(t('subscribe.toggleFailed', { action, message: result.message }))
}
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -233,6 +237,7 @@ async function resetSubscribe() {
emit('save')
} else $toast.error(t('subscribe.resetFailed', { name: props.media?.name, message: result.message }))
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -259,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
}
@@ -475,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>
@@ -530,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>
@@ -571,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>

View File

@@ -47,8 +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}`
}
// 查看媒体详情
@@ -101,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-calcdar" 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>

View File

@@ -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')
}
// 获取优惠类型样式

View File

@@ -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 },
)

View 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()
})
})

View File

@@ -0,0 +1,616 @@
import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import { clearCachedMediaSubscribeStatuses } from '@/utils/mediaStatusCache'
import { fireEvent, waitFor } from '@testing-library/vue'
import { createMediaInfo } from '@tests/support/factories/media'
import { mediaExistsHandler } from '@tests/support/msw/handlers/media'
import { querySubscribeByMediaHandler, subscribeListHandler } from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
openSharedDialog: vi.fn(),
routerPush: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/router', () => ({
default: {
push: (...args: unknown[]) => mocks.routerPush(...args),
},
}))
const API_BASE_URL = 'http://localhost/api/v1/'
const siteListUrl = new URL('site/', API_BASE_URL).href
const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_URL).href
let intersectionObservers: IntersectionObserverMock[] = []
class IntersectionObserverMock implements IntersectionObserver {
readonly root: Element | Document | null
readonly rootMargin: string
readonly thresholds: readonly number[]
readonly disconnect = vi.fn()
readonly observe = vi.fn((target: Element) => {
this.target = target
})
readonly unobserve = vi.fn()
private target: Element = document.body
constructor(
private readonly callback: IntersectionObserverCallback,
options: IntersectionObserverInit = {},
) {
this.root = options.root ?? null
this.rootMargin = options.rootMargin ?? '0px'
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold ?? 0]
intersectionObservers.push(this)
}
takeRecords(): IntersectionObserverEntry[] {
return []
}
trigger(isIntersecting = true) {
const bounds = this.target.getBoundingClientRect()
this.callback(
[
{
boundingClientRect: bounds,
intersectionRatio: isIntersecting ? 1 : 0,
intersectionRect: bounds,
isIntersecting,
rootBounds: null,
target: this.target,
time: 0,
},
],
this,
)
}
}
interface RenderCardOptions {
permissions?: Record<string, boolean>
superUser?: boolean
}
async function renderCard(media: MediaInfo, options: RenderCardOptions = {}) {
return renderWithProviders(MediaCard, {
props: {
media,
width: '9rem',
},
initialState: {
user: {
permissions: options.permissions ?? {
discovery: true,
manage: false,
search: true,
subscribe: true,
},
superUser: options.superUser ?? true,
},
},
})
}
function getCard(container: Element) {
const card = container.querySelector<HTMLElement>('.media-card')
expect(card).not.toBeNull()
return card as HTMLElement
}
function getHoverArea(container: Element) {
const area = container.querySelector<HTMLElement>('.media-card-hover-area')
expect(area).not.toBeNull()
return area as HTMLElement
}
function getActionButtons(container: Element) {
return [...container.querySelectorAll<HTMLButtonElement>('.media-card .v-card-text button')]
}
function getSearchButton(container: Element) {
const button = getActionButtons(container)[0]
expect(button).toBeDefined()
return button
}
function getStatusObservers() {
return intersectionObservers.filter(observer => observer.thresholds.includes(0.1))
}
function installSearchHandlers(sites: Record<string, unknown>[], selected: number[]) {
server.use(
http.get(siteListUrl, () => HttpResponse.json(sites)),
http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })),
)
}
describe('MediaCard', () => {
beforeEach(() => {
intersectionObservers = []
clearCachedMediaSubscribeStatuses()
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
})
it('loads exact subscribe and exists status only after entering the viewport', async () => {
const media = createMediaInfo({ season: 2, title: '视口状态剧集', tmdb_id: 9101, type: '电视剧' })
const subscribeRequest = vi.fn<(url: URL) => void>()
const existsRequest = vi.fn<(url: URL) => void>()
server.use(
querySubscribeByMediaHandler('tmdb:9101', { id: 71, season: 2 }, 200, subscribeRequest),
mediaExistsHandler({ data: { item: { id: 'library-item' } }, success: true }, 200, existsRequest),
)
const { container } = await renderCard(media)
expect(subscribeRequest).not.toHaveBeenCalled()
expect(existsRequest).not.toHaveBeenCalled()
getStatusObservers()[0]?.trigger()
await waitFor(() => {
expect(subscribeRequest).toHaveBeenCalledOnce()
expect(existsRequest).toHaveBeenCalledOnce()
})
expect(subscribeRequest.mock.calls[0][0].searchParams.get('season')).toBe('2')
expect(subscribeRequest.mock.calls[0][0].searchParams.get('title')).toBe('视口状态剧集')
expect(Object.fromEntries(existsRequest.mock.calls[0][0].searchParams)).toEqual({
mtype: '电视剧',
season: '2',
title: '视口状态剧集',
tmdbid: '9101',
year: '2026',
})
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
expect(getStatusObservers()[0]?.disconnect).toHaveBeenCalledOnce()
})
it('coalesces status requests for duplicate cards while updating both card states', async () => {
const media = createMediaInfo({ title: '重复媒体', tmdb_id: 9102 })
const subscribeRequest = vi.fn<(url: URL) => void>()
const existsRequest = vi.fn<(url: URL) => void>()
server.use(
querySubscribeByMediaHandler('tmdb:9102', { id: 72 }, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest),
)
const Harness = {
components: { MediaCard },
data: () => ({ media }),
template: '<div><MediaCard :media="media" width="9rem" /><MediaCard :media="media" width="9rem" /></div>',
}
const { container } = await renderWithProviders(Harness, {
initialState: { user: { superUser: true } },
})
expect(getStatusObservers()).toHaveLength(2)
getStatusObservers().forEach(observer => observer.trigger())
await waitFor(() => {
expect(subscribeRequest).toHaveBeenCalledOnce()
expect(existsRequest).toHaveBeenCalledOnce()
expect(container.querySelectorAll('.media-card .v-card-text button.text-error')).toHaveLength(2)
})
})
it.each([
['TMDB', createMediaInfo({ season: 3, tmdb_id: 9201, type: '电视剧' }), 'tmdb:9201', '3'],
[
'Douban',
createMediaInfo({ douban_id: 'db-9202', season: undefined, tmdb_id: undefined }),
'douban:db-9202',
null,
],
[
'Bangumi',
createMediaInfo({ bangumi_id: '9203', season: 1, tmdb_id: undefined, type: '电视剧' }),
'bangumi:9203',
'1',
],
[
'extension',
createMediaInfo({ media_id: 'item-9204', mediaid_prefix: 'custom', tmdb_id: undefined }),
'custom:item-9204',
null,
],
])('queries the current %s media identifier and season', async (_source, media, mediaId, season) => {
const subscribeRequest = vi.fn<(url: URL) => void>()
server.use(
querySubscribeByMediaHandler(mediaId, {}, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }),
)
await renderCard(media)
getStatusObservers()[0]?.trigger()
await waitFor(() => expect(subscribeRequest).toHaveBeenCalledOnce())
expect(subscribeRequest.mock.calls[0][0].searchParams.get('season')).toBe(season)
})
it('skips status requests for collections and releases observer and touch listeners on unmount', async () => {
vi.spyOn(window, 'matchMedia').mockReturnValue({
...window.matchMedia(''),
matches: true,
})
const subscribeRequest = vi.fn<(url: URL) => void>()
const existsRequest = vi.fn<(url: URL) => void>()
const addListener = vi.spyOn(document, 'addEventListener')
const removeListener = vi.spyOn(document, 'removeEventListener')
server.use(
querySubscribeByMediaHandler('tmdb:9301', {}, 200, subscribeRequest),
mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest),
)
const { unmount } = await renderCard(createMediaInfo({ collection_id: 44, tmdb_id: 9301 }))
getStatusObservers()[0]?.trigger()
await Promise.resolve()
expect(subscribeRequest).not.toHaveBeenCalled()
expect(existsRequest).not.toHaveBeenCalled()
expect(addListener).toHaveBeenCalledWith('pointerdown', expect.any(Function))
unmount()
expect(getStatusObservers()[0]?.disconnect).toHaveBeenCalledOnce()
expect(removeListener).toHaveBeenCalledWith('pointerdown', expect.any(Function))
})
it.each([
[
'media details',
createMediaInfo({ title: '详情电影', tmdb_id: 9401 }),
'/media',
{ mediaid: 'tmdb:9401', title: '详情电影', type: '电影', year: '2026' },
],
[
'collection browse',
createMediaInfo({ collection_id: 88, title: '合集入口', tmdb_id: 9402 }),
'/browse/tmdb/collection/88',
{ title: '合集入口' },
],
])('opens %s from the desktop hover state', async (_case, media, path, query) => {
const { container } = await renderCard(media)
await fireEvent.mouseEnter(getHoverArea(container))
await waitFor(() => expect(getCard(container)).toHaveClass('app-hover-lift-card--hovering'))
await fireEvent.click(getCard(container))
await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith({ path, query }))
})
it('routes directly to resource search when no active sites are available', async () => {
installSearchHandlers([], [3, 5])
const media = createMediaInfo({ season: 4, title: '直接搜索剧集', tmdb_id: 9501, type: '电视剧' })
const { container } = await renderCard(media)
await fireEvent.mouseEnter(getHoverArea(container))
await fireEvent.click(getSearchButton(container))
await waitFor(() =>
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/resource',
query: {
area: 'title',
keyword: 'tmdb:9501',
season: 4,
sites: '3,5',
title: '直接搜索剧集',
type: '电视剧',
year: '2026',
},
}),
)
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
})
it('falls back to global search when site settings cannot provide active selections', async () => {
server.use(
http.get(siteListUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
http.get(selectedSitesUrl, () => HttpResponse.json({ success: true })),
)
const media = createMediaInfo({ title: '站点失败搜索', tmdb_id: 9503 })
const { container } = await renderCard(media)
await fireEvent.mouseEnter(getHoverArea(container))
await fireEvent.click(getSearchButton(container))
await waitFor(() =>
expect(mocks.routerPush).toHaveBeenCalledWith(
expect.objectContaining({ path: '/resource', query: expect.objectContaining({ sites: '' }) }),
),
)
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
})
it('opens site selection and routes with the selected sites when active sites exist', async () => {
installSearchHandlers(
[
{
domain: 'tracker.example',
downloader: 'default',
id: 7,
is_active: true,
name: '测试站点',
url: 'https://tracker.example',
},
],
[7],
)
const media = createMediaInfo({ title: '多站搜索电影', tmdb_id: 9502 })
const { container } = await renderCard(media)
await fireEvent.mouseEnter(getHoverArea(container))
await fireEvent.click(getSearchButton(container))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, dialogProps, dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
unknown,
{ selected: number[]; sites: Array<{ id: number }> },
{ search: (sites: number[]) => void },
]
expect(dialogProps.selected).toEqual([7])
expect(dialogProps.sites.map(site => site.id)).toEqual([7])
dialogEvents.search([7, 9])
await waitFor(() =>
expect(mocks.routerPush).toHaveBeenCalledWith(
expect.objectContaining({
path: '/resource',
query: expect.objectContaining({ sites: '7,9' }),
}),
),
)
})
it('opens active sites with an empty selection when the saved setting fails', async () => {
server.use(
http.get(siteListUrl, () =>
HttpResponse.json([
{
domain: 'fallback.example',
downloader: 'default',
id: 8,
is_active: true,
name: '备用站点',
url: 'https://fallback.example',
},
]),
),
http.get(selectedSitesUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
)
const { container } = await renderCard(createMediaInfo({ tmdb_id: 9504 }))
await fireEvent.mouseEnter(getHoverArea(container))
await fireEvent.click(getSearchButton(container))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, { selected: number[] }]
expect(dialogProps.selected).toEqual([])
})
it('loads matching TV seasons before opening the subscription dialog', async () => {
const media = createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' })
server.use(
querySubscribeByMediaHandler('tmdb:9551', { id: 81, season: 2 }),
mediaExistsHandler({ data: { item: {} }, success: false }),
subscribeListHandler([
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
{ id: 84, tmdbid: 9551, type: '电影' },
]),
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: { best_version: 0 } }, success: true }),
),
)
const { container } = await renderCard(media)
getStatusObservers()[0]?.trigger()
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
await fireEvent.mouseEnter(getHoverArea(container))
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
expect(dialogProps).toMatchObject({
selectedSeason: undefined,
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
subscribedSeasons: [1, 3],
})
})
it('matches custom media IDs when collecting subscribed TV seasons', async () => {
const media = createMediaInfo({
media_id: 'series-9553',
mediaid_prefix: 'custom',
season: 2,
tmdb_id: undefined,
type: '电视剧',
})
server.use(
querySubscribeByMediaHandler('custom:series-9553', { id: 91, season: 2 }),
mediaExistsHandler({ data: { item: {} }, success: false }),
subscribeListHandler([
{ id: 91, mediaid: 'custom:series-9553', season: 2, type: '电视剧' },
{ id: 92, mediaid: 'custom:other', season: 5, type: '电视剧' },
]),
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: {} }, success: true }),
),
)
const { container } = await renderCard(media)
getStatusObservers()[0]?.trigger()
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
await fireEvent.mouseEnter(getHoverArea(container))
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
expect(dialogProps).toMatchObject({ subscribedSeasons: [2] })
})
it.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',
source: 'themoviedb',
tmdb_id: 9552,
type: '电视剧',
vote_average: 8.6,
})
const VImgStub = defineComponent({
name: 'VImg',
emits: ['error', 'load'],
props: { src: String },
setup(props, { emit, slots }) {
return () =>
h('div', { 'data-src': props.src }, [
h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }),
h('button', { 'aria-label': '图片加载失败', onClick: () => emit('error') }),
slots.default?.(),
])
},
})
const { container } = await renderWithProviders(MediaCard, {
props: { media, width: '9rem' },
initialState: { user: { superUser: true } },
global: { stubs: { VImg: VImgStub } },
})
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
await waitFor(() => expect(container.querySelector('.media-card')).toHaveClass('ring-1'))
expect(container).toHaveTextContent('TV')
expect(container).toHaveTextContent('8.6')
await fireEvent.click(container.querySelector('[aria-label="图片加载失败"]') as HTMLElement)
await waitFor(() =>
expect(container.querySelector('.media-card-title')?.parentElement).not.toHaveStyle({ display: 'none' }),
)
})
it('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: {
discovery: true,
manage: false,
search: false,
subscribe: false,
},
superUser: false,
})
await fireEvent.mouseEnter(getHoverArea(container))
expect(getActionButtons(container)).toHaveLength(0)
})
it('uses first tap to reveal details, second tap to route, and outside pointerdown to collapse', async () => {
vi.spyOn(window, 'matchMedia').mockReturnValue({
...window.matchMedia(''),
matches: true,
})
const media = createMediaInfo({ title: '触摸卡片', tmdb_id: 9701 })
const { container } = await renderCard(media)
const detail = container.querySelector<HTMLElement>('.media-card-title')?.parentElement
expect(detail).not.toBeNull()
await fireEvent.click(getCard(container))
expect(detail).not.toHaveStyle({ display: 'none' })
expect(mocks.routerPush).not.toHaveBeenCalled()
await fireEvent.pointerDown(document.body)
expect(detail).toHaveStyle({ display: 'none' })
await fireEvent.click(getCard(container))
await fireEvent.click(getCard(container))
await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith(expect.objectContaining({ path: '/media' })))
})
})

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