Compare commits

...

467 Commits

Author SHA1 Message Date
jxxghp
5d22cb84bf 更新 package.json 2025-07-06 20:03:25 +08:00
jxxghp
f01c61e09f 更新 App.vue 2025-07-06 19:52:37 +08:00
jxxghp
d50e67f3bc Merge pull request #359 from jxxghp/cursor/pwa-5007
分析PWA状态切换体验问题
2025-07-06 18:35:53 +08:00
Cursor Agent
3726c472fc Remove console logs for silent PWA state restoration optimization
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 10:31:35 +00:00
Cursor Agent
dc174e81cf Optimize PWA state restoration for seamless, silent background switching
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 10:14:40 +00:00
Cursor Agent
c9867bc453 Optimize PWA state restoration and loading experience
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 08:30:12 +00:00
Cursor Agent
8e282fb216 Add PWA performance analysis report for background-to-foreground experience
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 07:22:01 +00:00
jxxghp
e9c0792cb3 Merge pull request #358 from jxxghp/cursor/prevent-ios-from-killing-pwa-background-245b
fix: PWA状态管理器初始化在DOM已加载时失败的问题
2025-07-06 15:04:45 +08:00
Cursor Agent
e7e1b4c43f fix: PWA状态管理器初始化在DOM已加载时失败的问题
- 修复DOMContentLoaded事件监听器可能不触发的问题
- 检查document.readyState状态,如果DOM已就绪则立即初始化
- 确保PWA状态管理器在所有情况下都能正确初始化
- 解决main.ts作为模块加载时的时序问题
2025-07-06 06:58:15 +00:00
jxxghp
dc56c177b7 Merge pull request #356 from jxxghp/cursor/prevent-ios-from-killing-pwa-background-245b 2025-07-06 14:54:36 +08:00
Cursor Agent
c0ee998874 feat: 实现PWA状态管理防止iOS后台被杀
- 添加多层存储策略(localStorage + sessionStorage + IndexedDB + Service Worker缓存)
- 实现智能状态恢复决策机制
- 自动监听页面生命周期事件进行状态保存和恢复
- 支持表单数据、滚动位置、UI状态的自动保存
- 专为iOS设备PWA优化,解决后台被杀导致状态丢失的问题
- 版本号更新至 2.6.3
2025-07-06 06:52:18 +00:00
Cursor Agent
e1ff50e1e3 Refactor usePWAState and useGlobalPWAState composables
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 06:50:30 +00:00
Cursor Agent
0e440955c8 Implement PWA state management for improved iOS background persistence
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 06:44:06 +00:00
Cursor Agent
a16dd497c4 Add comprehensive PWA state management solution for iOS background handling
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-06 06:34:25 +00:00
jxxghp
5aa4e9339d Merge pull request #355 from jxxghp/cursor/fix-sse-issues-in-public-service-js-b303 2025-07-06 08:10:30 +08:00
Cursor Agent
723fa96519 Refactor SSE proxy middleware for improved event streaming support
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-05 23:59:58 +00:00
Cursor Agent
75252fded6 Refactor proxy middleware for improved SSE and API request handling
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-05 23:56:51 +00:00
jxxghp
51fbcdfa56 更新 Footer.vue 2025-07-06 00:51:44 +08:00
jxxghp
61c9b97d70 优化垂直导航布局和动态标签页的样式 2025-07-06 00:43:06 +08:00
jxxghp
23b09d09ce 优化移动端Header UI 2025-07-06 00:43:06 +08:00
jxxghp
a00f6ab8ff 更新 HeaderTab.vue 2025-07-05 21:56:28 +08:00
jxxghp
bb59095bad 优化 HeaderTab 组件的模糊背景样式 2025-07-05 20:47:18 +08:00
jxxghp
da57124d5e Merge pull request #354 from jxxghp/cursor/add-blurred-background-to-headertab-d515 2025-07-05 17:31:17 +08:00
Cursor Agent
a00800a128 Remove demo.html file with header tab scroll effect example
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-05 09:24:44 +00:00
Cursor Agent
a98db1699d Create demo page for HeaderTab scroll transparency effect
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-05 09:21:25 +00:00
Cursor Agent
e3d9e736ad Add blur background effect on header tab when scrolling
Co-authored-by: jxxghp <jxxghp@163.com>
2025-07-05 09:12:18 +00:00
jxxghp
28f38d8b80 Fix tab selection on discovery page (#353)
* Fix: Ensure first tab is selected when no valid tab is active

Co-authored-by: jxxghp <jxxghp@163.com>

* Fix discover page tab selection logic when no tab is active

Co-authored-by: jxxghp <jxxghp@163.com>

* Fix tab selection logic in discover page lifecycle hooks

Co-authored-by: jxxghp <jxxghp@163.com>

* Remove unnecessary onMounted hook in discover page

Co-authored-by: jxxghp <jxxghp@163.com>

* Move dynamic header tab registration after tabs initialization

Co-authored-by: jxxghp <jxxghp@163.com>

* Refactor dynamic header tab registration in discover page

Co-authored-by: jxxghp <jxxghp@163.com>

* Fix tab selection logic in discover page to ensure first tab is selected

Co-authored-by: jxxghp <jxxghp@163.com>

* Update useDynamicHeaderTab to sync modelValue in tab configuration

Co-authored-by: jxxghp <jxxghp@163.com>

* Remove auto-tab selection logic from discover page

Co-authored-by: jxxghp <jxxghp@163.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2025-07-05 16:41:24 +08:00
jxxghp
3b7c34258f 更新 _misc.scss 2025-07-05 14:46:54 +08:00
jxxghp
9dde646695 修改 tab-header 的圆角样式 2025-07-05 14:34:30 +08:00
jxxghp
4bdee63f28 优化动态标签页注册逻辑 2025-07-05 14:26:57 +08:00
jxxghp
20dced021d 优化动态标签页注册逻辑 2025-07-05 14:06:00 +08:00
jxxghp
17cf640e23 优化 PageContentTitle 组件的样式,调整了边距;在 discover 页面中引入 useDynamicHeaderTab 组合函数;移除 UserListView 中未使用的 useDisplay 导入。 2025-07-05 12:22:08 +08:00
jxxghp
24369daea0 v2.6.2 2025-07-05 12:13:53 +08:00
jxxghp
873bf905ab 优化动态标签页注册逻辑 2025-07-05 12:13:53 +08:00
jxxghp
da0756adf0 动态Tab组件 2025-07-05 12:13:53 +08:00
jxxghp
09942ec946 更新 SubscribeEditDialog.vue 2025-07-05 09:21:25 +08:00
jxxghp
2650bc6068 添加离线状态管理和网络请求处理 2025-07-05 08:23:06 +08:00
jxxghp
6bd7274c9c Update index.html 2025-07-05 06:49:58 +08:00
jxxghp
129ccf9e39 更新 index.html 2025-07-05 06:43:38 +08:00
jxxghp
e2b789cfbc 优化加载动画逻辑 2025-07-04 21:26:44 +08:00
jxxghp
bb70e91277 重构服务工作者逻辑优 2025-07-04 18:32:04 +08:00
jxxghp
f6c07a29ce 更新服务工作者逻辑 2025-07-04 17:30:01 +08:00
jxxghp
4347983fc7 更新vite.config.ts,扩展缓存策略以支持更多文件类型和API请求 2025-07-04 16:57:51 +08:00
jxxghp
12b463d9e8 更新vite.config.ts,增加页面缓存配置 2025-07-04 16:39:31 +08:00
jxxghp
edc0949bed 移除全局设置store并更新引用路径 2025-07-04 16:21:05 +08:00
jxxghp
85780917c2 整合全局设置store,优化PWA模式检测 2025-07-04 16:19:50 +08:00
jxxghp
e45919cac1 优化PWA支持 2025-07-04 13:33:06 +08:00
jxxghp
c61821ef4e 在App.vue中优化加载动画逻辑,移除不必要的延迟 2025-07-04 12:12:13 +08:00
jxxghp
011902598b 在App.vue中添加主题支持以配置ApexCharts 2025-07-04 08:12:10 +08:00
jxxghp
3186c6ca0e 更新 AnalyticsNetwork.vue 2025-07-03 22:12:28 +08:00
jxxghp
3a680a132f 添加可拖拽排序功能 2025-07-03 20:05:08 +08:00
jxxghp
455dda54e8 添加存储后自动保存 2025-07-03 19:57:29 +08:00
jxxghp
5ea5ab07d9 移除WorkflowActionsDialog组件中的VSpacer元素 2025-07-03 19:50:04 +08:00
jxxghp
35c8025b00 在仪表板中添加网络流量组件 2025-07-03 19:14:31 +08:00
jxxghp
615c162663 插件图标使用缓存 2025-07-03 17:09:56 +08:00
jxxghp
c4bd15e5a0 fix storage save 2025-07-03 15:41:44 +08:00
jxxghp
edc92905f7 在MediaInfoCard组件中添加web_source信息的显示 2025-07-03 14:02:53 +08:00
jxxghp
bf5bbd3689 添加SMB网络共享支持 2025-07-03 12:43:42 +08:00
jxxghp
eb70ca233b 重构DefaultLayout.vue组件 2025-07-03 08:48:44 +08:00
jxxghp
8718816fce 将多个组件中的VFab按钮包裹在Teleport中,以确保在移动设备上正确显示 2025-07-03 07:18:31 +08:00
jxxghp
7d36330b4b 在PluginDataDialog组件中添加show_switch属性的绑定 2025-07-02 21:55:02 +08:00
jxxghp
1fa0474fef 调整DownloaderCard、MediaServerCard和StorageCard组件中图标的上边距 2025-07-02 21:49:42 +08:00
jxxghp
4070b27148 调整QuickAccess.vue组件的过渡时间为0.6秒 2025-07-02 21:39:41 +08:00
jxxghp
3892b0ed05 添加PluginDataDialog组件的show_switch属性 2025-07-02 21:30:44 +08:00
jxxghp
a06cf69d7a 优化QuickAccess.vue组件样式 2025-07-02 20:43:33 +08:00
jxxghp
61dc2568e8 优化快速访问组件 2025-07-02 20:28:58 +08:00
jxxghp
ac6362e698 更新 QuickAccess.vue 2025-07-02 17:55:19 +08:00
jxxghp
94afdf5495 更新样式和布局 2025-07-02 17:41:58 +08:00
jxxghp
d96f8acdbc 优化默认布局和快速访问组件 2025-07-02 17:12:14 +08:00
jxxghp
d39c795f92 更新快速访问组件的导入方式 2025-07-02 16:11:12 +08:00
jxxghp
8e12e0562b 更改快速访问组件的导入路径 2025-07-02 16:08:27 +08:00
jxxghp
7a1babb418 重构插件快速访问组件 2025-07-02 16:07:18 +08:00
jxxghp
8d65f0c2a8 优化快速访问插件的下拉手势逻辑 2025-07-02 15:59:11 +08:00
jxxghp
b8dff560f0 添加插件快速访问功能,支持下拉手势触发 2025-07-02 14:18:58 +08:00
jxxghp
b48c26ee73 调整日历视图的背景颜色 2025-07-02 12:31:30 +08:00
jxxghp
8328e51ae0 调整存储添加逻辑 2025-07-02 08:58:16 +08:00
jxxghp
7070eb8a7d 更改流媒体平台的源芯片背景颜色 2025-07-01 17:32:24 +08:00
jxxghp
d0aa26441c 单独显示流媒体平台 2025-07-01 17:14:03 +08:00
jxxghp
1bba7103c8 调整主题背景颜色为深灰色以提升视觉效果 2025-07-01 12:54:01 +08:00
jxxghp
7f8dd744f2 调整表格和输入框的背景颜色以适应透明主题 2025-07-01 12:39:44 +08:00
jxxghp
2f4a707498 为筛选菜单添加内边距样式 2025-07-01 11:58:57 +08:00
jxxghp
569bc3c8ec 站点添加筛选功能 2025-07-01 11:38:00 +08:00
jxxghp
b01421aa94 优化组件加载逻辑 2025-06-30 20:38:50 +08:00
jxxghp
30d933bd85 更新 package.json 2025-06-30 20:16:14 +08:00
jxxghp
377998335b 简化导航状态管理 2025-06-30 20:14:31 +08:00
jxxghp
21d21aa438 优化图片加载逻辑,添加导航状态管理 2025-06-30 19:55:27 +08:00
jxxghp
18cf1ea3d7 更新 FileList.vue、FileNavigator.vue 和 FileToolbar.vue 中 axios 属性的类型定义为 Function 2025-06-30 19:39:02 +08:00
jxxghp
60ea884fe2 添加全局请求和图片优化器 2025-06-30 17:37:30 +08:00
jxxghp
999fa9d9a6 自定义存储类型添加索引以区分不同的自定义存储 2025-06-29 11:21:40 +08:00
jxxghp
e80034e7f8 更新 package.json 2025-06-29 07:54:18 +08:00
jxxghp
b16f99941a Merge pull request #350 from tbc0309/v2 2025-06-29 07:52:27 +08:00
ERROR204
3503e7d5b1 fix service.js 2025-06-29 03:06:31 +08:00
ERROR204
d1d80acef8 fix service.js 2025-06-29 03:00:25 +08:00
jxxghp
16fe916b07 将 AList 更名为 OpenList 2025-06-28 08:32:36 +08:00
jxxghp
d754c3dae3 更新 NoDataFound 组件 2025-06-27 23:26:43 +08:00
jxxghp
1b32a3e8cd 在消息视图中添加倒序功能 2025-06-27 20:39:15 +08:00
jxxghp
15a6f215b4 更新 TorrentCard.vue 2025-06-27 18:09:55 +08:00
jxxghp
38014ba342 添加发布时间显示功能,并在排序中支持按发布时间排序 2025-06-27 17:43:43 +08:00
jxxghp
7dcc293a09 fix mobile toast 2025-06-27 10:03:18 +08:00
jxxghp
35ce244490 Merge pull request #348 from Aqr-K/fix-progress 2025-06-26 15:47:08 +08:00
Aqr-K
3bade2060a fix(progress): 修复重复点击时,progressEventSource 被覆盖会产生孤儿事件的情况。 2025-06-26 14:31:51 +08:00
jxxghp
f8307f25c9 fix service.js 2025-06-26 12:32:16 +08:00
jxxghp
5c9ebb9aae 为Toast组件添加隐藏进度条选项以优化用户体验 2025-06-25 19:47:24 +08:00
jxxghp
ebc2a764c2 将vue-toast-notification替换为vue-toastification,并更新相关样式和依赖项 2025-06-25 17:42:36 +08:00
jxxghp
bed21856ab 调整背景透明度 2025-06-23 19:57:35 +08:00
jxxghp
61805d13ab 为通知列表添加细 scrollbar 样式以改善用户体验 2025-06-23 11:23:57 +08:00
jxxghp
e47d8d5d2b 修复通知列表的溢出问题 2025-06-23 11:18:55 +08:00
jxxghp
0bd81499f6 更新版本号至2.5.7-1 2025-06-17 20:00:07 +08:00
jxxghp
201ae2c237 fix https://github.com/jxxghp/MoviePilot/issues/4456 2025-06-17 19:59:23 +08:00
jxxghp
df4c3c7676 fix https://github.com/jxxghp/MoviePilot/issues/4455 2025-06-16 14:07:45 +08:00
jxxghp
667693902f 为MessageCard组件添加最小高度属性 2025-06-16 12:29:24 +08:00
jxxghp
9e261d30f8 v2.5.7 2025-06-16 11:52:21 +08:00
jxxghp
5f6bade809 为MessageCard组件添加图片加载占位符 2025-06-16 11:51:34 +08:00
jxxghp
273168ae5c 优化消息滚动逻辑 2025-06-15 13:40:01 +08:00
jxxghp
a55269e9e6 优化消息滚动逻辑 2025-06-15 08:05:05 +08:00
jxxghp
9c386f8533 为MessageCard组件添加图片加载事件,更新MessageView以处理图片加载完成后的滚动事件 2025-06-15 07:59:35 +08:00
jxxghp
17ee5f456a 实现未读消息的全局事件处理 2025-06-14 14:17:26 +08:00
jxxghp
6cefdb5d37 调整应用启动时的延迟时间和重试机制 2025-06-14 13:16:51 +08:00
jxxghp
74fc8bd131 优化插件和订阅的加载顺序配置,移除本地存储逻辑,增加错误处理 2025-06-14 11:08:42 +08:00
jxxghp
aa9dab5d96 优化未读消息弹窗的打开逻辑 2025-06-14 10:11:41 +08:00
jxxghp
5b461f8e1f 更新网络测试视图 2025-06-14 08:13:44 +08:00
jxxghp
bde06be3df Merge pull request #347 from cddjr/feat_nettest
feat 网络测试支持GitHub加速代理、新增pip测试
2025-06-14 07:57:19 +08:00
jxxghp
fe17986b2a 更新桌面图标徽章的逻辑 2025-06-14 07:52:55 +08:00
景大侠
e9160ecefd feat 网络测试支持GitHub加速代理、新增pip测试 2025-06-13 18:38:59 +08:00
jxxghp
05ebd48f09 Merge pull request #346 from wumode/fix_download_api 2025-06-13 14:25:18 +08:00
wumode
6dbc3f4bab fix: 无法设置非默认下载器状态 2025-06-13 08:50:54 +08:00
jxxghp
bc7166789b 更新 package.json 2025-06-12 23:05:11 +08:00
jxxghp
750b91db66 新增未读消息计数和桌面图标徽章更新功能 2025-06-12 22:58:16 +08:00
jxxghp
b69a338e13 fix https://github.com/jxxghp/MoviePilot/pull/4434 2025-06-12 18:42:42 +08:00
jxxghp
036fe65b12 Merge pull request #345 from alfchao/v2 2025-06-12 16:16:09 +08:00
xuchao3
732017ac77 fix:修改清华pip源地址 2025-06-12 11:25:04 +08:00
jxxghp
5bd71b4688 fix https://github.com/jxxghp/MoviePilot/issues/4424#issuecomment-2964853532 2025-06-12 11:13:37 +08:00
jxxghp
44ba2dff78 调整用户编辑对话框的样式 2025-06-11 20:38:20 +08:00
jxxghp
0954e4bde2 新增季NFO相关翻译及设置选项 2025-06-11 19:50:56 +08:00
jxxghp
5b183d31e2 更新 Footer.vue 2025-06-11 19:29:59 +08:00
jxxghp
b2017764eb 更新 Footer.vue 2025-06-11 19:22:07 +08:00
jxxghp
f27cd796b6 调整多个组件的高度计算逻辑 2025-06-11 13:21:42 +08:00
jxxghp
3c051b8698 优化用户信息展示和权限显示逻辑 2025-06-11 12:56:10 +08:00
jxxghp
052d6edd13 更新版本号至 2.5.5 2025-06-11 00:02:26 +08:00
jxxghp
e7dc61e3d9 移除 TOKENIZED_SEARCH 设定 2025-06-11 00:01:34 +08:00
jxxghp
f0aefdfdf8 更新管理描述,明确下载管理和站点管理功能 2025-06-10 23:55:43 +08:00
jxxghp
0beec368b8 重构用户卡片和用户编辑对话框中的权限显示逻辑 2025-06-10 23:52:45 +08:00
jxxghp
3f1d03a127 在多个组件中实现权限管理功能 2025-06-10 23:44:06 +08:00
jxxghp
eb143c28e3 新增用户权限管理功能 2025-06-10 23:25:59 +08:00
jxxghp
1631951a24 优化Footer组件中的按钮图标大小和样式 2025-06-10 22:55:14 +08:00
jxxghp
31bdd89373 更新刮削开关设置界面 2025-06-10 21:21:38 +08:00
jxxghp
ad5ae12d44 新增刮削开关设置功能 2025-06-10 19:56:12 +08:00
jxxghp
c838db262c 优化重启流程,增加重启状态管理和轮询清理逻辑 2025-06-09 21:09:12 +08:00
jxxghp
623b807a11 在插件安装成功后,清空过滤条件以确保数据刷新时的准确性 2025-06-09 20:54:16 +08:00
jxxghp
ce9335a842 优化插件卡片列表视图中的按钮颜色逻辑 2025-06-09 20:47:55 +08:00
jxxghp
1c62465c3e 新增插件市场手动刷新功能 2025-06-09 20:35:05 +08:00
jxxghp
a2c176bdee 新增服务状态检测与轮询功能,优化重启流程,增加超时提示信息 2025-06-09 16:21:31 +08:00
jxxghp
bff8c0f86b 优化注销流程,增加10秒延迟后再执行注销操作 2025-06-09 15:56:03 +08:00
jxxghp
1065973e07 feat:插件筛选运行中插件 2025-06-09 12:49:41 +08:00
jxxghp
8e042d5691 移除内存监控相关设置及其翻译,简化系统设置界面 2025-06-08 18:37:02 +08:00
jxxghp
d9a6b32e5f add apple-touch-icon-precomposed 2025-06-06 21:33:37 +08:00
jxxghp
eed3f97fbf 更新 package.json 2025-06-06 14:04:46 +08:00
jxxghp
6b9a8ed108 feat:内存监控开关 2025-06-06 13:50:09 +08:00
jxxghp
adc718b751 实现文件浏览器的拖动分隔条功能 2025-06-06 08:44:06 +08:00
jxxghp
df9981d0c9 重构 LoadingBanner 组件 2025-06-06 08:32:07 +08:00
jxxghp
f58b661b1b Merge pull request #344 from cddjr/fix_search_progress 2025-06-05 20:46:50 +08:00
景大侠
ec1926ba60 fix: 优化搜索进度条,避免卡”正在搜索,请稍候...“
1、通过进度有无变化来判定超时,避免误判
2、避免搜索期间误判完成,导致SSE被提前终止
2025-06-05 20:29:51 +08:00
jxxghp
e853851933 修改点击事件和工具栏密度设置 2025-06-05 19:31:34 +08:00
jxxghp
3705ce3b90 更新 UserAuthDialog.vue 2025-06-04 22:45:05 +08:00
jxxghp
7ad73ff251 移除保存设置时的重载系统调用,进一步简化设置保存逻辑 2025-06-04 08:19:16 +08:00
jxxghp
6c23e8892a 移除多个组件中的重载系统生效配置函数,简化保存设置逻辑 2025-06-03 19:53:31 +08:00
jxxghp
58efafac71 Merge pull request #343 from wkeylin/v2 2025-06-03 15:19:53 +08:00
wkeylin
abf2364bf6 fix: 日志日期优化 2025-06-03 14:30:15 +08:00
jxxghp
0650f35dbb Update module-federation-guide.md 2025-06-03 10:39:34 +08:00
jxxghp
cc593634d2 更新模块联邦指南,添加关于上传dist文件夹的注意事项,明确不需要上传的目录和文件类型 2025-06-03 10:37:12 +08:00
jxxghp
79a3b9de8a 更新版本号至 2.5.3 2025-06-03 10:17:19 +08:00
jxxghp
ceb46ec974 Merge pull request #342 from jtcymc/v2 2025-06-03 06:43:07 +08:00
shaw
a7e2893a57 refactor(components): 将 VSelect 组件替换为 VAutocomplete组件
- 在 DirectoryCard.vue 中将 VSelect 替换为VAutocomplete,用于 library_storage 字段
- 在 FilterRuleGroupCard.vue 中将两个 VSelect 组件替换为 VAutocomplete,用于 media_type 和 category 字段
2025-06-02 22:37:36 +08:00
shaw
2efe8efde0 refactor(components): 将 VSelect 组件替换为 VAutocomplete组件,以支持搜索待选项
- 在多个组件中将 VSelect 组件替换为 VAutocomplete 组件,以支持搜索待选项
- 此更改可以提供更丰富的用户交互体验和更好的性能
2025-06-02 21:48:07 +08:00
jxxghp
31047b0d44 优化账户设置缓存页面的筛选条件 2025-05-30 17:01:47 +08:00
jxxghp
7c2b724d10 fix ui 2025-05-30 09:04:15 +08:00
jxxghp
ca5670f06b v2.5.2 2025-05-30 08:48:39 +08:00
jxxghp
427e05871d 调整SubscribeCard组件的样式 2025-05-30 08:32:16 +08:00
jxxghp
bef56bdb56 优化账户设置缓存页面中的输入字段,添加持久提示和图标,提升用户体验 2025-05-30 08:27:10 +08:00
jxxghp
d450d02e18 在账户设置缓存页面中添加固定表头 2025-05-30 08:25:03 +08:00
jxxghp
85a766cc7b 调整多个组件的样式和结构,优化用户界面体验 2025-05-30 08:15:48 +08:00
jxxghp
a473f356c9 优化缓存管理页面 2025-05-29 22:56:40 +08:00
jxxghp
52b5fdf383 添加清空缓存确认提示,优化缓存管理页面的用户体验 2025-05-29 22:37:03 +08:00
jxxghp
b886f02043 缓存管理页面 2025-05-29 20:49:19 +08:00
jxxghp
61963ea497 reset 2025-05-29 20:12:14 +08:00
jxxghp
2f9b27ad9e reset 2025-05-29 20:11:34 +08:00
jxxghp
9334109767 Merge pull request #341 from madrays/v2
增加缓存管理页面
2025-05-29 12:32:51 +08:00
jxxghp
2bc52576d9 更新package.json中的版本号 2025-05-29 08:23:18 +08:00
jxxghp
700d2c4a51 刷新数据时重新加载文件夹配置,以确保插件正确显示。 2025-05-29 08:21:17 +08:00
madrays
103bdb32c8 增加缓存管理页面 2025-05-29 00:45:12 +08:00
jxxghp
92b745e180 优化搜索站点对话框 2025-05-28 21:25:37 +08:00
jxxghp
a2007083b8 更新MoviePilot自动更新设置逻辑,支持'release'和'dev'选项 2025-05-28 21:15:52 +08:00
jxxghp
36a5f7ff29 添加自动更新MoviePilot和站点资源的设置选项 2025-05-28 21:05:46 +08:00
jxxghp
f727aea51d 为多个设置组件的保存按钮添加图标,以提升用户体验和一致性。 2025-05-28 10:09:05 +08:00
jxxghp
936ca24328 优化对话框组件,添加图标以提升用户体验 2025-05-28 08:59:31 +08:00
jxxghp
62f49b6087 优化插件文件夹内插件的筛选逻辑 2025-05-28 08:49:53 +08:00
jxxghp
e9ddbf9962 添加代理服务器设置 2025-05-28 08:24:42 +08:00
jxxghp
196cf522e6 fix 2025-05-27 21:41:06 +08:00
jxxghp
3fce3bf4a7 优化多个组件的输入框,添加图标以提升用户体验,确保提示信息的一致性和可读性。 2025-05-27 21:38:25 +08:00
jxxghp
1cfee25695 优化多个组件的输入框,添加图标以提升用户体验,确保提示信息的一致性和可读性。 2025-05-27 21:23:08 +08:00
jxxghp
5711285a77 更新多个卡片组件,统一标题文本为“配置”,添加图标以提升用户体验,优化输入框提示信息,确保一致性和可读性。 2025-05-27 17:46:51 +08:00
jxxghp
e6f537ca3a 优化多个对话框组件的布局,添加图标以提升用户体验,调整部分文本提示,确保一致性和可读性。 2025-05-27 17:40:20 +08:00
jxxghp
3b5220af57 fix plugin list loading 2025-05-27 14:00:15 +08:00
jxxghp
fa6b4b1d2d 调整插件列表显示行数,从三行改为两行,以优化界面布局。 2025-05-27 13:49:55 +08:00
jxxghp
7968e5374b 优化文件夹内插件的显示顺序,确保按照保存顺序排列插件,提升用户体验。 2025-05-27 13:48:13 +08:00
jxxghp
64997ebe45 重构插件混合排序逻辑,优化全局排序配置,兼容旧格式,提升插件和文件夹的排序体验。 2025-05-27 13:40:55 +08:00
jxxghp
f8592b01e2 优化错误日志输出 2025-05-27 13:29:53 +08:00
jxxghp
087474f514 fix 2025-05-27 13:26:09 +08:00
jxxghp
1725088f05 fix 插件混合排序问题 2025-05-27 13:12:09 +08:00
jxxghp
ec1b756a3d 添加混合排序功能,重构插件列表显示逻辑,移除冗余代码并优化拖拽排序体验。 2025-05-27 13:01:08 +08:00
jxxghp
76a06e0817 移除 AddDownloadDialog 组件中的显示器宽度逻辑,简化对话框全屏显示设置 2025-05-27 07:54:34 +08:00
jxxghp
02fb608d7b 更新 PluginCard.vue 2025-05-26 22:40:48 +08:00
jxxghp
e17fc2fc12 更新 package.json 2025-05-26 21:38:10 +08:00
jxxghp
4f6c317652 修复 PersonDetailView 组件中的 VImg 标签,移除多余的 v-img 指令以简化代码。 2025-05-26 21:30:23 +08:00
jxxghp
46c198be26 重构 credits.vue 和 media.vue 组件,简化 API 路径处理,移除不必要的路由参数,同时优化 PersonCardListView 组件的样式。 2025-05-26 21:28:52 +08:00
jxxghp
8552203d43 PluginCard 组件中的实时日志弹窗代码 2025-05-26 13:26:13 +08:00
jxxghp
139eaa7016 优化 PluginCard 组件 2025-05-26 12:44:08 +08:00
jxxghp
d81120ab8f 为 PluginCard 组件添加实时日志弹窗功能 2025-05-26 12:37:49 +08:00
jxxghp
6353d56beb Merge pull request #339 from madrays/v2 2025-05-26 11:26:26 +08:00
madrays
aa05496b42 插件分身多语言支持 2025-05-26 11:20:10 +08:00
madrays
dc15e537d8 增加插件分身功能 2025-05-26 10:55:55 +08:00
jxxghp
6fbd41f40a 优化 PluginAppCard 和 PluginCard 组件的样式 2025-05-25 20:57:42 +08:00
jxxghp
0181f614e1 为 SiteCard 和 SubscribeCard 组件添加显示器宽度逻辑,优化图标的鼠标移动样式 2025-05-25 19:50:57 +08:00
jxxghp
fded7b0b28 为多个组件的对话框添加全屏显示逻辑 2025-05-25 19:44:04 +08:00
jxxghp
7e637f835a 优化 TorrentCardListView 和 TorrentRowListView 组件的确认按钮样式 2025-05-25 15:51:24 +08:00
jxxghp
deaaf1834d 为 v-table 组件的表头添加背景模糊效果和背景色,提升视觉效果 2025-05-25 15:01:28 +08:00
jxxghp
139c870f99 更新 MediaServerCard.vue 2025-05-25 11:01:26 +08:00
jxxghp
4cc2350bc6 移除 SiteResourceDialog 组件中的分页文本绑定 2025-05-25 09:17:00 +08:00
jxxghp
8b31a118da 为英文和中文语言文件添加分页文本格式,提升用户界面信息展示 2025-05-24 22:28:58 +08:00
jxxghp
cca26acb78 更新 PluginFolderCard 和 PluginCardListView 组件的默认渐变背景颜色,提升视觉效果 2025-05-24 20:09:43 +08:00
jxxghp
245edbd2f6 优化 PluginAppCard 组件的文本显示方式 2025-05-24 20:06:11 +08:00
jxxghp
903d22c622 优化多个组件的样式和结构,调整文本显示方式,提升用户界面体验 2025-05-24 20:01:20 +08:00
jxxghp
8b1805628e 为 PluginFolderCard 组件添加背景图片计算逻辑和背景遮罩样式,优化背景显示效果 2025-05-24 17:36:32 +08:00
jxxghp
11c8c488da 调整 ConfirmDialog 组件的宽度属性 2025-05-24 17:22:49 +08:00
jxxghp
4dd4e0e148 自实现 UseConfirm 组件 2025-05-24 17:19:43 +08:00
jxxghp
21f352aa64 优化 PluginAppCard 组件,添加插件标签显示功能;调整 PluginFolderCard 组件的菜单位置和图标样式;更新 PluginCardListView 组件的文件夹显示逻辑。 2025-05-24 16:38:34 +08:00
jxxghp
6c4beffdb7 优化多个组件的按钮样式 2025-05-24 15:37:40 +08:00
jxxghp
43d3efa838 优化 PluginFolderCard 组件 2025-05-24 14:47:47 +08:00
jxxghp
1c99839ab4 更新版本号至 2.5.0 2025-05-24 14:20:36 +08:00
jxxghp
c9e05ce5b1 调整 PluginFolderCard 组件的最小高度属性,从 9rem 修改为 8.5rem 2025-05-24 14:11:39 +08:00
jxxghp
3fe7ed0e1d 优化多个组件中的按钮样式 2025-05-24 14:06:10 +08:00
jxxghp
b3bff5c6f5 移除 PluginCardListView 组件中的调试日志,优化错误处理逻辑 2025-05-24 14:06:10 +08:00
jxxghp
e357bac70f 为文件夹功能添加国际化支持 2025-05-24 14:06:10 +08:00
jxxghp
ad51d4e4f3 调整 PluginCardListView 组件的样式 2025-05-24 14:06:10 +08:00
jxxghp
912d8ced93 更新 PluginFolderCard 组件,添加国际化支持 2025-05-24 14:06:10 +08:00
jxxghp
8334999e98 优化 PluginAppCard、PluginCard 和 PluginFolderCard 组件的样式,调整布局和响应式设计 2025-05-24 14:06:10 +08:00
jxxghp
5e23ea7809 更新 NotificationChannelCard.vue 2025-05-24 09:43:47 +08:00
jxxghp
b62d291aab Merge pull request #338 from madrays/v2 2025-05-24 06:34:30 +08:00
madrays
a34dd8148f 重构插件页面,增加文件夹功能 2025-05-24 03:58:14 +08:00
jxxghp
ba13e6ac35 fix #337 2025-05-23 22:29:19 +08:00
jxxghp
8efa5f7a28 调整 SubscribeCard 组件中 VCardText 的下边距,从 1 修改为 2,以改善布局效果 2025-05-23 08:04:26 +08:00
jxxghp
f0ef9565e2 更新 SubscribeCard.vue 2025-05-23 07:24:23 +08:00
jxxghp
78688ab63c 优化 SubscribeCard 组件的样式,调整文本和图标的大小,增强可读性 2025-05-23 07:15:30 +08:00
jxxghp
e90b30bf63 调整 SubscribeCard 组件中图像容器的宽度,从 16px 修改为 14px,以优化布局 2025-05-22 15:22:51 +08:00
jxxghp
5312b82ba7 优化 PluginAppCard、PluginCard 和 SubscribeCard 组件的样式,调整布局和间距,增强响应式设计 2025-05-22 15:21:25 +08:00
jxxghp
bc705f2560 更新 SubscribeCard.vue 2025-05-22 06:59:39 +08:00
jxxghp
6477f43de1 更新 SubscribeCard.vue 2025-05-22 06:50:32 +08:00
jxxghp
bdc0fdd076 优化 PluginAppCard 和 PluginCard 组件的样式 2025-05-21 21:29:26 +08:00
jxxghp
1f09e1ff93 优化垂直导航样式,修复边框半径设置,删除不必要的代码,移除 TransitionExpand 组件 2025-05-21 21:06:40 +08:00
jxxghp
4bcc89d9da 优化 PluginAppCard 和 PluginCard 组件的样式 2025-05-21 20:49:52 +08:00
jxxghp
8f93b49dde 优化多个组件的样式,调整卡片布局和间距,更新网格列数以适应不同屏幕尺寸 2025-05-21 20:26:48 +08:00
jxxghp
74eeae900e 调整背景透明度 2025-05-21 19:32:42 +08:00
jxxghp
63424bb134 Merge pull request #336 from Aqr-K/fix/i18n 2025-05-20 19:48:58 +08:00
Aqr-K
1c5e410881 fix(i18n): 修复非支持地区输出null,导致的显示问题 2025-05-20 19:14:31 +08:00
jxxghp
f79cc41f3c 更新 FetchMediasAction 组件,调整下拉框选项格式为包含值和标题的对象 2025-05-19 12:27:07 +08:00
jxxghp
49cccbe69e 更新 package.json 版本号至 2.4.9 2025-05-18 15:36:46 +08:00
jxxghp
c4a02f7497 新增自定义通知类型支持,更新相关提示信息和样式 2025-05-18 13:39:44 +08:00
jxxghp
59e12c5e96 优化 TorrentCard 组件的样式,更新替换词支持格式的描述信息 2025-05-18 12:55:38 +08:00
jxxghp
a347bdc412 将 package.json 版本号降级至 2.4.8 2025-05-16 12:38:34 +08:00
jxxghp
3f3c1ecd02 更新 package.json 版本号至 2.4.9 2025-05-16 12:37:34 +08:00
jxxghp
d5d9c78c91 重构 InvokePluginAction 组件,优化插件和动作选项的加载逻辑 2025-05-15 22:12:57 +08:00
jxxghp
5b0d8d902b 工作流新增调用插件功能组件 2025-05-15 20:53:41 +08:00
jxxghp
2978e46d02 fix ui 2025-05-15 13:03:09 +08:00
jxxghp
54e0633d77 更新 package.json 2025-05-15 12:09:53 +08:00
jxxghp
ab3db66195 增加安全图片域名功能,优化自定义壁纸API相关提示信息 2025-05-15 09:59:51 +08:00
jxxghp
17e19da3d8 Merge pull request #334 from Seed680/v2
背景壁纸增加自定义API
2025-05-15 09:22:36 +08:00
qiaoyun680
f22aca0c5d 背景壁纸增加自定义API,优化输入提示 2025-05-14 20:52:10 +08:00
qiaoyun680
c257e11ee3 背景壁纸增加自定义API 2025-05-14 20:28:01 +08:00
jxxghp
8b23f0bb2e 在登录页面中优化背景模糊效果 2025-05-14 14:35:39 +08:00
jxxghp
a82a89afd3 优化样式,调整背景模糊效果和颜色透明度,以提升用户界面视觉效果。 2025-05-14 14:22:44 +08:00
jxxghp
5c0d0d5a95 更新 DashboardItem 接口,将 render_mode 属性改为可选,并调整 dashboard.vue 中 VDialog 组件的最大高度以改善用户界面体验。 2025-05-14 13:22:31 +08:00
jxxghp
9dbd090482 优化 TorrentItem.vue 中的 VChip 组件,调整属性格式以提升代码可读性。 2025-05-14 11:26:53 +08:00
jxxghp
e25583dff9 v2.4.7 2025-05-14 09:11:44 +08:00
jxxghp
d997dc0394 优化登录页面,添加登录按钮的加载状态管理,确保用户体验流畅。 2025-05-13 23:28:03 +08:00
jxxghp
6b6353ed41 优化 App.vue 中的背景图片加载逻辑,调整异步加载方式并简化图片地址获取逻辑。 2025-05-13 19:25:41 +08:00
jxxghp
e73d906564 fix #333 2025-05-13 19:14:13 +08:00
jxxghp
7e3e850e21 Merge pull request #333 from Seed680/v2 2025-05-13 18:46:23 +08:00
qiaoyun680
56b2dc4ebf 资源搜索结果页面增加排序切换 2025-05-13 16:25:22 +08:00
jxxghp
9444b0e518 优化 App.vue 中的背景图片加载逻辑,添加登录状态变化时清空背景图片数组的处理,并更新图片地址获取逻辑以支持缓存和原始地址的选择。 2025-05-13 13:37:37 +08:00
jxxghp
bcb72118f5 在背景图片加载失败时添加重试机制,3秒后自动重试加载背景图片 2025-05-13 08:18:44 +08:00
jxxghp
c59be8d981 更新 module-federation-guide.md 2025-05-12 18:03:09 +08:00
jxxghp
8466a40455 重构 App.vue 中的背景图片加载逻辑 2025-05-12 13:51:45 +08:00
jxxghp
f435b4fc52 在 fetchBackgroundImages 函数中初始化 activeImageIndex 为 0,以确保背景图片加载时的索引正确。 2025-05-12 11:23:48 +08:00
jxxghp
5686c6fe65 在构建工作流中移除了删除标签的选项,以简化发布流程。 2025-05-12 11:09:49 +08:00
jxxghp
6810112eda 在 App.vue 中添加登录状态变化的监听,确保登录后重新加载背景图片;同时更新 .vscode/settings.json,增加 i18n-ally.localesPaths 配置。 2025-05-12 10:44:01 +08:00
jxxghp
11a2d07935 优化 App.vue 中的国际化代码,调整 LoadingBanner 组件的样式,增加 SubscribeFilesDialog 组件的加载状态管理。 2025-05-12 07:56:52 +08:00
jxxghp
02cd2f1570 在构建工作流中添加了删除标签和发布的选项,并设置在出错时继续执行。 2025-05-11 08:44:30 +08:00
jxxghp
924c1d72ea 优化自定义滚动条样式 2025-05-11 08:41:48 +08:00
jxxghp
5d9b2e1919 更新 AlistConfigDialog.vue 2025-05-11 07:56:50 +08:00
jxxghp
f7fa440f9a 更新 AlistConfigDialog.vue 2025-05-10 23:31:49 +08:00
jxxghp
d4aaa46968 优化 SubscribeCard 和 SubscribeShareCard 组件的结构 2025-05-10 23:18:00 +08:00
jxxghp
93ac5e1b3b 优化 PluginAppCard 和 PluginCard 组件的背景样式,更新渐变效果以增强视觉层次感。 2025-05-10 22:38:19 +08:00
jxxghp
c7a8c68e14 调整 PluginCard 组件的背景样式,优化渐变效果以提升视觉效果。 2025-05-10 22:25:53 +08:00
jxxghp
77afb4d736 优化 PluginAppCard 和 PluginCard 组件的背景样式 2025-05-10 22:10:02 +08:00
jxxghp
141796ab24 更新 AccountSettingSystem.vue 中的 v-model,修改为 TMDB_SCRAP_ORIGINAL_IMAGE,以更准确地反映设置项。 2025-05-10 21:58:13 +08:00
jxxghp
30d733f55d v2.4.6 2025-05-10 21:54:32 +08:00
jxxghp
6a39e65b6b 添加 TMDB 刮削原语种选项。 2025-05-10 21:45:45 +08:00
jxxghp
c27013b7ad Merge pull request #332 from Seed680/v2 2025-05-10 21:24:45 +08:00
jxxghp
582ce496fa 添加 TMDB 刮削图片语言相关 2025-05-10 20:44:06 +08:00
jxxghp
5b4dbb82d5 调整 ShortcutBar 组件中的对话框最大宽度 2025-05-10 19:33:57 +08:00
jxxghp
011a0d16ab 加载远程组件时如未注册则重新注册 2025-05-10 08:40:14 +08:00
jxxghp
ac5539194d 优化 PersonCard 组件,移除多余的样式类以简化结构 2025-05-09 20:29:54 +08:00
Seed680
6b7e1b3c4e Merge branch 'jxxghp:v2' into v2 2025-05-09 09:21:10 +08:00
jxxghp
30c3d00139 移除 Vite 配置中的手动分块选项,简化配置以提升可读性和维护性。 2025-05-09 00:06:58 +08:00
jxxghp
36d460cd74 更新 Vite 配置,启用压缩选项以移除控制台日志和调试器, 2025-05-09 00:04:14 +08:00
jxxghp
af287f50bb 更新 Vite 配置,添加 dummy 远程模块,简化共享库列表,提升代码可读性和维护性。 2025-05-08 23:54:34 +08:00
jxxghp
3199392637 优化 Vite 配置,更新共享库列表,移除不必要的 Rollup 选项,并简化远程模块加载逻辑,提升代码可读性和维护性。 2025-05-08 23:32:13 +08:00
Seed680
11cb2eb0f8 Merge branch 'jxxghp:v2' into v2 2025-05-08 23:28:18 +08:00
qiaoyun680
4dce1c94a3 feat(storge): 添加alist存储的登录方式(令牌、访客) 2025-05-08 23:26:54 +08:00
jxxghp
4e3a61b8a8 增强共享作用域管理,添加初始化和dummy远程模块功能,防止生产环境中的共享作用域问题,同时为远程模块URL添加版本标记以防止缓存。 2025-05-08 23:08:48 +08:00
jxxghp
3b1e65fc75 修复 Vite 配置中的压缩选项,将 drop_console 设置为 false,以保留控制台日志。 2025-05-08 22:53:56 +08:00
jxxghp
32b4b944cc fix remoteEntry url 2025-05-08 22:42:20 +08:00
jxxghp
22a51a524e fix ui 2025-05-08 19:48:35 +08:00
jxxghp
ac0cbbdb95 Merge pull request #331 from Seed680/v2 2025-05-08 17:50:06 +08:00
qiaoyun680
2260f23d3c feat(storge): 添加存储重置选项 2025-05-08 17:39:36 +08:00
jxxghp
d43952c0bf 优化 Vuetify 实例配置,添加默认主题和主题设置,以提升主题管理的灵活性和可维护性。 2025-05-08 17:32:28 +08:00
jxxghp
bd368123d2 更新 package.json 中的版本号,从 2.4.4-1 升级至 2.4.5 2025-05-08 17:26:31 +08:00
jxxghp
cbdd70427e fix 联邦组件样式冲突问题 2025-05-08 17:24:04 +08:00
jxxghp
d7526f5283 移除 Vite 配置中的 PostCSS 插件设置,并在 SiteCard 组件中为按钮添加统一的大小属性,以提升界面一致性。 2025-05-08 16:09:32 +08:00
jxxghp
08e914a968 优化 SiteCard 组件,调整按钮样式和图标大小,提升界面一致性和可读性。 2025-05-08 15:53:32 +08:00
jxxghp
53a8835b6d 更新 Tailwind CSS 配置,添加重要性设置;在 Vite 配置中添加 PostCSS 插件;优化多个组件的 VCard 结构,移除多余的 class 属性以提升代码整洁性。 2025-05-08 15:31:57 +08:00
jxxghp
e3bff71a91 优化 Vue 渲染模式下的组件结构,将动态组件包裹在 VCard 和 VCardText 中,以提升布局一致性和可读性,同时在 DashboardElement.vue 中为动态插件组件添加 API 属性。 2025-05-08 14:48:33 +08:00
jxxghp
6276009e88 修复文件浏览器工具栏中的“向上”按钮,仅在路径段存在时显示 2025-05-08 13:13:49 +08:00
jxxghp
ddc5320f71 fix https://github.com/jxxghp/MoviePilot/issues/4211#issuecomment-2858674237 订阅排序相互干扰的问题 2025-05-08 10:35:07 +08:00
jxxghp
15af66aaaf fix 资源筛选 2025-05-08 07:11:29 +08:00
jxxghp
fe7a080553 更新插件组件的依赖版本 2025-05-07 17:25:59 +08:00
jxxghp
66bfc3e868 更新 Vite 配置 2025-05-07 17:16:39 +08:00
jxxghp
93aa3fb95d 更新示例 2025-05-07 16:20:28 +08:00
jxxghp
4f5caf1712 更新 module-federation-guide.md 2025-05-07 15:45:42 +08:00
jxxghp
9d27e967cd 优化示例UI 2025-05-07 14:49:24 +08:00
jxxghp
eb3e035a7c 更新 Vite 配置以支持 vuetify/styles 的版本要求,优化共享依赖配置 2025-05-07 14:34:10 +08:00
jxxghp
04200e94ff 移除 Config.vue 和 Dashboard.vue 中的样式,更新 Page.vue 以使用 API 获取最近记录,增强组件功能和代码整洁性。 2025-05-07 13:28:42 +08:00
jxxghp
ae9a13e0fa 添加“综合筛选”和“清除全部”功能 2025-05-07 12:22:54 +08:00
jxxghp
df8857fb52 fix example 2025-05-07 10:56:28 +08:00
jxxghp
9642fed1f1 移除 DashboardItem 接口中的 component_url 属性,简化类型定义。 2025-05-07 10:56:28 +08:00
jxxghp
1a273ea2d6 更新 module-federation-guide.md 2025-05-07 09:32:41 +08:00
jxxghp
c0ba921a7e 更新 Config.vue 2025-05-07 09:31:58 +08:00
jxxghp
8bbad227eb 更新 Page.vue 2025-05-07 09:31:29 +08:00
jxxghp
d3f9c04209 更新插件组件文档,调整多个组件以支持关闭功能,增强用户交互体验,并修正配置示例以反映最新的代码结构和依赖关系。 2025-05-07 08:21:44 +08:00
jxxghp
d3a6703a77 添加关闭页面的功能,更新多个组件以支持新的事件通知机制,并调整文档以反映最新的功能变化,提升用户交互体验。 2025-05-07 06:59:53 +08:00
jxxghp
1100fa47be 更新 PluginConfigDialog.vue 2025-05-07 00:25:19 +08:00
jxxghp
1e33087786 更新插件组件文档 2025-05-07 00:05:25 +08:00
jxxghp
e59423e912 将样式标签修改为 scoped,以提高样式的局部作用域,增强组件的样式隔离性。 2025-05-07 00:00:02 +08:00
jxxghp
146a1fe23d 更新多个组件以支持新的事件通知机制,添加切换到配置页面的功能,调整文档以反映组件文件名的变化,提升用户交互体验。 2025-05-06 23:56:28 +08:00
jxxghp
4586f6982a 优化多个组件的远程加载逻辑,移除不必要的属性绑定,增强错误处理机制,提升用户体验。 2025-05-06 23:31:37 +08:00
jxxghp
703204c69a 优化 Vite 配置,移除不再使用的代理规则,更新多个组件以增强远程组件加载逻辑,添加错误处理和加载状态显示,提升用户体验。 2025-05-06 21:34:49 +08:00
jxxghp
05cc160311 在 Vite 配置中添加新的代理规则,支持 '/plugin_static' 路径的请求转发至本地 API 2025-05-06 13:16:35 +08:00
jxxghp
0568f8a85d 更新 Vite 配置,修改主机名称为 'MoviePilot',优化插件配置,调整共享依赖格式以简化配置。 2025-05-06 13:00:36 +08:00
jxxghp
36b113ef1c 更新 module-federation-guide.md 2025-05-06 11:58:01 +08:00
jxxghp
520180f6f5 更新模块联邦文档,调整远程组件API路径格式,优化组件加载逻辑,移除不必要的注册步骤,增强代码可读性。 2025-05-06 11:44:08 +08:00
jxxghp
d349d2b500 增强模块联邦支持,添加动态导入远程模块的声明,更新示例项目以展示新组件结构和配置,调整 Vite 配置以支持更灵活的远程组件加载。 2025-05-06 08:53:33 +08:00
jxxghp
643ca35aed 增强远程组件注册机制,添加动态注册和获取功能,更新相关文档以指导插件开发者。调整多个组件以支持新注册逻辑。 2025-05-05 22:13:07 +08:00
jxxghp
36ef7ba589 更新 README 文档,调整开发和配置部分的标题格式,以提高可读性和一致性。 2025-05-05 21:44:22 +08:00
jxxghp
b5761bd18d 更新组件声明,移除 LocaleSwitcher 和 ThemeSwitcher,更新 README 文档以增强模块联邦功能的描述,并调整 Vite 配置以支持 ESNext 目标。 2025-05-05 21:41:03 +08:00
jxxghp
047e827884 添加 @originjs/vite-plugin-federation 依赖,并在多个组件中实现远程组件加载功能 2025-05-05 21:26:53 +08:00
jxxghp
48828fd72d 更新消息模板标题,添加国际化支持 2025-05-05 20:34:13 +08:00
jxxghp
3f4165e4b1 更新国际化文件,添加消息模板相关文本,并优化模板编辑器界面 2025-05-05 20:31:15 +08:00
jxxghp
6d789ed73b Merge pull request #330 from wikrin/template
feat(setting): 添加消息推送自定义选项
2025-05-05 19:30:42 +08:00
Attente
e77297f7b2 feat(setting): 添加消息推送自定义选项
- wikrin/MoviePilot@20c1f30
2025-05-05 05:43:48 +08:00
jxxghp
bb52a4704f 更新 zh-TW.ts 2025-05-04 14:44:13 +08:00
jxxghp
127df15674 更新 zh-CN.ts 2025-05-04 14:43:45 +08:00
jxxghp
95bcc263e8 更新 en-US.ts 2025-05-04 14:43:04 +08:00
jxxghp
20b120c247 在 PluginConfigDialog.vue 和 PluginDataDialog.vue 中,将错误提示从 $toast 更改为 console.error,以便更好地调试组件加载失败的情况,并在错误信息中添加详细的错误描述。 2025-05-03 23:29:24 +08:00
jxxghp
e644f6bacc 更新 DashboardElement.vue 2025-05-03 22:55:03 +08:00
jxxghp
5e9c7124ce 更新 PluginConfigDialog.vue 2025-05-03 22:54:19 +08:00
jxxghp
84e121bc0e 更新 PluginDataDialog.vue 2025-05-03 22:53:32 +08:00
jxxghp
abff2071bd 在多个组件中优化动态加载逻辑,使用 API 获取组件并处理加载失败情况,以提升用户体验和代码健壮性 2025-05-03 22:31:38 +08:00
jxxghp
078afd5174 在多语言文件中添加管理员和用户ID列表的占位符文本,以提升用户输入体验 2025-05-03 21:36:38 +08:00
jxxghp
4a8cf16012 在多个组件中添加渲染模式支持,优化插件配置和数据加载逻辑,增强用户体验和代码可读性 2025-05-03 10:04:50 +08:00
jxxghp
04e9b68e4a 在 custom.scss 中添加 .match-height.v-row 样式,确保 .v-card 组件的高度为 100%,以提升布局一致性 2025-05-02 22:03:17 +08:00
jxxghp
f12c3dac9f 在 FileBrowser.vue 组件中,更新存储图标逻辑,添加默认图标以提升用户体验 2025-05-02 21:50:08 +08:00
jxxghp
73b9663b27 优化 resource.vue 中搜索标题栏的样式,调整字体类名和布局,以提升界面美观性和一致性 2025-05-02 21:14:24 +08:00
jxxghp
a73068069c 调整 TorrentItem.vue 组件,修改图标和名称的布局样式,以提升界面美观性和可读性 2025-05-02 21:07:39 +08:00
jxxghp
2f963ba7ab 调整 resource.vue 中搜索标题的字体大小,从 1.5rem 修改为 1.2rem,以改善界面一致性 2025-05-02 20:50:31 +08:00
jxxghp
9df70e5485 优化 StorageCard.vue 组件,简化存储信息显示逻辑,提升代码可读性 2025-05-02 20:43:37 +08:00
jxxghp
dfa34f090b 更新 resource.vue 2025-05-02 20:26:04 +08:00
jxxghp
388e9987cd 更新 resource.vue 2025-05-02 20:17:28 +08:00
jxxghp
9fe434849c 优化用户通知组件,增加通知列表容器样式以支持滚动,提升用户体验 2025-05-02 13:28:59 +08:00
jxxghp
95edaa99b6 更新国际化文本,将“类型”和“内容”替换为“渠道”和“用户ID”以提升用户体验 2025-05-02 12:09:14 +08:00
jxxghp
b3501d791e 更新 StorageCard.vue 2025-05-02 09:57:16 +08:00
jxxghp
5f2e93dde3 添加下载器和媒体服务器选项,重构相关组件以支持新功能,并更新国际化文本以提升用户体验 2025-05-02 08:01:19 +08:00
jxxghp
bf22d7f5e9 更新 package.json 2025-05-01 22:44:35 +08:00
jxxghp
08bbe8d841 优化文件重命名对话框,添加“自动识别名称”按钮,并更新国际化文本以支持新功能 2025-05-01 20:27:56 +08:00
jxxghp
572293bb4d 重构存储选项,更新为存储属性,优化存储字典和图标字典的生成逻辑,提升组件对存储配置的支持 2025-05-01 19:56:06 +08:00
jxxghp
f56d1c68c7 更新存储卡组件,添加自定义存储配置对话框,优化存储名称显示,并完善国际化文本 2025-05-01 19:35:08 +08:00
jxxghp
900dd6e958 添加自定义存储选项,更新存储卡组件以支持关闭事件,并完善国际化文本 2025-05-01 13:51:13 +08:00
jxxghp
5327c04e7e 添加自定义下载器和媒体服务器选项 2025-05-01 13:41:02 +08:00
jxxghp
f1835dd46c 更新验证器:为必填项和数字输入添加国际化支持,提升用户体验 2025-04-30 10:57:35 +08:00
jxxghp
9b620a760d 更新 FullCalendarView.vue 2025-04-30 06:50:31 +08:00
jxxghp
530174ff79 在README.md中添加语言切换链接,支持中文和英文版本,提升用户友好性 2025-04-29 19:47:01 +08:00
jxxghp
b6bb3691f0 优化消息对话框和消息视图组件:增加消息对话框和滚动容器的引用,调整滚动逻辑以确保更流畅的用户体验,更新样式以改善消息显示效果 2025-04-29 17:28:13 +08:00
jxxghp
6fd5e30fdc 优化对话框和消息视图组件:调整消息对话框最大宽度,增加全屏支持,更新消息内容引用,修改消息项的样式,提升用户体验 2025-04-29 17:08:55 +08:00
jxxghp
ba09afb744 调整对话框最大高度:将PluginCard和SearchBarDialog组件中的对话框最大高度设置为85vh,优化用户界面体验 2025-04-29 15:49:24 +08:00
jxxghp
d04aea6067 更新国际化支持:将壁纸项中的“Bing每日图片”替换为“媒体服务器”,提升用户体验 2025-04-29 15:26:26 +08:00
jxxghp
4ff9be458c 更新fetchGlobalSettings函数:在API请求中添加token参数以增强安全性 2025-04-29 14:58:45 +08:00
jxxghp
6f5dbe5808 优化图片地址获取逻辑:在使用图片缓存时增加登录状态检查,移除对doubanio.com的代理处理 2025-04-29 13:36:36 +08:00
jxxghp
b772e2d9ef 更新国际化支持:为存储、媒体类型、通知开关及操作步骤等组件添加多语言文本,提升用户体验 2025-04-29 13:24:27 +08:00
jxxghp
b75c93231e 更新 discover.vue 2025-04-29 12:19:25 +08:00
jxxghp
ca20931ed6 更新国际化支持:为目录相关组件添加多语言文本,提升用户体验 2025-04-29 11:55:43 +08:00
jxxghp
893df36c9d 更新国际化支持:为过滤媒体和资源组件添加多语言文本,提升用户体验 2025-04-29 11:51:45 +08:00
jxxghp
2a6abded08 更新国际化支持:为下载列表和工作流组件添加多语言文本,提升用户体验 2025-04-29 10:30:48 +08:00
jxxghp
675cdd5bba 更新国际化支持:为发现页面、插件卡片列表和下载列表等组件添加多语言文本,提升用户体验 2025-04-29 08:45:59 +08:00
jxxghp
b0150f25f6 更新类型定义:在auto-imports.d.ts中添加Slot和createRef类型,增强Vue组件的类型支持 2025-04-29 08:29:16 +08:00
jxxghp
87cda220ad 更新国际化支持:为豆瓣相关组件及字典添加多语言文本,提升用户体验 2025-04-29 08:29:05 +08:00
jxxghp
ce90ed84f6 更新国际化支持:为Bangumi和TMDB相关组件添加多语言文本,提升用户体验 2025-04-29 08:25:20 +08:00
jxxghp
2ae843fb3e 更新国际化支持:为工作流侧边栏及相关组件添加多语言文本,提升用户体验 2025-04-29 08:15:19 +08:00
jxxghp
48513efbe0 更新国际化支持:为工作流组件及相关对话框添加多语言文本,提升用户体验 2025-04-29 07:16:33 +08:00
jxxghp
83cb69b794 更新国际化支持:为订阅季节对话框及相关组件添加多语言文本,提升用户体验 2025-04-28 22:17:43 +08:00
jxxghp
7879a75ba8 更新国际化支持:为订阅历史对话框及相关组件添加多语言文本,提升用户体验 2025-04-28 22:14:24 +08:00
jxxghp
4682cdb1a8 更新国际化支持:为站点资源对话框及相关组件添加多语言文本,提升用户体验 2025-04-28 22:10:30 +08:00
jxxghp
b228246508 更新国际化支持:为站点Cookie更新对话框及相关组件添加多语言文本,提升用户体验 2025-04-28 22:04:41 +08:00
jxxghp
021e0b34f0 更新国际化支持:为插件配置对话框及相关组件添加多语言文本,提升用户体验 2025-04-28 22:02:14 +08:00
jxxghp
2182b3f325 更新国际化支持:为用户卡片及相关组件添加多语言文本,提升用户体验 2025-04-28 21:59:08 +08:00
jxxghp
b5fbf7ccd8 更新国际化支持:为订阅卡片及相关组件添加多语言文本,提升用户体验 2025-04-28 21:55:44 +08:00
jxxghp
17b8f9bddd 更新国际化支持:为存储卡片及相关组件添加多语言文本,提升用户体验 2025-04-28 21:53:46 +08:00
jxxghp
09229ad5ef 更新国际化支持:为站点卡片及相关组件添加多语言文本,提升用户体验 2025-04-28 21:52:04 +08:00
jxxghp
3dbfa750c9 更新国际化支持:为插件卡片及相关组件添加多语言文本,提升用户体验 2025-04-28 20:47:21 +08:00
jxxghp
c14dfe0bee 更新国际化支持:为通知渠道卡片及相关组件添加多语言文本,提升用户体验 2025-04-28 20:34:37 +08:00
jxxghp
fa6ba8b1fc 更新国际化支持:修正媒体类型文本函数名称并添加通知开关相关文本,提升用户体验 2025-04-28 20:29:11 +08:00
jxxghp
8854affc4c 更新国际化支持:为工作流任务卡片添加多语言文本,提升用户体验 2025-04-28 20:07:41 +08:00
jxxghp
995e07c351 更新国际化支持:为媒体服务器卡片和相关组件添加多语言文本,提升用户体验 2025-04-28 20:05:49 +08:00
jxxghp
40711fa640 更新国际化支持:为下载器和过滤规则组件添加多语言文本,提升用户体验 2025-04-28 19:54:01 +08:00
jxxghp
99212c1186 更新国际化支持:为自定义规则卡片添加多语言文本,提升用户体验 2025-04-28 19:32:04 +08:00
jxxghp
434543ce41 更新国际化支持:为转移历史视图添加多语言文本,增强用户体验 2025-04-28 17:46:58 +08:00
jxxghp
b6b19f628c 优化国际化支持:移除动态加载语言文件的代码,简化语言设置逻辑 2025-04-28 17:26:42 +08:00
jxxghp
bc841a630f 更新国际化支持:为种子列表和卡片视图添加多语言文本,优化用户体验 2025-04-28 17:19:59 +08:00
jxxghp
6f78e8196b 更新国际化支持:为仪表板各组件添加多语言文本,优化用户体验 2025-04-28 16:57:06 +08:00
jxxghp
f3af10e93e 更新国际化支持:为媒体详情视图添加多语言文本,增强用户体验 2025-04-28 16:37:49 +08:00
jxxghp
149403e5c0 更新国际化支持:将多个组件中的文本替换为国际化支持 2025-04-28 16:25:45 +08:00
jxxghp
b24c29b217 更新背景图片API路径 2025-04-28 15:56:18 +08:00
jxxghp
43460d4198 删除语言和主题切换组件,整合相关功能至用户个人资料组件中 2025-04-28 15:43:07 +08:00
jxxghp
6be4694327 更新国际化支持 2025-04-28 15:12:07 +08:00
jxxghp
308a8ab30d 优化推荐页面的列表渲染 2025-04-28 14:12:27 +08:00
jxxghp
51f7694788 优化SlideView和Footer组件 2025-04-28 14:05:24 +08:00
jxxghp
dca5885ef1 为标签头部添加左右滚动按钮功能,优化用户体验,支持平滑滚动效果。 2025-04-28 13:28:44 +08:00
jxxghp
8cf4b612d5 更新国际化支持 2025-04-28 13:23:51 +08:00
jxxghp
6b49464059 更新国际化支持 2025-04-28 12:23:05 +08:00
jxxghp
034238716a 更新国际化支持:在账户设置中引入多个新配置项,优化用户体验,支持用户辅助认证、全局图片缓存、订阅数据分享等功能的多语言显示。 2025-04-28 12:15:49 +08:00
jxxghp
7575c5acfa 添加TMDB元数据语言选项:在账户设置中引入TMDB_LANGUAGE配置,支持简体中文、繁体中文和英文选择,优化用户体验。 2025-04-28 09:16:01 +08:00
jxxghp
af7aa7d47b 更新国际化支持:在多个对话框组件中引入 vue-i18n,优化文本翻译,确保多语言显示的一致性和准确性。 2025-04-28 08:55:52 +08:00
jxxghp
daf70b6da4 更新国际化支持:在多个对话框组件中引入 vue-i18n,优化文本翻译,确保多语言显示的一致性和准确性。 2025-04-28 08:29:08 +08:00
jxxghp
819dd01d60 更新国际化支持:修正替换词格式的显示,确保英文和中文文本中的格式一致性。 2025-04-28 07:53:56 +08:00
jxxghp
947590ac91 更新国际化支持:在多个组件中优化文本翻译,确保系统配置和存储设置相关提示信息的多语言显示。 2025-04-27 22:28:34 +08:00
jxxghp
71787ece64 更新国际化支持:在多个组件中引入 vue-i18n,优化文本翻译和结构 2025-04-27 22:19:57 +08:00
jxxghp
7a3d566875 更新国际化支持:调整多个组件中的文本引入,优化站点设置相关的翻译和文本结构,确保语言切换的准确性和一致性。 2025-04-27 21:59:26 +08:00
jxxghp
082f666839 删除 NavbarThemeSwitcher 组件,移除不再使用的主题切换功能。 2025-04-27 21:25:29 +08:00
jxxghp
a641e90031 更新国际化支持:在多个组件中引入 vue-i18n 2025-04-27 21:23:29 +08:00
jxxghp
0396f180ae 更新国际化支持:调整多个组件中的文本引入,优化语言切换和翻译功能,删除不再使用的类型文件。 2025-04-27 20:49:44 +08:00
jxxghp
f809c8e538 添加国际化支持:在多个页面和组件中引入 vue-i18n 2025-04-27 20:27:45 +08:00
jxxghp
733d74ac36 添加国际化支持:在多个组件中引入 vue-i18n,更新文本以实现多语言显示 2025-04-27 18:06:15 +08:00
jxxghp
c46d556684 添加国际化支持:在多个组件中引入 vue-i18n,更新文本以支持多语言显示 2025-04-27 17:53:22 +08:00
jxxghp
d0b3bc8137 添加国际化支持:引入 vue-i18n,更新多个组件以支持语言切换和文本翻译 2025-04-27 17:44:09 +08:00
jxxghp
80ae853582 更新版本号至2.4.3 2025-04-27 12:57:24 +08:00
jxxghp
8c405d941b 调整全局磨砂层显示逻辑,仅在用户登录时显示;修改分享订阅对话框的最大宽度;为登录按钮添加图标。 2025-04-27 12:55:43 +08:00
jxxghp
79f45b8499 优化背景图片切换逻辑:添加图片预加载功能,确保图片成功加载后再切换 2025-04-27 12:41:20 +08:00
jxxghp
6ecf6bfb34 Merge pull request #328 from cddjr/fix_more_sources 2025-04-24 20:04:29 +08:00
景大侠
2a5a93bdb5 修复更多来源不能正确跳转站点 2025-04-24 19:46:11 +08:00
249 changed files with 30199 additions and 22562 deletions

View File

@@ -44,6 +44,7 @@ jobs:
- name: Delete Release
uses: dev-drprasad/delete-tag-and-release@v1.1
continue-on-error: true
with:
tag_name: ${{ env.frontend_version }}
delete_release: true

View File

@@ -106,5 +106,8 @@
}
]
},
"vue3snippets.enable-compile-vue-file-on-did-save-code": false
"vue3snippets.enable-compile-vue-file-on-did-save-code": false,
"i18n-ally.localesPaths": [
"src/locales"
]
}

View File

@@ -1,16 +1,37 @@
# MoviePilot-Frontend
*中文 | [English](README_EN.md)*
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目NodeJS版本>= `v20.12.1`
## 推荐的IDE设置
## 特性
- 基于 Vue 3 和 Vuetify 3 构建的现代化界面
- 使用 Vite 作为构建工具,提供快速的开发体验
- 支持多语言(中文/英文)
- 完整的插件系统支持,包括远程组件动态加载
## 模块联邦功能
MoviePilot 现已支持模块联邦Module Federation功能允许插件开发者创建可动态加载的远程组件实现更丰富的插件用户界面。
### 相关文档
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目
## 开发部署
### 推荐的IDE设置
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (并禁用 Vetur).
## 配置Vite
### 配置Vite
请参阅 [Vite 配置参考](https://vitejs.dev/config/).
## 依赖安装
### 依赖安装
```sh
yarn

59
README_EN.md Normal file
View File

@@ -0,0 +1,59 @@
# MoviePilot-Frontend
*[中文](README.md) | English*
Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS version required: >= `v20.12.1`.
## Features
- Modern interface built with Vue 3 and Vuetify 3
- Fast development experience with Vite build tool
- 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
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (disable Vetur).
### Configure Vite
See [Vite Configuration Reference](https://vitejs.dev/config/).
### Install Dependencies
```sh
yarn
```
### Development Server
```sh
yarn dev
```
### Build for Production
```sh
yarn build
```
### Static Deployment
1. Host the `dist` static files using a web server like `nginx`. Refer to `public/nginx.conf` for nginx configuration.
2. Alternatively, run the `service.js` directly with the `node` command. It listens on port `3000` by default. Set the `NGINX_PORT` environment variable to adjust the port.
```shell
node dist/service.js
```

8
auto-imports.d.ts vendored
View File

@@ -25,6 +25,7 @@ declare global {
const createPinia: typeof import('pinia')['createPinia']
const createProjection: typeof import('@vueuse/math')['createProjection']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createRef: typeof import('@vueuse/core')['createRef']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
@@ -159,6 +160,7 @@ declare global {
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCountdown: typeof import('@vueuse/core')['useCountdown']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
@@ -198,6 +200,7 @@ declare global {
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useI18n: typeof import('vue-i18n')['useI18n']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
@@ -325,7 +328,7 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
@@ -353,6 +356,7 @@ declare module 'vue' {
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
@@ -487,6 +491,7 @@ declare module 'vue' {
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useCountdown: UnwrapRef<typeof import('@vueuse/core')['useCountdown']>
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
@@ -526,6 +531,7 @@ declare module 'vue' {
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>

3
components.d.ts vendored
View File

@@ -2,11 +2,13 @@
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ConfirmDialog: typeof import('./src/@core/components/ConfirmDialog.vue')['default']
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default']
ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default']
@@ -17,6 +19,5 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView']
ScrollToTopBtn: typeof import('./src/@core/components/ScrollToTopBtn.vue')['default']
StatIcon: typeof import('./src/@core/components/StatIcon.vue')['default']
ThemeSwitcher: typeof import('./src/@core/components/ThemeSwitcher.vue')['default']
}
}

View File

@@ -0,0 +1,110 @@
# MoviePilot 模块联邦问题排查指南
本文档提供了针对 MoviePilot 项目中使用模块联邦时可能遇到的常见问题及解决方案。
## 远程组件注册机制
MoviePilot 使用自动注册机制来加载远程组件:
1. 对于使用 Vue 渲染模式的插件,自动注册其远程组件
2. 每个远程组件根据插件 ID 唯一标识,确保不会冲突
3. 在需要加载组件时,会优先检查已注册的组件信息
这种设计使得插件开发者只需专注于组件开发,而不需要担心加载机制的复杂性。
## 常见错误
### 1. "Module name 'vue' does not resolve to a valid URL"
**原因**:远程组件无法正确解析共享依赖的 URL通常是因为共享依赖配置不正确。
**解决方案**
1.**插件组件项目**`vite.config.js` 中正确配置共享依赖:
```js
federation({
// ...
shared: {
vue: {
singleton: true,
requiredVersion: false // 关闭版本检查
}
}
})
```
2.**主应用**`vite.config.ts` 中确保共享依赖配置正确:
```ts
federation({
name: 'host',
remotes: {},
shared: ['vue', 'vuetify']
})
```
### 2. "Top-level await is not available in the configured target environment"
**原因**:模块联邦使用了顶层 await但目标构建环境不支持此功能。
**解决方案**
**主应用****插件组件项目** 的构建配置中添加 `target: 'esnext'`
```js
build: {
target: 'esnext', // 支持顶层await
// 其他配置...
}
```
### 3. "TypeError: Failed to fetch dynamically imported module"
**原因**:远程组件 JS 文件无法被正确加载,可能是路径错误或网络问题。
**解决方案**
1. 检查网络请求是否成功状态码200
2. 确认组件 URL 是否正确
3. 确保服务器允许访问该 JS 文件CORS 配置)
4. 检查插件后端是否正确提供了静态文件服务
### 4. 组件加载后渲染为空白或出现错误
**原因**:组件内部代码错误或与主应用不兼容。
**解决方案**
1. 检查浏览器控制台错误信息
2. 确保组件代码没有语法错误
3. 避免在组件中使用主应用未提供的依赖
4. 确保所有路径如图片、API请求URL等都是正确的
## 调试技巧
### 1. 启用详细日志
在浏览器控制台中设置:
```js
localStorage.setItem('debug', 'vite:*')
```
### 2. 分析网络请求
1. 打开浏览器开发者工具
2. 转到 Network 标签页
3. 确认远程组件 JS 文件请求是否成功
4. 分析响应内容是否为有效的 JavaScript
### 3. 隔离测试远程组件
创建一个独立的简单页面来测试插件组件,排除主应用的干扰因素。
## 其他资源
- [MoviePilot 插件组件示例](../examples/plugin-component/)
- [Vite 模块联邦插件文档](https://github.com/originjs/vite-plugin-federation)
- [Vite 官方文档](https://vitejs.dev/guide/build.html)
- [Origin.js 模块联邦示例](https://github.com/originjs/vite-plugin-federation/tree/main/packages/examples)

View File

@@ -0,0 +1,380 @@
# MoviePilot前端远程模块开发指南
## 1. 概述
MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态加载和集成。本文档详细说明如何开发符合要求的远程模块以便在MoviePilot中作为插件使用。
关联阅读后端插件开发文档:[第三方插件开发说明](https://github.com/jxxghp/MoviePilot-Plugins/blob/main/README.md)
## 2. 技术要求
- Node.js 20+
- Vue 3
- Vite 4+
- TypeScript 5+
## 3. 核心概念
每个插件需要提供三个标准组件:
| 组件名称 | 文件名 | 用途 |
|---------|-------|------|
| Page | Page.vue | 插件详情页面 |
| Config | Config.vue | 插件配置页面 |
| Dashboard | Dashboard.vue | 仪表板组件 |
## 4. 快速开始
### 创建项目
```bash
# 创建项目
npm create vite@latest my-plugin -- --template vue-ts
# 进入项目目录
cd my-plugin
# 安装依赖
yarn
```
### 配置vite.config.ts
```typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'MyPlugin',
filename: 'remoteEntry.js',
exposes: {
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
},
shared: {
vue: {
requiredVersion: false,
generate: false,
},
vuetify: {
requiredVersion: false,
generate: false,
singleton: true,
},
'vuetify/styles': {
requiredVersion: false,
generate: false,
singleton: true,
},
},
format: 'esm'
})
],
build: {
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: true, // 改为true以便能分离样式文件
},
css: {
preprocessorOptions: {
scss: {
additionalData: '/* 覆盖vuetify样式 */',
}
},
postcss: {
plugins: [
{
postcssPlugin: 'internal:charset-removal',
AtRule: {
charset: (atRule) => {
if (atRule.name === 'charset') {
atRule.remove();
}
}
}
},
{
postcssPlugin: 'vuetify-filter',
Root(root) {
// 过滤掉所有vuetify相关的CSS
root.walkRules(rule => {
if (rule.selector && (
rule.selector.includes('.v-') ||
rule.selector.includes('.mdi-'))) {
rule.remove();
}
});
}
}
]
}
},
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
},
})
```
## 5. 组件开发规范
### 5.1 Page组件详情页面
```vue
<script setup lang="ts">
// 自定义事件,用于通知主应用刷新数据
const emit = defineEmits(['action', 'switch', 'close'])
// 接收API对象
const props = defineProps({
api: {
type: Object,
default: () => {}
}
})
// 页面逻辑代码...
// 通知主应用刷新数据
function notifyRefresh() {
emit('action')
}
// 通知主应用切换到配置页面
function notifySwitch() {
emit('switch')
}
// 通知主应用关闭当前页面
function notifyClose() {
emit('close')
}
</script>
<template>
<div class="plugin-page">
<!-- 插件详情页面操作按钮示例 -->
<v-btn @click="notifyRefresh">刷新数据</v-btn>
<v-btn @click="notifySwitch">配置插件</v-btn>
<v-btn @click="notifyClose">关闭页面</v-btn>
</div>
</template>
```
### 5.2 Config组件配置页面
```vue
<script setup lang="ts">
// 接收初始配置和API对象
const props = defineProps({
initialConfig: {
type: Object,
default: () => ({})
},
api: {
type: Object,
default: () => {}
}
})
// 配置数据
const config = ref({...props.initialConfig})
// 自定义事件,用于保存配置
const emit = defineEmits(['save', 'close', 'switch'])
// 保存配置
function saveConfig() {
emit('save', config.value)
}
// 通知主应用切换到详情页面
function notifySwitch() {
emit('switch')
}
// 通知主应用关闭当前页面
function notifyClose() {
emit('close')
}
</script>
<template>
<div class="plugin-config">
<!-- 配置表单示例 -->
<v-text-field v-model="config.someField" label="配置项"></v-text-field>
<!-- 保存按钮示例 -->
<v-btn color="primary" @click="saveConfig">保存配置</v-btn>
<!-- 关闭按钮示例 -->
<v-btn color="primary" @click="notifyClose">关闭页面</v-btn>
<!-- 切换按钮示例 -->
<v-btn color="primary" @click="notifySwitch">切换到详情页面</v-btn>
</div>
</template>
```
### 5.3 Dashboard组件仪表板
```vue
<script setup lang="ts">
// 接收配置和刷新控制
const props = defineProps({
config: {
type: Object,
default: () => ({})
},
allowRefresh: {
type: Boolean,
default: true
}
})
// 仪表板逻辑...
</script>
<template>
<div class="dashboard-widget">
<!-- 仪表板内容 -->
<v-card>
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
<v-card-text>
<!-- 组件内容 -->
</v-card-text>
</v-card>
</div>
</template>
```
## 6. 构建和部署
### 构建项目
```bash
yarn build
```
- 将生成的dist文件夹上传到插件后端目录下默认为`dist/assets`
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*``_plugin-vue_export-helper-*``remoteEntry.js`
- 在插件的后端python代码中实现以下方法来集成远程组件
```python
def get_render_mode() -> Tuple[str, str]:
"""
获取插件渲染模式
:return: 1、渲染模式支持vue/vuetify默认vuetify
:return: 2、组件路径默认 dist/assets
"""
return "vue", "dist/assets"
```
- 需要在插件前端页面调用后端接口时通过传入的api模块发起调用后端api接口声明认证类型为`bear`
```typescript
// 演示使用api模块调用插件接口
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
```
```python
def get_api(self) -> List[Dict[str, Any]]:
"""
注册插件API
"""
return [
{
"path": "/history",
"endpoint": self.get_history,
"methods": ["GET"],
"auth": "bear", # 认证类型设为bear
"summary": "查询历史记录"
}
]
```
## 7. 调试与排错
### 常见问题
1. **模块无法加载**
- 检查网络请求是否成功状态码200
- 确认文件路径是否正确
- 检查CORS跨域设置
2. **模块加载但组件不显示**
- 检查控制台错误信息
- 确认组件是否正确导出
- 验证共享依赖配置
3. **"Module name 'vue' does not resolve to a valid URL"**
- 检查`shared`配置是否正确
- 设置`requiredVersion: false`尝试解决
4. **"Top-level await is not available"**
- 确保`build.target`设置为`esnext`
## 8. 高级配置
### 8.1 CSS隔离
为防止样式冲突建议使用CSS Modules或scoped样式
```vue
<style scoped>
/* 组件样式 */
</style>
```
### 8.2 共享更多依赖
如果您的插件需要共享更多依赖可以扩展shared配置
```js
shared: {
vue: { requiredVersion: false },
vuetify: { requiredVersion: false },
'@vueuse/core': { requiredVersion: false },
pinia: { requiredVersion: false }
}
```
### 8.3 开发环境测试
开发期间可以使用以下配置在本地测试:
```typescript
// vite.config.ts
export default defineConfig({
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
}
})
```
## 9. 示例代码
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
- [模块联邦问题排查指南](./federation-troubleshooting.md) - 常见问题排查
## 10. 参考资料
- [Vite Plugin Federation](https://github.com/originjs/vite-plugin-federation)
- [Vue 3官方文档](https://vuejs.org/)
---
如有问题请提交Issue。

7
env.d.ts vendored
View File

@@ -8,3 +8,10 @@ declare module 'vue-router' {
navActiveLink?: RouteLocationRaw
}
}
// 支持动态导入远程模块
declare module '*' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -0,0 +1,42 @@
# MoviePilot 插件远程组件示例
这是 MoviePilot 插件远程组件的示例项目展示了如何正确配置和开发与主应用兼容的远程组件。本示例实现了三个标准组件Page详情页面、Config配置页面和Dashboard仪表板组件
## 1. 开发环境准备
### 安装依赖
```bash
npm install
# 或
yarn
```
### 开发模式运行
```bash
npm run dev
# 或
yarn dev
```
## 2. 项目结构
```
plugin-component/
├── src/
│ ├── components/
│ │ ├── Page.vue # 插件详情页面组件
│ │ ├── Config.vue # 插件配置页面组件
│ │ └── Dashboard.vue # 插件仪表板组件
│ ├── App.vue # 本地开发入口组件
│ └── main.js # 本地开发入口文件
├── vite.config.js # Vite和模块联邦配置
├── index.html # 本地开发HTML入口
└── package.json # 依赖配置
```
## 3. 开发指引
- [模块联邦开发指南](../../docs/module-federation-guide.md)
- [模块联邦问题排查指南](../../docs/federation-troubleshooting.md)。

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MoviePilot插件组件示例</title>
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@6.x/css/materialdesignicons.min.css" rel="stylesheet" />
<style>
body {
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
{
"name": "moviepilot-plugin-component",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vuetify": "3.7.3",
"echarts": "^5.4.3",
"vue-echarts": "^6.6.1",
"@vueuse/core": "^12.4.0"
},
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.4.1",
"@vitejs/plugin-vue": "^4.4.0",
"vite": "^5.4.11"
}
}

View File

@@ -0,0 +1,128 @@
<template>
<div class="app-container">
<v-app>
<v-app-bar color="primary" app>
<v-app-bar-title>MoviePilot插件组件示例</v-app-bar-title>
</v-app-bar>
<v-main>
<v-container>
<v-tabs v-model="activeTab" bg-color="primary">
<v-tab value="page">详情页面</v-tab>
<v-tab value="config">配置页面</v-tab>
<v-tab value="dashboard">仪表板</v-tab>
</v-tabs>
<v-window v-model="activeTab" class="mt-4">
<v-window-item value="page">
<h2 class="text-h5 mb-4">Page组件</h2>
<div class="component-preview">
<page-component @action="handleAction"></page-component>
</div>
</v-window-item>
<v-window-item value="config">
<h2 class="text-h5 mb-4">Config组件</h2>
<div class="component-preview">
<config-component :initial-config="initialConfig" @save="handleConfigSave"></config-component>
</div>
</v-window-item>
<v-window-item value="dashboard">
<h2 class="text-h5 mb-4">Dashboard组件</h2>
<v-switch v-model="dashboardConfig.attrs.border" label="显示边框" color="primary" class="mb-4"></v-switch>
<div class="component-preview">
<dashboard-component :config="dashboardConfig" :allow-refresh="true"></dashboard-component>
</div>
</v-window-item>
</v-window>
</v-container>
</v-main>
<v-footer app color="primary" class="text-center d-flex justify-center">
<span class="text-white">MoviePilot 模块联邦示例 ©{{ new Date().getFullYear() }}</span>
</v-footer>
</v-app>
<!-- 通知弹窗 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="snackbar.timeout">
{{ snackbar.text }}
<template v-slot:actions>
<v-btn variant="text" @click="snackbar.show = false"> 关闭 </v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import PageComponent from './components/Page.vue'
import ConfigComponent from './components/Config.vue'
import DashboardComponent from './components/Dashboard.vue'
// 活动标签页
const activeTab = ref('page')
// 配置初始值
const initialConfig = {
name: '测试插件',
description: '这是一个测试配置',
enable_notifications: true,
update_interval: 30,
api_url: 'https://api.example.com',
api_key: 'test_api_key_123',
concurrent_tasks: 2,
tags: ['电影', '测试'],
}
// 仪表板配置
const dashboardConfig = reactive({
id: 'test_plugin',
name: '测试插件',
attrs: {
title: '仪表板示例',
subtitle: '插件数据展示',
border: true,
},
})
// 通知状态
const snackbar = reactive({
show: false,
text: '',
color: 'success',
timeout: 3000,
})
// 显示通知
function showNotification(text, color = 'success') {
snackbar.text = text
snackbar.color = color
snackbar.show = true
}
// 处理详情页面操作
function handleAction() {
showNotification('Page组件触发了action事件')
}
// 处理配置保存
function handleConfigSave(config) {
console.log('配置已保存:', config)
showNotification('配置已保存')
}
</script>
<style scoped>
/* 为了使测试应用更美观 */
.app-container {
block-size: 100vh;
inline-size: 100vw;
}
.component-preview {
overflow: hidden;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<div class="plugin-config">
<v-card>
<v-card-item>
<v-card-title>插件配置</v-card-title>
<template #append>
<v-btn icon color="primary" variant="text" @click="notifyClose">
<v-icon left>mdi-close</v-icon>
</v-btn>
</template>
</v-card-item>
<v-card-text class="overflow-y-auto">
<v-alert v-if="error" type="error" class="mb-4">{{ error }}</v-alert>
<v-form ref="form" v-model="isFormValid" @submit.prevent="saveConfig">
<!-- 基本设置区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">基本设置</div>
<v-row>
<v-col cols="12">
<v-switch
v-model="config.enable"
label="启用插件"
color="primary"
inset
hint="启用插件后,插件将开始工作"
persistent-hint
></v-switch>
</v-col>
<v-col cols="12">
<v-text-field
v-model="config.name"
label="插件名称"
variant="outlined"
:rules="[v => !!v || '名称不能为空']"
hint="显示在插件列表中的名称"
></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea
v-model="config.description"
label="插件描述"
variant="outlined"
rows="3"
hint="简要说明插件的功能和用途"
></v-textarea>
</v-col>
</v-row>
<!-- 功能配置区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">功能配置</div>
<v-row>
<v-col cols="12">
<v-select
v-model="config.update_interval"
label="更新频率"
:items="updateIntervalOptions"
variant="outlined"
item-title="text"
item-value="value"
></v-select>
</v-col>
</v-row>
<!-- API配置区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">API设置</div>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="config.api_url"
label="API地址"
variant="outlined"
hint="外部服务API地址"
:rules="[v => !v || v.startsWith('http') || '请输入有效的URL']"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="config.api_key"
label="API密钥"
variant="outlined"
:append-inner-icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'"
:type="showApiKey ? 'text' : 'password'"
@click:append-inner="showApiKey = !showApiKey"
></v-text-field>
</v-col>
</v-row>
<!-- 高级选项区域 -->
<v-expansion-panels variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title>高级选项</v-expansion-panel-title>
<v-expansion-panel-text>
<v-slider
v-model="config.concurrent_tasks"
label="并发任务数"
min="1"
max="10"
step="1"
thumb-label
></v-slider>
<v-combobox
v-model="config.tags"
label="标签"
variant="outlined"
chips
multiple
closable-chips
></v-combobox>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn color="secondary" @click="resetForm">重置</v-btn>
<v-spacer></v-spacer>
<v-btn color="primary" :disabled="!isFormValid" @click="saveConfig" :loading="saving">保存配置</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
// 接收初始配置
const props = defineProps({
initialConfig: {
type: Object,
default: () => ({}),
},
api: {
type: Object,
default: () => {},
},
})
// 表单状态
const form = ref(null)
const isFormValid = ref(true)
const error = ref(null)
const saving = ref(false)
const showApiKey = ref(false)
// 更新频率选项
const updateIntervalOptions = [
{ text: '5分钟', value: 5 },
{ text: '15分钟', value: 15 },
{ text: '30分钟', value: 30 },
{ text: '1小时', value: 60 },
{ text: '2小时', value: 120 },
{ text: '6小时', value: 360 },
{ text: '12小时', value: 720 },
{ text: '1天', value: 1440 },
]
// 配置数据,使用默认值和初始配置合并
const defaultConfig = {
name: '我的插件',
description: '',
enable: true,
update_interval: 60,
api_url: '',
api_key: '',
concurrent_tasks: 3,
tags: [],
}
// 合并默认配置和初始配置
const config = reactive({ ...defaultConfig })
// 初始化配置
onMounted(() => {
// 加载初始配置
if (props.initialConfig) {
Object.keys(props.initialConfig).forEach(key => {
if (key in config) {
config[key] = props.initialConfig[key]
}
})
}
})
// 自定义事件,用于保存配置
const emit = defineEmits(['save', 'close', 'switch'])
// 保存配置
async function saveConfig() {
if (!isFormValid.value) {
error.value = '请修正表单错误'
return
}
saving.value = true
error.value = null
try {
// 模拟API调用等待
await new Promise(resolve => setTimeout(resolve, 1000))
// 发送保存事件
emit('save', { ...config })
} catch (err) {
console.error('保存配置失败:', err)
error.value = err.message || '保存配置失败'
} finally {
saving.value = false
}
}
// 重置表单
function resetForm() {
Object.keys(defaultConfig).forEach(key => {
config[key] = defaultConfig[key]
})
if (form.value) {
form.value.resetValidation()
}
}
// 通知主应用关闭组件
function notifyClose() {
emit('close')
}
</script>

View File

@@ -0,0 +1,298 @@
<template>
<div class="dashboard-widget">
<v-card v-if="!config?.attrs?.border" flat>
<v-card-text class="pa-0">
<div class="dashboard-content">
<!-- 加载中状态 -->
<div v-if="loading" class="d-flex justify-center align-center py-4">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<!-- 数据内容 -->
<div v-else>
<!-- 数据图表 -->
<div v-if="chartData" class="chart-container">
<v-chart class="chart" :option="chartOptions" autoresize />
</div>
<!-- 数据列表 -->
<v-list v-if="items.length" density="compact" class="py-0">
<v-list-item v-for="(item, index) in items" :key="index" :title="item.title" :subtitle="item.subtitle">
<template v-slot:prepend>
<v-avatar :color="getStatusColor(item.status)" size="small">
<v-icon size="small" color="white">{{ getStatusIcon(item.status) }}</v-icon>
</v-avatar>
</template>
<template v-slot:append v-if="item.value">
<span class="text-caption">{{ item.value }}</span>
</template>
</v-list-item>
</v-list>
</div>
</div>
</v-card-text>
</v-card>
<!-- 带边框的卡片 -->
<v-card v-else>
<v-card-item>
<v-card-title>{{ config?.attrs?.title || '仪表板组件' }}</v-card-title>
<v-card-subtitle v-if="config?.attrs?.subtitle">{{ config.attrs.subtitle }}</v-card-subtitle>
</v-card-item>
<v-card-text>
<!-- 加载中状态 -->
<div v-if="loading" class="d-flex justify-center align-center py-4">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<!-- 数据内容 -->
<div v-else>
<!-- 数据图表 -->
<div v-if="chartData" class="chart-container">
<v-chart class="chart" :option="chartOptions" autoresize />
</div>
<!-- 数据列表 -->
<v-list v-if="items.length" density="compact" class="rounded pa-0">
<v-list-item v-for="(item, index) in items" :key="index" :title="item.title" :subtitle="item.subtitle">
<template v-slot:prepend>
<v-avatar :color="getStatusColor(item.status)" size="small">
<v-icon size="small" color="white">{{ getStatusIcon(item.status) }}</v-icon>
</v-avatar>
</template>
<template v-slot:append v-if="item.value">
<span class="text-caption">{{ item.value }}</span>
</template>
</v-list-item>
</v-list>
</div>
</v-card-text>
</v-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, PieChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components'
// 注册ECharts组件
try {
use([CanvasRenderer, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent])
} catch (e) {
console.warn('ECharts components registration failed', e)
}
// 接收仪表板配置
const props = defineProps({
config: {
type: Object,
default: () => ({}),
},
allowRefresh: {
type: Boolean,
default: true,
},
})
// 组件状态
const loading = ref(true)
const items = ref([])
const chartData = ref(null)
let refreshTimer = null
// 获取状态图标
function getStatusIcon(status) {
const icons = {
'success': 'mdi-check-circle',
'warning': 'mdi-alert',
'error': 'mdi-alert-circle',
'info': 'mdi-information',
'running': 'mdi-play-circle',
'pending': 'mdi-clock-outline',
'completed': 'mdi-check-circle-outline',
}
return icons[status] || 'mdi-help-circle'
}
// 获取状态颜色
function getStatusColor(status) {
const colors = {
'success': 'success',
'warning': 'warning',
'error': 'error',
'info': 'info',
'running': 'primary',
'pending': 'secondary',
'completed': 'success',
}
return colors[status] || 'grey'
}
// 图表选项
const chartOptions = computed(() => {
if (!chartData.value) return {}
const { type, data } = chartData.value
if (type === 'line') {
return {
tooltip: {
trigger: 'axis',
},
xAxis: {
type: 'category',
data: data.xAxis,
axisLabel: {
color: '#888',
},
},
yAxis: {
type: 'value',
axisLabel: {
color: '#888',
},
},
series: data.series.map(series => ({
name: series.name,
type: 'line',
smooth: true,
data: series.data,
areaStyle: { opacity: 0.1 },
})),
}
}
if (type === 'pie') {
return {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)',
},
series: [
{
name: data.name,
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: '12',
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: data.items,
},
],
}
}
return {}
})
// 获取仪表板数据
async function fetchDashboardData() {
if (!props.allowRefresh) return
loading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// 随机决定显示饼图或折线图
const showPie = Math.random() > 0.5
if (showPie) {
// 饼图数据
chartData.value = {
type: 'pie',
data: {
name: '文件分布',
items: [
{ value: Math.floor(Math.random() * 50) + 30, name: '电影' },
{ value: Math.floor(Math.random() * 40) + 20, name: '电视剧' },
{ value: Math.floor(Math.random() * 30) + 10, name: '动漫' },
{ value: Math.floor(Math.random() * 20) + 5, name: '纪录片' },
],
},
}
} else {
// 折线图数据
const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
chartData.value = {
type: 'line',
data: {
xAxis: days,
series: [
{
name: '下载量',
data: days.map(() => Math.floor(Math.random() * 10) + 1),
},
{
name: '完成量',
data: days.map(() => Math.floor(Math.random() * 8) + 1),
},
],
},
}
}
// 生成列表数据
const statuses = ['success', 'warning', 'error', 'info', 'running', 'pending', 'completed']
items.value = Array.from({ length: 5 }, (_, i) => {
const status = statuses[Math.floor(Math.random() * statuses.length)]
return {
title: `项目 ${i + 1}`,
subtitle: `上次更新: ${new Date().toLocaleTimeString()}`,
status,
value: Math.floor(Math.random() * 100) + '%',
}
})
} catch (error) {
console.error('获取仪表板数据失败:', error)
} finally {
loading.value = false
}
}
// 设置定时刷新
function setupRefreshTimer() {
if (props.allowRefresh) {
// 每30秒刷新一次
refreshTimer = setInterval(() => {
fetchDashboardData()
}, 30000)
}
}
// 初始化
onMounted(() => {
fetchDashboardData()
setupRefreshTimer()
})
// 清理
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
}
})
</script>

View File

@@ -0,0 +1,169 @@
<template>
<div class="plugin-page">
<v-card>
<v-card-item>
<v-card-title>{{ title }}</v-card-title>
<template #append>
<v-btn icon color="primary" variant="text" @click="notifyClose">
<v-icon left>mdi-close</v-icon>
</v-btn>
</template>
</v-card-item>
<v-card-text>
<v-alert v-if="error" type="error" class="mb-4">{{ error }}</v-alert>
<v-skeleton-loader v-if="loading" type="card"></v-skeleton-loader>
<div v-else>
<!-- 数据统计展示 -->
<v-row v-if="stats">
<v-col v-for="(value, key) in stats" :key="key" cols="12" sm="6" md="4">
<v-card variant="outlined" class="text-center">
<v-card-text>
<div class="text-h4 font-weight-bold">{{ value }}</div>
<div class="text-subtitle-1">{{ key }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 最近记录展示 -->
<div v-if="recentItems && recentItems.length" class="mt-4">
<div class="text-h6 mb-2">最近记录</div>
<v-timeline density="compact">
<v-timeline-item
v-for="(item, index) in recentItems"
:key="index"
:dot-color="getItemColor(item.type)"
size="small"
>
<div class="d-flex align-center">
<v-icon :color="getItemColor(item.type)" size="small" class="mr-2">
{{ getItemIcon(item.type) }}
</v-icon>
<span class="font-weight-medium">{{ item.title }}</span>
</div>
<div class="text-caption text-secondary">{{ item.time }}</div>
</v-timeline-item>
</v-timeline>
</div>
<!-- 当前状态 -->
<div class="mt-4 text-subtitle-2">
<div>
<strong>状态:</strong>
<v-chip size="small" :color="status === 'running' ? 'success' : 'warning'">{{ status }}</v-chip>
</div>
<div><strong>最后更新:</strong> {{ lastUpdated }}</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-btn color="primary" @click="refreshData" :loading="loading">
<v-icon left>mdi-refresh</v-icon>
刷新数据
</v-btn>
<v-spacer></v-spacer>
<v-btn color="primary" @click="notifySwitch">
<v-icon left>mdi-cog</v-icon>
配置
</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// 接收初始配置
const props = defineProps({
model: {
type: Object,
default: () => {},
},
api: {
type: Object,
default: () => {},
},
})
// 组件状态
const title = ref('插件详情页面')
const loading = ref(true)
const error = ref(null)
const stats = ref(null)
const recentItems = ref([])
const status = ref('running')
const lastUpdated = ref('')
// 自定义事件,用于通知主应用刷新数据
const emit = defineEmits(['action', 'switch', 'close'])
// 获取状态图标
function getItemIcon(type) {
const icons = {
'movie': 'mdi-movie',
'tv': 'mdi-television-classic',
'download': 'mdi-download',
'error': 'mdi-alert-circle',
'success': 'mdi-check-circle',
}
return icons[type] || 'mdi-information'
}
// 获取状态颜色
function getItemColor(type) {
const colors = {
'movie': 'blue',
'tv': 'green',
'download': 'purple',
'error': 'red',
'success': 'success',
}
return colors[type] || 'grey'
}
// 获取和刷新数据
async function refreshData() {
loading.value = true
error.value = null
try {
// 模拟数据
stats.value = {
'电影': Math.floor(Math.random() * 100) + 50,
'电视剧': Math.floor(Math.random() * 100) + 30,
'动漫': Math.floor(Math.random() * 100) + 20,
'纪录片': Math.floor(Math.random() * 100) + 10,
'综艺': Math.floor(Math.random() * 100) + 5,
}
// 演示使用api模块调用插件接口
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
status.value = Math.random() > 0.2 ? 'running' : 'paused'
lastUpdated.value = new Date().toLocaleString()
} catch (err) {
console.error('获取数据失败:', err)
error.value = err.message || '获取数据失败'
} finally {
loading.value = false
// 通知主应用组件已更新
emit('action')
}
}
// 通知主应用切换到配置页面
function notifySwitch() {
emit('switch')
}
// 通知主应用关闭组件
function notifyClose() {
emit('close')
}
// 组件挂载时加载数据
onMounted(() => {
refreshData()
})
</script>

View File

@@ -0,0 +1,25 @@
import { createApp } from 'vue'
import App from './App.vue'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import defaults from './vuetify/defaults'
import theme from './vuetify/theme'
import 'vuetify/styles'
// 创建Vuetify实例
const vuetify = createVuetify({
components,
directives,
theme,
defaults
})
// 创建应用
const app = createApp(App)
// 使用插件
app.use(vuetify)
// 挂载应用
app.mount('#app')

View File

@@ -0,0 +1,148 @@
export default {
IconBtn: {
icon: true,
color: 'default',
variant: 'text',
VIcon: {
size: 24,
},
},
VAlert: {
VBtn: {
color: undefined,
},
},
VAvatar: {
// Remove after next release
variant: 'flat',
VIcon: {
size: 24,
},
},
VBadge: {
// set v-badge default color to primary
color: 'primary',
},
VBtn: {
// set v-btn default color to primary
color: 'primary',
elevation: 0,
},
VCard: {
elevation: 0,
rounded: 'lg',
},
VMenu: {
elevation: 0,
},
VChip: {
elevation: 0,
},
VBottomSheet: {
elevation: 0,
},
VDialog: {
elevation: 0,
rounded: 'lg',
},
VExpansionPanels: {
elevation: 0,
},
VList: {
color: 'primary',
elevation: 0,
},
VListItem: {
rounded: 'md',
},
VPagination: {
activeColor: 'primary',
},
VTabs: {
// set v-tabs default color to primary
color: 'primary',
VSlideGroup: {
showArrows: true,
},
},
VTooltip: {
// set v-tooltip default location to top
location: 'top',
},
VCheckboxBtn: {
color: 'primary',
hideDetails: 'auto',
},
VCheckbox: {
// set v-checkbox default color to primary
color: 'primary',
hideDetails: 'auto',
},
VRadioGroup: {
color: 'primary',
hideDetails: 'auto',
},
VRadio: {
color: 'primary',
hideDetails: 'auto',
},
VSelect: {
variant: 'outlined',
color: 'primary',
hideDetails: 'auto',
menuProps: { elevation: 0 },
},
VRangeSlider: {
// set v-range-slider default color to primary
color: 'primary',
density: 'comfortable',
thumbLabel: true,
hideDetails: 'auto',
},
VRating: {
// set v-rating default color to primary
color: 'rgba(var(--v-theme-on-background),0.23)',
activeColor: 'warning',
halfIncrements: true,
},
VProgressCircular: {
// set v-progress-circular default color to primary
color: 'primary',
},
VSlider: {
// set v-slider default color to primary
color: 'primary',
hideDetails: 'auto',
},
VTextField: {
variant: 'outlined',
color: 'primary',
hideDetails: 'auto',
},
VAutocomplete: {
variant: 'outlined',
color: 'primary',
hideDetails: 'auto',
},
VCombobox: {
variant: 'outlined',
color: 'primary',
hideDetails: 'auto',
menuProps: { elevation: 0 },
},
VFileInput: {
variant: 'outlined',
color: 'primary',
hideDetails: 'auto',
},
VTextarea: {
variant: 'outlined',
color: 'primary',
hideDetails: 'auto',
},
VSwitch: {
// set v-switch default color to primary
color: 'primary',
hideDetails: 'auto',
},
}

View File

@@ -0,0 +1,216 @@
import type { VuetifyOptions } from 'vuetify'
const theme: VuetifyOptions['theme'] = {
defaultTheme: 'light',
themes: {
light: {
dark: false,
colors: {
'primary': '#9155FD',
'secondary': '#8A8D93',
'on-secondary': '#FFFFFF',
'success': '#56CA00',
'info': '#16B1FF',
'warning': '#FFB400',
'error': '#FF4C51',
'on-primary': '#FFFFFF',
'on-success': '#FFFFFF',
'on-warning': '#FFFFFF',
'background': '#F4F5FA',
'on-background': '#3A3541',
'on-surface': '#3A3541',
'grey-50': '#FAFAFA',
'grey-100': '#F0F2F8',
'grey-200': '#EEEEEE',
'grey-300': '#E0E0E0',
'grey-400': '#BDBDBD',
'grey-500': '#9E9E9E',
'grey-600': '#757575',
'grey-700': '#616161',
'grey-800': '#424242',
'grey-900': '#212121',
'perfect-scrollbar-thumb': '#DBDADE',
'skin-bordered-background': '#FFFFFF',
'skin-bordered-surface': '#FFFFFF',
},
variables: {
'code-color': '#D400FF',
'overlay-scrim-background': '#3A3541',
'overlay-scrim-opacity': 0.5,
'hover-opacity': 0.04,
'focus-opacity': 0.1,
'selected-opacity': 0.12,
'activated-opacity': 0.1,
'pressed-opacity': 0.14,
'dragged-opacity': 0.1,
'border-color': '#3A3541',
'table-header-background': '#F9FAFC',
'custom-background': '#F9F8F9',
// Shadows
'shadow-key-umbra-opacity': 'rgba(var(--v-theme-on-surface), 0.08)',
'shadow-key-penumbra-opacity': 'rgba(var(--v-theme-on-surface), 0.12)',
'shadow-key-ambient-opacity': 'rgba(var(--v-theme-on-surface), 0.04)',
},
},
dark: {
dark: true,
colors: {
'primary': '#6E66ED',
'secondary': '#8A8D93',
'on-secondary': '#FFFFFF',
'success': '#56CA00',
'info': '#16B1FF',
'warning': '#FFB400',
'error': '#FF4C51',
'on-primary': '#FFFFFF',
'on-success': '#FFFFFF',
'on-warning': '#FFFFFF',
'background': '#0E1116',
'on-background': '#E7E3FC',
'surface': '#14161F',
'on-surface': '#E7E3FC',
'grey-50': '#2A2E42',
'grey-100': '#474360',
'grey-200': '#4A5072',
'grey-300': '#5E6692',
'grey-400': '#7983BB',
'grey-500': '#8692D0',
'grey-600': '#AAB3DE',
'grey-700': '#B6BEE3',
'grey-800': '#CFD3EC',
'grey-900': '#E7E9F6',
'perfect-scrollbar-thumb': '#4A5072',
'skin-bordered-background': '#312d4b',
'skin-bordered-surface': '#312d4b',
},
variables: {
'code-color': '#d400ff',
'overlay-scrim-background': '#191D21',
'overlay-scrim-opacity': 0.6,
'hover-opacity': 0.04,
'focus-opacity': 0.1,
'selected-opacity': 0.12,
'activated-opacity': 0.1,
'pressed-opacity': 0.14,
'dragged-opacity': 0.1,
'border-color': '#E7E3FC',
'table-header-background': '#14161F',
'custom-background': '#373452',
// Shadows
'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',
'shadow-key-penumbra-opacity': 'rgba(20, 18, 33, 0.12)',
'shadow-key-ambient-opacity': 'rgba(20, 18, 33, 0.04)',
},
},
purple: {
dark: true,
colors: {
'primary': '#9155FD',
'secondary': '#8A8D93',
'on-secondary': '#FFFFFF',
'success': '#56CA00',
'info': '#16B1FF',
'warning': '#FFB400',
'error': '#FF4C51',
'on-primary': '#FFFFFF',
'on-success': '#FFFFFF',
'on-warning': '#FFFFFF',
'background': '#28243D',
'on-background': '#E7E3FC',
'surface': '#312D4B',
'on-surface': '#E7E3FC',
'grey-50': '#2A2E42',
'grey-100': '#474360',
'grey-200': '#4A5072',
'grey-300': '#5E6692',
'grey-400': '#7983BB',
'grey-500': '#8692D0',
'grey-600': '#AAB3DE',
'grey-700': '#B6BEE3',
'grey-800': '#CFD3EC',
'grey-900': '#E7E9F6',
'perfect-scrollbar-thumb': '#4A5072',
'skin-bordered-background': '#312d4b',
'skin-bordered-surface': '#312d4b',
},
variables: {
'code-color': '#d400ff',
'overlay-scrim-background': '#2C2942',
'overlay-scrim-opacity': 0.6,
'hover-opacity': 0.04,
'focus-opacity': 0.1,
'selected-opacity': 0.12,
'activated-opacity': 0.1,
'pressed-opacity': 0.14,
'dragged-opacity': 0.1,
'border-color': '#E7E3FC',
'table-header-background': '#3D3759',
'custom-background': '#373452',
// Shadows
'shadow-key-umbra-opacity': 'rgba(20, 18, 33, 0.08)',
'shadow-key-penumbra-opacity': 'rgba(20, 18, 33, 0.12)',
'shadow-key-ambient-opacity': 'rgba(20, 18, 33, 0.04)',
},
},
transparent: {
dark: true,
colors: {
'primary': '#A370F7',
'secondary': '#8A8D93',
'on-secondary': '#FFFFFF',
'success': '#66BB6A',
'info': '#42A5F5',
'warning': '#FFA726',
'error': '#EF5350',
'on-primary': '#FFFFFF',
'on-success': '#FFFFFF',
'on-warning': '#FFFFFF',
'background': '#000000',
'on-background': '#E7E3FC',
'surface': 'rgba(30, 30, 30, 0.3)',
'on-surface': '#E7E3FC',
'surface-variant': 'rgba(30, 30, 30, 0.2)',
'on-surface-variant': 'rgba(255, 255, 255, 0.65)',
'grey-50': 'rgba(42, 46, 66, 0.15)',
'grey-100': 'rgba(71, 67, 96, 0.15)',
'grey-200': 'rgba(74, 80, 114, 0.15)',
'grey-300': 'rgba(94, 102, 146, 0.15)',
'grey-400': 'rgba(121, 131, 187, 0.15)',
'grey-500': 'rgba(134, 146, 208, 0.15)',
'grey-600': 'rgba(170, 179, 222, 0.15)',
'grey-700': 'rgba(182, 190, 227, 0.15)',
'grey-800': 'rgba(207, 211, 236, 0.15)',
'grey-900': 'rgba(231, 233, 246, 0.15)',
'perfect-scrollbar-thumb': 'rgba(158, 158, 190, 0.4)',
'skin-bordered-background': 'rgba(30, 30, 30, 0.3)',
'skin-bordered-surface': 'rgba(30, 30, 30, 0.3)',
'card-background': 'rgba(30, 30, 30, 0.3)',
},
variables: {
'code-color': '#6D9EEB',
'overlay-scrim-background': '0, 0, 0',
'overlay-scrim-opacity': 0.7,
'hover-opacity': 0.1,
'focus-opacity': 0.15,
'selected-opacity': 0.2,
'activated-opacity': 0.15,
'pressed-opacity': 0.2,
'dragged-opacity': 0.15,
'border-color': '#E7E3FC',
'table-header-background': 'rgba(30, 30, 30, 0.3)',
'custom-background': 'rgba(30, 30, 30, 0.3)',
'card-background': 'rgba(30, 30, 30, 0.3)',
// Shadows
'shadow-key-umbra-opacity': 'rgba(0, 0, 0, 0.07)',
'shadow-key-penumbra-opacity': 'rgba(0, 0, 0, 0.1)',
'shadow-key-ambient-opacity': 'rgba(0, 0, 0, 0.05)',
},
},
},
}
export default theme

View File

@@ -0,0 +1,79 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'MyPlugin',
filename: 'remoteEntry.js',
exposes: {
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
},
shared: {
vue: {
requiredVersion: false,
generate: false,
},
vuetify: {
requiredVersion: false,
generate: false,
singleton: true,
},
'vuetify/styles': {
requiredVersion: false,
generate: false,
singleton: true,
},
},
format: 'esm'
})
],
build: {
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: true, // 改为true以便能分离样式文件
},
css: {
preprocessorOptions: {
scss: {
additionalData: '/* 覆盖vuetify样式 */',
}
},
postcss: {
plugins: [
{
postcssPlugin: 'internal:charset-removal',
AtRule: {
charset: (atRule) => {
if (atRule.name === 'charset') {
atRule.remove();
}
}
}
},
{
postcssPlugin: 'vuetify-filter',
Root(root) {
// 过滤掉所有vuetify相关的CSS
root.walkRules(rule => {
if (rule.selector && (
rule.selector.includes('.v-') ||
rule.selector.includes('.mdi-'))) {
rule.remove();
}
});
}
}
]
}
},
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
},
})

View File

@@ -0,0 +1,561 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@babel/helper-string-parser@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
"@babel/helper-validator-identifier@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8"
integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==
"@babel/parser@^7.25.3":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.1.tgz#c55d5bed74449d1223701f1869b9ee345cc94cc9"
integrity sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==
dependencies:
"@babel/types" "^7.27.1"
"@babel/types@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.1.tgz#9defc53c16fc899e46941fc6901a9eea1c9d8560"
integrity sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==
dependencies:
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.27.1"
"@esbuild/aix-ppc64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==
"@esbuild/android-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052"
integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==
"@esbuild/android-arm@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28"
integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==
"@esbuild/android-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e"
integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==
"@esbuild/darwin-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a"
integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==
"@esbuild/darwin-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22"
integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==
"@esbuild/freebsd-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e"
integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==
"@esbuild/freebsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261"
integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==
"@esbuild/linux-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b"
integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==
"@esbuild/linux-arm@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9"
integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==
"@esbuild/linux-ia32@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2"
integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==
"@esbuild/linux-loong64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df"
integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==
"@esbuild/linux-mips64el@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe"
integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==
"@esbuild/linux-ppc64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4"
integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==
"@esbuild/linux-riscv64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc"
integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==
"@esbuild/linux-s390x@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de"
integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==
"@esbuild/linux-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0"
integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==
"@esbuild/netbsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047"
integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==
"@esbuild/openbsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70"
integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==
"@esbuild/sunos-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b"
integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==
"@esbuild/win32-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d"
integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==
"@esbuild/win32-ia32@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b"
integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==
"@esbuild/win32-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c"
integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==
"@jridgewell/sourcemap-codec@^1.4.13", "@jridgewell/sourcemap-codec@^1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a"
integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==
"@originjs/vite-plugin-federation@^1.4.1":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@originjs/vite-plugin-federation/-/vite-plugin-federation-1.4.1.tgz#e6abc8f18f2cf82783eb87853f4d03e6358b43c2"
integrity sha512-Uo08jW5pj1t58OUKuZNkmzcfTN2pqeVuAWCCiKf/75/oll4Efq4cHOqSE1FXMlvwZNGDziNdDyBbQ5IANem3CQ==
dependencies:
estree-walker "^3.0.2"
magic-string "^0.27.0"
"@rollup/rollup-android-arm-eabi@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz#c228d00a41f0dbd6fb8b7ea819bbfbf1c1157a10"
integrity sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==
"@rollup/rollup-android-arm64@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz#e2b38d0c912169fd55d7e38d723aada208d37256"
integrity sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==
"@rollup/rollup-darwin-arm64@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz#1fddb3690f2ae33df16d334c613377f05abe4878"
integrity sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==
"@rollup/rollup-darwin-x64@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz#818298d11c8109e1112590165142f14be24b396d"
integrity sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==
"@rollup/rollup-freebsd-arm64@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz#91a28dc527d5bed7f9ecf0e054297b3012e19618"
integrity sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==
"@rollup/rollup-freebsd-x64@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz#28acadefa76b5c7bede1576e065b51d335c62c62"
integrity sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==
"@rollup/rollup-linux-arm-gnueabihf@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz#819691464179cbcd9a9f9d3dc7617954840c6186"
integrity sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==
"@rollup/rollup-linux-arm-musleabihf@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz#d149207039e4189e267e8724050388effc80d704"
integrity sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==
"@rollup/rollup-linux-arm64-gnu@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz#fa72ebddb729c3c6d88973242f1a2153c83e86ec"
integrity sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==
"@rollup/rollup-linux-arm64-musl@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz#2054216e34469ab8765588ebf343d531fc3c9228"
integrity sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==
"@rollup/rollup-linux-loongarch64-gnu@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz#818de242291841afbfc483a84f11e9c7a11959bc"
integrity sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==
"@rollup/rollup-linux-powerpc64le-gnu@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz#0bb4cb8fc4a2c635f68c1208c924b2145eb647cb"
integrity sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==
"@rollup/rollup-linux-riscv64-gnu@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz#4b3b8e541b7b13e447ae07774217d98c06f6926d"
integrity sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==
"@rollup/rollup-linux-riscv64-musl@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz#e065405e67d8bd64a7d0126c931bd9f03910817f"
integrity sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==
"@rollup/rollup-linux-s390x-gnu@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz#dda3265bbbfe16a5d0089168fd07f5ebb2a866fe"
integrity sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==
"@rollup/rollup-linux-x64-gnu@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz#90993269b8b995b4067b7b9d72ff1c360ef90a17"
integrity sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==
"@rollup/rollup-linux-x64-musl@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz#fdf5b09fd121eb8d977ebb0fda142c7c0167b8de"
integrity sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==
"@rollup/rollup-win32-arm64-msvc@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz#6397e1e012db64dfecfed0774cb9fcf89503d716"
integrity sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==
"@rollup/rollup-win32-ia32-msvc@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz#df0991464a52a35506103fe18d29913bf8798a0c"
integrity sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==
"@rollup/rollup-win32-x64-msvc@4.40.2":
version "4.40.2"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz#8dae04d01a2cbd84d6297d99356674c6b993f0fc"
integrity sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==
"@types/estree@1.0.7", "@types/estree@^1.0.0":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
"@types/web-bluetooth@^0.0.21":
version "0.0.21"
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63"
integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==
"@vitejs/plugin-vue@^4.4.0":
version "4.6.2"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz#057d2ded94c4e71b94e9814f92dcd9306317aa46"
integrity sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==
"@vue/compiler-core@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.13.tgz#b0ae6c4347f60c03e849a05d34e5bf747c9bda05"
integrity sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==
dependencies:
"@babel/parser" "^7.25.3"
"@vue/shared" "3.5.13"
entities "^4.5.0"
estree-walker "^2.0.2"
source-map-js "^1.2.0"
"@vue/compiler-dom@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz#bb1b8758dbc542b3658dda973b98a1c9311a8a58"
integrity sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==
dependencies:
"@vue/compiler-core" "3.5.13"
"@vue/shared" "3.5.13"
"@vue/compiler-sfc@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz#461f8bd343b5c06fac4189c4fef8af32dea82b46"
integrity sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==
dependencies:
"@babel/parser" "^7.25.3"
"@vue/compiler-core" "3.5.13"
"@vue/compiler-dom" "3.5.13"
"@vue/compiler-ssr" "3.5.13"
"@vue/shared" "3.5.13"
estree-walker "^2.0.2"
magic-string "^0.30.11"
postcss "^8.4.48"
source-map-js "^1.2.0"
"@vue/compiler-ssr@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz#e771adcca6d3d000f91a4277c972a996d07f43ba"
integrity sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==
dependencies:
"@vue/compiler-dom" "3.5.13"
"@vue/shared" "3.5.13"
"@vue/reactivity@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.13.tgz#b41ff2bb865e093899a22219f5b25f97b6fe155f"
integrity sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==
dependencies:
"@vue/shared" "3.5.13"
"@vue/runtime-core@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.13.tgz#1fafa4bf0b97af0ebdd9dbfe98cd630da363a455"
integrity sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==
dependencies:
"@vue/reactivity" "3.5.13"
"@vue/shared" "3.5.13"
"@vue/runtime-dom@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz#610fc795de9246300e8ae8865930d534e1246215"
integrity sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==
dependencies:
"@vue/reactivity" "3.5.13"
"@vue/runtime-core" "3.5.13"
"@vue/shared" "3.5.13"
csstype "^3.1.3"
"@vue/server-renderer@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.13.tgz#429ead62ee51de789646c22efe908e489aad46f7"
integrity sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==
dependencies:
"@vue/compiler-ssr" "3.5.13"
"@vue/shared" "3.5.13"
"@vue/shared@3.5.13":
version "3.5.13"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.13.tgz#87b309a6379c22b926e696893237826f64339b6f"
integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==
"@vueuse/core@^12.4.0":
version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-12.8.2.tgz#007c6dd29a7d1f6933e916e7a2f8ef3c3f968eaa"
integrity sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==
dependencies:
"@types/web-bluetooth" "^0.0.21"
"@vueuse/metadata" "12.8.2"
"@vueuse/shared" "12.8.2"
vue "^3.5.13"
"@vueuse/metadata@12.8.2":
version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-12.8.2.tgz#6cb3a4e97cdcf528329eebc1bda73cd7f64318d3"
integrity sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==
"@vueuse/shared@12.8.2":
version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-12.8.2.tgz#b9e4611d0603629c8e151f982459da394e22f930"
integrity sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==
dependencies:
vue "^3.5.13"
csstype@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
echarts@^5.4.3:
version "5.6.0"
resolved "https://registry.yarnpkg.com/echarts/-/echarts-5.6.0.tgz#2377874dca9fb50f104051c3553544752da3c9d6"
integrity sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==
dependencies:
tslib "2.3.0"
zrender "5.6.1"
entities@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
esbuild@^0.21.3:
version "0.21.5"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d"
integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==
optionalDependencies:
"@esbuild/aix-ppc64" "0.21.5"
"@esbuild/android-arm" "0.21.5"
"@esbuild/android-arm64" "0.21.5"
"@esbuild/android-x64" "0.21.5"
"@esbuild/darwin-arm64" "0.21.5"
"@esbuild/darwin-x64" "0.21.5"
"@esbuild/freebsd-arm64" "0.21.5"
"@esbuild/freebsd-x64" "0.21.5"
"@esbuild/linux-arm" "0.21.5"
"@esbuild/linux-arm64" "0.21.5"
"@esbuild/linux-ia32" "0.21.5"
"@esbuild/linux-loong64" "0.21.5"
"@esbuild/linux-mips64el" "0.21.5"
"@esbuild/linux-ppc64" "0.21.5"
"@esbuild/linux-riscv64" "0.21.5"
"@esbuild/linux-s390x" "0.21.5"
"@esbuild/linux-x64" "0.21.5"
"@esbuild/netbsd-x64" "0.21.5"
"@esbuild/openbsd-x64" "0.21.5"
"@esbuild/sunos-x64" "0.21.5"
"@esbuild/win32-arm64" "0.21.5"
"@esbuild/win32-ia32" "0.21.5"
"@esbuild/win32-x64" "0.21.5"
estree-walker@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
estree-walker@^3.0.2:
version "3.0.3"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d"
integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==
dependencies:
"@types/estree" "^1.0.0"
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
magic-string@^0.27.0:
version "0.27.0"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.27.0.tgz#e4a3413b4bab6d98d2becffd48b4a257effdbbf3"
integrity sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==
dependencies:
"@jridgewell/sourcemap-codec" "^1.4.13"
magic-string@^0.30.11:
version "0.30.17"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453"
integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.0"
nanoid@^3.3.8:
version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
postcss@^8.4.43, postcss@^8.4.48:
version "8.5.3"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb"
integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==
dependencies:
nanoid "^3.3.8"
picocolors "^1.1.1"
source-map-js "^1.2.1"
resize-detector@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/resize-detector/-/resize-detector-0.3.0.tgz#fe495112e184695500a8f51e0389f15774cb1cfc"
integrity sha512-R/tCuvuOHQ8o2boRP6vgx8hXCCy87H1eY9V5imBYeVNyNVpuL9ciReSccLj2gDcax9+2weXy3bc8Vv+NRXeEvQ==
rollup@^4.20.0:
version "4.40.2"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.40.2.tgz#778e88b7a197542682b3e318581f7697f55f0619"
integrity sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==
dependencies:
"@types/estree" "1.0.7"
optionalDependencies:
"@rollup/rollup-android-arm-eabi" "4.40.2"
"@rollup/rollup-android-arm64" "4.40.2"
"@rollup/rollup-darwin-arm64" "4.40.2"
"@rollup/rollup-darwin-x64" "4.40.2"
"@rollup/rollup-freebsd-arm64" "4.40.2"
"@rollup/rollup-freebsd-x64" "4.40.2"
"@rollup/rollup-linux-arm-gnueabihf" "4.40.2"
"@rollup/rollup-linux-arm-musleabihf" "4.40.2"
"@rollup/rollup-linux-arm64-gnu" "4.40.2"
"@rollup/rollup-linux-arm64-musl" "4.40.2"
"@rollup/rollup-linux-loongarch64-gnu" "4.40.2"
"@rollup/rollup-linux-powerpc64le-gnu" "4.40.2"
"@rollup/rollup-linux-riscv64-gnu" "4.40.2"
"@rollup/rollup-linux-riscv64-musl" "4.40.2"
"@rollup/rollup-linux-s390x-gnu" "4.40.2"
"@rollup/rollup-linux-x64-gnu" "4.40.2"
"@rollup/rollup-linux-x64-musl" "4.40.2"
"@rollup/rollup-win32-arm64-msvc" "4.40.2"
"@rollup/rollup-win32-ia32-msvc" "4.40.2"
"@rollup/rollup-win32-x64-msvc" "4.40.2"
fsevents "~2.3.2"
source-map-js@^1.2.0, source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
vite@^5.4.11:
version "5.4.19"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.19.tgz#20efd060410044b3ed555049418a5e7d1998f959"
integrity sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==
dependencies:
esbuild "^0.21.3"
postcss "^8.4.43"
rollup "^4.20.0"
optionalDependencies:
fsevents "~2.3.3"
vue-demi@^0.13.11:
version "0.13.11"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.13.11.tgz#7d90369bdae8974d87b1973564ad390182410d99"
integrity sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==
vue-echarts@^6.6.1:
version "6.7.3"
resolved "https://registry.yarnpkg.com/vue-echarts/-/vue-echarts-6.7.3.tgz#30efafc51a4a9de1b8117d3b63e74b0c761ff3ba"
integrity sha512-vXLKpALFjbPphW9IfQPOVfb1KjGZ/f8qa/FZHi9lZIWzAnQC1DgnmEK3pJgEkyo6EP7UnX6Bv/V3Ke7p+qCNXA==
dependencies:
resize-detector "^0.3.0"
vue-demi "^0.13.11"
vue@^3.5.13:
version "3.5.13"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a"
integrity sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==
dependencies:
"@vue/compiler-dom" "3.5.13"
"@vue/compiler-sfc" "3.5.13"
"@vue/runtime-dom" "3.5.13"
"@vue/server-renderer" "3.5.13"
"@vue/shared" "3.5.13"
vuetify@3.7.3:
version "3.7.3"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.7.3.tgz#0e89f7f0298d452510bcbc01b0e9b53a5ce6e883"
integrity sha512-bpuvBpZl1/+nLlXDgdVXekvMNR6W/ciaoa8CYlpeAzAARbY8zUFSoBq05JlLhkIHI58AnzKVy4c09d0OtfYAPg==
zrender@5.6.1:
version "5.6.1"
resolved "https://registry.yarnpkg.com/zrender/-/zrender-5.6.1.tgz#e08d57ecf4acac708c4fcb7481eb201df7f10a6b"
integrity sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==
dependencies:
tslib "2.3.0"

View File

@@ -1,105 +1,130 @@
<!DOCTYPE html>
<html lang="en" style="
<html lang="zh-CN" style="
overflow: hidden auto;
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
background: var(--initial-loader-bg, #fff);
">
<head>
<meta http-equiv="pragma" content="no-cache" />
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="expires" content="0" />
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="initial-scale=1, viewport-fit=cover, width=device-width, user-scalable=no" />
<title>MoviePilot</title>
<meta charset="UTF-8" />
<!-- 核心viewport设置 - 针对PWA优化 -->
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
<!-- 防止缩放和选择,提供原生应用体验 -->
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
<!-- 基础信息 -->
<meta name="description" content="MoviePilot - 智能影视媒体库管理工具" />
<meta name="author" content="MoviePilot" />
<meta name="keywords" content="MoviePilot,影视,媒体库,管理" />
<!-- 安全和隐私 -->
<meta name="Robots" content="noindex,nofollow,noarchive" />
<meta name="referrer" content="origin" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="referrer" content="no-referrer" />
<!-- PWA - 基础图标 -->
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="icon" type="image/png" href="/logo.png" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<!-- iOS Safari PWA 优化 -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<meta name="mobile-web-app-capable" content="yes" />
<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 全屏模式 -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="MoviePilot" />
<meta name="description" content="MoviePilot" />
<meta name="format-detection" content="telephone=no" />
<meta name="referrer" content="never" />
<meta name="msapplication-TileColor" content="#7D34FD" />
<meta name="color-scheme" content="light dark" />
<!-- iOS Safari 防止自动识别 -->
<meta name="apple-mobile-web-app-orientations" content="portrait" />
<!-- Android Chrome PWA 优化 -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-title" content="MoviePilot" />
<!-- Microsoft Windows PWA -->
<meta name="msapplication-TileColor" content="#0E1116" />
<meta name="msapplication-TileImage" content="/android-chrome-192x192.png" />
<meta name="msapplication-config" content="none" />
<meta name="msapplication-tap-highlight" content="no" />
<meta name="msapplication-navbutton-color" content="#0E1116" />
<!-- 主题色彩 - 适配深色和浅色模式 -->
<meta name="theme-color" content="#0E1116" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
<meta name="color-scheme" content="dark light" />
<!-- 屏幕方向锁定 -->
<meta name="screen-orientation" content="portrait" />
<meta name="x5-orientation" content="portrait" />
<meta name="x5-fullscreen" content="true" />
<meta name="x5-page-mode" content="app" />
<!-- UC浏览器优化 -->
<meta name="browsermode" content="application" />
<meta name="wap-font-scale" content="no" />
<!-- 360浏览器优化 -->
<meta name="renderer" content="webkit" />
<!-- 触摸优化 -->
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<!-- 缓存控制 -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- DNS预解析 -->
<link rel="dns-prefetch" href="//fonts.googleapis.com" />
<link rel="dns-prefetch" href="//cdn.jsdelivr.net" />
<!-- 预加载关键资源 -->
<link rel="preload" href="/loader.css" as="style" />
<!-- 加载样式 -->
<link rel="stylesheet" type="text/css" href="/loader.css" />
<!-- 初始化脚本 -->
<script>
// 主题色彩初始化
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
if (loaderColor) document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
if (primaryColor) document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
// 状态栏适配
if (window.navigator.standalone) {
document.documentElement.style.setProperty('--status-bar-height', '20px')
}
// 安全区域适配
function updateSafeArea() {
const safeAreaTop = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)')
const safeAreaBottom = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)')
if (safeAreaTop) document.documentElement.style.setProperty('--safe-area-top', safeAreaTop)
if (safeAreaBottom) document.documentElement.style.setProperty('--safe-area-bottom', safeAreaBottom)
}
updateSafeArea()
window.addEventListener('resize', updateSafeArea)
window.addEventListener('orientationchange', updateSafeArea)
</script>
</head>
<body style="margin: 0">
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch;">
<div id="loading-bg">
<div class="loading-logo">
<!-- Logo -->
<svg width="160px" height="160px" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
<style>
/* 添加SVG内部的动画样式 */
@keyframes pulse {
0%,
100% {
opacity: 0.8;
}
50% {
opacity: 1;
}
}
@keyframes glow {
0%,
100% {
filter: drop-shadow(0 0 3px rgba(141, 81, 249, 0.3));
}
50% {
filter: drop-shadow(0 0 6px rgba(141, 81, 249, 0.6));
}
}
/* 为各个元素添加动画 */
#a2-c {
filter: drop-shadow(0 0 5px rgba(141, 81, 249, 0.3));
animation: glow 3s ease-in-out infinite;
}
path {
animation: pulse 2s ease-in-out infinite;
}
/* 错开不同元素的动画开始时间 */
g:nth-child(2) path {
animation-delay: 0.3s;
}
g:nth-child(3) path {
animation-delay: 0.6s;
}
g:nth-child(4) path {
animation-delay: 0.9s;
}
g:nth-child(5) path {
animation-delay: 1.2s;
}
</style>
<g transform="matrix(1,0,0,1,-2606,-236)">
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
<rect x="0" y="0" width="192" height="192" style="fill: none" />
@@ -211,4 +236,4 @@
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>

14609
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
{
"name": "moviepilot",
"version": "2.4.2",
"version": "2.6.2",
"private": true,
"type": "module",
"bin": "dist/service.js",
"scripts": {
"dev": "vite --host",
@@ -44,6 +45,7 @@
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"http-proxy-middleware": "^3.0.0",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",
"mousetrap": "^1.6.5",
@@ -55,13 +57,12 @@
"tailwindcss": "^ 3.4.17",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"vue-toast-notification": "^3.1.3",
"vue-toastification": "^2.0.0-rc.5",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.8.0",
"vue3-perfect-scrollbar": "^2.0.0",
"vuedraggable": "^4.1.0",
"vuetify": "3.7.3",
"vuetify-use-dialog": "^0.6.11",
"webfontloader": "^1.6.28"
},
"devDependencies": {
@@ -69,6 +70,7 @@
"@iconify/tools": "^4.0.4",
"@iconify/vue": "^4.3.0",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@originjs/vite-plugin-federation": "^1.4.1",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
@@ -111,4 +113,4 @@
"workbox-window": "^7.3.0"
},
"packageManager": "yarn@1.22.18"
}
}

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -1,6 +1,6 @@
#loading-bg {
position: fixed;
z-index: 9999;
z-index: 99999;
display: block;
background: var(--initial-loader-bg, #fff);
block-size: 100vh;
@@ -94,4 +94,4 @@
opacity: 1;
transform: rotate(1turn);
}
}
}

View File

@@ -1,6 +1,7 @@
const path = require('node:path')
const express = require('express')
const proxy = require('express-http-proxy')
const { createProxyMiddleware } = require('http-proxy-middleware')
const app = express()
const port = process.env.NGINX_PORT || 3000
@@ -14,16 +15,141 @@ const proxyConfig = {
// 静态文件服务目录
app.use(express.static(__dirname))
// 配置代理中间件将请求转发给后端API
app.use(
'/api',
proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, {
// 路径加上 /api 前缀
proxyReqPathResolver: (req) => {
return `/api${req.url}`
// 创建专门的SSE代理中间件
const sseProxyMiddleware = createProxyMiddleware({
target: `http://${proxyConfig.URL}:${proxyConfig.PORT}`,
changeOrigin: true,
ws: false,
timeout: 0, // 无超时
proxyTimeout: 0, // 无超时
headers: {
'Connection': 'keep-alive',
'Cache-Control': 'no-cache'
},
onProxyRes: (proxyRes, req, res) => {
// 检测SSE响应
const isSSE = proxyRes.headers['content-type'] &&
proxyRes.headers['content-type'].includes('text/event-stream');
if (isSSE) {
// 设置SSE响应头
res.writeHead(proxyRes.statusCode, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control, Content-Type, Authorization'
});
// 直接将代理响应流式传输到客户端
proxyRes.pipe(res);
// 处理客户端断开连接
req.on('close', () => {
console.log('Client disconnected from SSE stream');
if (proxyRes.destroy) {
proxyRes.destroy();
}
});
// 处理代理响应结束
proxyRes.on('end', () => {
console.log('SSE stream ended');
if (!res.headersSent) {
res.end();
}
});
// 处理代理响应错误
proxyRes.on('error', (err) => {
console.error('SSE proxy response error:', err);
if (!res.headersSent) {
res.status(500).end();
}
});
}
})
);
},
onError: (err, req, res) => {
console.error('SSE proxy error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Proxy error' });
}
}
});
// 创建普通API代理中间件
const apiProxyMiddleware = proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, {
// 路径加上 /api 前缀
proxyReqPathResolver: (req) => {
return `/api${req.url}`
},
proxyReqOptDecorator: (proxyReqOpts, srcReq) => {
proxyReqOpts.headers = proxyReqOpts.headers || {};
// 检测是否为SSE请求
const isSSE = srcReq.headers.accept && srcReq.headers.accept.includes('text/event-stream');
if (!isSSE) {
// 普通请求设置超时
proxyReqOpts.timeout = 600000; // 600秒超时
}
return proxyReqOpts;
},
userResDecorator: (proxyRes, proxyResData, userReq, userRes) => {
// 只处理非SSE响应
const isSSEResponse = proxyRes.headers['content-type'] &&
proxyRes.headers['content-type'].includes('text/event-stream');
if (!isSSEResponse) {
// 普通响应:正常处理
return proxyResData;
}
// SSE响应不在这里处理已经由专门的中间件处理
return proxyResData;
},
// 错误处理
proxyErrorHandler: (err, res, next) => {
// 客户端断开连接的正常情况
if (err.code === 'ECONNRESET' || err.code === 'EPIPE') {
console.log('Client disconnected:', err.code);
if (!res.headersSent) {
res.end();
}
return;
}
// 超时错误处理
if (err.code === 'ETIMEDOUT') {
console.log('Proxy request timed out:', err.code);
if (!res.headersSent) {
res.status(504).send('Gateway Timeout');
}
return;
}
// 其他错误
console.error('Proxy error:', err);
if (!res.headersSent) {
res.status(500).send('Internal Server Error');
}
}
});
// 配置API代理路由
app.use('/api', (req, res, next) => {
// 检测是否为SSE请求
const isSSE = req.headers.accept && req.headers.accept.includes('text/event-stream');
if (isSSE) {
// 使用专门的SSE代理中间件
sseProxyMiddleware(req, res, next);
} else {
// 使用普通API代理中间件
apiProxyMiddleware(req, res, next);
}
});
// 配置代理中间件将CookieCloud请求转发给后端API
app.use(

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
modelValue: boolean
type?: 'info' | 'warn' | 'error'
title?: string
content?: string
confirmText?: string
cancelText?: string
width?: string | number
}
const props = withDefaults(defineProps<Props>(), {
type: 'info',
title: '',
content: '',
confirmText: '',
cancelText: '',
width: '28rem',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
(e: 'cancel'): void
}>()
// 对话框类型对应的图标和颜色
const typeConfig = {
info: {
icon: 'mdi-information',
color: 'info',
},
warn: {
icon: 'mdi-alert',
color: 'warning',
},
error: {
icon: 'mdi-alert-circle',
color: 'error',
},
}
// 获取当前类型的配置
const currentType = computed(() => typeConfig[props.type])
// 确认按钮点击
function handleConfirm() {
emit('confirm')
emit('update:modelValue', false)
}
// 取消按钮点击
function handleCancel() {
emit('cancel')
emit('update:modelValue', false)
}
</script>
<template>
<VDialog :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" :max-width="width">
<VCard>
<VCardItem>
<div class="d-flex align-center justify-start mt-3">
<VAvatar :color="currentType.color" variant="text" size="x-large">
<VIcon size="x-large" :icon="currentType.icon" />
</VAvatar>
<div class="mx-3">
<p class="font-weight-bold text-xl text-high-emphasis">{{ title }}</p>
<p>{{ content }}</p>
</div>
</div>
</VCardItem>
<VCardActions class="mx-auto">
<VBtn variant="tonal" color="secondary" class="px-5" @click="handleCancel">
{{ cancelText }}
</VBtn>
<VBtn variant="elevated" :color="currentType.color" @click="handleConfirm" class="px-5">
{{ confirmText }}
</VBtn>
</VCardActions>
<VDialogCloseBtn @click="handleCancel" />
</VCard>
</VDialog>
</template>

View File

@@ -1,15 +1,88 @@
<script lang="ts" setup>
// 定义输入参数
const props = defineProps({
progress: Number,
text: String,
})
</script>
<template>
<div class="w-full text-center text-gray-500 text-sm flex flex-col items-center">
<VProgressCircular v-if="!props.text || !props.progress" class="mb-3" size="64" indeterminate color="primary" />
<VProgressCircular v-if="props.progress" class="mb-3" color="primary" :model-value="props.progress" size="64" />
<span>{{ props.text }}</span>
<div class="w-full text-center text-gray-500 text-sm flex flex-col items-center my-5">
<div class="initial-loading-container">
<div class="initial-loading-content">
<div class="wave-loader">
<div class="wave-dot"></div>
<div class="wave-dot"></div>
<div class="wave-dot"></div>
<div class="wave-dot"></div>
</div>
<div class="initial-loading-text" v-if="props.text">{{ props.text }}</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 初始的加载状态 */
.initial-loading-container {
display: flex;
align-items: center;
justify-content: center;
min-block-size: 20vh;
}
.initial-loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.wave-loader {
display: flex;
align-items: center;
block-size: 40px;
gap: 6px;
}
.wave-dot {
border-radius: 50%;
animation: wave 1.5s ease-in-out infinite;
background-color: rgb(var(--v-theme-primary));
block-size: 8px;
inline-size: 8px;
}
.wave-dot:nth-child(1) {
animation-delay: 0s;
}
.wave-dot:nth-child(2) {
animation-delay: 0.2s;
}
.wave-dot:nth-child(3) {
animation-delay: 0.4s;
}
.wave-dot:nth-child(4) {
animation-delay: 0.6s;
}
@keyframes wave {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-15px);
}
}
.initial-loading-text {
color: rgb(var(--v-theme-primary));
font-size: 0.9rem;
font-weight: 500;
letter-spacing: 1px;
}
</style>

View File

@@ -5,7 +5,7 @@ defineProps({
})
</script>
<template>
<div v-if="title" class="my-3 md:flex md:items-center md:justify-between">
<div v-if="title" class="my-3 mx-3 md:flex md:items-center md:justify-between">
<div class="min-w-0 flex-1 mx-0">
<h2
class="ms-1 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-3xl sm:leading-9 md:mb-0"

View File

@@ -1,189 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useDisplay, useTheme } from 'vuetify'
import type { ThemeSwitcherTheme } from '@layouts/types'
import api from '@/api'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { useToast } from 'vue-toast-notification'
import { saveLocalTheme } from '../utils/theme'
// 显示器宽度
const display = useDisplay()
const props = defineProps<{
themes: ThemeSwitcherTheme[]
}>()
const { name: themeName, global: globalTheme } = useTheme()
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
const currentThemeName = ref(savedTheme.value)
const getNextThemeName = () => {
const currentIndex = props.themes.findIndex(t => t.name === currentThemeName.value)
const nextIndex = (currentIndex + 1) % props.themes.length
return props.themes[nextIndex].name
}
const $toast = useToast()
// 自定义CSS弹窗
const cssDialog = ref(false)
// 自定义 CSS
const customCSS = ref('')
// 编辑器主题
const editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai'))
// 更新主题
function updateTheme() {
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value
globalTheme.name.value = theme
// 保存原始主题设置,而不是计算后的值
savedTheme.value = currentThemeName.value
// 保存主题到本地
saveLocalTheme(currentThemeName.value, globalTheme)
// 刷新页面
location.reload()
}
// 切换主题
function changeTheme(theme: string) {
let nextTheme = theme
if (!theme) nextTheme = getNextThemeName()
currentThemeName.value = nextTheme
// 保存主题到服务端
try {
api.post('/user/config/Layout', {
theme: nextTheme,
})
} catch (e) {
console.error(e)
}
}
// 是否有滚动条
function hasScrollbar(el?: Element | null) {
if (!el || el.nodeType !== Node.ELEMENT_NODE) return false
const style = window.getComputedStyle(el)
return style.overflowY === 'scroll' || (style.overflowY === 'auto' && el.scrollHeight > el.clientHeight)
}
// 监听系统主题变化
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
} catch (e) {
console.error('当前设备不支持监听系统主题变化')
}
// 查询当前主题的图标
const getThemeIcon = computed(() => {
const theme = props.themes.find(t => t.name === currentThemeName.value)
return theme?.icon ?? 'mdi-circle'
})
// 监听设置主题变化
watch(
() => currentThemeName.value,
() => updateTheme(),
)
// 获取自定义 CSS
async function getCustomCSS() {
try {
const result: { [key: string]: any } = await api.get('system/setting/UserCustomCSS')
if (result && result.success && result.data?.value) {
customCSS.value = result.data?.value ?? ''
if (customCSS.value) {
const style = document.createElement('style')
style.innerHTML = result.data?.value ?? ''
document.head.appendChild(style)
}
}
} catch (error) {
console.error(error)
}
}
// 保存自定义 CSS
async function saveCustomCSS() {
cssDialog.value = false
try {
const result: { [key: string]: any } = await api.post('system/setting/UserCustomCSS', customCSS.value, {
headers: {
'Content-Type': 'text/plain',
},
})
if (result.success) $toast.success('自定义CSS保存成功请刷新页面生效')
} catch (e) {
console.error('保存自定义 CSS 到服务端失败')
}
}
onMounted(() => {
getCustomCSS()
})
</script>
<template>
<VMenu v-if="props.themes" class="theme-menu" scrim>
<template v-slot:activator="{ props }">
<IconBtn v-bind="props">
<VIcon :icon="getThemeIcon" />
</IconBtn>
</template>
<VList>
<div class="px-2">
<VListItem
v-for="theme in props.themes"
:key="theme.name"
@click="changeTheme(theme.name)"
:active="currentThemeName === theme.name"
class="mb-1"
>
<template #prepend>
<VIcon :icon="theme.icon" />
</template>
<VListItemTitle>{{ theme.title }}</VListItemTitle>
<template #append v-if="currentThemeName === theme.name">
<VIcon icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
<VDivider class="my-2" />
<VListItem @click="cssDialog = true">
<template #prepend>
<VIcon icon="mdi-palette" />
</template>
<VListItemTitle>自定义主题</VListItemTitle>
</VListItem>
</div>
</VList>
</VMenu>
<!-- 自定义 CSS -- -->
<VDialog v-if="cssDialog" v-model="cssDialog" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-palette" class="me-2" />
自定义主题风格
</VCardTitle>
<VDialogCloseBtn @click="cssDialog = false" />
</VCardItem>
<VDivider />
<VAceEditor v-model:value="customCSS" lang="css" :theme="editorTheme" class="w-full min-h-[30rem]" />
<VDivider />
<VCardText class="text-center">
<VBtn @click="saveCustomCSS" class="w-1/2">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
保存
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -1,7 +1,7 @@
@use "@configured-variables" as variables;
// ————————————————————————————————————
//* ——— Perfect Scrollbar
// Perfect Scrollbar
// ————————————————————————————————————
.v-application.v-theme--dark {

View File

@@ -118,11 +118,6 @@
opacity: var(--v-disabled-opacity);
pointer-events: none;
}
}
// 👉 Vertical nav link
.nav-link {
@extend %nav-link;
> .router-link-exact-active {
@extend %nav-link-active;

View File

@@ -16,14 +16,14 @@ $header: ".layout-navbar";
@if variables.$vertical-nav-navbar-style == "elevated" {
// Add transition
#{$header} {
transition: padding 0.2s ease, background-color 0.18s ease;
transition: padding 0.2s ease;
}
// If navbar is contained => Add border radius to header
@if variables.$layout-vertical-nav-navbar-is-contained {
#{$header} {
// #{$header} {
// border-radius: 0 0 variables.$default-layout-with-vertical-nav-navbar-footer-roundness variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
}
// }
}
// Scrolled styles for sticky navbar
@@ -31,7 +31,7 @@ $header: ".layout-navbar";
/* This html selector with not selector is required when:
dialog is opened and window don't have any scroll. This removes window-scrolled class from layout and out style broke
*/
html.v-overlay-scroll-blocked:not([style*="--v-body-scroll-y: 0px;"]) .layout-navbar-fixed,
html.v-overlay-scroll-blocked .layout-navbar-fixed,
&.window-scrolled.layout-navbar-fixed {
#{$header} {
@@ -63,7 +63,7 @@ $header: ".layout-navbar";
#{$header} {
@if variables.$layout-vertical-nav-navbar-is-contained {
// border-radius: variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
border-radius: variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
}
background-color: rgb(var(--v-theme-surface));

View File

@@ -1,120 +1,45 @@
%blurry-bg {
position: relative;
background: transparent;
box-shadow: none;
&::before {
position: absolute;
z-index: -1;
block-size: calc(env(safe-area-inset-top, 0px) + 5rem);
content: "";
inset-block-start: 0;
inset-inline: 0;
pointer-events: none;
transition: opacity 0.2s ease-in-out, background 0.2s ease-in-out;
// PC端样式 (默认)
.v-theme--light & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-surface), 0.9) 0%,
rgba(var(--v-theme-surface), 0.7) 20%,
rgba(var(--v-theme-surface), 0.5) 40%,
rgba(var(--v-theme-surface), 0.3) 60%,
rgba(var(--v-theme-surface), 0.1) 80%,
rgba(var(--v-theme-surface), 0.0) 100%
);
}
.v-theme--dark & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 0.8) 0%,
rgba(var(--v-theme-background), 0.6) 20%,
rgba(var(--v-theme-background), 0.4) 40%,
rgba(var(--v-theme-background), 0.25) 60%,
rgba(var(--v-theme-background), 0.1) 80%,
rgba(var(--v-theme-background), 0.0) 100%
);
}
.v-theme--purple & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 0.8) 0%,
rgba(var(--v-theme-background), 0.6) 20%,
rgba(var(--v-theme-background), 0.4) 40%,
rgba(var(--v-theme-background), 0.25) 60%,
rgba(var(--v-theme-background), 0.1) 80%,
rgba(var(--v-theme-background), 0.0) 100%
);
}
background: rgba(var(--v-theme-background), 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
@media (width > 768px) {
.v-theme--transparent & {
background: linear-gradient(
to bottom,
rgba(11, 11, 11, 60%) 0%,
rgba(11, 11, 11, 50%) 20%,
rgba(11, 11, 11, 40%) 40%,
rgba(11, 11, 11, 25%) 60%,
rgba(11, 11, 11, 10%) 80%,
rgba(11, 11, 11, 0%) 100%
);
backdrop-filter: blur(5px);
background: rgba(var(--v-theme-background), 0.1);
}
}
}
// 移动端样式
@media (pointer: coarse) {
%blurry-bg {
&::before {
.v-theme--light & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-surface), 1) 0%,
rgba(var(--v-theme-surface), 0.9) 20%,
rgba(var(--v-theme-surface), 0.7) 40%,
rgba(var(--v-theme-surface), 0.5) 60%,
rgba(var(--v-theme-surface), 0.2) 80%,
rgba(var(--v-theme-surface), 0.0) 100%
);
}
.v-theme--dark & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 1) 0%,
rgba(var(--v-theme-background), 0.85) 20%,
rgba(var(--v-theme-background), 0.7) 40%,
rgba(var(--v-theme-background), 0.5) 60%,
rgba(var(--v-theme-background), 0.3) 80%,
rgba(var(--v-theme-background), 0.0) 100%
);
}
.v-theme--purple & {
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 1) 0%,
rgba(var(--v-theme-background), 0.85) 20%,
rgba(var(--v-theme-background), 0.7) 40%,
rgba(var(--v-theme-background), 0.5) 60%,
rgba(var(--v-theme-background), 0.3) 80%,
rgba(var(--v-theme-background), 0.0) 100%
);
}
@media (width <= 768px) {
background: transparent;
.v-theme--transparent & {
background: linear-gradient(
to bottom,
rgba(11, 11, 11, 90%) 0%,
rgba(11, 11, 11, 80%) 20%,
rgba(11, 11, 11, 60%) 40%,
rgba(11, 11, 11, 40%) 60%,
rgba(11, 11, 11, 15%) 80%,
rgba(11, 11, 11, 0%) 100%
);
}
&::before {
position: absolute;
z-index: -1;
backdrop-filter: blur(24px);
block-size: calc(env(safe-area-inset-top, 0px) + var(--navbar-tab-height) + 4rem);
content: "";
inset-block-start: 0;
inset-inline: 0;
pointer-events: none;
transition: all 0.3s ease-in-out;
.v-theme--light & {
background: rgba(var(--v-theme-surface), 0.6);
}
.v-theme--dark & {
background: rgba(var(--v-theme-background), 0.5);
}
.v-theme--purple & {
background: rgba(var(--v-theme-background), 0.5);
}
.v-theme--transparent & {
background: rgba(var(--v-theme-background), 0.3);
}
}
}
}
}

View File

@@ -2,8 +2,7 @@ import ColorThief from 'colorthief'
// 将 RGB 转换为十六进制
function rgbStringToHex(rgbArray: number[]): string {
if (rgbArray.length !== 3 || rgbArray.some(isNaN))
throw new Error('Invalid RGB string format')
if (rgbArray.length !== 3 || rgbArray.some(isNaN)) throw new Error('Invalid RGB string format')
const [r, g, b] = rgbArray
@@ -21,3 +20,27 @@ export async function getDominantColor(image: HTMLImageElement): Promise<string>
const dominantColor = colorThief.getColor(image)
return rgbStringToHex(dominantColor)
}
// 预加载图片
export async function preloadImage(url: string): Promise<boolean> {
return new Promise(resolve => {
const img = new Image()
img.onload = () => resolve(true)
img.onerror = () => resolve(false)
// 设置超时,防止图片长时间加载
const timeout = setTimeout(() => {
img.src = ''
resolve(false)
}, 5000) // 5秒超时
img.src = url
// 如果图片已经缓存onload可能不会触发
if (img.complete) {
clearTimeout(timeout)
resolve(true)
}
})
}

View File

@@ -12,6 +12,14 @@
*/
import { promises as fs } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module'
// Get current directory
const __dirname = dirname(fileURLToPath(import.meta.url))
// Create require function for importing JSON files in ESM
const require = createRequire(import.meta.url)
// Installation: npm install --save-dev @iconify/tools @iconify/utils @iconify/json @iconify/iconify
import {

View File

@@ -1,17 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"module": "Node16",
"declaration": false,
"declarationMap": false,
"sourceMap": false,
"composite": false,
"strict": true,
"moduleResolution": "node",
"moduleResolution": "node16",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
},
"exclude": [
"./*.js"
]
}
}

View File

@@ -1 +1 @@
{"root":["./build-icons.ts"],"version":"5.7.3"}
{"root":["./build-icons.ts"],"version":"5.8.3"}

View File

@@ -1,209 +0,0 @@
<script lang="ts">
import { Transition } from 'vue'
import { useDisplay } from 'vuetify'
import VerticalNav from '@layouts/components/VerticalNav.vue'
export default defineComponent({
setup(props, { slots }) {
const isOverlayNavActive = ref(false)
const isLayoutOverlayVisible = ref(false)
const toggleIsOverlayNavActive = useToggle(isOverlayNavActive)
const route = useRoute()
const { mdAndDown } = useDisplay()
// 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.
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
const scrollDistance = ref(window.scrollY)
onMounted(() => {
window.addEventListener('scroll', () => {
scrollDistance.value = window.scrollY
})
})
return () => {
// 👉 Vertical nav
const verticalNav = h(
VerticalNav,
{ isOverlayNavActive: isOverlayNavActive.value, toggleIsOverlayNavActive },
{
'nav-header': () => slots['vertical-nav-header']?.(),
'before-nav-items': () => slots['before-vertical-nav-items']?.(),
'default': () => slots['vertical-nav-content']?.(),
'after-nav-items': () => slots['after-vertical-nav-items']?.(),
},
)
// 👉 Navbar
const navbar = h('header', { class: ['layout-navbar navbar-blur'] }, [
h(
'div',
{ class: 'navbar-content-container' },
slots.navbar?.({
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
}),
),
])
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?.()),
),
)
// 👉 根据路由 meta 决定 footer 高度
const shouldShowFooter = !route.meta.hideFooter
// 👉 Footer
const footer = h('footer', { class: 'layout-footer' }, [
h(
'div',
{
class: ['footer-content-container', !shouldShowFooter && 'footer-content-container-noheight'],
},
slots.footer?.(),
),
])
// 👉 Overlay
const layoutOverlay = h('div', {
class: ['layout-overlay', 'touch-none', { visible: isLayoutOverlayVisible.value }],
onClick: () => {
isLayoutOverlayVisible.value = !isLayoutOverlayVisible.value
},
})
return h(
'div',
{
class: [
'layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid',
'layout-navbar-fixed',
mdAndDown.value && 'layout-overlay-nav',
route.meta.layoutWrapperClasses,
scrollDistance.value && 'window-scrolled',
],
},
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
)
}
},
})
</script>
<style lang="scss">
@use '@configured-variables' as variables;
@use '@layouts/styles/placeholders';
@use '@layouts/styles/mixins';
.layout-wrapper.layout-nav-type-vertical {
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
block-size: 100%;
.layout-content-wrapper {
display: flex;
flex-direction: column;
flex-grow: 1;
min-block-size: calc(var(--vh, 1vh) * 100);
transition: padding-inline-start 0.2s ease-in-out;
will-change: padding-inline-start;
}
.layout-navbar {
position: fixed;
width: calc(100vw - variables.$layout-vertical-nav-width - 1rem);
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
inset-block-start: 0;
.navbar-content-container {
block-size: calc(env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height);
}
@at-root {
.layout-wrapper.layout-nav-type-vertical {
.layout-navbar {
@if variables.$layout-vertical-nav-navbar-is-contained {
@include mixins.boxed-content;
} @else {
.navbar-content-container {
// @include mixins.boxed-content;
}
}
}
}
}
}
&.layout-navbar-fixed .layout-navbar {
@extend %layout-navbar-fixed;
}
&.layout-navbar-hidden .layout-navbar {
@extend %layout-navbar-hidden;
}
// 👉 Footer
.layout-footer {
@include mixins.boxed-content;
}
// 👉 Layout overlay
.layout-overlay {
position: fixed;
z-index: variables.$layout-overlay-z-index;
background-color: rgb(0 0 0 / 60%);
cursor: pointer;
inset: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease-in-out;
will-change: transform;
&.visible {
opacity: 1;
pointer-events: auto;
}
}
&:not(.layout-overlay-nav) .layout-content-wrapper {
padding-inline-start: variables.$layout-vertical-nav-width;
}
// 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;
}
// 👉 Content height fixed
&.layout-content-height-fixed {
.layout-content-wrapper {
max-block-size: calc(var(--vh) * 100);
}
.layout-page-content {
// display: flex;
overflow: hidden;
.page-content-container {
inline-size: 100%;
> :first-child {
max-block-size: 100%;
overflow-y: auto;
}
}
}
}
}
.layout-wrapper.layout-nav-type-vertical.layout-overlay-nav {
.layout-navbar {
width: 100%;
}
}
</style>

View File

@@ -38,15 +38,25 @@ export default defineComponent({
)
// 👉 Navbar
const navbar = h('header', { class: ['layout-navbar navbar-blur'] }, [
h(
'div',
{ class: 'navbar-content-container' },
slots.navbar?.({
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
}),
),
])
const navbar = h(
'header',
{ class: ['layout-navbar navbar-blur'] },
[
h(
'div',
{ class: 'navbar-content-container' },
[
slots.navbar?.({
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
}),
// 👉 Dynamic Header Tab in NavBar
slots['dynamic-header-tab']?.()
? h('div', { class: 'layout-dynamic-header-tab' }, slots['dynamic-header-tab']?.())
: null,
].filter(Boolean),
),
].filter(Boolean),
)
const main = h(
'main',
@@ -127,7 +137,9 @@ export default defineComponent({
inset-block-start: 0;
.navbar-content-container {
block-size: calc(env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height);
block-size: calc(
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height + var(--navbar-tab-height)
);
}
@at-root {
@@ -135,10 +147,6 @@ export default defineComponent({
.layout-navbar {
@if variables.$layout-vertical-nav-navbar-is-contained {
@include mixins.boxed-content;
} @else {
.navbar-content-container {
// @include mixins.boxed-content;
}
}
}
}

View File

@@ -1,7 +1,11 @@
import type { ValidationRule } from 'vuetify/types/services/validation'
// 必输校验
export const requiredValidator: ValidationRule = (value: any) => !!value || '此项为必填项'
export const requiredValidator: ValidationRule = (value: any) => {
return !!value
}
// 数字校验
export const numberValidator: ValidationRule = (value: any) => !isNaN(value) || '请输入数字'
export const numberValidator: ValidationRule = (value: any) => {
return !isNaN(value)
}

View File

@@ -3,7 +3,12 @@ import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
import { SupportedLocale } from '@/types/i18n'
import { checkAndEmitUnreadMessages } from '@/utils/badge'
import { preloadImage } from './@core/utils/image'
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
// 生效主题
const { global: globalTheme } = useTheme()
@@ -11,22 +16,28 @@ let themeValue = localStorage.getItem('theme') || 'light'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 显示状态
const show = ref(false)
// 生效语言
const localeValue = getBrowserLocale()
setI18nLanguage(localeValue as SupportedLocale)
// 检查是否登录
const authStore = useAuthStore()
const isLogin = computed(() => authStore.token)
// 全局设置store
const globalSettingsStore = useGlobalSettingsStore()
// 生成背景图片key
const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'))
// 背景图片
const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
let backgroundRotationTimer: NodeJS.Timeout | null = null
// ApexCharts 全局配置
declare global {
interface Window {
@@ -34,78 +45,80 @@ declare global {
}
}
if (window.Apex) {
// 数据标签
window.Apex.dataLabels = {
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
// 如果有小数点,保留两位小数,否则保留整数
const data = w.config.series[seriesIndex]
return data.toFixed(data % 1 === 0 ? 0 : 1)
},
}
// 图例
window.Apex.legend = {
labels: {
useSeriesColors: true,
},
}
// 标题
window.Apex.title = {
style: {
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
},
// 配置 ApexCharts 全局选项
function configureApexCharts() {
if (typeof window !== 'undefined' && window.Apex) {
try {
// 获取当前主题
const currentTheme = globalTheme.name.value
const isDark = currentTheme === 'dark' || currentTheme === 'transparent'
// 数据标签
window.Apex.dataLabels = {
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
// 如果有小数点,保留两位小数,否则保留整数
const data = w.config.series[seriesIndex]
return data.toFixed(data % 1 === 0 ? 0 : 1)
},
}
// 图例
window.Apex.legend = {
labels: {
useSeriesColors: true,
},
}
// 标题
window.Apex.title = {
style: {
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
},
}
// 鼠标悬浮提示
window.Apex.tooltip = {
theme: isDark ? 'dark' : 'light',
}
} catch (error) {
console.warn('ApexCharts 全局配置失败:', error)
}
}
}
// 更新data-theme属性以便CSS选择器能正确匹配
function updateHtmlThemeAttribute(themeName: string) {
document.documentElement.setAttribute('data-theme', themeName)
// 确保body元素也有相同的主题属性以便更好地选择弹出窗口
document.body.setAttribute('data-theme', themeName)
}
// 获取背景图片
async function fetchBackgroundImages() {
try {
backgroundImages.value = await api.get('/login/wallpapers')
const controller = new AbortController()
backgroundImages.value = await api.get(`/login/wallpapers`, {
signal: controller.signal,
})
activeImageIndex.value = 0
} catch (e) {
console.error(e)
throw e
}
}
// 开始背景图片轮换
function startBackgroundRotation() {
// 清除轮换定时器
if (backgroundRotationTimer) clearInterval(backgroundRotationTimer)
if (backgroundImages.value.length > 1) {
// 每10秒切换一次
backgroundRotationTimer = setInterval(() => {
activeImageIndex.value = (activeImageIndex.value + 1) % backgroundImages.value.length
}, 10000) // 每10秒切换一次
}
}
// 计算图片地址
function getImgUrl(url: string) {
// 使用图片缓存
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
}
// 处理页面可见性变化
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
// 如果已有背景图片数据,直接重启轮换
if (backgroundImages.value.length > 0) {
startBackgroundRotation()
}
// 如果没有背景图片数据,重新获取
else {
fetchBackgroundImages().then(() => startBackgroundRotation())
}
// 计算下一个图片索引
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
// 预加载下一张图片
preloadImage(backgroundImages.value[nextIndex]).then(success => {
// 只有图片成功加载才切换
if (success) {
activeImageIndex.value = nextIndex
}
})
}, 10000)
}
}
@@ -113,44 +126,100 @@ function handleVisibilityChange() {
function animateAndRemoveLoader() {
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
if (loadingBg) {
// 先添加完成动画类
loadingBg.classList.add('loading-complete')
// 等待动画完成后再移除元素
setTimeout(() => {
removeEl('#loading-bg')
// 将background属性从html的style中移除
document.documentElement.style.removeProperty('background')
// 显示页面
show.value = true
}, 500) // 与CSS动画持续时间匹配
removeEl('#loading-bg')
document.documentElement.style.removeProperty('background')
}
}
onMounted(() => {
// 检查PWA状态并移除加载界面
async function removeLoadingWithStateCheck() {
try {
// 设置各个组件的加载状态
globalLoadingStateManager.setLoadingState('pwa-state', true)
globalLoadingStateManager.setLoadingState('global-settings', true)
globalLoadingStateManager.setLoadingState('background-images', true)
// 静默检查PWA状态恢复
const pwaController = (window as any).pwaStateController
if (pwaController) {
await pwaController.waitForStateRestore()
}
globalLoadingStateManager.setLoadingState('pwa-state', false)
// 并行加载关键资源
await Promise.all([
globalSettingsStore.initialize().then(() => {
globalLoadingStateManager.setLoadingState('global-settings', false)
}),
new Promise(resolve => {
setTimeout(() => {
globalLoadingStateManager.setLoadingState('background-images', false)
resolve(void 0)
}, 50)
})
])
// 等待所有加载完成
await globalLoadingStateManager.waitForAllComplete()
// 移除加载界面
animateAndRemoveLoader()
// 检查未读消息
checkAndEmitUnreadMessages()
} catch (error) {
// 即使出错也要移除加载界面
globalLoadingStateManager.reset()
animateAndRemoveLoader()
}
}
// 加载背景图片
async function loadBackgroundImages(retryCount = 0) {
const maxRetries = 3
try {
await fetchBackgroundImages()
startBackgroundRotation()
} catch (error: any) {
const isAbortError = error.name === 'AbortError' || error.code === 'ERR_CANCELED'
if (retryCount < maxRetries) {
const baseDelay = isAbortError ? 1000 : 3000
const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)
setTimeout(() => {
loadBackgroundImages(retryCount + 1)
}, retryDelay)
}
}
}
onMounted(async () => {
// 配置 ApexCharts
configureApexCharts()
// 初始化data-theme属性
updateHtmlThemeAttribute(globalTheme.name.value)
// 加载背景图片并开始轮换
fetchBackgroundImages().then(() => startBackgroundRotation())
// 监听主题变化
watch(
() => globalTheme.name.value,
newTheme => {
// 更新HTML主题属性
updateHtmlThemeAttribute(newTheme)
// 重新配置ApexCharts以适应新主题
configureApexCharts()
},
)
// 添加页面可见性变化监听
document.addEventListener('visibilitychange', handleVisibilityChange)
// 加载背景图片
loadBackgroundImages()
// 使用优化后的加载界面移除逻辑
ensureRenderComplete(() => {
nextTick(() => {
setTimeout(() => {
// 移除加载动画
animateAndRemoveLoader()
}, 1500)
})
nextTick(removeLoadingWithStateCheck)
})
})
onUnmounted(() => {
// 移除页面可见性监听
document.removeEventListener('visibilitychange', handleVisibilityChange)
// 清除轮换定时器
if (backgroundRotationTimer) {
clearInterval(backgroundRotationTimer)
@@ -162,21 +231,19 @@ onUnmounted(() => {
<template>
<div class="app-wrapper">
<!-- 透明主题背景 -->
<template v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)">
<div class="background-container">
<div
v-for="(imageUrl, index) in backgroundImages"
:key="index"
class="background-image"
:class="{ 'active': index === activeImageIndex }"
:style="{ backgroundImage: `url(${getImgUrl(imageUrl)})` }"
></div>
<!-- 全局磨砂层 -->
<div class="global-blur-layer"></div>
</div>
</template>
<VApp v-show="show" :class="{ 'transparent-app': isTransparentTheme }">
<div v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)" class="background-container">
<div
v-for="(imageUrl, index) in backgroundImages"
:key="`bg-${index}-${loginStateKey}`"
class="background-image"
:class="{ 'active': index === activeImageIndex }"
:style="{ 'backgroundImage': `url(${imageUrl})` }"
/>
<!-- 全局磨砂层 -->
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
</div>
<!-- 页面内容 -->
<VApp :class="{ 'transparent-app': isTransparentTheme }">
<RouterView />
</VApp>
</div>
@@ -238,4 +305,29 @@ onUnmounted(() => {
inset-block-start: 0;
inset-inline-start: 0;
}
/* 优化加载完成动画 */
.loading-complete {
animation: fadeOutScale 0.8s ease-out forwards;
}
@keyframes fadeOutScale {
0% {
opacity: 1;
transform: scale(1);
filter: blur(0px);
}
70% {
opacity: 0.3;
transform: scale(1.05);
filter: blur(2px);
}
100% {
opacity: 0;
transform: scale(1.1);
filter: blur(5px);
}
}
</style>

View File

@@ -1,85 +1,353 @@
export const storageOptions = [
import i18n from '@/plugins/i18n'
export const storageAttributes = [
{
title: '本地',
value: 'local',
type: 'local',
icon: 'mdi-folder-multiple-outline',
remote: false,
},
{
title: '阿里云盘',
value: 'alipan',
type: 'alipan',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: '115网盘',
value: 'u115',
type: 'u115',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: 'RClone',
value: 'rclone',
type: 'rclone',
icon: 'mdi-server-network-outline',
remote: true,
},
{
title: 'AList',
value: 'alist',
type: 'alist',
icon: 'mdi-server-network-outline',
remote: true,
},
{
type: 'smb',
icon: 'mdi-folder-network-outline',
remote: true,
},
]
export const innerFilterRules = [
{ title: '特效字幕', value: ' SPECSUB ' },
{ title: '中文字幕', value: ' CNSUB ' },
{ title: '国语配音', value: ' CNVOI ' },
{ title: '官种', value: ' GZ ' },
{ title: '排除: 国语配音', value: ' !CNVOI ' },
{ title: '粤语配音', value: ' HKVOI ' },
{ title: '排除: 粤语配音', value: ' !HKVOI ' },
{ title: '促销: 免费', value: ' FREE ' },
{ title: '分辨率: 4K', value: ' 4K ' },
{ title: '分辨率: 1080P', value: ' 1080P ' },
{ title: '分辨率: 720P', value: ' 720P ' },
{ title: '排除: 720P', value: ' !720P ' },
{ title: '质量: 蓝光原盘', value: ' BLU ' },
{ title: '排除: 蓝光原盘', value: ' !BLU ' },
{ title: '质量: BLURAY', value: ' BLURAY ' },
{ title: '排除: BLURAY', value: ' !BLURAY ' },
{ title: '质量: UHD', value: ' UHD ' },
{ title: '排除: UHD', value: ' !UHD ' },
{ title: '质量: REMUX', value: ' REMUX ' },
{ title: '排除: REMUX', value: ' !REMUX ' },
{ title: '质量: WEB-DL', value: ' WEBDL ' },
{ title: '排除: WEB-DL', value: ' !WEBDL ' },
{ title: '质量: 60fps', value: ' 60FPS ' },
{ title: '排除: 60fps', value: ' !60FPS ' },
{ title: '编码: H265', value: ' H265 ' },
{ title: '排除: H265', value: ' !H265 ' },
{ title: '编码: H264', value: ' H264 ' },
{ title: '排除: H264', value: ' !H264 ' },
{ title: '效果: 杜比视界', value: ' DOLBY ' },
{ title: '排除: 杜比视界', value: ' !DOLBY ' },
{ title: '效果: 杜比全景声', value: ' ATMOS ' },
{ title: '排除: 杜比全景声', value: ' !ATMOS ' },
{ title: '效果: HDR', value: ' HDR ' },
{ title: '排除: HDR', value: ' !HDR ' },
{ title: '效果: SDR', value: ' SDR ' },
{ title: '排除: SDR', value: ' !SDR ' },
{ title: '效果: 3D', value: ' 3D ' },
{ title: '排除: 3D', value: ' !3D ' },
export const storageIconDict = storageAttributes.reduce((dict, item) => {
dict[item.type] = item.icon
return dict
}, {} as Record<string, string>)
export const storageRemoteDict = storageAttributes.reduce((dict, item) => {
dict[item.type] = item.remote
return dict
}, {} as Record<string, boolean>)
export const downloaderOptions = [
{
value: 'qbittorrent',
title: i18n.global.t('setting.system.qbittorrent'),
},
{
value: 'transmission',
title: i18n.global.t('setting.system.transmission'),
},
]
export const storageDict = storageOptions.reduce((dict, item) => {
export const downloaderDict = downloaderOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const transferTypeOptions = [
{ title: '复制', value: 'copy' },
{ title: '移动', value: 'move' },
{ title: '硬链接', value: 'link' },
{ title: '软链接', value: 'softlink' },
export const mediaServerOptions = [
{
value: 'emby',
title: i18n.global.t('setting.system.emby'),
},
{
value: 'jellyfin',
title: i18n.global.t('setting.system.jellyfin'),
},
{
value: 'plex',
title: i18n.global.t('setting.system.plex'),
},
{
value: 'trimemedia',
title: i18n.global.t('setting.system.trimeMedia'),
},
]
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const innerFilterRules = [
{ title: i18n.global.t('filterRules.specSub'), value: ' SPECSUB ' },
{ title: i18n.global.t('filterRules.cnSub'), value: ' CNSUB ' },
{ title: i18n.global.t('filterRules.cnVoi'), value: ' CNVOI ' },
{ title: i18n.global.t('filterRules.gz'), value: ' GZ ' },
{ title: i18n.global.t('filterRules.notCnVoi'), value: ' !CNVOI ' },
{ title: i18n.global.t('filterRules.hkVoi'), value: ' HKVOI ' },
{ title: i18n.global.t('filterRules.notHkVoi'), value: ' !HKVOI ' },
{ title: i18n.global.t('filterRules.free'), value: ' FREE ' },
{ title: i18n.global.t('filterRules.resolution4k'), value: ' 4K ' },
{ title: i18n.global.t('filterRules.resolution1080p'), value: ' 1080P ' },
{ title: i18n.global.t('filterRules.resolution720p'), value: ' 720P ' },
{ title: i18n.global.t('filterRules.not720p'), value: ' !720P ' },
{ title: i18n.global.t('filterRules.qualityBlu'), value: ' BLU ' },
{ title: i18n.global.t('filterRules.notBlu'), value: ' !BLU ' },
{ title: i18n.global.t('filterRules.qualityBluray'), value: ' BLURAY ' },
{ title: i18n.global.t('filterRules.notBluray'), value: ' !BLURAY ' },
{ title: i18n.global.t('filterRules.qualityUhd'), value: ' UHD ' },
{ title: i18n.global.t('filterRules.notUhd'), value: ' !UHD ' },
{ title: i18n.global.t('filterRules.qualityRemux'), value: ' REMUX ' },
{ title: i18n.global.t('filterRules.notRemux'), value: ' !REMUX ' },
{ title: i18n.global.t('filterRules.qualityWebdl'), value: ' WEBDL ' },
{ title: i18n.global.t('filterRules.notWebdl'), value: ' !WEBDL ' },
{ title: i18n.global.t('filterRules.quality60fps'), value: ' 60FPS ' },
{ title: i18n.global.t('filterRules.not60fps'), value: ' !60FPS ' },
{ title: i18n.global.t('filterRules.codecH265'), value: ' H265 ' },
{ title: i18n.global.t('filterRules.notH265'), value: ' !H265 ' },
{ title: i18n.global.t('filterRules.codecH264'), value: ' H264 ' },
{ title: i18n.global.t('filterRules.notH264'), value: ' !H264 ' },
{ title: i18n.global.t('filterRules.effectDolby'), value: ' DOLBY ' },
{ title: i18n.global.t('filterRules.notDolby'), value: ' !DOLBY ' },
{ title: i18n.global.t('filterRules.effectAtmos'), value: ' ATMOS ' },
{ title: i18n.global.t('filterRules.notAtmos'), value: ' !ATMOS ' },
{ title: i18n.global.t('filterRules.effectHdr'), value: ' HDR ' },
{ title: i18n.global.t('filterRules.notHdr'), value: ' !HDR ' },
{ title: i18n.global.t('filterRules.effectSdr'), value: ' SDR ' },
{ title: i18n.global.t('filterRules.notSdr'), value: ' !SDR ' },
{ title: i18n.global.t('filterRules.effect3d'), value: ' 3D ' },
{ title: i18n.global.t('filterRules.not3d'), value: ' !3D ' },
]
export const transferTypeOptions = [
{ title: i18n.global.t('transferType.copy'), value: 'copy' },
{ title: i18n.global.t('transferType.move'), value: 'move' },
{ title: i18n.global.t('transferType.link'), value: 'link' },
{ title: i18n.global.t('transferType.softlink'), value: 'softlink' },
]
export const qualityOptions = ref([
{
title: i18n.global.t('qualityOptions.all'),
value: '',
},
{
title: i18n.global.t('qualityOptions.blurayOriginal'),
value: 'Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD',
},
{
title: i18n.global.t('qualityOptions.remux'),
value: 'Remux',
},
{
title: i18n.global.t('qualityOptions.bluray'),
value: 'Blu-?Ray',
},
{
title: i18n.global.t('qualityOptions.uhd'),
value: 'UHD|UltraHD',
},
{
title: i18n.global.t('qualityOptions.webdl'),
value: 'WEB-?DL|WEB-?RIP',
},
{
title: i18n.global.t('qualityOptions.hdtv'),
value: 'HDTV',
},
{
title: i18n.global.t('qualityOptions.h265'),
value: '[Hx].?265|HEVC',
},
{
title: i18n.global.t('qualityOptions.h264'),
value: '[Hx].?264|AVC',
},
])
// 分辨率选择框数据
export const resolutionOptions = ref([
{
title: i18n.global.t('resolutionOptions.all'),
value: '',
},
{
title: i18n.global.t('resolutionOptions.4k'),
value: '4K|2160p|x2160',
},
{
title: i18n.global.t('resolutionOptions.1080p'),
value: '1080[pi]|x1080',
},
{
title: i18n.global.t('resolutionOptions.720p'),
value: '720[pi]|x720',
},
])
// 特效选择框数据
export const effectOptions = ref([
{
title: i18n.global.t('effectOptions.all'),
value: '',
},
{
title: i18n.global.t('effectOptions.dolbyVision'),
value: 'Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+',
},
{
title: i18n.global.t('effectOptions.dolbyAtmos'),
value: 'Dolby[\\s.]*\\+?Atmos|Atmos',
},
{
title: i18n.global.t('effectOptions.hdr'),
value: '[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+',
},
{
title: i18n.global.t('effectOptions.sdr'),
value: '[\\s.]+SDR[\\s.]+',
},
])
// 媒体类型选项
export const mediaTypeOptions = [
{
title: i18n.global.t('mediaType.movie'),
value: '电影',
},
{
title: i18n.global.t('mediaType.tv'),
value: '电视剧',
},
{
title: i18n.global.t('mediaType.anime'),
value: '动漫',
},
{
title: i18n.global.t('mediaType.collection'),
value: '合集',
},
{
title: i18n.global.t('mediaType.unknown'),
value: '未知',
},
]
// 媒体类型字典
export const mediaTypeDict = mediaTypeOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
// 通知开关选项
export const notificationSwitchOptions = [
{
title: i18n.global.t('notificationSwitch.resourceDownload'),
value: '资源下载',
},
{
title: i18n.global.t('notificationSwitch.organize'),
value: '整理入库',
},
{
title: i18n.global.t('notificationSwitch.subscribe'),
value: '订阅',
},
{
title: i18n.global.t('notificationSwitch.site'),
value: '站点',
},
{
title: i18n.global.t('notificationSwitch.mediaServer'),
value: '媒体服务器',
},
{
title: i18n.global.t('notificationSwitch.manual'),
value: '手动处理',
},
{
title: i18n.global.t('notificationSwitch.plugin'),
value: '插件',
},
{
title: i18n.global.t('notificationSwitch.other'),
value: '其它',
},
]
// 通知开关字典
export const notificationSwitchDict = notificationSwitchOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
// 操作步骤选项
export const actionStepOptions = [
{
title: i18n.global.t('actionStep.addDownload'),
value: '添加下载',
},
{
title: i18n.global.t('actionStep.addSubscribe'),
value: '添加订阅',
},
{
title: i18n.global.t('actionStep.fetchDownloads'),
value: '获取下载任务',
},
{
title: i18n.global.t('actionStep.fetchMedias'),
value: '获取媒体数据',
},
{
title: i18n.global.t('actionStep.fetchRss'),
value: '获取RSS资源',
},
{
title: i18n.global.t('actionStep.fetchTorrents'),
value: '搜索站点资源',
},
{
title: i18n.global.t('actionStep.filterMedias'),
value: '过滤媒体数据',
},
{
title: i18n.global.t('actionStep.filterTorrents'),
value: '过滤资源',
},
{
title: i18n.global.t('actionStep.scanFile'),
value: '扫描目录',
},
{
title: i18n.global.t('actionStep.scrapeFile'),
value: '刮削文件',
},
{
title: i18n.global.t('actionStep.sendEvent'),
value: '发送事件',
},
{
title: i18n.global.t('actionStep.sendMessage'),
value: '发送消息',
},
{
title: i18n.global.t('actionStep.transferFile'),
value: '整理文件',
},
{
title: i18n.global.t('actionStep.invokePlugin'),
value: '调用插件',
},
]
// 操作步骤字典
export const actionStepDict = actionStepOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)

View File

@@ -1,12 +1,27 @@
import axios from 'axios'
import router from '@/router'
import { useAuthStore } from '@/stores'
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
// 创建axios实例
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
})
// 声明全局变量类型
declare global {
interface Window {
MoviePilotAPI: typeof api
}
}
// 将 API 实例暴露到全局,供插件使用
window.MoviePilotAPI = api
// 初始化请求优化器(必须在其他拦截器之前)
initializeRequestOptimizer(api)
// 添加请求拦截器
api.interceptors.request.use(config => {
// 认证 Store
@@ -18,15 +33,45 @@ api.interceptors.request.use(config => {
return config
})
// 离线状态管理
const globalOfflineStatus = useGlobalOfflineStatus()
// 添加响应拦截器
api.interceptors.response.use(
response => {
// 成功响应时,清除应用离线状态
globalOfflineStatus.setAppOffline(false)
return response.data
},
error => {
if (!error.response) {
// 请求超时
return Promise.reject(new Error(error))
// 网络错误或请求超时 - 通知离线状态管理系统
const isNetworkError =
error.code === 'NETWORK_ERROR' ||
error.code === 'ERR_NETWORK' ||
error.code === 'ECONNABORTED' ||
error.name === 'NetworkError'
if (isNetworkError) {
let reason = 'Network connection failed'
if (error.code === 'ECONNABORTED') {
reason = 'Request timeout'
}
globalOfflineStatus.setAppOffline(true, reason)
}
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
// 网络连接问题
return Promise.reject(new Error('Network connection failed, please check your network status'))
} else if (error.code === 'ECONNABORTED') {
// 请求超时
return Promise.reject(new Error('Request timeout, please try again later'))
} else if (error.name === 'AbortError') {
// 请求被中止(路由切换等)
return Promise.reject(new Error('Request cancelled'))
}
// 其他网络错误
return Promise.reject(new Error(error.message || 'Network error'))
} else if (error.response.status === 403) {
// 认证 Store
const authStore = useAuthStore()
@@ -41,13 +86,3 @@ api.interceptors.response.use(
)
export default api
export async function fetchGlobalSettings() {
try {
const result: { [key: string]: any } = await api.get('system/global')
return result.data || {}
} catch (error) {
console.error('Failed to fetch global settings', error)
throw error
}
}

View File

@@ -565,9 +565,9 @@ export interface NotExistMediaInfo {
// 插件
export interface Plugin {
id?: string
id: string
// 插件名称
plugin_name?: string
plugin_name: string
// 插件描述
plugin_desc?: string
// 插件图标
@@ -631,6 +631,8 @@ export interface DashboardItem {
cols: { [key: string]: number }
// 页面元素
elements: RenderProps[]
// 渲染方式
render_mode?: string
}
// 种子信息
@@ -767,6 +769,8 @@ export interface MetaInfo {
audio_term: string
// 资源类型+特效
edition: string
// 流媒体平台
web_source: string
// 应用的自定义识别词
apply_words: string[]
}
@@ -1006,6 +1010,8 @@ export interface SystemNotification {
text: string
// 通知时间
date: string
// 是否已读
read?: boolean
}
// 下载器配置
@@ -1303,3 +1309,49 @@ export interface Workflow {
// 最后执行时间
last_time?: string
}
// 种子缓存项
export interface TorrentCacheItem {
// 种子hash用于操作标识
hash: string
// 站点域名
domain: string
// 种子标题
title: string
// 种子描述
description?: string
// 种子大小
size: number
// 发布时间
pubdate?: string
// 站点名称
site_name?: string
// 识别的媒体名称
media_name?: string
// 识别的媒体年份
media_year?: string
// 识别的媒体类型
media_type?: string
// 季集信息
season_episode?: string
// 资源信息
resource_term?: string
// 种子链接
enclosure?: string
// 详情页面
page_url?: string
// 海报图片
poster_path?: string
// 背景图片
backdrop_path?: string
}
// 种子缓存数据
export interface TorrentCacheData {
// 缓存数量
count: number
// 站点数量
sites: number
// 缓存数据
data: TorrentCacheItem[]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -3,8 +3,9 @@ import FileList from './filebrowser/FileList.vue'
import FileToolbar from './filebrowser/FileToolbar.vue'
import FileNavigator from './filebrowser/FileNavigator.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { storageOptions } from '@/api/constants'
import { useDisplay } from 'vuetify'
import { storageIconDict } from '@/api/constants'
import { usePWA } from '@/composables/usePWA'
// 输入参数
const props = defineProps({
@@ -33,7 +34,8 @@ const emit = defineEmits(['pathchanged'])
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
// PWA模式检测
const { appMode } = usePWA()
const fileIcons = {
// 压缩包
@@ -136,10 +138,19 @@ const sort = ref('name')
// 是否显示目录树
const showDirTree = ref(false)
// 拖动分隔条相关
const navigatorWidth = ref(280) // 初始宽度
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartWidth = ref(0)
// 计算属性
const storagesArray = computed(() => {
const storageCodes = props.storages?.map(item => item.type)
return storageOptions.filter(item => storageCodes?.includes(item.value))
return props.storages?.map(item => ({
title: item.name,
value: item.type,
icon: storageIconDict[item.type] ?? 'mdi-server-network-outline',
}))
})
// 方法
@@ -178,16 +189,68 @@ function fileListUpdated(items: FileItem[]) {
fileListItems.value = items
}
// 阻止选择事件
function preventSelect(event: Event) {
event.preventDefault()
return false
}
// 拖动分隔条相关方法
function startDrag(event: MouseEvent) {
event.preventDefault() // 阻止默认行为
event.stopPropagation() // 阻止事件冒泡
isDragging.value = true
dragStartX.value = event.clientX
dragStartWidth.value = navigatorWidth.value
document.addEventListener('mousemove', handleDrag, { passive: false })
document.addEventListener('mouseup', stopDrag, { passive: false })
document.addEventListener('selectstart', preventSelect) // 阻止选择开始
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
;(document.body.style as any).webkitUserSelect = 'none' // Safari兼容
;(document.body.style as any).mozUserSelect = 'none' // Firefox兼容
}
function handleDrag(event: MouseEvent) {
if (!isDragging.value) return
event.preventDefault() // 阻止默认行为
const deltaX = event.clientX - dragStartX.value
const newWidth = dragStartWidth.value + deltaX
// 设置最小和最大宽度限制
const minWidth = 200
const maxWidth = window.innerWidth * 0.6
navigatorWidth.value = Math.max(minWidth, Math.min(maxWidth, newWidth))
}
function stopDrag() {
isDragging.value = false
document.removeEventListener('mousemove', handleDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('selectstart', preventSelect)
document.body.style.cursor = ''
document.body.style.userSelect = ''
;(document.body.style as any).webkitUserSelect = ''
;(document.body.style as any).mozUserSelect = ''
}
// 外层DIV大小控制
const scrollStyle = computed(() => {
return appMode
? 'height: calc(100vh - 10.5rem - env(safe-area-inset-bottom) - 6.5rem)'
return appMode.value
? 'height: calc(100vh - 10.5rem - env(safe-area-inset-bottom) - 7rem)'
: 'height: calc(100vh - 10.5rem - env(safe-area-inset-bottom)'
})
// 文件列表大小限制
const fileListStyle = computed(() => {
return appMode
return appMode.value
? 'height: calc(100vh - 14rem - env(safe-area-inset-bottom) - 7rem)'
: 'height: calc(100vh - 14rem - env(safe-area-inset-bottom)'
})
@@ -216,8 +279,14 @@ const fileListStyle = computed(() => {
:items="fileListItems"
:endpoints="endpoints"
:axios="axios"
:style="{ width: `${navigatorWidth}px`, minWidth: `${navigatorWidth}px` }"
@navigate="pathChanged"
/>
<!-- 拖动分隔条 -->
<div v-if="showDirTree" class="divider" :class="{ 'divider-dragging': isDragging }" @mousedown="startDrag">
<div class="divider-line"></div>
<VIcon class="divider-icon" size="small">mdi-drag-vertical</VIcon>
</div>
<FileList
:item="item"
:storage="activeStorage"
@@ -228,6 +297,7 @@ const fileListStyle = computed(() => {
:sort="sort"
:listStyle="fileListStyle"
:showTree="showDirTree"
:style="{ flex: 1 }"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@@ -240,3 +310,64 @@ const fileListStyle = computed(() => {
</div>
</div>
</template>
<style scoped>
.divider {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
cursor: col-resize;
inline-size: 4px;
transition: background-color 0.2s ease;
user-select: none;
}
.divider:hover {
background-color: rgba(var(--v-theme-on-surface), 0.08);
}
.divider-dragging {
background-color: rgba(var(--v-theme-primary), 0.12) !important;
}
.divider-line {
background-color: rgba(var(--v-theme-outline), 0.3);
block-size: 100%;
inline-size: 1px;
transition: background-color 0.2s ease;
user-select: none;
}
.divider-dragging .divider-line {
background-color: rgb(var(--v-theme-primary)) !important;
}
.divider:hover .divider-line {
background-color: rgba(var(--v-theme-primary), 0.8);
}
.divider-icon {
position: absolute;
z-index: 1;
padding: 2px;
border-radius: 2px;
background-color: rgba(var(--v-theme-surface), 0.9);
color: rgba(var(--v-theme-on-surface-variant), 0.6);
opacity: 0;
pointer-events: none;
transition: all 0.2s ease;
}
.divider-dragging .divider-icon {
background-color: rgba(var(--v-theme-surface), 0.95);
color: rgb(var(--v-theme-primary));
opacity: 1;
}
.divider:hover .divider-icon {
color: rgba(var(--v-theme-primary), 0.9);
opacity: 1;
}
</style>

View File

@@ -1,4 +1,10 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import page404 from '@images/pages/404.svg'
// 国际化
const { t } = useI18n()
const props = defineProps<Props>()
interface Props {
@@ -14,26 +20,17 @@ interface Props {
<div class="no-data-container">
<!-- 图标容器 -->
<div class="icon-wrapper">
<div class="icon-glow"></div>
<div class="icon-container">
<VIcon
:icon="props.icon || 'mdi-file-search-outline'"
:color="props.iconColor || 'white'"
size="48"
class="main-icon"
/>
</div>
<div class="pulse-ring"></div>
<img :src="page404" alt="404" />
</div>
<!-- 标题 -->
<div class="error-title">
{{ props.errorTitle || '暂无数据' }}
{{ props.errorTitle || t('common.noData') }}
</div>
<!-- 描述 -->
<div class="error-description">
{{ props.errorDescription || '没有找到相关内容' }}
{{ props.errorDescription || t('common.noContent') }}
</div>
<!-- 按钮插槽 -->
@@ -52,8 +49,7 @@ interface Props {
justify-content: center;
inline-size: 100%;
min-block-size: 300px;
padding-block: 3rem;
padding-inline: 1rem;
padding-block-start: 3rem;
text-align: center;
}
@@ -63,109 +59,17 @@ interface Props {
display: flex;
align-items: center;
justify-content: center;
block-size: 100px;
inline-size: 100px;
margin-block: 0 2rem;
inline-size: 15rem;
margin-block: 0 1rem;
margin-inline: auto;
}
.icon-glow {
position: absolute;
border-radius: 50%;
animation: pulse 3s infinite ease-in-out;
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.8) 0%, rgba(var(--v-theme-primary), 0) 70%);
block-size: 80px;
filter: blur(15px);
inline-size: 80px;
opacity: 0.8;
}
.icon-container {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.9), rgba(var(--v-theme-secondary), 0.8));
block-size: 80px;
inline-size: 80px;
}
.main-icon {
animation: slight-bounce 3s infinite ease-in-out;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 30%));
}
.pulse-ring {
position: absolute;
z-index: 1;
border: 2px solid rgba(var(--v-theme-primary), 0.5);
border-radius: 50%;
animation: ripple 2s infinite ease-out;
block-size: 100px;
inline-size: 100px;
inset-block-start: 50%;
inset-inline-start: 50%;
opacity: 0;
transform: translate(-50%, -50%);
}
.pulse-ring::before {
position: absolute;
border: 2px solid rgba(var(--v-theme-primary), 0.3);
border-radius: 50%;
animation: ripple 2s infinite 0.5s ease-out;
block-size: 85px;
content: '';
inline-size: 85px;
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
}
@keyframes ripple {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(0.9);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.5);
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.5;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.1);
}
}
@keyframes slight-bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
/* 文字样式 */
.error-title {
position: relative;
color: rgba(var(--v-theme-on-surface), 0.95);
font-size: 1.75rem;
font-weight: 700;
font-size: 1.5rem;
font-weight: 500;
margin-block-end: 0.75rem;
text-shadow: 0 1px 2px rgba(0, 0, 0, 5%);
}
@@ -176,69 +80,15 @@ interface Props {
background: linear-gradient(90deg, rgba(var(--v-theme-primary), 0.8), rgba(var(--v-theme-primary), 0.2));
block-size: 3px;
content: '';
inline-size: 40px;
margin-block: 0.5rem 0;
inline-size: 60px;
margin-inline: auto;
}
.error-description {
color: rgba(var(--v-theme-on-surface), 0.75);
font-size: 1.1rem;
line-height: 1.6;
margin-block-end: 1.5rem;
font-size: 1rem;
margin-block-end: 1rem;
margin-inline: auto;
max-inline-size: 80%;
}
.actions-container {
margin-block-start: 1.5rem;
}
.actions-container :deep(.v-btn) {
transform: translateY(0);
transition: transform 0.2s ease;
}
.actions-container :deep(.v-btn:hover) {
transform: translateY(-2px);
}
/* 响应式调整 */
@media (width <= 600px) {
.no-data-container {
padding-block: 2rem;
padding-inline: 1rem;
}
.icon-wrapper {
block-size: 80px;
inline-size: 80px;
margin-block-end: 1.5rem;
}
.icon-container {
block-size: 70px;
inline-size: 70px;
}
.icon-glow {
block-size: 70px;
inline-size: 70px;
}
.pulse-ring,
.pulse-ring::before {
block-size: 80px;
inline-size: 80px;
}
.error-title {
font-size: 1.4rem;
}
.error-description {
font-size: 0.95rem;
max-inline-size: 90%;
}
}
</style>

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
@@ -29,7 +28,7 @@ const getImgUrl = computed(() => {
</script>
<template>
<VHover v-bind="props">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"

View File

@@ -1,9 +1,14 @@
<script lang="ts" setup>
import { CustomRule } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import filter_svg from '@images/svg/filter.svg'
import { cloneDeep } from 'lodash-es'
import { innerFilterRules } from '@/api/constants'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
@@ -21,6 +26,7 @@ const props = defineProps({
// 提示框
const $toast = useToast()
const { t } = useI18n()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
@@ -51,28 +57,28 @@ function saveRuleInfo() {
// 有空值
if (!ruleInfo.value.id || !ruleInfo.value.name) {
if (!ruleInfo.value.id && !ruleInfo.value.name) {
$toast.error('规则ID和规则名称不能为空')
$toast.error(t('customRule.error.emptyIdName'))
}
return
}
// 检查ID是否在内置的规则中
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
$toast.error('当前规则ID已被内置规则占用')
$toast.error(t('customRule.error.idOccupied'))
return
}
// 检查规则名称是否在内置的规则中
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
$toast.error('当前规则名称已被内置规则占用')
$toast.error(t('customRule.error.nameOccupied'))
return
}
// ID已存在
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
$toast.error(`规则ID【${ruleInfo.value.id}】已存在`)
$toast.error(t('customRule.error.idExists', { id: ruleInfo.value.id }))
return
}
// 规则名称已存在
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
$toast.error(`规则名称【${ruleInfo.value.name}】已存在`)
$toast.error(t('customRule.error.nameExists', { name: ruleInfo.value.name }))
return
}
// 保存数据
@@ -104,8 +110,20 @@ function onClose() {
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="ruleInfoDialog" v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.rule.id} - 配置`" class="rounded-t">
<VDialog
v-if="ruleInfoDialog"
v-model="ruleInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-filter-outline" class="me-2" />
</template>
<VCardTitle>{{ t('customRule.title', { id: props.rule.id }) }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />
<VCardText>
@@ -114,78 +132,87 @@ function onClose() {
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.id"
label="规则ID"
placeholder="必填不可与其他规则ID重名"
hint="字符与数字组合,不能含空格"
:label="t('customRule.field.ruleId')"
:placeholder="t('customRule.placeholder.ruleId')"
:hint="t('customRule.hint.ruleId')"
persistent-hint
active
prepend-inner-icon="mdi-identifier"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.name"
label="规则名称"
placeholder="必填;不可与其他规则名称重名"
hint="使用别名便于区分规则"
:label="t('customRule.field.ruleName')"
:placeholder="t('customRule.placeholder.ruleName')"
:hint="t('customRule.hint.ruleName')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.include"
placeholder="关键字/正则表达式"
label="包含"
hint="必须包含的关键字或正则表达式,多个值使用|分隔"
:label="t('customRule.field.include')"
:placeholder="t('customRule.placeholder.include')"
:hint="t('customRule.hint.include')"
persistent-hint
active
prepend-inner-icon="mdi-plus-circle"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.exclude"
placeholder="关键字/正则表达式"
label="排除"
hint="不能包含的关键字或正则表达式,多个值使用|分隔"
:label="t('customRule.field.exclude')"
:placeholder="t('customRule.placeholder.exclude')"
:hint="t('customRule.hint.exclude')"
persistent-hint
active
prepend-inner-icon="mdi-minus-circle"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.size_range"
placeholder="0/1-10"
label="资源体积MB"
hint="最小资源文件体积或体积范围(剧集计算单集平均大小)"
:label="t('customRule.field.sizeRange')"
:placeholder="t('customRule.placeholder.sizeRange')"
:hint="t('customRule.hint.sizeRange')"
persistent-hint
active
prepend-inner-icon="mdi-harddisk"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.seeders"
placeholder="0/1-10"
label="做种人数"
hint="最小做种人数或做种人数范围"
:label="t('customRule.field.seeders')"
:placeholder="t('customRule.placeholder.seeders')"
:hint="t('customRule.hint.seeders')"
persistent-hint
active
prepend-inner-icon="mdi-account-group"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.publish_time"
placeholder="0/1-10"
label="发布时间(分钟)"
hint="距离资源发布的最小时间间隔或时间区间"
:label="t('customRule.field.publishTime')"
:placeholder="t('customRule.placeholder.publishTime')"
:hint="t('customRule.hint.publishTime')"
persistent-hint
active
prepend-inner-icon="mdi-calendar-clock"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveRuleInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 确定 </VBtn>
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">{{
t('customRule.action.confirm')
}}</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -1,8 +1,12 @@
<script lang="ts" setup>
import type { TransferDirectoryConf } from '@/api/types'
import type { StorageConf, TransferDirectoryConf } from '@/api/types'
import api from '@/api'
import { nextTick } from 'vue'
import { storageOptions } from '@/api/constants'
import { useI18n } from 'vue-i18n'
import { storageRemoteDict } from '@/api/constants'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -15,6 +19,10 @@ const props = defineProps({
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
storages: {
type: Array as PropType<StorageConf[]>,
required: true,
},
width: String,
height: String,
})
@@ -23,30 +31,43 @@ const props = defineProps({
const isCollapsed = ref(true)
// 类型下拉字典
const typeItems = [
{ title: '全部', value: '' },
{ title: '电影', value: '电影' },
{ title: '电视剧', value: '电视剧' },
]
const typeItems = computed(() => [
{ title: t('common.all'), value: '' },
{ title: t('mediaType.movie'), value: '电影' },
{ title: t('mediaType.tv'), value: '电视剧' },
])
// 计算资源存储字典(整理方式为下载器时不能为远程存储)
const resourceStorageOptions = computed(() => {
return storageOptions.filter(item => !item.remote || props.directory.monitor_type !== 'downloader')
return props.storages
.filter(item => !storageRemoteDict[item.type] || props.directory.monitor_type !== 'downloader')
.map(item => ({
title: item.name,
value: item.type,
}))
})
// 存储字典
const libraryStorageOptions = computed(() => {
return props.storages.map(item => ({
title: item.name,
value: item.type,
}))
})
// 自动整理方式下拉字典
const transferSourceItems = [
{ title: '不整理', value: '' },
{ title: '下载器监控', value: 'downloader' },
{ title: '目录监控', value: 'monitor' },
{ title: '手动整理', value: 'manual' },
]
const transferSourceItems = computed(() => [
{ title: t('directory.noTransfer'), value: '' },
{ title: t('directory.downloaderMonitor'), value: 'downloader' },
{ title: t('directory.directoryMonitor'), value: 'monitor' },
{ title: t('directory.manualTransfer'), value: 'manual' },
])
// 监控模式下拉字典
const MonitorModeItems = [
{ title: '性能模式', value: 'fast' },
{ title: '兼容模式', value: 'compatibility' },
]
const MonitorModeItems = computed(() => [
{ title: t('directory.performanceMode'), value: 'fast' },
{ title: t('directory.compatibilityMode'), value: 'compatibility' },
])
// 整理方式下拉字典
const transferTypeItems = ref<{ title: string; value: string }[]>([])
@@ -103,23 +124,23 @@ async function loadTransferTypeItems() {
// 整理方式无数据提示
const computedNoDataText = computed(() => {
if (!props.directory.library_storage && !props.directory.storage) {
return '请选择储存'
return t('directory.pleaseSelectStorage')
} else if (!props.directory.library_storage) {
return '请选择媒体库储存'
return t('directory.pleaseSelectLibraryStorage')
} else if (!props.directory.storage) {
return '请选择下载器储存'
return t('directory.pleaseSelectDownloadStorage')
} else {
return '选择的存储类型没有支持的整理方式'
return t('directory.noSupportedTransferType')
}
})
// 覆盖模式下拉字典
const overwriteModeItems = [
{ title: '从不', value: 'never' },
{ title: '总是', value: 'always' },
{ title: '按文件大小', value: 'size' },
{ title: '仅保留最新版本', value: 'latest' },
]
const overwriteModeItems = computed(() => [
{ title: t('directory.never'), value: 'never' },
{ title: t('directory.always'), value: 'always' },
{ title: t('directory.byFileSize'), value: 'size' },
{ title: t('directory.keepLatestOnly'), value: 'latest' },
])
// 定义触发的自定义事件
const emit = defineEmits(['close', 'changed', 'update:modelValue'])
@@ -131,7 +152,7 @@ function onClose() {
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: '全部', value: '' }]
const default_value = [{ title: t('common.all'), value: '' }]
if (!props.categories || !props.categories[props.directory?.media_type ?? '']) return default_value
return default_value.concat(props.categories[props.directory.media_type ?? ''])
})
@@ -180,7 +201,7 @@ watch(
<VTextField
v-model="props.directory.name"
variant="underlined"
label="别名"
:label="t('directory.alias')"
class="me-20 text-high-emphasis font-weight-bold"
/>
<span class="absolute top-3 right-12">
@@ -193,28 +214,28 @@ watch(
<VForm>
<VRow>
<VCol cols="6">
<VSelect
<VAutocomplete
v-model="props.directory.media_type"
variant="underlined"
:items="typeItems"
label="媒体类型"
:label="t('directory.mediaType')"
@update:modelValue="props.directory.media_category = ''"
/>
</VCol>
<VCol cols="6">
<VSelect
<VAutocomplete
v-model="props.directory.media_category"
variant="underlined"
:items="getCategories"
label="媒体类别"
:label="t('directory.mediaCategory')"
/>
</VCol>
<VCol cols="4">
<VSelect
<VAutocomplete
v-model="props.directory.storage"
variant="underlined"
:items="resourceStorageOptions"
label="资源存储"
:label="t('directory.resourceStorage')"
/>
</VCol>
<VCol cols="8">
@@ -222,14 +243,17 @@ watch(
v-model="props.directory.download_path"
:storage="props.directory.storage"
variant="underlined"
label="资源目录"
:label="t('directory.resourceDirectory')"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
<VSwitch v-model="props.directory.download_type_folder" label="按类型分类"></VSwitch>
<VSwitch v-model="props.directory.download_type_folder" :label="t('directory.sortByType')"></VSwitch>
</VCol>
<VCol cols="6" v-if="!props.directory.media_category || props.directory.media_category === ''">
<VSwitch v-model="props.directory.download_category_folder" label="按类别分类"></VSwitch>
<VSwitch
v-model="props.directory.download_category_folder"
:label="t('directory.sortByCategory')"
></VSwitch>
</VCol>
</VRow>
<VDivider v-if="$props.directory.monitor_type" class="my-3 bg-primary" />
@@ -239,7 +263,7 @@ watch(
v-model="props.directory.monitor_type"
variant="underlined"
:items="transferSourceItems"
label="自动整理"
:label="t('directory.autoTransfer')"
/>
</VCol>
</VRow>
@@ -249,15 +273,15 @@ watch(
v-model="props.directory.monitor_mode"
variant="underlined"
:items="MonitorModeItems"
label="监控模式"
:label="t('directory.monitorMode')"
/>
</VCol>
<VCol cols="4">
<VSelect
<VAutocomplete
v-model="props.directory.library_storage"
variant="underlined"
:items="storageOptions"
label="媒体库存储"
:items="libraryStorageOptions"
:label="t('directory.libraryStorage')"
/>
</VCol>
<VCol cols="8">
@@ -265,7 +289,7 @@ watch(
v-model="props.directory.library_path"
:storage="props.directory.library_storage"
variant="underlined"
label="媒体库目录"
:label="t('directory.libraryDirectory')"
/>
</VCol>
<VCol cols="4">
@@ -273,7 +297,7 @@ watch(
v-model="props.directory.transfer_type"
variant="underlined"
:items="transferTypeItems"
label="整理方式"
:label="t('directory.transferType')"
:no-data-text="computedNoDataText"
/>
</VCol>
@@ -282,23 +306,23 @@ watch(
v-model="props.directory.overwrite_mode"
variant="underlined"
:items="overwriteModeItems"
label="覆盖模式"
:label="t('directory.overwriteMode')"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
<VSwitch v-model="props.directory.library_type_folder" label="按类型分类"></VSwitch>
<VSwitch v-model="props.directory.library_type_folder" :label="t('directory.sortByType')"></VSwitch>
</VCol>
<VCol cols="6" v-if="!props.directory.media_category || props.directory.media_category === ''">
<VSwitch v-model="props.directory.library_category_folder" label="按类别分类"></VSwitch>
<VSwitch v-model="props.directory.library_category_folder" :label="t('directory.sortByCategory')"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.renaming" label="智能重命名"></VSwitch>
<VSwitch v-model="props.directory.renaming" :label="t('directory.smartRename')"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.scraping" label="刮削元数据"></VSwitch>
<VSwitch v-model="props.directory.scraping" :label="t('directory.scrapingMetadata')"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.notify" label="发送通知"></VSwitch>
<VSwitch v-model="props.directory.notify" :label="t('directory.sendNotification')"></VSwitch>
</VCol>
</VRow>
</VForm>

View File

@@ -2,11 +2,21 @@
import api from '@/api'
import { formatFileSize } from '@/@core/utils/formatters'
import { DownloaderConf } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import type { DownloaderInfo } from '@/api/types'
import qbittorrent_image from '@images/logos/qbittorrent.png'
import transmission_image from '@images/logos/transmission.png'
import custom_image from '@images/logos/downloader.png'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { downloaderDict } from '@/api/constants'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 获取i18n实例
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -91,12 +101,12 @@ function openDownloaderInfoDialog() {
function saveDownloaderInfo() {
// 为空不保存,跳出警告框
if (!downloaderInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
$toast.error(t('downloader.nameRequired'))
return
}
// 重名判断
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
$toast.error(`${downloaderInfo.value.name}】已存在,请替换为其他名称`)
$toast.error(t('downloader.nameDuplicate'))
return
}
// 默认下载器去重
@@ -104,7 +114,7 @@ function saveDownloaderInfo() {
props.downloaders.forEach(item => {
if (item.default && item !== props.downloader) {
item.default = false
$toast.info(`存在默认下载器【${item.name}】,已替换成【${downloaderInfo.value.name}`)
$toast.info(t('downloader.defaultChanged'))
}
})
}
@@ -122,7 +132,7 @@ const getIcon = computed(() => {
case 'transmission':
return transmission_image
default:
return qbittorrent_image
return custom_image
}
})
@@ -168,76 +178,101 @@ onUnmounted(() => {
/>
<span class="text-h6">{{ downloader.name }}</span>
</div>
<div class="mt-1 flex flex-wrap text-sm" v-if="props.downloader.enabled">
<div v-if="downloaderDict[downloader.type] && props.downloader.enabled" class="mt-1 flex flex-wrap text-sm">
<span class="me-2">{{ `${formatFileSize(upload_rate, 1)}/s ` }}</span>
<span>{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
</div>
<div v-else-if="!downloaderDict[downloader.type]" class="mt-1 flex flex-wrap text-sm">
<span class="me-2">自定义下载器</span>
</div>
</div>
<div class="h-20">
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
</div>
</VCardText>
</VCard>
</VHover>
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.downloader.name} - 配置`" class="rounded-t">
<VDialog
v-if="downloaderInfoDialog"
v-model="downloaderInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-download" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.downloader.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.enabled" label="启用下载器" />
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
</VCol>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.default" label="默认下载器" :disabled="!downloaderInfo.enabled" />
<VSwitch
v-model="downloaderInfo.default"
:label="t('downloader.default')"
:disabled="!downloaderInfo.enabled"
/>
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="下载器的别名"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
label="地址"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
label="用户名"
hint="登录使用的用户名"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
label="密码"
hint="登录使用的密码"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.category"
label="自动分类管理"
hint="由下载器自动管理分类和下载目录"
:label="t('downloader.category')"
:hint="t('downloader.category')"
persistent-hint
active
/>
@@ -245,8 +280,8 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.sequentail"
label="顺序下载"
hint="按顺序依次下载文件"
:label="t('downloader.sequentail')"
:hint="t('downloader.sequentail')"
persistent-hint
active
/>
@@ -254,8 +289,8 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.force_resume"
label="强制继续"
hint="强制继续、强制上传模式"
:label="t('downloader.force_resume')"
:hint="t('downloader.force_resume')"
persistent-hint
active
/>
@@ -263,59 +298,85 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.first_last_piece"
label="优先首尾文件"
hint="优先下载首尾文件块"
:label="t('downloader.first_last_piece')"
:hint="t('downloader.first_last_piece')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'transmission'">
<VRow v-else-if="downloaderInfo.type == 'transmission'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="下载器的别名"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
label="地址"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
label="用户名"
hint="登录使用的用户名"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
label="密码"
hint="登录使用的密码"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.type"
:label="t('downloader.type')"
:hint="t('downloader.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:hint="t('downloader.nameRequired')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveDownloaderInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -6,6 +6,7 @@ import { formatFileSize } from '@/@core/utils/formatters'
// 输入参数
const props = defineProps({
info: Object as PropType<DownloadingInfo>,
downloaderName: String,
})
// 是否显示卡片
@@ -51,7 +52,11 @@ function getTextClass() {
async function toggleDownload() {
const operation = isDownloading.value ? 'stop' : 'start'
try {
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`)
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`, {
params: {
name: props.downloaderName
}
})
if (result.success) isDownloading.value = !isDownloading.value
} catch (error) {
@@ -62,7 +67,7 @@ async function toggleDownload() {
// 删除下截
async function deleteDownload() {
try {
await api.delete(`download/${props.info?.hash}`)
await api.delete(`download/${props.info?.hash}`, {params: {name: props.downloaderName}})
cardState.value = false
} catch (error) {
console.error(error)

View File

@@ -2,6 +2,10 @@
import { innerFilterRules } from '@/api/constants'
import { CustomRule } from '@/api/types'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
// 获取i18n实例
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -49,15 +53,15 @@ onMounted(() => {
</span>
<VDialogCloseBtn @click="onClose" />
<VCardItem>
<VCardTitle>优先级 {{ props.pri }}</VCardTitle>
<VCardTitle>{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
<VRow>
<VCol>
<VSelect
<VAutocomplete
v-model="props.rules"
variant="underlined"
:items="selectFilterOptions"
chips
label=""
:label="t('filterRule.rules')"
multiple
clearable
@update:modelValue="filtersChanged"

View File

@@ -3,10 +3,18 @@ import draggable from 'vuedraggable'
import { copyToClipboard } from '@/@core/utils/navigator'
import { CustomRule, FilterRuleGroup } from '@/api/types'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
import filter_group_svg from '@images/svg/filter-group.svg'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 获取i18n实例
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -56,14 +64,14 @@ const groupInfo = ref<FilterRuleGroup>({
// 媒体类型字典
const mediaTypeItems = [
{ title: '通用', value: '' },
{ title: '电影', value: '电影' },
{ title: '电视剧', value: '电视剧' },
{ title: t('common.all'), value: '' },
{ title: t('mediaType.movie'), value: '电影' },
{ title: t('mediaType.tv'), value: '电视剧' },
]
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: '全部', value: '' }]
const default_value = [{ title: t('common.all'), value: '' }]
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
return default_value
}
@@ -72,11 +80,6 @@ const getCategories = computed(() => {
// 规则组规则卡片列表
const filterRuleCards = ref<FilterCard[]>([])
// 规则组类型,仅用于导入判断
const filterRuleCardsType = ref<FilterCard>({
pri: '',
rules: [],
})
// 导入代码弹窗
const importCodeDialog = ref(false)
@@ -112,10 +115,10 @@ async function shareRules() {
try {
let success
success = copyToClipboard(value)
if (await success) $toast.success('优先级规则已复制到剪贴板!')
else $toast.error('优先级规则复制失败:可能是浏览器不支持或被用户阻止!')
if (await success) $toast.success(t('filterRule.shareSuccess'))
else $toast.error(t('filterRule.shareFailed'))
} catch (error) {
$toast.error('优先级规则复制失败!')
$toast.error(t('filterRule.shareFailed'))
console.error(error)
}
}
@@ -143,7 +146,7 @@ function saveCodeString(type: string, code: any) {
}))
}
} catch (error) {
$toast.error('导入失败!')
$toast.error(t('filterRule.importFailed'))
console.error(error)
}
}
@@ -177,11 +180,11 @@ function opengroupInfoDialog() {
// 保存详情数据
function saveGroupInfo() {
if (!groupInfo.value.name.trim()) {
$toast.error('规则组名称不能为空')
$toast.error(t('filterRule.nameRequired'))
return
}
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
$toast.error(`规则组名称【${groupInfo.value.name}】已存在,请替换`)
$toast.error(t('filterRule.nameDuplicate'))
return
}
@@ -213,15 +216,21 @@ function onClose() {
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
<div class="text-body-1 mb-3">
<span v-if="!props.group.category">{{ props.group.media_type || '通用' }}</span>
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
<span v-else>{{ props.group.category }}</span>
</div>
</div>
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="groupInfoDialog" v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
<VCard :title="`${props.group.name} - 配置`" class="rounded-t">
<VDialog
v-if="groupInfoDialog"
v-model="groupInfoDialog"
scrollable
max-width="80rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`">
<VDialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardItem class="pt-1">
@@ -229,31 +238,34 @@ function onClose() {
<VCol cols="12" md="6">
<VTextField
v-model="groupInfo.name"
label="规则组名称"
placeholder="必填;不可与其他规则组重名"
hint="自定义规则组名称"
:label="t('filterRule.groupName')"
:placeholder="t('filterRule.nameRequired')"
:hint="t('filterRule.groupName')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
<VAutocomplete
v-model="groupInfo.media_type"
label="适用媒体类型"
:label="t('filterRule.mediaType')"
:items="mediaTypeItems"
hint="选择规则组适用的媒体类型"
:hint="t('filterRule.mediaType')"
persistent-hint
active
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
<VAutocomplete
v-model="groupInfo.category"
:items="getCategories"
label="适用媒体类别"
hint="选择规则组适用的媒体类别"
:label="t('filterRule.category')"
:hint="t('filterRule.category')"
persistent-hint
active
prepend-inner-icon="mdi-folder-open"
/>
</VCol>
</VRow>
@@ -278,27 +290,29 @@ function onClose() {
/>
</template>
</draggable>
<div class="text-center" v-if="filterRuleCards.length == 0">请添加或导入规则</div>
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn color="primary" variant="tonal" @click="addFilterCard">
<VBtn color="primary" @click="addFilterCard">
<VIcon icon="mdi-plus" />
</VBtn>
<VBtn color="success" variant="tonal" @click="importRules('priority')">
<VBtn color="success" @click="importRules('priority')">
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" variant="tonal" @click="shareRules">
<VBtn color="info" @click="shareRules">
<VIcon icon="mdi-share" />
</VBtn>
<VSpacer />
<VBtn @click="saveGroupInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 确定 </VBtn>
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"
title="导入规则优先级"
:title="t('filterRule.import')"
:dataType="importCodeType"
@close="importCodeDialog = false"
@save="saveCodeString"

View File

@@ -151,7 +151,7 @@ onMounted(async () => {
</script>
<template>
<VHover v-bind="props" :height="props.height" :width="props.width">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"

View File

@@ -4,15 +4,21 @@ import tmdbImage from '@images/logos/tmdb.png'
import doubanImage from '@images/logos/douban-black.png'
import bangumiImage from '@images/logos/bangumi.png'
import api from '@/api'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { formatSeason, formatRating } from '@/@core/utils/formatters'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
import router, { registerAbortController } from '@/router'
import { useUserStore } from '@/stores'
import router from '@/router'
import { useUserStore, useGlobalSettingsStore } from '@/stores'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
import { useI18n } from 'vue-i18n'
import { mediaTypeDict } from '@/api/constants'
import { hasPermission } from '@/utils/permission'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -22,7 +28,9 @@ const props = defineProps({
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 用户 Store
const userStore = useUserStore()
@@ -180,11 +188,11 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
function showSubscribeAddToast(result: boolean, title: string, season: number, message: string, best_version: number) {
if (season) title = `${title} ${formatSeason(season.toString())}`
let subname = '订阅'
if (best_version > 0) subname = '洗版订阅'
let subname = t('subscribe.normalSub')
if (best_version > 0) subname = t('subscribe.versionSub')
if (result) $toast.success(`${title} 添加${subname}成功!`)
else if (!result) $toast.error(`${title} 添加${subname}失败:${message}`)
if (result) $toast.success(`${title} ${t('subscribe.addSuccess', { name: subname })}`)
else if (!result) $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message: message })}`)
}
// 调用API取消订阅
@@ -202,9 +210,9 @@ async function removeSubscribe() {
if (result.success) {
isSubscribed.value = false
$toast.success(`${props.media?.title} 已取消订阅!`)
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
} else {
$toast.error(`${props.media?.title} 取消订阅失败:${result.message}`)
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
}
} catch (error) {
console.error(error)
@@ -226,9 +234,6 @@ async function handleCheckSubscribe() {
// 查询当前媒体是否已入库
async function handleCheckExists() {
try {
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: props.media?.tmdb_id,
@@ -237,7 +242,6 @@ async function handleCheckExists() {
season: props.media?.season,
mtype: props.media?.type,
},
signal,
})
if (result.success) isExists.value = true
@@ -249,16 +253,13 @@ async function handleCheckExists() {
// 调用API检查是否已订阅电视剧需要指定季
async function checkSubscribe(season = 0) {
try {
const abortController = new AbortController()
registerAbortController(abortController)
const { signal } = abortController
// AbortController 现在由全局请求优化器自动管理
const mediaid = getMediaId()
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
season,
title: props.media?.title,
},
signal,
})
return result.id || null
@@ -397,15 +398,6 @@ function setupIntersectionObserver() {
}
}
onMounted(() => {
setupIntersectionObserver()
})
onBeforeUnmount(() => {
observer.value?.disconnect()
observer.value = null
})
// 计算图片地址
const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
@@ -423,6 +415,21 @@ const getImgUrl: Ref<string> = computed(() => {
function onRemoveSubscribe() {
subscribeEditDialog.value = false
}
// 获取媒体类型文本
function getMediaTypeText(type: string | undefined) {
if (!type) return ''
return mediaTypeDict[type]
}
onMounted(() => {
setupIntersectionObserver()
})
onBeforeUnmount(() => {
observer.value?.disconnect()
observer.value = null
})
</script>
<template>
@@ -470,7 +477,13 @@ function onRemoveSubscribe() {
</p>
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
<div v-else class="flex align-center justify-between">
<IconBtn icon="mdi-magnify" color="white" @click.stop="clickSearch" />
<IconBtn
v-if="hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')"
icon="mdi-magnify"
color="white"
@click.stop="clickSearch"
/>
<VSpacer />
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
</div>
</VCardText>
@@ -482,7 +495,7 @@ function onRemoveSubscribe() {
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 text-white font-bold"
>
{{ props.media?.type }}
{{ getMediaTypeText(props.media?.type) }}
</VChip>
<!-- 本地存在标识 -->
<ExistIcon v-if="isExists && !hover.isHovering" />

View File

@@ -87,6 +87,9 @@ function openTmdbPage(type: string, tmdbId: number) {
{{ context?.media_info?.tmdb_id }}
</VChip>
<!-- meta_info -->
<VChip v-if="context?.meta_info?.web_source" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
{{ context?.meta_info?.web_source }}
</VChip>
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
{{ context?.meta_info?.edition }}
</VChip>

View File

@@ -1,12 +1,22 @@
<script setup lang="ts">
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import emby_image from '@images/logos/emby.png'
import jellyfin_image from '@images/logos/jellyfin.png'
import plex_image from '@images/logos/plex.png'
import trimemedia_image from '@images/logos/trimemedia.png'
import custom_image from '@images/logos/mediaserver.png'
import api from '@/api'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { mediaServerDict } from '@/api/constants'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 获取i18n实例
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -32,17 +42,17 @@ const emit = defineEmits(['close', 'done', 'change'])
const infoItems = ref([
{
avatar: 'mdi-movie-roll',
title: '电影',
title: t('mediaType.movie'),
amount: '0',
},
{
avatar: 'mdi-television-box',
title: '电视剧',
title: t('mediaType.tv'),
amount: '0',
},
{
avatar: 'mdi-account',
title: '用户',
title: t('common.user'),
amount: '0',
},
])
@@ -50,7 +60,7 @@ const infoItems = ref([
// 同步媒体库选项
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
{
title: '全部',
title: t('common.all'),
value: 'all',
},
])
@@ -81,12 +91,12 @@ function openMediaServerInfoDialog() {
function saveMediaServerInfo() {
// 为空不保存,跳出警告框
if (!mediaServerInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
$toast.error(t('common.nameRequired'))
return
}
// 重名判断
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
$toast.error(`${mediaServerInfo.value.name}】已存在,请替换为其他名称`)
$toast.error(t('common.nameExists', { name: mediaServerInfo.value.name }))
return
}
// 执行保存
@@ -104,8 +114,10 @@ const getIcon = computed(() => {
return jellyfin_image
case 'trimemedia':
return trimemedia_image
default:
case 'plex':
return plex_image
default:
return custom_image
}
})
@@ -127,17 +139,17 @@ async function loadMediaStatistic() {
infoItems.value = [
{
avatar: 'mdi-movie-roll',
title: '电影',
title: t('mediaType.movie'),
amount: res.movie_count.toLocaleString(),
},
{
avatar: 'mdi-television-box',
title: '电视剧',
title: t('mediaType.tv'),
amount: res.tv_count.toLocaleString(),
},
{
avatar: 'mdi-account',
title: '用户',
title: t('common.user'),
amount: res.user_count.toLocaleString(),
},
]
@@ -160,7 +172,7 @@ async function loadLibrary(server: string) {
librariesOptions.value = []
}
librariesOptions.value.unshift({
title: '全部',
title: t('common.all'),
value: 'all',
})
} catch (e) {
@@ -179,209 +191,318 @@ onMounted(() => {
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<div class="text-h6 mb-1">{{ mediaserver.name }}</div>
<div class="text-sm mt-5 flex flex-wrap">
<div v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled" class="text-sm mt-5 flex flex-wrap">
<span v-for="item in infoItems" :key="item.title" class="me-2 mb-1">
<VIcon rounded :icon="item.avatar" class="me-1" />{{ item.amount }}
</span>
</div>
<div v-else-if="!mediaServerDict[mediaserver.type]" class="text-sm mt-5 flex flex-wrap">
<span class="me-2 mb-1">自定义媒体服务器</span>
</div>
</div>
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.mediaserver.name} - 配置`" class="rounded-t">
<VDialog
v-if="mediaServerInfoDialog"
v-model="mediaServerInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-cog" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.mediaserver.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="mediaServerInfo.enabled" label="启用媒体服务器" />
<VSwitch v-model="mediaServerInfo.enabled" :label="t('mediaserver.enableMediaServer')" />
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'emby'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
label="API密钥"
hint="Emby设置->高级->API密钥中生成的密钥"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.embyApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'jellyfin'">
<VRow v-else-if="mediaServerInfo.type == 'jellyfin'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
label="API密钥"
hint="Jellyfin设置->高级->API密钥中生成的密钥"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.jellyfinApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'trimemedia'">
<VRow v-else-if="mediaServerInfo.type == 'trimemedia'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="mediaServerInfo.config.username" label="用户名" active />
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField type="password" v-model="mediaServerInfo.config.password" label="密码" active />
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'plex'">
<VRow v-else-if="mediaServerInfo.type == 'plex'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.token"
label="X-Plex-Token"
hint="浏览器F12->网络从Plex请求URL中获取的X-Plex-Token"
:label="t('mediaserver.plexToken')"
:hint="t('mediaserver.plexTokenHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VSelect
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
label="同步媒体库"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
hint="只有选中的媒体库才会被同步"
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.type"
:label="t('mediaserver.type')"
:hint="t('mediaserver.customTypeHint')"
persistent-hint
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
:label="t('common.name')"
:hint="t('mediaserver.nameRequired')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveMediaServerInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -10,6 +10,9 @@ const props = defineProps({
height: String,
})
// 定义事件
const emit = defineEmits(['imageload'])
// 图片是否加载完成
const isImageLoaded = ref(false)
@@ -19,6 +22,7 @@ const imageLoadError = ref(false)
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
emit('imageload')
}
// 链接打开新窗口
@@ -55,7 +59,14 @@ function replaceNewLine(value: string) {
position="top"
@load="imageLoaded"
@error="imageLoadError = true"
/>
min-height="10rem"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div
v-if="

View File

@@ -6,8 +6,16 @@ import vocechat_image from '@images/logos/vocechat.png'
import synologychat_image from '@images/logos/synologychat.png'
import slack_image from '@images/logos/slack.webp'
import chrome_image from '@images/logos/chrome.png'
import { useToast } from 'vue-toast-notification'
import custom_image from '@images/logos/notification.png'
import { useToast } from 'vue-toastification'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -42,24 +50,25 @@ const notificationInfo = ref<NotificationConf>({
// 各通知类型的名称字典
const notificationTypeNames: { [key: string]: string } = {
wechat: '企业微信',
telegram: 'Telegram',
vocechat: 'VoceChat',
synologychat: 'Synology Chat',
slack: 'Slack',
webpush: 'WebPush',
wechat: t('notification.wechat.name'),
telegram: t('notification.telegram.name'),
vocechat: t('notification.vocechat.name'),
synologychat: t('notification.synologychat.name'),
slack: t('notification.slack.name'),
webpush: t('notification.webpush.name'),
custom: t('setting.notification.custom'),
}
// 消息类型下拉字典
const notificationTypes = [
{ value: '资源下载', title: '资源下载' },
{ value: '整理入库', title: '整理入库' },
{ value: '订阅', title: '订阅' },
{ value: '站点', title: '站点' },
{ value: '媒体服务器', title: '媒体服务器' },
{ value: '手动处理', title: '手动处理' },
{ value: '插件', title: '插件' },
{ value: '其它', title: '其它' },
{ value: '资源下载', title: t('notificationSwitch.resourceDownload') },
{ value: '整理入库', title: t('notificationSwitch.organize') },
{ value: '订阅', title: t('notificationSwitch.subscribe') },
{ value: '站点', title: t('notificationSwitch.site') },
{ value: '媒体服务器', title: t('notificationSwitch.mediaServer') },
{ value: '手动处理', title: t('notificationSwitch.manual') },
{ value: '插件', title: t('notificationSwitch.plugin') },
{ value: '其它', title: t('notificationSwitch.other') },
]
// 打开详情弹窗
@@ -73,12 +82,12 @@ function openNotificationInfoDialog() {
function saveNotificationInfo() {
// 为空不保存,跳出警告框
if (!notificationInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
$toast.error(t('notification.name') + t('common.required'))
return
}
// 重名判断
if (props.notifications.some(item => item.name === notificationInfo.value.name && item !== props.notification)) {
$toast.error(`通知渠道${notificationInfo.value.name}已存在,请替换`)
$toast.error(t('notification.channel') + `${notificationInfo.value.name}` + t('common.exists'))
return
}
notificationInfoDialog.value = false
@@ -102,7 +111,7 @@ const getIcon = computed(() => {
case 'webpush':
return chrome_image
default:
return wechat_image
return custom_image
}
})
@@ -128,29 +137,44 @@ function onClose() {
</div>
<div class="text-body-1 mb-3">{{ notificationTypeNames[notification.type] }}</div>
</div>
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" />
<VImg :src="getIcon" cover class="mt-7 me-1" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-if="notificationInfoDialog" v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.notification.name} - 配置`" class="rounded-t">
<VDialogCloseBtn v-model="notificationInfoDialog" />
<VDialog
v-if="notificationInfoDialog"
v-model="notificationInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-cog" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.notification.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="notificationInfoDialog = false" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="notificationInfo.enabled" label="启用通知" />
<VSwitch v-model="notificationInfo.enabled" :label="t('notification.enabled')" />
</VCol>
<VCol cols="12">
<VSelect
<VAutocomplete
v-model="notificationInfo.switchs"
:items="notificationTypes"
label="消息类型"
hint="开启通知的消息类型"
:label="t('notification.type')"
:hint="t('notification.typeHint')"
multiple
clearable
chips
persistent-hint
prepend-inner-icon="mdi-bell-outline"
/>
</VCol>
</VRow>
@@ -158,240 +182,297 @@ function onClose() {
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_CORPID"
label="企业ID"
hint="企业微信后台企业信息中的企业ID"
:label="t('notification.wechat.corpId')"
:hint="t('notification.wechat.corpIdHint')"
persistent-hint
prepend-inner-icon="mdi-domain"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_ID"
label="应用 AgentId"
hint="企业微信自建应用的AgentId"
:label="t('notification.wechat.appId')"
:hint="t('notification.wechat.appIdHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_SECRET"
label="应用 Secret"
hint="企业微信自建应用的Secret"
:label="t('notification.wechat.appSecret')"
:hint="t('notification.wechat.appSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_PROXY"
label="代理地址"
hint="微信消息的转发代理地址2022年6月20日后创建的自建应用才需要不使用代理时需要保留默认值"
:label="t('notification.wechat.proxy')"
:hint="t('notification.wechat.proxyHint')"
persistent-hint
prepend-inner-icon="mdi-server-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_TOKEN"
label="Token"
hint="微信企业自建应用->API接收消息配置中的Token"
:label="t('notification.wechat.token')"
:hint="t('notification.wechat.tokenHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
label="EncodingAESKey"
hint="微信企业自建应用->API接收消息配置中的EncodingAESKey"
:label="t('notification.wechat.encodingAesKey')"
:hint="t('notification.wechat.encodingAesKeyHint')"
persistent-hint
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
:label="t('notification.wechat.admins')"
:placeholder="t('notification.wechat.adminsPlaceholder')"
:hint="t('notification.wechat.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'telegram'">
<VRow v-else-if="notificationInfo.type == 'telegram'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_TOKEN"
label="Bot Token"
hint="Telegram机器人token格式123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
:label="t('notification.telegram.token')"
:hint="t('notification.telegram.tokenHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_CHAT_ID"
label="Chat ID"
hint="接受消息通知的用户、群组或频道Chat ID"
:label="t('notification.telegram.chatId')"
:hint="t('notification.telegram.chatIdHint')"
persistent-hint
prepend-inner-icon="mdi-chat"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_USERS"
label="用户白名单"
placeholder="多个用,分隔"
hint="可使用Telegram机器人的用户ID清单多个用户用,分隔,不填写则所有用户都能使用"
:label="t('notification.telegram.users')"
:placeholder="t('notification.telegram.usersPlaceholder')"
:hint="t('notification.telegram.usersHint')"
persistent-hint
prepend-inner-icon="mdi-account-group"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
:label="t('notification.telegram.admins')"
:placeholder="t('notification.telegram.adminsPlaceholder')"
:hint="t('notification.telegram.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.API_URL"
:label="t('notification.telegram.apiUrl')"
:placeholder="t('notification.telegram.apiUrlPlaceholder')"
:hint="t('notification.telegram.apiUrlHint')"
persistent-hint
prepend-inner-icon="mdi-web"
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'slack'">
<VRow v-else-if="notificationInfo.type == 'slack'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_OAUTH_TOKEN"
label="Slack Bot User OAuth Token"
placeholder="xoxb-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="Slack应用`OAuth & Permissions`页面中的`Bot User OAuth Token`"
:label="t('notification.slack.oauthToken')"
:placeholder="t('notification.slack.oauthTokenPlaceholder')"
:hint="t('notification.slack.oauthTokenHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_APP_TOKEN"
label="Slack App-Level Token"
placeholder="xapp-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="Slack应用`OAuth & Permissions`页面中的`App-Level Token`"
:label="t('notification.slack.appToken')"
:placeholder="t('notification.slack.appTokenPlaceholder')"
:hint="t('notification.slack.appTokenHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_CHANNEL"
label="频道名称"
placeholder="全体"
hint="消息发送频道,默认`全体`"
:label="t('notification.slack.channel')"
:placeholder="t('notification.slack.channelPlaceholder')"
:hint="t('notification.slack.channelHint')"
persistent-hint
prepend-inner-icon="mdi-pound"
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'synologychat'">
<VRow v-else-if="notificationInfo.type == 'synologychat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SYNOLOGYCHAT_WEBHOOK"
label="机器人传入URL"
hint="Synology Chat机器人传入URL"
:label="t('notification.synologychat.webhook')"
:hint="t('notification.synologychat.webhookHint')"
persistent-hint
prepend-inner-icon="mdi-webhook"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SYNOLOGYCHAT_TOKEN"
label="令牌"
hint="Synology Chat机器人令牌"
:label="t('notification.synologychat.token')"
:hint="t('notification.synologychat.tokenHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'vocechat'">
<VRow v-else-if="notificationInfo.type == 'vocechat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_HOST"
label="地址"
hint="VoceChat服务端地址格式http(s)://ip:port"
:label="t('notification.vocechat.host')"
:hint="t('notification.vocechat.hostHint')"
persistent-hint
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_API_KEY"
label="机器人密钥"
hint="VoceChat机器人密钥"
:label="t('notification.vocechat.apiKey')"
:hint="t('notification.vocechat.apiKeyHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_CHANNEL_ID"
label="频道ID"
placeholder="不包含#号"
hint="VoceChat的频道ID不包含#号"
:label="t('notification.vocechat.channelId')"
:placeholder="t('notification.vocechat.channelIdPlaceholder')"
:hint="t('notification.vocechat.channelIdHint')"
persistent-hint
prepend-inner-icon="mdi-pound"
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'webpush'">
<VRow v-else-if="notificationInfo.type == 'webpush'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WEBPUSH_USERNAME"
label="登录用户名"
hint="只有对应的用户登录后才会推送消息"
:label="t('notification.webpush.username')"
:hint="t('notification.webpush.usernameHint')"
persistent-hint
prepend-inner-icon="mdi-account"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.type"
:label="t('notification.type')"
:hint="t('notification.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:hint="t('notification.nameRequired')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveNotificationInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
<VBtn @click="saveNotificationInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -2,6 +2,7 @@
import personIcon from '@images/misc/person-icon.png'
import type { Person } from '@/api/types'
import router from '@/router'
import { useGlobalSettingsStore } from '@/stores'
const personProps = defineProps({
person: Object as PropType<Person>,
@@ -10,7 +11,9 @@ const personProps = defineProps({
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 当前人物
const personInfo = ref(personProps.person)
@@ -82,9 +85,7 @@ function goPersonDetail() {
}"
@click.stop="goPersonDetail"
>
<div
class="person-card relative transform-gpu cursor-pointer rounded transition duration-150 ease-in-out scale-100 ring-gray-700"
>
<div class="person-card relative cursor-pointer ring-gray-700">
<div style="padding-block-end: 150%">
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
@@ -100,10 +101,7 @@ function goPersonDetail() {
<div class="w-full truncate text-center font-bold">
{{ getPersonName() }}
</div>
<div
class="overflow-hidden whitespace-normal text-center text-sm"
style="display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2"
>
<div class="overflow-hidden whitespace-normal text-center text-sm text-ellipsis line-clamp-2">
{{ getPersonCharacter() }}
</div>
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import VersionHistory from '../misc/VersionHistory.vue'
import api from '@/api'
import type { Plugin } from '@/api/types'
@@ -7,6 +7,7 @@ import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image'
import { isNullOrEmptyObject } from '@/@core/utils'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
// 输入参数
const props = defineProps({
@@ -19,6 +20,9 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['install'])
// 多语言
const { t } = useI18n()
// 背景颜色
const backgroundColor = ref('#28A9E1')
@@ -32,7 +36,17 @@ const $toast = useToast()
const progressDialog = ref(false)
// 进度框文本
const progressText = ref('正在安装插件...')
const progressText = ref('')
// 获取当前插件的标签
const pluginLabels = computed(() => {
if (!props.plugin?.plugin_label) return []
return props.plugin.plugin_label
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0)
})
// 图片是否加载完成
const isImageLoaded = ref(false)
@@ -59,7 +73,10 @@ async function installPlugin() {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在安装 ${props.plugin?.plugin_name} v${props?.plugin?.plugin_version} ...`
progressText.value = t('plugin.installing', {
name: props.plugin?.plugin_name,
version: props?.plugin?.plugin_version,
})
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
@@ -72,12 +89,12 @@ async function installPlugin() {
progressDialog.value = false
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 安装成功!`)
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
detailDialog.value = false
// 通知父组件刷新
emit('install')
} else {
$toast.error(`插件 ${props.plugin?.plugin_name} 安装失败:${result.message}`)
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
console.error(error)
@@ -89,7 +106,7 @@ const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}&cache=true`
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
@@ -125,7 +142,7 @@ function showUpdateHistory() {
// 弹出菜单
const dropdownItems = ref([
{
title: '项目主页',
title: t('plugin.projectHome'),
value: 1,
show: true,
props: {
@@ -134,7 +151,7 @@ const dropdownItems = ref([
},
},
{
title: '更新说明',
title: t('plugin.updateHistory'),
value: 2,
show: !isNullOrEmptyObject(props.plugin?.history || {}),
props: {
@@ -160,51 +177,77 @@ const dropdownItems = ref([
}"
>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
class="flex-grow"
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
></div>
<div class="relative flex-1 min-w-0">
<VCardText class="px-2 pt-2 pb-0">
<VCardTitle
class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis ..."
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
>
{{ props.plugin?.plugin_name }}
<span class="text-sm text-gray-200">v{{ props.plugin?.plugin_version }}</span>
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
</VCardTitle>
<VCardText class="text-white text-sm px-2 py-0 text-shadow overflow-hidden line-clamp-3 ...">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
<div class="relative flex-shrink-0 self-center">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</VCardText>
<div class="relative flex flex-row items-start px-2 justify-between grow">
<div class="relative flex-1 min-w-0">
<div
class="text-white text-sm px-2 py-1 text-shadow overflow-hidden ..."
:class="{ 'line-clamp-3': !props.plugin?.plugin_label, 'line-clamp-2': props.plugin?.plugin_label }"
>
{{ props.plugin?.plugin_desc }}
</div>
<!-- 插件标签 -->
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2">
<VChip
v-for="tag in pluginLabels"
:key="tag"
size="x-small"
variant="tonal"
color="info"
class="me-1 mb-1"
tile
>
{{ tag }}
</VChip>
</div>
</div>
<div class="relative flex-shrink-0 self-center pb-3">
<VAvatar size="48">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span>
<VIcon icon="mdi-github" class="me-1" />
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }}
</a>
</span>
<span v-if="props.count" class="ms-3">
<VIcon icon="mdi-download" />
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
</span>
<div class="me-n3 absolute bottom-1 right-3">
<VCardText
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
>
<div class="flex flex-nowrap items-center w-full pe-10">
<div class="flex flex-nowrap max-w-40 items-center align-middle">
<VIcon icon="mdi-github" class="me-1" />
<a
class="overflow-hidden text-ellipsis whitespace-nowrap"
:href="props.plugin?.author_url"
target="_blank"
@click.stop
>
{{ props.plugin?.plugin_author }}
</a>
</div>
<div v-if="props.count" class="ms-2 flex-shrink-0 download-count align-middle items-center">
<VIcon size="small" icon="mdi-download" />
<span class="text-sm">{{ props.count?.toLocaleString() }}</span>
</div>
</div>
<div class="absolute bottom-0 right-0">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VIcon size="small" icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem v-for="(item, i) in dropdownItems" v-show="item.show" :key="i" @click="item.props.click">
@@ -225,7 +268,7 @@ const dropdownItems = ref([
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
@@ -263,13 +306,13 @@ const dropdownItems = ref([
<VList lines="one">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">版本</span>
<span class="font-weight-medium">{{ t('common.version') }}</span>
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">作者</span>
<span class="font-weight-medium">{{ t('common.author') }}</span>
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
{{ props.plugin?.plugin_author }}
</span>
@@ -277,9 +320,13 @@ const dropdownItems = ref([
</VListItem>
</VList>
<div class="text-center text-md-left">
<VBtn color="primary" @click="installPlugin" prepend-icon="mdi-download"> 安装到本地 </VBtn>
<VBtn color="primary" @click="installPlugin" prepend-icon="mdi-download">{{
t('plugin.installToLocal')
}}</VBtn>
<div class="text-xs mt-2" v-if="props.count">
<VIcon icon="mdi-fire" /> {{ props.count?.toLocaleString() }} 次下载
<VIcon icon="mdi-fire" />{{
t('plugin.totalDownloads', { count: props.count?.toLocaleString() })
}}
</div>
</div>
</VCardItem>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
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'
@@ -10,6 +10,12 @@ import VersionHistory from '@/components/misc/VersionHistory.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
import PluginDataDialog from '../dialog/PluginDataDialog.vue'
import LoggingView from '@/views/system/LoggingView.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
@@ -23,6 +29,9 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save', 'actionDone'])
// 多语言
const { t } = useI18n()
// 背景颜色
const backgroundColor = ref('#28A9E1')
@@ -50,6 +59,9 @@ const progressDialog = ref(false)
// 插件数据页面
const pluginInfoDialog = ref(false)
// 实时日志弹窗
const loggingDialog = ref(false)
// 进度框文本
const progressText = ref('正在更新插件...')
@@ -65,6 +77,18 @@ const imageLoadError = ref(false)
// 更新日志弹窗
const releaseDialog = ref(false)
// 插件分身对话框
const pluginCloneDialog = ref(false)
// 插件分身表单
const cloneForm = ref({
suffix: '',
name: '',
description: '',
version: '',
icon: '',
})
// 监听动作标识如为true则打开详情
watch(
() => props.action,
@@ -97,8 +121,8 @@ function showUpdateHistory() {
// 调用API卸载插件
async function uninstallPlugin() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认卸载插件 ${props.plugin?.plugin_name} ?`,
title: t('common.confirm'),
content: t('plugin.confirmUninstall', { name: props.plugin?.plugin_name }),
})
if (!isConfirmed) return
@@ -106,17 +130,22 @@ async function uninstallPlugin() {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在卸载 ${props.plugin?.plugin_name} ...`
progressText.value = t('plugin.uninstalling', { name: props.plugin?.plugin_name })
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
// 隐藏等待提示框
progressDialog.value = false
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 已卸载`)
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
// 通知父组件刷新
emit('remove')
} else {
$toast.error(`插件 ${props.plugin?.plugin_name} 卸载失败:${result.message}}`)
$toast.error(
t('plugin.uninstallFailed', {
name: props.plugin?.plugin_name,
message: result.message,
}),
)
}
} catch (error) {
console.error(error)
@@ -141,7 +170,7 @@ const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}&cache=true`
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
@@ -151,14 +180,14 @@ const authorPath: Ref<string> = computed(() => {
// 网络图片则使用代理后返回
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.author_url + '.png',
)}`
)}&cache=true`
})
// 重置插件
async function resetPlugin() {
const isConfirmed = await createConfirm({
title: '确认',
content: `此操作将恢复插件 ${props.plugin?.plugin_name} 的默认设置,并清除所有相关数据,确定要继续吗?`,
title: t('common.confirm'),
content: t('plugin.confirmReset', { name: props.plugin?.plugin_name }),
})
if (!isConfirmed) return
@@ -166,11 +195,16 @@ async function resetPlugin() {
try {
const result: { [key: string]: any } = await api.get(`plugin/reset/${props.plugin?.id}`)
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 数据已重置`)
$toast.success(t('plugin.resetSuccess', { name: props.plugin?.plugin_name }))
// 通知父组件刷新
emit('save')
} else {
$toast.error(`插件 ${props.plugin?.plugin_name} 重置失败:${result.message}}`)
$toast.error(
t('plugin.resetFailed', {
name: props.plugin?.plugin_name,
message: result.message,
}),
)
}
} catch (error) {
console.error(error)
@@ -183,7 +217,7 @@ async function updatePlugin() {
releaseDialog.value = false
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在更新 ${props.plugin?.plugin_name} ...`
progressText.value = t('plugin.updating', { name: props.plugin?.plugin_name })
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
@@ -196,12 +230,17 @@ async function updatePlugin() {
progressDialog.value = false
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 更新成功!`)
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
// 通知父组件刷新
emit('save')
} else {
$toast.error(`插件 ${props.plugin?.plugin_name} 更新失败:${result.message}`)
$toast.error(
t('plugin.updateFailed', {
name: props.plugin?.plugin_name,
message: result.message,
}),
)
}
} catch (error) {
console.error(error)
@@ -233,10 +272,58 @@ function configDone() {
emit('save')
}
// 显示插件分身对话框
function showPluginClone() {
cloneForm.value = {
suffix: '',
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
version: props.plugin?.plugin_version || '1.0',
icon: props.plugin?.plugin_icon || '',
}
pluginCloneDialog.value = true
}
// 执行插件分身
async function executePluginClone() {
if (!cloneForm.value.suffix.trim()) {
$toast.error(t('plugin.suffixRequired'))
return
}
try {
progressDialog.value = true
progressText.value = t('plugin.cloning', { name: props.plugin?.plugin_name })
const result: { [key: string]: any } = await api.post(`plugin/clone/${props.plugin?.id}`, {
suffix: cloneForm.value.suffix.trim(),
name: cloneForm.value.name.trim(),
description: cloneForm.value.description.trim(),
version: cloneForm.value.version.trim(),
icon: cloneForm.value.icon.trim(),
})
progressDialog.value = false
if (result.success) {
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.value.name }))
pluginCloneDialog.value = false
// 通知父组件刷新
emit('remove')
} else {
$toast.error(t('plugin.cloneFailed', { message: result.message }))
}
} catch (error) {
progressDialog.value = false
$toast.error(t('plugin.cloneFailedGeneral'))
console.error(error)
}
}
// 弹出菜单
const dropdownItems = ref([
{
title: '查看数据',
title: t('plugin.viewData'),
value: 1,
show: props.plugin?.has_page,
props: {
@@ -245,7 +332,7 @@ const dropdownItems = ref([
},
},
{
title: '设置',
title: t('plugin.settings'),
value: 2,
show: true,
props: {
@@ -254,7 +341,17 @@ const dropdownItems = ref([
},
},
{
title: '更新',
title: t('plugin.clone'),
value: 8,
show: true,
props: {
prependIcon: 'mdi-content-copy',
color: 'info',
click: showPluginClone,
},
},
{
title: t('plugin.update'),
value: 3,
show: props.plugin?.has_update,
props: {
@@ -264,7 +361,7 @@ const dropdownItems = ref([
},
},
{
title: '重置',
title: t('plugin.reset'),
value: 4,
show: true,
props: {
@@ -274,7 +371,7 @@ const dropdownItems = ref([
},
},
{
title: '卸载',
title: t('plugin.uninstall'),
value: 5,
show: true,
props: {
@@ -284,18 +381,18 @@ const dropdownItems = ref([
},
},
{
title: '查看日志',
title: t('plugin.viewLogs'),
value: 6,
show: true,
props: {
prependIcon: 'mdi-file-document-outline',
click: () => {
openLoggerWindow()
loggingDialog.value = true
},
},
},
{
title: '作者主页',
title: t('plugin.authorHome'),
value: 7,
show: true,
props: {
@@ -324,7 +421,7 @@ watch(
</script>
<template>
<div>
<div class="h-full">
<!-- 插件卡片 -->
<VHover>
<template #default="hover">
@@ -340,50 +437,61 @@ watch(
}"
>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
class="flex-grow"
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
/>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
<VBadge v-if="props.plugin?.state" dot inline color="success" />
<VCardText class="px-2 pt-2 pb-0">
<VCardTitle
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
>
<VBadge dot inline :color="props.plugin?.state ? 'success' : 'secondary'" />
{{ props.plugin?.plugin_name }}
<span class="text-sm mt-1 text-gray-200"> v{{ props.plugin?.plugin_version }} </span>
</VCardTitle>
<VCardText class="px-2 py-0 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
{{ props.plugin?.plugin_desc }}
</VCardText>
</div>
<div class="relative flex-shrink-0 self-center cursor-move">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</VCardText>
<div class="relative flex flex-row items-start px-2 justify-between grow">
<div class="relative flex-1 min-w-0">
<div class="px-2 py-1 text-white text-sm text-shadow overflow-hidden line-clamp-3 ...">
{{ props.plugin?.plugin_desc }}
</div>
</div>
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': display.mdAndUp.value }">
<VAvatar size="48">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span class="author-info">
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
<VIcon v-if="!isAvatarLoaded" icon="mdi-github" class="me-1" />
</VImg>
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }}
</a>
</span>
<span v-if="props.count" class="ms-3">
<VIcon icon="mdi-download" />
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
</span>
<div class="me-n3 absolute bottom-1 right-3">
<VCardText
class="flex flex-col align-self-baseline justify-between px-2 py-2 w-full overflow-hidden max-h-10 min-h-10"
>
<div class="flex flex-nowrap items-center w-full pe-10">
<div class="flex flex-nowrap max-w-40 items-center align-middle">
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
</VImg>
<a
:href="props.plugin?.author_url"
target="_blank"
@click.stop
class="overflow-hidden text-ellipsis whitespace-nowrap"
>
{{ props.plugin?.plugin_author }}
</a>
</div>
<span v-if="props.count" class="ms-2 flex-shrink-0 download-count items-center align-middle">
<VIcon size="small" icon="mdi-download" />
<span class="text-sm">{{ props.count?.toLocaleString() }}</span>
</span>
</div>
<div class="absolute bottom-0 right-0">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
@@ -435,8 +543,8 @@ watch(
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" max-height="80vh" scrollable>
<VCard :title="`${props.plugin?.plugin_name} 更新说明`">
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable :fullscreen="!display.mdAndUp.value">
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
@@ -446,11 +554,149 @@ watch(
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
更新到最新版本
{{ t('plugin.updateToLatest') }}
</VBtn>
</VCardItem>
</VCard>
</VDialog>
<!-- 实时日志弹窗 -->
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="60rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="loggingDialog = false" />
<VCardItem>
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('plugin.logTitle') }}
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
<VChip color="grey-darken-1" size="small" class="ml-2">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>
</VDialog>
<!-- 插件分身对话框 -->
<VDialog
v-if="pluginCloneDialog"
v-model="pluginCloneDialog"
width="600"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-content-copy" class="me-2" />
</template>
<VCardTitle>{{ t('plugin.cloneTitle') }}</VCardTitle>
<VCardSubtitle>{{ t('plugin.cloneSubtitle', { name: props.plugin?.plugin_name }) }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="pluginCloneDialog = false" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.suffix"
:label="t('plugin.suffix') + ' *'"
:placeholder="t('plugin.suffixPlaceholder')"
:hint="t('plugin.suffixHint')"
persistent-hint
:rules="[
v => !!v || t('plugin.suffixRequired'),
v => /^[a-zA-Z0-9]+$/.test(v) || t('plugin.suffixFormatError'),
v => v.length <= 20 || t('plugin.suffixLengthError'),
]"
required
prepend-inner-icon="mdi-tag"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.name"
:label="t('plugin.cloneName')"
:placeholder="t('plugin.cloneNamePlaceholder')"
:hint="t('plugin.cloneNameHint')"
persistent-hint
prepend-inner-icon="mdi-rename-box"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="cloneForm.description"
:label="t('plugin.cloneDescriptionLabel')"
:placeholder="t('plugin.cloneDescriptionPlaceholder')"
:hint="t('plugin.cloneDescriptionHint')"
persistent-hint
prepend-inner-icon="mdi-text"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.version"
:label="t('plugin.cloneVersion')"
:placeholder="t('plugin.cloneVersionPlaceholder')"
:hint="t('plugin.cloneVersionHint')"
persistent-hint
prepend-inner-icon="mdi-numeric"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.icon"
:label="t('plugin.cloneIcon')"
:placeholder="t('plugin.cloneIconPlaceholder')"
:hint="t('plugin.cloneIconHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
<!-- 重要提醒 -->
<VCol cols="12">
<VAlert type="warning" variant="tonal" density="compact" class="mt-2" icon="mdi-alert-circle-outline">
<div class="text-body-2">
<strong>{{ t('common.notice') }}</strong
>{{ t('plugin.cloneNotice') }}
</div>
</VAlert>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn
color="primary"
@click="executePluginClone"
prepend-icon="mdi-content-copy"
class="px-5"
:disabled="!cloneForm.suffix.trim()"
>
{{ t('plugin.createClone') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>
@@ -465,11 +711,6 @@ watch(
inset: 0;
}
.author-info {
display: flex;
align-items: center;
}
.author-avatar {
border-radius: 50%;
block-size: 24px;

View File

@@ -0,0 +1,663 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 文件夹配置接口
interface FolderConfig {
plugins?: string[]
order?: number
background?: string
icon?: string
color?: string
gradient?: string
showIcon?: boolean
}
// 输入参数
const props = defineProps({
folderName: String,
pluginCount: Number,
folderConfig: {
type: Object as PropType<FolderConfig>,
default: () => ({}),
},
width: String,
height: String,
})
// 定义触发的自定义事件
const emit = defineEmits(['open', 'delete', 'rename', 'update-config'])
// 多语言
const { t } = useI18n()
// 响应式显示
const display = useDisplay()
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 菜单显示状态
const menuVisible = ref(false)
// 重命名对话框
const renameDialog = ref(false)
// 设置对话框
const settingDialog = ref(false)
// 新名称
const newFolderName = ref('')
// 默认颜色
const defaultColor = '#2196F3'
// 默认图标
const defaultIcon = 'mdi-folder'
// 默认渐变
const defaultGradient =
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8s) 100%)'
// 文件夹设置
const folderSettings = ref<FolderConfig>({
background: '',
icon: defaultIcon,
color: defaultColor,
gradient: defaultGradient,
showIcon: true,
})
// 计算背景图片
const backgroundImage = computed(() => {
return props.folderConfig.background || folderSettings.value.background
})
// 预设图标选项
const iconOptions = [
'mdi-folder',
'mdi-folder-star',
'mdi-folder-heart',
'mdi-folder-cog',
'mdi-folder-music',
'mdi-folder-image',
'mdi-folder-video',
'mdi-folder-download',
'mdi-folder-network',
'mdi-folder-special',
]
// 预设颜色选项
const colorOptions = [
'#2196F3', // 蓝色
'#4CAF50', // 绿色
'#FF9800', // 橙色
'#9C27B0', // 紫色
'#F44336', // 红色
'#607D8B', // 蓝灰色
'#795548', // 棕色
'#E91E63', // 粉色
]
// 预设渐变选项
const gradientOptions = [
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(76, 175, 80, 0.7) 0%, rgba(76, 175, 80, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(255, 152, 0, 0.7) 0%, rgba(255, 152, 0, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(156, 39, 176, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(244, 67, 54, 0.7) 0%, rgba(244, 67, 54, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(96, 125, 139, 0.7) 0%, rgba(96, 125, 139, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(233, 30, 99, 0.7) 0%, rgba(233, 30, 99, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(63, 81, 181, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
]
// 计算背景渐变
const backgroundGradient = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.gradient || settings.gradient || gradientOptions[0]
})
// 计算图标
const folderIcon = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.icon || settings.icon || defaultIcon
})
// 计算图标颜色
const iconColor = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.color || settings.color || defaultColor
})
// 计算是否显示图标
const shouldShowIcon = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.showIcon !== undefined ? config.showIcon : settings.showIcon !== undefined ? settings.showIcon : true
})
// 监听props变化更新本地设置
watch(
() => props.folderConfig,
newConfig => {
if (newConfig) {
folderSettings.value = {
...folderSettings.value,
...newConfig,
}
}
},
{ deep: true, immediate: true },
)
// 打开文件夹
function openFolder() {
emit('open', props.folderName)
}
// 重命名文件夹
function showRenameDialog() {
newFolderName.value = props.folderName || ''
renameDialog.value = true
}
// 确认重命名
async function confirmRename() {
if (!newFolderName.value.trim()) {
$toast.error(t('folder.folderNameCannotBeEmpty'))
return
}
if (newFolderName.value === props.folderName) {
renameDialog.value = false
return
}
try {
emit('rename', props.folderName, newFolderName.value)
renameDialog.value = false
} catch (error) {
console.error(error)
}
}
// 删除文件夹
async function deleteFolder() {
const isConfirmed = await createConfirm({
title: t('common.confirm'),
content: t('folder.confirmDeleteFolder', { folderName: props.folderName }),
})
if (!isConfirmed) return
try {
emit('delete', props.folderName)
} catch (error) {
console.error(error)
}
}
// 显示设置对话框
function showSettingDialog() {
folderSettings.value = {
background: props.folderConfig?.background || '',
icon: props.folderConfig?.icon || defaultIcon,
color: props.folderConfig?.color || defaultColor,
gradient: props.folderConfig?.gradient || gradientOptions[0],
showIcon: props.folderConfig?.showIcon !== undefined ? props.folderConfig.showIcon : true,
}
settingDialog.value = true
}
// 保存设置
function saveSettings() {
const config = {
...props.folderConfig,
...folderSettings.value,
}
emit('update-config', props.folderName, config)
settingDialog.value = false
$toast.success(t('folder.folderSettingsSaved'))
}
// 弹出菜单
const dropdownItems = ref([
{
title: t('folder.settingAppearance'),
value: 0,
show: true,
props: {
prependIcon: 'mdi-palette',
click: showSettingDialog,
},
},
{
title: t('folder.rename'),
value: 1,
show: true,
props: {
prependIcon: 'mdi-pencil',
click: showRenameDialog,
},
},
{
title: t('folder.deleteFolder'),
value: 2,
show: true,
props: {
prependIcon: 'mdi-delete',
color: 'error',
click: deleteFolder,
},
},
])
</script>
<template>
<div class="h-full">
<!-- 文件夹卡片 -->
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:ripple="false"
:width="props.width"
:height="props.height"
min-height="8.5rem"
@click="openFolder"
class="plugin-folder-card h-full"
:class="{
'plugin-folder-card--mobile': display.mobile,
'plugin-folder-card--hover': hover.isHovering,
}"
>
<template v-if="backgroundImage" #image>
<VImg :src="backgroundImage" cover position="top"> </VImg>
</template>
<!-- 背景遮罩当有背景图片时 -->
<div v-if="backgroundImage" class="plugin-folder-card__overlay" />
<!-- 背景渐变层 -->
<div v-else class="plugin-folder-card__bg" :style="{ background: backgroundGradient }" />
<!-- 卡片内容 -->
<div class="plugin-folder-card__content">
<!-- 主体内容 -->
<div class="plugin-folder-card__body" :class="{ 'plugin-folder-card__body--no-icon': !shouldShowIcon }">
<!-- 文件夹图标 -->
<div v-if="shouldShowIcon" class="plugin-folder-card__icon-container">
<VIcon
:icon="folderIcon"
:size="display.mobile ? 56 : 72"
:color="iconColor"
:class="{ 'cursor-move': display.mdAndUp.value }"
/>
</div>
<!-- 文件夹信息 -->
<div
class="plugin-folder-card__info"
:class="{ 'cursor-move': display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
>
<!-- 文件夹名称 -->
<h3 class="plugin-folder-card__name">
{{ props.folderName }}
</h3>
<!-- 插件数量 -->
<p class="plugin-folder-card__count">{{ t('folder.pluginCount', { count: props.pluginCount }) }}</p>
</div>
</div>
<!-- 更多菜单按钮 - 右下角 -->
<div class="absolute top-0 right-0">
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
<template #activator="{ props: menuProps }">
<IconBtn v-bind="menuProps" @click.stop>
<VIcon size="small" icon="mdi-dots-vertical" class="text-white" />
</IconBtn>
</template>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" size="16" />
</template>
<VListItemTitle class="text-body-2">{{ item.title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</div>
</div>
</VCard>
</template>
</VHover>
<!-- 重命名对话框 -->
<VDialog v-if="renameDialog" v-model="renameDialog" max-width="400">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-pencil" class="me-2" />
</template>
<VCardTitle>{{ t('folder.renameFolder') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="renameDialog = false" />
<VDivider />
<VCardText>
<VTextField
v-model="newFolderName"
:label="t('folder.folderName')"
variant="outlined"
autofocus
@keyup.enter="confirmRename"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 设置对话框 -->
<VDialog
v-if="settingDialog"
v-model="settingDialog"
max-width="600"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="settingDialog = false" />
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-palette" class="mr-2" />
{{ t('folder.folderAppearanceSettings') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<!-- 显示图标开关 -->
<VCol cols="12">
<VSwitch
v-model="folderSettings.showIcon"
:label="t('folder.showFolderIcon')"
color="primary"
hide-details
/>
</VCol>
<!-- 图标选择 -->
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.icon') }}</VCardSubtitle>
<div class="icon-grid">
<VBtn
v-for="icon in iconOptions"
icon
:key="icon"
:variant="folderSettings.icon === icon ? 'tonal' : 'text'"
:color="folderSettings.icon === icon ? 'primary' : 'default'"
size="large"
class="ma-1"
@click="folderSettings.icon = icon"
>
<VIcon :icon="icon" size="24" />
</VBtn>
</div>
</VCol>
<!-- 颜色选择 -->
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.iconColor') }}</VCardSubtitle>
<div class="color-grid">
<VBtn
v-for="color in colorOptions"
:key="color"
:variant="folderSettings.color === color ? 'tonal' : 'text'"
:color="color"
size="large"
class="ma-1 color-btn"
:style="{ backgroundColor: color }"
@click="folderSettings.color = color"
>
<VIcon v-if="folderSettings.color === color" icon="mdi-check" color="white" />
</VBtn>
</div>
</VCol>
<!-- 渐变背景选择 -->
<VCol cols="12">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.backgroundGradient') }}</VCardSubtitle>
<div class="gradient-grid">
<VBtn
v-for="(gradient, index) in gradientOptions"
:key="index"
:variant="folderSettings.gradient === gradient ? 'tonal' : 'text'"
class="ma-1 gradient-btn"
:style="{ background: gradient }"
size="large"
@click="folderSettings.gradient = gradient"
>
<VIcon v-if="folderSettings.gradient === gradient" icon="mdi-check" color="white" />
</VBtn>
</div>
</VCol>
<!-- 自定义背景图片 -->
<VCol cols="12">
<VTextField
v-model="folderSettings.background"
:label="t('folder.customBackgroundImageURL')"
placeholder="https://example.com/image.jpg"
variant="outlined"
:hint="t('folder.customBackgroundImageHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>
<style lang="scss" scoped>
.plugin-folder-card {
position: relative;
overflow: hidden;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&--hover {
transform: translateY(-4px);
}
&__bg {
position: absolute;
z-index: 0;
inset: 0;
outline: none;
}
&__overlay {
position: absolute;
z-index: 1;
background: rgba(0, 0, 0, 60%);
inset: 0;
}
&__content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
padding: 16px;
block-size: 100%;
padding-block-end: 12px;
.plugin-folder-card--mobile & {
padding: 12px;
padding-block-end: 10px;
}
}
&__body {
display: flex;
flex: 1;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 16px;
padding-block: 0;
padding-inline: 8px;
.plugin-folder-card--mobile & {
gap: 12px;
padding-block: 0;
padding-inline: 4px;
}
&--no-icon {
align-items: flex-start;
justify-content: flex-start;
padding: 16px;
gap: 0;
.plugin-folder-card--mobile & {
padding: 12px;
gap: 0;
}
}
}
&__icon-container {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
&__info {
flex: 1;
min-block-size: 0;
text-align: start;
&--no-icon {
flex: none;
text-align: start;
}
}
&__name {
display: -webkit-box;
overflow: hidden;
margin: 0;
-webkit-box-orient: vertical;
color: white;
font-size: 1.1rem;
font-weight: 600;
-webkit-line-clamp: 1;
line-clamp: 1;
line-height: 1.3;
max-inline-size: none;
text-overflow: ellipsis;
text-shadow: 0 2px 4px rgba(0, 0, 0, 50%);
.plugin-folder-card--mobile & {
font-size: 1rem;
}
.plugin-folder-card__info--no-icon & {
font-size: 1.3rem;
font-weight: 700;
-webkit-line-clamp: 2;
line-clamp: 2;
margin-block-end: 4px;
.plugin-folder-card--mobile & {
font-size: 1.2rem;
}
}
}
&__count {
color: white;
font-size: 0.85rem;
margin-block: 2px 0;
margin-inline: 0;
opacity: 0.9;
text-shadow: 0 1px 2px rgba(0, 0, 0, 50%);
.plugin-folder-card--mobile & {
font-size: 0.8rem;
}
.plugin-folder-card__info--no-icon & {
font-size: 0.9rem;
margin-block-start: 0;
.plugin-folder-card--mobile & {
font-size: 0.85rem;
}
}
}
}
// 设置对话框样式
.icon-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
max-block-size: 200px;
overflow-y: auto;
}
.color-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
}
.gradient-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
max-block-size: 200px;
overflow-y: auto;
}
.color-btn {
border-radius: 8px !important;
block-size: 60px !important;
min-inline-size: 60px !important;
}
.gradient-btn {
border-radius: 8px !important;
block-size: 60px !important;
min-inline-size: 120px !important;
}
</style>

View File

@@ -0,0 +1,183 @@
<script lang="ts" setup>
import PluginCard from './PluginCard.vue'
import PluginFolderCard from './PluginFolderCard.vue'
interface MixedSortItem {
type: 'folder' | 'plugin'
id: string
data: any
order: number
}
interface Props {
item: MixedSortItem
pluginStatistics?: { [key: string]: number }
pluginActions?: { [key: string]: boolean }
showRemoveButton?: boolean
}
const props = withDefaults(defineProps<Props>(), {
pluginStatistics: () => ({}),
pluginActions: () => ({}),
showRemoveButton: false,
})
const emit = defineEmits<{
openFolder: [folderName: string]
deleteFolder: [folderName: string]
renameFolder: [oldName: string, newName: string]
updateFolderConfig: [folderName: string, config: any]
refreshData: []
actionDone: [pluginId: string]
removeFromFolder: [pluginId: string]
dropToFolder: [event: DragEvent, folderName: string]
}>()
// 拖拽事件处理
function handleDragOver(event: DragEvent) {
// 只有当拖拽的是插件时才允许放入文件夹
if (props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
event.dataTransfer!.dropEffect = 'move'
const target = event.currentTarget as HTMLElement
target.classList.add('drag-over')
}
}
function handleDragEnter(event: DragEvent) {
if (props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
}
}
function handleDragLeave(event: DragEvent) {
if (props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
const target = event.currentTarget as HTMLElement
target.classList.remove('drag-over')
}
}
function handleDropToFolder(event: DragEvent) {
if (props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
const target = event.currentTarget as HTMLElement
target.classList.remove('drag-over')
emit('dropToFolder', event, props.item.id)
}
}
</script>
<template>
<div class="mixed-sort-card-wrapper h-full">
<!-- 文件夹卡片 -->
<div
v-if="item.type === 'folder'"
class="drop-zone h-full"
:data-plugin-id="item.id"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@drop="handleDropToFolder"
>
<PluginFolderCard
:folder-name="item.data.name"
:plugin-count="item.data.pluginCount"
:folder-config="item.data.config"
@open="$emit('openFolder', item.id)"
@delete="$emit('deleteFolder', item.id)"
@rename="(oldName, newName) => $emit('renameFolder', oldName, newName)"
@update-config="(folderName, config) => $emit('updateFolderConfig', folderName, config)"
/>
</div>
<!-- 插件卡片 -->
<div v-else-if="item.type === 'plugin'" class="plugin-item-wrapper h-full" :data-plugin-id="item.id">
<PluginCard
:count="pluginStatistics[item.id] || 0"
:plugin="item.data"
:action="pluginActions[item.id] || false"
@remove="$emit('refreshData')"
@save="$emit('refreshData')"
@action-done="$emit('actionDone', item.id)"
/>
<!-- 移出文件夹按钮(仅在文件夹内显示) -->
<VBtn
v-if="showRemoveButton"
icon="mdi-folder-remove"
variant="text"
color="warning"
size="small"
class="remove-from-folder-btn"
@click="$emit('removeFromFolder', item.id)"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.mixed-sort-card-wrapper {
block-size: 100%;
inline-size: 100%;
// 确保拖拽时的边界清晰
&.sortable-chosen {
opacity: 0.5;
}
&.sortable-ghost {
border: 2px dashed #2196f3;
border-radius: 16px;
background: rgba(33, 150, 243, 10%);
opacity: 0.3;
}
}
// 拖拽相关样式
.drop-zone {
position: relative;
isolation: isolate; // 创建新的层叠上下文
transition: all 0.3s ease;
&.drag-over {
border: 2px dashed #2196f3;
border-radius: 16px;
box-shadow: 0 0 20px rgba(33, 150, 243, 50%);
transform: scale(1.02);
}
}
.plugin-item-wrapper {
position: relative;
isolation: isolate; // 创建新的层叠上下文
.remove-from-folder-btn {
position: absolute;
z-index: 10;
border-radius: 50%;
backdrop-filter: blur(4px);
background: rgba(255, 255, 255, 10%);
inset-block-start: 4px;
inset-inline-end: 4px;
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover .remove-from-folder-btn {
opacity: 1;
}
}
// 拖拽时的样式优化
.mixed-sort-card-wrapper.sortable-drag {
.remove-from-folder-btn {
display: none !important;
}
}
</style>

View File

@@ -37,7 +37,7 @@ function goPlay(isHovering: boolean | null = false) {
</script>
<template>
<VHover v-bind="props">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"

View File

@@ -1,7 +1,8 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import noImage from '@images/logos/site.webp'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'
import SiteResourceDialog from '../dialog/SiteResourceDialog.vue'
@@ -10,16 +11,24 @@ import api from '@/api'
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import { formatFileSize } from '@/@core/utils/formatters'
import { useConfirm } from 'vuetify-use-dialog'
import { useConfirm } from '@/composables/useConfirm'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
// 输入参数
const cardProps = defineProps({
site: Object as PropType<Site>,
data: Object as PropType<SiteUserData>,
stats: Object as PropType<SiteStatistic>,
})
// 定义触发的自定义事件
const emit = defineEmits(['update', 'remove'])
const emit = defineEmits(['update', 'remove', 'refresh-stats'])
// 确认框
const createConfirm = useConfirm()
@@ -31,7 +40,7 @@ const siteIcon = ref<string>('')
const $toast = useToast()
// 测试按钮文字
const testButtonText = ref('测试连通性')
const testButtonText = ref(t('site.testConnectivity'))
// 测试按钮可用性
const testButtonDisable = ref(false)
@@ -48,9 +57,6 @@ const resourceDialog = ref(false)
// 用户数据弹窗
const siteUserDataDialog = ref(false)
// 站点使用统计
const siteStats = ref<SiteStatistic>({})
// 查询站点图标
async function getSiteIcon() {
try {
@@ -66,26 +72,18 @@ async function getSiteIcon() {
// 测试站点连通性
async function testSite() {
try {
testButtonText.value = '测试中 ...'
testButtonText.value = t('site.testing')
testButtonDisable.value = true
const result: { [key: string]: any } = await api.get(`site/test/${cardProps.site?.id}`)
if (result.success) $toast.success(`${cardProps.site?.name} 连通性测试成功,可正常使用!`)
else $toast.error(`${cardProps.site?.name} 连通性测试失败:${result.message}`)
if (result.success) $toast.success(t('site.testSuccess', { name: cardProps.site?.name }))
else $toast.error(t('site.testFailed', { name: cardProps.site?.name, message: result.message }))
testButtonText.value = '测试连通性'
testButtonText.value = t('site.testConnectivity')
testButtonDisable.value = false
getSiteStats()
} catch (error) {
console.error(error)
}
}
// 查询站点使用统计
async function getSiteStats() {
try {
siteStats.value = await api.get(`site/statistic/${cardProps.site?.domain}`)
// 测试完成后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
} catch (error) {
console.error(error)
}
@@ -114,8 +112,8 @@ function openSitePage() {
// 调用API删除站点信息
async function deleteSiteInfo() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认删除站点?`,
title: t('common.confirm'),
content: t('site.deleteConfirm'),
})
if (!isConfirmed) return
@@ -123,25 +121,26 @@ async function deleteSiteInfo() {
try {
const result: { [key: string]: any } = await api.delete(`site/${cardProps.site?.id}`)
if (result.success) emit('remove')
else $toast.error(`${cardProps.site?.name} 删除失败:${result.message}`)
else $toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: result.message }))
} catch (error) {
$toast.error(`${cardProps.site?.name} 删除失败!`)
$toast.error(t('site.deleteFailed', { name: cardProps.site?.name, message: error }))
console.error(error)
}
}
// 根据站点状态显示不同的状态图标
const statColor = computed(() => {
if (isNullOrEmptyObject(siteStats.value)) {
if (!cardProps.stats || isNullOrEmptyObject(cardProps.stats)) {
return 'secondary'
}
if (siteStats.value?.lst_state == 1) {
if (cardProps.stats?.lst_state === 1) {
return 'error'
} else if (siteStats.value?.lst_state == 0) {
if (!siteStats.value?.seconds) return 'secondary'
if (siteStats.value?.seconds >= 5) return 'warning'
} else if (cardProps.stats?.lst_state === 0) {
if (!cardProps.stats?.seconds) return 'secondary'
if (cardProps.stats?.seconds >= 5) return 'warning'
return 'success'
}
return 'secondary'
})
// 数据百分比计算
@@ -177,19 +176,20 @@ function saveSite() {
// 更新站点Cookie UA后的回调
function onSiteCookieUpdated() {
siteCookieDialog.value = false
getSiteStats()
// Cookie更新后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
}
// 资源浏览弹窗关闭后的回调
function onSiteResourceDone() {
resourceDialog.value = false
getSiteStats()
// 资源操作完成后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
getSiteStats()
})
</script>
@@ -210,7 +210,7 @@ onMounted(() => {
elevation="0"
rounded="lg"
hover
@click="siteEditDialog = true"
@click="handleResourceBrowse"
>
<!-- 装饰性状态指示器 -->
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
@@ -220,7 +220,7 @@ onMounted(() => {
<!-- 顶部图标和站点名称 -->
<div class="flex items-center mb-1">
<!-- 站点图标 -->
<VAvatar tile rounded="lg" size="32" class="me-2 cursor-move">
<VAvatar tile rounded="lg" size="32" class="me-2" :class="{ 'cursor-move': display.mdAndUp.value }">
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
<template #placeholder>
<div class="w-full h-full">
@@ -289,21 +289,20 @@ onMounted(() => {
</div>
<!-- 右侧操作按钮区 -->
<VSheet
class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1 transform translate-x-full transition-transform duration-200"
>
<VSheet class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
<!-- 测试按钮 -->
<VBtn
icon
variant="text"
density="comfortable"
class="mb-1 relative w-10 h-10 min-w-10 flex items-center justify-center rounded-full"
class="mb-1 relative flex items-center justify-center rounded-full mx-auto"
:disabled="testButtonDisable"
@click.stop="testSite"
size="36"
>
<div class="relative flex items-center justify-center w-full h-full">
<div
class="w-[22px] h-[22px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
class="w-[20px] h-[20px] rounded-full shadow-[inset_0_0_0_2px_rgba(var(--v-theme-on-surface),0.1)] pulse-dot"
:class="statColor"
></div>
</div>
@@ -318,31 +317,31 @@ onMounted(() => {
</VBtn>
<!-- 用户数据按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUserData">
<VIcon icon="mdi-chart-bell-curve" size="small" />
<VBtn icon variant="text" @click.stop="handleSiteUserData" size="36">
<VIcon icon="mdi-chart-bell-curve" size="20" />
</VBtn>
<!-- 更新按钮 -->
<VBtn icon variant="text" @click.stop="handleSiteUpdate">
<VIcon icon="mdi-refresh" size="small" />
<VBtn icon variant="text" @click.stop="handleSiteUpdate" size="36">
<VIcon icon="mdi-refresh" size="20" />
</VBtn>
<!-- 更多选项按钮 -->
<VBtn icon variant="text" class="mt-auto">
<VIcon icon="mdi-dots-vertical" size="small" />
<VBtn icon variant="text" class="mt-auto" size="36">
<VIcon icon="mdi-dots-vertical" size="20" />
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
<VList>
<VListItem @click="handleResourceBrowse" base-color="info">
<VListItem @click="siteEditDialog = true" base-color="info">
<template #prepend>
<VIcon icon="mdi-web" size="small" />
<VIcon icon="mdi-file-edit-outline" size="20" />
</template>
<VListItemTitle>浏览资源</VListItemTitle>
<VListItemTitle>{{ t('site.actions.edit') }}</VListItemTitle>
</VListItem>
<VListItem @click="deleteSiteInfo">
<template #prepend>
<VIcon icon="mdi-delete-outline" size="small" color="error" />
<VIcon icon="mdi-delete-outline" size="20" color="error" />
</template>
<VListItemTitle class="text-error">删除站点</VListItemTitle>
<VListItemTitle class="text-error">{{ t('site.deleteSite') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
@@ -382,12 +381,6 @@ onMounted(() => {
</template>
<style scoped>
.site-card:hover {
.site-card-actions {
transform: translateX(0);
}
}
.site-status-indicator {
position: absolute;
z-index: 1;
@@ -426,15 +419,15 @@ onMounted(() => {
/* 上传下载条样式 */
.upload-bar {
animation: pulse-width 2s infinite;
background: linear-gradient(90deg, #4d79ff, #07f);
box-shadow: 0 0 4px rgba(0, 119, 255, 50%);
animation: pulse-width 2s infinite;
}
.download-bar {
animation: pulse-width 2s infinite;
background: linear-gradient(90deg, #42d392, #00b77e);
box-shadow: 0 0 4px rgba(0, 183, 126, 50%);
animation: pulse-width 2s infinite;
}
/* 测试状态点样式 */
@@ -442,22 +435,22 @@ onMounted(() => {
position: absolute;
z-index: 1;
border-radius: 50%;
block-size: 70%;
content: '';
height: 70%;
width: 70%;
top: 15%;
left: 15%;
inline-size: 70%;
inset-block-start: 15%;
inset-inline-start: 15%;
}
.pulse-dot::after {
position: absolute;
z-index: 2;
border-radius: 50%;
block-size: 100%;
content: '';
height: 100%;
width: 100%;
top: 0;
left: 0;
inline-size: 100%;
inset-block-start: 0;
inset-inline-start: 0;
}
.pulse-dot.error::before {
@@ -504,11 +497,11 @@ onMounted(() => {
.spinner-circle {
position: absolute;
border: 1px solid rgba(var(--v-theme-primary), 0.2);
border-top-color: rgba(var(--v-theme-primary), 1);
border-radius: 50%;
width: 100%;
height: 100%;
animation: spin 0.8s linear infinite;
block-size: 100%;
border-block-start-color: rgba(var(--v-theme-primary), 1);
inline-size: 100%;
}
/* 动画关键帧 */
@@ -518,6 +511,7 @@ onMounted(() => {
opacity: 0.85;
transform: scaleX(0.95);
}
50% {
opacity: 1;
transform: scaleX(1.05);
@@ -528,9 +522,11 @@ onMounted(() => {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-error), 0.6);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-error), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-error), 0);
}
@@ -540,9 +536,11 @@ onMounted(() => {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-warning), 0.6);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-warning), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-warning), 0);
}
@@ -552,9 +550,11 @@ onMounted(() => {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0.6);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-success), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-success), 0);
}
@@ -564,9 +564,11 @@ onMounted(() => {
0% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-secondary), 0.6);
}
70% {
box-shadow: 0 0 0 10px rgba(var(--v-theme-secondary), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--v-theme-secondary), 0);
}
@@ -576,6 +578,7 @@ onMounted(() => {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
@@ -585,8 +588,22 @@ onMounted(() => {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.site-card-actions {
opacity: 0;
transform: translateX(100%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
visibility: hidden;
}
.site-card:hover .site-card-actions {
opacity: 1;
transform: translateX(0);
visibility: visible;
}
</style>

View File

@@ -5,14 +5,25 @@ import storage_png from '@images/misc/storage.png'
import alipan_png from '@images/misc/alipan.webp'
import u115_png from '@images/misc/u115.png'
import rclone_png from '@images/misc/rclone.png'
import alist_png from '@images/misc/alist.svg'
import alist_png from '@images/misc/openlist.svg'
import custom_png from '@images/misc/database.png'
import smb_png from '@images/misc/smb.png'
import api from '@/api'
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
import RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'
import AlistConfigDialog from '../dialog/AlistConfigDialog.vue'
import { useToast } from 'vue-toast-notification'
import SmbConfigDialog from '../dialog/SmbConfigDialog.vue'
import { useToast } from 'vue-toastification'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -23,7 +34,7 @@ const props = defineProps({
})
// 定义事件
const emit = defineEmits(['done'])
const emit = defineEmits(['done', 'close'])
// 提示信息
const $toast = useToast()
@@ -39,6 +50,15 @@ const used = computed(() => {
return total.value - available.value
})
// 存储
const storage_ref = ref(props.storage)
// 自定义存储名称
const customName = ref(props.storage.name)
// 自定义存储类型
const storageType = ref(props.storage.type)
// 阿里云盘认证对话框
const aliyunAuthDialog = ref(false)
// 115网盘认证对话框
@@ -47,6 +67,10 @@ const u115AuthDialog = ref(false)
const rcloneConfigDialog = ref(false)
// AList配置对话框
const aListConfigDialog = ref(false)
// SMB配置对话框
const smbConfigDialog = ref(false)
// 自定义存储配置对话框
const customConfigDialog = ref(false)
// 打开存储对话框
function openStorageDialog() {
@@ -63,8 +87,14 @@ function openStorageDialog() {
case 'alist':
aListConfigDialog.value = true
break
case 'smb':
smbConfigDialog.value = true
break
case 'local':
$toast.info(t('storage.noConfigNeeded'))
break
default:
$toast.info('此存储类型无需配置参数,请直接配置目录!')
customConfigDialog.value = true
break
}
}
@@ -82,8 +112,10 @@ const getIcon = computed(() => {
return rclone_png
case 'alist':
return alist_png
case 'smb':
return smb_png
default:
return storage_png
return custom_png
}
})
@@ -120,23 +152,34 @@ function handleDone() {
u115AuthDialog.value = false
rcloneConfigDialog.value = false
aListConfigDialog.value = false
emit('done')
smbConfigDialog.value = false
customConfigDialog.value = false
// 更新存储
storage_ref.value.name = customName.value
storage_ref.value.type = storageType.value
emit('done', storage_ref.value)
}
onMounted(() => {
queryStorage()
})
// 关闭
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" @click="openStorageDialog">
<VDialogCloseBtn @click="onClose" class="absolute top-1 right-1" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">未配置</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
</div>
<VImg :src="getIcon" cover class="mt-5" max-width="3rem" min-width="3rem" />
<VImg :src="getIcon" cover class="mt-8" max-width="3rem" min-width="3rem" />
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
@@ -170,5 +213,56 @@ onMounted(() => {
@close="aListConfigDialog = false"
@done="handleDone"
/>
<SmbConfigDialog
v-if="smbConfigDialog"
v-model="smbConfigDialog"
:conf="props.storage.config || {}"
@close="smbConfigDialog = false"
@done="handleDone"
/>
<VDialog
v-if="customConfigDialog"
v-model="customConfigDialog"
scrollable
max-width="30rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-cog" />
</template>
<VCardTitle>{{ t('storage.custom') }}</VCardTitle>
<VDialogCloseBtn v-model="customConfigDialog" />
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="storageType"
:label="t('storage.type')"
:hint="t('storage.customTypeHint')"
persistent-hint
prepend-inner-icon="mdi-database"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="customName"
:label="t('storage.name')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue'
import SubscribeShareDialog from '../dialog/SubscribeShareDialog.vue'
@@ -8,6 +8,15 @@ import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
import api from '@/api'
import type { Subscribe } from '@/api/types'
import router from '@/router'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -15,7 +24,9 @@ const props = defineProps({
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
@@ -88,22 +99,22 @@ async function searchSubscribe() {
async function toggleSubscribeStatus(state: 'R' | 'S') {
try {
// 根据传入的 state 判断对应的操作文字
const action = state === 'S' ? '暂停' : '启用'
const action = state === 'S' ? t('common.pause') : t('common.enable')
// 弹出确认框
const isConfirmed = await createConfirm({
title: `确认${action}`,
content: `是否${action}订阅 ${props.media?.name}`,
title: t('common.confirmAction', { action }),
content: t('subscribe.confirmToggle', { action, name: props.media?.name }),
})
if (!isConfirmed) return
// 调用 API 更新订阅状态
const result: { [key: string]: any } = await api.put(`subscribe/status/${props.media?.id}?state=${state}`)
// 提示
if (result.success) {
$toast.success(`${props.media?.name}${action}`)
$toast.success(t('subscribe.toggleSuccess', { name: props.media?.name, action }))
subscribeState.value = state
emit('save')
} else {
$toast.error(`${action}失败:${result.message}`)
$toast.error(t('subscribe.toggleFailed', { action, message: result.message }))
}
} catch (e) {
console.log(e)
@@ -115,18 +126,18 @@ async function resetSubscribe() {
// 确认
try {
const isConfirmed = await createConfirm({
title: '确认',
content: `重置后 ${props.media?.name} 将恢复初始状态,已下载记录将被清除,未入库的内容将会重新下载,是否确认?`,
title: t('common.confirm'),
content: t('subscribe.resetConfirm', { name: props.media?.name }),
})
if (!isConfirmed) return
// 重置
const result: { [key: string]: any } = await api.get(`subscribe/reset/${props.media?.id}`)
// 提示
if (result.success) {
$toast.success(`${props.media?.name} 重置成功!`)
$toast.success(t('subscribe.resetSuccess', { name: props.media?.name }))
subscribeState.value = 'R'
emit('save')
} else $toast.error(`${props.media?.name} 重置失败:${result.message}`)
} else $toast.error(t('subscribe.resetFailed', { name: props.media?.name, message: result.message }))
} catch (e) {
console.log(e)
}
@@ -171,7 +182,7 @@ async function viewSubscribeFiles() {
// 弹出菜单
const dropdownItems = computed(() => [
{
title: '编辑',
title: t('common.edit'),
value: 1,
props: {
prependIcon: 'mdi-file-edit-outline',
@@ -179,7 +190,7 @@ const dropdownItems = computed(() => [
},
},
{
title: '搜索',
title: t('common.search'),
value: 2,
props: {
prependIcon: 'mdi-magnify',
@@ -187,7 +198,7 @@ const dropdownItems = computed(() => [
},
},
{
title: '详情',
title: t('common.details'),
value: 3,
props: {
prependIcon: 'mdi-information-outline',
@@ -195,7 +206,7 @@ const dropdownItems = computed(() => [
},
},
{
title: '文件',
title: t('common.files'),
value: 4,
props: {
prependIcon: 'mdi-file-document-outline',
@@ -203,7 +214,7 @@ const dropdownItems = computed(() => [
},
},
{
title: subscribeState.value === 'S' ? '启用' : '暂停',
title: subscribeState.value === 'S' ? t('common.enable') : t('common.pause'),
value: 5,
props: {
prependIcon: subscribeState.value === 'S' ? 'mdi-play' : 'mdi-pause',
@@ -212,7 +223,7 @@ const dropdownItems = computed(() => [
},
},
{
title: '重置',
title: t('common.reset'),
value: 6,
props: {
prependIcon: 'mdi-restore-alert',
@@ -221,7 +232,7 @@ const dropdownItems = computed(() => [
},
},
{
title: '分享',
title: t('common.share'),
value: 7,
props: {
prependIcon: 'mdi-share',
@@ -231,7 +242,7 @@ const dropdownItems = computed(() => [
show: props.media?.type === '电视剧',
},
{
title: '取消订阅',
title: t('common.unsubscribe'),
value: 8,
props: {
prependIcon: 'mdi-trash-can-outline',
@@ -292,99 +303,116 @@ function onSubscribeEditRemove() {
<div>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
<div
class="w-full h-full rounded-lg overflow-hidden"
:class="{
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'opacity-70': subscribeState === 'S',
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
}"
min-height="170"
@click="editSubscribeDialog"
:ripple="false"
>
<div class="me-n3 absolute top-1 right-2">
<IconBtn>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</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 py-3">
<div class="h-auto w-16 flex-shrink-0 overflow-hidden rounded-md cursor-move" v-if="imageLoaded">
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
<div class="mr-2 min-w-0 text-lg font-bold text-white">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap py-3">
<div class="flex align-center">
<IconBtn
v-if="props.media?.total_episode"
v-bind="props"
icon="mdi-progress-download"
color="white"
class="me-1"
/>
<div v-if="props.media?.season" class="text-subtitle-2 me-4 text-white">
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}
</div>
<IconBtn v-if="props.media?.username" icon="mdi-account" color="white" class="me-1" />
<span v-if="props.media?.username" class="text-subtitle-2 me-4 text-white">
{{ props.media?.username }}
</span>
</div>
</VCardText>
<VCardText v-if="lastUpdateText" class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear
v-if="getPercentage() > 0"
:model-value="getPercentage()"
bg-color="success"
color="success"
/>
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
:class="{
'opacity-70': subscribeState === 'S',
}"
rounded="0"
min-height="150"
@click="editSubscribeDialog"
:ripple="false"
>
<div class="me-n3 absolute top-1 right-4">
<IconBtn>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
</div>
</VCard>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</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"
v-if="imageLoaded"
:class="{ 'cursor-move': display.mdAndUp.value }"
>
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
<div class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ...">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap px-3">
<div class="flex align-center">
<IconBtn
v-if="props.media?.total_episode"
size="small"
v-bind="props"
icon="mdi-progress-download"
color="white"
/>
<div v-if="props.media?.season" class="text-subtitle-2 me-2 text-white">
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}
</div>
<IconBtn v-if="props.media?.username" icon="mdi-account" size="small" color="white" />
<span v-if="props.media?.username" class="text-subtitle-2 text-white">
{{ props.media?.username }}
</span>
</div>
</VCardText>
<VCardText
v-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">
<VProgressLinear
v-if="getPercentage() > 0"
:model-value="getPercentage()"
bg-color="success"
color="success"
/>
</div>
</div>
</VCard>
</div>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->

View File

@@ -4,6 +4,7 @@ import type { SubscribeShare } from '@/api/types'
import router from '@/router'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import ForkSubscribeDialog from '../dialog/ForkSubscribeDialog.vue'
import { useGlobalSettingsStore } from '@/stores'
// 输入参数
const props = defineProps({
@@ -14,7 +15,9 @@ const props = defineProps({
const emit = defineEmits(['delete'])
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 图片是否加载完成
const imageLoaded = ref(false)
@@ -97,64 +100,69 @@ function doDelete() {
<div class="h-full">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
<div
class="w-full h-full rounded-lg overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
min-height="170"
@click="showForkSubscribe"
>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
rounded="0"
min-height="150"
@click="showForkSubscribe"
>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
</template>
<div class="h-full flex flex-col">
<VCardText class="flex items-center pa-3 pb-1 grow">
<div class="h-auto w-16 flex-shrink-0 overflow-hidden rounded-md" v-if="imageLoaded">
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
</template>
<div class="absolute inset-0 subscribe-card-background"></div>
</VImg>
</template>
<div class="h-full flex flex-col">
<VCardText class="flex items-center pb-1 grow">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md" v-if="imageLoaded">
<VImg :src="posterUrl" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="flex flex-col justify-center pl-2 xl:pl-4">
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.share_title }}
<div class="flex flex-col justify-center pl-2 xl:pl-4">
<div class="mr-2 min-w-0 text-lg font-bold text-white line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.share_title }}
</div>
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
{{ props.media?.share_comment }}
</div>
</div>
<div class="text-sm font-medium text-gray-200 sm:pt-1 line-clamp-3 overflow-hidden text-ellipsis ...">
{{ props.media?.share_comment }}
</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" color="white" class="me-1" />
<div class="text-subtitle-2 me-4 text-white">
{{ props.media?.share_user }}
</div>
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
{{ props.media?.count.toLocaleString() }}
</span>
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap">
<div class="flex align-center">
<IconBtn v-bind="props" icon="mdi-account" color="white" class="me-1" />
<div class="text-subtitle-2 me-4 text-white">
{{ props.media?.share_user }}
</div>
<IconBtn v-if="props.media?.count" icon="mdi-fire" color="white" class="me-1" />
<span v-if="props.media?.count" class="text-subtitle-2 me-4 text-white">
{{ props.media?.count.toLocaleString() }}
</span>
</div>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-calcdar" class="me-1" />
{{ dateText }}
</VCardText>
</div>
</VCard>
</VCardText>
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300">
<VIcon icon="mdi-calcdar" class="me-1" />
{{ dateText }}
</VCardText>
</div>
</VCard>
</div>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { formatFileSize } from '@/@core/utils/formatters'
import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import type { Context } from '@/api/types'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
@@ -70,7 +70,11 @@ async function handleAddDownload(item: Context | null = null) {
}
// 打开种子详情页面
function openTorrentDetail() {
function openTorrentDetail(item: Context | null = null) {
if (item && !isNullOrEmptyObject(item) && !isNullOrEmptyObject(item.torrent_info)) {
window.open(item.torrent_info.page_url, '_blank')
return
}
window.open(torrent.value?.page_url, '_blank')
}
@@ -192,8 +196,19 @@ onMounted(() => {
{{ meta?.subtitle || torrent?.description }}
</div>
<!-- 发布时间 -->
<div v-if="torrent?.pubdate" class="d-flex align-center justify-start mb-2">
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
<span class="text-sm text-medium-emphasis">{{ formatDateDifference(torrent.pubdate) }}</span>
</div>
<!-- 资源标签区 -->
<div class="d-flex flex-wrap gap-1 mb-2">
<!-- 流媒体平台 -->
<VChip v-if="meta?.web_source" class="chip-web-source rounded-sm" size="x-small" variant="elevated">
{{ meta?.web_source }}
</VChip>
<!-- 版本标签 -->
<VChip v-if="meta?.edition" class="chip-edition rounded-sm" size="x-small" variant="elevated">
{{ meta?.edition }}
@@ -255,7 +270,7 @@ onMounted(() => {
<VChip v-if="torrent?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm mr-2">
{{ formatFileSize(torrent.size) }}
</VChip>
<VBtn icon size="small" variant="text" color="primary" @click.stop="openTorrentDetail">
<VBtn icon size="small" variant="text" color="primary" @click.stop="openTorrentDetail()">
<VIcon icon="mdi-information-outline"></VIcon>
</VBtn>
</div>
@@ -279,7 +294,7 @@ onMounted(() => {
v-for="(item, index) in props.more"
:key="index"
@click.stop="handleAddDownload(item)"
class="border-b border-opacity-5 hover:bg-primary-lighten-5"
class="hover:bg-primary-lighten-5"
>
<template v-slot:prepend>
<div class="d-flex align-center gap-1">
@@ -333,7 +348,7 @@ onMounted(() => {
</span>
<span>
<VIcon
@click.stop="openTorrentDetail"
@click.stop="openTorrentDetail(item)"
size="small"
color="secondary"
icon="mdi-arrow-top-right"
@@ -402,6 +417,11 @@ onMounted(() => {
color: white;
}
.chip-web-source {
background-color: #8000FF;
color: white;
}
.chip-edition {
background-color: #f44336;
color: white;

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { formatFileSize } from '@/@core/utils/formatters'
import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import type { Context } from '@/api/types'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
@@ -121,19 +121,24 @@ onMounted(() => {
</div>
<template v-slot:prepend>
<div class="d-flex align-center">
<img v-if="siteIcon" :src="siteIcon" :alt="torrent?.site_name" class="rounded mr-2" width="32" height="32" />
<VAvatar v-else size="24" class="mr-2 text-caption bg-primary-lighten-4 text-primary font-weight-bold">
<div class="d-flex flex-column align-center pr-3">
<VImg v-if="siteIcon" :src="siteIcon" :alt="torrent?.site_name" class="rounded mb-1" width="32" height="32" />
<VAvatar v-else size="24" class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold">
{{ torrent?.site_name?.substring(0, 1) }}
</VAvatar>
<div class="font-weight-bold text-body-2 d-none d-sm-block">{{ torrent?.site_name }}</div>
<div class="font-weight-bold text-body-2 text-center d-none d-sm-block">{{ torrent?.site_name }}</div>
</div>
</template>
<VListItemTitle>
<div class="d-flex flex-row flex-wrap align-center mb-2">
<span class="text-h6 font-weight-bold me-2">{{ media?.title ?? meta?.name }}</span>
<VChip v-if="meta?.season_episode" class="chip-season rounded-sm font-weight-bold" variant="elevated">
<VChip
v-if="meta?.season_episode"
class="chip-season rounded-sm font-weight-bold"
variant="elevated"
size="small"
>
{{ meta?.season_episode }}
</VChip>
</div>
@@ -149,7 +154,18 @@ onMounted(() => {
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
</div>
<!-- 发布时间 -->
<div v-if="torrent?.pubdate" class="d-flex align-center mb-2">
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
<span class="text-sm text-medium-emphasis">{{ formatDateDifference(torrent.pubdate) }}</span>
</div>
<div class="d-flex flex-wrap gap-1 mb-2">
<!-- 流媒体平台 -->
<VChip v-if="meta?.web_source" class="chip-web-source rounded-sm" size="x-small" variant="elevated">
{{ meta?.web_source }}
</VChip>
<!-- 版本标签 -->
<VChip v-if="meta?.edition" class="chip-edition rounded-sm" size="x-small" variant="elevated">
{{ meta?.edition }}
@@ -249,6 +265,11 @@ onMounted(() => {
color: white;
}
.chip-web-source {
background-color: #8000ff;
color: white;
}
.chip-edition {
background-color: #f44336;
color: white;

View File

@@ -3,10 +3,14 @@ import api from '@/api'
import { Subscribe, User } from '@/api/types'
import { useUserStore } from '@/stores'
import avatar1 from '@images/avatars/avatar-1.png'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 扩展User类型以包含昵称字段
interface ExtendedUser extends User {
@@ -77,21 +81,21 @@ async function fetchSubscriptions() {
// 删除用户
async function removeUser() {
if (props.user.id === currentLoginUserId.value) {
$toast.error('不能删除当前登录用户!')
$toast.error(t('user.cannotDeleteCurrentUser'))
return
}
try {
const isConfirmed = await createConfirm({
title: '注意',
content: `删除用户 ${props.user?.name} 的所有数据,是否确认?`,
title: t('common.confirm'),
content: t('user.confirmDeleteUser', { username: props.user?.name }),
})
if (!isConfirmed) return
const result: { [key: string]: any } = await api.delete(`user/id/${props.user.id}`)
if (result.success) {
$toast.success('用户删除成功')
$toast.success(t('user.deleteSuccess'))
emit('remove')
} else {
$toast.error('用户删除失败!')
$toast.error(t('user.deleteFailed'))
}
} catch (error) {
console.log(error)
@@ -119,155 +123,176 @@ onMounted(() => {
'transition-transform duration-300 hover:-translate-y-1',
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
]"
class="flex flex-column"
@click="userEditDialog = true"
>
<!-- 用户头像和基本信息 -->
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
<template v-slot:prepend>
<div class="position-relative mr-4">
<VAvatar
size="72"
rounded="lg"
:class="[
user.is_superuser ? 'admin-avatar' : 'border-4 bg-surface',
!user.is_active ? 'grayscale-50 opacity-90' : '',
]"
:style="user.is_superuser ? 'border: 4px solid rgba(var(--v-theme-warning), 0.3);' : ''"
>
<VImg :src="user.avatar || avatar1" :alt="user.name" />
<div
v-if="!user.is_active"
class="position-absolute d-flex align-center justify-center rounded-lg bg-surface-variant opacity-20"
style="inset: 0"
>
<VIcon icon="mdi-account-lock" color="white" />
</div>
</VAvatar>
<div v-if="user.is_superuser" class="admin-crown">
<VIcon icon="mdi-crown" color="warning" />
</div>
</div>
</template>
<VCardTitle class="pa-0 d-flex flex-column">
<div class="d-flex flex-column mb-1">
<div class="d-flex align-center">
<span
<div class="flex-grow">
<!-- 用户头像和基本信息 -->
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
<template v-slot:prepend>
<div class="position-relative mr-4">
<VAvatar
size="72"
rounded="lg"
:class="[
'text-h6 font-weight-bold truncate',
user.is_superuser ? 'text-warning' : '',
!user.is_active ? 'text-medium-emphasis' : '',
user.is_superuser ? 'admin-avatar' : 'border-4 bg-surface',
!user.is_active ? 'grayscale-50 opacity-90' : '',
]"
:style="user.is_superuser ? 'border: 4px solid rgba(var(--v-theme-warning), 0.3);' : ''"
>
{{ displayName }}
<VIcon
v-if="user.nickname || user.settings?.nickname"
icon="mdi-format-quote-close"
size="x-small"
color="info"
class="animate-pulse"
/>
</span>
<VImg :src="user.avatar || avatar1" :alt="user.name" />
<div
v-if="!user.is_active"
class="position-absolute d-flex align-center justify-center rounded-lg bg-surface-variant opacity-20"
style="inset: 0"
>
<VIcon icon="mdi-account-lock" color="white" />
</div>
</VAvatar>
<div v-if="user.is_superuser" class="admin-crown">
<VIcon icon="mdi-crown" color="warning" />
</div>
</div>
<div class="d-flex flex-wrap gap-1 overflow-auto">
<VChip v-if="user.is_superuser" size="x-small" color="error" variant="outlined" label>管理员</VChip>
<VChip v-else size="x-small" label>普通用户</VChip>
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" label>
{{ user.is_active ? '激活' : '已停用' }}
</VChip>
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" label>2FA</VChip>
</template>
<VCardTitle class="pa-0 d-flex flex-column">
<div class="d-flex flex-column mb-1">
<div class="d-flex align-center">
<span
:class="[
'text-h6 font-weight-bold truncate',
user.is_superuser ? 'text-warning' : '',
!user.is_active ? 'text-medium-emphasis' : '',
]"
>
{{ displayName }}
<VIcon
v-if="user.nickname || user.settings?.nickname"
icon="mdi-format-quote-close"
size="x-small"
color="info"
class="animate-pulse"
/>
</span>
</div>
<div class="d-flex flex-wrap gap-1 overflow-auto">
<VChip v-if="user.is_superuser" size="x-small" color="error" variant="outlined" label>{{
t('user.admin')
}}</VChip>
<VChip v-else size="x-small" label>{{ t('user.normal') }}</VChip>
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" label>
{{ user.is_active ? t('user.active') : t('user.inactive') }}
</VChip>
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" label>2FA</VChip>
</div>
</div>
</div>
<!-- 移动端订阅数据信息 -->
<div v-if="isMobile" class="d-flex gap-5 mt-2">
<div class="d-flex align-center">
<VIcon size="x-small" icon="mdi-movie-outline" color="primary" class="mr-1" />
<span class="text-body-2">{{ movieSubscriptions }}</span>
<!-- 移动端订阅数据信息 -->
<div v-if="isMobile" class="d-flex gap-5 mt-2">
<div class="d-flex align-center">
<VIcon size="x-small" icon="mdi-movie-outline" color="primary" class="mr-1" />
<span class="text-body-2">{{ movieSubscriptions }}</span>
</div>
<div class="d-flex align-center">
<VIcon size="x-small" icon="mdi-television-classic" color="primary" class="mr-1" />
<span class="text-body-2">{{ tvShowSubscriptions }}</span>
</div>
</div>
<div class="d-flex align-center">
<VIcon size="x-small" icon="mdi-television-classic" color="primary" class="mr-1" />
<span class="text-body-2">{{ tvShowSubscriptions }}</span>
</VCardTitle>
<!-- 头部操作按钮 -->
<template v-slot:append>
<div :class="['d-flex', isMobile ? 'position-absolute top-2 right-2' : '']">
<VBtn
icon
size="small"
:color="user.is_superuser ? 'warning' : 'primary'"
variant="text"
class="opacity-70 hover:opacity-100 transition-opacity"
@click.stop="editUser"
>
<VIcon icon="mdi-pencil" />
</VBtn>
<VBtn
v-if="props.user.id != currentLoginUserId && currentUserIsSuperuser"
icon
size="small"
color="error"
variant="text"
class="opacity-70 hover:opacity-100 transition-opacity"
@click.stop="removeUser"
>
<VIcon icon="mdi-delete" />
</VBtn>
</div>
</div>
</VCardTitle>
<!-- 头部操作按钮 -->
<template v-slot:append>
<div :class="['d-flex', isMobile ? 'position-absolute top-2 right-2' : '']">
<VBtn
icon
size="small"
:color="user.is_superuser ? 'warning' : 'primary'"
variant="text"
class="opacity-70 hover:opacity-100 transition-opacity"
@click.stop="editUser"
>
<VIcon icon="mdi-pencil" />
</VBtn>
<VBtn
v-if="props.user.id != currentLoginUserId && currentUserIsSuperuser"
icon
size="small"
color="error"
variant="text"
class="opacity-70 hover:opacity-100 transition-opacity"
@click.stop="removeUser"
>
<VIcon icon="mdi-delete" />
</VBtn>
</div>
</template>
</VCardItem>
</template>
</VCardItem>
<!-- 权限显示 -->
<div v-if="!user.is_superuser && user.permissions" class="d-flex flex-wrap gap-1 px-7 pb-3">
<VChip v-if="user.permissions.discovery" size="x-small" color="purple" variant="outlined" label>
{{ t('dialog.userAddEdit.permissions.discovery') }}
</VChip>
<VChip v-if="user.permissions.search" size="x-small" color="blue" variant="outlined" label>
{{ t('dialog.userAddEdit.permissions.search') }}
</VChip>
<VChip v-if="user.permissions.subscribe" size="x-small" color="green" variant="outlined" label>
{{ t('dialog.userAddEdit.permissions.subscribe') }}
</VChip>
<VChip v-if="user.permissions.manage" size="x-small" color="orange" variant="outlined" label>
{{ t('dialog.userAddEdit.permissions.manage') }}
</VChip>
</div>
</div>
<!-- 独立的邮箱显示 -->
<VDivider class="mx-4" />
<div>
<VCardText class="d-flex align-center py-2 px-4 text-medium-emphasis">
<VIcon icon="mdi-email-outline" size="small" color="primary" class="mr-2 opacity-70" />
<span class="text-body-2 truncate">{{ user.email || t('user.noEmail') }}</span>
</VCardText>
<VCardText class="d-flex align-center py-2 px-4 text-medium-emphasis">
<VIcon icon="mdi-email-outline" size="small" color="primary" class="mr-2 opacity-70" />
<span class="text-body-2 truncate">{{ user.email || '未设置邮箱' }}</span>
</VCardText>
<!-- PC端显示订阅统计信息 -->
<VCardText v-if="!isMobile" class="px-4 pt-0 pb-4">
<div rounded="lg" class="d-flex justify-space-around pa-3">
<div class="d-flex align-center gap-3">
<VAvatar
tile
rounded="lg"
size="large"
class="mr-1"
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
>
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-movie-outline" size="20" />
<!-- PC端显示订阅统计信息 -->
<VCardText v-if="!isMobile" class="px-4 pt-0 pb-4">
<div rounded="lg" class="d-flex justify-space-around">
<div class="d-flex align-center gap-3">
<VAvatar
tile
rounded="lg"
size="large"
class="mr-1"
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
>
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-movie-outline" size="20" />
</div>
</VAvatar>
<div class="d-flex flex-column">
<span class="text-lg text-medium-emphasis font-weight-bold">{{ movieSubscriptions }}</span>
<span class="text-caption text-medium-emphasis">{{ t('user.movieSubscriptions') }}</span>
</div>
</div>
<div class="d-flex align-center gap-3">
<VAvatar
tile
rounded="lg"
size="large"
class="mr-1"
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
>
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-television-classic" />
</div>
</VAvatar>
<div class="d-flex flex-column">
<span class="text-lg text-medium-emphasis">{{ tvShowSubscriptions }}</span>
<span class="text-caption text-medium-emphasis">{{ t('user.tvSubscriptions') }}</span>
</div>
</VAvatar>
<div class="d-flex flex-column">
<span class="text-lg text-medium-emphasis font-weight-bold">{{ movieSubscriptions }}</span>
<span class="text-caption text-medium-emphasis">电影订阅</span>
</div>
</div>
<div class="d-flex align-center gap-3">
<VAvatar
tile
rounded="lg"
size="large"
class="mr-1"
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
>
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-television-classic" />
</div>
</VAvatar>
<div class="d-flex flex-column">
<span class="text-lg text-medium-emphasis">{{ tvShowSubscriptions }}</span>
<span class="text-caption text-medium-emphasis">剧集订阅</span>
</div>
</div>
</div>
</VCardText>
</VCardText>
</div>
</VCard>
<!-- 用户编辑弹窗 -->
@@ -288,9 +313,10 @@ onMounted(() => {
z-index: 1;
display: flex;
align-items: center;
width: 100%;
top: 0;
padding: 8px 12px;
inline-size: 100%;
inset-block-start: 0;
padding-block: 8px;
padding-inline: 12px;
}
.admin-header {
@@ -320,10 +346,12 @@ onMounted(() => {
opacity: 0.6;
transform: scale(0.95);
}
70% {
opacity: 0.2;
transform: scale(1.05);
}
100% {
opacity: 0.6;
transform: scale(0.95);
@@ -334,19 +362,21 @@ onMounted(() => {
position: absolute;
z-index: 5;
animation: float 3s ease-in-out infinite;
top: -10px;
left: -6px;
transform: rotate(-25deg);
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 40%));
inset-block-start: -10px;
inset-inline-start: -6px;
transform: rotate(-25deg);
}
@keyframes float {
0% {
transform: rotate(-25deg) translateY(0);
}
50% {
transform: rotate(-25deg) translateY(-3px);
}
100% {
transform: rotate(-25deg) translateY(0);
}
@@ -362,6 +392,7 @@ onMounted(() => {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);

View File

@@ -1,10 +1,13 @@
<script lang="ts" setup>
import { Workflow } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
import api from '@/api'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 定义输入参数
const props = defineProps({
@@ -42,11 +45,6 @@ function handleFlow(item: Workflow) {
flowDialog.value = true
}
// 计算已完成的动作数
function resolveDoneActions(item: Workflow) {
return item.current_action?.split(',').length || 0
}
// 编辑完成
function editDone() {
editDialog.value = false
@@ -57,8 +55,8 @@ function editDone() {
// 删除任务
async function handleDelete(item: Workflow) {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认删除任务 ${item.name} ?`,
title: t('common.confirm'),
content: t('workflow.task.confirmDelete', { name: item.name }),
})
if (!isConfirmed) return
@@ -66,10 +64,10 @@ async function handleDelete(item: Workflow) {
try {
const result: { [key: string]: string } = await api.delete(`workflow/${item.id}`)
if (result.success) {
$toast.success('删除任务成功!')
$toast.success(t('workflow.task.deleteSuccess'))
emit('refresh')
} else {
$toast.error(`删除任务失败:${result.message}`)
$toast.error(t('workflow.task.deleteFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
@@ -82,10 +80,10 @@ async function handleEnable(item: Workflow) {
try {
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/start`)
if (result.success) {
$toast.success('启用任务成功!')
$toast.success(t('workflow.task.enableSuccess'))
emit('refresh')
} else {
$toast.error(`启用任务失败:${result.message}`)
$toast.error(t('workflow.task.enableFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
@@ -99,10 +97,10 @@ async function handlePause(item: Workflow) {
try {
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/pause`)
if (result.success) {
$toast.success('停用任务成功!')
$toast.success(t('workflow.task.pauseSuccess'))
emit('refresh')
} else {
$toast.error(`停用任务失败:${result.message}`)
$toast.error(t('workflow.task.pauseFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
@@ -121,10 +119,10 @@ async function handleRun(item: Workflow, from_begin: boolean) {
from_begin,
})
if (result.success) {
$toast.success('任务执行完成!')
$toast.success(t('workflow.task.runSuccess'))
emit('refresh')
} else {
$toast.error(`任务执行失败:${result.message}`)
$toast.error(t('workflow.task.runFailed', { message: result.message }))
emit('refresh')
}
} catch (error) {
@@ -136,8 +134,8 @@ async function handleRun(item: Workflow, from_begin: boolean) {
// 重置任务
async function handleReset(item: Workflow) {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认重置任务 ${item.name} ?`,
title: t('common.confirm'),
content: t('workflow.task.confirmReset', { name: item.name }),
})
if (!isConfirmed) return
@@ -145,10 +143,10 @@ async function handleReset(item: Workflow) {
try {
const result: { [key: string]: string } = await api.post(`workflow/${item.id}/reset`)
if (result.success) {
$toast.success('重置任务成功!')
$toast.success(t('workflow.task.resetSuccess'))
emit('refresh')
} else {
$toast.error(`重置任务失败:${result.message}`)
$toast.error(t('workflow.task.resetFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
@@ -157,11 +155,11 @@ async function handleReset(item: Workflow) {
// 计算状态颜色
const resolveStatusVariant = (status: string | undefined) => {
if (status === 'S') return { color: 'success', text: '成功' }
else if (status === 'R') return { color: 'primary', text: '运行中' }
else if (status === 'F') return { color: 'error', text: '失败' }
else if (status === 'P') return { color: 'secondary', text: '暂停' }
else return { color: 'info', text: '等待' }
if (status === 'S') return { color: 'success', text: t('workflow.task.status.success') }
else if (status === 'R') return { color: 'primary', text: t('workflow.task.status.running') }
else if (status === 'F') return { color: 'error', text: t('workflow.task.status.failed') }
else if (status === 'P') return { color: 'secondary', text: t('workflow.task.status.paused') }
else return { color: 'info', text: t('workflow.task.status.waiting') }
}
// 计算当前动作占比
@@ -181,7 +179,13 @@ const resolveProgress = (item: Workflow) => {
:loading="loading"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<VCardItem class="py-3" :class="`bg-${resolveStatusVariant(workflow?.state).color}`">
<VCardItem
:class="{
'py-1': workflow?.description,
'py-3': !workflow?.description,
[`bg-${resolveStatusVariant(workflow?.state).color}`]: true,
}"
>
<template #prepend>
<VAvatar variant="text" class="me-2">
<VIcon
@@ -210,37 +214,37 @@ const resolveProgress = (item: Workflow) => {
<template #prepend>
<VIcon icon="mdi-note-edit" />
</template>
<VListItemTitle>编辑任务</VListItemTitle>
<VListItemTitle>{{ t('workflow.task.edit') }}</VListItemTitle>
</VListItem>
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, false)">
<template #prepend>
<VIcon icon="mdi-play-speed" />
</template>
<VListItemTitle>继续执行</VListItemTitle>
<VListItemTitle>{{ t('workflow.task.continue') }}</VListItemTitle>
</VListItem>
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, true)">
<template #prepend>
<VIcon icon="mdi-replay" />
</template>
<VListItemTitle>重新执行</VListItemTitle>
<VListItemTitle>{{ t('workflow.task.restart') }}</VListItemTitle>
</VListItem>
<VListItem v-else base-color="info" @click="handleRun(workflow, true)">
<template #prepend>
<VIcon icon="mdi-run" />
</template>
<VListItemTitle>立即执行</VListItemTitle>
<VListItemTitle>{{ t('workflow.task.run') }}</VListItemTitle>
</VListItem>
<VListItem base-color="warning" @click="handleReset(workflow)">
<template #prepend>
<VIcon icon="mdi-restore-alert" />
</template>
<VListItemTitle>重置任务</VListItemTitle>
<VListItemTitle>{{ t('workflow.task.reset') }}</VListItemTitle>
</VListItem>
<VListItem base-color="error" @click="handleDelete(workflow)">
<template #prepend>
<VIcon icon="mdi-delete" />
</template>
<VListItemTitle>删除任务</VListItemTitle>
<VListItemTitle>{{ t('workflow.task.delete') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
@@ -252,11 +256,11 @@ const resolveProgress = (item: Workflow) => {
<div class="d-flex flex-column gap-y-4">
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">定时</div>
<div class="mb-1">{{ t('workflow.task.info.timer') }}</div>
<h5 class="text-h6">{{ workflow?.timer }}</h5>
</div>
<div class="flex-1">
<div class="mb-1">状态</div>
<div class="mb-1">{{ t('workflow.task.info.status') }}</div>
<h5 class="text-h6" :class="`text-${resolveStatusVariant(workflow?.state).color}`">
{{ resolveStatusVariant(workflow?.state).text }}
</h5>
@@ -264,7 +268,7 @@ const resolveProgress = (item: Workflow) => {
</div>
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">动作数</div>
<div class="mb-1">{{ t('workflow.task.info.actionCount') }}</div>
<div>
<VAvatar size="32" color="primary" variant="tonal">
<span class="text-sm">{{ workflow?.actions?.length }}</span>
@@ -272,13 +276,13 @@ const resolveProgress = (item: Workflow) => {
</div>
</div>
<div class="flex-1">
<div class="mb-1">已执行次数</div>
<div class="mb-1">{{ t('workflow.task.info.runCount') }}</div>
<h5 class="text-h6">{{ workflow?.run_count }}</h5>
</div>
</div>
<div class="d-flex flex-wrap gap-x-6">
<div class="flex-1">
<div class="mb-1">进度</div>
<div class="mb-1">{{ t('workflow.task.info.progress') }}</div>
<div class="d-flex align-center gap-5">
<div class="flex-grow-1">
<VProgressLinear color="info" rounded :model-value="resolveProgress(workflow)" />
@@ -289,7 +293,7 @@ const resolveProgress = (item: Workflow) => {
</div>
<div class="d-flex flex-wrap gap-x-6" v-if="workflow?.result">
<div class="flex-1">
<div class="mb-1">错误信息</div>
<div class="mb-1">{{ t('workflow.task.info.error') }}</div>
<div class="text-error">{{ workflow?.result }}</div>
</div>
</div>

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'
import { formatFileSize } from '@/@core/utils/formatters'
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -38,7 +42,9 @@ const loading = ref(false)
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
// 计算按钮文字
const buttonText = computed(() => (loading.value ? '下载中...' : '开始下载'))
const buttonText = computed(() =>
loading.value ? t('dialog.addDownload.downloading') : t('dialog.addDownload.startDownload'),
)
// 加载目录设置
async function loadDirectories() {
@@ -96,12 +102,20 @@ async function addDownload() {
if (result && result.success) {
// 添加下载成功
$toast.success(`${props.torrent?.site_name} ${props.torrent?.title} 下载成功!`)
$toast.success(
t('dialog.addDownload.downloadSuccess', { site: props.torrent?.site_name, title: props.torrent?.title }),
)
// 下载成功,返回链接
emit('done', props.torrent?.enclosure)
} else {
// 添加下载失败
$toast.error(`${props.torrent?.site_name} ${props.torrent?.title} 下载失败:${result?.message}`)
$toast.error(
t('dialog.addDownload.downloadFailed', {
site: props.torrent?.site_name,
title: props.torrent?.title,
message: result?.message,
}),
)
// 下载失败,返回错误原因
emit('error', result?.message)
}
@@ -120,69 +134,75 @@ onMounted(() => {
<template>
<VDialog max-width="35rem" scrollable>
<VCard>
<VCardTitle class="py-4 me-12">
<VIcon icon="mdi-download" class="me-2" />
<span v-if="title">{{ torrent?.site_name }} - {{ title }}</span>
<span v-else>确认下载</span>
</VCardTitle>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-monitor-arrow-down-variant" class="me-2" />
</template>
<VCardTitle>{{ t('dialog.addDownload.confirmDownload') }}</VCardTitle>
<VCardSubtitle>{{ torrent?.site_name }} - {{ title }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VList lines="one">
<VListItem>
<template #prepend>
<VIcon icon="mdi-web"></VIcon>
</template>
<VListItemTitle>
<span class="whitespace-break-spaces me-2">{{ torrent?.title }}</span>
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="torrent?.description">
<template #prepend>
<VIcon icon="mdi-subtitles-outline"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-2 whitespace-break-spaces">{{ torrent?.description }}</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="torrent?.size">
<template #prepend>
<VIcon icon="mdi-database"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-2">
<VChip variant="tonal" label>
{{ formatFileSize(torrent?.size || 0) }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
</VList>
<VRow class="px-7">
<VCol cols="12" md="4">
<VSelect
v-model="selectedDownloader"
:items="downloaderOptions"
size="small"
label="下载器(默认)"
variant="underlined"
placeholder="留空默认"
density="compact"
/>
</VCol>
<VCol cols="12" md="8">
<VCombobox
v-model="selectedDirectory"
:items="targetDirectories"
label="保存目录(自动)"
size="small"
placeholder="留空自动匹配"
variant="underlined"
density="compact"
/>
</VCol>
</VRow>
<VCardText>
<VList lines="one">
<VListItem>
<template #prepend>
<VIcon icon="mdi-web"></VIcon>
</template>
<VListItemTitle>
<span class="whitespace-break-spaces me-2">{{ torrent?.title }}</span>
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="torrent?.description">
<template #prepend>
<VIcon icon="mdi-subtitles-outline"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-2 whitespace-break-spaces">{{ torrent?.description }}</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="torrent?.size">
<template #prepend>
<VIcon icon="mdi-database"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-2">
<VChip variant="tonal" label>
{{ formatFileSize(torrent?.size || 0) }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
</VList>
<VRow class="px-5">
<VCol cols="12" md="6">
<VSelect
v-model="selectedDownloader"
:items="downloaderOptions"
size="small"
:label="t('dialog.addDownload.downloader')"
variant="underlined"
:placeholder="t('dialog.addDownload.defaultPlaceholder')"
density="comfortable"
prepend-inner-icon="mdi-download"
/>
</VCol>
<VCol cols="12" md="6">
<VCombobox
v-model="selectedDirectory"
:items="targetDirectories"
:label="t('dialog.addDownload.saveDirectory')"
size="small"
:placeholder="t('dialog.addDownload.autoPlaceholder')"
variant="underlined"
density="comfortable"
prepend-inner-icon="mdi-folder"
/>
</VCol>
</VRow>
</VCardText>
<VCardText class="text-center">
<VBtn variant="elevated" :disabled="loading" @click="addDownload" :prepend-icon="icon" class="px-5">
{{ buttonText }}

View File

@@ -1,5 +1,13 @@
<script lang="ts" setup>
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 多语言支持
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -18,7 +26,40 @@ async function handleDone() {
emit('done')
}
// 保存rclone设
// 重置配
async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/alist')
if (result.success) {
// 重置成功
handleDone()
}
} catch (e) {
console.error(e)
}
}
// 登录类型
let loginType = ref('username')
if (props.conf.token) {
loginType = ref('token')
} else if (props.conf.username) {
loginType = ref('username')
} else {
loginType = ref('guest')
}
// 数据源
const sourceItems = [
{
'title': t('dialog.alistConfig.loginTypeOptions.username'),
'value': 'username',
},
{ 'title': t('dialog.alistConfig.loginTypeOptions.token'), 'value': 'token' },
{ 'title': t('dialog.alistConfig.loginTypeOptions.guest'), 'value': 'guest' },
]
// 保存alist设置
async function savaAlistConfig() {
try {
await api.post(`storage/save/alist`, props.conf)
@@ -29,31 +70,77 @@ async function savaAlistConfig() {
</script>
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="AList配置" class="rounded-t">
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
<template #prepend>
<VIcon icon="mdi-cog-outline" class="me-2" />
</template>
<VCardTitle>
{{ t('dialog.alistConfig.title') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="props.conf.url" hint="AList服务地址" label="地址" persistent-hint />
<VTextField
v-model="props.conf.url"
:hint="t('dialog.alistConfig.serverUrl')"
:label="t('dialog.alistConfig.serverUrl')"
persistent-hint
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="props.conf.username" hint="AList登录用户名" label="用户名" persistent-hint />
<VCol cols="12" md="4">
<VSelect
v-model="loginType"
:items="sourceItems"
:label="t('dialog.alistConfig.loginType')"
:hint="t('dialog.alistConfig.loginType')"
persistent-hint
prepend-inner-icon="mdi-login"
/>
</VCol>
<VCol cols="12" md="6">
<VCol cols="12" md="4" v-if="loginType == 'username'">
<VTextField
v-model="props.conf.username"
:hint="t('dialog.alistConfig.username')"
:label="t('dialog.alistConfig.username')"
persistent-hint
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="4" v-if="loginType == 'username'">
<VTextField
type="password"
v-model="props.conf.password"
hint="AList登录密码"
label="密码"
:hint="t('dialog.alistConfig.password')"
:label="t('dialog.alistConfig.password')"
persistent-hint
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="8" v-if="loginType == 'token'">
<VTextField
v-model="props.conf.token"
:hint="t('dialog.alistConfig.loginTypeOptions.token')"
:label="t('dialog.alistConfig.loginTypeOptions.token')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
{{ t('dialog.alistConfig.reset') }}
</VBtn>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('dialog.alistConfig.complete') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -1,5 +1,13 @@
<script lang="ts" setup>
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 多语言支持
const { t } = useI18n()
// 定义输入
defineProps({
@@ -16,7 +24,7 @@ const emit = defineEmits(['done', 'close'])
const qrCodeUrl = ref('')
// 下方的提示信息
const text = ref('请用阿里云盘 App 扫码')
const text = ref(t('dialog.aliyunAuth.scanQrCode'))
// 提醒类型
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
@@ -74,6 +82,24 @@ async function checkQrcode() {
}
}
// 重置配置
async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/alipan')
console.log(result.success)
if (result.success) {
// 重置成功
alertType.value = 'success'
handleDone()
} else {
alertType.value = 'error'
text.value = result.message
}
} catch (e) {
console.error(e)
}
}
onMounted(async () => {
await getQrcode()
})
@@ -84,11 +110,20 @@ onUnmounted(() => {
</script>
<template>
<VDialog width="40rem" scrollable max-height="85vh">
<VCard title="阿里云盘登录" class="rounded-t">
<VDialog width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2 flex flex-col items-center">
<div class="my-6 rounded text-center p-3 border">
<VCardItem>
<template #prepend>
<VIcon icon="mdi-qrcode" class="me-2" />
</template>
<VCardTitle>
{{ t('dialog.aliyunAuth.loginTitle') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="pt-2 flex flex-col items-center justify-center">
<div class="mt-6 rounded text-center p-3 border">
<VImg class="mx-auto" :src="qrCodeUrl" width="200" height="200">
<template #placeholder>
<div class="w-full h-full">
@@ -97,13 +132,20 @@ onUnmounted(() => {
</template>
</VImg>
</div>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
<template #prepend />
</VAlert>
<div>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
<template #prepend />
</VAlert>
</div>
</VCardText>
<VCardActions>
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
{{ t('dialog.aliyunAuth.reset') }}
</VBtn>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('dialog.aliyunAuth.complete') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -3,8 +3,13 @@ import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { SubscribeShare } from '@/api/types'
import router from '@/router'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { VBtn } from 'vuetify/lib/components/index.mjs'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -15,7 +20,9 @@ const props = defineProps({
const emit = defineEmits(['fork', 'delete', 'close'])
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 提示框
const $toast = useToast()
@@ -116,11 +123,11 @@ async function doFork() {
const result: { [key: string]: any } = await api.post('subscribe/fork', props.media)
// 订阅状态
if (result.success) {
$toast.success(`${props.media?.share_title} 添加订阅成功!`)
$toast.success(t('subscribe.addSuccess', { name: props.media?.share_title }))
// 完成
emit('fork', result.data.id)
} else {
$toast.error(`${props.media?.share_title} 添加订阅失败:${result.message}`)
$toast.error(t('subscribe.addFailed', { name: props.media?.share_title, message: result.message }))
}
} catch (error) {
console.error(error)
@@ -144,11 +151,11 @@ async function doDelete() {
})
// 订阅状态
if (result.success) {
$toast.success(`${props.media?.share_title} 取消分享成功!`)
$toast.success(t('subscribe.cancelSuccess'))
// 完成
emit('delete', result.data.id)
} else {
$toast.error(`${props.media?.share_title} 取消分享失败:${result.message}`)
$toast.error(t('subscribe.cancelFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
@@ -200,13 +207,13 @@ onMounted(() => {
<VList lines="one">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">分享人</span>
<span class="font-weight-medium">{{ t('subscribe.sharer') }}</span>
<span class="text-body-1"> {{ media?.share_user }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0" v-if="media?.keyword">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">搜索词</span>
<span class="font-weight-medium">{{ t('subscribe.keyword') }}</span>
<span class="text-body-1"> {{ media?.keyword }}</span>
</VListItemTitle>
</VListItem>
@@ -217,7 +224,7 @@ onMounted(() => {
'line-clamp-4 overflow-hidden text-ellipsis': !isExpanded,
}"
>
<span class="font-weight-medium">识别词</span>
<span class="font-weight-medium">{{ t('subscribe.recognitionWords') }}</span>
<span class="text-body-1"> {{ media?.custom_words }}</span>
</VListItemTitle>
</VListItem>
@@ -232,7 +239,7 @@ onMounted(() => {
:loading="processing"
class="mb-2 me-2"
>
订阅
{{ t('subscribe.normalSub') }}
</VBtn>
<VBtn
v-if="isFollowed && props.media?.share_uid"
@@ -241,7 +248,7 @@ onMounted(() => {
prepend-icon="mdi-account-remove"
class="mb-2 me-2"
>
取消关注
{{ t('subscribe.unfollow') }}
</VBtn>
<VBtn
v-else-if="props.media?.share_uid"
@@ -250,7 +257,7 @@ onMounted(() => {
prepend-icon="mdi-account-plus"
class="mb-2 me-2"
>
关注
{{ t('subscribe.follow') }}
</VBtn>
<VBtn
v-if="
@@ -264,11 +271,13 @@ onMounted(() => {
:loading="deleting"
class="mb-2 me-2"
>
取消分享
{{ t('subscribe.cancelShare') }}
</VBtn>
</div>
<div class="text-xs mt-2" v-if="props.media?.count">
<VIcon icon="mdi-fire" /> {{ props.media?.count?.toLocaleString() }} 次复用
<VIcon icon="mdi-fire" />{{
t('subscribe.usageCount', { count: props.media?.count?.toLocaleString() })
}}
</div>
</div>
</VCardItem>

View File

@@ -1,4 +1,9 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 输入参数
const props = defineProps({
title: String,
@@ -19,15 +24,23 @@ function handleImport() {
</script>
<template>
<VDialog width="40rem" scrollable max-height="85vh" persistent>
<VCard :title="props.title" class="rounded-t">
<VDialog width="40rem" scrollable max-height="85vh">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-code-json" class="me-2" />
</template>
<VCardTitle>{{ props.title }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">
<VTextarea v-model="codeString" />
<VTextarea v-model="codeString" prepend-inner-icon="mdi-code-json" />
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleImport" prepend-icon="mdi-import" class="px-5 me-3"> 导入 </VBtn>
<VBtn @click="handleImport" prepend-icon="mdi-import" class="px-5 me-3">
{{ t('dialog.importCode.import') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -1,6 +1,10 @@
<script setup lang="ts">
import { Context } from '@/api/types'
import MediaInfoCard from '../cards/MediaInfoCard.vue'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
// 输入参数
defineProps({

View File

@@ -3,9 +3,14 @@ import { useDisplay } from 'vuetify'
import type { Plugin } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import api from '@/api'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import FormRender from '../render/FormRender.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { loadRemoteComponent } from '@/utils/federationLoader'
// 国际化
const { t } = useI18n()
// 输入参数
const props = defineProps({
@@ -38,71 +43,148 @@ const $toast = useToast()
// 是否刷新
const isRefreshed = ref(false)
// 调用API读取表单页面
async function loadPluginForm() {
try {
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
if (result) {
pluginFormItems = result.conf
if (result.model) pluginConfigForm.value = result.model
// 渲染模式: 'vuetify' 或 'vue'
const renderMode = ref('vuetify')
// Vue 模式:动态加载的组件
const dynamicComponent = defineAsyncComponent({
// 工厂函数
loader: async () => {
try {
if (!props.plugin?.id) {
throw new Error('插件ID不存在')
}
// 动态加载远程组件
const module = await loadRemoteComponent(props.plugin.id, 'Config')
// 直接返回加载的组件无需再获取default
return module
} catch (error) {
console.error('加载远程组件失败:', error)
}
} catch (error) {
},
// 加载中显示的组件
loadingComponent: {
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
},
// 添加错误处理
errorComponent: {
template: `
<div class="pa-4">
<VAlert type="error" title="组件加载错误">
无法加载组件,请稍后再试
</VAlert>
</div>
`,
},
// 添加超时设置
timeout: 20000,
})
//调用API读取UI和配置数据
async function loadPluginUIData() {
// 重置
isRefreshed.value = false
pluginFormItems = []
pluginConfigForm.value = {}
renderMode.value = 'vuetify'
try {
// 获取UI定义
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
if (!result) {
console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败无效的响应`)
return
}
renderMode.value = result.render_mode
if (renderMode.value === 'vue') {
// Vue模式下初始配置在同一个API返回
if (!isNullOrEmptyObject(result.model)) {
pluginConfigForm.value = result.model
}
} else {
// Vuetify模式
pluginFormItems = result.conf || []
if (result.model) {
pluginConfigForm.value = result.model
}
}
} catch (error: any) {
console.error(error)
} finally {
isRefreshed.value = true
}
isRefreshed.value = true
}
// 调用API读取配置数据
async function loadPluginConf() {
try {
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
} catch (error) {
console.error(error)
}
isRefreshed.value = true
// 处理 Vue 组件触发的保存事件
function handleVueComponentSave(newConfig: Record<string, any>) {
pluginConfigForm.value = newConfig
savePluginConf()
}
// 调用API保存配置数据
async function savePluginConf() {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
progressText.value = t('dialog.pluginConfig.saving', { name: props.plugin?.plugin_name })
try {
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
if (result.success) {
progressDialog.value = false
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
$toast.success(t('dialog.pluginConfig.saveSuccess', { name: props.plugin?.plugin_name }))
// 通知父组件刷新
emit('save')
} else {
progressDialog.value = false
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
$toast.error(t('dialog.pluginConfig.saveFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
console.error(error)
}
progressDialog.value = false
}
onBeforeMount(async () => {
await loadPluginForm()
await loadPluginConf()
await loadPluginUIData()
})
</script>
<template>
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name} - 配置`" class="rounded-t">
<!-- Vuetify 渲染模式 -->
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText v-if="isRefreshed">
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
<VCardText v-else="isRefreshed">
<div>
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" variant="outlined" color="info"> 查看数据 </VBtn>
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" color="info">
{{ t('dialog.pluginConfig.viewData') }}
</VBtn>
<VSpacer />
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 保存 </VBtn>
<!-- 只有Vuetify模式显示默认保存按钮Vue模式由组件内部控制 -->
<VBtn v-if="renderMode === 'vuetify'" @click="savePluginConf" prepend-icon="mdi-content-save" class="px-5">
保存
</VBtn>
</VCardActions>
</VCard>
<!-- Vue 渲染模式 -->
<VCard v-else-if="renderMode === 'vue'">
<VCardText class="pa-0">
<component
:is="dynamicComponent"
:initial-config="pluginConfigForm"
:api="api"
@save="handleVueComponentSave"
@switch="emit('switch')"
@close="emit('close')"
/>
</VCardText>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</VDialog>

View File

@@ -3,12 +3,18 @@ import { useDisplay } from 'vuetify'
import type { Plugin } from '@/api/types'
import PageRender from '@/components/render/PageRender.vue'
import api from '@/api'
import { loadRemoteComponent } from '@/utils/federationLoader'
import { usePWA } from '@/composables/usePWA'
// 输入参数
const props = defineProps({
plugin: {
type: Object as PropType<Plugin>,
},
show_switch: {
type: Boolean,
default: true,
},
})
// 定义事件
@@ -17,38 +23,120 @@ const emit = defineEmits(['close', 'save', 'switch'])
// 显示器宽度
const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
// PWA模式检测
const { appMode } = usePWA()
// 是否刷新
const isRefreshed = ref(false)
// 组件是否已加载成功
const componentLoaded = ref(false)
// 是否正在加载数据
const isLoading = ref(false)
// 渲染模式: 'vuetify' 或 'vue'
const renderMode = ref('vuetify')
// 插件数据页面配置项
let pluginPageItems = ref([])
// 调用API读取数据页面
async function loadPluginPage() {
// Vue 模式:动态加载的组件
const dynamicComponent = defineAsyncComponent({
// 工厂函数
loader: async () => {
try {
if (!props.plugin?.id) {
throw new Error('插件ID不存在')
}
// 动态加载远程组件
const module = await loadRemoteComponent(props.plugin.id, 'Page')
componentLoaded.value = true
return module
} catch (error) {
console.error('加载远程组件失败:', error)
componentLoaded.value = false
}
},
// 加载中显示的组件
loadingComponent: {
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
},
// 添加错误处理
errorComponent: {
template: `
<div class="pa-4">
<VAlert type="error" title="组件加载错误">
无法加载组件,请稍后再试
</VAlert>
</div>
`,
},
// 添加超时设置
timeout: 20000,
})
// 调用API读取数据页面UI
async function loadPluginUIData() {
// 如果正在加载,则不重复加载
if (isLoading.value) return
isLoading.value = true
isRefreshed.value = false
pluginPageItems.value = []
try {
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
if (result) pluginPageItems.value = result
} catch (error) {
// 如果已经是vue模式且组件已加载成功不需要再请求模式
if (renderMode.value === 'vue' && componentLoaded.value) {
isRefreshed.value = true
isLoading.value = false
return
}
const result: { [key: string]: any } = await api.get(`plugin/page/${props.plugin?.id}`)
if (!result || !result.render_mode) {
console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败无效的响应`)
return
}
renderMode.value = result.render_mode
if (renderMode.value === 'vuetify') {
// Vuetify模式
pluginPageItems.value = result.page || []
}
} catch (error: any) {
console.error(error)
} finally {
isRefreshed.value = true
isLoading.value = false
}
isRefreshed.value = true
}
// 重新加载数据(可由 PageRender 或 Vue component 触发)
function handleAction(event: any) {
// 避免在组件已加载的情况下重复调用loadPluginUIData
if (renderMode.value === 'vue' && componentLoaded.value) {
return
}
loadPluginUIData()
}
onMounted(() => {
loadPluginPage()
loadPluginUIData()
})
</script>
<template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`${props.plugin?.plugin_name}`" class="rounded-t">
<!-- Vuetify 渲染模式 -->
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
<VDialogCloseBtn @click="emit('close')" />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
<VCardText v-else class="min-h-40">
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
<div>
<PageRender @action="handleAction" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
<div v-if="!pluginPageItems || pluginPageItems.length === 0">此插件没有详情页面</div>
</div>
</VCardText>
<VFab
v-if="show_switch"
icon="mdi-cog"
location="bottom"
size="x-large"
@@ -59,5 +147,18 @@ onMounted(() => {
:class="{ 'mb-10': appMode }"
/>
</VCard>
<!-- Vue 渲染模式 -->
<VCard v-else-if="renderMode === 'vue'">
<VCardText class="pa-0">
<component
:is="dynamicComponent"
:api="api"
:show_switch="show_switch"
@action="handleAction"
@switch="emit('switch')"
@close="emit('close')"
/>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -1,11 +1,29 @@
<script lang="ts" setup>
import api from '@/api'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
const $toast = useToast()
// 插件仓库设置字符串
const repoString = ref('')
// 用于显示的仓库地址数组
const repoArray = ref<string[]>([])
// 计算属性:在数组和换行符分隔的字符串之间转换
const displayRepos = computed({
get: () => repoArray.value.join('\n'),
set: (value: string) => {
repoArray.value = value.split('\n').filter((repo: string) => repo.trim() !== '')
},
})
// 定义事件
const emit = defineEmits(['save', 'close'])
@@ -14,7 +32,10 @@ const emit = defineEmits(['save', 'close'])
async function queryMarketRepoSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
if (result && result.data && result.data.value) repoString.value = result.data.value
if (result && result.data && result.data.value) {
repoString.value = result.data.value
repoArray.value = result.data.value.split(',').filter((repo: string) => repo.trim() !== '')
}
} catch (error) {
console.log(error)
}
@@ -23,13 +44,14 @@ async function queryMarketRepoSetting() {
// 保存设置
async function saveHandle() {
try {
// 用户名密码
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoString.value)
// 将数组转换为逗号分隔的字符串
const repoStringToSave = repoArray.value.join(',')
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)
if (result.success) {
$toast.success('插件仓库保存成功')
$toast.success(t('dialog.pluginMarketSetting.saveSuccess'))
emit('save')
} else $toast.error(`插件仓库保存失败:${result?.message}`)
} else $toast.error(t('dialog.pluginMarketSetting.saveFailed', { message: result?.message }))
} catch (error) {
console.log(error)
}
@@ -41,27 +63,29 @@ onMounted(() => {
</script>
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard class="rounded-t">
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-store-cog" class="me-2" />
插件仓库设置
{{ t('dialog.pluginMarketSetting.title') }}
</VCardTitle>
<VDialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText class="pt-2">
<VTextarea
v-model="repoString"
placeholder="格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/"
hint="多个地址使用逗号分隔仅支持Github仓库"
v-model="displayRepos"
:placeholder="t('dialog.pluginMarketSetting.repoPlaceholder')"
:hint="t('dialog.pluginMarketSetting.repoHint')"
persistent-hint
auto-grow
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="saveHandle" prepend-icon="mdi-content-save-check" class="px-5 me-3">
保存
<VBtn @click="saveHandle" prepend-icon="mdi-content-save-check" class="px-5 me-3">
{{ t('dialog.pluginMarketSetting.save') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -1,15 +1,19 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
value: Number,
text: String,
})
</script>
<template>
<!-- 手动整理进度框 -->
<!-- Progress Dialog -->
<VDialog :scrim="false" width="25rem">
<VCard elevation="3" color="primary">
<VCardText class="text-center">
{{ props.text }}
{{ props.text || t('dialog.progress.processing') }}
<VProgressLinear color="white" class="mb-0 mt-1" :model-value="props.value" indeterminate />
</VCardText>
</VCard>

View File

@@ -1,5 +1,13 @@
<script lang="ts" setup>
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 多语言支持
const { t } = useI18n()
// 定义输入
const props = defineProps({
@@ -14,7 +22,7 @@ if (!props.conf.filepath) {
}
if (!props.conf.content) {
props.conf.content = '# 请在此处填写rclone配置文件内容 \n# 请参考 https://rclone.org/docs/ \n# 存储节点名必须为MP'
props.conf.content = t('dialog.rcloneConfig.defaultContent')
}
// 定义事件
@@ -34,32 +42,61 @@ async function savaRcloneConfig() {
console.error(e)
}
}
// 重置配置
async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/rclone')
if (result.success) {
handleDone()
}
} catch (e) {
console.error(e)
}
}
</script>
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="RClone配置" class="rounded-t">
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
<template #prepend>
<VIcon icon="mdi-cog-outline" class="me-2" />
</template>
<VCardTitle>
{{ t('dialog.rcloneConfig.title') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="props.conf.filepath" label="rclone配置文件路径" />
<VTextField
v-model="props.conf.filepath"
:label="t('dialog.rcloneConfig.filePath')"
prepend-inner-icon="mdi-file-document"
/>
</VCol>
<VCol cols="12">
<VAceEditor
v-model:value="props.conf.content"
lang="ini"
theme="monokai"
style="block-size: 30rem"
class="rounded"
class="rounded h-full min-h-[30rem]"
>
</VAceEditor>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
{{ t('dialog.rcloneConfig.reset') }}
</VBtn>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('dialog.rcloneConfig.complete') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>

View File

@@ -1,12 +1,17 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import api from '@/api'
import { storageOptions, transferTypeOptions } from '@/api/constants'
import { transferTypeOptions } from '@/api/constants'
import { numberValidator } from '@/@validators'
import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue'
import { FileItem, TransferDirectoryConf, TransferForm } from '@/api/types'
import { FileItem, StorageConf, TransferDirectoryConf, TransferForm } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -20,10 +25,12 @@ const props = defineProps({
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 当前识别类型
const mediaSource = ref(globalSettings.data?.RECOGNIZE_SOURCE || 'themoviedb')
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
// 定义事件
const emit = defineEmits(['done', 'close'])
@@ -31,7 +38,7 @@ const emit = defineEmits(['done', 'close'])
// 生成1到100季的下拉框选项
const seasonItems = ref(
Array.from({ length: 101 }, (_, i) => i).map(item => ({
title: `${item}`,
title: `${t('dialog.subscribeEdit.seasonFormat', { number: item })}`,
value: item,
})),
)
@@ -49,22 +56,47 @@ const progressEventSource = ref<EventSource>()
const progressDialog = ref(false)
// 整理进度文本
const progressText = ref('正在处理 ...')
const progressText = ref(t('dialog.reorganize.processing'))
// 整理进度
const progressValue = ref(0)
// 标题
const dialogTitle = computed(() => {
if (props.items) {
if (props.items.length > 1) return `整理 - 共 ${props.items.length}`
return `整理 - ${props.items[0].path}`
} else if (props.logids) {
return `整理 - 共 ${props.logids.length}`
// 所有存储
const storages = ref<StorageConf[]>([])
// 查询存储
async function loadStorages() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Storages')
storages.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
return '手动整理'
}
// 存储字典
const storageOptions = computed(() => {
return storages.value.map(item => ({
title: item.name,
value: item.type,
}))
})
// 标题
const dialogTitle = computed(() => {
return t('dialog.reorganize.manualTitle')
})
// 副标题
const dialogSubtitle = computed(() => {
if (props.items) {
if (props.items.length > 1) return t('dialog.reorganize.multipleItemsTitle', { count: props.items.length })
return t('dialog.reorganize.singleItemTitle', { path: props.items[0].path })
} else if (props.logids) {
return t('dialog.reorganize.multipleItemsTitle', { count: props.logids.length })
}
})
// 禁用指定集数
const disableEpisodeDetail = computed(() => {
if (props.items) {
@@ -138,7 +170,7 @@ async function handleTransfer(item: FileItem, background: boolean = false) {
try {
const result: { [key: string]: any } = await api.post(`transfer/manual?background=${background}`, transferForm)
if (!result.success) $toast.error(result.message)
else if (background) $toast.success(`文件 ${item.name} 已加入整理队列!`)
else if (background) $toast.success(t('dialog.reorganize.successMessage', { name: item.name }))
} catch (e) {
console.log(e)
}
@@ -159,7 +191,12 @@ async function handleTransferLog(logid: number, background: boolean = false) {
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
// 在创建新连接之前,先确保任何可能存在的旧连接都被关闭了,防止因快速重复点击而产生孤儿连接。
if (progressEventSource.value) {
progressEventSource.value.close()
}
progressText.value = t('dialog.reorganize.processing')
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
@@ -168,6 +205,13 @@ function startLoadingProgress() {
progressValue.value = progress.value
}
}
// 发生错误时,也确保连接被关闭,避免重试等意外行为
progressEventSource.value.onerror = () => {
if (progressEventSource.value) {
progressEventSource.value.close()
}
}
}
// 停止监听加载进度
@@ -214,6 +258,7 @@ async function transfer(background: boolean = false) {
onMounted(() => {
loadDirectories()
loadStorages()
})
onUnmounted(() => {
@@ -223,7 +268,12 @@ onUnmounted(() => {
<template>
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="dialogTitle" class="rounded-t">
<VCard>
<VCardItem class="py-2">
<template #prepend> <VIcon icon="mdi-folder-move" class="me-2" /> </template>
<VCardTitle>{{ dialogTitle }}</VCardTitle>
<VCardSubtitle>{{ dialogSubtitle }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
@@ -233,22 +283,24 @@ onUnmounted(() => {
<VSelect
v-model="transferForm.target_storage"
:items="storageOptions"
label="目的存储"
placeholder="留空自动"
hint="整理目的存储"
:label="t('dialog.reorganize.targetStorage')"
:placeholder="t('dialog.reorganize.targetPathPlaceholder')"
:hint="t('dialog.reorganize.targetStorageHint')"
persistent-hint
prepend-inner-icon="mdi-harddisk"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="transferForm.transfer_type"
label="整理方式"
:label="t('dialog.reorganize.transferType')"
:items="transferTypeOptions"
hint="文件操作整理方式"
:hint="t('dialog.reorganize.transferTypeHint')"
persistent-hint
prepend-inner-icon="mdi-swap-horizontal"
>
<template v-slot:selection="{ item }">
{{ transferForm.transfer_type === '' ? '自动' : item.title }}
{{ transferForm.transfer_type === '' ? t('dialog.reorganize.auto') : item.title }}
</template>
</VSelect>
</VCol>
@@ -256,10 +308,11 @@ onUnmounted(() => {
<VCombobox
v-model="transferForm.target_path"
:items="targetDirectories"
label="目的路径"
placeholder="留空自动"
hint="整理目的路径,留空将自动匹配"
:label="t('dialog.reorganize.targetPath')"
:placeholder="t('dialog.reorganize.targetPathPlaceholder')"
:hint="t('dialog.reorganize.targetPathHint')"
persistent-hint
prepend-inner-icon="mdi-folder-outline"
/>
</VCol>
</VRow>
@@ -267,14 +320,15 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VSelect
v-model="transferForm.type_name"
label="类型"
:label="t('dialog.reorganize.mediaType')"
:items="[
{ title: '自动', value: '' },
{ title: '电影', value: '电影' },
{ title: '电视剧', value: '电视剧' },
{ title: t('dialog.reorganize.auto'), value: '' },
{ title: t('dialog.reorganize.movie'), value: '电影' },
{ title: t('dialog.reorganize.tv'), value: '电视剧' },
]"
hint="文件的媒体类型"
:hint="t('dialog.reorganize.mediaTypeHint')"
persistent-hint
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="12" md="6">
@@ -282,24 +336,26 @@ onUnmounted(() => {
v-if="mediaSource === 'themoviedb'"
v-model="transferForm.tmdbid"
:disabled="transferForm.type_name === ''"
label="TheMovieDb编号"
placeholder="留空自动识别"
:label="t('dialog.reorganize.tmdbId')"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
hint="按名称查询媒体编号,留空自动识别"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
prepend-inner-icon="mdi-identifier"
@click:append-inner="mediaSelectorDialog = true"
/>
<VTextField
v-else
v-model="transferForm.doubanid"
:disabled="transferForm.type_name === ''"
label="豆瓣编号"
placeholder="留空自动识别"
:label="t('dialog.reorganize.doubanId')"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
hint="按名称查询媒体编号,留空自动识别"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
prepend-inner-icon="mdi-identifier"
@click:append-inner="mediaSelectorDialog = true"
/>
</VCol>
@@ -308,47 +364,52 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_group"
label="剧集组编号"
placeholder="手动查询剧集组"
hint="指定剧集组"
:label="t('dialog.reorganize.episodeGroup')"
:placeholder="t('dialog.reorganize.episodeGroupPlaceholder')"
:hint="t('dialog.reorganize.episodeGroupHint')"
persistent-hint
prepend-inner-icon="mdi-view-list"
/>
</VCol>
<VCol cols="12" md="3">
<VSelect
v-model.number="transferForm.season"
label=""
:label="t('dialog.reorganize.season')"
:items="seasonItems"
hint="第几季"
:hint="t('dialog.reorganize.seasonHint')"
persistent-hint
prepend-inner-icon="mdi-calendar"
/>
</VCol>
<VCol cols="12" md="3">
<VTextField
v-model="transferForm.episode_detail"
:disabled="disableEpisodeDetail"
label=""
placeholder="起始集,终止集"
hint="集数或范围如1或1,2"
:label="t('dialog.reorganize.episodeDetail')"
:placeholder="t('dialog.reorganize.episodeDetailPlaceholder')"
:hint="t('dialog.reorganize.episodeDetailHint')"
persistent-hint
prepend-inner-icon="mdi-playlist-play"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_format"
label="集数定位"
placeholder="使用{ep}定位集数"
hint="使用{ep}定位文件名中的集数部分以辅助识别"
:label="t('dialog.reorganize.episodeFormat')"
:placeholder="t('dialog.reorganize.episodeFormatPlaceholder')"
:hint="t('dialog.reorganize.episodeFormatHint')"
persistent-hint
prepend-inner-icon="mdi-format-text"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_offset"
label="集数偏移"
placeholder="如-10"
hint="集数偏移运算,如-10或EP*2"
:label="t('dialog.reorganize.episodeOffset')"
:placeholder="t('dialog.reorganize.episodeOffsetPlaceholder')"
:hint="t('dialog.reorganize.episodeOffsetHint')"
persistent-hint
prepend-inner-icon="mdi-numeric"
/>
</VCol>
</VRow>
@@ -356,20 +417,22 @@ onUnmounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="transferForm.episode_part"
label="指定Part"
placeholder="如part1"
hint="指定Part如part1"
:label="t('dialog.reorganize.episodePart')"
:placeholder="t('dialog.reorganize.episodePartPlaceholder')"
:hint="t('dialog.reorganize.episodePartHint')"
persistent-hint
prepend-inner-icon="mdi-file-multiple"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="transferForm.min_filesize"
label="最小文件大小MB"
:label="t('dialog.reorganize.minFileSize')"
:rules="[numberValidator]"
placeholder="0"
hint="只整理大于最小文件大小的文件"
:hint="t('dialog.reorganize.minFileSizeHint')"
persistent-hint
prepend-inner-icon="mdi-file-document-outline"
/>
</VCol>
</VRow>
@@ -377,32 +440,32 @@ onUnmounted(() => {
<VCol cols="12" md="6" v-if="transferForm.target_path">
<VSwitch
v-model="transferForm.library_type_folder"
label="按类型分类"
hint="整理时目的路径下按媒体类型添加子目录"
:label="t('dialog.reorganize.typeFolderOption')"
:hint="t('dialog.reorganize.typeFolderHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6" v-if="transferForm.target_path">
<VSwitch
v-model="transferForm.library_category_folder"
label="按类别分类"
hint="整理时在目的路径下按媒体类别添加子目录"
:label="t('dialog.reorganize.categoryFolderOption')"
:hint="t('dialog.reorganize.categoryFolderHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="transferForm.scrape"
label="刮削元数据"
hint="整理完成后自动刮削元数据"
:label="t('dialog.reorganize.scrapeOption')"
:hint="t('dialog.reorganize.scrapeHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6" v-if="props.logids">
<VSwitch
v-model="transferForm.from_history"
label="复用历史识别信息"
hint="使用历史整理记录中已识别的媒体信息"
:label="t('dialog.reorganize.fromHistoryOption')"
:hint="t('dialog.reorganize.fromHistoryHint')"
persistent-hint
/>
</VCol>
@@ -411,11 +474,11 @@ onUnmounted(() => {
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn variant="elevated" color="success" @click="transfer(true)" prepend-icon="mdi-plus" class="px-5">
加入整理队列
<VBtn color="success" @click="transfer(true)" prepend-icon="mdi-plus" class="px-5">
{{ t('dialog.reorganize.addToQueue') }}
</VBtn>
<VBtn variant="elevated" @click="transfer(false)" prepend-icon="mdi-arrow-right-bold" class="px-5">
立即整理
<VBtn @click="transfer(false)" prepend-icon="mdi-arrow-right-bold" class="px-5">
{{ t('dialog.reorganize.reorganizeNow') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -1,10 +1,19 @@
<script setup lang="ts">
import api from '@/api'
import type { Site, Plugin, Subscribe } from '@/api/types'
import { SystemNavMenus, SettingTabs } from '@/router/menu'
import { getNavMenus, getSettingTabs } from '@/router/i18n-menu'
import { NavMenu } from '@/@layouts/types'
import { useUserStore } from '@/stores'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { hasPermission, filterMenusByPermission } from '@/utils/permission'
// 显示器宽度
const display = useDisplay()
// 多语言支持
const { t } = useI18n()
// 定义props接收modelValue
const props = defineProps<{
@@ -23,6 +32,37 @@ const superUser = userStore.superUser
// 当前用户名
const userName = userStore.userName
// 权限检查
const hasSearchPermission = computed(() => {
return hasPermission(
{
is_superuser: userStore.superUser,
...userStore.permissions,
},
'search',
)
})
const hasSubscribePermission = computed(() => {
return hasPermission(
{
is_superuser: userStore.superUser,
...userStore.permissions,
},
'subscribe',
)
})
const hasManagePermission = computed(() => {
return hasPermission(
{
is_superuser: userStore.superUser,
...userStore.permissions,
},
'manage',
)
})
// 所有订阅数据
const SubscribeItems = ref<Subscribe[]>([])
@@ -73,7 +113,7 @@ function loadRecentSearches() {
function getMenus(): NavMenu[] {
let menus: NavMenu[] = []
// 导航菜单
SystemNavMenus.forEach(
getNavMenus().forEach(
item =>
item &&
menus.push({
@@ -85,11 +125,11 @@ function getMenus(): NavMenu[] {
}),
)
// 设置标签页
SettingTabs.forEach(
getSettingTabs().forEach(
item =>
item &&
menus.push({
title: '设定 -> ' + item.title,
title: t('setting') + ' -> ' + item.title,
icon: item.icon,
to: `/setting?tab=${item.tab}`,
header: '',
@@ -101,18 +141,27 @@ function getMenus(): NavMenu[] {
return menus
}
// 获取用户权限信息
const userPermissions = computed(() => ({
is_superuser: userStore.superUser,
...userStore.permissions,
}))
// 匹配的菜单列表
const matchedMenuItems = computed(() => {
if (!searchWord.value) return []
if (!superUser) return []
const lowerWord = (searchWord.value as string).toLowerCase()
const menuItems = getMenus()
if (menuItems)
return menuItems.filter(
if (menuItems) {
// 先根据用户权限过滤菜单
const filteredMenus = filterMenusByPermission(menuItems, userPermissions.value)
// 再根据搜索词过滤
return filteredMenus.filter(
item =>
item.title.toLowerCase().includes(lowerWord) ||
(item.description && item.description.toLowerCase().includes(lowerWord)),
)
}
return []
})
@@ -132,10 +181,10 @@ async function fetchInstalledPlugins() {
}
}
// 配的插件列表
// 配的插件列表
const matchedPluginItems = computed(() => {
if (!searchWord.value) return []
if (!superUser) return []
if (!hasManagePermission.value) return []
const lowerWord = (searchWord.value as string).toLowerCase()
return pluginItems.value.filter((item: Plugin) => {
if (!item.plugin_name && !item.plugin_desc) return false
@@ -188,6 +237,7 @@ const openSiteDialog = () => {
// 匹配的订阅列表
const matchedSubscribeItems = computed(() => {
if (!searchWord.value) return []
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
@@ -290,37 +340,41 @@ onMounted(() => {
setTimeout(() => {
searchWordInput.value?.focus()
}, 500)
fetchInstalledPlugins()
fetchSubscribes()
// 根据权限加载不同的数据
if (hasManagePermission.value) {
fetchInstalledPlugins()
}
if (hasSubscribePermission.value) {
fetchSubscribes()
}
loadRecentSearches()
loadUserSitePreferences()
if (superUser) queryAllSites()
if (hasSearchPermission.value) {
loadUserSitePreferences()
if (hasManagePermission.value) {
queryAllSites()
}
}
})
</script>
<template>
<VDialog v-model="dialog" max-width="42rem" scrollable>
<VDialog v-model="dialog" max-width="42rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard class="search-dialog">
<!-- 搜索输入框 -->
<VCardItem class="pa-4 pa-sm-5 search-box-container">
<template #prepend>
<VIcon icon="mdi-magnify" color="primary" size="x-large" />
</template>
<VCombobox
ref="searchWordInput"
v-model="searchWord"
density="comfortable"
variant="outlined"
class="search-input"
placeholder="输入关键词搜索..."
prepend-inner-icon="mdi-magnify"
append-inner-icon="mdi-close"
@click:append-inner="emit('close')"
:placeholder="t('dialog.searchBar.searchPlaceholder')"
@keydown.enter="searchMedia('media')"
hide-details
clearable
/>
<template #append>
<IconBtn>
<VIcon icon="mdi-close" color="primary" @click="emit('close')" size="x-large" />
</IconBtn>
</template>
</VCardItem>
<VDivider />
@@ -330,7 +384,9 @@ onMounted(() => {
<!-- 有搜索词时显示结果 -->
<VList lines="two" v-if="searchWord" class="search-list py-2">
<!-- 搜索结果分组标题 -->
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 媒体 </VListSubheader>
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6">
{{ t('common.media') }}
</VListSubheader>
<!-- 媒体搜索选项 -->
<VHover>
@@ -352,9 +408,12 @@ onMounted(() => {
/>
</div>
</template>
<VListItemTitle class="font-weight-medium"> 电影电视剧 </VListItemTitle>
<VListItemTitle class="font-weight-medium"
>{{ t('recommend.categoryMovie') }}{{ t('recommend.categoryTV') }}</VListItemTitle
>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的影视作品
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('resource.title') }}
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
@@ -382,9 +441,10 @@ onMounted(() => {
/>
</div>
</template>
<VListItemTitle class="font-weight-medium"> 系列合集 </VListItemTitle>
<VListItemTitle class="font-weight-medium">{{ t('dialog.searchBar.collections') }}</VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的系列作品
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.collectionSearch') }}
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
@@ -412,9 +472,10 @@ onMounted(() => {
/>
</div>
</template>
<VListItemTitle class="font-weight-medium"> 演职人员 </VListItemTitle>
<VListItemTitle class="font-weight-medium">{{ t('browse.actor') }}</VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的演员导演等
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.actorSearch') }}
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
@@ -423,7 +484,7 @@ onMounted(() => {
</template>
</VHover>
<VHover v-if="superUser">
<VHover v-if="hasManagePermission">
<template #default="hover">
<VListItem
density="comfortable"
@@ -438,9 +499,10 @@ onMounted(() => {
<VIcon icon="mdi-history" :color="hover.isHovering ? 'primary' : 'medium-emphasis'" size="small" />
</div>
</template>
<VListItemTitle class="font-weight-medium"> 整理记录 </VListItemTitle>
<VListItemTitle class="font-weight-medium">{{ t('navItems.history') }}</VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关的历史记录
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.historySearch') }}
</VListItemSubtitle>
<template #append>
<VIcon v-if="hover.isHovering" icon="mdi-chevron-right" color="primary" />
@@ -452,7 +514,9 @@ onMounted(() => {
<!-- 其他搜索结果 -->
<template v-if="matchedSubscribeItems.length > 0">
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 订阅 </VListSubheader>
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6">{{
t('dialog.searchBar.subscriptions')
}}</VListSubheader>
<VHover v-for="subscribe in matchedSubscribeItems" :key="subscribe.id">
<template #default="hover">
@@ -475,7 +539,9 @@ onMounted(() => {
</template>
<VListItemTitle class="font-weight-medium">
{{ subscribe.name
}}<span v-if="subscribe.season" class="text-body-2"> {{ subscribe.season }} </span>
}}<span v-if="subscribe.season" class="text-body-2">
{{ t('resource.season') }} {{ subscribe.season }}</span
>
</VListItemTitle>
<VListItemSubtitle class="text-body-2 text-medium-emphasis mt-1">
{{ subscribe.type }}
@@ -490,7 +556,9 @@ onMounted(() => {
<template v-if="matchedMenuItems.length > 0">
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 功能 </VListSubheader>
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6">{{
t('dialog.searchBar.functions')
}}</VListSubheader>
<VHover v-for="menu in matchedMenuItems" :key="menu.title">
<template #default="hover">
@@ -527,7 +595,9 @@ onMounted(() => {
<template v-if="matchedPluginItems.length > 0">
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 插件 </VListSubheader>
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6">{{
t('dialog.searchBar.plugins')
}}</VListSubheader>
<VHover v-for="plugin in matchedPluginItems" :key="plugin.id">
<template #default="hover">
@@ -541,7 +611,7 @@ onMounted(() => {
>
<template #prepend>
<div class="option-icon-wrapper d-flex align-center justify-center">
<VIcon icon="mdi-puzzle" :color="hover.isHovering ? 'primary' : 'medium-emphasis'" size="small" />
<VIcon icon="mdi-apps" :color="hover.isHovering ? 'primary' : 'medium-emphasis'" size="small" />
</div>
</template>
<VListItemTitle class="font-weight-medium">
@@ -559,9 +629,11 @@ onMounted(() => {
</template>
<!-- 将站点资源搜索移到最底部 -->
<template v-if="searchWord">
<template v-if="searchWord && hasSearchPermission">
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6"> 站点资源 </VListSubheader>
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6">{{
t('dialog.searchBar.siteResources')
}}</VListSubheader>
<VCard class="mx-3 mx-sm-6 mb-4 mt-2 site-search-card">
<VCardText class="pa-3 pa-sm-4">
@@ -571,9 +643,10 @@ onMounted(() => {
<VIcon icon="mdi-file-search" color="primary" size="small" />
</div>
<div class="flex-grow-1">
<div class="font-weight-medium text-body-1">在站点中搜索种子资源</div>
<div class="font-weight-medium text-body-1">{{ t('dialog.searchBar.searchInSites') }}</div>
<div class="text-caption text-medium-emphasis mt-1">
搜索 <span class="primary-text font-weight-medium">{{ searchWord }}</span> 相关资源
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
{{ t('dialog.searchBar.relatedResources') }}
</div>
</div>
<VBtn
@@ -584,12 +657,12 @@ onMounted(() => {
variant="flat"
class="search-btn"
>
搜索
{{ t('common.search') }}
</VBtn>
</div>
<div
v-if="superUser"
v-if="hasManagePermission"
class="d-flex align-center flex-wrap site-chips-container mt-4 py-2 px-2 px-sm-3"
>
<div class="d-flex align-center flex-wrap flex-grow-1">
@@ -628,7 +701,7 @@ onMounted(() => {
class="ml-auto site-select-btn"
rounded="pill"
>
选择站点
{{ t('dialog.searchBar.selectSites') }}
<VIcon size="small" class="ml-1">mdi-cog-outline</VIcon>
</VBtn>
</div>
@@ -641,7 +714,7 @@ onMounted(() => {
<!-- 无搜索词时显示最近搜索和提示 -->
<div v-else class="recent-searches py-6 px-4 px-sm-6">
<div v-if="recentSearches.length > 0" class="mb-6">
<div class="text-h6 font-weight-medium mb-3">最近搜索</div>
<div class="text-h6 font-weight-medium mb-3">{{ t('dialog.searchBar.recentSearches') }}</div>
<div class="d-flex flex-wrap">
<VChip
v-for="(word, index) in recentSearches"
@@ -658,12 +731,12 @@ onMounted(() => {
</div>
</div>
<div class="text-center mt-6 py-6 empty-search-state">
<div v-else class="text-center mt-6 py-6 empty-search-state">
<div class="search-icon-wrapper mx-auto mb-4">
<VIcon icon="mdi-magnify" size="large" color="primary" />
</div>
<div class="text-h6 font-weight-medium mb-2">输入关键词开始搜索</div>
<div class="text-body-2 text-medium-emphasis">可搜索电影电视剧演员资源等</div>
<div class="text-h6 font-weight-medium mb-2">{{ t('dialog.searchBar.searchPlaceholder') }}</div>
<div class="text-body-2 text-medium-emphasis">{{ t('dialog.searchBar.searchTip') }}</div>
</div>
</div>
</VCardText>
@@ -790,10 +863,10 @@ onMounted(() => {
.empty-search-state,
.empty-site-state {
animation: fadeIn 0.3s ease-in-out;
animation: fade-in 0.3s ease-in-out;
}
@keyframes fadeIn {
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);

View File

@@ -1,5 +1,9 @@
<script setup lang="ts">
import type { Site } from '@/api/types'
import { useI18n } from 'vue-i18n'
// 多语言支持
const { t } = useI18n()
const props = defineProps({
sites: {
@@ -29,7 +33,9 @@ watch(
// 全选/全不选按钮文字
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')
})
// 全选/全不选
@@ -49,26 +55,19 @@ const filteredSites = computed(() => {
})
</script>
<template>
<!-- 手动整理进度框 -->
<!-- Site Selection Dialog -->
<VDialog max-width="40rem" fullscreen-mobile>
<VCard class="site-dialog">
<VCardTitle class="d-flex align-center pa-4">
<span class="text-h6 font-weight-medium">选择搜索站点</span>
<VSpacer />
<VTextField
v-model="siteFilter"
placeholder="过滤站点..."
density="compact"
variant="outlined"
hide-details
class="ml-4"
style="max-inline-size: 200px"
prepend-inner-icon="mdi-magnify"
clearable
/>
</VCardTitle>
<VDivider class="search-divider" />
<VCardItem>
<template #prepend>
<VIcon icon="mdi-web-check" />
</template>
<VCardTitle>
{{ t('dialog.searchSite.selectSites') }}
</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText style="max-block-size: 420px" class="overflow-y-auto px-4 py-4">
<!-- 站点列表 -->
<div v-if="filteredSites.length > 0">
@@ -91,7 +90,7 @@ const filteredSites = computed(() => {
class="text-body-2 font-weight-medium"
:class="selectedSites.length > 0 ? 'text-primary' : 'text-medium-emphasis'"
>
已选择 {{ selectedSites.length }}/{{ sites.length }} 个站点
{{ t('dialog.searchSite.searchAllSites', { selected: selectedSites.length, total: sites.length }) }}
</div>
</div>
@@ -137,9 +136,9 @@ const filteredSites = computed(() => {
<div class="search-icon-wrapper mb-4 mx-auto warning">
<VIcon icon="mdi-alert-circle-outline" size="large" color="warning" />
</div>
<div class="text-h6 font-weight-medium mb-2">没有找到匹配的站点</div>
<div class="text-h6 font-weight-medium mb-2">{{ t('torrent.noMatchingResults') }}</div>
<div class="text-subtitle-1 text-medium-emphasis mb-4">
{{ siteFilter ? '请尝试修改过滤条件' : '站点数据加载失败,请刷新页面重试' }}
{{ siteFilter ? t('site.noFilterData') : t('site.sitesWillBeShownHere') }}
</div>
<VBtn
v-if="siteFilter"
@@ -149,35 +148,24 @@ const filteredSites = computed(() => {
prepend-icon="mdi-refresh"
@click="siteFilter = ''"
>
重置
{{ t('torrent.clearFilters') }}
</VBtn>
<VBtn v-else color="primary" variant="flat" class="mt-3" prepend-icon="mdi-refresh" @click="emit('reload')">
重新加载站点
{{ t('common.loading') }}
</VBtn>
</div>
</VCardText>
<VDivider class="search-divider" />
<VCardActions class="pa-4">
<VCardActions class="pt-3">
<VSpacer />
<VBtn
color="grey-darken-1"
variant="text"
@click="emit('close')"
class="mr-2 d-flex align-center justify-center"
>
取消
</VBtn>
<VBtn
color="primary"
variant="flat"
:disabled="selectedSites.length === 0"
@click="emit('search', selectedSites)"
prepend-icon="mdi-magnify"
class="d-flex align-center justify-center px-5"
>
搜索
{{ t('common.search') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -1,10 +1,14 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import type { DownloaderConf, Site } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { numberValidator, requiredValidator } from '@/@validators'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -45,8 +49,8 @@ const isLimit = ref(false)
// 状态下拉项
const statusItems = [
{ title: '启用', value: true },
{ title: '停用', value: false },
{ title: t('site.status.enabled'), value: true },
{ title: t('site.status.disabled'), value: false },
]
// 生成1到50的优先级下拉框选项
@@ -64,14 +68,14 @@ async function loadDownloaderSetting() {
try {
const downloaders: DownloaderConf[] = await api.get('download/clients')
downloaderOptions.value = [
{ title: '默认', value: '' },
{ title: t('common.default'), value: '' },
...downloaders.map((item: { name: any }) => ({
title: item.name,
value: item.name,
})),
]
} catch (error) {
console.error('加载下载器设置失败:', error)
console.error(t('site.errors.loadDownloader'), error)
}
}
@@ -93,10 +97,10 @@ async function addSite() {
try {
const result: { [key: string]: string } = await api.post('site/', siteForm.value)
if (result.success) {
$toast.success('新增站点成功')
$toast.success(t('site.messages.addSuccess'))
emit('save')
} else {
$toast.error(`新增站点失败${result.message}`)
$toast.error(`${t('site.messages.addFailed')}${result.message}`)
}
} catch (error) {
console.error(error)
@@ -119,13 +123,13 @@ async function updateSiteInfo() {
}
const result: { [key: string]: any } = await api.put('site/', siteForm.value)
if (result.success) {
$toast.success(`${siteForm.value?.name} 更新成功!`)
$toast.success(`${siteForm.value?.name} ${t('site.messages.updateSuccess')}`)
emit('save')
} else {
$toast.error(`${siteForm.value?.name} 更新失败${result.message}`)
$toast.error(`${siteForm.value?.name} ${t('site.messages.updateFailed')}${result.message}`)
}
} catch (error) {
$toast.error(`${siteForm.value?.name} 更新失败`)
$toast.error(`${siteForm.value?.name} ${t('site.messages.updateFailed')}`)
console.error(error)
}
doneNProgress()
@@ -144,10 +148,14 @@ onMounted(async () => {
<template>
<VDialog scrollable :close-on-back="false" eager max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard
:title="`${props.oper === 'add' ? '新增' : '编辑'}站点${props.oper !== 'add' ? ` - ${siteForm.name}` : ''}`"
class="rounded-t"
>
<VCard>
<VCardItem :class="props.oper === 'add' ? 'py-3' : 'py-2'">
<template #prepend>
<VIcon :icon="oper == 'add' ? 'mdi-web-plus' : 'mdi-web'" class="me-2" />
</template>
<VCardTitle>{{ `${props.oper === 'add' ? t('site.actions.add') : t('site.actions.edit')}` }}</VCardTitle>
<VCardSubtitle>{{ siteForm.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
@@ -156,29 +164,32 @@ onMounted(async () => {
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.url"
label="站点地址"
:label="t('site.fields.url')"
:rules="[requiredValidator]"
hint="格式http://www.example.com/"
:hint="t('site.hints.url')"
persistent-hint
prepend-inner-icon="mdi-web"
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
<VAutocomplete
v-model="siteForm.pri"
label="优先级"
:label="t('site.fields.priority')"
:items="priorityItems"
:rules="[requiredValidator]"
hint="优先级越小越优先"
:hint="t('site.hints.priority')"
persistent-hint
prepend-inner-icon="mdi-priority-high"
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
v-model="siteForm.is_active"
:items="statusItems"
label="状态"
hint="站点启用/停用"
:label="t('site.fields.status')"
:hint="t('site.hints.status')"
persistent-hint
prepend-inner-icon="mdi-toggle-switch"
/>
</VCol>
</VRow>
@@ -186,26 +197,29 @@ onMounted(async () => {
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.rss"
label="RSS地址"
hint="订阅模式为`站点RSS`时使用的订阅链接,如未自动获取需手动补充"
:label="t('site.fields.rss')"
:hint="t('site.hints.rss')"
persistent-hint
prepend-inner-icon="mdi-rss"
/>
</VCol>
<VCol cols="12" md="3">
<VTextField
v-model="siteForm.timeout"
label="超时时间(秒)"
hint="站点请求超时时间为0时不限制"
:label="t('site.fields.timeout')"
:hint="t('site.hints.timeout')"
persistent-hint
prepend-inner-icon="mdi-timer"
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
<VAutocomplete
v-model="siteForm.downloader"
label="下载器"
:label="t('site.fields.downloader')"
:items="downloaderOptions"
hint="此站点使用的下载器"
:hint="t('site.hints.downloader')"
persistent-hint
prepend-inner-icon="mdi-download"
/>
</VCol>
</VRow>
@@ -229,17 +243,19 @@ onMounted(async () => {
<VCol cols="12">
<VTextarea
v-model="siteForm.cookie"
label="站点Cookie"
hint="站点请求头中的Cookie信息"
:label="t('site.fields.cookie')"
:hint="t('site.hints.cookie')"
persistent-hint
prepend-inner-icon="mdi-cookie"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
hint="获取Cookie的浏览器对应的User-Agent"
:label="t('site.fields.userAgent')"
:hint="t('site.hints.userAgent')"
persistent-hint
prepend-inner-icon="mdi-web-box"
/>
</VCol>
</VRow>
@@ -249,17 +265,19 @@ onMounted(async () => {
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.token"
label="请求头Authorization"
hint="站点请求头中的Authorization信息,特殊站点需要"
:label="t('site.fields.authorization')"
:hint="t('site.hints.authorization')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.apikey"
label="令牌API Key"
hint="站点的访问API Key特殊站点需要"
:label="t('site.fields.apiKey')"
:hint="t('site.hints.apiKey')"
persistent-hint
prepend-inner-icon="mdi-api"
/>
</VCol>
</VRow>
@@ -267,47 +285,55 @@ onMounted(async () => {
</VWindow>
<VRow>
<VCol cols="12" md="4">
<VSwitch v-model="isLimit" label="限制站点访问频率" />
<VSwitch v-model="isLimit" :label="t('site.fields.limitAccess')" />
</VCol>
</VRow>
<VRow v-if="isLimit">
<VCol cols="12" md="4">
<VTextField
v-model="siteForm.limit_interval"
label="单位周期(秒)"
:label="t('site.fields.limitInterval')"
:rules="[numberValidator]"
hint="限流控制的单位周期时长"
:hint="t('site.hints.limitInterval')"
persistent-hint
prepend-inner-icon="mdi-clock-outline"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="siteForm.limit_count"
label="周期内访问次数"
:label="t('site.fields.limitCount')"
:rules="[numberValidator]"
hint="单位周期内允许的访问次数"
:hint="t('site.hints.limitCount')"
persistent-hint
prepend-inner-icon="mdi-counter"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="siteForm.limit_seconds"
label="访问间隔(秒)"
:label="t('site.fields.limitSeconds')"
:rules="[numberValidator]"
hint="每次访问需要间隔的最小时间"
:hint="t('site.hints.limitSeconds')"
persistent-hint
prepend-inner-icon="mdi-timer-sand"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="siteForm.proxy" label="使用代理访问" hint="使用代理服务器访问该站点" persistent-hint />
<VSwitch
v-model="siteForm.proxy"
:label="t('site.fields.useProxy')"
:hint="t('site.hints.useProxy')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="siteForm.render"
label="浏览器仿真"
hint="使用浏览器模拟真实访问该站点"
:label="t('site.fields.browserSimulation')"
:hint="t('site.hints.browserSimulation')"
persistent-hint
/>
</VCol>
@@ -316,25 +342,11 @@ onMounted(async () => {
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn
v-if="props.oper === 'add'"
color="primary"
variant="elevated"
@click="addSite"
prepend-icon="mdi-plus"
class="px-5"
>
新增
<VBtn v-if="props.oper === 'add'" color="primary" @click="addSite" prepend-icon="mdi-plus" class="px-5">
{{ t('site.actions.add') }}
</VBtn>
<VBtn
v-else
color="primary"
variant="elevated"
@click="updateSiteInfo"
prepend-icon="mdi-content-save"
class="px-5"
>
保存
<VBtn v-else color="primary" @click="updateSiteInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -2,8 +2,12 @@
import api from '@/api'
import { Site } from '@/api/types'
import { requiredValidator } from '@/@validators'
import { useToast } from 'vue-toast-notification'
import { useToast } from 'vue-toastification'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 输入参数
const cardProps = defineProps({
@@ -33,7 +37,7 @@ const updateButtonDisable = ref(false)
const progressDialog = ref(false)
// 进度文本
const progressText = ref('请稍候 ...')
const progressText = ref(t('dialog.siteCookieUpdate.processing'))
// 调用API更新站点Cookie UA
async function updateSiteCookie() {
@@ -44,7 +48,7 @@ async function updateSiteCookie() {
updateButtonDisable.value = true
progressDialog.value = true
progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...`
progressText.value = t('dialog.siteCookieUpdate.updating', { site: cardProps.site?.name })
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
params: {
@@ -55,9 +59,9 @@ async function updateSiteCookie() {
})
if (result.success) {
$toast.success(`${cardProps.site?.name} 更新Cookie & UA 成功!`)
$toast.success(t('dialog.siteCookieUpdate.success', { site: cardProps.site?.name }))
emit('done')
} else $toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
} else $toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message: result.message }))
progressDialog.value = false
updateButtonDisable.value = false
@@ -67,21 +71,21 @@ async function updateSiteCookie() {
}
</script>
<template>
<VDialog max-width="30rem">
<VDialog max-width="30rem" scrollable>
<!-- Dialog Content -->
<VCard title="更新站点Cookie & UA">
<VCard :title="t('dialog.siteCookieUpdate.title')">
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12">
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
<VTextField v-model="userPwForm.username" :label="t('login.username')" :rules="[requiredValidator]" />
</VCol>
<VCol cols="12">
<VTextField
v-model="userPwForm.password"
label="密码"
:label="t('login.password')"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@@ -90,7 +94,7 @@ async function updateSiteCookie() {
/>
</VCol>
<VCol cols="12">
<VTextField v-model="userPwForm.code" label="两步验证" />
<VTextField v-model="userPwForm.code" :label="t('login.otpCode')" />
</VCol>
</VRow>
</VForm>
@@ -98,14 +102,13 @@ async function updateSiteCookie() {
<VCardActions class="mx-auto">
<VBtn
size="large"
variant="elevated"
@click="updateSiteCookie"
:disabled="updateButtonDisable"
:loading="updateButtonDisable"
prepend-icon="mdi-refresh"
class="px-5"
>
开始更新
{{ t('dialog.siteCookieUpdate.updateButton') }}
</VBtn>
</VCardActions>
</VCard>

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