Compare commits

...

270 Commits

Author SHA1 Message Date
InfinityPacer
ff871e7049 feat(login): add hidden logo lab access (#542) 2026-07-17 16:47:11 +08:00
InfinityPacer
20f80d0f1f test(subscribe): cover subscription calendar (#541) 2026-07-17 16:14:24 +08:00
jxxghp
36bb99cf06 Improve plugin market filter menu layout 2026-07-17 15:45:41 +08:00
jxxghp
24c2e3b49c Fix login layout on short mobile screens 2026-07-17 14:47:19 +08:00
jxxghp
486a471c0c 更新 package.json 2026-07-17 14:18:55 +08:00
InfinityPacer
ce32da9176 fix(theme): preserve logo detail across theme colors (#540) 2026-07-17 14:18:35 +08:00
InfinityPacer
39dd1b9e00 feat: 让应用 Logo 跟随主题色 (#538)
* fix(login): show form with logo

* feat(theme): sync logos with primary color

* test(theme): cover favicon color sync

* chore(login): remove unused metal logo component

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-07-17 13:01:25 +08:00
InfinityPacer
807711cae8 test(subscribe): cover card actions (#539) 2026-07-17 12:57:28 +08:00
jxxghp
00feeb86ca Fix agent assistant layering and login card visibility 2026-07-17 12:53:01 +08:00
InfinityPacer
4378c4843b feat: 增强登录页交互式 3D Logo (#536)
* feat(login): add interactive optical logo lab

* feat(login): add prismatic logo component

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

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

* feat(login): add logo palette toggle

* feat(login): add configurable logo presets

* fix(login): ignore lab route after authentication

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

* perf(theme): reduce realtime glass backdrop filters

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

* fix(subscribe): prompt mode for single existing season
2026-06-25 11:30:24 +08:00
jxxghp
175f610524 Animate dashboard metrics and standardize card sizing 2026-06-25 11:28:36 +08:00
jxxghp
530fe9d35b fix: 修复卡片 hover 上浮抖动 2026-06-24 21:34:38 +08:00
jxxghp
48ed396a19 Refresh notifications when opening the menu 2026-06-24 20:58:46 +08:00
jxxghp
b0356c217d feat: add welcome back message to login page and enhance styles 2026-06-24 17:38:03 +08:00
jxxghp
55eed1ecb5 Fix notification read and clear state 2026-06-24 16:58:08 +08:00
jxxghp
50ae739a4d fix: resolve stylelint warnings and enhance bubble layout for agent assistant 2026-06-24 14:33:55 +08:00
jxxghp
d9cbcc2991 Increase agent assistant bubble text size 2026-06-24 13:44:09 +08:00
jxxghp
ad12701fe2 chore: update version to 2.13.15 2026-06-24 12:55:32 +08:00
jxxghp
2b426a47c6 Route toast notifications to agent assistant bubbles 2026-06-24 12:55:01 +08:00
jxxghp
7f0f12ac41 fix: adjust wave action duration and refine animation keyframes 2026-06-24 12:01:24 +08:00
jxxghp
6789d63ca1 Add happy-jump animation to agent assistant FAB 2026-06-24 08:45:44 +08:00
jxxghp
3202251f55 Add disassemble animation to assistant FAB 2026-06-24 08:33:59 +08:00
jxxghp
8e99ad9cf9 Fix agent assistant arm animation 2026-06-24 07:39:29 +08:00
jxxghp
83dde400e7 fix: refine agent wave animation 2026-06-24 07:28:06 +08:00
jxxghp
1b57f925ee Add random FAB actions and global pointer tracking 2026-06-24 07:14:52 +08:00
InfinityPacer
16428066b9 ci: review PRs with PR-Agent (#500) 2026-06-24 06:10:12 +08:00
InfinityPacer
e211a80cf4 ci: add PR-Agent review workflow (#499) 2026-06-23 23:21:55 +08:00
jxxghp
ea0b5b62d9 Remove obsolete frontend code 2026-06-23 20:16:39 +08:00
jxxghp
62dc2c4f66 feat(agent-assistant): enhance FAB interactive bounds calculation for improved drag functionality 2026-06-23 19:58:13 +08:00
jxxghp
b2a2c7080e feat(agent-assistant): add active prop to AgentAssistantEntry for visibility control 2026-06-23 17:56:53 +08:00
jxxghp
05c2e7855a feat: add agent assistant notification bubble functionality
- Introduced a new utility for managing agent assistant notifications.
- Created functions to emit and listen for notification bubbles using custom events.
- Defined types for notification payloads to ensure type safety.
2026-06-23 17:37:48 +08:00
jxxghp
8d9c622dc5 Improve agent assistant message scrolling 2026-06-23 16:24:23 +08:00
jxxghp
bf0b17c314 style(agent-assistant): update FAB styles for transparency and layout adjustments 2026-06-23 16:13:00 +08:00
jxxghp
37f31f6554 style(agent-assistant): update FAB and mini-bot styles with new color variables 2026-06-23 15:57:11 +08:00
jxxghp
3de409fb07 style(agent-assistant): update FAB styles for transparency and bubble background 2026-06-23 15:52:07 +08:00
jxxghp
7e9c0fd206 feat(notification): support scoped message clearing 2026-06-23 13:43:33 +08:00
jxxghp
fb4f5658a8 更新 package.json 2026-06-23 11:46:32 +08:00
jxxghp
a9f4ec963b fix(plugin): align market detail actions on small screens 2026-06-22 13:25:23 +08:00
jxxghp
542e33d7b4 fix(discover): stabilize media card hover 2026-06-22 12:51:48 +08:00
jxxghp
39c250ba09 更新 package.json 2026-06-21 16:17:11 +08:00
jxxghp
924fcef403 fix(setting): 将LLM最大上下文令牌数从64增加到128 2026-06-19 19:41:10 +08:00
InfinityPacer
e586342b19 fix(pwa): prevent stale service worker caching (#498) 2026-06-19 18:52:23 +08:00
Album
c795de9b2d feat(reorganize): 优化手动整理预览的分页、排序与样式显示 (#497) 2026-06-19 16:05:39 +08:00
jxxghp
6fa1cf28f4 fix(calendar): 优化日历组件中的代码格式和样式 2026-06-19 14:32:33 +08:00
jxxghp
3f70aafdad feat(theme): 添加阴影级别支持并优化主题定制器的阴影设置 2026-06-19 14:12:58 +08:00
jxxghp
f8ceee39b3 fix(plugin): 优化插件市场详情对话框的按钮布局和样式 2026-06-19 11:31:46 +08:00
jxxghp
0a22f33e34 fix(package): 更新版本号至2.13.12 2026-06-19 10:22:01 +08:00
jxxghp
cf88ed9a58 fix(notification): 调整通知标题和计数样式以改善可读性 2026-06-19 10:17:10 +08:00
jxxghp
49dfd794c1 feat(notification): add support for grouped notification display and localization 2026-06-19 10:08:03 +08:00
jxxghp
68f2f010d1 fix(notification): improve notification expansion handling and styling 2026-06-19 10:02:16 +08:00
jxxghp
9eed2fea87 feat: optimize notification center controls 2026-06-19 09:49:22 +08:00
jxxghp
1f170030ee fix: harden plugin market list rendering 2026-06-18 19:45:21 +08:00
jxxghp
e78ed20936 fix: 更新 viewport meta 标签,移除不必要的 interactive-widget 属性 2026-06-18 19:30:52 +08:00
InfinityPacer
b1787b207d fix(plugin): sanitize version history markdown (#496) 2026-06-18 19:30:24 +08:00
InfinityPacer
fdb34732cc fix(plugin): refine release version history UI (#495) 2026-06-18 18:52:54 +08:00
jxxghp
fc1f163a94 fix: 更新 SiteResourceDialog 组件样式,优化移动端显示和用户交互体验 2026-06-18 17:54:25 +08:00
InfinityPacer
a771dc5354 feat(plugin): add release version install actions (#494) 2026-06-18 15:48:09 +08:00
jxxghp
d28360a161 feat: load agent assistant history 2026-06-18 11:45:50 +08:00
jxxghp
a730abc437 refactor: 优化通知组件的样式和逻辑,移除不必要的状态,改善用户体验 2026-06-17 22:23:04 +08:00
jxxghp
5b72eda4fc fix: keep notification read action available 2026-06-17 18:17:38 +08:00
jxxghp
6c49d7a59e feat: 添加日历事件展开功能,优化事件显示和用户交互体验 2026-06-17 18:05:40 +08:00
jxxghp
8900366faf fix: 修复订阅卡片的待定状态样式,确保正确应用 pending tint 2026-06-17 17:21:56 +08:00
jxxghp
e8e0ac9084 refactor: remove scoped global css escapes 2026-06-17 17:19:09 +08:00
jxxghp
c66ee881b1 refactor: remove ShortcutMessageDialog and MessageView components; update ShortcutBar and UserNotification for improved notification handling 2026-06-17 16:32:00 +08:00
jxxghp
c055740926 feat: 优化错误处理逻辑,避免重复提示并改善用户体验 2026-06-17 12:36:34 +08:00
PKC278
a5bc4e6baf fix: restore search filter chip colors (#493) 2026-06-17 12:03:20 +08:00
jxxghp
15b4ee5893 feat: 隐藏主题定制器的滚动条,改善用户界面体验 2026-06-17 12:00:11 +08:00
jxxghp
8868403ff3 feat: 在组件挂载时同步主题定制器状态,确保全局 FAB 预留右侧空间 2026-06-17 11:29:26 +08:00
jxxghp
3abff72e25 feat: 添加语音录制功能,支持录音和相关提示信息 2026-06-17 11:11:55 +08:00
jxxghp
0c56cf0be7 feat: 优化消息列表样式,增加内容存在时的底部填充,改善滚动体验 2026-06-17 08:31:23 +08:00
jxxghp
ce12d04648 feat: 添加新会话时自动滚动到顶部,优化空态展示 2026-06-17 08:15:30 +08:00
jxxghp
efc0ae4df6 fix: keep agent composer floating without early scroll 2026-06-17 07:15:36 +08:00
jxxghp
2530c3bcd9 fix: stabilize mobile panel height 2026-06-17 07:01:33 +08:00
jxxghp
60e2402aff feat: 优化 AgentAssistantWidget 组件的样式和结构,增强可读性和用户体验 2026-06-16 23:29:58 +08:00
jxxghp
1a478f97fb feat: 添加历史会话功能,支持会话恢复和删除,更新多语言文本 2026-06-16 23:18:14 +08:00
jxxghp
33666703af feat: 添加选择功能和附件上传支持,更新多语言文本 2026-06-16 22:55:26 +08:00
jxxghp
cd69172a99 feat: 更新 AgentAssistantWidget 组件的空状态样式和文本内容 2026-06-16 22:23:37 +08:00
jxxghp
61749e3595 feat: Web Agent 透明主题磨砂效果 (#492) 2026-06-16 21:52:45 +08:00
jxxghp
b658533262 更新 package.json 2026-06-16 21:20:58 +08:00
jxxghp
d8015b7def feat: add Agent Assistant component and integrate with theme customizer
- Implemented Agent Assistant widget with chat functionality, including message handling and streaming responses.
- Added new localization strings for Agent Assistant in English, Simplified Chinese, and Traditional Chinese.
- Updated DefaultLayout to include the Agent Assistant and Theme Customizer components.
- Enhanced UserProfile to manage the opening of the Theme Customizer through global events.
- Adjusted CSS styles for the Agent Assistant and its interactions with other components.
- Introduced new events for opening the Theme Customizer and managing its state.
2026-06-16 20:44:00 +08:00
246 changed files with 44312 additions and 6881 deletions

1
.env.test Normal file
View File

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

52
.github/workflows/pr-agent.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: PR-Agent
on:
pull_request_target:
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
types:
- opened
- reopened
- ready_for_review
- review_requested
- synchronize
issue_comment:
types:
- created
- edited
permissions:
contents: read
pull-requests: write
issues: write
jobs:
pr-agent:
if: >-
github.event.sender.type != 'Bot' &&
(
github.event_name == 'pull_request_target' ||
(
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)
)
)
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 Review
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
env:
GITHUB_TOKEN: ${{ github.token }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
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

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

@@ -0,0 +1,37 @@
name: Frontend Tests
on:
pull_request:
branches:
- v2
permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '24'
cache: yarn
cache-dependency-path: yarn.lock
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Typecheck
run: yarn typecheck
- name: Unit tests with coverage
run: yarn test:coverage

3
.gitignore vendored
View File

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

View File

@@ -40,6 +40,15 @@ yarn dev
yarn build
```
### 单元测试
```sh
yarn test:run
yarn test:coverage
```
测试文件组织、共享测试设施、HTTP mock、覆盖率门禁和新增用例规范见[单元测试架构](docs/testing.md)。
### 静态运行
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件nginx配置参考 `public/nginx.conf`

View File

@@ -449,21 +449,16 @@ 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/) - 开发插件组件的完整示例项目

94
docs/testing.md Normal file
View File

@@ -0,0 +1,94 @@
# 单元测试架构
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`
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`。测试模式保留 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 typecheck
yarn lint
yarn build
```
Pull Request 测试工作流使用 Node 24 LTS 和 frozen lockfile依次执行类型检查和覆盖率门禁。现有 lint 基线问题按仓库当前维护约定单独处理,新增测试代码不得引入新的 lint 错误。

View File

@@ -4,7 +4,7 @@
--safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-top: env(safe-area-inset-top);
--initial-loader-bg: #0E1116;
--initial-loader-color: #9155FD;
--initial-loader-color: #8D51F9;
--initial-loader-height: 100svh;
--initial-loader-width: 100vw;
--initial-color-scheme: dark;
@@ -18,7 +18,7 @@
<meta charset="UTF-8" />
<!-- 核心viewport设置 - 针对PWA优化 -->
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no, interactive-widget=resizes-content" />
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
<!-- 防止缩放和选择,提供原生应用体验 -->
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
@@ -33,9 +33,9 @@
<meta name="referrer" content="no-referrer" />
<!-- PWA - 基础图标 -->
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="icon" type="image/png" href="/logo.png" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="64x64" />
<link rel="icon" type="image/png" href="/logo.png" sizes="192x192" />
<link id="theme-favicon" rel="icon" type="image/svg+xml" href="/logo.svg" sizes="any" />
<!-- iOS Safari PWA 优化 -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
@@ -171,6 +171,11 @@
display: block;
block-size: auto;
inline-size: 100%;
opacity: 0;
}
.loading-logo img[data-theme-ready='true'] {
opacity: 1;
}
.loading-footer {
@@ -273,11 +278,11 @@
}
#timeout-btn {
color: var(--initial-loader-color, #9155FD);
color: var(--initial-loader-color, #8D51F9);
text-decoration: none;
font-weight: bold;
margin-inline-start: 8px;
border-bottom: 1px solid var(--initial-loader-color, #9155FD);
border-bottom: 1px solid var(--initial-loader-color, #8D51F9);
}
</style>
@@ -303,7 +308,7 @@
const launchThemePalettes = {
light: {
background: '#F4F5FA',
primary: '#9155FD',
primary: '#8D51F9',
},
dark: {
background: '#0E1116',
@@ -311,7 +316,7 @@
},
purple: {
background: '#28243D',
primary: '#9155FD',
primary: '#8D51F9',
},
transparent: {
background: '#1C1C1C',
@@ -362,6 +367,194 @@
document.head.appendChild(meta)
}
let logoSvgSourcePromise
let pendingFaviconColor = '#8D51F9'
const themeLogoCacheKey = 'moviepilot-themed-logo-cache'
const sourceLogoPalette = {
'rgb(141,81,249)': 'primary',
'rgb(165,118,255)': 'light',
'rgb(211,187,255)': 'highlight',
'rgb(116,50,223)': 'dark',
'rgb(110,38,217)': 'darker',
'rgb(104,0,197)': 'deep',
'rgb(91,0,197)': 'deepest',
}
const sourceLogoRgb = {
primary: [141, 81, 249],
light: [165, 118, 255],
highlight: [211, 187, 255],
dark: [116, 50, 223],
darker: [110, 38, 217],
deep: [104, 0, 197],
deepest: [91, 0, 197],
}
function clampLogoChannel(value, min = 0, max = 1) {
return Math.min(max, Math.max(min, value))
}
function logoRgbToHsl([red, green, blue]) {
const r = red / 255
const g = green / 255
const b = blue / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const delta = max - min
const l = (max + min) / 2
if (delta === 0) return { h: 0, l, s: 0 }
const s = delta / (1 - Math.abs(2 * l - 1))
let h = 0
if (max === r) h = ((g - b) / delta) % 6
else if (max === g) h = (b - r) / delta + 2
else h = (r - g) / delta + 4
return { h: (h * 60 + 360) % 360, l, s }
}
function logoHslToRgb({ h, l, s }) {
const chroma = (1 - Math.abs(2 * l - 1)) * s
const segment = h / 60
const secondary = chroma * (1 - Math.abs((segment % 2) - 1))
let channels
if (segment < 1) channels = [chroma, secondary, 0]
else if (segment < 2) channels = [secondary, chroma, 0]
else if (segment < 3) channels = [0, chroma, secondary]
else if (segment < 4) channels = [0, secondary, chroma]
else if (segment < 5) channels = [secondary, 0, chroma]
else channels = [chroma, 0, secondary]
const offset = l - chroma / 2
const rgb = channels.map(channel => Math.round((channel + offset) * 255))
return `rgb(${rgb.join(',')})`
}
function shiftLogoTone(color, hueOffset, lightnessOffset, saturationScale = 1) {
return logoHslToRgb({
h: (color.h + hueOffset + 360) % 360,
l: clampLogoChannel(color.l + lightnessOffset, 0.08, 0.92),
s: clampLogoChannel(color.s * saturationScale),
})
}
function createLaunchLogoPalette(primaryColor) {
const normalized = primaryColor.slice(1)
const rgb = [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16))
const hsl = logoRgbToHsl(rgb)
const sourcePrimaryHsl = logoRgbToHsl(sourceLogoRgb.primary)
const lightDirection = hsl.l >= 0.78 ? -1 : 1
const darkDirection = hsl.l <= 0.22 ? 1 : -1
return Object.fromEntries(
Object.entries(sourceLogoRgb).map(([key, sourceRgb]) => {
if (key === 'primary') return [key, `rgb(${rgb.join(',')})`]
const sourceHsl = logoRgbToHsl(sourceRgb)
const hueOffset = sourceHsl.h - sourcePrimaryHsl.h
const sourceLightnessDelta = sourceHsl.l - sourcePrimaryHsl.l
const lightnessDelta =
Math.abs(sourceLightnessDelta) * (sourceLightnessDelta >= 0 ? lightDirection : darkDirection)
const saturationScale = sourcePrimaryHsl.s ? sourceHsl.s / sourcePrimaryHsl.s : 1
return [key, shiftLogoTone(hsl, hueOffset, lightnessDelta, saturationScale)]
}),
)
}
function createThemedLogoDataUrl(svgSource, primaryColor) {
const palette = createLaunchLogoPalette(primaryColor)
const themedSvg = Object.entries(sourceLogoPalette).reduce(
(svg, [sourceColor, paletteKey]) => svg.replaceAll(sourceColor, palette[paletteKey]),
svgSource,
)
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(themedSvg)}`
}
function withLoadingLogo(callback) {
const loadingLogo = document.querySelector('.loading-logo img')
if (loadingLogo) {
callback(loadingLogo)
return
}
if (document.readyState !== 'loading') return
const observer = new MutationObserver(() => {
const nextLoadingLogo = document.querySelector('.loading-logo img')
if (!nextLoadingLogo) return
observer.disconnect()
callback(nextLoadingLogo)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
}
function applyThemedLogoUrl(themedLogoUrl) {
const faviconLink = document.querySelector('#theme-favicon')
faviconLink?.setAttribute('type', 'image/svg+xml')
faviconLink?.setAttribute('href', themedLogoUrl)
withLoadingLogo(loadingLogo => {
const revealLogo = () => loadingLogo.setAttribute('data-theme-ready', 'true')
loadingLogo.addEventListener('load', revealLogo, { once: true })
loadingLogo.setAttribute('src', themedLogoUrl)
if (loadingLogo.complete) revealLogo()
})
}
function revealOriginalLoadingLogo() {
withLoadingLogo(loadingLogo => loadingLogo.setAttribute('data-theme-ready', 'true'))
}
// 启动层和 Tab 图标共享原始矢量结构,主题切换只替换色阶,不破坏分面和透明高光。
function syncThemeFavicon(primaryColor) {
if (!/^#[0-9a-f]{6}$/i.test(primaryColor)) return
pendingFaviconColor = primaryColor
try {
const cachedLogo = JSON.parse(localStorage.getItem(themeLogoCacheKey) || 'null')
if (cachedLogo?.color === primaryColor && typeof cachedLogo.url === 'string') applyThemedLogoUrl(cachedLogo.url)
} catch {
// 缓存异常不影响根据品牌源文件重新生成主题标识。
}
logoSvgSourcePromise ||= fetch('/logo.svg').then(response => {
if (!response.ok) throw new Error(`Logo SVG request failed: ${response.status}`)
return response.text()
})
logoSvgSourcePromise
.then(svgSource => {
if (primaryColor !== pendingFaviconColor) return
const themedLogoUrl = createThemedLogoDataUrl(svgSource, primaryColor)
applyThemedLogoUrl(themedLogoUrl)
try {
localStorage.setItem(themeLogoCacheKey, JSON.stringify({ color: primaryColor, url: themedLogoUrl }))
} catch {
// 存储空间不可用时仍保留当前页面内的主题标识。
}
})
.catch(() => {
// 原始 SVG 始终保留为无网络或解析异常时的可见回退。
revealOriginalLoadingLogo()
})
}
window.addEventListener('moviepilot-theme-primary-color-change', event => {
syncThemeFavicon(event.detail?.color)
})
function applyLaunchThemeChrome() {
const themePreference = getSavedThemePreference()
const resolvedLaunchTheme = resolveLaunchTheme(themePreference)
@@ -391,6 +584,7 @@
setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark')
syncThemeColorMeta(loaderColor)
syncThemeFavicon(primaryColor)
return {
background: loaderColor,

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.13.10",
"version": "2.14.5",
"private": true,
"type": "module",
"bin": "dist/service.js",
@@ -9,6 +9,9 @@
"prebuild": "npm run build:icons",
"build": "vite build",
"preview": "vite preview --port 5050",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
@@ -20,6 +23,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 +57,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 +65,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",
@@ -82,7 +91,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,11 +105,15 @@
"@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",
@@ -105,6 +123,8 @@
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-vue": "^9.12.0",
"jsdom": "26.1.0",
"msw": "2.15.0",
"postcss": "^8.5.1",
"postcss-html": "^1.5.0",
"stylelint": "^16.13.2",
@@ -123,6 +143,7 @@
"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-shepherd": "^4.1.0",
"vue-tsc": "^2.0.10",
"workbox-build": "^7.3.0",

View File

@@ -35,6 +35,23 @@ http {
try_files $uri $uri/ /index.html;
}
location = /service-worker.js {
# Service Worker 必须保持稳定 URL 并每次重新验证,避免前端更新后继续注册旧版本。
expires off;
add_header Cache-Control "no-cache, must-revalidate";
root html;
try_files $uri =404;
}
location = /manifest.webmanifest {
# Web App Manifest 参与 PWA 安装与资源发现,不能跟普通静态资源一起长缓存。
expires off;
default_type application/manifest+json;
add_header Cache-Control "no-cache, must-revalidate";
root html;
try_files $uri =404;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
# 静态资源
expires 1y;
@@ -44,8 +61,7 @@ http {
location /assets {
# 静态资源
expires 1y;
add_header Cache-Control "public";
add_header Cache-Control "public, max-age=31536000, immutable";
root html;
}

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useTheme } from 'vuetify'
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api from '@/api'
import api, { type ConnectionAwareRequestConfig } from '@/api'
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
import { SupportedLocale } from '@/types/i18n'
@@ -9,16 +9,28 @@ import { checkAndEmitUnreadMessages } from '@/utils/badge'
import { preloadImage } from './@core/utils/image'
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
import {
applyStoredTransparencySettings,
TRANSPARENCY_SETTINGS_CHANGED_EVENT,
type TransparencyGlassQuality,
type TransparencySettings,
} from '@/composables/useTransparencySettings'
import { completeLaunchLoading } from '@/composables/useLaunchLoading'
import { usePWA } from '@/composables/usePWA'
import { themeManager } from '@/utils/themeManager'
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
import { configureApexChartsTheme } from '@/utils/apexCharts'
import {
useGlobalOfflineStatus,
type ConnectionFailureReason,
} from '@/composables/useOfflineStatus'
const LOGIN_WALLPAPER_ROUTE = '/login'
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 180_000
// 生效主题
const vuetifyTheme = useTheme()
@@ -48,6 +60,7 @@ const authStore = useAuthStore()
const isLogin = computed(() => authStore.token)
const route = useRoute()
const { initializePWA } = usePWA()
const offlineStatus = useGlobalOfflineStatus()
// 全局设置store
const globalSettingsStore = useGlobalSettingsStore()
@@ -58,65 +71,233 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
// 背景图片
const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const previousImageIndex = ref<number | null>(null)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
const shouldLoadBackgroundImages = computed(
() => (!isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) || (Boolean(isLogin.value) && isTransparentTheme.value),
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
)
const transparentBackgroundBlur = ref(16)
const transparencyGlassQuality = ref<TransparencyGlassQuality>(
localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight',
)
const shouldRenderGlobalBlurLayer = computed(
() =>
shouldUseTransparentBackgroundTreatment.value &&
transparentBackgroundBlur.value > 0 &&
transparencyGlassQuality.value === 'realtime',
)
const isRenderThrottled = ref(document.visibilityState === 'hidden')
let backgroundRetryTimer: number | null = null
let backgroundRequestController: AbortController | null = null
let backgroundCrossfadeTimer: number | null = null
let authenticatedStateTimer: number | null = null
let windowBlurRenderThrottleTimer: number | null = null
function getStoredNumber(key: string, fallback: number, min: number, max: number) {
const parsed = Number.parseFloat(localStorage.getItem(key) || '')
if (!Number.isFinite(parsed)) return fallback
// 读取并同步透明主题背景设置到根组件响应式状态。
function applyTransparentBackgroundSettings() {
const settings = applyStoredTransparencySettings()
return Math.min(max, Math.max(min, parsed))
transparentBackgroundBlur.value = settings.backgroundBlur
transparencyGlassQuality.value = settings.glassQuality
}
function applyTransparentBackgroundSettings() {
document.documentElement.style.setProperty(
'--transparent-background-poster-opacity',
(1 - getStoredNumber('transparency-background-poster-opacity', 0, 0, 1)).toString(),
)
document.documentElement.style.setProperty(
'--transparent-background-blur',
`${getStoredNumber('transparency-background-blur', 16, 0, 30)}px`,
)
// 响应透明主题设置变更事件,刷新背景模糊和玻璃质量。
function handleTransparencySettingsChanged(event: Event) {
const { backgroundBlur, glassQuality } = (event as CustomEvent<TransparencySettings>).detail
transparentBackgroundBlur.value = backgroundBlur
transparencyGlassQuality.value = glassQuality
}
applyTransparentBackgroundSettings()
// 心跳检测
let heartbeatInterval: number | null = null
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
function clearWindowBlurRenderThrottleTimer() {
if (windowBlurRenderThrottleTimer) {
window.clearTimeout(windowBlurRenderThrottleTimer)
windowBlurRenderThrottleTimer = null
}
}
// 启动心跳
const startHeartbeat = () => {
// 如果已经有心跳,则先停止
if (heartbeatInterval) {
stopHeartbeat()
function restoreForegroundRendering() {
const wasRenderThrottled = isRenderThrottled.value
clearWindowBlurRenderThrottleTimer()
isRenderThrottled.value = false
if (wasRenderThrottled && backgroundImages.value.length > 1) {
startBackgroundRotation()
rotateBackgroundImage()
}
}
function throttleBackgroundRendering() {
clearWindowBlurRenderThrottleTimer()
resetBackgroundCrossfade()
isRenderThrottled.value = true
}
function handleWindowBlurRenderThrottle() {
clearWindowBlurRenderThrottleTimer()
if (document.visibilityState === 'hidden') {
throttleBackgroundRendering()
return
}
// 开始心跳任务
heartbeatInterval = window.setInterval(async () => {
try {
if (isLogin.value) {
await api.get('system/ping')
}
} catch (error) {
console.warn('Heartbeat request failed:', error)
windowBlurRenderThrottleTimer = window.setTimeout(() => {
if (document.visibilityState === 'visible' && !document.hasFocus()) {
isRenderThrottled.value = true
}
windowBlurRenderThrottleTimer = null
}, WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS)
}
function handleWindowFocusRenderThrottle() {
if (document.visibilityState === 'visible') {
restoreForegroundRendering()
}
}
let heartbeatInterval: number | null = null
let connectionRetryTimer: number | null = null
let connectionProbePromise: Promise<boolean> | null = null
let connectionProbeFailures = 0
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
const SERVER_PROBE_TIMEOUT_MS = 8_000
const SERVER_PROBE_FAILURE_THRESHOLD = 2
const SERVER_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000] as const
/** 清除等待中的服务重连任务。 */
function clearConnectionRetryTimer() {
if (!connectionRetryTimer) return
window.clearTimeout(connectionRetryTimer)
connectionRetryTimer = null
}
/** 根据浏览器状态和请求错误判断本次探测失败原因。 */
function resolveProbeFailureReason(error: unknown): ConnectionFailureReason {
if (!offlineStatus.browserOnline.value) return 'browser-offline'
const errorCode = (error as { code?: string } | null)?.code
if (errorCode === 'ECONNABORTED' || errorCode === 'ETIMEDOUT') return 'timeout'
return 'server-unreachable'
}
/** 按退避间隔安排下一次 MoviePilot 服务探测。 */
function scheduleConnectionRetry() {
clearConnectionRetryTimer()
const retryIndex = Math.min(Math.max(connectionProbeFailures - 1, 0), SERVER_RETRY_DELAYS_MS.length - 1)
connectionRetryTimer = window.setTimeout(() => {
connectionRetryTimer = null
void probeServerConnection()
}, SERVER_RETRY_DELAYS_MS[retryIndex])
}
/** 使用后端 ping 接口执行去重后的权威服务连通性探测。 */
async function probeServerConnection(showChecking = false): Promise<boolean> {
if (!isLogin.value) return false
if (connectionProbePromise) return connectionProbePromise
clearConnectionRetryTimer()
if (showChecking) offlineStatus.markConnectionChecking(offlineStatus.connectionReason.value ?? undefined)
const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value
const probePromise = (async () => {
try {
await api.get(
'system/ping',
{
skipConnectionTracking: true,
timeout: SERVER_PROBE_TIMEOUT_MS,
} as ConnectionAwareRequestConfig,
)
connectionProbeFailures = 0
return true
} catch (error) {
if (!isLogin.value) {
offlineStatus.markServerOnline()
return false
}
// 探测期间若已有其他接口成功,则以更新的成功响应为准,避免旧失败覆盖新状态。
if (offlineStatus.serverSuccessSequence.value > successSequenceAtProbeStart) {
connectionProbeFailures = 0
return true
}
connectionProbeFailures += 1
const failureReason = resolveProbeFailureReason(error)
if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) {
offlineStatus.markServerOffline(failureReason)
} else {
offlineStatus.markConnectionChecking(failureReason)
}
scheduleConnectionRetry()
return false
}
})()
connectionProbePromise = probePromise
try {
return await probePromise
} finally {
if (connectionProbePromise === probePromise) connectionProbePromise = null
}
}
/** 启动即时服务探测和五分钟在线心跳。 */
function startHeartbeat() {
if (heartbeatInterval) window.clearInterval(heartbeatInterval)
void probeServerConnection()
heartbeatInterval = window.setInterval(async () => {
if (isLogin.value) await probeServerConnection()
}, 5 * 60 * 1000)
}
// 停止心跳
const stopHeartbeat = () => {
/** 停止心跳和等待中的自动重连任务。 */
function stopHeartbeat() {
if (heartbeatInterval) {
window.clearInterval(heartbeatInterval)
heartbeatInterval = null
}
clearConnectionRetryTimer()
connectionProbeFailures = 0
}
watch(
() => offlineStatus.connectionCheckRequestId.value,
() => {
if (isLogin.value) void probeServerConnection(true)
},
)
watch(
() => offlineStatus.connectionStatus.value,
status => {
if (status !== 'online') return
connectionProbeFailures = 0
clearConnectionRetryTimer()
},
)
watch(
() => offlineStatus.browserOnline.value,
browserIsOnline => {
if (!isLogin.value) return
offlineStatus.requestConnectionCheck(browserIsOnline ? undefined : 'browser-offline')
},
)
// 更新data-theme属性以便CSS选择器能正确匹配
function updateHtmlThemeAttribute(themeName: string) {
document.documentElement.setAttribute('data-theme', themeName)
@@ -124,6 +305,7 @@ function updateHtmlThemeAttribute(themeName: string) {
syncRootLaunchPalette()
}
// 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。
function syncThemePreferenceFromStorage() {
themeValue = localStorage.getItem('theme') || 'auto'
@@ -147,22 +329,60 @@ function syncThemePreferenceFromStorage() {
})
}
// 系统配色变化时,在自动主题模式下刷新当前实际主题。
function handleSystemThemeChange() {
if ((localStorage.getItem('theme') || 'auto') === 'auto') {
syncThemePreferenceFromStorage()
}
}
/** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
function handleVisibilityThemeSync() {
if (document.visibilityState === 'visible') {
restoreForegroundRendering()
syncThemePreferenceFromStorage()
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
} else {
throttleBackgroundRendering()
}
}
/** 页面从缓存或重新聚焦恢复时刷新主题偏好和异常连接状态。 */
function handlePageShowThemeSync() {
if (document.visibilityState === 'visible') {
restoreForegroundRendering()
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
}
syncThemePreferenceFromStorage()
}
// 清理背景图交叉淡入淡出定时器。
function clearBackgroundCrossfadeTimer() {
if (backgroundCrossfadeTimer) {
window.clearTimeout(backgroundCrossfadeTimer)
backgroundCrossfadeTimer = null
}
}
// 重置背景图交叉淡入淡出状态。
function resetBackgroundCrossfade() {
clearBackgroundCrossfadeTimer()
previousImageIndex.value = null
}
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
function activateBackgroundImage(nextIndex: number) {
if (nextIndex === activeImageIndex.value) return
clearBackgroundCrossfadeTimer()
previousImageIndex.value = activeImageIndex.value
activeImageIndex.value = nextIndex
backgroundCrossfadeTimer = window.setTimeout(() => {
previousImageIndex.value = null
backgroundCrossfadeTimer = null
}, BACKGROUND_CROSSFADE_DURATION_MS)
}
// 获取背景图片
async function fetchBackgroundImages() {
try {
@@ -171,6 +391,7 @@ async function fetchBackgroundImages() {
backgroundImages.value = await api.get(`/login/wallpapers`, {
signal: backgroundRequestController.signal,
})
resetBackgroundCrossfade()
activeImageIndex.value = 0
} catch (e) {
throw e
@@ -179,6 +400,8 @@ async function fetchBackgroundImages() {
// 背景图片轮换函数
function rotateBackgroundImage() {
if (isRenderThrottled.value) return
if (backgroundImages.value.length > 1) {
// 计算下一个图片索引
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
@@ -186,7 +409,7 @@ function rotateBackgroundImage() {
preloadImage(backgroundImages.value[nextIndex]).then(success => {
// 只有图片成功加载才切换
if (success) {
activeImageIndex.value = nextIndex
activateBackgroundImage(nextIndex)
}
})
}
@@ -211,6 +434,7 @@ function startBackgroundRotation() {
}
}
// 停止登录页或透明主题背景图加载、重试和轮播。
function stopBackgroundLoading() {
backgroundRequestController?.abort()
backgroundRequestController = null
@@ -220,9 +444,11 @@ function stopBackgroundLoading() {
backgroundRetryTimer = null
}
resetBackgroundCrossfade()
removeBackgroundTimer('background-rotation')
}
// 初始化登录后的全局设置和用户设置状态。
async function initializeAuthenticatedState() {
if (!isLogin.value) return
@@ -235,6 +461,7 @@ async function initializeAuthenticatedState() {
}
}
// 延迟初始化登录态数据,避开登录成功后的即时路由跳转窗口。
function scheduleAuthenticatedStateInitialization() {
if (authenticatedStateTimer) {
window.clearTimeout(authenticatedStateTimer)
@@ -359,6 +586,9 @@ onMounted(async () => {
document.addEventListener('visibilitychange', handleVisibilityThemeSync)
window.addEventListener('pageshow', handlePageShowThemeSync)
window.addEventListener('focus', handlePageShowThemeSync)
window.addEventListener('focus', handleWindowFocusRenderThrottle)
window.addEventListener('blur', handleWindowBlurRenderThrottle)
window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
watch(
@@ -394,6 +624,7 @@ onMounted(async () => {
authenticatedStateTimer = null
}
stopHeartbeat()
offlineStatus.markServerOnline()
}
})
})
@@ -405,6 +636,7 @@ onUnmounted(() => {
window.clearTimeout(authenticatedStateTimer)
authenticatedStateTimer = null
}
clearWindowBlurRenderThrottleTimer()
// 停止心跳
stopHeartbeat()
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
@@ -412,29 +644,36 @@ onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
window.removeEventListener('pageshow', handlePageShowThemeSync)
window.removeEventListener('focus', handlePageShowThemeSync)
window.removeEventListener('focus', handleWindowFocusRenderThrottle)
window.removeEventListener('blur', handleWindowBlurRenderThrottle)
window.removeEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
})
</script>
<template>
<div class="app-wrapper">
<div class="app-wrapper" :class="{ 'app-wrapper--render-throttled': isRenderThrottled }">
<!-- 透明主题背景 -->
<div
v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)"
class="background-container"
:class="{ 'is-transparent-theme': isTransparentTheme && isLogin }"
:class="{
'is-transparent-theme': shouldUseTransparentBackgroundTreatment,
'is-transparent-glass-lightweight':
shouldUseTransparentBackgroundTreatment && transparencyGlassQuality === 'lightweight',
}"
>
<div
v-for="(imageUrl, index) in backgroundImages"
:key="`bg-${index}-${loginStateKey}`"
class="background-image"
:class="{ 'active': index === activeImageIndex }"
:class="{ 'active': index === activeImageIndex, 'previous': index === previousImageIndex }"
:style="{ 'backgroundImage': `url(${imageUrl})` }"
/>
<!-- 全局磨砂层 -->
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
</div>
<!-- 页面内容 -->
<VApp>
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
<RouterView />
<!-- 全局共享弹窗入口列表与卡片按需在这里挂载业务弹窗 -->
<SharedDialogHost />
@@ -485,18 +724,36 @@ onUnmounted(() => {
}
&.active {
z-index: 2;
opacity: 1;
}
&.previous {
z-index: 1;
}
}
.background-container.is-transparent-theme .background-image.active {
opacity: var(--transparent-background-poster-opacity, 1);
}
.background-container.is-transparent-glass-lightweight .background-image.active,
.background-container.is-transparent-glass-lightweight .background-image.previous {
filter: blur(var(--transparent-background-blur, 16px));
transform: scale(1.03);
}
.background-container.is-transparent-glass-lightweight .background-image.active::after,
.background-container.is-transparent-glass-lightweight .background-image.previous::after {
background:
linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%),
rgba(128, 128, 128, 30%);
}
/* 全局磨砂层 */
.global-blur-layer {
position: absolute;
z-index: 1;
z-index: 3;
backdrop-filter: blur(var(--transparent-background-blur, 16px));
background-color: rgba(128, 128, 128, 30%);
block-size: 100%;
@@ -504,4 +761,25 @@ onUnmounted(() => {
inset-block-start: 0;
inset-inline-start: 0;
}
.app-wrapper--render-throttled {
.global-blur-layer {
backdrop-filter: none;
}
.login-bg-decor *,
.login-logo,
.login-logo-wrapper,
.login-logo-wrapper::before,
.login-title,
.login-subtitle,
.agent-assistant-fab * {
animation-play-state: paused !important;
}
}
/* 登录页壁纸在 VApp 外层渲染,登录页 VApp 需要透明才能露出壁纸。 */
.app-shell--login-wallpaper.v-application {
background: transparent !important;
}
</style>

View File

@@ -530,6 +530,56 @@ 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
},
)
}
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
@@ -555,4 +605,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

@@ -656,6 +656,8 @@ export interface Plugin {
system_version_message?: string
// 主系统版本限定范围
system_version?: string
// 是否声明支持通过 GitHub Release 资产安装
release?: boolean
// 是否本地插件
is_local?: boolean
// 插件仓库地址
@@ -668,6 +670,38 @@ export interface Plugin {
page_open?: boolean
}
// 插件 Release 可安装版本
export interface PluginReleaseVersion {
// 插件版本
version: string
// GitHub Release tag
tag_name: string
// Release 标题
name?: string
// 发布时间
published_at?: string
// Release 说明
body?: string
// 匹配到的资产文件名
asset_name?: string
// 是否为当前市场最新版本
is_latest?: boolean
// 是否为本地已安装版本
is_current?: boolean
}
// 插件 Release 可安装版本响应
export interface PluginReleaseVersionsResponse {
// 当前插件是否存在可直接安装的 Release 资产
release_supported: boolean
// 当前市场 package 声明的最新版本
latest_version?: string | null
// 本地已安装版本
current_version?: string | null
// 可安装版本列表
items: PluginReleaseVersion[]
}
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
export interface PluginSidebarNavItem {
plugin_id: string
@@ -966,6 +1000,12 @@ export interface MediaStatistic {
episode_count: number | null
// 用户数量
user_count: number
// 本月新增电影数量
movie_count_month: number
// 本月新增电视剧数量
tv_count_month: number
// 本月新增剧集数量
episode_count_month: number
}
// 后台进程
@@ -986,6 +1026,18 @@ export interface Process {
memory: number
}
// 仪表板系统摘要
export interface DashboardSystemInfo {
// 主机名称
hostname: string
// 操作系统名称
operating_system: string
// MoviePilot 运行时间,单位秒
runtime: number
// MoviePilot 后端版本
version: string
}
// 下载器信息
export interface DownloaderInfo {
// 下载速度
@@ -1000,18 +1052,86 @@ export interface DownloaderInfo {
free_space: number
}
// 仪表板系统内存信息
export interface DashboardMemoryInfo {
// 总内存字节数
total: number
// 已使用内存字节数,不包含缓存
used: number
// 缓存与缓冲区占用字节数
cached: number
// 可用内存字节数
available: number
// 已使用内存占总内存百分比,不包含缓存
usage: number
}
// 定时服务进度信息
export interface ScheduleProgress {
// ID
id?: string
// 名称
name?: string
// 多语言名称
name_i18n?: string
// 提供者
provider?: string
// 多语言提供者
provider_i18n?: string
// 是否正在执行
enable?: boolean
// 当前完成百分比
value?: number
// 当前进度文本
text?: string
// 多语言进度文本
text_i18n?: string
// 执行状态
status?: string
// 最近一次执行是否成功
success?: boolean
// 最近一次开始时间
started_at?: string
// 最近一次结束时间
finished_at?: string
// 最近一次错误信息
error?: string
// 多语言错误信息
error_i18n?: string
// 扩展数据
data?: Record<string, unknown>
}
// 定时服务信息
export interface ScheduleInfo {
// ID
id: string
// 名称
name: string
// 多语言名称
name_i18n?: string
// 提供者
provider: string
// 多语言提供者
provider_i18n?: string
// 状态
status: string
// 多语言状态
status_i18n?: string
// 下次运行时间
next_run: string
// 多语言下次运行时间
next_run_i18n?: string
// 当前完成百分比
progress?: number
// 进度文本
progress_text?: string
// 多语言进度文本
progress_text_i18n?: string
// 是否正在更新进度
progress_enable?: boolean
// 进度详情
progress_detail?: ScheduleProgress
}
// 消息通知
@@ -1117,6 +1237,8 @@ export interface MediaServerLibrary {
path?: string
// 类型
type?: string
// 库内媒体数量
item_count?: number
// 图片
image?: string
// 图片列表
@@ -1131,6 +1253,12 @@ export interface MediaServerLibrary {
// 消息通知
export interface Message {
// 消息ID
id?: number
// 消息渠道
channel?: string
// 消息来源
source?: string
// 消息类型
mtype?: string
// 消息标题
@@ -1150,19 +1278,15 @@ export interface Message {
// 消息方向0-接收1-发送
action?: number
// JSON
note?: string
note?: string | any[] | Record<string, any>
}
// 系统通知
export interface SystemNotification {
// 通知类型 user/system/plugin
type: string
// 通知标题
title: string
// 通知内容
text: string
export interface SystemNotification extends Message {
// 通知类型 user/system/plugin/notification
type?: string
// 通知时间
date: string
date?: string
// 是否已读
read?: boolean
}
@@ -1301,6 +1425,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 {
// 种子名称
@@ -1321,6 +1467,12 @@ export interface SubscribeLibraryFileInfo {
storage?: string
// 文件路径
file_path?: string
// 媒体服务器名称
server?: string
// 媒体服务器类型emby、jellyfin、plex 等
server_type?: string
// 媒体服务器条目 ID
itemid?: string
}
// 订阅集详情
@@ -1599,6 +1751,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 {
// 分享人
@@ -1613,6 +1799,7 @@ export interface SubscribeShareStatistics {
export interface ApiResponse<T = any> {
success: boolean
message?: string
message_i18n?: string
data: T
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

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,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';

View File

@@ -0,0 +1,2 @@
@use './cssRobotBase';
@use './actions/cssRobotActions';

View File

@@ -0,0 +1,484 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab__bot,
.agent-assistant-fab__bot span {
box-sizing: border-box;
}
.agent-assistant-fab__bot {
position: absolute;
display: block;
block-size: 4.7rem;
filter: drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow));
inline-size: 3.85rem;
inset-block-end: 0.1rem;
inset-inline-end: 1.42rem;
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
transform-origin: 50% 72%;
transition:
inset-inline-end 0.24s ease,
filter 0.18s ease,
transform 0.14s ease;
}
.agent-assistant-fab__antenna {
position: absolute;
z-index: 3;
display: block;
border-radius: 999px;
animation: agent-fab-antenna-idle 3.9s ease-in-out infinite;
background: var(--agent-assistant-robot-outline);
block-size: 0.66rem;
inline-size: 0.18rem;
inset-block-start: 0.72rem;
inset-inline-start: 2.62rem;
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
transform-origin: bottom center;
transition:
opacity 0.2s ease,
transform 0.22s ease;
}
.agent-assistant-fab__antenna::after {
position: absolute;
border: 2px solid var(--agent-assistant-robot-outline);
border-radius: 999px;
background: var(--agent-assistant-robot-shell-start);
block-size: 0.38rem;
content: '';
inline-size: 0.38rem;
inset-block-start: -0.34rem;
inset-inline-start: -0.13rem;
}
.agent-assistant-fab__head {
position: absolute;
z-index: 4;
display: block;
border: 2px solid var(--agent-assistant-robot-outline);
border-radius: 11px;
animation: agent-fab-head-idle 4.6s ease-in-out infinite;
background: linear-gradient(
145deg,
var(--agent-assistant-robot-shell-start) 0%,
var(--agent-assistant-robot-shell-end) 100%
);
block-size: 2.05rem;
box-shadow:
inset 0 -0.2rem 0 rgba(54, 0, 126, 26%),
inset 0.15rem 0.14rem 0 rgba(255, 255, 255, 24%);
inline-size: 2.82rem;
inset-block-start: 1.42rem;
inset-inline-start: 0.88rem;
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y));
transform-origin: 50% 85%;
}
.agent-assistant-fab__face {
position: absolute;
display: block;
overflow: hidden;
border: 2px solid var(--agent-assistant-robot-outline-soft);
border-radius: 8px;
background: linear-gradient(
180deg,
var(--agent-assistant-robot-face-start) 0%,
var(--agent-assistant-robot-face-end) 100%
);
block-size: 1.28rem;
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
inline-size: 2.1rem;
inset-block-start: 0.33rem;
inset-inline-start: 0.25rem;
}
.agent-assistant-fab__eye {
position: absolute;
display: block;
border-radius: 0 0 999px 999px;
animation: agent-fab-blink 4.8s ease-in-out infinite;
block-size: 0.42rem;
border-block-end: 0.15rem solid var(--agent-assistant-robot-eye);
inline-size: 0.42rem;
inset-block-start: 0.36rem;
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
/* 触屏设备没有连续 hover 轨迹,给眼神位移补过渡避免点按时瞬移。 */
transition: transform 0.2s ease-out;
}
.agent-assistant-fab__eye--left {
inset-inline-start: 0.43rem;
}
.agent-assistant-fab__eye--right {
inset-inline-end: 0.43rem;
}
.agent-assistant-fab__smile {
position: absolute;
display: block;
border-radius: 0 0 999px 999px;
block-size: 0.32rem;
border-block-end: 0.13rem solid var(--agent-assistant-robot-eye);
inline-size: 0.7rem;
inset-block-start: 0.75rem;
inset-inline-start: 50%;
opacity: 0;
transform: translateX(-50%) scale(0.72);
transform-origin: center top;
}
.agent-assistant-fab__body {
position: absolute;
z-index: 3;
display: block;
border: 2px solid var(--agent-assistant-robot-outline);
border-radius: 0.65rem 0.65rem 0.55rem 0.55rem;
animation: agent-fab-body-idle 4.2s ease-in-out infinite;
background: linear-gradient(
145deg,
var(--agent-assistant-robot-shell-mid) 0%,
var(--agent-assistant-robot-shell-end) 82%
);
block-size: 1.34rem;
box-shadow:
inset 0 -0.18rem 0 rgba(54, 0, 126, 24%),
inset 0.16rem 0.12rem 0 rgba(255, 255, 255, 24%);
inline-size: 1.88rem;
inset-block-start: 3.24rem;
inset-inline-start: 1.32rem;
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
transform-origin: 50% 18%;
transition:
opacity 0.2s ease,
transform 0.22s ease;
}
.agent-assistant-fab__core {
position: absolute;
display: block;
background: transparent;
block-size: 100%;
box-shadow: none;
inline-size: 100%;
inset: 0;
pointer-events: none;
}
.agent-assistant-fab__core::before {
position: absolute;
background: var(--agent-assistant-robot-play);
block-size: 0.46rem;
clip-path: polygon(18% 8%, 18% 92%, 90% 50%);
content: '';
inline-size: 0.48rem;
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-42%, -50%);
}
.agent-assistant-fab__arm,
.agent-assistant-fab__leg {
position: absolute;
z-index: 2;
display: block;
border: 2px solid var(--agent-assistant-robot-outline);
background: linear-gradient(
160deg,
var(--agent-assistant-robot-shell-mid) 0%,
var(--agent-assistant-robot-outline-soft) 100%
);
box-shadow: inset 0 -0.12rem 0 rgba(54, 0, 126, 24%);
transition:
opacity 0.2s ease,
transform 0.22s ease;
}
.agent-assistant-fab__arm {
border-radius: 999px;
block-size: 1rem;
inline-size: 0.46rem;
inset-block-start: 3.3rem;
}
.agent-assistant-fab__arm--left {
animation: agent-fab-arm-left-idle 3.8s ease-in-out infinite;
inset-inline-start: 0.9rem;
transform: rotate(17deg);
transform-origin: top center;
}
.agent-assistant-fab__arm--right {
animation: agent-fab-arm-right-idle 4.1s ease-in-out infinite;
inset-inline-start: 3.08rem;
transform: rotate(-17deg);
transform-origin: top center;
}
.agent-assistant-fab__leg {
border-radius: 0.35rem;
block-size: 0.66rem;
inline-size: 0.48rem;
inset-block-start: 4.36rem;
}
.agent-assistant-fab__leg--left {
animation: agent-fab-leg-left-idle 4.8s ease-in-out infinite;
inset-inline-start: 1.48rem;
transform-origin: top center;
}
.agent-assistant-fab__leg--right {
animation: agent-fab-leg-right-idle 4.8s ease-in-out 0.35s infinite;
inset-inline-start: 2.46rem;
transform-origin: top center;
}
@media (hover: hover) and (pointer: fine) {
.agent-assistant-fab.is-bubble-visible .agent-assistant-fab__bubble:hover {
box-shadow: var(--app-surface-hover-shadow);
}
.agent-assistant-fab__bubble:hover .agent-assistant-fab__bubble-close {
opacity: 1;
}
.agent-assistant-fab__trigger:hover .agent-assistant-fab__bot {
filter: drop-shadow(0 0.7rem 0.7rem var(--agent-assistant-robot-shadow-strong));
}
}
@media (hover: none), (pointer: coarse) {
.agent-assistant-fab__bubble-close {
opacity: 1;
}
}
.agent-assistant-fab__trigger:focus-visible .agent-assistant-fab__bot {
filter:
drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
drop-shadow(0 0 0.34rem rgba(var(--v-theme-primary), 0.55));
}
.agent-assistant-fab.is-pressed .agent-assistant-fab__bot {
transform: translateY(0.22rem) scale(var(--agent-assistant-bot-pressed-scale))
rotate(var(--agent-assistant-robot-tilt));
}
.agent-assistant-fab.is-thinking .agent-assistant-fab__face {
box-shadow:
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
0 0 0.65rem rgba(114, 255, 240, 50%);
}
.agent-assistant-fab.is-thinking .agent-assistant-fab__core {
animation: agent-fab-core-pulse 0.9s ease-in-out infinite;
}
.agent-assistant-fab.is-docked .agent-assistant-fab__bot {
inset-inline-end: -0.42rem;
transform: translateY(-0.2rem) scale(var(--agent-assistant-bot-scale)) rotate(-19deg);
}
.agent-assistant-fab.is-docked .agent-assistant-fab__eye {
transform: translate(calc(var(--agent-assistant-eye-x) * 0.24 - 0.22rem), calc(var(--agent-assistant-eye-y) * 0.24));
}
.agent-assistant-fab.is-docked .agent-assistant-fab__body,
.agent-assistant-fab.is-docked .agent-assistant-fab__leg {
opacity: 0;
transform: translateX(0.8rem) scale(0.72);
}
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--left,
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--right {
animation: none;
opacity: 0;
transform: translateX(0.8rem) scale(0.72);
}
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--left {
z-index: 5;
block-size: 0.95rem;
inline-size: 0.42rem;
inset-block-start: 3.62rem;
inset-inline-start: 1.86rem;
opacity: 1;
transform: rotate(78deg) scale(0.9);
}
.agent-assistant-fab.is-docked .agent-assistant-fab__antenna {
animation: none;
opacity: 0.75;
transform: translate(0.34rem, 0.02rem) rotate(2deg) scale(0.82);
}
@keyframes agent-fab-head-idle {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
50% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-1.8deg);
}
}
@keyframes agent-fab-body-idle {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
}
50% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.04rem)) scaleY(0.97);
}
}
@keyframes agent-fab-antenna-idle {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
50% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(15deg);
}
}
@keyframes agent-fab-arm-left-idle {
0%,
100% {
transform: rotate(17deg);
}
50% {
transform: rotate(12deg) translateY(0.05rem);
}
}
@keyframes agent-fab-arm-right-idle {
0%,
100% {
transform: rotate(-17deg);
}
50% {
transform: rotate(-11deg) translateY(0.05rem);
}
}
@keyframes agent-fab-leg-left-idle {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(4deg) translateY(0.03rem);
}
}
@keyframes agent-fab-leg-right-idle {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(-4deg) translateY(0.03rem);
}
}
@keyframes agent-fab-core-pulse {
0%,
100% {
opacity: 0.78;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.18);
}
}
@keyframes agent-fab-blink {
0%,
4%,
8%,
100% {
opacity: 1;
scale: 1 1;
}
6% {
opacity: 0.45;
scale: 1 0.15;
}
}
@media (width <= 600px) {
.agent-assistant-fab {
block-size: 6.65rem;
inline-size: min(12.4rem, calc(100vw - 1rem));
}
.agent-assistant-fab.is-docked {
inline-size: 3.45rem;
}
.agent-assistant-fab__bubbles {
inline-size: min(16.5rem, calc(100vw - 5.6rem));
max-block-size: min(30rem, calc(100vh - 9.2rem));
}
.agent-assistant-fab__bubble-stack {
gap: 0.38rem;
}
.agent-assistant-fab__bubble {
padding-block: 0.56rem;
padding-inline: 0.72rem 1.62rem;
}
.agent-assistant-fab__bubble-title {
font-size: 1.05rem;
}
.agent-assistant-fab__bubble > span {
font-size: 1rem;
}
.agent-assistant-fab__trigger {
block-size: 4.8rem;
inline-size: 5rem;
}
.agent-assistant-fab__bot {
inset-inline-end: 1.02rem;
transform-origin: 70% 78%;
}
.agent-assistant-fab {
--agent-assistant-bot-scale: 0.82;
--agent-assistant-bot-pressed-scale: 0.78;
}
.agent-assistant-fab.is-docked .agent-assistant-fab__bot {
inset-inline-end: -0.72rem;
transform: translateY(-0.16rem) scale(var(--agent-assistant-bot-scale)) rotate(-19deg);
}
}
@media (prefers-reduced-motion: reduce) {
.agent-assistant-fab,
.agent-assistant-fab * {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,41 @@
export type AgentPetActionName =
| 'wave'
| 'sit'
| 'eye-roll'
| 'faint'
| 'disassemble'
| 'happy-jump'
| 'sleep'
| 'stretch'
| 'peek'
| 'scan'
| 'charge'
| 'spin-cheer'
| 'shy'
| 'confused'
export type AgentPetIntent =
| 'idle'
| 'thinking'
| 'speaking'
| 'notify'
| 'success'
| 'warning'
| 'error'
| 'dragging'
| 'docked'
| 'sleeping'
| 'reaction'
export type AgentPetRendererKind = 'css-robot'
export interface AgentPetActionDefinition {
name: AgentPetActionName
intent: AgentPetIntent
clip: string
duration: number
priority: number
interruptible: boolean
cooldown?: number
next?: AgentPetActionName
}

View File

@@ -0,0 +1,127 @@
import { onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
import {
getAgentPetActionDuration,
getAgentPetRandomActionDelay,
pickAgentPetRandomAction,
} from './agentPetActions'
import type { AgentPetActionName } from './types'
interface AgentPetMachineOptions {
active: MaybeRefOrGetter<boolean>
thinking: MaybeRefOrGetter<boolean>
docked: Ref<boolean>
dragging: Ref<boolean>
pressed: Ref<boolean>
shouldAutoDock: () => boolean
scheduleAutoDock: () => void
}
/** 管理桌面宠物的空闲动作队列,避免入口组件直接维护动画计时器。 */
export function useAgentPetMachine(options: AgentPetMachineOptions) {
const currentAction = ref<AgentPetActionName | null>(null)
let lastAction: AgentPetActionName | null = null
let randomActionTimer: number | null = null
let actionEndTimer: number | null = null
/** 判断当前交互状态是否适合播放空闲趣味动作。 */
function canRunRandomAction() {
return (
toValue(options.active) &&
!options.docked.value &&
!options.dragging.value &&
!options.pressed.value &&
!toValue(options.thinking)
)
}
/** 清理等待中的随机动作计时器。 */
function clearRandomActionTimer() {
if (randomActionTimer === null) return
window.clearTimeout(randomActionTimer)
randomActionTimer = null
}
/** 清理正在播放动作的结束计时器。 */
function clearActionEndTimer() {
if (actionEndTimer === null) return
window.clearTimeout(actionEndTimer)
actionEndTimer = null
}
/** 停止当前动作并清理全部宠物动作计时器。 */
function clearAction() {
clearRandomActionTimer()
clearActionEndTimer()
currentAction.value = null
}
/** 完成当前动作后恢复空闲态,并按贴边规则决定是否自动收起。 */
function finishRandomAction() {
clearActionEndTimer()
currentAction.value = null
if (!options.docked.value && options.shouldAutoDock()) {
options.scheduleAutoDock()
return
}
scheduleRandomAction()
}
/** 立即播放指定动作,供通知、交互或外部 renderer 编排主动触发。 */
function playAction(action: AgentPetActionName) {
clearRandomActionTimer()
clearActionEndTimer()
currentAction.value = action
actionEndTimer = window.setTimeout(finishRandomAction, getAgentPetActionDuration(action))
}
/** 播放一个随机趣味动作,由 renderer 根据动作名呈现具体动画。 */
function runRandomAction() {
if (!canRunRandomAction()) return
const action = pickAgentPetRandomAction(lastAction)
lastAction = action
playAction(action)
}
/** 安排下一次随机动作,只在宠物可见且空闲时生效。 */
function scheduleRandomAction() {
clearRandomActionTimer()
if (!canRunRandomAction() || currentAction.value || actionEndTimer !== null) return
randomActionTimer = window.setTimeout(() => {
randomActionTimer = null
runRandomAction()
}, getAgentPetRandomActionDelay())
}
/** 根据入口交互状态同步随机动作队列。 */
function syncSchedule() {
if (canRunRandomAction()) {
if (!currentAction.value && randomActionTimer === null && actionEndTimer === null) scheduleRandomAction()
return
}
clearAction()
}
watch(
[() => toValue(options.active), () => toValue(options.thinking), options.docked, options.dragging, options.pressed],
syncSchedule,
)
onScopeDispose(clearAction)
return {
clearAction,
currentAction,
playAction,
scheduleRandomAction,
syncSchedule,
}
}

View File

@@ -0,0 +1,798 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import type { ApiResponse, RecognitionCacheData, RecognitionCacheItem } from '@/api/types'
import { useConfirm } from '@/composables/useConfirm'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
type RecognitionCacheSource = 'tmdb' | 'douban'
const MOBILE_CACHE_PAGE_SIZE = 20
const { t } = useI18n()
const display = useDisplay()
const createConfirm = useConfirm()
const $toast = useToast()
const globalSettingsStore = useGlobalSettingsStore()
const isMobile = computed(() => display.smAndDown.value)
const recognitionSource = computed<RecognitionCacheSource>(() =>
globalSettingsStore.globalSettings.RECOGNIZE_SOURCE === 'douban' ? 'douban' : 'tmdb',
)
const recognitionSourceName = computed(() =>
recognitionSource.value === 'douban'
? t('setting.cache.recognitionSource.douban')
: t('setting.cache.recognitionSource.themoviedb'),
)
const recognitionIdLabel = computed(() =>
recognitionSource.value === 'douban' ? t('setting.cache.doubanId') : t('setting.cache.tmdbId'),
)
const recognitionCacheEndpoint = computed(() => `${recognitionSource.value}/cache`)
const recognitionFilterPlaceholder = computed(() =>
t('setting.cache.filterRecognitionCache', { source: recognitionSourceName.value }),
)
const loading = ref(false)
const searchFilter = ref('')
const statusFilter = ref<RecognitionStatusFilter>('all')
const selectedItems = ref<string[]>([])
const cacheData = ref<RecognitionCacheData>({
count: 0,
recognized: 0,
unrecognized: 0,
data: [],
})
const mobileVisibleCount = ref(MOBILE_CACHE_PAGE_SIZE)
const mobileInfiniteKey = ref(0)
let cacheLoadRequestId = 0
const statusOptions = computed(() => [
{ title: t('setting.cache.allStatuses'), value: 'all' },
{ title: t('setting.cache.recognizedOnly'), value: 'recognized' },
{ title: t('setting.cache.unrecognizedOnly'), value: 'unrecognized' },
])
const tableHeaders = computed(() => [
{ title: '', key: 'data-table-select', sortable: false, width: '48px' },
{ title: t('setting.cache.poster'), key: 'poster', sortable: false, width: '76px' },
{ title: t('setting.cache.cacheKey'), key: 'key', sortable: true },
{ title: t('setting.cache.recognitionResult'), key: 'result', sortable: false, width: '220px' },
{ title: recognitionIdLabel.value, key: 'recognition_id', sortable: true, width: '120px' },
{ title: t('setting.cache.recognitionStatus'), key: 'status', sortable: true, width: '120px' },
{ title: t('setting.cache.actions'), key: 'actions', sortable: false, width: '72px' },
])
const filteredData = computed(() => {
const keyword = searchFilter.value.trim().toLowerCase()
return cacheData.value.data.filter(item => {
const matchesKeyword =
!keyword ||
[item.key, item.title, item.year, getRecognitionId(item)].some(value => value.toLowerCase().includes(keyword))
const matchesStatus =
statusFilter.value === 'all' || (statusFilter.value === 'recognized' ? isRecognized(item) : !isRecognized(item))
return matchesKeyword && matchesStatus
})
})
const mobileVisibleData = computed(() => filteredData.value.slice(0, mobileVisibleCount.value))
const mobileHasMore = computed(() => mobileVisibleData.value.length < filteredData.value.length)
/** 重置移动端分页,让筛选或刷新后的识别缓存从第一页开始展示。 */
function resetMobilePagination() {
mobileVisibleCount.value = MOBILE_CACHE_PAGE_SIZE
mobileInfiniteKey.value++
}
/** 追加移动端下一页识别缓存,并由虚拟滚动限制实际渲染节点。 */
function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) => void }) {
if (loading.value) {
done('ok')
return
}
if (!mobileHasMore.value) {
done('empty')
return
}
mobileVisibleCount.value = Math.min(mobileVisibleCount.value + MOBILE_CACHE_PAGE_SIZE, filteredData.value.length)
done(mobileHasMore.value ? 'ok' : 'empty')
}
/** 加载当前识别数据源对应的缓存列表。 */
async function loadCacheData(showSuccess = false) {
const requestId = ++cacheLoadRequestId
try {
loading.value = true
const response = (await api.get(recognitionCacheEndpoint.value)) as unknown as ApiResponse<RecognitionCacheData>
if (requestId !== cacheLoadRequestId) return
const responseData = response.data ?? { count: 0, recognized: 0, unrecognized: 0, data: [] }
cacheData.value = {
...responseData,
data: responseData.data.map(item => ({ ...item, recognition_id: getRecognitionId(item) })),
}
selectedItems.value = selectedItems.value.filter(key => cacheData.value.data.some(item => item.key === key))
resetMobilePagination()
if (showSuccess) $toast.success(t('setting.cache.listRefreshSuccess'))
} catch (error) {
if (requestId !== cacheLoadRequestId) return
console.error(error)
$toast.error(t('setting.cache.loadFailed'))
} finally {
if (requestId === cacheLoadRequestId) loading.value = false
}
}
/** 清空当前识别数据源的全部识别缓存。 */
async function clearAllCache() {
const confirmed = await createConfirm({
type: 'warn',
title: t('common.confirm'),
content: t('setting.cache.recognitionClearConfirm', { source: recognitionSourceName.value }),
})
if (!confirmed) return
try {
loading.value = true
const response = (await api.delete(recognitionCacheEndpoint.value)) as unknown as ApiResponse
if (!response.success) throw new Error(response.message)
$toast.success(response.message || t('setting.cache.clearSuccess'))
await loadCacheData()
selectedItems.value = []
} catch (error) {
console.error(error)
$toast.error(t('setting.cache.clearFailed'))
} finally {
loading.value = false
}
}
/** 请求当前识别数据源接口删除指定识别缓存。 */
async function deleteCacheItem(key: string) {
const response = (await api.delete(
`${recognitionCacheEndpoint.value}/${encodeURIComponent(key)}`,
)) as unknown as ApiResponse
if (!response.success) throw new Error(response.message)
}
/** 删除桌面端表格中选中的识别缓存。 */
async function deleteSelectedItems() {
if (selectedItems.value.length === 0) {
$toast.warning(t('setting.cache.selectDeleteWarning'))
return
}
const deleteCount = selectedItems.value.length
try {
loading.value = true
await Promise.all(selectedItems.value.map(deleteCacheItem))
$toast.success(t('setting.cache.deleteSelectedSuccess', { count: deleteCount }))
await loadCacheData()
selectedItems.value = []
} catch (error) {
console.error(error)
$toast.error(t('setting.cache.deleteSelectedFailed'))
} finally {
loading.value = false
}
}
/** 删除单条识别缓存。 */
async function deleteSingleItem(item: RecognitionCacheItem) {
try {
loading.value = true
await deleteCacheItem(item.key)
$toast.success(t('setting.cache.deleteSuccess'))
await loadCacheData()
} catch (error) {
console.error(error)
$toast.error(t('setting.cache.deleteFailed'))
} finally {
loading.value = false
}
}
/** 获取识别缓存海报的可展示地址。 */
function getPosterUrl(item: RecognitionCacheItem): string {
if (!item.poster_path) return ''
const sourceUrl = item.poster_path.startsWith('/')
? `https://${globalSettingsStore.globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w300${item.poster_path}`
: item.poster_path
return getDisplayImageUrl(sourceUrl, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
}
/** 获取当前识别数据源对应的媒体 ID。 */
function getRecognitionId(item: RecognitionCacheItem): string {
const recognitionId = recognitionSource.value === 'douban' ? item.douban_id : item.tmdb_id
return recognitionId ? String(recognitionId) : ''
}
/** 判断识别缓存条目是否包含有效媒体 ID。 */
function isRecognized(item: RecognitionCacheItem): boolean {
const recognitionId = getRecognitionId(item)
return Boolean(recognitionId && recognitionId !== '0')
}
/** 获取移动端识别缓存卡片的稳定渲染 key。 */
function getRecognitionCacheItemKey(item: RecognitionCacheItem): string {
return item.key
}
/** 获取本地化的媒体类型名称。 */
function getMediaTypeLabel(mediaType: string): string {
if (mediaType === 'movie') return t('setting.cache.mediaType.movie')
if (mediaType === 'tv') return t('setting.cache.mediaType.tv')
return t('setting.cache.mediaType.unknown')
}
/** 获取媒体类型对应的主题颜色。 */
function getMediaTypeColor(mediaType: string): string {
if (mediaType === 'movie') return 'primary'
if (mediaType === 'tv') return 'success'
return 'secondary'
}
/** 获取识别状态的本地化名称。 */
function getRecognitionStatusLabel(item: RecognitionCacheItem): string {
return isRecognized(item) ? t('setting.cache.recognized') : t('setting.cache.unrecognized')
}
onMounted(() => {
void loadCacheData()
})
watch([searchFilter, statusFilter], () => {
resetMobilePagination()
})
watch(recognitionSource, () => {
searchFilter.value = ''
statusFilter.value = 'all'
selectedItems.value = []
void loadCacheData()
})
</script>
<template>
<section class="recognition-cache-panel">
<div class="cache-panel-toolbar">
<div class="cache-panel-stats">
<div class="cache-panel-stat cache-panel-stat--primary">
<VIcon icon="mdi-database-outline" :size="isMobile ? 32 : 22" />
<div>
<strong>{{ cacheData.count }}</strong>
<span>{{ t('setting.cache.totalCount') }}</span>
</div>
</div>
<div class="cache-panel-stat cache-panel-stat--success">
<VIcon icon="mdi-check-decagram-outline" :size="isMobile ? 32 : 22" />
<div>
<strong>{{ cacheData.recognized }}</strong>
<span>{{ t('setting.cache.recognized') }}</span>
</div>
</div>
<div v-if="!isMobile" class="cache-panel-stat cache-panel-stat--warning">
<VIcon icon="mdi-help-circle-outline" size="22" />
<div>
<strong>{{ cacheData.unrecognized }}</strong>
<span>{{ t('setting.cache.unrecognized') }}</span>
</div>
</div>
</div>
<div v-if="!isMobile" class="cache-panel-actions">
<VBtn icon variant="text" color="primary" :loading="loading" @click="loadCacheData(true)">
<VIcon icon="mdi-refresh" />
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.refreshList') }}</VTooltip>
</VBtn>
<VBtn
icon
variant="text"
color="warning"
:disabled="selectedItems.length === 0"
:loading="loading"
@click="deleteSelectedItems"
>
<VIcon icon="mdi-delete-sweep-outline" />
<VTooltip activator="parent" location="bottom">
{{ t('setting.cache.deleteSelected') }} ({{ selectedItems.length }})
</VTooltip>
</VBtn>
<VBtn icon variant="text" color="error" :loading="loading" @click="clearAllCache">
<VIcon icon="mdi-delete-variant" />
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.clearAll') }}</VTooltip>
</VBtn>
</div>
</div>
<div class="cache-panel-filters">
<VTextField
v-model="searchFilter"
class="cache-panel-filter"
:label="isMobile ? undefined : recognitionFilterPlaceholder"
:placeholder="isMobile ? recognitionFilterPlaceholder : undefined"
prepend-inner-icon="mdi-magnify"
variant="outlined"
:density="isMobile ? 'comfortable' : 'compact'"
:single-line="isMobile"
clearable
hide-details
/>
<VSelect
v-model="statusFilter"
class="cache-panel-filter"
:label="isMobile ? undefined : t('setting.cache.recognitionStatus')"
:placeholder="isMobile ? t('setting.cache.recognitionStatus') : undefined"
:items="statusOptions"
prepend-inner-icon="mdi-list-status"
variant="outlined"
:density="isMobile ? 'comfortable' : 'compact'"
:single-line="isMobile"
hide-details
/>
</div>
<div v-if="isMobile" class="cache-panel-mobile-actions">
<VBtn variant="tonal" color="primary" :loading="loading" prepend-icon="mdi-refresh" @click="loadCacheData(true)">
{{ t('setting.cache.refresh') }}
</VBtn>
<VBtn variant="tonal" color="error" :loading="loading" prepend-icon="mdi-delete-variant" @click="clearAllCache">
{{ t('setting.cache.clearAll') }}
</VBtn>
</div>
<template v-if="isMobile">
<VInfiniteScroll
v-if="mobileVisibleData.length > 0 || loading"
:key="mobileInfiniteKey"
mode="intersect"
side="end"
:items="mobileVisibleData"
class="recognition-cache-mobile-scroll"
@load="loadMoreMobileCache"
>
<template #loading>
<div class="cache-panel-load-state">
<VProgressCircular indeterminate color="primary" size="22" width="3" />
<span>{{ t('setting.cache.loadingMore') }}</span>
</div>
</template>
<template #empty />
<ProgressiveCardGrid
v-if="mobileVisibleData.length > 0"
:items="mobileVisibleData"
:columns="1"
:gap="10"
:estimated-item-height="152"
:overscan-rows="5"
:get-item-key="getRecognitionCacheItemKey"
>
<template #default="{ item }">
<article class="recognition-cache-mobile-item">
<div class="recognition-cache-poster">
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
<VIcon v-else icon="mdi-image-off-outline" size="28" />
</div>
<div class="recognition-cache-mobile-item__content">
<div class="recognition-cache-mobile-item__title">
{{ item.title || t('setting.cache.unrecognized') }}
</div>
<div class="recognition-cache-mobile-item__meta">
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
{{ getMediaTypeLabel(item.media_type) }}
</VChip>
<span v-if="item.year">{{ item.year }}</span>
<span v-if="isRecognized(item)">{{ recognitionIdLabel }} #{{ getRecognitionId(item) }}</span>
</div>
<div class="recognition-cache-mobile-item__key">{{ item.key }}</div>
</div>
<VBtn
icon
size="small"
variant="text"
color="error"
:aria-label="t('common.delete')"
@click="deleteSingleItem(item)"
>
<VIcon icon="mdi-delete-outline" size="20" />
</VBtn>
</article>
</template>
</ProgressiveCardGrid>
</VInfiniteScroll>
<div v-else class="cache-panel-empty">
<VIcon icon="mdi-database-search-outline" size="42" />
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
</div>
</template>
<VDataTable
v-else
v-model="selectedItems"
class="recognition-cache-table"
:headers="tableHeaders"
:items="filteredData"
:loading="loading"
item-value="key"
show-select
hover
:items-per-page-text="t('common.itemsPerPage')"
:no-data-text="t('common.noDataText')"
:loading-text="t('common.loadingText')"
>
<template #item.poster="{ item }">
<div class="recognition-cache-table__poster">
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
<VIcon v-else icon="mdi-image-off-outline" />
</div>
</template>
<template #item.key="{ item }">
<div class="recognition-cache-table__key">{{ item.key }}</div>
</template>
<template #item.result="{ item }">
<div class="recognition-cache-result">
<strong>{{ item.title || t('setting.cache.unrecognized') }}</strong>
<span v-if="item.year">{{ item.year }}</span>
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
{{ getMediaTypeLabel(item.media_type) }}
</VChip>
</div>
</template>
<template #item.recognition_id="{ item }">
<span v-if="isRecognized(item)" class="font-weight-medium">#{{ getRecognitionId(item) }}</span>
<span v-else class="text-medium-emphasis">-</span>
</template>
<template #item.status="{ item }">
<VChip size="small" variant="tonal" :color="isRecognized(item) ? 'success' : 'warning'">
{{ getRecognitionStatusLabel(item) }}
</VChip>
</template>
<template #item.actions="{ item }">
<VBtn icon size="small" variant="text" color="error" @click="deleteSingleItem(item)">
<VIcon icon="mdi-delete-outline" size="18" />
<VTooltip activator="parent" location="start">{{ t('common.delete') }}</VTooltip>
</VBtn>
</template>
<template #no-data>
<div class="cache-panel-empty">
<VIcon icon="mdi-database-search-outline" size="42" />
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
</div>
</template>
</VDataTable>
</section>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.recognition-cache-panel {
display: flex;
flex: 1 1 auto;
flex-direction: column;
padding: 20px;
gap: 16px;
min-block-size: 0;
overflow-y: auto;
}
.cache-panel-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.cache-panel-stats {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.cache-panel-stat {
display: flex;
align-items: center;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
gap: 10px;
min-block-size: 58px;
min-inline-size: 126px;
padding-block: 10px;
padding-inline: 14px;
}
.cache-panel-stat strong,
.cache-panel-stat span {
display: block;
}
.cache-panel-stat strong {
color: rgba(var(--v-theme-on-surface), 0.9);
font-size: 18px;
line-height: 1.15;
}
.cache-panel-stat span {
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 12px;
margin-block-start: 3px;
}
.cache-panel-stat--primary {
color: rgb(var(--v-theme-primary));
}
.cache-panel-stat--success {
color: rgb(var(--v-theme-success));
}
.cache-panel-stat--warning {
color: rgb(var(--v-theme-warning));
}
.cache-panel-actions {
display: flex;
flex: 0 0 auto;
gap: 2px;
}
.cache-panel-filters {
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.35fr);
}
.cache-panel-filters :deep(.v-field) {
border-radius: var(--app-field-radius);
background: var(--app-grouped-list-background);
}
.cache-panel-mobile-actions {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cache-panel-mobile-actions :deep(.v-btn) {
min-block-size: 44px;
}
.recognition-cache-table {
overflow: hidden;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
box-shadow: var(--app-surface-shadow);
}
.recognition-cache-table__poster,
.recognition-cache-poster {
display: flex;
overflow: hidden;
align-items: center;
justify-content: center;
border-radius: var(--app-control-radius);
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.36);
}
.recognition-cache-table__poster {
block-size: 62px;
inline-size: 44px;
margin-block: 4px;
}
.recognition-cache-table__poster :deep(.v-img),
.recognition-cache-poster :deep(.v-img) {
block-size: 100%;
inline-size: 100%;
}
.recognition-cache-table__key {
color: rgba(var(--v-theme-on-surface), 0.68);
font-family: monospace;
font-size: 12px;
max-inline-size: 36rem;
overflow-wrap: anywhere;
}
.recognition-cache-result {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.recognition-cache-result strong {
color: rgba(var(--v-theme-on-surface), 0.88);
inline-size: 100%;
}
.recognition-cache-result span {
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 12px;
}
.cache-panel-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
color: rgba(var(--v-theme-on-surface), 0.48);
gap: 8px;
min-block-size: 14rem;
text-align: center;
}
.cache-panel-empty strong {
color: rgba(var(--v-theme-on-surface), 0.78);
font-size: 15px;
}
.cache-panel-empty span {
font-size: 13px;
max-inline-size: 30rem;
}
@media (width <= 959.98px) {
.recognition-cache-panel {
block-size: 100%;
padding-block: 14px calc(18px + env(safe-area-inset-bottom));
padding-inline: 16px;
}
.cache-panel-toolbar {
align-items: flex-start;
}
.cache-panel-stats {
display: grid;
flex: 1 1 auto;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cache-panel-stat {
flex-direction: row;
align-items: center;
padding: 18px;
gap: 14px;
min-block-size: 92px;
min-inline-size: 0;
}
.cache-panel-stat strong {
font-size: 28px;
font-weight: 800;
line-height: 1.05;
white-space: nowrap;
}
.cache-panel-stat span {
font-size: 14px;
font-weight: 600;
margin-block-start: 8px;
}
.cache-panel-filters {
gap: 10px;
grid-template-columns: 1fr;
}
.cache-panel-filter :deep(.v-field__outline) {
color: rgba(var(--v-theme-on-surface), 0.18);
}
.cache-panel-filter :deep(.v-field__input) {
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 16px;
min-block-size: 54px;
}
.recognition-cache-mobile-scroll {
overflow: visible !important;
min-block-size: 20rem;
}
.recognition-cache-mobile-scroll :deep(.v-infinite-scroll__container),
.recognition-cache-mobile-scroll :deep(.progressive-card-grid),
.recognition-cache-mobile-scroll :deep(.progressive-card-grid__track) {
overflow: visible !important;
}
.recognition-cache-mobile-scroll :deep(.v-infinite-scroll__side) {
padding-block: 14px 2px;
}
.cache-panel-load-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 15px;
font-weight: 700;
gap: 8px;
min-block-size: 70px;
}
.recognition-cache-mobile-item {
display: grid;
align-items: start;
padding: 12px;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
gap: 12px;
grid-template-columns: 54px minmax(0, 1fr) 36px;
}
.recognition-cache-poster {
block-size: 78px;
inline-size: 54px;
}
.recognition-cache-mobile-item__content {
min-inline-size: 0;
}
.recognition-cache-mobile-item__title {
color: rgba(var(--v-theme-on-surface), 0.9);
font-size: 15px;
font-weight: 700;
overflow-wrap: anywhere;
}
.recognition-cache-mobile-item__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 12px;
gap: 7px;
margin-block-start: 7px;
}
.recognition-cache-mobile-item__key {
color: rgba(var(--v-theme-on-surface), 0.48);
font-family: monospace;
font-size: 11px;
line-height: 1.35;
margin-block-start: 8px;
overflow-wrap: anywhere;
}
}
@media (width <= 374.98px) {
.recognition-cache-panel {
padding-inline: 12px;
}
.cache-panel-stat {
padding: 12px;
gap: 10px;
}
}
</style>

View File

@@ -13,6 +13,12 @@ const props = defineProps({
const imageLoaded = ref(false)
const imageLoadError = ref(false)
const cardStyle = computed(() => ({
aspectRatio: props.height ? undefined : '3 / 2',
blockSize: props.height,
inlineSize: props.width || '100%',
}))
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
@@ -46,22 +52,30 @@ const getImgUrl = computed(() => {
<template>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="ring-gray-500"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'ring-1': imageLoaded,
}"
@click="goPlay"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="backdrop-card-hover-area">
<VCard
:style="cardStyle"
class="app-hover-lift-card ring-gray-500"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
'ring-1': imageLoaded,
}"
@click="goPlay"
>
<template #image>
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
<VImg
:src="getImgUrl"
aspect-ratio="2/3"
class="backdrop-card-image"
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
cover
@load="imageLoadHandler"
@error="imageErrorHandler"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
<div class="backdrop-card-placeholder">
<VSkeletonLoader class="backdrop-card-skeleton" />
</div>
</template>
<template #default>
@@ -86,7 +100,43 @@ const getImgUrl = computed(() => {
color="success"
/>
</div>
</VCard>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.backdrop-card-hover-area {
block-size: 100%;
inline-size: 100%;
}
.backdrop-card-image {
block-size: 100%;
inline-size: 100%;
}
.backdrop-card-placeholder,
.backdrop-card-skeleton {
block-size: 100%;
inline-size: 100%;
}
.backdrop-card-image :deep(.v-img__img) {
opacity: 0;
transition: opacity 0.2s ease;
}
.backdrop-card-image--loaded :deep(.v-img__img) {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.backdrop-card-image :deep(.v-img__img) {
transition: none;
}
}
</style>

View File

@@ -5,7 +5,7 @@ import { nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { storageRemoteDict } from '@/api/constants'
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253'
const DEFAULT_DIRECTORY_ACCENT_RGB = '141, 81, 249'
const STORAGE_ACCENT_COLOR_MAP = {
local: '#FFB400',
alipan: '#00A7F2',
@@ -75,10 +75,12 @@ const transferSourceItems = computed(() => [
{ title: t('directory.manualTransfer'), value: 'manual' },
])
/** 判断存储类型是否具备预设强调色。 */
function hasKnownStorageType(storageType?: string): storageType is keyof typeof STORAGE_ACCENT_COLOR_MAP {
return !!storageType && Object.prototype.hasOwnProperty.call(STORAGE_ACCENT_COLOR_MAP, storageType)
}
/** 将十六进制颜色转换为 CSS RGB 通道字符串。 */
function hexToRgbString(hexColor: string) {
const normalizedColor = hexColor.replace('#', '')
const colorValue = Number.parseInt(normalizedColor, 16)
@@ -88,6 +90,7 @@ function hexToRgbString(hexColor: string) {
return `${(colorValue >> 16) & 255}, ${(colorValue >> 8) & 255}, ${colorValue & 255}`
}
/** 根据自定义存储序号选取离散的强调色。 */
function getCustomStoragePaletteColor(storageType?: string) {
const customStorageIndex = Math.max(Number(storageType?.match(/\d+$/)?.[0] ?? 1) - 1, 0)
const customStorageColors = ['#F97316', '#8B5CF6', '#06B6D4', '#84CC16', '#EC4899', '#14B8A6']
@@ -95,6 +98,7 @@ function getCustomStoragePaletteColor(storageType?: string) {
return customStorageColors[customStorageIndex % customStorageColors.length]
}
/** 获取指定存储类型在目录卡片中使用的强调色。 */
function getStorageAccentColor(storageType?: string) {
if (hasKnownStorageType(storageType)) return STORAGE_ACCENT_COLOR_MAP[storageType]
@@ -108,6 +112,7 @@ const directoryAccentStyle = computed(() => ({
'--app-card-accent-end-rgb': libraryAccentRgb.value,
}))
/** 根据目录两端的存储类型刷新卡片强调色。 */
function updateDirectoryAccentColors() {
const downloadStorage = props.directory.storage
const libraryStorage = props.directory.library_storage || props.directory.storage
@@ -270,6 +275,7 @@ watch(
v-model="props.directory.name"
variant="underlined"
:label="t('directory.alias')"
mobile-control-width="65%"
class="me-20 text-high-emphasis font-weight-bold"
/>
<span class="app-card-top-action absolute top-3 right-12">
@@ -287,6 +293,7 @@ watch(
variant="underlined"
:items="typeItems"
:label="t('directory.mediaType')"
mobile-control-width="65%"
@update:modelValue="props.directory.media_category = ''"
/>
</VCol>
@@ -296,6 +303,7 @@ watch(
variant="underlined"
:items="getCategories"
:label="t('directory.mediaCategory')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="4">
@@ -304,6 +312,7 @@ watch(
variant="underlined"
:items="resourceStorageOptions"
:label="t('directory.resourceStorage')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="8">
@@ -312,6 +321,7 @@ watch(
:storage="props.directory.storage"
variant="underlined"
:label="t('directory.resourceDirectory')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
@@ -332,6 +342,7 @@ watch(
variant="underlined"
:items="transferSourceItems"
:label="t('directory.autoTransfer')"
mobile-control-width="65%"
/>
</VCol>
</VRow>
@@ -342,6 +353,7 @@ watch(
variant="underlined"
:items="MonitorModeItems"
:label="t('directory.monitorMode')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="4">
@@ -350,6 +362,7 @@ watch(
variant="underlined"
:items="libraryStorageOptions"
:label="t('directory.libraryStorage')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="8">
@@ -358,6 +371,7 @@ watch(
:storage="props.directory.library_storage"
variant="underlined"
:label="t('directory.libraryDirectory')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="4">
@@ -367,6 +381,7 @@ watch(
:items="transferTypeItems"
:label="t('directory.transferType')"
:no-data-text="computedNoDataText"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="8">
@@ -375,6 +390,7 @@ watch(
variant="underlined"
:items="overwriteModeItems"
:label="t('directory.overwriteMode')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">

View File

@@ -73,16 +73,16 @@ async function deleteDownload() {
<template>
<VHover>
<template #default="hover">
<VCard
v-if="cardState"
v-bind="hover.props"
:key="props.info?.hash"
class="downloading-card app-surface flex flex-col h-full overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
min-height="150"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-if="cardState" v-bind="hover.props" class="downloading-card-hover-area h-full">
<VCard
:key="props.info?.hash"
class="downloading-card app-hover-lift-card app-surface flex flex-col h-full overflow-hidden"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
}"
min-height="150"
>
<template #image>
<VImg
:src="props.info?.media.image"
@@ -130,7 +130,8 @@ async function deleteDownload() {
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
</VCardActions>
</div>
</VCard>
</VCard>
</div>
</template>
</VHover>
</template>
@@ -138,6 +139,10 @@ async function deleteDownload() {
<style lang="scss" scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.downloading-card-hover-area {
inline-size: 100%;
}
.downloading-card-image {
block-size: 100%;
}

View File

@@ -17,12 +17,12 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['close', 'changed'])
// 按钮点击
/** 关闭当前优先级规则卡片。 */
function onClose() {
emit('close')
}
// 选项变化
/** 将当前优先级的规则选择结果通知父组件。 */
function filtersChanged(value: string[]) {
emit('changed', props.pri, value)
}
@@ -61,6 +61,7 @@ const selectFilterOptions = computed<{ [key: string]: string }[]>(() => {
:items="selectFilterOptions"
chips
:label="t('filterRule.rules')"
mobile-control-width="80%"
multiple
clearable
@update:modelValue="filtersChanged"

View File

@@ -25,18 +25,51 @@ const imageLoaded = ref(false)
// 图片是否加载错误
const imageError = ref(false)
// 图片加载完成响应
const cardStyle = computed(() => ({
aspectRatio: props.height ? undefined : '3 / 2',
blockSize: props.height,
inlineSize: props.width || '100%',
}))
// 媒体库内条目数量,兼容不同后端字段。
const libraryItemCount = computed(() => props.media?.item_count)
// 是否展示右上角数量折角。
const showCountCorner = computed(() => typeof libraryItemCount.value === 'number' && libraryItemCount.value >= 0)
// 右上角数量文案。
const countLabel = computed(() => {
if (!showCountCorner.value) return ''
return formatLibraryCount(libraryItemCount.value || 0)
})
/**
* 格式化右上角媒体库数量。
* @param count 媒体库内媒体数量
*/
function formatLibraryCount(count: number) {
if (count >= 10000) return `${(count / 10000).toFixed(1).replace(/\.0$/, '')}`
return `${count}`
}
/**
* 标记封面加载完成。
*/
function imageLoadHandler() {
imageLoaded.value = true
}
// 图片加载错误
/**
* 标记封面加载失败并切换默认图。
*/
function imageErrorHandler() {
imageError.value = true
imgUrl.value = getDefaultImage()
}
// 默认图片
/**
* 获取媒体服务器默认封面图。
*/
function getDefaultImage() {
if (props.media?.server_type === 'plex') return plex
else if (props.media?.server_type === 'emby') return emby
@@ -47,14 +80,20 @@ function getDefaultImage() {
else return plex
}
// 跳转播放
/**
* 跳转到媒体服务器媒体库页面。
*/
async function goPlay() {
if (props.media) {
await openMediaServerItem(props.media)
}
}
// 生成图片代理路径
/**
* 生成图片代理路径。
* @param url 原始图片地址
* @param use_cookies 是否携带 Cookie 代理图片
*/
function getImgUrl(url: string, use_cookies?: boolean) {
if (!url || imageError.value) return getDefaultImage()
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
@@ -64,7 +103,11 @@ function getImgUrl(url: string, use_cookies?: boolean) {
return imgurl
}
// 根据多张图片生成媒体库封面
/**
* 根据多张图片生成媒体库封面。
* @param imageList 媒体库封面候选图列表
* @param use_cookies 是否携带 Cookie 代理图片
*/
async function drawImages(imageList: string[], use_cookies?: boolean) {
// 图片
const IMAGES = [...imageList]
@@ -96,7 +139,11 @@ async function drawImages(imageList: string[], use_cookies?: boolean) {
// 设置背景色为透明
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 绘制图片
/**
* 绘制单张海报及其倒影。
* @param imgSrc 海报图片地址
* @param index 海报位置
*/
async function drawImageWithReflection(imgSrc: string, index: number) {
if (!canvas) return
@@ -156,35 +203,162 @@ onMounted(async () => {
<template>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
@click="goPlay"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="library-card-hover-area">
<VCard
:style="cardStyle"
class="app-hover-lift-card"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
}"
@click="goPlay"
>
<template #image>
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
<VImg
:src="imgUrl"
aspect-ratio="2/3"
class="library-card-image"
:class="{ 'library-card-image--loaded': imageLoaded }"
cover
@load="imageLoadHandler"
@error="imageErrorHandler"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
<div class="library-card-placeholder">
<VSkeletonLoader class="library-card-skeleton" />
</div>
</template>
<template #default>
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
>
<h1 class="mb-1 text-white text-shadow font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.name }}
</h1>
</VCardText>
<div class="library-card-shade" aria-hidden="true" />
<div v-if="showCountCorner" class="library-card-count-corner">
<span>{{ countLabel }}</span>
</div>
<div class="library-card-label">
<span>{{ props.media?.name }}</span>
</div>
</template>
</VImg>
</template>
</VCard>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.library-card-hover-area {
block-size: 100%;
inline-size: 100%;
}
.library-card-image {
block-size: 100%;
inline-size: 100%;
}
.library-card-shade {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(2, 6, 23, 0%) 0%, rgba(2, 6, 23, 2%) 56%, rgba(2, 6, 23, 28%) 100%),
linear-gradient(90deg, rgba(2, 6, 23, 8%) 0%, rgba(2, 6, 23, 0%) 42%, rgba(2, 6, 23, 10%) 100%);
pointer-events: none;
}
.library-card-label {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
background: rgba(6, 10, 17, 58%);
block-size: 1.75rem;
border-block-start: 1px solid rgba(255, 255, 255, 12%);
border-end-end-radius: var(--app-surface-radius);
border-end-start-radius: var(--app-surface-radius);
color: #fff;
font-size: 0.75rem;
font-weight: 700;
inset-block-end: 0;
inset-inline: 0;
line-height: 1;
padding-inline: 0.75rem;
text-align: center;
text-shadow: 0 1px 4px rgba(0, 0, 0, 48%);
}
.library-card-label span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-card-count-corner {
position: absolute;
block-size: 3.35rem;
color: #fff;
inline-size: 3.35rem;
inset-block-start: 0;
inset-inline-end: 0;
pointer-events: none;
}
.library-card-count-corner::before {
position: absolute;
background: rgba(8, 13, 22, 72%);
block-size: 100%;
clip-path: polygon(100% 0, 100% 100%, 0 0);
content: "";
inset-block-start: 0;
inset-inline-end: 0;
inline-size: 100%;
}
.library-card-count-corner::after {
position: absolute;
background: rgba(var(--v-theme-primary), 62%);
block-size: 100%;
clip-path: polygon(100% 0, 100% 100%, 0 0);
content: "";
inset-block-start: 0;
inset-inline-end: 0;
inline-size: 100%;
}
.library-card-count-corner span {
position: absolute;
inset-block-start: 0.63rem;
inset-inline-end: 0.18rem;
font-size: 0.625rem;
font-weight: 800;
line-height: 1;
text-shadow: 0 1px 4px rgba(0, 0, 0, 44%);
transform: rotate(45deg);
transform-origin: center;
white-space: nowrap;
z-index: 1;
}
.library-card-placeholder,
.library-card-skeleton {
block-size: 100%;
inline-size: 100%;
}
.library-card-image :deep(.v-img__img) {
opacity: 0;
transition: opacity 0.2s ease;
}
.library-card-image--loaded :deep(.v-img__img) {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.library-card-image :deep(.v-img__img) {
transition: none;
}
}
</style>

View File

@@ -1,27 +1,34 @@
<script lang="ts">
import { ref } from 'vue'
const activeTouchMediaCardId = ref<number | null>(null)
let mediaCardIdSeed = 0
</script>
<script lang="ts" setup>
import noImage from '@images/no-image.jpeg'
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
import api from '@/api'
import { useToast } from 'vue-toastification'
import { formatSeason, formatRating } from '@/@core/utils/formatters'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
import { formatRating } from '@/@core/utils/formatters'
import type { MediaInfo, Site, Subscribe } from '@/api/types'
import router from '@/router'
import { useUserStore, useGlobalSettingsStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import { mediaTypeDict } from '@/api/constants'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { openSharedDialog } from '@/composables/useSharedDialog'
import {
getMediaSubscribeId,
getSubscribeMode,
useMediaSubscribe,
type SeasonSubscribeModes,
} from '@/composables/useMediaSubscribe'
import {
getCachedMediaExistsStatus,
getCachedMediaSubscribeStatus,
setCachedMediaExistsStatus,
setCachedMediaSubscribeStatus,
} from '@/utils/mediaStatusCache'
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
const SubscribeSeasonDialog = defineAsyncComponent(() => import('../dialog/SubscribeSeasonDialog.vue'))
// 国际化
const { t } = useI18n()
@@ -49,9 +56,6 @@ const userPermissions = computed(() => buildUserPermissionContext(userStore.supe
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
// 提示框
const $toast = useToast()
// 图片加载状态
const isImageLoaded = ref(false)
@@ -64,8 +68,15 @@ const isSubscribed = ref(false)
// 本地存在状态
const isExists = ref(false)
// 选中的订阅季
const seasonsSelected = ref<MediaSeason[]>([])
// 当前媒体已订阅的季号
const subscribedSeasons = ref<number[]>([])
// 当前媒体已订阅季的订阅模式
const subscribedSeasonModes = ref<SeasonSubscribeModes>({})
const subscribedSeasonsLoaded = ref(false)
const subscribedSeasonsLoading = ref(false)
// 来源角标字典
const sourceIconDict: { [key: string]: any } = {
@@ -89,32 +100,12 @@ const selectedSites = ref<number[]>([])
// 搜索菜单显示状态
const searchMenuShow = ref(false)
// 选择的剧集组
const episodeGroup = ref('')
const mediaCardId = ++mediaCardIdSeed
// 打开订阅季选择弹窗,避免每个媒体卡片都持有弹窗实例
function openSubscribeSeasonDialog() {
openSharedDialog(
SubscribeSeasonDialog,
{ media: props.media },
{
subscribe: subscribeSeasons,
},
{ closeOn: ['close', 'subscribe'] },
)
}
// 打开订阅编辑弹窗,保存、关闭或删除时释放共享弹窗实例。
function openSubscribeEditDialog(subid: number) {
openSharedDialog(
SubscribeEditDialog,
{ subid },
{
remove: onRemoveSubscribe,
},
{ closeOn: ['close', 'save', 'remove'] },
)
}
// 粗指针设备使用点击展开详情,避免 iOS 返回后沿用 VHover 的触摸态
const isTouchLikePointer = ref(
typeof window !== 'undefined' && Boolean(window.matchMedia?.('(hover: none), (pointer: coarse)').matches),
)
// 打开站点选择弹窗,并把选择结果交回当前媒体卡片继续搜索。
function openSearchSiteDialog() {
@@ -155,10 +146,7 @@ async function querySelectedSites() {
// 获得mediaid
function getMediaId() {
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
return getMediaSubscribeId(props.media)
}
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
@@ -177,6 +165,15 @@ function getExistsStatusKey() {
].join('::')
}
function isSameSubscribeMedia(subscribe: Subscribe) {
if (props.media?.tmdb_id && subscribe.tmdbid) return props.media.tmdb_id === subscribe.tmdbid
if (props.media?.douban_id && subscribe.doubanid) return props.media.douban_id === subscribe.doubanid
if (props.media?.bangumi_id && subscribe.bangumiid) return props.media.bangumi_id === subscribe.bangumiid
const mediaId = props.media?.media_id ? `${props.media.mediaid_prefix}:${props.media.media_id}` : ''
return Boolean(mediaId && subscribe.mediaid === mediaId)
}
// 角标颜色
function getChipColor(type: string) {
if (type === '电影') return 'border-blue-500 bg-blue-600'
@@ -184,101 +181,6 @@ function getChipColor(type: string) {
else return 'border-purple-600 bg-purple-600'
}
// 添加订阅处理
async function handleAddSubscribe() {
if (props.media?.type === '电视剧') {
// 弹出季选择列表,支持多选
seasonsSelected.value = []
openSubscribeSeasonDialog()
} else {
// 电影
addSubscribe()
}
}
// 调用API添加订阅电视剧的话需要指定季
async function addSubscribe(season: number | null = null, best_version: number = 0) {
// 开始处理
startNProgress()
try {
// 是否洗版
if (!best_version && props.media?.type == '电影') best_version = isExists.value ? 1 : 0
// 请求API
const result: { [key: string]: any } = await api.post('subscribe/', {
name: props.media?.title,
type: props.media?.type,
year: props.media?.year,
tmdbid: props.media?.tmdb_id,
doubanid: props.media?.douban_id,
bangumiid: props.media?.bangumi_id,
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
season: props.media?.type === '电影' ? null : season,
best_version,
episode_group: episodeGroup.value,
})
// 订阅状态
if (result.success) {
// 订阅成功
isSubscribed.value = true
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
}
// 提示
showSubscribeAddToast(result.success, props.media?.title ?? '', season, result.message, best_version)
// 弹出订阅编辑弹窗
if (result.success && seasonsSelected.value.length <= 1) {
const show_edit_dialog = await queryDefaultSubscribeConfig()
if (show_edit_dialog) {
openSubscribeEditDialog(result.data.id)
}
}
} catch (error) {
console.error(error)
} finally {
doneNProgress()
}
}
// 弹出添加订阅提示
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
let subname = t('subscribe.normalSub')
if (best_version > 0) subname = t('subscribe.versionSub')
if (result) $toast.success(`${title} ${t('subscribe.addSuccess', { name: subname })}`)
else if (!result) $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message: message })}`)
}
// 调用API取消订阅
async function removeSubscribe() {
// 开始处理
startNProgress()
try {
const mediaid = getMediaId()
const result: { [key: string]: any } = await api.delete(`subscribe/media/${mediaid}`, {
params: {
season: props.media?.season,
},
})
if (result.success) {
isSubscribed.value = false
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
} else {
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
}
} catch (error) {
console.error(error)
} finally {
doneNProgress()
}
}
// 查询当前媒体是否已订阅
async function handleCheckSubscribe() {
try {
@@ -291,6 +193,36 @@ async function handleCheckSubscribe() {
}
}
async function querySubscribedSeasons() {
if (
props.media?.type !== '电视剧' ||
!isSubscribed.value ||
subscribedSeasonsLoaded.value ||
subscribedSeasonsLoading.value
) {
return
}
subscribedSeasonsLoading.value = true
try {
const subscribes: Subscribe[] = await api.get('subscribe/')
const mediaSubscribes = subscribes.filter(
item => item.type === '电视剧' && item.season !== undefined && isSameSubscribeMedia(item),
)
subscribedSeasons.value = mediaSubscribes.map(item => item.season as number).sort((a, b) => a - b)
subscribedSeasonModes.value = mediaSubscribes.reduce<SeasonSubscribeModes>((modes, item) => {
if (item.season !== undefined) modes[item.season] = getSubscribeMode(item)
return modes
}, {})
subscribedSeasonsLoaded.value = true
} catch (error) {
console.error(error)
} finally {
subscribedSeasonsLoading.value = false
}
}
// 查询当前媒体是否已入库
async function handleCheckExists() {
try {
@@ -317,63 +249,20 @@ async function handleCheckExists() {
// 调用API检查是否已订阅电视剧需要指定季
async function checkSubscribe(season: number | null) {
try {
// AbortController 现在由全局请求优化器自动管理
const mediaid = getMediaId()
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
season,
title: props.media?.title,
},
})
return Boolean(result.id)
} catch (error: any) {
if (error?.response?.status === 404) {
return false
}
throw error
}
}
// 查询订阅弹窗规则
async function queryDefaultSubscribeConfig() {
if (!canSubscribe.value) return false
try {
let subscribe_config_url = ''
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.get(subscribe_config_url)
if (result.data?.value) return result.data.value.show_edit_dialog
} catch (error) {
console.log(error)
}
return false
return subscribeActions.checkSubscribe(season)
}
// 爱心订阅按钮响应
function handleSubscribe() {
if (isSubscribed.value) removeSubscribe()
else handleAddSubscribe()
}
// 订阅多季
function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {
episodeGroup.value = groudId
seasonsSelected.value = seasons || []
seasonsSelected.value.forEach(season => {
let best_version = 0
if (season && props.media?.tmdb_id)
// 全部存在时洗版
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
addSubscribe(season.season_number ?? null, best_version)
})
async function handleSubscribe() {
await querySubscribedSeasons()
subscribeActions.handleSubscribe()
}
// 打开详情页
function goMediaDetail(isHovering = false) {
if (isHovering) {
resetMediaCardDetailState()
if (props.media?.collection_id) {
// 跳转到合集列表
router.push({
@@ -397,6 +286,42 @@ function goMediaDetail(isHovering = false) {
}
}
// 当前卡片是否进入可操作详情态,桌面使用 hover触摸端使用显式点击态。
function isMediaCardActive(isHovering: boolean | null | undefined) {
return activeTouchMediaCardId.value === mediaCardId || (!isTouchLikePointer.value && Boolean(isHovering))
}
// 当前卡片详情层是否显示,保留图片错误和站点选择时的强制显示。
function isMediaCardDetailVisible(isHovering: boolean | null | undefined) {
return isMediaCardActive(isHovering) || imageLoadError.value || searchMenuShow.value
}
// 清理移动端详情层展开态,避免路由返回后继续显示上一次的详情层。
function resetMediaCardDetailState() {
if (activeTouchMediaCardId.value === mediaCardId) {
activeTouchMediaCardId.value = null
}
}
// 处理媒体卡片点击:触摸端第一次点击展开,展开后再次点击进入详情。
function handleMediaCardClick(isHovering: boolean | null | undefined) {
if (isTouchLikePointer.value && !isMediaCardDetailVisible(isHovering)) {
activeTouchMediaCardId.value = mediaCardId
querySubscribedSeasons()
return
}
goMediaDetail(isMediaCardActive(isHovering) || isMediaCardDetailVisible(isHovering))
}
function handleDocumentPointerDown(event: PointerEvent) {
if (!isTouchLikePointer.value || activeTouchMediaCardId.value !== mediaCardId) return
if (!(event.target instanceof Node)) return
if (mediaCardRef.value?.contains(event.target)) return
resetMediaCardDetailState()
}
// 点击搜索
async function clickSearch() {
if (allSites.value?.length == 0) {
@@ -469,22 +394,54 @@ const getImgUrl: Ref<string> = computed(() => {
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
})
// 移除订阅
function onRemoveSubscribe() {
isSubscribed.value = false
}
// 获取媒体类型文本
function getMediaTypeText(type: string | undefined) {
if (!type) return ''
return mediaTypeDict[type]
}
onMounted(() => {
setupIntersectionObserver()
const subscribeActions = useMediaSubscribe({
media: () => props.media,
canSubscribe: () => canSubscribe.value,
isSubscribed,
isExists: () => isExists.value,
subscribedSeasons,
subscribedSeasonModes,
primarySeason: () => props.media?.season ?? null,
getSubscribeStatusKey,
})
watch(isSubscribed, subscribed => {
subscribedSeasonsLoaded.value = false
if (!subscribed) {
subscribedSeasons.value = []
subscribedSeasonModes.value = {}
}
})
watch(
() => props.media,
() => {
resetMediaCardDetailState()
subscribedSeasons.value = []
subscribedSeasonModes.value = {}
subscribedSeasonsLoaded.value = false
},
)
onMounted(() => {
setupIntersectionObserver()
if (isTouchLikePointer.value) {
document.addEventListener('pointerdown', handleDocumentPointerDown)
}
})
onActivated(resetMediaCardDetailState)
onDeactivated(resetMediaCardDetailState)
onBeforeUnmount(() => {
resetMediaCardDetailState()
document.removeEventListener('pointerdown', handleDocumentPointerDown)
observer.value?.disconnect()
observer.value = null
})
@@ -493,17 +450,18 @@ onBeforeUnmount(() => {
<template>
<VHover>
<template #default="hover">
<div ref="mediaCardRef">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div ref="mediaCardRef" v-bind="hover.props" class="media-card-hover-area" @mouseenter="querySubscribedSeasons">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none ring-gray-500 media-card"
:ripple="false"
class="app-hover-lift-card outline-none ring-gray-500 media-card"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
'ring-1': isImageLoaded,
}"
@click.stop="goMediaDetail(hover.isHovering ?? false)"
@click.stop="handleMediaCardClick(hover.isHovering)"
>
<VImg
aspect-ratio="2/3"
@@ -522,7 +480,7 @@ onBeforeUnmount(() => {
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError || searchMenuShow"
v-show="isMediaCardDetailVisible(hover.isHovering)"
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
>
@@ -535,17 +493,11 @@ onBeforeUnmount(() => {
</p>
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
<div v-else class="flex align-center justify-between">
<IconBtn
v-if="canSearch"
icon="mdi-magnify"
color="white"
size="small"
@click.stop="clickSearch"
/>
<IconBtn v-if="canSearch" icon="mdi-magnify" color="white" size="small" @click.stop="clickSearch" />
<VSpacer />
<IconBtn
v-if="canSubscribe"
icon="mdi-heart"
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
:color="isSubscribed ? 'error' : 'white'"
size="small"
@click.stop="handleSubscribe"
@@ -563,10 +515,10 @@ onBeforeUnmount(() => {
{{ getMediaTypeText(props.media?.type) }}
</VChip>
<!-- 本地存在标识 -->
<ExistIcon v-if="isExists && !hover.isHovering" />
<ExistIcon v-if="isExists && !isMediaCardActive(hover.isHovering)" />
<!-- 评分角标 -->
<VChip
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !isMediaCardActive(hover.isHovering))"
variant="elevated"
size="small"
:class="getChipColor('rating')"
@@ -581,7 +533,7 @@ onBeforeUnmount(() => {
density="compact"
class="absolute bottom-1 right-1"
tile
v-if="!hover.isHovering && isImageLoaded && props.media?.source && !imageLoadError"
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
>
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
</VAvatar>
@@ -591,6 +543,10 @@ onBeforeUnmount(() => {
</VHover>
</template>
<style scoped>
.media-card-hover-area {
inline-size: 100%;
}
.media-card-title {
font-size: 1.125rem;
line-height: 1.25rem;
@@ -600,4 +556,20 @@ onBeforeUnmount(() => {
font-size: 0.875rem;
line-height: 1rem;
}
.media-card-subscribe-summary {
display: flex;
align-items: center;
color: white;
font-size: 0.75rem;
gap: 0.25rem;
line-height: 1rem;
min-block-size: 1.25rem;
}
.media-card-subscribe-summary span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -1,226 +0,0 @@
<script lang="ts" setup>
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
import { isNullOrEmptyObject } from '@/@core/utils'
import type { Message } from '@/api/types'
import { formatDateDifference } from '@core/utils/formatters'
// 输入参数
const props = defineProps({
message: Object as PropType<Message>,
width: String,
height: String,
})
// 定义事件
const emit = defineEmits(['imageload'])
// 图片是否加载完成
const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 初始化 markdown-it
const md = new MarkdownIt({
html: true,
breaks: true,
linkify: true,
typographer: true,
})
// 插件:链接在新窗口打开
md.use(mdLinkAttributes, {
attrs: {
target: '_blank',
rel: 'noopener noreferrer',
},
})
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
emit('imageload')
}
// 链接打开新窗口
function openLink() {
if (props.message?.link) window.open(props.message.link, '_blank')
}
// 将note转换为json
function noteToJson() {
if (props.message?.note) {
try {
return JSON.parse(props.message.note)
} catch (error) {
return props.message.note
}
}
return {}
}
// 渲染 Markdown
function renderMarkdown(value: string) {
if (!value) return ''
return md.render(value)
}
</script>
<template>
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink" max-width="23rem">
<div v-if="props.message?.image" class="relative text-center card-cover-blurred">
<VImg
:src="props.message?.image"
aspect-ratio="3/2"
cover
position="top"
@load="imageLoaded"
@error="imageLoadError = true"
min-height="10rem"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div
v-if="
props.message?.title &&
!props.message?.text &&
!props.message?.image &&
isNullOrEmptyObject(props.message?.note) &&
props.message?.action === 0
"
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
>
<p class="mb-0">{{ props.message?.title }}</p>
</div>
<VCardTitle v-else-if="props.message?.title" class="break-words whitespace-break-spaces">
{{ props.message?.title }}
</VCardTitle>
<div
v-if="props.message?.text && props.message?.action === 0"
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
>
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
</div>
<VCardText
v-if="props.message?.text && props.message?.action === 1"
class="markdown-body"
v-html="renderMarkdown(props.message?.text)"
/>
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
<VList>
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
{{ Number(key) + 1 }}. {{ value.title_year }}
</VListItemTitle>
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
{{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
</VListItemTitle>
<VListItemSubtitle v-if="value.type">
类型:{{ value.type }} 评分:{{ value.vote_average }}
</VListItemSubtitle>
<VListItemSubtitle v-if="value.enclosure" class="whitespace-break-spaces">
{{ value.description }}
</VListItemSubtitle>
</VListItem>
</VList>
</VCardText>
</VCard>
<div class="text-end">
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
<span class="text-sm italic me-2">{{
formatDateDifference(props.message?.reg_time || props.message?.date || '')
}}</span>
</div>
</template>
<style lang="scss">
.markdown-body {
word-break: break-all;
p {
margin-block-end: 0.5rem;
}
p:last-child {
margin-block-end: 0;
}
a {
color: inherit;
text-decoration: underline;
}
ul {
list-style-type: disc;
margin-block-end: 0.5rem;
padding-inline-start: 1.5rem;
}
ol {
list-style-type: decimal;
margin-block-end: 0.5rem;
padding-inline-start: 1.5rem;
}
li {
display: list-item;
margin-block-end: 0.25rem;
}
code {
border-radius: 4px;
background-color: rgba(var(--v-border-color), 0.1);
font-family: monospace;
padding-block: 0.2rem;
padding-inline: 0.4rem;
}
pre {
overflow: auto;
padding: 1rem;
border-radius: 8px;
background-color: rgba(var(--v-border-color), 0.1);
margin-block-end: 0.5rem;
code {
padding: 0;
background-color: transparent;
}
}
blockquote {
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
font-style: italic;
margin-block-end: 0.5rem;
padding-inline-start: 1rem;
}
table {
border-collapse: collapse;
inline-size: 100%;
margin-block-end: 1rem;
th,
td {
padding: 0.5rem;
border: 1px solid rgba(var(--v-border-color), 0.1);
text-align: start;
}
th {
background-color: rgba(var(--v-border-color), 0.05);
}
}
img {
block-size: auto;
max-inline-size: 100%;
}
}
</style>

View File

@@ -75,15 +75,17 @@ function goPersonDetail() {
<template>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="personProps.height"
:width="personProps.width"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
@click.stop="goPersonDetail"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="person-card-hover-area">
<VCard
:height="personProps.height"
:width="personProps.width"
class="app-hover-lift-card"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
}"
@click.stop="goPersonDetail"
>
<div class="person-card relative cursor-pointer ring-gray-700">
<div style="padding-block-end: 150%">
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
@@ -107,12 +109,17 @@ function goPersonDetail() {
</div>
</div>
</div>
</VCard>
</VCard>
</div>
</template>
</VHover>
</template>
<style lang="scss" scoped>
.person-card-hover-area {
inline-size: 100%;
}
.person-card {
background-image: linear-gradient(
45deg,

View File

@@ -0,0 +1,365 @@
<script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { openMediaServerItem } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
width: String,
height: String,
})
// 图片是否加载完成
const imageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 卡片内联尺寸,便于仪表盘网格按高度进行虚拟布局。
const cardStyle = computed(() => ({
aspectRatio: props.height ? undefined : '16 / 10',
blockSize: props.height,
inlineSize: props.width || '100%',
}))
// 规范化后的播放进度,避免后端异常值撑破进度条。
const progressValue = computed(() => {
const percent = Number(props.media?.percent ?? 0)
if (Number.isNaN(percent)) return 0
return Math.min(Math.max(percent, 0), 100)
})
// 是否存在可续播进度。
const hasProgress = computed(() => progressValue.value > 0)
// 右上角进度标签。
const progressLabel = computed(() => (hasProgress.value ? `${Math.round(progressValue.value)}%` : 'NEW'))
// 副信息兜底,避免空副标题导致卡片底部信息层过空。
const subtitleText = computed(() => props.media?.subtitle || props.media?.type || '继续观看')
// 根据播放进度生成简短提示。
const progressHint = computed(() => {
if (!hasProgress.value) return '未开始'
if (progressValue.value >= 90) return '快看完'
if (progressValue.value <= 10) return '刚开始'
return '继续上次进度'
})
// 计算图片代理地址。
const imageUrl = computed(() => {
const image = props.media?.image || ''
if (!image || imageLoadError.value) return noImage
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
const useCookies = props.media?.use_cookies
if (useCookies) {
url += `&use_cookies=${encodeURIComponent(useCookies)}`
}
return url
})
/**
* 标记封面加载完成。
*/
function imageLoadHandler() {
imageLoaded.value = true
}
/**
* 标记封面加载失败并切换到默认图。
*/
function imageErrorHandler() {
imageLoadError.value = true
}
/**
* 跳转到媒体服务器播放页面。
*/
async function goPlay() {
if (props.media) {
await openMediaServerItem(props.media)
}
}
</script>
<template>
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="playing-card-hover-area">
<VCard
:style="cardStyle"
class="playing-card app-hover-lift-card"
:class="{
'app-hover-lift-card--hovering playing-card--hovering': hover.isHovering,
'playing-card--loaded': imageLoaded,
}"
role="button"
tabindex="0"
@click="goPlay"
@keyup.enter="goPlay"
@keyup.space="goPlay"
>
<VImg
:src="imageUrl"
class="playing-card__image"
:class="{ 'playing-card__image--loaded': imageLoaded }"
cover
@load="imageLoadHandler"
@error="imageErrorHandler"
>
<template #placeholder>
<div class="playing-card__placeholder">
<VSkeletonLoader class="playing-card__skeleton" />
</div>
</template>
</VImg>
<div class="playing-card__scrim" />
<div class="playing-card__bottom-scrim" />
<div class="playing-card__percent">
{{ progressLabel }}
</div>
<div class="playing-card__play" aria-hidden="true">
<VIcon icon="mdi-play" size="26" />
</div>
<div class="playing-card__content">
<div class="playing-card__title">
{{ props.media?.title }}
</div>
<div class="playing-card__subtitle">
{{ subtitleText }}
</div>
<div class="playing-card__meta">
<span>{{ progressHint }}</span>
<span v-if="props.media?.server_type" class="playing-card__server">
{{ props.media.server_type }}
</span>
</div>
</div>
<div class="playing-card__progress" aria-hidden="true">
<div class="playing-card__progress-track">
<div class="playing-card__progress-bar" :style="{ inlineSize: `${progressValue}%` }" />
</div>
</div>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.playing-card-hover-area {
block-size: 100%;
inline-size: 100%;
}
.playing-card {
position: relative;
overflow: hidden;
border: 1px solid rgba(var(--v-border-color), 0.16);
background: rgb(var(--v-theme-surface));
color: #fff;
cursor: pointer;
isolation: isolate;
}
.playing-card__image {
block-size: 100%;
inline-size: 100%;
}
.playing-card__placeholder,
.playing-card__skeleton {
block-size: 100%;
inline-size: 100%;
}
.playing-card__image :deep(.v-img__img) {
filter: saturate(0.96) brightness(0.92);
opacity: 0;
transform: scale(1.01);
transition:
opacity 0.2s ease,
transform 0.35s ease;
}
.playing-card__image--loaded :deep(.v-img__img) {
opacity: 1;
}
.playing-card--hovering .playing-card__image :deep(.v-img__img) {
transform: scale(1.05);
}
.playing-card__scrim,
.playing-card__bottom-scrim {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
}
.playing-card__scrim {
background:
linear-gradient(90deg, rgba(0, 0, 0, 24%) 0%, rgba(0, 0, 0, 4%) 48%, rgba(0, 0, 0, 28%) 100%),
linear-gradient(180deg, rgba(0, 0, 0, 8%) 0%, rgba(0, 0, 0, 0%) 42%);
}
.playing-card__bottom-scrim {
background:
linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
}
.playing-card__percent,
.playing-card__play,
.playing-card__content,
.playing-card__progress {
position: absolute;
z-index: 2;
}
.playing-card__percent {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 18%);
border-radius: 999px;
backdrop-filter: blur(12px);
background: rgba(255, 255, 255, 16%);
block-size: 26px;
font-size: 0.75rem;
font-weight: 800;
inline-size: 52px;
inset-block-start: 12px;
inset-inline-end: 12px;
}
.playing-card__play {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255, 255, 255, 92%);
block-size: 40px;
box-shadow: 0 8px 18px rgba(0, 0, 0, 24%);
color: rgb(11, 15, 20);
inline-size: 40px;
inset-block-end: 46px;
inset-inline-end: 18px;
transition:
background-color 0.2s ease,
transform 0.2s ease;
}
.playing-card--hovering .playing-card__play {
background: #fff;
transform: scale(1.06);
}
.playing-card__content {
inset-block-end: 18px;
inset-inline: 16px 66px;
min-inline-size: 0;
text-shadow: 0 2px 8px rgba(0, 0, 0, 45%);
}
.playing-card__title {
overflow: hidden;
font-size: 1.08rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.playing-card__subtitle {
overflow: hidden;
margin-block-start: 4px;
color: rgba(255, 255, 255, 86%);
font-size: 0.78rem;
font-weight: 600;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.playing-card__meta {
display: flex;
overflow: hidden;
align-items: center;
justify-content: space-between;
margin-block-start: 7px;
color: rgba(255, 255, 255, 70%);
font-size: 0.72rem;
gap: 10px;
line-height: 1.2;
white-space: nowrap;
}
.playing-card__server {
overflow: hidden;
max-inline-size: 6rem;
text-overflow: ellipsis;
text-transform: uppercase;
}
.playing-card__progress {
inset-block-end: 6px;
inset-inline: 16px;
}
.playing-card__progress-track {
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 24%);
block-size: 5px;
}
.playing-card__progress-bar {
border-radius: inherit;
background: linear-gradient(90deg, rgb(var(--v-theme-success)) 0%, rgba(255, 255, 255, 92%) 100%);
block-size: 100%;
min-inline-size: 8px;
transition: inline-size 0.25s ease;
}
@media (max-width: 600px) {
.playing-card__content {
inset-inline-end: 58px;
}
.playing-card__title {
font-size: 1rem;
}
.playing-card__play {
block-size: 36px;
inline-size: 36px;
inset-inline-end: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.playing-card__image :deep(.v-img__img),
.playing-card__play,
.playing-card__progress-bar {
transition: none;
}
.playing-card--hovering .playing-card__image :deep(.v-img__img),
.playing-card--hovering .playing-card__play {
transform: none;
}
}
</style>

View File

@@ -1,16 +1,20 @@
<script lang="ts" setup>
import api from '@/api'
import type { Plugin } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { getDominantColor } from '@/@core/utils/image'
import { isNullOrEmptyObject } from '@/@core/utils'
import { formatDownloadCount } from '@/@core/utils/formatters'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useConfirm } from '@/composables/useConfirm'
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
const PluginVersionHistoryDialog = defineAsyncComponent(
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
)
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
// 输入参数
const props = defineProps({
@@ -26,6 +30,11 @@ const emit = defineEmits(['install'])
// 多语言
const { t } = useI18n()
// 提示框
const $toast = useToast()
const createConfirm = useConfirm()
// 背景颜色
const backgroundColor = ref('#28A9E1')
@@ -48,6 +57,21 @@ const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 打开插件安装进度弹窗。 */
function showInstallProgress(text: string) {
progressDialogController?.close()
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
}
/** 关闭插件安装进度弹窗。 */
function closeInstallProgress() {
progressDialogController?.close()
progressDialogController = null
}
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
@@ -96,14 +120,69 @@ function visitPluginPage() {
// 显示更新日志
function showUpdateHistory() {
openSharedDialog(
versionHistoryDialogController?.close()
versionHistoryDialogController = openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin },
{},
{ plugin: props.plugin, actionMode: 'install' },
{
update: installPlugin,
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
/** 从插件市场版本历史安装指定 Release最新版本走普通安装路径以保留主程序兼容校验。 */
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
if (releaseVersion) {
const isConfirmed = await createConfirm({
title: t('common.confirm'),
content: t('plugin.confirmInstallOldRelease', {
name: props.plugin?.plugin_name,
version: releaseVersion,
}),
confirmText: t('common.confirm'),
})
if (!isConfirmed) return
}
try {
showInstallProgress(
t('plugin.installing', {
name: props.plugin?.plugin_name,
version: releaseVersion || props.plugin?.plugin_version,
}),
)
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
repo_url: repoUrl || props.plugin?.repo_url,
release_version: releaseVersion,
force: props.plugin?.has_update || Boolean(releaseVersion),
},
})
closeInstallProgress()
if (result.success) {
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close()
versionHistoryDialogController = null
emit('install')
} else {
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
closeInstallProgress()
console.error(error)
}
}
/** 打开共享插件市场详情弹窗。 */
function showPluginDetail() {
openSharedDialog(
@@ -140,22 +219,28 @@ const dropdownItems = ref([
},
},
])
onUnmounted(() => {
closeInstallProgress()
versionHistoryDialogController?.close()
})
</script>
<template>
<div>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:width="props.width"
:height="props.height"
@click="showPluginDetail"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="plugin-app-card-hover-area h-full">
<VCard
:width="props.width"
:height="props.height"
@click="showPluginDetail"
class="app-hover-lift-card flex flex-col h-full"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
}"
>
<div
class="flex-grow"
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
@@ -241,13 +326,18 @@ const dropdownItems = ref([
</IconBtn>
</div>
</VCardText>
</VCard>
</VCard>
</div>
</template>
</VHover>
</div>
</template>
<style scoped>
.plugin-app-card-hover-area {
inline-size: 100%;
}
.plugin-app-card__tags-section {
display: flex;
overflow: hidden;

View File

@@ -69,6 +69,7 @@ const imageLoadError = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
function showPluginProgress(text: string) {
@@ -103,11 +104,12 @@ async function imageLoaded() {
// 显示更新日志
function showUpdateHistory(showUpdateAction: boolean = false) {
openSharedDialog(
versionHistoryDialogController?.close()
versionHistoryDialogController = openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin, showUpdateAction },
{ update: updatePlugin },
{ closeOn: ['close', 'update', 'update:modelValue'] },
{ closeOn: ['close', 'update:modelValue'] },
)
}
@@ -219,19 +221,37 @@ async function resetPlugin() {
}
// 更新插件
async function updatePlugin() {
if (props.plugin?.system_version_compatible === false) {
async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
if (releaseVersion) {
const isConfirmed = await createConfirm({
title: t('common.confirm'),
content: t('plugin.confirmInstallOldRelease', {
name: props.plugin?.plugin_name,
version: releaseVersion,
}),
confirmText: t('common.confirm'),
})
if (!isConfirmed) return
}
try {
// 显示等待提示框
showPluginProgress(t('plugin.updating', { name: props.plugin?.plugin_name }))
showPluginProgress(
releaseVersion
? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion })
: t('plugin.updating', { name: props.plugin?.plugin_name }),
)
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
repo_url: props.plugin?.repo_url,
repo_url: repoUrl || props.plugin?.repo_url,
release_version: releaseVersion,
force: true,
},
})
@@ -241,6 +261,8 @@ async function updatePlugin() {
if (result.success) {
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close()
versionHistoryDialogController = null
// 通知父组件刷新
emit('save')
@@ -545,19 +567,19 @@ watch(
<!-- 插件卡片 -->
<VHover>
<template #default="hover">
<VCard
v-if="isVisible"
v-bind="hover.props"
:width="props.width"
:height="props.height"
@click="handleCardClick"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
'cursor-move': props.sortable,
}"
:ripple="!props.sortable"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-if="isVisible" v-bind="hover.props" class="plugin-card-hover-area h-full">
<VCard
:width="props.width"
:height="props.height"
@click="handleCardClick"
class="app-hover-lift-card flex flex-col h-full"
:class="{
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
'cursor-move': props.sortable,
}"
:ripple="!props.sortable"
>
<div
class="flex-grow"
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
@@ -647,7 +669,8 @@ watch(
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
<VIcon icon="mdi-new-box" class="text-white" />
</div>
</VCard>
</VCard>
</div>
</template>
</VHover>
@@ -655,6 +678,10 @@ watch(
</template>
<style lang="scss" scoped>
.plugin-card-hover-area {
inline-size: 100%;
}
.card-cover-blurred::before {
position: absolute;
/* stylelint-disable-next-line property-no-vendor-prefix */

View File

@@ -211,20 +211,21 @@ const dropdownItems = ref([
<!-- 文件夹卡片 -->
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:ripple="false"
:width="props.width"
:height="props.height"
min-height="8.5rem"
@click="handleCardClick"
class="plugin-folder-card h-full"
:class="{
'plugin-folder-card--mobile': display.mobile,
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
'plugin-folder-card--sortable': props.sortable,
}"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="plugin-folder-card-hover-area h-full">
<VCard
:ripple="false"
:width="props.width"
:height="props.height"
min-height="8.5rem"
@click="handleCardClick"
class="plugin-folder-card app-hover-lift-card h-full"
:class="{
'plugin-folder-card--mobile': display.mobile,
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
'plugin-folder-card--sortable': props.sortable,
}"
>
<template v-if="backgroundImage" #image>
<VImg :src="backgroundImage" cover position="top"> </VImg>
</template>
@@ -288,25 +289,29 @@ const dropdownItems = ref([
</VMenu>
</div>
</div>
</VCard>
</VCard>
</div>
</template>
</VHover>
</div>
</template>
<style lang="scss" scoped>
.plugin-folder-card-hover-area {
inline-size: 100%;
}
.plugin-folder-card {
position: relative;
overflow: hidden;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&--sortable {
cursor: move;
}
&--hover {
transform: translateY(-4px);
transform: translate3d(0, -0.25rem, 0);
}
&__bg {

View File

@@ -17,6 +17,12 @@ const isImageLoaded = ref(false)
// 图片加载失败
const imageLoadError = ref(false)
const cardStyle = computed(() => ({
aspectRatio: props.height ? undefined : '2 / 3',
blockSize: props.height,
inlineSize: props.width || '100%',
}))
// 角标颜色
function getChipColor(type: string) {
if (type === '电影') return 'border-blue-500 bg-blue-600'
@@ -47,20 +53,21 @@ async function goPlay(isHovering: boolean | null = false) {
<template>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none ring-gray-500"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'ring-1': isImageLoaded,
}"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="poster-card-hover-area">
<VCard
:style="cardStyle"
class="app-hover-lift-card outline-none ring-gray-500"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
'ring-1': isImageLoaded,
}"
>
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3"
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
:class="{ 'poster-card-image--loaded': isImageLoaded }"
cover
@load="isImageLoaded = true"
@error="imageLoadError = true"
@@ -77,7 +84,7 @@ async function goPlay(isHovering: boolean | null = false) {
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 text-white font-bold"
class="poster-card-chip absolute left-2 top-2 bg-opacity-80 text-white font-bold"
>
{{ props.media?.type }}
</VChip>
@@ -93,7 +100,41 @@ async function goPlay(isHovering: boolean | null = false) {
{{ props.media?.title }}
</h1>
</VCardText>
</VCard>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.poster-card-hover-area {
block-size: 100%;
inline-size: 100%;
}
.poster-card-image {
block-size: 100%;
inline-size: 100%;
}
.poster-card-image :deep(.v-img__img) {
opacity: 0;
transition: opacity 0.2s ease;
}
.poster-card-image--loaded :deep(.v-img__img) {
opacity: 1;
}
.poster-card-image :deep(.v-responsive__sizer) {
padding-bottom: 150%;
}
@media (prefers-reduced-motion: reduce) {
.poster-card-image :deep(.v-img__img) {
transition: none;
}
}
</style>

View File

@@ -239,25 +239,27 @@ onMounted(() => {
<template>
<div>
<VCard
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
:class="[
cardProps.site?.is_active ? '' : 'opacity-70',
{
'border-error': statColor === 'error',
'border-warning': statColor === 'warning',
'border-success': statColor === 'success',
'cursor-pointer hover:-translate-y-1': !cardProps.sortable,
'cursor-move': cardProps.sortable,
'site-card--sortable': cardProps.sortable,
},
]"
:ripple="false"
variant="flat"
elevation="0"
:hover="!cardProps.sortable"
@click="handleCardClick"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div class="site-card-hover-area h-full">
<VCard
class="site-card app-hover-lift-card relative h-full flex flex-col overflow-hidden group"
:class="[
cardProps.site?.is_active ? '' : 'opacity-70',
{
'border-error': statColor === 'error',
'border-warning': statColor === 'warning',
'border-success': statColor === 'success',
'cursor-pointer site-card--hoverable': !cardProps.sortable,
'cursor-move': cardProps.sortable,
'site-card--sortable': cardProps.sortable,
},
]"
:ripple="false"
variant="flat"
elevation="0"
:hover="!cardProps.sortable"
@click="handleCardClick"
>
<!-- 装饰性状态指示器 -->
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
@@ -419,11 +421,20 @@ onMounted(() => {
</VMenu>
</VBtn>
</VSheet>
</VCard>
</VCard>
</div>
</div>
</template>
<style scoped>
.site-card-hover-area {
inline-size: 100%;
}
.site-card-hover-area:hover .site-card--hoverable {
transform: translate3d(0, -0.25rem, 0);
}
.site-status-indicator {
position: absolute;
z-index: 1;
@@ -455,7 +466,7 @@ onMounted(() => {
}
/* 站点卡片悬停时状态指示器变化 */
.site-card:not(.site-card--sortable):hover .site-status-indicator {
.site-card-hover-area:hover .site-card:not(.site-card--sortable) .site-status-indicator {
block-size: 2px;
opacity: 0.8;
}
@@ -644,7 +655,7 @@ onMounted(() => {
visibility: hidden;
}
.site-card:hover .site-card-actions {
.site-card-hover-area:hover .site-card-actions {
opacity: 1;
transform: translateX(0);
visibility: visible;

View File

@@ -92,6 +92,17 @@ const rightBottomStateDisplay = computed(() => {
return null
})
// 移动端紧凑卡片的状态展示,颜色统一映射到 Vuetify 全局主题 token。
const compactStateDisplay = computed(() => {
if (subscribeState.value === 'S') {
return { color: 'secondary', icon: 'mdi-pause-circle-outline', label: t('subscribe.cardStatePaused') }
}
if (subscribeState.value === 'P') {
return { color: 'info', icon: 'mdi-timer-sand', label: t('subscribe.cardStatePending') }
}
return { color: 'primary', icon: 'mdi-rss', label: t('subscribe.subscribing') }
})
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
const bestVersionBadge = computed(() => {
if (!isEnabledFlag(props.media?.best_version)) return null
@@ -162,6 +173,7 @@ async function removeSubscribe() {
emit('remove')
}
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -173,7 +185,9 @@ async function searchSubscribe() {
// 提示
if (result.success) $toast.success(`${props.media?.name} 提交搜索请求成功!`)
else $toast.error(t('subscribe.requestFailed'))
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -200,6 +214,7 @@ async function toggleSubscribeStatus(state: 'R' | 'S') {
$toast.error(t('subscribe.toggleFailed', { action, message: result.message }))
}
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -222,6 +237,7 @@ async function resetSubscribe() {
emit('save')
} else $toast.error(t('subscribe.resetFailed', { name: props.media?.name, message: result.message }))
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -290,22 +306,6 @@ const dropdownItems = computed(() => [
click: searchSubscribe,
},
},
{
title: t('common.details'),
value: 3,
props: {
prependIcon: 'mdi-information-outline',
click: viewMediaDetail,
},
},
{
title: t('common.files'),
value: 4,
props: {
prependIcon: 'mdi-file-document-outline',
click: viewSubscribeFiles,
},
},
{
title: subscribeState.value === 'S' ? t('common.enable') : t('common.pause'),
value: 5,
@@ -334,6 +334,22 @@ const dropdownItems = computed(() => [
},
show: props.media?.type === '电视剧',
},
{
title: t('subscribe.mediaDetail'),
value: 3,
props: {
prependIcon: 'mdi-information-outline',
click: viewMediaDetail,
},
},
{
title: t('subscribe.fileStatistics'),
value: 4,
props: {
prependIcon: 'mdi-file-document-outline',
click: viewSubscribeFiles,
},
},
{
title: t('common.unsubscribe'),
value: 8,
@@ -404,63 +420,162 @@ function handleCardClick() {
<div>
<VHover>
<template #default="hover">
<div
class="subscribe-card-shell w-full h-full relative"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
'subscribe-card-pending-tint': subscribeState === 'P',
}"
>
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full overflow-hidden"
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="subscribe-card-hover-area w-full h-full">
<div
class="subscribe-card-shell app-hover-lift-card w-full h-full relative"
:class="{
'subscribe-card-paused': subscribeState === 'S',
'cursor-move': props.sortable,
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
'subscribe-card-shell--selected': props.batchMode && props.selected,
}"
min-height="150"
@click="handleCardClick"
:ripple="!props.batchMode && !props.sortable"
>
<div
v-if="bestVersionBadge && imageLoaded"
class="best-version-badge"
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
<VCard
:key="props.media?.id"
class="subscribe-card flex flex-col h-full overflow-hidden"
:class="{
'subscribe-card-paused': subscribeState === 'S',
'subscribe-card-pending-tint': subscribeState === 'P',
'cursor-move': props.sortable,
}"
min-height="150"
@click="handleCardClick"
:ripple="display.smAndUp.value && !props.batchMode && !props.sortable"
>
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
</div>
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
<IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
<div
v-if="bestVersionBadge && imageLoaded"
class="best-version-badge"
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
>
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
</div>
<div v-if="!props.sortable && display.smAndUp.value" class="me-n3 absolute top-1 right-4">
<IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<template #image v-if="display.smAndUp.value">
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<template #default>
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</template>
</VImg>
</template>
<template v-if="display.xs.value">
<div class="subscribe-card-mobile-media">
<VImg
:src="backdropUrl || posterUrl"
:aspect-ratio="2"
cover
position="top"
@load="imageLoadHandler"
>
<template #placeholder>
<VSkeletonLoader class="h-full w-full" />
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</VImg>
<div
v-if="props.media?.username || lastUpdateText"
class="subscribe-card-mobile-image-meta"
:class="{ 'subscribe-card-mobile-image-meta--with-badge': bestVersionBadge }"
>
<div
v-if="props.media?.username"
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__user"
:title="props.media?.username"
>
<VIcon icon="mdi-account" size="14" />
<span>{{ props.media?.username }}</span>
</div>
<div
v-if="lastUpdateText"
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__updated"
>
<VIcon icon="mdi-download" size="14" />
<span>{{ lastUpdateText }}</span>
</div>
</div>
</template>
<template #default>
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</template>
</VImg>
</template>
<div>
</div>
<div class="subscribe-card-mobile-body">
<div class="subscribe-card-mobile-title">
{{ props.media?.name }}
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
</div>
<div class="subscribe-card-mobile-footer">
<div class="subscribe-card-mobile-meta">
<div
class="subscribe-card-mobile-state"
:style="{ color: `rgb(var(--v-theme-${compactStateDisplay.color}))` }"
:title="compactStateDisplay.label"
:aria-label="compactStateDisplay.label"
>
<VIcon :icon="compactStateDisplay.icon" size="18" />
<span v-if="subscribeProgressText" class="subscribe-card-mobile-progress-text">
{{ subscribeProgressText }}
</span>
</div>
<IconBtn
v-if="!props.sortable"
class="subscribe-card-mobile-menu"
size="small"
@click.stop
>
<VIcon icon="mdi-dots-horizontal" size="20" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem
v-if="item.show !== false"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<div v-if="props.media?.total_episode" class="subscribe-card-mobile-progress">
<VProgressLinear
:model-value="getPercentage()"
:bg-color="compactStateDisplay.color"
:color="compactStateDisplay.color"
bg-opacity="0.18"
height="4"
rounded
/>
</div>
</div>
</div>
</template>
<div v-else>
<VCardText class="flex items-center pt-3 pb-2">
<div
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
@@ -567,14 +682,185 @@ function handleCardClick() {
color="success"
/>
</div>
</div>
</VCard>
</div>
</VCard>
</div>
</div>
</template>
</VHover>
</div>
</template>
<style lang="scss" scoped>
.subscribe-card-hover-area {
inline-size: 100%;
}
/**
* 订阅卡片外壳:选中态虚线框复用同一圆角,避免 outline 在圆角卡片外形成直角。
*/
.subscribe-card-shell {
border-radius: var(--app-surface-radius);
}
.subscribe-card {
border: var(--app-card-light-border);
}
.subscribe-card-mobile-media {
position: relative;
overflow: hidden;
aspect-ratio: 2 / 1;
flex-shrink: 0;
inline-size: 100%;
}
.subscribe-card-mobile-media .v-img {
block-size: 100%;
}
.subscribe-card-mobile-image-meta {
position: absolute;
z-index: 2;
font-size: 0.6875rem;
font-weight: 500;
inset: 0;
pointer-events: none;
}
.subscribe-card-mobile-image-meta__item {
position: absolute;
display: flex;
min-inline-size: 0;
align-items: center;
gap: 0.25rem;
color: rgba(255, 255, 255, 0.88);
isolation: isolate;
line-height: 1.2;
padding-block: 0.0625rem;
padding-inline: 0.125rem;
}
.subscribe-card-mobile-image-meta__item::before {
position: absolute;
z-index: -1;
border-radius: 0.4rem;
background: rgba(0, 0, 0, 0.36);
content: '';
filter: blur(3px);
inset: -0.25rem -0.55rem;
}
.subscribe-card-mobile-image-meta__user {
max-inline-size: calc(100% - 1rem);
inset-block-start: 0.5rem;
inset-inline-start: 0.5rem;
transition: inset-block-start 0.2s ease;
}
.subscribe-card-mobile-image-meta--with-badge .subscribe-card-mobile-image-meta__user {
max-inline-size: calc(100% - 4.25rem);
inset-block-start: 0.5rem;
}
.subscribe-card-mobile-image-meta__user span {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subscribe-card-mobile-image-meta__updated {
flex-shrink: 0;
color: rgba(255, 255, 255, 0.76);
inset-block-end: 0.5rem;
inset-inline-end: 0.5rem;
}
.subscribe-card-mobile-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.subscribe-card-mobile-title {
display: -webkit-box;
overflow: hidden;
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.subscribe-card-mobile-footer {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-block-start: auto;
}
.subscribe-card-mobile-meta {
display: flex;
min-inline-size: 0;
min-block-size: 2rem;
align-items: center;
gap: 0.25rem;
justify-content: space-between;
}
.subscribe-card-mobile-state {
display: flex;
min-inline-size: 0;
align-items: center;
flex: 1 1 auto;
gap: 0.35rem;
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.25;
white-space: nowrap;
}
.subscribe-card-mobile-state span {
overflow: hidden;
text-overflow: ellipsis;
}
.subscribe-card-mobile-progress-text {
flex-shrink: 0;
}
.subscribe-card-mobile-menu {
block-size: 2rem;
min-block-size: 2rem;
inline-size: 2rem;
min-inline-size: 2rem;
flex: 0 0 2rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.subscribe-card-mobile-progress {
display: flex;
block-size: 4px;
inline-size: 100%;
}
.subscribe-card-mobile-progress .v-progress-linear {
flex: 1 1 auto;
}
.subscribe-card-shell--selected::after {
position: absolute;
z-index: 5;
border: 2px solid rgb(var(--v-theme-primary));
border-radius: inherit;
content: '';
inset: 0;
pointer-events: none;
}
.subscribe-card-background {
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
@@ -588,7 +874,7 @@ function handleCardClick() {
}
/**
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
* 待定:内发光挂在实际 VCard 上,跟随卡片圆角并被 overflow-hidden 裁剪。
*/
.subscribe-card-pending-tint {
position: relative;
@@ -598,7 +884,7 @@ function handleCardClick() {
position: absolute;
z-index: 3;
border-radius: inherit;
box-shadow: inset 0 0 48px rgba(56, 189, 248, 40%); // sky-400
box-shadow: inset 0 0 48px rgba(var(--v-theme-info), 0.28);
content: '';
inset: 0;
pointer-events: none;
@@ -630,4 +916,30 @@ function handleCardClick() {
background: rgba(255, 255, 255, 22%);
box-shadow: 0 2px 8px rgba(255, 255, 255, 15%);
}
@media (width <= 599px) {
.subscribe-card {
min-block-size: 0 !important;
}
.subscribe-card-paused {
opacity: 1;
}
.subscribe-card-paused .subscribe-card-mobile-media {
filter: saturate(0.65);
opacity: 0.58;
}
.best-version-badge {
inset-inline-start: auto;
inset-inline-end: 0.5rem;
}
.subscribe-card-pending-tint::after {
box-shadow:
inset 0 0 0 1px rgba(var(--v-theme-info), 0.28),
inset 0 -4rem 5rem rgba(var(--v-theme-info), 0.08);
}
}
</style>

View File

@@ -93,16 +93,17 @@ function doDelete() {
<div class="h-full">
<VHover>
<template #default="hover">
<div
class="w-full h-full overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="subscribe-share-card-hover-area w-full h-full">
<div
class="app-hover-lift-card w-full h-full overflow-hidden"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
}"
>
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
class="app-hover-lift-card flex flex-col h-full"
min-height="150"
@click="showForkSubscribe"
>
@@ -155,13 +156,18 @@ function doDelete() {
{{ dateText }}
</VCardText>
</div>
</VCard>
</VCard>
</div>
</div>
</template>
</VHover>
</div>
</template>
<style lang="scss" scoped>
.subscribe-share-card-hover-area {
inline-size: 100%;
}
.subscribe-card-background {
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}

View File

@@ -100,12 +100,13 @@ watch(
</script>
<template>
<div class="h-full">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div class="subtitle-card-hover-area h-full">
<VCard
:width="props.width || '100%'"
:variant="isDownloaded ? 'outlined' : 'flat'"
@click="handleAddDownload"
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden subtitle-card"
class="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden subtitle-card"
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
hover
>
@@ -203,11 +204,16 @@ watch(
</template>
<style scoped>
.subtitle-card {
border: 1px solid transparent;
.subtitle-card-hover-area {
inline-size: 100%;
}
.subtitle-card:hover {
.subtitle-card {
border: var(--app-card-light-border);
}
.subtitle-card-hover-area:hover .subtitle-card {
border-color: rgba(var(--v-theme-primary), 0.3);
transform: translate3d(0, -0.25rem, 0);
}
</style>

View File

@@ -99,10 +99,11 @@ watch(
</script>
<template>
<div class="w-100">
<!-- Hover 命中区域保持静止避免列表项上浮后底边反复触发 mouseleave -->
<div class="subtitle-item-hover-area w-100">
<VListItem
:value="subtitle?.enclosure"
class="pa-3 mb-2 rounded subtitle-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
class="app-hover-lift-card pa-3 mb-2 rounded subtitle-item overflow-hidden"
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
@click="handleAddDownload"
>
@@ -206,11 +207,16 @@ watch(
</template>
<style scoped>
.subtitle-item {
border: 1px solid transparent;
.subtitle-item-hover-area {
inline-size: 100%;
}
.subtitle-item:hover {
.subtitle-item {
border: var(--app-card-light-border);
}
.subtitle-item-hover-area:hover .subtitle-item {
border-color: rgba(var(--v-theme-primary), 0.3);
transform: translate3d(0, -0.25rem, 0);
}
</style>

View File

@@ -146,12 +146,13 @@ watch(
</script>
<template>
<div class="h-full">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div class="torrent-card-hover-area h-full">
<VCard
:width="props.width || '100%'"
:variant="isDownloaded ? 'outlined' : 'flat'"
@click="handleAddDownload(props.torrent)"
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
class="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden torrent-card"
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
hover
>
@@ -316,13 +317,18 @@ watch(
inset-inline-end: 0;
}
/* 卡片悬停效果 */
.torrent-card {
border: 1px solid transparent;
.torrent-card-hover-area {
inline-size: 100%;
}
.torrent-card:hover {
/* 卡片悬停效果 */
.torrent-card {
border: var(--app-card-light-border);
}
.torrent-card-hover-area:hover .torrent-card {
border-color: rgba(var(--v-theme-primary), 0.3);
transform: translate3d(0, -0.25rem, 0);
}
/* 优惠标签样式 */

View File

@@ -115,10 +115,11 @@ watch(
</script>
<template>
<div class="w-100">
<!-- Hover 命中区域保持静止避免列表项上浮后底边反复触发 mouseleave -->
<div class="torrent-item-hover-area w-100">
<VListItem
:value="props.torrent?.torrent_info?.enclosure"
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
class="app-hover-lift-card pa-3 mb-2 rounded torrent-item overflow-hidden"
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
@click="handleAddDownload"
>
@@ -262,12 +263,17 @@ watch(
inset-inline-end: 0;
}
.torrent-item {
border: 1px solid transparent;
.torrent-item-hover-area {
inline-size: 100%;
}
.torrent-item:hover {
.torrent-item {
border: var(--app-card-light-border);
}
.torrent-item-hover-area:hover .torrent-item {
border-color: rgba(var(--v-theme-primary), 0.3);
transform: translate3d(0, -0.25rem, 0);
}
.chip-season {

View File

@@ -127,14 +127,16 @@ onMounted(() => {
})
</script>
<template>
<VCard
:class="[
'transition-transform duration-300 hover:-translate-y-1',
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
]"
class="user-card flex flex-column h-full"
@click="editUser"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div class="user-card-hover-area h-full">
<VCard
:class="[
'app-hover-lift-card',
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
]"
class="user-card flex flex-column h-full"
@click="editUser"
>
<div class="user-card__body flex-grow flex-grow-1">
<!-- 用户头像和基本信息 -->
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
@@ -302,10 +304,19 @@ onMounted(() => {
</div>
</VCardText>
</div>
</VCard>
</VCard>
</div>
</template>
<style scoped>
.user-card-hover-area {
inline-size: 100%;
}
.user-card-hover-area:hover .user-card {
transform: translate3d(0, -0.25rem, 0);
}
.user-card {
block-size: 100%;
}

View File

@@ -95,17 +95,18 @@ function doDelete() {
<div class="h-full">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.workflow?.id"
class="workflow-share-card flex flex-col h-full cursor-pointer overflow-hidden"
:class="{
'workflow-share-card--hovering': hover.isHovering,
}"
min-height="150"
:style="{ background: gradientStyle }"
@click="showForkWorkflow"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="workflow-share-card-hover-area h-full">
<VCard
:key="props.workflow?.id"
class="workflow-share-card app-hover-lift-card flex flex-col h-full cursor-pointer overflow-hidden"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
}"
min-height="150"
:style="{ background: gradientStyle }"
@click="showForkWorkflow"
>
<div class="h-full flex flex-col">
<VCardText class="flex items-center pa-3 pb-1 grow">
<div class="flex flex-col justify-center w-full">
@@ -134,20 +135,16 @@ function doDelete() {
{{ dateText }}
</VCardText>
</div>
</VCard>
</VCard>
</div>
</template>
</VHover>
</div>
</template>
<style lang="scss" scoped>
// 阴影需要落在实际卡片上,不能被额外的 overflow 容器裁掉。
.workflow-share-card {
transition: transform 0.3s ease, box-shadow 0.2s ease;
transform: translateZ(0);
.workflow-share-card-hover-area {
inline-size: 100%;
}
.workflow-share-card--hovering {
transform: translate3d(0, -0.25rem, 0);
}
</style>

View File

@@ -220,14 +220,15 @@ const resolveProgress = (item: Workflow) => {
<template>
<div class="h-full">
<VHover v-slot="hover">
<VCard
v-bind="hover.props"
class="mx-auto h-full"
@click="handleFlow(workflow)"
:ripple="false"
:loading="loading"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="workflow-task-card-hover-area h-full">
<VCard
class="app-hover-lift-card mx-auto h-full"
@click="handleFlow(workflow)"
:ripple="false"
:loading="loading"
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
>
<VCardItem
class="px-2 py-2"
:style="{
@@ -367,7 +368,14 @@ const resolveProgress = (item: Workflow) => {
</div>
</div>
</VCardText>
</VCard>
</VCard>
</div>
</VHover>
</div>
</template>
<style scoped>
.workflow-task-card-hover-area {
inline-size: 100%;
}
</style>

View File

@@ -0,0 +1,438 @@
import { formatDateDifference } from '@/@core/utils/formatters'
import type { Subscribe } from '@/api/types'
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createSubscribe } from '@tests/support/factories/subscribe'
import {
deleteSubscribeByIdHandler,
resetSubscribeByIdHandler,
searchSubscribeByIdHandler,
updateSubscribeStatusHandler,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
openSharedDialog: vi.fn(),
routerPush: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/router', () => ({
default: { push: (...args: unknown[]) => mocks.routerPush(...args) },
}))
function setViewport(width: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
window.dispatchEvent(new Event('resize'))
}
function observeElementsImmediately() {
class ImmediateIntersectionObserver {
readonly root = null
readonly rootMargin = '0px'
readonly thresholds = [0]
constructor(private readonly callback: IntersectionObserverCallback) {}
disconnect() {}
observe(target: Element) {
this.callback([{ intersectionRatio: 1, isIntersecting: true, target } as IntersectionObserverEntry], this)
}
takeRecords(): IntersectionObserverEntry[] {
return []
}
unobserve() {}
}
vi.stubGlobal('IntersectionObserver', ImmediateIntersectionObserver)
}
async function renderCard(
mediaOverrides: Partial<Subscribe> = {},
props: Partial<{ batchMode: boolean; selected: boolean; sortable: boolean }> = {},
globalImageCache = false,
) {
const media = createSubscribe({
backdrop: 'https://images.example.com/backdrop.jpg',
id: 2501,
last_update: '2026-07-16 12:00:00',
name: '卡片测试媒体',
poster: 'https://images.example.com/poster.jpg',
...mediaOverrides,
})
const result = await renderWithProviders(SubscribeCard, {
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
initialized: true,
loading: false,
},
},
props: { media, ...props },
})
return { ...result, media }
}
function getMenuButton(container: Element) {
const selector = window.innerWidth < 600 ? '.subscribe-card-mobile-menu' : '.absolute.top-1.right-4 .v-btn'
const button = container.querySelector<HTMLButtonElement>(selector)
expect(button).not.toBeNull()
return button as HTMLButtonElement
}
async function openMenu(container: Element) {
await fireEvent.click(getMenuButton(container))
}
async function chooseMenuItem(container: Element, label: string) {
await openMenu(container)
await fireEvent.click(await screen.findByText(label))
}
function getDialogCall(index = 0) {
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
unknown,
Record<string, unknown>,
Record<string, () => void>,
Record<string, unknown>,
]
return { events, options, props }
}
describe('SubscribeCard display and progress', () => {
beforeEach(() => {
setViewport(1024)
observeElementsImmediately()
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('renders stable movie metadata and omits episode progress without a total', async () => {
const { container, media } = await renderCard({ total_episode: undefined, type: '电影', year: '2025' }, {}, true)
expect(screen.getByText(media.name)).toBeInTheDocument()
expect(screen.getByText('2025')).toBeInTheDocument()
expect(screen.getByText(media.username)).toHaveAttribute('title', media.username)
const image = container.querySelector<HTMLImageElement>('img')
expect(image).not.toBeNull()
expect((image as HTMLImageElement).src).toContain('system/cache/image?url=')
expect((image as HTMLImageElement).src).toContain(encodeURIComponent(media.backdrop || ''))
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
})
it.each([
['regular progress', 10, 4, '6 / 10', '60'],
['negative missing episodes', 10, -2, '10 / 10', '100'],
['missing episodes above the total', 10, 12, '0 / 10', null],
['zero total', 0, 0, null, null],
])('normalizes %s', async (_case, totalEpisode, lackEpisode, expectedText, expectedProgress) => {
await renderCard({ lack_episode: lackEpisode, season: 2, total_episode: totalEpisode, type: '电视剧' })
if (expectedText) expect(screen.getByText(expectedText)).toBeInTheDocument()
else expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
if (expectedProgress) expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', expectedProgress)
else expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.getByText(/卡片测试媒体 S02/)).toBeInTheDocument()
})
it.each([
['boolean flag with tv type', true, false, 3, 'tv', 30, true, false],
['numeric flags with negative completed episodes', 1, 1, -2, '电视剧', 0, true, true],
['string flags with completed episodes above the total', '1', '1', 12, '电影', 100, true, true],
['disabled flag', false, true, 3, '电视剧', 80, false, false],
])(
'normalizes %s for wash progress and badges',
async (_case, bestVersion, bestVersionFull, completedEpisode, type, expectedProgress, expectedWash, expectedFull) => {
const { container } = await renderCard({
best_version: bestVersion,
best_version_full: bestVersionFull,
completed_episode: completedEpisode,
lack_episode: 2,
total_episode: 10,
type,
})
const image = container.querySelector<HTMLImageElement>('img')
expect(image).not.toBeNull()
await fireEvent.load(image as HTMLImageElement)
const progress = screen.getByRole('progressbar')
expect(progress).toHaveAttribute('aria-valuenow', String(expectedProgress))
expect(progress.querySelector('.v-progress-linear__buffer')).toHaveStyle({ width: expectedWash ? '80%' : '0%' })
expect(Boolean(container.querySelector('.best-version-badge'))).toBe(expectedWash)
expect(Boolean(container.querySelector('.best-version-badge-full'))).toBe(expectedFull)
},
)
it('keeps mobile wash progress compact while preserving P, S, and R metadata', async () => {
setViewport(480)
const { media, rerender } = await renderCard({
best_version: true,
completed_episode: 3,
lack_episode: 2,
state: 'P',
total_episode: 10,
type: '电视剧',
})
const lastUpdateText = formatDateDifference(media.last_update)
expect(screen.getByLabelText('待定中')).toBeInTheDocument()
expect(screen.getByText('8 / 10')).toBeInTheDocument()
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '30')
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
expect(document.querySelector('.subscribe-card-mobile-menu')).toBeInTheDocument()
await rerender({ media: { ...media, state: 'S' } })
expect(screen.getByLabelText('已暂停')).toBeInTheDocument()
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
await rerender({ media: { ...media, state: 'R' } })
expect(screen.getByLabelText('订阅中')).toBeInTheDocument()
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
})
it('synchronizes desktop P, S, and R state from updated media props', async () => {
const { container, media, rerender } = await renderCard({ state: 'P' })
const lastUpdateText = formatDateDifference(media.last_update)
expect(screen.getByText('待定中')).toBeInTheDocument()
expect(screen.queryByText(lastUpdateText)).not.toBeInTheDocument()
await rerender({ media: { ...media, state: 'S' } })
expect(screen.getByText('已暂停')).toBeInTheDocument()
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
await rerender({ media: { ...media, state: 'R' } })
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
expect(screen.queryByText('已暂停')).not.toBeInTheDocument()
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
})
})
describe('SubscribeCard interaction boundaries', () => {
beforeEach(() => {
setViewport(1024)
observeElementsImmediately()
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('routes normal, batch, selected, and sortable card clicks without overlap', async () => {
const { container, emitted, media, rerender } = await renderCard()
const card = container.querySelector('.subscribe-card') as HTMLElement
await fireEvent.click(card)
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
await rerender({ batchMode: true, media, selected: true })
await fireEvent.click(card)
expect(emitted('select')).toHaveLength(1)
expect(container.querySelector('.subscribe-card-shell')).toHaveClass('subscribe-card-shell--selected')
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
expect(container.querySelector('.absolute.top-1.right-4 .v-btn')).toBeInTheDocument()
await rerender({ batchMode: true, media, selected: true, sortable: true })
await fireEvent.click(card)
expect(emitted('select')).toHaveLength(1)
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
expect(container.querySelector('.absolute.top-1.right-4 .v-btn')).not.toBeInTheDocument()
})
it('opens page-selected editing and forwards only save and remove events', async () => {
const { emitted, media } = await renderCard({ page_open: true })
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const dialog = getDialogCall()
expect(dialog.props).toEqual({ subid: media.id })
expect(dialog.options).toEqual({ closeOn: ['close', 'save', 'remove'] })
dialog.events.save()
dialog.events.remove()
expect(emitted('save')).toHaveLength(1)
expect(emitted('remove')).toHaveLength(1)
})
it('passes exact file and TV share data while keeping compatibility TV values unshared', async () => {
const { container, media, rerender } = await renderCard({ season: 1, total_episode: 12, type: '电视剧' })
await chooseMenuItem(container, '分享')
expect(getDialogCall().props).toEqual({ sub: media })
expect(getDialogCall().options).toEqual({ closeOn: ['close'] })
await chooseMenuItem(container, '文件统计')
expect(getDialogCall(1).props).toEqual({ subid: media.id })
expect(getDialogCall(1).options).toEqual({ closeOn: ['close'] })
await rerender({ media: { ...media, type: 'tv' } })
await openMenu(container)
expect(screen.queryByText('分享')).not.toBeInTheDocument()
})
it.each([
['TMDB before all fallbacks', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'],
['Douban before Bangumi', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'],
['Bangumi before custom', { bangumiid: '33', doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'],
['custom media ID last', { bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'custom:44'],
])('routes media details with %s', async (_case, identifiers, expectedMediaId) => {
const { container, media } = await renderCard(identifiers)
await chooseMenuItem(container, '媒体详情')
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media',
query: {
mediaid: expectedMediaId,
title: media.name,
type: media.type,
year: media.year,
},
})
})
})
describe('SubscribeCard item operations', () => {
beforeEach(() => {
setViewport(1024)
observeElementsImmediately()
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it.each([
['success', 200, { success: true }, 'success', '卡片测试媒体 提交搜索请求成功!'],
['business failure', 200, { message: 'rejected', success: false }, 'error', '请求失败,请稍后重试'],
['HTTP failure', 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
] as const)('reports search %s through the exact endpoint', async (_case, status, response, toastType, message) => {
const requested = vi.fn()
const { container, media } = await renderCard()
server.use(searchSubscribeByIdHandler(media.id, response, status, requested))
await chooseMenuItem(container, '搜索')
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
})
it('pauses and enables only after confirmed successful status responses', async () => {
const requested: URL[] = []
const { container, emitted, media } = await renderCard({ state: 'R' })
server.use(
updateSubscribeStatusHandler(media.id, { success: true }, 200, url => {
requested.push(url)
}),
)
await chooseMenuItem(container, '暂停')
await waitFor(() => expect(requested).toHaveLength(1))
expect(requested[0].searchParams.get('state')).toBe('S')
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${media.name} 已暂停!`)
await chooseMenuItem(container, '启用')
await waitFor(() => expect(requested).toHaveLength(2))
expect(requested[1].searchParams.get('state')).toBe('R')
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${media.name} 已启用!`)
expect(emitted('save')).toHaveLength(2)
})
it.each([
['confirmation cancellation', false, 200, { success: true }, null],
['business failure', true, 200, { message: 'rejected', success: false }, '暂停失败rejected'],
['HTTP failure', true, 500, { message: 'server down', success: false }, '请求失败,请稍后重试'],
] as const)(
'keeps status unchanged after %s',
async (_case, confirmed, status, response, expectedError) => {
const requested = vi.fn()
mocks.confirm.mockResolvedValue(confirmed)
const { container, emitted, media } = await renderCard({ state: 'R' })
server.use(updateSubscribeStatusHandler(media.id, response, status, requested))
await chooseMenuItem(container, '暂停')
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
else expect(requested).not.toHaveBeenCalled()
if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError))
else expect(mocks.toastError).not.toHaveBeenCalled()
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
expect(emitted('save') ?? []).toHaveLength(0)
},
)
it.each([
['success', true, 200, { success: true }, 'success', '卡片测试媒体 重置成功!'],
['confirmation cancellation', false, 200, { success: true }, null, null],
['business failure', true, 200, { message: 'rejected', success: false }, 'error', '卡片测试媒体 重置失败rejected'],
['HTTP failure', true, 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
] as const)(
'handles reset %s without speculative state',
async (_case, confirmed, status, response, toastType, message) => {
const requested = vi.fn()
mocks.confirm.mockResolvedValue(confirmed)
const { container, emitted, media } = await renderCard({ state: 'S' })
server.use(resetSubscribeByIdHandler(media.id, response, status, requested))
await chooseMenuItem(container, '重置')
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
else expect(requested).not.toHaveBeenCalled()
if (toastType && message) {
const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
} else {
expect(mocks.toastSuccess).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
}
if (_case === 'success') {
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
expect(emitted('save')).toHaveLength(1)
} else {
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
expect(emitted('save') ?? []).toHaveLength(0)
}
},
)
it.each([
['success', 200, { success: true }, true, null],
['HTTP failure', 500, { message: 'server down', success: false }, false, '请求失败,请稍后重试'],
] as const)('handles delete %s without a synthetic business-failure branch', async (_case, status, response, removed, error) => {
const requested = vi.fn()
const { container, emitted, media } = await renderCard()
server.use(deleteSubscribeByIdHandler(media.id, response, status, requested))
await chooseMenuItem(container, '取消订阅')
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error))
else expect(mocks.toastError).not.toHaveBeenCalled()
})
})

View File

@@ -68,6 +68,14 @@ const buttonText = computed(() =>
loading.value ? t('dialog.addDownload.downloading') : t('dialog.addDownload.startDownload'),
)
// 下载确认副标题,未传媒体标题时回退到种子标题。
const dialogSubtitle = computed(() => {
const siteName = props.torrent?.site_name?.trim()
const displayTitle = props.title?.trim() || props.torrent?.title?.trim()
return [siteName, displayTitle].filter(Boolean).join(' - ')
})
// 加载目录设置
async function loadDirectories() {
try {
@@ -78,6 +86,7 @@ async function loadDirectories() {
}
}
// 将下载目录配置转换为下载器可识别的存储路径。
function convertToUri(item: TransferDirectoryConf) {
if (!item.download_path) {
return undefined
@@ -181,7 +190,7 @@ onMounted(() => {
<VIcon icon="mdi-monitor-arrow-down-variant" class="me-2" />
</template>
<VCardTitle>{{ t('dialog.addDownload.confirmDownload') }}</VCardTitle>
<VCardSubtitle>{{ torrent?.site_name }} - {{ title }}</VCardSubtitle>
<VCardSubtitle>{{ dialogSubtitle }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />

View File

@@ -0,0 +1,449 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
type AgentMcpTransport = 'stdio' | 'sse' | 'http' | 'streamable_http'
interface AgentMcpServer {
id: string
name: string
enabled: boolean
transport: AgentMcpTransport
description?: string | null
command?: string | null
args: string[]
env: Record<string, string>
url?: string | null
headers: Record<string, string>
timeout: number
tool_prefix?: string | null
require_admin: boolean
}
interface EditableAgentMcpServer extends AgentMcpServer {
argsText: string
envText: string
headersText: string
}
interface AgentMcpToolInfo {
name: string
agent_tool_name: string
description?: string
}
interface AgentMcpTestState {
loading: boolean
success?: boolean
message?: string
tools?: AgentMcpToolInfo[]
}
const props = defineProps<{
modelValue: boolean
servers: AgentMcpServer[]
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'saved', value: AgentMcpServer[]): void
}>()
const { t } = useI18n()
const toast = useToast()
const display = useDisplay()
const saving = ref(false)
const localServers = ref<EditableAgentMcpServer[]>([])
const testStates = ref<Record<string, AgentMcpTestState>>({})
const dialogVisible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const transportItems = computed(() => [
{ title: t('setting.system.aiAgentMcpTransportStdio'), value: 'stdio' },
{ title: t('setting.system.aiAgentMcpTransportSse'), value: 'sse' },
{ title: t('setting.system.aiAgentMcpTransportHttp'), value: 'http' },
])
function createServerId() {
return `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
function dictToLines(value?: Record<string, string>) {
return Object.entries(value || {})
.map(([key, item]) => `${key}=${item}`)
.join('\n')
}
function linesToDict(value: string) {
return String(value || '')
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.reduce<Record<string, string>>((result, line) => {
const separatorIndex = line.indexOf('=')
const key = separatorIndex >= 0 ? line.slice(0, separatorIndex).trim() : line
if (!key) return result
result[key] = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : ''
return result
}, {})
}
function toEditableServer(server: AgentMcpServer): EditableAgentMcpServer {
return {
id: server.id || createServerId(),
name: server.name || t('setting.system.aiAgentMcpUnnamedServer'),
enabled: server.enabled !== false,
transport: server.transport || 'stdio',
description: server.description || '',
command: server.command || '',
args: [...(server.args || [])],
env: { ...(server.env || {}) },
url: server.url || '',
headers: { ...(server.headers || {}) },
timeout: Number(server.timeout || 30),
tool_prefix: server.tool_prefix || '',
require_admin: server.require_admin !== false,
argsText: (server.args || []).join('\n'),
envText: dictToLines(server.env),
headersText: dictToLines(server.headers),
}
}
function toServerPayload(server: EditableAgentMcpServer): AgentMcpServer {
return {
id: server.id || createServerId(),
name: String(server.name || '').trim() || t('setting.system.aiAgentMcpUnnamedServer'),
enabled: Boolean(server.enabled),
transport: server.transport || 'stdio',
description: String(server.description || '').trim() || null,
command: String(server.command || '').trim() || null,
args: String(server.argsText || '')
.split('\n')
.map(item => item.trim())
.filter(Boolean),
env: linesToDict(server.envText),
url: String(server.url || '').trim() || null,
headers: linesToDict(server.headersText),
timeout: Math.max(1, Number(server.timeout || 30)),
tool_prefix: String(server.tool_prefix || '').trim() || null,
require_admin: Boolean(server.require_admin),
}
}
function resetLocalServers() {
localServers.value = (props.servers || []).map(toEditableServer)
testStates.value = {}
}
function addServer() {
localServers.value.push(
toEditableServer({
id: createServerId(),
name: t('setting.system.aiAgentMcpNewServer'),
enabled: true,
transport: 'stdio',
description: '',
command: '',
args: [],
env: {},
url: '',
headers: {},
timeout: 30,
tool_prefix: '',
require_admin: true,
}),
)
}
function removeServer(server: EditableAgentMcpServer) {
localServers.value = localServers.value.filter(item => item.id !== server.id)
}
async function testServer(server: EditableAgentMcpServer) {
const payload = toServerPayload(server)
testStates.value[payload.id] = { loading: true }
try {
const result: { [key: string]: any } = await api.post('message/agent/mcp/servers/test', { server: payload })
testStates.value[payload.id] = {
loading: false,
success: Boolean(result.success),
message: result.message || result.data?.message || '',
tools: result.data?.tools || [],
}
} catch (error) {
testStates.value[payload.id] = {
loading: false,
success: false,
message: error instanceof Error ? error.message : String(error),
tools: [],
}
}
}
async function saveServers() {
saving.value = true
try {
const servers = localServers.value.map(toServerPayload)
const result: { [key: string]: any } = await api.post('message/agent/mcp/servers', { servers })
if (result.success) {
toast.success(t('setting.system.aiAgentMcpSaveSuccess'))
emit('saved', servers)
dialogVisible.value = false
return
}
toast.error(result.message || t('setting.system.aiAgentMcpSaveFailed'))
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
} finally {
saving.value = false
}
}
watch(
() => props.modelValue,
value => {
if (value) resetLocalServers()
},
)
watch(
() => props.servers,
() => {
if (props.modelValue) resetLocalServers()
},
{ deep: true },
)
</script>
<template>
<VDialog v-model="dialogVisible" scrollable max-width="72rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-server-network" class="me-2" />
</template>
<VCardTitle>{{ t('setting.system.aiAgentMcpDialogTitle') }}</VCardTitle>
<VCardSubtitle>{{ t('setting.system.aiAgentMcpDialogDesc') }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="dialogVisible = false" />
<VCardText>
<div class="d-flex justify-end mb-4">
<VBtn color="success" variant="tonal" prepend-icon="mdi-plus" @click="addServer">
{{ t('setting.system.aiAgentMcpAddServer') }}
</VBtn>
</div>
<VAlert v-if="localServers.length === 0" type="info" variant="tonal">
{{ t('setting.system.aiAgentMcpEmpty') }}
</VAlert>
<VExpansionPanels v-else variant="accordion" class="agent-mcp-panels">
<VExpansionPanel v-for="server in localServers" :key="server.id">
<VExpansionPanelTitle>
<div class="agent-mcp-panel-title">
<VIcon :icon="server.enabled ? 'mdi-lan-connect' : 'mdi-lan-disconnect'" />
<span>{{ server.name || t('setting.system.aiAgentMcpUnnamedServer') }}</span>
<VChip size="small" variant="tonal">{{ server.transport }}</VChip>
</div>
</VExpansionPanelTitle>
<VExpansionPanelText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="server.name"
:label="t('setting.system.aiAgentMcpName')"
prepend-inner-icon="mdi-label-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="server.transport"
:label="t('setting.system.aiAgentMcpTransport')"
:items="transportItems"
prepend-inner-icon="mdi-transit-connection-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="server.enabled"
:label="t('setting.system.aiAgentMcpEnabled')"
:hint="t('setting.system.aiAgentMcpEnabledHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="server.require_admin"
:label="t('setting.system.aiAgentMcpRequireAdmin')"
:hint="t('setting.system.aiAgentMcpRequireAdminHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="server.timeout"
:label="t('setting.system.aiAgentMcpTimeout')"
type="number"
min="1"
prepend-inner-icon="mdi-timer-sand"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="server.tool_prefix"
:label="t('setting.system.aiAgentMcpToolPrefix')"
:hint="t('setting.system.aiAgentMcpToolPrefixHint')"
persistent-hint
prepend-inner-icon="mdi-form-textbox"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="server.description"
:label="t('setting.system.aiAgentMcpDescription')"
rows="1"
auto-grow
prepend-inner-icon="mdi-text-box-outline"
/>
</VCol>
<template v-if="server.transport === 'stdio'">
<VCol cols="12" md="6">
<VTextField
v-model="server.command"
:label="t('setting.system.aiAgentMcpCommand')"
placeholder="npx"
prepend-inner-icon="mdi-console"
/>
</VCol>
<VCol cols="12" md="6">
<VTextarea
v-model="server.argsText"
:label="t('setting.system.aiAgentMcpArgs')"
:hint="t('setting.system.aiAgentMcpArgsHint')"
persistent-hint
rows="2"
auto-grow
prepend-inner-icon="mdi-format-list-bulleted"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="server.envText"
:label="t('setting.system.aiAgentMcpEnv')"
:hint="t('setting.system.aiAgentMcpKeyValueHint')"
persistent-hint
rows="2"
auto-grow
prepend-inner-icon="mdi-code-braces"
/>
</VCol>
</template>
<template v-else>
<VCol cols="12">
<VTextField
v-model="server.url"
:label="t('setting.system.aiAgentMcpUrl')"
placeholder="http://127.0.0.1:3001/api/v1/mcp"
prepend-inner-icon="mdi-link-variant"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="server.headersText"
:label="t('setting.system.aiAgentMcpHeaders')"
:hint="t('setting.system.aiAgentMcpKeyValueHint')"
persistent-hint
rows="2"
auto-grow
prepend-inner-icon="mdi-format-align-left"
/>
</VCol>
</template>
</VRow>
<VAlert
v-if="testStates[server.id]?.message"
:type="testStates[server.id]?.success ? 'success' : 'error'"
variant="tonal"
density="comfortable"
class="mt-3"
>
<div>{{ testStates[server.id]?.message }}</div>
<div v-if="testStates[server.id]?.tools?.length" class="agent-mcp-tool-list mt-2">
<VChip
v-for="tool in testStates[server.id]?.tools"
:key="tool.agent_tool_name"
size="small"
variant="tonal"
>
{{ tool.agent_tool_name }}
</VChip>
</div>
</VAlert>
<div class="agent-mcp-actions mt-4">
<VBtn
color="info"
variant="tonal"
prepend-icon="mdi-connection"
:loading="testStates[server.id]?.loading"
@click="testServer(server)"
>
{{ t('setting.system.aiAgentMcpTest') }}
</VBtn>
<VBtn color="error" variant="text" prepend-icon="mdi-delete-outline" @click="removeServer(server)">
{{ t('common.delete') }}
</VBtn>
</div>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</VCardText>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
prepend-icon="mdi-content-save"
class="px-5"
:loading="saving"
@click="saveServers"
>
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>
.agent-mcp-panel-title {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.agent-mcp-actions,
.agent-mcp-tool-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
</style>

View File

@@ -9,7 +9,6 @@ type UnknownRecord = Record<string, any>
const props = withDefaults(
defineProps<{
colors?: Record<string, string>
enabled: Record<string, boolean>
elevated?: boolean
hint: string
@@ -24,7 +23,6 @@ const props = withDefaults(
valueGetter?: (item: UnknownRecord) => string
}>(),
{
colors: () => ({}),
elevated: false,
labelGetter: undefined,
modelValue: true,
@@ -63,17 +61,42 @@ const elevatedValue = computed({
})
watch(
() => [props.enabled, props.elevated, props.items],
() => props.enabled,
() => {
resetLocalSettings()
},
{ deep: true, immediate: true },
)
watch(
() => props.elevated,
value => {
localElevated.value = value
},
)
watch(
() => props.items.map(item => getItemValue(item)).join('\u0000'),
() => {
syncLocalEnabledItems()
},
)
// 重置弹窗内部设置副本,避免直接修改父级 props。
function resetLocalSettings() {
localEnabled.value = { ...props.enabled }
localElevated.value = props.elevated
syncLocalEnabledItems()
}
// 同步新增设置项的默认状态,避免刷新列表时覆盖用户正在编辑的本地选择。
function syncLocalEnabledItems() {
props.items.forEach(item => {
const key = getItemValue(item)
if (key && !(key in localEnabled.value)) {
localEnabled.value[key] = Boolean(props.enabled[key])
}
})
}
// 获取设置项的稳定键值。
@@ -129,12 +152,13 @@ function submitSettings() {
<VCardText>
<p class="settings-hint">{{ props.hint }}</p>
<div class="settings-grid">
<div
<button
v-for="item in props.items"
:key="getItemValue(item)"
type="button"
class="setting-item"
:class="{ 'enabled': localEnabled[getItemValue(item)] }"
:style="{ '--item-color': props.colors[getItemValue(item)] }"
:aria-pressed="Boolean(localEnabled[getItemValue(item)])"
@click="toggleItem(item)"
>
<div class="setting-item-inner">
@@ -147,7 +171,7 @@ function submitSettings() {
</div>
<span class="setting-label">{{ getItemLabel(item) }}</span>
</div>
</div>
</button>
</div>
<p v-if="props.switchLabel" class="mt-3">
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
@@ -186,43 +210,48 @@ function submitSettings() {
.settings-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.setting-label {
flex: 1;
color: rgba(var(--v-theme-on-surface), 0.8);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
font-weight: 550;
line-height: 1.35;
text-align: start;
transition: color 0.2s ease;
}
.setting-item {
appearance: none;
position: relative;
overflow: hidden;
border-radius: var(--app-surface-radius);
background-color: rgba(var(--v-theme-surface-variant), 0.3);
min-block-size: 48px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 10px;
background-color: rgba(var(--v-theme-on-surface), 0.04);
cursor: pointer;
font: inherit;
padding-block: 10px;
padding-inline: 12px;
transition: all 0.2s ease;
transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
}
.setting-item::before {
position: absolute;
background: linear-gradient(90deg, var(--item-color, rgb(var(--v-theme-primary))) 0%, transparent 100%);
background: rgb(var(--v-theme-primary));
content: '';
inline-size: 3px;
inset-block: 0;
inset-inline-start: 0;
opacity: 0.3;
opacity: 0;
transition: opacity 0.2s ease;
}
.setting-item.enabled {
border-color: rgba(var(--v-theme-primary), 0.4);
border-color: rgba(var(--v-theme-primary), 0.3);
background-color: rgba(var(--v-theme-primary), 0.08);
}
@@ -230,6 +259,24 @@ function submitSettings() {
opacity: 1;
}
.setting-item:hover {
border-color: rgba(var(--v-theme-primary), 0.32);
background-color: rgba(var(--v-theme-primary), 0.06);
}
.setting-item:active {
transform: scale(0.99);
}
.setting-item:focus-visible {
outline: 3px solid rgba(var(--v-theme-primary), 0.28);
outline-offset: 2px;
}
.setting-item.enabled .setting-label {
color: rgb(var(--v-theme-on-surface));
}
.setting-item-inner {
display: flex;
align-items: center;
@@ -240,4 +287,11 @@ function submitSettings() {
display: flex;
flex-shrink: 0;
}
@media (width <= 760px) {
.settings-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -99,9 +99,9 @@ function submitCustomCSS() {
<style scoped>
.custom-css-dialog {
display: flex;
overflow: hidden;
flex-direction: column;
max-block-size: calc(100dvh - 2rem);
overflow: hidden;
}
.custom-css-header {
@@ -111,7 +111,7 @@ function submitCustomCSS() {
.custom-css-editor-body {
flex: 1 1 auto;
min-block-size: 0;
min-block-size: 240px;
}
.custom-css-editor {
@@ -141,8 +141,8 @@ function submitCustomCSS() {
.custom-css-editor {
flex: 1 1 auto;
min-block-size: 0;
block-size: auto;
min-block-size: 0;
}
.custom-css-actions {

View File

@@ -9,12 +9,10 @@ const display = useDisplay()
const props = withDefaults(
defineProps<{
colors?: Record<string, string>
modelValue?: boolean
tabs: DiscoverSource[]
}>(),
{
colors: () => ({}),
modelValue: true,
},
)
@@ -55,9 +53,16 @@ function submitOrder() {
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="35rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VDialog
v-if="visible"
v-model="visible"
width="35rem"
class="settings-dialog"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard class="settings-card">
<VCardItem class="settings-card-header">
<VCardTitle>
<VIcon icon="mdi-order-alphabetical-ascending" size="small" class="me-2" />
{{ t('discover.setTabOrder') }}
@@ -72,14 +77,11 @@ function submitOrder() {
handle=".cursor-move"
item-key="mediaid_prefix"
tag="div"
:animation="180"
:component-data="{ 'class': 'settings-grid' }"
>
<template #item="{ element }">
<VCard
variant="text"
class="setting-item enabled"
:style="{ '--item-color': props.colors[element.mediaid_prefix] }"
>
<VCard variant="text" class="setting-item enabled">
<div class="setting-item-inner">
<span class="setting-label">{{ element.name }}</span>
<VIcon icon="mdi-drag" class="drag-icon cursor-move" />
@@ -102,6 +104,11 @@ function submitOrder() {
</template>
<style scoped>
.settings-card-header {
padding-block: 16px;
padding-inline: 20px;
}
.settings-hint {
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: 0.9rem;
@@ -110,50 +117,86 @@ function submitOrder() {
.settings-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.setting-item {
position: relative;
overflow: hidden;
background-color: rgba(var(--v-theme-primary), 0.08);
min-block-size: 48px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 10px;
background-color: rgba(var(--v-theme-on-surface), 0.04);
cursor: grab;
padding-block: 10px;
padding-inline: 12px;
transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
}
.setting-item::before {
position: absolute;
background-color: var(--item-color, #4caf50);
background-color: rgb(var(--v-theme-primary));
block-size: 100%;
content: '';
inline-size: 4px;
inline-size: 3px;
inset-block-start: 0;
inset-inline-start: 0;
opacity: 0;
transition: opacity 0.2s ease;
}
.setting-item.enabled {
border-color: rgba(var(--v-theme-primary), 0.3);
background-color: rgba(var(--v-theme-primary), 0.08);
}
.setting-item.enabled::before {
opacity: 1;
}
.setting-item:hover {
border-color: rgba(var(--v-theme-primary), 0.32);
background-color: rgba(var(--v-theme-primary), 0.06);
}
.setting-item:active {
cursor: grabbing;
transform: scale(0.99);
}
.setting-item-inner {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
}
.setting-label {
flex: 1;
color: rgba(var(--v-theme-primary), 0.9);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
font-weight: 550;
line-height: 1.35;
transition: color 0.2s ease;
}
.setting-item.enabled .setting-label {
color: rgb(var(--v-theme-on-surface));
}
.drag-icon {
opacity: 0.5;
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.52);
transition: color 0.2s ease;
}
@media (width <= 600px) {
.setting-item:hover .drag-icon {
color: rgb(var(--v-theme-primary));
}
@media (width <= 760px) {
.settings-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -105,9 +105,9 @@ function submitTemplate() {
<style scoped>
.notification-template-editor-dialog {
display: flex;
overflow: hidden;
flex-direction: column;
max-block-size: calc(100dvh - 2rem);
overflow: hidden;
}
.template-editor-header {
@@ -147,8 +147,8 @@ function submitTemplate() {
.template-ace-editor {
flex: 1 1 auto;
min-block-size: 0;
block-size: auto;
min-block-size: 0;
}
.template-editor-actions {

View File

@@ -1,171 +0,0 @@
<script setup lang="ts">
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
const props = withDefaults(
defineProps<{
modelValue?: boolean
type?: 'offline' | 'online'
}>(),
{
modelValue: true,
type: 'offline',
},
)
const { t } = useI18n()
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
// 重试连接
const retrying = ref(false)
/** 尝试请求静态资源来触发网络状态重新检测。 */
async function handleRetry() {
if (retrying.value) return
retrying.value = true
try {
await fetch('/favicon.ico?' + new Date().getTime(), {
method: 'HEAD',
cache: 'no-cache',
})
setTimeout(() => {
retrying.value = false
}, 1000)
} catch (error) {
retrying.value = false
}
}
// 状态文本
const statusText = computed(() => {
if (props.type === 'online') {
return t('app.onlineMessage')
}
return getOfflineMessage()
})
// 图标
const statusIcon = computed(() => {
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
})
// 颜色主题
const colorTheme = computed(() => {
return props.type === 'online' ? 'success' : 'error'
})
</script>
<template>
<VDialog :model-value="props.modelValue" persistent max-width="420" scrollable>
<VCard class="offline-dialog">
<div class="status-icon-wrapper">
<div class="status-icon-bg">
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
</div>
</div>
<VCardText class="text-center">
<h2 class="offline-title mb-4">
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
</h2>
<p class="offline-message mb-6">
{{ statusText }}
</p>
<div class="action-section mb-6">
<VBtn
v-if="props.type === 'offline'"
:loading="retrying"
:color="colorTheme"
size="default"
variant="flat"
@click="handleRetry"
>
<VIcon icon="mdi-refresh" class="me-2" />
{{ retrying ? t('common.checking') : t('common.retry') }}
</VBtn>
</div>
<div class="status-indicators">
<VChip
:color="isOnline ? 'success' : 'error'"
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
variant="tonal"
size="small"
class="me-2"
>
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
</VChip>
<VChip
:color="canPerformNetworkAction ? 'success' : 'warning'"
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
variant="tonal"
size="small"
>
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
</VChip>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
.status-icon-wrapper {
padding-block: 24px 0;
padding-inline: 24px;
text-align: center;
}
.status-icon-bg {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
animation: icon-pulse 3s ease-in-out infinite;
background: rgba(var(--v-theme-surface-variant), 0.5);
block-size: 80px;
inline-size: 80px;
margin-block: 0;
margin-inline: auto;
}
.status-icon-bg::before {
position: absolute;
z-index: -1;
border-radius: 50%;
animation: icon-glow 2s ease-in-out infinite alternate;
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
content: '';
inset: -3px;
opacity: 0.1;
}
@keyframes icon-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@keyframes icon-glow {
0% {
opacity: 0.1;
transform: scale(1);
}
100% {
opacity: 0.3;
transform: scale(1.1);
}
}
</style>

View File

@@ -46,6 +46,14 @@ const isRefreshed = ref(false)
// 渲染模式: 'vuetify' 或 'vue'
const renderMode = ref('vuetify')
// 插件未声明布局偏好时沿用标准配置弹窗宽度。
const dialogMaxWidth = ref('60rem')
interface PluginConfigLayout {
/** 插件配置界面期望的最大宽度,使用合法 CSS 尺寸。 */
maxWidth?: string
}
// Vue 模式:动态加载的组件
const dynamicComponent = defineAsyncComponent({
// 工厂函数
@@ -89,6 +97,7 @@ async function loadPluginUIData() {
pluginFormItems = []
pluginConfigForm.value = {}
renderMode.value = 'vuetify'
dialogMaxWidth.value = '60rem'
try {
// 获取UI定义
@@ -123,6 +132,12 @@ function handleVueComponentSave(newConfig: Record<string, any>) {
savePluginConf()
}
// 联邦配置组件可按自身布局密度覆盖宿主弹窗宽度。
function handleVueComponentLayout(layout?: PluginConfigLayout | null) {
const maxWidth = typeof layout?.maxWidth === 'string' ? layout.maxWidth.trim() : ''
dialogMaxWidth.value = maxWidth || '60rem'
}
// 调用API保存配置数据
async function savePluginConf() {
// 显示等待提示框
@@ -148,7 +163,7 @@ onBeforeMount(async () => {
})
</script>
<template>
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 -->
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
<VDialogCloseBtn @click="emit('close')" />
@@ -192,6 +207,7 @@ onBeforeMount(async () => {
:initial-config="pluginConfigForm"
:api="api"
@save="handleVueComponentSave"
@layout="handleVueComponentLayout"
@switch="emit('switch')"
@close="emit('close')"
/>

View File

@@ -51,8 +51,14 @@ function downloadLogger() {
</script>
<template>
<VDialog v-if="visible" v-model="visible" scrollable max-width="72rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialog
v-if="visible"
v-model="visible"
:scrollable="display.mdAndUp.value"
max-width="72rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard class="logging-dialog-card">
<VDialogCloseBtn v-model="visible" />
<VCardItem>
<VCardTitle class="d-inline-flex">
@@ -75,9 +81,29 @@ function downloadLogger() {
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<VCardText class="logging-dialog-content pa-0">
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
@media (width <= 960px) {
.logging-dialog-card {
display: flex;
flex-direction: column;
overflow: hidden;
block-size: 100%;
}
.logging-dialog-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
inline-size: 100%;
min-block-size: 0;
overflow: hidden;
}
}
</style>

View File

@@ -6,8 +6,12 @@ import { getLogoUrl } from '@/utils/imageUtils'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useConfirm } from '@/composables/useConfirm'
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
const PluginVersionHistoryDialog = defineAsyncComponent(
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
)
// 多语言
const { t } = useI18n()
@@ -15,6 +19,8 @@ const { t } = useI18n()
// 提示框
const $toast = useToast()
const createConfirm = useConfirm()
// 输入参数
const props = defineProps({
modelValue: {
@@ -47,6 +53,7 @@ const imageRef = ref<any>()
const imageLoadError = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 打开插件安装进度弹窗。 */
function showInstallProgress(text: string) {
@@ -97,24 +104,38 @@ function visitPluginPage() {
}
/** 安装插件并通知父级刷新市场列表。 */
async function installPlugin() {
if (props.plugin?.system_version_compatible === false) {
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
if (releaseVersion) {
const isConfirmed = await createConfirm({
title: t('common.confirm'),
content: t('plugin.confirmInstallOldRelease', {
name: props.plugin?.plugin_name,
version: releaseVersion,
}),
confirmText: t('common.confirm'),
})
if (!isConfirmed) return
}
try {
showInstallProgress(
t('plugin.installing', {
name: props.plugin?.plugin_name,
version: props?.plugin?.plugin_version,
version: releaseVersion || props?.plugin?.plugin_version,
}),
)
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
repo_url: props.plugin?.repo_url,
force: props.plugin?.has_update,
repo_url: repoUrl || props.plugin?.repo_url,
release_version: releaseVersion,
force: props.plugin?.has_update || Boolean(releaseVersion),
},
})
@@ -122,6 +143,8 @@ async function installPlugin() {
if (result.success) {
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close()
versionHistoryDialogController = null
visible.value = false
emit('install')
} else {
@@ -133,8 +156,22 @@ async function installPlugin() {
}
}
/** 打开版本历史并支持从 Release 资产安装指定版本。 */
function showUpdateHistory() {
versionHistoryDialogController?.close()
versionHistoryDialogController = openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin, actionMode: 'install' },
{
update: installPlugin,
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
onUnmounted(() => {
closeInstallProgress()
versionHistoryDialogController?.close()
})
</script>
@@ -190,16 +227,21 @@ onUnmounted(() => {
class="mb-3"
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<div class="text-center text-md-left">
<VBtn
color="primary"
@click="installPlugin"
prepend-icon="mdi-download"
:disabled="props.plugin?.system_version_compatible === false"
>
{{ t('plugin.installToLocal') }}
</VBtn>
<div class="text-xs mt-2" v-if="props.count">
<div class="plugin-market-detail-actions">
<div class="plugin-market-detail-actions__buttons">
<VBtn
color="primary"
@click="installPlugin()"
prepend-icon="mdi-download"
:disabled="props.plugin?.system_version_compatible === false"
>
{{ t('plugin.installToLocal') }}
</VBtn>
<VBtn variant="tonal" @click="showUpdateHistory" prepend-icon="mdi-update">
{{ t('plugin.versionHistory') }}
</VBtn>
</div>
<div class="plugin-market-detail-actions__downloads" v-if="props.count">
<VIcon icon="mdi-fire" />
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
</div>
@@ -212,3 +254,42 @@ onUnmounted(() => {
</VCard>
</VDialog>
</template>
<style scoped>
.plugin-market-detail-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.plugin-market-detail-actions__buttons {
/* 窄屏换行时用统一 gap 控制按钮间距,避免第二个按钮带左边距导致视觉偏移。 */
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.plugin-market-detail-actions__downloads {
flex-basis: 100%;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
text-align: center;
}
@media (width >= 960px) {
.plugin-market-detail-actions {
justify-content: flex-start;
}
.plugin-market-detail-actions__buttons {
justify-content: flex-start;
}
.plugin-market-detail-actions__downloads {
text-align: start;
}
}
</style>

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import api from '@/api'
import type { Plugin } from '@/api/types'
import type { Plugin, PluginReleaseVersion, PluginReleaseVersionsResponse } from '@/api/types'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import { useI18n } from 'vue-i18n'
// 多语言
const { t } = useI18n()
const { t, locale } = useI18n()
// 输入参数
const props = defineProps({
@@ -21,14 +21,25 @@ const props = defineProps({
type: Boolean,
default: false,
},
actionMode: {
type: String as PropType<'install' | 'update'>,
default: 'update',
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'update'])
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'close'): void
(event: 'update', releaseVersion?: string, repoUrl?: string): void
}>()
const loading = ref(false)
const loadError = ref('')
const pluginDetail = ref<Plugin | null>(null)
const releaseLoading = ref(false)
const releaseError = ref('')
const releaseDetail = ref<PluginReleaseVersionsResponse | null>(null)
// 弹窗显示状态
const visible = computed({
@@ -41,19 +52,73 @@ const visible = computed({
const resolvedPlugin = computed(() => pluginDetail.value ?? props.plugin)
const resolvedHistory = computed(() => resolvedPlugin.value?.history || {})
const resolvedHistory = computed(() => {
const history = { ...(resolvedPlugin.value?.history || {}) }
releaseItems.value.forEach(item => {
const key = normalizeHistoryVersion(item.version)
if (!(key in history)) history[key] = item.body || ''
})
return history
})
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
const latestActionText = computed(() => props.actionMode === 'install' ? t('plugin.installReleaseVersion') : t('plugin.updateToLatest'))
const releaseItems = computed(() => releaseDetail.value?.items || [])
const shouldShowUpdatePanel = computed(() => props.showUpdateAction)
const releaseByHistoryVersion = computed(() => {
const releaseMap = new Map<string, PluginReleaseVersion>()
releaseItems.value.forEach(item => {
releaseMap.set(normalizeHistoryVersion(item.version), item)
})
return releaseMap
})
function normalizeHistoryVersion(version: string) {
return version.startsWith('v') ? version : `v${version}`
}
function formatReleaseDate(value?: string) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale.value)
}
function releaseItemByHistoryVersion(version: string) {
return releaseByHistoryVersion.value.get(version)
}
function shouldShowReleaseButton(item?: PluginReleaseVersion) {
if (!item || item.is_current) return false
return !(item.is_latest && shouldShowUpdatePanel.value && props.actionMode === 'update')
}
async function loadPluginHistory() {
if (!props.plugin?.id) {
pluginDetail.value = null
loadError.value = ''
releaseDetail.value = null
releaseError.value = ''
return
}
loading.value = true
loadError.value = ''
releaseDetail.value = null
releaseError.value = ''
// 插件市场条目已经携带远端信息history 接口只查询已安装插件,
// 未安装插件打开版本历史时只能基于传入的市场数据和 Release 列表展示。
if (props.actionMode === 'install' && props.plugin?.repo_url) {
pluginDetail.value = null
loading.value = false
loadPluginReleases(props.plugin, false)
return
}
try {
pluginDetail.value = await api.get(`plugin/history/${props.plugin.id}`, {
@@ -61,6 +126,7 @@ async function loadPluginHistory() {
force: true,
},
})
loadPluginReleases(pluginDetail.value ?? props.plugin, true)
} catch (error) {
pluginDetail.value = null
loadError.value = t('plugin.updateHistoryLoadFailed')
@@ -70,36 +136,108 @@ async function loadPluginHistory() {
}
}
async function loadPluginReleases(plugin: Plugin | null | undefined = resolvedPlugin.value, force = false) {
if (!plugin?.id || !plugin?.repo_url || !plugin?.release) {
releaseDetail.value = null
releaseError.value = ''
return
}
releaseLoading.value = true
releaseError.value = ''
try {
releaseDetail.value = await api.get(`plugin/releases/${plugin.id}`, {
params: {
repo_url: plugin.repo_url,
force,
},
})
} catch (error) {
releaseDetail.value = null
releaseError.value = t('plugin.releaseVersionsLoadFailed')
console.error(error)
} finally {
releaseLoading.value = false
}
}
/** 触发插件更新操作。 */
function handleUpdate() {
emit('update')
function handleUpdate(releaseItem?: PluginReleaseVersion) {
emit('update', releaseItem?.is_latest ? undefined : releaseItem?.version, resolvedPlugin.value?.repo_url)
}
watch(
() => [visible.value, props.plugin?.id],
([isVisible]) => {
if (isVisible) loadPluginHistory()
if (isVisible) {
loadPluginHistory()
}
},
{ immediate: true },
)
</script>
<template>
<VDialog v-if="visible" v-model="visible" width="600" max-height="85vh" scrollable>
<VDialog v-if="visible" v-model="visible" width="680" max-height="85vh" scrollable>
<VCard :title="t('plugin.updateHistoryTitle', { name: resolvedPlugin?.plugin_name })">
<VDialogCloseBtn v-model="visible" />
<VDivider />
<VProgressLinear v-if="releaseLoading && !loading" indeterminate color="primary" height="2" />
<div v-if="loading" class="plugin-version-history-dialog__loading">
<VProgressCircular indeterminate color="primary" />
</div>
<VCardText v-else-if="loadError && !hasHistory">
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" />
</VCardText>
<VCardText v-else-if="!hasHistory">
<VCardText v-else-if="!hasHistory && !releaseLoading">
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
</VCardText>
<VersionHistory v-else :history="resolvedHistory" />
<template v-if="props.showUpdateAction">
<template v-else>
<VCardText v-if="releaseError" class="pb-0">
<VAlert type="warning" variant="tonal" density="compact" :text="releaseError" />
</VCardText>
<VersionHistory
:history="resolvedHistory"
:has-action="version => shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
>
<template #meta="{ version }">
<div v-if="releaseItemByHistoryVersion(version)" class="plugin-release-meta">
<span v-if="formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at)" class="plugin-release-meta__date">
{{ formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at) }}
</span>
<VChip v-if="releaseItemByHistoryVersion(version)?.is_latest" size="x-small" color="primary" variant="tonal">
{{ t('plugin.latestVersion') }}
</VChip>
<VChip v-if="releaseItemByHistoryVersion(version)?.is_current" size="x-small" color="success" variant="tonal">
{{ t('plugin.currentVersion') }}
</VChip>
</div>
</template>
<template #action="{ version }">
<VBtn
v-if="shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
class="plugin-release-button"
size="small"
min-width="5rem"
:color="releaseItemByHistoryVersion(version)?.is_latest ? 'primary' : undefined"
:variant="releaseItemByHistoryVersion(version)?.is_latest ? 'flat' : 'tonal'"
:disabled="
releaseItemByHistoryVersion(version)?.is_current ||
(releaseItemByHistoryVersion(version)?.is_latest && resolvedPlugin?.system_version_compatible === false)
"
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
>
{{
releaseItemByHistoryVersion(version)?.is_latest
? latestActionText
: t('plugin.installReleaseVersion')
}}
</VBtn>
</template>
</VersionHistory>
</template>
<template v-if="shouldShowUpdatePanel">
<VDivider />
<VCardItem>
<VAlert
@@ -110,7 +248,11 @@ watch(
class="mb-3"
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<VBtn @click="handleUpdate" block :disabled="resolvedPlugin?.system_version_compatible === false">
<VBtn
@click="handleUpdate()"
block
:disabled="resolvedPlugin?.system_version_compatible === false"
>
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
@@ -129,4 +271,23 @@ watch(
align-items: center;
justify-content: center;
}
.plugin-release-button {
white-space: nowrap;
}
.plugin-release-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
min-width: 0;
}
.plugin-release-meta__date {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
font-size: 0.875rem;
white-space: nowrap;
}
</style>

View File

@@ -10,6 +10,7 @@ import {
ManualTransferPayload,
ManualTransferPreviewData,
ManualTransferPreviewItem,
MediaInfo,
StorageConf,
TransferDirectoryConf,
TransferForm,
@@ -18,7 +19,6 @@ import { useBackground } from '@/composables/useBackground'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import ProgressDialog from './ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { nextTick } from 'vue'
import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
@@ -124,6 +124,20 @@ interface TargetDirectoryOption {
const AUTO_TARGET_PATH_VALUE = '__moviepilot_auto_target_path__'
// 媒体类型映射到手动整理接口接受的类型名。
function resolveTransferMediaType(type?: string) {
const normalizedType = type?.trim().toLowerCase()
if (!normalizedType) return undefined
const movieTypes = ['电影', 'movie']
if (movieTypes.includes(normalizedType)) return '电影'
const tvTypes = ['电视剧', 'tv', 'series']
if (tvTypes.includes(normalizedType)) return '电视剧'
return undefined
}
// 生成文件项稳定键,用于去重和状态同步。
function getFileItemKey(item?: FileItem) {
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
@@ -150,13 +164,7 @@ const normalizedItems = computed(() => dedupeFileItems(props.items))
// 分页
const previewPage = ref(1)
const previewPageSize = ref(10)
// 预览列表主体元素
const previewFileBodyRef = ref<HTMLElement>()
// 预览列表尺寸观察器
let previewFileBodyResizeObserver: ResizeObserver | undefined
const previewPageSize = ref(20)
// 所有存储
const storages = ref<StorageConf[]>([])
@@ -305,6 +313,14 @@ const transferForm = reactive<TransferForm>({
episode_group: null,
})
// 处理媒体搜索结果选择,同步搜索结果中已识别的媒体类型。
function handleMediaSelected(item: Pick<MediaInfo, 'type'>) {
const typeName = resolveTransferMediaType(item.type)
if (!typeName) return
transferForm.type_name = typeName
}
// 所有媒体库目录
const directories = ref<TransferDirectoryConf[]>([])
@@ -419,9 +435,39 @@ watch(
},
)
// 过滤后的预览数据
// 过滤并排序后的预览数据
const filteredPreviewItems = computed(() => {
return previewData.value?.items ?? []
const items = [...(previewData.value?.items ?? [])]
return items.sort((a, b) => {
// 1. 获取季号(如果有的话优先按季号排)
const seasonA = getPreviewSeasonNumber(a)
const seasonB = getPreviewSeasonNumber(b)
if (seasonA !== seasonB) {
if (seasonA === undefined) return 1
if (seasonB === undefined) return -1
return seasonA - seasonB
}
// 2. 获取集数
const epA = toPreviewNumber(a.episode)
const epB = toPreviewNumber(b.episode)
// 如果都有集数,按集数排序
if (epA !== undefined && epB !== undefined) {
if (epA !== epB) return epA - epB
// 集数相同(可能是同集的视频、字幕等),退化到按文件名排序,保证相关文件挨在一起
}
// 3. 有集数的排前面,没集数的(通常是其他文件)排后面
if (epA !== undefined && epB === undefined) return -1
if (epA === undefined && epB !== undefined) return 1
// 4. 如果都没集数,或者集数完全相同,则按照目标路径(或源路径)的字母顺序排
const nameA = a.target || a.source || ''
const nameB = b.target || b.source || ''
return nameA.localeCompare(nameB, undefined, { numeric: true })
})
})
// 分页后的预览数据(含文件名解析)
@@ -996,6 +1042,19 @@ function mergePreviewData(target: ManualTransferPreviewData, incoming?: ManualTr
}
}
// 从标准响应中提取可展示的整理预览数据,优先保留顶层本地化消息。
function resolvePreviewResponseData(result: ApiResponse<ManualTransferPreviewData>) {
if (!result.data) return result.data
const message = result.message_i18n || result.message || result.data.message
if (!message || message === result.data.message) return result.data
return {
...result.data,
message,
}
}
// 预览整理结果
async function previewTransfer() {
if (!props.logids && !normalizedItems.value.length) return
@@ -1023,7 +1082,7 @@ async function previewTransfer() {
}),
)
} else {
mergePreviewData(mergedPreviewData, result.data)
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
}
} catch (err: any) {
console.warn(`预览请求异常: ${err?.message}`)
@@ -1055,7 +1114,7 @@ async function previewTransfer() {
return
}
mergePreviewData(mergedPreviewData, result.data)
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
} catch (err: any) {
console.warn(`预览请求异常: ${err?.message}`)
mergePreviewData(
@@ -1091,7 +1150,7 @@ async function previewTransfer() {
return
}
mergePreviewData(mergedPreviewData, result.data)
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
} catch (err: any) {
console.warn(`预览请求异常: ${err?.message}`)
mergePreviewData(
@@ -1110,7 +1169,6 @@ async function previewTransfer() {
previewData.value = mergedPreviewData
previewLoaded.value = true
nextTick(() => updatePreviewPageSize())
if (previewHasFailures(mergedPreviewData)) {
$toast.warning(getPreviewResultSummaryMessage(mergedPreviewData))
@@ -1137,45 +1195,6 @@ async function togglePreview() {
await previewTransfer()
}
// 根据可用高度自动计算每页条数,保持统一行高
function updatePreviewPageSize() {
const bodyHeight = previewFileBodyRef.value?.clientHeight ?? 0
if (bodyHeight <= 0) return
const firstRow = previewFileBodyRef.value?.querySelector('.preview-file-row')
const rowHeight = firstRow?.getBoundingClientRect().height ?? 46
const pageSize = Math.max(1, Math.floor(bodyHeight / rowHeight))
previewPageSize.value = pageSize
const totalPages = Math.max(1, Math.ceil(filteredPreviewItems.value.length / pageSize))
if (previewPage.value > totalPages) {
previewPage.value = totalPages
}
}
// 启动预览列表高度监听
function setupPreviewFileBodyObserver() {
previewFileBodyResizeObserver?.disconnect()
if (!previewFileBodyRef.value || typeof ResizeObserver === 'undefined') return
previewFileBodyResizeObserver = new ResizeObserver(() => {
updatePreviewPageSize()
})
previewFileBodyResizeObserver.observe(previewFileBodyRef.value)
}
watch([() => previewLoaded.value, () => previewVisible.value], ([loaded, visible]) => {
if (loaded && visible) {
nextTick(() => {
setupPreviewFileBodyObserver()
updatePreviewPageSize()
})
} else {
previewFileBodyResizeObserver?.disconnect()
}
})
// 整理文件
async function handleTransfer(item: FileItem, background: boolean = false) {
try {
@@ -1213,7 +1232,7 @@ async function handleTransferLog(logid: number, background: boolean = false) {
function handleProgressMessage(event: MessageEvent) {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressText.value = progress.text_i18n || progress.text
progressValue.value = progress.value
}
}
@@ -1303,7 +1322,6 @@ onMounted(async () => {
onUnmounted(() => {
stopLoadingProgress()
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
previewFileBodyResizeObserver?.disconnect()
})
</script>
@@ -1671,7 +1689,7 @@ onUnmounted(() => {
</div>
</div>
<div class="reorganize-preview-list">
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
<div v-if="pagedPreviewRows.length" class="preview-file-body">
<div
v-for="(item, index) in pagedPreviewRows"
:key="`${item.source}-${item.target}-${index}`"
@@ -1732,12 +1750,14 @@ onUnmounted(() => {
v-if="mediaSource === 'themoviedb'"
v-model="transferForm.tmdbid"
@close="mediaSelectorDialog = false"
@select="handleMediaSelected"
:type="mediaSource"
/>
<MediaIdSelector
v-else
v-model="transferForm.doubanid"
@close="mediaSelectorDialog = false"
@select="handleMediaSelected"
:type="mediaSource"
/>
</VDialog>
@@ -1894,6 +1914,8 @@ onUnmounted(() => {
.preview-overview-card {
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.5rem;
gap: 0.375rem;
min-inline-size: 0;
padding-block: 0.875rem;
@@ -1919,6 +1941,8 @@ onUnmounted(() => {
.preview-custom-words {
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.5rem;
gap: 0.75rem;
padding-block: 0.875rem;
padding-inline: 1rem;
@@ -1970,8 +1994,12 @@ onUnmounted(() => {
}
.preview-custom-words__chip {
block-size: auto !important;
max-inline-size: 100%;
min-block-size: 1.5rem;
padding-block: 0.25rem;
white-space: normal;
word-break: break-all;
}
.reorganize-preview-pane__scroll {
@@ -2011,9 +2039,9 @@ onUnmounted(() => {
flex: 0 0 auto;
flex-direction: column;
margin-block-end: 1.5rem;
margin-inline: 1.5rem;
min-block-size: 0;
min-inline-size: 0;
padding-inline: 1.5rem;
}
.preview-file-body {
@@ -2024,13 +2052,13 @@ onUnmounted(() => {
gap: 0.75rem;
min-block-size: 0;
min-inline-size: 0;
padding-block: 1rem;
padding-inline: 1rem;
}
.preview-file-row {
display: grid;
align-items: center;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.5rem;
gap: 0.875rem;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
min-block-size: 5.25rem;
@@ -2039,10 +2067,6 @@ onUnmounted(() => {
padding-inline: 1rem;
}
.preview-file-row + .preview-file-row {
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.preview-file-row--failed {
background: rgba(var(--v-theme-error), 0.04);
}
@@ -2168,7 +2192,7 @@ onUnmounted(() => {
.reorganize-preview-list {
margin-block-end: 1rem;
margin-inline: 1rem;
padding-inline: 1rem;
}
}

View File

@@ -7,6 +7,7 @@ import { useUserStore, useGlobalSettingsStore } from '@/stores'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { VDialog, VMenu } from 'vuetify/components'
import { buildUserPermissionContext, hasPermission, filterMenusByPermission } from '@/utils/permission'
// 显示器宽度
@@ -15,10 +16,16 @@ const display = useDisplay()
// 多语言支持
const { t } = useI18n()
// 定义props接收modelValue
const props = defineProps<{
modelValue: boolean
}>()
// 定义 props接收浮层状态及是否显示响应式入口。
const props = withDefaults(
defineProps<{
modelValue: boolean
showActivator?: boolean
}>(),
{
showActivator: false,
},
)
// 路由
const router = useRouter()
@@ -78,16 +85,33 @@ const dialog = computed({
set: val => emit('update:modelValue', val),
})
// 桌面使用锚定下拉,小屏继续使用原有搜索弹窗。
const searchOverlay = computed(() => (display.mdAndUp.value ? VMenu : VDialog))
const searchOverlayProps = computed(() =>
display.mdAndUp.value
? {
closeOnContentClick: false,
location: 'bottom start' as const,
offset: 8,
scrollStrategy: 'reposition' as const,
}
: {
fullscreen: true,
maxWidth: '40rem',
scrollable: true,
},
)
// 搜索词
const searchWord = ref<string | null>(null)
// ref
const searchWordInput = ref<HTMLElement | null>(null)
// 当前尺寸下可见的搜索输入框。
const searchWordInput = ref<HTMLInputElement | null>(null)
// 近期搜索词条
const recentSearches = ref<string[]>([])
// 检测操作系统是否是Mac
/** 检测操作系统是否为 macOS。 */
function isMac() {
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
}
@@ -95,7 +119,7 @@ function isMac() {
// 计算属性:根据操作系统显示不同的按键提示
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
// 保存近期搜索到本地
/** 将有效关键词保存近期搜索记录。 */
function saveRecentSearches(keyword: string) {
if (!keyword) return
if (recentSearches.value.includes(keyword)) return
@@ -103,7 +127,7 @@ function saveRecentSearches(keyword: string) {
localStorage.setItem('MP_RecentSearches', JSON.stringify(recentSearches.value))
}
// 从本地加载近期搜索
/** 从本地存储加载并裁剪近期搜索记录。 */
function loadRecentSearches() {
const recentSearchesStr = localStorage.getItem('MP_RecentSearches')
if (recentSearchesStr) {
@@ -115,7 +139,7 @@ function loadRecentSearches() {
}
}
// 所有菜单功能
/** 获取可参与全局搜索的导航菜单和设置入口。 */
function getMenus(): NavMenu[] {
let menus: NavMenu[] = []
// 导航菜单
@@ -129,6 +153,7 @@ function getMenus(): NavMenu[] {
header: item.header,
admin: item.admin,
permission: item.permission,
feature: item.feature,
}),
)
// 设置标签页
@@ -170,7 +195,7 @@ const matchedMenuItems = computed(() => {
// 所有插件(已安装)
const pluginItems = ref<Plugin[]>([])
// 获取插件列表数据
/** 加载已安装插件,供搜索结果匹配。 */
async function fetchInstalledPlugins() {
try {
pluginItems.value = await api.get('plugin/', {
@@ -194,7 +219,7 @@ const matchedPluginItems = computed(() => {
})
})
// 获取订阅列表数据
/** 加载订阅列表,供搜索结果匹配。 */
async function fetchSubscribes() {
try {
SubscribeItems.value = await api.get('subscribe/')
@@ -203,7 +228,7 @@ async function fetchSubscribes() {
}
}
// 从接口加载用户站点偏好设置
/** 从接口加载用户站点搜索偏好。 */
const loadUserSitePreferences = async () => {
try {
const result = await api.get('system/setting/public/IndexerSites')
@@ -216,7 +241,7 @@ const loadUserSitePreferences = async () => {
}
}
// 查询所有站点
/** 查询所有启用站点,并初始化默认选择。 */
async function queryAllSites() {
try {
const data: Site[] = await api.get('site/')
@@ -231,7 +256,7 @@ async function queryAllSites() {
}
}
// 打开站点选择对话框
/** 打开指定资源类型的站点选择对话框。 */
const openSiteDialog = (type: 'torrent' | 'subtitle' = 'torrent') => {
siteSearchType.value = type
chooseSiteDialog.value = true
@@ -247,7 +272,7 @@ const matchedSubscribeItems = computed(() => {
})
})
// 搜索多站点
/** 使用选中的站点执行当前资源类型搜索。 */
function searchSites(sites: number[]) {
chooseSiteDialog.value = false
selectedSites.value = sites
@@ -258,7 +283,7 @@ function searchSites(sites: number[]) {
searchTorrent()
}
// 搜索资源
/** 使用当前关键词搜索站点资源。 */
function searchTorrent() {
if (!searchWord.value || !hasSearchPermission.value) return
// 记录搜索词
@@ -273,12 +298,10 @@ function searchTorrent() {
sites: selectedSites.value.join(','),
},
})
// 关闭搜索对话框
dialog.value = false
emit('close')
closeSearch()
}
// 搜索字幕资源
/** 使用当前关键词搜索字幕资源。 */
function searchSubtitle() {
if (!searchWord.value || !hasSearchPermission.value) return
saveRecentSearches(searchWord.value)
@@ -291,11 +314,10 @@ function searchSubtitle() {
sites: selectedSites.value.join(','),
},
})
dialog.value = false
emit('close')
closeSearch()
}
// 跳转媒体搜索页面
/** 跳转到指定类型的媒体搜索结果页。 */
function searchMedia(searchType: string) {
// 搜索类型 media/person
if (!searchWord.value || !hasDiscoveryPermission.value) return
@@ -307,10 +329,10 @@ function searchMedia(searchType: string) {
type: searchType,
},
})
emit('close')
closeSearch()
}
// 跳转到历史记录页面
/** 跳转到包含当前关键词的历史记录页。 */
function searchHistory() {
if (!searchWord.value) return
saveRecentSearches(searchWord.value)
@@ -320,10 +342,10 @@ function searchHistory() {
search: searchWord.value,
},
})
emit('close')
closeSearch()
}
// 跳转到订阅分享页面
/** 跳转到包含当前关键词的订阅分享页。 */
function searchSubscribeShares() {
if (!searchWord.value) return
saveRecentSearches(searchWord.value)
@@ -333,10 +355,10 @@ function searchSubscribeShares() {
keyword: searchWord.value,
},
})
emit('close')
closeSearch()
}
// 跳转插件页面
/** 打开匹配插件的已安装详情。 */
function showPlugin(pluginId: string) {
router.push({
path: `/plugins/`,
@@ -345,16 +367,16 @@ function showPlugin(pluginId: string) {
id: pluginId,
},
})
emit('close')
closeSearch()
}
// 跳转菜单页面
/** 跳转到匹配的功能菜单。 */
function goPage(to: string) {
router.push(to)
emit('close')
closeSearch()
}
// 跳转订阅页面
/** 根据订阅类型跳转到对应订阅详情。 */
function goSubscribe(subscribe: Subscribe) {
if (subscribe.type === '电影') {
router.push({
@@ -371,13 +393,29 @@ function goSubscribe(subscribe: Subscribe) {
},
})
}
closeSearch()
}
/** 关闭搜索浮层并通知外层入口同步状态。 */
function closeSearch() {
dialog.value = false
emit('close')
}
/** 聚焦当前可见输入框,供快捷键入口复用。 */
function focusSearchInput() {
searchWordInput.value?.focus()
}
watch(dialog, async isOpen => {
if (!isOpen) return
await nextTick()
focusSearchInput()
})
defineExpose({ focusSearchInput })
onMounted(() => {
setTimeout(() => {
searchWordInput.value?.focus()
}, 500)
// 根据权限加载不同的数据
if (hasAdminPermission.value) {
fetchInstalledPlugins()
@@ -395,10 +433,20 @@ onMounted(() => {
})
</script>
<template>
<VDialog v-model="dialog" max-width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard class="search-dialog">
<!-- 搜索输入框区域 -->
<div class="search-header">
<component :is="searchOverlay" v-model="dialog" v-bind="searchOverlayProps">
<template v-if="showActivator" #activator="{ props: activatorProps }">
<!-- 小屏入口保持图标形态点击后打开全屏搜索弹窗 -->
<IconBtn
v-if="!display.mdAndUp.value"
v-bind="activatorProps"
class="search-icon-trigger"
:aria-label="t('dialog.searchBar.openSearch')"
>
<VIcon class="search-icon-trigger__icon" icon="mdi-magnify" />
</IconBtn>
<!-- 中屏及以上常驻搜索输入框输入时直接在下方展示同一组选项 -->
<div v-else v-bind="activatorProps" class="search-desktop-activator">
<div class="search-input-wrapper">
<VIcon icon="mdi-magnify" size="22" class="search-input-icon" />
<input
@@ -408,7 +456,26 @@ onMounted(() => {
class="search-native-input"
:placeholder="t('dialog.searchBar.searchPlaceholder')"
@keydown.enter="searchMedia('media')"
@keydown.escape="emit('close')"
@keydown.escape.stop="closeSearch"
/>
<kbd class="search-shortcut-badge">{{ metaKey }}</kbd>
</div>
</div>
</template>
<VCard class="search-dialog" :class="{ 'search-dialog--dropdown': display.mdAndUp.value }">
<!-- 弹窗模式保留原有搜索输入区 -->
<div v-if="!display.mdAndUp.value" class="search-header">
<div class="search-input-wrapper">
<VIcon icon="mdi-text" size="22" class="search-input-icon" />
<input
ref="searchWordInput"
v-model="searchWord"
type="text"
class="search-native-input"
:placeholder="t('dialog.searchBar.searchPlaceholder')"
@keydown.enter="searchMedia('media')"
@keydown.escape.stop="closeSearch"
/>
<VBtn icon size="small" variant="text" class="search-submit-btn" @click="searchMedia('media')">
<VIcon icon="mdi-magnify" size="20" />
@@ -697,26 +764,14 @@ onMounted(() => {
</div>
</div>
<!-- 底部区域 -->
<!-- 桌面端快捷键提示 -->
<div v-if="display.mdAndUp.value" class="search-footer">
<div class="shortcut-group">
<kbd>Esc</kbd>
<span class="shortcut-label">{{ t('dialog.searchBar.escClose') }}</span>
</div>
<div class="shortcut-group">
<kbd>{{ metaKey }}</kbd>
<span class="shortcut-label">{{ t('dialog.searchBar.openSearch') }}</span>
</div>
</div>
<!-- 移动端关闭图标 -->
<div v-else class="search-footer-mobile">
<VBtn icon variant="tonal" @click="emit('close')">
<!-- 弹窗形态保留底部关闭按钮桌面下拉不显示页脚 -->
<div v-if="!display.mdAndUp.value" class="search-footer-mobile">
<VBtn icon variant="tonal" @click="closeSearch">
<VIcon icon="mdi-close" size="20" />
</VBtn>
</div>
</VCard>
</VDialog>
</component>
<!-- 站点选择对话框 -->
<SearchSiteDialog
@@ -731,12 +786,46 @@ onMounted(() => {
</template>
<style scoped>
/* stylelint-disable no-descending-specificity */
.search-dialog {
display: flex;
overflow: hidden;
flex-direction: column;
}
.search-dialog--dropdown {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
inline-size: min(32rem, calc(100vw - 2rem));
max-block-size: min(72vh, 42rem);
}
.search-desktop-activator {
flex: 0 1 22rem;
inline-size: clamp(15rem, 32vw, 22rem);
max-inline-size: calc(100vw - 10rem);
}
.search-desktop-activator .search-input-wrapper {
border-color: rgba(var(--v-theme-on-surface), 0.12);
background: rgba(var(--v-theme-surface), 0.72);
block-size: 42px;
padding-inline: 14px 8px;
}
html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
.v-theme--transparent .search-desktop-activator .search-input-wrapper {
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
}
.search-icon-trigger {
flex: 0 0 auto;
}
.search-icon-trigger__icon {
transform: scaleX(-1);
}
/* 搜索头部区域 */
.search-header {
padding-block: 16px 12px;
@@ -748,18 +837,18 @@ onMounted(() => {
display: flex;
align-items: center;
border: 1.5px solid rgba(var(--v-theme-primary), 0.4);
border-radius: 28px;
border-radius: var(--app-vuetify-rounded-pill);
background-color: rgba(var(--v-theme-surface-variant), 0.04);
block-size: 48px;
padding-inline: 14px 6px;
transition:
border-color 0.2s ease,
border-radius 0.2s ease,
box-shadow 0.2s ease;
}
.search-input-wrapper:focus-within {
border-color: rgb(var(--v-theme-primary));
box-shadow: 0 0 0 3px rgba(var(--v-theme-on-surface), 0.04);
}
.search-input-icon {
@@ -797,13 +886,24 @@ onMounted(() => {
background-color: rgba(var(--v-theme-on-surface), 0.12) !important;
}
.search-shortcut-badge {
flex-shrink: 0;
margin-inline-start: 10px;
white-space: nowrap;
}
/* 主内容区域 */
.search-content {
max-block-size: 600px;
min-block-size: 150px;
overscroll-behavior-y: contain;
overflow-y: auto;
}
.search-dialog--dropdown .search-content {
max-block-size: min(58vh, 34rem);
}
.search-list {
background: transparent !important;
}
@@ -848,28 +948,13 @@ onMounted(() => {
.recent-searches-section {
inline-size: 100%;
padding-block-start: 1rem;
}
.empty-hint {
text-align: center;
}
/* 底部快捷键提示 */
.search-footer {
display: flex;
align-items: center;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
gap: 16px;
padding-block: 10px;
padding-inline: 16px;
}
.shortcut-group {
display: flex;
align-items: center;
gap: 6px;
}
kbd {
border: 1px solid rgba(var(--v-theme-on-surface), 0.15);
border-radius: 5px;
@@ -883,11 +968,6 @@ kbd {
padding-inline: 6px;
}
.shortcut-label {
color: rgba(var(--v-theme-on-surface), 0.45);
font-size: 12px;
}
/* 移动端底部关闭图标 */
.search-footer-mobile {
display: flex;
@@ -912,10 +992,5 @@ kbd {
.search-native-input {
font-size: 14px;
}
.search-footer {
padding-block: 8px;
padding-inline: 12px;
}
}
</style>

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import type { Site } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 多语言支持
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
const props = defineProps({
sites: {
type: Array as PropType<Site[]>,
@@ -84,7 +88,7 @@ const filteredSites = computed(() => {
</script>
<template>
<!-- Site Selection Dialog -->
<VDialog max-width="40rem" fullscreen-mobile>
<VDialog max-width="40rem" :fullscreen="!display.smAndUp.value">
<VCard class="site-dialog">
<VCardItem>
<template #prepend>

View File

@@ -42,8 +42,14 @@ function allLoggingDownloadUrl() {
</script>
<template>
<VDialog v-if="visible" v-model="visible" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialog
v-if="visible"
v-model="visible"
:scrollable="display.mdAndUp.value"
max-width="80rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard class="logging-dialog-card">
<VDialogCloseBtn v-model="visible" />
<VCardItem>
<VCardTitle class="d-inline-flex">
@@ -66,9 +72,29 @@ function allLoggingDownloadUrl() {
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<VCardText class="logging-dialog-content pa-0">
<LoggingView logfile="moviepilot.log" />
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
@media (width <= 960px) {
.logging-dialog-card {
display: flex;
flex-direction: column;
overflow: hidden;
block-size: 100%;
}
.logging-dialog-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
inline-size: 100%;
min-block-size: 0;
overflow: hidden;
}
}
</style>

View File

@@ -1,139 +0,0 @@
<script setup lang="ts">
import api from '@/api'
import { clearUnreadMessages } from '@/utils/badge'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
const MessageView = defineAsyncComponent(() => import('@/views/system/MessageView.vue'))
type MessageViewExpose = {
pauseSSE?: () => void
resumeSSE?: () => void
refreshLatestMessages?: () => Promise<void> | void
forceScrollToEnd?: () => void
}
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 输入消息
const user_message = ref('')
// 发送按钮是否可用
const sendButtonDisabled = ref(false)
// 消息视图引用
const messageViewRef = ref<MessageViewExpose | null>(null)
/** 发送 Web 消息。 */
async function sendMessage() {
const messageText = user_message.value.trim()
if (!messageText) {
return
}
try {
sendButtonDisabled.value = true
await api.post(`message/web?text=${encodeURIComponent(messageText)}`)
user_message.value = ''
messageViewRef.value?.forceScrollToEnd?.()
} catch (error) {
console.error(error)
} finally {
sendButtonDisabled.value = false
}
}
/** 清除未读消息计数和桌面角标。 */
function clearUnreadMessageState() {
window.setTimeout(() => {
void clearUnreadMessages()
}, 500)
}
watch(visible, async newValue => {
if (newValue) {
await nextTick()
messageViewRef.value?.resumeSSE?.()
clearUnreadMessageState()
return
}
messageViewRef.value?.pauseSSE?.()
})
onMounted(async () => {
await nextTick()
clearUnreadMessageState()
})
onUnmounted(() => {
messageViewRef.value?.pauseSSE?.()
})
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-message" class="me-2" />
{{ t('shortcut.message.subtitle') }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText>
<MessageView ref="messageViewRef" />
</VCardText>
<VDivider />
<VCardActions class="pa-4">
<div class="d-flex w-100 gap-2">
<VTextField
v-model="user_message"
variant="outlined"
hide-details
density="compact"
:placeholder="t('common.inputMessage')"
@keyup.enter="sendMessage"
/>
<VBtn
variant="elevated"
:disabled="sendButtonDisabled"
@click="sendMessage"
:loading="sendButtonDisabled"
color="primary"
prepend-icon="mdi-send"
>{{ t('common.send') }}
</VBtn>
</div>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { useDisplay } from 'vuetify'
import { useDisplay, useTheme } from 'vuetify'
// 显示器宽度
const display = useDisplay()
const theme = useTheme()
// 输入参数
const props = withDefaults(
@@ -39,21 +40,43 @@ const visible = computed({
if (!value) emit('close')
},
})
const isFullscreen = computed(() => !display.mdAndUp.value)
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
const isSchedulerDialog = computed(() => props.cardClass.split(/\s+/).includes('scheduler-shortcut-dialog-card'))
// 透明主题下仅定时服务全屏弹窗取消外层 VCard 的背景和模糊,避免整屏磨砂遮住界面。
const cardClasses = computed(() => [
props.cardClass,
{
'scheduler-shortcut-dialog-card--transparent':
isFullscreen.value && isTransparentTheme.value && isSchedulerDialog.value,
},
])
// 仅系统健康检查弹窗需要在全屏时取消固定高度,避免其它快捷弹窗被误伤。
const bodyClasses = computed(() => [
props.bodyClass,
{
'system-health-dialog-body--fullscreen':
isFullscreen.value && props.bodyClass.split(/\s+/).includes('system-health-dialog-body'),
},
])
</script>
<template>
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="!display.mdAndUp.value">
<VCard :class="props.cardClass">
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="isFullscreen">
<VCard :class="cardClasses">
<VCardItem>
<VCardTitle>
<VIcon :icon="props.icon" class="me-2" />
{{ props.title }}
</VCardTitle>
<VCardSubtitle v-if="props.subtitle">{{ props.subtitle }}</VCardSubtitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText :class="props.bodyClass">
<VCardText :class="bodyClasses">
<Component :is="props.view" v-bind="props.viewProps" />
</VCardText>
</VCard>
@@ -61,8 +84,6 @@ const visible = computed({
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.system-health-dialog-card {
display: flex;
overflow: hidden;
@@ -78,7 +99,47 @@ const visible = computed({
min-block-size: 0;
}
:global(.v-dialog--fullscreen) .system-health-dialog-body {
.system-health-dialog-body--fullscreen {
block-size: auto;
}
@media (max-width: 959.98px) {
.words-shortcut-dialog-card {
display: flex;
overflow: hidden !important;
flex-direction: column;
}
.words-shortcut-dialog-body {
display: flex;
overflow: hidden !important;
flex: 1 1 auto;
inline-size: 100%;
min-block-size: 0;
padding: 0 !important;
}
.scheduler-shortcut-dialog-card--transparent {
background: transparent !important;
background-color: transparent !important;
backdrop-filter: none !important;
}
.cache-shortcut-dialog-card {
display: flex;
overflow: hidden;
flex-direction: column;
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
}
.cache-shortcut-dialog-body {
display: flex;
overflow: hidden !important;
flex: 1 1 auto;
inline-size: 100%;
min-block-size: 0;
padding: 0 !important;
}
}
</style>

View File

@@ -2,7 +2,7 @@
import api from '@/api'
import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
import { formatFileSize } from '@core/utils/formatters'
import { useDisplay } from 'vuetify'
import { useDisplay, useTheme } from 'vuetify'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useI18n } from 'vue-i18n'
@@ -13,6 +13,9 @@ const { t, locale } = useI18n()
// 响应式断点
const display = useDisplay()
// 当前主题
const theme = useTheme()
// 输入参数
const props = defineProps({
site: Object as PropType<Site>,
@@ -92,9 +95,13 @@ const resultSummaryText = computed(() => {
// 是否小屏幕
const isMobileLayout = computed(() => display.smAndDown.value)
// 是否透明主题
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
// 移动端分页数据
const mobileResourceList = computed(() => resourceDataList.value)
// 获取资源项唯一标识
function getResourceItemKey(item: TorrentInfo, index: number) {
return item.page_url || item.enclosure || `${item.title}-${item.pubdate || ''}-${index}`
}
@@ -188,10 +195,12 @@ watch(
},
)
// 切换移动端搜索栏
function toggleMobileSearch() {
mobileSearchExpanded.value = !mobileSearchExpanded.value
}
// 关闭移动端搜索栏
function closeMobileSearch() {
mobileSearchExpanded.value = false
}
@@ -475,26 +484,30 @@ onMounted(() => {
:items="mobileResourceList"
:columns="1"
:gap="12"
:estimated-item-height="320"
:estimated-item-height="220"
:overscan-rows="5"
:get-item-key="getResourceItemKey"
>
<template #default="{ item }">
<VCard>
<VCardText class="pa-4">
<VCard
class="site-resource-card"
:class="{ 'site-resource-card--transparent': isTransparentTheme }"
variant="flat"
>
<VCardText class="pa-3">
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
<div class="text-body-1 font-weight-medium text-high-emphasis">
<div class="site-resource-card__title text-body-1 font-weight-medium text-high-emphasis">
{{ item.title }}
</div>
<div
v-if="item.description"
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
class="site-resource-card__description mt-1 text-body-2 text-medium-emphasis"
>
{{ item.description }}
</div>
</button>
<div class="mt-3">
<div class="site-resource-card__chips mt-2">
<VChip
v-if="item.hit_and_run"
variant="elevated"
@@ -533,47 +546,82 @@ onMounted(() => {
</VChip>
</div>
<div class="site-resource-card__meta mt-4">
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
<!-- 移动端在操作区前展示关键资源指标方便点击前快速判断 -->
<div class="site-resource-card__summary mt-3">
<div class="site-resource-card__stat">
<VIcon icon="mdi-clock-outline" size="15" />
<span>{{ item.date_elapsed || item.pubdate || '-' }}</span>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
<div class="site-resource-card__stat">
<VIcon icon="mdi-harddisk" size="15" />
<span>{{ formatFileSize(item.size) }}</span>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
<div class="site-resource-card__stat site-resource-card__stat--success">
<VIcon icon="mdi-arrow-up" size="15" />
<span>{{ item.seeders ?? '-' }}</span>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
<div class="site-resource-card__stat site-resource-card__stat--warning">
<VIcon icon="mdi-arrow-down" size="15" />
<span>{{ item.peers ?? '-' }}</span>
</div>
</div>
<div class="site-resource-card__actions mt-4">
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
<!-- 下载保留文本其它低频操作改为图标按钮并保持同一行 -->
<div class="site-resource-card__actions mt-2">
<VBtn
color="primary"
variant="flat"
class="site-resource-card__download-btn"
prepend-icon="mdi-download"
@click="addDownload(item)"
>
{{ t('actionStep.addDownload') }}
</VBtn>
<div class="site-resource-card__secondary-actions mt-2">
<VBtn
variant="tonal"
prepend-icon="mdi-open-in-new"
@click="openTorrentDetail(item.page_url || '')"
>
{{ t('common.viewDetails') }}
</VBtn>
<VBtn
v-if="item.enclosure?.startsWith('http')"
variant="tonal"
prepend-icon="mdi-tray-arrow-down"
@click="downloadTorrentFile(item.enclosure)"
>
{{ t('dialog.siteResource.downloadTorrent') }}
</VBtn>
</div>
<VTooltip :text="t('common.viewDetails')" location="top">
<template #activator="{ props: tooltipProps }">
<VBtn
v-bind="tooltipProps"
icon
variant="tonal"
color="primary"
class="site-resource-card__icon-btn"
:aria-label="t('common.viewDetails')"
@click="openTorrentDetail(item.page_url || '')"
>
<VIcon icon="mdi-open-in-new" />
</VBtn>
</template>
</VTooltip>
<VTooltip
v-if="item.enclosure?.startsWith('http')"
:text="t('dialog.siteResource.downloadTorrent')"
location="top"
>
<template #activator="{ props: tooltipProps }">
<VBtn
v-bind="tooltipProps"
icon
variant="tonal"
color="primary"
class="site-resource-card__icon-btn"
:aria-label="t('dialog.siteResource.downloadTorrent')"
@click="downloadTorrentFile(item.enclosure)"
>
<VIcon icon="mdi-file-download-outline" />
</VBtn>
</template>
</VTooltip>
<VBtn
v-else
icon
variant="tonal"
color="primary"
disabled
class="site-resource-card__icon-btn"
:aria-label="t('dialog.siteResource.downloadTorrent')"
>
<VIcon icon="mdi-file-download-outline" />
</VBtn>
</div>
</VCardText>
</VCard>
@@ -702,44 +750,107 @@ onMounted(() => {
white-space: nowrap;
}
.site-resource-card {
--site-resource-card-bg:
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.94)),
radial-gradient(circle at top right, rgba(var(--v-theme-primary), 0.08), transparent 34%);
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
background: var(--site-resource-card-bg);
}
.site-resource-card--transparent {
--site-resource-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity));
backdrop-filter: blur(var(--transparent-blur));
}
.site-resource-card__summary {
display: grid;
gap: 0.35rem;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(2.5rem, 0.62fr) minmax(2.5rem, 0.62fr);
align-items: center;
}
.site-resource-card__stat {
display: inline-flex;
overflow: hidden;
align-items: center;
justify-content: center;
gap: 0.22rem;
border-radius: 6px;
background: rgba(var(--v-theme-on-surface), 0.05);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 0.74rem;
font-weight: 600;
line-height: 1;
min-block-size: 1.65rem;
min-inline-size: 0;
padding-inline: 0.4rem;
}
.site-resource-card__stat span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-resource-card__stat--success {
color: rgb(var(--v-theme-success));
}
.site-resource-card__stat--warning {
color: rgb(var(--v-theme-warning));
}
.site-resource-card__title {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-height: 1.38;
}
.site-resource-card__description {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
-webkit-line-clamp: 2;
line-height: 1.35;
}
.site-resource-card__meta {
.site-resource-card__chips {
max-block-size: 4.75rem;
overflow: hidden;
}
.site-resource-card__actions {
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
grid-template-columns: minmax(0, 1fr) 2.5rem 2.5rem;
align-items: center;
}
.site-resource-card__meta-item {
background: rgba(var(--v-theme-surface), 0.78);
min-block-size: 0;
padding-block: 0.55rem;
padding-inline: 0.65rem;
.site-resource-card__download-btn {
min-block-size: 2.5rem;
min-inline-size: 0;
box-shadow: 0 6px 16px rgba(var(--v-theme-primary), 0.17);
}
.site-resource-card__meta-item :deep(.text-caption) {
font-size: 0.72rem !important;
line-height: 1.2;
.site-resource-card__download-btn :deep(.v-btn__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-resource-card__meta-item :deep(.text-body-2) {
font-size: 0.82rem !important;
line-height: 1.25;
.site-resource-card__icon-btn {
block-size: 2.5rem;
inline-size: 2.5rem;
min-inline-size: 2.5rem;
}
.site-resource-card__secondary-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.site-resource-card__secondary-actions :deep(.v-btn) {
flex: 1 1 12rem;
.site-resource-card__icon-btn :deep(.v-btn__content) {
font-size: 1.05rem;
}
@media (width >= 960px) {
@@ -761,4 +872,14 @@ onMounted(() => {
min-block-size: 2.5rem;
}
}
@media (width <= 420px) {
.site-resource-card__summary {
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.95fr) minmax(2.3rem, 0.55fr) minmax(2.3rem, 0.55fr);
}
.site-resource-card__stat {
padding-inline: 0.3rem;
}
}
</style>

View File

@@ -64,8 +64,10 @@ const historyChartOptions = computed(() => {
enabled: true,
},
zoom: {
autoScaleYaxis: true,
enabled: false,
allowMouseWheelZoom: false,
},
selection: { enabled: false },
},
theme: {
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
@@ -156,8 +158,10 @@ const seedingChartOptions = computed(() => {
background: currentTheme.value.surface, // 新增背景色同步
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
zoom: {
autoScaleYaxis: true,
enabled: false,
allowMouseWheelZoom: false,
},
selection: { enabled: false },
},
theme: {
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式

View File

@@ -9,6 +9,18 @@ import { useI18n } from 'vue-i18n'
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { formatSeason } from '@/@core/utils/formatters'
// 从变更请求异常中提取可展示消息,并为非标准错误提供稳定兜底。
function getRequestErrorMessage(error: unknown, fallback: string) {
if (typeof error === 'object' && error !== null) {
const responseMessage = (error as { response?: { data?: { message?: unknown } } }).response?.data?.message
if (typeof responseMessage === 'string' && responseMessage) return responseMessage
}
if (error instanceof Error && error.message) return error.message
return fallback
}
// i18n
const { t } = useI18n()
const userStore = useUserStore()
@@ -96,6 +108,19 @@ const seasonItems = ref(
})),
)
function getSubscribeDisplayName() {
const name = subscribeForm.value.name || ''
const season = subscribeForm.value.season
if (season === null || season === undefined) return name
return `${name} ${formatSeason(season.toString())}`
}
function getDefaultSubscribeTypeName() {
if (props.type === '电影') return t('mediaType.movie')
if (props.type === '电视剧') return t('mediaType.tv')
return props.type ?? ''
}
// 剧集组选项属性
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
return {
@@ -154,18 +179,30 @@ const filterRuleGroupOptions = computed(() => {
// 调用API修改订阅
async function updateSubscribeInfo() {
const displayName = getSubscribeDisplayName()
try {
const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value)
// 提示
if (result.success) {
$toast.success(`${subscribeForm.value.name} 更新成功!`)
$toast.success(t('dialog.subscribeEdit.updateSuccess', { name: displayName }))
// 通知父组件刷新
emit('save')
emit('save', subscribeForm.value)
} else {
$toast.error(`${subscribeForm.value.name} 更新失败:${result.message}`)
$toast.error(
t('dialog.subscribeEdit.updateFailed', {
name: displayName,
message: result.message ?? t('subscribe.requestFailed'),
}),
)
}
} catch (e) {
console.log(e)
$toast.error(
t('dialog.subscribeEdit.updateFailed', {
name: displayName,
message: getRequestErrorMessage(e, t('subscribe.requestFailed')),
}),
)
}
}
@@ -173,19 +210,33 @@ async function updateSubscribeInfo() {
async function saveDefaultSubscribeConfig() {
if (!canAdmin.value) return
const typeName = getDefaultSubscribeTypeName()
try {
let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)
if (result.success) $toast.success(`${props.type}订阅默认规则保存成功`)
else $toast.error(`${props.type}订阅默认规则保存失败!`)
// 通知父组件刷新
emit('save')
if (result.success) {
$toast.success(t('dialog.subscribeEdit.defaultSaveSuccess', { type: typeName }))
// 通知父组件刷新
emit('save', subscribeForm.value)
} else {
$toast.error(
t('dialog.subscribeEdit.defaultSaveFailed', {
type: typeName,
message: result.message ?? t('subscribe.requestFailed'),
}),
)
}
} catch (error) {
console.log(error)
$toast.error(
t('dialog.subscribeEdit.defaultSaveFailed', {
type: typeName,
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
}),
)
}
}
@@ -254,16 +305,28 @@ async function removeSubscribe() {
})
if (!isConfirmed) return
const displayName = getSubscribeDisplayName()
try {
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
if (result.success) {
$toast.success(`订阅 ${subscribeForm.value.name} 已取消!`)
$toast.success(`${displayName} ${t('subscribe.cancelSuccess')}`)
// 通知父组件刷新
emit('remove')
} else {
$toast.error(
`${displayName} ${t('subscribe.cancelFailed', {
message: result.message ?? t('subscribe.requestFailed'),
})}`,
)
}
} catch (e) {
console.log(e)
$toast.error(
`${displayName} ${t('subscribe.cancelFailed', {
message: getRequestErrorMessage(e, t('subscribe.requestFailed')),
})}`,
)
}
}
@@ -281,8 +344,10 @@ async function loadDownloadDirectories() {
// 保存目录下拉框
const targetDirectories = computed(() => {
// 去重后的下载目录
return downloadDirectories.value.map(item => item.download_path)
const paths = downloadDirectories.value
.map(item => item.download_path?.trim())
.filter((path): path is string => Boolean(path))
return [...new Set(paths)]
})
// 仅电视剧订阅支持全集洗版,电影保持原有洗版逻辑
@@ -317,10 +382,7 @@ onMounted(() => {
{{ props.default ? t('dialog.subscribeEdit.titleDefault') : t('dialog.subscribeEdit.titleEdit') }}
</VCardTitle>
<VCardSubtitle v-if="!props.default">
{{ subscribeForm.name }}
<span v-if="subscribeForm.season">
{{ t('dialog.subscribeEdit.seasonFormat', { number: subscribeForm.season }) }}
</span>
{{ getSubscribeDisplayName() }}
</VCardSubtitle>
<VCardSubtitle v-else>
{{ props.type }}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
type SubscribeMode = 'normal' | 'best_version' | 'best_version_full'
const props = defineProps<{
modelValue?: boolean
type?: string
modes?: SubscribeMode[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'choose', mode: SubscribeMode): void
(e: 'close'): void
}>()
const { t } = useI18n()
const modeItems = computed<SubscribeMode[]>(() =>
props.modes?.length
? props.modes
: props.type === '电视剧'
? ['normal', 'best_version', 'best_version_full']
: ['normal', 'best_version'],
)
const optionMeta: Record<SubscribeMode, { icon: string; title: string }> = {
normal: {
icon: 'mdi-plus-circle-outline',
title: t('dialog.subscribeMode.normal'),
},
best_version: {
icon: 'mdi-refresh',
title:
props.type === '电视剧' ? t('dialog.subscribeMode.bestVersionEpisode') : t('dialog.subscribeMode.bestVersion'),
},
best_version_full: {
icon: 'mdi-shimmer',
title: t('dialog.subscribeMode.bestVersionFull'),
},
}
</script>
<template>
<VDialog :model-value="modelValue" max-width="28rem" @update:model-value="emit('update:modelValue', $event)">
<VCard>
<VCardTitle class="text-lg font-weight-bold px-5 pt-5">
{{ t('dialog.subscribeMode.title') }}
</VCardTitle>
<VDivider />
<VList class="py-2">
<VListItem
v-for="mode in modeItems"
:key="mode"
:prepend-icon="optionMeta[mode].icon"
:title="optionMeta[mode].title"
@click="emit('choose', mode)"
/>
</VList>
<VDialogCloseBtn @click="emit('close')" />
</VCard>
</VDialog>
</template>

View File

@@ -2,12 +2,28 @@
import api from '@/api'
import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
import { PropType } from 'vue'
import NoDataFound from '@/components/NoDataFound.vue'
import NoDataFound from '@/components/states/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
import type { SeasonSubscribeModes, SubscribeMode } from '@/composables/useMediaSubscribe'
import { useDisplay } from 'vuetify'
type SubscribeModeOption = {
icon: string
title: string
value: SubscribeMode
}
type EpisodeGroupOption = {
title: string
subtitle: string
value: string
icon: string
}
// 国际化
const { t } = useI18n()
const { mdAndUp } = useDisplay()
// 定义事件
const emit = defineEmits(['subscribe', 'close'])
@@ -15,6 +31,11 @@ const emit = defineEmits(['subscribe', 'close'])
// 定义输入
const props = defineProps({
media: Object as PropType<MediaInfo>,
selectedSeason: Number,
initialEpisodeGroup: String,
subscribedSeasons: Array as PropType<number[]>,
subscribedSeasonModes: Object as PropType<SeasonSubscribeModes>,
defaultSubscribeMode: String as PropType<SubscribeMode>,
})
// 从 provide 中获取全局设置
@@ -25,8 +46,14 @@ const globalSettings = globalSettingsStore.globalSettings
// 季详情
const seasonInfos = ref<MediaSeason[]>([])
// 选中的订阅季
const seasonsSelected = ref<MediaSeason[]>([])
// 选中的订阅季
const seasonsSelected = ref<number[]>([])
// 各季订阅方式
const seasonModes = ref<Record<number, SubscribeMode>>({})
// 用户已手动选择过模式的季号,入库状态异步刷新时不再覆盖。
const manuallySelectedModeSeasons = ref(new Set<number>())
// 各季缺失状态0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
const seasonsNotExisted = ref<{ [key: number]: number }>({})
@@ -38,35 +65,99 @@ const isRefreshed = ref(false)
const episodeGroups = ref<{ [key: string]: any }[]>([])
// 当前选择剧集组
const episodeGroup = ref('')
const episodeGroup = ref(props.initialEpisodeGroup ?? '')
// 剧集组选项属性
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
return {
title: item.title,
subtitle: item.subtitle,
}
// 剧集组横向轨道
const episodeGroupRail = ref<HTMLElement | null>(null)
// 剧集组轨道左右滚动状态
const canScrollEpisodeGroupsBackward = ref(false)
const canScrollEpisodeGroupsForward = ref(false)
const subscribeModeOptions = computed<SubscribeModeOption[]>(() => [
{
title: t('dialog.subscribeMode.normal'),
value: 'normal',
icon: 'mdi-plus-circle-outline',
},
{
title: t('dialog.subscribeMode.bestVersionEpisode'),
value: 'best_version',
icon: 'mdi-refresh',
},
{
title: t('dialog.subscribeMode.bestVersionFull'),
value: 'best_version_full',
icon: 'mdi-shimmer',
},
])
// 获取订阅模式的主题色。
function getSubscribeModeColor(mode: SubscribeMode) {
if (mode === 'normal') return 'primary'
if (mode === 'best_version') return 'warning'
return 'success'
}
// 校验弹窗输入是否为支持的订阅模式。
function isSubscribeMode(value: unknown): value is SubscribeMode {
return value === 'normal' || value === 'best_version' || value === 'best_version_full'
}
const subscribedSeasonSet = computed(() => new Set(props.subscribedSeasons ?? []))
const selectedSeasonSet = computed(() => new Set(seasonsSelected.value))
const visibleSeasonNumbers = computed(() =>
seasonInfos.value
.map(item => item.season_number)
.filter((season): season is number => season !== null && season !== undefined),
)
const hasSelectionChanges = computed(() => {
const visibleSeasons = new Set(visibleSeasonNumbers.value)
for (const season of visibleSeasons) {
if (subscribedSeasonSet.value.has(season) !== selectedSeasonSet.value.has(season)) return true
if (
subscribedSeasonSet.value.has(season) &&
selectedSeasonSet.value.has(season) &&
(props.subscribedSeasonModes?.[season] ?? 'normal') !== (seasonModes.value[season] ?? 'normal')
) {
return true
}
}
return false
})
const submitButtonText = computed(() => {
if (!hasSelectionChanges.value && seasonsSelected.value.length === 0) return t('dialog.subscribeSeason.selectSeasons')
return t('dialog.subscribeSeason.submit')
})
// 剧集组选项
const episodeGroupOptions = computed(() => {
let options = (episodeGroups.value as { id: string; name: string; group_count: number; episode_count: number }[]).map(
item => {
return {
title: item.name,
subtitle: `${t('dialog.subscribeSeason.seasonCount', { count: item.group_count })} • ${t(
'dialog.subscribeSeason.episodeCount',
{ count: item.episode_count },
)}`,
value: item.id,
}
},
)
const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => {
const options = (
episodeGroups.value as { id: string; name: string; group_count: number; episode_count: number }[]
).map(item => {
return {
title: item.name,
subtitle: `${t('dialog.subscribeSeason.seasonCount', { count: item.group_count })} • ${t(
'dialog.subscribeSeason.episodeCount',
{ count: item.episode_count },
)}`,
value: item.id,
icon: 'mdi-folder-play-outline',
}
})
// 添加不使用选项
options.unshift({
title: t('dialog.subscribeSeason.defaultGroup'),
subtitle: t('dialog.subscribeSeason.seasonCount', { count: seasonInfos.value.length }),
value: '',
icon: 'mdi-layers-outline',
})
return options
})
@@ -89,6 +180,8 @@ async function getEpisodeGroups() {
episodeGroups.value = await api.get(`media/groups/${props.media?.tmdb_id}`)
} catch (error) {
console.error(error)
} finally {
nextTick(updateEpisodeGroupScrollState)
}
}
@@ -125,9 +218,10 @@ async function getGroupSeasons() {
async function checkSeasonsNotExists() {
// 开始处理
try {
let tmpMedia = props.media ?? { episode_group: '' }
if (episodeGroup.value) tmpMedia.episode_group = episodeGroup.value
else tmpMedia.episode_group = ''
const tmpMedia = {
...(props.media ?? {}),
episode_group: episodeGroup.value || '',
}
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', tmpMedia)
if (result) {
result.forEach(item => {
@@ -183,8 +277,141 @@ function getYear(airDate: string) {
return date.getFullYear()
}
// 切换当前剧集组并清空上一组的派生数据。
function setEpisodeGroup(value: string) {
if (episodeGroup.value === value) return
seasonsNotExisted.value = {}
seasonInfos.value = []
episodeGroup.value = value
nextTick(updateEpisodeGroupScrollState)
}
// 刷新剧集组横向轨道的左右滚动按钮状态。
function updateEpisodeGroupScrollState() {
const rail = episodeGroupRail.value
if (!rail) {
canScrollEpisodeGroupsBackward.value = false
canScrollEpisodeGroupsForward.value = false
return
}
const maxScrollLeft = Math.max(rail.scrollWidth - rail.clientWidth, 0)
canScrollEpisodeGroupsBackward.value = rail.scrollLeft > 4
canScrollEpisodeGroupsForward.value = rail.scrollLeft < maxScrollLeft - 4
}
// 按一屏内可辨识的距离横向滚动剧集组轨道。
function scrollEpisodeGroups(direction: 'backward' | 'forward') {
const rail = episodeGroupRail.value
if (!rail) return
rail.scrollBy({
behavior: 'smooth',
left: direction === 'backward' ? -Math.max(rail.clientWidth * 0.72, 240) : Math.max(rail.clientWidth * 0.72, 240),
})
}
// 提交当前剧集组下选中的季及其订阅模式。
function subscribeSeasons() {
emit('subscribe', seasonsSelected.value, seasonsNotExisted.value, episodeGroup.value)
const selectedSeasons = seasonInfos.value.filter(item => {
const seasonNumber = item.season_number ?? null
return seasonNumber !== null && selectedSeasonSet.value.has(seasonNumber)
})
emit(
'subscribe',
selectedSeasons,
seasonsNotExisted.value,
episodeGroup.value,
{ ...seasonModes.value },
visibleSeasonNumbers.value,
)
}
// 写入指定季的订阅模式。
function setSeasonMode(season: number, mode: SubscribeMode) {
seasonModes.value = {
...seasonModes.value,
[season]: mode,
}
}
// 处理用户手动切换指定季的订阅模式。
function updateSeasonMode(season: number, mode: unknown) {
if (!isSubscribeMode(mode)) return
manuallySelectedModeSeasons.value.add(season)
setSeasonMode(season, mode)
}
// 根据入库状态和系统配置计算指定季的默认订阅模式。
function getDefaultSeasonMode(season: number) {
if (!seasonsNotExisted.value[season]) return 'best_version_full'
return props.defaultSubscribeMode ?? 'normal'
}
// 确保指定季已初始化订阅模式。
function ensureSeasonMode(season: number) {
if (!seasonModes.value[season]) setSeasonMode(season, props.subscribedSeasonModes?.[season] ?? getDefaultSeasonMode(season))
}
// 在入库状态刷新后同步尚未手动修改的默认模式。
function syncDefaultSeasonModes() {
seasonsSelected.value.forEach(season => {
if (subscribedSeasonSet.value.has(season) || manuallySelectedModeSeasons.value.has(season)) return
setSeasonMode(season, getDefaultSeasonMode(season))
})
}
// 判断指定季是否已有订阅。
function isSeasonSubscribed(season: number) {
return subscribedSeasonSet.value.has(season)
}
// 判断指定季是否在本次提交选择中。
function isSeasonSelected(season: number) {
return selectedSeasonSet.value.has(season)
}
// 设置指定季的选择状态并同步其订阅模式。
function setSeasonSelected(season: number, selected: boolean | null) {
const nextSeasons = new Set(seasonsSelected.value)
if (selected) {
nextSeasons.add(season)
ensureSeasonMode(season)
} else {
nextSeasons.delete(season)
}
seasonsSelected.value = [...nextSeasons].sort((a, b) => a - b)
}
// 切换指定季的选择状态。
function toggleSeasonSelected(season: number) {
setSeasonSelected(season, !isSeasonSelected(season))
}
// 将入口预选季和已有订阅同步到当前剧集组的可见季列表。
function syncSelectedSeason() {
if (!seasonInfos.value.length) return
const seasonNumbers = new Set<number>()
props.subscribedSeasons?.forEach(season => seasonNumbers.add(season))
if (props.selectedSeason !== undefined) seasonNumbers.add(props.selectedSeason)
const validSeasonNumbers = new Set(
seasonInfos.value
.map(item => item.season_number)
.filter((season): season is number => season !== null && season !== undefined),
)
seasonsSelected.value = [...seasonNumbers].filter(season => validSeasonNumbers.has(season)).sort((a, b) => a - b)
seasonsSelected.value.forEach(ensureSeasonMode)
Object.entries(props.subscribedSeasonModes ?? {}).forEach(([season, mode]) => {
const seasonNumber = Number(season)
if (!validSeasonNumbers.has(seasonNumber)) return
setSeasonMode(seasonNumber, mode)
})
}
watchEffect(() => {
@@ -193,33 +420,104 @@ watchEffect(() => {
checkSeasonsNotExists()
})
watch(seasonInfos, syncSelectedSeason)
watch(seasonsNotExisted, syncDefaultSeasonModes, { deep: true })
watch(() => props.selectedSeason, syncSelectedSeason)
watch(() => props.subscribedSeasons, syncSelectedSeason)
watch(() => props.subscribedSeasonModes, syncSelectedSeason)
watch(episodeGroupOptions, () => nextTick(updateEpisodeGroupScrollState), { flush: 'post' })
onMounted(async () => {
getMediaSeasons()
window.addEventListener('resize', updateEpisodeGroupScrollState)
// 自定义剧集组由 watchEffect 首次加载,避免默认季数据异步覆盖它。
if (!episodeGroup.value) getMediaSeasons()
getEpisodeGroups()
checkSeasonsNotExists()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateEpisodeGroupScrollState)
})
</script>
<template>
<VBottomSheet inset scrollable>
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
<VCardTitle class="pe-10"> {{ t('dialog.subscribeSeason.title', { title: props.media?.title }) }} </VCardTitle>
<VDialog max-width="45rem" scrollable :fullscreen="!mdAndUp">
<VCard class="subscribe-season-dialog">
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-clipboard-list-outline" class="me-2" />
</template>
<VCardTitle class="pe-10">
{{ t('dialog.subscribeSeason.selectSeasons') }}
</VCardTitle>
<VCardSubtitle>
{{ props.media?.title }}
</VCardSubtitle>
<VDialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText>
<VSelect
v-model="episodeGroup"
:items="episodeGroupOptions"
:item-props="episodeGroupItemProps"
:label="t('dialog.subscribeSeason.selectGroup')"
persistent-hint
/>
<div class="subscribe-season-group-selector">
<div class="subscribe-season-group-label">
{{ t('dialog.subscribeSeason.selectGroup') }}
</div>
<div class="subscribe-season-group-rail-shell">
<button
v-if="mdAndUp && canScrollEpisodeGroupsBackward"
type="button"
class="subscribe-season-group-nav subscribe-season-group-nav--backward"
:aria-label="t('media.episodeGroups.previous')"
@click="scrollEpisodeGroups('backward')"
>
<VIcon icon="mdi-chevron-left" />
</button>
<div
ref="episodeGroupRail"
class="subscribe-season-group-options"
@scroll.passive="updateEpisodeGroupScrollState"
>
<button
v-for="group in episodeGroupOptions"
:key="group.value || 'default'"
type="button"
class="subscribe-season-group-option"
:class="{ 'subscribe-season-group-option--active': episodeGroup === group.value }"
@click="setEpisodeGroup(group.value)"
>
<VIcon :icon="group.icon" size="small" />
<span class="subscribe-season-group-text">
<span class="subscribe-season-group-title">{{ group.title }}</span>
<span class="subscribe-season-group-subtitle">{{ group.subtitle }}</span>
</span>
</button>
</div>
<button
v-if="mdAndUp && canScrollEpisodeGroupsForward"
type="button"
class="subscribe-season-group-nav subscribe-season-group-nav--forward"
:aria-label="t('media.episodeGroups.next')"
@click="scrollEpisodeGroups('forward')"
>
<VIcon icon="mdi-chevron-right" />
</button>
</div>
</div>
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
<div v-else-if="seasonInfos.length > 0">
<VList v-model:selected="seasonsSelected" lines="three" select-strategy="classic">
<VListItem v-for="(item, i) in seasonInfos" :key="i" :value="item">
<VList lines="three" class="subscribe-season-list">
<VListItem
v-for="(item, i) in seasonInfos"
:key="i"
:active="isSeasonSelected(item.season_number || 0)"
rounded="lg"
class="subscribe-season-list-item"
@click="toggleSeasonSelected(item.season_number || 0)"
>
<template #prepend>
<VImg
height="90"
@@ -258,10 +556,41 @@ onMounted(async () => {
>
{{ getExistText(item.season_number || 0) }}
</VChip>
<VChip v-if="isSeasonSubscribed(item.season_number || 0)" class="mt-2 ms-2" size="small" color="error">
{{ t('media.status.subscribed') }}
</VChip>
</VListItemSubtitle>
<template #append="{ isSelected }">
<VListItemAction start>
<VSwitch :model-value="isSelected" />
<template #append>
<VListItemAction start class="subscribe-season-actions">
<VSwitch
:model-value="isSeasonSelected(item.season_number || 0)"
hide-details
@click.stop
@update:model-value="setSeasonSelected(item.season_number || 0, $event)"
/>
<VBtnToggle
v-if="isSeasonSelected(item.season_number || 0)"
:model-value="seasonModes[item.season_number || 0] || 'normal'"
density="compact"
divided
mandatory
variant="outlined"
class="subscribe-season-mode-toggle"
@click.stop
@update:model-value="updateSeasonMode(item.season_number || 0, $event)"
>
<VBtn
v-for="mode in subscribeModeOptions"
:key="mode.value"
:value="mode.value"
:color="getSubscribeModeColor(mode.value)"
size="small"
class="subscribe-season-mode-button"
>
<VIcon :icon="mode.icon" size="small" />
<span>{{ mode.title }}</span>
</VBtn>
</VBtnToggle>
</VListItemAction>
</template>
</VListItem>
@@ -269,15 +598,246 @@ onMounted(async () => {
</div>
<NoDataFound v-else errorTitle="出错啦!" :errorDescription="`${props.media?.title} 未查询到季集信息`" />
</VCardText>
<div class="my-2 text-center">
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
{{
seasonsSelected.length === 0
? t('dialog.subscribeSeason.selectSeasons')
: t('dialog.subscribeSeason.submit')
}}
<VCardActions class="justify-center py-3">
<VBtn
:disabled="!hasSelectionChanges"
width="30%"
min-width="8rem"
color="primary"
variant="elevated"
class="subscribe-season-submit"
@click="subscribeSeasons"
>
<template #prepend>
<VIcon icon="mdi-check" />
</template>
{{ submitButtonText }}
</VBtn>
</div>
</VCardActions>
</VCard>
</VBottomSheet>
</VDialog>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.subscribe-season-actions {
display: grid;
align-content: space-between;
align-self: stretch;
block-size: 100%;
gap: 0.5rem;
justify-items: end;
min-inline-size: 18rem;
}
.subscribe-season-group-selector {
display: grid;
gap: 0.5rem;
margin-block-end: 1rem;
}
.subscribe-season-group-label {
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 0.75rem;
line-height: 1rem;
}
.subscribe-season-group-rail-shell {
position: relative;
min-inline-size: 0;
}
.subscribe-season-group-options {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-block: 0.125rem 0.375rem;
scroll-behavior: smooth;
scroll-snap-type: inline proximity;
scrollbar-width: none;
}
.subscribe-season-group-options::-webkit-scrollbar {
display: none;
}
.subscribe-season-group-option {
display: inline-flex;
flex: 0 0 12rem;
align-items: center;
backdrop-filter: var(--app-grouped-list-backdrop-filter, blur(var(--transparent-blur-light, 6px)));
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
background: rgba(var(--v-theme-surface), 0.82);
color: rgb(var(--v-theme-on-surface));
gap: 0.5rem;
min-inline-size: 0;
padding-block: 0.5rem;
padding-inline: 0.75rem;
scroll-snap-align: start;
text-align: start;
transition:
border-color 0.16s ease,
background-color 0.16s ease,
color 0.16s ease;
}
.subscribe-season-group-option:hover {
border-color: rgba(var(--v-theme-primary), 0.45);
background: rgba(var(--v-theme-surface), 0.9);
box-shadow: 0 6px 18px rgba(var(--v-theme-on-surface), 0.08);
}
.subscribe-season-group-option--active {
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.18);
color: rgb(var(--v-theme-primary));
}
.subscribe-season-group-option--active:hover {
background: rgba(var(--v-theme-primary), 0.24);
}
.subscribe-season-group-option:focus-visible,
.subscribe-season-group-nav:focus-visible {
outline: 2px solid rgba(var(--v-theme-primary), 0.45);
outline-offset: 2px;
}
.subscribe-season-group-text {
display: grid;
min-inline-size: 0;
}
.subscribe-season-group-title,
.subscribe-season-group-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subscribe-season-group-title {
font-size: 0.875rem;
font-weight: 600;
line-height: 1.125rem;
}
.subscribe-season-group-subtitle {
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 0.75rem;
line-height: 1rem;
}
.subscribe-season-group-option--active .subscribe-season-group-subtitle {
color: rgba(var(--v-theme-primary), 0.82);
}
.subscribe-season-group-nav {
position: absolute;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
backdrop-filter: var(--app-grouped-list-backdrop-filter, blur(var(--transparent-blur-light, 6px)));
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 9999px;
background: rgba(var(--v-theme-surface), 0.96);
block-size: 2.5rem;
color: rgb(var(--v-theme-on-surface));
inline-size: 2.5rem;
inset-block-start: 50%;
transform: translateY(-55%);
transition:
border-color 0.16s ease,
background-color 0.16s ease,
color 0.16s ease;
}
.subscribe-season-group-nav:hover {
border-color: rgba(var(--v-theme-primary), 0.45);
background: rgba(var(--v-theme-surface), 0.96);
box-shadow: 0 6px 18px rgba(var(--v-theme-on-surface), 0.12);
color: rgb(var(--v-theme-primary));
}
.subscribe-season-group-nav--backward {
inset-inline-start: -0.5rem;
}
.subscribe-season-group-nav--forward {
inset-inline-end: -0.5rem;
}
.subscribe-season-list {
display: grid;
gap: 0.5rem;
}
.subscribe-season-list-item {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.subscribe-season-list-item :deep(.v-list-item__append) {
align-items: stretch;
align-self: stretch;
}
.subscribe-season-mode-toggle {
block-size: 2rem;
max-inline-size: 18rem;
}
.subscribe-season-mode-button {
min-inline-size: 0;
padding-inline: 0.5rem;
}
.subscribe-season-mode-button span {
font-size: 0.75rem;
line-height: 1rem;
margin-inline-start: 0.25rem;
white-space: nowrap;
}
@media (width <= 960px) {
.subscribe-season-actions {
gap: 0.375rem;
min-inline-size: 6.75rem;
}
.subscribe-season-mode-toggle {
inline-size: 6.75rem;
max-inline-size: 6.75rem;
}
.subscribe-season-mode-button {
flex: 1 1 0;
padding-inline: 0.25rem;
}
.subscribe-season-mode-button span {
display: none;
}
}
@media (width <= 640px) {
.subscribe-season-submit {
inline-size: 100% !important;
}
.subscribe-season-list-item :deep(.v-list-item__content) {
min-inline-size: 0;
}
.subscribe-season-group-option {
flex-basis: 10rem;
}
}
@media (hover: none) and (pointer: coarse) {
.subscribe-season-group-nav {
display: none;
}
}
</style>

View File

@@ -141,4 +141,29 @@ function updateFilter(key: string, values: string[]) {
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
}
.filter-options {
display: flex;
flex-wrap: wrap;
}
.filter-chip {
border: 1px solid rgba(var(--v-theme-primary), 0.2);
margin: 4px;
background-color: rgba(var(--v-theme-primary), 0.1) !important;
color: rgba(var(--v-theme-on-surface), 0.9) !important;
font-weight: 500;
transition: all 0.2s ease;
}
.filter-chip:hover {
background-color: rgba(var(--v-theme-primary), 0.15) !important;
}
.filter-chip.v-chip--selected {
background-color: rgba(var(--v-theme-primary), 0.85) !important;
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
color: rgb(var(--v-theme-on-primary)) !important;
font-weight: 600;
}
</style>

View File

@@ -142,4 +142,24 @@ function handleDetail(item: Context) {
max-block-size: 60vh;
overflow-y: auto;
}
.chip-season {
background-color: #3f51b5;
color: white;
}
.chip-free {
background-color: #4caf50;
color: white;
}
.chip-discount {
background-color: #ff5722;
color: white;
}
.chip-bonus {
background-color: #9c27b0;
color: white;
}
</style>

View File

@@ -85,7 +85,7 @@ function updateFilter(values: string[]) {
@update:model-value="updateFilter"
>
<VChip
v-for="option in options"
v-for="option in options"
:key="option"
:value="option"
filter
@@ -106,3 +106,30 @@ function updateFilter(values: string[]) {
</VCard>
</VDialog>
</template>
<style scoped>
.filter-options {
display: flex;
flex-wrap: wrap;
}
.filter-chip {
border: 1px solid rgba(var(--v-theme-primary), 0.2);
margin: 4px;
background-color: rgba(var(--v-theme-primary), 0.1) !important;
color: rgba(var(--v-theme-on-surface), 0.9) !important;
font-weight: 500;
transition: all 0.2s ease;
}
.filter-chip:hover {
background-color: rgba(var(--v-theme-primary), 0.15) !important;
}
.filter-chip.v-chip--selected {
background-color: rgba(var(--v-theme-primary), 0.85) !important;
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
color: rgb(var(--v-theme-on-primary)) !important;
font-weight: 600;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import { useTransparencySettings } from '@/composables/useTransparencySettings'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = withDefaults(
defineProps<{
@@ -38,15 +42,17 @@ const {
onBackgroundBlurChange,
onBackgroundPosterOpacityChange,
onBlurChange,
onGlassQualityChange,
onOpacityChange,
resetTransparencySettings,
transparencyBlur,
transparencyGlassQuality,
transparencyOpacity,
} = useTransparencySettings()
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="30rem">
<VDialog v-if="visible" v-model="visible" max-width="30rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
@@ -118,6 +124,34 @@ const {
/>
</div>
<div>
<span class="text-body-2 d-block mb-2">{{ t('theme.transparencyGlassQuality') }}</span>
<VBtnToggle
v-model="transparencyGlassQuality"
mandatory
divided
density="comfortable"
variant="outlined"
color="primary"
class="w-full"
@update:model-value="onGlassQualityChange"
>
<VBtn value="lightweight" class="flex-1">
{{ t('theme.transparencyGlassQualityLightweight') }}
</VBtn>
<VBtn value="realtime" class="flex-1">
{{ t('theme.transparencyGlassQualityRealtime') }}
</VBtn>
</VBtnToggle>
<p class="text-caption text-medium-emphasis mt-2 mb-0">
{{
transparencyGlassQuality === 'realtime'
? t('theme.transparencyGlassQualityRealtimeHint')
: t('theme.transparencyGlassQualityLightweightHint')
}}
</p>
</div>
<div>
<span class="text-body-2 d-block mb-2">{{ t('common.preset') }}</span>
<VBtnGroup density="compact" variant="outlined" class="w-full">

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