Compare commits

..

183 Commits

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

2
.gitignore vendored
View File

@@ -35,3 +35,5 @@ package-lock.json
# iconify dist files
src/@iconify/*.js
public/plugin_icon/**
docs-lock/
.trae/

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>
@@ -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.12.0",
"version": "2.13.10",
"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",
@@ -128,4 +129,4 @@
"workbox-window": "^7.3.0"
},
"packageManager": "yarn@1.22.18"
}
}

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

@@ -23,6 +23,7 @@ code {
%blurry-bg {
position: relative;
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'
@@ -11,25 +10,33 @@ import { preloadImage } from './@core/utils/image'
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,
})
}
// 生效语言
@@ -40,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()
@@ -80,6 +88,7 @@ applyTransparentBackgroundSettings()
// 心跳检测
let heartbeatInterval: number | null = null
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
// 启动心跳
const startHeartbeat = () => {
@@ -92,7 +101,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)
@@ -115,6 +124,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 {
@@ -200,19 +248,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()
}
}
@@ -229,13 +283,15 @@ async function removeLoadingWithStateCheck() {
}
globalLoadingStateManager.setLoadingState('pwa-state', false)
// PWA/App 模式会影响布局和底部导航,必须在启动屏退场前稳定下来。
await initializePWA()
await initializeAuthenticatedState()
// 等待所有加载完成
await globalLoadingStateManager.waitForAllComplete()
// 移除加载界面
animateAndRemoveLoader()
await animateAndRemoveLoader()
// 检查未读消息
if (isLogin.value) {
@@ -244,7 +300,7 @@ async function removeLoadingWithStateCheck() {
} catch (error) {
// 即使出错也要移除加载界面
globalLoadingStateManager.reset()
animateAndRemoveLoader()
await animateAndRemoveLoader()
}
}
@@ -284,6 +340,8 @@ onMounted(async () => {
// 初始化主题管理器 - 统一处理主题初始化
await themeManager.setTheme(themeValue)
applyStoredThemeCustomizerAppearance(vuetifyTheme)
updateHtmlThemeAttribute(globalTheme.name.value)
// 监听主题变化
watch(
@@ -296,6 +354,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,
@@ -343,6 +407,11 @@ onUnmounted(() => {
}
// 停止心跳
stopHeartbeat()
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
prefersColorSchemeMediaQuery = null
document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
window.removeEventListener('pageshow', handlePageShowThemeSync)
window.removeEventListener('focus', handlePageShowThemeSync)
})
</script>
@@ -367,6 +436,8 @@ onUnmounted(() => {
<!-- 页面内容 -->
<VApp>
<RouterView />
<!-- 全局共享弹窗入口列表与卡片按需在这里挂载业务弹窗 -->
<SharedDialogHost />
<!-- PWA安装提示 -->
<PWAInstallPrompt />
</VApp>

View File

@@ -14,6 +14,10 @@ import modeIniUrl from 'ace-builds/src-noconflict/mode-ini?url'
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
import themeGithubDarkUrl from 'ace-builds/src-noconflict/theme-github_dark?url'
import themeGithubLightDefaultUrl from 'ace-builds/src-noconflict/theme-github_light_default?url'
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
import themeMonokaiUrl from 'ace-builds/src-noconflict/theme-monokai?url'
@@ -533,6 +537,8 @@ ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
ace.config.setModuleUrl('ace/mode/css', modeCssUrl)
ace.config.setModuleUrl('ace/mode/ini', modeIniUrl)
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
ace.config.setModuleUrl('ace/theme/github_dark', themeGithubDarkUrl)
ace.config.setModuleUrl('ace/theme/github_light_default', themeGithubLightDefaultUrl)
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
ace.config.setModuleUrl('ace/mode/base', workerBaseUrl)

View File

@@ -46,8 +46,10 @@ export interface Subscribe {
start_episode?: number
// 缺失集数
lack_episode?: number
// 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数)
completed_episode?: number
// 附加信息
note?: string
note?: string | number[]
// 状态N-新建 R-订阅中 P-待定 S-暂停
state: string
// 最后更新时间
@@ -64,6 +66,8 @@ export interface Subscribe {
search_imdbid?: any
// 当前优先级
current_priority: number
// 洗版时已下载剧集的优先级状态
episode_priority?: Record<string, number>
// 保存目录
save_path?: string
// 时间
@@ -646,6 +650,12 @@ export interface Plugin {
has_page?: boolean
// 是否有新版本
has_update?: boolean
// 主系统版本是否兼容
system_version_compatible?: boolean
// 主系统版本兼容提示
system_version_message?: string
// 主系统版本限定范围
system_version?: string
// 是否本地插件
is_local?: boolean
// 插件仓库地址
@@ -692,6 +702,8 @@ export interface DashboardItem {
attrs: { [key: string]: any }
// col列数
cols: { [key: string]: number }
// Grid行数
rows?: number
// 页面元素
elements: RenderProps[]
// 渲染方式
@@ -756,6 +768,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 {
// 是否处理的文件
@@ -1015,6 +1079,10 @@ export interface FileItem {
export interface MediaServerPlayItem {
// ID
id?: string | number
// 媒体服务器项目ID
item_id?: string | number
// 媒体服务器ID
server_id?: string
// 标题
title: string
// 副标题
@@ -1039,6 +1107,10 @@ export interface MediaServerLibrary {
server: string
// ID
id?: string | number
// 媒体服务器项目ID
item_id?: string | number
// 媒体服务器ID
server_id?: string
// 名称
name: string
// 路径
@@ -1280,9 +1352,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
@@ -1292,7 +1364,7 @@ export interface TransferForm {
// 类型
type_name?: string
// 整理方式
transfer_type: string
transfer_type: string | null
// 自定义格式
episode_format?: string
// 指定集数
@@ -1304,21 +1376,42 @@ 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
episode_group?: string | null
// 预览模式
preview?: boolean
}
// 手动整理请求
export interface ManualTransferPayload extends TransferForm {}
export interface ManualTransferPayload extends Omit<TransferForm, 'fileitem'> {
// 文件项
fileitem?: 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 {
@@ -1354,6 +1447,14 @@ export interface ManualTransferPreviewItem {
episode_end?: number | string
// Part
part?: string
// 原始识别字符串
org_string?: string
// 应用的自定义识别词
apply_words?: string[]
// 制作组/字幕组
resource_team?: string
// 自定义占位符
customization?: string
}
// 手动整理预览数据
@@ -1442,6 +1543,10 @@ export interface Workflow {
actions?: any[]
// 动作流
flows?: any[]
// 工作流执行配置
execution_config?: { [key: string]: any }
// 工作流结构化执行状态
execution_state?: { [key: string]: any }
// 创建时间
add_time?: string
// 最后执行时间

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

File diff suppressed because it is too large Load Diff

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

@@ -1,14 +1,11 @@
<script lang="ts" setup>
import { CustomRule } from '@/api/types'
import { useToast } from 'vue-toastification'
import type { CustomRule } from '@/api/types'
import filter_svg from '@images/svg/filter.svg'
import { cloneDeep } from 'lodash-es'
import { innerFilterRules } from '@/api/constants'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
// 显示器宽度
const display = useDisplay()
const CustomRuleInfoDialog = defineAsyncComponent(() => import('@/components/dialog/CustomRuleInfoDialog.vue'))
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#8A8D93')
// 输入参数
const props = defineProps({
@@ -24,206 +21,52 @@ const props = defineProps({
},
})
// 提示框
const $toast = useToast()
const { t } = useI18n()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 规则详情弹窗
const ruleInfoDialog = ref(false)
// 规则详情
const ruleInfo = ref<CustomRule>({
id: '',
name: '',
include: '',
exclude: '',
size_range: '',
seeders: '',
publish_time: '',
})
// 打开详情弹窗
/** 打开共享自定义规则配置弹窗。 */
function openRuleInfoDialog() {
// 深复制
ruleInfo.value = cloneDeep(props.rule)
ruleInfoDialog.value = true
openSharedDialog(
CustomRuleInfoDialog,
{
rule: props.rule,
rules: props.rules,
},
{
change: (...args: unknown[]) => emit('change', ...args),
done: () => emit('done'),
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 保存详情数据
function saveRuleInfo() {
// 有空值
if (!ruleInfo.value.id || !ruleInfo.value.name) {
if (!ruleInfo.value.id && !ruleInfo.value.name) {
$toast.error(t('customRule.error.emptyIdName'))
}
return
}
// 检查ID是否在内置的规则中
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
$toast.error(t('customRule.error.idOccupied'))
return
}
// 检查规则名称是否在内置的规则中
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
$toast.error(t('customRule.error.nameOccupied'))
return
}
// ID已存在
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
$toast.error(t('customRule.error.idExists', { id: ruleInfo.value.id }))
return
}
// 规则名称已存在
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
$toast.error(t('customRule.error.nameExists', { name: ruleInfo.value.name }))
return
}
// 保存数据
ruleInfoDialog.value = false
emit('change', ruleInfo.value, props.rule.id)
emit('done')
}
// 验证规则ID输入
function validateRuleId() {
// 只允许英文和数字,不允许空格
ruleInfo.value.id = ruleInfo.value.id.replace(/[^a-zA-Z0-9]/g, '')
}
// 按钮点击
/** 关闭自定义规则卡片。 */
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" class="app-card-shell" @click="openRuleInfoDialog">
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<h5 class="app-card-summary__title text-h6">{{ props.rule.name }}</h5>
<div class="app-card-summary__subtitle text-body-1">{{ props.rule.id }}</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="filter_svg" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>
<VDialog
v-if="ruleInfoDialog"
v-model="ruleInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-filter-outline" class="me-2" />
</template>
<VCardTitle>{{ t('customRule.title', { id: props.rule.id }) }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.id"
:label="t('customRule.field.ruleId')"
:placeholder="t('customRule.placeholder.ruleId')"
:hint="t('customRule.hint.ruleId')"
persistent-hint
active
prepend-inner-icon="mdi-identifier"
@input="validateRuleId"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.name"
:label="t('customRule.field.ruleName')"
:placeholder="t('customRule.placeholder.ruleName')"
:hint="t('customRule.hint.ruleName')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.include"
:label="t('customRule.field.include')"
:placeholder="t('customRule.placeholder.include')"
:hint="t('customRule.hint.include')"
persistent-hint
active
prepend-inner-icon="mdi-plus-circle"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.exclude"
:label="t('customRule.field.exclude')"
:placeholder="t('customRule.placeholder.exclude')"
:hint="t('customRule.hint.exclude')"
persistent-hint
active
prepend-inner-icon="mdi-minus-circle"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.size_range"
:label="t('customRule.field.sizeRange')"
:placeholder="t('customRule.placeholder.sizeRange')"
:hint="t('customRule.hint.sizeRange')"
persistent-hint
active
prepend-inner-icon="mdi-harddisk"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.seeders"
:label="t('customRule.field.seeders')"
:placeholder="t('customRule.placeholder.seeders')"
:hint="t('customRule.hint.seeders')"
persistent-hint
active
prepend-inner-icon="mdi-account-group"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.publish_time"
:label="t('customRule.field.publishTime')"
:placeholder="t('customRule.placeholder.publishTime')"
:hint="t('customRule.hint.publishTime')"
persistent-hint
active
prepend-inner-icon="mdi-calendar-clock"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">{{
t('customRule.action.confirm')
}}</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openRuleInfoDialog"
>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<h5 class="app-card-summary__title text-h6">{{ props.rule.name }}</h5>
<div class="app-card-summary__subtitle text-body-1">{{ props.rule.id }}</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg ref="imageRef" :src="filter_svg" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>
</template>

View File

@@ -5,8 +5,20 @@ import { nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { storageRemoteDict } from '@/api/constants'
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253'
const STORAGE_ACCENT_COLOR_MAP = {
local: '#FFB400',
alipan: '#00A7F2',
u115: '#17B26A',
rclone: '#6675FF',
alist: '#12B8D7',
smb: '#3B82F6',
}
// 国际化
const { t } = useI18n()
const downloadAccentRgb = ref(DEFAULT_DIRECTORY_ACCENT_RGB)
const libraryAccentRgb = ref(DEFAULT_DIRECTORY_ACCENT_RGB)
// 输入参数
const props = defineProps({
@@ -63,6 +75,47 @@ const transferSourceItems = computed(() => [
{ title: t('directory.manualTransfer'), value: 'manual' },
])
function hasKnownStorageType(storageType?: string): storageType is keyof typeof STORAGE_ACCENT_COLOR_MAP {
return !!storageType && Object.prototype.hasOwnProperty.call(STORAGE_ACCENT_COLOR_MAP, storageType)
}
function hexToRgbString(hexColor: string) {
const normalizedColor = hexColor.replace('#', '')
const colorValue = Number.parseInt(normalizedColor, 16)
if (Number.isNaN(colorValue) || normalizedColor.length !== 6) return DEFAULT_DIRECTORY_ACCENT_RGB
return `${(colorValue >> 16) & 255}, ${(colorValue >> 8) & 255}, ${colorValue & 255}`
}
function getCustomStoragePaletteColor(storageType?: string) {
const customStorageIndex = Math.max(Number(storageType?.match(/\d+$/)?.[0] ?? 1) - 1, 0)
const customStorageColors = ['#F97316', '#8B5CF6', '#06B6D4', '#84CC16', '#EC4899', '#14B8A6']
return customStorageColors[customStorageIndex % customStorageColors.length]
}
function getStorageAccentColor(storageType?: string) {
if (hasKnownStorageType(storageType)) return STORAGE_ACCENT_COLOR_MAP[storageType]
// 自定义存储没有固定品牌图标,使用离散调色板,保证连续 custom1/custom2 也能明显区分。
return getCustomStoragePaletteColor(storageType)
}
// 目录卡片用下载存储和媒体库存储两端的图标主色生成轻渐变,体现整理链路的两个存储端点。
const directoryAccentStyle = computed(() => ({
'--app-card-accent-rgb': downloadAccentRgb.value,
'--app-card-accent-end-rgb': libraryAccentRgb.value,
}))
function updateDirectoryAccentColors() {
const downloadStorage = props.directory.storage
const libraryStorage = props.directory.library_storage || props.directory.storage
downloadAccentRgb.value = hexToRgbString(getStorageAccentColor(downloadStorage))
libraryAccentRgb.value = hexToRgbString(getStorageAccentColor(libraryStorage))
}
// 监控模式下拉字典
const MonitorModeItems = computed(() => [
{ title: t('directory.performanceMode'), value: 'fast' },
@@ -168,6 +221,15 @@ watch(
{ immediate: true },
)
// 存储类型切换后主动重新提取图标色,避免图片缓存导致 load 事件不触发。
watch(
[() => props.directory.storage, () => props.directory.library_storage],
() => {
updateDirectoryAccentColors()
},
{ immediate: true },
)
// 媒体类别和类型变更非空时将按类型分类和按类别分类置为false
watch(
[() => props.directory.media_type, () => props.directory.media_category],
@@ -195,7 +257,13 @@ watch(
</script>
<template>
<VCard variant="tonal" class="app-card-shell" :width="props.width" :height="props.height">
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="directoryAccentStyle"
:width="props.width"
:height="props.height"
>
<VDialogCloseBtn @click="onClose" />
<VCardItem>
<VTextField

View File

@@ -1,22 +1,20 @@
<script setup lang="ts">
import api from '@/api'
import { formatFileSize } from '@/@core/utils/formatters'
import { DownloaderConf } from '@/api/types'
import { useToast } from 'vue-toastification'
import type { DownloaderInfo } from '@/api/types'
import type { DownloaderConf, DownloaderInfo } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { downloaderDict, storageAttributes } from '@/api/constants'
import { useDisplay } from 'vuetify'
import { downloaderDict } from '@/api/constants'
import { useBackground } from '@/composables/useBackground'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
// 显示器宽度
const display = useDisplay()
const DownloaderInfoDialog = defineAsyncComponent(() => import('@/components/dialog/DownloaderInfoDialog.vue'))
// 获取i18n实例
const { t } = useI18n()
const { useConditionalDataRefresh } = useBackground()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor()
// 定义输入
const props = defineProps({
@@ -40,98 +38,18 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['close', 'done', 'change'])
// 提示框
const $toast = useToast()
// 上传速率
const upload_rate = ref(0)
// 下载速度
const download_rate = ref(0)
// 下载器详情弹窗
const downloaderInfoDialog = ref(false)
// 表单
const downloaderForm = ref()
// 路径前缀选项
const prefixOptions = computed(() => {
return storageAttributes.map(item => ({
title: t(`storage.${item.type}`),
value: item.type,
}))
})
function getStorageType(path: string) {
if (!path) return 'local'
// 查找匹配的存储类型
const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))
return storage?.type || 'local'
}
function storage2Prefix(storage: string) {
return storage === 'local' ? '' : storage + ':'
}
// 获取存储路径前后缀
function parseStoragePath(path: string): [prefix: string, suffix: string] {
if (!path) return ['', '']
const storage = getStorageType(path)
const prefix = storage2Prefix(storage)
return [prefix, path.slice(prefix.length)]
}
// 更新存储路径前缀
function updateStoragePrefix(row: PathMappingRow, storage: string) {
const [, currentSuffix] = parseStoragePath(row.storage)
const prefix = storage2Prefix(storage)
row.storage = prefix + currentSuffix
}
// 更新存储路径后缀
function updateStorageSuffix(row: PathMappingRow, suffix: string) {
const [currentPrefix] = parseStoragePath(row.storage)
row.storage = currentPrefix + suffix
}
const pathValidationRules = [
(v: string) => !!v || t('downloader.pathMappingRequired'),
(v: string) => v.startsWith('/') || t('downloader.pathMappingError'),
]
// 下载器详情
const downloaderInfo = ref<DownloaderConf>({
name: '',
type: '',
default: false,
enabled: false,
config: {},
path_mapping: [],
})
// 路径映射行定义
interface PathMappingRow {
id: string
storage: string
download: string
}
// 路径映射行数据
const pathMappingRows = ref<PathMappingRow[]>([])
// 生成随机ID
function generateId() {
return Math.random().toString(36).substring(2, 9)
}
// 下载器是否应该刷新数据的计算属性
const shouldRefresh = computed(() => props.allowRefresh && props.downloader.enabled)
// 调用API查询下载器数据
/** 调用 API 查询下载器实时速率数据。 */
async function loadDownloaderInfo() {
if (!shouldRefresh.value) {
// 当下载器被禁用时,重置速率数据
upload_rate.value = 0
download_rate.value = 0
return
@@ -152,51 +70,20 @@ async function loadDownloaderInfo() {
}
}
// 打开详情弹窗
/** 打开共享下载器配置弹窗。 */
function openDownloaderInfoDialog() {
// 深复制
downloaderInfo.value = cloneDeep(props.downloader)
// 初始化路径映射行数据
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
id: generateId(),
storage: item[0],
download: item[1],
}))
downloaderInfoDialog.value = true
}
// 保存详情数据
async function saveDownloaderInfo() {
// 表单校验
const { valid } = await downloaderForm.value?.validate()
if (!valid) return
// 同步路径映射数据
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
// 为空不保存,跳出警告框
if (!downloaderInfo.value.name) {
$toast.error(t('downloader.nameRequired'))
return
}
// 重名判断
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
$toast.error(t('downloader.nameDuplicate'))
return
}
// 默认下载器去重
if (downloaderInfo.value.default) {
props.downloaders.forEach(item => {
if (item.default && item !== props.downloader) {
item.default = false
$toast.info(t('downloader.defaultChanged'))
}
})
}
// 执行保存
downloaderInfoDialog.value = false
emit('change', downloaderInfo.value, props.downloader.name)
emit('done')
openSharedDialog(
DownloaderInfoDialog,
{
downloader: props.downloader,
downloaders: props.downloaders,
},
{
change: (...args: unknown[]) => emit('change', ...args),
done: () => emit('done'),
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 根据存储类型选择图标
@@ -213,21 +100,7 @@ const getIcon = computed(() => {
}
})
// 添加路径映射
function addPathMapping() {
pathMappingRows.value.push({
id: generateId(),
storage: '',
download: '',
})
}
// 移除路径映射
function removePathMapping(index: number) {
pathMappingRows.value.splice(index, 1)
}
// 按钮点击
/** 关闭下载器卡片。 */
function onClose() {
emit('close')
}
@@ -236,9 +109,9 @@ function onClose() {
const { stop: stopRefresh } = useConditionalDataRefresh(
`downloader-${props.downloader.name}`,
loadDownloaderInfo,
shouldRefresh, // 响应式条件只有当allowRefresh为true且downloader启用时才运行
3000, // 3秒间隔
true, // 立即执行一次
shouldRefresh,
3000,
true,
)
onUnmounted(() => {
@@ -247,379 +120,44 @@ onUnmounted(() => {
</script>
<template>
<div>
<VHover v-slot="hover">
<VCard
v-bind="hover.props"
variant="tonal"
class="app-card-shell"
@click="openDownloaderInfoDialog"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<VDialogCloseBtn @click="onClose" />
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VCardText class="app-card-summary app-card-summary--double-action">
<div class="app-card-summary__content">
<div class="app-card-summary__title-row">
<VBadge
v-if="props.downloader.default && props.downloader.enabled"
dot
inline
color="success"
class="me-1"
/>
<span class="app-card-summary__title text-h6">{{ downloader.name }}</span>
</div>
<div
v-if="downloaderDict[downloader.type] && props.downloader.enabled"
class="app-card-summary__meta text-sm"
>
<span class="app-card-summary__meta-item">{{ `${formatFileSize(upload_rate, 1)}/s` }}</span>
<span class="app-card-summary__meta-item">{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
</div>
<div v-else-if="!downloaderDict[downloader.type]" class="app-card-summary__subtitle text-sm">
自定义下载器
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>
</VHover>
<VDialog
v-if="downloaderInfoDialog"
v-model="downloaderInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
<VHover v-slot="hover">
<VCard
v-bind="hover.props"
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openDownloaderInfoDialog"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-download" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.downloader.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm ref="downloaderForm">
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.default"
:label="t('downloader.default')"
:disabled="!downloaderInfo.enabled"
/>
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="downloaderInfo.config.apikey"
type="password"
:label="t('downloader.apiKey')"
:hint="t('downloader.qbittorrentApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.category"
:label="t('downloader.category')"
:hint="t('downloader.category')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.sequentail"
:label="t('downloader.sequentail')"
:hint="t('downloader.sequentail')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.force_resume"
:label="t('downloader.force_resume')"
:hint="t('downloader.force_resume')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.first_last_piece"
:label="t('downloader.first_last_piece')"
:hint="t('downloader.first_last_piece')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'transmission'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port/RPC2"
:hint="t('downloader.rtorrentHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.type"
:label="t('downloader.type')"
:hint="t('downloader.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:hint="t('downloader.nameRequired')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VDivider class="my-2">
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
</VDivider>
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
</div>
<VCard v-for="(row, index) in pathMappingRows" :key="row.id" variant="outlined" class="my-2">
<VCardText class="pa-3">
<VRow align="center" no-gutters>
<VCol cols="12" class="mb-2">
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
</div>
<VRow no-gutters>
<VCol cols="12" sm="4" class="pe-2">
<VSelect
:model-value="getStorageType(row.storage)"
:items="prefixOptions"
density="compact"
variant="outlined"
hide-details
@update:model-value="v => updateStoragePrefix(row, v)"
/>
</VCol>
<VCol cols="12" sm="8">
<VTextField
:model-value="parseStoragePath(row.storage)[1]"
:placeholder="'/path/to/storage'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
@update:model-value="v => updateStorageSuffix(row, v)"
/>
</VCol>
</VRow>
</VCol>
<VCol cols="12" class="mb-1">
<div class="d-flex align-center justify-center my-1">
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
</div>
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
</div>
<VTextField
v-model="row.download"
:placeholder="'/path/to/download'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
/>
</VCol>
<VCol cols="12" class="d-flex justify-end pt-1">
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</VCol>
</VRow>
</VCardText>
</VCard>
<VBtn
variant="tonal"
color="primary"
prepend-icon="mdi-plus-circle-outline"
@click="addPathMapping"
class="mt-1"
size="small"
>
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
<VDialogCloseBtn @click="onClose" />
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VCardText class="app-card-summary app-card-summary--double-action">
<div class="app-card-summary__content">
<div class="app-card-summary__title-row">
<VBadge
v-if="props.downloader.default && props.downloader.enabled"
dot
inline
color="success"
class="me-1"
/>
<span class="app-card-summary__title text-h6">{{ downloader.name }}</span>
</div>
<div v-if="downloaderDict[downloader.type] && props.downloader.enabled" class="app-card-summary__meta text-sm">
<span class="app-card-summary__meta-item">{{ `${formatFileSize(upload_rate, 1)}/s` }}</span>
<span class="app-card-summary__meta-item">{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
</div>
<div v-else-if="!downloaderDict[downloader.type]" class="app-card-summary__subtitle text-sm">
{{ t('setting.system.custom') }}
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>
</VHover>
</template>

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

@@ -28,19 +28,18 @@ function filtersChanged(value: string[]) {
}
// 过滤规则下拉框
const selectFilterOptions = ref<{ [key: string]: string }[]>([])
onMounted(() => {
selectFilterOptions.value = cloneDeep(innerFilterRules)
if (props.custom_rules) {
console.log(props.custom_rules)
props.custom_rules.map(rule => {
selectFilterOptions.value.push({
title: rule.name,
value: rule.id,
})
// 同时包含内置规则与用户自定义规则;使用 computed 而非 onMounted 一次性赋值,
// 是为了在父组件异步加载完 custom_rules 或后续新增/删除规则时,
// 选项与已选 chip 的显示名title能跟随刷新避免回退到原始 ID如 "zhong")。
const selectFilterOptions = computed<{ [key: string]: string }[]>(() => {
const options = cloneDeep(innerFilterRules)
props.custom_rules?.forEach(rule => {
options.push({
title: rule.name,
value: rule.id,
})
}
})
return options
})
</script>

View File

@@ -1,22 +1,15 @@
<script lang="ts" setup>
import { copyToClipboard } from '@/@core/utils/navigator'
import { CustomRule, FilterRuleGroup } from '@/api/types'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import { useToast } from 'vue-toastification'
import type { CustomRule, FilterRuleGroup } from '@/api/types'
import filter_group_svg from '@images/svg/filter-group.svg'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
// 显示器宽度
const display = useDisplay()
const FilterRuleGroupInfoDialog = defineAsyncComponent(() => import('@/components/dialog/FilterRuleGroupInfoDialog.vue'))
// 获取i18n实例
const { t } = useI18n()
// 规则组详情弹窗内才需要拖拽和导入代码,避免规则组卡片列表首屏带入重交互依赖。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const ImportCodeDialog = defineAsyncComponent(() => import('@/components/dialog/ImportCodeDialog.vue'))
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#8A8D93')
// 输入参数
const props = defineProps({
@@ -39,287 +32,57 @@ const props = defineProps({
custom_rules: Array as PropType<CustomRule[]>,
})
// 规则卡片类型
interface FilterCard {
// 优先级
pri: string
// 已选规则
rules: string[]
}
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 规则详情弹窗
const groupInfoDialog = ref(false)
// 规则详情
const groupInfo = ref<FilterRuleGroup>({
name: props.group?.name ?? '',
rule_string: props.group?.rule_string ?? '',
media_type: props.group?.media_type ?? '',
category: props.group?.category ?? '',
})
// 媒体类型字典
const mediaTypeItems = [
{ title: t('common.all'), value: '' },
{ title: t('mediaType.movie'), value: '电影' },
{ title: t('mediaType.tv'), value: '电视剧' },
]
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: t('common.all'), value: '' }]
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
return default_value
}
return default_value.concat(props.categories[groupInfo.value.media_type] || [])
})
// 规则组规则卡片列表
const filterRuleCards = ref<FilterCard[]>([])
// 导入代码弹窗
const importCodeDialog = ref(false)
// 导入代码类型
const importCodeType = ref('')
// 更新规则卡片的值
function updateFilterCardValue(pri: string, rules: string[]) {
const card = filterRuleCards.value.find(card => card.pri === pri)
if (card && Array.isArray(rules)) card.rules = rules
/** 打开共享过滤规则组配置弹窗。 */
function openGroupInfoDialog() {
openSharedDialog(
FilterRuleGroupInfoDialog,
{
group: props.group,
groups: props.groups,
categories: props.categories,
custom_rules: props.custom_rules,
},
{
change: (...args: unknown[]) => emit('change', ...args),
done: () => emit('done'),
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 移除卡片
function filterCardClose(pri: string) {
filterRuleCards.value = filterRuleCards.value
.filter(card => card.pri !== pri)
.map((card, index) => {
card.pri = (index + 1).toString()
return card
})
}
// 分享规则
async function shareRules() {
if (filterRuleCards.value.length === 0) return
const value = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
try {
let success
success = copyToClipboard(value)
if (await success) $toast.success(t('filterRule.shareSuccess'))
else $toast.error(t('filterRule.shareFailed'))
} catch (error) {
$toast.error(t('filterRule.shareFailed'))
console.error(error)
}
}
// 导入规则
async function importRules(ruleType: string) {
importCodeType.value = ruleType
importCodeDialog.value = true
}
// 保存导入的代码,直接覆盖原有值
function saveCodeString(type: string, code: any) {
try {
code = code.value
if (type === 'priority') {
// 解析值
if (!code) return
// 首尾增加空格
if (!code.startsWith(' ')) code = ` ${code}`
if (!code.endsWith(' ')) code = `${code} `
const groups = code.split('>')
filterRuleCards.value = groups.map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
}
} catch (error) {
$toast.error(t('filterRule.importFailed'))
console.error(error)
}
}
// 增加卡片
function addFilterCard() {
const pri = (filterRuleCards.value.length + 1).toString()
const newCard: FilterCard = { pri, rules: [] }
filterRuleCards.value.push(newCard)
}
// 根据列表的拖动顺序更新优先级
function dragOrderEnd() {
filterRuleCards.value.forEach((card, index) => {
card.pri = (index + 1).toString()
})
}
// 打开详情弹窗
function opengroupInfoDialog() {
groupInfo.value = cloneDeep(props.group)
if (props.group.rule_string) {
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
}
groupInfoDialog.value = true
}
// 保存详情数据
function saveGroupInfo() {
if (!groupInfo.value.name.trim()) {
$toast.error(t('filterRule.nameRequired'))
return
}
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
$toast.error(t('filterRule.nameDuplicate'))
return
}
groupInfoDialog.value = false
groupInfo.value.rule_string = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
emit('change', groupInfo.value, props.group.name)
emit('done')
}
// 按钮点击
/** 关闭过滤规则组卡片。 */
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" class="app-card-shell" @click="opengroupInfoDialog">
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<h5 class="app-card-summary__title text-h6">{{ props.group.name }}</h5>
<div class="app-card-summary__subtitle text-body-1">
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
<span v-else>{{ props.group.category }}</span>
</div>
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openGroupInfoDialog"
>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<h5 class="app-card-summary__title text-h6">{{ props.group.name }}</h5>
<div class="app-card-summary__subtitle text-body-1">
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
<span v-else>{{ props.group.category }}</span>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="filter_group_svg" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>
<VDialog
v-if="groupInfoDialog"
v-model="groupInfoDialog"
scrollable
max-width="80rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`">
<VDialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardItem class="pt-1">
<VRow class="mt-1">
<VCol cols="12" md="6">
<VTextField
v-model="groupInfo.name"
:label="t('filterRule.groupName')"
:placeholder="t('filterRule.nameRequired')"
:hint="t('filterRule.groupName')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="6" md="3">
<VAutocomplete
v-model="groupInfo.media_type"
:label="t('filterRule.mediaType')"
:items="mediaTypeItems"
:hint="t('filterRule.mediaType')"
persistent-hint
active
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="6" md="3">
<VAutocomplete
v-model="groupInfo.category"
:items="getCategories"
:label="t('filterRule.category')"
:hint="t('filterRule.category')"
persistent-hint
active
prepend-inner-icon="mdi-folder-open"
/>
</VCol>
</VRow>
</VCardItem>
<VCardText>
<Draggable
v-model="filterRuleCards"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="dragOrderEnd"
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
>
<template #item="{ element }">
<FilterRuleCard
:pri="element.pri"
:maxpri="filterRuleCards.length.toString()"
:rules="element.rules"
:custom_rules="props.custom_rules"
@changed="updateFilterCardValue"
@close="filterCardClose(element.pri)"
/>
</template>
</Draggable>
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn color="primary" @click="addFilterCard">
<VIcon icon="mdi-plus" />
</VBtn>
<VBtn color="success" @click="importRules('priority')">
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" @click="shareRules">
<VIcon icon="mdi-share" />
</VBtn>
<VSpacer />
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"
:title="t('filterRule.import')"
:dataType="importCodeType"
@close="importCodeDialog = false"
@save="saveCodeString"
/>
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg ref="imageRef" :src="filter_group_svg" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>
</template>

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'
@@ -8,12 +8,10 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
import router from '@/router'
import { useUserStore, useGlobalSettingsStore } from '@/stores'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
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,
getCachedMediaSubscribeStatus,
@@ -21,6 +19,10 @@ import {
setCachedMediaSubscribeStatus,
} from '@/utils/mediaStatusCache'
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
const SubscribeSeasonDialog = defineAsyncComponent(() => import('../dialog/SubscribeSeasonDialog.vue'))
// 国际化
const { t } = useI18n()
@@ -43,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()
@@ -59,15 +64,6 @@ const isSubscribed = ref(false)
// 本地存在状态
const isExists = ref(false)
// 订阅季弹窗
const subscribeSeasonDialog = ref(false)
// 订阅编辑弹窗
const subscribeEditDialog = ref(false)
// 订阅ID
const subscribeId = ref<number>()
// 选中的订阅季
const seasonsSelected = ref<MediaSeason[]>([])
@@ -93,12 +89,48 @@ const selectedSites = ref<number[]>([])
// 搜索菜单显示状态
const searchMenuShow = ref(false)
// 选择站点对话框
const chooseSiteDialog = ref(false)
// 选择的剧集组
const episodeGroup = ref('')
// 打开订阅季选择弹窗,避免每个媒体卡片都持有弹窗实例。
function openSubscribeSeasonDialog() {
openSharedDialog(
SubscribeSeasonDialog,
{ media: props.media },
{
subscribe: subscribeSeasons,
},
{ closeOn: ['close', 'subscribe'] },
)
}
// 打开订阅编辑弹窗,保存、关闭或删除时释放共享弹窗实例。
function openSubscribeEditDialog(subid: number) {
openSharedDialog(
SubscribeEditDialog,
{ subid },
{
remove: onRemoveSubscribe,
},
{ closeOn: ['close', 'save', 'remove'] },
)
}
// 打开站点选择弹窗,并把选择结果交回当前媒体卡片继续搜索。
function openSearchSiteDialog() {
openSharedDialog(
SearchSiteDialog,
{
sites: allSites.value,
selected: selectedSites.value,
},
{
search: searchSites,
},
{ closeOn: ['close', 'search'] },
)
}
// 查询所有站点
async function querySites() {
try {
@@ -114,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)
@@ -157,7 +189,7 @@ async function handleAddSubscribe() {
if (props.media?.type === '电视剧') {
// 弹出季选择列表,支持多选
seasonsSelected.value = []
subscribeSeasonDialog.value = true
openSubscribeSeasonDialog()
} else {
// 电影
addSubscribe()
@@ -199,8 +231,7 @@ async function addSubscribe(season: number | null = null, best_version: number =
if (result.success && seasonsSelected.value.length <= 1) {
const show_edit_dialog = await queryDefaultSubscribeConfig()
if (show_edit_dialog) {
subscribeId.value = result.data.id
subscribeEditDialog.value = true
openSubscribeEditDialog(result.data.id)
}
}
} catch (error) {
@@ -308,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) {
@@ -330,7 +360,6 @@ function handleSubscribe() {
// 订阅多季
function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {
subscribeSeasonDialog.value = false
episodeGroup.value = groudId
seasonsSelected.value = seasons || []
seasonsSelected.value.forEach(season => {
@@ -375,7 +404,7 @@ async function clickSearch() {
await querySelectedSites()
}
if (allSites.value?.length > 0) {
chooseSiteDialog.value = true
openSearchSiteDialog()
} else {
handleSearch()
}
@@ -399,7 +428,6 @@ function handleSearch() {
// 搜索多站点
function searchSites(sites: number[]) {
chooseSiteDialog.value = false
selectedSites.value = sites
handleSearch()
}
@@ -438,18 +466,12 @@ function setupIntersectionObserver() {
const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
})
// 移除订阅
function onRemoveSubscribe() {
subscribeEditDialog.value = false
isSubscribed.value = false
}
// 获取媒体类型文本
@@ -514,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"
@@ -522,6 +544,7 @@ onBeforeUnmount(() => {
/>
<VSpacer />
<IconBtn
v-if="canSubscribe"
icon="mdi-heart"
:color="isSubscribed ? 'error' : 'white'"
size="small"
@@ -554,6 +577,7 @@ onBeforeUnmount(() => {
<!--来源图标-->
<VAvatar
size="24"
variant="plain"
density="compact"
class="absolute bottom-1 right-1"
tile
@@ -565,32 +589,6 @@ onBeforeUnmount(() => {
</div>
</template>
</VHover>
<!-- 订阅季弹窗 -->
<subscribeSeasonDialog
v-if="subscribeSeasonDialog"
v-model="subscribeSeasonDialog"
:media="media"
@subscribe="subscribeSeasons"
@close="subscribeSeasonDialog = false"
/>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="onRemoveSubscribe"
/>
<!-- 站点选择对话框 -->
<SearchSiteDialog
v-if="chooseSiteDialog"
v-model="chooseSiteDialog"
:sites="allSites"
:selected="selectedSites"
@search="searchSites"
@close="chooseSiteDialog = false"
/>
</template>
<style scoped>
.media-card-title {

View File

@@ -1,18 +1,17 @@
<script setup lang="ts">
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
import { useToast } from 'vue-toastification'
import { getLogoUrl } from '@/utils/imageUtils'
import api from '@/api'
import { cloneDeep } from 'lodash-es'
import type { MediaServerConf, MediaStatistic } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { useI18n } from 'vue-i18n'
import { mediaServerDict } from '@/api/constants'
import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
// 显示器宽度
const display = useDisplay()
const MediaServerInfoDialog = defineAsyncComponent(() => import('@/components/dialog/MediaServerInfoDialog.vue'))
// 获取i18n实例
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#56CA00')
// 定义输入
const props = defineProps({
@@ -28,9 +27,6 @@ const props = defineProps({
},
})
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'done', 'change'])
@@ -53,67 +49,20 @@ const infoItems = ref([
},
])
// 同步媒体库选项
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
{
title: t('common.all'),
value: 'all',
},
])
const ugreenScanModeOptions = computed(() => [
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
])
// 媒体服务器详情弹窗
const mediaServerInfoDialog = ref(false)
// 媒体服务器详情
const mediaServerInfo = ref<MediaServerConf>({
name: '',
type: '',
enabled: false,
config: {},
})
// 打开详情弹窗
/** 打开共享媒体服务器配置弹窗。 */
function openMediaServerInfoDialog() {
loadLibrary(props.mediaserver.name)
// 深复制
mediaServerInfo.value = cloneDeep(props.mediaserver)
if (mediaServerInfo.value.type === 'ugreen') {
mediaServerInfo.value.config = mediaServerInfo.value.config || {}
if (!mediaServerInfo.value.config.scan_mode) {
mediaServerInfo.value.config.scan_mode = 'supplement_missing'
}
if (mediaServerInfo.value.config.verify_ssl === undefined) {
mediaServerInfo.value.config.verify_ssl = true
}
}
mediaServerInfoDialog.value = true
if (!props.mediaserver.sync_libraries) {
mediaServerInfo.value.sync_libraries = ['all']
}
}
// 保存详情数据
function saveMediaServerInfo() {
// 为空不保存,跳出警告框
if (!mediaServerInfo.value.name) {
$toast.error(t('common.nameRequired'))
return
}
// 重名判断
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
$toast.error(t('common.nameExists', { name: mediaServerInfo.value.name }))
return
}
// 执行保存
mediaServerInfoDialog.value = false
emit('change', mediaServerInfo.value, props.mediaserver.name)
emit('done')
openSharedDialog(
MediaServerInfoDialog,
{
mediaserver: props.mediaserver,
mediaservers: props.mediaservers,
},
{
change: (...args: unknown[]) => emit('change', ...args),
done: () => emit('done'),
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 根据存储类型选择图标
@@ -136,12 +85,12 @@ const getIcon = computed(() => {
}
})
// 按钮点击
/** 关闭媒体服务器卡片。 */
function onClose() {
emit('close')
}
// 调用API加载媒体统计数据
/** 调用 API 加载媒体服务器统计数据。 */
async function loadMediaStatistic() {
try {
const res: MediaStatistic = await api.get('dashboard/statistic', {
@@ -174,529 +123,38 @@ async function loadMediaStatistic() {
}
}
// 调用API查询媒体库
async function loadLibrary(server: string) {
try {
const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })
if (result && result.length > 0) {
librariesOptions.value = result.map(item => ({
title: item.name,
value: item.id?.toString(),
}))
} else {
librariesOptions.value = []
}
librariesOptions.value.unshift({
title: t('common.all'),
value: 'all',
})
} catch (e) {
console.log(e)
}
}
onMounted(() => {
loadMediaStatistic()
})
</script>
<template>
<div>
<VCard variant="tonal" class="app-card-shell" @click="openMediaServerInfoDialog">
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--single-action">
<div class="app-card-summary__content">
<div class="app-card-summary__title text-h6">{{ mediaserver.name }}</div>
<div
v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled"
class="grid min-h-6 grid-cols-3 gap-2 text-sm text-medium-emphasis"
>
<span v-for="item in infoItems" :key="item.title" class="flex min-w-0 items-center">
<VIcon rounded :icon="item.avatar" class="me-1 shrink-0" />
<span class="truncate">{{ item.amount }}</span>
</span>
</div>
<div v-else-if="!mediaServerDict[mediaserver.type]" class="app-card-summary__subtitle text-sm">
自定义媒体服务器
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>
<VDialog
v-if="mediaServerInfoDialog"
v-model="mediaServerInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-cog" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.mediaserver.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="mediaServerInfo.enabled" :label="t('mediaserver.enableMediaServer')" />
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'emby'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
:hint="t('mediaserver.usernameHint')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.embyApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'zspace'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
:hint="t('mediaserver.usernameHint')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'jellyfin'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.jellyfinApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'trimemedia'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'ugreen'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="mediaServerInfo.config.scan_mode"
:label="t('mediaserver.scanMode')"
:items="ugreenScanModeOptions"
:hint="t('mediaserver.scanModeHint')"
persistent-hint
active
prepend-inner-icon="mdi-radar"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="mediaServerInfo.config.verify_ssl"
:label="t('mediaserver.verifySsl')"
:hint="t('mediaserver.verifySslHint')"
persistent-hint
color="primary"
inset
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'plex'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.token"
:label="t('mediaserver.plexToken')"
:hint="t('mediaserver.plexTokenHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.type"
:label="t('mediaserver.type')"
:hint="t('mediaserver.customTypeHint')"
persistent-hint
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
:label="t('common.name')"
:hint="t('mediaserver.nameRequired')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
<template>
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openMediaServerInfoDialog"
>
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--single-action">
<div class="app-card-summary__content">
<div class="app-card-summary__title text-h6">{{ mediaserver.name }}</div>
<div
v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled"
class="grid min-h-6 grid-cols-3 gap-2 text-sm text-medium-emphasis"
>
<span v-for="item in infoItems" :key="item.title" class="flex min-w-0 items-center">
<VIcon rounded :icon="item.avatar" class="me-1 shrink-0" />
<span class="truncate">{{ item.amount }}</span>
</span>
</div>
<div v-else-if="!mediaServerDict[mediaserver.type]" class="app-card-summary__subtitle text-sm">
{{ t('setting.system.custom') }}
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>
</template>

File diff suppressed because it is too large Load Diff

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,16 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import VersionHistory from '../misc/VersionHistory.vue'
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 ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
const PluginVersionHistoryDialog = defineAsyncComponent(
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
)
// 输入参数
const props = defineProps({
@@ -30,15 +32,6 @@ const backgroundColor = ref('#28A9E1')
// 图片对象
const imageRef = ref<any>()
// 提示框
const $toast = useToast()
// 进度框
const progressDialog = ref(false)
// 进度框文本
const progressText = ref('')
// 获取当前插件的标签
const pluginLabels = computed(() => {
if (!props.plugin?.plugin_label) return []
@@ -55,12 +48,6 @@ const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 更新日志弹窗
const releaseDialog = ref(false)
// 插件详情弹窗
const detailDialog = ref(false)
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
@@ -69,39 +56,6 @@ async function imageLoaded() {
backgroundColor.value = await getDominantColor(imageElement)
}
// 安装插件
async function installPlugin() {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = t('plugin.installing', {
name: props.plugin?.plugin_name,
version: 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,
},
})
// 隐藏等待提示框
progressDialog.value = false
if (result.success) {
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
detailDialog.value = false
// 通知父组件刷新
emit('install')
} else {
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
console.error(error)
}
}
// 计算图标路径
const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) return getLogoUrl('plugin')
@@ -142,11 +96,40 @@ function visitPluginPage() {
// 显示更新日志
function showUpdateHistory() {
releaseDialog.value = true
openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin },
{},
{ closeOn: ['close', 'update:modelValue'] },
)
}
/** 打开共享插件市场详情弹窗。 */
function showPluginDetail() {
openSharedDialog(
PluginMarketDetailDialog,
{
plugin: props.plugin,
count: props.count,
},
{
install: () => emit('install'),
},
{ closeOn: ['close', 'install', 'update:modelValue'] },
)
}
// 弹出菜单
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,
@@ -156,15 +139,6 @@ const dropdownItems = ref([
click: visitPluginPage,
},
},
{
title: t('plugin.updateHistory'),
value: 2,
show: !isNullOrEmptyObject(props.plugin?.history || {}),
props: {
prependIcon: 'mdi-update',
click: showUpdateHistory,
},
},
])
</script>
@@ -176,7 +150,7 @@ const dropdownItems = ref([
v-bind="hover.props"
:width="props.width"
:height="props.height"
@click="detailDialog = true"
@click="showPluginDetail"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
@@ -203,14 +177,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 }}
@@ -270,77 +244,27 @@ const dropdownItems = ref([
</VCard>
</template>
</VHover>
<!-- 安装插件进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" max-height="85vh" scrollable>
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
</VCard>
</VDialog>
<!-- 插件详情-->
<VDialog v-if="detailDialog" v-model="detailDialog" max-width="30rem">
<VCard>
<VDialogCloseBtn @click="detailDialog = false" />
<VCardText>
<VCol>
<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="iconPath"
aspect-ratio="4/3"
cover
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
<div class="flex-grow">
<VCardItem>
<VCardTitle class="text-center text-md-left">
{{ props.plugin?.plugin_name }}
</VCardTitle>
<VCardSubtitle
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ..."
>
{{ props.plugin?.plugin_desc }}
</VCardSubtitle>
<VList lines="one">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('common.version') }}</span>
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('common.author') }}</span>
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
{{ props.plugin?.plugin_author }}
</span>
</VListItemTitle>
</VListItem>
</VList>
<div class="text-center text-md-left">
<VBtn color="primary" @click="installPlugin" prepend-icon="mdi-download">{{
t('plugin.installToLocal')
}}</VBtn>
<div class="text-xs mt-2" v-if="props.count">
<VIcon icon="mdi-fire" />{{
t('plugin.totalDownloads', { count: formatDownloadCount(props.count) })
}}
</div>
</div>
</VCardItem>
</div>
</div>
</VCol>
</VCardText>
</VCard>
</VDialog>
</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,22 +3,20 @@ 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'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
import PluginDataDialog from '../dialog/PluginDataDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
const LoggingView = defineAsyncComponent(() => import('@/views/system/LoggingView.vue'))
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
const PluginDataDialog = defineAsyncComponent(() => import('../dialog/PluginDataDialog.vue'))
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
const PluginCloneDialog = defineAsyncComponent(() => import('../dialog/PluginCloneDialog.vue'))
const PluginLogDialog = defineAsyncComponent(() => import('../dialog/PluginLogDialog.vue'))
const PluginVersionHistoryDialog = defineAsyncComponent(() => import('../dialog/PluginVersionHistoryDialog.vue'))
// 输入参数
const props = defineProps({
@@ -39,6 +37,9 @@ const emit = defineEmits(['remove', 'save', 'actionDone'])
// 多语言
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 背景颜色
const backgroundColor = ref('#28A9E1')
@@ -54,24 +55,9 @@ const createConfirm = useConfirm()
// 本身是否可见
const isVisible = ref(true)
// 插件配置页面
const pluginConfigDialog = ref(false)
// 菜单显示状态
const menuVisible = ref(false)
// 进度框
const progressDialog = ref(false)
// 插件数据页面
const pluginInfoDialog = ref(false)
// 实时日志弹窗
const loggingDialog = ref(false)
// 进度框文本
const progressText = ref('正在更新插件...')
// 用户头像是否加载完成
const isAvatarLoaded = ref(false)
@@ -81,20 +67,20 @@ const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 更新日志弹窗
const releaseDialog = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
// 插件分身对话框
const pluginCloneDialog = ref(false)
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
function showPluginProgress(text: string) {
progressDialogController?.close()
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
}
// 插件分身表单
const cloneForm = ref({
suffix: '',
name: '',
description: '',
version: '',
icon: '',
})
/** 关闭当前插件操作进度弹窗。 */
function closePluginProgress() {
progressDialogController?.close()
progressDialogController = null
}
// 监听动作标识如为true则打开详情
watch(
@@ -116,13 +102,13 @@ async function imageLoaded() {
}
// 显示更新日志
function showUpdateHistory() {
// 检查当前版本是否有更新日志
if (isNullOrEmptyObject(props.plugin?.history)) {
updatePlugin()
} else {
releaseDialog.value = true
}
function showUpdateHistory(showUpdateAction: boolean = false) {
openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin, showUpdateAction },
{ update: updatePlugin },
{ closeOn: ['close', 'update', 'update:modelValue'] },
)
}
// 调用API卸载插件
@@ -136,11 +122,10 @@ async function uninstallPlugin() {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = t('plugin.uninstalling', { name: props.plugin?.plugin_name })
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
// 隐藏等待提示框
progressDialog.value = false
closePluginProgress()
if (result.success) {
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
@@ -155,21 +140,34 @@ async function uninstallPlugin() {
)
}
} catch (error) {
closePluginProgress()
console.error(error)
}
}
// 显示插件数据
async function showPluginInfo() {
pluginConfigDialog.value = false
pluginInfoDialog.value = true
openSharedDialog(
PluginDataDialog,
{ plugin: props.plugin },
{
switch: showPluginConfig,
},
{ closeOn: ['close', 'switch'] },
)
}
// 显示插件配置
async function showPluginConfig() {
// 显示对话框
pluginInfoDialog.value = false
pluginConfigDialog.value = true
openSharedDialog(
PluginConfigDialog,
{ plugin: props.plugin },
{
save: configDone,
switch: showPluginInfo,
},
{ closeOn: ['close', 'save', 'switch'] },
)
}
// 计算图标路径
@@ -222,11 +220,14 @@ async function resetPlugin() {
// 更新插件
async function updatePlugin() {
if (props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
try {
releaseDialog.value = false
// 显示等待提示框
progressDialog.value = true
progressText.value = t('plugin.updating', { name: props.plugin?.plugin_name })
showPluginProgress(t('plugin.updating', { name: props.plugin?.plugin_name }))
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
@@ -236,7 +237,7 @@ async function updatePlugin() {
})
// 隐藏等待提示框
progressDialog.value = false
closePluginProgress()
if (result.success) {
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
@@ -252,21 +253,107 @@ async function updatePlugin() {
)
}
} catch (error) {
closePluginProgress()
console.error(error)
}
}
// 访问作者主页
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
}
// 查看日志URL
function openLoggerWindow() {
const url = `${
import.meta.env.VITE_API_BASE_URL
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
window.open(url, '_blank')
/** 判断插件当前是否已经有可用的远程项目地址。 */
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()
}
// 打开插件详情
@@ -285,58 +372,61 @@ function handleCardClick() {
// 配置完成
function configDone() {
pluginConfigDialog.value = false
emit('save')
}
// 显示插件分身对话框
/** 显示插件分身共享弹窗。 */
function showPluginClone() {
cloneForm.value = {
suffix: '',
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
version: props.plugin?.plugin_version || '1.0',
icon: props.plugin?.plugin_icon || '',
}
pluginCloneDialog.value = true
cloneDialogController?.close()
cloneDialogController = openSharedDialog(
PluginCloneDialog,
{ plugin: props.plugin },
{ clone: executePluginClone },
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 执行插件分身
async function executePluginClone() {
if (!cloneForm.value.suffix.trim()) {
async function executePluginClone(cloneForm: { suffix: string; name: string; description: string; version: string; icon: string }) {
if (!cloneForm.suffix.trim()) {
$toast.error(t('plugin.suffixRequired'))
return
}
try {
progressDialog.value = true
progressText.value = t('plugin.cloning', { name: props.plugin?.plugin_name })
showPluginProgress(t('plugin.cloning', { name: props.plugin?.plugin_name }))
const result: { [key: string]: any } = await api.post(`plugin/clone/${props.plugin?.id}`, {
suffix: cloneForm.value.suffix.trim(),
name: cloneForm.value.name.trim(),
description: cloneForm.value.description.trim(),
version: cloneForm.value.version.trim(),
icon: cloneForm.value.icon.trim(),
suffix: cloneForm.suffix.trim(),
name: cloneForm.name.trim(),
description: cloneForm.description.trim(),
version: cloneForm.version.trim(),
icon: cloneForm.icon.trim(),
})
progressDialog.value = false
closePluginProgress()
if (result.success) {
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.value.name }))
pluginCloneDialog.value = false
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.name }))
cloneDialogController?.close()
cloneDialogController = null
// 通知父组件刷新
emit('remove')
} else {
$toast.error(t('plugin.cloneFailed', { message: result.message }))
}
} catch (error) {
progressDialog.value = false
closePluginProgress()
$toast.error(t('plugin.cloneFailedGeneral'))
console.error(error)
}
}
onUnmounted(() => {
closePluginProgress()
cloneDialogController?.close()
})
// 弹出菜单
const dropdownItems = ref([
{
@@ -374,7 +464,7 @@ const dropdownItems = ref([
props: {
prependIcon: 'mdi-arrow-up-circle-outline',
color: 'success',
click: showUpdateHistory,
click: () => showUpdateHistory(true),
},
},
{
@@ -397,6 +487,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,
@@ -404,17 +503,17 @@ const dropdownItems = ref([
props: {
prependIcon: 'mdi-file-document-outline',
click: () => {
loggingDialog.value = true
openSharedDialog(PluginLogDialog, { plugin: props.plugin }, {}, { closeOn: ['close', 'update:modelValue'] })
},
},
},
{
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,
},
},
])
@@ -425,6 +524,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
},
)
@@ -549,183 +651,6 @@ watch(
</template>
</VHover>
<!-- 插件配置页面 -->
<PluginConfigDialog
v-if="pluginConfigDialog"
v-model="pluginConfigDialog"
:plugin="props.plugin"
@save="configDone"
@close="pluginConfigDialog = false"
@switch="showPluginInfo"
/>
<!-- 插件数据页面 -->
<PluginDataDialog
v-if="pluginInfoDialog"
v-model="pluginInfoDialog"
:plugin="props.plugin"
@close="pluginInfoDialog = false"
@switch="showPluginConfig"
/>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable max-height="85vh">
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
<VDivider />
<VCardItem>
<VBtn @click="updatePlugin" block>
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
{{ t('plugin.updateToLatest') }}
</VBtn>
</VCardItem>
</VCard>
</VDialog>
<!-- 实时日志弹窗 -->
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="72rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="loggingDialog = false" />
<VCardItem>
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('plugin.logTitle') }}
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
<VChip color="grey-darken-1" size="small" class="ml-2">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>
</VDialog>
<!-- 插件分身对话框 -->
<VDialog
v-if="pluginCloneDialog"
v-model="pluginCloneDialog"
width="600"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-content-copy" class="me-2" />
</template>
<VCardTitle>{{ t('plugin.cloneTitle') }}</VCardTitle>
<VCardSubtitle>{{ t('plugin.cloneSubtitle', { name: props.plugin?.plugin_name }) }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="pluginCloneDialog = false" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.suffix"
:label="t('plugin.suffix') + ' *'"
:placeholder="t('plugin.suffixPlaceholder')"
:hint="t('plugin.suffixHint')"
persistent-hint
:rules="[
v => !!v || t('plugin.suffixRequired'),
v => /^[a-zA-Z0-9]+$/.test(v) || t('plugin.suffixFormatError'),
v => v.length <= 20 || t('plugin.suffixLengthError'),
]"
required
prepend-inner-icon="mdi-tag"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.name"
:label="t('plugin.cloneName')"
:placeholder="t('plugin.cloneNamePlaceholder')"
:hint="t('plugin.cloneNameHint')"
persistent-hint
prepend-inner-icon="mdi-rename-box"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="cloneForm.description"
:label="t('plugin.cloneDescriptionLabel')"
:placeholder="t('plugin.cloneDescriptionPlaceholder')"
:hint="t('plugin.cloneDescriptionHint')"
persistent-hint
prepend-inner-icon="mdi-text"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.version"
:label="t('plugin.cloneVersion')"
:placeholder="t('plugin.cloneVersionPlaceholder')"
:hint="t('plugin.cloneVersionHint')"
persistent-hint
prepend-inner-icon="mdi-numeric"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.icon"
:label="t('plugin.cloneIcon')"
:placeholder="t('plugin.cloneIconPlaceholder')"
:hint="t('plugin.cloneIconHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
<!-- 重要提醒 -->
<VCol cols="12">
<VAlert type="warning" variant="tonal" density="compact" class="mt-2" icon="mdi-alert-circle-outline">
<div class="text-body-2">
<strong>{{ t('common.notice') }}</strong
>{{ t('plugin.cloneNotice') }}
</div>
</VAlert>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn
color="primary"
@click="executePluginClone"
prepend-icon="mdi-content-copy"
class="px-5"
:disabled="!cloneForm.suffix.trim()"
>
{{ t('plugin.createClone') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -3,6 +3,10 @@ import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
const PluginFolderRenameDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderRenameDialog.vue'))
const PluginFolderSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderSettingsDialog.vue'))
// 文件夹配置接口
interface FolderConfig {
@@ -48,15 +52,7 @@ const createConfirm = useConfirm()
// 菜单显示状态
const menuVisible = ref(false)
// 重命名对话框
const renameDialog = ref(false)
// 设置对话框
const settingDialog = ref(false)
// 新名称
const newFolderName = ref('')
let renameDialogController: ReturnType<typeof openSharedDialog> | null = null
// 默认颜色
const defaultColor = '#2196F3'
@@ -66,104 +62,35 @@ const defaultIcon = 'mdi-folder'
const defaultGradient =
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8s) 100%)'
// 文件夹设置
const folderSettings = ref<FolderConfig>({
background: '',
icon: defaultIcon,
color: defaultColor,
gradient: defaultGradient,
showIcon: true,
})
// 计算背景图片
const backgroundImage = computed(() => {
return props.folderConfig.background || folderSettings.value.background
return props.folderConfig.background
})
// 预设图标选项
const iconOptions = [
'mdi-folder',
'mdi-folder-star',
'mdi-folder-heart',
'mdi-folder-cog',
'mdi-folder-music',
'mdi-folder-image',
'mdi-folder-video',
'mdi-folder-download',
'mdi-folder-network',
'mdi-folder-special',
]
// 预设颜色选项
const colorOptions = [
'#2196F3', // 蓝色
'#4CAF50', // 绿色
'#FF9800', // 橙色
'#9C27B0', // 紫色
'#F44336', // 红色
'#607D8B', // 蓝灰色
'#795548', // 棕色
'#E91E63', // 粉色
]
// 预设渐变选项
const gradientOptions = [
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(76, 175, 80, 0.7) 0%, rgba(76, 175, 80, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(255, 152, 0, 0.7) 0%, rgba(255, 152, 0, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(156, 39, 176, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(244, 67, 54, 0.7) 0%, rgba(244, 67, 54, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(96, 125, 139, 0.7) 0%, rgba(96, 125, 139, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(233, 30, 99, 0.7) 0%, rgba(233, 30, 99, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(63, 81, 181, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
]
// 计算背景渐变
const backgroundGradient = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.gradient || settings.gradient || gradientOptions[0]
return config.gradient || defaultGradient
})
// 计算图标
const folderIcon = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.icon || settings.icon || defaultIcon
return config.icon || defaultIcon
})
// 计算图标颜色
const iconColor = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.color || settings.color || defaultColor
return config.color || defaultColor
})
// 计算是否显示图标
const shouldShowIcon = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.showIcon !== undefined ? config.showIcon : settings.showIcon !== undefined ? settings.showIcon : true
return config.showIcon !== undefined ? config.showIcon : true
})
// 监听props变化更新本地设置
watch(
() => props.folderConfig,
newConfig => {
if (newConfig) {
folderSettings.value = {
...folderSettings.value,
...newConfig,
}
}
},
{ deep: true, immediate: true },
)
// 打开文件夹
function openFolder() {
emit('open', props.folderName)
@@ -177,27 +104,34 @@ function handleCardClick() {
openFolder()
}
// 重命名文件夹
/** 打开文件夹重命名共享弹窗。 */
function showRenameDialog() {
newFolderName.value = props.folderName || ''
renameDialog.value = true
renameDialogController?.close()
renameDialogController = openSharedDialog(
PluginFolderRenameDialog,
{ folderName: props.folderName },
{ rename: confirmRename },
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 确认重命名
async function confirmRename() {
if (!newFolderName.value.trim()) {
async function confirmRename(newFolderName: string) {
if (!newFolderName.trim()) {
$toast.error(t('folder.folderNameCannotBeEmpty'))
return
}
if (newFolderName.value === props.folderName) {
renameDialog.value = false
if (newFolderName === props.folderName) {
renameDialogController?.close()
renameDialogController = null
return
}
try {
emit('rename', props.folderName, newFolderName.value)
renameDialog.value = false
emit('rename', props.folderName, newFolderName)
renameDialogController?.close()
renameDialogController = null
} catch (error) {
console.error(error)
}
@@ -221,28 +155,24 @@ async function deleteFolder() {
// 显示设置对话框
function showSettingDialog() {
folderSettings.value = {
background: props.folderConfig?.background || '',
icon: props.folderConfig?.icon || defaultIcon,
color: props.folderConfig?.color || defaultColor,
gradient: props.folderConfig?.gradient || gradientOptions[0],
showIcon: props.folderConfig?.showIcon !== undefined ? props.folderConfig.showIcon : true,
}
settingDialog.value = true
openSharedDialog(
PluginFolderSettingsDialog,
{ folderConfig: props.folderConfig },
{ save: saveSettings },
{ closeOn: ['close', 'save', 'update:modelValue'] },
)
}
// 保存设置
function saveSettings() {
const config = {
...props.folderConfig,
...folderSettings.value,
}
function saveSettings(config: FolderConfig) {
emit('update-config', props.folderName, config)
settingDialog.value = false
$toast.success(t('folder.folderSettingsSaved'))
}
onUnmounted(() => {
renameDialogController?.close()
})
// 弹出菜单
const dropdownItems = ref([
{
@@ -361,139 +291,6 @@ const dropdownItems = ref([
</VCard>
</template>
</VHover>
<!-- 重命名对话框 -->
<VDialog v-if="renameDialog" v-model="renameDialog" max-width="400">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-pencil" class="me-2" />
</template>
<VCardTitle>{{ t('folder.renameFolder') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="renameDialog = false" />
<VDivider />
<VCardText>
<VTextField
v-model="newFolderName"
:label="t('folder.folderName')"
variant="outlined"
autofocus
@keyup.enter="confirmRename"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 设置对话框 -->
<VDialog
v-if="settingDialog"
v-model="settingDialog"
max-width="600"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="settingDialog = false" />
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-palette" class="mr-2" />
{{ t('folder.folderAppearanceSettings') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<!-- 显示图标开关 -->
<VCol cols="12">
<VSwitch
v-model="folderSettings.showIcon"
:label="t('folder.showFolderIcon')"
color="primary"
hide-details
/>
</VCol>
<!-- 图标选择 -->
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.icon') }}</VCardSubtitle>
<div class="icon-grid">
<VBtn
v-for="icon in iconOptions"
icon
:key="icon"
:variant="folderSettings.icon === icon ? 'tonal' : 'text'"
:color="folderSettings.icon === icon ? 'primary' : 'default'"
size="large"
class="ma-1"
@click="folderSettings.icon = icon"
>
<VIcon :icon="icon" size="24" />
</VBtn>
</div>
</VCol>
<!-- 颜色选择 -->
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.iconColor') }}</VCardSubtitle>
<div class="color-grid">
<VBtn
v-for="color in colorOptions"
:key="color"
:variant="folderSettings.color === color ? 'tonal' : 'text'"
:color="color"
size="large"
class="ma-1 color-btn"
:style="{ backgroundColor: color }"
@click="folderSettings.color = color"
>
<VIcon v-if="folderSettings.color === color" icon="mdi-check" color="white" />
</VBtn>
</div>
</VCol>
<!-- 渐变背景选择 -->
<VCol cols="12">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.backgroundGradient') }}</VCardSubtitle>
<div class="gradient-grid">
<VBtn
v-for="(gradient, index) in gradientOptions"
:key="index"
:variant="folderSettings.gradient === gradient ? 'tonal' : 'text'"
class="ma-1 gradient-btn"
:style="{ background: gradient }"
size="large"
@click="folderSettings.gradient = gradient"
>
<VIcon v-if="folderSettings.gradient === gradient" icon="mdi-check" color="white" />
</VBtn>
</div>
</VCol>
<!-- 自定义背景图片 -->
<VCol cols="12">
<VTextField
v-model="folderSettings.background"
:label="t('folder.customBackgroundImageURL')"
placeholder="https://example.com/image.jpg"
variant="outlined"
:hint="t('folder.customBackgroundImageHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

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

@@ -3,10 +3,6 @@ import type { PropType } from 'vue'
import { getLogoUrl } from '@/utils/imageUtils'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'
import SiteResourceDialog from '../dialog/SiteResourceDialog.vue'
import SiteCookieUpdateDialog from '../dialog/SiteCookieUpdateDialog.vue'
import api from '@/api'
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
@@ -14,6 +10,12 @@ import { formatFileSize } from '@/@core/utils/formatters'
import { useConfirm } from '@/composables/useConfirm'
import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
const SiteAddEditDialog = defineAsyncComponent(() => import('../dialog/SiteAddEditDialog.vue'))
const SiteCookieUpdateDialog = defineAsyncComponent(() => import('../dialog/SiteCookieUpdateDialog.vue'))
const SiteResourceDialog = defineAsyncComponent(() => import('../dialog/SiteResourceDialog.vue'))
const SiteUserDataDialog = defineAsyncComponent(() => import('../dialog/SiteUserDataDialog.vue'))
// 显示器宽度
const display = useDisplay()
@@ -51,18 +53,6 @@ const testButtonText = ref(t('site.testConnectivity'))
// 测试按钮可用性
const testButtonDisable = ref(false)
// 更新站点Cookie UA弹窗
const siteCookieDialog = ref(false)
// 站点编辑弹窗
const siteEditDialog = ref(false)
// 资源浏览弹窗
const resourceDialog = ref(false)
// 用户数据弹窗
const siteUserDataDialog = ref(false)
// 查询站点图标
async function getSiteIcon() {
const siteId = cardProps.site?.id
@@ -105,17 +95,44 @@ async function testSite() {
// 打开更新站点Cookie UA弹窗
async function handleSiteUpdate() {
siteCookieDialog.value = true
openSharedDialog(
SiteCookieUpdateDialog,
{ site: cardProps.site },
{
done: onSiteCookieUpdated,
},
{ closeOn: ['close', 'done'] },
)
}
// 打开资源浏览弹窗
async function handleResourceBrowse() {
resourceDialog.value = true
openSharedDialog(
SiteResourceDialog,
{ site: cardProps.site },
{
close: onSiteResourceDone,
},
{ closeOn: ['close'] },
)
}
// 打开站点用户数据弹窗
async function handleSiteUserData() {
siteUserDataDialog.value = true
openSharedDialog(SiteUserDataDialog, { site: cardProps.site }, {}, { closeOn: ['close'] })
}
// 打开站点编辑弹窗
function handleSiteEdit() {
openSharedDialog(
SiteAddEditDialog,
{ siteid: cardProps.site?.id },
{
save: saveSite,
remove: () => emit('remove'),
},
{ closeOn: ['close', 'save', 'remove'] },
)
}
// 打开站点页面
@@ -199,20 +216,17 @@ const getDownloadPercent = computed(() => {
// 保存站点
function saveSite() {
siteEditDialog.value = false
emit('update')
}
// 更新站点Cookie UA后的回调
function onSiteCookieUpdated() {
siteCookieDialog.value = false
// Cookie更新后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
}
// 资源浏览弹窗关闭后的回调
function onSiteResourceDone() {
resourceDialog.value = false
// 资源操作完成后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
}
@@ -241,7 +255,6 @@ onMounted(() => {
:ripple="false"
variant="flat"
elevation="0"
rounded="lg"
:hover="!cardProps.sortable"
@click="handleCardClick"
>
@@ -390,7 +403,7 @@ onMounted(() => {
<VIcon icon="mdi-dots-vertical" size="20" />
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
<VList>
<VListItem @click="siteEditDialog = true" base-color="info">
<VListItem @click="handleSiteEdit" base-color="info">
<template #prepend>
<VIcon icon="mdi-file-edit-outline" size="20" />
</template>
@@ -407,35 +420,6 @@ onMounted(() => {
</VBtn>
</VSheet>
</VCard>
<!-- 对话框组件 -->
<SiteCookieUpdateDialog
v-if="siteCookieDialog"
v-model="siteCookieDialog"
:site="cardProps.site"
@close="siteCookieDialog = false"
@done="onSiteCookieUpdated"
/>
<SiteAddEditDialog
v-if="siteEditDialog"
v-model="siteEditDialog"
:siteid="cardProps.site?.id"
@save="saveSite"
@remove="emit('remove')"
@close="siteEditDialog = false"
/>
<SiteUserDataDialog
v-if="siteUserDataDialog"
v-model="siteUserDataDialog"
:site="cardProps.site"
@close="siteUserDataDialog = false"
/>
<SiteResourceDialog
v-if="resourceDialog"
v-model="resourceDialog"
:site="cardProps.site"
@close="onSiteResourceDone"
/>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { StorageConf } from '@/api/types'
import type { StorageConf } from '@/api/types'
import { formatBytes } from '@core/utils/formatters'
import storage_png from '@images/misc/storage.png'
import alipan_png from '@images/misc/alipan.webp'
@@ -9,21 +9,22 @@ import alist_png from '@images/misc/openlist.svg'
import custom_png from '@images/misc/database.png'
import smb_png from '@images/misc/smb.png'
import api from '@/api'
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
import RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'
import AlistConfigDialog from '../dialog/AlistConfigDialog.vue'
import SmbConfigDialog from '../dialog/SmbConfigDialog.vue'
import { useToast } from 'vue-toastification'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
// 显示器宽度
const display = useDisplay()
const AliyunAuthDialog = defineAsyncComponent(() => import('../dialog/AliyunAuthDialog.vue'))
const U115AuthDialog = defineAsyncComponent(() => import('../dialog/U115AuthDialog.vue'))
const RcloneConfigDialog = defineAsyncComponent(() => import('../dialog/RcloneConfigDialog.vue'))
const AlistConfigDialog = defineAsyncComponent(() => import('../dialog/AlistConfigDialog.vue'))
const SmbConfigDialog = defineAsyncComponent(() => import('../dialog/SmbConfigDialog.vue'))
const StorageCustomConfigDialog = defineAsyncComponent(() => import('../dialog/StorageCustomConfigDialog.vue'))
// 国际化
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#FFB400')
// 定义输入
const props = defineProps({
@@ -50,53 +51,34 @@ const used = computed(() => {
return total.value - available.value
})
// 存储
const storage_ref = ref(props.storage)
// 自定义存储名称
const customName = ref(props.storage.name)
// 自定义存储类型
const storageType = ref(props.storage.type)
// 阿里云盘认证对话框
const aliyunAuthDialog = ref(false)
// 115网盘认证对话框
const u115AuthDialog = ref(false)
// Rclone配置对话框
const rcloneConfigDialog = ref(false)
// AList配置对话框
const aListConfigDialog = ref(false)
// SMB配置对话框
const smbConfigDialog = ref(false)
// 自定义存储配置对话框
const customConfigDialog = ref(false)
// 打开存储对话框
/** 打开指定类型的共享存储配置弹窗。 */
function openStorageDialog() {
switch (props.storage.type) {
case 'alipan':
aliyunAuthDialog.value = true
break
case 'u115':
u115AuthDialog.value = true
break
case 'rclone':
rcloneConfigDialog.value = true
break
case 'alist':
aListConfigDialog.value = true
break
case 'smb':
smbConfigDialog.value = true
break
case 'local':
$toast.info(t('storage.noConfigNeeded'))
break
default:
customConfigDialog.value = true
break
const dialogMap: Record<string, Component> = {
alipan: AliyunAuthDialog,
u115: U115AuthDialog,
rclone: RcloneConfigDialog,
alist: AlistConfigDialog,
smb: SmbConfigDialog,
}
if (props.storage.type === 'local') {
$toast.info(t('storage.noConfigNeeded'))
return
}
const dialog = dialogMap[props.storage.type] || StorageCustomConfigDialog
const dialogProps = dialog === StorageCustomConfigDialog
? { storage: props.storage }
: { conf: props.storage.config || {} }
openSharedDialog(
dialog,
dialogProps,
{
done: handleDone,
},
{ closeOn: ['close', 'done', 'update:modelValue'] },
)
}
// 根据存储类型选择图标
@@ -135,7 +117,7 @@ const usage = computed(() => {
return Math.round((used.value / (total.value || 1)) * 1000) / 10
})
// 查询存储信息
/** 查询存储空间使用信息。 */
async function queryStorage() {
try {
const data: { total: number; available: number } = await api.get(`storage/usage/${props.storage.type}`)
@@ -146,123 +128,47 @@ async function queryStorage() {
}
}
// 完成配置后的处理
function handleDone() {
aliyunAuthDialog.value = false
u115AuthDialog.value = false
rcloneConfigDialog.value = false
aListConfigDialog.value = false
smbConfigDialog.value = false
customConfigDialog.value = false
// 更新存储
storage_ref.value.name = customName.value
storage_ref.value.type = storageType.value
emit('done', storage_ref.value)
/** 完成配置后的处理并通知父级刷新。 */
function handleDone(storage?: StorageConf) {
emit('done', storage || props.storage)
}
onMounted(() => {
queryStorage()
})
// 关闭
/** 关闭存储卡片。 */
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" @click="openStorageDialog">
<VDialogCloseBtn @click="onClose" class="absolute top-1 right-1" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
</div>
<VImg :src="getIcon" cover class="mt-8" max-width="3rem" min-width="3rem" />
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openStorageDialog"
>
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
</div>
</VCard>
<AliyunAuthDialog
v-if="aliyunAuthDialog"
v-model="aliyunAuthDialog"
:conf="props.storage.config || {}"
@close="aliyunAuthDialog = false"
@done="handleDone"
/>
<U115AuthDialog
v-if="u115AuthDialog"
v-model="u115AuthDialog"
:conf="props.storage.config || {}"
@close="u115AuthDialog = false"
@done="handleDone"
/>
<RcloneConfigDialog
v-if="rcloneConfigDialog"
v-model="rcloneConfigDialog"
:conf="props.storage.config || {}"
@close="rcloneConfigDialog = false"
@done="handleDone"
/>
<AlistConfigDialog
v-if="aListConfigDialog"
v-model="aListConfigDialog"
:conf="props.storage.config || {}"
@close="aListConfigDialog = false"
@done="handleDone"
/>
<SmbConfigDialog
v-if="smbConfigDialog"
v-model="smbConfigDialog"
:conf="props.storage.config || {}"
@close="smbConfigDialog = false"
@done="handleDone"
/>
<VDialog
v-if="customConfigDialog"
v-model="customConfigDialog"
scrollable
max-width="30rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-cog" />
</template>
<VCardTitle>{{ t('storage.custom') }}</VCardTitle>
<VDialogCloseBtn v-model="customConfigDialog" />
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="storageType"
:label="t('storage.type')"
:hint="t('storage.customTypeHint')"
persistent-hint
prepend-inner-icon="mdi-database"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="customName"
:label="t('storage.name')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
<VImg
ref="imageRef"
:src="getIcon"
cover
class="mt-8"
max-width="3rem"
min-width="3rem"
@load="updateAccentColor"
/>
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
</div>
</VCard>
</template>

View File

@@ -1,16 +1,20 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue'
import SubscribeShareDialog from '../dialog/SubscribeShareDialog.vue'
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'
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'))
const SubscribeShareDialog = defineAsyncComponent(() => import('../dialog/SubscribeShareDialog.vue'))
// 显示器宽度
const display = useDisplay()
@@ -52,15 +56,6 @@ const $toast = useToast()
// 图片是否加载完成
const imageLoaded = ref(false)
// 订阅弹窗
const subscribeEditDialog = ref(false)
// 订阅文件信息弹窗
const subscribeFilesDialog = ref(false)
// 分享订阅弹窗
const subscribeShareDialog = ref(false)
// 当前的订阅状态
const subscribeState = ref<string>(props.media?.state ?? 'P')
@@ -77,13 +72,64 @@ function isTvSubscribe(media?: Subscribe) {
return media?.type === '电视剧' || media?.type === 'tv' || !!media?.season || !!media?.total_episode
}
// TV 洗版订阅在卡片上展示分集或全集短标签
const bestVersionModeLabel = computed(() => {
if (!isEnabledFlag(props.media?.best_version) || !isTvSubscribe(props.media)) return ''
// 已下载集数total_episode - lack_episode
const downloadedEpisode = computed(() => {
const total = props.media?.total_episode || 0
if (!total) return 0
return Math.min(Math.max(total - (props.media?.lack_episode || 0), 0), total)
})
return isEnabledFlag(props.media?.best_version_full)
? t('subscribe.bestVersionWholeShort')
: t('subscribe.bestVersionEpisodeShort')
// 是否为洗版订阅(影响进度条与 tooltip 的展示分支)
const isBestVersion = computed(() => isEnabledFlag(props.media?.best_version) && isTvSubscribe(props.media))
const rightBottomStateDisplay = computed(() => {
if (subscribeState.value === 'S') {
return { icon: 'mdi-pause-circle', label: t('subscribe.cardStatePaused') }
}
if (subscribeState.value === 'P') {
return { icon: 'mdi-clock', label: t('subscribe.cardStatePending') }
}
return null
})
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
const bestVersionBadge = computed(() => {
if (!isEnabledFlag(props.media?.best_version)) return null
return {
icon: 'mdi-shimmer',
full: isEnabledFlag(props.media?.best_version_full),
}
})
// 已洗版集数:取后端派生字段 completed_episode
const completedEpisode = computed(() => {
const total = props.media?.total_episode || 0
return Math.min(Math.max(props.media?.completed_episode ?? 0, 0), total)
})
// 卡片主文案:已下载集数 / 总集数
const subscribeProgressText = computed(() => {
const total = props.media?.total_episode || 0
if (!total) return ''
return `${downloadedEpisode.value} / ${total}`
})
// 订阅卡片 hover 文案:
// - 普通订阅:「已下载 X · 共 Y 集」
// - 洗版订阅:「已下载 X · 已洗版 N · 共 Y 集」
const subscribeProgressTooltip = computed(() => {
const total = props.media?.total_episode || 0
if (!total) return ''
if (isBestVersion.value) {
return t('subscribe.bestVersionEpisodeProgressTooltip', {
completed: completedEpisode.value,
downloaded: downloadedEpisode.value,
total,
})
}
return t('subscribe.subscribeProgressTooltip', { downloaded: downloadedEpisode.value, total })
})
// 图片加载完成响应
@@ -91,13 +137,19 @@ function imageLoadHandler() {
imageLoaded.value = true
}
// 计算百分
// 进度条 model 段百分比:洗版订阅表示"已洗版"占比(亮段),普通订阅表示"已下载"占
function getPercentage() {
if (props.media?.total_episode === 0) return 0
const total = props.media?.total_episode || 0
if (!total) return 0
const value = isBestVersion.value ? completedEpisode.value : downloadedEpisode.value
return Math.round((value / total) * 100)
}
return Math.round(
(((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0)) / (props.media?.total_episode ?? 1)) * 100,
)
// 洗版进度条的 buffer 段百分比:表示"已下载"占比,仅在洗版场景被模板调用
function getBufferPercentage() {
const total = props.media?.total_episode || 0
if (!isBestVersion.value || !total) return 0
return Math.round((downloadedEpisode.value / total) * 100)
}
// 删除订阅
@@ -176,12 +228,22 @@ async function resetSubscribe() {
// 分享订阅
async function shareSubscribe() {
subscribeShareDialog.value = true
if (!props.media) return
openSharedDialog(SubscribeShareDialog, { sub: props.media }, {}, { closeOn: ['close'] })
}
// 编辑订阅响应
async function editSubscribeDialog() {
subscribeEditDialog.value = true
openSharedDialog(
SubscribeEditDialog,
{ subid: props.media?.id },
{
remove: onSubscribeEditRemove,
save: onSubscribeEditSave,
},
{ closeOn: ['close', 'save', 'remove'] },
)
}
// 获得mediaid
@@ -207,7 +269,7 @@ async function viewMediaDetail() {
// 查看文件详情
async function viewSubscribeFiles() {
subscribeFilesDialog.value = true
openSharedDialog(SubscribeFilesDialog, { subid: props.media?.id }, {}, { closeOn: ['close'] })
}
// 弹出菜单
@@ -303,30 +365,22 @@ 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)
})
// 订阅编辑保存
function onSubscribeEditSave() {
subscribeEditDialog.value = false
emit('save')
}
// 订阅编辑取消
function onSubscribeEditRemove() {
subscribeEditDialog.value = false
emit('remove')
}
@@ -351,26 +405,32 @@ function handleCardClick() {
<VHover>
<template #default="hover">
<div
class="w-full h-full rounded-lg overflow-hidden"
class="subscribe-card-shell w-full h-full relative"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'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="{
'opacity-70': subscribeState === 'S',
'subscribe-card-paused': subscribeState === 'S',
'cursor-move': props.sortable,
}"
rounded="0"
min-height="150"
@click="handleCardClick"
:ripple="!props.batchMode && !props.sortable"
>
<div
v-if="bestVersionBadge && imageLoaded"
class="best-version-badge"
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
>
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
</div>
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
<IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" />
@@ -399,15 +459,11 @@ function handleCardClick() {
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</template>
</VImg>
<div
v-if="subscribeState === 'P'"
class="absolute inset-0 bg-yellow-900 opacity-80 pointer-events-none"
/>
</template>
<div>
<VCardText class="flex items-center pt-3 pb-2">
<div
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
v-if="imageLoaded"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
>
@@ -423,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>
@@ -443,37 +499,69 @@ function handleCardClick() {
icon="mdi-progress-download"
color="white"
/>
<div v-if="props.media?.season" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}
<!-- 守卫改用 total_episode电视剧订阅可能不带 season 字段旧数据或自定义来源仍应展示集数进度 -->
<div v-if="props.media?.total_episode" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
{{ subscribeProgressText }}
<VTooltip v-if="subscribeProgressTooltip" activator="parent" location="top">
{{ subscribeProgressTooltip }}
</VTooltip>
</div>
<VChip
v-if="bestVersionModeLabel"
size="x-small"
color="primary"
variant="flat"
class="me-2 flex-shrink-0"
>
{{ bestVersionModeLabel }}
</VChip>
<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>
</VCardText>
<!-- 右下角元数据暂停 / 待定时替换"x 天前"为状态文案 -->
<VCardText
v-if="lastUpdateText"
v-if="rightBottomStateDisplay"
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
>
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
{{ rightBottomStateDisplay.label }}
</VCardText>
<VCardText
v-else-if="lastUpdateText"
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
>
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<div class="w-full absolute bottom-0">
<!--
分集洗版模式底色保持深绿buffer 段显示"已下载未洗版"为浅绿model 段显示"已洗版完成"为亮绿
形成两段语义其余订阅维持原有单段进度条
-->
<VProgressLinear
v-if="getPercentage() > 0"
v-if="isBestVersion && getBufferPercentage() > 0"
:model-value="getPercentage()"
:buffer-value="getBufferPercentage()"
bg-color="success"
bg-opacity="0.25"
color="success"
buffer-color="success"
buffer-opacity="0.55"
/>
<VProgressLinear
v-else-if="getPercentage() > 0"
:model-value="getPercentage()"
bg-color="success"
color="success"
@@ -484,34 +572,62 @@ function handleCardClick() {
</div>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="props.media?.id"
@remove="onSubscribeEditRemove"
@save="onSubscribeEditSave"
@close="subscribeEditDialog = false"
/>
<!-- 订阅文件信息弹窗 -->
<SubscribeFilesDialog
v-if="subscribeFilesDialog"
v-model="subscribeFilesDialog"
:subid="props.media?.id"
@close="subscribeFilesDialog = false"
/>
<!-- 分享订阅弹窗 -->
<SubscribeShareDialog
v-if="subscribeShareDialog"
v-model="subscribeShareDialog"
:sub="props.media"
@close="subscribeShareDialog = false"
/>
</div>
</template>
<style lang="scss" scoped>
.subscribe-card-background {
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
/**
* 暂停:降低不透明度表达"已停止活动"
*/
.subscribe-card-paused {
opacity: 0.65;
transition: opacity 0.2s ease;
}
/**
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
*/
.subscribe-card-pending-tint {
position: relative;
}
.subscribe-card-pending-tint::after {
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;
}
/**
* 洗版标识:卡片左上角 24x24 圆形徽标
* 分集:深色半透底 + 模糊
* 全集:磨砂玻璃半透白底 + 大模糊
*/
.best-version-badge {
position: absolute;
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
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 {
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 22%);
box-shadow: 0 2px 8px rgba(255, 255, 255, 15%);
}
</style>

View File

@@ -2,9 +2,12 @@
import { formatDateDifference } from '@/@core/utils/formatters'
import type { SubscribeShare } from '@/api/types'
import router from '@/router'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import ForkSubscribeDialog from '../dialog/ForkSubscribeDialog.vue'
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'))
// 输入参数
const props = defineProps({
@@ -22,15 +25,6 @@ const globalSettings = globalSettingsStore.globalSettings
// 图片是否加载完成
const imageLoaded = ref(false)
// 订阅编辑弹窗
const subscribeEditDialog = ref(false)
// 复用订阅弹窗
const forkSubscribeDialog = ref(false)
// 订阅ID
const subscribeId = ref<number>()
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
@@ -42,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
@@ -78,19 +66,24 @@ async function viewMediaDetail() {
// 复用订阅
function showForkSubscribe() {
forkSubscribeDialog.value = true
openSharedDialog(
ForkSubscribeDialog,
{ media: props.media },
{
fork: finishForkSubscribe,
delete: doDelete,
},
{ closeOn: ['close', 'fork', 'delete'] },
)
}
// 完成复用订阅
function finishForkSubscribe(subid: number) {
subscribeId.value = subid
forkSubscribeDialog.value = false
subscribeEditDialog.value = true
openSharedDialog(SubscribeEditDialog, { subid }, {}, { closeOn: ['close', 'save', 'remove'] })
}
// 删除订阅分享时处理
function doDelete() {
forkSubscribeDialog.value = false
// 通知父组件刷新
emit('delete')
}
@@ -101,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,
}"
@@ -110,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"
>
@@ -167,24 +159,6 @@ function doDelete() {
</div>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="subscribeEditDialog = false"
/>
<!-- 复用订阅弹窗 -->
<ForkSubscribeDialog
v-if="forkSubscribeDialog"
v-model="forkSubscribeDialog"
:media="props.media"
@close="forkSubscribeDialog = false"
@fork="finishForkSubscribe"
@delete="doDelete"
/>
</div>
</template>
<style lang="scss" scoped>

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

@@ -3,10 +3,13 @@ import type { PropType } from 'vue'
import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import type { Context } from '@/api/types'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
import { openSharedDialog } from '@/composables/useSharedDialog'
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
const TorrentMoreSourcesDialog = defineAsyncComponent(() => import('../dialog/TorrentMoreSourcesDialog.vue'))
// 输入参数
const props = defineProps({
@@ -16,9 +19,6 @@ const props = defineProps({
height: String,
})
// 更多来源界面
const showMoreTorrents = ref(false)
// 种子信息
const torrent = ref(props.torrent?.torrent_info)
@@ -36,18 +36,14 @@ const siteIcons = ref<Record<number, string>>({})
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
// 添加下载对话框
const addDownloadDialog = ref(false)
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
markTorrentDownloaded(url)
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
console.error(error)
}
// 查询站点图标
@@ -77,7 +73,21 @@ async function handleAddDownload(item: Context | null = null) {
downloadItem.value = item
}
// 打开下载对话框
addDownloadDialog.value = true
openSharedDialog(
AddDownloadDialog,
{
title: `${downloadItem.value?.media_info?.title_year || downloadItem.value?.meta_info?.name} ${
downloadItem.value?.meta_info?.season_episode
}`,
media: downloadItem.value?.media_info,
torrent: downloadItem.value?.torrent_info,
},
{
done: addDownloadSuccess,
error: addDownloadError,
},
{ closeOn: ['close', 'done', 'error'] },
)
}
// 打开种子详情页面
@@ -103,21 +113,23 @@ function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolum
else return ''
}
// 获取优惠标签类
function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
if (!downloadVolumeFactor) return 'chip-free'
if (downloadVolumeFactor === 0) return 'chip-free'
else if (downloadVolumeFactor < 1) return 'chip-discount'
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'chip-bonus'
else return ''
}
// 打开更多来源对话框
async function openMoreTorrentsDialog() {
props.more?.forEach(t => {
return getSiteIcon(t.torrent_info?.site)
})
showMoreTorrents.value = true
openSharedDialog(
TorrentMoreSourcesDialog,
{
items: props.more || [],
siteIcons: siteIcons.value,
},
{
download: handleAddDownload,
detail: openTorrentDetail,
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
watch(
@@ -276,7 +288,7 @@ watch(
class="pa-1 d-flex align-center"
@click.stop="openMoreTorrentsDialog"
>
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" size="small" class="mr-1"></VIcon>
<VIcon icon="mdi-chevron-down" size="small" class="mr-1"></VIcon>
更多来源 ({{ props.more.length }})
</VBtn>
</div>
@@ -294,105 +306,6 @@ watch(
</div>
</VCardActions>
</VCard>
<!-- 更多来源对话框 -->
<VDialog v-model="showMoreTorrents" max-width="25rem" location="center">
<VCard>
<VCardTitle class="py-3 d-flex align-center">
<span>其他来源</span>
<VSpacer />
<VBtn variant="text" size="small" icon="mdi-close" @click.stop="showMoreTorrents = false"></VBtn>
</VCardTitle>
<VDivider />
<VCardText class="more-sources-content pa-0">
<VList lines="one" density="compact">
<VListItem
v-for="(item, index) in props.more"
:key="index"
@click.stop="handleAddDownload(item)"
class="hover:bg-primary-lighten-5"
>
<template v-slot:prepend>
<div class="d-flex align-center gap-1">
<VImg
v-if="siteIcons[item.torrent_info?.site || 0]"
:src="siteIcons[item.torrent_info?.site || 0]"
:alt="item.torrent_info?.site_name"
width="16"
height="16"
class="rounded"
/>
<VAvatar v-else size="16" class="text-caption bg-surface-variant">
{{ item.torrent_info?.site_name?.substring(0, 1) }}
</VAvatar>
<span class="text-body-2 font-weight-bold">{{ item.torrent_info.site_name }}</span>
<VChip
v-if="item.meta_info?.season_episode"
class="chip-season rounded-sm ml-1"
size="x-small"
variant="elevated"
>
{{ item.meta_info.season_episode }}
</VChip>
<VChip
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
:class="
getPromotionChipClass(
item.torrent_info?.downloadvolumefactor,
item.torrent_info?.uploadvolumefactor,
)
"
size="x-small"
variant="elevated"
class="rounded-sm ml-1"
>
{{ item.torrent_info?.volume_factor }}
</VChip>
</div>
</template>
<template v-slot:append>
<div class="d-flex align-center gap-2">
<span class="text-caption font-weight-bold text-primary">
{{ formatFileSize(item.torrent_info?.size) }}
</span>
<span class="d-flex align-center text-caption font-weight-bold">
<VIcon size="small" color="success" icon="mdi-arrow-up" class="mr-1"></VIcon>
{{ item.torrent_info?.seeders }}
</span>
<span>
<VIcon
@click.stop="openTorrentDetail(item)"
size="small"
color="secondary"
icon="mdi-arrow-top-right"
class="mr-1"
></VIcon>
</span>
</div>
</template>
</VListItem>
</VList>
</VCardText>
</VCard>
</VDialog>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:title="`${downloadItem?.media_info?.title_year || downloadItem?.meta_info?.name} ${
downloadItem?.meta_info?.season_episode
}`"
:media="downloadItem?.media_info"
:torrent="downloadItem?.torrent_info"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</div>
</template>
@@ -403,11 +316,6 @@ watch(
inset-inline-end: 0;
}
.more-sources-content {
max-block-size: 60vh;
overflow-y: auto;
}
/* 卡片悬停效果 */
.torrent-card {
border: 1px solid transparent;

View File

@@ -3,9 +3,11 @@ import type { PropType } from 'vue'
import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import type { Context } from '@/api/types'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
import { openSharedDialog } from '@/composables/useSharedDialog'
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
// 输入参数
const props = defineProps({
@@ -26,9 +28,6 @@ const siteIcon = ref('')
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
// 添加下载对话框
const addDownloadDialog = ref(false)
// 查询站点图标
async function getSiteIcon() {
if (!torrent?.value?.site) {
@@ -73,18 +72,29 @@ function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadV
// 询问并添加下载
async function handleAddDownload() {
// 打开下载对话框
addDownloadDialog.value = true
openSharedDialog(
AddDownloadDialog,
{
title: `${media.value?.title_year || meta.value?.name} ${meta.value?.season_episode || ''}`,
media: media.value,
torrent: torrent.value,
},
{
done: addDownloadSuccess,
error: addDownloadError,
},
{ closeOn: ['close', 'done', 'error'] },
)
}
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
markTorrentDownloaded(url)
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
console.error(error)
}
// 打开种子详情页面
@@ -241,17 +251,6 @@ watch(
</div>
</template>
</VListItem>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:title="`${media?.title_year || meta?.name} ${meta?.season_episode || ''}`"
:media="media"
:torrent="torrent"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</div>
</template>

View File

@@ -5,9 +5,11 @@ import { useUserStore } from '@/stores'
import avatar1 from '@images/avatars/avatar-1.png'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
const UserAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/UserAddEditDialog.vue'))
// 国际化
const { t } = useI18n()
@@ -46,9 +48,6 @@ const emit = defineEmits(['remove', 'save'])
// 确认框
const createConfirm = useConfirm()
// 用户信息弹窗
const userEditDialog = ref(false)
// 提示框
const $toast = useToast()
@@ -104,12 +103,22 @@ async function removeUser() {
// 编辑用户
function editUser() {
userEditDialog.value = true
openSharedDialog(
UserAddEditDialog,
{
username: props.user?.name,
usernames: props.users.map(item => item.name),
oper: 'edit',
},
{
save: onUserUpdate,
},
{ closeOn: ['close', 'save'] },
)
}
// 用户更新完成时
function onUserUpdate() {
userEditDialog.value = false
emit('save')
}
@@ -124,7 +133,7 @@ onMounted(() => {
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
]"
class="user-card flex flex-column h-full"
@click="userEditDialog = true"
@click="editUser"
>
<div class="user-card__body flex-grow flex-grow-1">
<!-- 用户头像和基本信息 -->
@@ -294,17 +303,6 @@ onMounted(() => {
</VCardText>
</div>
</VCard>
<!-- 用户编辑弹窗 -->
<UserAddEditDialog
v-if="userEditDialog"
v-model="userEditDialog"
:username="props.user?.name"
:usernames="props.users.map(item => item.name)"
oper="edit"
@save="onUserUpdate"
@close="userEditDialog = false"
/>
</template>
<style scoped>

View File

@@ -1,7 +1,9 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import type { WorkflowShare } from '@/api/types'
import ForkWorkflowDialog from '../dialog/ForkWorkflowDialog.vue'
import { openSharedDialog } from '@/composables/useSharedDialog'
const ForkWorkflowDialog = defineAsyncComponent(() => import('../dialog/ForkWorkflowDialog.vue'))
// 输入参数
const props = defineProps({
@@ -15,9 +17,6 @@ const props = defineProps({
// 定义删除事件
const emit = defineEmits(['delete', 'update'])
// 复用工作流弹窗
const forkWorkflowDialog = ref(false)
// 工作流ID
const workflowId = ref<string>()
@@ -65,19 +64,28 @@ onMounted(() => {
// 复用工作流
function showForkWorkflow() {
forkWorkflowDialog.value = true
openSharedDialog(
ForkWorkflowDialog,
{
workflow: props.workflow,
eventTypes: props.eventTypes,
},
{
fork: finishForkWorkflow,
delete: doDelete,
},
{ closeOn: ['close', 'fork', 'delete'] },
)
}
// 完成复用工作流
function finishForkWorkflow(wid: string) {
workflowId.value = wid
forkWorkflowDialog.value = false
emit('update')
}
// 删除工作流分享时处理
function doDelete() {
forkWorkflowDialog.value = false
// 通知父组件刷新
emit('delete')
}
@@ -87,62 +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>
<!-- 复用工作流弹窗 -->
<ForkWorkflowDialog
v-if="forkWorkflowDialog"
v-model="forkWorkflowDialog"
:workflow="props.workflow"
:event-types="props.eventTypes"
@close="forkWorkflowDialog = false"
@fork="finishForkWorkflow"
@delete="doDelete"
/>
</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

@@ -2,11 +2,13 @@
import { Workflow } from '@/api/types'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
import WorkflowShareDialog from '@/components/dialog/WorkflowShareDialog.vue'
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
const WorkflowActionsDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowActionsDialog.vue'))
const WorkflowAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowAddEditDialog.vue'))
const WorkflowShareDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowShareDialog.vue'))
const { t } = useI18n()
@@ -31,15 +33,6 @@ const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 编辑对话框
const editDialog = ref(false)
// 流程对话框
const flowDialog = ref(false)
// 分享对话框
const shareDialog = ref(false)
// 加载中
const loading = ref(false)
@@ -51,24 +44,35 @@ const getEventTypeText = (eventTypeValue: string) => {
// 编辑任务
function handleEdit(item: Workflow) {
editDialog.value = true
openSharedDialog(
WorkflowAddEditDialog,
{ workflow: item },
{
save: editDone,
},
{ closeOn: ['close', 'save'] },
)
}
// 编辑流程
function handleFlow(item: Workflow) {
flowDialog.value = true
openSharedDialog(
WorkflowActionsDialog,
{ workflow: item },
{
save: editDone,
},
{ closeOn: ['close', 'save'] },
)
}
// 分享工作流
function handleShare(item: Workflow) {
shareDialog.value = true
openSharedDialog(WorkflowShareDialog, { workflow: item }, {}, { closeOn: ['close'] })
}
// 编辑完成
function editDone() {
editDialog.value = false
flowDialog.value = false
shareDialog.value = false
emit('refresh')
}
@@ -365,23 +369,5 @@ const resolveProgress = (item: Workflow) => {
</VCardText>
</VCard>
</VHover>
<!-- 流程对话框 -->
<WorkflowActionsDialog
v-if="flowDialog"
v-model="flowDialog"
@close="flowDialog = false"
@save="editDone"
:workflow="workflow"
/>
<!-- 编辑对话框 -->
<WorkflowAddEditDialog
v-if="editDialog"
v-model="editDialog"
@close="editDialog = false"
@save="editDone"
:workflow="workflow"
/>
<!-- 分享对话框 -->
<WorkflowShareDialog v-if="shareDialog" v-model="shareDialog" :workflow="workflow" @close="shareDialog = false" />
</div>
</template>

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>([])
@@ -84,6 +91,128 @@ const releaseDialogTitle = ref('')
// 变更日志对话框内容
const releaseDialogBody = ref('')
// 版本统计对话框
const versionStatisticDialog = ref(false)
// 版本统计加载状态
const versionStatisticLoading = ref(false)
// 版本统计数据
const versionStatistic = ref<any>({})
// 后端版本统计
const backendVersionStatistics = computed(() => versionStatistic.value?.backend_versions ?? [])
// 前端版本统计
const frontendVersionStatistics = computed(() => versionStatistic.value?.frontend_versions ?? [])
// 活跃用户统计
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
@@ -91,6 +220,28 @@ function showReleaseDialog(title: string, body: string) {
releaseDialog.value = true
}
// 查询版本统计
async function queryVersionStatistic() {
if (!systemEnv.value.USAGE_STATISTIC_SHARE) return
versionStatisticLoading.value = true
try {
const result: { [key: string]: any } = await api.get('system/usage/statistic')
versionStatistic.value = result.data ?? {}
} catch (error) {
console.log(error)
versionStatistic.value = {}
} finally {
versionStatisticLoading.value = false
}
}
// 打开版本统计对话框
async function showVersionStatisticDialog() {
versionStatisticDialog.value = true
await queryVersionStatistic()
}
// 查询系统环境变量
async function querySystemEnv() {
try {
@@ -102,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 {
@@ -143,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>
@@ -182,6 +353,18 @@ onMounted(() => {
{{ t('setting.about.latest') }}
</span>
</a>
<VTooltip v-if="systemEnv.USAGE_STATISTIC_SHARE" :text="t('setting.about.versionStatistic')">
<template #activator="{ props }">
<VBtn
v-bind="props"
icon="mdi-chart-bar"
size="x-small"
variant="text"
class="ms-2 flex-shrink-0"
@click="showVersionStatisticDialog"
/>
</template>
</VTooltip>
</span>
</dd>
</div>
@@ -260,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>
@@ -406,6 +599,86 @@ onMounted(() => {
<VCardText class="markdown-body" v-html="releaseDialogBody" />
</VCard>
</VDialog>
<VDialog v-if="versionStatisticDialog" v-model="versionStatisticDialog" width="680" scrollable max-height="85vh">
<VCard>
<VCardItem>
<VDialogCloseBtn @click="versionStatisticDialog = false" />
<VCardTitle>
<VIcon icon="mdi-chart-bar" class="me-2" />
{{ t('setting.about.versionStatisticTitle') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VProgressLinear v-if="versionStatisticLoading" indeterminate color="primary" />
<VCardText>
<div class="version-stat-summary">
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.totalInstallUsers') }}</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">{{ formatVersionStatisticNumber(activeUsers.today) }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active7Days') }}</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">{{ formatVersionStatisticNumber(activeUsers.last_30_days) }}</div>
</div>
</div>
<div class="mt-5">
<div class="text-subtitle-2 mb-2">{{ t('setting.about.backendVersionStatistic') }}</div>
<VTable density="compact">
<thead>
<tr>
<th>{{ t('setting.about.version') }}</th>
<th class="text-end">{{ t('setting.about.users') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in backendVersionStatistics" :key="`backend-${item.version}`">
<td>
<code>{{ item.version }}</code>
</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>
</tr>
</tbody>
</VTable>
</div>
<div class="mt-5">
<div class="text-subtitle-2 mb-2">{{ t('setting.about.frontendVersionStatistic') }}</div>
<VTable density="compact">
<thead>
<tr>
<th>{{ t('setting.about.version') }}</th>
<th class="text-end">{{ t('setting.about.users') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in frontendVersionStatistics" :key="`frontend-${item.version}`">
<td>
<code>{{ item.version }}</code>
</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>
</tr>
</tbody>
</VTable>
</div>
<div v-if="versionStatistic.updated_at" class="mt-4 text-caption text-medium-emphasis">
{{ t('setting.about.lastUpdated') }}: {{ versionStatistic.updated_at }}
</div>
</VCardText>
</VCard>
</VDialog>
</VDialog>
</template>
@@ -422,6 +695,18 @@ onMounted(() => {
margin-block: 0.5rem 2.5rem;
}
.version-stat-summary {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
}
.version-stat-number {
font-size: 1.5rem;
font-weight: 700;
line-height: 2rem;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {

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

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
itemTitle?: string
loading?: boolean
modelValue?: boolean
recognizeSource?: string
}>(),
{
itemTitle: '',
loading: false,
modelValue: true,
recognizeSource: '',
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'confirm', payload: { doubanId?: string; tmdbId?: number }): void
(event: 'update:modelValue', value: boolean): void
}>()
const tmdbId = ref<number | undefined>()
const doubanId = ref<string | undefined>()
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 提交重新识别参数给缓存页执行接口调用。
function submitReidentify() {
emit('confirm', {
doubanId: doubanId.value,
tmdbId: tmdbId.value,
})
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" scrollable max-width="35rem">
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon>mdi-text-recognition</VIcon>
</template>
<VCardTitle>{{ t('setting.cache.reidentifyDialog.title') }}</VCardTitle>
<VCardSubtitle>{{ props.itemTitle }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="visible" />
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
v-if="props.recognizeSource === 'themoviedb'"
v-model="tmdbId"
:label="t('setting.cache.reidentifyDialog.tmdbId')"
:hint="t('setting.cache.reidentifyDialog.tmdbIdHint')"
clearable
prepend-inner-icon="mdi-id-card"
persistent-hint
/>
<VTextField
v-else
v-model="doubanId"
:label="t('setting.cache.reidentifyDialog.doubanId')"
:hint="t('setting.cache.reidentifyDialog.doubanIdHint')"
clearable
prepend-inner-icon="mdi-id-card"
persistent-hint
/>
</VCol>
</VRow>
<VAlert type="info" variant="tonal" class="mt-4">
{{ t('setting.cache.reidentifyDialog.autoHint') }}
</VAlert>
</VCardText>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
:loading="props.loading"
prepend-icon="mdi-check"
class="px-5"
@click="submitReidentify"
>
{{ t('setting.cache.reidentifyDialog.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

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

@@ -0,0 +1,243 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const display = useDisplay()
type UnknownRecord = Record<string, any>
const props = withDefaults(
defineProps<{
colors?: Record<string, string>
enabled: Record<string, boolean>
elevated?: boolean
hint: string
items: UnknownRecord[]
labelGetter?: (item: UnknownRecord) => string
modelValue?: boolean
selectAllText?: string
selectNoneText?: string
showBulkActions?: boolean
switchLabel?: string
title: string
valueGetter?: (item: UnknownRecord) => string
}>(),
{
colors: () => ({}),
elevated: false,
labelGetter: undefined,
modelValue: true,
selectAllText: '',
selectNoneText: '',
showBulkActions: false,
switchLabel: '',
valueGetter: undefined,
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'save', payload: { elevated: boolean; enabled: Record<string, boolean> }): void
(event: 'update:elevated', value: boolean): void
(event: 'update:modelValue', value: boolean): void
}>()
const localEnabled = ref<Record<string, boolean>>({})
const localElevated = ref(props.elevated)
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
const elevatedValue = computed({
get: () => localElevated.value,
set: value => {
localElevated.value = value
emit('update:elevated', value)
},
})
watch(
() => [props.enabled, props.elevated, props.items],
() => {
resetLocalSettings()
},
{ deep: true, immediate: true },
)
// 重置弹窗内部设置副本,避免直接修改父级 props。
function resetLocalSettings() {
localEnabled.value = { ...props.enabled }
localElevated.value = props.elevated
}
// 获取设置项的稳定键值。
function getItemValue(item: UnknownRecord) {
return props.valueGetter?.(item) ?? String(item.id ?? item.title ?? item.name ?? '')
}
// 获取设置项展示名称。
function getItemLabel(item: UnknownRecord) {
return props.labelGetter?.(item) ?? String(item.attrs?.title ?? item.name ?? item.title ?? '')
}
// 切换单个设置项的启用状态。
function toggleItem(item: UnknownRecord) {
const key = getItemValue(item)
localEnabled.value[key] = !localEnabled.value[key]
}
// 批量设置所有项目启用状态。
function setAllItems(value: boolean) {
props.items.forEach(item => {
localEnabled.value[getItemValue(item)] = value
})
}
// 提交通用内容开关设置。
function submitSettings() {
emit('save', {
elevated: localElevated.value,
enabled: { ...localEnabled.value },
})
}
</script>
<template>
<VDialog
v-if="visible"
v-model="visible"
width="35rem"
class="settings-dialog"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard class="settings-card">
<VCardItem class="settings-card-header">
<VCardTitle>
<VIcon icon="mdi-tune" size="small" class="me-2" />
{{ props.title }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText>
<p class="settings-hint">{{ props.hint }}</p>
<div class="settings-grid">
<div
v-for="item in props.items"
:key="getItemValue(item)"
class="setting-item"
:class="{ 'enabled': localEnabled[getItemValue(item)] }"
:style="{ '--item-color': props.colors[getItemValue(item)] }"
@click="toggleItem(item)"
>
<div class="setting-item-inner">
<div class="setting-check">
<VIcon
:icon="localEnabled[getItemValue(item)] ? 'mdi-check-circle' : 'mdi-circle-outline'"
:color="localEnabled[getItemValue(item)] ? 'primary' : undefined"
size="small"
/>
</div>
<span class="setting-label">{{ getItemLabel(item) }}</span>
</div>
</div>
</div>
<p v-if="props.switchLabel" class="mt-3">
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
</p>
</VCardText>
<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" color="warning" variant="tonal" @click="setAllItems(false)">
{{ props.selectNoneText }}
</VBtn>
<VSpacer />
<VBtn color="primary" variant="flat" class="px-5" @click="submitSettings">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.settings-card-header {
padding-block: 16px;
padding-inline: 20px;
}
.settings-hint {
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: 0.9rem;
margin-block-end: 16px;
}
.settings-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.setting-label {
flex: 1;
color: rgba(var(--v-theme-on-surface), 0.8);
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
transition: color 0.2s ease;
}
.setting-item {
position: relative;
overflow: hidden;
border-radius: var(--app-surface-radius);
background-color: rgba(var(--v-theme-surface-variant), 0.3);
cursor: pointer;
padding-block: 10px;
padding-inline: 12px;
transition: all 0.2s ease;
}
.setting-item::before {
position: absolute;
background: linear-gradient(90deg, var(--item-color, rgb(var(--v-theme-primary))) 0%, transparent 100%);
content: '';
inline-size: 3px;
inset-block: 0;
inset-inline-start: 0;
opacity: 0.3;
transition: opacity 0.2s ease;
}
.setting-item.enabled {
border-color: rgba(var(--v-theme-primary), 0.4);
background-color: rgba(var(--v-theme-primary), 0.08);
}
.setting-item.enabled::before {
opacity: 1;
}
.setting-item-inner {
display: flex;
align-items: center;
gap: 10px;
}
.setting-check {
display: flex;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = withDefaults(
defineProps<{
css?: string
editorTheme?: string
modelValue?: boolean
}>(),
{
css: '',
editorTheme: 'monokai',
modelValue: true,
},
)
// 定义触发的自定义事件
const emit = defineEmits<{
(e: 'close'): void
(e: 'save', css: string): void
(e: 'update:modelValue', value: boolean): void
}>()
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 正在编辑的 CSS 内容
const editableCSS = ref(props.css)
const editorOptions = {
displayIndentGuides: true,
fontSize: 14,
highlightActiveLine: true,
scrollPastEnd: 0.2,
showPrintMargin: false,
tabSize: 2,
}
watch(
() => props.css,
value => {
editableCSS.value = value
},
)
/** 提交当前 CSS 内容给调用方保存。 */
function submitCustomCSS() {
emit('save', editableCSS.value)
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="custom-css-dialog">
<VCardItem class="custom-css-header py-3">
<template #prepend>
<VAvatar color="primary" variant="tonal" rounded size="40" class="me-2">
<VIcon icon="mdi-palette" size="22" />
</VAvatar>
</template>
<VCardTitle>
{{ t('theme.custom') }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<div class="custom-css-editor-body">
<VAceEditor
v-model:value="editableCSS"
lang="css"
:theme="props.editorTheme"
:options="editorOptions"
wrap
class="custom-css-editor"
/>
</div>
<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>
</VCard>
</VDialog>
</template>
<style scoped>
.custom-css-dialog {
display: flex;
flex-direction: column;
max-block-size: calc(100dvh - 2rem);
overflow: hidden;
}
.custom-css-header {
flex: 0 0 auto;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.custom-css-editor-body {
flex: 1 1 auto;
min-block-size: 0;
}
.custom-css-editor {
overflow: hidden;
background: rgb(var(--v-theme-surface));
block-size: min(62vh, 34rem);
inline-size: 100%;
}
.custom-css-actions {
flex: 0 0 auto;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 0.875rem;
padding-inline: 1rem;
}
@media (width <= 960px) {
.custom-css-dialog {
block-size: 100dvh;
max-block-size: 100dvh;
}
.custom-css-editor-body {
display: flex;
flex-direction: column;
}
.custom-css-editor {
flex: 1 1 auto;
min-block-size: 0;
block-size: auto;
}
.custom-css-actions {
padding-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.75rem));
}
}
</style>

View File

@@ -0,0 +1,210 @@
<script lang="ts" setup>
import { innerFilterRules } from '@/api/constants'
import type { CustomRule } from '@/api/types'
import { cloneDeep } from 'lodash-es'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
// 单条规则
rule: {
type: Object as PropType<CustomRule>,
required: true,
},
// 所有规则
rules: {
type: Array as PropType<CustomRule[]>,
required: true,
},
})
// 提示框
const $toast = useToast()
const { t } = useI18n()
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
// 规则详情弹窗
const ruleInfoDialog = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 规则详情
const ruleInfo = ref<CustomRule>({
id: '',
name: '',
include: '',
exclude: '',
size_range: '',
seeders: '',
publish_time: '',
})
/** 初始化规则编辑表单数据。 */
function initializeRuleInfo() {
ruleInfo.value = cloneDeep(props.rule)
}
/** 保存规则编辑结果并通知父级刷新。 */
function saveRuleInfo() {
if (!ruleInfo.value.id || !ruleInfo.value.name) {
if (!ruleInfo.value.id && !ruleInfo.value.name) {
$toast.error(t('customRule.error.emptyIdName'))
}
return
}
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
$toast.error(t('customRule.error.idOccupied'))
return
}
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
$toast.error(t('customRule.error.nameOccupied'))
return
}
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
$toast.error(t('customRule.error.idExists', { id: ruleInfo.value.id }))
return
}
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
$toast.error(t('customRule.error.nameExists', { name: ruleInfo.value.name }))
return
}
ruleInfoDialog.value = false
emit('change', ruleInfo.value, props.rule.id)
emit('done')
}
/** 规范化规则 ID 输入,只保留英文和数字。 */
function validateRuleId() {
ruleInfo.value.id = ruleInfo.value.id.replace(/[^a-zA-Z0-9]/g, '')
}
onMounted(() => {
initializeRuleInfo()
})
</script>
<template>
<VDialog
v-if="ruleInfoDialog"
v-model="ruleInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-filter-outline" class="me-2" />
</template>
<VCardTitle>{{ t('customRule.title', { id: props.rule.id }) }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.id"
:label="t('customRule.field.ruleId')"
:placeholder="t('customRule.placeholder.ruleId')"
:hint="t('customRule.hint.ruleId')"
persistent-hint
active
prepend-inner-icon="mdi-identifier"
@input="validateRuleId"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.name"
:label="t('customRule.field.ruleName')"
:placeholder="t('customRule.placeholder.ruleName')"
:hint="t('customRule.hint.ruleName')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.include"
:label="t('customRule.field.include')"
:placeholder="t('customRule.placeholder.include')"
:hint="t('customRule.hint.include')"
persistent-hint
active
prepend-inner-icon="mdi-plus-circle"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.exclude"
:label="t('customRule.field.exclude')"
:placeholder="t('customRule.placeholder.exclude')"
:hint="t('customRule.hint.exclude')"
persistent-hint
active
prepend-inner-icon="mdi-minus-circle"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.size_range"
:label="t('customRule.field.sizeRange')"
:placeholder="t('customRule.placeholder.sizeRange')"
:hint="t('customRule.hint.sizeRange')"
persistent-hint
active
prepend-inner-icon="mdi-harddisk"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.seeders"
:label="t('customRule.field.seeders')"
:placeholder="t('customRule.placeholder.seeders')"
:hint="t('customRule.hint.seeders')"
persistent-hint
active
prepend-inner-icon="mdi-account-group"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.publish_time"
:label="t('customRule.field.publishTime')"
:placeholder="t('customRule.placeholder.publishTime')"
:hint="t('customRule.hint.publishTime')"
persistent-hint
active
prepend-inner-icon="mdi-calendar-clock"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<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>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import draggable from 'vuedraggable'
import type { DiscoverSource } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const display = useDisplay()
const props = withDefaults(
defineProps<{
colors?: Record<string, string>
modelValue?: boolean
tabs: DiscoverSource[]
}>(),
{
colors: () => ({}),
modelValue: true,
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'save', tabs: DiscoverSource[]): void
(event: 'update:modelValue', value: boolean): void
}>()
const localTabs = ref<DiscoverSource[]>([])
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
watch(
() => props.tabs,
() => {
resetLocalTabs()
},
{ deep: true, immediate: true },
)
// 重置弹窗内部排序副本。
function resetLocalTabs() {
localTabs.value = props.tabs.map(item => ({ ...item }))
}
// 保存当前拖拽后的发现标签顺序。
function submitOrder() {
emit('save', localTabs.value)
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="35rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-order-alphabetical-ascending" size="small" class="me-2" />
{{ t('discover.setTabOrder') }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText>
<p class="settings-hint">{{ t('discover.dragToReorder') }}</p>
<draggable
v-model="localTabs"
handle=".cursor-move"
item-key="mediaid_prefix"
tag="div"
:component-data="{ 'class': 'settings-grid' }"
>
<template #item="{ element }">
<VCard
variant="text"
class="setting-item enabled"
:style="{ '--item-color': props.colors[element.mediaid_prefix] }"
>
<div class="setting-item-inner">
<span class="setting-label">{{ element.name }}</span>
<VIcon icon="mdi-drag" class="drag-icon cursor-move" />
</div>
</VCard>
</template>
</draggable>
</VCardText>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" class="px-5" @click="submitOrder">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.settings-hint {
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: 0.9rem;
margin-block-end: 16px;
}
.settings-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.setting-item {
position: relative;
overflow: hidden;
background-color: rgba(var(--v-theme-primary), 0.08);
cursor: grab;
padding-block: 10px;
padding-inline: 12px;
}
.setting-item::before {
position: absolute;
background-color: var(--item-color, #4caf50);
block-size: 100%;
content: '';
inline-size: 4px;
inset-block-start: 0;
inset-inline-start: 0;
}
.setting-item-inner {
display: flex;
align-items: center;
gap: 8px;
}
.setting-label {
flex: 1;
color: rgba(var(--v-theme-primary), 0.9);
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
}
.drag-icon {
opacity: 0.5;
}
@media (width <= 600px) {
.settings-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -0,0 +1,559 @@
<script setup lang="ts">
import type { DownloaderConf } from '@/api/types'
import { storageAttributes } from '@/api/constants'
import { cloneDeep } from 'lodash-es'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 获取i18n实例
const { t } = useI18n()
// 定义输入
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
downloader: {
type: Object as PropType<DownloaderConf>,
required: true,
},
downloaders: {
type: Array as PropType<DownloaderConf[]>,
required: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
// 提示框
const $toast = useToast()
// 表单
const downloaderForm = ref()
// 下载器详情弹窗
const downloaderInfoDialog = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 下载器详情
const downloaderInfo = ref<DownloaderConf>({
name: '',
type: '',
default: false,
enabled: false,
config: {},
path_mapping: [],
})
// 路径映射行定义
interface PathMappingRow {
id: string
storage: string
download: string
}
// 路径映射行数据
const pathMappingRows = ref<PathMappingRow[]>([])
// 路径前缀选项
const prefixOptions = computed(() => {
return storageAttributes.map(item => ({
title: t(`storage.${item.type}`),
value: item.type,
}))
})
/** 获取路径所属的存储类型。 */
function getStorageType(path: string) {
if (!path) return 'local'
const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))
return storage?.type || 'local'
}
/** 将存储类型转换为路径前缀。 */
function storage2Prefix(storage: string) {
return storage === 'local' ? '' : storage + ':'
}
/** 拆分存储路径的前缀和真实路径。 */
function parseStoragePath(path: string): [prefix: string, suffix: string] {
if (!path) return ['', '']
const storage = getStorageType(path)
const prefix = storage2Prefix(storage)
return [prefix, path.slice(prefix.length)]
}
/** 更新单行路径映射的存储前缀。 */
function updateStoragePrefix(row: PathMappingRow, storage: string) {
const [, currentSuffix] = parseStoragePath(row.storage)
const prefix = storage2Prefix(storage)
row.storage = prefix + currentSuffix
}
/** 更新单行路径映射的存储路径主体。 */
function updateStorageSuffix(row: PathMappingRow, suffix: string) {
const [currentPrefix] = parseStoragePath(row.storage)
row.storage = currentPrefix + suffix
}
const pathValidationRules = [
(v: string) => !!v || t('downloader.pathMappingRequired'),
(v: string) => v.startsWith('/') || t('downloader.pathMappingError'),
]
/** 生成路径映射行使用的临时唯一 ID。 */
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],
download: item[1],
}))
}
/** 保存下载器编辑结果并通知父级刷新。 */
async function saveDownloaderInfo() {
const { valid } = (await downloaderForm.value?.validate()) || { valid: true }
if (!valid) return
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
if (!downloaderInfo.value.name) {
$toast.error(t('downloader.nameRequired'))
return
}
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
$toast.error(t('downloader.nameDuplicate'))
return
}
if (downloaderInfo.value.default) {
props.downloaders.forEach(item => {
if (item.default && item !== props.downloader) {
item.default = false
$toast.info(t('downloader.defaultChanged'))
}
})
}
downloaderInfoDialog.value = false
emit('change', downloaderInfo.value, props.downloader.name)
emit('done')
}
/** 新增一行路径映射。 */
function addPathMapping() {
pathMappingRows.value.push({
id: generateId(),
storage: '',
download: '',
})
}
/** 移除指定位置的路径映射。 */
function removePathMapping(index: number) {
pathMappingRows.value.splice(index, 1)
}
onMounted(() => {
initializeDownloaderInfo()
})
</script>
<template>
<VDialog
v-if="downloaderInfoDialog"
v-model="downloaderInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-download" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.downloader.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm ref="downloaderForm">
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.default"
:label="t('downloader.default')"
:disabled="!downloaderInfo.enabled"
/>
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="downloaderInfo.config.apikey"
type="password"
:label="t('downloader.apiKey')"
:hint="t('downloader.qbittorrentApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.category"
:label="t('downloader.category')"
:hint="t('downloader.category')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.sequentail"
:label="t('downloader.sequentail')"
:hint="t('downloader.sequentail')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.force_resume"
:label="t('downloader.force_resume')"
:hint="t('downloader.force_resume')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.first_last_piece"
:label="t('downloader.first_last_piece')"
:hint="t('downloader.first_last_piece')"
persistent-hint
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">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
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">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port/RPC2"
:hint="t('downloader.rtorrentHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.type"
:label="t('downloader.type')"
:hint="t('downloader.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:hint="t('downloader.nameRequired')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VDivider class="my-2">
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
</VDivider>
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
</div>
<VCard
v-for="(row, index) in pathMappingRows"
:key="row.id"
variant="outlined"
class="path-mapping-card my-2"
>
<VCardText class="pa-3">
<VRow align="center" no-gutters>
<VCol cols="12" class="mb-2">
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
</div>
<VRow no-gutters>
<VCol cols="12" sm="4" class="path-storage-select-col pe-sm-2">
<VSelect
:model-value="getStorageType(row.storage)"
:items="prefixOptions"
density="compact"
variant="outlined"
hide-details
@update:model-value="v => updateStoragePrefix(row, v)"
/>
</VCol>
<VCol cols="12" sm="8">
<VTextField
:model-value="parseStoragePath(row.storage)[1]"
:placeholder="'/path/to/storage'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
@update:model-value="v => updateStorageSuffix(row, v)"
/>
</VCol>
</VRow>
</VCol>
<VCol cols="12" class="mb-1">
<div class="d-flex align-center justify-center my-1">
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
</div>
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
</div>
<VRow no-gutters>
<VCol cols="12" sm="4" class="d-none d-sm-block" />
<VCol cols="12" sm="8">
<VTextField
v-model="row.download"
:placeholder="'/path/to/download'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
/>
</VCol>
</VRow>
</VCol>
<VCol cols="12" class="d-flex justify-end pt-1">
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</VCol>
</VRow>
</VCardText>
</VCard>
<VBtn
variant="tonal"
color="primary"
prepend-icon="mdi-plus-circle-outline"
@click="addPathMapping"
class="mt-1"
size="small"
>
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
<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>
</VCard>
</VDialog>
</template>
<style scoped>
.path-mapping-card {
border-color: rgba(var(--v-border-color), 0.08) !important;
}
@media (max-width: 599.98px) {
.path-storage-select-col {
margin-block-end: 8px;
}
}
</style>

View File

@@ -0,0 +1,70 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
name: {
type: String,
default: '',
},
})
const emit = defineEmits<{
(event: 'close'): void
(event: 'create'): void
(event: 'update:modelValue', value: boolean): void
(event: 'update:name', value: string): void
}>()
const { t } = useI18n()
const dialogVisible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const folderName = computed({
get: () => props.name,
set: value => emit('update:name', value),
})
// 关闭新建目录弹窗并通知共享弹窗 Host 回收实例。
function closeDialog() {
emit('close')
emit('update:modelValue', false)
}
</script>
<template>
<VDialog v-model="dialogVisible" max-width="35rem">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-folder-plus-outline" class="me-2" />
</template>
<VCardTitle>{{ t('file.newFolder') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="closeDialog" />
<VDivider />
<VCardText>
<VTextField v-model="folderName" :label="t('common.name')" prepend-inner-icon="mdi-format-text" />
</VCardText>
<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>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,102 @@
<script lang="ts" setup>
import type { FileItem } from '@/api/types'
import { useI18n } from 'vue-i18n'
const props = defineProps({
item: Object as PropType<FileItem>,
loading: {
type: Boolean,
default: false,
},
modelValue: {
type: Boolean,
default: true,
},
name: {
type: String,
default: '',
},
recursive: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(event: 'auto-name'): void
(event: 'close'): void
(event: 'rename'): void
(event: 'update:modelValue', value: boolean): void
(event: 'update:name', value: string): void
(event: 'update:recursive', value: boolean): void
}>()
const { t } = useI18n()
const dialogVisible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const renameName = computed({
get: () => props.name,
set: value => emit('update:name', value),
})
const includeSubfolders = computed({
get: () => props.recursive,
set: value => emit('update:recursive', value),
})
// 关闭弹窗并通知共享弹窗 Host 回收当前实例。
function closeDialog() {
emit('close')
emit('update:modelValue', false)
}
</script>
<template>
<VDialog v-model="dialogVisible" max-width="35rem">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-pencil" class="me-2" />
</template>
<VCardTitle>{{ t('file.rename') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="closeDialog" />
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
v-model="renameName"
:label="t('file.newName')"
:loading="loading"
prepend-inner-icon="mdi-format-text"
/>
</VCol>
<VCol v-if="item && item.type == 'dir'" cols="12">
<VSwitch v-model="includeSubfolders" :label="t('file.includeSubfolders')" />
</VCol>
</VRow>
</VCardText>
<VCardActions class="app-dialog-actions">
<VBtn color="success" variant="tonal" prepend-icon="mdi-magic" @click="emit('auto-name')">
{{ t('file.autoRecognizeName') }}
</VBtn>
<VSpacer />
<VBtn
color="primary"
variant="flat"
:disabled="!renameName"
prepend-icon="mdi-check"
class="px-5"
@click="emit('rename')"
>
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,319 @@
<script lang="ts" setup>
import { copyToClipboard } from '@/@core/utils/navigator'
import { CustomRule, FilterRuleGroup } from '@/api/types'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useToast } from 'vue-toastification'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 获取i18n实例
const { t } = useI18n()
// 规则组详情弹窗内才需要拖拽和导入代码,避免规则组卡片列表首屏带入重交互依赖。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const ImportCodeDialog = defineAsyncComponent(() => import('@/components/dialog/ImportCodeDialog.vue'))
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
// 单个规则组
group: {
type: Object as PropType<FilterRuleGroup>,
required: true,
},
// 所有规则组
groups: {
type: Array as PropType<FilterRuleGroup[]>,
required: true,
},
// 媒体类型字典
categories: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
// 自定义规则列表
custom_rules: Array as PropType<CustomRule[]>,
})
// 规则卡片类型
interface FilterCard {
// 优先级
pri: string
// 已选规则
rules: string[]
}
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
// 规则详情弹窗
const groupInfoDialog = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 规则详情
const groupInfo = ref<FilterRuleGroup>({
name: props.group?.name ?? '',
rule_string: props.group?.rule_string ?? '',
media_type: props.group?.media_type ?? '',
category: props.group?.category ?? '',
})
// 媒体类型字典
const mediaTypeItems = [
{ title: t('common.all'), value: '' },
{ title: t('mediaType.movie'), value: '电影' },
{ title: t('mediaType.tv'), value: '电视剧' },
]
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: t('common.all'), value: '' }]
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
return default_value
}
return default_value.concat(props.categories[groupInfo.value.media_type] || [])
})
// 规则组规则卡片列表
const filterRuleCards = ref<FilterCard[]>([])
/** 更新指定优先级规则卡片的选中规则。 */
function updateFilterCardValue(pri: string, rules: string[]) {
const card = filterRuleCards.value.find(card => card.pri === pri)
if (card && Array.isArray(rules)) card.rules = rules
}
/** 移除指定优先级规则卡片并重排优先级。 */
function filterCardClose(pri: string) {
filterRuleCards.value = filterRuleCards.value
.filter(card => card.pri !== pri)
.map((card, index) => {
card.pri = (index + 1).toString()
return card
})
}
/** 将当前规则组规则串复制到剪贴板。 */
async function shareRules() {
if (filterRuleCards.value.length === 0) return
const value = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
try {
let success
success = copyToClipboard(value)
if (await success) $toast.success(t('filterRule.shareSuccess'))
else $toast.error(t('filterRule.shareFailed'))
} catch (error) {
$toast.error(t('filterRule.shareFailed'))
console.error(error)
}
}
/** 打开共享导入弹窗并导入规则串。 */
async function importRules(ruleType: string) {
openSharedDialog(
ImportCodeDialog,
{
title: t('filterRule.import'),
dataType: ruleType,
},
{
save: saveCodeString,
},
{ closeOn: ['close', 'save'] },
)
}
/** 保存导入的规则代码并覆盖当前规则卡片。 */
function saveCodeString(type: string, code: any) {
try {
code = code.value
if (type === 'priority') {
// 解析值
if (!code) return
// 首尾增加空格
if (!code.startsWith(' ')) code = ` ${code}`
if (!code.endsWith(' ')) code = `${code} `
const groups = code.split('>')
filterRuleCards.value = groups.map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
}
} catch (error) {
$toast.error(t('filterRule.importFailed'))
console.error(error)
}
}
/** 新增一个空的规则优先级卡片。 */
function addFilterCard() {
const pri = (filterRuleCards.value.length + 1).toString()
const newCard: FilterCard = { pri, rules: [] }
filterRuleCards.value.push(newCard)
}
/** 根据列表的拖动顺序更新优先级。 */
function dragOrderEnd() {
filterRuleCards.value.forEach((card, index) => {
card.pri = (index + 1).toString()
})
}
/** 初始化规则组编辑数据。 */
function opengroupInfoDialog() {
groupInfo.value = cloneDeep(props.group)
if (props.group.rule_string) {
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
}
groupInfoDialog.value = true
}
/** 保存规则组编辑结果并通知父级刷新。 */
function saveGroupInfo() {
if (!groupInfo.value.name.trim()) {
$toast.error(t('filterRule.nameRequired'))
return
}
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
$toast.error(t('filterRule.nameDuplicate'))
return
}
groupInfoDialog.value = false
groupInfo.value.rule_string = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
emit('change', groupInfo.value, props.group.name)
emit('done')
}
/** 关闭规则组编辑弹窗。 */
function onClose() {
emit('close')
}
onMounted(() => {
opengroupInfoDialog()
})
</script>
<template>
<VDialog
v-if="groupInfoDialog"
v-model="groupInfoDialog"
scrollable
max-width="80rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`">
<VDialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardItem class="pt-1">
<VRow class="mt-1">
<VCol cols="12" md="6">
<VTextField
v-model="groupInfo.name"
:label="t('filterRule.groupName')"
:placeholder="t('filterRule.nameRequired')"
:hint="t('filterRule.groupName')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="6" md="3">
<VAutocomplete
v-model="groupInfo.media_type"
:label="t('filterRule.mediaType')"
:items="mediaTypeItems"
:hint="t('filterRule.mediaType')"
persistent-hint
active
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="6" md="3">
<VAutocomplete
v-model="groupInfo.category"
:items="getCategories"
:label="t('filterRule.category')"
:hint="t('filterRule.category')"
persistent-hint
active
prepend-inner-icon="mdi-folder-open"
/>
</VCol>
</VRow>
</VCardItem>
<VCardText>
<Draggable
v-model="filterRuleCards"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="dragOrderEnd"
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
>
<template #item="{ element }">
<FilterRuleCard
:pri="element.pri"
:maxpri="filterRuleCards.length.toString()"
:rules="element.rules"
:custom_rules="props.custom_rules"
@changed="updateFilterCardValue"
@close="filterCardClose(element.pri)"
/>
</template>
</Draggable>
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
</VCardText>
<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"
variant="tonal"
class="app-dialog-actions__icon-btn"
@click="importRules('priority')"
>
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" variant="tonal" class="app-dialog-actions__icon-btn" @click="shareRules">
<VIcon icon="mdi-share" />
</VBtn>
<VSpacer />
<VBtn color="primary" variant="flat" @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

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

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { LlmProviderAuthSession } from '@/composables/useLlmProviderDirectory'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
authSession?: LlmProviderAuthSession | null
modelValue?: boolean
polling?: boolean
popupBlocked?: boolean
}>(),
{
authSession: null,
modelValue: true,
polling: false,
popupBlocked: false,
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'openAuthPage'): void
(event: 'poll'): void
(event: 'update:modelValue', value: boolean): void
}>()
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 关闭授权弹窗并通知调用方停止轮询。
function closeDialog() {
visible.value = false
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="560">
<VCard>
<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 }}
</VAlert>
<VAlert v-if="props.popupBlocked" type="warning" variant="tonal">
{{ t('setting.system.llmProviderPopupBlocked') }}
</VAlert>
<div v-if="props.authSession?.user_code">
<div class="text-caption text-medium-emphasis mb-1">{{ t('setting.system.llmProviderDeviceCode') }}</div>
<div class="text-h5 font-weight-bold">{{ props.authSession.user_code }}</div>
</div>
<div v-if="props.authSession?.message" class="text-body-2">
{{ props.authSession.message }}
</div>
<div class="d-flex flex-wrap ga-2">
<VBtn color="primary" prepend-icon="mdi-open-in-new" @click="emit('openAuthPage')">
{{ t('setting.system.llmProviderOpenAuthPage') }}
</VBtn>
<VBtn variant="tonal" prepend-icon="mdi-refresh" :loading="props.polling" @click="emit('poll')">
{{ t('setting.system.llmProviderCheckAuthStatus') }}
</VBtn>
</div>
</VCardText>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" class="px-5" @click="closeDialog">
{{ t('common.close') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
errorMessage?: string
modelValue?: boolean
otpPassword?: string
passkeyLoading?: boolean
}>(),
{
errorMessage: '',
modelValue: true,
otpPassword: '',
passkeyLoading: false,
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'otp'): void
(event: 'passkey'): void
(event: 'update:modelValue', value: boolean): void
(event: 'update:otpPassword', value: string): void
}>()
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
const otpValue = computed({
get: () => props.otpPassword,
set: value => emit('update:otpPassword', value),
})
// 提交 OTP 登录请求。
function submitOtp() {
emit('otp')
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="400" persistent>
<VCard>
<VCardTitle class="text-h5 text-center mt-4 pb-2">{{ t('login.secondaryVerification') }}</VCardTitle>
<VCardText class="pt-0">
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
<VCard variant="tonal" class="mb-3">
<VCardText>
<VForm @submit.prevent="submitOtp">
<VTextField
v-model="otpValue"
:label="t('login.otpCode')"
:placeholder="t('login.otpPlaceholder')"
type="text"
name="otp"
id="otp"
autocomplete="one-time-code"
inputmode="numeric"
prepend-inner-icon="mdi-shield-key"
class="mb-2"
/>
<VBtn block type="submit" color="primary" :disabled="!otpValue">
{{ t('login.loginWithOtp') }}
</VBtn>
</VForm>
</VCardText>
</VCard>
<VCard variant="tonal">
<VCardText>
<p class="text-body-2 mb-2">{{ t('login.orUsePasskey') }}</p>
<VBtn
block
variant="tonal"
color="success"
class="passkey-btn"
prepend-icon="material-symbols:passkey"
:loading="props.passkeyLoading"
@click="emit('passkey')"
>
{{ t('login.verifyWithPasskey') }}
</VBtn>
</VCardText>
</VCard>
<VAlert v-if="props.errorMessage" type="error" variant="tonal" class="mt-3">
{{ props.errorMessage }}
</VAlert>
<VBtn block variant="text" class="mt-4" @click="visible = false">{{ t('common.cancel') }}</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,608 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
import { cloneDeep } from 'lodash-es'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 获取i18n实例
const { t } = useI18n()
// 定义输入
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
mediaserver: {
type: Object as PropType<MediaServerConf>,
required: true,
},
mediaservers: {
type: Array as PropType<MediaServerConf[]>,
required: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'done', 'change'])
// 提示框
const $toast = useToast()
// 媒体服务器详情弹窗
const mediaServerInfoDialog = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 媒体服务器详情
const mediaServerInfo = ref<MediaServerConf>({
name: '',
type: '',
enabled: false,
config: {},
})
// 同步媒体库选项
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
{
title: t('common.all'),
value: 'all',
},
])
const ugreenScanModeOptions = computed(() => [
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
])
/** 初始化媒体服务器编辑表单数据。 */
function initializeMediaServerInfo() {
loadLibrary(props.mediaserver.name)
mediaServerInfo.value = cloneDeep(props.mediaserver)
if (mediaServerInfo.value.type === 'ugreen') {
mediaServerInfo.value.config = mediaServerInfo.value.config || {}
if (!mediaServerInfo.value.config.scan_mode) {
mediaServerInfo.value.config.scan_mode = 'supplement_missing'
}
if (mediaServerInfo.value.config.verify_ssl === undefined) {
mediaServerInfo.value.config.verify_ssl = true
}
}
if (!props.mediaserver.sync_libraries) {
mediaServerInfo.value.sync_libraries = ['all']
}
}
/** 保存媒体服务器编辑结果并通知父级刷新。 */
function saveMediaServerInfo() {
if (!mediaServerInfo.value.name) {
$toast.error(t('common.nameRequired'))
return
}
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
$toast.error(t('common.nameExists', { name: mediaServerInfo.value.name }))
return
}
mediaServerInfoDialog.value = false
emit('change', mediaServerInfo.value, props.mediaserver.name)
emit('done')
}
/** 调用 API 查询指定媒体服务器的媒体库列表。 */
async function loadLibrary(server: string) {
try {
const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })
if (result && result.length > 0) {
librariesOptions.value = result.map(item => ({
title: item.name,
value: item.id?.toString(),
}))
} else {
librariesOptions.value = []
}
librariesOptions.value.unshift({
title: t('common.all'),
value: 'all',
})
} catch (e) {
console.log(e)
}
}
onMounted(() => {
initializeMediaServerInfo()
})
</script>
<template>
<VDialog
v-if="mediaServerInfoDialog"
v-model="mediaServerInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-cog" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.mediaserver.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="mediaServerInfo.enabled" :label="t('mediaserver.enableMediaServer')" />
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'emby'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
:hint="t('mediaserver.usernameHint')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.embyApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'zspace'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
:hint="t('mediaserver.usernameHint')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'jellyfin'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.jellyfinApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'trimemedia'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'ugreen'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="mediaServerInfo.config.scan_mode"
:label="t('mediaserver.scanMode')"
:items="ugreenScanModeOptions"
:hint="t('mediaserver.scanModeHint')"
persistent-hint
active
prepend-inner-icon="mdi-radar"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="mediaServerInfo.config.verify_ssl"
:label="t('mediaserver.verifySsl')"
:hint="t('mediaserver.verifySslHint')"
persistent-hint
color="primary"
inset
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'plex'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.token"
:label="t('mediaserver.plexToken')"
:hint="t('mediaserver.plexTokenHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.type"
:label="t('mediaserver.type')"
:hint="t('mediaserver.customTypeHint')"
persistent-hint
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
:label="t('common.name')"
:hint="t('mediaserver.nameRequired')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<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>
</VCard>
</VDialog>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const display = useDisplay()
const props = withDefaults(
defineProps<{
content?: string
editorTheme?: string
modelValue?: boolean
subtitle?: string
templateType?: string
}>(),
{
content: '{}',
editorTheme: 'monokai',
modelValue: true,
subtitle: '',
templateType: '',
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'save', value: string): void
(event: 'update:content', value: string): void
(event: 'update:modelValue', value: boolean): void
}>()
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
const editableContent = ref(props.content)
const editorOptions = {
displayIndentGuides: true,
fontSize: 14,
highlightActiveLine: true,
scrollPastEnd: 0.2,
showPrintMargin: false,
tabSize: 2,
}
watch(
() => props.content,
value => {
editableContent.value = value
},
)
watch(editableContent, value => {
emit('update:content', value)
})
// 提交通知模板内容,由调用方负责保存到后端。
function submitTemplate() {
emit('save', editableContent.value)
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="notification-template-editor-dialog">
<VCardItem class="template-editor-header py-3">
<template #prepend>
<VAvatar color="primary" variant="tonal" rounded size="40" class="me-2">
<VIcon icon="mdi-code-json" size="22" />
</VAvatar>
</template>
<VCardTitle>
{{ t('setting.notification.templateConfigTitle') }}
</VCardTitle>
<VCardSubtitle>
{{ props.subtitle }}
</VCardSubtitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<div class="template-editor-body">
<VAceEditor
:key="`${props.templateType}-jinja2-json`"
v-model:value="editableContent"
lang="jinja2_json"
:theme="props.editorTheme"
:options="editorOptions"
wrap
class="template-ace-editor"
/>
</div>
<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>
</VCard>
</VDialog>
</template>
<style scoped>
.notification-template-editor-dialog {
display: flex;
flex-direction: column;
max-block-size: calc(100dvh - 2rem);
overflow: hidden;
}
.template-editor-header {
flex: 0 0 auto;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.template-editor-body {
flex: 1 1 auto;
min-block-size: 0;
}
.template-ace-editor {
overflow: hidden;
background: rgb(var(--v-theme-surface));
block-size: min(62vh, 34rem);
inline-size: 100%;
}
.template-editor-actions {
flex: 0 0 auto;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 0.875rem;
padding-inline: 1rem;
}
@media (width <= 960px) {
.notification-template-editor-dialog {
block-size: 100dvh;
max-block-size: 100dvh;
}
.template-editor-body {
display: flex;
flex-direction: column;
}
.template-ace-editor {
flex: 1 1 auto;
min-block-size: 0;
block-size: auto;
}
.template-editor-actions {
padding-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.75rem));
}
}
</style>

View File

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

View File

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

View File

@@ -206,6 +206,7 @@ watch(
passkeyList.value = []
}
},
{ immediate: true },
)
</script>
@@ -298,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

@@ -0,0 +1,173 @@
<script setup lang="ts">
import type { Plugin } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 多语言
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
plugin: {
type: Object as PropType<Plugin>,
required: true,
},
loading: {
type: Boolean,
default: false,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'clone'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 插件分身表单
const cloneForm = ref({
suffix: '',
name: '',
description: '',
version: '',
icon: '',
})
/** 初始化插件分身表单。 */
function initializeCloneForm() {
cloneForm.value = {
suffix: '',
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
version: props.plugin?.plugin_version || '1.0',
icon: props.plugin?.plugin_icon || '',
}
}
/** 提交插件分身表单。 */
function submitClone() {
emit('clone', { ...cloneForm.value })
}
onMounted(() => {
initializeCloneForm()
})
</script>
<template>
<VDialog v-if="visible" v-model="visible" width="600" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-content-copy" class="me-2" />
</template>
<VCardTitle>{{ t('plugin.cloneTitle') }}</VCardTitle>
<VCardSubtitle>{{ t('plugin.cloneSubtitle', { name: props.plugin?.plugin_name }) }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="visible" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.suffix"
:label="t('plugin.suffix') + ' *'"
:placeholder="t('plugin.suffixPlaceholder')"
:hint="t('plugin.suffixHint')"
persistent-hint
:rules="[
v => !!v || t('plugin.suffixRequired'),
v => /^[a-zA-Z0-9]+$/.test(v) || t('plugin.suffixFormatError'),
v => v.length <= 20 || t('plugin.suffixLengthError'),
]"
required
prepend-inner-icon="mdi-tag"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.name"
:label="t('plugin.cloneName')"
:placeholder="t('plugin.cloneNamePlaceholder')"
:hint="t('plugin.cloneNameHint')"
persistent-hint
prepend-inner-icon="mdi-rename-box"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="cloneForm.description"
:label="t('plugin.cloneDescriptionLabel')"
:placeholder="t('plugin.cloneDescriptionPlaceholder')"
:hint="t('plugin.cloneDescriptionHint')"
persistent-hint
prepend-inner-icon="mdi-text"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.version"
:label="t('plugin.cloneVersion')"
:placeholder="t('plugin.cloneVersionPlaceholder')"
:hint="t('plugin.cloneVersionHint')"
persistent-hint
prepend-inner-icon="mdi-numeric"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.icon"
:label="t('plugin.cloneIcon')"
:placeholder="t('plugin.cloneIconPlaceholder')"
:hint="t('plugin.cloneIconHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
<VCol cols="12">
<VAlert type="warning" variant="tonal" density="compact" class="mt-2" icon="mdi-alert-circle-outline">
<div class="text-body-2">
<strong>{{ t('common.notice') }}</strong
>{{ t('plugin.cloneNotice') }}
</div>
</VAlert>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
@click="submitClone"
prepend-icon="mdi-content-copy"
class="px-5"
:disabled="!cloneForm.suffix.trim()"
:loading="props.loading"
>
{{ t('plugin.createClone') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

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

@@ -0,0 +1,65 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
name: {
type: String,
default: '',
},
})
const emit = defineEmits<{
(event: 'close'): void
(event: 'create'): void
(event: 'update:modelValue', value: boolean): void
(event: 'update:name', value: string): void
}>()
const { t } = useI18n()
const dialogVisible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const folderName = computed({
get: () => props.name,
set: value => emit('update:name', value),
})
// 关闭插件文件夹新建弹窗。
function closeDialog() {
emit('close')
emit('update:modelValue', false)
}
</script>
<template>
<VDialog v-model="dialogVisible" max-width="400">
<VCard>
<VDialogCloseBtn @click="closeDialog" />
<VCardItem>
<VCardTitle>{{ t('plugin.newFolder') }}</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VTextField
v-model="folderName"
:label="t('plugin.folderName')"
variant="outlined"
@keyup.enter="emit('create')"
/>
</VCardText>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
{{ t('plugin.create') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
// 多语言
const { t } = useI18n()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
folderName: {
type: String,
default: '',
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'rename'])
// 新名称
const newFolderName = ref(props.folderName)
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
/** 提交文件夹重命名。 */
function confirmRename() {
emit('rename', newFolderName.value)
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="400">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-pencil" class="me-2" />
</template>
<VCardTitle>{{ t('folder.renameFolder') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn v-model="visible" />
<VDivider />
<VCardText>
<VTextField
v-model="newFolderName"
:label="t('folder.folderName')"
variant="outlined"
autofocus
@keyup.enter="confirmRename"
/>
</VCardText>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
interface FolderConfig {
plugins?: string[]
order?: number
background?: string
icon?: string
color?: string
gradient?: string
showIcon?: boolean
}
// 多语言
const { t } = useI18n()
// 响应式显示
const display = useDisplay()
// 默认颜色
const defaultColor = '#2196F3'
// 默认图标
const defaultIcon = 'mdi-folder'
// 预设图标选项
const iconOptions = [
'mdi-folder',
'mdi-folder-star',
'mdi-folder-heart',
'mdi-folder-cog',
'mdi-folder-music',
'mdi-folder-image',
'mdi-folder-video',
'mdi-folder-download',
'mdi-folder-network',
'mdi-folder-special',
]
// 预设颜色选项
const colorOptions = [
'#2196F3',
'#4CAF50',
'#FF9800',
'#9C27B0',
'#F44336',
'#607D8B',
'#795548',
'#E91E63',
]
// 预设渐变选项
const gradientOptions = [
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(76, 175, 80, 0.7) 0%, rgba(76, 175, 80, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(255, 152, 0, 0.7) 0%, rgba(255, 152, 0, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(156, 39, 176, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(244, 67, 54, 0.7) 0%, rgba(244, 67, 54, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(96, 125, 139, 0.7) 0%, rgba(96, 125, 139, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(233, 30, 99, 0.7) 0%, rgba(233, 30, 99, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(63, 81, 181, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
]
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
folderConfig: {
type: Object as PropType<FolderConfig>,
default: () => ({}),
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'save'])
// 文件夹设置
const folderSettings = ref<FolderConfig>({
background: '',
icon: defaultIcon,
color: defaultColor,
gradient: gradientOptions[0],
showIcon: true,
})
// 设置对话框
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
/** 初始化文件夹外观设置。 */
function initializeSettings() {
folderSettings.value = {
background: props.folderConfig?.background || '',
icon: props.folderConfig?.icon || defaultIcon,
color: props.folderConfig?.color || defaultColor,
gradient: props.folderConfig?.gradient || gradientOptions[0],
showIcon: props.folderConfig?.showIcon !== undefined ? props.folderConfig.showIcon : true,
}
}
/** 保存文件夹外观设置。 */
function saveSettings() {
emit('save', {
...props.folderConfig,
...folderSettings.value,
})
}
onMounted(() => {
initializeSettings()
})
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="600" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn v-model="visible" />
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-palette" class="mr-2" />
{{ t('folder.folderAppearanceSettings') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSwitch v-model="folderSettings.showIcon" :label="t('folder.showFolderIcon')" color="primary" hide-details />
</VCol>
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.icon') }}</VCardSubtitle>
<div class="icon-grid">
<VBtn
v-for="icon in iconOptions"
icon
:key="icon"
:variant="folderSettings.icon === icon ? 'tonal' : 'text'"
:color="folderSettings.icon === icon ? 'primary' : 'default'"
size="large"
class="ma-1"
@click="folderSettings.icon = icon"
>
<VIcon :icon="icon" size="24" />
</VBtn>
</div>
</VCol>
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.iconColor') }}</VCardSubtitle>
<div class="color-grid">
<VBtn
v-for="color in colorOptions"
:key="color"
:variant="folderSettings.color === color ? 'tonal' : 'text'"
:color="color"
size="large"
class="ma-1 color-btn"
:style="{ backgroundColor: color }"
@click="folderSettings.color = color"
>
<VIcon v-if="folderSettings.color === color" icon="mdi-check" color="white" />
</VBtn>
</div>
</VCol>
<VCol cols="12">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.backgroundGradient') }}</VCardSubtitle>
<div class="gradient-grid">
<VBtn
v-for="(gradient, index) in gradientOptions"
:key="index"
:variant="folderSettings.gradient === gradient ? 'tonal' : 'text'"
class="ma-1 gradient-btn"
:style="{ background: gradient }"
size="large"
@click="folderSettings.gradient = gradient"
>
<VIcon v-if="folderSettings.gradient === gradient" icon="mdi-check" color="white" />
</VBtn>
</div>
</VCol>
<VCol cols="12">
<VTextField
v-model="folderSettings.background"
:label="t('folder.customBackgroundImageURL')"
placeholder="https://example.com/image.jpg"
variant="outlined"
:hint="t('folder.customBackgroundImageHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">
保存
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import type { Plugin } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
const LoggingView = defineAsyncComponent(() => import('@/views/system/LoggingView.vue'))
// 多语言
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
plugin: {
type: Object as PropType<Plugin>,
required: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
/** 打开当前插件日志的新窗口。 */
function openLoggerWindow() {
const url = `${
import.meta.env.VITE_API_BASE_URL
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
window.open(url, '_blank')
}
/** 下载当前插件日志压缩包。 */
function downloadLogger() {
const url = `${import.meta.env.VITE_API_BASE_URL}system/logging/download/${props.plugin?.id?.toLowerCase()}`
window.open(url, '_blank')
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" scrollable max-width="72rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn v-model="visible" />
<VCardItem>
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('plugin.logTitle') }}
<span class="ms-4 d-inline-flex align-center ga-1">
<a class="d-inline-flex align-center cursor-pointer" @click="downloadLogger">
<VChip color="grey-darken-1" size="small">
<VIcon icon="mdi-download" size="small" start />
{{ t('common.download') }}
</VChip>
</a>
<a class="d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
<VChip color="grey-darken-1" size="small">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</span>
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,214 @@
<script lang="ts" setup>
import api from '@/api'
import type { Plugin } from '@/api/types'
import { formatDownloadCount } from '@/@core/utils/formatters'
import { getLogoUrl } from '@/utils/imageUtils'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
// 多语言
const { t } = useI18n()
// 提示框
const $toast = useToast()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
plugin: {
type: Object as PropType<Plugin>,
required: true,
},
count: Number,
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'install'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 图片对象
const imageRef = ref<any>()
// 图片是否加载失败
const imageLoadError = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 打开插件安装进度弹窗。 */
function showInstallProgress(text: string) {
progressDialogController?.close()
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
}
/** 关闭插件安装进度弹窗。 */
function closeInstallProgress() {
progressDialogController?.close()
progressDialogController = null
}
/** 计算插件图标路径。 */
function pluginIconPath() {
if (imageLoadError.value) return getLogoUrl('plugin')
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.plugin_icon,
)}&cache=true`
return `./plugin_icon/${props.plugin?.plugin_icon}`
}
/** 访问插件项目或作者页面。 */
function visitPluginPage() {
let repoUrl = props.plugin?.repo_url
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
repoUrl = props.plugin?.author_url
}
if (repoUrl) {
if (repoUrl.includes('raw.githubusercontent.com')) {
if (!repoUrl.endsWith('/')) repoUrl += '/'
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
try {
const [user, repo] = repoUrl.split('/').slice(-4, -2)
repoUrl = `https://github.com/${user}/${repo}`
} catch (error) {
return
}
}
} else {
repoUrl = props.plugin?.author_url
}
window.open(repoUrl, '_blank')
}
/** 安装插件并通知父级刷新市场列表。 */
async function installPlugin() {
if (props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
try {
showInstallProgress(
t('plugin.installing', {
name: props.plugin?.plugin_name,
version: 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,
},
})
closeInstallProgress()
if (result.success) {
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
visible.value = false
emit('install')
} else {
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
closeInstallProgress()
console.error(error)
}
}
onUnmounted(() => {
closeInstallProgress()
})
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="30rem">
<VCard>
<VDialogCloseBtn v-model="visible" />
<VCardText>
<VCol>
<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" />
</VAvatar>
</div>
<div class="flex-grow">
<VCardItem>
<VCardTitle class="text-center text-md-left">
{{ props.plugin?.plugin_name }}
</VCardTitle>
<VCardSubtitle
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ..."
>
{{ props.plugin?.plugin_desc }}
</VCardSubtitle>
<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>
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('common.author') }}</span>
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
{{ props.plugin?.plugin_author }}
</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="props.plugin?.system_version" class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('plugin.systemVersion') }}</span>
<span class="text-body-1">{{ props.plugin?.system_version }}</span>
</VListItemTitle>
</VListItem>
</VList>
<VAlert
v-if="props.plugin?.system_version_compatible === false"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<div class="text-center text-md-left">
<VBtn
color="primary"
@click="installPlugin"
prepend-icon="mdi-download"
:disabled="props.plugin?.system_version_compatible === false"
>
{{ t('plugin.installToLocal') }}
</VBtn>
<div class="text-xs mt-2" v-if="props.count">
<VIcon icon="mdi-fire" />
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
</div>
</div>
</VCardItem>
</div>
</div>
</VCol>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -10,27 +10,124 @@ const display = useDisplay()
const { t } = useI18n()
const $toast = useToast()
type EditorMode = 'list' | 'text'
interface RepoParseResult {
repos: string[]
invalidRepos: string[]
duplicateRepos: string[]
}
const editorMode = ref<EditorMode>('list')
const repoList = ref<string[]>([])
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 saveDisabled = computed(
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
)
/** 判断仓库地址是否为可保存的 HTTP URL。 */
function isValidRepoUrl(url: string) {
return /^https?:\/\//i.test(url)
}
/** 将粘贴的仓库地址文本解析为有效、无效和重复地址列表。 */
function parseRepoInput(value: string): RepoParseResult {
const repos: string[] = []
const invalidRepos: string[] = []
const duplicateRepos: string[] = []
const seenRepos = new Set<string>()
value
.split(/[\n,]+/)
.map(repo => repo.trim())
.filter(Boolean)
.forEach(repo => {
if (!isValidRepoUrl(repo)) {
invalidRepos.push(repo)
return
}
if (seenRepos.has(repo)) {
duplicateRepos.push(repo)
return
}
seenRepos.add(repo)
repos.push(repo)
})
return {
repos,
invalidRepos,
duplicateRepos: [...new Set(duplicateRepos)],
}
}
/** 将列表模式中的仓库地址同步到文本模式。 */
function syncTextFromList() {
repoText.value = repoList.value.join('\n')
}
/** 将文本模式中的仓库地址同步到列表模式,并忽略无法加入列表的无效地址。 */
function syncListFromText() {
const result = parseRepoInput(repoText.value)
repoList.value = result.repos
syncTextFromList()
if (result.invalidRepos.length > 0) {
$toast.warning(t('dialog.pluginMarketSetting.invalidTextIgnored', { count: result.invalidRepos.length }))
}
}
/** 切换仓库维护模式,并在切换时同步当前模式的编辑内容。 */
function switchEditorMode(mode: EditorMode | undefined) {
if (!mode || mode === editorMode.value) return
if (editorMode.value === 'text') {
syncListFromText()
}
if (mode === 'text') {
syncTextFromList()
}
editorMode.value = mode
}
/** 加载插件市场仓库配置。 */
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 = result.data.value.split(',').filter((repo: string) => repo.trim() !== '')
repoList.value = parseRepoInput(result.data.value).repos
syncTextFromList()
}
} catch (error) {
console.log(error)
}
}
/** 保存插件市场仓库配置。 */
async function saveHandle() {
try {
const repoStringToSave = repoList.value.join(',')
const reposToSave = normalizeCurrentRepos()
if (!reposToSave) return
const repoStringToSave = reposToSave.join(',')
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)
if (result.success) {
@@ -42,62 +139,125 @@ 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') {
const result = parseRepoInput(repoText.value)
if (result.invalidRepos.length > 0) {
$toast.error(t('dialog.pluginMarketSetting.invalidText', { count: result.invalidRepos.length }))
return null
}
repoList.value = result.repos
syncTextFromList()
return result.repos
}
return repoList.value
}
/** 校验单个仓库地址是否可以加入或更新到列表。 */
function validateRepoUrl(url: string, editingRepoIndex: number | null = null) {
if (!url) return false
if (!isValidRepoUrl(url)) {
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
return false
}
const duplicated = repoList.value.some((repo, index) => repo === url && index !== editingRepoIndex)
if (duplicated) {
$toast.error(t('dialog.pluginMarketSetting.duplicateUrl'))
return false
}
return true
}
/** 添加一个仓库地址到列表。 */
function addRepo() {
const url = newRepoUrl.value.trim()
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
return
}
if (repoList.value.includes(url)) {
$toast.error(t('dialog.pluginMarketSetting.duplicateUrl'))
return
}
if (!validateRepoUrl(url)) return
repoList.value.push(url)
newRepoUrl.value = ''
syncTextFromList()
}
/** 从列表中删除一个仓库地址。 */
function removeRepo(index: number) {
repoList.value.splice(index, 1)
syncTextFromList()
}
/** 进入指定仓库地址的行内编辑状态。 */
function startEdit(index: number) {
editingIndex.value = index
editingUrl.value = repoList.value[index]
}
function saveEdit() {
if (editingIndex.value === null) return
/** 保存当前行内编辑的仓库地址。 */
function saveEdit(index = editingIndex.value) {
if (index === null) return
const url = editingUrl.value.trim()
if (!url) return
if (!validateRepoUrl(url, index)) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
return
}
repoList.value[editingIndex.value] = url
repoList.value[index] = url
syncTextFromList()
editingIndex.value = null
editingUrl.value = ''
}
/** 取消当前行内编辑状态。 */
function cancelEdit() {
editingIndex.value = null
editingUrl.value = ''
}
/** 将仓库地址格式化为更易扫描的显示名称。 */
function formatRepoDisplay(url: string) {
try {
const parsedUrl = new URL(url)
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$/, '')}`
}
@@ -108,6 +268,7 @@ function formatRepoDisplay(url: string) {
return url
}
/** 返回拖拽列表项的稳定键。 */
function repoItemKey(repo: string) {
return repo
}
@@ -118,108 +279,224 @@ onMounted(() => {
</script>
<template>
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="56rem" :fullscreen="!display.mdAndUp.value">
<VCard class="plugin-market-dialog-card">
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-store-cog" class="me-2" />
{{ t('dialog.pluginMarketSetting.title') }}
</VCardTitle>
<VCardItem class="plugin-market-card-item">
<div class="plugin-market-header">
<VCardTitle class="plugin-market-title d-flex align-center pa-0">
<VIcon icon="mdi-store-cog" class="me-2" />
{{ t('dialog.pluginMarketSetting.title') }}
</VCardTitle>
</div>
<VDialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText class="plugin-market-dialog-body pt-4">
<div class="plugin-market-input mb-4">
<VTextField
v-model="newRepoUrl"
density="compact"
:placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')"
prepend-inner-icon="mdi-link-plus"
clearable
@keyup.enter="addRepo"
>
<template #append>
<VBtn icon="mdi-plus" variant="text" color="primary" @click="addRepo" />
</template>
</VTextField>
</div>
<div class="plugin-market-list-wrap">
<VList v-if="repoList.length > 0" class="px-0">
<draggable
v-model="repoList"
:item-key="repoItemKey"
handle=".drag-handle"
animation="200"
:disabled="editingIndex !== null"
>
<template #item="{ element: repo, index }">
<div>
<VListItem class="py-2">
<template #prepend>
<VBtn
icon="mdi-drag-vertical"
size="small"
variant="text"
color="primary"
class="drag-handle me-2"
:disabled="editingIndex !== null"
/>
</template>
<VListItemTitle v-if="editingIndex !== index">
<span class="text-truncate" :title="repo">{{ formatRepoDisplay(repo) }}</span>
</VListItemTitle>
<VTextField
v-else
v-model="editingUrl"
density="compact"
variant="outlined"
hide-details
@keyup.enter="saveEdit"
@keyup.escape="cancelEdit"
/>
<template #append v-if="editingIndex !== index">
<div class="d-flex align-center">
<IconBtn icon="mdi-pencil" size="small" variant="text" @click="startEdit(index)" />
<IconBtn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="removeRepo(index)"
/>
</div>
</template>
<template #append v-else>
<div class="d-flex align-center">
<IconBtn icon="mdi-check" size="small" variant="text" color="success" @click="saveEdit" />
<IconBtn icon="mdi-close" size="small" variant="text" @click="cancelEdit" />
</div>
</template>
</VListItem>
<VDivider v-if="index < repoList.length - 1" class="mx-4" />
</div>
<div class="plugin-market-toolbar">
<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>
</draggable>
</VList>
<div v-else class="text-center text-medium-emphasis py-8">
<VIcon icon="mdi-folder-open-outline" size="48" class="mb-2" />
<div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div>
</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">
<div class="plugin-market-input">
<VTextField
v-model="newRepoUrl"
density="compact"
:placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')"
prepend-inner-icon="mdi-link-plus"
clearable
hide-details
@keyup.enter="addRepo"
>
<template #append>
<VBtn
icon="mdi-plus"
variant="tonal"
color="primary"
:aria-label="t('dialog.pluginMarketSetting.addRepo')"
@click="addRepo"
/>
</template>
</VTextField>
</div>
<div class="plugin-market-list-wrap">
<VList v-if="repoList.length > 0" class="plugin-market-repo-list px-0">
<draggable
v-model="repoList"
:item-key="repoItemKey"
handle=".drag-handle"
animation="200"
:disabled="editingIndex !== null"
@end="syncTextFromList"
>
<template #item="{ element: repo, index }">
<div>
<VListItem class="plugin-market-repo-item py-3">
<template #prepend>
<VBtn
icon="mdi-drag-vertical"
size="small"
variant="text"
color="primary"
class="drag-handle me-2"
:disabled="editingIndex !== null"
/>
</template>
<template v-if="editingIndex !== index">
<VListItemTitle>
<div class="plugin-market-repo-title">
<span class="plugin-market-repo-index">{{ index + 1 }}</span>
<span class="plugin-market-repo-name" :title="repo">{{ formatRepoDisplay(repo) }}</span>
</div>
</VListItemTitle>
<VListItemSubtitle class="plugin-market-repo-url mt-1" :title="repo">
{{ repo }}
</VListItemSubtitle>
</template>
<VTextField
v-else
v-model="editingUrl"
density="compact"
variant="outlined"
hide-details
autofocus
@keyup.enter="saveEdit(index)"
@keyup.escape="cancelEdit"
/>
<template #append v-if="editingIndex !== index">
<div class="d-flex align-center">
<IconBtn icon="mdi-pencil" size="small" variant="text" @click="startEdit(index)" />
<IconBtn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="removeRepo(index)"
/>
</div>
</template>
<template #append v-else>
<div class="d-flex align-center">
<VBtn
icon="mdi-check"
size="small"
variant="text"
color="success"
@click.stop="saveEdit(index)"
/>
</div>
</template>
</VListItem>
<VDivider v-if="index < repoList.length - 1" class="mx-4" />
</div>
</template>
</draggable>
</VList>
<div v-else class="plugin-market-empty text-center text-medium-emphasis">
<VIcon icon="mdi-source-repository-multiple" size="48" class="mb-2" />
<div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div>
</div>
</div>
</div>
<div v-else class="plugin-market-text-panel">
<div class="plugin-market-textarea-field">
<VIcon icon="mdi-text-box-edit-outline" class="plugin-market-textarea-icon" />
<textarea
v-model="repoText"
class="plugin-market-textarea"
:placeholder="t('dialog.pluginMarketSetting.textPlaceholder')"
/>
</div>
<div class="plugin-market-text-hint">
{{ t('dialog.pluginMarketSetting.textHint') }}
</div>
<VAlert
v-if="parsedTextRepos.invalidRepos.length > 0"
type="error"
variant="tonal"
density="compact"
class="plugin-market-invalid-alert"
>
<div>{{ t('dialog.pluginMarketSetting.invalidText', { count: parsedTextRepos.invalidRepos.length }) }}</div>
<div class="text-truncate">
{{ parsedTextRepos.invalidRepos.slice(0, 3).join(', ') }}
</div>
</VAlert>
<VAlert
v-else-if="parsedTextRepos.duplicateRepos.length > 0"
type="warning"
variant="tonal"
density="compact"
>
{{ t('dialog.pluginMarketSetting.duplicateTextIgnored') }}
</VAlert>
</div>
</VCardText>
<VCardActions>
<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"
variant="flat"
@click="saveHandle"
prepend-icon="mdi-content-save-check"
class="px-5 me-3"
:disabled="repoList.length === 0"
class="px-5"
:disabled="saveDisabled"
>
{{ t('dialog.pluginMarketSetting.save') }}
</VBtn>
@@ -232,6 +509,24 @@ onMounted(() => {
.plugin-market-dialog-card {
display: flex;
flex-direction: column;
block-size: min(82vh, 50rem);
}
.plugin-market-card-item {
flex: 0 0 auto;
padding-block: 0.875rem;
}
.plugin-market-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-inline-end: 2rem;
}
.plugin-market-title {
min-inline-size: 0;
}
.plugin-market-dialog-body {
@@ -239,6 +534,87 @@ onMounted(() => {
overflow: hidden;
flex: 1;
flex-direction: column;
gap: 0.875rem;
min-block-size: 0;
padding-block: 0.875rem !important;
}
.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-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;
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));
}
}
.plugin-market-list-panel,
.plugin-market-text-panel {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.5rem;
min-block-size: 0;
}
@@ -248,7 +624,169 @@ onMounted(() => {
.plugin-market-list-wrap {
flex: 1;
background: rgba(var(--v-theme-surface), 0.72);
min-block-size: 0;
overflow-y: auto;
}
.plugin-market-repo-list {
background: transparent;
}
.plugin-market-repo-item {
min-block-size: 4.5rem;
}
.plugin-market-repo-title {
display: flex;
align-items: center;
gap: 0.5rem;
min-inline-size: 0;
}
.plugin-market-repo-name,
.plugin-market-repo-url {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
line-break: anywhere;
-webkit-line-clamp: 2;
overflow-wrap: anywhere;
white-space: normal;
word-break: break-word;
}
.plugin-market-repo-url {
line-height: 1.4;
}
.plugin-market-repo-index {
flex: 0 0 auto;
color: rgba(var(--v-theme-on-surface), 0.48);
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
inline-size: 1.75rem;
}
.plugin-market-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-block-size: 14rem;
}
.plugin-market-textarea-field {
position: relative;
display: flex;
overflow: hidden;
flex: 1;
background: rgba(var(--v-theme-surface), 0.72);
min-block-size: 0;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
&:focus-within {
border-color: rgb(var(--v-theme-primary));
box-shadow: 0 0 0 1px rgb(var(--v-theme-primary));
}
}
.plugin-market-textarea-icon {
position: absolute;
z-index: 1;
color: rgba(var(--v-theme-on-surface), 0.62);
inset-block-start: 1.25rem;
inset-inline-start: 1rem;
pointer-events: none;
}
.plugin-market-textarea {
flex: 1;
border: 0;
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-size: 1rem;
line-height: 1.6;
min-block-size: 0;
outline: none;
overflow-y: auto;
padding-block: 1rem;
padding-inline: 3.25rem 1rem;
resize: none;
white-space: pre-wrap;
word-break: break-word;
}
.plugin-market-text-hint {
flex: 0 0 auto;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 0.8125rem;
line-height: 1.4;
padding-inline: 1rem;
}
.plugin-market-invalid-alert {
:deep(.v-alert__content) {
min-inline-size: 0;
}
}
@media (width <= 600px) {
.plugin-market-dialog-card {
block-size: 100dvh;
}
.plugin-market-card-item {
padding-block: 0.75rem 0.625rem;
padding-inline: 1rem;
}
.plugin-market-header {
align-items: center;
gap: 0.5rem;
padding-inline-end: 2.25rem;
}
.plugin-market-header :deep(.v-card-title) {
font-size: 1.125rem;
line-height: 1.35;
}
.plugin-market-dialog-body {
gap: 0.625rem;
padding-block: 0.75rem !important;
padding-inline: 1rem !important;
}
.plugin-market-toolbar {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.plugin-market-mode-switch {
flex: 0 0 auto;
}
.plugin-market-toolbar-hint {
flex: 1 1 auto;
}
.plugin-market-list-panel,
.plugin-market-text-panel {
gap: 0.625rem;
}
.plugin-market-list-wrap {
min-block-size: 0;
}
.plugin-market-empty {
min-block-size: 10rem;
}
}
</style>

View File

@@ -0,0 +1,137 @@
<script lang="ts" setup>
import { getLogoUrl } from '@/utils/imageUtils'
import type { Plugin } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const props = defineProps({
keyword: {
type: String,
default: '',
},
modelValue: {
type: Boolean,
default: true,
},
plugins: {
type: Array as PropType<Plugin[]>,
default: () => [],
},
})
const emit = defineEmits<{
(event: 'close'): void
(event: 'open-plugin', plugin: Plugin): void
(event: 'update:keyword', value: string): void
(event: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const display = useDisplay()
const pluginIconLoaded = ref<Record<string, boolean>>({})
const dialogVisible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const searchKeyword = computed({
get: () => props.keyword,
set: value => emit('update:keyword', value),
})
// 返回插件图标地址,并在远程图标失败后回退到默认图标。
function pluginIcon(item: Plugin) {
if (pluginIconLoaded.value[item.id || '0'] === false) return getLogoUrl('plugin')
if (item?.plugin_icon?.startsWith('http')) {
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}&cache=true`
}
return `./plugin_icon/${item?.plugin_icon}`
}
// 标记指定插件图标加载失败。
function pluginIconError(item: Plugin) {
pluginIconLoaded.value[item.id || '0'] = false
}
// 获取插件标签列表。
function pluginLabels(label: string | undefined) {
if (!label) return []
return label.split(',')
}
// 关闭搜索弹窗并通知共享弹窗 Host 回收实例。
function closeDialog() {
emit('close')
emit('update:modelValue', false)
}
</script>
<template>
<VDialog
v-model="dialogVisible"
scrollable
max-width="40rem"
:max-height="!display.mdAndUp.value ? '' : '85vh'"
:fullscreen="!display.mdAndUp.value"
>
<VCard class="mx-auto" width="100%">
<VToolbar flat class="p-0">
<VTextField
v-model="searchKeyword"
:label="t('plugin.searchPlugins')"
single-line
:placeholder="t('plugin.searchPlaceholder')"
variant="solo"
prepend-inner-icon="mdi-magnify"
flat
class="mx-1"
/>
</VToolbar>
<VDialogCloseBtn @click="closeDialog" />
<VList v-if="plugins.length > 0" class="plugin-search-list" lines="two">
<VVirtualScroll :items="plugins">
<template #default="{ item }">
<VListItem @click="emit('open-plugin', item)">
<template #prepend>
<VAvatar>
<VImg :src="pluginIcon(item)" @error="pluginIconError(item)">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
</div>
</template>
</VImg>
</VAvatar>
</template>
<VListItemTitle>
{{ item.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ item?.plugin_version }}</span>
<VIcon v-if="item.installed" color="success" icon="mdi-check-circle" class="ms-2" size="small" />
</VListItemTitle>
<VListItemSubtitle>
<VChip
v-for="label in pluginLabels(item.plugin_label)"
:key="label"
variant="tonal"
size="small"
class="me-1 my-1"
color="info"
label
>
{{ label }}
</VChip>
{{ item.plugin_desc }}
</VListItemSubtitle>
</VListItem>
</template>
</VVirtualScroll>
</VList>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>
.plugin-search-list {
border-radius: 0 !important;
}
</style>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import api from '@/api'
import type { Plugin } from '@/api/types'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import { useI18n } from 'vue-i18n'
// 多语言
const { t } = useI18n()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
plugin: {
type: Object as PropType<Plugin>,
required: true,
},
showUpdateAction: {
type: Boolean,
default: false,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'update'])
const loading = ref(false)
const loadError = ref('')
const pluginDetail = ref<Plugin | null>(null)
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
const resolvedPlugin = computed(() => pluginDetail.value ?? props.plugin)
const resolvedHistory = computed(() => resolvedPlugin.value?.history || {})
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
async function loadPluginHistory() {
if (!props.plugin?.id) {
pluginDetail.value = null
loadError.value = ''
return
}
loading.value = true
loadError.value = ''
try {
pluginDetail.value = await api.get(`plugin/history/${props.plugin.id}`, {
params: {
force: true,
},
})
} catch (error) {
pluginDetail.value = null
loadError.value = t('plugin.updateHistoryLoadFailed')
console.error(error)
} finally {
loading.value = false
}
}
/** 触发插件更新操作。 */
function handleUpdate() {
emit('update')
}
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: resolvedPlugin?.plugin_name })">
<VDialogCloseBtn v-model="visible" />
<VDivider />
<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">
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
</VCardText>
<VersionHistory v-else :history="resolvedHistory" />
<template v-if="props.showUpdateAction">
<VDivider />
<VCardItem>
<VAlert
v-if="resolvedPlugin?.system_version_compatible === false"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<VBtn @click="handleUpdate" block :disabled="resolvedPlugin?.system_version_compatible === false">
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
{{ t('plugin.updateToLatest') }}
</VBtn>
</VCardItem>
</template>
</VCard>
</VDialog>
</template>
<style scoped>
.plugin-version-history-dialog__loading {
min-height: 12rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -7,6 +7,9 @@ const props = defineProps({
value: Number,
text: String,
})
// 有明确进度值时显示确定进度,否则显示不确定进度条。
const hasProgressValue = computed(() => typeof props.value === 'number' && Number.isFinite(props.value))
</script>
<template>
<!-- Progress Dialog -->
@@ -14,7 +17,12 @@ const props = defineProps({
<VCard elevation="3" color="primary">
<VCardText class="text-center">
{{ props.text || t('dialog.progress.processing') }}
<VProgressLinear color="white" class="mb-0 mt-1" :model-value="props.value" indeterminate />
<VProgressLinear
color="white"
class="mb-0 mt-1"
:model-value="hasProgressValue ? props.value : undefined"
:indeterminate="!hasProgressValue"
/>
</VCardText>
</VCard>
</VDialog>

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>

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,64 @@
<script lang="ts" setup>
import type { SharedDialogEntry } from '@/composables/useSharedDialog'
import { closeSharedDialog, useSharedDialog } from '@/composables/useSharedDialog'
const { dialogs } = useSharedDialog()
type ReadonlySharedDialogEntry = Readonly<SharedDialogEntry> & {
readonly closeOn: readonly string[]
readonly events: Readonly<SharedDialogEntry['events']>
readonly props: Readonly<SharedDialogEntry['props']>
}
// 关闭弹窗并同步组件自身的 v-model 状态。
function closeEntry(entry: ReadonlySharedDialogEntry) {
closeSharedDialog(entry.id)
}
// 处理弹窗内部 v-model 变化,用户点击遮罩或返回键关闭时也能释放实例。
function handleModelUpdate(entry: ReadonlySharedDialogEntry, value: boolean) {
if (!value) closeSharedDialog(entry.id)
}
// 转发业务事件给调用方,并按配置自动关闭当前弹窗。
function handleDialogEvent(entry: ReadonlySharedDialogEntry, eventName: string, args: any[]) {
entry.events[eventName]?.(...args)
if (entry.closeOn.includes(eventName) && (eventName !== 'update:modelValue' || args[0] === false)) {
closeEntry(entry)
}
}
// 生成动态组件事件监听器,让不同业务弹窗复用同一个 Host。
function createDialogListeners(entry: ReadonlySharedDialogEntry) {
const listeners: Record<string, (...args: any[]) => void> = {}
listeners['update:modelValue'] = value => {
handleModelUpdate(entry, Boolean(value))
entry.events['update:modelValue']?.(value)
}
Object.keys(entry.events).forEach(eventName => {
if (eventName === 'update:modelValue') return
listeners[eventName] = (...args: any[]) => handleDialogEvent(entry, eventName, args)
})
entry.closeOn.forEach(eventName => {
if (!listeners[eventName]) {
listeners[eventName] = (...args: any[]) => handleDialogEvent(entry, eventName, args)
}
})
return listeners
}
</script>
<template>
<Component
:is="entry.component"
v-for="entry in dialogs"
:key="entry.id"
v-bind="{ ...entry.props, modelValue: entry.visible }"
v-on="createDialogListeners(entry)"
/>
</template>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
const LoggingView = defineAsyncComponent(() => import('@/views/system/LoggingView.vue'))
// 国际化
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')
},
})
/** 拼接全部日志 URL。 */
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>
<VDialog v-if="visible" v-model="visible" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn v-model="visible" />
<VCardItem>
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('shortcut.log.subtitle') }}
<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 />
<VCardText class="pa-0">
<LoggingView logfile="moviepilot.log" />
</VCardText>
</VCard>
</VDialog>
</template>

View File

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

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = withDefaults(
defineProps<{
bodyClass?: string
cardClass?: string
icon?: string
maxWidth?: string
modelValue?: boolean
subtitle?: string
title: string
view: Component
viewProps?: Record<string, unknown>
}>(),
{
bodyClass: '',
cardClass: '',
icon: 'mdi-cog',
maxWidth: '35rem',
modelValue: true,
viewProps: () => ({}),
},
)
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
</script>
<template>
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="!display.mdAndUp.value">
<VCard :class="props.cardClass">
<VCardItem>
<VCardTitle>
<VIcon :icon="props.icon" class="me-2" />
{{ props.title }}
</VCardTitle>
<VCardSubtitle v-if="props.subtitle">{{ props.subtitle }}</VCardSubtitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText :class="props.bodyClass">
<Component :is="props.view" v-bind="props.viewProps" />
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.system-health-dialog-card {
display: flex;
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;
}
:global(.v-dialog--fullscreen) .system-health-dialog-body {
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">
@@ -611,11 +611,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 +621,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));
}
@@ -718,8 +716,6 @@ onMounted(() => {
}
.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;

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

@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { StorageConf } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
// 定义输入
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
storage: {
type: Object as PropType<StorageConf>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['update:modelValue', 'close', 'done'])
// 自定义存储名称
const customName = ref(props.storage.name)
// 自定义存储类型
const storageType = ref(props.storage.type)
// 自定义存储配置对话框
const customConfigDialog = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
/** 保存自定义存储基础信息并通知父级刷新。 */
function handleDone() {
const nextStorage = {
...props.storage,
name: customName.value,
type: storageType.value,
}
customConfigDialog.value = false
emit('done', nextStorage)
}
</script>
<template>
<VDialog
v-if="customConfigDialog"
v-model="customConfigDialog"
scrollable
max-width="30rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-cog" />
</template>
<VCardTitle>{{ t('storage.custom') }}</VCardTitle>
<VDialogCloseBtn v-model="customConfigDialog" />
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="storageType"
:label="t('storage.type')"
:hint="t('storage.customTypeHint')"
persistent-hint
prepend-inner-icon="mdi-database"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="customName"
:label="t('storage.name')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VCardText>
<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>
</VCard>
</VDialog>
</template>

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"

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