Compare commits

..

151 Commits

Author SHA1 Message Date
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
184 changed files with 14377 additions and 3055 deletions

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-1",
"version": "2.13.11",
"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

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

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

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 { UserPermissionKey } from '@/utils/permission'
import { ContentWidth, FooterType, NavbarType } from './enums'
export interface UserConfig {
@@ -119,6 +120,14 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
badgeContent?: string
badgeClass?: string
disable?: boolean
permission?: UserPermissionKey
}
export interface NavMenuTabItem {
title: string
icon?: string
tab: string
description?: string
}
export interface NavMenu extends NavLink {
@@ -126,6 +135,8 @@ export interface NavMenu extends NavLink {
description?: string
admin?: boolean
footer?: boolean
// 水平三级菜单和页面动态标签页共用的静态标签定义。
tabs?: NavMenuTabItem[]
}
// 👉 Vertical nav group

View File

@@ -1,6 +1,5 @@
<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 { useAuthStore, useGlobalSettingsStore } from '@/stores'
@@ -12,25 +11,32 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
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'
const LOGIN_WALLPAPER_ROUTE = '/login'
// 生效主题
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 +47,7 @@ setI18nLanguage(localeValue as SupportedLocale)
const authStore = useAuthStore()
const isLogin = computed(() => authStore.token)
const route = useRoute()
const { initializePWA } = usePWA()
// 全局设置store
const globalSettingsStore = useGlobalSettingsStore()
@@ -52,8 +59,9 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
const shouldLoadBackgroundImages = computed(
() => (!isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) || (Boolean(isLogin.value) && isTransparentTheme.value),
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
)
let backgroundRetryTimer: number | null = null
let backgroundRequestController: AbortController | null = null
@@ -81,6 +89,7 @@ applyTransparentBackgroundSettings()
// 心跳检测
let heartbeatInterval: number | null = null
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
// 启动心跳
const startHeartbeat = () => {
@@ -93,7 +102,7 @@ const startHeartbeat = () => {
heartbeatInterval = window.setInterval(async () => {
try {
if (isLogin.value) {
await api.get('dashboard/cpu')
await api.get('system/ping')
}
} catch (error) {
console.warn('Heartbeat request failed:', error)
@@ -116,6 +125,45 @@ function updateHtmlThemeAttribute(themeName: string) {
syncRootLaunchPalette()
}
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') {
syncThemePreferenceFromStorage()
}
}
function handlePageShowThemeSync() {
syncThemePreferenceFromStorage()
}
// 获取背景图片
async function fetchBackgroundImages() {
try {
@@ -201,19 +249,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 +284,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 +301,7 @@ async function removeLoadingWithStateCheck() {
} catch (error) {
// 即使出错也要移除加载界面
globalLoadingStateManager.reset()
animateAndRemoveLoader()
await animateAndRemoveLoader()
}
}
@@ -285,6 +341,8 @@ onMounted(async () => {
// 初始化主题管理器 - 统一处理主题初始化
await themeManager.setTheme(themeValue)
applyStoredThemeCustomizerAppearance(vuetifyTheme)
updateHtmlThemeAttribute(globalTheme.name.value)
// 监听主题变化
watch(
@@ -297,6 +355,12 @@ 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)
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
watch(
shouldLoadBackgroundImages,
@@ -344,6 +408,11 @@ onUnmounted(() => {
}
// 停止心跳
stopHeartbeat()
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
prefersColorSchemeMediaQuery = null
document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
window.removeEventListener('pageshow', handlePageShowThemeSync)
window.removeEventListener('focus', handlePageShowThemeSync)
})
</script>
@@ -366,7 +435,7 @@ onUnmounted(() => {
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
</div>
<!-- 页面内容 -->
<VApp>
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
<RouterView />
<!-- 全局共享弹窗入口列表与卡片按需在这里挂载业务弹窗 -->
<SharedDialogHost />
@@ -436,4 +505,9 @@ onUnmounted(() => {
inset-block-start: 0;
inset-inline-start: 0;
}
/* 登录页壁纸在 VApp 外层渲染,登录页 VApp 需要透明才能露出壁纸。 */
.app-shell--login-wallpaper.v-application {
background: transparent !important;
}
</style>

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 {
// 是否处理的文件
@@ -1025,6 +1113,10 @@ export interface FileItem {
export interface MediaServerPlayItem {
// ID
id?: string | number
// 媒体服务器项目ID
item_id?: string | number
// 媒体服务器ID
server_id?: string
// 标题
title: string
// 副标题
@@ -1049,6 +1141,10 @@ export interface MediaServerLibrary {
server: string
// ID
id?: string | number
// 媒体服务器项目ID
item_id?: string | number
// 媒体服务器ID
server_id?: string
// 名称
name: string
// 路径
@@ -1069,6 +1165,12 @@ export interface MediaServerLibrary {
// 消息通知
export interface Message {
// 消息ID
id?: number
// 消息渠道
channel?: string
// 消息来源
source?: string
// 消息类型
mtype?: string
// 消息标题
@@ -1088,19 +1190,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 +1388,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 +1400,7 @@ export interface TransferForm {
// 类型
type_name?: string
// 整理方式
transfer_type: string
transfer_type: string | null
// 自定义格式
episode_format?: string
// 指定集数
@@ -1314,13 +1412,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 +1433,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 +1483,14 @@ export interface ManualTransferPreviewItem {
episode_end?: number | string
// Part
part?: string
// 原始识别字符串
org_string?: string
// 应用的自定义识别词
apply_words?: string[]
// 制作组/字幕组
resource_team?: string
// 自定义占位符
customization?: string
}
// 手动整理预览数据
@@ -1457,6 +1579,10 @@ export interface Workflow {
actions?: any[]
// 动作流
flows?: any[]
// 工作流执行配置
execution_config?: { [key: string]: any }
// 工作流结构化执行状态
execution_state?: { [key: string]: any }
// 创建时间
add_time?: string
// 最后执行时间

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@ import { storageIconDict } from '@/api/constants'
import type { AxiosInstance } from 'axios'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
// LocalStorage keys
const SORT_KEY = 'fileBrowser.sort'
@@ -41,6 +43,10 @@ const props = defineProps({
const emit = defineEmits(['pathchanged'])
const route = useRoute()
const { appMode } = usePWA()
const userStore = useUserStore()
const canManage = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
)
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
const fileIcons = {
@@ -136,11 +142,12 @@ function openNewFolderDialog() {
toolbarRef.value?.openNewFolderDialog()
}
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager')
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager' && canManage.value)
useDynamicButton({
icon: 'mdi-folder-plus-outline',
onClick: openNewFolderDialog,
permission: 'manage',
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
})
@@ -161,15 +168,15 @@ const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartWidth = ref(0)
watch(sort, (val) => {
watch(sort, val => {
localStorage.setItem(SORT_KEY, val)
})
watch(showDirTree, (val) => {
watch(showDirTree, val => {
localStorage.setItem(SHOW_TREE_KEY, String(val))
})
watch(navigatorWidth, (val) => {
watch(navigatorWidth, val => {
localStorage.setItem(NAV_WIDTH_KEY, String(val))
})
@@ -182,7 +189,6 @@ const storagesArray = computed(() => {
}))
})
// 方法
function loadingChanged(isLoading: number) {
if (isLoading) loading.value++
@@ -272,7 +278,7 @@ function stopDrag() {
</script>
<template>
<div class="mx-auto" :loading="loading > 0">
<div class="mx-auto overflow-hidden" :loading="loading > 0">
<div v-if="item">
<FileToolbar
ref="toolbarRef"
@@ -347,7 +353,7 @@ function stopDrag() {
justify-content: center;
background-color: transparent;
cursor: col-resize;
inline-size: 4px;
inline-size: 1px;
transition: background-color 0.2s ease;
user-select: none;
}

View File

@@ -163,9 +163,9 @@ const instructions = computed(() => {
</VAlert>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="text" @click="showInstructions = false">
<VBtn color="primary" variant="flat" class="px-5" @click="showInstructions = false">
{{ t('pwa.gotIt') }}
</VBtn>
</VCardActions>
@@ -177,10 +177,7 @@ const instructions = computed(() => {
.pwa-install-banner {
position: fixed;
z-index: 1000;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 12px;
background: rgb(var(--v-theme-surface));
box-shadow: 0 4px 20px rgba(0, 0, 0, 10%);
inset-block-end: 5rem;
inset-inline: 20px;
}

View File

@@ -0,0 +1,904 @@
<script setup lang="ts">
import {
themeCustomizerPrimaryColors,
useThemeCustomizer,
type ThemeCustomizerLayout,
type ThemeCustomizerRadius,
type ThemeCustomizerShadow,
type ThemeCustomizerSkin,
type ThemeCustomizerTheme,
} from '@/composables/useThemeCustomizer'
import { usePWA } from '@/composables/usePWA'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
const emit = defineEmits<{
'close': []
}>()
const customColorInput = ref<HTMLInputElement | null>(null)
const {
isCustomized,
resetSettings,
setLayout,
setPrimaryColor,
setRadius,
setSemiDarkMenu,
setShadow,
setSkin,
setTheme,
settings,
} = useThemeCustomizer()
const { appMode } = usePWA()
const { t } = useI18n()
const { global: globalTheme } = useTheme()
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
// 将主题定制器打开状态同步到根节点,供全局悬浮按钮避让右侧面板。
function syncThemeCustomizerOpenState(isOpen: boolean) {
if (typeof document === 'undefined') return
if (isOpen) {
document.documentElement.setAttribute('data-theme-customizer-open', 'true')
document.body.setAttribute('data-theme-customizer-open', 'true')
return
}
document.documentElement.removeAttribute('data-theme-customizer-open')
document.body.removeAttribute('data-theme-customizer-open')
}
// 组件卸载时清理根节点状态,避免路由切换后悬浮按钮继续保持让位。
function clearThemeCustomizerOpenState() {
syncThemeCustomizerOpenState(false)
}
function handleGlobalKeydown(event: KeyboardEvent) {
// 固定侧栏不再依赖 Vuetify overlay手动补上常见的 Esc 关闭行为。
if (event.key === 'Escape') emit('close')
}
onMounted(() => {
// 面板一挂载就代表已打开,及时同步根节点状态让全局 FAB 预留右侧空间。
syncThemeCustomizerOpenState(true)
window.addEventListener('keydown', handleGlobalKeydown)
})
onScopeDispose(clearThemeCustomizerOpenState)
onScopeDispose(() => {
if (typeof window === 'undefined') return
window.removeEventListener('keydown', handleGlobalKeydown)
})
const themeOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerTheme }>>(() => [
{ title: t('theme.light'), value: 'light', icon: 'mdi-white-balance-sunny' },
{ title: t('theme.dark'), value: 'dark', icon: 'mdi-weather-night' },
{ title: t('theme.auto'), value: 'auto', icon: 'mdi-monitor' },
{ title: t('theme.purple'), value: 'purple', icon: 'mdi-theme-light-dark' },
{ title: t('theme.transparent'), value: 'transparent', icon: 'mdi-blur' },
])
const skinOptions = computed<Array<{ title: string; value: ThemeCustomizerSkin }>>(() => [
{ title: t('theme.customizer.skinDefault'), value: 'default' },
{ title: t('theme.customizer.skinBordered'), value: 'bordered' },
])
const shadowOptions = computed<
Array<{
title: string
value: ThemeCustomizerShadow
}>
>(() => [
{
title: t('theme.customizer.shadowNone'),
value: 'none',
},
{
title: t('theme.customizer.shadowLow'),
value: 'low',
},
{
title: t('theme.customizer.shadowMedium'),
value: 'medium',
},
{
title: t('theme.customizer.shadowHigh'),
value: 'high',
},
])
const radiusOptions = computed<
Array<{
previewRadius: string
title: string
value: ThemeCustomizerRadius
}>
>(() => [
{
previewRadius: '4px',
title: t('theme.customizer.radiusSmall'),
value: 'small',
},
{
previewRadius: '8px',
title: t('theme.customizer.radiusDefault'),
value: 'default',
},
{
previewRadius: '12px',
title: t('theme.customizer.radiusLarge'),
value: 'large',
},
{
previewRadius: '16px',
title: t('theme.customizer.radiusExtra'),
value: 'extra',
},
{
previewRadius: '24px',
title: t('theme.customizer.radiusHuge'),
value: 'huge',
},
])
const layoutOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerLayout }>>(() => [
{ title: t('theme.customizer.layoutVertical'), value: 'vertical', icon: 'mdi-dock-left' },
{ title: t('theme.customizer.layoutCollapsed'), value: 'collapsed', icon: 'mdi-dock-window' },
{ title: t('theme.customizer.layoutHorizontal'), value: 'horizontal', icon: 'mdi-dock-top' },
])
const showLayoutSection = computed(() => !appMode.value)
const hasAppModeCustomization = computed(() => {
return (
settings.value.primaryColor !== defaultPrimaryColor ||
settings.value.radius !== 'default' ||
settings.value.shadow !== 'none' ||
settings.value.skin !== 'default' ||
settings.value.theme !== 'auto'
)
})
const showResetBadge = computed(() => (appMode.value ? hasAppModeCustomization.value : isCustomized.value))
const showSemiDarkMenuOption = computed(() => {
return (
!appMode.value &&
!globalTheme.current.value.dark &&
(settings.value.layout === 'vertical' || settings.value.layout === 'collapsed')
)
})
function openColorPicker() {
customColorInput.value?.click()
}
function handleCustomColorInput(event: Event) {
const color = (event.target as HTMLInputElement).value
setPrimaryColor(color)
}
function handleLayoutChange(layout: ThemeCustomizerLayout) {
// App 模式固定使用移动端导航,避免切换桌面布局后破坏底部导航体验。
if (appMode.value) return
setLayout(layout)
}
async function handleResetSettings() {
if (!appMode.value) {
await resetSettings()
return
}
// App 模式共享定制器,但保留桌面导航相关偏好,只重置 App 侧可调整的外观设置。
await setPrimaryColor(defaultPrimaryColor)
await setRadius('default')
await setShadow('none')
await setSkin('default')
await setTheme('auto')
}
</script>
<template>
<aside
class="theme-customizer-panel-host"
role="dialog"
:aria-label="t('theme.customizer.title')"
>
<div class="theme-customizer-panel" :class="{ 'theme-customizer-panel--dialog': appMode, 'app-surface': appMode }">
<div class="theme-customizer-header py-5 px-4">
<div>
<h2 class="theme-customizer-title">{{ t('theme.customizer.title') }}</h2>
</div>
<div class="theme-customizer-header-actions">
<VBadge color="error" dot :model-value="showResetBadge" location="top end" offset-x="2" offset-y="2">
<IconBtn :aria-label="t('theme.customizer.reset')" @click="handleResetSettings">
<VIcon class="text-high-emphasis" icon="mdi-refresh" />
</IconBtn>
</VBadge>
<IconBtn :aria-label="t('common.close')" @click="emit('close')">
<VIcon class="text-high-emphasis" icon="mdi-close" />
</IconBtn>
</div>
</div>
<VDivider />
<PerfectScrollbar class="theme-customizer-body" :options="{ wheelPropagation: false }">
<section class="theme-customizer-section">
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.primaryColor') }}</h3>
<div class="theme-customizer-color-grid">
<div
v-for="color in themeCustomizerPrimaryColors"
:key="color.value"
class="theme-customizer-color-option"
:class="{ 'is-active': settings.primaryColor === color.value }"
:aria-label="t('theme.customizer.usePrimaryColor', { color: color.name })"
@click="setPrimaryColor(color.value)"
>
<span class="theme-customizer-color-swatch" :style="{ backgroundColor: color.value }" />
</div>
<div
v-if="!appMode"
class="theme-customizer-color-option theme-customizer-color-option--picker"
:class="{
'is-active': !themeCustomizerPrimaryColors.some(color => color.value === settings.primaryColor),
}"
:aria-label="t('theme.customizer.chooseCustomColor')"
@click="openColorPicker"
>
<VIcon class="theme-customizer-native-icon" icon="mdi-palette-outline" size="30" />
<input
ref="customColorInput"
class="theme-customizer-native-color"
type="color"
:value="settings.primaryColor"
@input="handleCustomColorInput"
/>
</div>
</div>
<h3 class="theme-customizer-section-title">{{ t('common.theme') }}</h3>
<div class="theme-customizer-option-grid theme-customizer-option-grid--theme">
<div
v-for="theme in themeOptions"
:key="theme.value"
class="theme-customizer-card-option"
:class="{ 'is-active': settings.theme === theme.value }"
@click="setTheme(theme.value)"
>
<VIcon class="theme-customizer-theme-icon" :icon="theme.icon" size="36" />
<span>{{ theme.title }}</span>
</div>
</div>
<VDivider class="mt-7" />
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.skins') }}</h3>
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--skins">
<div
v-for="skin in skinOptions"
:key="skin.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.skin === skin.value }"
@click="setSkin(skin.value)"
>
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${skin.value}`">
<span class="mini-sidebar">
<i />
<i />
<i />
<i />
</span>
<span class="mini-content">
<i />
<i />
<i />
</span>
</span>
<span>{{ skin.title }}</span>
</div>
</div>
<VDivider class="mt-7" />
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.radius') }}</h3>
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--radius">
<div
v-for="radius in radiusOptions"
:key="radius.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.radius === radius.value }"
@click="setRadius(radius.value)"
>
<span
class="theme-customizer-radius-scene"
:style="{ '--theme-customizer-radius-preview': radius.previewRadius }"
>
<span class="theme-customizer-radius-scene__card">
<span class="theme-customizer-radius-scene__badge" />
<span class="theme-customizer-radius-scene__line" />
<span class="theme-customizer-radius-scene__line theme-customizer-radius-scene__line--short" />
</span>
</span>
<span>{{ radius.title }}</span>
</div>
</div>
<VDivider class="mt-7" />
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.shadow') }}</h3>
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--shadow">
<div
v-for="shadow in shadowOptions"
:key="shadow.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.shadow === shadow.value }"
@click="setShadow(shadow.value)"
>
<span class="theme-customizer-shadow-scene" :class="`theme-customizer-shadow-scene--${shadow.value}`">
<span class="theme-customizer-shadow-scene__panel">
<span class="theme-customizer-shadow-scene__panel-line" />
<span
class="theme-customizer-shadow-scene__panel-line theme-customizer-shadow-scene__panel-line--short"
/>
</span>
<span class="theme-customizer-shadow-scene__card">
<span class="theme-customizer-shadow-scene__badge" />
<span class="theme-customizer-shadow-scene__line theme-customizer-shadow-scene__line--short" />
<span class="theme-customizer-shadow-scene__line" />
</span>
</span>
<span>{{ shadow.title }}</span>
</div>
</div>
<div v-if="showSemiDarkMenuOption" class="theme-customizer-semi-dark">
<span>{{ t('theme.customizer.semiDarkMenu') }}</span>
<VSwitch
:model-value="settings.semiDarkMenu"
color="primary"
inset
hide-details
@update:model-value="setSemiDarkMenu(Boolean($event))"
/>
</div>
</section>
<VDivider v-if="showLayoutSection" />
<section v-if="showLayoutSection" class="theme-customizer-section">
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.layout') }}</h3>
<div class="theme-customizer-preview-grid">
<div
v-for="layout in layoutOptions"
:key="layout.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.layout === layout.value, 'is-disabled': appMode }"
@click="handleLayoutChange(layout.value)"
>
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${layout.value}`">
<span class="mini-sidebar">
<i />
<i />
<i />
</span>
<span class="mini-content">
<i />
<i />
<i />
</span>
</span>
<span>{{ layout.title }}</span>
</div>
</div>
</section>
</PerfectScrollbar>
</div>
</aside>
</template>
<style lang="scss" scoped>
/* stylelint-disable no-descending-specificity */
.theme-customizer-panel-host {
position: fixed !important;
z-index: 2102 !important;
display: flex;
overflow: hidden;
border-radius: 0;
background: rgb(var(--v-theme-surface));
/* 背景层保持完整视口高度,避免 iOS 键盘触发 visual viewport resize 后露出底层页面。 */
block-size: 100vh !important;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
box-shadow: var(--app-surface-shadow) !important;
inline-size: 420px !important;
inset-block-start: 0 !important;
inset-inline-end: 0 !important;
max-block-size: none !important;
min-block-size: 100vh !important;
}
@supports (block-size: 100lvh) {
.theme-customizer-panel-host {
block-size: 100lvh !important;
min-block-size: 100lvh !important;
}
}
.theme-customizer-panel {
position: relative;
display: flex;
flex-direction: column;
block-size: 100%;
inline-size: 100%;
min-block-size: 0;
}
.theme-customizer-panel--dialog {
overflow: hidden;
block-size: 100%;
max-block-size: 100%;
/* 独立 App 模式会贴近 viewport-fit=cover 顶部,面板内部需要避开 iOS 状态栏。 */
padding-block-start: env(safe-area-inset-top, 0px);
}
.theme-customizer-panel--dialog .theme-customizer-body {
block-size: auto;
padding-block-end: env(safe-area-inset-bottom, 0px);
}
@media (width <= 600px) {
.theme-customizer-panel-host {
inline-size: 100vw !important;
}
}
.theme-customizer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.theme-customizer-title {
margin: 0;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.45rem;
font-weight: 600;
line-height: 1.2;
}
.theme-customizer-header-actions {
display: flex;
align-items: center;
gap: 4px;
}
.theme-customizer-body {
flex: 1 1 auto;
min-block-size: 0;
-ms-overflow-style: none;
overscroll-behavior: contain;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
:deep(.ps__rail-x),
:deep(.ps__rail-y) {
display: none !important;
}
}
.theme-customizer-section {
padding-block-end: 28px;
padding-inline: 32px;
}
.theme-customizer-section-title {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.1rem;
font-weight: 600;
line-height: 1.25;
margin-block: 28px 16px;
}
.theme-customizer-section-note {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.875rem;
line-height: 1.45;
margin-block: -6px 16px;
}
.theme-customizer-color-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, 48px);
}
.theme-customizer-color-option {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
appearance: none;
block-size: 48px;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
cursor: pointer;
inline-size: 48px;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease;
&.is-active {
border-width: 2px;
border-color: rgb(var(--v-theme-primary));
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.12);
}
}
.theme-customizer-color-swatch {
display: block;
border-radius: 8px;
block-size: 30px;
inline-size: 30px;
}
.theme-customizer-color-option--picker {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.theme-customizer-native-color {
position: absolute;
block-size: 1px;
inline-size: 1px;
inset-block: 50% auto;
inset-inline: 50% auto;
opacity: 0;
pointer-events: none;
}
.theme-customizer-option-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.theme-customizer-option-grid--theme {
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
}
.theme-customizer-card-option,
.theme-customizer-preview-option {
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
appearance: none;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
cursor: pointer;
font-size: 1rem;
gap: 10px;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
&.is-active {
border-width: 2px;
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.08);
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
}
.theme-customizer-card-option {
justify-content: center;
padding: 16px;
min-block-size: 112px;
}
.theme-customizer-preview-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.theme-customizer-preview-grid--skins {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.theme-customizer-preview-grid--shadow {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.theme-customizer-preview-grid--radius {
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
}
.theme-customizer-preview-option {
align-items: flex-start;
padding: 0;
border: 0;
background: transparent;
box-shadow: none !important;
&.is-active {
background: transparent;
box-shadow: none !important;
.theme-customizer-mini-layout,
.theme-customizer-radius-scene,
.theme-customizer-shadow-scene {
border-width: 2px;
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.04);
}
}
> span:last-child {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
padding-inline-start: 2px;
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.52;
}
}
.theme-customizer-semi-dark {
display: flex;
align-items: center;
justify-content: space-between;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.1rem;
font-weight: 600;
margin-block-start: 28px;
margin-inline: -32px;
padding-inline: 32px;
}
.theme-customizer-mini-layout {
display: grid;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
block-size: 74px;
grid-template-columns: 34% 1fr;
inline-size: 100%;
min-inline-size: 92px;
}
.theme-customizer-mini-layout--collapsed {
grid-template-columns: 18% 1fr;
}
.theme-customizer-mini-layout--horizontal {
grid-template-columns: 1fr;
grid-template-rows: 24% 1fr;
.mini-sidebar {
flex-direction: row;
align-items: center;
}
}
.mini-sidebar,
.mini-content {
display: flex;
flex-direction: column;
padding: 10px;
gap: 8px;
}
.mini-sidebar {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.mini-sidebar i,
.mini-content i {
display: block;
border-radius: 4px;
background: rgba(var(--v-theme-on-surface), 0.12);
block-size: 6px;
}
.mini-content i {
background: rgba(var(--v-theme-on-surface), 0.06);
block-size: 18px;
}
.theme-customizer-mini-layout--bordered {
.mini-content i,
.mini-sidebar i {
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
background: transparent;
}
}
.theme-customizer-radius-scene {
position: relative;
display: block;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(var(--v-theme-on-surface), 0.02), rgba(var(--v-theme-on-surface), 0.05)),
rgb(var(--v-theme-surface));
block-size: 90px;
inline-size: 100%;
min-inline-size: 0;
}
.theme-customizer-radius-scene__card {
position: absolute;
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: var(--theme-customizer-radius-preview);
background: rgb(var(--v-theme-surface));
gap: 8px;
inset: 16px;
padding-block: 12px;
padding-inline: 14px;
}
.theme-customizer-radius-scene__badge,
.theme-customizer-radius-scene__line {
display: block;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.1);
}
.theme-customizer-radius-scene__badge {
block-size: 8px;
inline-size: 42%;
min-inline-size: 28px;
}
.theme-customizer-radius-scene__line {
block-size: 7px;
}
.theme-customizer-radius-scene__line--short {
inline-size: 66%;
}
.theme-customizer-shadow-scene {
position: relative;
display: block;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(var(--v-theme-on-surface), 0.02), rgba(var(--v-theme-on-surface), 0.06)),
rgb(var(--v-theme-surface));
block-size: 110px;
inline-size: 100%;
min-inline-size: 0;
}
.theme-customizer-shadow-scene__panel,
.theme-customizer-shadow-scene__card {
position: absolute;
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
background: rgb(var(--v-theme-surface));
box-shadow: none;
transition: box-shadow 0.18s ease;
}
.theme-customizer-shadow-scene__panel {
padding: 12px;
gap: 8px;
inset-block-start: 16px;
inset-inline: 14px;
min-block-size: 54px;
}
.theme-customizer-shadow-scene__card {
gap: 8px;
inset-block-end: 12px;
inset-inline: 20px 16px;
min-block-size: 46px;
padding-block: 10px;
padding-inline: 12px;
}
.theme-customizer-shadow-scene__panel-line,
.theme-customizer-shadow-scene__line,
.theme-customizer-shadow-scene__badge {
display: block;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.1);
}
.theme-customizer-shadow-scene__badge {
block-size: 6px;
inline-size: 34%;
min-inline-size: 28px;
}
.theme-customizer-shadow-scene__panel-line,
.theme-customizer-shadow-scene__line {
block-size: 7px;
}
.theme-customizer-shadow-scene__panel-line--short,
.theme-customizer-shadow-scene__line--short {
inline-size: 62%;
}
.theme-customizer-shadow-scene--low {
.theme-customizer-shadow-scene__panel {
box-shadow:
0 8px 18px rgba(var(--v-theme-on-surface), 0.08),
0 2px 6px rgba(var(--v-theme-on-surface), 0.05);
}
.theme-customizer-shadow-scene__card {
box-shadow:
0 10px 22px rgba(var(--v-theme-on-surface), 0.1),
0 4px 10px rgba(var(--v-theme-on-surface), 0.06);
}
}
.theme-customizer-shadow-scene--medium {
.theme-customizer-shadow-scene__panel {
box-shadow:
0 12px 28px rgba(var(--v-theme-on-surface), 0.12),
0 4px 12px rgba(var(--v-theme-on-surface), 0.08);
}
.theme-customizer-shadow-scene__card {
box-shadow:
0 16px 34px rgba(var(--v-theme-on-surface), 0.14),
0 6px 16px rgba(var(--v-theme-on-surface), 0.09);
}
}
.theme-customizer-shadow-scene--high {
.theme-customizer-shadow-scene__panel {
box-shadow:
0 16px 38px rgba(var(--v-theme-on-surface), 0.16),
0 6px 18px rgba(var(--v-theme-on-surface), 0.1);
}
.theme-customizer-shadow-scene__card {
box-shadow:
0 22px 48px rgba(var(--v-theme-on-surface), 0.18),
0 8px 22px rgba(var(--v-theme-on-surface), 0.12);
}
}
@media (width <= 600px) {
.theme-customizer-header,
.theme-customizer-section {
padding-inline: 22px;
}
.theme-customizer-preview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

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

View File

@@ -71,52 +71,80 @@ 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">
<VCard
v-if="cardState"
v-bind="hover.props"
:key="props.info?.hash"
class="downloading-card app-surface flex flex-col h-full overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
min-height="150"
>
<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>
</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-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({
@@ -49,8 +49,8 @@ function getDefaultImage() {
// 跳转播放
async function goPlay() {
if (props.media?.link) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
if (props.media) {
await openMediaServerItem(props.media)
}
}

View File

@@ -1,6 +1,6 @@
<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'
@@ -10,7 +10,7 @@ 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 {
getCachedMediaExistsStatus,
@@ -45,6 +45,9 @@ const globalSettings = globalSettingsStore.globalSettings
// 用户 Store
const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
// 提示框
const $toast = useToast()
@@ -143,7 +146,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)
@@ -336,12 +339,11 @@ async function checkSubscribe(season: number | null) {
// 查询订阅弹窗规则
async function queryDefaultSubscribeConfig() {
// 非管理员不显示
if (!userStore.superUser) return false
if (!canSubscribe.value) return false
try {
let subscribe_config_url = ''
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.get(subscribe_config_url)
if (result.data?.value) return result.data.value.show_edit_dialog
} catch (error) {
@@ -464,13 +466,7 @@ 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)
})
// 移除订阅
@@ -540,7 +536,7 @@ onBeforeUnmount(() => {
<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')"
v-if="canSearch"
icon="mdi-magnify"
color="white"
size="small"
@@ -548,6 +544,7 @@ onBeforeUnmount(() => {
/>
<VSpacer />
<IconBtn
v-if="canSubscribe"
icon="mdi-heart"
:color="isSubscribed ? 'error' : 'white'"
size="small"
@@ -580,6 +577,7 @@ onBeforeUnmount(() => {
<!--来源图标-->
<VAvatar
size="24"
variant="plain"
density="compact"
class="absolute bottom-1 right-1"
tile

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)
}
// 人物姓名

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,17 +218,12 @@ 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>
@@ -176,14 +261,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 }}
@@ -245,3 +330,25 @@ const dropdownItems = ref([
</VHover>
</div>
</template>
<style scoped>
.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
},
)

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({
@@ -38,8 +38,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>

View File

@@ -255,7 +255,6 @@ onMounted(() => {
:ripple="false"
variant="flat"
elevation="0"
rounded="lg"
:hover="!cardProps.sortable"
@click="handleCardClick"
>

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'))
@@ -363,19 +365,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)
})
// 订阅编辑保存
@@ -409,22 +405,21 @@ function handleCardClick() {
<VHover>
<template #default="hover">
<div
class="w-full h-full rounded-lg overflow-hidden relative"
class="subscribe-card-shell w-full h-full relative"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
'subscribe-card-pending-tint': subscribeState === 'P',
}"
>
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
class="flex flex-col h-full overflow-hidden"
:class="{
'subscribe-card-paused': subscribeState === 'S',
'subscribe-card-pending-tint': subscribeState === 'P',
'cursor-move': props.sortable,
}"
rounded="0"
min-height="150"
@click="handleCardClick"
:ripple="!props.batchMode && !props.sortable"
@@ -484,7 +479,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 +506,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>
@@ -577,19 +588,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(56, 189, 248, 40%); // sky-400
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 +611,23 @@ 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%);
}
</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
@@ -99,7 +94,7 @@ function doDelete() {
<VHover>
<template #default="hover">
<div
class="w-full h-full rounded-lg overflow-hidden"
class="w-full h-full overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
@@ -108,7 +103,6 @@ function doDelete() {
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
rounded="0"
min-height="150"
@click="showForkSubscribe"
>

View File

@@ -0,0 +1,213 @@
<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>
<div class="h-full">
<VCard
:width="props.width || '100%'"
:variant="isDownloaded ? 'outlined' : 'flat'"
@click="handleAddDownload"
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden subtitle-card"
:class="{ '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 {
border: 1px solid transparent;
}
.subtitle-card:hover {
border-color: rgba(var(--v-theme-primary), 0.3);
}
</style>

View File

@@ -0,0 +1,216 @@
<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>
<div class="w-100">
<VListItem
:value="subtitle?.enclosure"
class="pa-3 mb-2 rounded subtitle-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
:class="{ '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 {
border: 1px solid transparent;
}
.subtitle-item:hover {
border-color: rgba(var(--v-theme-primary), 0.3);
}
</style>

View File

@@ -95,52 +95,59 @@ function doDelete() {
<div class="h-full">
<VHover>
<template #default="hover">
<div
class="w-full h-full rounded-lg overflow-hidden"
<VCard
v-bind="hover.props"
:key="props.workflow?.id"
class="workflow-share-card flex flex-col h-full cursor-pointer overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'workflow-share-card--hovering': hover.isHovering,
}"
min-height="150"
:style="{ background: gradientStyle }"
@click="showForkWorkflow"
>
<VCard
v-bind="hover.props"
:key="props.workflow?.id"
class="flex flex-col h-full"
rounded="0"
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>
</VCard>
</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>
</template>
</VHover>
</div>
</template>
<style lang="scss" scoped>
// 阴影需要落在实际卡片上,不能被额外的 overflow 容器裁掉。
.workflow-share-card {
transition: transform 0.3s ease, box-shadow 0.2s ease;
transform: translateZ(0);
}
.workflow-share-card--hovering {
transform: translate3d(0, -0.25rem, 0);
}
</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

@@ -109,7 +109,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>
@@ -146,15 +153,15 @@ function submitSettings() {
<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>
@@ -195,8 +202,7 @@ function submitSettings() {
.setting-item {
position: relative;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 8px;
border-radius: var(--app-surface-radius);
background-color: rgba(var(--v-theme-surface-variant), 0.3);
cursor: pointer;
padding-block: 10px;

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

@@ -88,9 +88,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>
@@ -117,8 +117,6 @@ function submitOrder() {
.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);
cursor: grab;
padding-block: 10px;

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>

View File

@@ -115,10 +115,6 @@ const colorTheme = computed(() => {
</template>
<style scoped>
.offline-dialog {
border-radius: 16px;
}
.status-icon-wrapper {
padding-block: 24px 0;
padding-inline: 24px;

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,6 +42,12 @@ 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>
@@ -52,12 +58,20 @@ function openLoggerWindow() {
<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 />

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,23 @@ onUnmounted(() => {
class="mb-3"
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<div class="text-center text-md-left">
<div class="plugin-market-detail-actions">
<VBtn
variant="tonal"
@click="showUpdateHistory"
prepend-icon="mdi-update"
>
{{ t('plugin.versionHistory') }}
</VBtn>
<VBtn
color="primary"
@click="installPlugin"
@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__downloads" v-if="props.count">
<VIcon icon="mdi-fire" />
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
</div>
@@ -218,3 +256,30 @@ onUnmounted(() => {
</VCard>
</VDialog>
</template>
<style scoped>
.plugin-market-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: center;
}
.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 (min-width: 960px) {
.plugin-market-detail-actions {
justify-content: flex-start;
}
.plugin-market-detail-actions__downloads {
text-align: start;
}
}
</style>

View File

@@ -24,11 +24,14 @@ const repoText = ref('')
const newRepoUrl = ref('')
const editingIndex = ref<number | null>(null)
const editingUrl = ref('')
const syncingWiki = ref(false)
const emit = defineEmits(['save', 'close'])
const parsedTextRepos = computed(() => parseRepoInput(repoText.value))
const activeRepoCount = computed(() => (editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length))
const activeRepoCount = computed(() =>
editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length,
)
const saveDisabled = computed(
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
)
@@ -108,7 +111,7 @@ function switchEditorMode(mode: EditorMode | undefined) {
/** 加载插件市场仓库配置。 */
async function queryMarketRepoSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
const result: { [key: string]: any } = await api.get('system/setting/public/PLUGIN_MARKET')
if (result && result.data && result.data.value) {
repoList.value = parseRepoInput(result.data.value).repos
syncTextFromList()
@@ -136,6 +139,35 @@ async function saveHandle() {
}
}
/** 从 Wiki 同步公开插件仓库清单并写入配置。 */
async function syncWikiRepos() {
try {
syncingWiki.value = true
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})
if (result.success) {
const repos = Array.isArray(result.data?.repos)
? result.data.repos
: parseRepoInput(result.data?.value || '').repos
repoList.value = repos
syncTextFromList()
$toast.success(
t('dialog.pluginMarketSetting.syncSuccess', {
added: result.data?.added_count ?? 0,
total: result.data?.total_count ?? repos.length,
}),
)
} else {
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: result?.message }))
}
} catch (error) {
console.log(error)
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: error instanceof Error ? error.message : '' }))
} finally {
syncingWiki.value = false
}
}
/** 获取当前维护模式下可保存的仓库地址。 */
function normalizeCurrentRepos() {
if (editorMode.value === 'text') {
@@ -224,8 +256,8 @@ function formatRepoDisplay(url: string) {
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean)
if (
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname)
&& pathSegments.length >= 2
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname) &&
pathSegments.length >= 2
) {
return `${pathSegments[0]}/${pathSegments[1].replace(/\.git$/, '')}`
}
@@ -258,25 +290,47 @@ onMounted(() => {
</div>
<VDialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText class="plugin-market-dialog-body pt-4">
<div class="plugin-market-toolbar">
<VBtnToggle
:model-value="editorMode"
mandatory
color="primary"
density="comfortable"
variant="tonal"
class="plugin-market-mode-toggle"
@update:model-value="switchEditorMode"
>
<VBtn value="list" prepend-icon="mdi-format-list-bulleted">
{{ t('dialog.pluginMarketSetting.listMode') }}
</VBtn>
<VBtn value="text" prepend-icon="mdi-text-box-edit-outline">
{{ t('dialog.pluginMarketSetting.textMode') }}
</VBtn>
</VBtnToggle>
<div class="plugin-market-toolbar-hint">
<VIcon icon="mdi-information-outline" size="18" />
<span>{{ t('dialog.pluginMarketSetting.repoCountHint', { count: activeRepoCount }) }}</span>
</div>
<div class="plugin-market-mode-switch" role="tablist" :aria-label="t('dialog.pluginMarketSetting.title')">
<VTooltip :text="t('dialog.pluginMarketSetting.listMode')" location="top">
<template #activator="{ props }">
<button
v-bind="props"
type="button"
class="plugin-market-mode-button"
:class="{ 'is-active': editorMode === 'list' }"
role="tab"
:aria-label="t('dialog.pluginMarketSetting.listMode')"
:aria-selected="editorMode === 'list'"
@click="switchEditorMode('list')"
>
<VIcon icon="mdi-format-list-bulleted" size="20" />
</button>
</template>
</VTooltip>
<VTooltip :text="t('dialog.pluginMarketSetting.textMode')" location="top">
<template #activator="{ props }">
<button
v-bind="props"
type="button"
class="plugin-market-mode-button"
:class="{ 'is-active': editorMode === 'text' }"
role="tab"
:aria-label="t('dialog.pluginMarketSetting.textMode')"
:aria-selected="editorMode === 'text'"
@click="switchEditorMode('text')"
>
<VIcon icon="mdi-text-box-edit-outline" size="20" />
</button>
</template>
</VTooltip>
</div>
</div>
<div v-if="editorMode === 'list'" class="plugin-market-list-panel">
@@ -424,7 +478,17 @@ onMounted(() => {
</div>
</VCardText>
<VCardActions class="plugin-market-actions">
<VCardActions class="app-dialog-actions">
<VBtn
color="success"
variant="tonal"
prepend-icon="mdi-cloud-sync-outline"
:loading="syncingWiki"
:disabled="syncingWiki"
@click="syncWikiRepos"
>
{{ t('dialog.pluginMarketSetting.syncWiki') }}
</VBtn>
<VSpacer />
<VBtn
color="primary"
@@ -478,14 +542,70 @@ onMounted(() => {
.plugin-market-toolbar {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-block-size: 2.25rem;
}
.plugin-market-mode-toggle {
inline-size: 100%;
.plugin-market-toolbar-hint {
display: flex;
align-items: center;
border-radius: 0.375rem;
background: rgba(var(--v-theme-info), 0.08);
color: rgb(var(--v-theme-info));
font-size: 0.875rem;
gap: 0.5rem;
min-inline-size: 0;
padding-block: 0.5rem;
padding-inline: 1rem;
:deep(.v-btn) {
flex: 1;
min-inline-size: 0;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.plugin-market-mode-switch {
display: inline-flex;
padding: 0.125rem;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 0.375rem;
background: rgba(var(--v-theme-surface), 0.72);
gap: 0.125rem;
}
.plugin-market-mode-button {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 0.375rem;
background: transparent;
block-size: 2.25rem;
color: rgba(var(--v-theme-on-surface), 0.68);
cursor: pointer;
font: inherit;
inline-size: 2.25rem;
transition:
background-color 0.16s ease,
color 0.16s ease;
&:hover {
background: rgba(var(--v-theme-primary), 0.07);
color: rgb(var(--v-theme-on-surface));
}
&:focus-visible {
outline: 2px solid rgba(var(--v-theme-primary), 0.48);
outline-offset: 2px;
}
&.is-active {
background: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
}
@@ -504,8 +624,6 @@ onMounted(() => {
.plugin-market-list-wrap {
flex: 1;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
background: rgba(var(--v-theme-surface), 0.72);
min-block-size: 0;
overflow-y: auto;
@@ -531,8 +649,8 @@ onMounted(() => {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-break: anywhere;
-webkit-line-clamp: 2;
overflow-wrap: anywhere;
white-space: normal;
word-break: break-word;
@@ -552,22 +670,22 @@ onMounted(() => {
.plugin-market-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-direction: column;
min-block-size: 14rem;
}
.plugin-market-textarea-field {
position: relative;
display: flex;
overflow: hidden;
flex: 1;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
background: rgba(var(--v-theme-surface), 0.72);
min-block-size: 0;
overflow: hidden;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
&:focus-within {
border-color: rgb(var(--v-theme-primary));
@@ -590,13 +708,14 @@ onMounted(() => {
background: transparent;
block-size: 100%;
color: rgb(var(--v-theme-on-surface));
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
font-size: 1rem;
line-height: 1.6;
min-block-size: 0;
outline: none;
overflow-y: auto;
padding: 1rem 1rem 1rem 3.25rem;
padding-block: 1rem;
padding-inline: 3.25rem 1rem;
resize: none;
white-space: pre-wrap;
word-break: break-word;
@@ -616,19 +735,14 @@ onMounted(() => {
}
}
.plugin-market-actions {
flex: 0 0 auto;
gap: 0.5rem;
padding: 0.75rem 1.5rem 1rem;
}
@media (max-width: 600px) {
@media (width <= 600px) {
.plugin-market-dialog-card {
block-size: 100dvh;
}
.plugin-market-card-item {
padding: 0.75rem 1rem 0.625rem;
padding-block: 0.75rem 0.625rem;
padding-inline: 1rem;
}
.plugin-market-header {
@@ -644,16 +758,22 @@ onMounted(() => {
.plugin-market-dialog-body {
gap: 0.625rem;
padding: 0.75rem 1rem !important;
padding-block: 0.75rem !important;
padding-inline: 1rem !important;
}
.plugin-market-mode-toggle {
inline-size: 100%;
.plugin-market-toolbar {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
:deep(.v-btn) {
flex: 1;
min-inline-size: 0;
}
.plugin-market-mode-switch {
flex: 0 0 auto;
}
.plugin-market-toolbar-hint {
flex: 1 1 auto;
}
.plugin-market-list-panel,
@@ -668,9 +788,5 @@ onMounted(() => {
.plugin-market-empty {
min-block-size: 10rem;
}
.plugin-market-actions {
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
}
}
</style>

View File

@@ -90,7 +90,7 @@ function closeDialog() {
/>
</VToolbar>
<VDialogCloseBtn @click="closeDialog" />
<VList v-if="plugins.length > 0" lines="two">
<VList v-if="plugins.length > 0" class="plugin-search-list" lines="two">
<VVirtualScroll :items="plugins">
<template #default="{ item }">
<VListItem @click="emit('open-plugin', item)">
@@ -130,4 +130,8 @@ function closeDialog() {
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>
.plugin-search-list {
border-radius: 0 !important;
}
</style>

View File

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

View File

@@ -89,12 +89,12 @@ async function handleReset() {
</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.rcloneConfig.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.rcloneConfig.complete') }}
</VBtn>
</VCardActions>

View File

@@ -102,17 +102,28 @@ interface EpisodeFormatRecommendData {
const episodeFormatRecommendState = reactive<{
loading: boolean
ruleName?: string
rulePattern?: string
generatedFormat?: string
sampleFile?: string
lastMessage?: string
}>({
loading: false,
ruleName: undefined,
rulePattern: undefined,
generatedFormat: undefined,
sampleFile: undefined,
lastMessage: undefined,
})
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
interface TargetDirectoryOption {
title: string
value: string
}
const AUTO_TARGET_PATH_VALUE = '__moviepilot_auto_target_path__'
// 生成文件项稳定键,用于去重和状态同步。
function getFileItemKey(item?: FileItem) {
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
@@ -162,7 +173,7 @@ let episodeGroupQueryTimer: ReturnType<typeof setTimeout> | undefined
// 查询存储
async function loadStorages() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Storages')
const result: { [key: string]: any } = await api.get('system/setting/public/Storages')
storages.value = result.data?.value ?? []
} catch (error) {
@@ -172,10 +183,27 @@ async function loadStorages() {
// 存储字典
const storageOptions = computed(() => {
return storages.value.map(item => ({
title: item.name,
value: item.type,
}))
return [
{
title: t('dialog.reorganize.auto'),
value: null,
},
...storages.value.map(item => ({
title: item.name,
value: item.type,
})),
]
})
// 整理方式选项,包含可提交 null 的自动项。
const manualTransferTypeOptions = computed(() => {
return [
{
title: t('dialog.reorganize.auto'),
value: null,
},
...transferTypeOptions,
]
})
// 剧集组选项属性
@@ -260,16 +288,20 @@ const disableEpisodeDetail = computed(() => {
}
})
const initialTargetPath = normalizeTargetPath(props.target_path)
// 表单
const transferForm = reactive<TransferForm>({
fileitem: {} as FileItem,
logid: 0,
target_storage: props.target_storage ?? 'local',
target_path: props.target_path ?? '',
transfer_type: '',
target_storage: initialTargetPath ? (props.target_storage ?? 'local') : null,
target_path: initialTargetPath,
transfer_type: null,
min_filesize: 0,
scrape: false,
scrape: initialTargetPath ? false : null,
from_history: false,
library_type_folder: null,
library_category_folder: null,
episode_group: null,
})
@@ -279,19 +311,54 @@ const directories = ref<TransferDirectoryConf[]>([])
// 查询目录
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)
}
}
// 目的目录下拉框
const targetDirectories = computed(() => {
const libraryDirectories = directories.value.map(item => item.library_path)
return [...new Set(libraryDirectories)]
// 目的目录下拉框,第一项用于把目标路径显式重置为后端自动匹配。
const targetDirectoryOptions = computed<TargetDirectoryOption[]>(() => {
const libraryDirectories = directories.value.map(item => item.library_path).filter(Boolean) as string[]
return [
{
title: t('dialog.reorganize.auto'),
value: AUTO_TARGET_PATH_VALUE,
},
...[...new Set(libraryDirectories)].map(path => ({
title: path,
value: path,
})),
]
})
// 目标路径选择值,用哨兵值把界面上的“自动”和接口里的 null 解耦。
const targetPathSelection = computed({
get() {
return transferForm.target_path ?? AUTO_TARGET_PATH_VALUE
},
set(value: string | null) {
const targetPath = normalizeTargetPath(value)
if (!targetPath || targetPath === AUTO_TARGET_PATH_VALUE) {
resetAutomaticTargetConfig()
return
}
transferForm.target_path = targetPath
},
})
// 重置为完全自动匹配状态,提交时不携带目标路径及其派生配置。
function resetAutomaticTargetConfig() {
transferForm.target_storage = null
transferForm.target_path = null
transferForm.transfer_type = null
transferForm.scrape = null
transferForm.library_type_folder = null
transferForm.library_category_folder = null
}
// 监听目的路径变化,配置默认值
watch(
() => transferForm.target_path,
@@ -305,6 +372,7 @@ watch(
transferForm.library_category_folder = directory.library_category_folder ?? false
transferForm.library_type_folder = directory.library_type_folder ?? false
} else {
transferForm.target_storage = transferForm.target_storage || 'local'
transferForm.transfer_type = transferForm.transfer_type || 'copy'
transferForm.scrape = false
transferForm.library_category_folder = false
@@ -312,9 +380,9 @@ watch(
}
} else {
// 路径为空时, 恢复到`自动`条件
transferForm.transfer_type = ''
transferForm.library_type_folder = undefined
transferForm.library_category_folder = undefined
transferForm.transfer_type = null
transferForm.library_type_folder = null
transferForm.library_category_folder = null
}
},
)
@@ -332,15 +400,22 @@ watch(
)
// 切换媒体类型或识别源时,非 TMDB 电视剧不保留剧集组选择。
watch([() => transferForm.type_name, () => mediaSource.value], ([typeName, source]) => {
if (typeName === '电视剧' && source === 'themoviedb' && transferForm.tmdbid) {
getEpisodeGroups(transferForm.tmdbid)
return
}
transferForm.episode_group = null
episodeGroups.value = []
})
watch(
[() => transferForm.type_name, () => mediaSource.value],
([typeName, source]) => {
if (typeName === '电视剧' && source === 'themoviedb' && transferForm.tmdbid) {
getEpisodeGroups(transferForm.tmdbid)
return
() => transferForm.episode_group,
episodeGroup => {
const normalizedEpisodeGroup = normalizeEpisodeGroup(episodeGroup)
if (episodeGroup !== normalizedEpisodeGroup) {
transferForm.episode_group = normalizedEpisodeGroup
}
transferForm.episode_group = null
episodeGroups.value = []
},
)
@@ -397,6 +472,32 @@ function getUniqueValues(values: (string | undefined)[]) {
return [...new Set(values.map(item => item?.trim()).filter(Boolean) as string[])]
}
// 归一化可选目的路径,保证未指定时向接口传递 null 而不是空字符串。
function normalizeTargetPath(path?: string | null) {
const normalizedPath = path?.trim()
return normalizedPath || null
}
// 归一化可选文本参数,保证自动项提交 null 而不是空字符串。
function normalizeOptionalText(value?: string | null) {
const normalizedValue = value?.trim()
return normalizedValue || null
}
// 归一化剧集组值,兼容历史对象态值。
function normalizeEpisodeGroup(episodeGroup?: string | { value?: string | null } | null) {
if (!episodeGroup) return null
if (typeof episodeGroup === 'string') {
const normalizedEpisodeGroup = episodeGroup.trim()
return normalizedEpisodeGroup || null
}
if (typeof episodeGroup === 'object' && typeof episodeGroup.value === 'string') {
const normalizedEpisodeGroup = episodeGroup.value.trim()
return normalizedEpisodeGroup || null
}
return null
}
// 统一解析接口返回的数字字段,兼容 string/number
function toPreviewNumber(value: unknown) {
if (value === undefined || value === null || value === '') return undefined
@@ -511,6 +612,99 @@ const previewFileRows = computed(() => {
})
})
// 标准化预览项中的识别词命中详情
function getPreviewApplyWords(item: ManualTransferPreviewItem) {
return [
...new Set((item.apply_words ?? []).map(word => word?.trim()).filter((word): word is string => Boolean(word))),
]
}
// 手动整理识别词应用详情
const previewCustomWordDetails = computed(() => {
const groupedDetails = new Map<string, { sourceNames: string[]; orgString?: string; applyWords: string[] }>()
filteredPreviewItems.value.forEach(item => {
const applyWords = getPreviewApplyWords(item)
if (!applyWords.length) return
const sourceName = getFileName(item.source)
const orgString = item.org_string?.trim() || undefined
const detailKey = JSON.stringify(applyWords)
const existingDetail = groupedDetails.get(detailKey)
if (existingDetail) {
if (!existingDetail.sourceNames.includes(sourceName)) existingDetail.sourceNames.push(sourceName)
return
}
groupedDetails.set(detailKey, {
sourceNames: [sourceName],
orgString,
applyWords,
})
})
return [...groupedDetails.values()].map(detail => ({
sourceName:
detail.sourceNames.length > 1
? t('dialog.reorganize.customWordsSameRules', { count: detail.sourceNames.length })
: detail.sourceNames[0],
orgString: detail.sourceNames.length > 1 ? undefined : detail.orgString,
applyWords: detail.applyWords,
}))
})
const previewEpisodeFormatRuleDetails = computed(() => {
const episodeFormat = transferForm.episode_format?.trim()
if (!episodeFormat) return []
const rulePattern = episodeFormatRecommendState.rulePattern?.trim()
const isGeneratedEpisodeFormat =
Boolean(episodeFormatRecommendState.generatedFormat) &&
episodeFormatRecommendState.generatedFormat === episodeFormat
if (!isGeneratedEpisodeFormat || !episodeFormatRecommendState.ruleName) {
return [
{
sourceName: t('dialog.reorganize.episodeFormatManualInput'),
orgString: t('dialog.reorganize.episodeFormatFinal', {
format: episodeFormat,
}),
applyWords: [],
},
]
}
return [
{
sourceName: t('dialog.reorganize.episodeFormatRecommendRule', {
rule: episodeFormatRecommendState.ruleName,
}),
orgString: t('dialog.reorganize.episodeFormatFinal', {
format: episodeFormat,
}),
applyWords: rulePattern
? [
t('dialog.reorganize.episodeFormatRulePattern', {
pattern: rulePattern,
}),
]
: [],
},
]
})
const previewRecognitionDetails = computed(() => [
...previewCustomWordDetails.value,
...previewEpisodeFormatRuleDetails.value,
])
const previewRecognitionDetailTitle = computed(() => {
return previewCustomWordDetails.value.length
? t('dialog.reorganize.customWordsApplied')
: t('dialog.reorganize.episodeFormatRuleDetails')
})
// 是否需要拓宽窗口
const previewNeedsWideLayout = computed(() => {
const candidates = [...previewFileRows.value.map(item => `${item.sourceName}${item.targetName}`)]
@@ -550,9 +744,7 @@ const episodeFormatRecommendSelectedFileItems = computed(() => {
const episodeFormatRecommendHasValidSelectedFiles = computed(() => {
if (episodeFormatRecommendSelectedFileItems.value.length <= 1) return false
const directoryKeys = new Set(
episodeFormatRecommendSelectedFileItems.value.map(item => getFileParentKey(item)),
)
const directoryKeys = new Set(episodeFormatRecommendSelectedFileItems.value.map(item => getFileParentKey(item)))
return directoryKeys.size === 1
})
@@ -563,19 +755,21 @@ const episodeFormatRecommendSourceItem = computed<FileItem | undefined>(() => {
const canRecommendEpisodeFormat = computed(() => {
return (
(Boolean(episodeFormatRecommendSourceItem.value?.path) ||
episodeFormatRecommendHasValidSelectedFiles.value) &&
(Boolean(episodeFormatRecommendSourceItem.value?.path) || episodeFormatRecommendHasValidSelectedFiles.value) &&
!progressDialog.value &&
!episodeFormatRecommendState.loading
)
})
const episodeFormatRecommendSelectionKey = computed(() => {
const sourceItem = episodeFormatRecommendSourceItem.value
if (sourceItem) return getFileItemKey(sourceItem)
return episodeFormatRecommendSelectedFileItems.value.map(item => getFileItemKey(item)).join('||')
})
const episodeFormatRecommendTooltip = computed(() => {
if (episodeFormatRecommendState.loading) return t('dialog.reorganize.episodeFormatRecommendLoading')
if (
normalizedItems.value.length > 1 &&
!episodeFormatRecommendHasValidSelectedFiles.value
) {
if (normalizedItems.value.length > 1 && !episodeFormatRecommendHasValidSelectedFiles.value) {
return t('dialog.reorganize.episodeFormatRecommendInvalidSelection')
}
if (!episodeFormatRecommendSourceItem.value?.path && !episodeFormatRecommendHasValidSelectedFiles.value) {
@@ -586,14 +780,14 @@ const episodeFormatRecommendTooltip = computed(() => {
})
watch(
() => getFileItemKey(episodeFormatRecommendSourceItem.value),
sourceKey => {
episodeFormatRecommendSelectionKey,
() => {
transferForm.fileitem = episodeFormatRecommendSourceItem.value ?? ({} as FileItem)
if (!sourceKey) {
episodeFormatRecommendState.ruleName = undefined
episodeFormatRecommendState.sampleFile = undefined
episodeFormatRecommendState.lastMessage = undefined
}
episodeFormatRecommendState.ruleName = undefined
episodeFormatRecommendState.rulePattern = undefined
episodeFormatRecommendState.generatedFormat = undefined
episodeFormatRecommendState.sampleFile = undefined
episodeFormatRecommendState.lastMessage = undefined
},
{ immediate: true },
)
@@ -611,16 +805,15 @@ function getBatchItemsLabel(items: FileItem[]) {
// 构造整理请求
function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; logid?: number; preview?: boolean }) {
const sourceItem =
options.item ??
(options.items?.length
? options.items[0]
: ({} as FileItem))
const sourceItem = options.item ?? (options.items?.length ? options.items[0] : ({} as FileItem))
const payload: ManualTransferPayload = {
...transferForm,
fileitem: sourceItem,
logid: options.logid ?? 0,
episode_group: transferForm.episode_group?.trim() || null,
target_storage: normalizeOptionalText(transferForm.target_storage),
target_path: normalizeTargetPath(transferForm.target_path),
transfer_type: normalizeOptionalText(transferForm.transfer_type),
episode_group: normalizeEpisodeGroup(transferForm.episode_group),
}
if (options.items?.length) {
@@ -645,7 +838,7 @@ async function requestManualTransfer<T = any>(
// 加载剧集格式规则配置状态,用于决定是否允许自动推荐。
async function loadEpisodeFormatRuleConfiguration() {
try {
const result: { [key: string]: any } = await api.get('system/setting/EpisodeFormatRuleTable')
const result: { [key: string]: any } = await api.get('system/setting/public/EpisodeFormatRuleTable')
episodeFormatRuleConfigured.value = Boolean(result.data?.value?.length)
} catch (error) {
console.log(error)
@@ -700,6 +893,8 @@ async function handleRecommendEpisodeFormat() {
transferForm.episode_format = data.episode_format
episodeFormatRecommendState.ruleName = data.rule_name
episodeFormatRecommendState.rulePattern = data.pattern
episodeFormatRecommendState.generatedFormat = data.episode_format
episodeFormatRecommendState.sampleFile = data.sample_file
episodeFormatRecommendState.lastMessage = data.message
@@ -1099,8 +1294,8 @@ async function transfer(background: boolean = false) {
emit('done')
}
onMounted(() => {
loadDirectories()
onMounted(async () => {
await loadDirectories()
loadStorages()
loadEpisodeFormatRuleConfiguration()
})
@@ -1150,20 +1345,19 @@ onUnmounted(() => {
<VSelect
v-model="transferForm.transfer_type"
:label="t('dialog.reorganize.transferType')"
:items="transferTypeOptions"
:items="manualTransferTypeOptions"
:hint="t('dialog.reorganize.transferTypeHint')"
persistent-hint
prepend-inner-icon="mdi-swap-horizontal"
>
<template v-slot:selection="{ item }">
{{ transferForm.transfer_type === '' ? t('dialog.reorganize.auto') : item.title }}
</template>
</VSelect>
/>
</VCol>
<VCol cols="12">
<VCombobox
v-model="transferForm.target_path"
:items="targetDirectories"
v-model="targetPathSelection"
:items="targetDirectoryOptions"
item-title="title"
item-value="value"
:return-object="false"
:label="t('dialog.reorganize.targetPath')"
:placeholder="t('dialog.reorganize.targetPathPlaceholder')"
:hint="t('dialog.reorganize.targetPathHint')"
@@ -1218,9 +1412,11 @@ onUnmounted(() => {
</VRow>
<VRow v-show="transferForm.type_name === '电视剧'">
<VCol v-if="mediaSource === 'themoviedb'" cols="12" md="6">
<VCombobox
<VSelect
v-model="transferForm.episode_group"
:items="episodeGroupOptions"
item-title="title"
item-value="value"
:item-props="episodeGroupItemProps"
:loading="episodeGroupLoading"
:disabled="!transferForm.tmdbid"
@@ -1320,7 +1516,7 @@ onUnmounted(() => {
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6" v-if="transferForm.target_path">
<VCol cols="12" md="6">
<VSwitch
v-model="transferForm.library_type_folder"
:label="t('dialog.reorganize.typeFolderOption')"
@@ -1328,7 +1524,7 @@ onUnmounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6" v-if="transferForm.target_path">
<VCol cols="12" md="6">
<VSwitch
v-model="transferForm.library_category_folder"
:label="t('dialog.reorganize.categoryFolderOption')"
@@ -1354,35 +1550,39 @@ onUnmounted(() => {
</VCol>
</VRow>
</VForm>
<VCardActions class="reorganize-form-pane__actions pt-3 px-0 pb-0">
<VBtn
color="info"
:variant="previewVisible ? 'tonal' : 'text'"
@click="togglePreview"
:prepend-icon="previewToggleIcon"
class="reorganize-action-btn reorganize-action-btn--preview"
:class="{ 'reorganize-action-btn--active': previewVisible }"
:loading="previewLoading"
>
{{ t('dialog.reorganize.previewResult') }}
</VBtn>
<VBtn
color="success"
@click="transfer(true)"
prepend-icon="mdi-plus"
class="reorganize-action-btn reorganize-action-btn--queue"
>
{{ t('dialog.reorganize.addToQueue') }}
</VBtn>
<VBtn
@click="transfer(false)"
prepend-icon="mdi-arrow-right-bold"
class="reorganize-action-btn reorganize-action-btn--primary"
>
{{ t('dialog.reorganize.reorganizeNow') }}
</VBtn>
</VCardActions>
</div>
<VCardActions class="app-dialog-actions reorganize-form-pane__actions">
<VBtn
color="info"
variant="tonal"
@click="togglePreview"
:prepend-icon="previewToggleIcon"
class="reorganize-action-btn reorganize-action-btn--preview"
:class="{ 'reorganize-action-btn--active': previewVisible }"
:loading="previewLoading"
>
{{ t('dialog.reorganize.previewResult') }}
</VBtn>
<VBtn
color="success"
variant="tonal"
@click="transfer(true)"
prepend-icon="mdi-plus"
class="reorganize-action-btn reorganize-action-btn--queue"
>
{{ t('dialog.reorganize.addToQueue') }}
</VBtn>
<VSpacer />
<VBtn
color="primary"
variant="flat"
@click="transfer(false)"
prepend-icon="mdi-arrow-right-bold"
class="reorganize-action-btn reorganize-action-btn--primary"
>
{{ t('dialog.reorganize.reorganizeNow') }}
</VBtn>
</VCardActions>
</div>
<div v-show="previewVisible" class="reorganize-preview-pane">
<div class="reorganize-preview-pane__header">
@@ -1439,13 +1639,43 @@ onUnmounted(() => {
<span class="preview-overview-card__value">{{ previewEpisodeCountText }}</span>
</div>
</div>
<div v-if="previewRecognitionDetails.length" class="preview-custom-words">
<div class="preview-custom-words__title">
<VIcon icon="mdi-tag-text-outline" size="16" />
<span>{{ previewRecognitionDetailTitle }}</span>
</div>
<div class="preview-custom-words__items">
<div
v-for="(detail, index) in previewRecognitionDetails"
:key="`${detail.sourceName}-${index}`"
class="preview-custom-words__item"
>
<div class="preview-custom-words__source">{{ detail.sourceName }}</div>
<div v-if="detail.orgString" class="preview-custom-words__original">
{{ detail.orgString }}
</div>
<div v-if="detail.applyWords.length" class="preview-custom-words__chips">
<VChip
v-for="(word, wordIndex) in detail.applyWords"
:key="`${word}-${wordIndex}`"
variant="outlined"
color="info"
size="small"
class="preview-custom-words__chip"
>
{{ word }}
</VChip>
</div>
</div>
</div>
</div>
</div>
<div class="reorganize-preview-list">
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
<div
v-for="(item, index) in pagedPreviewRows"
:key="`${item.source}-${item.target}-${index}`"
class="preview-file-row"
class="preview-file-row app-surface-shape"
:class="{ 'preview-file-row--failed': item.success === false }"
>
<div class="preview-file-row__card preview-file-row__card--source">
@@ -1585,17 +1815,9 @@ onUnmounted(() => {
}
.reorganize-form-pane__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem;
margin-block-start: auto;
}
.reorganize-action-btn {
min-inline-size: 0;
}
.reorganize-action-btn--active {
background: rgba(var(--v-theme-info), 0.12);
}
@@ -1656,8 +1878,6 @@ onUnmounted(() => {
}
.preview-note {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 1rem;
color: rgb(var(--v-theme-error));
font-size: 0.875rem;
line-height: 1.5;
@@ -1674,8 +1894,6 @@ onUnmounted(() => {
.preview-overview-card {
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 1rem;
gap: 0.375rem;
min-inline-size: 0;
padding-block: 0.875rem;
@@ -1698,6 +1916,64 @@ onUnmounted(() => {
white-space: nowrap;
}
.preview-custom-words {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-block: 0.875rem;
padding-inline: 1rem;
}
.preview-custom-words__title {
display: inline-flex;
align-items: center;
color: rgb(var(--v-theme-info));
font-size: 0.875rem;
font-weight: 600;
gap: 0.375rem;
}
.preview-custom-words__items {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-inline-size: 0;
}
.preview-custom-words__item {
display: flex;
flex-direction: column;
gap: 0.375rem;
min-inline-size: 0;
}
.preview-custom-words__source {
color: rgb(var(--v-theme-on-surface));
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.4;
overflow-wrap: anywhere;
}
.preview-custom-words__original {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1.4;
overflow-wrap: anywhere;
}
.preview-custom-words__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
min-inline-size: 0;
}
.preview-custom-words__chip {
max-inline-size: 100%;
white-space: normal;
}
.reorganize-preview-pane__scroll {
display: flex;
overflow: hidden auto;
@@ -1734,8 +2010,6 @@ onUnmounted(() => {
overflow: visible;
flex: 0 0 auto;
flex-direction: column;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 1rem;
margin-block-end: 1.5rem;
margin-inline: 1.5rem;
min-block-size: 0;
@@ -1797,11 +2071,13 @@ onUnmounted(() => {
}
.preview-file-row__path {
overflow: hidden;
overflow: visible;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8125rem;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
overflow-wrap: anywhere;
white-space: normal;
word-break: break-all;
}
.preview-file-row__card--target .preview-file-row__name {
@@ -1881,15 +2157,9 @@ onUnmounted(() => {
border-inline-end: none;
}
.reorganize-form-pane__actions {
display: grid;
justify-content: stretch;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.reorganize-action-btn {
inline-size: 100%;
min-block-size: 2.75rem;
padding-inline: 1rem;
}
.reorganize-preview-pane__summary {
@@ -1904,14 +2174,10 @@ onUnmounted(() => {
@media (width <= 640px) {
.reorganize-form-pane__actions {
justify-content: stretch;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.reorganize-action-btn {
min-inline-size: 0;
}
.reorganize-action-btn--primary {
grid-column: 1 / -1;
}

View File

@@ -7,7 +7,7 @@ import { useUserStore, useGlobalSettingsStore } from '@/stores'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { hasPermission, filterMenusByPermission } from '@/utils/permission'
import { buildUserPermissionContext, hasPermission, filterMenusByPermission } from '@/utils/permission'
// 显示器宽度
const display = useDisplay()
@@ -30,41 +30,29 @@ const userStore = useUserStore()
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 超级用户
const superUser = userStore.superUser
// 当前用户名
const userName = userStore.userName
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
// 权限检查
const hasSearchPermission = computed(() => {
return hasPermission(
{
is_superuser: userStore.superUser,
...userStore.permissions,
},
'search',
)
return hasPermission(userPermissions.value, 'search')
})
const hasDiscoveryPermission = computed(() => {
return hasPermission(userPermissions.value, 'discovery')
})
const hasSubscribePermission = computed(() => {
return hasPermission(
{
is_superuser: userStore.superUser,
...userStore.permissions,
},
'subscribe',
)
return hasPermission(userPermissions.value, 'subscribe')
})
const hasManagePermission = computed(() => {
return hasPermission(
{
is_superuser: userStore.superUser,
...userStore.permissions,
},
'manage',
)
return hasPermission(userPermissions.value, 'manage')
})
const hasAdminPermission = computed(() => {
return hasPermission(userPermissions.value, 'admin')
})
// 是否显示合集搜索项当SEARCH_SOURCE包含themoviedb时显示
@@ -79,6 +67,7 @@ const SubscribeItems = ref<Subscribe[]>([])
const chooseSiteDialog = ref(false)
const selectedSites = ref<number[]>([])
const allSites = ref<Site[]>([])
const siteSearchType = ref<'torrent' | 'subtitle'>('torrent')
// 定义事件
const emit = defineEmits(['close', 'update:modelValue'])
@@ -139,6 +128,7 @@ function getMenus(): NavMenu[] {
to: item.to,
header: item.header,
admin: item.admin,
permission: item.permission,
}),
)
// 设置标签页
@@ -151,6 +141,7 @@ function getMenus(): NavMenu[] {
to: `/setting?tab=${item.tab}`,
header: '',
admin: true,
permission: 'admin',
description: item.description,
}),
)
@@ -158,12 +149,6 @@ function getMenus(): NavMenu[] {
return menus
}
// 获取用户权限信息
const userPermissions = computed(() => ({
is_superuser: userStore.superUser,
...userStore.permissions,
}))
// 匹配的菜单列表
const matchedMenuItems = computed(() => {
if (!searchWord.value) return []
@@ -201,7 +186,7 @@ async function fetchInstalledPlugins() {
// 匹配的插件列表
const matchedPluginItems = computed(() => {
if (!searchWord.value) return []
if (!hasManagePermission.value) return []
if (!hasAdminPermission.value) return []
const lowerWord = (searchWord.value as string).toLowerCase()
return pluginItems.value.filter((item: Plugin) => {
if (!item.plugin_name && !item.plugin_desc) return false
@@ -221,7 +206,7 @@ async function fetchSubscribes() {
// 从接口加载用户站点偏好设置
const loadUserSitePreferences = async () => {
try {
const result = await api.get('system/setting/IndexerSites')
const result = await api.get('system/setting/public/IndexerSites')
if (result && result.data && result.data.value) {
selectedSites.value = result.data.value
return
@@ -247,7 +232,8 @@ async function queryAllSites() {
}
// 打开站点选择对话框
const openSiteDialog = () => {
const openSiteDialog = (type: 'torrent' | 'subtitle' = 'torrent') => {
siteSearchType.value = type
chooseSiteDialog.value = true
}
@@ -257,7 +243,7 @@ const matchedSubscribeItems = computed(() => {
if (!hasSubscribePermission.value) return []
const lowerWord = (searchWord.value as string).toLowerCase()
return SubscribeItems.value.filter((item: Subscribe) => {
return (item.name.toLowerCase().includes(lowerWord) && (superUser || userName === item.username)) || false
return (item.name.toLowerCase().includes(lowerWord) && (userStore.superUser || userName === item.username)) || false
})
})
@@ -265,12 +251,16 @@ const matchedSubscribeItems = computed(() => {
function searchSites(sites: number[]) {
chooseSiteDialog.value = false
selectedSites.value = sites
if (siteSearchType.value === 'subtitle') {
searchSubtitle()
return
}
searchTorrent()
}
// 搜索资源
function searchTorrent() {
if (!searchWord.value) return
if (!searchWord.value || !hasSearchPermission.value) return
// 记录搜索词
saveRecentSearches(searchWord.value)
// 跳转到搜索页面
@@ -279,6 +269,7 @@ function searchTorrent() {
query: {
keyword: searchWord.value,
area: 'title',
result_type: 'torrent',
sites: selectedSites.value.join(','),
},
})
@@ -287,10 +278,27 @@ function searchTorrent() {
emit('close')
}
// 搜索字幕资源
function searchSubtitle() {
if (!searchWord.value || !hasSearchPermission.value) return
saveRecentSearches(searchWord.value)
router.push({
path: '/resource',
query: {
keyword: searchWord.value,
area: 'title',
result_type: 'subtitle',
sites: selectedSites.value.join(','),
},
})
dialog.value = false
emit('close')
}
// 跳转媒体搜索页面
function searchMedia(searchType: string) {
// 搜索类型 media/person
if (!searchWord.value) return
if (!searchWord.value || !hasDiscoveryPermission.value) return
saveRecentSearches(searchWord.value)
router.push({
path: '/browse/media/search',
@@ -371,7 +379,7 @@ onMounted(() => {
searchWordInput.value?.focus()
}, 500)
// 根据权限加载不同的数据
if (hasManagePermission.value) {
if (hasAdminPermission.value) {
fetchInstalledPlugins()
}
if (hasSubscribePermission.value) {
@@ -413,58 +421,60 @@ onMounted(() => {
<!-- 有搜索词时显示搜索入口和匹配结果 -->
<VList lines="two" v-if="searchWord" class="search-list pa-0 py-2">
<!-- 媒体搜索入口 -->
<VListSubheader class="font-weight-medium text-uppercase px-4">
{{ t('common.media') }}
</VListSubheader>
<template v-if="hasDiscoveryPermission">
<VListSubheader class="font-weight-medium text-uppercase px-4">
{{ t('common.media') }}
</VListSubheader>
<VListItem density="comfortable" link @click="searchMedia('media')" class="search-result-item mx-2 my-1">
<template #prepend>
<div class="result-icon-wrapper">
<VIcon icon="mdi-movie-search" size="small" color="medium-emphasis" />
</div>
</template>
<VListItemTitle class="font-weight-medium text-body-2">
{{ t('recommend.categoryMovie') }}{{ t('recommend.categoryTV') }}
</VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('resource.title') }}
</VListItemSubtitle>
</VListItem>
<VListItem density="comfortable" link @click="searchMedia('media')" class="search-result-item mx-2 my-1">
<template #prepend>
<div class="result-icon-wrapper">
<VIcon icon="mdi-movie-search" size="small" color="medium-emphasis" />
</div>
</template>
<VListItemTitle class="font-weight-medium text-body-2">
{{ t('recommend.categoryMovie') }}{{ t('recommend.categoryTV') }}
</VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('resource.title') }}
</VListItemSubtitle>
</VListItem>
<VListItem
v-if="showCollectionSearch"
density="comfortable"
link
@click="searchMedia('collection')"
class="search-result-item mx-2 my-1"
>
<template #prepend>
<div class="result-icon-wrapper">
<VIcon icon="mdi-movie-filter" size="small" color="medium-emphasis" />
</div>
</template>
<VListItemTitle class="font-weight-medium text-body-2">{{
t('dialog.searchBar.collections')
}}</VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.collectionSearch') }}
</VListItemSubtitle>
</VListItem>
<VListItem
v-if="showCollectionSearch"
density="comfortable"
link
@click="searchMedia('collection')"
class="search-result-item mx-2 my-1"
>
<template #prepend>
<div class="result-icon-wrapper">
<VIcon icon="mdi-movie-filter" size="small" color="medium-emphasis" />
</div>
</template>
<VListItemTitle class="font-weight-medium text-body-2">{{
t('dialog.searchBar.collections')
}}</VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.collectionSearch') }}
</VListItemSubtitle>
</VListItem>
<VListItem density="comfortable" link @click="searchMedia('person')" class="search-result-item mx-2 my-1">
<template #prepend>
<div class="result-icon-wrapper">
<VIcon icon="mdi-account-search" size="small" color="medium-emphasis" />
</div>
</template>
<VListItemTitle class="font-weight-medium text-body-2">{{ t('browse.actor') }}</VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.actorSearch') }}
</VListItemSubtitle>
</VListItem>
<VListItem density="comfortable" link @click="searchMedia('person')" class="search-result-item mx-2 my-1">
<template #prepend>
<div class="result-icon-wrapper">
<VIcon icon="mdi-account-search" size="small" color="medium-emphasis" />
</div>
</template>
<VListItemTitle class="font-weight-medium text-body-2">{{ t('browse.actor') }}</VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.actorSearch') }}
</VListItemSubtitle>
</VListItem>
</template>
<VListItem
v-if="hasSubscribePermission"
@@ -622,7 +632,34 @@ onMounted(() => {
variant="tonal"
color="primary"
rounded="pill"
@click.stop="openSiteDialog"
@click.stop="openSiteDialog('torrent')"
>
{{ t('dialog.searchBar.selectSites') }}
</VBtn>
</template>
</VListItem>
<VListItem density="comfortable" link @click="searchSubtitle" class="search-result-item mx-2 my-1">
<template #prepend>
<div class="result-icon-wrapper">
<VIcon icon="mdi-subtitles-outline" size="small" color="medium-emphasis" />
</div>
</template>
<VListItemTitle class="font-weight-medium text-body-2">{{
t('dialog.searchBar.searchSubtitlesInSites')
}}</VListItemTitle>
<VListItemSubtitle class="text-caption text-medium-emphasis">
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.relatedSubtitles') }}
</VListItemSubtitle>
<template #append>
<VBtn
v-if="hasManagePermission"
size="x-small"
variant="tonal"
color="primary"
rounded="pill"
@click.stop="openSiteDialog('subtitle')"
>
{{ t('dialog.searchBar.selectSites') }}
</VBtn>
@@ -698,8 +735,6 @@ onMounted(() => {
display: flex;
overflow: hidden;
flex-direction: column;
border-radius: 16px !important;
box-shadow: 0 12px 40px rgba(0, 0, 0, 12%) !important;
}
/* 搜索头部区域 */
@@ -712,7 +747,7 @@ onMounted(() => {
.search-input-wrapper {
display: flex;
align-items: center;
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.15);
border: 1.5px solid rgba(var(--v-theme-primary), 0.4);
border-radius: 28px;
background-color: rgba(var(--v-theme-surface-variant), 0.04);
block-size: 48px;
@@ -723,7 +758,7 @@ onMounted(() => {
}
.search-input-wrapper:focus-within {
border-color: rgba(var(--v-theme-on-surface), 0.3);
border-color: rgb(var(--v-theme-primary));
box-shadow: 0 0 0 3px rgba(var(--v-theme-on-surface), 0.04);
}
@@ -774,7 +809,6 @@ onMounted(() => {
}
.search-result-item {
border-radius: 10px !important;
margin-block-end: 2px;
transition: background-color 0.15s ease;
}

View File

@@ -10,7 +10,7 @@ const props = defineProps({
type: Array as PropType<Site[]>,
required: true,
},
selected: Array as PropType<Number[]>,
selected: Array as PropType<number[]>,
})
// 定义事件
@@ -20,38 +20,66 @@ const emit = defineEmits(['close', 'search', 'reload'])
const siteFilter = ref('')
// 已选择站点
const selectedSites = ref<any[]>(props.selected || [])
const selectedSites = ref<number[]>([])
// 根据当前可用站点清理选中项,避免停用或已删除站点参与计数。
function normalizeSelectedSites(selectedSiteIds: number[] = []) {
const availableSiteIds = new Set(props.sites.map((site: Site) => site.id))
const normalizedSiteIds: number[] = []
selectedSiteIds.forEach(siteId => {
if (availableSiteIds.has(siteId) && !normalizedSiteIds.includes(siteId)) {
normalizedSiteIds.push(siteId)
}
})
return normalizedSiteIds
}
watch(
() => props.selected,
value => {
if (selectedSites.value.length == 0 && value) {
selectedSites.value = value
}
[() => props.selected, () => props.sites],
([value]) => {
selectedSites.value = normalizeSelectedSites(value || [])
},
{ immediate: true },
)
// 全选/全不选按钮文字
const checkAllText = computed(() => {
return selectedSites.value.length < props.sites?.length
return selectedSites.value.length < props.sites.length
? t('dialog.searchSite.selectAll')
: t('dialog.searchSite.deselectAll')
})
// 全选/全不选
const checkAllSitesorNot = () => {
if (selectedSites.value.length < props.sites?.length) {
selectedSites.value = props.sites?.map((item: Site) => item.id)
if (selectedSites.value.length < props.sites.length) {
selectedSites.value = props.sites.map((item: Site) => item.id)
} else {
selectedSites.value = []
}
}
// 切换单个站点的选择状态。
function toggleSiteSelection(siteId: number) {
const index = selectedSites.value.indexOf(siteId)
if (index === -1) {
selectedSites.value.push(siteId)
} else {
selectedSites.value.splice(index, 1)
}
}
// 确认搜索时只提交当前可用站点。
function confirmSearch() {
emit('search', normalizeSelectedSites(selectedSites.value))
}
// 根据筛选条件过滤站点
const filteredSites = computed(() => {
if (!siteFilter.value) return props.sites
const filter = siteFilter.value.toLowerCase()
return props.sites?.filter((site: Site) => site.name.toLowerCase().includes(filter))
return props.sites.filter((site: Site) => site.name.toLowerCase().includes(filter))
})
</script>
<template>
@@ -101,22 +129,13 @@ const filteredSites = computed(() => {
<div
v-bind="props"
:class="[
'site-checkbox-wrapper pa-2 pa-sm-3 rounded-lg d-flex align-center',
'site-checkbox-wrapper pa-2 pa-sm-3 d-flex align-center',
{
'site-selected': selectedSites.includes(site.id),
'site-hover': isHovering && !selectedSites.includes(site.id),
},
]"
@click="
() => {
const index = selectedSites.indexOf(site.id)
if (index === -1) {
selectedSites.push(site.id)
} else {
selectedSites.splice(index, 1)
}
}
"
@click="toggleSiteSelection(site.id)"
>
<VIcon
:icon="selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'"
@@ -156,12 +175,13 @@ const filteredSites = computed(() => {
</div>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
:disabled="selectedSites.length === 0"
@click="emit('search', selectedSites)"
@click="confirmSearch"
prepend-icon="mdi-magnify"
class="d-flex align-center justify-center px-5"
>
@@ -174,8 +194,11 @@ const filteredSites = computed(() => {
<style scoped>
.site-checkbox-wrapper {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: var(--app-surface-radius);
cursor: pointer;
transition: transform 0.2s ease, background-color 0.2s ease;
transition:
transform 0.2s ease,
background-color 0.2s ease;
}
.site-checkbox-wrapper:hover {

View File

@@ -34,6 +34,11 @@ const visible = computed({
function allLoggingUrl() {
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
}
/** 拼接主程序日志下载 URL。 */
function allLoggingDownloadUrl() {
return `${import.meta.env.VITE_API_BASE_URL}system/logging/download/moviepilot`
}
</script>
<template>
@@ -44,12 +49,20 @@ function allLoggingUrl() {
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('shortcut.log.subtitle') }}
<a class="mx-2 d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
<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" :href="allLoggingDownloadUrl()" target="_blank">
<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" :href="allLoggingUrl()" target="_blank">
<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 />

View File

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

View File

@@ -39,10 +39,21 @@ const visible = computed({
if (!value) emit('close')
},
})
const isFullscreen = computed(() => !display.mdAndUp.value)
// 仅系统健康检查弹窗需要在全屏时取消固定高度,避免其它快捷弹窗被误伤。
const bodyClasses = computed(() => [
props.bodyClass,
{
'system-health-dialog-body--fullscreen':
isFullscreen.value && props.bodyClass.split(/\s+/).includes('system-health-dialog-body'),
},
])
</script>
<template>
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="isFullscreen">
<VCard :class="props.cardClass">
<VCardItem>
<VCardTitle>
@@ -53,7 +64,7 @@ const visible = computed({
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText :class="props.bodyClass">
<VCardText :class="bodyClasses">
<Component :is="props.view" v-bind="props.viewProps" />
</VCardText>
</VCard>
@@ -63,20 +74,20 @@ const visible = computed({
<style scoped>
.system-health-dialog-card {
display: flex;
flex-direction: column;
overflow: hidden;
flex-direction: column;
}
.system-health-dialog-body {
/* 弹窗正文本身不滚动,滚动只交给健康检查结果列表。 */
display: flex;
overflow: hidden !important;
flex: 1 1 auto;
block-size: min(42rem, calc(100dvh - 8rem - env(safe-area-inset-top) - env(safe-area-inset-bottom)));
min-block-size: 0;
overflow: hidden !important;
}
:global(.v-dialog--fullscreen) .system-health-dialog-body {
.system-health-dialog-body--fullscreen {
block-size: auto;
}
</style>

View File

@@ -340,12 +340,26 @@ onMounted(async () => {
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn v-if="props.oper === 'add'" color="primary" @click="addSite" prepend-icon="mdi-plus" class="px-5">
<VBtn
v-if="props.oper === 'add'"
color="primary"
variant="flat"
@click="addSite"
prepend-icon="mdi-plus"
class="px-5"
>
{{ t('site.actions.add') }}
</VBtn>
<VBtn v-else color="primary" @click="updateSiteInfo" prepend-icon="mdi-content-save" class="px-5">
<VBtn
v-else
color="primary"
variant="flat"
@click="updateSiteInfo"
prepend-icon="mdi-content-save"
class="px-5"
>
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -50,23 +50,34 @@ async function updateSiteCookie() {
progressDialog.value = true
progressText.value = t('dialog.siteCookieUpdate.updating', { site: cardProps.site?.name })
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
params: {
username: userPwForm.value.username,
password: userPwForm.value.password,
code: userPwForm.value.code,
},
const result: { [key: string]: any } = await api.post(`site/cookie/${cardProps.site?.id}`, {
username: userPwForm.value.username,
password: userPwForm.value.password,
code: userPwForm.value.code,
})
if (result.success) {
$toast.success(t('dialog.siteCookieUpdate.success', { site: cardProps.site?.name }))
emit('done')
} else $toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message: result.message }))
} else {
$toast.error(
t('dialog.siteCookieUpdate.failed', {
site: cardProps.site?.name,
message: result.message || t('dialog.siteCookieUpdate.requestFailed'),
}),
)
}
} catch (error: any) {
console.error(error)
const detail = error?.response?.data?.detail
const message =
error?.response?.data?.message ||
(typeof detail === 'string' ? detail : error?.message) ||
t('dialog.siteCookieUpdate.requestFailed')
$toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message }))
} finally {
progressDialog.value = false
updateButtonDisable.value = false
} catch (error) {
console.error(error)
}
}
</script>
@@ -99,9 +110,11 @@ async function updateSiteCookie() {
</VRow>
</VForm>
</VCardText>
<VCardActions class="mx-auto">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
size="large"
color="primary"
variant="flat"
@click="updateSiteCookie"
:disabled="updateButtonDisable"
:loading="updateButtonDisable"

View File

@@ -237,7 +237,7 @@ watch(selectedFile, async newFile => {
<!-- 阶段1选择文件阶段 -->
<div v-if="currentStage === ImportStage.SELECT_FILE" class="upload-area">
<div
class="upload-zone"
class="upload-zone app-surface-shape"
:class="{ 'dragging': isDragging }"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@@ -394,7 +394,6 @@ watch(selectedFile, async newFile => {
.upload-zone {
padding: 2rem;
border: 2px dashed #ccc;
border-radius: 8px;
text-align: center;
transition: all 0.3s ease;
}

View File

@@ -220,7 +220,7 @@ onMounted(() => {
<div class="pa-3 pb-2">
<template v-if="!isMobileLayout">
<VSheet class="site-resource-filter-panel" rounded="lg" border>
<VSheet class="site-resource-filter-panel">
<div class="site-resource-filter-panel__inner">
<VRow class="site-resource-filter-row">
<VCol cols="12" md="4">
@@ -304,7 +304,7 @@ onMounted(() => {
<VExpandTransition>
<div v-if="mobileSearchExpanded" class="mt-2">
<VSheet class="site-resource-filter-panel" rounded="lg" border>
<VSheet class="site-resource-filter-panel">
<div class="site-resource-filter-panel__inner">
<VRow class="site-resource-filter-row">
<VCol cols="12">
@@ -475,26 +475,26 @@ onMounted(() => {
:items="mobileResourceList"
:columns="1"
:gap="12"
:estimated-item-height="320"
:estimated-item-height="220"
:overscan-rows="5"
:get-item-key="getResourceItemKey"
>
<template #default="{ item }">
<VCard>
<VCardText class="pa-4">
<VCard class="site-resource-card" variant="flat">
<VCardText class="pa-3">
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
<div class="text-body-1 font-weight-medium text-high-emphasis">
<div class="site-resource-card__title text-body-1 font-weight-medium text-high-emphasis">
{{ item.title }}
</div>
<div
v-if="item.description"
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
class="site-resource-card__description mt-1 text-body-2 text-medium-emphasis"
>
{{ item.description }}
</div>
</button>
<div class="mt-3">
<div class="site-resource-card__chips mt-2">
<VChip
v-if="item.hit_and_run"
variant="elevated"
@@ -533,47 +533,82 @@ onMounted(() => {
</VChip>
</div>
<div class="site-resource-card__meta mt-4">
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
<!-- 移动端在操作区前展示关键资源指标方便点击前快速判断 -->
<div class="site-resource-card__summary mt-3">
<div class="site-resource-card__stat">
<VIcon icon="mdi-clock-outline" size="15" />
<span>{{ item.date_elapsed || item.pubdate || '-' }}</span>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
<div class="site-resource-card__stat">
<VIcon icon="mdi-harddisk" size="15" />
<span>{{ formatFileSize(item.size) }}</span>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
<div class="site-resource-card__stat site-resource-card__stat--success">
<VIcon icon="mdi-arrow-up" size="15" />
<span>{{ item.seeders ?? '-' }}</span>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
<div class="site-resource-card__stat site-resource-card__stat--warning">
<VIcon icon="mdi-arrow-down" size="15" />
<span>{{ item.peers ?? '-' }}</span>
</div>
</div>
<div class="site-resource-card__actions mt-4">
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
<!-- 下载保留文本其它低频操作改为图标按钮并保持同一行 -->
<div class="site-resource-card__actions mt-2">
<VBtn
color="primary"
variant="flat"
class="site-resource-card__download-btn"
prepend-icon="mdi-download"
@click="addDownload(item)"
>
{{ t('actionStep.addDownload') }}
</VBtn>
<div class="site-resource-card__secondary-actions mt-2">
<VBtn
variant="tonal"
prepend-icon="mdi-open-in-new"
@click="openTorrentDetail(item.page_url || '')"
>
{{ t('common.viewDetails') }}
</VBtn>
<VBtn
v-if="item.enclosure?.startsWith('http')"
variant="tonal"
prepend-icon="mdi-tray-arrow-down"
@click="downloadTorrentFile(item.enclosure)"
>
{{ t('dialog.siteResource.downloadTorrent') }}
</VBtn>
</div>
<VTooltip :text="t('common.viewDetails')" location="top">
<template #activator="{ props: tooltipProps }">
<VBtn
v-bind="tooltipProps"
icon
variant="tonal"
color="primary"
class="site-resource-card__icon-btn"
:aria-label="t('common.viewDetails')"
@click="openTorrentDetail(item.page_url || '')"
>
<VIcon icon="mdi-open-in-new" />
</VBtn>
</template>
</VTooltip>
<VTooltip
v-if="item.enclosure?.startsWith('http')"
:text="t('dialog.siteResource.downloadTorrent')"
location="top"
>
<template #activator="{ props: tooltipProps }">
<VBtn
v-bind="tooltipProps"
icon
variant="tonal"
color="primary"
class="site-resource-card__icon-btn"
:aria-label="t('dialog.siteResource.downloadTorrent')"
@click="downloadTorrentFile(item.enclosure)"
>
<VIcon icon="mdi-file-download-outline" />
</VBtn>
</template>
</VTooltip>
<VBtn
v-else
icon
variant="tonal"
color="primary"
disabled
class="site-resource-card__icon-btn"
:aria-label="t('dialog.siteResource.downloadTorrent')"
>
<VIcon icon="mdi-file-download-outline" />
</VBtn>
</div>
</VCardText>
</VCard>
@@ -611,11 +646,9 @@ onMounted(() => {
}
.site-resource-filter-panel {
border-color: rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
background:
radial-gradient(circle at top left, rgba(var(--v-theme-primary), 0.06), transparent 40%),
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.93));
box-shadow: 0 10px 24px rgba(15, 23, 42, 4%);
}
.site-resource-filter-panel__inner {
@@ -623,7 +656,7 @@ onMounted(() => {
}
.site-resource-filter-input :deep(.v-field) {
border-radius: 0.75rem;
border-radius: var(--app-field-radius);
background: rgba(var(--v-theme-surface), 0.92);
box-shadow: inset 0 0 0 1px rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.8));
}
@@ -704,46 +737,107 @@ onMounted(() => {
white-space: nowrap;
}
.site-resource-card {
--site-resource-card-bg:
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.94)),
radial-gradient(circle at top right, rgba(var(--v-theme-primary), 0.08), transparent 34%);
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
background: var(--site-resource-card-bg);
}
:global(html[data-theme="transparent"]) .site-resource-card {
--site-resource-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity));
backdrop-filter: blur(var(--transparent-blur));
}
.site-resource-card__summary {
display: grid;
gap: 0.35rem;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(2.5rem, 0.62fr) minmax(2.5rem, 0.62fr);
align-items: center;
}
.site-resource-card__stat {
display: inline-flex;
overflow: hidden;
align-items: center;
justify-content: center;
gap: 0.22rem;
border-radius: 6px;
background: rgba(var(--v-theme-on-surface), 0.05);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 0.74rem;
font-weight: 600;
line-height: 1;
min-block-size: 1.65rem;
min-inline-size: 0;
padding-inline: 0.4rem;
}
.site-resource-card__stat span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-resource-card__stat--success {
color: rgb(var(--v-theme-success));
}
.site-resource-card__stat--warning {
color: rgb(var(--v-theme-warning));
}
.site-resource-card__title {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-height: 1.38;
}
.site-resource-card__description {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
-webkit-line-clamp: 2;
line-height: 1.35;
}
.site-resource-card__meta {
.site-resource-card__chips {
max-block-size: 4.75rem;
overflow: hidden;
}
.site-resource-card__actions {
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
grid-template-columns: minmax(0, 1fr) 2.5rem 2.5rem;
align-items: center;
}
.site-resource-card__meta-item {
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.7));
border-radius: 0.6rem;
background: rgba(var(--v-theme-surface), 0.78);
min-block-size: 0;
padding-block: 0.55rem;
padding-inline: 0.65rem;
.site-resource-card__download-btn {
min-block-size: 2.5rem;
min-inline-size: 0;
box-shadow: 0 6px 16px rgba(var(--v-theme-primary), 0.17);
}
.site-resource-card__meta-item :deep(.text-caption) {
font-size: 0.72rem !important;
line-height: 1.2;
.site-resource-card__download-btn :deep(.v-btn__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-resource-card__meta-item :deep(.text-body-2) {
font-size: 0.82rem !important;
line-height: 1.25;
.site-resource-card__icon-btn {
block-size: 2.5rem;
inline-size: 2.5rem;
min-inline-size: 2.5rem;
}
.site-resource-card__secondary-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.site-resource-card__secondary-actions :deep(.v-btn) {
flex: 1 1 12rem;
.site-resource-card__icon-btn :deep(.v-btn__content) {
font-size: 1.05rem;
}
@media (width >= 960px) {
@@ -765,4 +859,14 @@ onMounted(() => {
min-block-size: 2.5rem;
}
}
@media (width <= 420px) {
.site-resource-card__summary {
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.95fr) minmax(2.3rem, 0.55fr) minmax(2.3rem, 0.55fr);
}
.site-resource-card__stat {
padding-inline: 0.3rem;
}
}
</style>

View File

@@ -391,8 +391,6 @@ onMounted(() => {
.stat-card {
padding: 16px;
border: 1px solid var(--v-border-color);
border-radius: 8px;
background: var(--v-theme-surface);
min-inline-size: 100px;
text-align: center;

View File

@@ -117,12 +117,12 @@ async function saveSmbConfig() {
</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.smbConfig.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.smbConfig.complete') }}
</VBtn>
</VCardActions>

View File

@@ -89,8 +89,9 @@ function handleDone() {
</VCol>
</VRow>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

@@ -7,8 +7,14 @@ import { useDisplay } from 'vuetify'
import { useConfirm } from '@/composables/useConfirm'
import { useI18n } from 'vue-i18n'
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
// i18n
const { t } = useI18n()
const userStore = useUserStore()
const canAdmin = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
)
// 显示器宽度
const display = useDisplay()
@@ -128,6 +134,8 @@ async function loadDownloaderSetting() {
// 加载规则组
async function queryFilterRuleGroups() {
if (!canAdmin.value) return
try {
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
filterRuleGroups.value = result.data?.value ?? []
@@ -163,6 +171,8 @@ async function updateSubscribeInfo() {
// 设置用户设置的默认订阅规则
async function saveDefaultSubscribeConfig() {
if (!canAdmin.value) return
try {
let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
@@ -183,8 +193,8 @@ async function saveDefaultSubscribeConfig() {
async function queryDefaultSubscribeConfig() {
try {
let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
if (props.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.get(subscribe_config_url)
@@ -260,7 +270,7 @@ async function removeSubscribe() {
// 查询下载目录
async function loadDownloadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Directories')
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
if (result.success && result.data?.value) {
downloadDirectories.value = result.data.value
}
@@ -549,12 +559,14 @@ onMounted(() => {
</VWindow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="!props.default" color="error" @click="removeSubscribe" class="me-3">
<VCardActions class="app-dialog-actions">
<VBtn v-if="!props.default" color="error" variant="tonal" @click="removeSubscribe">
{{ t('dialog.subscribeEdit.cancelSubscribe') }}
</VBtn>
<VSpacer />
<VBtn
color="primary"
variant="flat"
@click=";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`"
prepend-icon="mdi-content-save"
class="px-5"

View File

@@ -216,11 +216,50 @@ function getMediaTypeText(type: string | undefined) {
</VVirtualScroll>
</VInfiniteScroll>
</VList>
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{
t('dialog.subscribeHistory.noData')
}}</VCardText>
<VCardText v-if="historyList.length === 0 && isRefreshed" class="subscribe-history-empty">
<VIcon class="subscribe-history-empty__icon" icon="mdi-sync" size="30" />
<div class="subscribe-history-empty__headline">
{{ t('dialog.subscribeHistory.noData') }}
</div>
<div class="subscribe-history-empty__description">
{{ t('dialog.subscribeHistory.noDataHint') }}
</div>
</VCardText>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</VDialog>
</template>
<style scoped>
.subscribe-history-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
min-block-size: 13rem;
padding-block: 2.5rem !important;
padding-inline: 1.5rem !important;
text-align: center;
}
.subscribe-history-empty__icon {
color: rgba(var(--v-theme-on-surface), 0.32);
}
.subscribe-history-empty__headline {
color: rgba(var(--v-theme-on-surface), 0.9);
font-size: 1.15rem;
font-weight: 600;
line-height: 1.4;
}
.subscribe-history-empty__description {
color: rgba(var(--v-theme-on-surface), 0.6);
font-size: 0.92rem;
line-height: 1.65;
max-inline-size: 25rem;
}
</style>

View File

@@ -105,9 +105,17 @@ const $toast = useToast()
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn :disabled="shareDoing" @click="doShare" prepend-icon="mdi-share" class="px-5" :loading="shareDoing">
<VBtn
color="primary"
variant="flat"
:disabled="shareDoing"
@click="doShare"
prepend-icon="mdi-share"
class="px-5"
:loading="shareDoing"
>
{{ t('dialog.subscribeShare.confirmShare') }}
</VBtn>
</VCardActions>

View File

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

View File

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

View File

@@ -85,7 +85,7 @@ function updateFilter(values: string[]) {
@update:model-value="updateFilter"
>
<VChip
v-for="option in options"
v-for="option in options"
:key="option"
:value="option"
filter
@@ -97,12 +97,39 @@ function updateFilter(values: string[]) {
</VChip>
</VChipGroup>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="visible = false">
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="visible = false">
{{ t('torrent.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.filter-options {
display: flex;
flex-wrap: wrap;
}
.filter-chip {
border: 1px solid rgba(var(--v-theme-primary), 0.2);
margin: 4px;
background-color: rgba(var(--v-theme-primary), 0.1) !important;
color: rgba(var(--v-theme-on-surface), 0.9) !important;
font-weight: 500;
transition: all 0.2s ease;
}
.filter-chip:hover {
background-color: rgba(var(--v-theme-primary), 0.15) !important;
}
.filter-chip.v-chip--selected {
background-color: rgba(var(--v-theme-primary), 0.85) !important;
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
color: rgb(var(--v-theme-on-primary)) !important;
font-weight: 600;
}
</style>

View File

@@ -311,8 +311,16 @@ onUnmounted(() => {
<VProgressLinear v-if="dataList.length > 0" :model-value="overallProgressComputed" color="primary" />
<VDivider v-else />
<VCardText v-if="dataList.length === 0" class="text-center">
{{ t('dialog.transferQueue.noTasks') }}
<VCardText v-if="dataList.length === 0" class="transfer-queue-empty">
<VIcon class="transfer-queue-empty__icon" icon="mdi-sync" size="30" />
<div class="transfer-queue-empty__headline">
{{ t('dialog.transferQueue.noTasks') }}
</div>
<div class="transfer-queue-empty__description">
{{ t('dialog.transferQueue.noTasksHint') }}
</div>
</VCardText>
<VCardText v-if="dataList.length > 0">
@@ -366,3 +374,51 @@ onUnmounted(() => {
</VCard>
</VDialog>
</template>
<style scoped>
.transfer-queue-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
min-block-size: 13rem;
padding-block: 2.5rem !important;
padding-inline: 1.5rem !important;
text-align: center;
}
.transfer-queue-empty__icon {
color: rgba(var(--v-theme-on-surface), 0.32);
}
.transfer-queue-empty__headline {
color: rgba(var(--v-theme-on-surface), 0.9);
font-size: 1.15rem;
font-weight: 600;
line-height: 1.4;
}
.transfer-queue-empty__description {
color: rgba(var(--v-theme-on-surface), 0.6);
font-size: 0.92rem;
line-height: 1.65;
max-inline-size: 25rem;
}
@media (width <= 600px) {
.transfer-queue-empty {
min-block-size: 11rem;
padding-block: 2rem !important;
padding-inline: 1rem !important;
}
.transfer-queue-empty__headline {
font-size: 1.05rem;
}
.transfer-queue-empty__description {
font-size: 0.9rem;
}
}
</style>

View File

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

View File

@@ -612,12 +612,13 @@ onMounted(() => {
</div>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
v-if="props.oper === 'add'"
:disabled="isAdding"
color="primary"
variant="flat"
@click="addUser"
prepend-icon="mdi-plus"
class="px-5"
@@ -629,6 +630,7 @@ onMounted(() => {
v-else
:disabled="isUpdating"
color="primary"
variant="flat"
@click="updateUser"
prepend-icon="mdi-content-save"
class="px-5"

View File

@@ -25,7 +25,33 @@ onConnect((connection: Connection) => {
$toast.warning(t('dialog.workflowActions.invalidConnection'))
return
}
addEdges(connection)
addEdges(
normalizeWorkflowEdge({
...connection,
id: `edge_${connection.source}_${connection.target}_${Date.now()}`,
type: 'animation',
animated: true,
}),
)
})
// 当前选中的流程边ID
const selectedEdgeId = ref<string | null>(null)
// 流程边配置表单
const edgeForm = ref({
condition: '',
})
// 后端动作固定契约,供条件构造器读取上一节点输出
const actionDefinitions = ref<any[]>([])
// 动作类型到契约的映射
const actionContractMap = computed(() => {
return actionDefinitions.value.reduce((result, action) => {
result[action.type] = action.contract || {}
return result
}, {} as Record<string, any>)
})
// 获取指定节点端口的类型(输入/输出)
@@ -59,6 +85,197 @@ const isValidConnection = (connection: Connection) => {
return sourcePortType === 'output' && targetPortType === 'input' && connection.source !== connection.target
}
// 读取流程边扩展配置,兼容后端支持的顶层字段与 data 字段
const getEdgeConfigValue = (edge: any, key: string) => {
return edge?.[key] ?? edge?.data?.[key] ?? ''
}
// 复制对象并移除不再由前端编辑的高级配置
const omitConfigKeys = (value: any, keys: string[]) => {
const result = { ...(value || {}) }
keys.forEach(key => delete result[key])
return result
}
// 统一流程边数据结构,前端只编辑边条件,汇合和分支策略由执行器默认处理
const normalizeWorkflowEdge = (edge: any) => {
const condition = String(getEdgeConfigValue(edge, 'condition') || '').trim()
const edgeClass = String(edge?.class || '')
.replace(/\bworkflow-conditional-edge\b/g, '')
.trim()
const data = omitConfigKeys(edge?.data, ['join_policy', 'branch_policy'])
data.condition = condition || undefined
const edgePayload = omitConfigKeys(edge, ['join_policy', 'branch_policy'])
return {
...edgePayload,
animated: edge?.animated ?? true,
type: edge?.type || 'animation',
label: condition ? t('dialog.workflowActions.edgeConditionalLabel') : undefined,
class: [edgeClass, condition ? 'workflow-conditional-edge' : ''].filter(Boolean).join(' ') || undefined,
condition: condition || undefined,
data,
}
}
// 标准化所有流程边,导入和保存前都会调用
const normalizeWorkflowEdges = () => {
edges.value = (edges.value || []).map(edge => normalizeWorkflowEdge(edge))
}
// 统一动作节点数据结构,高级运行配置由后端默认值和动作契约接管
const normalizeWorkflowNode = (node: any) => {
const hiddenConfigKeys = [
'inputs',
'outputs',
'join_policy',
'fail_policy',
'branch_policy',
'concurrency_key',
'timeout',
'retry',
'contract',
'_contract',
]
const data = omitConfigKeys(node?.data, hiddenConfigKeys)
const nodePayload = omitConfigKeys(node, hiddenConfigKeys)
return {
...nodePayload,
data,
}
}
// 标准化所有动作节点,导入和保存前都会调用
const normalizeWorkflowNodes = () => {
nodes.value = (nodes.value || []).map(node => normalizeWorkflowNode(node))
}
// 获取节点名称,便于在边设置面板展示流转关系
const getNodeName = (nodeId?: string) => {
const node = nodes.value.find(item => item.id === nodeId)
return (node as any)?.name || node?.data?.label || nodeId || ''
}
// 获取流程边源节点可用于条件判断的输出字段
const getEdgeConditionFields = (edge: any) => {
const sourceNode = edge
? nodes.value.find(node => node.id === edge.source)
: null
const contract = sourceNode ? actionContractMap.value[sourceNode.type] || {} : {}
const fields = contract.condition_fields || contract.outputs || []
return Array.isArray(fields)
? fields.filter((field: any) => field?.name || field)
: []
}
// 判断流程边是否存在可编辑条件
const canConfigureEdge = (edge: any) => {
const condition = String(getEdgeConfigValue(edge, 'condition') || '').trim()
return Boolean(condition || getEdgeConditionFields(edge).length)
}
// 选中流程边时打开设置面板
async function handleEdgeClick(params: any) {
const edge = params?.edge
if (!edge) return
if (!actionDefinitions.value.length) {
await loadActionDefinitions()
}
if (!canConfigureEdge(edge)) {
closeEdgeSettings()
$toast.info(t('dialog.workflowActions.edgeNoConditionFields'))
return
}
selectedEdgeId.value = edge.id
edgeForm.value = {
condition: String(getEdgeConfigValue(edge, 'condition') || ''),
}
}
// 关闭流程边设置面板
function closeEdgeSettings() {
selectedEdgeId.value = null
edgeForm.value = {
condition: '',
}
}
// 保存流程边设置
function saveEdgeSettings() {
if (!selectedEdgeId.value) return
edges.value = edges.value.map(edge => {
if (edge.id !== selectedEdgeId.value) return edge
return normalizeWorkflowEdge({
...edge,
condition: edgeForm.value.condition,
data: {
...(edge.data || {}),
condition: edgeForm.value.condition,
},
})
})
$toast.success(t('dialog.workflowActions.edgeSaveSuccess'))
}
// 删除当前选中的流程边
function deleteSelectedEdge() {
if (!selectedEdgeId.value) return
edges.value = edges.value.filter(edge => edge.id !== selectedEdgeId.value)
closeEdgeSettings()
}
// 当前选中的流程边
const selectedEdge = computed(() => {
if (!selectedEdgeId.value) return null
return edges.value.find(edge => edge.id === selectedEdgeId.value) || null
})
// 当前边可用于条件判断的输出字段
const selectedEdgeConditionFields = computed(() => (
selectedEdge.value ? getEdgeConditionFields(selectedEdge.value) : []
))
// 当前边的条件下拉选项,按源节点固定输出自动生成
const edgeConditionOptions = computed(() => {
const sourceNode = selectedEdge.value
? nodes.value.find(node => node.id === selectedEdge.value?.source)
: null
const options = [{ title: t('dialog.workflowActions.conditionAlways'), value: '' }]
selectedEdgeConditionFields.value.forEach((field: any) => {
const fieldName = field.name || field
if (!fieldName) return
const fieldLabel = field.label || fieldName
if (field.kind === 'list') {
options.push({
title: t('dialog.workflowActions.conditionHasOutput', { field: fieldLabel }),
value: `outputs.${sourceNode?.id}.${fieldName}.count > 0`,
})
options.push({
title: t('dialog.workflowActions.conditionNoOutput', { field: fieldLabel }),
value: `outputs.${sourceNode?.id}.${fieldName}.count == 0`,
})
return
}
options.push({
title: t('dialog.workflowActions.conditionHasValue', { field: fieldLabel }),
value: `outputs.${sourceNode?.id}.${fieldName} != None`,
})
})
if (edgeForm.value.condition && !options.some(item => item.value === edgeForm.value.condition)) {
options.push({
title: t('dialog.workflowActions.conditionCustom'),
value: edgeForm.value.condition,
})
}
return options
})
// 选中动作节点时关闭可能打开的边条件面板,不再提供节点运行设置
function handleNodeClick() {
closeEdgeSettings()
}
// 自定义节点类型
const nodeTypes: Record<string, any> = ref({})
@@ -85,6 +302,17 @@ for (const path in components) {
})
}
// 加载动作契约,供边条件构造器使用
async function loadActionDefinitions() {
try {
const actionList = await api.get('workflow/actions')
actionDefinitions.value = Array.isArray(actionList) ? actionList : []
} catch (error) {
console.error(error)
actionDefinitions.value = []
}
}
// 定义输入参数
const props = defineProps({
workflow: Object as PropType<Workflow>,
@@ -142,8 +370,10 @@ function handleComponentClick(action: any) {
// 调用API 编辑任务
async function updateWorkflow() {
// 更新节点和流程
workflowForm.value.actions = nodes
workflowForm.value.flows = edges
normalizeWorkflowNodes()
normalizeWorkflowEdges()
workflowForm.value.actions = nodes.value
workflowForm.value.flows = edges.value
try {
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
@@ -166,6 +396,11 @@ function saveCodeString(type: string, code: any) {
if (type === 'workflow') {
nodes.value = codeObject.actions || []
edges.value = codeObject.flows || []
if (codeObject.execution_config) {
workflowForm.value.execution_config = codeObject.execution_config
}
normalizeWorkflowNodes()
normalizeWorkflowEdges()
}
importCodeDialog.value = false
$toast.success(t('dialog.workflowActions.importSuccess'))
@@ -178,18 +413,47 @@ function saveCodeString(type: string, code: any) {
// 分享工作流程
function shareWorkflow() {
const codeString = JSON.stringify({ actions: nodes.value, flows: edges.value })
normalizeWorkflowNodes()
normalizeWorkflowEdges()
const codeString = JSON.stringify({
actions: nodes.value,
flows: edges.value,
execution_config: workflowForm.value.execution_config,
})
navigator.clipboard.writeText(codeString)
$toast.success(t('dialog.workflowActions.codeCopied'))
}
onMounted(() => {
loadActionDefinitions()
if (props.workflow) {
nodes.value = props.workflow.actions ?? []
edges.value = props.workflow.flows ?? []
normalizeWorkflowNodes()
normalizeWorkflowEdges()
}
})
watch(
edges,
() => {
if (selectedEdgeId.value && !selectedEdge.value) {
closeEdgeSettings()
}
},
{ deep: true },
)
watch(
nodes,
() => {
if (selectedEdge.value && !canConfigureEdge(selectedEdge.value)) {
closeEdgeSettings()
}
},
{ deep: true },
)
// 判断是不是MACOS
const isMacOS = computed(() => {
return /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent)
@@ -231,6 +495,8 @@ const isMacOS = computed(() => {
:edge-updater-radius="10"
@dragover="onDragOver"
@dragleave="onDragLeave"
@node-click="handleNodeClick"
@edge-click="handleEdgeClick"
:delete-key-code="isMacOS ? 'Backspace' : 'Delete'"
auto-connect
>
@@ -243,6 +509,50 @@ const isMacOS = computed(() => {
>
</DropzoneBackground>
</VueFlow>
<div v-if="selectedEdge" class="workflow-edge-panel">
<div class="edge-panel-header">
<div class="edge-panel-title">
<VIcon icon="mdi-source-branch" size="20" />
<span>{{ t('dialog.workflowActions.edgeSettingsTitle') }}</span>
</div>
<VBtn icon variant="text" size="small" @click="closeEdgeSettings">
<VIcon icon="mdi-close" />
</VBtn>
</div>
<div class="edge-route">
<span>{{ getNodeName(selectedEdge.source) }}</span>
<VIcon icon="mdi-arrow-right" size="18" />
<span>{{ getNodeName(selectedEdge.target) }}</span>
</div>
<VSelect
v-model="edgeForm.condition"
:items="edgeConditionOptions"
:label="t('dialog.workflowActions.edgeConditionLabel')"
clearable
item-title="title"
item-value="value"
variant="outlined"
density="comfortable"
hide-details="auto"
/>
<div class="edge-panel-actions">
<VBtn icon variant="text" color="error" @click="deleteSelectedEdge">
<VIcon icon="mdi-delete" />
</VBtn>
<VSpacer />
<VBtn variant="text" @click="closeEdgeSettings">
{{ t('dialog.workflowActions.edgeCancel') }}
</VBtn>
<VBtn color="primary" @click="saveEdgeSettings">
{{ t('dialog.workflowActions.edgeSave') }}
</VBtn>
</div>
</div>
<WorkflowSidebar @component-click="handleComponentClick" />
</div>
</VCardText>
@@ -285,12 +595,64 @@ const isMacOS = computed(() => {
inline-size: 100%;
}
.workflow-edge-panel {
position: absolute;
z-index: 120;
display: flex;
flex-direction: column;
padding: 16px;
background-color: rgb(var(--v-theme-surface));
gap: 14px;
inline-size: min(360px, calc(100vw - 32px));
inset-block-start: 20px;
inset-inline-end: 20px;
max-block-size: calc(100% - 40px);
overflow-y: auto;
}
.edge-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.edge-panel-title {
display: flex;
align-items: center;
color: rgb(var(--v-theme-on-surface));
font-size: 16px;
font-weight: 600;
gap: 8px;
}
.edge-route {
display: flex;
align-items: center;
border-radius: 6px;
background-color: rgba(var(--v-theme-primary), 0.08);
color: rgb(var(--v-theme-on-surface));
font-size: 13px;
gap: 8px;
padding-block: 8px;
padding-inline: 10px;
span {
overflow: hidden;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.edge-panel-actions {
display: flex;
align-items: center;
gap: 8px;
}
.vue-flow__minimap {
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);
box-shadow: 0 4px 15px rgba(var(--v-shadow-key-umbra-color), 0.1);
inset-block-end: 20px;
inset-inline-end: 20px;
transform: scale(75%);
@@ -318,16 +680,12 @@ const isMacOS = computed(() => {
// 自定义节点样式
.vue-flow__node {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 12px;
&:hover {
box-shadow: 0 8px 16px rgba(var(--v-shadow-key-umbra-color), 0.15) !important;
transform: translateY(-2px);
}
&.selected {
box-shadow: 0 0 0 1px rgb(var(--v-theme-primary)) !important;
box-shadow: 0 0 0 1px rgb(var(--v-theme-primary));
}
}
@@ -345,9 +703,23 @@ const isMacOS = computed(() => {
}
}
.vue-flow__edge.workflow-conditional-edge {
.vue-flow__edge-path {
stroke: rgb(var(--v-theme-warning));
}
}
@media screen and (width <= 600px) {
.vue-flow__minimap {
display: none;
}
.workflow-edge-panel {
inline-size: auto;
inset-block: auto 88px;
inset-inline: 16px;
max-block-size: min(72vh, calc(100% - 112px));
}
}
</style>

View File

@@ -37,9 +37,35 @@ const workflowForm = ref<Workflow>(
event_type: undefined,
state: 'P',
run_count: 0,
execution_config: {},
},
)
// 将并发数清洗为正整数,空值表示使用后端默认值
const normalizePositiveInteger = (value: any) => {
if (value === undefined || value === null || value === '') return undefined
const numberValue = Number(value)
if (!Number.isFinite(numberValue) || numberValue < 1) return undefined
return Math.floor(numberValue)
}
// 工作流级执行配置中的最大并行数
const workflowMaxWorkers = computed<number | null>({
get() {
return normalizePositiveInteger(workflowForm.value.execution_config?.max_workers) ?? null
},
set(value) {
const executionConfig = { ...(workflowForm.value.execution_config || {}) }
const maxWorkers = normalizePositiveInteger(value)
if (maxWorkers) {
executionConfig.max_workers = maxWorkers
} else {
delete executionConfig.max_workers
}
workflowForm.value.execution_config = Object.keys(executionConfig).length ? executionConfig : undefined
},
})
// 监听props变化处理存量数据
watch(
() => props.workflow,
@@ -49,7 +75,10 @@ watch(
if (!newWorkflow.trigger_type) {
newWorkflow.trigger_type = 'timer'
}
workflowForm.value = { ...newWorkflow }
workflowForm.value = {
...newWorkflow,
execution_config: { ...(newWorkflow.execution_config || {}) },
}
}
},
{ immediate: true },
@@ -99,6 +128,18 @@ watch(
// 提示框
const $toast = useToast()
// 保存前统一清洗工作流执行配置
function normalizeWorkflowExecutionConfig() {
const executionConfig = { ...(workflowForm.value.execution_config || {}) }
const maxWorkers = normalizePositiveInteger(executionConfig.max_workers)
if (maxWorkers) {
executionConfig.max_workers = maxWorkers
} else {
delete executionConfig.max_workers
}
workflowForm.value.execution_config = Object.keys(executionConfig).length ? executionConfig : undefined
}
// 调用API 新增任务
async function addWorkflow() {
if (!workflowForm.value.name) {
@@ -122,6 +163,7 @@ async function addWorkflow() {
return
}
normalizeWorkflowExecutionConfig()
startNProgress()
try {
const result: { [key: string]: string } = await api.post('workflow/', workflowForm.value)
@@ -160,6 +202,7 @@ async function editWorkflow() {
return
}
normalizeWorkflowExecutionConfig()
startNProgress()
try {
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
@@ -256,15 +299,32 @@ onMounted(() => {
prepend-inner-icon="mdi-text-box-outline"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model.number="workflowMaxWorkers"
type="number"
min="1"
clearable
:label="t('dialog.workflowAddEdit.maxWorkers')"
prepend-inner-icon="mdi-call-split"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn v-if="workflow" color="primary" @click="editWorkflow" prepend-icon="mdi-content-save" class="px-5">
<VBtn
v-if="workflow"
color="primary"
variant="flat"
@click="editWorkflow"
prepend-icon="mdi-content-save"
class="px-5"
>
{{ t('dialog.workflowAddEdit.confirm') }}
</VBtn>
<VBtn v-else color="primary" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
<VBtn v-else color="primary" variant="flat" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
{{ t('dialog.workflowAddEdit.confirm') }}
</VBtn>
</VCardActions>

View File

@@ -125,9 +125,17 @@ const $toast = useToast()
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn :disabled="shareDoing" @click="doShare" prepend-icon="mdi-share" class="px-5" :loading="shareDoing">
<VBtn
color="primary"
variant="flat"
:disabled="shareDoing"
@click="doShare"
prepend-icon="mdi-share"
class="px-5"
:loading="shareDoing"
>
{{ t('dialog.workflowShare.confirmShare') }}
</VBtn>
</VCardActions>

View File

@@ -29,8 +29,7 @@ const display = useDisplay()
const { appMode } = usePWA()
// 计算列表可用高度
// componentOffset = FileToolbar(48) + FileList操作栏(40) + VCard边距(4) = 92
const { availableHeight: listAvailableHeight } = useAvailableHeight(92, 300)
const { availableHeight: listAvailableHeight } = useAvailableHeight(100, 300)
// 输入参数
const inProps = defineProps({
@@ -239,6 +238,12 @@ function changeSelectMode() {
if (!selectMode.value) selected.value = []
}
// 退出多选模式
function exitSelectMode() {
selectMode.value = false
selected.value = []
}
// 调API加载文件夹内的内容
async function list_files(context: KeepAliveRefreshContext = {}) {
const silentRefresh = Boolean(context.silent && items.value.length > 0)
@@ -261,7 +266,7 @@ async function list_files(context: KeepAliveRefreshContext = {}) {
}
// 加载数据
const data = ((await inProps.axios.request<FileItem[], FileItem[]>(config))) ?? []
const data = (await inProps.axios.request<FileItem[], FileItem[]>(config)) ?? []
// 如果当前路径已经变化,则放弃此次加载结果
if (prevURI !== takeURISnapshot()) {
return
@@ -316,6 +321,8 @@ async function deleteItem(item: FileItem, confirm: boolean = true) {
// 批量删除
async function batchDelete() {
if (!selected.value.length) return
const confirmed = await createConfirm({
title: t('common.confirm'),
content: t('file.confirmBatchDelete', { count: selected.value.length }),
@@ -327,18 +334,24 @@ async function batchDelete() {
progressValue.value = 0
openProgressDialog(progressText.value, progressValue.value)
// 删除选中的项目
selected.value.every(async item => {
progressText.value = t('file.deleting', { name: item.name })
progressDialogController?.updateProps({ text: progressText.value })
await deleteItem(item, false)
})
try {
const selectedItems = dedupeFileItems(selected.value)
// 关闭进度条
closeProgressDialog()
// 删除选中的项目
for (const item of selectedItems) {
progressText.value = t('file.deleting', { name: item.name })
progressDialogController?.updateProps({ text: progressText.value })
await deleteItem(item, false)
}
// 重新加载
list_files()
exitSelectMode()
} finally {
// 关闭进度条
closeProgressDialog()
// 重新加载
list_files()
}
}
// 切换路径
@@ -367,7 +380,7 @@ async function download(item: FileItem) {
responseType: 'blob',
}
// 加载数据
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
const result: Blob = await inProps.axios.request<Blob, Blob>(config)
if (result) {
const downloadUrl = URL.createObjectURL(result)
window.open(downloadUrl, '_blank')
@@ -388,7 +401,7 @@ async function getImgLink(item: FileItem) {
responseType: 'blob',
}
// 加载二进制数据
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
const result: Blob = await inProps.axios.request<Blob, Blob>(config)
if (result) {
// 创建图片地址
revokeCurrentImgLink()
@@ -494,7 +507,7 @@ async function rename() {
method: inProps.endpoints?.rename.method || 'post',
data: currentItem.value,
}
const result: { [key: string]: any } = (await inProps.axios?.request<any, { [key: string]: any }>(config))
const result: { [key: string]: any } = await inProps.axios?.request<any, { [key: string]: any }>(config)
if (!result.success) {
$toast.error(result.message)
}
@@ -528,6 +541,7 @@ function showBatchTransfer() {
// 整理完成
function transferDone() {
exitSelectMode()
list_files()
}
@@ -688,6 +702,8 @@ async function scrape(item: FileItem, confirm: boolean = true) {
// 批量刮削
async function batchScrape() {
if (!selected.value.length) return
// 确认
const confirmed = await createConfirm({
title: t('common.confirm'),
@@ -695,9 +711,17 @@ async function batchScrape() {
})
if (!confirmed) return
selected.value.map(item => {
scrape(item, false)
})
try {
const selectedItems = dedupeFileItems(selected.value)
for (const item of selectedItems) {
await scrape(item, false)
}
exitSelectMode()
} finally {
list_files({ silent: true })
}
}
// 进度SSE消息处理函数
@@ -743,17 +767,9 @@ onUnmounted(() => {
})
</script>
<style scoped>
.file-list-container {
overflow: hidden auto;
block-size: 100%;
max-block-size: 100%;
}
</style>
<template>
<div>
<VCard class="d-flex flex-column w-full h-full rounded-t-0" :class="{ 'rounded-s-0': showTree }">
<VCard class="d-flex flex-column w-full h-full file-list">
<div v-if="!loading" class="flex">
<IconBtn v-if="display.mdAndUp.value">
<VIcon v-if="showTree" icon="mdi-file-tree" @click="switchFileTree(false)" />
@@ -767,7 +783,7 @@ onUnmounted(() => {
density="compact"
variant="plain"
:placeholder="t('file.filterPlaceholder')"
:prepend-inner-icon="(filter.includes('*') || filter.includes('?')) ? 'mdi-asterisk' : 'mdi-filter-outline'"
:prepend-inner-icon="filter.includes('*') || filter.includes('?') ? 'mdi-asterisk' : 'mdi-filter-outline'"
class="mx-2"
rounded
/>
@@ -906,3 +922,17 @@ onUnmounted(() => {
</VCard>
</div>
</template>
<style scoped>
.file-list {
border-radius: 0 !important;
box-shadow: none !important;
}
.file-list-container {
overflow: hidden auto;
border-radius: 0 !important;
block-size: 100%;
max-block-size: 100%;
}
</style>

View File

@@ -4,7 +4,6 @@ import type { FileItem } from '@/api/types'
import { useDisplay } from 'vuetify'
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useAvailableHeight } from '@/composables/useAvailableHeight'
// 国际化
@@ -12,16 +11,13 @@ const { t } = useI18n()
const display = useDisplay()
const { appMode } = usePWA()
type TreeRow =
| { type: 'root'; key: string; level: number }
| { type: 'loading'; key: string; path: string; level: number }
| { type: 'directory'; key: string; dir: FileItem; level: number }
// 计算列表可用高度
// componentOffset = FileToolbar(48) = 48
const { availableHeight } = useAvailableHeight(48, 300)
const { availableHeight } = useAvailableHeight(58, 300)
// 输入参数
const props = defineProps({
@@ -116,7 +112,7 @@ async function loadSubdirectories(path: string) {
data: fakeItem,
}
const result = (await props.axios?.request(config))
const result = await props.axios?.request(config)
if (result && Array.isArray(result)) {
// 过滤出目录项
const dirs = result.filter(item => item.type === 'dir')
@@ -249,11 +245,10 @@ function getTreeRowStyle(level: number) {
onMounted(async () => {
await loadRootDirectories()
})
</script>
<template>
<VCard class="file-navigator rounded-e-0 rounded-t-0" v-if="!isMobile" :height="`${availableHeight}px`">
<VCard class="file-navigator" v-if="!isMobile" :height="`${availableHeight}px`">
<VVirtualScroll :items="visibleTreeRows" :item-height="32" class="tree-container">
<template #default="{ item }">
<div
@@ -296,13 +291,7 @@ onMounted(async () => {
:style="getTreeRowStyle(item.level)"
>
<div class="folder-toggle" @click.stop="toggleFolder(item.dir.path || '')">
<VProgressCircular
v-if="loading[item.dir.path || '']"
indeterminate
size="14"
width="2"
color="primary"
/>
<VProgressCircular v-if="loading[item.dir.path || '']" indeterminate size="14" width="2" color="primary" />
<VIcon
v-else
size="small"
@@ -332,9 +321,9 @@ onMounted(async () => {
overflow: hidden;
flex-direction: column;
flex-shrink: 0;
background: rgb(var(--v-table-header-background));
border-radius: 0 !important;
block-size: 100%;
border-end-start-radius: 12px;
box-shadow: none !important;
inline-size: 240px;
}

View File

@@ -372,7 +372,7 @@ onMounted(() => {
:key="key"
variant="tonal"
size="small"
:color="filterForm[key].length > 0 ? 'primary' : undefined"
color="primary"
:prepend-icon="getFilterIcon(key)"
class="filter-btn"
rounded="pill"
@@ -555,7 +555,7 @@ onMounted(() => {
v-for="(title, key) in filterTitles"
v-show="filterOptions[key].length > 0"
:key="key"
variant="text"
variant="tonal"
color="primary"
class="filter-btn-mobile"
@click="toggleFilterMenu(key)"
@@ -575,7 +575,7 @@ onMounted(() => {
</VBtn>
<!-- 全部筛选按钮 -->
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
<VBtn variant="tonal" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
<span class="filter-label">
{{ t('torrent.allFilters') }}
@@ -609,8 +609,6 @@ onMounted(() => {
.filter-toolbar-card {
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
background: rgba(var(--v-theme-surface), 0.82);
}
@@ -632,11 +630,6 @@ onMounted(() => {
margin-inline-end: 2px !important;
}
.sort-menu-list {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
}
.sort-menu-list :deep(.v-list-item__prepend > .v-icon) {
margin-inline-end: 0px !important;
}
@@ -672,7 +665,6 @@ onMounted(() => {
.filter-btn {
min-inline-size: 0;
background: rgba(var(--v-theme-surface-variant), 0.1);
transition: opacity 0.2s;
}
@@ -740,7 +732,6 @@ onMounted(() => {
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
background-color: rgba(var(--v-theme-surface-variant), 0.08);
block-size: auto;
min-block-size: 48px;
padding-block: 4px;
@@ -758,10 +749,6 @@ onMounted(() => {
}
@media (width <= 600px) {
.filter-toolbar-card {
border-radius: 8px;
}
.filter-buttons-grid {
gap: 6px;
}

View File

@@ -6,6 +6,8 @@ import DashboardRender from '@/components/render/DashboardRender.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
import { loadRemoteComponent } from '@/utils/federationLoader'
type DashboardComponentLoader = () => Promise<any>
const DashboardSkeleton = {
setup() {
const SkeletonLoader = resolveComponent('VSkeletonLoader')
@@ -19,51 +21,59 @@ const asyncDashboardOptions = {
loadingComponent: DashboardSkeleton,
}
const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader> = {
storage: () => import('@/views/dashboard/AnalyticsStorage.vue'),
mediaStatistic: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'),
weeklyOverview: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'),
speed: () => import('@/views/dashboard/AnalyticsSpeed.vue'),
scheduler: () => import('@/views/dashboard/AnalyticsScheduler.vue'),
cpu: () => import('@/views/dashboard/AnalyticsCpu.vue'),
memory: () => import('@/views/dashboard/AnalyticsMemory.vue'),
network: () => import('@/views/dashboard/AnalyticsNetwork.vue'),
library: () => import('@/views/dashboard/MediaServerLibrary.vue'),
playing: () => import('@/views/dashboard/MediaServerPlaying.vue'),
latest: () => import('@/views/dashboard/MediaServerLatest.vue'),
}
const builtInDashboardComponentPromises = new Map<string, Promise<any>>()
// 复用内置仪表盘组件加载 Promise让页面层可以等待异步组件模块真正加载完成。
function loadBuiltInDashboardComponent(id: string) {
const loader = builtInDashboardComponentLoaders[id]
if (!loader) return Promise.resolve()
let loadPromise = builtInDashboardComponentPromises.get(id)
if (!loadPromise) {
loadPromise = loader().catch(error => {
builtInDashboardComponentPromises.delete(id)
throw error
})
builtInDashboardComponentPromises.set(id, loadPromise)
}
return loadPromise
}
// 创建内置仪表盘异步组件,并与加载完成上报共享同一份加载 Promise。
function createAsyncDashboardComponent(id: string) {
return defineAsyncComponent({
loader: () => loadBuiltInDashboardComponent(id),
...asyncDashboardOptions,
})
}
// 内置仪表盘按需加载,关闭的卡片不再挤进 dashboard 首屏 chunk。
const AnalyticsStorage = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsStorage.vue'),
...asyncDashboardOptions,
})
const AnalyticsMediaStatistic = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'),
...asyncDashboardOptions,
})
const AnalyticsWeeklyOverview = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'),
...asyncDashboardOptions,
})
const AnalyticsSpeed = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsSpeed.vue'),
...asyncDashboardOptions,
})
const AnalyticsScheduler = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsScheduler.vue'),
...asyncDashboardOptions,
})
const AnalyticsCpu = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsCpu.vue'),
...asyncDashboardOptions,
})
const AnalyticsMemory = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsMemory.vue'),
...asyncDashboardOptions,
})
const AnalyticsNetwork = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsNetwork.vue'),
...asyncDashboardOptions,
})
const MediaServerLibrary = defineAsyncComponent({
loader: () => import('@/views/dashboard/MediaServerLibrary.vue'),
...asyncDashboardOptions,
})
const MediaServerPlaying = defineAsyncComponent({
loader: () => import('@/views/dashboard/MediaServerPlaying.vue'),
...asyncDashboardOptions,
})
const MediaServerLatest = defineAsyncComponent({
loader: () => import('@/views/dashboard/MediaServerLatest.vue'),
...asyncDashboardOptions,
})
const AnalyticsStorage = createAsyncDashboardComponent('storage')
const AnalyticsMediaStatistic = createAsyncDashboardComponent('mediaStatistic')
const AnalyticsWeeklyOverview = createAsyncDashboardComponent('weeklyOverview')
const AnalyticsSpeed = createAsyncDashboardComponent('speed')
const AnalyticsScheduler = createAsyncDashboardComponent('scheduler')
const AnalyticsCpu = createAsyncDashboardComponent('cpu')
const AnalyticsMemory = createAsyncDashboardComponent('memory')
const AnalyticsNetwork = createAsyncDashboardComponent('network')
const MediaServerLibrary = createAsyncDashboardComponent('library')
const MediaServerPlaying = createAsyncDashboardComponent('playing')
const MediaServerLatest = createAsyncDashboardComponent('latest')
// 输入参数
const props = defineProps({
@@ -78,27 +88,43 @@ const props = defineProps({
},
})
const emit = defineEmits(['update:refreshStatus'])
const emit = defineEmits(['update:refreshStatus', 'loaded'])
// 当前仪表盘节点是否已经向页面层报告过加载完成。
const isDashboardElementLoaded = ref(false)
let isDashboardElementUnmounted = false
let pluginDashboardComponentLoadPromise: Promise<any> | null = null
// 插件UI渲染模式 ('vuetify' 或 'vue')
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
// 加载 Vue 模式的插件仪表盘远程组件,并缓存当前节点的加载 Promise。
function loadPluginDashboardComponent() {
if (!props.config?.id) return Promise.reject(new Error('插件ID不存在'))
if (!pluginDashboardComponentLoadPromise) {
pluginDashboardComponentLoadPromise = loadRemoteComponent(props.config.id, 'Dashboard').catch(error => {
pluginDashboardComponentLoadPromise = null
throw error
})
}
return pluginDashboardComponentLoadPromise
}
// Vue 模式:动态加载的组件
const dynamicPluginComponent = defineAsyncComponent({
// 工厂函数
loader: async () => {
try {
if (!props.config?.id) {
throw new Error('插件ID不存在')
}
// 动态加载远程组件
const module = await loadRemoteComponent(props.config.id, 'Dashboard')
const module = await loadPluginDashboardComponent()
// 直接返回加载的组件无需再获取default
return module
} catch (error) {
console.error('加载远程组件失败:', error)
throw error
}
},
// 加载中显示的组件
@@ -115,7 +141,53 @@ const dynamicPluginComponent = defineAsyncComponent({
},
})
// 判断当前配置是否对应内置异步仪表盘组件。
function isBuiltInDashboardElement() {
return !!props.config?.id && !!builtInDashboardComponentLoaders[props.config.id]
}
// 判断当前配置是否需要等待插件 Vue 远程组件加载。
function isVuePluginDashboardElement() {
return !isBuiltInDashboardElement() && pluginRenderMode.value === 'vue' && !isNullOrEmptyObject(props.config)
}
// 向页面层上报当前仪表盘节点已完成首次组件加载。
function emitDashboardElementLoaded() {
if (isDashboardElementLoaded.value || isDashboardElementUnmounted) return
isDashboardElementLoaded.value = true
emit('loaded')
}
// 等待当前仪表盘节点的异步组件加载完成,静态渲染模式则等待一次 DOM 更新。
async function waitForDashboardElementLoaded() {
if (isDashboardElementLoaded.value) return
try {
if (isBuiltInDashboardElement() && props.config?.id) {
await loadBuiltInDashboardComponent(props.config.id)
} else if (isVuePluginDashboardElement()) {
await loadPluginDashboardComponent()
}
await nextTick()
} catch (error) {
console.error(error)
} finally {
emitDashboardElementLoaded()
}
}
watch(
() => [props.config?.id, props.config?.key, pluginRenderMode.value],
() => {
void waitForDashboardElementLoaded()
},
{ immediate: true },
)
onUnmounted(() => {
isDashboardElementUnmounted = true
// 组件卸载时禁用刷新状态
emit('update:refreshStatus', false)
})
@@ -136,43 +208,47 @@ onUnmounted(() => {
<!-- 插件仪表板 -->
<template v-else-if="!isNullOrEmptyObject(props.config)">
<!-- Vue 渲染模式 -->
<div v-if="pluginRenderMode === 'vue'">
<div v-if="pluginRenderMode === 'vue'" class="dashboard-plugin-vue-renderer">
<component :is="dynamicPluginComponent" :config="props.config" :allow-refresh="props.allowRefresh" :api="api" />
</div>
<!-- Vuetify 渲染模式 -->
<VHover v-else-if="pluginRenderMode === 'vuetify'">
<template #default="hover">
<!-- 无边框 -->
<div v-if="props.config?.attrs.border === false">
<VCard v-bind="hover.props">
<VCardText class="p-0">
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
</VCardText>
<div v-if="hover.isHovering" class="absolute right-5 top-5">
<VIcon class="cursor-move">mdi-drag</VIcon>
</div>
</VCard>
</div>
<!-- 有边框 -->
<VCard v-else v-bind="hover.props">
<VCardItem v-if="props.config?.attrs.border !== false">
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>
{{ props.config?.attrs?.title ?? props.config?.name }}
</VCardTitle>
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
</VCardItem>
<VCardText>
<template v-else-if="pluginRenderMode === 'vuetify'">
<!-- 无边框 -->
<div v-if="props.config?.attrs.border === false">
<VCard>
<VCardText class="p-0">
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
</VCardText>
</VCard>
</template>
</VHover>
</div>
<!-- 有边框 -->
<VCard v-else>
<VCardItem v-if="props.config?.attrs.border !== false">
<VCardTitle>
{{ props.config?.attrs?.title ?? props.config?.name }}
</VCardTitle>
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
</VCardItem>
<VCardText>
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
</VCardText>
</VCard>
</template>
<!-- 未知模式或错误 -->
<VCard v-else>
<VCardText>无法渲染插件仪表盘部件: 未知渲染模式或配置错误</VCardText>
</VCard>
</template>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.dashboard-plugin-vue-renderer {
display: flex;
flex-direction: column;
block-size: 100%;
inline-size: 100%;
min-block-size: 0;
}
</style>

View File

@@ -3,9 +3,9 @@ import type { PropType } from 'vue'
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
// 初始化 markdown-it
// 版本历史可能来自插件市场或 Release 内容,禁止透传原始 HTML避免外部内容注入脚本或事件属性。
const md = new MarkdownIt({
html: true,
html: false,
linkify: true,
typographer: true,
})
@@ -27,23 +27,100 @@ function renderMarkdown(value: string) {
// 输入参数
const props = defineProps({
history: Object as PropType<{ [key: string]: string }>,
hasAction: Function as PropType<(version: string) => boolean>,
})
function shouldRenderAction(version: string) {
return props.hasAction?.(version) ?? true
}
</script>
<template>
<VCardText>
<VList>
<VListItem v-for="(value, key) in props.history" :key="key">
<VListItemTitle class="font-bold text-lg">
{{ key }}
</VListItemTitle>
<div class="markdown-body text-gray-500" v-html="renderMarkdown(value)" />
</VListItem>
</VList>
<VCardText class="version-history">
<div class="version-history__list">
<section v-for="(value, key) in props.history" :key="key" class="version-history__item">
<div
class="version-history__top"
:class="{ 'version-history__top--with-action': $slots.action && shouldRenderAction(String(key)) }"
>
<div class="version-history__header">
<div class="version-history__version">
{{ key }}
</div>
<div v-if="$slots.meta" class="version-history__meta">
<slot name="meta" :version="String(key)" />
</div>
</div>
<div v-if="$slots.action && shouldRenderAction(String(key))" class="version-history__action">
<slot name="action" :version="String(key)" />
</div>
</div>
<div class="markdown-body text-medium-emphasis" v-html="renderMarkdown(value)" />
</section>
</div>
</VCardText>
</template>
<style scoped>
.version-history {
padding: 0;
}
.version-history__list {
display: flex;
flex-direction: column;
}
.version-history__item {
padding: 1.25rem 2rem;
}
.version-history__item + .version-history__item {
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.version-history__top {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "main";
gap: 0;
align-items: center;
margin-block-end: 0.5rem;
}
.version-history__top--with-action {
grid-template-columns: minmax(0, 1fr) max-content;
grid-template-areas: "main action";
gap: 1rem;
}
.version-history__header {
grid-area: main;
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: flex-start;
min-width: 0;
}
.version-history__version {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.25rem;
font-weight: 700;
line-height: 1.25;
}
.version-history__meta {
display: flex;
min-width: 0;
}
.version-history__action {
grid-area: action;
align-self: center;
justify-self: end;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
@@ -112,4 +189,28 @@ const props = defineProps({
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
color: rgba(127, 127, 127, 0.8);
}
@media (max-width: 600px) {
.version-history {
padding: 0;
}
.version-history__item {
padding: 1rem;
}
.version-history__top--with-action {
gap: 0.75rem;
}
.version-history__header {
flex-wrap: wrap;
justify-content: flex-start;
}
.version-history__version {
font-size: 1.125rem;
}
}
</style>

View File

@@ -140,7 +140,8 @@ function slideNext(next: boolean) {
if (!element) return
const visibleCount = Math.max(1, Math.trunc(element.clientWidth / itemStep.value))
const currentIndex = element.scrollLeft === 0 ? 0 : Math.trunc((element.scrollLeft + itemStep.value / 2) / itemStep.value)
const currentIndex =
element.scrollLeft === 0 ? 0 : Math.trunc((element.scrollLeft + itemStep.value / 2) / itemStep.value)
let targetLeft = 0
if (next) {
@@ -285,15 +286,22 @@ watch(
<style lang="scss" scoped>
.slider-container {
position: relative;
isolation: isolate;
margin-block-end: 8px;
--slider-shadow-bleed-start: 28px;
--slider-shadow-bleed-end: 56px;
}
.slider-header {
// 阴影缓冲区会把滚动区域上移,标题层级需高于滚动区域以保留按钮点击。
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-block-end: 8px;
margin-block-end: 12px;
padding-block: 0;
padding-inline: 8px;
@@ -340,20 +348,22 @@ watch(
.slider-content-wrapper {
position: relative;
z-index: 1;
inline-size: 100%;
}
.slider-content-container {
position: relative;
overflow: hidden;
inline-size: 100%;
}
.slider-content {
overflow: scroll hidden !important;
// 横向滚动会让纵向 visible 被浏览器计算成可裁剪区域,这里用缓冲区承接卡片阴影。
margin-block: calc(var(--slider-shadow-bleed-start) * -1) calc(var(--slider-shadow-bleed-end) * -1);
-ms-overflow-style: none !important;
overflow: auto hidden;
overscroll-behavior-x: contain !important;
padding-block: 8px;
padding-block: var(--slider-shadow-bleed-start) var(--slider-shadow-bleed-end);
padding-inline: 12px;
scroll-behavior: smooth;
scrollbar-width: none !important;
@@ -380,6 +390,11 @@ watch(
flex: 0 0 auto;
}
.virtual-slide-item,
.loading-track > * {
padding-block-end: 12px;
}
.nav-button {
position: absolute;
z-index: 20;
@@ -399,8 +414,12 @@ watch(
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
transform: translateY(-50%);
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease,
box-shadow 0.3s ease, border-color 0.3s ease;
transition:
opacity 0.3s ease,
transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1),
background-color 0.3s ease,
box-shadow 0.3s ease,
border-color 0.3s ease;
svg {
block-size: 22px;

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