Compare commits

..

139 Commits

Author SHA1 Message Date
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
176 changed files with 11393 additions and 2159 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.3",
"version": "2.13.9",
"private": true,
"type": "module",
"bin": "dist/service.js",
@@ -49,6 +49,7 @@
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"gridstack": "^12.6.0",
"http-proxy-middleware": "^3.0.0",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

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

View File

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

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';
@@ -223,6 +243,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 +458,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,10 +11,9 @@ 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;
}
@@ -37,10 +36,8 @@ body,
.layout-page-content {
@include mixins.boxed-content(true);
// Chrome 144+ 兼容性:使用 clip 替代 hidden避免滚动锁定问题
// overflow: hidden 在新版 Chrome 中可能意外阻止垂直滚动
overflow: clip;
flex-grow: 1;
overflow: clip visible;
// TODO: Use grid gutter variable here;
padding-block: 1.5rem;

View File

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

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup>
import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api from '@/api'
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
@@ -12,25 +11,32 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
import { completeLaunchLoading } from '@/composables/useLaunchLoading'
import { usePWA } from '@/composables/usePWA'
import { themeManager } from '@/utils/themeManager'
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
import { configureApexChartsTheme } from '@/utils/apexCharts'
const LOGIN_WALLPAPER_ROUTE = '/login'
// 生效主题
const { global: globalTheme } = useTheme()
const vuetifyTheme = useTheme()
const { global: globalTheme } = vuetifyTheme
let themeValue = localStorage.getItem('theme') || 'auto'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
globalTheme.name.value = resolveThemeName(themeValue)
applyStoredThemeCustomizerAppearance(vuetifyTheme)
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
function syncRootLaunchPalette() {
const { background, primary } = globalTheme.current.value.colors
document.documentElement.style.setProperty('--initial-loader-bg', background)
document.documentElement.style.setProperty('--initial-loader-color', primary)
document.documentElement.style.backgroundColor = background
document.body.style.backgroundColor = background
applyDocumentThemeChrome(themeValue, {
background,
persistLoaderColors: true,
primary,
resolvedTheme: globalTheme.name.value,
})
}
// 生效语言
@@ -41,6 +47,7 @@ setI18nLanguage(localeValue as SupportedLocale)
const authStore = useAuthStore()
const isLogin = computed(() => authStore.token)
const route = useRoute()
const { initializePWA } = usePWA()
// 全局设置store
const globalSettingsStore = useGlobalSettingsStore()
@@ -81,6 +88,7 @@ applyTransparentBackgroundSettings()
// 心跳检测
let heartbeatInterval: number | null = null
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
// 启动心跳
const startHeartbeat = () => {
@@ -93,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)
@@ -116,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 {
@@ -201,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()
}
}
@@ -230,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) {
@@ -245,7 +300,7 @@ async function removeLoadingWithStateCheck() {
} catch (error) {
// 即使出错也要移除加载界面
globalLoadingStateManager.reset()
animateAndRemoveLoader()
await animateAndRemoveLoader()
}
}
@@ -285,6 +340,8 @@ onMounted(async () => {
// 初始化主题管理器 - 统一处理主题初始化
await themeManager.setTheme(themeValue)
applyStoredThemeCustomizerAppearance(vuetifyTheme)
updateHtmlThemeAttribute(globalTheme.name.value)
// 监听主题变化
watch(
@@ -297,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,
@@ -344,6 +407,11 @@ onUnmounted(() => {
}
// 停止心跳
stopHeartbeat()
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
prefersColorSchemeMediaQuery = null
document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
window.removeEventListener('pageshow', handlePageShowThemeSync)
window.removeEventListener('focus', handlePageShowThemeSync)
})
</script>

View File

@@ -46,6 +46,8 @@ export interface Subscribe {
start_episode?: number
// 缺失集数
lack_episode?: number
// 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数)
completed_episode?: number
// 附加信息
note?: string
// 状态N-新建 R-订阅中 P-待定 S-暂停
@@ -64,6 +66,8 @@ export interface Subscribe {
search_imdbid?: any
// 当前优先级
current_priority: number
// 洗版时已下载剧集的优先级状态
episode_priority?: Record<string, number>
// 保存目录
save_path?: string
// 时间
@@ -698,6 +702,8 @@ export interface DashboardItem {
attrs: { [key: string]: any }
// col列数
cols: { [key: string]: number }
// Grid行数
rows?: number
// 页面元素
elements: RenderProps[]
// 渲染方式
@@ -762,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 {
// 是否处理的文件
@@ -1021,6 +1079,10 @@ export interface FileItem {
export interface MediaServerPlayItem {
// ID
id?: string | number
// 媒体服务器项目ID
item_id?: string | number
// 媒体服务器ID
server_id?: string
// 标题
title: string
// 副标题
@@ -1045,6 +1107,10 @@ export interface MediaServerLibrary {
server: string
// ID
id?: string | number
// 媒体服务器项目ID
item_id?: string | number
// 媒体服务器ID
server_id?: string
// 名称
name: string
// 路径
@@ -1286,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
@@ -1298,7 +1364,7 @@ export interface TransferForm {
// 类型
type_name?: string
// 整理方式
transfer_type: string
transfer_type: string | null
// 自定义格式
episode_format?: string
// 指定集数
@@ -1310,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 {
@@ -1360,6 +1447,14 @@ export interface ManualTransferPreviewItem {
episode_end?: number | string
// Part
part?: string
// 原始识别字符串
org_string?: string
// 应用的自定义识别词
apply_words?: string[]
// 制作组/字幕组
resource_team?: string
// 自定义占位符
customization?: string
}
// 手动整理预览数据
@@ -1448,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

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

View File

@@ -4,7 +4,7 @@ import plex from '@images/misc/plex.png'
import emby from '@images/misc/emby.png'
import jellyfin from '@images/misc/jellyfin.png'
import { getLogoUrl } from '@/utils/imageUtils'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
import { openMediaServerItem } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
@@ -49,8 +49,8 @@ function getDefaultImage() {
// 跳转播放
async function goPlay() {
if (props.media?.link) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
if (props.media) {
await openMediaServerItem(props.media)
}
}

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import noImage from '@images/no-image.jpeg'
import { getLogoUrl } from '@/utils/imageUtils'
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
import api from '@/api'
import { useToast } from 'vue-toastification'
import { formatSeason, formatRating } from '@/@core/utils/formatters'
@@ -10,7 +10,7 @@ import router from '@/router'
import { useUserStore, useGlobalSettingsStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import { mediaTypeDict } from '@/api/constants'
import { hasPermission } from '@/utils/permission'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { openSharedDialog } from '@/composables/useSharedDialog'
import {
getCachedMediaExistsStatus,
@@ -45,6 +45,9 @@ const globalSettings = globalSettingsStore.globalSettings
// 用户 Store
const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
// 提示框
const $toast = useToast()
@@ -143,7 +146,7 @@ async function querySites() {
// 查询用户选中的站点
async function querySelectedSites() {
try {
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
const result: { [key: string]: any } = await api.get('system/setting/public/IndexerSites')
selectedSites.value = result.data?.value ?? []
} catch (error) {
console.log(error)
@@ -336,12 +339,11 @@ async function checkSubscribe(season: number | null) {
// 查询订阅弹窗规则
async function queryDefaultSubscribeConfig() {
// 非管理员不显示
if (!userStore.superUser) return false
if (!canSubscribe.value) return false
try {
let subscribe_config_url = ''
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.get(subscribe_config_url)
if (result.data?.value) return result.data.value.show_edit_dialog
} catch (error) {
@@ -464,13 +466,7 @@ function setupIntersectionObserver() {
const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
})
// 移除订阅
@@ -540,7 +536,7 @@ onBeforeUnmount(() => {
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
<div v-else class="flex align-center justify-between">
<IconBtn
v-if="hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')"
v-if="canSearch"
icon="mdi-magnify"
color="white"
size="small"
@@ -548,6 +544,7 @@ onBeforeUnmount(() => {
/>
<VSpacer />
<IconBtn
v-if="canSubscribe"
icon="mdi-heart"
:color="isSubscribed ? 'error' : 'white'"
size="small"

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

@@ -8,7 +8,9 @@ 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 PluginVersionHistoryDialog = defineAsyncComponent(
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
)
// 输入参数
const props = defineProps({
@@ -119,6 +121,15 @@ function showPluginDetail() {
// 弹出菜单
const dropdownItems = ref([
{
title: t('plugin.versionHistory'),
value: 2,
show: !isNullOrEmptyObject(props.plugin?.history || {}),
props: {
prependIcon: 'mdi-update',
click: showUpdateHistory,
},
},
{
title: t('plugin.projectHome'),
value: 1,
@@ -128,17 +139,7 @@ const dropdownItems = ref([
click: visitPluginPage,
},
},
{
title: t('plugin.updateHistory'),
value: 2,
show: !isNullOrEmptyObject(props.plugin?.history || {}),
props: {
prependIcon: 'mdi-update',
click: showUpdateHistory,
},
},
])
</script>
<template>
@@ -176,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 }}
@@ -245,3 +246,25 @@ const dropdownItems = ref([
</VHover>
</div>
</template>
<style scoped>
.plugin-app-card__tags-section {
display: flex;
overflow: hidden;
flex-wrap: nowrap;
gap: 4px;
max-inline-size: 100%;
}
.plugin-app-card__tag {
flex: 0 0 auto;
max-inline-size: 100%;
min-inline-size: 0;
}
.plugin-app-card__tag :deep(.v-chip__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
@@ -70,13 +71,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 })
})
// 图片加载完成响应
@@ -84,13 +136,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)
}
// 删除订阅
@@ -306,19 +364,13 @@ watch(
// 计算backdrop图片地址
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
})
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
})
// 订阅编辑保存
@@ -352,26 +404,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" />
@@ -400,15 +458,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 }"
>
@@ -444,37 +498,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"
@@ -491,4 +577,56 @@ function handleCardClick() {
.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

@@ -4,6 +4,7 @@ import type { SubscribeShare } from '@/api/types'
import router from '@/router'
import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const ForkSubscribeDialog = defineAsyncComponent(() => import('../dialog/ForkSubscribeDialog.vue'))
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
@@ -35,19 +36,13 @@ const dateText = ref(props.media && props.media?.date ? formatDateDifference(pro
// 计算backdrop图片地址
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
})
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
})
// 获得mediaid
@@ -99,7 +94,7 @@ function doDelete() {
<VHover>
<template #default="hover">
<div
class="w-full h-full rounded-lg overflow-hidden"
class="w-full h-full overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
@@ -108,7 +103,6 @@ function doDelete() {
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
rounded="0"
min-height="150"
@click="showForkSubscribe"
>

View File

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

View File

@@ -0,0 +1,216 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import type { SubtitleInfo } from '@/api/types'
import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useI18n } from 'vue-i18n'
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
// 多语言支持
const { t } = useI18n()
// 输入参数
const props = defineProps({
subtitle: Object as PropType<SubtitleInfo>,
})
// 字幕信息
const subtitle = ref(props.subtitle)
// 站点图标
const siteIcon = ref('')
const isDownloaded = computed(() => Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]))
// 查询站点图标
async function getSiteIcon() {
if (!subtitle.value?.site) {
siteIcon.value = ''
return
}
try {
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
try {
const response = await api.get(`site/icon/${subtitle.value?.site}`)
return response?.data?.icon || ''
} catch (error) {
console.error('Failed to load site icon:', error)
return ''
}
})
} catch (error) {
console.error('Failed to load site icon:', error)
siteIcon.value = ''
}
}
// 询问并下载字幕
async function handleAddDownload() {
openSharedDialog(
AddSubtitleDownloadDialog,
{
title: subtitle.value?.title,
subtitle: subtitle.value,
},
{
done: addDownloadSuccess,
error: addDownloadError,
},
{ closeOn: ['close', 'done', 'error'] },
)
}
// 添加字幕下载成功
function addDownloadSuccess(url: string) {
markSubtitleDownloaded(url)
}
// 添加字幕下载失败
function addDownloadError(error: string) {
console.error(error)
}
// 打开字幕详情页面
function openSubtitleDetail() {
if (!subtitle.value?.page_url) return
window.open(subtitle.value.page_url, '_blank')
}
// 打开字幕举报页面
function openReportPage() {
if (!subtitle.value?.report_url) return
window.open(subtitle.value.report_url, '_blank')
}
watch(
() => props.subtitle,
value => {
subtitle.value = value
getSiteIcon()
},
{ immediate: true },
)
</script>
<template>
<div class="w-100">
<VListItem
:value="subtitle?.enclosure"
class="pa-3 mb-2 rounded subtitle-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
@click="handleAddDownload"
>
<template #prepend>
<div class="d-flex flex-column align-center pr-3" :title="subtitle?.site_name">
<VImg
v-if="siteIcon"
:src="siteIcon"
:alt="subtitle?.site_name"
class="rounded mb-1 site-icon"
width="32"
height="32"
/>
<VAvatar
v-else
size="32"
class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold site-icon"
>
{{ subtitle?.site_name?.substring(0, 1) }}
</VAvatar>
</div>
</template>
<VListItemTitle class="whitespace-normal">
<div class="d-flex flex-row flex-wrap align-center gap-2 mb-2">
<span class="text-h6 font-weight-bold me-1">{{ subtitle?.site_name }}</span>
<VChip v-if="subtitle?.season_episode" size="x-small" color="secondary" variant="tonal" class="rounded-sm">
{{ subtitle.season_episode }}
</VChip>
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
<VImg
v-if="subtitle?.language_icon"
:src="subtitle.language_icon"
:alt="subtitle.language"
width="14"
height="14"
class="me-1"
/>
{{ subtitle.language }}
</VChip>
<VChip v-if="isDownloaded" size="x-small" color="success" variant="tonal" class="rounded-sm">
{{ t('dialog.addSubtitleDownload.downloaded') }}
</VChip>
</div>
<div class="text-subtitle-2 font-weight-medium mb-2 break-all" :title="subtitle?.title">
{{ subtitle?.title }}
</div>
<div v-if="subtitle?.description" class="text-body-2 text-medium-emphasis mb-2 break-all" :title="subtitle.description">
{{ subtitle.description }}
</div>
<div class="d-flex flex-wrap gap-2 mb-2">
<span v-if="subtitle?.pubdate || subtitle?.date_elapsed" class="d-flex align-center text-sm text-medium-emphasis">
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
</span>
<span v-if="subtitle?.grabs !== undefined" class="d-flex align-center text-sm text-medium-emphasis">
<VIcon size="small" color="primary" icon="mdi-download-outline" class="me-1"></VIcon>
{{ subtitle.grabs }}
</span>
<span v-if="subtitle?.uploader" class="d-flex align-center text-sm text-medium-emphasis">
<VIcon size="small" color="grey" icon="mdi-account-outline" class="me-1"></VIcon>
{{ subtitle.uploader }}
</span>
</div>
</VListItemTitle>
<template #append>
<div class="d-flex flex-column align-end gap-2">
<VChip v-if="subtitle?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm">
{{ formatFileSize(subtitle.size) }}
</VChip>
<div class="d-flex align-center">
<VBtn
v-if="subtitle?.report_url"
icon
size="small"
variant="text"
color="warning"
@click.stop="openReportPage"
>
<VIcon icon="mdi-alert-outline"></VIcon>
</VBtn>
<VBtn
v-if="subtitle?.page_url"
icon
size="small"
variant="text"
color="primary"
@click.stop="openSubtitleDetail"
>
<VIcon icon="mdi-information-outline"></VIcon>
</VBtn>
</div>
</div>
</template>
</VListItem>
</div>
</template>
<style scoped>
.subtitle-item {
border: 1px solid transparent;
}
.subtitle-item:hover {
border-color: rgba(var(--v-theme-primary), 0.3);
}
</style>

View File

@@ -95,52 +95,59 @@ function doDelete() {
<div class="h-full">
<VHover>
<template #default="hover">
<div
class="w-full h-full rounded-lg overflow-hidden"
<VCard
v-bind="hover.props"
:key="props.workflow?.id"
class="workflow-share-card flex flex-col h-full cursor-pointer overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'workflow-share-card--hovering': hover.isHovering,
}"
min-height="150"
:style="{ background: gradientStyle }"
@click="showForkWorkflow"
>
<VCard
v-bind="hover.props"
:key="props.workflow?.id"
class="flex flex-col h-full"
rounded="0"
min-height="150"
:style="{ background: gradientStyle }"
@click="showForkWorkflow"
>
<div class="h-full flex flex-col">
<VCardText class="flex items-center pa-3 pb-1 grow">
<div class="flex flex-col justify-center w-full">
<VCardTitle class="text-lg text-bold text-white line-clamp-2 break-words">
{{ props.workflow?.share_title }}
</VCardTitle>
<div class="px-4 text-white text-opacity-90 overflow-hidden line-clamp-3 break-all ...">
{{ props.workflow?.share_comment }}
</div>
<div class="h-full flex flex-col">
<VCardText class="flex items-center pa-3 pb-1 grow">
<div class="flex flex-col justify-center w-full">
<VCardTitle class="text-lg text-bold text-white line-clamp-2 break-words">
{{ props.workflow?.share_title }}
</VCardTitle>
<div class="px-4 text-white text-opacity-90 overflow-hidden line-clamp-3 break-all ...">
{{ props.workflow?.share_comment }}
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap py-2">
<div class="flex align-center">
<IconBtn v-bind="props" icon="mdi-account" class="me-1 text-white" />
<div class="text-subtitle-2 me-4 text-white text-opacity-90">
{{ props.workflow?.share_user }}
</div>
<IconBtn v-if="props.workflow?.count" icon="mdi-fire" class="me-1 text-white" />
<span v-if="props.workflow?.count" class="text-subtitle-2 me-4 text-white text-opacity-90">
{{ props.workflow?.count.toLocaleString() }}
</span>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap py-2">
<div class="flex align-center">
<IconBtn v-bind="props" icon="mdi-account" class="me-1 text-white" />
<div class="text-subtitle-2 me-4 text-white text-opacity-90">
{{ props.workflow?.share_user }}
</div>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-white text-sm text-opacity-75">
<VIcon icon="mdi-calendar" size="small" class="me-1" />
{{ dateText }}
</VCardText>
</div>
</VCard>
</div>
<IconBtn v-if="props.workflow?.count" icon="mdi-fire" class="me-1 text-white" />
<span v-if="props.workflow?.count" class="text-subtitle-2 me-4 text-white text-opacity-90">
{{ props.workflow?.count.toLocaleString() }}
</span>
</div>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-white text-sm text-opacity-75">
<VIcon icon="mdi-calendar" size="small" class="me-1" />
{{ dateText }}
</VCardText>
</div>
</VCard>
</template>
</VHover>
</div>
</template>
<style lang="scss" scoped>
// 阴影需要落在实际卡片上,不能被额外的 overflow 容器裁掉。
.workflow-share-card {
transition: transform 0.3s ease, box-shadow 0.2s ease;
transform: translateZ(0);
}
.workflow-share-card--hovering {
transform: translate3d(0, -0.25rem, 0);
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import type { Process as SystemProcess } from '@/api/types'
import { clearCacheAndReload } from '@/composables/useVersionChecker'
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
@@ -37,6 +38,12 @@ md.use(mdLinkAttributes, {
// 系统环境变量
const systemEnv = ref<any>({})
// 系统运行时间的基准秒数和同步时间,用于在弹窗打开后实时递增展示。
const systemUptimeBaseSeconds = ref<number | null>(null)
const systemUptimeSyncedAt = ref(0)
const systemUptimeNow = ref(Date.now())
let systemUptimeTimer: ReturnType<typeof setInterval> | null = null
// 所有Release
const allRelease = ref<any>([])
@@ -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

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

View File

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

View File

@@ -109,7 +109,14 @@ function submitSettings() {
</script>
<template>
<VDialog v-if="visible" v-model="visible" width="35rem" class="settings-dialog" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog
v-if="visible"
v-model="visible"
width="35rem"
class="settings-dialog"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard class="settings-card">
<VCardItem class="settings-card-header">
<VCardTitle>
@@ -146,15 +153,15 @@ function submitSettings() {
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
</p>
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(true)">
<VCardActions class="app-dialog-actions">
<VBtn v-if="props.showBulkActions" color="success" variant="tonal" @click="setAllItems(true)">
{{ props.selectAllText }}
</VBtn>
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(false)">
<VBtn v-if="props.showBulkActions" color="warning" variant="tonal" @click="setAllItems(false)">
{{ props.selectNoneText }}
</VBtn>
<VSpacer />
<VBtn color="primary" class="px-5" @click="submitSettings">
<VBtn color="primary" variant="flat" class="px-5" @click="submitSettings">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
@@ -195,8 +202,7 @@ function submitSettings() {
.setting-item {
position: relative;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 8px;
border-radius: var(--app-surface-radius);
background-color: rgba(var(--v-theme-surface-variant), 0.3);
cursor: pointer;
padding-block: 10px;

View File

@@ -86,8 +86,9 @@ function submitCustomCSS() {
class="custom-css-editor"
/>
</div>
<VCardActions class="custom-css-actions">
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
<VCardActions class="app-dialog-actions custom-css-actions">
<VSpacer />
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
{{ t('common.save') }}
</VBtn>
</VCardActions>

View File

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

View File

@@ -88,9 +88,9 @@ function submitOrder() {
</template>
</draggable>
</VCardText>
<VCardActions class="pt-3">
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn @click="submitOrder">
<VBtn color="primary" variant="flat" class="px-5" @click="submitOrder">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
@@ -117,8 +117,6 @@ function submitOrder() {
.setting-item {
position: relative;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-primary), 0.3);
border-radius: 8px;
background-color: rgba(var(--v-theme-primary), 0.08);
cursor: grab;
padding-block: 10px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,12 @@ function openLoggerWindow() {
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
window.open(url, '_blank')
}
/** 下载当前插件日志压缩包。 */
function downloadLogger() {
const url = `${import.meta.env.VITE_API_BASE_URL}system/logging/download/${props.plugin?.id?.toLowerCase()}`
window.open(url, '_blank')
}
</script>
<template>
@@ -52,12 +58,20 @@ function openLoggerWindow() {
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('plugin.logTitle') }}
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
<VChip color="grey-darken-1" size="small" class="ml-2">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
<span class="ms-4 d-inline-flex align-center ga-1">
<a class="d-inline-flex align-center cursor-pointer" @click="downloadLogger">
<VChip color="grey-darken-1" size="small">
<VIcon icon="mdi-download" size="small" start />
{{ t('common.download') }}
</VChip>
</a>
<a class="d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
<VChip color="grey-darken-1" size="small">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</span>
</VCardTitle>
</VCardItem>
<VDivider />

View File

@@ -147,13 +147,7 @@ onUnmounted(() => {
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
<div class="mx-auto mt-5">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="pluginIconPath()"
aspect-ratio="4/3"
cover
@error="imageLoadError = true"
/>
<VImg ref="imageRef" :src="pluginIconPath()" aspect-ratio="4/3" cover @error="imageLoadError = true" />
</VAvatar>
</div>
<div class="flex-grow">
@@ -166,7 +160,7 @@ onUnmounted(() => {
>
{{ props.plugin?.plugin_desc }}
</VCardSubtitle>
<VList lines="one">
<VList lines="one" class="border-0">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('common.version') }}</span>

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

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

View File

@@ -1,4 +1,5 @@
<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'
@@ -25,6 +26,10 @@ const props = defineProps({
// 定义触发的自定义事件
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,
@@ -34,30 +39,78 @@ const visible = computed({
},
})
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: props.plugin?.plugin_name })">
<VCard :title="t('plugin.updateHistoryTitle', { name: resolvedPlugin?.plugin_name })">
<VDialogCloseBtn v-model="visible" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
<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="props.plugin?.system_version_compatible === false"
v-if="resolvedPlugin?.system_version_compatible === false"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<VBtn @click="handleUpdate" block :disabled="props.plugin?.system_version_compatible === false">
<VBtn @click="handleUpdate" block :disabled="resolvedPlugin?.system_version_compatible === false">
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
@@ -68,3 +121,12 @@ function handleUpdate() {
</VCard>
</VDialog>
</template>
<style scoped>
.plugin-version-history-dialog__loading {
min-height: 12rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

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

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

@@ -34,6 +34,11 @@ const visible = computed({
function allLoggingUrl() {
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
}
/** 拼接主程序日志下载 URL。 */
function allLoggingDownloadUrl() {
return `${import.meta.env.VITE_API_BASE_URL}system/logging/download/moviepilot`
}
</script>
<template>
@@ -44,12 +49,20 @@ function allLoggingUrl() {
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('shortcut.log.subtitle') }}
<a class="mx-2 d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
<VChip color="grey-darken-1" size="small" class="ml-2">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
<span class="ms-4 d-inline-flex align-center ga-1">
<a class="d-inline-flex align-center" :href="allLoggingDownloadUrl()" target="_blank">
<VChip color="grey-darken-1" size="small">
<VIcon icon="mdi-download" size="small" start />
{{ t('common.download') }}
</VChip>
</a>
<a class="d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
<VChip color="grey-darken-1" size="small">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</span>
</VCardTitle>
</VCardItem>
<VDivider />

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import api from '@/api'
import { clearAppBadge } from '@/utils/badge'
import { clearUnreadMessages } from '@/utils/badge'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
@@ -67,15 +67,19 @@ async function sendMessage() {
}
}
/** 清除未读消息计数和桌面角标。 */
function clearUnreadMessageState() {
window.setTimeout(() => {
void clearUnreadMessages()
}, 500)
}
watch(visible, async newValue => {
if (newValue) {
await nextTick()
messageViewRef.value?.resumeSSE?.()
messageViewRef.value?.forceScrollToEnd?.()
window.setTimeout(() => {
void clearAppBadge()
}, 500)
clearUnreadMessageState()
return
}
@@ -85,12 +89,8 @@ watch(visible, async newValue => {
onMounted(async () => {
await nextTick()
messageViewRef.value?.resumeSSE?.()
messageViewRef.value?.forceScrollToEnd?.()
window.setTimeout(() => {
void clearAppBadge()
}, 500)
clearUnreadMessageState()
})
onUnmounted(() => {

View File

@@ -61,19 +61,21 @@ const visible = computed({
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.system-health-dialog-card {
display: flex;
flex-direction: column;
overflow: hidden;
flex-direction: column;
}
.system-health-dialog-body {
/* 弹窗正文本身不滚动,滚动只交给健康检查结果列表。 */
display: flex;
overflow: hidden !important;
flex: 1 1 auto;
block-size: min(42rem, calc(100dvh - 8rem - env(safe-area-inset-top) - env(safe-area-inset-bottom)));
min-block-size: 0;
overflow: hidden !important;
}
:global(.v-dialog--fullscreen) .system-health-dialog-body {

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

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

View File

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

View File

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

View File

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

View File

@@ -97,9 +97,9 @@ function updateFilter(values: string[]) {
</VChip>
</VChipGroup>
</VCardText>
<VCardActions>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="visible = false">
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="visible = false">
{{ t('torrent.confirm') }}
</VBtn>
</VCardActions>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -609,8 +609,6 @@ onMounted(() => {
.filter-toolbar-card {
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
background: rgba(var(--v-theme-surface), 0.82);
}
@@ -632,11 +630,6 @@ onMounted(() => {
margin-inline-end: 2px !important;
}
.sort-menu-list {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
}
.sort-menu-list :deep(.v-list-item__prepend > .v-icon) {
margin-inline-end: 0px !important;
}
@@ -758,10 +751,6 @@ onMounted(() => {
}
@media (width <= 600px) {
.filter-toolbar-card {
border-radius: 8px;
}
.filter-buttons-grid {
gap: 6px;
}

View File

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

View File

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

View File

@@ -4,8 +4,14 @@ import { FilterRuleGroup } from '@/api/types'
import { Handle, Position } from '@vue-flow/core'
import { useI18n } from 'vue-i18n'
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
const { t } = useI18n()
const userStore = useUserStore()
const canAdmin = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
)
defineProps({
id: {
@@ -23,6 +29,8 @@ const filterRuleGroups = ref<FilterRuleGroup[]>([])
// 加载规则组
async function queryFilterRuleGroups() {
if (!canAdmin.value) return
try {
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
filterRuleGroups.value = result.data?.value ?? []

View File

@@ -22,7 +22,7 @@ const storages = ref<StorageConf[]>([])
// 查询存储
async function loadStorages() {
const result: { [key: string]: any } = await api.get('system/setting/Storages')
const result: { [key: string]: any } = await api.get('system/setting/public/Storages')
storages.value = result.data?.value ?? []
}

View File

@@ -3,8 +3,14 @@ import api from '@/api'
import { NotificationConf } from '@/api/types'
import { Handle, Position } from '@vue-flow/core'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
const { t } = useI18n()
const userStore = useUserStore()
const canAdmin = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
)
defineProps({
id: {
@@ -22,6 +28,8 @@ const notifications = ref<NotificationConf[]>([])
// 调用API查询通知渠道设置
async function loadNotificationSetting() {
if (!canAdmin.value) return
try {
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
notifications.value = result.data?.value ?? []

View File

@@ -12,6 +12,7 @@ import {
type ComputedRef,
type Ref,
} from 'vue'
import type { UserPermissionKey } from '@/utils/permission'
// 声明全局变量类型
declare global {
@@ -29,6 +30,7 @@ export interface DynamicButtonMenuItem {
titleParams?: Record<string, unknown>
icon?: string
color?: string
permission?: UserPermissionKey
action: () => void
}
@@ -57,11 +59,12 @@ export function useDynamicButton(options: {
icon: MaybeRefValue<string>
onClick?: () => void
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
permission?: UserPermissionKey
show?: MaybeRefValue<boolean>
autoRegister?: boolean // 是否自动注册默认为true
}) {
// 提取配置
const { icon, onClick, menuItems, show, autoRegister = true } = options
const { icon, onClick, menuItems, permission, show, autoRegister = true } = options
// 动态按钮相关
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
@@ -81,6 +84,7 @@ export function useDynamicButton(options: {
return {
icon: resolvedIcon.value,
action: onClick || (() => {}),
permission,
show: resolvedShow.value,
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
}
@@ -174,7 +178,7 @@ export function useDynamicButton(options: {
cleanupDynamicButton()
})
watch([resolvedIcon, resolvedShow, resolvedMenuItems], () => {
watch([resolvedIcon, resolvedShow, resolvedMenuItems, () => permission], () => {
if (!componentActive.value) return
setupDynamicButton()

View File

@@ -1,5 +1,6 @@
import type { ComputedRef, Ref } from 'vue'
import { useTabStateRestore } from '@/composables/useStateRestore'
import type { UserPermissionKey } from '@/utils/permission'
// 动态标签页相关类型
interface DynamicHeaderTabButton {
@@ -9,7 +10,9 @@ interface DynamicHeaderTabButton {
size?: string
class?: string
action?: () => void
permission?: UserPermissionKey
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string // 用于VMenu定位的data属性
}

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