Compare commits

..

336 Commits

Author SHA1 Message Date
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
jxxghp
33599cc21d fix(DefaultLayout): improve code formatting and style consistency 2026-06-16 14:36:13 +08:00
jxxghp
bf22a4809d fix: add breathing room below header tabs 2026-06-16 14:35:36 +08:00
jxxghp
4a6f7390e6 fix: keep navbar row height stable with tabs 2026-06-16 14:32:26 +08:00
jxxghp
405e460ad6 fix: stabilize iOS Safari mobile navigation 2026-06-16 14:20:12 +08:00
jxxghp
18566c0e9d revert: rollback dependency upgrades 2026-06-16 13:46:13 +08:00
jxxghp
2c471a936f fix(MediaCard): add variant to VAvatar for improved styling 2026-06-16 12:41:03 +08:00
jxxghp
2efb07402f Refactor code structure for improved readability and maintainability 2026-06-16 12:35:53 +08:00
jxxghp
9434ef71e4 fix(login): adjust login header spacing 2026-06-15 22:26:23 +08:00
jxxghp
e06b9537ff feat(login): add tagline and copyright information to login page 2026-06-15 22:10:30 +08:00
InfinityPacer
2829e3b082 fix(subscribe): display special season labels (#491) 2026-06-15 15:54:08 +08:00
jxxghp
1a0fc10559 优化全局智能助手提示信息,简化指令使用说明 2026-06-15 15:52:16 +08:00
jxxghp
5a1aec3323 更新全局智能助手提示信息,明确指令使用方式 2026-06-15 15:41:34 +08:00
jxxghp
48913b8811 优化订阅日历入库状态展示 2026-06-15 10:05:35 +08:00
jxxghp
0a7d53b5c7 修复消息中心滚动显示 2026-06-14 21:32:14 +08:00
jxxghp
da0cd14af8 调整未读消息入口提示 2026-06-14 21:11:36 +08:00
jxxghp
342c62c085 docs: update README files to include Module Federation documentation 2026-06-14 16:35:57 +08:00
jxxghp
891274cc0e refactor(dialogs): unify button styles and layout in dialog components
- Updated VCardActions in multiple dialog components to use a consistent class `app-dialog-actions` for styling.
- Changed button variants to `flat` for primary actions and `tonal` for secondary actions across various dialogs.
- Adjusted padding and spacing for buttons to improve layout consistency.
- Enhanced responsiveness for dialog actions in mobile views.
2026-06-14 14:47:06 +08:00
jxxghp
889a4b744a chore: update version to 2.13.9 in package.json 2026-06-14 13:28:51 +08:00
jxxghp
7fc5b74851 feat: add wiki sync to plugin market settings 2026-06-14 12:57:40 +08:00
jxxghp
785cbcf81d fix manual transfer default auto target 2026-06-13 22:21:54 +08:00
InfinityPacer
364b660390 feat: add log download actions (#489) 2026-06-13 20:35:55 +08:00
jxxghp
599ca912f4 feat: 增强仪表板布局响应式支持,添加布局档位管理和本地存储功能 2026-06-13 20:34:42 +08:00
jxxghp
2f66f0f1fc feat: 增加仪表板配置的归一化和本地存储支持,优化用户配置加载 2026-06-13 20:00:02 +08:00
jxxghp
cd2f561194 chore: update version to 2.13.8 in package.json 2026-06-13 18:40:14 +08:00
jxxghp
c59a555a2d feat: enhance plugin tags display with improved styling and layout 2026-06-13 18:03:26 +08:00
jxxghp
4413fedec5 fix manual transfer auto target options 2026-06-13 10:42:34 +08:00
jxxghp
d7562ea506 feat: add downloader suffix toggles 2026-06-13 08:43:37 +08:00
jxxghp
951d76481b feat: add system uptime tracking and localization support 2026-06-12 15:52:22 +08:00
jxxghp
68b0071009 refactor: remove unnecessary dashboard reveal state and simplify reveal logic 2026-06-12 14:19:59 +08:00
jxxghp
0594d1d5b2 feat: implement launch loading state management and footer navigation visibility control 2026-06-12 13:59:59 +08:00
jxxghp
4f328add1b feat: enhance page transition animations and overlay effects 2026-06-12 10:42:41 +08:00
jxxghp
62c9a10377 fix: improve search loading state handling and UI feedback 2026-06-11 08:10:44 +08:00
jxxghp
d3d0d847f6 fix: show subtitle episode metadata 2026-06-10 00:54:59 +08:00
jxxghp
b7dd397664 style: shrink header search trigger 2026-06-09 23:01:46 +08:00
jxxghp
c0276fca9f chore: update version to 2.13.7 2026-06-09 22:10:51 +08:00
jxxghp
4691d12faa fix: enforce permission-aware navigation 2026-06-09 21:45:51 +08:00
jxxghp
d0cac34d08 fix: show subtitle search in site resources 2026-06-09 17:25:52 +08:00
jxxghp
2f46c19826 feat: add subtitle search actions 2026-06-09 17:04:17 +08:00
jxxghp
b1cb07ae8c feat: add subtitle search functionality and download feature
- Added subtitle search results support in zh-TW locale.
- Enhanced resource page to handle subtitle search results, including new computed properties and methods for managing subtitle data.
- Introduced SubtitleCard and SubtitleItem components for displaying subtitle information.
- Created AddSubtitleDownloadDialog for managing subtitle downloads with directory selection and media ID options.
- Implemented subtitle download caching mechanism to track downloaded subtitles.
2026-06-09 06:47:09 +08:00
jxxghp
19710a5f0f feat: 添加管理员白名单配置,支持多个通知类型的管理员设置 2026-06-08 13:41:36 +08:00
jxxghp
4362bbed42 refactor: 优化日志视图样式,添加边框和阴影,提升视觉效果 2026-06-07 22:03:05 +08:00
jxxghp
89cf7070bb refactor: 在仪表板中添加加载横幅组件,提升用户体验 2026-06-07 21:40:32 +08:00
jxxghp
369afd6674 refactor: 移除列表组件的边框样式,提升视觉一致性 2026-06-07 20:32:23 +08:00
jxxghp
c4fd8f5631 refactor: 统一导航栏阴影样式,提升视觉一致性 2026-06-07 20:04:00 +08:00
jxxghp
c9ebf23977 refactor: 统一组件样式,优化圆角和阴影效果,提升视觉一致性 2026-06-07 19:49:15 +08:00
jxxghp
e239c0c5ea refactor: 优化代码格式,简化部分逻辑,提升可读性 2026-06-07 18:10:10 +08:00
jxxghp
87d780d985 refactor: remove unnecessary border and border-radius styles across various components
- Removed border and border-radius styles from SubscribeCard, CategoryEditDialog, ContentToggleSettingsDialog, DiscoverTabOrderDialog, ForkWorkflowDialog, OfflineStatusDialog, PluginMarketSettingDialog, ReorganizeDialog, SearchBarDialog, SiteImportDialog, SiteResourceDialog, SiteStatisticsDialog, WorkflowActionsDialog, TorrentFilterBar, and several setup views.
- Updated common.scss to introduce new variables for surface radius and border styles.
- Adjusted component styles to utilize new app surface styles for consistency.
2026-06-07 15:43:42 +08:00
ui_beam
f51b253c83 fix(pluginSidebarNav): 修复首次登录后插件侧栏菜单不显示的问题 (#486) 2026-06-07 14:27:23 +08:00
jxxghp
bf05cd0697 fix: wrap button in a div for better layout structure 2026-06-07 11:05:36 +08:00
jxxghp
8e5b8f7207 feat(dashboard): 优化仪表盘组件加载逻辑,支持异步加载和状态管理 2026-06-07 08:57:25 +08:00
jxxghp
5e3e106d91 fix: sync login autofill fields 2026-06-06 23:40:45 +08:00
jxxghp
c5fa6aad68 feat(transfer-queue): enhance empty state display and add hint for no tasks 2026-06-06 23:17:42 +08:00
jxxghp
989e8b4c5e feat(dashboard): 增加媒体组件的内边距以改善布局 2026-06-06 23:04:45 +08:00
jxxghp
a865aa433c fix: exit dashboard edit mode after reset
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-06 21:18:51 +08:00
jxxghp
df8e6016cd feat(dashboard): improve layout and styling for media components 2026-06-06 20:54:46 +08:00
jxxghp
75da7d35b4 fix(dashboard): stabilize editable layout controls 2026-06-06 18:22:36 +08:00
jxxghp
e2722801e4 feat(dashboard): enhance layout and responsiveness of dashboard components 2026-06-06 17:56:53 +08:00
jxxghp
63e28b76c8 更新 dashboard.vue 2026-06-06 11:32:38 +08:00
jxxghp
9dc63e2c21 feat(dashboard): integrate GridStack for enhanced layout management
- Added GridStack for dynamic dashboard layout with drag-and-drop functionality.
- Introduced new properties for DashboardItem to support row configuration.
- Enhanced ContentToggleSettingsDialog with reset functionality.
- Updated localization files for new dashboard features.
- Refactored dashboard components to utilize GridStack for layout rendering.
- Improved responsiveness and styling for dashboard elements.
- Removed deprecated draggable component in favor of GridStack.
2026-06-06 08:45:45 +08:00
jxxghp
08a2741c06 fix: order radius option labels 2026-06-05 23:36:35 +08:00
jxxghp
0dd95508b5 fix: refine theme radius options 2026-06-05 23:29:11 +08:00
jxxghp
f3c524b6b5 feat: add theme radius customization 2026-06-05 23:06:10 +08:00
jxxghp
a73c28c1f7 fix vlist scroll 2026-06-05 13:30:17 +08:00
jxxghp
b3fb7e1de1 feat: 增强主题管理,支持动态主题切换和持久化设置 2026-06-05 09:06:06 +08:00
jxxghp
3620b2a979 fix: 调整布局和样式,增加卡片网格的内边距以避免溢出 2026-06-05 08:24:16 +08:00
jxxghp
1046cb276f 更新 _default-layout.scss 2026-06-05 06:53:32 +08:00
jxxghp
6dfda4807c fix: 简化 showPasskeyLogin 计算属性,调整登录卡片内边距 2026-06-05 00:26:36 +08:00
jxxghp
8d13f3e5ca fix: 调整布局和样式,修复滚动和间距问题 2026-06-04 23:33:20 +08:00
jxxghp
9ebe740c69 更新 DownloadingListView.vue 2026-06-04 22:53:06 +08:00
jxxghp
73673820f1 fix: prevent download card shadow clipping 2026-06-04 22:42:43 +08:00
jxxghp
c6d0116e0f fix: apply theme shadow to workflow and download cards 2026-06-04 22:22:55 +08:00
jxxghp
6a06001dae fix: 修复横向滑块标题按钮点击区域 2026-06-04 22:07:02 +08:00
jxxghp
ad75b50a0c fix: 修复主题定制器顶部安全区 2026-06-04 21:54:52 +08:00
jxxghp
2a68aa05f6 fix: 修复主题定制器移动端滚动高度 2026-06-04 21:45:21 +08:00
jxxghp
fa90411c7a feat: 添加滑块阴影缓冲区,优化横向滚动效果 2026-06-04 21:25:08 +08:00
jxxghp
643ddcef07 feat: enhance theme customizer with shadow options and update styles
- Added shadow customization options to the theme customizer, allowing users to select from 'none', 'low', 'medium', and 'high'.
- Updated the theme customizer settings interface and default values to include shadow settings.
- Enhanced the CSS variables for shadows in common.scss to support different shadow levels based on user selection.
- Modified the VirtualSlideView component styles to improve layout and scrolling behavior.
- Updated localization files for English, Simplified Chinese, and Traditional Chinese to include new shadow-related terms.
- Adjusted various components to ensure consistent application of shadow styles across the application.
2026-06-04 21:20:08 +08:00
jxxghp
addc0838c0 feat: 添加工作流执行配置和并行数设置,优化工作流管理功能 2026-06-04 15:57:08 +08:00
jxxghp
8b43e0a754 更新 package.json 2026-06-04 08:45:25 +08:00
jxxghp
f81a9f0929 fix: 移除无用的认证提供方加载状态,简化登录逻辑 2026-06-04 08:37:19 +08:00
jxxghp
3cf5cc24cd feat: 添加插件认证支持,优化登录流程 2026-06-04 08:24:10 +08:00
jxxghp
841e9479af refactor: unify header tab menu definitions 2026-06-03 07:21:34 +08:00
jxxghp
8c3380e8f5 fix: 更新主题定制器翻译,优化用户界面文本 2026-06-02 23:03:19 +08:00
jxxghp
0ac42f0a76 fix: 添加订阅排序方式的本地存储功能,优化用户体验 2026-06-02 22:48:42 +08:00
jxxghp
caef6eca67 fix: 同步主题定制器状态到根节点,优化全局悬浮按钮位置 2026-06-02 22:44:53 +08:00
jxxghp
0867236b68 fix: 更新主题定制器图标样式,提升可读性 2026-06-02 21:26:30 +08:00
jxxghp
09dfdbaf67 fix: 简化主题定制器按钮样式,提升界面整洁度 2026-06-02 21:11:13 +08:00
jxxghp
57224e15fb fix: 调整主题定制器和对话框样式,优化用户体验 2026-06-02 21:06:24 +08:00
jxxghp
200500a060 fix: use dialog theme customizer in app mode 2026-06-02 19:14:56 +08:00
jxxghp
a4731aade1 feat: enable app mode theme customizer 2026-06-02 19:00:48 +08:00
jxxghp
7d21eabf1a fix: prevent theme customizer startup crash 2026-06-02 18:35:05 +08:00
jxxghp
b639737bd6 feat: refine theme customizer and horizontal navigation 2026-06-02 17:56:41 +08:00
jxxghp
d02ece234c fix: 修改主题定制器标题,提升语言一致性 2026-06-02 17:07:16 +08:00
jxxghp
889a5c9e51 fix: 优化主题定制器按钮样式和布局,提升用户体验 2026-06-02 17:02:23 +08:00
jxxghp
880a34f508 fix: 调整主题定制器面板的透明度设置显示逻辑 2026-06-02 16:18:00 +08:00
jxxghp
50b0148ed6 feat: add theme customizer component and functionality
- Introduced a new ThemeCustomizer component for real-time theme customization.
- Updated UserProfile.vue to integrate the ThemeCustomizer and manage theme settings.
- Enhanced theme management with new settings for layout, primary color, and skin.
- Added support for semi-dark menu and responsive design adjustments.
- Implemented local storage persistence for theme settings.
- Updated localization files to include new theme customizer strings in English, Simplified Chinese, and Traditional Chinese.
- Modified styles to support new bordered skin and theme customizer layout.
- Refactored existing components (HeaderTab, SearchBar) to accommodate new theme features.
2026-06-02 16:16:20 +08:00
jxxghp
285ddab45a fix: 调整插件项目主页链接逻辑,优化下拉菜单项顺序 2026-06-02 13:10:14 +08:00
jxxghp
aa12f4b6b6 fix: 优化插件项目主页链接解析逻辑 2026-06-02 12:54:54 +08:00
jxxghp
9bbb060073 fix: 修复插件项目主页跳转被拦截 2026-06-02 08:18:12 +08:00
jxxghp
3b0623628c fix: 优化插件卡片项目主页与版本历史入口 2026-06-02 07:53:10 +08:00
jxxghp
b45c147452 fix: 调整插件更新说明菜单行为 2026-06-02 07:31:17 +08:00
jxxghp
25bc7c4b3c feat: 添加插件更新历史功能及相关国际化支持 2026-06-02 07:16:05 +08:00
jxxghp
d6b7b6d813 fix: close plugin filter menus after selection 2026-06-01 21:32:24 +08:00
jxxghp
a3ac46c891 更新 package.json 2026-06-01 12:00:14 +08:00
Album
b6e824246b 优化文件管理多选操作体验 (#483) 2026-05-31 21:31:59 +08:00
Album
5191f6780d 恢复识别词应用详情标题文案 (#482) 2026-05-31 18:13:53 +08:00
Album
261aaf17ad 优化识别词应用详情显示 (#481) 2026-05-31 17:51:05 +08:00
jxxghp
258e64bca7 fix: 更新站点 Cookie 处理逻辑,添加请求失败提示,优化服务工作者缓存策略 2026-05-31 09:16:52 +08:00
jxxghp
e905df014e fix: add precomposed apple touch icon 2026-05-31 08:39:21 +08:00
jxxghp
b93f8f2bff fix: 消息中心首次打开时SSE与数据库消息重复显示
SSE消息只有date字段、note为null,数据库消息只有reg_time、note为{},
原getMessageKey将reg_time和date作为两个独立字段拼接签名导致同一条消息签名不同。
归一化时间字段(reg_time||date)和note字段后去重恢复正常。
2026-05-30 19:18:55 +08:00
jxxghp
9aa0a5e1b7 更新 package.json 2026-05-30 08:58:34 +08:00
jxxghp
ee9f41d015 更新 package.json 2026-05-30 08:58:22 +08:00
jxxghp
ad6a664cbe fix: proxy bangumi images 2026-05-30 08:54:40 +08:00
Album
3387067636 fix: handle episode group values in preview transfer (#480) 2026-05-30 08:28:00 +08:00
jxxghp
07dc3c3e9a fix: build Emby app deep links with server ids 2026-05-28 15:06:30 +08:00
jxxghp
262b4bebd4 更新 package.json 2026-05-28 14:37:07 +08:00
jxxghp
6e50cf31de fix: correct media server card links 2026-05-28 14:33:50 +08:00
jxxghp
14aa75dfae fix: format version install statistics 2026-05-27 17:48:58 +08:00
jxxghp
348aa4757b fix: normalize search site selection 2026-05-27 15:21:44 +08:00
jxxghp
6e6819acc1 fix: auto match manual transfer target path 2026-05-27 13:26:01 +08:00
jxxghp
51a58aaae0 fix: show manual transfer recognition details 2026-05-27 11:03:55 +08:00
jxxghp
fbde99389e 更新 package.json 2026-05-27 07:11:01 +08:00
jxxghp
5a4e345529 feat: add LLM proxy toggle 2026-05-27 06:57:09 +08:00
jxxghp
b446afb6d8 fix: improve plugin market editor layout 2026-05-26 17:39:14 +08:00
jxxghp
8580af36d1 fix: compact plugin market settings dialog 2026-05-26 17:16:19 +08:00
jxxghp
95ca092117 feat: optimize plugin market repository settings 2026-05-26 16:30:31 +08:00
jxxghp
ba200cae5c fix: move LLM user agent after max context 2026-05-26 08:30:33 +08:00
jxxghp
87c73e0253 feat: add llm user agent setting 2026-05-26 08:20:02 +08:00
jxxghp
d4d7f635f5 fix: allow rust acceleration re-enable 2026-05-25 23:48:09 +08:00
jxxghp
729db1510e 更新 package.json 2026-05-25 23:11:22 +08:00
jxxghp
8a12ecf918 fix: render OTP QR code reliably 2026-05-25 23:07:45 +08:00
268 changed files with 38621 additions and 6743 deletions

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

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

2
.gitignore vendored
View File

@@ -37,3 +37,5 @@ src/@iconify/*.js
public/plugin_icon/**
docs-lock/
.trae/
output/
.codex-qa/

View File

@@ -11,15 +11,6 @@
- 支持多语言(中文/英文)
- 完整的插件系统支持,包括远程组件动态加载
## 模块联邦功能
MoviePilot 现已支持模块联邦Module Federation功能允许插件开发者创建可动态加载的远程组件实现更丰富的插件用户界面。
### 相关文档
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目
## 开发部署
@@ -58,3 +49,12 @@ yarn build
```shell
node dist/service.js
```
### 模块联邦功能
MoviePilot 现已支持模块联邦Module Federation功能允许插件开发者创建可动态加载的远程组件实现更丰富的插件用户界面。
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目

View File

@@ -11,15 +11,6 @@ Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS
- Multi-language support (Chinese/English)
- Complete plugin system with dynamic remote component loading
## Module Federation
MoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.
### Documentation
- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions
- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components
## Development
### Recommended IDE Setup
@@ -57,3 +48,10 @@ yarn build
```shell
node dist/service.js
```
### Module Federation
MoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.
- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions
- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components

View File

@@ -7,8 +7,10 @@
--initial-loader-color: #9155FD;
--initial-loader-height: 100svh;
--initial-loader-width: 100vw;
--initial-color-scheme: dark;
background: var(--initial-loader-bg, #0E1116);
background-color: var(--initial-loader-bg, #0E1116);
color-scheme: dark;
">
<head>
@@ -16,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" />
@@ -37,7 +39,7 @@
<!-- iOS Safari PWA 优化 -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
<!-- iOS Safari 全屏模式 -->
@@ -99,6 +101,7 @@
body {
background: var(--initial-loader-bg, #0E1116);
background-color: var(--initial-loader-bg, #0E1116);
color-scheme: var(--initial-color-scheme, dark);
}
html[data-launch-loading="true"],
@@ -118,6 +121,12 @@
overscroll-behavior: contain;
}
html[data-launch-loading="true"] .footer-nav-container {
opacity: 0 !important;
pointer-events: none !important;
visibility: hidden !important;
}
#loading-bg {
position: fixed;
inset: 0;
@@ -282,27 +291,113 @@
}
}
// 根据当前主题提前确定启动屏色彩,避免 iOS PWA 从原生启动图切到网页时露出默认白底。
const launchThemeBackgrounds = {
light: '#F4F5FA',
dark: '#0E1116',
purple: '#28243D',
transparent: '#1C1C1C',
default: '#F4F5FA',
function getLocalStorageValue(key) {
try {
return localStorage.getItem(key)
} catch (e) {
return null
}
}
const savedTheme = localStorage.getItem('theme') || 'auto'
const resolvedLaunchTheme = savedTheme === 'auto'
? (checkPrefersColorSchemeIsDark() ? 'dark' : 'light')
: savedTheme
// 根据当前主题提前确定启动屏色彩,避免 iOS PWA 从原生启动图切到网页时露出默认白底。
const launchThemePalettes = {
light: {
background: '#F4F5FA',
primary: '#9155FD',
},
dark: {
background: '#0E1116',
primary: '#6E66ED',
},
purple: {
background: '#28243D',
primary: '#9155FD',
},
transparent: {
background: '#1C1C1C',
primary: '#A370F7',
},
}
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|| launchThemeBackgrounds[resolvedLaunchTheme]
|| launchThemeBackgrounds.light
function getSavedThemePreference() {
return getLocalStorageValue('theme') || 'auto'
}
let primaryColor = localStorage.getItem('materio-initial-loader-color')
if (!primaryColor) {
primaryColor = '#9155FD'
function resolveLaunchTheme(themePreference) {
if (themePreference === 'auto') {
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
}
if (themePreference === 'default') {
return 'light'
}
return launchThemePalettes[themePreference] ? themePreference : 'light'
}
function getLaunchColorScheme(themeName) {
return ['dark', 'purple', 'transparent'].includes(themeName) ? 'dark' : 'light'
}
function setMetaContent(selector, content) {
document.querySelectorAll(selector).forEach(meta => {
meta.setAttribute('content', content)
})
}
function syncThemeColorMeta(themeColor) {
const metas = document.querySelectorAll('meta[name="theme-color"]')
if (metas.length) {
metas.forEach(meta => {
meta.setAttribute('content', themeColor)
})
return
}
const meta = document.createElement('meta')
meta.name = 'theme-color'
meta.content = themeColor
document.head.appendChild(meta)
}
function applyLaunchThemeChrome() {
const themePreference = getSavedThemePreference()
const resolvedLaunchTheme = resolveLaunchTheme(themePreference)
const colorScheme = getLaunchColorScheme(resolvedLaunchTheme)
const palette = launchThemePalettes[resolvedLaunchTheme] || launchThemePalettes.light
// auto 模式下系统明暗可能已变化,不能复用旧的启动背景缓存。
const storedLoaderColor = themePreference === 'auto' ? null : getLocalStorageValue('materio-initial-loader-bg')
const loaderColor = storedLoaderColor || palette.background
const primaryColor = getLocalStorageValue('materio-initial-loader-color') || palette.primary
document.documentElement.setAttribute('data-launch-theme', resolvedLaunchTheme)
document.documentElement.setAttribute('data-theme', resolvedLaunchTheme)
document.documentElement.setAttribute('data-theme-preference', themePreference)
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
document.documentElement.style.setProperty('--initial-color-scheme', colorScheme)
document.documentElement.style.backgroundColor = loaderColor
document.documentElement.style.colorScheme = colorScheme
if (document.body) {
document.body.setAttribute('data-theme', resolvedLaunchTheme)
document.body.setAttribute('data-theme-preference', themePreference)
document.body.style.backgroundColor = loaderColor
document.body.style.colorScheme = colorScheme
}
setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark')
syncThemeColorMeta(loaderColor)
return {
background: loaderColor,
colorScheme,
resolvedLaunchTheme,
themePreference,
}
}
// 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 safe area 时把 logo 顶下去。
@@ -328,20 +423,39 @@
}
// 应用主题色彩
document.documentElement.setAttribute('data-launch-theme', resolvedLaunchTheme)
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
document.documentElement.style.backgroundColor = loaderColor
applyLaunchThemeChrome()
syncInitialViewport(true)
document.addEventListener('DOMContentLoaded', () => {
document.body.style.backgroundColor = loaderColor
applyLaunchThemeChrome()
})
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
applyLaunchThemeChrome()
}
})
window.addEventListener('pageshow', () => {
applyLaunchThemeChrome()
})
window.addEventListener('focus', () => {
applyLaunchThemeChrome()
})
window.addEventListener('orientationchange', () => {
window.setTimeout(() => syncInitialViewport(true), 160)
})
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
applyLaunchThemeChrome()
})
} catch (e) {
// 老浏览器不支持监听系统主题变化时,运行时主题管理器仍会继续接管。
}
// 状态栏适配
if (window.navigator.standalone) {
document.documentElement.style.setProperty('--status-bar-height', '20px')

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.13.1",
"version": "2.14.2",
"private": true,
"type": "module",
"bin": "dist/service.js",
@@ -49,6 +49,7 @@
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"gridstack": "^12.6.0",
"http-proxy-middleware": "^3.0.0",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

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

@@ -0,0 +1,12 @@
import assert from 'node:assert/strict'
import { formatSeasonLabel } from '../src/@core/utils/season.ts'
assert.equal(formatSeasonLabel(0, '特别篇'), '特别篇')
assert.equal(formatSeasonLabel('0', 'Specials'), 'Specials')
assert.equal(formatSeasonLabel(1, '特别篇'), 'S01')
assert.equal(formatSeasonLabel('12', '特别篇'), 'S12')
assert.equal(formatSeasonLabel(null, '特别篇'), '')
assert.equal(formatSeasonLabel(undefined, '特别篇'), '')
console.log('season label checks passed')

View File

@@ -22,8 +22,8 @@ code {
%blurry-bg {
position: relative;
isolation: isolate;
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
isolation: isolate;
@media (width >= 1280px) and (hover: hover) {
background: rgba(var(--v-theme-background), 1);

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

@@ -5,13 +5,13 @@
// Vertical nav scrolled sticky elevated nav
%default-layout-vertical-nav-scrolled-sticky-elevated-nav {
background-color: rgb(var(--v-theme-surface));
box-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
box-shadow: var(--app-surface-shadow);
}
// Floating navbar and sticky elevated navbar scrolled
%default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled {
background-color: rgb(var(--v-theme-surface));
box-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
box-shadow: var(--app-surface-shadow);
}
// Floating navbar overlay

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) {
// 负数标记

15
src/@core/utils/season.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* 格式化用户可见的季标签。
*
* TMDB 使用季号 0 表示特别季;调用方传入当前语言的特别季名称,
* 其余季号保持 MoviePilot 现有的 Sxx 展示口径。
*/
export function formatSeasonLabel(
season: number | string | null | undefined,
specialsLabel: string,
): string {
if (season === null || season === undefined || season === '') return ''
if (Number(season) === 0) return specialsLabel
return `S${String(season).padStart(2, '0')}`
}

View File

@@ -1,7 +1,12 @@
<script lang="ts">
import { Transition } from 'vue'
import { useDisplay } from 'vuetify'
import VerticalNav from '@layouts/components/VerticalNav.vue'
import {
readThemeCustomizerSettings,
THEME_CUSTOMIZER_CHANGE_EVENT,
type ThemeCustomizerSettings,
} from '@/composables/useThemeCustomizer'
import { usePWA } from '@/composables/usePWA'
export default defineComponent({
setup(props, { slots }) {
@@ -11,6 +16,11 @@ export default defineComponent({
const route = useRoute()
const { mdAndDown } = useDisplay()
const { appMode } = usePWA()
const themeLayout = ref(readThemeCustomizerSettings().layout)
const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value)
const isCollapsedLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'collapsed')
const isHorizontalLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'horizontal')
// This is alternative to below two commented watcher
// We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa.
@@ -25,6 +35,10 @@ export default defineComponent({
scrollDistance.value = window.scrollY
}
const handleThemeCustomizerChange = (event: Event) => {
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
}
// 监听弹窗状态变化
const checkDialogState = () => {
const wasDialogOpen = isDialogOpen.value
@@ -32,12 +46,13 @@ export default defineComponent({
// 当弹窗刚打开时,记录当前的滚动状态
if (!wasDialogOpen && isDialogOpen.value) {
wasScrolledBeforeDialog.value = scrollDistance.value > 0
wasScrolledBeforeDialog.value = scrollDistance.value > 10
}
}
onMounted(() => {
window.addEventListener('scroll', handleScroll)
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
// 初始检查弹窗状态
checkDialogState()
@@ -52,6 +67,7 @@ export default defineComponent({
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
dialogObserver?.disconnect()
dialogObserver = null
})
@@ -93,13 +109,12 @@ export default defineComponent({
const main = h(
'main',
{ class: 'layout-page-content' },
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
h('section', { class: 'page-content-container' }, slots.default?.()),
),
h('section', { class: 'page-content-container' }, slots.default?.()),
)
// 👉 根据路由 meta 决定 footer 高度
const shouldShowFooter = !route.meta.hideFooter
const isNavbarScrolled = scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)
// 👉 Footer
const footer = h('footer', { class: 'layout-footer' }, [
@@ -127,8 +142,11 @@ export default defineComponent({
'layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid',
'layout-navbar-fixed',
mdAndDown.value && 'layout-overlay-nav',
isCollapsedLayout.value && 'layout-vertical-nav-collapsed',
isHorizontalLayout.value && 'layout-horizontal-nav-active',
isHorizontalLayout.value && isNavbarScrolled && 'layout-horizontal-nav-scrolled',
route.meta.layoutWrapperClasses,
(scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)) && 'window-scrolled',
!isHorizontalLayout.value && isNavbarScrolled && 'window-scrolled',
],
},
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
@@ -139,6 +157,8 @@ export default defineComponent({
</script>
<style lang="scss">
/* stylelint-disable no-descending-specificity */
@use '@configured-variables' as variables;
@use '@layouts/styles/placeholders';
@use '@layouts/styles/mixins';
@@ -150,6 +170,10 @@ export default defineComponent({
}
.layout-wrapper.layout-nav-type-vertical {
--layout-navbar-block-size: calc(
env(safe-area-inset-top, 0px) + #{variables.$layout-vertical-nav-navbar-height} + var(--navbar-tab-height)
);
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
min-block-size: 100%;
@@ -165,13 +189,16 @@ export default defineComponent({
.layout-navbar {
position: fixed;
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
// iOS Safari 在地址栏收起和惯性滚动时可能把 fixed 顶栏和页面滚动层合成到一起,
// 单独提升顶栏图层可避免导航栏短暂上移到安全区下方。
backface-visibility: hidden;
block-size: var(--layout-navbar-block-size);
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
inset-block-start: 0;
transform: translate3d(0, 0, 0);
.navbar-content-container {
block-size: calc(
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height + var(--navbar-tab-height)
);
block-size: var(--layout-navbar-block-size);
}
@at-root {
@@ -223,6 +250,211 @@ export default defineComponent({
// Adjust right column pl when vertical nav is collapsed
&.layout-vertical-nav-collapsed .layout-content-wrapper {
padding-inline-start: variables.$layout-vertical-nav-collapsed-width;
.page-content-container > div:first-child {
inline-size: calc(100vw - variables.$layout-vertical-nav-collapsed-width - 1rem);
}
}
&.layout-vertical-nav-collapsed .layout-navbar {
inline-size: calc(100vw - variables.$layout-vertical-nav-collapsed-width - 0.5rem);
}
&.layout-vertical-nav-collapsed .layout-vertical-nav:not(.overlay-nav) {
.nav-header {
justify-content: center;
margin-inline: 0;
padding-inline: 0;
}
.app-logo {
justify-content: center;
inline-size: 100%;
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 {
block-size: 2.5rem;
inline-size: 2.5rem;
}
.app-logo h1,
.nav-item-title,
.nav-section-title {
display: none;
}
.nav-link > a {
justify-content: center;
border-radius: 0.75rem !important;
block-size: 2.75rem;
margin-inline: 0.75rem;
padding-inline: 0;
}
.nav-item-icon {
margin-inline-end: 0 !important;
}
}
&.layout-horizontal-nav-active {
.layout-vertical-nav:not(.overlay-nav) {
pointer-events: none;
transform: translateX(-100%);
visibility: hidden;
}
.layout-content-wrapper {
padding-inline-start: 0;
}
.layout-navbar {
background: rgb(var(--v-theme-background));
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
inline-size: 100%;
max-inline-size: none;
padding-inline: 0;
}
.navbar-content-container {
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
inline-size: 100%;
margin-inline: auto;
max-inline-size: variables.$layout-boxed-content-width;
padding-inline: 1.5rem;
}
.layout-page-content {
inline-size: 100%;
margin-inline: auto;
max-inline-size: variables.$layout-boxed-content-width;
padding-inline: 1rem;
}
.page-content-container > div:first-child {
inline-size: 100%;
}
}
@at-root {
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed .layout-navbar {
backdrop-filter: blur(12px) saturate(1.2);
background: rgb(var(--v-theme-surface)) !important;
box-shadow:
0 1px 3px rgba(0, 0, 0, 4%),
0 1px 2px rgba(0, 0, 0, 2%);
}
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.navbar-content-container {
backdrop-filter: none !important;
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
filter: none !important;
padding-inline: 1.5rem !important;
&::before {
display: none !important;
backdrop-filter: none !important;
background: transparent !important;
background-color: transparent !important;
content: none !important;
filter: none !important;
}
}
html[data-theme='transparent'] .layout-wrapper.layout-horizontal-nav-active .layout-navbar,
.v-theme--transparent .layout-wrapper.layout-horizontal-nav-active .layout-navbar {
backdrop-filter: none !important;
background: transparent !important;
border-block-end-color: rgba(var(--v-theme-on-surface), 0.04);
box-shadow: none !important;
}
html[data-theme='transparent'] .layout-wrapper.layout-horizontal-nav-active .navbar-content-container,
.v-theme--transparent .layout-wrapper.layout-horizontal-nav-active .navbar-content-container {
backdrop-filter: none !important;
background: transparent !important;
box-shadow: none !important;
}
// 透明主题的水平导航不叠加滚动磨砂层,避免中间区域出现一块更深的背景。
html[data-theme='transparent']
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.layout-navbar,
.v-theme--transparent
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.layout-navbar {
backdrop-filter: blur(var(--transparent-blur-light, 6px)) !important;
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2)) !important;
box-shadow: none !important;
}
// 透明主题滚动时只让外层导航栏承载整屏背景,避免内部最大宽度容器单独变深。
html[data-theme='transparent']
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.navbar-content-container,
.v-theme--transparent
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.navbar-content-container {
backdrop-filter: none !important;
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
filter: none !important;
padding-inline: 1.5rem !important;
&::before {
display: none !important;
backdrop-filter: none !important;
background: transparent !important;
background-color: transparent !important;
content: none !important;
filter: none !important;
}
}
html[data-theme='light'][data-theme-semi-dark-menu='true'][data-theme-layout='vertical']
.layout-wrapper.layout-nav-type-vertical:not(.layout-horizontal-nav-active)
.layout-vertical-nav:not(.overlay-nav),
html[data-theme='light'][data-theme-semi-dark-menu='true'][data-theme-layout='collapsed']
.layout-wrapper.layout-nav-type-vertical:not(.layout-horizontal-nav-active)
.layout-vertical-nav:not(.overlay-nav) {
background: #2f3349;
color: #e7e3fc;
.app-logo h1,
.nav-section-title,
.nav-link > a,
.nav-item-icon {
color: rgba(231, 227, 252, 78%) !important;
}
.nav-link > a:hover {
background-color: rgba(231, 227, 252, 6%);
}
.nav-link > .router-link-exact-active {
color: #fff !important;
.nav-item-icon,
.nav-item-title {
color: #fff !important;
}
}
}
}
// 👉 Content height fixed
@@ -233,9 +465,7 @@ export default defineComponent({
.layout-page-content {
// display: flex;
// 使用 clip 替代 hidden避免 Chrome 144+ 滚动锁定问题
overflow-x: clip;
overflow-y: auto;
overflow: auto;
.page-content-container {
inline-size: 100%;

View File

@@ -11,12 +11,11 @@ html {
}
body {
overflow: visible !important;
background: rgb(var(--v-theme-background));
overscroll-behavior-y: contain;
// Chrome 144+ 兼容性:覆盖 Vuetify 的内联 overflow: hidden 样式
overflow: visible !important;
--webkit-overflow-scrolling: touch;
-webkit-overflow-scrolling: touch;
}
body,
@@ -37,10 +36,8 @@ body,
.layout-page-content {
@include mixins.boxed-content(true);
// Chrome 144+ 兼容性:使用 clip 替代 hidden避免滚动锁定问题
// overflow: hidden 在新版 Chrome 中可能意外阻止垂直滚动
overflow: clip;
flex-grow: 1;
overflow: clip visible;
// TODO: Use grid gutter variable here;
padding-block: 1.5rem;

View File

@@ -1,5 +1,6 @@
import type { Component, Ref, VNode } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
import { ContentWidth, FooterType, NavbarType } from './enums'
export interface UserConfig {
@@ -112,13 +113,27 @@ 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 {
title: string
icon?: string
tab: string
description?: string
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
export interface NavMenu extends NavLink {
@@ -126,6 +141,8 @@ export interface NavMenu extends NavLink {
description?: string
admin?: boolean
footer?: boolean
// 水平三级菜单和页面动态标签页共用的静态标签定义。
tabs?: NavMenuTabItem[]
}
// 👉 Vertical nav group

View File

@@ -1,8 +1,7 @@
<script lang="ts" setup>
import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
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'
@@ -10,27 +9,46 @@ 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 { global: globalTheme } = useTheme()
const vuetifyTheme = useTheme()
const { global: globalTheme } = vuetifyTheme
let themeValue = localStorage.getItem('theme') || 'auto'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
globalTheme.name.value = resolveThemeName(themeValue)
applyStoredThemeCustomizerAppearance(vuetifyTheme)
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
function syncRootLaunchPalette() {
const { background, primary } = globalTheme.current.value.colors
document.documentElement.style.setProperty('--initial-loader-bg', background)
document.documentElement.style.setProperty('--initial-loader-color', primary)
document.documentElement.style.backgroundColor = background
document.body.style.backgroundColor = background
applyDocumentThemeChrome(themeValue, {
background,
persistLoaderColors: true,
primary,
resolvedTheme: globalTheme.name.value,
})
}
// 生效语言
@@ -41,6 +59,8 @@ setI18nLanguage(localeValue as SupportedLocale)
const authStore = useAuthStore()
const isLogin = computed(() => authStore.token)
const route = useRoute()
const { initializePWA } = usePWA()
const offlineStatus = useGlobalOfflineStatus()
// 全局设置store
const globalSettingsStore = useGlobalSettingsStore()
@@ -51,64 +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
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('dashboard/cpu')
}
} 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)
@@ -116,6 +305,84 @@ function updateHtmlThemeAttribute(themeName: string) {
syncRootLaunchPalette()
}
// 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。
function syncThemePreferenceFromStorage() {
themeValue = localStorage.getItem('theme') || 'auto'
const resolvedTheme = resolveThemeName(themeValue)
if (globalTheme.name.value !== resolvedTheme) {
globalTheme.name.value = resolvedTheme
}
applyStoredThemeCustomizerAppearance(vuetifyTheme)
updateHtmlThemeAttribute(resolvedTheme)
configureApexChartsTheme(resolvedTheme)
// 前台恢复时重新跑一次主题管理器,补齐 transparent CSS 和 auto 的实际 DOM 主题。
void themeManager
.setTheme(themeValue)
.then(() => {
updateHtmlThemeAttribute(globalTheme.name.value)
})
.catch(error => {
console.error('同步主题管理器失败:', error)
})
}
// 系统配色变化时,在自动主题模式下刷新当前实际主题。
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 {
@@ -124,6 +391,7 @@ async function fetchBackgroundImages() {
backgroundImages.value = await api.get(`/login/wallpapers`, {
signal: backgroundRequestController.signal,
})
resetBackgroundCrossfade()
activeImageIndex.value = 0
} catch (e) {
throw e
@@ -132,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
@@ -139,7 +409,7 @@ function rotateBackgroundImage() {
preloadImage(backgroundImages.value[nextIndex]).then(success => {
// 只有图片成功加载才切换
if (success) {
activeImageIndex.value = nextIndex
activateBackgroundImage(nextIndex)
}
})
}
@@ -164,6 +434,7 @@ function startBackgroundRotation() {
}
}
// 停止登录页或透明主题背景图加载、重试和轮播。
function stopBackgroundLoading() {
backgroundRequestController?.abort()
backgroundRequestController = null
@@ -173,9 +444,11 @@ function stopBackgroundLoading() {
backgroundRetryTimer = null
}
resetBackgroundCrossfade()
removeBackgroundTimer('background-rotation')
}
// 初始化登录后的全局设置和用户设置状态。
async function initializeAuthenticatedState() {
if (!isLogin.value) return
@@ -188,6 +461,7 @@ async function initializeAuthenticatedState() {
}
}
// 延迟初始化登录态数据,避开登录成功后的即时路由跳转窗口。
function scheduleAuthenticatedStateInitialization() {
if (authenticatedStateTimer) {
window.clearTimeout(authenticatedStateTimer)
@@ -201,19 +475,25 @@ function scheduleAuthenticatedStateInitialization() {
}
// 添加logo动画效果并延迟移除加载界面
function animateAndRemoveLoader() {
async function animateAndRemoveLoader() {
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
if (loadingBg) {
// 只收掉启动内容,背景层保持实色直到节点被移除,避免底部 safe area 先透出页面内容。
loadingBg.classList.add('loading-complete')
window.setTimeout(() => {
removeEl('#loading-bg')
await new Promise<void>(resolve => {
window.setTimeout(() => {
removeEl('#loading-bg')
// 启动阶段的根节点锁定只在 loader 存在时生效,移除后恢复正常页面与弹窗布局。
document.documentElement.removeAttribute('data-launch-loading')
document.documentElement.style.removeProperty('overflow')
document.body.style.removeProperty('overflow')
}, 120)
// 启动阶段的根节点锁定只在 loader 存在时生效,移除后恢复正常页面与弹窗布局。
document.documentElement.removeAttribute('data-launch-loading')
document.documentElement.style.removeProperty('overflow')
document.body.style.removeProperty('overflow')
completeLaunchLoading()
resolve()
}, 120)
})
} else {
completeLaunchLoading()
}
}
@@ -230,13 +510,15 @@ async function removeLoadingWithStateCheck() {
}
globalLoadingStateManager.setLoadingState('pwa-state', false)
// PWA/App 模式会影响布局和底部导航,必须在启动屏退场前稳定下来。
await initializePWA()
await initializeAuthenticatedState()
// 等待所有加载完成
await globalLoadingStateManager.waitForAllComplete()
// 移除加载界面
animateAndRemoveLoader()
await animateAndRemoveLoader()
// 检查未读消息
if (isLogin.value) {
@@ -245,7 +527,7 @@ async function removeLoadingWithStateCheck() {
} catch (error) {
// 即使出错也要移除加载界面
globalLoadingStateManager.reset()
animateAndRemoveLoader()
await animateAndRemoveLoader()
}
}
@@ -285,6 +567,8 @@ onMounted(async () => {
// 初始化主题管理器 - 统一处理主题初始化
await themeManager.setTheme(themeValue)
applyStoredThemeCustomizerAppearance(vuetifyTheme)
updateHtmlThemeAttribute(globalTheme.name.value)
// 监听主题变化
watch(
@@ -297,6 +581,15 @@ onMounted(async () => {
},
)
prefersColorSchemeMediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)') ?? null
prefersColorSchemeMediaQuery?.addEventListener('change', handleSystemThemeChange)
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(
shouldLoadBackgroundImages,
@@ -331,6 +624,7 @@ onMounted(async () => {
authenticatedStateTimer = null
}
stopHeartbeat()
offlineStatus.markServerOnline()
}
})
})
@@ -342,31 +636,44 @@ onUnmounted(() => {
window.clearTimeout(authenticatedStateTimer)
authenticatedStateTimer = null
}
clearWindowBlurRenderThrottleTimer()
// 停止心跳
stopHeartbeat()
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
prefersColorSchemeMediaQuery = null
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 />
@@ -417,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%;
@@ -436,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

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

@@ -49,7 +49,7 @@ export interface Subscribe {
// 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数)
completed_episode?: number
// 附加信息
note?: string
note?: string | number[]
// 状态N-新建 R-订阅中 P-待定 S-暂停
state: string
// 最后更新时间
@@ -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
@@ -702,6 +736,8 @@ export interface DashboardItem {
attrs: { [key: string]: any }
// col列数
cols: { [key: string]: number }
// Grid行数
rows?: number
// 页面元素
elements: RenderProps[]
// 渲染方式
@@ -766,6 +802,58 @@ export interface TorrentInfo {
category: string
}
// 字幕信息
export interface SubtitleInfo {
// 站点ID
site?: number
// 站点名称
site_name?: string
// 站点Cookie
site_cookie?: string
// 站点UA
site_ua?: string
// 站点是否使用代理
site_proxy?: boolean
// 站点优先级
site_order?: number
// 字幕标题
title?: string
// 字幕描述
description?: string
// 字幕下载链接
enclosure?: string
// 详情页面
page_url?: string
// 语言
language?: string
// 语言图标
language_icon?: string
// 字幕大小
size?: number
// 发布时间
pubdate?: string
// 已过时间
date_elapsed?: string
// 点击/下载次数
grabs?: number
// 上传者
uploader?: string
// 举报页面
report_url?: string
// 种子ID
torrent_id?: string
// 字幕ID
subtitle_id?: string
// 下载文件名
file_name?: string
// 识别元数据
meta_info?: MetaInfo
// SxxExx
season_episode?: string
// 集列表
episode_list?: number[]
}
// 识别元数据
export interface MetaInfo {
// 是否处理的文件
@@ -912,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
}
// 后台进程
@@ -932,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 {
// 下载速度
@@ -946,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
}
// 消息通知
@@ -1025,6 +1199,10 @@ export interface FileItem {
export interface MediaServerPlayItem {
// ID
id?: string | number
// 媒体服务器项目ID
item_id?: string | number
// 媒体服务器ID
server_id?: string
// 标题
title: string
// 副标题
@@ -1049,12 +1227,18 @@ export interface MediaServerLibrary {
server: string
// ID
id?: string | number
// 媒体服务器项目ID
item_id?: string | number
// 媒体服务器ID
server_id?: string
// 名称
name: string
// 路径
path?: string
// 类型
type?: string
// 库内媒体数量
item_count?: number
// 图片
image?: string
// 图片列表
@@ -1069,6 +1253,12 @@ export interface MediaServerLibrary {
// 消息通知
export interface Message {
// 消息ID
id?: number
// 消息渠道
channel?: string
// 消息来源
source?: string
// 消息类型
mtype?: string
// 消息标题
@@ -1088,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
}
@@ -1290,9 +1476,9 @@ export interface TransferForm {
// 历史ID
logid: number
// 目标存储
target_storage: string
target_storage: string | null
// 目标路径
target_path: string
target_path: string | null
// TMDB ID
tmdbid?: number
// 豆瓣 ID
@@ -1302,7 +1488,7 @@ export interface TransferForm {
// 类型
type_name?: string
// 整理方式
transfer_type: string
transfer_type: string | null
// 自定义格式
episode_format?: string
// 指定集数
@@ -1314,13 +1500,13 @@ export interface TransferForm {
// 最小文件大小
min_filesize: number
// 刮削
scrape: boolean
scrape: boolean | null
// 复用历史识别信息
from_history: boolean
// 媒体库类型子目录
library_type_folder?: boolean
library_type_folder?: boolean | null
// 媒体库类别子目录
library_category_folder?: boolean
library_category_folder?: boolean | null
// 剧集组编号
episode_group?: string | null
// 预览模式
@@ -1335,6 +1521,22 @@ export interface ManualTransferPayload extends Omit<TransferForm, 'fileitem'> {
fileitems?: FileItem[]
}
// 手动整理目的路径匹配结果
export interface ManualTransferTargetPathData {
// 目标存储
target_storage?: string | null
// 目标路径
target_path?: string | null
// 整理方式
transfer_type?: string | null
// 刮削
scrape?: boolean | null
// 媒体库类型子目录
library_type_folder?: boolean | null
// 媒体库类别子目录
library_category_folder?: boolean | null
}
// 手动整理预览统计
export interface ManualTransferPreviewSummary {
// 总数
@@ -1369,6 +1571,14 @@ export interface ManualTransferPreviewItem {
episode_end?: number | string
// Part
part?: string
// 原始识别字符串
org_string?: string
// 应用的自定义识别词
apply_words?: string[]
// 制作组/字幕组
resource_team?: string
// 自定义占位符
customization?: string
}
// 手动整理预览数据
@@ -1457,6 +1667,10 @@ export interface Workflow {
actions?: any[]
// 动作流
flows?: any[]
// 工作流执行配置
execution_config?: { [key: string]: any }
// 工作流结构化执行状态
execution_state?: { [key: string]: any }
// 创建时间
add_time?: string
// 最后执行时间
@@ -1523,6 +1737,7 @@ export interface SubscribeShareStatistics {
export interface ApiResponse<T = any> {
success: boolean
message?: string
message_i18n?: string
data: T
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
import { openMediaServerItem } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
@@ -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
@@ -25,8 +31,8 @@ function imageErrorHandler() {
// 跳转播放
async function goPlay() {
if (props.media?.link) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
if (props.media) {
await openMediaServerItem(props.media)
}
}
@@ -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

@@ -71,52 +71,85 @@ async function deleteDownload() {
</script>
<template>
<VCard v-if="cardState" :key="props.info?.hash" class="flex flex-col h-full" min-height="150">
<template #image>
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
<VHover>
<template #default="hover">
<!-- 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"
class="downloading-card-image"
aspect-ratio="2/3"
cover
@load="imageLoadHandler"
position="top"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
<template #default>
<div class="absolute inset-0 outline-none downloading-card-background"></div>
</template>
</VImg>
</template>
<template #default>
<div class="absolute inset-0 outline-none downloading-card-background"></div>
</template>
</VImg>
<div>
<VCardTitle class="break-words whitespace-normal text-white">
{{ props.info?.media.title || props.info?.name }}
{{
props.info?.media.episode
? `${props.info?.media.season} ${props.info?.media.episode}`
: props.info?.season_episode
}}
</VCardTitle>
<VCardSubtitle class="break-words whitespace-normal text-white">
{{ props.info?.title }}
</VCardSubtitle>
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
{{ getSpeedText() }}
</VCardText>
<VCardText v-if="getPercentage() > 0" class="text-white">
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
</VCardText>
<VCardActions class="justify-space-between">
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
</VCardActions>
</div>
</VCard>
</div>
</template>
<div>
<VCardTitle class="break-words whitespace-normal text-white">
{{ props.info?.media.title || props.info?.name }}
{{
props.info?.media.episode
? `${props.info?.media.season} ${props.info?.media.episode}`
: props.info?.season_episode
}}
</VCardTitle>
<VCardSubtitle class="break-words whitespace-normal text-white">
{{ props.info?.title }}
</VCardSubtitle>
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
{{ getSpeedText() }}
</VCardText>
<VCardText v-if="getPercentage() > 0" class="text-white">
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
</VCardText>
<VCardActions class="justify-space-between">
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
</VCardActions>
</div>
</VCard>
</VHover>
</template>
<style lang="scss" scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.downloading-card-hover-area {
inline-size: 100%;
}
.downloading-card-image {
block-size: 100%;
}
.downloading-card-background {
border-radius: inherit;
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
pointer-events: none;
}
</style>

View File

@@ -4,7 +4,7 @@ import plex from '@images/misc/plex.png'
import emby from '@images/misc/emby.png'
import jellyfin from '@images/misc/jellyfin.png'
import { getLogoUrl } from '@/utils/imageUtils'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
import { openMediaServerItem } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
@@ -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?.link) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
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 { getLogoUrl } from '@/utils/imageUtils'
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 { hasPermission } from '@/utils/permission'
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()
@@ -45,9 +52,9 @@ const globalSettings = globalSettingsStore.globalSettings
// 用户 Store
const userStore = useUserStore()
// 提示框
const $toast = useToast()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
// 图片加载状态
const isImageLoaded = ref(false)
@@ -61,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 } = {
@@ -86,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() {
@@ -143,7 +137,7 @@ async function querySites() {
// 查询用户选中的站点
async function querySelectedSites() {
try {
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
const result: { [key: string]: any } = await api.get('system/setting/public/IndexerSites')
selectedSites.value = result.data?.value ?? []
} catch (error) {
console.log(error)
@@ -152,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) {
@@ -174,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'
@@ -181,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 {
@@ -288,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 {
@@ -314,64 +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 (!userStore.superUser) return false
try {
let subscribe_config_url = ''
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/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({
@@ -395,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) {
@@ -464,31 +391,57 @@ function setupIntersectionObserver() {
const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
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
})
@@ -497,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"
@@ -526,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%)"
>
@@ -539,16 +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="hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')"
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
icon="mdi-heart"
v-if="canSubscribe"
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
:color="isSubscribed ? 'error' : 'white'"
size="small"
@click.stop="handleSubscribe"
@@ -566,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')"
@@ -580,10 +529,11 @@ onBeforeUnmount(() => {
<!--来源图标-->
<VAvatar
size="24"
variant="plain"
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>
@@ -593,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;
@@ -602,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

@@ -3,6 +3,7 @@ import personIcon from '@images/misc/person-icon.png'
import type { Person } from '@/api/types'
import router from '@/router'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const personProps = defineProps({
person: Object as PropType<Person>,
@@ -40,9 +41,7 @@ function getPersonImage() {
} else {
return personIcon
}
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
}
// 人物姓名
@@ -76,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">
@@ -108,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,14 +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 PluginVersionHistoryDialog = defineAsyncComponent(
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
)
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
// 输入参数
const props = defineProps({
@@ -24,6 +30,11 @@ const emit = defineEmits(['install'])
// 多语言
const { t } = useI18n()
// 提示框
const $toast = useToast()
const createConfirm = useConfirm()
// 背景颜色
const backgroundColor = ref('#28A9E1')
@@ -46,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
@@ -94,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(
@@ -119,6 +200,15 @@ function showPluginDetail() {
// 弹出菜单
const dropdownItems = ref([
{
title: t('plugin.versionHistory'),
value: 2,
show: !isNullOrEmptyObject(props.plugin?.history || {}),
props: {
prependIcon: 'mdi-update',
click: showUpdateHistory,
},
},
{
title: t('plugin.projectHome'),
value: 1,
@@ -128,33 +218,29 @@ const dropdownItems = ref([
click: visitPluginPage,
},
},
{
title: t('plugin.updateHistory'),
value: 2,
show: !isNullOrEmptyObject(props.plugin?.history || {}),
props: {
prependIcon: 'mdi-update',
click: showUpdateHistory,
},
},
])
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%)`"
@@ -176,14 +262,14 @@ const dropdownItems = ref([
{{ props.plugin?.plugin_desc }}
</div>
<!-- 插件标签 -->
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2">
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2 mb-2">
<VChip
v-for="tag in pluginLabels"
:key="tag"
size="x-small"
variant="tonal"
color="info"
class="me-1 mb-1"
class="plugin-app-card__tag"
tile
>
{{ tag }}
@@ -240,8 +326,35 @@ 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;
flex-wrap: nowrap;
gap: 4px;
max-inline-size: 100%;
}
.plugin-app-card__tag {
flex: 0 0 auto;
max-inline-size: 100%;
min-inline-size: 0;
}
.plugin-app-card__tag :deep(.v-chip__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -3,7 +3,6 @@ import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import api from '@/api'
import type { Plugin } from '@/api/types'
import { isNullOrEmptyObject } from '@core/utils'
import { getLogoUrl } from '@/utils/imageUtils'
import { getDominantColor } from '@/@core/utils/image'
import { formatDownloadCount } from '@/@core/utils/formatters'
@@ -70,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,18 +103,14 @@ async function imageLoaded() {
}
// 显示更新日志
function showUpdateHistory() {
// 检查当前版本是否有更新日志
if (isNullOrEmptyObject(props.plugin?.history)) {
updatePlugin()
} else {
openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin, showUpdateAction: true },
{ update: updatePlugin },
{ closeOn: ['close', 'update', 'update:modelValue'] },
)
}
function showUpdateHistory(showUpdateAction: boolean = false) {
versionHistoryDialogController?.close()
versionHistoryDialogController = openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin, showUpdateAction },
{ update: updatePlugin },
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 调用API卸载插件
@@ -225,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,
},
})
@@ -247,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')
@@ -264,9 +280,102 @@ async function updatePlugin() {
}
}
// 访问作者主页
function visitAuthorPage() {
window.open(props.plugin?.author_url, '_blank')
/** 将 raw.githubusercontent.com 插件地址转换为可访问的 GitHub 项目主页。 */
function normalizePluginRepoUrl(repoUrl?: string) {
if (!repoUrl || !repoUrl.includes('raw.githubusercontent.com')) return repoUrl
try {
const rawUrl = new URL(repoUrl)
const [user, repo] = rawUrl.pathname.split('/').filter(Boolean)
if (user && repo) return `https://github.com/${user}/${repo}`
} catch (error) {
console.error(error)
}
return repoUrl
}
/** 判断插件当前是否已经有可用的远程项目地址。 */
function hasRemoteRepoUrl(plugin?: Plugin) {
return Boolean(plugin?.repo_url && !plugin.repo_url.startsWith('local://'))
}
/** 优先解析插件仓库地址,本地插件或缺少仓库地址时回退到作者主页。 */
function resolvePluginPageUrl(plugin?: Plugin) {
if (!plugin) return ''
const repoUrl =
hasRemoteRepoUrl(plugin)
? normalizePluginRepoUrl(plugin.repo_url)
: plugin.author_url
return repoUrl || plugin.author_url || ''
}
/** 从插件市场中查找同 ID 插件,补齐已安装插件缺失的 repo_url。 */
async function fetchMarketPlugin(pluginId?: string) {
if (!pluginId) return null
try {
const marketPlugins: Plugin[] = await api.get('plugin/', {
params: {
state: 'market',
force: false,
},
})
return marketPlugins.find(plugin => plugin.id === pluginId) || null
} catch (error) {
console.error(error)
return null
}
}
// 访问插件项目主页
async function visitPluginPage() {
const popup = window.open('about:blank', '_blank')
let pluginDetail = props.plugin
if (popup) popup.opener = null
try {
if (props.plugin?.id) {
const historyPlugin: Plugin = await api.get(`plugin/history/${props.plugin.id}`, {
params: {
force: false,
},
})
// 历史接口可能只返回部分字段,合并原卡片数据避免丢失 author_url 兜底。
pluginDetail = { ...(props.plugin || {}), ...(historyPlugin || {}) } as Plugin
}
} catch (error) {
console.error(error)
}
if (!hasRemoteRepoUrl(pluginDetail)) {
const marketPlugin = await fetchMarketPlugin(props.plugin?.id)
if (marketPlugin) {
// 插件市场条目通常包含真实仓库地址,优先使用它来对齐市场卡片跳转。
pluginDetail = { ...(pluginDetail || {}), ...marketPlugin } as Plugin
}
}
const repoUrl = resolvePluginPageUrl(pluginDetail)
if (repoUrl) {
if (popup) {
popup.location.replace(repoUrl)
return
}
window.open(repoUrl, '_blank')
return
}
popup?.close()
}
// 打开插件详情
@@ -377,7 +486,7 @@ const dropdownItems = ref([
props: {
prependIcon: 'mdi-arrow-up-circle-outline',
color: 'success',
click: showUpdateHistory,
click: () => showUpdateHistory(true),
},
},
{
@@ -400,6 +509,15 @@ const dropdownItems = ref([
click: uninstallPlugin,
},
},
{
title: t('plugin.versionHistory'),
value: 9,
show: !props.plugin?.has_update,
props: {
prependIcon: 'mdi-update',
click: () => showUpdateHistory(false),
},
},
{
title: t('plugin.viewLogs'),
value: 6,
@@ -412,12 +530,12 @@ const dropdownItems = ref([
},
},
{
title: t('plugin.authorHome'),
title: t('plugin.projectHome'),
value: 7,
show: true,
props: {
prependIcon: 'mdi-home-circle-outline',
click: visitAuthorPage,
prependIcon: 'mdi-github',
click: visitPluginPage,
},
},
])
@@ -428,6 +546,9 @@ watch(
(newHasUpdate, _) => {
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
const updateHistoryItemIndex = dropdownItems.value.findIndex(item => item.value === 9)
if (updateHistoryItemIndex !== -1) dropdownItems.value[updateHistoryItemIndex].show = !newHasUpdate
},
)
@@ -446,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%)`"
@@ -548,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>
@@ -556,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

@@ -137,7 +137,7 @@ function handleDropToFolder(event: DragEvent) {
&.sortable-ghost {
border: 2px dashed #2196f3;
border-radius: 16px;
border-radius: var(--app-surface-radius);
background: rgba(33, 150, 243, 10%);
opacity: 0.3;
}
@@ -151,7 +151,7 @@ function handleDropToFolder(event: DragEvent) {
&.drag-over {
border: 2px dashed #2196f3;
border-radius: 16px;
border-radius: var(--app-surface-radius);
box-shadow: 0 0 20px rgba(33, 150, 243, 50%);
transform: scale(1.02);
}

View File

@@ -2,7 +2,7 @@
import type { PropType } from 'vue'
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
import { openMediaServerItem } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
@@ -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'
@@ -38,8 +44,8 @@ const getImgUrl = computed(() => {
// 跳转播放
async function goPlay(isHovering: boolean | null = false) {
if (props.media?.link && isHovering) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
if (props.media && isHovering) {
await openMediaServerItem(props.media)
}
}
</script>
@@ -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,26 +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"
rounded="lg"
: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>
@@ -420,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;
@@ -456,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;
}
@@ -645,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

@@ -1,7 +1,8 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
import { formatDateDifference } from '@/@core/utils/formatters'
import { formatSeasonLabel } from '@/@core/utils/season'
import api from '@/api'
import type { Subscribe } from '@/api/types'
import router from '@/router'
@@ -9,6 +10,7 @@ import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
@@ -90,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
@@ -288,22 +301,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,
@@ -332,6 +329,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,
@@ -363,19 +376,13 @@ watch(
// 计算backdrop图片地址
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
})
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
})
// 订阅编辑保存
@@ -408,64 +415,162 @@ function handleCardClick() {
<div>
<VHover>
<template #default="hover">
<div
class="w-full h-full rounded-lg overflow-hidden 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"
<!-- 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,
}"
rounded="0"
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"
@@ -484,7 +589,7 @@ function handleCardClick() {
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
<div class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ...">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
</div>
</div>
</VCardText>
@@ -511,10 +616,26 @@ function handleCardClick() {
{{ subscribeProgressTooltip }}
</VTooltip>
</div>
<VIcon v-if="props.media?.username && props.sortable" icon="mdi-account" size="small" color="white" class="flex-shrink-0 me-1" />
<IconBtn v-else-if="props.media?.username" icon="mdi-account" size="small" color="white" class="flex-shrink-0" />
<VIcon
v-if="props.media?.username && props.sortable"
icon="mdi-account"
size="small"
color="white"
class="flex-shrink-0 me-1"
/>
<IconBtn
v-else-if="props.media?.username"
icon="mdi-account"
size="small"
color="white"
class="flex-shrink-0"
/>
<!-- 用户名过长时限制在卡片宽度内并用省略号展示剩余内容 -->
<span v-if="props.media?.username" class="min-w-0 truncate text-subtitle-2 text-white" :title="props.media?.username">
<span
v-if="props.media?.username"
class="min-w-0 truncate text-subtitle-2 text-white"
:title="props.media?.username"
>
{{ props.media?.username }}
</span>
</div>
@@ -556,14 +677,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%);
}
@@ -577,19 +869,20 @@ function handleCardClick() {
}
/**
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
* 待定:内发光挂在实际 VCard 上,跟随卡片圆角并被 overflow-hidden 裁剪。
*/
.subscribe-card-pending-tint {
position: relative;
}
.subscribe-card-pending-tint::after {
content: '';
position: absolute;
z-index: 3;
border-radius: inherit;
box-shadow: inset 0 0 48px rgba(var(--v-theme-info), 0.28);
content: '';
inset: 0;
pointer-events: none;
border-radius: 8px;
box-shadow: inset 0 0 48px rgba(56, 189, 248, 0.4); // sky-400
z-index: 3;
}
/**
@@ -599,22 +892,49 @@ function handleCardClick() {
*/
.best-version-badge {
position: absolute;
top: 6px;
left: 8px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.75);
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
z-index: 4;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
border-radius: 50%;
backdrop-filter: blur(6px);
background: rgba(0, 0, 0, 75%);
block-size: 24px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 50%);
inline-size: 24px;
inset-block-start: 6px;
inset-inline-start: 8px;
}
.best-version-badge-full {
background: rgba(255, 255, 255, 0.22);
backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.15);
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

@@ -4,6 +4,7 @@ import type { SubscribeShare } from '@/api/types'
import router from '@/router'
import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const ForkSubscribeDialog = defineAsyncComponent(() => import('../dialog/ForkSubscribeDialog.vue'))
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
@@ -35,19 +36,13 @@ const dateText = ref(props.media && props.media?.date ? formatDateDifference(pro
// 计算backdrop图片地址
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
})
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
})
// 获得mediaid
@@ -98,17 +93,17 @@ function doDelete() {
<div class="h-full">
<VHover>
<template #default="hover">
<div
class="w-full h-full rounded-lg 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"
rounded="0"
class="app-hover-lift-card flex flex-col h-full"
min-height="150"
@click="showForkSubscribe"
>
@@ -161,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

@@ -0,0 +1,219 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import type { SubtitleInfo } from '@/api/types'
import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useI18n } from 'vue-i18n'
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
// 多语言支持
const { t } = useI18n()
// 输入参数
const props = defineProps({
subtitle: Object as PropType<SubtitleInfo>,
width: String,
})
// 字幕信息
const subtitle = ref(props.subtitle)
// 站点图标
const siteIcon = ref('')
const isDownloaded = computed(() => Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]))
// 查询站点图标
async function getSiteIcon() {
if (!subtitle.value?.site) {
siteIcon.value = ''
return
}
try {
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
try {
const response = await api.get(`site/icon/${subtitle.value?.site}`)
return response?.data?.icon || ''
} catch (error) {
console.error('Failed to load site icon:', error)
return ''
}
})
} catch (error) {
console.error('Failed to load site icon:', error)
siteIcon.value = ''
}
}
// 添加字幕下载成功
function addDownloadSuccess(url: string) {
markSubtitleDownloaded(url)
}
// 添加字幕下载失败
function addDownloadError(error: string) {
console.error(error)
}
// 询问并下载字幕
async function handleAddDownload() {
openSharedDialog(
AddSubtitleDownloadDialog,
{
title: subtitle.value?.title,
subtitle: subtitle.value,
},
{
done: addDownloadSuccess,
error: addDownloadError,
},
{ closeOn: ['close', 'done', 'error'] },
)
}
// 打开字幕详情页面
function openSubtitleDetail() {
if (!subtitle.value?.page_url) return
window.open(subtitle.value.page_url, '_blank')
}
// 打开字幕举报页面
function openReportPage() {
if (!subtitle.value?.report_url) return
window.open(subtitle.value.report_url, '_blank')
}
watch(
() => props.subtitle,
value => {
subtitle.value = value
getSiteIcon()
},
{ immediate: true },
)
</script>
<template>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div class="subtitle-card-hover-area h-full">
<VCard
:width="props.width || '100%'"
:variant="isDownloaded ? 'outlined' : 'flat'"
@click="handleAddDownload"
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
>
<VCardItem class="pt-3 pb-0">
<div class="d-flex justify-space-between align-center flex-wrap gap-2 mb-2">
<div class="d-flex align-center min-w-0">
<VImg
v-if="siteIcon"
:src="siteIcon"
:alt="subtitle?.site_name"
class="mr-2 rounded"
width="20"
height="20"
/>
<VAvatar v-else size="20" class="mr-2 text-caption bg-surface-variant" color="surface-variant">
{{ subtitle?.site_name?.substring(0, 1) }}
</VAvatar>
<span class="font-weight-bold text-body-2 text-truncate">{{ subtitle?.site_name }}</span>
</div>
<div class="d-flex align-center gap-2">
<VChip v-if="subtitle?.season_episode" size="x-small" color="secondary" variant="tonal" class="rounded-sm">
{{ subtitle.season_episode }}
</VChip>
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
<VImg
v-if="subtitle?.language_icon"
:src="subtitle.language_icon"
:alt="subtitle.language"
width="14"
height="14"
class="me-1"
/>
{{ subtitle.language }}
</VChip>
<VChip v-if="isDownloaded" size="x-small" color="success" variant="tonal" class="rounded-sm">
{{ t('dialog.addSubtitleDownload.downloaded') }}
</VChip>
</div>
</div>
</VCardItem>
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-2 break-all" :title="subtitle?.title">
{{ subtitle?.title }}
</div>
<div
v-if="subtitle?.description"
class="text-body-2 text-medium-emphasis mb-2 break-all"
:title="subtitle?.description"
>
{{ subtitle.description }}
</div>
<div class="d-flex flex-wrap align-center gap-2 mb-2">
<span v-if="subtitle?.pubdate || subtitle?.date_elapsed" class="d-flex align-center text-sm text-medium-emphasis">
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
</span>
<span v-if="subtitle?.grabs !== undefined" class="d-flex align-center text-sm text-medium-emphasis">
<VIcon size="small" color="primary" icon="mdi-download-outline" class="me-1"></VIcon>
{{ subtitle.grabs }}
</span>
<span v-if="subtitle?.uploader" class="d-flex align-center text-sm text-medium-emphasis">
<VIcon size="small" color="grey" icon="mdi-account-outline" class="me-1"></VIcon>
{{ subtitle.uploader }}
</span>
</div>
<div class="d-flex flex-wrap gap-1">
<VChip v-if="subtitle?.torrent_id" size="x-small" variant="tonal" class="rounded-sm">
TID {{ subtitle.torrent_id }}
</VChip>
<VChip v-if="subtitle?.subtitle_id" size="x-small" variant="tonal" class="rounded-sm">
SID {{ subtitle.subtitle_id }}
</VChip>
</div>
</VCardText>
<VCardActions class="border-t border-opacity-10 mt-auto pa-2">
<VChip v-if="subtitle?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm">
{{ formatFileSize(subtitle.size) }}
</VChip>
<VSpacer />
<VBtn v-if="subtitle?.report_url" icon size="small" variant="text" color="warning" @click.stop="openReportPage">
<VIcon icon="mdi-alert-outline"></VIcon>
</VBtn>
<VBtn v-if="subtitle?.page_url" icon size="small" variant="text" color="primary" @click.stop="openSubtitleDetail">
<VIcon icon="mdi-information-outline"></VIcon>
</VBtn>
</VCardActions>
</VCard>
</div>
</template>
<style scoped>
.subtitle-card-hover-area {
inline-size: 100%;
}
.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

@@ -0,0 +1,222 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import type { SubtitleInfo } from '@/api/types'
import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useI18n } from 'vue-i18n'
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
// 多语言支持
const { t } = useI18n()
// 输入参数
const props = defineProps({
subtitle: Object as PropType<SubtitleInfo>,
})
// 字幕信息
const subtitle = ref(props.subtitle)
// 站点图标
const siteIcon = ref('')
const isDownloaded = computed(() => Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]))
// 查询站点图标
async function getSiteIcon() {
if (!subtitle.value?.site) {
siteIcon.value = ''
return
}
try {
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
try {
const response = await api.get(`site/icon/${subtitle.value?.site}`)
return response?.data?.icon || ''
} catch (error) {
console.error('Failed to load site icon:', error)
return ''
}
})
} catch (error) {
console.error('Failed to load site icon:', error)
siteIcon.value = ''
}
}
// 询问并下载字幕
async function handleAddDownload() {
openSharedDialog(
AddSubtitleDownloadDialog,
{
title: subtitle.value?.title,
subtitle: subtitle.value,
},
{
done: addDownloadSuccess,
error: addDownloadError,
},
{ closeOn: ['close', 'done', 'error'] },
)
}
// 添加字幕下载成功
function addDownloadSuccess(url: string) {
markSubtitleDownloaded(url)
}
// 添加字幕下载失败
function addDownloadError(error: string) {
console.error(error)
}
// 打开字幕详情页面
function openSubtitleDetail() {
if (!subtitle.value?.page_url) return
window.open(subtitle.value.page_url, '_blank')
}
// 打开字幕举报页面
function openReportPage() {
if (!subtitle.value?.report_url) return
window.open(subtitle.value.report_url, '_blank')
}
watch(
() => props.subtitle,
value => {
subtitle.value = value
getSiteIcon()
},
{ immediate: true },
)
</script>
<template>
<!-- Hover 命中区域保持静止避免列表项上浮后底边反复触发 mouseleave -->
<div class="subtitle-item-hover-area w-100">
<VListItem
:value="subtitle?.enclosure"
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"
>
<template #prepend>
<div class="d-flex flex-column align-center pr-3" :title="subtitle?.site_name">
<VImg
v-if="siteIcon"
:src="siteIcon"
:alt="subtitle?.site_name"
class="rounded mb-1 site-icon"
width="32"
height="32"
/>
<VAvatar
v-else
size="32"
class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold site-icon"
>
{{ subtitle?.site_name?.substring(0, 1) }}
</VAvatar>
</div>
</template>
<VListItemTitle class="whitespace-normal">
<div class="d-flex flex-row flex-wrap align-center gap-2 mb-2">
<span class="text-h6 font-weight-bold me-1">{{ subtitle?.site_name }}</span>
<VChip v-if="subtitle?.season_episode" size="x-small" color="secondary" variant="tonal" class="rounded-sm">
{{ subtitle.season_episode }}
</VChip>
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
<VImg
v-if="subtitle?.language_icon"
:src="subtitle.language_icon"
:alt="subtitle.language"
width="14"
height="14"
class="me-1"
/>
{{ subtitle.language }}
</VChip>
<VChip v-if="isDownloaded" size="x-small" color="success" variant="tonal" class="rounded-sm">
{{ t('dialog.addSubtitleDownload.downloaded') }}
</VChip>
</div>
<div class="text-subtitle-2 font-weight-medium mb-2 break-all" :title="subtitle?.title">
{{ subtitle?.title }}
</div>
<div v-if="subtitle?.description" class="text-body-2 text-medium-emphasis mb-2 break-all" :title="subtitle.description">
{{ subtitle.description }}
</div>
<div class="d-flex flex-wrap gap-2 mb-2">
<span v-if="subtitle?.pubdate || subtitle?.date_elapsed" class="d-flex align-center text-sm text-medium-emphasis">
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
</span>
<span v-if="subtitle?.grabs !== undefined" class="d-flex align-center text-sm text-medium-emphasis">
<VIcon size="small" color="primary" icon="mdi-download-outline" class="me-1"></VIcon>
{{ subtitle.grabs }}
</span>
<span v-if="subtitle?.uploader" class="d-flex align-center text-sm text-medium-emphasis">
<VIcon size="small" color="grey" icon="mdi-account-outline" class="me-1"></VIcon>
{{ subtitle.uploader }}
</span>
</div>
</VListItemTitle>
<template #append>
<div class="d-flex flex-column align-end gap-2">
<VChip v-if="subtitle?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm">
{{ formatFileSize(subtitle.size) }}
</VChip>
<div class="d-flex align-center">
<VBtn
v-if="subtitle?.report_url"
icon
size="small"
variant="text"
color="warning"
@click.stop="openReportPage"
>
<VIcon icon="mdi-alert-outline"></VIcon>
</VBtn>
<VBtn
v-if="subtitle?.page_url"
icon
size="small"
variant="text"
color="primary"
@click.stop="openSubtitleDetail"
>
<VIcon icon="mdi-information-outline"></VIcon>
</VBtn>
</div>
</div>
</template>
</VListItem>
</div>
</template>
<style scoped>
.subtitle-item-hover-area {
inline-size: 100%;
}
.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,52 +95,56 @@ function doDelete() {
<div class="h-full">
<VHover>
<template #default="hover">
<div
class="w-full h-full rounded-lg overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="workflow-share-card-hover-area h-full">
<VCard
v-bind="hover.props"
:key="props.workflow?.id"
class="flex flex-col h-full"
rounded="0"
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">
<VCardTitle class="text-lg text-bold text-white line-clamp-2 break-words">
{{ props.workflow?.share_title }}
</VCardTitle>
<div class="px-4 text-white text-opacity-90 overflow-hidden line-clamp-3 break-all ...">
{{ props.workflow?.share_comment }}
</div>
<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">
<VCardTitle class="text-lg text-bold text-white line-clamp-2 break-words">
{{ props.workflow?.share_title }}
</VCardTitle>
<div class="px-4 text-white text-opacity-90 overflow-hidden line-clamp-3 break-all ...">
{{ props.workflow?.share_comment }}
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap py-2">
<div class="flex align-center">
<IconBtn v-bind="props" icon="mdi-account" class="me-1 text-white" />
<div class="text-subtitle-2 me-4 text-white text-opacity-90">
{{ props.workflow?.share_user }}
</div>
<IconBtn v-if="props.workflow?.count" icon="mdi-fire" class="me-1 text-white" />
<span v-if="props.workflow?.count" class="text-subtitle-2 me-4 text-white text-opacity-90">
{{ props.workflow?.count.toLocaleString() }}
</span>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap py-2">
<div class="flex align-center">
<IconBtn v-bind="props" icon="mdi-account" class="me-1 text-white" />
<div class="text-subtitle-2 me-4 text-white text-opacity-90">
{{ props.workflow?.share_user }}
</div>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-white text-sm text-opacity-75">
<VIcon icon="mdi-calendar" size="small" class="me-1" />
{{ dateText }}
</VCardText>
</div>
<IconBtn v-if="props.workflow?.count" icon="mdi-fire" class="me-1 text-white" />
<span v-if="props.workflow?.count" class="text-subtitle-2 me-4 text-white text-opacity-90">
{{ props.workflow?.count.toLocaleString() }}
</span>
</div>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-white text-sm text-opacity-75">
<VIcon icon="mdi-calendar" size="small" class="me-1" />
{{ dateText }}
</VCardText>
</div>
</VCard>
</div>
</template>
</VHover>
</div>
</template>
<style lang="scss" scoped>
.workflow-share-card-hover-area {
inline-size: 100%;
}
</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

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import type { Process as SystemProcess } from '@/api/types'
import { clearCacheAndReload } from '@/composables/useVersionChecker'
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
@@ -37,6 +38,12 @@ md.use(mdLinkAttributes, {
// 系统环境变量
const systemEnv = ref<any>({})
// 系统运行时间的基准秒数和同步时间,用于在弹窗打开后实时递增展示。
const systemUptimeBaseSeconds = ref<number | null>(null)
const systemUptimeSyncedAt = ref(0)
const systemUptimeNow = ref(Date.now())
let systemUptimeTimer: ReturnType<typeof setInterval> | null = null
// 所有Release
const allRelease = ref<any>([])
@@ -102,6 +109,110 @@ const frontendVersionStatistics = computed(() => versionStatistic.value?.fronten
// 活跃用户统计
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
// 系统运行秒数
const systemUptimeSeconds = computed(() => {
if (systemUptimeBaseSeconds.value === null) return null
const elapsedSeconds = Math.floor((systemUptimeNow.value - systemUptimeSyncedAt.value) / 1000)
return Math.max(0, systemUptimeBaseSeconds.value + elapsedSeconds)
})
// 友好的系统运行时间文本
const systemUptimeText = computed(() => {
if (systemUptimeSeconds.value === null) return ''
return formatUptimeDuration(systemUptimeSeconds.value)
})
/** 格式化版本安装统计数字为千分位展示。 */
function formatVersionStatisticNumber(value: unknown) {
const numberValue = Number(value ?? 0)
if (!Number.isFinite(numberValue)) return '0'
return numberValue.toLocaleString()
}
/** 将秒数保存为运行时间基准,并记录本地同步时间。 */
function syncSystemUptime(seconds: number | null) {
if (seconds === null) return
const now = Date.now()
systemUptimeBaseSeconds.value = seconds
systemUptimeSyncedAt.value = now
systemUptimeNow.value = now
}
/** 将接口返回值规范化为可展示的秒数。 */
function normalizeUptimeSeconds(value: unknown) {
const numberValue = Number(value)
if (!Number.isFinite(numberValue) || numberValue < 0) return null
return Math.floor(numberValue)
}
/** 从进程创建时间推导运行秒数;兼容秒级和毫秒级时间戳。 */
function uptimeSecondsFromCreateTime(value: unknown) {
const timestamp = Number(value)
if (!Number.isFinite(timestamp) || timestamp <= 0) return null
const timestampMs = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
return Math.max(0, Math.floor((Date.now() - timestampMs) / 1000))
}
/** 获取单个进程的运行秒数,优先使用创建时间以保留跨天运行时长。 */
function getProcessUptimeSeconds(process: SystemProcess) {
return uptimeSecondsFromCreateTime(process.create_time) ?? normalizeUptimeSeconds(process.run_time)
}
/** 从进程列表中挑选 MoviePilot 主进程,找不到时使用运行时间最长的进程兜底。 */
function resolveSystemUptimeSeconds(processes: SystemProcess[]) {
const availableProcesses = processes
.map(process => ({
process,
uptimeSeconds: getProcessUptimeSeconds(process),
}))
.filter((item): item is { process: SystemProcess; uptimeSeconds: number } => item.uptimeSeconds !== null)
if (!availableProcesses.length) return null
const preferredProcesses = availableProcesses.filter(({ process }) =>
/moviepilot|python|uvicorn|gunicorn|hypercorn/i.test(process.name ?? ''),
)
const targetProcesses = preferredProcesses.length ? preferredProcesses : availableProcesses
return targetProcesses.reduce((max, item) => (item.uptimeSeconds > max.uptimeSeconds ? item : max)).uptimeSeconds
}
/** 格式化单个运行时间单位。 */
function formatUptimeUnit(value: number, unit: 'day' | 'hour' | 'minute' | 'second') {
const unitKey = value === 1 ? unit : `${unit}s`
return t(`setting.about.uptimeUnits.${unitKey}`, { count: value })
}
/** 将运行秒数格式化为两段以内的友好文本例如“3天 2小时”。 */
function formatUptimeDuration(totalSeconds: number) {
const normalizedSeconds = Math.max(0, Math.floor(totalSeconds))
const days = Math.floor(normalizedSeconds / 86400)
const hours = Math.floor((normalizedSeconds % 86400) / 3600)
const minutes = Math.floor((normalizedSeconds % 3600) / 60)
const seconds = normalizedSeconds % 60
const parts: string[] = []
if (days > 0) parts.push(formatUptimeUnit(days, 'day'))
if (hours > 0) parts.push(formatUptimeUnit(hours, 'hour'))
if (minutes > 0 && parts.length < 2) parts.push(formatUptimeUnit(minutes, 'minute'))
if (!parts.length) parts.push(formatUptimeUnit(seconds, 'second'))
return parts.slice(0, 2).join(' ')
}
// 打开日志对话框
function showReleaseDialog(title: string, body: string) {
releaseDialogTitle.value = title
@@ -142,6 +253,17 @@ async function querySystemEnv() {
}
}
// 查询系统运行时间
async function querySystemUptime() {
try {
const processes: SystemProcess[] = await api.get('dashboard/processes')
syncSystemUptime(resolveSystemUptimeSeconds(processes))
} catch (error) {
console.log(error)
}
}
// 查询所有Release
async function queryAllRelease() {
try {
@@ -183,8 +305,17 @@ async function clearCache() {
onMounted(() => {
querySystemEnv()
querySystemUptime()
queryAllRelease()
querySupportingSites()
systemUptimeTimer = setInterval(() => {
if (systemUptimeBaseSeconds.value !== null) systemUptimeNow.value = Date.now()
}, 1000)
})
onBeforeUnmount(() => {
if (systemUptimeTimer) clearInterval(systemUptimeTimer)
})
</script>
@@ -312,6 +443,16 @@ onMounted(() => {
</dd>
</div>
</div>
<div v-if="systemUptimeText">
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.systemUptime') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow flex flex-row items-center truncate">
<code class="truncate">{{ systemUptimeText }}</code>
</span>
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.supportingSites') }}</dt>
@@ -473,19 +614,19 @@ onMounted(() => {
<div class="version-stat-summary">
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.totalInstallUsers') }}</div>
<div class="version-stat-number">{{ versionStatistic.total_users ?? 0 }}</div>
<div class="version-stat-number">{{ formatVersionStatisticNumber(versionStatistic.total_users) }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.activeToday') }}</div>
<div class="version-stat-number">{{ activeUsers.today ?? 0 }}</div>
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.today) }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active7Days') }}</div>
<div class="version-stat-number">{{ activeUsers.last_7_days ?? 0 }}</div>
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_7_days) }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active30Days') }}</div>
<div class="version-stat-number">{{ activeUsers.last_30_days ?? 0 }}</div>
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_30_days) }}</div>
</div>
</div>
<div class="mt-5">
@@ -502,7 +643,7 @@ onMounted(() => {
<td>
<code>{{ item.version }}</code>
</td>
<td class="text-end">{{ item.count }}</td>
<td class="text-end">{{ formatVersionStatisticNumber(item.count) }}</td>
</tr>
<tr v-if="!backendVersionStatistics.length">
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>
@@ -524,7 +665,7 @@ onMounted(() => {
<td>
<code>{{ item.version }}</code>
</td>
<td class="text-end">{{ item.count }}</td>
<td class="text-end">{{ formatVersionStatisticNumber(item.count) }}</td>
</tr>
<tr v-if="!frontendVersionStatistics.length">
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>

View File

@@ -71,7 +71,7 @@ const buttonText = computed(() =>
// 加载目录设置
async function loadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Directories')
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
directories.value = result.data?.value ?? []
} catch (error) {
console.log(error)

View File

@@ -0,0 +1,270 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { SubtitleInfo, TransferDirectoryConf } from '@/api/types'
import { formatFileSize } from '@/@core/utils/formatters'
import { useI18n } from 'vue-i18n'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import { numberValidator } from '@/@validators'
import { useGlobalSettingsStore } from '@/stores'
// 多语言支持
const { t } = useI18n()
// 从 provide 中获取全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 当前识别类型
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
// 输入参数
const props = defineProps({
title: String,
subtitle: Object as PropType<SubtitleInfo>,
})
// 定义成功和失败事件
const emit = defineEmits(['done', 'error', 'close'])
// 提示框
const $toast = useToast()
// 选择的保存目录
const selectedDirectory = ref<string | null>(null)
// 所有目录设置
const directories = ref<TransferDirectoryConf[]>([])
// 是否正在加载
const loading = ref(false)
// 是否显示高级选项
const showAdvancedOptions = ref(false)
// TMDB ID
const tmdbid = ref<number | undefined>(undefined)
// 豆瓣ID
const doubanId = ref<string | undefined>(undefined)
// TMDB选择对话框
const mediaSelectorDialog = ref(false)
// 计算按钮图标
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
// 计算按钮文字
const buttonText = computed(() =>
loading.value ? t('dialog.addSubtitleDownload.downloading') : t('dialog.addSubtitleDownload.startDownload'),
)
// 加载目录设置
async function loadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
directories.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
function convertToUri(item: TransferDirectoryConf) {
if (!item.download_path) {
return undefined
}
if (item.storage === 'local') {
return item.download_path
}
return item.storage + ':' + item.download_path
}
// 获取保存目录
const targetDirectories = computed(() => {
const downloadDirectories = directories.value
.map(item => convertToUri(item))
.filter((item): item is string => item !== undefined)
return [...new Set(downloadDirectories)]
})
// 下载字幕
async function addSubtitleDownload() {
startNProgress()
loading.value = true
try {
const payload: any = {
subtitle_in: props.subtitle,
save_path: selectedDirectory.value,
}
if (tmdbid.value) {
payload.tmdbid = tmdbid.value
}
if (doubanId.value) {
payload.doubanid = doubanId.value
}
const result: { [key: string]: any } = await api.post('download/subtitle', payload)
if (result && result.success) {
$toast.success(
t('dialog.addSubtitleDownload.downloadSuccess', {
site: props.subtitle?.site_name,
title: props.subtitle?.title,
}),
)
emit('done', props.subtitle?.enclosure)
} else {
$toast.error(
t('dialog.addSubtitleDownload.downloadFailed', {
site: props.subtitle?.site_name,
title: props.subtitle?.title,
message: result?.message,
}),
)
emit('error', result?.message)
}
} catch (error) {
console.error(error)
emit('error', String(error))
}
loading.value = false
doneNProgress()
}
onMounted(() => {
loadDirectories()
})
</script>
<template>
<VDialog max-width="35rem" scrollable>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-subtitles-outline" class="me-2" />
</template>
<VCardTitle>{{ t('dialog.addSubtitleDownload.confirmDownload') }}</VCardTitle>
<VCardSubtitle>{{ subtitle?.site_name }} - {{ title }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
<VList lines="one">
<VListItem>
<template #prepend>
<VIcon icon="mdi-web"></VIcon>
</template>
<VListItemTitle>
<span class="whitespace-break-spaces me-2">{{ subtitle?.title }}</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="subtitle?.description">
<template #prepend>
<VIcon icon="mdi-text-box-outline"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-2 whitespace-break-spaces">{{ subtitle?.description }}</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="subtitle?.language || subtitle?.uploader">
<template #prepend>
<VIcon icon="mdi-translate"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-2">
{{ subtitle?.language || t('common.unknown') }}
<span v-if="subtitle?.uploader" class="text-medium-emphasis ms-2">{{ subtitle.uploader }}</span>
</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="subtitle?.size">
<template #prepend>
<VIcon icon="mdi-database"></VIcon>
</template>
<VListItemTitle>
<VChip variant="tonal" label>
{{ formatFileSize(subtitle?.size || 0) }}
</VChip>
</VListItemTitle>
</VListItem>
</VList>
<VRow class="px-5">
<VCol cols="12">
<VCombobox
v-model="selectedDirectory"
:items="targetDirectories"
:label="t('dialog.addSubtitleDownload.saveDirectory')"
:placeholder="t('dialog.addSubtitleDownload.autoPlaceholder')"
variant="underlined"
density="comfortable"
prepend-inner-icon="mdi-folder"
/>
</VCol>
</VRow>
<VRow class="px-5 mt-2">
<VCol cols="12">
<VBtn
variant="text"
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click="showAdvancedOptions = !showAdvancedOptions"
>
{{
showAdvancedOptions
? t('dialog.addDownload.hideAdvancedOptions')
: t('dialog.addDownload.showAdvancedOptions')
}}
</VBtn>
</VCol>
</VRow>
<VRow v-show="showAdvancedOptions" class="px-5">
<VCol cols="12">
<VTextField
v-if="mediaSource === 'themoviedb'"
v-model="tmdbid"
:label="t('dialog.reorganize.tmdbId')"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
prepend-inner-icon="mdi-identifier"
variant="underlined"
density="comfortable"
@click:append-inner="mediaSelectorDialog = true"
/>
<VTextField
v-else
v-model="doubanId"
:label="t('dialog.reorganize.doubanId')"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
prepend-inner-icon="mdi-identifier"
variant="underlined"
density="comfortable"
@click:append-inner="mediaSelectorDialog = true"
/>
</VCol>
</VRow>
</VCardText>
<VCardText class="text-center">
<VBtn variant="elevated" :disabled="loading" @click="addSubtitleDownload" :prepend-icon="icon" class="px-5">
{{ buttonText }}
</VBtn>
</VCardText>
</VCard>
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
<MediaIdSelector
v-if="mediaSource === 'themoviedb'"
v-model="tmdbid"
@close="mediaSelectorDialog = false"
:type="mediaSource"
/>
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
</VDialog>
</VDialog>
</template>

View File

@@ -133,12 +133,12 @@ async function savaAlistConfig() {
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
<VCardActions class="app-dialog-actions">
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
{{ t('dialog.alistConfig.reset') }}
</VBtn>
<VSpacer />
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
{{ t('dialog.alistConfig.complete') }}
</VBtn>
</VCardActions>

View File

@@ -138,12 +138,12 @@ onUnmounted(() => {
</VAlert>
</div>
</VCardText>
<VCardActions>
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
<VCardActions class="app-dialog-actions">
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
{{ t('dialog.aliyunAuth.reset') }}
</VBtn>
<VSpacer />
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
{{ t('dialog.aliyunAuth.complete') }}
</VBtn>
</VCardActions>

View File

@@ -84,9 +84,16 @@ function submitReidentify() {
</VAlert>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" :loading="props.loading" prepend-icon="mdi-check" @click="submitReidentify">
<VBtn
color="primary"
variant="flat"
:loading="props.loading"
prepend-icon="mdi-check"
class="px-5"
@click="submitReidentify"
>
{{ t('setting.cache.reidentifyDialog.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -383,7 +383,7 @@ onMounted(() => {
</VTab>
</VTabs>
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 300px">
<div v-if="loading" class="d-flex justify-center align-center" style="min-block-size: 300px">
<VProgressCircular indeterminate color="primary" size="64" />
</div>
@@ -610,12 +610,16 @@ onMounted(() => {
</VWindow>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn variant="text" @click="emit('close')">
{{ t('common.cancel') }}
</VBtn>
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
<VBtn
color="primary"
variant="flat"
:loading="saving"
prepend-icon="mdi-content-save"
class="px-5"
@click="saveConfig"
>
{{ t('common.save') }}
</VBtn>
</VCardActions>
@@ -638,15 +642,6 @@ onMounted(() => {
cursor: grabbing;
}
.category-item {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
}
.category-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.add-category-btn {
border-style: dashed !important;
transition: all 0.2s ease;

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])
}
})
}
// 获取设置项的稳定键值。
@@ -109,7 +132,14 @@ function submitSettings() {
</script>
<template>
<VDialog v-if="visible" v-model="visible" width="35rem" class="settings-dialog" scrollable :fullscreen="!display.mdAndUp.value">
<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>
@@ -122,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">
@@ -140,21 +171,21 @@ 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" />
</p>
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(true)">
<VCardActions class="app-dialog-actions">
<VBtn v-if="props.showBulkActions" color="success" variant="tonal" @click="setAllItems(true)">
{{ props.selectAllText }}
</VBtn>
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(false)">
<VBtn v-if="props.showBulkActions" color="warning" variant="tonal" @click="setAllItems(false)">
{{ props.selectNoneText }}
</VBtn>
<VSpacer />
<VBtn color="primary" class="px-5" @click="submitSettings">
<VBtn color="primary" variant="flat" class="px-5" @click="submitSettings">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
@@ -179,44 +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;
min-block-size: 48px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 8px;
background-color: rgba(var(--v-theme-surface-variant), 0.3);
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);
}
@@ -224,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;
@@ -234,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

@@ -86,8 +86,9 @@ function submitCustomCSS() {
class="custom-css-editor"
/>
</div>
<VCardActions class="custom-css-actions">
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
<VCardActions class="app-dialog-actions custom-css-actions">
<VSpacer />
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
{{ t('common.save') }}
</VBtn>
</VCardActions>
@@ -98,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 {
@@ -110,7 +111,7 @@ function submitCustomCSS() {
.custom-css-editor-body {
flex: 1 1 auto;
min-block-size: 0;
min-block-size: 240px;
}
.custom-css-editor {
@@ -140,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

@@ -199,8 +199,9 @@ onMounted(() => {
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('customRule.action.confirm') }}
</VBtn>
</VCardActions>

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" />
@@ -88,9 +90,9 @@ function submitOrder() {
</template>
</draggable>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn @click="submitOrder">
<VBtn color="primary" variant="flat" class="px-5" @click="submitOrder">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
@@ -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,52 +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;
border: 1px solid rgba(var(--v-theme-primary), 0.3);
border-radius: 8px;
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

@@ -117,9 +117,20 @@ function generateId() {
return Math.random().toString(36).substring(2, 9)
}
/** 初始化下载器新增配置项的兼容默认值。 */
function initializeDownloaderConfigDefaults() {
if (!['qbittorrent', 'transmission'].includes(downloaderInfo.value.type)) return
if (!downloaderInfo.value.config) downloaderInfo.value.config = {}
if (downloaderInfo.value.type === 'qbittorrent' && downloaderInfo.value.config.incomplete_files_ext === undefined)
downloaderInfo.value.config.incomplete_files_ext = true
if (downloaderInfo.value.type === 'transmission' && downloaderInfo.value.config.rename_partial_files === undefined)
downloaderInfo.value.config.rename_partial_files = true
}
/** 初始化下载器编辑表单数据。 */
function initializeDownloaderInfo() {
downloaderInfo.value = cloneDeep(props.downloader)
initializeDownloaderConfigDefaults()
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
id: generateId(),
storage: item[0],
@@ -299,6 +310,15 @@ onMounted(() => {
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.incomplete_files_ext"
:label="t('downloader.incomplete_files_ext')"
:hint="t('downloader.incomplete_files_extHint')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'transmission'">
<VCol cols="12" md="6">
@@ -344,6 +364,15 @@ onMounted(() => {
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.rename_partial_files"
:label="t('downloader.rename_partial_files')"
:hint="t('downloader.rename_partial_filesHint')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
<VCol cols="12" md="6">
@@ -507,8 +536,9 @@ onMounted(() => {
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -52,9 +52,16 @@ function closeDialog() {
<VCardText>
<VTextField v-model="folderName" :label="t('common.name')" prepend-inner-icon="mdi-format-text" />
</VCardText>
<VCardActions>
<div class="flex-grow-1" />
<VBtn :disabled="!folderName" prepend-icon="mdi-folder-plus" class="px-5 me-3" @click="emit('create')">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
:disabled="!folderName"
prepend-icon="mdi-folder-plus"
class="px-5"
@click="emit('create')"
>
{{ t('common.create') }}
</VBtn>
</VCardActions>

View File

@@ -81,11 +81,19 @@ function closeDialog() {
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="success" prepend-icon="mdi-magic" class="px-5 me-3" @click="emit('auto-name')">
<VCardActions class="app-dialog-actions">
<VBtn color="success" variant="tonal" prepend-icon="mdi-magic" @click="emit('auto-name')">
{{ t('file.autoRecognizeName') }}
</VBtn>
<VBtn :disabled="!renameName" prepend-icon="mdi-check" class="px-5 me-3" @click="emit('rename')">
<VSpacer />
<VBtn
color="primary"
variant="flat"
:disabled="!renameName"
prepend-icon="mdi-check"
class="px-5"
@click="emit('rename')"
>
{{ t('common.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -294,18 +294,23 @@ onMounted(() => {
</Draggable>
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn color="primary" @click="addFilterCard">
<VCardActions class="app-dialog-actions">
<VBtn color="primary" variant="tonal" class="app-dialog-actions__icon-btn" @click="addFilterCard">
<VIcon icon="mdi-plus" />
</VBtn>
<VBtn color="success" @click="importRules('priority')">
<VBtn
color="success"
variant="tonal"
class="app-dialog-actions__icon-btn"
@click="importRules('priority')"
>
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" @click="shareRules">
<VBtn color="info" variant="tonal" class="app-dialog-actions__icon-btn" @click="shareRules">
<VIcon icon="mdi-share" />
</VBtn>
<VSpacer />
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
<VBtn color="primary" variant="flat" @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -7,6 +7,7 @@ import { useToast } from 'vue-toastification'
import { VBtn } from 'vuetify/lib/components/index.mjs'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
// 国际化
const { t } = useI18n()
@@ -50,7 +51,7 @@ function toggleExpand() {
// 加载follow用户列表
async function queryFollowUsers() {
try {
const result: { [key: string]: any } = await api.get('system/setting/FollowSubscribers')
const result: { [key: string]: any } = await api.get('system/setting/public/FollowSubscribers')
followUsers.value = result.data?.value ?? []
} catch (error) {
console.log(error)
@@ -88,10 +89,7 @@ async function unfollowUser() {
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
})
// 获得mediaid
@@ -203,7 +201,7 @@ onMounted(() => {
>
{{ props.media?.share_comment }}
</VCardSubtitle>
<VList lines="one">
<VList lines="one" class="border-0">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('subscribe.sharer') }}</span>

View File

@@ -193,7 +193,7 @@ async function doDelete() {
>
{{ props.workflow?.share_comment }}
</VCardSubtitle>
<VList lines="one">
<VList lines="one" class="border-0">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('workflow.sharer') }}</span>
@@ -277,8 +277,6 @@ async function doDelete() {
.workflow-preview {
position: relative;
overflow: hidden;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
background-color: rgba(var(--v-theme-surface), 0.8);
block-size: 280px;
inline-size: 240px;
@@ -289,8 +287,6 @@ async function doDelete() {
inline-size: 100%;
.vue-flow__node {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
font-size: 10px;
&:hover {

View File

@@ -36,9 +36,9 @@ function handleImport() {
<VCardText class="pt-2">
<VTextarea v-model="codeString" prepend-inner-icon="mdi-code-json" />
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn @click="handleImport" prepend-icon="mdi-import" class="px-5 me-3">
<VBtn color="primary" variant="flat" @click="handleImport" prepend-icon="mdi-import" class="px-5">
{{ t('dialog.importCode.import') }}
</VBtn>
</VCardActions>

View File

@@ -43,7 +43,10 @@ function closeDialog() {
<template>
<VDialog v-if="visible" v-model="visible" max-width="560">
<VCard>
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
<VCardItem>
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="d-flex flex-column ga-4">
<VAlert v-if="props.authSession?.instructions" type="info" variant="tonal">
{{ props.authSession.instructions }}
@@ -71,9 +74,9 @@ function closeDialog() {
</VBtn>
</div>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn variant="text" @click="closeDialog">
<VBtn color="primary" variant="flat" class="px-5" @click="closeDialog">
{{ t('common.close') }}
</VBtn>
</VCardActions>

View File

@@ -591,8 +591,15 @@ onMounted(() => {
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
@click="saveMediaServerInfo"
prepend-icon="mdi-content-save"
class="px-5"
>
{{ t('common.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -915,6 +915,16 @@ onMounted(() => {
prepend-inner-icon="mdi-pound"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_ADMINS"
:label="t('notification.slack.admins')"
:placeholder="t('notification.slack.adminsPlaceholder')"
:hint="t('notification.slack.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'discord'">
<VCol cols="12" md="6">
@@ -956,6 +966,16 @@ onMounted(() => {
prepend-inner-icon="mdi-pound-box"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.DISCORD_ADMINS"
:label="t('notification.discord.admins')"
:placeholder="t('notification.discord.adminsPlaceholder')"
:hint="t('notification.discord.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'synologychat'">
<VCol cols="12" md="6">
@@ -986,6 +1006,16 @@ onMounted(() => {
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SYNOLOGYCHAT_ADMINS"
:label="t('notification.synologychat.admins')"
:placeholder="t('notification.synologychat.adminsPlaceholder')"
:hint="t('notification.synologychat.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'vocechat'">
<VCol cols="12" md="6">
@@ -1026,6 +1056,16 @@ onMounted(() => {
prepend-inner-icon="mdi-pound"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_ADMINS"
:label="t('notification.vocechat.admins')"
:placeholder="t('notification.vocechat.adminsPlaceholder')"
:hint="t('notification.vocechat.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'qqbot'">
<VCol cols="12" md="6">
@@ -1076,6 +1116,16 @@ onMounted(() => {
prepend-inner-icon="mdi-account-group"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.QQBOT_ADMINS"
:label="t('notification.qqbot.admins')"
:placeholder="t('notification.qqbot.adminsPlaceholder')"
:hint="t('notification.qqbot.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'webpush'">
<VCol cols="12" md="6">
@@ -1121,8 +1171,15 @@ onMounted(() => {
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveNotificationInfo" prepend-icon="mdi-content-save" class="px-5">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
@click="saveNotificationInfo"
prepend-icon="mdi-content-save"
class="px-5"
>
{{ t('common.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -92,8 +92,9 @@ function submitTemplate() {
class="template-ace-editor"
/>
</div>
<VCardActions class="template-editor-actions">
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
<VCardActions class="app-dialog-actions template-editor-actions">
<VSpacer />
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
{{ t('common.save') }}
</VBtn>
</VCardActions>
@@ -104,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 {
@@ -146,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

@@ -41,42 +41,69 @@ const otpPassword = ref('')
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
// OTP 初始化加载状态
const otpLoading = ref(false)
// OTP 初始化失败信息
const otpGenerateError = ref('')
// 二维码图片 base64
const qrCodeImage = ref('')
// 二维码信息
const qrCode = ref('')
// 为当前用户获取Otp Uri
// 清空当前 OTP 设置流程的临时数据。
function resetOtpSetupState() {
qrCodeImage.value = ''
qrCode.value = ''
otpUri.value = ''
secret.value = ''
otpGenerateError.value = ''
}
// 标记 OTP 初始化失败,并向用户显示明确错误。
function setOtpGenerateError(message?: string) {
const errorMessage = message || t('common.error')
otpGenerateError.value = t('profile.otpGenerateFailed', { message: errorMessage })
$toast.error(otpGenerateError.value)
}
// 为当前用户获取 OTP URI 并生成二维码图片。
async function getOtpUri() {
resetOtpSetupState()
// 如果已经启用OTP只打开对话框不生成新的二维码
if (props.isOtp) {
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
qrCodeImage.value = ''
return
}
// 未启用OTP生成新的二维码
otpLoading.value = true
try {
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
uri: string
secret: string
}>
if (result.success) {
otpUri.value = result.data.uri
secret.value = result.data.secret
qrCode.value = result.data.uri
const uri = result.data?.uri?.trim()
const otpSecret = result.data?.secret?.trim()
if (result.success && uri) {
otpUri.value = uri
secret.value = otpSecret || ''
qrCode.value = uri
// 生成二维码图片
qrCodeImage.value = await QRCode.toDataURL(result.data.uri, {
qrCodeImage.value = await QRCode.toDataURL(uri, {
width: 200,
margin: 1,
})
} else {
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
setOtpGenerateError(result.message || 'empty otp uri')
}
} catch (error) {
console.error(error)
$toast.error(t('profile.otpGenerateFailed', { message: error instanceof Error ? error.message : String(error) }))
setOtpGenerateError(error instanceof Error ? error.message : String(error))
} finally {
otpLoading.value = false
}
}
@@ -145,10 +172,8 @@ watch(
otpPassword.value = ''
} else {
// 弹窗关闭时,清空数据
qrCodeImage.value = ''
qrCode.value = ''
otpUri.value = ''
secret.value = ''
resetOtpSetupState()
otpLoading.value = false
otpPassword.value = ''
}
},
@@ -194,16 +219,29 @@ watch(
<!-- 设置新的OTP -->
<template v-else>
<div class="my-6 rounded text-center p-3 border" style="width: fit-content; margin: 0 auto">
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
</div>
</template>
</VImg>
<div
class="my-6 rounded text-center p-3 border d-flex align-center justify-center"
style="width: 226px; height: 226px; margin: 0 auto"
>
<img
v-if="qrCodeImage"
class="mx-auto d-block otp-qrcode-image"
:src="qrCodeImage"
:alt="t('profile.setupAuthenticator')"
width="200"
height="200"
/>
<VProgressCircular v-else-if="otpLoading" indeterminate color="primary" />
<div v-else class="w-100">
<VAlert type="error" variant="tonal" density="compact" class="mb-3">
{{ otpGenerateError || t('profile.otpGenerateFailed', { message: t('common.error') }) }}
</VAlert>
<VBtn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="getOtpUri">
{{ t('common.retry') }}
</VBtn>
</div>
</div>
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<VAlert v-if="secret" :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<template #prepend />
</VAlert>
<VForm @submit.prevent="judgeOtpPassword">
@@ -221,7 +259,7 @@ watch(
<VBtn variant="outlined" color="secondary" @click="show = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn type="submit">
<VBtn type="submit" :disabled="!otpUri || otpLoading">
<template #prepend>
<VIcon icon="mdi-check" />
</template>
@@ -234,3 +272,10 @@ watch(
</VCard>
</VDialog>
</template>
<style scoped>
.otp-qrcode-image {
inline-size: 200px;
block-size: 200px;
}
</style>

View File

@@ -1,175 +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>
.offline-dialog {
border-radius: 16px;
}
.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

@@ -299,8 +299,9 @@ watch(
</VAlert>
</VCardText>
<VCardActions class="justify-end px-6 pb-4">
<VBtn variant="outlined" @click="show = false">{{ t('common.close') }}</VBtn>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" class="px-5" @click="show = false">{{ t('common.close') }}</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -154,10 +154,11 @@ onMounted(() => {
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
@click="submitClone"
prepend-icon="mdi-content-copy"
class="px-5"

View File

@@ -160,13 +160,26 @@ onBeforeMount(async () => {
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" color="info">
<VCardActions class="app-dialog-actions">
<VBtn
v-if="props.plugin?.has_page"
color="info"
variant="tonal"
prepend-icon="mdi-database-eye-outline"
@click="emit('switch')"
>
{{ t('dialog.pluginConfig.viewData') }}
</VBtn>
<VSpacer />
<!-- 只有Vuetify模式显示默认保存按钮Vue模式由组件内部控制 -->
<VBtn v-if="renderMode === 'vuetify'" @click="savePluginConf" prepend-icon="mdi-content-save" class="px-5">
<VBtn
v-if="renderMode === 'vuetify'"
color="primary"
variant="flat"
@click="savePluginConf"
prepend-icon="mdi-content-save"
class="px-5"
>
保存
</VBtn>
</VCardActions>

View File

@@ -54,9 +54,9 @@ function closeDialog() {
@keyup.enter="emit('create')"
/>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
<VBtn color="primary" variant="flat" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
{{ t('plugin.create') }}
</VBtn>
</VCardActions>

View File

@@ -57,9 +57,9 @@ function confirmRename() {
@keyup.enter="confirmRename"
/>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -201,9 +201,11 @@ onMounted(() => {
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">
保存
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -42,28 +42,68 @@ function openLoggerWindow() {
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
window.open(url, '_blank')
}
/** 下载当前插件日志压缩包。 */
function downloadLogger() {
const url = `${import.meta.env.VITE_API_BASE_URL}system/logging/download/${props.plugin?.id?.toLowerCase()}`
window.open(url, '_blank')
}
</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">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('plugin.logTitle') }}
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
<VChip color="grey-darken-1" size="small" class="ml-2">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
<span class="ms-4 d-inline-flex align-center ga-1">
<a class="d-inline-flex align-center cursor-pointer" @click="downloadLogger">
<VChip color="grey-darken-1" size="small">
<VIcon icon="mdi-download" size="small" start />
{{ t('common.download') }}
</VChip>
</a>
<a class="d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
<VChip color="grey-darken-1" size="small">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</span>
</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>
@@ -147,13 +184,7 @@ onUnmounted(() => {
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
<div class="mx-auto mt-5">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="pluginIconPath()"
aspect-ratio="4/3"
cover
@error="imageLoadError = true"
/>
<VImg ref="imageRef" :src="pluginIconPath()" aspect-ratio="4/3" cover @error="imageLoadError = true" />
</VAvatar>
</div>
<div class="flex-grow">
@@ -166,7 +197,7 @@ onUnmounted(() => {
>
{{ props.plugin?.plugin_desc }}
</VCardSubtitle>
<VList lines="one">
<VList lines="one" class="border-0">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('common.version') }}</span>
@@ -196,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>
@@ -218,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>

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