Compare commits

...

200 Commits

Author SHA1 Message Date
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
369 changed files with 59995 additions and 8520 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,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:
- v2
push:
branches:
- v2
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-coverage:
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 with coverage
run: yarn test:coverage

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

View File

@@ -6,7 +6,6 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
关联阅读后端插件开发文档:[第三方插件开发说明](https://github.com/jxxghp/MoviePilot-Plugins/blob/main/README.md)
## 2. 技术要求
- Node.js 20+
@@ -18,13 +17,13 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
每个 Vue 联邦插件需要提供下列标准组件(`AppPage` 为可选,用于主界面侧栏全页入口):
| 组件名称 | 暴露名 | 文件名 | 用途 |
|---------|--------|--------|------|
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
| Config | `./Config` | Config.vue | 插件配置页面 |
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
| 组件名称 | 暴露名 | 文件名 | 用途 |
| --------- | ---------------- | ---------------------- | --------------------------------------------- |
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
| Config | `./Config` | Config.vue | 插件配置页面 |
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
主应用在侧栏全页路由中按 `nav_key` 解析暴露名(如 `AppPageSettings`),再回退 `AppPage``Page``nav_key``main` 时仅尝试 `AppPage``Page`
@@ -79,55 +78,52 @@ export default defineConfig({
singleton: true,
},
},
format: 'esm'
})
format: 'esm',
}),
],
build: {
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: true, // 改为true以便能分离样式文件
},
css: {
preprocessorOptions: {
scss: {
additionalData: '/* 覆盖vuetify样式 */',
}
},
},
postcss: {
plugins: [
{
postcssPlugin: 'internal:charset-removal',
AtRule: {
charset: (atRule) => {
charset: atRule => {
if (atRule.name === 'charset') {
atRule.remove();
atRule.remove()
}
}
}
},
},
},
{
postcssPlugin: 'vuetify-filter',
Root(root) {
// 过滤掉所有vuetify相关的CSS
root.walkRules(rule => {
if (rule.selector && (
rule.selector.includes('.v-') ||
rule.selector.includes('.mdi-'))) {
rule.remove();
if (rule.selector && (rule.selector.includes('.v-') || rule.selector.includes('.mdi-'))) {
rule.remove()
}
});
}
}
]
}
})
},
},
],
},
},
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001',
},
})
})
```
## 5. 组件开发规范
@@ -139,12 +135,16 @@ export default defineConfig({
// 自定义事件,用于通知主应用刷新数据
const emit = defineEmits(['action', 'switch', 'close'])
// 接收API对象
// 接收主应用能力
const props = defineProps({
api: {
type: Object,
default: () => {}
}
default: () => {},
},
nativeSubscribe: {
type: Function,
default: null,
},
})
// 页面逻辑代码...
@@ -179,20 +179,24 @@ function notifyClose() {
```vue
<script setup lang="ts">
// 接收初始配置和API对象
// 接收初始配置和主应用能力
const props = defineProps({
initialConfig: {
type: Object,
default: () => ({})
default: () => ({}),
},
api: {
type: Object,
default: () => {}
}
default: () => {},
},
nativeSubscribe: {
type: Function,
default: null,
},
})
// 配置数据
const config = ref({...props.initialConfig})
const config = ref({ ...props.initialConfig })
// 自定义事件,用于保存配置
const emit = defineEmits(['save', 'close', 'switch'])
@@ -217,7 +221,7 @@ function notifyClose() {
<div class="plugin-config">
<!-- 配置表单示例 -->
<v-text-field v-model="config.someField" label="配置项"></v-text-field>
<!-- 保存按钮示例 -->
<v-btn color="primary" @click="saveConfig">保存配置</v-btn>
@@ -234,16 +238,20 @@ function notifyClose() {
```vue
<script setup lang="ts">
// 接收配置刷新控制
// 接收配置刷新控制和主应用能力
const props = defineProps({
config: {
type: Object,
default: () => ({})
default: () => ({}),
},
allowRefresh: {
type: Boolean,
default: true
}
default: true,
},
nativeSubscribe: {
type: Function,
default: null,
},
})
// 仪表板逻辑...
@@ -276,16 +284,18 @@ const props = defineProps({
主应用传入的 props
| 属性 | 说明 |
|------|------|
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
| `pluginId` | 当前插件 ID |
| 属性 | 说明 |
| ----------------- | ----------------------------------------------------- |
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
| `nativeSubscribe` | 打开主应用原生订阅交互 |
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
| `pluginId` | 当前插件 ID |
```vue
<script setup lang="ts">
const props = defineProps({
api: { type: Object, default: () => ({}) },
nativeSubscribe: { type: Function, default: null },
navKey: { type: String, default: 'main' },
pluginId: { type: String, default: '' },
})
@@ -300,18 +310,105 @@ const emit = defineEmits(['action'])
</template>
```
### 5.5 主应用宿主能力
登录后的联邦组件宿主会向插件开放以下能力:
| 能力 | Page | Config | Dashboard | AppPage | 调用方式 |
| ---------------- | ---- | ------ | --------- | ------- | ---------------------------------------------------------------- |
| 认证 API | ✓ | ✓ | ✓ | ✓ | `api` prop |
| 原生订阅交互 | ✓ | ✓ | ✓ | ✓ | `nativeSubscribe` prop 或 `inject('moviepilot:nativeSubscribe')` |
| 主应用统一 Toast | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:toast')` |
`nativeSubscribe` 和 Toast 都由主应用宿主提供。插件不应复制主程序订阅弹窗,也不应自行创建另一套 Toast 容器。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。
### 5.6 玻璃光学表面
主应用的 `Page``Config``AppPage` 宿主在玻璃主题下默认采用 `static-material` 光学模式:保留壁纸透射、材质色调和方向反射,但不响应指针流场、局部折射、拖尾或动态焦散。插件列表与 `Dashboard` 继续使用完整动态光学。视觉型插件可以在自己控制的 DOM 区域显式恢复完整动态光学:
```html
<div data-glass-optical-surface data-glass-optical-mode="dynamic">
<!-- 插件自己的视觉内容 -->
</div>
```
使用时需同时声明 `data-glass-optical-surface``data-glass-optical-mode="dynamic"`。模式会从最近的祖先容器继承,因此显式声明的动态子表面不会沿用宿主的静态模式。该合同适用于插件在 `Page``Config``AppPage` 中自行渲染并控制的区域;主应用生成的插件列表、插件市场卡片、`Dashboard` 及其他宿主 DOM 不属于插件的修改边界。
动态模式只在主应用启用玻璃主题和实时光学能力时生效。其他主题、降低动态效果或光学能力不可用时,插件必须保持内容与交互正常,不应依赖动态光学表达业务状态或必要反馈。
### 5.7 调用主应用原生订阅
`Page``Config``Dashboard``AppPage` 都会收到 `nativeSubscribe(mediaInfo)` prop。插件传入媒体信息后电视剧会打开主应用的选季抽屉电影会进入现有电影订阅流程。宿主也会用 `moviepilot:nativeSubscribe` 键提供同一个方法,深层子组件可以使用 `inject`,无需逐层传递 prop。
媒体信息必须包含:
- `type``电影` / `电视剧`,也兼容 `movie` / `tv`
- `title`
- 至少一个有效媒体标识:`tmdb_id` / `tmdbid``douban_id` / `doubanid``bangumi_id` / `bangumiid``anilist_id` / `anilistid`,或者 `media_id``mediaid_prefix` / `source` / `media_source` 的组合。
```vue
<script setup lang="ts">
import { inject } from 'vue'
type NativeSubscribeResult =
{ success: true } | { success: false; code: 'INVALID_MEDIA' | 'PERMISSION_DENIED'; message: string }
const props = defineProps<{
nativeSubscribe?: (mediaInfo: Record<string, unknown>) => Promise<NativeSubscribeResult>
}>()
const nativeSubscribe = inject('moviepilot:nativeSubscribe', props.nativeSubscribe)
/** 使用主应用订阅交互,宿主不接受时保留插件自己的 fallback。 */
async function subscribeMedia(mediaInfo: Record<string, unknown>) {
const result = await nativeSubscribe?.(mediaInfo)
if (!result?.success) {
// 插件可在这里执行自己的 fallback宿主已同时显示明确错误提示。
}
}
</script>
```
`success: true` 表示主应用已接受调用并启动原生交互,不表示用户已经完成订阅。字段无效或当前用户没有订阅权限时返回 `success: false`,插件可以依据 `code` 执行 fallback。
### 5.8 调用主应用 Toast
`Page``Config``Dashboard``AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器:
```vue
<script setup lang="ts">
import { inject } from 'vue'
interface HostToast {
error(message: string): unknown
info(message: string): unknown
success(message: string): unknown
warning(message: string): unknown
}
const toast = inject<HostToast | null>('moviepilot:toast', null)
// 保存完成后调用主应用的统一通知。
function saveComplete() {
toast?.success('保存成功')
}
</script>
```
可用方法与主项目 `vue-toastification` 一致,包括 `success``info``warning``error`。注入不存在时应静默降级,关键错误仍需保留页面内状态提示。
#### 后端:注册侧栏入口
插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致:
| 字段 | 说明 |
|------|------|
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
| `title` | 侧栏显示标题 |
| `icon` | MDI 图标名,如 `mdi-rss` |
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
| 字段 | 说明 |
| ------------ | ------------------------------------------------------------------------------------- |
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
| `title` | 侧栏显示标题 |
| `icon` | MDI 图标名,如 `mdi-rss` |
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
| `permission` | 可选:`subscribe` / `discovery` / `search` / `manage` / `admin`,与主应用菜单权限一致 |
| `order` | 可选:同组内排序,数值越小越靠前 |
| `order` | 可选:同组内排序,数值越小越靠前 |
```python
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
@@ -333,16 +430,16 @@ def get_sidebar_nav(self) -> List[Dict[str, Any]]:
前端加载远程组件的顺序为:
| `nav_key` | 依次尝试的联邦暴露名 |
|-----------|----------------------|
| `main` 或省略 | `./AppPage``./Page` |
| `nav_key` | 依次尝试的联邦暴露名 |
| -------------------------------- | ------------------------------------------------ |
| `main` 或省略 | `./AppPage``./Page` |
| 其它(如 `settings``my_tool` | `./AppPage{PascalCase}``./AppPage``./Page` |
`PascalCase` 规则:按 `-``_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings``my_tool``./AppPageMyTool`
**两种实现方式(二选一或混用):**
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
2. **多文件**:为某个入口单独暴露 `./AppPageSettings.vue` 等,主应用会优先加载对应模块,失败再回退到 `AppPage`
`vite.config` 多暴露示例:
@@ -365,8 +462,7 @@ yarn build
- 将生成的dist文件夹上传到插件后端目录下默认为`dist/assets`
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*``_plugin-vue_export-helper-*``remoteEntry.js`
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*``_plugin-vue_export-helper-*``remoteEntry.js`
- 在插件的后端python代码中实现以下方法来集成远程组件
@@ -380,7 +476,8 @@ def get_render_mode() -> Tuple[str, str]:
return "vue", "dist/assets"
```
- 需要在插件前端页面调用后端接口时通过传入的api模块发起调用后端api接口声明认证类型为`bear`
- 需要在插件前端页面调用后端接口时通过传入的api模块发起调用后端api接口声明认证类型为`bear`
```typescript
// 演示使用api模块调用插件接口
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
@@ -402,7 +499,6 @@ def get_api(self) -> List[Dict[str, Any]]:
]
```
## 7. 调试与排错
### 常见问题
@@ -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` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%Branches 不低于 75%。覆盖率报告写入 `coverage/`
## 命令与 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在面向 `v2` 的 Pull Request 和推送到 `v2` 时运行。`typecheck-and-coverage` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查。变更文件格式检查依赖 Pull Request 的 base/head SHA因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。

1234
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.14.0",
"version": "2.15.1",
"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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 16 KiB

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

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

View File

@@ -0,0 +1,68 @@
import { mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
import appleSplashSpecs from './pwa-splash-specs.json' with { type: 'json' }
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const logoPath = path.join(projectRoot, 'public', 'logo.svg')
const outputDirectory = path.join(projectRoot, 'public', 'splash')
const background = '#0E1116'
async function createSplash(width, height, scaleFactor, outputPath, format) {
// Match the DOM loader's `min(160px, 36vw)` in CSS pixels, then convert it
// to physical pixels for the selected Apple launch image.
const logoSize = Math.round(Math.min(160, (width / scaleFactor) * 0.36) * scaleFactor)
const logo = await sharp(logoPath).resize(logoSize, logoSize, { fit: 'contain' }).png().toBuffer()
const image = sharp({
create: {
width,
height,
channels: 4,
background,
},
}).composite([
{
input: logo,
left: Math.round((width - logoSize) / 2),
top: Math.round((height - logoSize) / 2),
},
])
if (format === 'png') {
await image.png({ compressionLevel: 9 }).toFile(outputPath)
return
}
await image.flatten({ background }).jpeg({ quality: 88, progressive: true }).toFile(outputPath)
}
await mkdir(outputDirectory, { recursive: true })
for (const { width: portraitWidth, height: portraitHeight, scaleFactor } of appleSplashSpecs) {
const landscapeWidth = portraitHeight
const landscapeHeight = portraitWidth
await createSplash(
portraitWidth,
portraitHeight,
scaleFactor,
path.join(outputDirectory, `apple-splash-${portraitWidth}-${portraitHeight}.jpg`),
'jpg',
)
await createSplash(
landscapeWidth,
landscapeHeight,
scaleFactor,
path.join(outputDirectory, `apple-splash-${landscapeWidth}-${landscapeHeight}.jpg`),
'jpg',
)
}
// Keep the previous fallback filename for older deployments and bookmarked
// entries that may still reference it. Its palette matches the new assets.
await createSplash(750, 1334, 2, path.join(outputDirectory, 'apple-splash.png'), 'png')
console.log(
`Generated ${appleSplashSpecs.length * 2 + 1} PWA splash assets in ${path.relative(projectRoot, outputDirectory)}`,
)

294
scripts/pwa-development.ts Normal file
View File

@@ -0,0 +1,294 @@
import type { Plugin } from 'vite'
const PWA_DEVELOPMENT_SCRIPT = 'dev:pwa'
export const DEV_SW_CLEANUP_PATH = '/__moviepilot_dev_sw_cleanup__'
const devEntryScriptTag = '<script type="module" src="/src/main.ts"></script>'
const devEntryScriptUrl = '/src/main.ts'
const moviePilotWorkerScripts = ['dev-sw.js?dev-sw', 'service-worker.js']
const moviePilotIdentityMessage = 'GET_UNREAD_COUNT'
const moviePilotIdentityTimeoutMs = 1500
const moviePilotIdentityAttempts = 2
/** 普通 development mode 仅在显式 dev:pwa 脚本中启用开发 Service Worker。 */
export const isPwaDevelopmentEnabled = (mode: string, lifecycleEvent?: string) =>
mode === 'development' && lifecycleEvent === PWA_DEVELOPMENT_SCRIPT
/** 普通开发服务器才执行历史 Service Worker 清理production preview 保持构建产物行为。 */
export const shouldEnableDevServiceWorkerCleanup = (
command: 'build' | 'serve',
mode: string,
isPreview: boolean | undefined,
lifecycleEvent?: string,
) =>
command === 'serve' && mode === 'development' && isPreview !== true && !isPwaDevelopmentEnabled(mode, lifecycleEvent)
/** 解析当前页面所属的应用根目录,兼容根路径和子路径部署。 */
export function resolveDevAppScope(pageUrl: string): URL {
return new URL('./', new URL(pageUrl))
}
/** Worker 的 scope 和脚本 URL 都必须精确落在当前应用根目录。 */
export function isManagedServiceWorkerRegistration(
scriptUrl: string,
registrationScope: string,
pageUrl: string,
): boolean {
const appScope = resolveDevAppScope(pageUrl)
const scope = new URL(registrationScope)
const script = new URL(scriptUrl)
return (
scope.href === appScope.href &&
moviePilotWorkerScripts.some(workerScript => script.href === new URL(workerScript, appScope).href)
)
}
/** 复用现有轻量消息协议确认 Worker 确由 MoviePilot 提供。 */
export function isMoviePilotServiceWorkerIdentityResponse(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
return typeof (value as { count?: unknown }).count === 'number'
}
/** 身份探测允许有限次重试;持续失败时保持现有注册,避免误清理未知 Worker。 */
export async function retryMoviePilotIdentityVerification(
verifyOnce: () => Promise<boolean>,
attempts: number,
): Promise<boolean> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (await verifyOnce()) return true
}
return false
}
/** 删除当前 origin 的全部 Cache Storage调用方必须先确认该 dev origin 由 MoviePilot Worker 管理。 */
export async function deleteCurrentOriginCaches(cacheStorage: Pick<CacheStorage, 'delete' | 'keys'>): Promise<void> {
const cacheNames = await cacheStorage.keys()
await Promise.all(cacheNames.map(cacheName => cacheStorage.delete(cacheName)))
}
/** 清理完成后只允许返回当前 origin避免开发中间页形成开放重定向。 */
export function resolveDevCleanupReturnUrl(requested: string | null, origin: string): URL {
if (!requested) return new URL('/', origin)
const target = new URL(requested, origin)
return target.origin === origin ? target : new URL('/', origin)
}
/**
* 普通开发服务器不应被历史 Service Worker 控制;受控页面先进入独立清理页,避免旧缓存模块抢先执行。
*/
export function createDevServiceWorkerCleanupPlugin(): Plugin {
const workerScripts = JSON.stringify(moviePilotWorkerScripts)
const identityMessage = JSON.stringify(moviePilotIdentityMessage)
const identityTimeoutMs = JSON.stringify(moviePilotIdentityTimeoutMs)
const identityAttempts = JSON.stringify(moviePilotIdentityAttempts)
const retryIdentityVerification = retryMoviePilotIdentityVerification.toString()
const deleteOriginCaches = deleteCurrentOriginCaches.toString()
const entryScriptUrl = JSON.stringify(devEntryScriptUrl)
const cleanupPath = JSON.stringify(DEV_SW_CLEANUP_PATH)
const cleanupAttemptKeyPrefix = JSON.stringify('moviepilot:dev-sw-cleanup')
const redirectScript = `
(() => {
const entryScriptUrl = ${entryScriptUrl}
const deleteCurrentOriginCaches = ${deleteOriginCaches}
let appStarted = false
const startApp = () => {
if (appStarted) return
appStarted = true
const entry = document.createElement('script')
entry.type = 'module'
entry.src = entryScriptUrl
document.head.appendChild(entry)
}
if (!('serviceWorker' in navigator)) {
startApp()
return
}
const workerScripts = ${workerScripts}
const identityMessage = ${identityMessage}
const identityTimeoutMs = ${identityTimeoutMs}
const identityAttempts = ${identityAttempts}
const retryIdentityVerification = ${retryIdentityVerification}
const cleanupPath = ${cleanupPath}
const appScope = new URL('./', location.href)
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
const cleanupState = sessionStorage.getItem(cleanupAttemptKey)
const getCandidateWorker = registration => {
if (new URL(registration.scope).href !== appScope.href) return null
return [registration.active, registration.waiting, registration.installing].find(worker => {
if (!worker) return false
const scriptUrl = new URL(worker.scriptURL).href
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
}) || null
}
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
const channel = new MessageChannel()
const finish = result => {
window.clearTimeout(timeout)
channel.port1.close()
resolve(result)
}
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
try {
worker.postMessage({ type: identityMessage }, [channel.port2])
} catch {
finish(false)
}
})
const verifyMoviePilotWorker = worker =>
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
const hasVerifiedRegistration = async () => {
const registrations = await navigator.serviceWorker.getRegistrations()
for (const registration of registrations) {
const worker = getCandidateWorker(registration)
if (worker && await verifyMoviePilotWorker(worker)) return true
}
return false
}
const redirectToCleanup = () => {
sessionStorage.setItem(cleanupAttemptKey, 'pending')
const target = new URL(cleanupPath.slice(1), appScope)
target.searchParams.set('return', location.href)
location.replace(target.href)
}
// unregister 不会立即解除当前 document 的 controller应用模块加载前需再导航一次以脱离旧 Worker。
if (cleanupState === 'complete') {
void (async () => {
// 旧 Worker 可能在注销后的首次导航中重新创建缓存;脱离控制后再清理一次。
if ('caches' in window) await deleteCurrentOriginCaches(caches)
sessionStorage.removeItem(cleanupAttemptKey)
location.reload()
})().catch(error => {
console.error('[PWA] Failed to finish stale development cache cleanup', error)
document.body.textContent = 'Failed to finish stale development cache cleanup. Reload to retry.'
})
return
}
void hasVerifiedRegistration().then(hasRegistration => {
if (hasRegistration) {
redirectToCleanup()
return
}
sessionStorage.removeItem(cleanupAttemptKey)
startApp()
}).catch(error => {
console.warn('[PWA] Failed to inspect historical Service Worker state', error)
startApp()
})
})()
`
const cleanupDocument = `<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>MoviePilot Dev Cleanup</title></head>
<body>
<script>
(() => {
const workerScripts = ${workerScripts}
const identityMessage = ${identityMessage}
const identityTimeoutMs = ${identityTimeoutMs}
const identityAttempts = ${identityAttempts}
const retryIdentityVerification = ${retryIdentityVerification}
const deleteCurrentOriginCaches = ${deleteOriginCaches}
const appScope = new URL('./', location.href)
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
const resolveReturnUrl = () => {
const requested = new URLSearchParams(location.search).get('return')
if (!requested) return appScope
const target = new URL(requested, appScope)
return target.href.startsWith(appScope.href) ? target : appScope
}
const returnUrl = resolveReturnUrl()
const getCandidateWorker = registration => {
if (new URL(registration.scope).href !== appScope.href) return null
return [registration.active, registration.waiting, registration.installing].find(worker => {
if (!worker) return false
const scriptUrl = new URL(worker.scriptURL).href
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
}) || null
}
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
const channel = new MessageChannel()
const finish = result => {
window.clearTimeout(timeout)
channel.port1.close()
resolve(result)
}
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
try {
worker.postMessage({ type: identityMessage }, [channel.port2])
} catch {
finish(false)
}
})
const verifyMoviePilotWorker = worker =>
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
const cleanup = async () => {
if (sessionStorage.getItem(cleanupAttemptKey) !== 'pending') {
throw new Error('Missing Service Worker cleanup context for current application scope')
}
const registrations = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistrations() : []
const managedRegistrations = []
for (const registration of registrations) {
const worker = getCandidateWorker(registration)
if (worker && await verifyMoviePilotWorker(worker)) managedRegistrations.push(registration)
}
if (!managedRegistrations.length) throw new Error('MoviePilot Service Worker identity verification failed')
await Promise.allSettled(managedRegistrations.map(registration => registration.unregister()))
// Vite dev server 使用独立 origin仅在确认 MoviePilot Worker 后清除其遗留模块响应。
if ('caches' in window) await deleteCurrentOriginCaches(caches)
// 回跳入口后由 head-prepend 脚本完成第二次导航,避免同一 client 继续复用旧模块响应。
sessionStorage.setItem(cleanupAttemptKey, 'complete')
location.replace(returnUrl.href)
}
void cleanup().catch(error => {
console.error('[PWA] Failed to clean stale development Service Worker state', error)
document.body.textContent = 'Failed to clean stale development Service Worker state. Reload to retry.'
})
})()
</script>
</body>
</html>`
return {
name: 'moviepilot:dev-service-worker-cleanup',
apply: 'serve',
configureServer(server) {
server.middlewares.use((request, response, next) => {
const pathname = new URL(request.url || '/', 'http://localhost').pathname
if (!pathname.endsWith(DEV_SW_CLEANUP_PATH)) {
next()
return
}
response.statusCode = 200
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.setHeader('Cache-Control', 'no-store')
response.end(cleanupDocument)
})
},
transformIndexHtml: {
order: 'pre',
handler(html) {
if (!html.includes(devEntryScriptTag)) {
throw new Error(`Expected development entry tag: ${devEntryScriptTag}`)
}
return {
html: html.replace(devEntryScriptTag, ''),
tags: [{ tag: 'script', children: redirectScript, injectTo: 'head-prepend' }],
}
},
},
}
}

View File

@@ -0,0 +1,22 @@
[
{ "width": 2048, "height": 2732, "scaleFactor": 2 },
{ "width": 1668, "height": 2388, "scaleFactor": 2 },
{ "width": 1536, "height": 2048, "scaleFactor": 2 },
{ "width": 1640, "height": 2360, "scaleFactor": 2 },
{ "width": 1668, "height": 2224, "scaleFactor": 2 },
{ "width": 1620, "height": 2160, "scaleFactor": 2 },
{ "width": 1488, "height": 2266, "scaleFactor": 2 },
{ "width": 1320, "height": 2868, "scaleFactor": 3 },
{ "width": 1206, "height": 2622, "scaleFactor": 3 },
{ "width": 1260, "height": 2736, "scaleFactor": 3 },
{ "width": 1290, "height": 2796, "scaleFactor": 3 },
{ "width": 1179, "height": 2556, "scaleFactor": 3 },
{ "width": 1170, "height": 2532, "scaleFactor": 3 },
{ "width": 1284, "height": 2778, "scaleFactor": 3 },
{ "width": 1125, "height": 2436, "scaleFactor": 3 },
{ "width": 1242, "height": 2688, "scaleFactor": 3 },
{ "width": 828, "height": 1792, "scaleFactor": 2 },
{ "width": 1242, "height": 2208, "scaleFactor": 3 },
{ "width": 750, "height": 1334, "scaleFactor": 2 },
{ "width": 640, "height": 1136, "scaleFactor": 2 }
]

View File

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

View File

@@ -34,10 +34,15 @@ $ps-track-size: 0.5rem;
inline-size: $ps-hover-size;
}
// fix bug
@media(hover: none) {
.ps > .ps__rail-x,
.ps > .ps__rail-y {
opacity: 0.6;
}
}
// 滚动条只反馈实际滚动状态,鼠标经过容器或键盘聚焦不应让轨道常驻。
.ps:hover > .ps__rail-x,
.ps:hover > .ps__rail-y,
.ps--focus > .ps__rail-x,
.ps--focus > .ps__rail-y {
opacity: 0 !important;
}
.ps.ps--scrolling-x > .ps__rail-x,
.ps.ps--scrolling-y > .ps__rail-y {
opacity: 0.6 !important;
}

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

@@ -70,22 +70,57 @@ 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)
}
})
}
/** 在纹理加载前建立带 CORS 响应头的缓存,避免普通图片缓存污染 WebGL 读取。 */
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

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

View File

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

View File

@@ -70,7 +70,7 @@ function registerJinja2Mode() {
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
const operators = 'and|in|is|not|or'
const contextVariables =
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
const keywordMapper = this.createKeywordMapper(
{
@@ -282,7 +282,7 @@ function registerJinja2Mode() {
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
const operators = 'and|in|is|not|or'
const contextVariables =
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
const keywordMapper = this.createKeywordMapper(
{
@@ -530,6 +530,140 @@ function registerJinja2Mode() {
)
}
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) {
this.$rules = {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
],
}
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) {
TextMode.call(this)
this.HighlightRules = WordListHighlightRules
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.$id = 'ace/mode/word_list'
}).call(Mode.prototype)
exports.Mode = Mode
},
)
aceModule.define?.(
'ace/mode/word_list_syntax_highlight_rules',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
const WordListSyntaxHighlightRules = function (this: any) {
this.$rules = {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
{
token: [
'text',
'word_list_replaced',
'keyword.operator.word-list',
'word_list_replacement',
'keyword.operator.word-list',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_replaced', 'keyword.operator.word-list', 'word_list_replacement', 'text'],
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
},
{
token: [
'text',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_block', 'text'],
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
},
],
}
this.normalizeRules()
}
oop.inherits(WordListSyntaxHighlightRules, TextHighlightRules)
exports.WordListSyntaxHighlightRules = WordListSyntaxHighlightRules
},
)
aceModule.define?.(
'ace/mode/word_list_syntax',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_syntax_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextMode = require('./text').Mode
const WordListSyntaxHighlightRules = require('./word_list_syntax_highlight_rules').WordListSyntaxHighlightRules
const Mode = function (this: any) {
TextMode.call(this)
this.HighlightRules = WordListSyntaxHighlightRules
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.$id = 'ace/mode/word_list_syntax'
}).call(Mode.prototype)
exports.Mode = Mode
},
)
}
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
@@ -555,4 +689,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

@@ -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' | (string & {})
// 手动刮削选项
export interface ManualScrapeOptions {
// 媒体数据源
media_source: MediaDataSource
// 数据源原生ID
media_id?: string
// 媒体类型
type_name?: string
}
// 订阅
export interface Subscribe {
// 订阅ID
@@ -15,7 +27,13 @@ export interface Subscribe {
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: string
bangumiid?: number
// AniList ID
anilistid?: number
// 主媒体数据源
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string
// 其它媒体ID
mediaid?: string
// 季号
@@ -114,6 +132,14 @@ export interface SubscribeShare {
tmdbid?: number
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: number
// AniList ID
anilistid?: number
// 主媒体数据源
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string
// 季号
season?: number
// 海报
@@ -216,6 +242,14 @@ export interface TransferHistory {
tvdbid?: number
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: number
// AniList ID
anilistid?: number
// 媒体数据源
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string
// 季Sxx
seasons?: string
// 集Exx
@@ -236,7 +270,7 @@ export interface TransferHistory {
// 媒体信息
export interface MediaInfo {
// 来源themoviedb、douban、bangumi
// 来源themoviedb、douban、bangumi、anilist
source?: string
// 类型 电影、电视剧、合集
type?: string
@@ -257,7 +291,11 @@ export interface MediaInfo {
// 豆瓣ID
douban_id?: string
// Bangumi ID
bangumi_id?: string
bangumi_id?: string | number
// AniList ID
anilist_id?: number
// AniDB ID
anidb_id?: number
// 合集ID
collection_id?: number
// 其它媒体ID前缀
@@ -414,7 +452,7 @@ export interface TmdbEpisode {
// TMDB人物信息
export interface Person {
// 来源themoviedb、douban、bangumi
// 来源themoviedb、douban、bangumi、anilist
source?: string
// ID
id?: number
@@ -962,7 +1000,7 @@ export interface User {
is_superuser: boolean
// 头像
avatar: string
// 是否开启双重验证
// 是否开启二次验证
is_otp: boolean
// 用户权限 json
permissions: { [key: string]: any }
@@ -1072,14 +1110,20 @@ export interface ScheduleProgress {
id?: string
// 名称
name?: string
// 多语言名称
name_i18n?: string
// 提供者
provider?: string
// 多语言提供者
provider_i18n?: string
// 是否正在执行
enable?: boolean
// 当前完成百分比
value?: number
// 当前进度文本
text?: string
// 多语言进度文本
text_i18n?: string
// 执行状态
status?: string
// 最近一次执行是否成功
@@ -1090,6 +1134,8 @@ export interface ScheduleProgress {
finished_at?: string
// 最近一次错误信息
error?: string
// 多语言错误信息
error_i18n?: string
// 扩展数据
data?: Record<string, unknown>
}
@@ -1100,16 +1146,26 @@ export interface ScheduleInfo {
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
// 进度详情
@@ -1333,6 +1389,8 @@ export interface MediaServerConf {
enabled: boolean
// 同步媒体体库列表
sync_libraries?: string[]
// 自动同步间隔(小时),为空时使用旧全局配置
sync_interval?: number | null
}
// 文件整理目录配置
@@ -1407,6 +1465,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 {
// 种子名称
@@ -1427,6 +1507,12 @@ export interface SubscribeLibraryFileInfo {
storage?: string
// 文件路径
file_path?: string
// 媒体服务器名称
server?: string
// 媒体服务器类型emby、jellyfin、plex 等
server_type?: string
// 媒体服务器条目 ID
itemid?: string
}
// 订阅集详情
@@ -1465,6 +1551,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
// 类型
@@ -1493,6 +1587,8 @@ export interface TransferForm {
episode_group?: string | null
// 预览模式
preview?: boolean
// 清理已有成功记录后重新整理
reorganize: boolean
}
// 手动整理请求
@@ -1519,6 +1615,14 @@ export interface ManualTransferTargetPathData {
library_category_folder?: boolean | null
}
// 手动整理命中的成功历史摘要
export interface ManualTransferHistoryInfo {
// 是否应执行重新整理
reorganize: boolean
// 命中的成功历史数量
history_count: number
}
// 手动整理预览统计
export interface ManualTransferPreviewSummary {
// 总数
@@ -1705,6 +1809,40 @@ export interface TorrentCacheData {
data: TorrentCacheItem[]
}
// 媒体识别缓存项
export interface RecognitionCacheItem {
// 缓存键
key: string
// TMDB ID0 表示未识别
tmdb_id?: number
// 豆瓣 ID0 表示未识别
douban_id?: string | 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
// 缓存数据
data: RecognitionCacheItem[]
}
// 订阅分享统计
export interface SubscribeShareStatistics {
// 分享人
@@ -1719,6 +1857,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

View File

@@ -4,6 +4,7 @@ import mdLinkAttributes from 'markdown-it-link-attributes'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useAuthStore, useUserStore } from '@/stores'
import { getCurrentLocale } from '@/plugins/i18n'
type AgentMessageRole = 'user' | 'assistant'
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
@@ -88,6 +89,7 @@ interface AgentStreamEvent {
choice?: Omit<AgentChoiceCard, 'status'>
content?: string
message?: string
message_i18n?: string
message_id?: string
target_message?: Partial<AgentChatMessage> & { id?: string }
session_id?: string
@@ -156,9 +158,12 @@ const userStore = useUserStore()
const props = withDefaults(
defineProps<{
modelValue?: boolean
/** 是否允许面板的装饰性动效不影响消息流、thinking 或输入反馈。 */
motionActive?: boolean
}>(),
{
modelValue: false,
motionActive: true,
},
)
@@ -209,13 +214,11 @@ let pendingMessageScrollToBottom = false
let streamPersistTimer: number | null = null
let userAbortRequested = false
let streamRecoveryTimer: number | null = null
let pendingStreamRecovery:
| {
sessionId: string
startedAt: number
attempts: number
}
| null = null
let pendingStreamRecovery: {
sessionId: string
startedAt: number
attempts: number
} | null = null
const md = new MarkdownIt({
html: true,
@@ -248,12 +251,11 @@ const filteredSlashCommands = computed(() => {
const query = slashCommandQuery.value
if (!query) return slashCommands.value
return slashCommands.value
.filter(command => {
const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase()
return slashCommands.value.filter(command => {
const haystack = `${command.command} ${command.description} ${command.category || ''}`.toLowerCase()
return haystack.includes(query)
})
return haystack.includes(query)
})
})
// 判断是否展示命令建议浮层。
const showSlashCommandMenu = computed(
@@ -622,19 +624,16 @@ function dedupeHistorySessions(sessions: AgentSessionHistoryItem[]) {
// 调用智能助手接口,并统一处理鉴权和标准响应格式。
async function fetchAgentApi(path: string, options: RequestInit = {}) {
const headers = new Headers(options.headers || {})
if (authStore.token) headers.set('Authorization', `Bearer ${authStore.token}`)
const response = await fetch(resolveApiUrl(path), {
...options,
headers,
headers: buildAgentRequestHeaders(options.headers),
credentials: 'include',
})
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
const result = await response.json()
if (!result?.success) throw new Error(result?.message || t('agentAssistant.error'))
if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.error'))
return result.data
}
@@ -981,6 +980,31 @@ function resolveApiUrl(path: string) {
return `${baseUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}`
}
// 构造智能助手 fetch 请求头,补齐 Axios 拦截器无法覆盖的鉴权和语言信息。
function buildAgentRequestHeaders(headers?: HeadersInit) {
const requestHeaders = new Headers(headers || {})
const locale = getCurrentLocale()
if (authStore.token) requestHeaders.set('Authorization', `Bearer ${authStore.token}`)
requestHeaders.set('X-MoviePilot-Locale', locale)
requestHeaders.set('Accept-Language', locale)
return requestHeaders
}
// 解析智能助手 fetch 失败响应,优先使用后端返回的本地化错误文本。
async function resolveAgentResponseErrorMessage(response: Response) {
try {
const payload = await response.clone().json()
const message = payload?.detail_i18n || payload?.message_i18n || payload?.detail || payload?.message
if (typeof message === 'string' && message) return message
} catch {
// 非 JSON 错误响应保留 HTTP 状态文本,避免吞掉原始错误。
}
return `${response.status} ${response.statusText}`.trim()
}
// 消息主列表使用原生滚动,避免流式回复时 JS 滚动库频繁测量影响手感。
function getMessageScrollerElement() {
return messageListRef.value
@@ -1194,7 +1218,7 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
case 'error':
assistantMessage.status = 'error'
// 后端流式错误已经以 AI 消息展示,避免底部提示条重复且持续占位。
assistantMessage.content ||= event.message || t('agentAssistant.error')
assistantMessage.content ||= event.message_i18n || event.message || t('agentAssistant.error')
emit('assistant-preview', assistantMessage.content)
markToolsDone(assistantMessage)
break
@@ -1370,17 +1394,15 @@ async function uploadAgentAttachment(file: File) {
const response = await fetch(resolveApiUrl('message/agent/upload'), {
method: 'POST',
headers: {
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
},
headers: buildAgentRequestHeaders(),
body: formData,
credentials: 'include',
})
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
const result = await response.json()
if (!result?.success) throw new Error(result?.message || t('agentAssistant.uploadFailed'))
if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.uploadFailed'))
return result.data as AgentMessageAttachment & AgentOutgoingFile
}
@@ -1452,10 +1474,9 @@ async function streamAgentMessage(
try {
const response = await fetch(resolveApiUrl('message/agent/stream'), {
method: 'POST',
headers: {
headers: buildAgentRequestHeaders({
'Content-Type': 'application/json',
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
},
}),
body: JSON.stringify({
text: content,
display_text: displayContent || content,
@@ -1473,7 +1494,7 @@ async function streamAgentMessage(
})
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`.trim())
throw new Error(await resolveAgentResponseErrorMessage(response))
}
await readAgentStream(response, assistantMessage)
@@ -1698,10 +1719,9 @@ async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceC
try {
const response = await fetch(resolveApiUrl('message/agent/callback'), {
method: 'POST',
headers: {
headers: buildAgentRequestHeaders({
'Content-Type': 'application/json',
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
},
}),
body: JSON.stringify({
session_id: sessionId.value,
callback_data: button.callback_data,
@@ -1711,10 +1731,10 @@ async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceC
credentials: 'include',
})
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
const result = await response.json()
if (!result?.success) throw new Error(result?.message || t('agentAssistant.choiceExpired'))
if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.choiceExpired'))
const agentMessage = String(result.data?.message || '')
if (result.data?.traditional) {
@@ -1936,9 +1956,11 @@ onScopeDispose(() => {
<aside
v-show="isOpen"
class="agent-assistant-panel"
:class="{ 'is-motion-paused': !props.motionActive, 'is-open': isOpen }"
:style="drawerStyle"
role="dialog"
:aria-label="t('agentAssistant.title')"
@focusin.stop
>
<div class="agent-assistant-shell">
<header class="agent-assistant-header">
@@ -1970,7 +1992,7 @@ onScopeDispose(() => {
location="bottom end"
offset="8"
max-width="360"
:z-index="2103"
:z-index="2603"
>
<template #activator="{ props }">
<IconBtn v-bind="props" :title="t('agentAssistant.history')" :aria-label="t('agentAssistant.history')">
@@ -2326,7 +2348,7 @@ onScopeDispose(() => {
<style lang="scss">
.agent-assistant-history-overlay {
z-index: 2103 !important;
z-index: 2603 !important;
}
</style>
@@ -2336,7 +2358,9 @@ onScopeDispose(() => {
.agent-assistant-panel {
position: fixed;
z-index: 2101;
/* Agent 会话层保持高于入口2600和业务弹窗同时低于自身弹出菜单。 */
z-index: 2601;
overflow: hidden;
background: rgb(var(--v-theme-surface));
@@ -2492,13 +2516,16 @@ onScopeDispose(() => {
position: absolute;
display: block;
border-radius: 0 0 999px 999px;
animation: agent-fab-blink 4.8s ease-in-out infinite;
block-size: 0.24rem;
border-block-end: 0.1rem solid var(--agent-assistant-mini-robot-eye);
inline-size: 0.22rem;
inset-block-start: 0.16rem;
}
.agent-assistant-panel.is-open:not(.is-motion-paused) .agent-assistant-mini-bot__eye {
animation: agent-fab-blink 4.8s ease-in-out 1;
}
.agent-assistant-mini-bot__eye--left {
inset-inline-start: 0.22rem;
}

View File

@@ -1,18 +1,22 @@
<script setup lang="ts">
import AgentAssistantEntry from './AgentAssistantEntry.vue'
import AgentAssistantPanel from './AgentAssistantPanel.vue'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
const panelOpen = ref(false)
const thinking = ref(false)
const entryRef = ref<AgentAssistantEntryRef | null>(null)
const { allowsDecorativeMotion } = useAppActivityLifecycle()
// 打开 Agent 面板并清空入口预览气泡。
function openPanel() {
panelOpen.value = true
entryRef.value?.clearBubbles()
}
// 在面板关闭时展示助手回复预览。
function handleAssistantPreview(value: string) {
if (panelOpen.value) return
@@ -21,6 +25,17 @@ function handleAssistantPreview(value: string) {
</script>
<template>
<AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" />
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" />
<AgentAssistantEntry
ref="entryRef"
:active="!panelOpen"
:motion-active="allowsDecorativeMotion"
:thinking="thinking"
@open="openPanel"
/>
<AgentAssistantPanel
v-model="panelOpen"
:motion-active="allowsDecorativeMotion"
@assistant-preview="handleAssistantPreview"
@thinking-change="thinking = $event"
/>
</template>

View File

@@ -0,0 +1,71 @@
import { shallowMount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AgentAssistantEntry from '@/components/agent/AgentAssistantEntry.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
describe('AgentAssistantEntry lifecycle motion', () => {
let animationFrameCallbacks: Map<number, FrameRequestCallback>
let nextAnimationFrameId: number
beforeEach(() => {
vi.useFakeTimers()
animationFrameCallbacks = new Map()
nextAnimationFrameId = 1
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
const id = nextAnimationFrameId++
animationFrameCallbacks.set(id, callback)
return id
}),
)
vi.stubGlobal(
'cancelAnimationFrame',
vi.fn((id: number) => {
animationFrameCallbacks.delete(id)
}),
)
})
afterEach(() => {
vi.useRealTimers()
})
it('cancels pointer frames and auto-dock timers when decorative motion stops', async () => {
const wrapper = shallowMount(AgentAssistantEntry, {
global: {
stubs: {
AgentPetStage: true,
VIcon: true,
},
},
props: {
active: true,
motionActive: true,
},
})
await nextTick()
const pointerEvent = new Event('pointermove')
Object.assign(pointerEvent, { clientX: 480, clientY: 320 })
const frameCountBeforePointer = animationFrameCallbacks.size
window.dispatchEvent(pointerEvent)
const pointerFrameId = nextAnimationFrameId - 1
expect(animationFrameCallbacks.size).toBe(frameCountBeforePointer + 1)
expect(vi.getTimerCount()).toBeGreaterThanOrEqual(2)
await wrapper.setProps({ motionActive: false })
await nextTick()
expect(cancelAnimationFrame).toHaveBeenCalledWith(pointerFrameId)
expect(animationFrameCallbacks.has(pointerFrameId)).toBe(false)
expect(vi.getTimerCount()).toBe(0)
wrapper.unmount()
})
})

View File

@@ -0,0 +1,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);
}
}

View File

@@ -0,0 +1,50 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-eye-roll .agent-assistant-fab__head {
animation: agent-fab-action-eye-roll-head 1.9s ease-in-out both;
}
.agent-assistant-fab.is-action-eye-roll .agent-assistant-fab__eye {
animation:
agent-fab-blink 4.8s ease-in-out infinite,
agent-fab-action-eye-roll 0.95s ease-in-out 2;
}
@keyframes agent-fab-action-eye-roll-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
24% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-6deg);
}
52% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(5deg);
}
78% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.03rem)) rotate(-3deg);
}
}
@keyframes agent-fab-action-eye-roll {
0%,
100% {
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
}
22% {
transform: translate(0.22rem, -0.2rem);
}
48% {
transform: translate(0, -0.3rem);
}
72% {
transform: translate(-0.22rem, -0.2rem);
}
}

View File

@@ -0,0 +1,138 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-faint .agent-assistant-fab__bot {
animation: agent-fab-action-faint-bot 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__antenna {
animation: agent-fab-action-faint-antenna 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__head {
animation: agent-fab-action-faint-head 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__body {
animation: agent-fab-action-faint-body 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__arm--left {
animation: agent-fab-action-faint-arm-left 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__arm--right {
animation: agent-fab-action-faint-arm-right 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__leg--left {
animation: agent-fab-action-faint-leg-left 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__leg--right {
animation: agent-fab-action-faint-leg-right 4.8s ease-in-out both;
}
@keyframes agent-fab-action-faint-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
26% {
transform: translateY(-0.1rem) scale(var(--agent-assistant-bot-scale)) rotate(12deg);
}
34%,
86% {
transform: translate(0.58rem, 1.08rem) scale(var(--agent-assistant-bot-scale)) rotate(98deg);
}
}
@keyframes agent-fab-action-faint-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
34%,
86% {
transform: translate(0.08rem, 0.02rem) rotate(-18deg);
}
}
@keyframes agent-fab-action-faint-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
28% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(9deg);
}
34%,
86% {
transform: translate(0.04rem, 0.02rem) rotate(-3deg);
}
}
@keyframes agent-fab-action-faint-body {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
}
34%,
86% {
transform: translate(0.04rem, 0.02rem) scaleY(0.9);
}
}
@keyframes agent-fab-action-faint-arm-left {
0%,
100% {
transform: rotate(17deg);
}
34%,
86% {
transform: translate(-0.12rem, 0.16rem) rotate(118deg);
}
}
@keyframes agent-fab-action-faint-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
34%,
86% {
transform: translate(0.12rem, 0.16rem) rotate(-118deg);
}
}
@keyframes agent-fab-action-faint-leg-left {
0%,
100% {
transform: rotate(0deg);
}
34%,
86% {
transform: translate(-0.24rem, -0.02rem) rotate(82deg);
}
}
@keyframes agent-fab-action-faint-leg-right {
0%,
100% {
transform: rotate(0deg);
}
34%,
86% {
transform: translate(0.24rem, -0.02rem) rotate(-82deg);
}
}

View File

@@ -0,0 +1,309 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__bot {
animation: agent-fab-action-happy-jump-bot 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__antenna {
animation: agent-fab-action-happy-jump-antenna 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__head {
animation: agent-fab-action-happy-jump-head 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__eye {
animation: agent-fab-action-happy-jump-eye 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__smile {
animation: agent-fab-action-happy-jump-smile 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__body {
animation: agent-fab-action-happy-jump-body 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__arm--left {
z-index: 6;
animation: agent-fab-action-happy-jump-arm-left 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__arm--right {
z-index: 6;
animation: agent-fab-action-happy-jump-arm-right 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__leg--left {
animation: agent-fab-action-happy-jump-leg-left 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__leg--right {
animation: agent-fab-action-happy-jump-leg-right 5.2s ease-in-out both;
}
@keyframes agent-fab-action-happy-jump-bot {
/* 三段蓄力、起跳、落地,让开心连跳比单纯上下移动更像真人动作。 */
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
9%,
37%,
65% {
transform: translateY(0.22rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 3deg));
}
20%,
48%,
76% {
transform: translateY(-0.76rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 4deg));
}
29%,
57%,
85% {
transform: translateY(0.1rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 2deg));
}
92% {
transform: translateY(-0.1rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 1deg));
}
}
@keyframes agent-fab-action-happy-jump-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
9%,
37%,
65% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(34deg);
}
20%,
48%,
76% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.2rem)) rotate(-18deg);
}
29%,
57%,
85% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.04rem)) rotate(38deg);
}
}
@keyframes agent-fab-action-happy-jump-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
9%,
37%,
65% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(-4deg);
}
20%,
48%,
76% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.16rem)) rotate(7deg);
}
29%,
57%,
85% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.04rem)) rotate(-5deg);
}
92% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(2deg);
}
}
@keyframes agent-fab-action-happy-jump-eye {
0%,
100% {
border-block-end-width: 0.15rem;
opacity: 1;
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1, 1);
}
8%,
36%,
64%,
92% {
border-block-end-width: 0.18rem;
transform: translate(0, -0.06rem) scale(1.08, 0.72);
}
20%,
48%,
76% {
border-block-end-width: 0.19rem;
transform: translate(0, -0.12rem) scale(1.16, 0.58);
}
}
@keyframes agent-fab-action-happy-jump-smile {
0%,
100% {
opacity: 0;
transform: translateX(-50%) scale(0.72);
}
6%,
90% {
opacity: 1;
}
9%,
37%,
65% {
transform: translateX(-50%) translateY(0.02rem) scale(0.92, 0.82);
}
20%,
48%,
76% {
transform: translateX(-50%) translateY(-0.04rem) scale(1.22, 1.08);
}
}
@keyframes agent-fab-action-happy-jump-body {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
}
9%,
37%,
65% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.18rem)) scaleY(0.84)
rotate(-2deg);
}
20%,
48%,
76% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) - 0.1rem)) scaleY(1.08)
rotate(4deg);
}
29%,
57%,
85% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.08rem)) scaleY(0.94)
rotate(-3deg);
}
}
@keyframes agent-fab-action-happy-jump-arm-left {
0%,
100% {
transform: rotate(17deg);
}
9%,
37%,
65% {
transform: translate(-0.08rem, 0.06rem) rotate(54deg);
}
20%,
48%,
76% {
transform: translate(-0.24rem, -0.38rem) rotate(152deg);
}
29%,
57%,
85% {
transform: translate(-0.1rem, -0.02rem) rotate(96deg);
}
}
@keyframes agent-fab-action-happy-jump-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
9%,
37%,
65% {
transform: translate(0.08rem, 0.06rem) rotate(-54deg);
}
20%,
48%,
76% {
transform: translate(0.24rem, -0.38rem) rotate(-152deg);
}
29%,
57%,
85% {
transform: translate(0.1rem, -0.02rem) rotate(-96deg);
}
}
@keyframes agent-fab-action-happy-jump-leg-left {
0%,
100% {
transform: rotate(0deg);
}
9%,
37%,
65% {
transform: translate(0.08rem, -0.22rem) rotate(82deg);
}
20%,
48%,
76% {
transform: translate(-0.18rem, 0.08rem) rotate(-32deg);
}
29%,
57%,
85% {
transform: translate(0.12rem, -0.08rem) rotate(44deg);
}
}
@keyframes agent-fab-action-happy-jump-leg-right {
0%,
100% {
transform: rotate(0deg);
}
9%,
37%,
65% {
transform: translate(-0.08rem, -0.22rem) rotate(-82deg);
}
20%,
48%,
76% {
transform: translate(0.18rem, 0.08rem) rotate(32deg);
}
29%,
57%,
85% {
transform: translate(-0.12rem, -0.08rem) rotate(-44deg);
}
}

View File

@@ -0,0 +1,106 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-peek .agent-assistant-fab__bot {
animation: agent-fab-action-peek-bot 2.8s ease-in-out both;
}
.agent-assistant-fab.is-action-peek .agent-assistant-fab__antenna {
animation: agent-fab-action-peek-antenna 2.8s ease-in-out both;
}
.agent-assistant-fab.is-action-peek .agent-assistant-fab__head {
animation: agent-fab-action-peek-head 2.8s ease-in-out both;
}
.agent-assistant-fab.is-action-peek .agent-assistant-fab__eye {
animation: agent-fab-action-peek-eye 2.8s ease-in-out both;
}
.agent-assistant-fab.is-action-peek .agent-assistant-fab__arm--left {
z-index: 6;
animation: agent-fab-action-peek-arm-left 2.8s ease-in-out both;
}
.agent-assistant-fab.is-action-peek .agent-assistant-fab__arm--right {
animation: agent-fab-action-peek-arm-right 2.8s ease-in-out both;
}
@keyframes agent-fab-action-peek-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
26%,
78% {
transform: translateX(-0.48rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 8deg));
}
48% {
transform: translateX(-0.58rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 11deg));
}
}
@keyframes agent-fab-action-peek-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
26%,
78% {
transform: translate(-0.12rem, -0.06rem) rotate(-12deg);
}
}
@keyframes agent-fab-action-peek-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
26%,
78% {
transform: translate(calc(var(--agent-assistant-head-x) - 0.18rem), calc(var(--agent-assistant-head-y) - 0.04rem))
rotate(-10deg);
}
}
@keyframes agent-fab-action-peek-eye {
0%,
100% {
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
}
26%,
78% {
transform: translate(-0.18rem, -0.02rem) scale(1.12, 1);
}
}
@keyframes agent-fab-action-peek-arm-left {
0%,
100% {
transform: rotate(17deg);
}
26%,
78% {
transform: translate(-0.24rem, -0.12rem) rotate(116deg);
}
}
@keyframes agent-fab-action-peek-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
26%,
78% {
transform: translate(0.04rem, 0.06rem) rotate(-34deg);
}
}

View File

@@ -0,0 +1,84 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-scan .agent-assistant-fab__head {
animation: agent-fab-action-scan-head 3.2s ease-in-out both;
}
.agent-assistant-fab.is-action-scan .agent-assistant-fab__face {
animation: agent-fab-action-scan-face 3.2s ease-in-out both;
}
.agent-assistant-fab.is-action-scan .agent-assistant-fab__eye {
animation: agent-fab-action-scan-eye 3.2s ease-in-out both;
}
.agent-assistant-fab.is-action-scan .agent-assistant-fab__core {
animation: agent-fab-action-scan-core 3.2s ease-in-out both;
}
@keyframes agent-fab-action-scan-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
24% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(-8deg);
}
52% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(8deg);
}
78% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(-4deg);
}
}
@keyframes agent-fab-action-scan-face {
0%,
100% {
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
}
18%,
78% {
box-shadow:
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
0 0 0.72rem rgba(114, 255, 240, 48%);
}
}
@keyframes agent-fab-action-scan-eye {
0%,
100% {
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
}
18% {
transform: translate(-0.28rem, -0.02rem) scale(1.1, 0.9);
}
46% {
transform: translate(0.28rem, -0.02rem) scale(1.1, 0.9);
}
74% {
transform: translate(0, -0.18rem) scale(1.18, 0.72);
}
}
@keyframes agent-fab-action-scan-core {
0%,
100% {
opacity: 0.78;
transform: scale(1);
}
25%,
75% {
opacity: 1;
transform: scale(1.24);
}
}

View File

@@ -0,0 +1,14 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__antenna,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__head,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__face,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__body,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__core,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__arm,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__leg,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__eye,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__smile {
transition: none;
}

View File

@@ -0,0 +1,88 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-shy .agent-assistant-fab__bot {
animation: agent-fab-action-shy-bot 3s ease-in-out both;
}
.agent-assistant-fab.is-action-shy .agent-assistant-fab__head {
animation: agent-fab-action-shy-head 3s ease-in-out both;
}
.agent-assistant-fab.is-action-shy .agent-assistant-fab__eye {
animation: agent-fab-action-shy-eye 3s ease-in-out both;
}
.agent-assistant-fab.is-action-shy .agent-assistant-fab__arm--left {
z-index: 6;
animation: agent-fab-action-shy-arm-left 3s ease-in-out both;
}
.agent-assistant-fab.is-action-shy .agent-assistant-fab__arm--right {
z-index: 6;
animation: agent-fab-action-shy-arm-right 3s ease-in-out both;
}
@keyframes agent-fab-action-shy-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
24%,
78% {
transform: translateX(0.16rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 7deg));
}
}
@keyframes agent-fab-action-shy-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
24%,
78% {
transform: translate(calc(var(--agent-assistant-head-x) + 0.08rem), calc(var(--agent-assistant-head-y) + 0.14rem))
rotate(10deg);
}
}
@keyframes agent-fab-action-shy-eye {
0%,
100% {
opacity: 1;
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
}
24%,
78% {
opacity: 0.78;
transform: translate(0, 0.08rem) scale(0.86, 0.48);
}
}
@keyframes agent-fab-action-shy-arm-left {
0%,
100% {
transform: rotate(17deg);
}
24%,
78% {
transform: translate(0.02rem, -0.16rem) rotate(118deg);
}
}
@keyframes agent-fab-action-shy-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
24%,
78% {
transform: translate(-0.02rem, -0.16rem) rotate(-118deg);
}
}

View File

@@ -0,0 +1,116 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-sit .agent-assistant-fab__bot {
animation: agent-fab-action-sit-bot 4.2s ease-in-out both;
}
.agent-assistant-fab.is-action-sit .agent-assistant-fab__head {
animation: agent-fab-action-sit-head 4.2s ease-in-out both;
}
.agent-assistant-fab.is-action-sit .agent-assistant-fab__body {
animation: agent-fab-action-sit-body 4.2s ease-in-out both;
}
.agent-assistant-fab.is-action-sit .agent-assistant-fab__arm--left {
animation: agent-fab-action-sit-arm-left 4.2s ease-in-out both;
}
.agent-assistant-fab.is-action-sit .agent-assistant-fab__arm--right {
animation: agent-fab-action-sit-arm-right 4.2s ease-in-out both;
}
.agent-assistant-fab.is-action-sit .agent-assistant-fab__leg--left {
z-index: 5;
animation: agent-fab-action-sit-leg-left 4.2s ease-in-out both;
}
.agent-assistant-fab.is-action-sit .agent-assistant-fab__leg--right {
z-index: 5;
animation: agent-fab-action-sit-leg-right 4.2s ease-in-out both;
}
@keyframes agent-fab-action-sit-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
22%,
84% {
transform: translateY(0.36rem) scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
}
@keyframes agent-fab-action-sit-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
22%,
84% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(2deg);
}
}
@keyframes agent-fab-action-sit-body {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
}
22%,
84% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.22rem)) scaleY(0.82);
}
}
@keyframes agent-fab-action-sit-arm-left {
0%,
100% {
transform: rotate(17deg);
}
22%,
84% {
transform: translate(0.08rem, 0.22rem) rotate(76deg);
}
}
@keyframes agent-fab-action-sit-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
22%,
84% {
transform: translate(-0.08rem, 0.22rem) rotate(-76deg);
}
}
@keyframes agent-fab-action-sit-leg-left {
0%,
100% {
transform: rotate(0deg);
}
22%,
84% {
transform: translate(0.18rem, -0.18rem) rotate(94deg);
}
}
@keyframes agent-fab-action-sit-leg-right {
0%,
100% {
transform: rotate(0deg);
}
22%,
84% {
transform: translate(-0.18rem, -0.18rem) rotate(-94deg);
}
}

View File

@@ -0,0 +1,144 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__bot {
animation: agent-fab-action-sleep-bot 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__antenna {
animation: agent-fab-action-sleep-antenna 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__head {
animation: agent-fab-action-sleep-head 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__eye {
animation: agent-fab-action-sleep-eye 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__body {
animation: agent-fab-action-sleep-body 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__arm--left {
animation: agent-fab-action-sleep-arm-left 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__arm--right {
animation: agent-fab-action-sleep-arm-right 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__leg {
animation: agent-fab-action-sleep-leg 5.8s ease-in-out both;
}
@keyframes agent-fab-action-sleep-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
22%,
82% {
transform: translateY(0.42rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 8deg));
}
46%,
64% {
transform: translateY(0.36rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 10deg));
}
}
@keyframes agent-fab-action-sleep-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
22%,
82% {
opacity: 0.62;
transform: translate(0.1rem, 0.16rem) rotate(78deg) scale(0.78);
}
}
@keyframes agent-fab-action-sleep-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
22%,
82% {
transform: translate(calc(var(--agent-assistant-head-x) - 0.12rem), calc(var(--agent-assistant-head-y) + 0.22rem))
rotate(-12deg);
}
}
@keyframes agent-fab-action-sleep-eye {
0%,
100% {
border-block-end-width: 0.15rem;
opacity: 1;
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
}
18%,
86% {
border-block-end-width: 0.1rem;
opacity: 0.62;
transform: translate(0, 0.1rem) scale(1.18, 0.24);
}
}
@keyframes agent-fab-action-sleep-body {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
}
22%,
82% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.26rem)) scaleY(0.78);
}
}
@keyframes agent-fab-action-sleep-arm-left {
0%,
100% {
transform: rotate(17deg);
}
22%,
82% {
transform: translate(0.12rem, 0.2rem) rotate(92deg);
}
}
@keyframes agent-fab-action-sleep-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
22%,
82% {
transform: translate(-0.12rem, 0.2rem) rotate(-92deg);
}
}
@keyframes agent-fab-action-sleep-leg {
0%,
100% {
transform: rotate(0deg);
}
22%,
82% {
opacity: 0.35;
transform: translateY(-0.16rem) scale(0.76);
}
}

View File

@@ -0,0 +1,130 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__bot {
animation: agent-fab-action-spin-cheer-bot 3.6s ease-in-out both;
}
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__antenna {
animation: agent-fab-action-spin-cheer-antenna 3.6s ease-in-out both;
}
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__head {
animation: agent-fab-action-spin-cheer-head 3.6s ease-in-out both;
}
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__eye {
animation: agent-fab-action-spin-cheer-eye 3.6s ease-in-out both;
}
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__smile {
animation: agent-fab-action-spin-cheer-smile 3.6s ease-in-out both;
}
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__arm--left {
z-index: 6;
animation: agent-fab-action-spin-cheer-arm-left 3.6s ease-in-out both;
}
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__arm--right {
z-index: 6;
animation: agent-fab-action-spin-cheer-arm-right 3.6s ease-in-out both;
}
@keyframes agent-fab-action-spin-cheer-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
18% {
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 7deg));
}
48% {
transform: translateY(-0.58rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 184deg));
}
78% {
transform: translateY(-0.14rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 356deg));
}
}
@keyframes agent-fab-action-spin-cheer-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
22%,
78% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.18rem)) rotate(-28deg);
}
}
@keyframes agent-fab-action-spin-cheer-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
22%,
78% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.12rem)) rotate(8deg);
}
}
@keyframes agent-fab-action-spin-cheer-eye {
0%,
100% {
border-block-end-width: 0.15rem;
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
}
20%,
80% {
border-block-end-width: 0.19rem;
transform: translate(0, -0.12rem) scale(1.18, 0.58);
}
}
@keyframes agent-fab-action-spin-cheer-smile {
0%,
100% {
opacity: 0;
transform: translateX(-50%) scale(0.72);
}
16%,
84% {
opacity: 1;
transform: translateX(-50%) translateY(-0.03rem) scale(1.24, 1.08);
}
}
@keyframes agent-fab-action-spin-cheer-arm-left {
0%,
100% {
transform: rotate(17deg);
}
20%,
80% {
transform: translate(-0.28rem, -0.44rem) rotate(148deg);
}
}
@keyframes agent-fab-action-spin-cheer-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
20%,
80% {
transform: translate(0.28rem, -0.44rem) rotate(-148deg);
}
}

View File

@@ -0,0 +1,146 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__bot {
animation: agent-fab-action-stretch-bot 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__antenna {
animation: agent-fab-action-stretch-antenna 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__head {
animation: agent-fab-action-stretch-head 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__body {
animation: agent-fab-action-stretch-body 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__arm--left {
z-index: 6;
animation: agent-fab-action-stretch-arm-left 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__arm--right {
z-index: 6;
animation: agent-fab-action-stretch-arm-right 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__leg--left {
animation: agent-fab-action-stretch-leg-left 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__leg--right {
animation: agent-fab-action-stretch-leg-right 4.4s ease-in-out both;
}
@keyframes agent-fab-action-stretch-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
18% {
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale)) scaleY(0.92)
rotate(var(--agent-assistant-robot-tilt));
}
44%,
70% {
transform: translateY(-0.34rem) scale(var(--agent-assistant-bot-scale)) scaleY(1.06)
rotate(calc(var(--agent-assistant-robot-tilt) + 2deg));
}
86% {
transform: translateY(0.08rem) scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
}
@keyframes agent-fab-action-stretch-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
44%,
70% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.2rem)) rotate(-24deg);
}
}
@keyframes agent-fab-action-stretch-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
44%,
70% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.28rem)) rotate(5deg);
}
}
@keyframes agent-fab-action-stretch-body {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
}
18% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.12rem)) scaleY(0.88);
}
44%,
70% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) - 0.08rem)) scaleY(1.12);
}
}
@keyframes agent-fab-action-stretch-arm-left {
0%,
100% {
transform: rotate(17deg);
}
44%,
70% {
transform: translate(-0.24rem, -0.7rem) rotate(168deg);
}
}
@keyframes agent-fab-action-stretch-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
44%,
70% {
transform: translate(0.24rem, -0.7rem) rotate(-168deg);
}
}
@keyframes agent-fab-action-stretch-leg-left {
0%,
100% {
transform: rotate(0deg);
}
44%,
70% {
transform: translate(-0.18rem, 0.02rem) rotate(-18deg);
}
}
@keyframes agent-fab-action-stretch-leg-right {
0%,
100% {
transform: rotate(0deg);
}
44%,
70% {
transform: translate(0.18rem, 0.02rem) rotate(18deg);
}
}

View File

@@ -0,0 +1,87 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-wave .agent-assistant-fab__bot {
animation: agent-fab-action-wave-bot 2.3s ease-in-out both;
}
.agent-assistant-fab.is-action-wave .agent-assistant-fab__head {
animation: agent-fab-action-wave-head 2.3s ease-in-out both;
}
.agent-assistant-fab.is-action-wave .agent-assistant-fab__arm--left {
z-index: 6;
animation: agent-fab-action-wave-arm-left 2.3s ease-in-out both;
transform-origin: top center;
}
.agent-assistant-fab.is-action-wave .agent-assistant-fab__arm--right {
animation: agent-fab-action-wave-arm-right 2.3s ease-in-out both;
}
@keyframes agent-fab-action-wave-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
22%,
66% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(calc(var(--agent-assistant-robot-tilt) - 4deg));
}
}
@keyframes agent-fab-action-wave-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
30%,
66% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.1rem)) rotate(-4deg);
}
}
@keyframes agent-fab-action-wave-arm-left {
0%,
100% {
transform: rotate(17deg);
}
16% {
transform: translate(-0.08rem, -0.06rem) rotate(64deg);
}
30% {
transform: translate(-0.26rem, -0.28rem) rotate(132deg);
}
44% {
transform: translate(-0.18rem, -0.2rem) rotate(92deg);
}
58% {
transform: translate(-0.26rem, -0.28rem) rotate(132deg);
}
72% {
transform: translate(-0.1rem, -0.08rem) rotate(64deg);
}
88% {
transform: translate(-0.02rem, -0.02rem) rotate(28deg);
}
}
@keyframes agent-fab-action-wave-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
30%,
70% {
transform: rotate(-28deg) translateY(0.06rem);
}
}

View File

@@ -0,0 +1,15 @@
@use './shared';
@use './wave';
@use './sit';
@use './eye-roll';
@use './faint';
@use './disassemble';
@use './happy-jump';
@use './sleep';
@use './stretch';
@use './peek';
@use './scan';
@use './charge';
@use './spin-cheer';
@use './shy';
@use './confused';

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