Compare commits

...

455 Commits

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

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

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

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

---------

Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-08-08 14:58:45 +08:00
jxxghp
693e6fb352 feat: 优化音乐搜索与详情页面 (#647)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-08-08 14:36:07 +08:00
jxxghp
70bb6e1040 feat(music): add MusicAlbumView, MusicArtistView, and MusicDetailView components
- Implement MusicAlbumView for displaying album details, including tracks and artist links.
- Create MusicArtistView to show artist information and their albums.
- Develop MusicDetailView for individual music tracks, integrating album details and related artists.
- Introduce MusicDetailLayout for consistent layout across music-related views.
- Add MusicArtistSlideView for displaying related artists in a slide format.
- Enhance user experience with loading states and error handling in NoDataFound component.
2026-08-08 12:56:22 +08:00
jxxghp
02e3314853 fix(v3): finish music discovery and detail flows 2026-08-07 23:46:54 +08:00
jxxghp
d48ee55bab fix(v3): route music search through global search 2026-08-07 22:54:39 +08:00
jxxghp
d72bb75996 chore(v3): prune stale eslint suppression 2026-08-07 22:16:40 +08:00
jxxghp
95674fa47a ci(v3): publish frontend from v3 2026-08-07 22:08:37 +08:00
jxxghp
6afa2bdc8a feat(v3): add music search and subscription UI 2026-08-07 21:50:22 +08:00
jxxghp
7ea14bc919 fix confirm dialog button text color 2026-08-07 17:33:02 +08:00
jxxghp
1dfb51f3c4 Improve agent input handling and tool summary grouping 2026-08-07 13:06:01 +08:00
jxxghp
871a9a072b fix(agent): preserve tool and content ordering 2026-08-07 13:01:47 +08:00
jxxghp
0eb81b4ecb perf(agent): smooth SSE streaming updates 2026-08-07 12:42:57 +08:00
jxxghp
4814f53c4e fix(download): place metadata chips below title 2026-08-07 07:35:07 +08:00
秋澪Akimio
0703a99e41 feat: words editor replacement highlight (#644) 2026-08-07 07:01:41 +08:00
jxxghp
21b485ca84 fix(api): type avatar upload response data 2026-08-06 23:36:34 +08:00
jxxghp
fc6313b79e fix(agent): preserve streamed message order 2026-08-06 22:24:54 +08:00
jxxghp
8badfb0da9 fix(download): keep torrent size visible 2026-08-06 17:45:56 +08:00
jxxghp
bfd61cd479 fix(api): read unified error messages 2026-08-06 17:31:00 +08:00
jxxghp
ac29818fd3 Refine assistant layering and plugin source settings 2026-08-06 10:33:20 +08:00
jxxghp
8d18f82e68 Refine responsive input action visibility 2026-08-06 08:21:49 +08:00
jxxghp
fd1bf8df7f test(site): cover chart tooltip formatters 2026-08-06 08:01:10 +08:00
jxxghp
6fd259427e refactor(cache): manage TMDB recognition cache only 2026-08-06 07:48:43 +08:00
jxxghp
0045ecf5d9 fix(site): keep chart dates compact 2026-08-05 22:13:22 +08:00
jxxghp
50fa751eb5 feat(site): enhance user data dashboard 2026-08-05 21:03:46 +08:00
jxxghp
aa6dfd8569 fix(plugin): clear update marker after refresh 2026-08-05 20:47:43 +08:00
jxxghp
b5ee946919 fix(test): align version statistic threshold fixture 2026-08-05 20:38:50 +08:00
jxxghp
6a21bb55c3 fix(test): avoid super-linear glass overlay regex 2026-08-05 19:52:25 +08:00
jxxghp
737d464950 更新 AboutDialog.vue 2026-08-05 19:45:23 +08:00
jxxghp
2c467335fa 更新 AboutDialog.vue 2026-08-05 19:44:16 +08:00
jxxghp
24f1580501 feat: 增加服务端联网搜索设置 2026-08-05 19:19:26 +08:00
jxxghp
1129d63fcc fix: 恢复识别结果媒体分类展示 2026-08-05 06:56:52 +08:00
jxxghp
b1f3d3eacb Refine shared workflow and subscription dialogs 2026-08-04 21:37:04 +08:00
InfinityPacer
487e567d4c fix(glass): define signed coverage square (#642) 2026-08-04 19:48:44 +08:00
jxxghp
872d46132e fix MediaInfoCard chip background 2026-08-04 19:29:31 +08:00
InfinityPacer
c65b79f4b8 fix(glass): refine fluid response coverage (#641) 2026-08-04 19:11:34 +08:00
jxxghp
09bbeda108 fix(glass): preserve chip variants with colored glass 2026-08-04 18:58:55 +08:00
InfinityPacer
afc95ad990 fix(glass): reuse CORS wallpaper sources (#640) 2026-08-04 16:35:17 +08:00
InfinityPacer
a545f88b3c feat(glass): add selectable dynamics modes (#639) 2026-08-04 16:34:39 +08:00
jxxghp
084b950fec feat: 修复玻璃主题下Chip标签颜色 (#638)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-08-04 15:18:30 +08:00
jxxghp
2728896a1e Propagate plugin rating updates and preserve rating snapshots 2026-08-04 13:39:27 +08:00
jxxghp
b4afba99bd Improve plugin card statuses and compact market details 2026-08-04 12:51:35 +08:00
jxxghp
df1e72e871 Improve plugin market detail layout and rating sorting 2026-08-04 12:34:02 +08:00
jxxghp
7e46428721 style(workflow): adapt card headers to material themes 2026-08-04 10:12:32 +08:00
jxxghp
14a4d9dc9c Redesign workflow task cards 2026-08-04 09:20:56 +08:00
jxxghp
3ac798ac5e Disable mobile layout for plugin search field 2026-08-04 06:42:40 +08:00
InfinityPacer
e3ee714732 fix(plugin): align lifecycle action state handling (#637) 2026-08-04 06:33:00 +08:00
jxxghp
41d5f579f7 Fix movie wash subscription card visuals 2026-08-03 22:42:15 +08:00
jxxghp
102abf7df9 Highlight best-version subscriptions on mobile cards 2026-08-03 22:26:04 +08:00
jxxghp
47b9633a86 Document test helpers and component stubs 2026-08-03 22:17:56 +08:00
jxxghp
e2902adba8 Improve card badge layouts and expand card tests 2026-08-03 22:16:28 +08:00
jxxghp
cd993e3cf4 Refine mobile subscription card layout 2026-08-03 22:00:10 +08:00
jxxghp
4097fd74dd Codex/compact downloading poster (#635)
* fix(download): compact poster cards

* fix(download): use poster artwork consistently

* Improve plugin and media ID search dialogs
2026-08-03 21:23:20 +08:00
jxxghp
37e645ed9c fix(download): compact poster cards (#634) 2026-08-03 20:09:05 +08:00
jxxghp
fa254499bc fix(download): redesign downloading card (#633) 2026-08-03 19:48:45 +08:00
jxxghp
be8b20da8d 更新 package.json 2026-08-03 18:50:09 +08:00
jxxghp
cef44692a8 feat(download): redesign active download cards (#632) 2026-08-03 18:49:23 +08:00
jxxghp
a627d63107 fix(dialog): align mobile site search actions 2026-08-03 18:19:42 +08:00
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
InfinityPacer
4378c4843b feat: 增强登录页交互式 3D Logo (#536)
* feat(login): add interactive optical logo lab

* feat(login): add prismatic logo component

* fix(login): refine optical logo colors and rendering

* feat(login): add matte logo material and sizing

* feat(login): add logo palette toggle

* feat(login): add configurable logo presets

* fix(login): ignore lab route after authentication

* fix(login): keep prism idle motion
2026-07-17 12:33:26 +08:00
jxxghp
5676e0b9b1 feat: 优化识别结果展示,调整图标和标题样式 2026-07-17 09:51:54 +08:00
jxxghp
38a841dc9c feat: 添加自定义识别词功能,支持保存和管理识别规则 2026-07-17 09:47:15 +08:00
jxxghp
6e92673438 feat: 更新金属材质设置,优化视觉效果和层次感 2026-07-17 09:04:21 +08:00
jxxghp
5e2585b210 feat: 添加媒体详情查看功能和相关计算属性 2026-07-17 08:42:22 +08:00
InfinityPacer
c3c323b018 test(subscribe): cover list management workflows (#535) 2026-07-17 06:53:56 +08:00
jxxghp
5390af50ac feat: 重新设计捷径测试结果页面 (#534) 2026-07-16 21:41:10 +08:00
jxxghp
c0f58c7df4 feat: 更新3D金属Logo材质和光照设置,提升视觉效果 2026-07-16 20:27:47 +08:00
kuke2733
9bdf102f7a fix(subscribe): improve mobile files dialog layout (#533) 2026-07-16 19:26:29 +08:00
jxxghp
52df6bef38 fix: adjust logo bevel thickness and depth for improved appearance 2026-07-16 17:40:09 +08:00
kuke2733
c5da08e388 feat(subscribe): “我的订阅”中“文件统计弹窗”支持媒体服务器入库展示 (#531) 2026-07-16 17:39:33 +08:00
InfinityPacer
8a069db55e test(subscribe): cover subscription management flows (#530) 2026-07-16 17:37:43 +08:00
jxxghp
9900042bc3 feat: enhance 3D MetalLogo component with rounded shapes and improved materials 2026-07-16 17:32:00 +08:00
jxxghp
e4f02a326f feat: add 3D MetalLogo component and update dependencies 2026-07-16 15:34:44 +08:00
InfinityPacer
199693abf8 test: introduce frontend unit testing foundation (#529) 2026-07-16 14:53:18 +08:00
jxxghp
32b0c1c00c fix: widen plugin market mobile filters 2026-07-16 12:44:20 +08:00
InfinityPacer
e4d0afc380 ci(pr-agent): use shared review runner (#528) 2026-07-16 06:26:25 +08:00
InfinityPacer
8e89078055 fix(dashboard): refine responsive recommendation controls (#527) 2026-07-16 06:25:50 +08:00
jxxghp
2e0a582d22 fix: scope mobile form layout styles 2026-07-15 13:53:13 +08:00
jxxghp
9c2c4d9fc4 Improve responsive input layouts and mobile control widths 2026-07-15 13:44:12 +08:00
jxxghp
4e0191ba5c fix(calendar): deepen today highlight 2026-07-15 12:41:33 +08:00
jxxghp
9247850411 Update the rule test empty-state icon 2026-07-15 12:38:18 +08:00
jxxghp
29b4854dae fix: allow wrapping mobile multi-select values 2026-07-15 12:06:01 +08:00
jxxghp
338af516be fix: stabilize mobile shortcut validation styles 2026-07-15 10:57:54 +08:00
jxxghp
ea892a31c0 feat: enhance recognition and rule testing features
- Updated maxWidth for NameTestView and RuleTestView dialogs to improve layout.
- Added new localization strings for recognition and rule testing in English, Simplified Chinese, and Traditional Chinese.
- Refactored NameTestView to include detailed analysis steps and summary items for better user feedback.
- Improved error handling and loading states in both NameTestView and RuleTestView.
- Redesigned form layouts for NameTestView and RuleTestView to enhance user experience and accessibility.
- Added computed properties for better data handling and display in the recognition and rule testing processes.
2026-07-15 09:11:27 +08:00
jxxghp
c0f3814560 Add responsive input separators for stacked mobile fields 2026-07-15 08:14:23 +08:00
jxxghp
15f984ba44 fix: ellipsize mobile input values 2026-07-14 22:21:49 +08:00
jxxghp
362c601c7e fix: remove mobile input dividers 2026-07-14 22:06:17 +08:00
jxxghp
4f0204d465 fix: normalize mobile input dividers 2026-07-14 22:03:07 +08:00
jxxghp
d92e79169c Reduce vertical spacing for responsive input rows 2026-07-14 21:53:16 +08:00
jxxghp
563849aab3 fix: hide trailing mobile input divider 2026-07-14 21:36:55 +08:00
jxxghp
b0a27c6405 fix: optimize mobile input layout 2026-07-14 21:25:27 +08:00
jxxghp
b664e0b447 更新 package.json 2026-07-14 13:54:01 +08:00
jxxghp
d625d1ad46 fix: 完善移动端表单响应式布局 2026-07-14 13:51:50 +08:00
jxxghp
cec9563963 feat: 添加响应式输入适配器,优化小屏幕录入控件布局 2026-07-14 12:45:11 +08:00
InfinityPacer
2df3ce1b86 ci(pr-agent): publish native code reviews (#526) 2026-07-13 18:41:32 +08:00
jxxghp
2a2ed9fc6a fix: 修复背景任务图标显示逻辑,优化任务状态展示 2026-07-13 13:58:08 +08:00
jxxghp
0921932601 feat: 优化定时服务状态展示,增强用户体验 2026-07-13 13:45:16 +08:00
jxxghp
50b8ea1bc9 fix: 移除固定表头,优化移动端缓存面板样式 2026-07-13 13:27:51 +08:00
jxxghp
f88ae5643c feat: 使用 ProgressiveCardGrid 组件优化移动端缓存和历史记录展示 2026-07-13 13:19:58 +08:00
jxxghp
cf5f4e5b6a feat: 优化移动端缓存管理界面,增强用户体验 2026-07-13 12:57:11 +08:00
jxxghp
a8db14a069 feat: 支持多数据源识别缓存管理 2026-07-13 12:33:45 +08:00
jxxghp
a20977e522 fix: 优化识别缓存移动端滚动 2026-07-13 11:03:09 +08:00
jxxghp
7efc32637b feat: 支持多类缓存管理 2026-07-13 09:48:08 +08:00
秋澪Akimio
5d1445fe64 fix: 修复识别词编辑器滚动闪烁 (#525) 2026-07-12 14:06:53 +08:00
秋澪Akimio
2070240b50 feat: highlight word list comments (#524) 2026-07-12 12:23:13 +08:00
InfinityPacer
1d130c99a3 docs(plugin): document federation watch workflow (#523) 2026-07-11 19:52:16 +08:00
InfinityPacer
7861afb2d7 feat(plugin): support responsive federated config controls (#521) 2026-07-11 18:16:31 +08:00
jxxghp
5c4511f935 Deduplicate offline connection prompts by status 2026-07-10 07:40:25 +08:00
jxxghp
212ba94e11 chore: update version to 2.14.3 2026-07-09 19:34:35 +08:00
jxxghp
f863a09212 Refine dashboard layout drafts and episode status badges 2026-07-09 19:33:27 +08:00
jxxghp
728ced475b feat: add computed dialog subtitle for download confirmation 2026-07-08 17:19:47 +08:00
InfinityPacer
a192e00db5 ci(pr-agent): simplify review workflow (#518) 2026-07-08 12:51:49 +08:00
jxxghp
85e6b17db7 feat: add agent MCP settings 2026-07-08 08:44:33 +08:00
InfinityPacer
156d835d6d ci(pr-agent): align inline review workflow (#517) 2026-07-08 07:05:24 +08:00
jxxghp
b27eeea6c0 Improve frontend dialog and layout behavior 2026-07-07 08:54:09 +08:00
jxxghp
39a9fe0541 fix: wrap words guide hint 2026-07-06 23:17:35 +08:00
jxxghp
92d153760b fix: sync media type when selecting media 2026-07-06 22:38:10 +08:00
jxxghp
ff9dc26040 feat: support backend i18n responses 2026-07-06 19:13:25 +08:00
jxxghp
269da6eefe fix(login): remove required attribute from stay logged in checkbox 2026-07-06 13:50:41 +08:00
jxxghp
817bc1019d Improve episode group carousel navigation 2026-07-05 20:39:06 +08:00
InfinityPacer
de8c22c180 fix(ui): improve fab layering and cookiecloud hint (#515) 2026-07-05 16:36:55 +08:00
jxxghp
8f074fb381 feat: add llm temperature setting 2026-07-05 13:04:51 +08:00
jxxghp
e16870859d fix: 优化媒体详情按钮布局 2026-07-05 11:56:11 +08:00
InfinityPacer
e1ba13abf2 feat(settings): expose cookiecloud upload secret (#514) 2026-07-05 09:46:14 +08:00
jxxghp
8d1f226035 Delete .codex-qa directory 2026-07-03 21:46:15 +08:00
jxxghp
5367a07a90 fix: remove redundant episode status icon 2026-07-03 21:40:52 +08:00
jxxghp
8e681dbbe7 fix: prevent nested scrolling in words shortcut 2026-07-03 20:32:16 +08:00
jxxghp
c26c3c4284 Remove playing backdrop status badge 2026-07-03 19:51:35 +08:00
jxxghp
461ed560f0 Improve logging dialogs and word rules layout 2026-07-03 19:45:58 +08:00
jxxghp
dd27bcdaca 更新 package.json 2026-07-03 18:47:06 +08:00
jxxghp
c076be868a feat: 桌面端日志级别改为按钮组 2026-07-03 18:06:57 +08:00
jxxghp
d2634095e4 feat: 增加仪表盘配置的更新时间戳和保存队列功能 2026-07-03 17:13:44 +08:00
jxxghp
a5a3e1e1e4 Add new animations and styles for robot actions
- Introduced new SCSS files for various robot actions including shy, sit, sleep, spin-cheer, stretch, and wave.
- Implemented keyframe animations for each action to enhance the robot's interactivity.
- Created a shared stylesheet for common styles and transitions.
- Updated the main CSS file to include all new action styles.
2026-07-03 14:15:10 +08:00
jxxghp
09a20967c0 feat: 增加宠物随机动作和相应的动画效果 2026-07-03 13:32:39 +08:00
jxxghp
9a2af0a412 feat: add agent pet types and state management for idle actions
- Introduced new types for agent pet actions and intents in `types.ts`.
- Implemented `useAgentPetMachine` to manage idle action scheduling and execution.
- Added functionality to handle random actions based on interaction states.
2026-07-03 13:19:30 +08:00
jxxghp
6bbac7db7d refactor: 重新设计词表页面 2026-07-03 08:37:44 +08:00
jxxghp
b212e066b6 添加 .codex-qa 到 .gitignore 2026-07-02 20:29:31 +08:00
jxxghp
f7d354fca4 Refine mobile subscribe cards and grouped list styling 2026-07-02 20:28:03 +08:00
jxxghp
693b081ecf 删除设计 QA 文档 2026-07-02 19:33:14 +08:00
jxxghp
d5f0fed954 Polish app center grouped menu styling 2026-07-02 19:32:39 +08:00
jxxghp
67ef98bfee Hide dashboard recommendation detail panel 2026-07-02 19:08:07 +08:00
jxxghp
1094d80b9c Refine episode group subscriptions and mobile carousel 2026-07-02 16:34:38 +08:00
jxxghp
98e6481812 Refine dashboard recommendations and transparent cards 2026-07-02 15:47:47 +08:00
jxxghp
7db088eb79 更新 package.json 2026-07-02 14:35:38 +08:00
jxxghp
dec154c042 Rename playback status label to continue watching 2026-07-02 12:42:56 +08:00
jxxghp
d06ce5d984 Refine offline status toasts and theme styling 2026-07-02 12:36:49 +08:00
jxxghp
af604d0c5c 增强离线状态管理,添加服务探测功能和连接状态提示 2026-07-02 11:56:34 +08:00
jxxghp
fa39c3750c Limit dashboard recommendations to TMDB sources 2026-07-02 10:51:43 +08:00
jxxghp
ce64fb03ce Add media recommendations dashboard and episode groups 2026-07-01 17:35:35 +08:00
jxxghp
764f14a7f4 Highlight today in full calendar with primary color 2026-07-01 10:37:13 +08:00
jxxghp
621200b3b2 Refine subscribe files dialog layout 2026-07-01 09:57:53 +08:00
jxxghp
60c46ebbaf Make search site dialog fullscreen on small screens 2026-07-01 09:48:41 +08:00
InfinityPacer
4fe65b770e perf(app): throttle inactive realtime blur rendering (#511) 2026-07-01 06:01:50 +08:00
jxxghp
c010162e54 fix subscribe files duplicate stats 2026-06-30 22:26:11 +08:00
jxxghp
5cc4ea7740 fix dashboard auto height growth 2026-06-30 22:22:47 +08:00
jxxghp
8a19fbc584 fix(subscribe): 调整统计卡片移动端布局 2026-06-30 21:33:57 +08:00
jxxghp
24412989bb fix(subscribe): 修复文件弹窗移动端布局 2026-06-30 21:20:41 +08:00
jxxghp
55d0693e41 fix(subscribe): 优化文件统计弹窗移动端样式 2026-06-30 20:49:52 +08:00
jxxghp
0459a2d108 feat(subscribe): 更新下拉菜单项并添加媒体详情和文件统计本地化字符串 2026-06-30 20:31:27 +08:00
jxxghp
624b55576d Refine login glass effect and plugin menu icon 2026-06-30 20:23:23 +08:00
jxxghp
c848b90599 Split movie release dates and polish poster radius 2026-06-30 20:13:37 +08:00
jxxghp
79bc99e624 feat(theme): 使用按钮切换替代标签,优化订阅文件选项卡样式 2026-06-30 20:02:32 +08:00
jxxghp
850f7ee9be Update subscription file terminology and add new localization strings
- Changed title from 'Downloaded Files' to 'Subscription Files' in English, Simplified Chinese, and Traditional Chinese locales.
- Added new localization strings related to subscription files, including status messages and file management terms across all three language files.
2026-06-30 20:02:32 +08:00
InfinityPacer
4cc105287f fix(auth): decouple webpush subscription from login (#510) 2026-06-30 19:59:51 +08:00
InfinityPacer
527c0845bc fix(theme): stabilize transparent background transitions (#509) 2026-06-30 19:16:31 +08:00
jxxghp
2a3ef1a1f6 fix theme border surfaces 2026-06-30 19:05:26 +08:00
jxxghp
7539fa26aa feat(theme): 更新主题定制器边框样式,优化视觉效果
fix(locales): 修改英文和中文边框相关文本
style(app): 调整应用界面边框和阴影样式
2026-06-30 18:42:39 +08:00
jxxghp
3547c146a8 Use overlay shadow for pull indicator 2026-06-30 18:03:32 +08:00
jxxghp
da630b0966 feat(theme): 添加主题定制器的圆角和阴影示例,优化透明主题下的视觉效果 2026-06-30 17:43:07 +08:00
InfinityPacer
cff4c1fecc fix(theme): restore transparent glass overlays (#508) 2026-06-30 14:47:42 +08:00
jxxghp
3f525c6457 fix: adjust dialog fullscreen breakpoint by width 2026-06-30 14:17:04 +08:00
jxxghp
8c1eb3e981 feat: add fullscreen support to TransparencySettingsDialog based on display size 2026-06-30 13:59:53 +08:00
InfinityPacer
effc83b9a1 feat: add transparent glass quality options (#507)
* feat(theme): add lightweight transparent glass mode

* perf(theme): reduce realtime glass backdrop filters

* fix(theme): align transparency presets
2026-06-30 13:39:22 +08:00
jxxghp
39ce8d93dd feat: add rounded corners to weekly bar chart for improved aesthetics and Safari compatibility 2026-06-30 13:37:00 +08:00
jxxghp
fa32ac9f48 feat: enhance compact FAB styles with backdrop filter and improved hover effects 2026-06-30 13:15:46 +08:00
jxxghp
ab243bbbb7 refactor: streamline transfer queue logic and improve styling 2026-06-30 12:45:10 +08:00
jxxghp
abcd3f5e81 refactor: simplify transfer queue header layout and styles 2026-06-30 12:41:53 +08:00
jxxghp
92bf1ca790 refine transfer queue layout 2026-06-30 12:07:36 +08:00
jxxghp
faadf39b80 feat: enhance transfer queue localization and UI improvements 2026-06-30 11:30:48 +08:00
jxxghp
878c3c98b2 fix: use native login inputs 2026-06-30 06:51:57 +08:00
jxxghp
2d8ae9f462 Refine folded FAB appearance 2026-06-30 06:24:36 +08:00
jxxghp
00051a0eac Fix dashboard settings and FAB folding 2026-06-30 06:21:37 +08:00
jxxghp
88726c0f12 Update dashboard localization labels 2026-06-29 22:37:44 +08:00
jxxghp
79bc9f0ff0 Make dashboard media grids responsive to screen size 2026-06-29 22:20:31 +08:00
jxxghp
4492e211bb 添加重新加载仪表板网格小部件的功能,以清理 GridStack 内部响应式布局缓存 2026-06-29 22:09:13 +08:00
jxxghp
bb5299c812 Fix dashboard media grid layout persistence and capacity 2026-06-29 22:07:31 +08:00
jxxghp
f54a5285d6 Polish library cards and storage dashboard 2026-06-29 21:47:37 +08:00
jxxghp
03b80d6434 添加 PlayingBackdropCard 组件并更新相关视图,优化媒体服务器设置和播放列表的加载逻辑 2026-06-29 21:20:00 +08:00
jxxghp
a916a1569d Fix dashboard task status and recent imports count 2026-06-29 20:34:03 +08:00
jxxghp
5c27c32914 Use dynamic viewport height for panels 2026-06-29 20:06:42 +08:00
jxxghp
c57a8b7b48 Switch panels to svh and native theme scrolling 2026-06-29 19:56:18 +08:00
jxxghp
0aa7d37e45 Fix panel viewport sizing on mobile 2026-06-29 19:54:06 +08:00
jxxghp
d3b3b08318 Preserve full path cache key for browse page 2026-06-29 19:40:31 +08:00
jxxghp
5127ab6c08 优化探索标签排序弹窗样式 2026-06-29 19:12:48 +08:00
jxxghp
a3810fb9e2 style(NotificationTemplateEditorDialog): adjust overflow property and update min-block-size for template editor
style(SearchBarDialog): change search icon and adjust dropdown width
style(common.scss): refine hover effects and update shadow settings for card components
style(AccountSettingNotification): improve layout and spacing for notification settings
2026-06-29 18:02:38 +08:00
InfinityPacer
f05eac0b93 fix(agent): stabilize assistant entry dragging (#505) 2026-06-29 15:50:29 +08:00
jxxghp
ef170b54c6 style(SearchBarDialog): adjust search desktop activator dimensions and disable specificity warning 2026-06-29 13:55:05 +08:00
jxxghp
536844d291 refactor(dashboard): remove unused color management logic 2026-06-29 13:48:59 +08:00
jxxghp
21d4f9fd73 Guard dashboard profile switches and tighten chart axis padding 2026-06-29 09:36:18 +08:00
jxxghp
2aae0f08ad 删除设计 QA 文档 2026-06-29 08:50:47 +08:00
jxxghp
248984261e Refine dashboard scheduler task layout 2026-06-29 08:49:52 +08:00
jxxghp
82ab594e0c fix(charts): disable zoom interactions 2026-06-29 08:08:53 +08:00
jxxghp
5ca374349d fix(dashboard): refine scheduler task list 2026-06-29 07:54:35 +08:00
jxxghp
7452e18fc3 feat(dashboard): refine network and scheduler visuals 2026-06-29 07:37:03 +08:00
jxxghp
75dffdb7c2 更新 package.json 2026-06-29 06:58:57 +08:00
jxxghp
a3d74929b3 feat(dashboard): add memory and scheduler progress interfaces, enhance dashboard layout and responsiveness 2026-06-28 22:59:36 +08:00
jxxghp
fd9c314d55 Refine card hover borders across subtitle and torrent cards 2026-06-28 21:39:29 +08:00
jxxghp
d02e98736f feat(calendar): enhance loading behavior and mobile layout for subscriptions 2026-06-28 21:22:43 +08:00
jxxghp
2e46ebe353 更新 package.json 2026-06-28 17:54:28 +08:00
jxxghp
fffb526a87 feat(dashboard): enhance media statistics and memory analytics
- Added monthly media counts (movies, TV shows, episodes) to the AnalyticsMediaStatistic component.
- Improved layout and styling for better responsiveness in the dashboard summary.
- Updated AnalyticsMemory component to display total and available memory with enhanced visuals.
- Refactored AnalyticsScheduler to include transfer queue management and improved task display.
- Enhanced AnalyticsSpeed component with a more structured speed overview.
- Introduced new components for quick actions and recent imports in the dashboard.
- Added system information display with runtime and version details in DashboardSystemInfo.
- Created a new composable for shortcut tools to streamline dialog management.
2026-06-28 17:48:50 +08:00
jxxghp
98769a313f Refine dashboard profile persistence and radius tokens 2026-06-28 16:28:25 +08:00
jxxghp
1c28d98d70 Update expired translation labels to aired 2026-06-28 15:33:42 +08:00
jxxghp
7a430b095a Limit mobile calendar history and simplify episode tags 2026-06-28 15:32:00 +08:00
jxxghp
864af36b1c Use theme primary border for selected subscribe card 2026-06-28 13:59:25 +08:00
jxxghp
e3fa0b9dae Add batch selection support for subscriptions and history 2026-06-28 13:47:13 +08:00
jxxghp
ed28832484 Fix selected subscribe card border 2026-06-28 13:13:43 +08:00
jxxghp
e692a91113 Refine mobile cache and scheduler layouts 2026-06-28 12:46:47 +08:00
jxxghp
7574719e04 Refactor frontend dialogs and add missing annotations 2026-06-28 12:08:24 +08:00
jxxghp
ca800f7ae7 Refine transfer history mobile list and search styling 2026-06-28 11:09:47 +08:00
jxxghp
7d0550825b fix: adjust header margins for consistency in resource and site card list views 2026-06-28 10:56:33 +08:00
jxxghp
59abda63e3 Format season episodes in calendar tags 2026-06-28 09:58:11 +08:00
jxxghp
0776bc9fa0 fix: adjust dashboard item rows and improve storage display styling for better layout 2026-06-28 09:44:58 +08:00
jxxghp
8c78f9ca90 feat: update dashboard components to use 'dashboard-grid-fill' class for improved layout handling 2026-06-28 09:35:50 +08:00
jxxghp
1889d5d1e3 feat: enhance dashboard layout with fixed height components and improved styling for media cards 2026-06-28 09:05:31 +08:00
jxxghp
165247b263 fix: update storage used percentage display to use div elements for better layout 2026-06-28 08:30:24 +08:00
jxxghp
9a2384bf02 Refine subscribe calendar mobile filters and bump version 2026-06-27 22:17:47 +08:00
jxxghp
71c4f587d3 Enable hover open for horizontal nav submenus 2026-06-27 21:44:48 +08:00
jxxghp
8edbfa0b0e Adjust assistant entry anchor for mobile FAB 2026-06-27 21:39:05 +08:00
jxxghp
ee187a1c75 refactor: remove unused episode subtitle search functionality 2026-06-27 18:21:11 +08:00
jxxghp
afb617724c fix: recover web agent stream after background 2026-06-27 15:09:41 +08:00
InfinityPacer
05bbecba53 fix(subscribe): refine season mode defaults (#503) 2026-06-27 06:55:45 +08:00
jxxghp
deb9b103da Improve assistant input and choice handling 2026-06-26 13:43:47 +08:00
jxxghp
2d92315e17 Enhance command prompts in localization files
- Updated placeholder text in English, Simplified Chinese, and Traditional Chinese to guide users on using commands.
- Added loading and load failed messages for commands in all three languages.
2026-06-26 12:42:49 +08:00
jxxghp
077f0c1366 fix: improve login autofill compatibility 2026-06-25 23:26:57 +08:00
jxxghp
eab4caa279 fix: disable MediaCard ripple feedback 2026-06-25 23:15:40 +08:00
jxxghp
3bba73abf1 fix: remove unused formatSeason function and subscribedSeasonText for cleaner code 2026-06-25 22:59:29 +08:00
jxxghp
8edf1f686a fix: refactor media card interaction for improved touch handling and state management 2026-06-25 22:45:12 +08:00
jxxghp
3440b377ee fix: improve mobile interaction handling for media cards 2026-06-25 21:33:37 +08:00
jxxghp
9161e5e300 fix: replace VBottomSheet with VDialog for improved layout and accessibility 2026-06-25 20:11:52 +08:00
jxxghp
25fdb09c61 fix: correct ios safe area in subscribe sheet 2026-06-25 17:39:35 +08:00
jxxghp
25119b224d fix: refactor SubscribeSeasonDialog for improved readability and style consistency 2026-06-25 17:22:29 +08:00
jxxghp
76f79e1656 fix: align bubbles after assistant undocks 2026-06-25 16:11:39 +08:00
jxxghp
b5230cef79 fix: match agent bubble arrow style 2026-06-25 16:02:25 +08:00
jxxghp
f871746efb fix: reposition agent assistant bubbles 2026-06-25 15:53:52 +08:00
jxxghp
d17ca69a28 fix: expand subscribe season sheet on mobile 2026-06-25 15:12:50 +08:00
jxxghp
e445437129 fix: hide agent assistant message scrollbar 2026-06-25 13:56:46 +08:00
jxxghp
090c5476c6 feat: unify media subscription flow 2026-06-25 13:52:13 +08:00
jxxghp
646c7b33dd Tune pull and swipe gesture thresholds 2026-06-25 12:33:49 +08:00
jxxghp
0902f16f84 fix ui 2026-06-25 11:39:52 +08:00
InfinityPacer
ec4eaed1df fix: refine subscribe mode selection (#501)
* fix(subscribe): refine subscribe mode selection

* fix(subscribe): prompt mode for single existing season
2026-06-25 11:30:24 +08:00
jxxghp
175f610524 Animate dashboard metrics and standardize card sizing 2026-06-25 11:28:36 +08:00
jxxghp
530fe9d35b fix: 修复卡片 hover 上浮抖动 2026-06-24 21:34:38 +08:00
jxxghp
48ed396a19 Refresh notifications when opening the menu 2026-06-24 20:58:46 +08:00
jxxghp
b0356c217d feat: add welcome back message to login page and enhance styles 2026-06-24 17:38:03 +08:00
jxxghp
55eed1ecb5 Fix notification read and clear state 2026-06-24 16:58:08 +08:00
jxxghp
50ae739a4d fix: resolve stylelint warnings and enhance bubble layout for agent assistant 2026-06-24 14:33:55 +08:00
jxxghp
d9cbcc2991 Increase agent assistant bubble text size 2026-06-24 13:44:09 +08:00
jxxghp
ad12701fe2 chore: update version to 2.13.15 2026-06-24 12:55:32 +08:00
jxxghp
2b426a47c6 Route toast notifications to agent assistant bubbles 2026-06-24 12:55:01 +08:00
553 changed files with 113343 additions and 12622 deletions

1
.env.test Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=/api/v1/

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

@@ -1,10 +1,10 @@
name: Build Moviepilot-Frontend v2
name: Build Moviepilot-Frontend v3
on:
workflow_dispatch:
push:
branches:
- v2
- v3
paths:
- 'package.json'

View File

@@ -1,9 +1,8 @@
name: PR Agent
name: PR-Agent
on:
pull_request_target:
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
# pull_request_target 允许 fork PR 使用仓库 secrets因此 workflow 只运行固定 digest 的 PR-Agent 容器。
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
types:
- opened
- reopened
@@ -11,24 +10,17 @@ on:
- review_requested
- synchronize
issue_comment:
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
# issue_comment 同时覆盖普通 issue因此 job 里还会再判断是否属于 PR。
types:
- created
- edited
permissions:
# 读取仓库内容和 PR diff。
contents: read
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
pull-requests: write
# PR 评论在 GitHub API 中属于 issue comments手动命令和总结评论需要该权限。
issues: write
jobs:
pr-agent:
name: PR-Agent review and describe
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
if: >-
github.event.sender.type != 'Bot' &&
(
@@ -36,88 +28,25 @@ jobs:
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
(
github.event.comment.body == '/review' ||
startsWith(github.event.comment.body, '/review ') ||
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ') ||
github.event.comment.body == '/improve' ||
startsWith(github.event.comment.body, '/improve ') ||
github.event.comment.body == '/ask' ||
startsWith(github.event.comment.body, '/ask ')
)
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association)
)
)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Run PR-Agent
id: pragent
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
uses: docker://pragent/pr-agent:0.37.0-github_action@sha256:4ec7bac814050a1bc8c96ab2fab6b7b0f65df0049a5ec43f3fee1a0b551c28ca
- name: Run PR Review
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
env:
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
GITHUB_TOKEN: ${{ github.token }}
# 仓库设置中添加的 SecretSettings -> Secrets and variables -> Actions。
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
# 模型、输出语言和大 diff 处理策略。
config.model: "gpt-5.5"
config.fallback_models: '["gpt-5.4"]'
config.reasoning_effort: "xhigh"
config.ai_timeout: "900"
config.response_language: "zh-CN"
config.large_patch_policy: "clip"
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
config.ignore_pr_labels: '["skip pr-agent"]'
# pull_request_target 事件默认自动执行 /review 和 /describe/improve 保持手动触发。
github_action_config.auto_review: "true"
github_action_config.auto_describe: "true"
github_action_config.auto_improve: "false"
# 允许触发自动工具的 PR 动作。包含 synchronize便于新 commit 推送后刷新结果。
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
# 保留 action outputs便于后续 workflow 编排或排查。
github_action_config.enable_output: "true"
# /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略。
pr_description.generate_ai_title: "false"
pr_description.publish_labels: "false"
pr_description.enable_pr_diagram: "false"
pr_description.collapsible_file_list: "adaptive"
pr_description.add_original_user_description: "true"
# /review 输出策略,聚焦维护者需要处理的风险和缺口。
pr_reviewer.extra_instructions: |
请用中文输出。
优先指出 P0/P1 风险,避免纠结纯格式问题。
重点检查安全、权限、状态一致性、异步/缓存、副作用和测试缺口。
pr_reviewer.num_max_findings: "5"
pr_reviewer.persistent_comment: "true"
pr_reviewer.publish_output_no_suggestions: "true"
pr_reviewer.require_tests_review: "true"
pr_reviewer.require_security_review: "true"
pr_reviewer.require_estimate_effort_to_review: "true"
pr_reviewer.require_can_be_split_review: "true"
pr_reviewer.require_todo_scan: "false"
pr_reviewer.enable_review_labels_effort: "false"
pr_reviewer.enable_review_labels_security: "true"
# /improve 和 /ask 的手动命令策略。
pr_code_suggestions.focus_only_on_problems: "true"
pr_code_suggestions.suggestions_score_threshold: "7"
pr_code_suggestions.commitable_code_suggestions: "false"
pr_questions.use_conversation_history: "true"
# 可选成本和噪音控制:
# github_action_config.auto_improve: "true"
# config.verbosity_level: "1"
# pr_reviewer.num_max_findings: "3"
PRR_AUTO_REVIEW_SCOPE: all
PRR_ALLOWED_ASSOCIATIONS: '["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'
PRR_DISABLED_COMMANDS: '["/improve"]'
PRR_SKIP_LABEL: skip pr-agent
PRR_SKIP_TITLE_PATTERN: '^(?:\[Auto\]|Auto)'
config.response_language: zh-CN

86
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: Frontend Tests
on:
pull_request:
branches:
- v3
push:
branches:
- v3
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
format:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- 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-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '24'
cache: yarn
cache-dependency-path: yarn.lock
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Typecheck
run: yarn typecheck
- name: Unit tests
run: yarn test:run

2
.gitignore vendored
View File

@@ -12,6 +12,7 @@ node_modules
dist
dist-ssr
dev-dist
coverage
*.local
package-lock.json
@@ -38,3 +39,4 @@ public/plugin_icon/**
docs-lock/
.trae/
output/
.codex-qa/

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`
## 特性
@@ -40,6 +40,17 @@ yarn dev
yarn build
```
### 单元测试
```sh
yarn test:run
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主测试 job 使用 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:run` CI 自动执行。
`eslint-suppressions.json` 只冻结迁移时已经确认的文件、规则和数量。新增问题不得加入 baseline修复存量问题后运行 `yarn lint:suppressions:prune`,并把裁剪结果与代码修复一同提交。日常开发和 CI 不得使用 `--suppress-all`
迁移完成的最低验收为:
```sh
yarn --frozen-lockfile
yarn lint
yarn typecheck
yarn test:run
yarn build
```
若新配置导致大量与业务目标无关的格式、排序或额外文件类型问题,应停止扩大规则范围,先调整配置职责;不得以一次性自动修复数百个文件作为通过迁移的手段。
## ESLint CI先观察再强制
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job
1. lint 与 `typecheck-and-tests` 使用不同 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/v3``refs/remotes/origin/v3``refs/heads/v3` 的顺序选择与当前提交具有共同祖先的基线并合并当前工作区与未跟踪文件。完整引用可避免同名标签造成歧义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:run``yarn build` 在格式收敛后全部通过。
- Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。
- workflow 已以非 required 状态稳定运行多个 PR。
最终 Pull Request 门禁目标为:
```sh
yarn --frozen-lockfile
yarn format:all:check
yarn lint
yarn typecheck
yarn test:run
yarn build
```
PR-Agent、编辑器诊断和人工 review 可以补充判断,但不能替代可重复执行的项目命令。
## Agent 与开发者提交规范
无论改动由 Agent 还是人工完成,提交前遵循同一顺序:
1. 只对本次新增或修改的文件执行 Prettier。
2. 运行全仓只读 lint。
3. 运行 typecheck。
4. 按影响面运行聚焦测试;覆盖率报告按需本地运行,不作为 Actions 门禁。
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. 调试与排错
### 常见问题
@@ -449,24 +545,19 @@ shared: {
}
```
### 8.3 开发环境测试
### 8.3 本地监听构建
开发期间可以使用以下配置在本地测试
插件前端可使用 Vite 的监听构建模式
```typescript
// vite.config.ts
export default defineConfig({
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
}
})
```bash
yarn dev
```
`dev` 脚本配置为 `vite build --watch` 后,源码变化会自动重新构建。使用本地插件仓并启用 `DEV``PLUGIN_AUTO_RELOAD`MoviePilot 会同步新的构建产物;刷新页面即可看到修改。
## 9. 示例代码
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
- [模块联邦问题排查指南](./federation-troubleshooting.md) - 常见问题排查
## 10. 参考资料
@@ -476,4 +567,4 @@ export default defineConfig({
---
如有问题请提交Issue。
如有问题请提交Issue。

95
docs/testing.md Normal file
View File

@@ -0,0 +1,95 @@
# 单元测试架构
MoviePilot-Frontend 使用 Vitest 运行单元测试和组件测试,使用 jsdom 提供 DOM 环境。测试代码参与 TypeScript 类型检查,但不作为生产构建入口。
## 测试类型
- 单元测试覆盖纯函数、store、composable、路由规则和独立模块的输入、输出及副作用。
- 组件测试挂载 Vue 组件或页面,覆盖 props、emits、用户交互、可见 DOM、Router、Pinia、HTTP 请求和生命周期清理。
- PWA、Service Worker、模块联邦远程入口、真实布局、拖拽和浏览器原生能力由真实浏览器验证不由 jsdom 测试单独证明。
## 目录结构
业务 spec 与源码共置在对应责任域的 `__tests__/` 目录中,文件名与被测源码保持一致并使用 `*.spec.ts`
```text
src/
├── pages/
│ ├── recommend.vue
│ └── __tests__/recommend.spec.ts
├── stores/
│ ├── auth.ts
│ └── __tests__/auth.spec.ts
├── utils/
│ ├── permission.ts
│ └── __tests__/permission.spec.ts
└── views/dashboard/
├── MediaRecommend.vue
└── __tests__/MediaRecommend.spec.ts
```
跨业务 spec 复用的测试设施位于 `tests/`
```text
tests/
├── setup.ts
└── support/
├── render.ts
├── factories/
└── msw/
├── server.ts
└── handlers/
```
- `tests/setup.ts` 注册 DOM matcher、MSW 生命周期、浏览器 API stub 和每例清理逻辑。
- `tests/support/render.ts` 提供带 Vuetify、i18n、Router 和 Pinia 的标准渲染入口。
- `tests/support/factories/` 按业务对象提供最小有效测试数据工厂。
- `tests/support/msw/handlers/` 按业务域定义 HTTP handler`server.ts` 只负责 MSW server 实例。
- spec 通过 `@tests/*` 访问共享测试设施,通过 `@/*` 访问生产源码。
## 工具职责
- Vitest 提供 runner、断言、mock、fake timers 和覆盖率执行入口。
- Vue Test Utils 用于 Vue 特有的 props、emits、slots 和局部组件控制。
- Testing Library、jest-dom 和 user-event 用于按角色、可访问名称和用户操作验证可见行为。
- MSW 在 HTTP 边界拦截真实 API 客户端请求。未声明请求会使测试失败,测试不得访问真实后端或外网。
- `@pinia/testing` 用于依赖 store 的组件测试store 自身使用真实 `createPinia()` 测试。
## 编写规范
- 一个 spec 对应一个主要源码文件;测试名称描述可观察行为或业务规则。
- 组件测试断言可见 DOM、emits、路由、请求和持久化结果不读取组件私有状态或私有方法。
- 纯逻辑优先直接调用依赖生命周期、provide 或 inject 的 composable 通过宿主组件挂载。
- HTTP handler 和 factory 按业务域拆分,不建立包含所有接口或所有数据字段的全局万能 mock。
- 只在与当前断言无关或无法由 jsdom 正确执行时 stub 子组件、浏览器能力或第三方重型组件。
- 每个用例保持独立不依赖文件执行顺序timer、mock、storage、DOM 和未完成请求由全局 setup 恢复。
- 不使用大面积快照或覆盖率占位用例。
## 新增测试
1. 业务测试在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`;工具链配置契约测试放在 `tests/config/`
2. 纯函数、store 和无渲染模块直接使用 VitestVue 组件使用标准渲染入口。
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
5. 核心覆盖范围发生变化时,同步更新 `vite.config.ts``coverage.include`
6. 提交前按影响面运行测试、类型检查、lint 和生产构建;覆盖率报告按需本地执行。
## 配置边界
Vitest 收集 `src/**/__tests__/**/*.spec.ts``tests/config/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。配置契约测试随全量测试执行,但不加入业务源码覆盖率统计范围。
当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护,覆盖率报告写入 `coverage/`。覆盖率配置保留给本地质量分析,不作为 GitHub Actions 的单元测试门禁。
## 命令与 CI
```sh
yarn test # watch 模式
yarn test:run # 单次运行
yarn test:coverage # 单次运行并检查覆盖率
yarn test:lint-config # 聚焦执行 ESLint 配置契约测试
yarn typecheck
yarn lint
yarn build
```
`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile在面向 `v3` 的 Pull Request 和推送到 `v3` 时运行。`typecheck-and-tests` job 执行类型检查和单元测试(`yarn test:run`),不要求覆盖率达标;覆盖率可按需在本地运行 `yarn test:coverage`。独立的 `lint` job 执行全仓只读 ESLint 检查。变更文件格式检查依赖 Pull Request 的 base/head SHA因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。

1054
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,16 +1,28 @@
{
"name": "moviepilot",
"version": "2.13.14",
"version": "3.0.0",
"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"
@@ -20,6 +32,10 @@
"dist/**/*"
]
},
"resolutions": {
"vitest/**/vite": "5.4.18",
"vitest/vite": "5.4.18"
},
"dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
@@ -50,6 +66,7 @@
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"gridstack": "^12.6.0",
"gsap": "^3.15.0",
"http-proxy-middleware": "^3.0.0",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",
@@ -57,11 +74,12 @@
"markdown-it-link-attributes": "^4.0.1",
"mousetrap": "^1.6.5",
"nprogress": "^0.2.0",
"pinia": "^3.0.1",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.2.0",
"qrcode": "^1.5.4",
"sass": "^1.83.4",
"tailwindcss": "^ 3.4.17",
"three": "^0.185.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"vue-toastification": "^2.0.0-rc.5",
@@ -73,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",
@@ -82,7 +101,12 @@
"@iconify/vue": "^4.3.0",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@originjs/vite-plugin-federation": "^1.4.1",
"@pinia/testing": "1.0.3",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@testing-library/dom": "9.3.4",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/user-event": "14.6.1",
"@testing-library/vue": "8.1.0",
"@types/body-scroll-lock": "^3.1.2",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2",
@@ -91,22 +115,24 @@
"@types/node": "^20.1.4",
"@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.6",
"@types/three": "^0.185.1",
"@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitest/coverage-v8": "3.2.7",
"@vue/compiler-dom": "3.5.13",
"@vue/test-utils": "2.4.11",
"autoprefixer": "^10.4.14",
"eslint": "^9.18.0",
"eslint-import-resolver-typescript": "^3.5.1",
"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",
@@ -114,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",
@@ -123,10 +150,15 @@
"vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.4",
"vitest": "3.2.7",
"vue-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

@@ -65,13 +65,14 @@ http {
root html;
}
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$|message/agent/stream$) {
# SSE MIME类型设置
default_type text/event-stream;
# 禁用缓存
add_header Cache-Control no-cache;
add_header Cache-Control "no-cache, no-transform";
add_header X-Accel-Buffering no;
gzip off;
proxy_buffering off;
proxy_cache off;

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/v3', 'refs/remotes/origin/v3', 'refs/heads/v3']
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024
const PRETTIER_ARGUMENT_BUDGET = 24_000
const prettierCliPath = fileURLToPath(new URL('../node_modules/prettier/bin/prettier.cjs', import.meta.url))
/** 解析 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
}
/** 为本地命令选择与当前提交具有共同祖先的 v3 基线。 */
function resolveDefaultBase(cwd, headCommit) {
for (const ref of DEFAULT_BASE_REFS) {
const commit = tryResolveCommit(cwd, ref)
if (commit && hasMergeBase(cwd, commit, headCommit)) return commit
}
throw new Error('无法定位可比较的 v3 基线,请通过 --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

@@ -72,7 +72,7 @@ function handleCancel() {
</div>
</div>
</VCardItem>
<VCardActions class="mx-auto">
<VCardActions class="app-confirm-dialog-actions mx-auto">
<VBtn variant="tonal" color="secondary" class="px-5" @click="handleCancel">
{{ cancelText }}
</VBtn>

View File

@@ -1,12 +1,13 @@
<script lang="ts" setup>
// 定义输入参数
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
// 是否显示
/** 覆盖关闭按钮默认的右上角定位 class。 */
innerClass: String,
})
// 定义触发的自定义事件
const emit = defineEmits(['click', 'update:modelValue'])
// 按钮点击
function onClick() {
emit('update:modelValue', false)
emit('click')
@@ -15,6 +16,7 @@ function onClick() {
<template>
<IconBtn
:aria-label="t('common.close')"
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'"
@click.stop="onClick"
>

View File

@@ -0,0 +1,40 @@
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import i18n from '@/plugins/i18n'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { afterEach, describe, expect, it } from 'vitest'
afterEach(() => {
i18n.global.locale.value = 'zh-CN'
})
describe('DialogCloseBtn', () => {
it('keeps its visual and event contracts while exposing a localized name', async () => {
const user = userEvent.setup()
const { container, emitted, rerender } = await renderWithProviders(DialogCloseBtn)
const button = screen.getByRole('button', { name: '关闭' })
expect(button).toHaveClass('absolute', 'right-3', 'top-3', 'z-10')
const icon = container.querySelector('svg.v-icon')
expect(icon).not.toBeNull()
expect(icon).toHaveAttribute('aria-hidden', 'true')
await waitFor(() =>
expect(icon?.querySelector('path')).toHaveAttribute(
'd',
'M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z',
),
)
i18n.global.locale.value = 'en-US'
await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toBe(button))
await user.click(button)
expect(emitted()['update:modelValue']).toEqual([[false]])
expect(emitted().click).toEqual([[]])
await rerender({ innerClass: 'dialog-close-custom' })
expect(button).toHaveClass('dialog-close-custom')
expect(button).not.toHaveClass('absolute', 'right-3', 'top-3', 'z-10')
})
})

View File

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

View File

@@ -3,6 +3,9 @@
// 👉 Apex chart
.apexcharts-canvas {
// 图表仅保留纵向页面滚动,禁止双指缩放等触摸手势。
touch-action: pan-y;
// For RTL alignment
.apexcharts-yaxis-texts-g {
text-align: start;

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

@@ -66,6 +66,43 @@ export const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : valu
// 格式化为Sxx
export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
/**
* 格式化为 SxxExx 季集标识,多个连续集会合并为范围。
*/
export function formatSeasonEpisode(
season: number | string | null | undefined,
episodeNumbers: number[],
): string {
const seasonText = season === null || season === undefined || season === '' ? '' : formatSeason(String(season))
const normalizedNumbers = [...new Set(episodeNumbers)]
.map(number => Number(number))
.filter(number => Number.isFinite(number) && number > 0)
.sort((first, second) => first - second)
if (!normalizedNumbers.length) return seasonText
const formatEpisode = (number: number) => `E${String(number).padStart(2, '0')}`
const ranges: string[] = []
let start = normalizedNumbers[0]
let end = normalizedNumbers[0]
for (let index = 1; index < normalizedNumbers.length; index++) {
const currentNumber = normalizedNumbers[index]
if (currentNumber === end + 1) {
end = currentNumber
} else {
ranges.push(start === end ? formatEpisode(start) : `${formatEpisode(start)}-${formatEpisode(end)}`)
start = currentNumber
end = currentNumber
}
}
ranges.push(start === end ? formatEpisode(start) : `${formatEpisode(start)}-${formatEpisode(end)}`)
return `${seasonText}${ranges.join('、')}`
}
// 格式化为xx[TGMK]B
export function formatFileSize(bytes: number, decimals = 2, prefix = 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">v3</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;
}

View File

@@ -1,6 +1,6 @@
import type { Component, Ref, VNode } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { UserPermissionKey } from '@/utils/permission'
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
import { ContentWidth, FooterType, NavbarType } from './enums'
export interface UserConfig {
@@ -113,14 +113,18 @@ export interface NavLinkProps {
rel?: ATagRelAttrValues
}
export type NavMenuIconColor = 'primary' | 'info' | 'success' | 'warning' | 'secondary'
export interface NavLink extends NavLinkProps, Partial<AclProperties> {
title: string
full_title?: string
icon?: unknown
iconColor?: NavMenuIconColor
badgeContent?: string
badgeClass?: string
disable?: boolean
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
export interface NavMenuTabItem {
@@ -128,6 +132,8 @@ export interface NavMenuTabItem {
icon?: string
tab: string
description?: string
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
export interface NavMenu extends NavLink {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,176 @@
import ace from 'ace-builds'
import { describe, expect, it } from 'vitest'
import '@/ace-config'
interface AceToken {
type: string
value: string
}
interface WordListMode {
getTokenizer: () => {
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
}
}
const WordListMode = ace.require('ace/mode/word_list').Mode as new (options?: { syntax?: boolean }) => WordListMode
const syntaxTokenizer = new WordListMode({ syntax: true }).getTokenizer()
const plainTokenizer = new WordListMode().getTokenizer()
function tokenize(line: string) {
return syntaxTokenizer.getLineTokens(line, 'start').tokens
}
function tokenizePlain(line: string) {
return plainTokenizer.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('parses valid replacement parameters', () => {
expect(tokenize('旧名.* => 新名 {[tmdbid=123;type=tv]}')).toEqual([
{ type: 'word_list_replaced', value: '旧名.*' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 ' },
{ type: 'word_list_parameter_syntax', value: '{[' },
{ type: 'word_list_parameter_key', value: 'tmdbid' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '123' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'type' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: 'tv' },
{ type: 'word_list_parameter_syntax', value: ']}' },
])
})
it('marks invalid replacement parameter keys and values', () => {
expect(tokenize('旧名 => 新名 {[unknown=1;tmdbid=abc;type=anime;g=group;s=2;e=3]}')).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 ' },
{ type: 'word_list_parameter_syntax', value: '{[' },
{ type: 'invalid.word-list', value: 'unknown' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '1' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'tmdbid' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'invalid.word-list', value: 'abc' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'type' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'invalid.word-list', value: 'anime' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'g' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: 'group' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 's' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '2' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'e' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '3' },
{ type: 'word_list_parameter_syntax', value: ']}' },
])
})
it('marks an unknown replacement parameter key as invalid', () => {
expect(tokenize('旧名 => {[unknown=1]}')).toContainEqual({ type: 'invalid.word-list', value: 'unknown' })
})
it('marks visible syntax for missing or malformed replacement parameters', () => {
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123;broken;;]}')
.filter(token => token.type === 'invalid.word-list')
.map(token => token.value)
expect(invalidTokenValues).toEqual(['tmdbid=', '=123', 'broken;;'])
expect(tokenize('旧名 => {[]}')).toContainEqual({ type: 'invalid.word-list', value: '{[]}' })
})
it('marks the second semicolon when it creates an empty parameter', () => {
const semicolonTokens = tokenize('旧名 => {[tmdbid=1;;type=tv]}').filter(token => token.value.includes(';'))
expect(semicolonTokens).toEqual([{ type: 'invalid.word-list', value: ';;' }])
})
it('marks both sides of missing keys and values', () => {
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123]}')
.filter(token => token.type === 'invalid.word-list')
.map(token => token.value)
expect(invalidTokenValues).toEqual(['tmdbid=', '=123'])
})
it('treats an unclosed parameter block as ordinary replacement text', () => {
expect(tokenize('旧名 => 新名 {[tmdbid=1')).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 {[tmdbid=1' },
])
})
it('accepts every supported replacement parameter type', () => {
expect(
tokenize('旧名 => {[tmdbid=1;doubanid=2;bangumiid=3;anilistid=4;type=movie;g=group;s=5;e=6]}'),
).not.toContainEqual(expect.objectContaining({ type: 'invalid.word-list' }))
})
it('accepts season and episode number ranges', () => {
expect(tokenize('旧名 => {[s=1-2;e=3-5]}')).not.toContainEqual(
expect.objectContaining({ type: 'invalid.word-list' }),
)
})
it('separates front, back, and episode offset fields', () => {
expect(tokenize('第 <> 集 >> EP+1')).toEqual([
{ type: 'word_list_front', value: '第' },
{ 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')
})
it('highlights comment lines', () => {
expect(tokenize('# 这是一个注释')).toEqual([{ type: 'comment.word-list', value: '# 这是一个注释' }])
})
})
describe('word list plain mode', () => {
it('does not highlight comment lines', () => {
expect(tokenizePlain('# 这是一个注释')).toEqual([{ type: 'text', value: '# 这是一个注释' }])
})
it('does not highlight word list fields', () => {
expect(tokenizePlain('旧名 => 新名 && 第 <> 集 >> 2*EP-1')).toEqual([
{ type: 'text', value: '旧名 => 新名 && 第 <> 集 >> 2*EP-1' },
])
})
it('returns no tokens for empty lines', () => {
expect(tokenizePlain('')).toEqual([])
})
})

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(
{
@@ -530,6 +530,278 @@ function registerJinja2Mode() {
)
}
interface WordListToken {
type: string
value: string
}
const wordListReplacementParametersPattern = /\{\[([^\]]*)\]\}/g
const wordListUnsignedIntegerPattern = /^\d+$/
const wordListUnsignedIntegerOrRangePattern = /^\d+(?:-\d+)?$/
const wordListParameterTypes = {
tmdbid: 'uint',
doubanid: 'uint',
bangumiid: 'uint',
anilistid: 'uint',
type: 'media-type',
g: 'string',
s: 'uint-or-range',
e: 'uint-or-range',
} as const
function appendWordListToken(tokens: WordListToken[], type: string, value: string) {
if (!value) return
const previousToken = tokens.at(-1)
if (previousToken?.type === type) previousToken.value += value
else tokens.push({ type, value })
}
function isValidWordListParameterValue(key: keyof typeof wordListParameterTypes, value: string) {
switch (wordListParameterTypes[key]) {
case 'uint':
return wordListUnsignedIntegerPattern.test(value)
case 'uint-or-range':
return wordListUnsignedIntegerOrRangePattern.test(value)
case 'media-type':
return value === 'movie' || value === 'tv'
case 'string':
return value.length > 0
}
}
function tokenizeWordListParameters(parameters: string): WordListToken[] {
const tokens: WordListToken[] = []
const parameterList = parameters.split(';')
parameterList.forEach((parameter, index) => {
const isEmptyParameter = parameter.length === 0
if (index > 0) {
appendWordListToken(
tokens,
parameterList[index - 1] === '' || isEmptyParameter ? 'invalid.word-list' : 'word_list_parameter_syntax',
';',
)
}
if (isEmptyParameter) return
const equalsIndex = parameter.indexOf('=')
if (equalsIndex === -1) {
appendWordListToken(tokens, 'invalid.word-list', parameter)
return
}
const key = parameter.slice(0, equalsIndex)
const value = parameter.slice(equalsIndex + 1)
const hasKey = key.length > 0
const hasValue = value.length > 0
const isKnownKey = hasKey && Object.hasOwn(wordListParameterTypes, key)
appendWordListToken(tokens, isKnownKey && hasValue ? 'word_list_parameter_key' : 'invalid.word-list', key)
appendWordListToken(tokens, hasKey && hasValue ? 'word_list_parameter_syntax' : 'invalid.word-list', '=')
const isValidValue =
isKnownKey &&
!value.includes('=') &&
isValidWordListParameterValue(key as keyof typeof wordListParameterTypes, value)
appendWordListToken(
tokens,
isValidValue || (!isKnownKey && hasKey) ? 'word_list_parameter_value' : 'invalid.word-list',
value,
)
})
return tokens
}
function tokenizeWordListReplacement(replacement: string): WordListToken[] {
const tokens: WordListToken[] = []
let replacementStart = 0
for (const match of replacement.matchAll(wordListReplacementParametersPattern)) {
const parameterStart = match.index ?? 0
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart, parameterStart))
if (!match[1]) appendWordListToken(tokens, 'invalid.word-list', match[0])
else {
appendWordListToken(tokens, 'word_list_parameter_syntax', '{[')
tokenizeWordListParameters(match[1]).forEach(token => appendWordListToken(tokens, token.type, token.value))
appendWordListToken(tokens, 'word_list_parameter_syntax', ']}')
}
replacementStart = parameterStart + match[0].length
}
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart))
return tokens
}
function splitWordListField(value: string, operator: string) {
let operatorIndex = value.indexOf(operator)
while (operatorIndex !== -1) {
const operatorEnd = operatorIndex + operator.length
if (value[operatorIndex - 1] === ' ' && value[operatorEnd] === ' ') {
let operatorStart = operatorIndex
while (value[operatorStart - 1] === ' ') operatorStart -= 1
let operatorWithSpacesEnd = operatorEnd
while (value[operatorWithSpacesEnd] === ' ') operatorWithSpacesEnd += 1
return {
before: value.slice(0, operatorStart),
operator: value.slice(operatorStart, operatorWithSpacesEnd),
after: value.slice(operatorWithSpacesEnd),
}
}
operatorIndex = value.indexOf(operator, operatorEnd)
}
}
function getWordListLineWhitespace(value: string) {
const leadingSpaceLength = value.length - value.trimStart().length
const trailingSpaceLength = value.length - value.trimEnd().length
return {
leadingSpace: value.slice(0, leadingSpaceLength),
content: value.slice(leadingSpaceLength, value.length - trailingSpaceLength),
trailingSpace: value.slice(value.length - trailingSpaceLength),
}
}
function tokenizeWordListReplacementLine(value: string): WordListToken[] {
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
const replacementField = splitWordListField(content, '=>')
if (!replacementField) return [{ type: 'text', value }]
const tokens: WordListToken[] = []
appendWordListToken(tokens, 'text', leadingSpace)
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
tokenizeWordListReplacement(replacementField.after).forEach(token =>
appendWordListToken(tokens, token.type, token.value),
)
appendWordListToken(tokens, 'text', trailingSpace)
return tokens
}
function tokenizeCombinedWordListLine(value: string): WordListToken[] {
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
const replacementField = splitWordListField(content, '=>')
const frontField = replacementField && splitWordListField(replacementField.after, '&&')
const backField = frontField && splitWordListField(frontField.after, '<>')
const offsetField = backField && splitWordListField(backField.after, '>>')
if (!replacementField || !frontField || !backField || !offsetField) return [{ type: 'text', value }]
const tokens: WordListToken[] = []
appendWordListToken(tokens, 'text', leadingSpace)
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
tokenizeWordListReplacement(frontField.before).forEach(token => appendWordListToken(tokens, token.type, token.value))
appendWordListToken(tokens, 'keyword.operator.word-list', frontField.operator)
appendWordListToken(tokens, 'word_list_front', backField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', backField.operator)
appendWordListToken(tokens, 'word_list_back', offsetField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', offsetField.operator)
appendWordListToken(tokens, 'word_list_offset', offsetField.after)
appendWordListToken(tokens, 'text', trailingSpace)
return tokens
}
function buildWordListRules(syntax: boolean) {
if (!syntax) {
return {
start: [
{
token: 'empty_line',
regex: '^$',
},
{
defaultToken: 'text',
},
],
}
}
return {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
{
token: 'text',
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
onMatch: tokenizeCombinedWordListLine,
},
{
token: 'text',
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
onMatch: tokenizeWordListReplacementLine,
},
{
token: [
'text',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_block', 'text'],
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
},
],
}
}
function registerWordListMode() {
aceModule.define?.(
'ace/mode/word_list_highlight_rules',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
const WordListHighlightRules = function (this: any, options?: { syntax?: boolean }) {
this.$rules = buildWordListRules(options?.syntax === true)
this.normalizeRules()
}
oop.inherits(WordListHighlightRules, TextHighlightRules)
exports.WordListHighlightRules = WordListHighlightRules
},
)
aceModule.define?.(
'ace/mode/word_list',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextMode = require('./text').Mode
const WordListHighlightRules = require('./word_list_highlight_rules').WordListHighlightRules
const Mode = function (this: any, options?: { syntax?: boolean }) {
TextMode.call(this)
this.HighlightRules = WordListHighlightRules
this.$highlightRuleConfig = options || {}
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.$id = 'ace/mode/word_list'
}).call(Mode.prototype)
exports.Mode = Mode
},
)
}
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
@@ -555,4 +827,5 @@ ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
registerJinja2Mode()
registerWordListMode()
ace.require('ace/ext/language_tools')

View File

@@ -26,6 +26,11 @@ export const storageAttributes = [
icon: 'mdi-server-network-outline',
remote: true,
},
{
type: 'alistgo',
icon: 'mdi-server-network-outline',
remote: true,
},
{
type: 'smb',
icon: 'mdi-folder-network-outline',
@@ -33,15 +38,21 @@ export const storageAttributes = [
},
]
export const storageIconDict = storageAttributes.reduce((dict, item) => {
dict[item.type] = item.icon
return dict
}, {} as Record<string, string>)
export const storageIconDict = storageAttributes.reduce(
(dict, item) => {
dict[item.type] = item.icon
return dict
},
{} as Record<string, string>,
)
export const storageRemoteDict = storageAttributes.reduce((dict, item) => {
dict[item.type] = item.remote
return dict
}, {} as Record<string, boolean>)
export const storageRemoteDict = storageAttributes.reduce(
(dict, item) => {
dict[item.type] = item.remote
return dict
},
{} as Record<string, boolean>,
)
export const downloaderOptions = [
{
@@ -58,10 +69,13 @@ export const downloaderOptions = [
},
]
export const downloaderDict = downloaderOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const downloaderDict = downloaderOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
export const mediaServerOptions = [
{
@@ -88,12 +102,19 @@ export const mediaServerOptions = [
value: 'ugreen',
title: i18n.global.t('setting.system.ugreen'),
},
{
value: 'navidrome',
title: i18n.global.t('setting.system.navidrome'),
},
]
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const mediaServerDict = mediaServerOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
export const innerFilterRules = [
{ title: i18n.global.t('filterRules.specSub'), value: ' SPECSUB ' },
@@ -104,6 +125,19 @@ export const innerFilterRules = [
{ title: i18n.global.t('filterRules.hkVoi'), value: ' HKVOI ' },
{ title: i18n.global.t('filterRules.notHkVoi'), value: ' !HKVOI ' },
{ title: i18n.global.t('filterRules.free'), value: ' FREE ' },
{ title: i18n.global.t('filterRules.audioHires'), value: ' HIRES ' },
{ title: i18n.global.t('filterRules.audioLossless'), value: ' LOSSLESS ' },
{ title: i18n.global.t('filterRules.audioFlac'), value: ' FLAC ' },
{ title: i18n.global.t('filterRules.audioAlac'), value: ' ALAC ' },
{ title: i18n.global.t('filterRules.audioApe'), value: ' APE ' },
{ title: i18n.global.t('filterRules.audioWav'), value: ' WAV ' },
{ title: i18n.global.t('filterRules.audioDsd'), value: ' DSD ' },
{ title: i18n.global.t('filterRules.audioMp3'), value: ' MP3 ' },
{ title: i18n.global.t('filterRules.audioAac'), value: ' AAC ' },
{ title: i18n.global.t('filterRules.audioOpus'), value: ' OPUS ' },
{ title: i18n.global.t('filterRules.bitrate320'), value: ' BITRATE320 ' },
{ title: i18n.global.t('filterRules.bitrate256'), value: ' BITRATE256 ' },
{ title: i18n.global.t('filterRules.bitrate192'), value: ' BITRATE192 ' },
{ title: i18n.global.t('filterRules.resolution4k'), value: ' 4K ' },
{ title: i18n.global.t('filterRules.resolution1080p'), value: ' 1080P ' },
{ title: i18n.global.t('filterRules.resolution720p'), value: ' 720P ' },
@@ -226,6 +260,46 @@ export const effectOptions = ref([
},
])
// 音乐音质等级选择框数据
export const audioQualityOptions = ref([
{ title: i18n.global.t('audioQualityOptions.all'), value: '' },
{ title: i18n.global.t('audioQualityOptions.hires'), value: 'hires' },
{ title: i18n.global.t('audioQualityOptions.lossless'), value: 'hires|lossless' },
{ title: i18n.global.t('audioQualityOptions.lossy'), value: 'lossy' },
])
// 音频格式选择框数据
export const audioFormatOptions = ref([
{ title: i18n.global.t('audioFormatOptions.all'), value: '' },
{ title: i18n.global.t('audioFormatOptions.lossless'), value: 'DSD|FLAC|ALAC|APE|WAV|AIFF|PCM' },
...['DSD', 'FLAC', 'ALAC', 'APE', 'WAV', 'AIFF', 'PCM', 'MP3', 'AAC', 'OGG', 'OPUS', 'WMA'].map(value => ({
title: value,
value,
})),
])
export const audioBitrateOptions = [
{ title: '128 kbps', value: 128000 },
{ title: '192 kbps', value: 192000 },
{ title: '256 kbps', value: 256000 },
{ title: '320 kbps', value: 320000 },
]
export const audioBitDepthOptions = [
{ title: '16-bit', value: 16 },
{ title: '24-bit', value: 24 },
{ title: '32-bit', value: 32 },
]
export const audioSampleRateOptions = [
{ title: '44.1 kHz', value: 44100 },
{ title: '48 kHz', value: 48000 },
{ title: '88.2 kHz', value: 88200 },
{ title: '96 kHz', value: 96000 },
{ title: '176.4 kHz', value: 176400 },
{ title: '192 kHz', value: 192000 },
]
// 媒体类型选项
export const mediaTypeOptions = [
{
@@ -236,6 +310,10 @@ export const mediaTypeOptions = [
title: i18n.global.t('mediaType.tv'),
value: '电视剧',
},
{
title: i18n.global.t('mediaType.music'),
value: '音乐',
},
{
title: i18n.global.t('mediaType.anime'),
value: '动漫',
@@ -251,10 +329,13 @@ export const mediaTypeOptions = [
]
// 媒体类型字典
export const mediaTypeDict = mediaTypeOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const mediaTypeDict = mediaTypeOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
// 通知开关选项
export const notificationSwitchOptions = [
@@ -297,10 +378,13 @@ export const notificationSwitchOptions = [
]
// 通知开关字典
export const notificationSwitchDict = notificationSwitchOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const notificationSwitchDict = notificationSwitchOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
// 操作步骤选项
export const actionStepOptions = [
@@ -367,7 +451,10 @@ export const actionStepOptions = [
]
// 操作步骤字典
export const actionStepDict = actionStepOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const actionStepDict = actionStepOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)

View File

@@ -1,14 +1,19 @@
import axios from 'axios'
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios'
import router from '@/router'
import { useAuthStore } from '@/stores'
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
import { getCurrentLocale } from '@/plugins/i18n'
// 创建axios实例
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
})
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
skipConnectionTracking?: boolean
}
// 声明全局变量类型
declare global {
interface Window {
@@ -30,42 +35,68 @@ api.interceptors.request.use(config => {
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
const locale = getCurrentLocale()
config.headers['X-MoviePilot-Locale'] = locale
config.headers['Accept-Language'] = locale
return config
})
// 离线状态管理
const globalOfflineStatus = useGlobalOfflineStatus()
/** 将 Axios 连接错误归类为全局服务探测可识别的原因。 */
function resolveConnectionFailureReason(error: AxiosError): 'network-error' | 'timeout' | null {
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout'
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') {
return 'network-error'
}
return null
}
interface LocalizedApiPayload {
detail?: unknown
detail_i18n?: unknown
message?: unknown
message_i18n?: unknown
}
/** 前端展示默认使用后端提供的多语言消息,同时不改变后端接口兼容字段。 */
function normalizeLocalizedMessage(payload: any): any {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload
const localizedPayload = payload as LocalizedApiPayload
if (typeof localizedPayload.message_i18n === 'string' && localizedPayload.message_i18n) {
localizedPayload.message = localizedPayload.message_i18n
}
if (typeof localizedPayload.detail_i18n === 'string' && localizedPayload.detail_i18n) {
localizedPayload.detail = localizedPayload.detail_i18n
}
return payload
}
// 添加响应拦截器
api.interceptors.response.use(
response => {
// 成功响应时,清除应用离线状态并重置连续错误计数
globalOfflineStatus.setAppOffline(false)
globalOfflineStatus.resetConsecutiveErrors()
return response.data
// 任意 API 成功响应都可以证明 MoviePilot 服务当前可达。
globalOfflineStatus.markServerOnline()
return normalizeLocalizedMessage(response.data)
},
error => {
(error: AxiosError) => {
if (!error.response) {
// 网络错误或请求超时 - 通知离线状态管理系统
const isNetworkError =
error.code === 'NETWORK_ERROR' ||
error.code === 'ERR_NETWORK' ||
error.code === 'ECONNABORTED' ||
error.name === 'NetworkError'
const requestConfig = error.config as ConnectionAwareRequestConfig | undefined
const failureReason = resolveConnectionFailureReason(error)
if (isNetworkError) {
let reason = 'Network connection failed'
if (error.code === 'ECONNABORTED') {
reason = 'Request timeout'
}
// 记录网络错误,只有连续三次才会设置为离线模式
globalOfflineStatus.recordNetworkError(reason)
// 普通请求失败只触发权威探测;探测请求自身失败由心跳管理器处理,避免递归。
if (!requestConfig?.skipConnectionTracking && failureReason) {
globalOfflineStatus.reportNetworkError(failureReason)
}
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
// 网络连接问题
return Promise.reject(new Error('Network connection failed, please check your network status'))
} else if (error.code === 'ECONNABORTED') {
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
// 请求超时
return Promise.reject(new Error('Request timeout, please try again later'))
} else if (error.name === 'AbortError') {
@@ -75,12 +106,15 @@ api.interceptors.response.use(
// 其他网络错误
return Promise.reject(new Error(error.message || 'Network error'))
} else if (error.response.status === 403) {
normalizeLocalizedMessage(error.response.data)
// 认证 Store
const authStore = useAuthStore()
// 清除登录状态信息
authStore.logout()
// token验证失败跳转到登录页面
router.push('/login')
} else {
normalizeLocalizedMessage(error.response.data)
}
return Promise.reject(error)

View File

@@ -1,3 +1,15 @@
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz' | (string & {})
// 手动刮削选项
export interface ManualScrapeOptions {
// 媒体数据源
media_source: MediaDataSource
// 数据源原生ID
media_id?: string
// 媒体类型
type_name?: string
}
// 订阅
export interface Subscribe {
// 订阅ID
@@ -6,7 +18,7 @@ export interface Subscribe {
name: string
// 订阅年份
year: string
// 订阅类型 电影/电视剧
// 订阅类型 电影/电视剧/音乐
type: string
// 搜索关键字
keyword?: string
@@ -15,7 +27,17 @@ export interface Subscribe {
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: string
bangumiid?: number
// AniList ID
anilistid?: number
// 主媒体数据源
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string
// 音乐实体类型recording 单曲、album 专辑、artist 艺术家
music_type?: MusicEntityType
// 专辑总曲目数
total_tracks?: number
// 其它媒体ID
mediaid?: string
// 季号
@@ -40,6 +62,16 @@ export interface Subscribe {
resolution?: string
// 特效
effect?: string
// 音乐音质等级正则
audio_quality?: string
// 音频格式正则
audio_format?: string
// 最低码率bps
min_bitrate?: number
// 最低位深bit
min_bit_depth?: number
// 最低采样率Hz
min_sample_rate?: number
// 总集数
total_episode?: number
// 开始集数
@@ -66,6 +98,14 @@ export interface Subscribe {
search_imdbid?: any
// 当前优先级
current_priority: number
// 当前音乐版本格式
current_audio_format?: string
// 当前音乐版本码率bps
current_bitrate?: number
// 当前音乐版本位深bit
current_bit_depth?: number
// 当前音乐版本采样率Hz
current_sample_rate?: number
// 洗版时已下载剧集的优先级状态
episode_priority?: Record<string, number>
// 保存目录
@@ -114,6 +154,18 @@ export interface SubscribeShare {
tmdbid?: number
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: number
// AniList ID
anilistid?: number
// 主媒体数据源
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string
// 音乐实体类型recording 单曲、album 专辑、artist 艺术家
music_type?: MusicEntityType
// 专辑总曲目数
total_tracks?: number
// 季号
season?: number
// 海报
@@ -216,6 +268,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
@@ -230,20 +290,94 @@ export interface TransferHistory {
errmsg?: string
// 日期
date?: string
// 音乐实体类型
music_type?: MusicEntityType
// 专辑预期总曲目数
total_tracks?: number
// 实际音频格式
audio_format?: string
// 是否无损音频
audio_lossless?: boolean
// 实际位深bit
bit_depth?: number
// 实际采样率Hz
sample_rate?: number
// 实际码率bps
bitrate?: number
// 源文件项
src_fileitem?: FileItem
}
// 媒体信息
export interface MediaInfo {
// 来源themoviedb、douban、bangumi
source?: string
// 类型 电影、电视剧、合集
// 下载历史记录
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
// 海报
poster?: 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、anilist、musicbrainz
source?: string
// 类型 电影、电视剧、音乐、合集
type?: string
// 媒体标题
title?: string
// 年份(音乐等数据源返回数字,订阅接口要求字符串)
year?: string | number
// 标题(年)
title_year?: string
// 季号
@@ -257,7 +391,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前缀
@@ -338,6 +476,193 @@ export interface MediaInfo {
names?: string[]
// 剧集组
episode_group?: string
// 音乐艺术家列表
artists?: string[]
// 音乐艺术家展示文本
artist?: string
// 音乐实体类型recording 单曲、album 专辑、artist 艺术家
music_type?: MusicEntityType
// 音乐艺术家标准ID顺序与 artists 一致
artist_ids?: string[]
// 专辑
album?: string
// 专辑艺术家
album_artist?: string
// 所属专辑标准ID
album_id?: string
// 专辑主类型Album、EP、Single 等
album_type?: string
// 发行版本
version?: string
// 音轨号
track_number?: number
// 碟号
disc_number?: number
// 总音轨数
total_tracks?: number
// 音轨时长(秒)
duration?: number
// ISRC
isrc?: string
// 音乐封面
cover_url?: string
// ListenBrainz 收听次数
listen_count?: number
// 音频格式
audio_format?: string
// 是否无损音频
audio_lossless?: boolean
// 音质等级
audio_quality?: 'hires' | 'lossless' | 'lossy'
// 音质洗版分数
audio_quality_score?: number
// 格式化音频参数
audio_specs?: string
// 音频位深
bit_depth?: number
// 音频采样率
sample_rate?: number
// 音频码率
bitrate?: number
}
// 音乐可浏览实体类型
export type MusicEntityType = 'recording' | 'album' | 'artist'
// 音乐专辑下的单个发行版本
export interface MusicRelease {
// 发行版本标准ID
media_id?: string
// 发行版本名称
title?: string
// 发行日期
date?: string
// 发行年份
year?: number
// 发行地区
country?: string
// 发行状态Official、Promotion 等
status?: string
// 包装形式
packaging?: string
// 介质格式CD、Vinyl 等
formats?: string[]
// 音轨总数
track_count?: number
// 封面
cover_url?: string
}
// 音乐专辑详情
export interface MusicAlbumInfo {
// 类型,固定为音乐
type?: string
// 实体类型,固定为 album
music_type?: MusicEntityType
// 来源
source?: string
// 专辑标准ID
media_id?: string
// 专辑名称
title?: string
// 艺术家列表
artists?: string[]
// 艺术家展示文本
artist?: string
// 艺术家标准ID顺序与 artists 一致
artist_ids?: string[]
// 专辑名称,与 title 一致,便于复用音乐展示组件
album?: string
// 专辑主类型
album_type?: string
// 专辑副类型
secondary_types?: string[]
// 首次发行年份
year?: number
// 首次发行日期
release_date?: string
// 音轨总数
total_tracks?: number
// 专辑总时长(秒)
duration?: number
// 封面
cover_url?: string
// 风格
genres?: string[]
// 标签
tags?: string[]
// 主类型与副类型组合文本
category?: string
// 10 分制评分
rating?: number
// 评分人数
rating_votes?: number
// 详情页面
detail_link?: string
// 专辑内的音乐
tracks?: MediaInfo[]
// 同一专辑的其它发行版本
releases?: MusicRelease[]
// 海报
poster_path?: string
// 背景图
backdrop_path?: string
// 摘要
overview?: string
}
// 音乐艺术家详情
export interface MusicArtistInfo {
// 类型,固定为音乐
type?: string
// 实体类型,固定为 artist
music_type?: MusicEntityType
// 来源
source?: string
// 艺术家标准ID
media_id?: string
// 艺术家名称
name?: string
// 名称,与 name 一致,便于复用通用展示组件
title?: string
// 排序名称
sort_name?: string
// 消歧义说明
disambiguation?: string
// 艺术家类型Person、Group 等
artist_type?: string
// 性别
gender?: string
// 国家代码
country?: string
// 地区
area?: string
// 出道或成立日期
begin_date?: string
// 解散或去世日期
end_date?: string
// 是否已结束活动
ended?: boolean
// 活跃时间区间文本
life_span?: string
// 风格
genres?: string[]
// 标签
tags?: string[]
// 别名
aliases?: string[]
// 与当前艺术家的关系文本
relation?: string
// 艺术家图片
image_url?: string
// 详情页面
detail_link?: string
// 外部站点链接
external_links?: Record<string, string>
// 海报
poster_path?: string
// 摘要
overview?: string
}
// 季信息
@@ -414,7 +739,7 @@ export interface TmdbEpisode {
// TMDB人物信息
export interface Person {
// 来源themoviedb、douban、bangumi
// 来源themoviedb、douban、bangumi、anilist
source?: string
// ID
id?: number
@@ -668,6 +993,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 可安装版本
@@ -871,7 +1210,25 @@ export interface MetaInfo {
// 识别的英文名
en_name?: string
// 年份
year?: string
year?: string | number
// 音乐艺术家列表
artists?: string[]
// 音乐艺术家展示文本
artist?: string
// 音乐专辑
album?: string
// 音乐专辑艺术家
album_artist?: string
// 音乐碟号
disc_number?: number
// 音乐音轨号
track_number?: number
// 音乐总音轨数
total_tracks?: number
// 音乐时长(秒)
duration?: number
// 音乐 ISRC
isrc?: string
// 总季数
total_season: number
// 识别的开始季 数字
@@ -898,6 +1255,22 @@ export interface MetaInfo {
video_encode?: string
// 音频编码
audio_encode?: string
// 音频格式
audio_format?: string
// 是否无损音频
audio_lossless?: boolean
// 音质等级
audio_quality?: 'hires' | 'lossless' | 'lossy'
// 音质洗版分数
audio_quality_score?: number
// 格式化音频参数
audio_specs?: string
// 音频位深
bit_depth?: number
// 音频采样率
sample_rate?: number
// 音频码率
bitrate?: number
// 名称(自动中英文)
name: string
// SXX-SXX
@@ -962,7 +1335,7 @@ export interface User {
is_superuser: boolean
// 头像
avatar: string
// 是否开启双重验证
// 是否开启二次验证
is_otp: boolean
// 用户权限 json
permissions: { [key: string]: any }
@@ -972,6 +1345,11 @@ export interface User {
nickname?: string
}
// 头像上传响应数据
export interface AvatarUploadData {
filename: string
}
// 通行密钥
export interface PassKey {
id: number
@@ -998,8 +1376,16 @@ export interface MediaStatistic {
tv_count: number
// 电视剧总集数,未获取时为 null
episode_count: number | null
// 用户数量
user_count: number
// 音乐总数
music_count: number
// 用户数量,仪表板已改为展示音乐数量,仅保留接口字段兼容
user_count?: number
// 本月新增电影数量
movie_count_month: number
// 本月新增电视剧数量
tv_count_month: number
// 本月新增剧集数量
episode_count_month: number
}
// 后台进程
@@ -1020,6 +1406,18 @@ export interface Process {
memory: number
}
// 仪表板系统摘要
export interface DashboardSystemInfo {
// 主机名称
hostname: string
// 操作系统名称
operating_system: string
// MoviePilot 运行时间,单位秒
runtime: number
// MoviePilot 后端版本
version: string
}
// 下载器信息
export interface DownloaderInfo {
// 下载速度
@@ -1034,18 +1432,86 @@ export interface DownloaderInfo {
free_space: number
}
// 仪表板系统内存信息
export interface DashboardMemoryInfo {
// 总内存字节数
total: number
// 已使用内存字节数,不包含缓存
used: number
// 缓存与缓冲区占用字节数
cached: number
// 可用内存字节数
available: number
// 已使用内存占总内存百分比,不包含缓存
usage: number
}
// 定时服务进度信息
export interface ScheduleProgress {
// ID
id?: string
// 名称
name?: string
// 多语言名称
name_i18n?: string
// 提供者
provider?: string
// 多语言提供者
provider_i18n?: string
// 是否正在执行
enable?: boolean
// 当前完成百分比
value?: number
// 当前进度文本
text?: string
// 多语言进度文本
text_i18n?: string
// 执行状态
status?: string
// 最近一次执行是否成功
success?: boolean
// 最近一次开始时间
started_at?: string
// 最近一次结束时间
finished_at?: string
// 最近一次错误信息
error?: string
// 多语言错误信息
error_i18n?: string
// 扩展数据
data?: Record<string, unknown>
}
// 定时服务信息
export interface ScheduleInfo {
// ID
id: string
// 名称
name: string
// 多语言名称
name_i18n?: string
// 提供者
provider: string
// 多语言提供者
provider_i18n?: string
// 状态
status: string
// 多语言状态
status_i18n?: string
// 下次运行时间
next_run: string
// 多语言下次运行时间
next_run_i18n?: string
// 当前完成百分比
progress?: number
// 进度文本
progress_text?: string
// 多语言进度文本
progress_text_i18n?: string
// 是否正在更新进度
progress_enable?: boolean
// 进度详情
progress_detail?: ScheduleProgress
}
// 消息通知
@@ -1151,6 +1617,8 @@ export interface MediaServerLibrary {
path?: string
// 类型
type?: string
// 库内媒体数量
item_count?: number
// 图片
image?: string
// 图片列表
@@ -1255,7 +1723,7 @@ export interface StorageConf {
export interface MediaServerConf {
// 名称
name: string
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen/navidrome
type: string
// 配置
config: { [key: string]: any }
@@ -1263,6 +1731,8 @@ export interface MediaServerConf {
enabled: boolean
// 同步媒体体库列表
sync_libraries?: string[]
// 自动同步间隔(小时),为空时使用旧全局配置
sync_interval?: number | null
}
// 文件整理目录配置
@@ -1337,6 +1807,28 @@ export interface FilterRuleGroup {
category?: string
}
// 规则测试结果
export interface RuleTestData {
// 原始标题
title?: string
// 原始副标题
subtitle?: string
// 规则组名称
rulegroup_name?: string
// 规则组详情
rulegroup?: FilterRuleGroup | null
// 识别元数据
meta_info?: MetaInfo | null
// 媒体信息
media_info?: MediaInfo | null
// 种子信息
torrent_info?: TorrentInfo | null
// 优先级
priority?: number | null
// 是否命中过滤规则
matched?: boolean
}
// 订阅下载文件详情
export interface SubscribeDownloadFileInfo {
// 种子名称
@@ -1357,6 +1849,12 @@ export interface SubscribeLibraryFileInfo {
storage?: string
// 文件路径
file_path?: string
// 媒体服务器名称
server?: string
// 媒体服务器类型emby、jellyfin、plex 等
server_type?: string
// 媒体服务器条目 ID
itemid?: string
}
// 订阅集详情
@@ -1395,6 +1893,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
// 类型
@@ -1423,6 +1929,8 @@ export interface TransferForm {
episode_group?: string | null
// 预览模式
preview?: boolean
// 清理已有成功记录后重新整理
reorganize: boolean
}
// 手动整理请求
@@ -1449,6 +1957,14 @@ export interface ManualTransferTargetPathData {
library_category_folder?: boolean | null
}
// 手动整理命中的成功历史摘要
export interface ManualTransferHistoryInfo {
// 是否应执行重新整理
reorganize: boolean
// 命中的成功历史数量
history_count: number
}
// 手动整理预览统计
export interface ManualTransferPreviewSummary {
// 总数
@@ -1635,6 +2151,42 @@ export interface TorrentCacheData {
data: TorrentCacheItem[]
}
// 媒体识别缓存项
export interface RecognitionCacheItem {
// 缓存键
key: string
// TMDB ID0 表示未识别
tmdb_id?: number
// 识别缓存对应的字符串 ID
recognition_id?: string
// 识别后的标题
title: string
// 识别后的年份
year: string
// 媒体类型
media_type: string
// TMDB 海报相对路径
poster_path?: string
// TMDB 背景图相对路径
backdrop_path?: string
}
// 媒体识别缓存数据
export interface RecognitionCacheData {
// 缓存总数
count: number
// 已识别数量
recognized: number
// 未识别数量
unrecognized: number
// 共享识别成功命中数量
shared_recognized: number
// 是否开启共享识别
shared_recognize_enabled: boolean
// 缓存数据
data: RecognitionCacheItem[]
}
// 订阅分享统计
export interface SubscribeShareStatistics {
// 分享人
@@ -1649,6 +2201,7 @@ export interface SubscribeShareStatistics {
export interface ApiResponse<T = any> {
success: boolean
message?: string
message_i18n?: string
data: T
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,82 @@
<script setup lang="ts">
import AgentAssistantEntry from './AgentAssistantEntry.vue'
import AgentAssistantPanel from './AgentAssistantPanel.vue'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
import { useTheme } from 'vuetify'
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
const panelOpen = ref(false)
const thinking = ref(false)
const entryRef = ref<AgentAssistantEntryRef | null>(null)
const { allowsDecorativeMotion } = useAppActivityLifecycle()
const { themeClasses } = useTheme()
const ASSISTANT_PREVIEW_INTERVAL = 125
let assistantPreviewTimer: number | null = null
let assistantPreviewPendingValue = ''
let assistantPreviewLastShownAt = 0
let assistantPreviewHasShown = false
function clearAssistantPreviewTimer() {
if (assistantPreviewTimer === null) return
window.clearTimeout(assistantPreviewTimer)
assistantPreviewTimer = null
}
function showPendingAssistantPreview() {
assistantPreviewTimer = null
if (panelOpen.value || !assistantPreviewPendingValue) return
entryRef.value?.showAssistantReplyPreview(assistantPreviewPendingValue)
assistantPreviewLastShownAt = performance.now()
assistantPreviewHasShown = true
}
// 打开 Agent 面板并清空入口预览气泡。
function openPanel() {
panelOpen.value = true
assistantPreviewPendingValue = ''
clearAssistantPreviewTimer()
entryRef.value?.clearBubbles()
}
// 面板关闭时限制预览更新频率,避免每个流式 token 都触发气泡布局。
function handleAssistantPreview(value: string) {
if (panelOpen.value) return
entryRef.value?.showAssistantReplyPreview(value)
assistantPreviewPendingValue = value
const elapsed = performance.now() - assistantPreviewLastShownAt
if (!assistantPreviewHasShown || elapsed >= ASSISTANT_PREVIEW_INTERVAL) {
clearAssistantPreviewTimer()
showPendingAssistantPreview()
return
}
if (assistantPreviewTimer !== null) return
assistantPreviewTimer = window.setTimeout(showPendingAssistantPreview, ASSISTANT_PREVIEW_INTERVAL - elapsed)
}
onScopeDispose(clearAssistantPreviewTimer)
</script>
<template>
<AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" />
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" />
<!-- 脱离 .v-application 的层叠上下文确保弹窗打开时入口消息气泡和面板仍在最上层 -->
<Teleport to="body">
<div class="agent-assistant-layer" :class="themeClasses">
<AgentAssistantEntry
ref="entryRef"
:active="!panelOpen"
:motion-active="allowsDecorativeMotion"
:thinking="thinking"
@open="openPanel"
/>
<AgentAssistantPanel
v-model="panelOpen"
:motion-active="allowsDecorativeMotion"
@assistant-preview="handleAssistantPreview"
@thinking-change="thinking = $event"
/>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { renderAgentMarkdown } from '@/utils/agentMarkdown'
const props = withDefaults(
defineProps<{
content: string
streaming?: boolean
variant?: 'choice' | 'message'
}>(),
{
streaming: false,
variant: 'message',
},
)
const STREAM_MARKDOWN_RENDER_INTERVAL = 96
const renderedHtml = shallowRef('')
let renderTimer: number | null = null
let lastRenderedAt = 0
let hasRendered = false
function clearRenderTimer() {
if (renderTimer === null) return
window.clearTimeout(renderTimer)
renderTimer = null
}
// 流式阶段限制 Markdown 全量解析频率;结束时立即渲染最终内容。
function renderContent(immediate = false) {
const now = performance.now()
const elapsed = now - lastRenderedAt
if (immediate || !hasRendered || elapsed >= STREAM_MARKDOWN_RENDER_INTERVAL) {
clearRenderTimer()
renderedHtml.value = renderAgentMarkdown(props.content)
lastRenderedAt = now
hasRendered = true
return
}
if (renderTimer !== null) return
renderTimer = window.setTimeout(() => {
renderTimer = null
renderedHtml.value = renderAgentMarkdown(props.content)
lastRenderedAt = performance.now()
hasRendered = true
}, STREAM_MARKDOWN_RENDER_INTERVAL - elapsed)
}
watch(
() => props.content,
() => renderContent(!props.streaming),
{ immediate: true },
)
watch(
() => props.streaming,
streaming => {
if (!streaming) renderContent(true)
},
)
onScopeDispose(clearRenderTimer)
</script>
<template>
<div
v-if="renderedHtml"
class="markdown-body"
:class="variant === 'choice' ? 'agent-assistant-choice__prompt' : 'agent-assistant-message__bubble'"
v-html="renderedHtml"
/>
</template>

View File

@@ -0,0 +1,111 @@
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()
})
it('updates an existing assistant preview without recreating its resize observer', async () => {
const observe = vi.fn()
const disconnect = vi.fn()
const resizeObserverConstructor = vi.fn()
vi.stubGlobal(
'ResizeObserver',
class {
constructor() {
resizeObserverConstructor()
}
observe = observe
disconnect = disconnect
},
)
const wrapper = shallowMount(AgentAssistantEntry, {
global: {
stubs: {
AgentPetStage: true,
VIcon: true,
},
},
props: {
active: true,
motionActive: true,
},
})
wrapper.vm.showAssistantReplyPreview('第一段')
await nextTick()
expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
wrapper.vm.showAssistantReplyPreview('第二段')
await nextTick()
expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
expect(wrapper.find('.agent-assistant-fab__bubble').text()).toContain('第二段')
wrapper.unmount()
})
})

View File

@@ -0,0 +1,552 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AgentAssistantPanel from '@/components/agent/AgentAssistantPanel.vue'
const displayState = vi.hoisted(() => ({ mdAndDown: { value: true } }))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vuetify', () => ({
useDisplay: () => displayState,
}))
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>>
}
const agentMarkdownContentStub = {
props: ['content', 'variant'],
template:
"<div :class=\"variant === 'choice' ? 'agent-assistant-choice__prompt' : 'agent-assistant-message__bubble'\">{{ content }}</div>",
}
// 构造符合 Agent 标准响应包装的 fetch 返回值。
function createAgentResponse(data: unknown) {
return {
json: vi.fn().mockResolvedValue({ success: true, data }),
ok: true,
} as unknown as Response
}
describe('AgentAssistantPanel stream recovery', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
displayState.mdAndDown.value = true
vi.useRealTimers()
})
it('toggles the desktop assistant between the side panel and fullscreen modes', async () => {
displayState.mdAndDown.value = false
vi.stubGlobal(
'fetch',
vi.fn(async () => createAgentResponse([])),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await flushPromises()
const panel = wrapper.get('.agent-assistant-panel')
const fullscreenToggle = wrapper.get('.agent-assistant-fullscreen-toggle')
expect(panel.classes()).not.toContain('is-fullscreen')
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 30rem')
expect(fullscreenToggle.attributes('title')).toBe('agentAssistant.enterFullscreen')
expect(fullscreenToggle.attributes('aria-pressed')).toBe('false')
await fullscreenToggle.trigger('click')
expect(panel.classes()).toContain('is-fullscreen')
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 100vw')
expect(fullscreenToggle.attributes('title')).toBe('agentAssistant.exitFullscreen')
expect(fullscreenToggle.attributes('aria-pressed')).toBe('true')
await fullscreenToggle.trigger('click')
expect(panel.classes()).not.toContain('is-fullscreen')
expect(panel.attributes('style')).toContain('--agent-assistant-panel-width: 30rem')
wrapper.unmount()
})
it('keeps the narrow assistant fullscreen without a redundant toggle', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => createAgentResponse([])),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await flushPromises()
expect(wrapper.get('.agent-assistant-panel').attributes('style')).toContain('--agent-assistant-panel-width: 100vw')
expect(wrapper.find('.agent-assistant-fullscreen-toggle').exists()).toBe(false)
wrapper.unmount()
})
it('restores a legacy empty message to loading and waits for the final reply after a PWA reload', async () => {
const startedAt = Date.now() - 5000
const localSessionId = 'web-local-session'
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: {
AgentMarkdownContent: agentMarkdownContentStub,
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: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
const textarea = wrapper.find('textarea')
await textarea.setValue('继续后台处理')
await textarea.trigger('keydown', { key: 'Enter' })
await flushPromises()
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()
})
it('does not send when Enter confirms an IME composition', async () => {
const fetchMock = vi.fn(async () => createAgentResponse([]))
vi.stubGlobal('fetch', fetchMock)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
const textarea = wrapper.find('textarea')
await flushPromises()
fetchMock.mockClear()
await textarea.setValue('搜索电影')
await textarea.trigger('compositionstart')
await textarea.trigger('keydown', { key: 'Enter', isComposing: true })
await textarea.trigger('compositionend')
await flushPromises()
expect(fetchMock).not.toHaveBeenCalled()
await textarea.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(fetchMock).toHaveBeenCalled()
wrapper.unmount()
})
it('renders interleaved assistant text and tool events in their SSE order', async () => {
const serverSessionId = 'web-agent:ordered-segments'
const streamEvents = [
{ type: 'start', session_id: serverSessionId },
{ type: 'delta', content: '先检查服务器。' },
{ type: 'tool', message: '(执行了 1 条命令)' },
{ type: 'delta', content: '检查完成,没有发现错误。' },
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
const textarea = wrapper.find('textarea')
await textarea.setValue('检查服务器')
await textarea.trigger('keydown', { key: 'Enter' })
await flushPromises()
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
expect(renderedSegments).toHaveLength(3)
expect(renderedSegments[0].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[0].text()).toContain('先检查服务器。')
expect(renderedSegments[1].classes()).toContain('agent-assistant-tool')
expect(renderedSegments[1].text()).toContain('执行了 1 条命令')
expect(renderedSegments[2].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[2].text()).toContain('检查完成,没有发现错误。')
const saveCall = fetchMock.mock.calls.find(([input]) => String(input).includes('/display'))
const savedMessages = JSON.parse(String(saveCall?.[1]?.body || '{}')).messages as Array<Record<string, unknown>>
expect(savedMessages.at(-1)).toMatchObject({
content: '先检查服务器。检查完成,没有发现错误。',
segments: [
{ type: 'text', content: '先检查服务器。' },
{ type: 'tool', toolIndex: 0 },
{ type: 'text', content: '检查完成,没有发现错误。' },
],
})
wrapper.unmount()
})
it('does not render an empty text bubble for trailing whitespace after a tool summary', async () => {
const streamEvents = [
{ type: 'start', session_id: 'web-agent:trailing-whitespace' },
{ type: 'delta', content: '最终结论' },
{ type: 'tool', message: '(查询了 1 次数据)' },
{ type: 'delta', content: '\n\n' },
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
}),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await wrapper.find('textarea').setValue('检查配置')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
expect(renderedSegments).toHaveLength(2)
expect(renderedSegments[0].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[0].text()).toBe('最终结论')
expect(renderedSegments[1].classes()).toContain('agent-assistant-tool')
wrapper.unmount()
})
it('aggregates adjacent non-verbose tools and starts a new group after text', async () => {
const streamEvents = [
{ type: 'start', session_id: 'web-agent:tool-groups' },
{ type: 'tool', message: '(查询了 1 次数据)' },
{ type: 'tool', message: '(查看了 1 个目录)' },
{ type: 'tool', message: '(查询了 1 次数据)' },
{ type: 'delta', content: '继续分析。' },
{ type: 'tool', message: '(读取了 1 个文件)' },
{ type: 'tool', message: '(读取了 1 个文件)' },
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
}),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await wrapper.find('textarea').setValue('分析插件')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
expect(renderedSegments).toHaveLength(3)
expect(renderedSegments[0].text()).toContain('查询了 2 次数据,查看了 1 个目录')
expect(renderedSegments[1].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[1].text()).toBe('继续分析。')
expect(renderedSegments[2].text()).toContain('读取了 2 个文件')
wrapper.unmount()
})
it('coalesces consecutive text deltas into one UI update before a terminal event', async () => {
const streamEvents = [
{ type: 'start', session_id: 'web-agent:coalesced' },
...Array.from({ length: 100 }, (_item, index) => ({ type: 'delta', content: String(index % 10) })),
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
}),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await wrapper.find('textarea').setValue('测试突发事件')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.emitted('assistant-preview')).toHaveLength(1)
expect(wrapper.find('.agent-assistant-message--assistant .agent-assistant-message__bubble').text()).toBe(
Array.from({ length: 100 }, (_item, index) => String(index % 10)).join(''),
)
wrapper.unmount()
})
})

View File

@@ -0,0 +1,94 @@
import { mount } from '@vue/test-utils'
import { defineComponent, h, nextTick, ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
vi.mock('vuetify', () => ({
useTheme: () => ({ themeClasses: ref('v-theme--test') }),
}))
vi.mock('@/composables/useAppActivityLifecycle', () => ({
useAppActivityLifecycle: () => ({ allowsDecorativeMotion: ref(true) }),
}))
describe('AgentAssistantWidget layering', () => {
afterEach(() => {
vi.useRealTimers()
document.body
.querySelectorAll('.agent-assistant-layer, .agent-assistant-test-host')
.forEach(element => element.remove())
})
it('teleports the assistant outside the application stacking context while preserving the active theme', () => {
const host = document.createElement('div')
host.className = 'agent-assistant-test-host v-application'
document.body.append(host)
const wrapper = mount(AgentAssistantWidget, {
attachTo: host,
global: {
stubs: {
AgentAssistantEntry: { template: '<div data-agent-assistant-entry />' },
AgentAssistantPanel: { template: '<div data-agent-assistant-panel />' },
},
},
})
const layer = document.body.querySelector(':scope > .agent-assistant-layer')
expect(layer).not.toBeNull()
expect(host.contains(layer)).toBe(false)
expect(layer).toHaveClass('v-theme--test')
expect(layer?.querySelector('[data-agent-assistant-entry]')).not.toBeNull()
expect(layer?.querySelector('[data-agent-assistant-panel]')).not.toBeNull()
wrapper.unmount()
})
it('reserves the highest CSS stacking levels in assistant display order', () => {
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.entry).toBe(2_147_483_645)
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.panel).toBe(2_147_483_646)
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.overlay).toBe(2_147_483_647)
})
it('limits closed-panel assistant preview updates and keeps the latest text', async () => {
vi.useFakeTimers()
const showAssistantReplyPreview = vi.fn()
const entryStub = defineComponent({
setup(_props, { expose }) {
expose({ clearBubbles: vi.fn(), showAssistantReplyPreview })
return () => h('div', { 'data-agent-assistant-entry': '' })
},
})
const panelStub = defineComponent({
emits: ['assistant-preview', 'thinking-change', 'update:modelValue'],
setup() {
return () => h('div', { 'data-agent-assistant-panel': '' })
},
})
const wrapper = mount(AgentAssistantWidget, {
global: {
stubs: {
AgentAssistantEntry: entryStub,
AgentAssistantPanel: panelStub,
},
},
})
const panel = wrapper.findComponent(panelStub)
panel.vm.$emit('assistant-preview', '第一段')
panel.vm.$emit('assistant-preview', '第二段')
panel.vm.$emit('assistant-preview', '最终预览')
await nextTick()
expect(showAssistantReplyPreview).toHaveBeenCalledTimes(1)
expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('第一段')
await vi.advanceTimersByTimeAsync(125)
expect(showAssistantReplyPreview).toHaveBeenCalledTimes(2)
expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('最终预览')
wrapper.unmount()
})
})

View File

@@ -0,0 +1,46 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AgentMarkdownContent from '@/components/agent/AgentMarkdownContent.vue'
describe('AgentMarkdownContent', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('throttles streaming Markdown and renders the final content immediately', async () => {
const wrapper = mount(AgentMarkdownContent, {
props: { content: '**开始**', streaming: true },
})
expect(wrapper.html()).toContain('<strong>开始</strong>')
await wrapper.setProps({ content: '**开始继续**' })
expect(wrapper.html()).not.toContain('<strong>开始继续</strong>')
await vi.advanceTimersByTimeAsync(96)
expect(wrapper.html()).toContain('<strong>开始继续</strong>')
await wrapper.setProps({ content: '**最终结果**', streaming: false })
expect(wrapper.html()).toContain('<strong>最终结果</strong>')
})
it('escapes raw HTML from Agent output', () => {
const wrapper = mount(AgentMarkdownContent, {
props: { content: '<img src=x onerror="alert(1)">' },
})
expect(wrapper.html()).not.toContain('<img')
expect(wrapper.text()).toContain('<img src=x onerror=')
})
it('does not render a bubble for whitespace-only Markdown', () => {
const wrapper = mount(AgentMarkdownContent, {
props: { content: '\n\n' },
})
expect(wrapper.find('.agent-assistant-message__bubble').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import CssRobotRenderer from './renderers/CssRobotRenderer.vue'
import type { AgentPetActionName, AgentPetIntent, AgentPetRendererKind } from './types'
const props = withDefaults(
defineProps<{
action?: AgentPetActionName | null
intent?: AgentPetIntent
renderer?: AgentPetRendererKind
thinking?: boolean
}>(),
{
action: null,
intent: 'idle',
renderer: 'css-robot',
thinking: false,
},
)
</script>
<template>
<CssRobotRenderer
v-if="props.renderer === 'css-robot'"
:action="props.action"
:intent="props.intent"
:thinking="props.thinking"
/>
</template>

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

@@ -0,0 +1,156 @@
import type { AgentPetActionDefinition, AgentPetActionName } from './types'
export const AGENT_PET_RANDOM_ACTION_MIN_DELAY = 8000
export const AGENT_PET_RANDOM_ACTION_MAX_DELAY = 18000
export const AGENT_PET_RANDOM_ACTIONS = [
'wave',
'sit',
'eye-roll',
'faint',
'disassemble',
'happy-jump',
'sleep',
'stretch',
'peek',
'scan',
'charge',
'spin-cheer',
'shy',
'confused',
] as const
export const AGENT_PET_ACTIONS: Record<AgentPetActionName, AgentPetActionDefinition> = {
wave: {
name: 'wave',
intent: 'reaction',
clip: 'agent-fab-action-wave',
duration: 2450,
priority: 1,
interruptible: true,
},
sit: {
name: 'sit',
intent: 'idle',
clip: 'agent-fab-action-sit',
duration: 4200,
priority: 1,
interruptible: true,
},
'eye-roll': {
name: 'eye-roll',
intent: 'reaction',
clip: 'agent-fab-action-eye-roll',
duration: 1900,
priority: 1,
interruptible: true,
},
faint: {
name: 'faint',
intent: 'reaction',
clip: 'agent-fab-action-faint',
duration: 4800,
priority: 1,
interruptible: true,
},
disassemble: {
name: 'disassemble',
intent: 'reaction',
clip: 'agent-fab-action-disassemble',
duration: 6200,
priority: 1,
interruptible: true,
},
'happy-jump': {
name: 'happy-jump',
intent: 'success',
clip: 'agent-fab-action-happy-jump',
duration: 5200,
priority: 1,
interruptible: true,
},
sleep: {
name: 'sleep',
intent: 'sleeping',
clip: 'agent-fab-action-sleep',
duration: 5800,
priority: 1,
interruptible: true,
},
stretch: {
name: 'stretch',
intent: 'idle',
clip: 'agent-fab-action-stretch',
duration: 4400,
priority: 1,
interruptible: true,
},
peek: {
name: 'peek',
intent: 'reaction',
clip: 'agent-fab-action-peek',
duration: 2800,
priority: 1,
interruptible: true,
},
scan: {
name: 'scan',
intent: 'thinking',
clip: 'agent-fab-action-scan',
duration: 3200,
priority: 1,
interruptible: true,
},
charge: {
name: 'charge',
intent: 'thinking',
clip: 'agent-fab-action-charge',
duration: 4800,
priority: 1,
interruptible: true,
},
'spin-cheer': {
name: 'spin-cheer',
intent: 'success',
clip: 'agent-fab-action-spin-cheer',
duration: 3600,
priority: 1,
interruptible: true,
},
shy: {
name: 'shy',
intent: 'reaction',
clip: 'agent-fab-action-shy',
duration: 3000,
priority: 1,
interruptible: true,
},
confused: {
name: 'confused',
intent: 'reaction',
clip: 'agent-fab-action-confused',
duration: 3400,
priority: 1,
interruptible: true,
},
}
/** 获取指定宠物动作的播放时长。 */
export function getAgentPetActionDuration(action: AgentPetActionName) {
return AGENT_PET_ACTIONS[action].duration
}
/** 生成下一次空闲趣味动作的随机等待时间。 */
export function getAgentPetRandomActionDelay() {
return (
AGENT_PET_RANDOM_ACTION_MIN_DELAY +
Math.round(Math.random() * (AGENT_PET_RANDOM_ACTION_MAX_DELAY - AGENT_PET_RANDOM_ACTION_MIN_DELAY))
)
}
/** 从动作池中选择一个不同于上一次的趣味动作。 */
export function pickAgentPetRandomAction(lastAction: AgentPetActionName | null): AgentPetActionName {
const candidates = AGENT_PET_RANDOM_ACTIONS.filter(action => action !== lastAction)
return candidates[Math.floor(Math.random() * candidates.length)] || AGENT_PET_RANDOM_ACTIONS[0]
}

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { AgentPetActionName, AgentPetIntent } from '../types'
const props = withDefaults(
defineProps<{
action?: AgentPetActionName | null
intent?: AgentPetIntent
thinking?: boolean
}>(),
{
action: null,
intent: 'idle',
thinking: false,
},
)
</script>
<template>
<span
class="agent-assistant-fab__bot"
:data-agent-pet-action="props.action || undefined"
:data-agent-pet-intent="props.intent"
:data-agent-pet-thinking="props.thinking ? 'true' : undefined"
aria-hidden="true"
>
<span class="agent-assistant-fab__antenna" />
<span class="agent-assistant-fab__head">
<span class="agent-assistant-fab__face">
<span class="agent-assistant-fab__eye agent-assistant-fab__eye--left" />
<span class="agent-assistant-fab__eye agent-assistant-fab__eye--right" />
<span class="agent-assistant-fab__smile" />
</span>
</span>
<span class="agent-assistant-fab__body">
<span class="agent-assistant-fab__core" />
</span>
<span class="agent-assistant-fab__arm agent-assistant-fab__arm--left" />
<span class="agent-assistant-fab__arm agent-assistant-fab__arm--right" />
<span class="agent-assistant-fab__leg agent-assistant-fab__leg--left" />
<span class="agent-assistant-fab__leg agent-assistant-fab__leg--right" />
</span>
</template>
<style src="./cssRobot.scss" lang="scss"></style>

View File

@@ -0,0 +1,100 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-charge .agent-assistant-fab__bot {
animation: agent-fab-action-charge-bot 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-charge .agent-assistant-fab__antenna {
animation: agent-fab-action-charge-antenna 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-charge .agent-assistant-fab__face {
animation: agent-fab-action-charge-face 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-charge .agent-assistant-fab__core {
animation: agent-fab-action-charge-core 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-charge .agent-assistant-fab__arm--left,
.agent-assistant-fab.is-action-charge .agent-assistant-fab__arm--right {
animation: agent-fab-action-charge-arm 4.8s ease-in-out both;
}
@keyframes agent-fab-action-charge-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
18%,
42%,
66% {
transform: translateY(0.1rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 2deg));
}
30%,
54%,
78% {
transform: translateY(-0.12rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 2deg));
}
}
@keyframes agent-fab-action-charge-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
30%,
54%,
78% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.16rem)) rotate(-20deg)
scale(1.12);
}
}
@keyframes agent-fab-action-charge-face {
0%,
100% {
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
}
20%,
84% {
box-shadow:
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
0 0 0.9rem rgba(166, 118, 255, 56%);
}
}
@keyframes agent-fab-action-charge-core {
0%,
100% {
opacity: 0.78;
transform: scale(1);
}
20%,
40%,
60%,
80% {
opacity: 1;
transform: scale(1.32);
}
}
@keyframes agent-fab-action-charge-arm {
0%,
100% {
transform: rotate(0deg);
}
22%,
82% {
transform: translateY(0.12rem) scaleY(0.9);
}
}

View File

@@ -0,0 +1,101 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-confused .agent-assistant-fab__antenna {
animation: agent-fab-action-confused-antenna 3.4s ease-in-out both;
}
.agent-assistant-fab.is-action-confused .agent-assistant-fab__head {
animation: agent-fab-action-confused-head 3.4s ease-in-out both;
}
.agent-assistant-fab.is-action-confused .agent-assistant-fab__eye {
animation:
agent-fab-blink 4.8s ease-in-out infinite,
agent-fab-action-confused-eye 0.85s ease-in-out 4;
}
.agent-assistant-fab.is-action-confused .agent-assistant-fab__arm--left {
animation: agent-fab-action-confused-arm-left 3.4s ease-in-out both;
}
.agent-assistant-fab.is-action-confused .agent-assistant-fab__arm--right {
animation: agent-fab-action-confused-arm-right 3.4s ease-in-out both;
}
@keyframes agent-fab-action-confused-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
20% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(58deg);
}
48% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(-28deg);
}
74% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(42deg);
}
}
@keyframes agent-fab-action-confused-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
20% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-10deg);
}
48% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(10deg);
}
74% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.03rem)) rotate(-6deg);
}
}
@keyframes agent-fab-action-confused-eye {
0%,
100% {
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
}
30% {
transform: translate(0.22rem, -0.16rem);
}
60% {
transform: translate(-0.22rem, 0.08rem);
}
}
@keyframes agent-fab-action-confused-arm-left {
0%,
100% {
transform: rotate(17deg);
}
24%,
74% {
transform: translate(-0.12rem, -0.12rem) rotate(88deg);
}
}
@keyframes agent-fab-action-confused-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
24%,
74% {
transform: translate(0.16rem, -0.08rem) rotate(-42deg);
}
}

View File

@@ -0,0 +1,195 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__bot {
animation: agent-fab-action-disassemble-bot 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__antenna {
animation: agent-fab-action-disassemble-antenna 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__head {
animation: agent-fab-action-disassemble-head 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__body {
animation: agent-fab-action-disassemble-body 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__arm--left {
animation: agent-fab-action-disassemble-arm-left 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__arm--right {
animation: agent-fab-action-disassemble-arm-right 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__leg--left {
animation: agent-fab-action-disassemble-leg-left 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__leg--right {
animation: agent-fab-action-disassemble-leg-right 6.2s ease-in-out both;
}
@keyframes agent-fab-action-disassemble-bot {
/* 34%-70% 让散落状态停留片刻,再用 84% 的轻微过冲表现回装吸附感。 */
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
12% {
transform: translateY(-0.08rem) scale(var(--agent-assistant-bot-scale)) rotate(-5deg);
}
24%,
72% {
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale)) rotate(0deg);
}
86% {
transform: translateY(-0.04rem) scale(var(--agent-assistant-bot-scale)) rotate(3deg);
}
}
@keyframes agent-fab-action-disassemble-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
16% {
transform: translate(0.02rem, -0.14rem) rotate(44deg);
}
32%,
70% {
transform: translate(0.96rem, 3.42rem) rotate(268deg);
}
84% {
transform: translate(-0.05rem, -0.08rem) rotate(8deg);
}
}
@keyframes agent-fab-action-disassemble-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
16% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.24rem)) rotate(-8deg);
}
32%,
70% {
transform: translate(-1.18rem, 2.72rem) rotate(-46deg);
}
84% {
transform: translate(0.08rem, -0.1rem) rotate(6deg);
}
}
@keyframes agent-fab-action-disassemble-body {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
}
18% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.08rem)) rotate(4deg);
}
34%,
70% {
transform: translate(0.26rem, 1.56rem) rotate(18deg);
}
84% {
transform: translate(-0.05rem, -0.08rem) rotate(-5deg);
}
}
@keyframes agent-fab-action-disassemble-arm-left {
0%,
100% {
transform: rotate(17deg);
}
18% {
transform: translate(-0.16rem, -0.08rem) rotate(78deg);
}
34%,
70% {
transform: translate(-1.72rem, 1.84rem) rotate(248deg);
}
84% {
transform: translate(0.08rem, -0.04rem) rotate(-4deg);
}
}
@keyframes agent-fab-action-disassemble-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
18% {
transform: translate(0.16rem, -0.08rem) rotate(-78deg);
}
34%,
70% {
transform: translate(1.58rem, 1.72rem) rotate(-238deg);
}
84% {
transform: translate(-0.08rem, -0.04rem) rotate(4deg);
}
}
@keyframes agent-fab-action-disassemble-leg-left {
0%,
100% {
transform: rotate(0deg);
}
20% {
transform: translate(-0.12rem, 0.18rem) rotate(34deg);
}
34%,
70% {
transform: translate(-0.98rem, 0.96rem) rotate(112deg);
}
84% {
transform: translate(0.07rem, -0.08rem) rotate(-8deg);
}
}
@keyframes agent-fab-action-disassemble-leg-right {
0%,
100% {
transform: rotate(0deg);
}
20% {
transform: translate(0.12rem, 0.18rem) rotate(-34deg);
}
34%,
70% {
transform: translate(0.92rem, 1.04rem) rotate(-118deg);
}
84% {
transform: translate(-0.07rem, -0.08rem) rotate(8deg);
}
}

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