Compare commits

...

115 Commits

Author SHA1 Message Date
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
89 changed files with 4508 additions and 579 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

@@ -4,15 +4,34 @@
[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

View File

@@ -2,39 +2,57 @@
*[中文](README.md) | English*
Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS version: >= `v20.12.1`.
Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS version required: >= `v20.12.1`.
## Recommended IDE Setup
## 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).
## Vite Configuration
### Configure Vite
Please refer to [Vite Configuration Reference](https://vitejs.dev/config/).
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Installation
### Install Dependencies
```sh
yarn
```
### Development
### Development Server
```sh
yarn dev
```
### Build
### Build for Production
```sh
yarn build
```
### Production Deployment
### Static Deployment
1. Use `nginx` or other web servers to host the `dist` static files. See `public/nginx.conf` for nginx configuration reference.
1. Host the `dist` static files using a web server like `nginx`. Refer to `public/nginx.conf` for nginx configuration.
2. Use `node` command to run `service.js` directly. It listens on port `3000` by default. Set the environment variable `NGINX_PORT` to adjust the port.
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

2
components.d.ts vendored
View File

@@ -12,13 +12,11 @@ declare module 'vue' {
ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default']
ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default']
LoadingBanner: typeof import('./src/@core/components/LoadingBanner.vue')['default']
LocaleSwitcher: typeof import('./src/@core/components/LocaleSwitcher.vue')['default']
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
PageContentTitle: typeof import('./src/@core/components/PageContentTitle.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
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,377 @@
# 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`
- 在插件的后端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,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.4.3",
"version": "2.4.6",
"private": true,
"type": "module",
"bin": "dist/service.js",
@@ -70,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",

View File

@@ -7,7 +7,7 @@ const props = defineProps({
</script>
<template>
<div class="w-full text-center text-gray-500 text-sm flex flex-col items-center">
<div class="w-full text-center text-gray-500 text-sm flex flex-col items-center mb-5">
<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>

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

@@ -4,13 +4,9 @@ import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useI18n } from 'vue-i18n'
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
import { SupportedLocale } from '@/types/i18n'
// 国际化
const { t } = useI18n()
// 生效主题
const { global: globalTheme } = useTheme()
let themeValue = localStorage.getItem('theme') || 'light'
@@ -21,9 +17,6 @@ globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
const localeValue = getBrowserLocale()
setI18nLanguage(localeValue as SupportedLocale)
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 显示状态
const show = ref(false)
@@ -31,6 +24,9 @@ const show = ref(false)
const authStore = useAuthStore()
const isLogin = computed(() => authStore.token)
// 生成背景图片key
const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'))
// 背景图片
const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
@@ -78,6 +74,7 @@ function updateHtmlThemeAttribute(themeName: string) {
async function fetchBackgroundImages() {
try {
backgroundImages.value = await api.get(`/login/wallpapers`)
activeImageIndex.value = 0
} catch (e) {
console.error(e)
}
@@ -85,6 +82,7 @@ async function fetchBackgroundImages() {
// 开始背景图片轮换
function startBackgroundRotation() {
// 清除轮换定时器
if (backgroundRotationTimer) clearInterval(backgroundRotationTimer)
if (backgroundImages.value.length > 1) {
@@ -106,7 +104,6 @@ function startBackgroundRotation() {
function preloadImage(url: string): Promise<boolean> {
return new Promise(resolve => {
const img = new Image()
const imageUrl = getImgUrl(url)
img.onload = () => resolve(true)
img.onerror = () => resolve(false)
@@ -117,7 +114,7 @@ function preloadImage(url: string): Promise<boolean> {
resolve(false)
}, 5000) // 5秒超时
img.src = imageUrl
img.src = url
// 如果图片已经缓存onload可能不会触发
if (img.complete) {
@@ -127,28 +124,6 @@ function preloadImage(url: string): Promise<boolean> {
})
}
// 计算图片地址
function getImgUrl(url: string) {
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && isLogin.value)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}
// 处理页面可见性变化
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
// 如果已有背景图片数据,直接重启轮换
if (backgroundImages.value.length > 0) {
startBackgroundRotation()
}
// 如果没有背景图片数据,重新获取
else {
fetchBackgroundImages().then(() => startBackgroundRotation())
}
}
}
// 添加logo动画效果并延迟移除加载界面
function animateAndRemoveLoader() {
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
@@ -167,29 +142,61 @@ function animateAndRemoveLoader() {
}
}
onMounted(() => {
// 加载背景图片
async function loadBackgroundImages() {
await fetchBackgroundImages()
.then(() => {
startBackgroundRotation()
})
.catch(() => {
// 3秒后重试
setTimeout(() => {
loadBackgroundImages()
}, 3000)
})
}
onMounted(async () => {
// 初始化data-theme属性
updateHtmlThemeAttribute(globalTheme.name.value)
// 加载背景图片并开始轮换
fetchBackgroundImages().then(() => startBackgroundRotation())
// 默认隐藏页面
show.value = false
// 添加页面可见性变化监听
document.addEventListener('visibilitychange', handleVisibilityChange)
// 加载背景图片
await loadBackgroundImages()
// 移除加载动画
ensureRenderComplete(() => {
nextTick(() => {
setTimeout(() => {
// 移除加载动画
// 移除加载动画,显示页面
animateAndRemoveLoader()
}, 1500)
})
})
// 添加页面可见性变化监听
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
loadBackgroundImages()
}
})
// 添加PWA的页面恢复事件监听
window.addEventListener('pageshow', event => {
// persisted属性为true表示页面是从bfcache中恢复的
if (event.persisted) {
loadBackgroundImages()
}
})
})
onUnmounted(() => {
// 移除页面可见性监听
document.removeEventListener('visibilitychange', handleVisibilityChange)
document.removeEventListener('visibilitychange', () => {})
// 移除PWA的页面恢复事件监听
window.removeEventListener('pageshow', () => {})
// 清除轮换定时器
if (backgroundRotationTimer) {
@@ -202,20 +209,18 @@ 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 v-if="isLogin" class="global-blur-layer"></div>
</div>
</template>
<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>
<!-- 全局磨砂层 -->
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
</div>
<!-- 页面内容 -->
<VApp v-show="show" :class="{ 'transparent-app': isTransparentTheme }">
<RouterView />
</VApp>

View File

@@ -1,39 +1,79 @@
import i18n from '@/plugins/i18n'
export const storageOptions = [
export const storageAttributes = [
{
title: i18n.global.t('storage.local'),
value: 'local',
type: 'local',
icon: 'mdi-folder-multiple-outline',
remote: false,
},
{
title: i18n.global.t('storage.alipan'),
value: 'alipan',
type: 'alipan',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: i18n.global.t('storage.u115'),
value: 'u115',
type: 'u115',
icon: 'mdi-cloud-outline',
remote: true,
},
{
title: i18n.global.t('storage.rclone'),
value: 'rclone',
type: 'rclone',
icon: 'mdi-server-network-outline',
remote: true,
},
{
title: i18n.global.t('storage.alist'),
value: 'alist',
type: 'alist',
icon: 'mdi-server-network-outline',
remote: true,
},
]
export const storageDict = storageOptions.reduce((dict, item) => {
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 downloaderDict = downloaderOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
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>)

View File

@@ -7,6 +7,16 @@ const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
})
// 声明全局变量类型
declare global {
interface Window {
MoviePilotAPI: typeof api
}
}
// 将 API 实例暴露到全局,供插件使用
window.MoviePilotAPI = api
// 添加请求拦截器
api.interceptors.request.use(config => {
// 认证 Store
@@ -41,17 +51,3 @@ api.interceptors.response.use(
)
export default api
export async function fetchGlobalSettings() {
try {
const result: { [key: string]: any } = await api.get('system/global', {
params: {
token: 'moviepilot',
},
})
return result.data || {}
} catch (error) {
console.error('Failed to fetch global settings', error)
throw error
}
}

View File

@@ -631,6 +631,8 @@ export interface DashboardItem {
cols: { [key: string]: number }
// 页面元素
elements: RenderProps[]
// 渲染方式
render_mode: string
}
// 种子信息

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: 12 KiB

View File

@@ -3,12 +3,8 @@ 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 { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
import { storageIconDict } from '@/api/constants'
// 输入参数
const props = defineProps({
@@ -142,8 +138,11 @@ const showDirTree = ref(false)
// 计算属性
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',
}))
})
// 方法

View File

@@ -28,7 +28,7 @@ const getImgUrl = computed(() => {
</script>
<template>
<VHover v-bind="props">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"

View File

@@ -107,7 +107,7 @@ function onClose() {
</VCardText>
</VCard>
<VDialog v-if="ruleInfoDialog" v-model="ruleInfoDialog" scrollable max-width="40rem">
<VCard :title="t('customRule.title', { id: props.rule.id })" class="rounded-t">
<VCard :title="t('customRule.title', { id: props.rule.id })">
<VDialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />
<VCardText>

View File

@@ -1,9 +1,9 @@
<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()
@@ -19,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,
})
@@ -29,13 +33,26 @@ const isCollapsed = ref(true)
// 类型下拉字典
const typeItems = computed(() => [
{ title: t('common.all'), value: '' },
{ title: t('media.movie'), value: '电影' },
{ title: t('media.tv'), 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,
}))
})
// 自动整理方式下拉字典
@@ -263,7 +280,7 @@ watch(
<VSelect
v-model="props.directory.library_storage"
variant="underlined"
:items="storageOptions"
:items="libraryStorageOptions"
:label="t('directory.libraryStorage')"
/>
</VCol>

View File

@@ -6,8 +6,10 @@ import { useToast } from 'vue-toast-notification'
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'
// 获取i18n实例
const { t } = useI18n()
@@ -126,7 +128,7 @@ const getIcon = computed(() => {
case 'transmission':
return transmission_image
default:
return qbittorrent_image
return custom_image
}
})
@@ -172,10 +174,13 @@ 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" />
@@ -183,8 +188,8 @@ onUnmounted(() => {
</VCardText>
</VCard>
</VHover>
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.downloader.name} - ${t('downloader.title')}`" class="rounded-t">
<VDialog v-if="downloaderInfoDialog" v-model="downloaderInfoDialog" scrollable max-width="40rem">
<VCard :title="`${props.downloader.name} - ${t('downloader.title')}`">
<VDialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
@@ -278,7 +283,7 @@ onUnmounted(() => {
/>
</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"
@@ -319,6 +324,26 @@ onUnmounted(() => {
/>
</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
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:hint="t('downloader.nameRequired')"
persistent-hint
active
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">

View File

@@ -220,7 +220,7 @@ function onClose() {
</VCardText>
</VCard>
<VDialog v-if="groupInfoDialog" v-model="groupInfoDialog" scrollable max-width="80rem">
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`" class="rounded-t">
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`">
<VDialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardItem class="pt-1">

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

@@ -5,9 +5,11 @@ 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'
// 获取i18n实例
const { t } = useI18n()
@@ -108,8 +110,10 @@ const getIcon = computed(() => {
return jellyfin_image
case 'trimemedia':
return trimemedia_image
default:
case 'plex':
return plex_image
default:
return custom_image
}
})
@@ -183,17 +187,20 @@ 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" />
</VCardText>
</VCard>
<VDialog v-if="mediaServerInfoDialog" v-model="mediaServerInfoDialog" scrollable max-width="40rem">
<VCard :title="`${props.mediaserver.name} - ${t('common.config')}`" class="rounded-t">
<VCard :title="`${props.mediaserver.name} - ${t('common.config')}`">
<VDialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />
<VCardText>
@@ -243,8 +250,23 @@ onMounted(() => {
active
/>
</VCol>
<VCol cols="12">
<VSelect
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"
@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"
@@ -284,8 +306,23 @@ onMounted(() => {
active
/>
</VCol>
<VCol cols="12">
<VSelect
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"
@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"
@@ -327,8 +364,23 @@ onMounted(() => {
active
/>
</VCol>
<VCol cols="12">
<VSelect
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"
@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"
@@ -368,8 +420,21 @@ onMounted(() => {
active
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VSelect
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"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="mediaServerInfo.sync_libraries"
@@ -386,6 +451,19 @@ onMounted(() => {
/>
</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
/>
</VCol>
<VCol cols="12" md="6">
<VTextField :label="t('common.name')" :hint="t('mediaserver.nameRequired')" persistent-hint />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">

View File

@@ -135,7 +135,7 @@ function onClose() {
</VCardText>
</VCard>
<VDialog v-if="notificationInfoDialog" v-model="notificationInfoDialog" scrollable max-width="40rem">
<VCard :title="`${props.notification.name} - ${t('notification.config')}`" class="rounded-t">
<VCard :title="`${props.notification.name} - ${t('notification.config')}`">
<VDialogCloseBtn v-model="notificationInfoDialog" />
<VDivider />
<VCardText>

View File

@@ -82,9 +82,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">

View File

@@ -168,12 +168,8 @@ const dropdownItems = ref([
>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
: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">
<VCardTitle
class="text-white text-lg px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis ..."

View File

@@ -345,12 +345,8 @@ watch(
>
<div
class="relative flex flex-row items-start pa-3 justify-between grow"
:style="{ background: `${backgroundColor}` }"
: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" />

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

@@ -293,21 +293,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>
@@ -322,29 +321,29 @@ 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">
<template #prepend>
<VIcon icon="mdi-web" size="small" />
<VIcon icon="mdi-web" size="20" />
</template>
<VListItemTitle>{{ t('site.browseResources') }}</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">{{ t('site.deleteSite') }}</VListItemTitle>
</VListItem>
@@ -386,12 +385,6 @@ onMounted(() => {
</template>
<style scoped>
.site-card:hover {
.site-card-actions {
transform: translateX(0);
}
}
.site-status-indicator {
position: absolute;
z-index: 1;
@@ -430,15 +423,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;
}
/* 测试状态点样式 */
@@ -446,22 +439,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 {
@@ -508,11 +501,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%;
}
/* 动画关键帧 */
@@ -522,6 +515,7 @@ onMounted(() => {
opacity: 0.85;
transform: scaleX(0.95);
}
50% {
opacity: 1;
transform: scaleX(1.05);
@@ -532,9 +526,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);
}
@@ -544,9 +540,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);
}
@@ -556,9 +554,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);
}
@@ -568,9 +568,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);
}
@@ -580,6 +582,7 @@ onMounted(() => {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
@@ -589,8 +592,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

@@ -6,6 +6,7 @@ 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 custom_png from '@images/misc/database.png'
import api from '@/api'
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
@@ -14,7 +15,7 @@ import AlistConfigDialog from '../dialog/AlistConfigDialog.vue'
import { useToast } from 'vue-toast-notification'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useI18n } from 'vue-i18n'
import { storageOptions } from '@/api/constants'
import { storageIconDict } from '@/api/constants'
// 国际化
const { t } = useI18n()
@@ -28,7 +29,7 @@ const props = defineProps({
})
// 定义事件
const emit = defineEmits(['done'])
const emit = defineEmits(['done', 'close'])
// 提示信息
const $toast = useToast()
@@ -44,6 +45,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网盘认证对话框
@@ -52,6 +62,8 @@ const u115AuthDialog = ref(false)
const rcloneConfigDialog = ref(false)
// AList配置对话框
const aListConfigDialog = ref(false)
// 自定义存储配置对话框
const customConfigDialog = ref(false)
// 打开存储对话框
function openStorageDialog() {
@@ -68,9 +80,12 @@ function openStorageDialog() {
case 'alist':
aListConfigDialog.value = true
break
default:
case 'local':
$toast.info(t('storage.noConfigNeeded'))
break
default:
customConfigDialog.value = true
break
}
}
@@ -88,7 +103,7 @@ const getIcon = computed(() => {
case 'alist':
return alist_png
default:
return storage_png
return custom_png
}
})
@@ -125,28 +140,33 @@ function handleDone() {
u115AuthDialog.value = false
rcloneConfigDialog.value = false
aListConfigDialog.value = false
emit('done')
}
// 根据存储类型获取文本
function getStorageTypeText(type: string) {
return storageOptions.find((option) => option.value === type)?.title
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 v-if="!storageIconDict[storage.type]" @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ getStorageTypeText(storage.type) }}</h5>
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
</div>
<VImg :src="getIcon" cover class="mt-5" max-width="3rem" min-width="3rem" />
<VImg :src="getIcon" cover class="mt-7" 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" />
@@ -180,5 +200,35 @@ onMounted(() => {
@close="aListConfigDialog = false"
@done="handleDone"
/>
<VDialog v-if="customConfigDialog" v-model="customConfigDialog" scrollable max-width="30rem">
<VCard>
<VCardItem>
<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
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="customName" :label="t('storage.name')" persistent-hint active />
</VCol>
</VRow>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="handleDone" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -296,99 +296,109 @@ 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="170"
@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 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"
/>
</div>
</div>
</VCard>
</div>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->

View File

@@ -97,64 +97,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="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" />
</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>
</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">
<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

@@ -121,12 +121,12 @@ 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>

View File

@@ -22,7 +22,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)
@@ -34,7 +67,7 @@ async function savaAlistConfig() {
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard :title="t('dialog.alistConfig.title')" class="rounded-t">
<VCard :title="t('dialog.alistConfig.title')">
<VDialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
@@ -46,7 +79,16 @@ async function savaAlistConfig() {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VCol cols="12" md="4">
<VSelect
v-model="loginType"
:items="sourceItems"
:label="t('dialog.alistConfig.loginType')"
:hint="t('dialog.alistConfig.loginType')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4" v-if="loginType == 'username'">
<VTextField
v-model="props.conf.username"
:hint="t('dialog.alistConfig.username')"
@@ -54,7 +96,7 @@ async function savaAlistConfig() {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VCol cols="12" md="4" v-if="loginType == 'username'">
<VTextField
type="password"
v-model="props.conf.password"
@@ -63,10 +105,21 @@ async function savaAlistConfig() {
persistent-hint
/>
</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
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="tonal" color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
{{ t('dialog.alistConfig.reset') }}
</VBtn>
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('dialog.alistConfig.complete') }}
</VBtn>

View File

@@ -78,6 +78,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()
})
@@ -89,7 +107,7 @@ onUnmounted(() => {
<template>
<VDialog width="40rem" scrollable max-height="85vh">
<VCard :title="t('dialog.aliyunAuth.loginTitle')" class="rounded-t">
<VCard :title="t('dialog.aliyunAuth.loginTitle')">
<VDialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2 flex flex-col items-center">
<div class="my-6 rounded text-center p-3 border">
@@ -107,6 +125,9 @@ onUnmounted(() => {
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="tonal" color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
{{ t('dialog.aliyunAuth.reset') }}
</VBtn>
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('dialog.aliyunAuth.complete') }}
</VBtn>

View File

@@ -25,7 +25,7 @@ function handleImport() {
<template>
<VDialog width="40rem" scrollable max-height="85vh">
<VCard :title="props.title" class="rounded-t">
<VCard :title="props.title">
<VDialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">
<VTextarea v-model="codeString" />

View File

@@ -7,6 +7,7 @@ import { useToast } from 'vue-toast-notification'
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()
@@ -42,29 +43,84 @@ 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保存配置数据
@@ -75,42 +131,66 @@ async function savePluginConf() {
try {
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
if (result.success) {
progressDialog.value = false
$toast.success(t('dialog.pluginConfig.saveSuccess', { name: props.plugin?.plugin_name }))
// 通知父组件刷新
emit('save')
} else {
progressDialog.value = false
$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} - ${t('dialog.pluginConfig.title')}`" 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">
{{ t('dialog.pluginConfig.viewData') }}
</VBtn>
<VSpacer />
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
{{ t('dialog.pluginConfig.save') }}
<!-- 只有Vuetify模式显示默认保存按钮Vue模式由组件内部控制 -->
<VBtn
v-if="renderMode === 'vuetify'"
@click="savePluginConf"
variant="elevated"
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,6 +3,7 @@ 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'
// 输入参数
const props = defineProps({
@@ -21,32 +22,112 @@ const appMode = inject('pwaMode') && display.mdAndDown.value
// 是否刷新
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
icon="mdi-cog"
@@ -59,5 +140,17 @@ onMounted(() => {
:class="{ 'mb-10': appMode }"
/>
</VCard>
<!-- Vue 渲染模式 -->
<VCard v-else-if="renderMode === 'vue'">
<VCardText class="pa-0">
<component
:is="dynamicComponent"
:api="api"
@action="handleAction"
@switch="emit('switch')"
@close="emit('close')"
/>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -45,7 +45,7 @@ onMounted(() => {
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard class="rounded-t">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-store-cog" class="me-2" />

View File

@@ -38,11 +38,23 @@ 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="t('dialog.rcloneConfig.title')" class="rounded-t">
<VCard :title="t('dialog.rcloneConfig.title')">
<VDialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
@@ -63,6 +75,9 @@ async function savaRcloneConfig() {
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="tonal" color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
{{ t('dialog.rcloneConfig.reset') }}
</VBtn>
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('dialog.rcloneConfig.complete') }}
</VBtn>

View File

@@ -2,11 +2,11 @@
import { useToast } from 'vue-toast-notification'
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'
// 国际化
@@ -58,6 +58,28 @@ const progressText = ref(t('dialog.reorganize.processing'))
// 整理进度
const progressValue = ref(0)
// 所有存储
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)
}
}
// 存储字典
const storageOptions = computed(() => {
return storages.value.map(item => ({
title: item.name,
value: item.type,
}))
})
// 标题
const dialogTitle = computed(() => {
if (props.items) {
@@ -218,6 +240,7 @@ async function transfer(background: boolean = false) {
onMounted(() => {
loadDirectories()
loadStorages()
})
onUnmounted(() => {
@@ -227,7 +250,7 @@ onUnmounted(() => {
<template>
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="dialogTitle" class="rounded-t">
<VCard :title="dialogTitle">
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>

View File

@@ -152,7 +152,6 @@ onMounted(async () => {
:title="`${props.oper === 'add' ? t('site.actions.add') : t('site.actions.edit')}${t('site.title')}${
props.oper !== 'add' ? ` - ${siteForm.name}` : ''
}`"
class="rounded-t"
>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />

View File

@@ -283,7 +283,7 @@ onBeforeMount(async () => {
<template>
<VDialog scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard class="rounded-t">
<VCard>
<VCardItem>
<VCardTitle
>{{ t('dialog.siteUserData.title') }} - {{ props.site?.name }}

View File

@@ -292,7 +292,6 @@ onMounted(() => {
: '',
})
"
class="rounded-t"
>
<VCardText>
<VDialogCloseBtn @click="emit('close')" />

View File

@@ -23,6 +23,9 @@ const emit = defineEmits(['close'])
// 订阅文件信息
const subScribeInfo = ref<SubscrbieInfo>()
// 是否加载中
const loading = ref(false)
// 下载文件表头
const downloadHeaders = [
{ title: t('dialog.subscribeFiles.episodeColumn'), key: 'episode_number', sortable: true },
@@ -39,9 +42,12 @@ const libraryHeaders = [
// 调用API查询订阅文件信息
async function loadSubscribeFilesInfo() {
try {
loading.value = true
subScribeInfo.value = await api.get(`subscribe/files/${props.subid}`)
} catch (e) {
console.log(e)
} finally {
loading.value = false
}
}
@@ -80,11 +86,12 @@ onBeforeMount(() => {
</script>
<template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard class="rounded-t">
<VCard>
<VCardItem class="my-2">
<VDialogCloseBtn @click="emit('close')" />
</VCardItem>
<VCardText>
<LoadingBanner v-if="loading" />
<VCardText v-else>
<div class="media-page">
<div class="media-header">
<div class="media-poster">

View File

@@ -60,7 +60,6 @@ const $toast = useToast()
:title="`${t('dialog.subscribeShare.shareSubscription')} - ${props.sub?.name} ${
props.sub?.season ? t('dialog.subscribeShare.season', { number: props.sub?.season }) : ''
}`"
class="rounded-t"
>
<VCardText>
<VDialogCloseBtn @click="emit('close')" />

View File

@@ -35,7 +35,23 @@ async function handleDone() {
emit('done')
}
// 调用/aliyun/qrcode api生成二维码
// 重置配置
async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/u115')
if (result.success) {
// 重置成功
alertType.value = 'success'
handleDone()
} else {
alertType.value = 'error'
text.value = result.message
}
} catch (e) {
console.error(e)
}
}
// 调用/u115/qrcode api生成二维码
async function getQrcode() {
try {
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
@@ -96,7 +112,7 @@ onUnmounted(() => {
<template>
<VDialog width="40rem" scrollable max-height="85vh">
<VCard :title="t('dialog.u115Auth.loginTitle')" class="rounded-t">
<VCard :title="t('dialog.u115Auth.loginTitle')">
<VDialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2 flex flex-col items-center">
<div class="my-6 rounded text-center p-3 border">
@@ -108,6 +124,9 @@ onUnmounted(() => {
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="tonal" color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
{{ t('dialog.u115Auth.reset') }}
</VBtn>
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('dialog.u115Auth.complete') }}
</VBtn>

View File

@@ -295,7 +295,6 @@ onMounted(() => {
:title="`${props.oper === 'add' ? t('dialog.userAddEdit.add') : t('dialog.userAddEdit.edit')}${
props.oper !== 'add' ? ` - ${userName}` : ''
}`"
class="rounded-t"
>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />

View File

@@ -134,7 +134,7 @@ onMounted(async () => {
<template>
<VDialog width="40rem" max-height="85vh">
<VCard :title="t('dialog.userAuth.title')" class="rounded-t">
<VCard :title="t('dialog.userAuth.title')">
<VDialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>

View File

@@ -86,7 +86,7 @@ async function editWorkflow() {
<template>
<VDialog scrollable :close-on-back="false" eager max-width="30rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="title" class="rounded-t">
<VCard :title="title">
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText>

View File

@@ -700,27 +700,21 @@ onMounted(() => {
<VDialogCloseBtn @click="renamePopper = false" />
<VDivider />
<VCardText>
<div class="mb-3">
<span>{{ t('file.currentName') }}: {{ currentItem?.name }}</span>
</div>
<VTextField v-model="newName" :label="t('file.newName')" />
<VCheckbox
v-if="false && currentItem?.type == 'dir'"
v-model="renameAll"
:label="t('file.includeSubfolders')"
></VCheckbox>
<VRow>
<VCol cols="12">
<VTextField v-model="newName" :label="t('file.newName')" :loading="renameLoading" />
</VCol>
<VCol cols="12" v-if="currentItem && currentItem.type == 'dir'">
<VSwitch v-model="renameAll" :label="t('file.includeSubfolders')" />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<div class="flex-grow-1" />
<VBtn
:disabled="!newName"
variant="elevated"
:loading="renameLoading"
@click="rename"
prepend-icon="mdi-check"
class="px-5 me-3"
>
{{ t('common.save') }}
<VBtn color="success" variant="elevated" @click="get_recommend_name" prepend-icon="mdi-magic" class="px-5 me-3">
{{ t('file.autoRecognizeName') }}
</VBtn>
<VBtn :disabled="!newName" variant="elevated" @click="rename" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>

View File

@@ -162,7 +162,7 @@ const sortIcon = computed(() => {
<IconBtn @click="changeSort">
<VIcon :icon="sortIcon" />
</IconBtn>
<IconBtn @click="goUp">
<IconBtn v-if="pathSegments.length > 0" @click="goUp">
<VIcon icon="mdi-arrow-up-bold-outline" />
</IconBtn>
<VDialog v-model="newFolderPopper" max-width="35rem">

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import api from '@/api'
import { DashboardItem } from '@/api/types'
import AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue'
import AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'
@@ -12,6 +13,7 @@ import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
import DashboardRender from '@/components/render/DashboardRender.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
import { loadRemoteComponent } from '@/utils/federationLoader'
// 输入参数
const props = defineProps({
@@ -28,6 +30,43 @@ const props = defineProps({
const emit = defineEmits(['update:refreshStatus'])
// 插件UI渲染模式 ('vuetify' 或 'vue')
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
// Vue 模式:动态加载的组件
const dynamicPluginComponent = defineAsyncComponent({
// 工厂函数
loader: async () => {
try {
if (!props.config?.id) {
throw new Error('插件ID不存在')
}
// 动态加载远程组件
const module = await loadRemoteComponent(props.config.id, 'Dashboard')
// 直接返回加载的组件无需再获取default
return module
} catch (error) {
console.error('加载远程组件失败:', error)
}
},
// 加载中显示的组件
loadingComponent: {
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
},
// 添加错误处理
errorComponent: {
template: `
<div class="pa-4">
<VAlert type="error" title="组件加载错误">
无法加载组件,请稍后再试
</VAlert>
</div>
`,
},
})
onUnmounted(() => {
// 组件卸载时禁用刷新状态
emit('update:refreshStatus', false)
@@ -46,34 +85,49 @@ onUnmounted(() => {
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
<MediaServerLatest v-else-if="config?.id === 'latest'" />
<!-- 插件仪表板 -->
<VHover v-else-if="!isNullOrEmptyObject(props.config)">
<template #default="hover">
<!-- 无边框 -->
<div v-if="props.config?.attrs.border === false">
<VCard v-bind="hover.props">
<VCardText class="p-0">
<template v-else-if="!isNullOrEmptyObject(props.config)">
<!-- Vue 渲染模式 -->
<div v-if="pluginRenderMode === 'vue'">
<component :is="dynamicPluginComponent" :config="props.config" :allow-refresh="props.allowRefresh" :api="api" />
<!-- Vue 模式下也可以显示拖拽句柄 -->
<div class="absolute right-5 top-5">
<VIcon class="cursor-move">mdi-drag</VIcon>
</div>
</div>
<!-- Vuetify 渲染模式 -->
<VHover v-else-if="pluginRenderMode === 'vuetify'">
<template #default="hover">
<!-- 无边框 -->
<div v-if="props.config?.attrs.border === false">
<VCard v-bind="hover.props">
<VCardText class="p-0">
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
</VCardText>
<div v-if="hover.isHovering" class="absolute right-5 top-5">
<VIcon class="cursor-move">mdi-drag</VIcon>
</div>
</VCard>
</div>
<!-- 有边框 -->
<VCard v-else v-bind="hover.props">
<VCardItem v-if="props.config?.attrs.border !== false">
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>
{{ props.config?.attrs?.title ?? props.config?.name }}
</VCardTitle>
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
</VCardItem>
<VCardText>
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
</VCardText>
<div v-if="hover.isHovering" class="absolute right-5 top-5">
<VIcon class="cursor-move">mdi-drag</VIcon>
</div>
</VCard>
</div>
<!-- 有边框 -->
<VCard v-else v-bind="hover.props">
<VCardItem v-if="props.config?.attrs.border !== false">
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>
{{ props.config?.attrs?.title ?? props.config?.name }}
</VCardTitle>
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
</VCardItem>
<VCardText>
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
</VCardText>
</VCard>
</template>
</VHover>
</template>
</VHover>
<!-- 未知模式或错误 -->
<VCard v-else>
<VCardText>无法渲染插件仪表盘部件: 未知渲染模式或配置错误</VCardText>
</VCard>
</template>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import api from '@/api'
import { StorageConf } from '@/api/types'
import { Handle, Position } from '@vue-flow/core'
import { storageOptions } from '@/api/constants'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -15,6 +16,27 @@ defineProps({
required: true,
},
})
// 所有存储
const storages = ref<StorageConf[]>([])
// 查询存储
async function loadStorages() {
const result: { [key: string]: any } = await api.get('system/setting/Storages')
storages.value = result.data?.value ?? []
}
// 存储字典
const storageOptions = computed(() => {
return storages.value.map(item => ({
title: item.name,
value: item.type,
}))
})
onMounted(() => {
loadStorages()
})
</script>
<template>
<div>

View File

@@ -203,7 +203,7 @@ onMounted(() => {
</VCard>
</VMenu>
<!-- 名称测试弹窗 -->
<VDialog v-if="nameTestDialog" v-model="nameTestDialog" max-width="35rem" scrollable>
<VDialog v-if="nameTestDialog" v-model="nameTestDialog" max-width="45rem" scrollable>
<VCard>
<VCardItem>
<VCardTitle>
@@ -298,7 +298,7 @@ onMounted(() => {
<VDialog
v-if="messageDialog"
v-model="messageDialog"
max-width="40rem"
max-width="50rem"
scrollable
:fullscreen="!display.mdAndUp.value"
ref="messageDialogRef"

View File

@@ -86,32 +86,41 @@ onBeforeUnmount(() => {
</template>
</VCardItem>
<VDivider />
<div v-if="notificationList.length > 0">
<VListItem v-for="(item, i) in notificationList" :key="i" lines="two" class="mb-1">
<template #prepend>
<VAvatar rounded>
<VIcon v-if="item.type === 'user'" icon="mdi-account-alert" size="large"></VIcon>
<VIcon v-else-if="item.type === 'plugin'" icon="mdi-robot" size="large"></VIcon>
<VIcon v-else icon="mdi-laptop" size="large"></VIcon>
</VAvatar>
</template>
<div>
<div class="text-body-1 text-high-emphasis break-words whitespace-break-spaces">
{{ item.title }}
<div class="notification-list-container">
<div v-if="notificationList.length > 0" class="h-full overflow-y-auto">
<VListItem v-for="(item, i) in notificationList" :key="i" lines="two" class="mb-1">
<template #prepend>
<VAvatar rounded>
<VIcon v-if="item.type === 'user'" icon="mdi-account-alert" size="large"></VIcon>
<VIcon v-else-if="item.type === 'plugin'" icon="mdi-robot" size="large"></VIcon>
<VIcon v-else icon="mdi-laptop" size="large"></VIcon>
</VAvatar>
</template>
<div>
<div class="text-body-1 text-high-emphasis break-words whitespace-break-spaces">
{{ item.title }}
</div>
<div class="text-caption mt-1.5">
{{ item.text }}
</div>
<div class="text-sm text-primary mt-1.5">
{{ formatDateDifference(item.date) }}
</div>
</div>
<div class="text-caption mt-1.5">
{{ item.text }}
</div>
<div class="text-sm text-primary mt-1.5">
{{ formatDateDifference(item.date) }}
</div>
</div>
</VListItem>
</div>
<div v-else class="py-8 text-center">
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" />
<div>{{ t('notification.empty') }}</div>
</VListItem>
</div>
<div v-else class="py-8 text-center">
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" />
<div>{{ t('notification.empty') }}</div>
</div>
</div>
</VCard>
</VMenu>
</template>
<style scoped>
.notification-list-container {
overflow: hidden;
max-block-size: 50vh;
}
</style>

View File

@@ -38,6 +38,7 @@ export default {
share: 'Share',
unsubscribe: 'Unsubscribe',
media: 'Media',
unknown: 'Unknown',
},
mediaType: {
movie: 'Movie',
@@ -296,6 +297,7 @@ export default {
encodingAesKeyHint: 'EncodingAESKey in WeChat Work self-built app -> API message receiving configuration',
admins: 'Admin Whitelist',
adminsHint: 'User IDs that can use admin menu and commands, separated by commas',
adminsPlaceholder: 'User IDs list, separated by commas',
},
telegram: {
name: 'Telegram',
@@ -307,6 +309,8 @@ export default {
usersHint: 'User IDs that can use Telegram bot, separated by commas. Leave empty to allow all users',
admins: 'Admin Whitelist',
adminsHint: 'User IDs that can use admin menu and commands, separated by commas',
adminsPlaceholder: 'User IDs list, separated by commas',
usersPlaceholder: 'User IDs list, separated by commas',
},
slack: {
name: 'Slack',
@@ -722,11 +726,17 @@ export default {
filterEdition: 'Edition',
filterResolution: 'Resolution',
filterReleaseGroup: 'Release Group',
noMatchingResults: 'No matching data',
allFilters: 'All Filters',
clearAll: 'Clear All',
},
calendar: {
episode: 'Episode {number}',
},
storage: {
name: 'Name',
type: 'Type',
customTypeHint: 'Custom storage type, used for plugins and other scenarios',
usedPercent: '{percent}% Used',
noConfigNeeded: 'This storage type does not require configuration, please configure the directory directly!',
notConfigured: 'Not Configured',
@@ -735,6 +745,7 @@ export default {
u115: '115 Cloud',
rclone: 'RClone',
alist: 'AList',
custom: 'Custom',
},
filterRules: {
specSub: 'Special Subtitle',
@@ -784,6 +795,7 @@ export default {
},
site: {
noSites: 'No Sites',
noFilterData: 'No matching sites found',
sitesWillBeShownHere: 'Added and supported sites will be displayed here.',
title: 'Site',
status: {
@@ -911,6 +923,7 @@ export default {
dataDirectory: '/moviepilot',
},
system: {
custom: 'Custom',
basicSettings: 'Basic Settings',
basicSettingsDesc: 'Configure server global functions.',
appDomain: 'Access Domain',
@@ -998,6 +1011,8 @@ export default {
scrapFollowTmdb: 'Follow TMDB Recognition',
scrapFollowTmdbHint:
'When turned off, organization history will be used (if available) to avoid TMDB data changes during subscription',
scrapOriginalImage: 'Scrap TheMovieDb Original Language Image',
scrapOriginalImageHint: 'Scrap original language image from themoviedb, otherwise scrap metadata language image',
fanartEnable: 'Fanart Image Data Source',
fanartEnableHint: 'Use image data from fanart.tv',
githubProxy: 'Github Acceleration Proxy',
@@ -1107,6 +1122,15 @@ export default {
notification: {
channels: 'Notification Channels',
channelsDesc: 'Set message sending channel parameters.',
organizeSuccess: 'Media Import',
downloadAdded: 'Download Added',
subscribeAdded: 'Subscribe Added',
subscribeComplete: 'Subscribe Complete',
templateConfigTitle: 'Message Template',
templateConfigDesc: 'Set message template, support Jinja2 syntax.',
templateSaveFailed: 'Failed to save template!',
templateSaveSuccess: 'Template saved successfully',
templateLoadFailed: 'Failed to load template!',
scope: 'Notification Scope',
scopeDesc: 'Corresponding message types will only be sent to specified users.',
messageType: 'Message Type',
@@ -1463,12 +1487,14 @@ export default {
scanQrCode: 'Please scan with WeChat or 115 client',
scanned: 'Scanned, please confirm login',
complete: 'Complete',
reset: 'Reset',
},
aliyunAuth: {
loginTitle: 'Aliyun Drive Login',
scanQrCode: 'Please scan with Aliyun Drive App',
scanned: 'Scanned',
complete: 'Complete',
reset: 'Reset',
},
rcloneConfig: {
title: 'RClone Configuration',
@@ -1477,6 +1503,7 @@ export default {
defaultContent:
'# Please fill in your rclone config file content here \n# Please refer to https://rclone.org/docs/ \n# Storage node name must be: MP',
complete: 'Complete',
reset: 'Reset',
},
alistConfig: {
title: 'Alist Configuration',
@@ -1491,6 +1518,7 @@ export default {
token: 'Token',
},
complete: 'Complete',
reset: 'Reset',
},
workflowAddEdit: {
addTitle: 'Add Workflow',
@@ -1826,6 +1854,7 @@ export default {
},
file: {
newFolder: 'New Folder',
autoRecognizeName: 'Auto Recognize Name',
createFolder: 'Create Folder',
fileName: 'File Name',
fileSize: 'File Size',
@@ -2081,6 +2110,7 @@ export default {
name: 'Name',
type: 'Type',
enabled: 'Enabled',
customTypeHint: 'Custom downloader type, for plugin scenarios',
default: 'Default',
host: 'Host',
username: 'Username',
@@ -2121,6 +2151,8 @@ export default {
},
},
mediaserver: {
type: 'Type',
customTypeHint: 'Custom media server type, for plugin scenarios',
enableMediaServer: 'Enable Media Server',
nameRequired: 'Required; cannot be duplicated',
serverAlias: 'Media server alias',
@@ -2320,4 +2352,8 @@ export default {
byFileSize: 'By File Size',
keepLatestOnly: 'Keep Latest Only',
},
validators: {
required: 'This field is required',
number: 'Please enter a number',
},
}

View File

@@ -38,6 +38,7 @@ export default {
share: '分享',
unsubscribe: '取消订阅',
media: '媒体',
unknown: '未知',
},
mediaType: {
movie: '电影',
@@ -294,6 +295,7 @@ export default {
encodingAesKeyHint: '微信企业自建应用->API接收消息配置中的EncodingAESKey',
admins: '管理员白名单',
adminsHint: '可使用管理菜单及命令的用户ID列表多个ID使用,分隔',
adminsPlaceholder: '用户ID列表多个ID使用,分隔',
},
telegram: {
name: 'Telegram',
@@ -305,6 +307,8 @@ export default {
usersHint: '可使用Telegram机器人的用户ID清单多个用户用,分隔,不填写则所有用户都能使用',
admins: '管理员白名单',
adminsHint: '可使用管理菜单及命令的用户ID列表多个ID使用,分隔',
adminsPlaceholder: '用户ID列表多个ID使用,分隔',
usersPlaceholder: '用户ID列表多个ID使用,分隔',
},
slack: {
name: 'Slack',
@@ -505,8 +509,8 @@ export default {
sendMessage: {
title: '发送消息',
subtitle: '发送系统消息',
type: '类型',
content: '内容',
channel: '渠道',
userId: '用户ID',
},
transferFile: {
title: '传输文件',
@@ -719,11 +723,17 @@ export default {
filterEdition: '质量',
filterResolution: '分辨率',
filterReleaseGroup: '制作组',
noMatchingResults: '没有数据',
allFilters: '综合筛选',
clearAll: '清除全部',
},
calendar: {
episode: '第{number}集',
},
storage: {
name: '名称',
type: '类型',
customTypeHint: '自定义存储类型,用于插件等场景',
usedPercent: '已使用 {percent}%',
noConfigNeeded: '此存储类型无需配置参数,请直接配置目录!',
notConfigured: '未配置',
@@ -732,6 +742,7 @@ export default {
u115: '115网盘',
rclone: 'RClone',
alist: 'AList',
custom: '自定义',
},
filterRules: {
specSub: '特效字幕',
@@ -782,6 +793,7 @@ export default {
site: {
noSites: '没有站点',
sitesWillBeShownHere: '已添加并支持的站点将会在这里显示。',
noFilterData: '没有符合条件的站点',
title: '站点',
status: {
enabled: '启用',
@@ -908,6 +920,7 @@ export default {
dataDirectory: '/moviepilot',
},
system: {
custom: '自定义',
basicSettings: '基础设置',
basicSettingsDesc: '设置服务器的全局功能。',
appDomain: '访问域名',
@@ -992,6 +1005,8 @@ export default {
metaCacheExpireMin: '元数据缓存时间必须大于等于0',
scrapFollowTmdb: '跟随TMDB识别整理',
scrapFollowTmdbHint: '关闭时以整理历史记录为准如有避免TMDB数据在订阅中途修改',
scrapOriginalImage: 'TMDB 刮削原语种图片',
scrapOriginalImageHint: '刮削原语种图片,否则刮削元数据语种图片',
fanartEnable: 'Fanart图片数据源',
fanartEnableHint: '使用 fanart.tv 的图片数据',
githubProxy: 'Github加速代理',
@@ -1095,6 +1110,15 @@ export default {
notification: {
channels: '通知渠道',
channelsDesc: '设置消息发送渠道参数。',
organizeSuccess: '资源入库',
downloadAdded: '资源下载',
subscribeAdded: '添加订阅',
subscribeComplete: '订阅完成',
templateConfigTitle: '通知模板',
templateConfigDesc: '设置通知模板支持Jinja2语法。',
templateSaveFailed: '模板保存失败!',
templateSaveSuccess: '模板保存成功',
templateLoadFailed: '模板加载失败!',
scope: '通知发送范围',
scopeDesc: '对应消息类型只会发送给设定的用户。',
messageType: '消息类型',
@@ -1442,12 +1466,14 @@ export default {
scanQrCode: '请使用微信或115客户端扫码',
scanned: '已扫码,请确认登录',
complete: '完成',
reset: '重置',
},
aliyunAuth: {
loginTitle: '阿里云盘登录',
scanQrCode: '请用阿里云盘 App 扫码',
scanned: '已扫码',
complete: '完成',
reset: '重置',
},
rcloneConfig: {
title: 'RClone配置',
@@ -1455,6 +1481,7 @@ export default {
fileContent: 'rclone配置文件内容',
defaultContent: '# 请在此处填写rclone配置文件内容 \n# 请参考 https://rclone.org/docs/ \n# 存储节点名必须为MP',
complete: '完成',
reset: '重置',
},
alistConfig: {
title: 'Alist配置',
@@ -1466,9 +1493,10 @@ export default {
loginTypeOptions: {
guest: '访客',
username: '用户名密码',
token: 'Token',
token: '令牌',
},
complete: '完成',
reset: '重置',
},
workflowAddEdit: {
addTitle: '添加工作流',
@@ -1804,6 +1832,7 @@ export default {
},
file: {
newFolder: '新建文件夹',
autoRecognizeName: '自动识别名称',
createFolder: '创建文件夹',
fileName: '文件名',
fileSize: '文件大小',
@@ -1851,7 +1880,7 @@ export default {
renamingAll: '正在重命名 {path} 及目录内所有文件...',
close: '关闭',
loadingDirectoryStructure: '加载目录结构...',
reorganize: '重新整理',
reorganize: '整理',
},
person: {
alias: '别名:',
@@ -2057,6 +2086,7 @@ export default {
name: '名称',
type: '类型',
enabled: '启用',
customTypeHint: '自定义下载器类型,用于插件等场景',
default: '默认',
host: '地址',
username: '用户名',
@@ -2067,7 +2097,7 @@ export default {
first_last_piece: '优先首尾文件',
saveSuccess: '下载器设置保存成功',
saveFailed: '下载器设置保存失败',
nameRequired: '名称不能为空',
nameRequired: '不能为空,且不能重名',
nameDuplicate: '名称已存在',
defaultChanged: '存在默认下载器,已替换',
},
@@ -2097,8 +2127,10 @@ export default {
},
},
mediaserver: {
type: '类型',
customTypeHint: '自定义媒体服务器类型,用于插件等场景',
enableMediaServer: '启用媒体服务器',
nameRequired: '必填不可与其他名称重名',
nameRequired: '必填不可重名',
serverAlias: '媒体服务器的别名',
host: '地址',
hostPlaceholder: 'http(s)://ip:port',
@@ -2296,4 +2328,8 @@ export default {
byFileSize: '按文件大小',
keepLatestOnly: '仅保留最新',
},
validators: {
required: '此项为必填项',
number: '请输入数字',
},
}

View File

@@ -38,6 +38,7 @@ export default {
share: '分享',
unsubscribe: '取消訂閱',
media: '媒體',
unknown: '未知',
},
mediaType: {
movie: '電影',
@@ -295,6 +296,7 @@ export default {
encodingAesKeyHint: '微信企業自建應用->API接收消息配置中的EncodingAESKey',
admins: '管理員白名單',
adminsHint: '可使用管理菜單及命令的用戶ID列表多個ID使用,分隔',
adminsPlaceholder: '用戶ID列表多個ID使用,分隔',
},
telegram: {
name: 'Telegram',
@@ -306,6 +308,8 @@ export default {
usersHint: '可使用Telegram機器人的用戶ID清單多個用戶用,分隔,不填寫則所有用戶都能使用',
admins: '管理員白名單',
adminsHint: '可使用管理菜單及命令的用戶ID列表多個ID使用,分隔',
adminsPlaceholder: '用戶ID列表多個ID使用,分隔',
usersPlaceholder: '用戶ID列表多個ID使用,分隔',
},
slack: {
name: 'Slack',
@@ -506,8 +510,8 @@ export default {
sendMessage: {
title: '發送消息',
subtitle: '發送任務執行消息',
type: '類型',
content: '內容',
channel: '渠道',
userId: '用戶ID',
},
transferFile: {
title: '整理文件',
@@ -720,11 +724,17 @@ export default {
filterEdition: '质量',
filterResolution: '分辨率',
filterReleaseGroup: '製作組',
noMatchingResults: '沒有數據',
allFilters: '綜合篩選',
clearAll: '清除全部',
},
calendar: {
episode: '第{number}集',
},
storage: {
name: '名稱',
type: '類型',
customTypeHint: '自定義存儲類型,用於插件等場景',
usedPercent: '已使用 {percent}%',
noConfigNeeded: '此存儲類型無需配置參數,請直接配置目錄!',
notConfigured: '未配置',
@@ -733,6 +743,7 @@ export default {
u115: '115網盤',
rclone: 'RClone',
alist: 'AList',
custom: '自定義',
},
filterRules: {
@@ -783,6 +794,7 @@ export default {
},
site: {
noSites: '沒有站點',
noFilterData: '沒有符合條件的站點',
sitesWillBeShownHere: '已添加並支持的站點將會在這裡顯示。',
title: '站點',
status: {
@@ -910,6 +922,7 @@ export default {
dataDirectory: '/moviepilot',
},
system: {
custom: '自定義',
basicSettings: '基礎設置',
basicSettingsDesc: '設置服務器的全局功能。',
appDomain: '訪問域名',
@@ -994,6 +1007,8 @@ export default {
metaCacheExpireMin: '元數據緩存時間必須大於等於0',
scrapFollowTmdb: '跟隨TMDB識別整理',
scrapFollowTmdbHint: '關閉時以整理歷史記錄為準如有避免TMDB數據在訂閱中途修改',
scrapOriginalImage: 'TMDB 刮削原語种圖片',
scrapOriginalImageHint: '刮削原語种圖片,否则數據元数据語种圖片',
fanartEnable: 'Fanart圖片數據源',
fanartEnableHint: '使用 fanart.tv 的圖片數據',
githubProxy: 'Github加速代理',
@@ -1097,6 +1112,15 @@ export default {
notification: {
channels: '通知渠道',
channelsDesc: '設置消息發送渠道參數。',
organizeSuccess: '資源入庫',
downloadAdded: '資源下載',
subscribeAdded: '添加訂閱',
subscribeComplete: '訂閱完成',
templateConfigTitle: '通知模板',
templateConfigDesc: '設置通知模板支持Jinja2語法。',
templateSaveFailed: '模板保存失敗!',
templateSaveSuccess: '模板保存成功',
templateLoadFailed: '模板載入失敗!',
scope: '通知發送範圍',
scopeDesc: '對應消息類型只會發送給設定的用戶。',
messageType: '消息類型',
@@ -1443,12 +1467,14 @@ export default {
scanQrCode: '請使用微信或115客戶端掃碼',
scanned: '已掃碼,請確認登錄',
complete: '完成',
reset: '重置',
},
aliyunAuth: {
loginTitle: '阿里雲盤登錄',
scanQrCode: '請用阿里雲盤 App 掃碼',
scanned: '已掃碼',
complete: '完成',
reset: '重置',
},
rcloneConfig: {
title: 'RClone配置',
@@ -1456,6 +1482,7 @@ export default {
fileContent: 'rclone配置文件內容',
defaultContent: '# 請在此處填寫rclone配置文件內容 \n# 請參考 https://rclone.org/docs/ \n# 存儲節點名必須為MP',
complete: '完成',
reset: '重置',
},
alistConfig: {
title: 'Alist配置',
@@ -1470,6 +1497,7 @@ export default {
token: 'Token',
},
complete: '完成',
reset: '重置',
},
workflowAddEdit: {
addTitle: '新增工作流',
@@ -1806,6 +1834,7 @@ export default {
},
file: {
newFolder: '新建文件夾',
autoRecognizeName: '自動識別名稱',
createFolder: '創建文件夾',
fileName: '文件名',
fileSize: '文件大小',
@@ -2058,6 +2087,7 @@ export default {
title: '下載器',
name: '名稱',
type: '類型',
customTypeHint: '自定義下載器類型,用於插件等場景',
enabled: '啟用',
default: '預設',
host: '地址',
@@ -2099,6 +2129,8 @@ export default {
},
},
mediaserver: {
type: '類型',
customTypeHint: '自定義媒體伺服器類型,用於插件等場景',
enableMediaServer: '啟用媒體伺服器',
nameRequired: '必填;不可與其他名稱重名',
serverAlias: '媒體伺服器的別名',
@@ -2298,4 +2330,8 @@ export default {
byFileSize: '按文件大小',
keepLatestOnly: '僅保留最新',
},
validators: {
required: '此項為必填項',
number: '請輸入數字',
},
}

View File

@@ -18,8 +18,9 @@ import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
import { CronVuetify } from '@vue-js-cron/vuetify'
// 4. 工具函数和其他辅助模块
import { fetchGlobalSettings } from './api'
import { isPWA } from './@core/utils/navigator'
import { loadRemoteComponents } from './utils/federationLoader'
import { fetchGlobalSettings } from './utils/globalSetting'
// 5. 其他插件和功能模块
import ToastPlugin from 'vue-toast-notification'
@@ -60,6 +61,9 @@ async function initializeApp() {
// 全局设置
const globalSettings = await fetchGlobalSettings()
app.provide('globalSettings', globalSettings)
// 加载并注册远程联邦组件
await loadRemoteComponents()
} catch (error) {
console.error('Failed to initialize app', error)
}

View File

@@ -7,20 +7,16 @@ import { requiredValidator } from '@/@validators'
import api from '@/api'
import router from '@/router'
import logo from '@images/logo.png'
import { useTheme } from 'vuetify'
import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
import { useI18n } from 'vue-i18n'
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
// 主题
const { global: globalTheme } = useTheme()
// 国际化
const { t } = useI18n()
// 认证 Store
const authStore = useAuthStore()
//用户 Store
const userStore = useUserStore()
// 国际化
const { t } = useI18n()
// 表单
const form = ref({
@@ -52,6 +48,9 @@ const currentLocale = ref(getCurrentLocale())
// 可用的语言列表
const locales = Object.values(SUPPORTED_LOCALES)
// 登录按钮 loading
const loading = ref(false)
// 切换语言
async function switchLanguage(locale: SupportedLocale) {
await setI18nLanguage(locale)
@@ -107,6 +106,8 @@ async function afterLogin(superuser: boolean) {
router.push(authStore.originalPath ?? '/')
// 订阅推送通知
if (superuser) await subscribeForPushNotifications()
// 登录按钮 loading
loading.value = false
}
// 登录获取token事件
@@ -117,6 +118,10 @@ function login() {
if (!form.value.username || !form.value.password || (isOTP.value && !form.value.otp_password)) {
return
}
// 登录按钮 loading
loading.value = true
// 用户名密码
const formData = new FormData()
@@ -159,6 +164,8 @@ function login() {
else if (error.response.status === 403) errorMessage.value = t('login.permissionDenied')
else if (error.response.status === 500) errorMessage.value = t('login.serverError')
else errorMessage.value = `${t('login.loginFailed')} ${error.response.status}${t('login.checkCredentials')}`
// 登录按钮 loading
loading.value = false
})
}
@@ -256,7 +263,9 @@ onMounted(async () => {
</VCol>
<VCol cols="12">
<!-- login button -->
<VBtn block type="submit" @click="login" prepend-icon="mdi-login"> {{ t('login.login') }} </VBtn>
<VBtn block type="submit" @click="login" prepend-icon="mdi-login" :loading="loading">
{{ t('login.login') }}
</VBtn>
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</VAlert>

View File

@@ -202,9 +202,11 @@ onUnmounted(() => {
<!-- 精简标题栏 -->
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3">
<div class="search-info-container d-flex align-center flex-wrap">
<div class="search-title text-primary">{{ t('resource.searchResults') }}</div>
<div class="search-tags d-flex flex-wrap">
<div class="search-info-container">
<div class="search-title text-moviepilot">
{{ t('resource.searchResults') }}
</div>
<div class="search-tags d-flex flex-wrap mt-1">
<VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="flat">
{{ t('resource.keyword') }}: {{ keyword }}
</VChip>
@@ -346,7 +348,7 @@ onUnmounted(() => {
}
.search-title {
font-size: 1.1rem;
font-size: 1.2rem;
font-weight: 600;
}
@@ -526,7 +528,7 @@ onUnmounted(() => {
}
.search-title {
font-size: 0.95rem;
font-size: 1.2rem;
white-space: nowrap;
}

View File

@@ -42,6 +42,12 @@ html.v-overlay-scroll-blocked {
transform: rotate(0deg) translate(0, 0);
}
.match-height.v-row {
.v-card {
block-size: 100%;
}
}
.v-toast--bottom {
z-index: 2500;
margin-block-end: env(safe-area-inset-bottom);
@@ -109,12 +115,14 @@ html.v-overlay-scroll-blocked {
// 美化滚动条
::-webkit-scrollbar {
block-size: 8px;
inline-size: 8px;
block-size: 4px;
inline-size: 4px;
opacity: 0;
transition: opacity 0.3s;
}
::-webkit-scrollbar-thumb {
border-radius: 3px;
border-radius: 2px;
background: rgb(var(--v-theme-perfect-scrollbar-thumb));
box-shadow: inset 0 0 10px rgba(0,0,0,20%);
@@ -125,6 +133,16 @@ html.v-overlay-scroll-blocked {
}
}
// 当鼠标悬停在可滚动元素上时显示滚动条
*:hover::-webkit-scrollbar {
opacity: 1;
}
// 当元素正在滚动时显示滚动条
*:active::-webkit-scrollbar {
opacity: 1;
}
.v-alert--variant-elevated, .v-alert--variant-flat {
background: rgb(var(--v-table-header-background));
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));

View File

@@ -0,0 +1,116 @@
import api from '@/api'
import {
__federation_method_setRemote,
__federation_method_getRemote,
__federation_method_unwrapDefault,
// @ts-ignore
} from 'virtual:__federation__'
// 定义远程模块接口
interface RemoteModule {
id: string
url: string
}
/**
* 获取单个远程模块信息
* @param id 远程模块ID
*/
async function fetchSingleRemoteModule(id: string): Promise<RemoteModule | null> {
try {
const modules = await fetchRemoteModules()
return modules.find(module => module.id === id) || null
} catch (error) {
console.error(`获取远程模块信息失败: ${id}`, error)
return null
}
}
/**
* 加载远程组件
* @param id 远程模块ID
* @param componentName 组件名称 (如 'Page')
*/
export async function loadRemoteComponent(id: string, componentName: string = 'Page') {
try {
const module = await __federation_method_getRemote(id, `./${componentName}`)
return __federation_method_unwrapDefault(module)
} catch (error) {
// 组件未注册,尝试重新注册
try {
const moduleInfo = await fetchSingleRemoteModule(id)
if (moduleInfo) {
console.log(`组件未注册,正在重新注册: ${id}`)
injectRemoteModule(moduleInfo)
// 重新尝试加载组件
const module = await __federation_method_getRemote(id, `./${componentName}`)
return __federation_method_unwrapDefault(module)
} else {
console.error(`无法找到远程模块信息: ${id}`)
throw new Error(`无法找到远程模块信息: ${id}`)
}
} catch (retryError) {
console.error(`重新注册并加载组件失败: ${id}/${componentName}`, retryError)
throw retryError
}
}
}
/**
* 从API获取远程模块列表
*/
async function fetchRemoteModules(): Promise<RemoteModule[]> {
try {
const response = await api.get('plugin/remotes?token=moviepilot')
return (response as any) || []
} catch (error) {
console.error('获取远程模块列表失败:', error)
return []
}
}
/**
* 动态注入Federation Remote模块
* @param modules 远程模块列表
*/
function injectRemoteModule(module: RemoteModule): void {
// 从浏览器地址栏获取当前地址前缀
const baseUrl = new URL(window.location.href)
// 环境变量
let apiBase = import.meta.env.VITE_API_BASE_URL
if (apiBase.startsWith('/')) {
apiBase = apiBase.slice(1)
}
if (apiBase.endsWith('/')) {
apiBase = apiBase.slice(0, -1)
}
__federation_method_setRemote(module.id, {
url: () => Promise.resolve(`${baseUrl.origin}/${apiBase}${module.url}`),
format: 'esm',
from: 'vite',
})
console.log('已注入远程模块:', module)
}
/**
* 初始化并加载所有远程组件
*/
export async function loadRemoteComponents(): Promise<void> {
try {
// 获取远程模块列表
const modules = await fetchRemoteModules()
// 确保有模块才注入
if (modules && modules.length > 0) {
// 注入远程模块
modules.forEach(module => {
injectRemoteModule(module)
})
} else {
console.log('没有发现可用的远程模块')
}
} catch (error) {
console.error('加载远程组件失败:', error)
}
}

View File

@@ -0,0 +1,14 @@
import api from '@/api'
export async function fetchGlobalSettings() {
try {
const result: { [key: string]: any } = await api.get('system/global', {
params: {
token: 'moviepilot',
},
})
return result.data || {}
} catch (error) {
console.error('Failed to fetch global settings', error)
throw error
}
}

View File

@@ -2,16 +2,16 @@
import { debounce } from 'lodash-es'
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { TransferHistory } from '@/api/types'
import type { StorageConf, TransferHistory } from '@/api/types'
import ReorganizeDialog from '@/components/dialog/ReorganizeDialog.vue'
import TransferQueueDialog from '@/components/dialog/TransferQueueDialog.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useRoute } from 'vue-router'
import router from '@/router'
import { useDisplay } from 'vuetify'
import { storageDict } from '@/api/constants'
import { formatFileSize } from '@/@core/utils/formatters'
import { useI18n } from 'vue-i18n'
import { storageAttributes } from '@/api/constants'
// i18n
const { t } = useI18n()
@@ -181,6 +181,28 @@ const deleteConfirmDialog = ref(false)
// 确认框标题
const confirmTitle = ref('')
// 所有存储
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)
}
}
// 存储字典
const storageDict = computed(() => {
return storages.value.reduce((dict, item) => {
dict[item.type] = item.name
return dict
}, {} as Record<string, string>)
})
// 转移方式字典
const TransferDict: { [key: string]: string } = {
copy: t('transferHistory.transferMode.copy'),
@@ -432,7 +454,10 @@ function ensureNumber(value: any, defaultValue: number = 0) {
}
// 初始加载数据
onMounted(fetchData)
onMounted(() => {
loadStorages()
fetchData()
})
</script>
<template>

View File

@@ -102,11 +102,6 @@ async function saveStorages() {
}
}
// 修改后生效
async function updatedStorage() {
await loadStorages()
}
// 查询目录
async function loadDirectories() {
try {
@@ -174,6 +169,31 @@ async function loadMediaCategories() {
}
}
// 添加存储
function addStorage() {
storages.value.push({
name: `${t('storage.custom')} ${storages.value.length + 1}`,
type: 'custom',
config: {},
})
}
// 移除存储
function removeStorage(storage: StorageConf) {
const index = storages.value.indexOf(storage)
if (index > -1) {
storages.value.splice(index, 1)
}
}
// 更新存储
async function updatedStorage(storage: StorageConf) {
const index = storages.value.indexOf(storage)
if (index > -1) {
storages.value[index] = storage
}
}
// 保存设置
async function saveSystemSettings(value: any) {
try {
@@ -212,7 +232,7 @@ onMounted(() => {
:component-data="{ 'class': 'grid gap-3 grid-app-card' }"
>
<template #item="{ element }">
<StorageCard :storage="element" @done="updatedStorage" />
<StorageCard :storage="element" @close="removeStorage(element)" @done="updatedStorage" />
</template>
</draggable>
</VCardText>
@@ -220,6 +240,9 @@ onMounted(() => {
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn type="submit" class="me-2" @click="saveStorages"> {{ t('common.save') }} </VBtn>
<VBtn color="success" variant="tonal" @click="addStorage">
<VIcon icon="mdi-plus" />
</VBtn>
</div>
</VForm>
</VCardText>
@@ -246,6 +269,7 @@ onMounted(() => {
<DirectoryCard
:directory="element"
:categories="mediaCategories"
:storages="storages"
@update:modelValue="(value: any) => {element.download_path = value?.download; element.library_path = value?.library}"
@close="removeDirectory(element)"
/>

View File

@@ -7,10 +7,45 @@ import NotificationChannelCard from '@/components/cards/NotificationChannelCard.
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { notificationSwitchDict } from '@/api/constants'
import { useTheme } from 'vuetify'
// 国际化
const { t } = useI18n()
// 初始化模板配置字典
const templateConfigs = ref<Record<string, string>>({
organizeSuccess: '{}',
downloadAdded: '{}',
subscribeAdded: '{}',
subscribeComplete: '{}',
})
// 模板类型配置
const templateTypes = ref([
{
type: 'organizeSuccess',
label: t('setting.notification.organizeSuccess'),
},
{
type: 'downloadAdded',
label: t('setting.notification.downloadAdded'),
},
{
type: 'subscribeAdded',
label: t('setting.notification.subscribeAdded'),
},
{
type: 'subscribeComplete',
label: t('setting.notification.subscribeComplete'),
},
])
// 编辑器主题
const { name: themeName, global: globalTheme } = useTheme()
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
const currentThemeName = ref(savedTheme.value)
const editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai'))
// 所有消息渠道
const notifications = ref<NotificationConf[]>([])
@@ -19,6 +54,9 @@ const $toast = useToast()
// 进度框
const progressDialog = ref(false)
const editorVisible = ref(false)
const currentTemplate = ref('')
const editorContent = ref('')
// 消息类型开关
const notificationSwitchs = ref<NotificationSwitchConf[]>([
@@ -105,6 +143,43 @@ async function loadNotificationSetting() {
}
}
async function openEditor(type: string) {
try {
currentTemplate.value = type
const result: { [key: string]: any } = await api.get('system/setting/NotificationTemplates')
templateConfigs.value = result.data?.value || {}
editorContent.value = templateConfigs.value[type] || '{}'
editorVisible.value = true
} catch (error) {
console.error(error)
$toast.error(t('setting.notification.templateLoadFailed'))
}
}
async function saveTemplate() {
try {
await api.post('system/setting/NotificationTemplates', {
...templateConfigs.value,
[currentTemplate.value]: editorContent.value,
})
$toast.success(t('setting.notification.templateSaveSuccess'))
editorVisible.value = false
} catch (error) {
console.error(error)
$toast.error(t('setting.notification.templateSaveFailed'))
}
}
async function loadTemplateConfigs() {
try {
const result: { [key: string]: any } = await api.get('system/setting/NotificationTemplates')
templateConfigs.value = result.data?.value || {}
} catch (error) {
console.error(error)
$toast.error(t('setting.notification.templateLoadFailed'))
}
}
// 调用API查询通知发送时间设置
async function loadNotificationTime() {
try {
@@ -182,6 +257,7 @@ onMounted(() => {
loadNotificationSetting()
loadNotificationSwitchs()
loadNotificationTime()
loadTemplateConfigs()
})
</script>
@@ -217,7 +293,7 @@ onMounted(() => {
<VBtn mtype="submit" @click="saveNotificationSetting"> {{ t('common.save') }} </VBtn>
<VBtn color="success" variant="tonal">
<VIcon icon="mdi-plus" />
<VMenu activator="parent" close-on-content-click>
<VMenu :activator="'parent'" :close-on-content-click="true">
<VList>
<VListItem @click="addNotification('wechat')">
<VListItemTitle>{{ t('setting.notification.wechat') }}</VListItemTitle>
@@ -246,6 +322,46 @@ onMounted(() => {
</VCard>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VCard>
<VCardItem>
<VCardTitle>{{ t('setting.notification.templateConfigTitle') }}</VCardTitle>
<VCardSubtitle>{{ t('setting.notification.templateConfigDesc') }}</VCardSubtitle>
</VCardItem>
<VCardText>
<VRow>
<VCol v-for="item in templateTypes" :key="item.type" cols="12" sm="6" md="3">
<VCard variant="tonal" class="template-card" :class="{ 'on-hover': true }" @click="openEditor(item.type)">
<VCardItem>
<template #prepend>
<VAvatar color="primary" variant="tonal" rounded size="42" class="me-3">
<VIcon
size="24"
:icon="
item.type === 'organizeSuccess'
? 'mdi-folder-check'
: item.type === 'downloadAdded'
? 'mdi-download'
: item.type === 'subscribeAdded'
? 'mdi-rss'
: 'mdi-check-circle'
"
/>
</VAvatar>
</template>
<VCardTitle>{{ item.label }}</VCardTitle>
<template #append>
<VIcon icon="mdi-chevron-right" />
</template>
</VCardItem>
</VCard>
</VCol>
</VRow>
</VCardText>
</VCard>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VCard>
@@ -321,4 +437,47 @@ onMounted(() => {
:text="t('setting.system.reloading')"
:indeterminate="true"
/>
<!-- 模板编辑器对话框 -->
<VDialog v-model="editorVisible" v-if="editorVisible" max-width="50rem">
<VCard>
<VCardItem>
<VCardTitle>
{{ templateTypes.find(t => t.type === currentTemplate)?.label }}
{{ t('setting.notification.templateConfigTitle') }}
</VCardTitle>
<VDialogCloseBtn @click="editorVisible = false" />
</VCardItem>
<VCardText class="py-0">
<VAceEditor
v-model:value="editorContent"
lang="json"
:theme="editorTheme"
class="w-full min-h-[30rem] rounded"
/>
</VCardText>
<VCardActions class="mx-auto pt-3">
<VBtn variant="elevated" color="primary" @click="saveTemplate" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
/* Monaco编辑器容器样式 */
.monaco-editor-container {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
overflow: hidden;
margin-top: 1rem;
}
.template-card {
cursor: pointer;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.template-card.on-hover:hover {
transform: translateY(-4px);
}
</style>

View File

@@ -10,6 +10,7 @@ import MediaServerCard from '@/components/cards/MediaServerCard.vue'
import { copyToClipboard } from '@/@core/utils/navigator'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { downloaderOptions, mediaServerOptions } from '@/api/constants'
// 国际化
const { t } = useI18n()
@@ -42,6 +43,7 @@ const SystemSettings = ref<any>({
META_CACHE_EXPIRE: 0,
SCRAP_FOLLOW_TMDB: true,
FANART_ENABLE: false,
TMDB_SCRAP_ORIGINAL_IMAGE: null,
// 网络
PROXY_HOST: null,
GITHUB_PROXY: null,
@@ -535,11 +537,11 @@ onDeactivated(() => {
<VIcon icon="mdi-plus" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem @click="addDownloader('qbittorrent')">
<VListItemTitle>{{ t('setting.system.qbittorrent') }}</VListItemTitle>
<VListItem v-for="item in downloaderOptions" @click="addDownloader(item.value)">
<VListItemTitle>{{ item.title }}</VListItemTitle>
</VListItem>
<VListItem @click="addDownloader('transmission')">
<VListItemTitle>{{ t('setting.system.transmission') }}</VListItemTitle>
<VListItem @click="addDownloader('custom')">
<VListItemTitle>{{ t('setting.system.custom') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
@@ -583,17 +585,11 @@ onDeactivated(() => {
<VIcon icon="mdi-plus" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem @click="addMediaServer('emby')">
<VListItemTitle>{{ t('setting.system.emby') }}</VListItemTitle>
<VListItem v-for="item in mediaServerOptions" @click="addMediaServer(item.value)">
<VListItemTitle>{{ item.title }}</VListItemTitle>
</VListItem>
<VListItem @click="addMediaServer('jellyfin')">
<VListItemTitle>{{ t('setting.system.jellyfin') }}</VListItemTitle>
</VListItem>
<VListItem @click="addMediaServer('plex')">
<VListItemTitle>{{ t('setting.system.plex') }}</VListItemTitle>
</VListItem>
<VListItem @click="addMediaServer('trimemedia')">
<VListItemTitle>{{ t('setting.system.trimeMedia') }}</VListItemTitle>
<VListItem @click="addMediaServer('custom')">
<VListItemTitle>{{ t('setting.system.custom') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
@@ -745,6 +741,14 @@ onDeactivated(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.TMDB_SCRAP_ORIGINAL_IMAGE"
:label="t('setting.system.scrapOriginalImage')"
:hint="t('setting.system.scrapOriginalImageHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.FANART_ENABLE"

View File

@@ -34,10 +34,6 @@ const props = defineProps({
// 是否刷新过
let isRefreshed = ref(false)
// 顺序存储键值
const localOrderKey = props.type === t('media.movie') ? 'MP_SUBSCRIBE_MOVIE_ORDER' : 'MP_SUBSCRIBE_TV_ORDER'
const orderRequestKey = props.type === t('media.movie') ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'
// 刷新状态
const loading = ref(false)
@@ -53,6 +49,10 @@ const orderConfig = ref<{ id: number }[]>([])
// 显示的订阅列表
const displayList = ref<Subscribe[]>([])
// 顺序存储键值(计算属性)
const localOrderKey = computed(() => (props.type === '电影' ? 'MP_SUBSCRIBE_MOVIE_ORDER' : 'MP_SUBSCRIBE_TV_ORDER'))
const orderRequestKey = computed(() => (props.type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'))
// 监听dataList变化同步更新displayList
watch([dataList, () => props.keyword], () => {
if (superUser)
@@ -74,26 +74,27 @@ watch([dataList, () => props.keyword], () => {
// 加载顺序
async function loadSubscribeOrderConfig() {
// 顺序配置
const local_order = localStorage.getItem(localOrderKey)
const local_order = localStorage.getItem(localOrderKey.value)
if (local_order) {
orderConfig.value = JSON.parse(local_order)
} else {
const response = await api.get(`/user/config/${orderRequestKey}`)
if (response && response.data && response.data.value) {
orderConfig.value = response.data.value
localStorage.setItem(localOrderKey, JSON.stringify(orderConfig.value))
localStorage.setItem(localOrderKey.value, JSON.stringify(orderConfig.value))
}
}
}
// 按order的顺序排序
function sortSubscribeOrder() {
async function sortSubscribeOrder() {
if (!orderConfig.value) {
return
}
if (displayList.value.length === 0) {
return
}
await loadSubscribeOrderConfig()
displayList.value.sort((a, b) => {
const aIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === a.id)
const bIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === b.id)
@@ -107,7 +108,7 @@ async function saveSubscribeOrder() {
const orderObj = displayList.value.map(item => ({ id: item.id }))
orderConfig.value = orderObj
const orderString = JSON.stringify(orderObj)
localStorage.setItem(localOrderKey, orderString)
localStorage.setItem(localOrderKey.value, orderString)
// 保存到服务端
try {
@@ -136,7 +137,6 @@ function historyDone() {
}
onMounted(async () => {
await loadSubscribeOrderConfig()
await fetchData()
if (props.subid) {
// 找到这个订阅

View File

@@ -37,6 +37,9 @@ const filterForm: Record<string, string[]> = reactive({
// 排序选项
const sortField = ref('default')
// 降序
const sortType = ref<'asc' | 'desc'>('desc')
const sortTitles: Record<string, string> = {
default: t('torrent.sortDefault'),
site: t('torrent.sortSite'),
@@ -78,11 +81,15 @@ const groupedDataList = ref<Map<string, Context[]>>()
// 过滤菜单相关
const filterMenuOpen = ref(false)
const currentFilter = ref('site')
const currentFilterTitle = computed(() => filterTitles[currentFilter.value])
const currentFilterOptions = computed(() => {
return filterOptions[currentFilter.value]
})
// 添加全部筛选菜单相关
const allFilterMenuOpen = ref(false)
// 初始化过滤选项
function initOptions(data: Context) {
const { torrent_info, meta_info } = data
@@ -208,7 +215,7 @@ onMounted(() => {
})
// 修改watch监听同时监听排序字段的变化
watch([filterForm, groupedDataList, sortField], filterData)
watch([filterForm, groupedDataList, sortField, sortType], filterData)
function filterData() {
// 清空列表
@@ -254,16 +261,30 @@ function filterData() {
// 排序数据
if (sortField.value !== 'default') {
filteredData.sort((a, b) => {
if (sortField.value === 'site') {
// 按站点名称排序
return (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
} else if (sortField.value === 'size') {
// 按文件大小排序(降序)
return (Number(b.torrent_info.size) || 0) - (Number(a.torrent_info.size) || 0)
} else if (sortField.value === 'seeder') {
// 按做种数排序(降序)
return (Number(b.torrent_info.seeders) || 0) - (Number(a.torrent_info.seeders) || 0)
if (sortType.value === 'desc') {
if (sortField.value === 'site') {
// 按站点名称排序
return (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
} else if (sortField.value === 'size') {
// 按文件大小排序(降序)
return (Number(b.torrent_info.size) || 0) - (Number(a.torrent_info.size) || 0)
} else if (sortField.value === 'seeder') {
// 按做种数排序(降序)
return (Number(b.torrent_info.seeders) || 0) - (Number(a.torrent_info.seeders) || 0)
}
} else {
if (sortField.value === 'site') {
// 按站点名称排序
return (b.torrent_info.site_name || '').localeCompare(a.torrent_info.site_name || '')
} else if (sortField.value === 'size') {
// 按文件大小排序(降序)
return (Number(a.torrent_info.size) || 0) - (Number(b.torrent_info.size) || 0)
} else if (sortField.value === 'seeder') {
// 按做种数排序(降序)
return (Number(a.torrent_info.seeders) || 0) - (Number(b.torrent_info.seeders) || 0)
}
}
return 0
})
}
@@ -303,6 +324,11 @@ function toggleFilterMenu(key: string) {
}
}
// 开关全部筛选菜单
function toggleAllFilterMenu() {
allFilterMenuOpen.value = !allFilterMenuOpen.value
}
// 清除所有过滤条件
function clearAllFilters() {
for (const key in filterForm) {
@@ -355,6 +381,12 @@ function loadMore({ done }: { done: any }) {
displayDataList.value.push(...itemsToMove)
done('ok')
}
// 处理图标点击
const handleSortIconClick = () => {
// 切换排序方向
sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'
}
</script>
<template>
@@ -375,9 +407,15 @@ function loadMore({ done }: { done: any }) {
density="compact"
hide-details
class="sort-select"
prepend-icon="mdi-sort"
variant="plain"
></VSelect>
>
<template #prepend-inner>
<!-- 添加排序点击事件 -->
<VIcon @mousedown.stop.prevent="handleSortIconClick">
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
</VIcon>
</template>
</VSelect>
</div>
<!-- 筛选按钮组 -->
@@ -432,6 +470,22 @@ function loadMore({ done }: { done: any }) {
</VMenu>
</VBtn>
<!-- 全部筛选按钮 -->
<VBtn
variant="tonal"
size="small"
color="primary"
class="filter-btn ms-2"
prepend-icon="mdi-filter-variant"
rounded="pill"
@click="toggleAllFilterMenu"
>
{{ t('torrent.allFilters') }}
<VChip v-if="getFilterCount > 0" size="small" color="primary" class="ms-1" variant="elevated">
{{ getFilterCount }}
</VChip>
</VBtn>
<!-- 清除全部筛选按钮 -->
<VBtn
v-if="getFilterCount > 0"
@@ -496,13 +550,35 @@ function loadMore({ done }: { done: any }) {
density="compact"
hide-details
class="mobile-sort-select"
prepend-icon="mdi-sort"
variant="plain"
></VSelect>
>
<template #prepend-inner>
<!-- 添加排序点击事件 -->
<VIcon @mousedown.stop.prevent="handleSortIconClick">
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
</VIcon>
</template>
</VSelect>
</div>
<!-- 筛选图标按钮区域 -->
<div class="filter-buttons-grid w-100">
<div class="filter-buttons-grid w-100 mt-2">
<!-- 全部筛选按钮 -->
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
<span class="filter-label">
{{ t('torrent.allFilters') }}
</span>
<VBadge
v-if="getFilterCount > 0"
:content="getFilterCount"
color="primary"
location="top end"
offset-x="-10"
offset-y="-10"
></VBadge>
</VBtn>
<VBtn
v-for="(title, key) in filterTitles"
v-show="filterOptions[key].length > 0"
@@ -529,6 +605,76 @@ function loadMore({ done }: { done: any }) {
</div>
</VCard>
<!-- 全部筛选弹窗 -->
<VDialog v-model="allFilterMenuOpen" max-width="50rem" max-height="90%" location="center" scrollable>
<VCard>
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
<VCardTitle class="py-3 d-flex align-center">
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
<span>{{ t('torrent.allFilters') }}</span>
<VSpacer />
<VBtn
v-if="getFilterCount > 0"
class="me-10"
variant="text"
size="small"
color="error"
@click="clearAllFilters"
>
{{ t('torrent.clearAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<div class="all-filters-grid">
<VCard
v-for="(title, key) in filterTitles"
variant="tonal"
:key="key"
class="filter-section"
v-show="filterOptions[key].length > 0"
>
<VCardItem class="py-2">
<template #prepend>
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
</template>
<VCardTitle>{{ title }}</VCardTitle>
<template #append>
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
{{ t('torrent.selectAll') }}
</VBtn>
<VBtn
v-if="filterForm[key].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(key)"
>
{{ t('torrent.clear') }}
</VBtn>
</template>
</VCardItem>
<VCardText>
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
<VChip
v-for="option in filterOptions[key]"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
</VCard>
</div>
</VCardText>
</VCard>
</VDialog>
<!-- 筛选弹窗 -->
<VDialog v-model="filterMenuOpen" max-width="25rem" max-height="80%" location="center">
<VCard>
@@ -550,7 +696,7 @@ function loadMore({ done }: { done: any }) {
</VBtn>
</VCardTitle>
<VDivider />
<VCardText class="filter-menu-content pt-4">
<VCardText>
<VChipGroup v-model="filterForm[currentFilter]" column multiple class="filter-options">
<VChip
v-for="option in currentFilterOptions"
@@ -567,7 +713,9 @@ function loadMore({ done }: { done: any }) {
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" color="primary" @click="filterMenuOpen = false"> {{ t('torrent.confirm') }} </VBtn>
<VBtn variant="elevated" color="primary" @click="filterMenuOpen = false">
{{ t('torrent.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
@@ -633,6 +781,7 @@ function loadMore({ done }: { done: any }) {
}
.filter-menu-content {
max-block-size: 50vh;
overflow-y: auto;
}
@@ -699,7 +848,7 @@ function loadMore({ done }: { done: any }) {
.mobile-sort-select {
max-inline-size: 130px;
min-inline-size: 110px;
min-inline-size: 80px;
}
.filter-buttons-grid {
@@ -739,4 +888,14 @@ function loadMore({ done }: { done: any }) {
background-color: rgba(var(--v-theme-background), 0.95);
inset-block-start: 0;
}
.all-filters-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.filter-section {
background-color: rgba(var(--v-theme-surface-variant), 0.08);
}
</style>

View File

@@ -61,6 +61,8 @@ const filterOptions: Record<string, string[]> = reactive({
// 排序字段
const sortField = ref('default')
// 降序
const sortType = ref<'asc' | 'desc'>('desc')
// 数据列表
const dataList = ref<Array<Context>>([])
@@ -202,7 +204,7 @@ function sortSeasonOptions() {
}
// 修改watch监听同时监听排序字段的变化
watch([filterForm, sortField], filterData)
watch([filterForm, sortField, sortType], filterData)
// 计算过滤后的列表
function filterData() {
@@ -247,16 +249,30 @@ function filterData() {
})
// 排序
if (sortField.value === 'default') {
filteredData = filteredData.sort((a, b) => b.torrent_info.pri_order - a.torrent_info.pri_order)
} else if (sortField.value === 'site') {
filteredData = filteredData.sort((a, b) =>
(a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || ''),
)
} else if (sortField.value === 'size') {
filteredData = filteredData.sort((a, b) => b.torrent_info.size - a.torrent_info.size)
} else if (sortField.value === 'seeder') {
filteredData = filteredData.sort((a, b) => b.torrent_info.seeders - a.torrent_info.seeders)
if (sortType.value === 'desc') {
if (sortField.value === 'default') {
filteredData = filteredData.sort((a, b) => b.torrent_info.pri_order - a.torrent_info.pri_order)
} else if (sortField.value === 'site') {
filteredData = filteredData.sort((a, b) =>
(a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || ''),
)
} else if (sortField.value === 'size') {
filteredData = filteredData.sort((a, b) => b.torrent_info.size - a.torrent_info.size)
} else if (sortField.value === 'seeder') {
filteredData = filteredData.sort((a, b) => b.torrent_info.seeders - a.torrent_info.seeders)
}
} else {
if (sortField.value === 'default') {
filteredData = filteredData.sort((a, b) => a.torrent_info.pri_order - b.torrent_info.pri_order)
} else if (sortField.value === 'site') {
filteredData = filteredData.sort((a, b) =>
(b.torrent_info.site_name || '').localeCompare(a.torrent_info.site_name || ''),
)
} else if (sortField.value === 'size') {
filteredData = filteredData.sort((a, b) => a.torrent_info.size - b.torrent_info.size)
} else if (sortField.value === 'seeder') {
filteredData = filteredData.sort((a, b) => a.torrent_info.seeders - b.torrent_info.seeders)
}
}
// 显示前20个
@@ -277,10 +293,17 @@ const filterMenuOpen = ref(false)
const currentFilter = ref('site')
const currentFilterTitle = computed(() => filterTitles[currentFilter.value])
const currentFilterOptions = computed(() => {
// 季集选项不再需要特殊处理,直接从 filterOptions 获取
return filterOptions[currentFilter.value]
})
// 添加全部筛选菜单相关
const allFilterMenuOpen = ref(false)
// 开关全部筛选菜单
function toggleAllFilterMenu() {
allFilterMenuOpen.value = !allFilterMenuOpen.value
}
// 给定过滤类型返回不同图标
function getFilterIcon(key: string) {
const icons: Record<string, string> = {
@@ -331,6 +354,12 @@ function loadMore({ done }: { done: any }) {
done('ok')
}
// 处理图标点击
const handleSortIconClick = () => {
// 切换排序方向
sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'
}
onMounted(() => {
filterData()
})
@@ -356,11 +385,17 @@ onMounted(() => {
density="compact"
hide-details
class="sort-select"
prepend-icon="mdi-sort"
variant="plain"
>
<template #prepend-inner>
<!-- 添加排序点击事件 -->
<VIcon @mousedown.stop.prevent="handleSortIconClick">
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
</VIcon>
</template>
</VSelect>
<div class="filter-divider"></div>
<!-- 筛选按钮 -->
<VBtn
v-for="(title, key) in filterTitles"
@@ -412,6 +447,22 @@ onMounted(() => {
</VMenu>
</VBtn>
<!-- 全部筛选按钮 -->
<VBtn
variant="tonal"
size="small"
color="primary"
class="filter-btn me-2"
prepend-icon="mdi-filter-variant"
rounded="pill"
@click="toggleAllFilterMenu"
>
{{ t('torrent.allFilters') }}
<VChip v-if="getFilterCount > 0" size="small" color="primary" class="ms-1" variant="elevated">
{{ getFilterCount }}
</VChip>
</VBtn>
<!-- 清除全部筛选按钮 -->
<VBtn
v-if="getFilterCount > 0"
@@ -463,7 +514,7 @@ onMounted(() => {
class="search-count me-auto"
prepend-icon="mdi-magnify"
>
{{ props.items?.length || 0 }} 个资源
{{ props.items?.length || 0 }} {{ t('torrent.resources') }}
</VChip>
<!-- 排序选择 -->
@@ -475,13 +526,35 @@ onMounted(() => {
density="compact"
hide-details
class="mobile-sort-select"
prepend-icon="mdi-sort"
variant="plain"
></VSelect>
>
<template #prepend-inner>
<!-- 添加排序点击事件 -->
<VIcon @mousedown.stop.prevent="handleSortIconClick">
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
</VIcon>
</template>
</VSelect>
</div>
<!-- 筛选图标按钮区域 -->
<div class="filter-buttons-grid w-100 mt-2">
<!-- 全部筛选按钮 -->
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
<span class="filter-label">
{{ t('torrent.allFilters') }}
</span>
<VBadge
v-if="getFilterCount > 0"
:content="getFilterCount"
color="primary"
location="top end"
offset-x="-10"
offset-y="-10"
></VBadge>
</VBtn>
<VBtn
v-for="(title, key) in filterTitles"
v-show="filterOptions[key].length > 0"
@@ -508,6 +581,76 @@ onMounted(() => {
</div>
</VCard>
<!-- 全部筛选弹窗 -->
<VDialog v-model="allFilterMenuOpen" max-width="50rem" max-height="90%" location="center" scrollable>
<VCard>
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
<VCardTitle class="py-3 d-flex align-center">
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
<span>{{ t('torrent.allFilters') }}</span>
<VSpacer />
<VBtn
v-if="getFilterCount > 0"
class="me-10"
variant="text"
size="small"
color="error"
@click="clearAllFilters"
>
{{ t('torrent.clearAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<div class="all-filters-grid">
<VCard
v-for="(title, key) in filterTitles"
variant="tonal"
:key="key"
class="filter-section"
v-show="filterOptions[key].length > 0"
>
<VCardItem class="py-2">
<template #prepend>
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
</template>
<VCardTitle>{{ title }}</VCardTitle>
<template #append>
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
{{ t('torrent.selectAll') }}
</VBtn>
<VBtn
v-if="filterForm[key].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(key)"
>
{{ t('torrent.clear') }}
</VBtn>
</template>
</VCardItem>
<VCardText>
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
<VChip
v-for="option in filterOptions[key]"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
</VCard>
</div>
</VCardText>
</VCard>
</VDialog>
<!-- 筛选弹窗 -->
<VDialog v-model="filterMenuOpen" max-width="25rem" max-height="80%" location="center">
<VCard>
@@ -522,12 +665,14 @@ onMounted(() => {
color="error"
@click="clearFilter(currentFilter)"
>
清除
{{ t('torrent.clear') }}
</VBtn>
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)">
{{ t('torrent.selectAll') }}
</VBtn>
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)"> 全选 </VBtn>
</VCardTitle>
<VDivider />
<VCardText class="filter-menu-content pt-4">
<VCardText>
<VChipGroup v-model="filterForm[currentFilter]" column multiple class="filter-options">
<VChip
v-for="option in currentFilterOptions"
@@ -544,7 +689,9 @@ onMounted(() => {
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" color="primary" @click="filterMenuOpen = false"> {{ t('torrent.confirm') }} </VBtn>
<VBtn variant="elevated" color="primary" @click="filterMenuOpen = false">
{{ t('torrent.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
@@ -727,6 +874,16 @@ onMounted(() => {
.mobile-sort-select {
max-inline-size: 130px;
min-inline-size: 110px;
min-inline-size: 80px;
}
.all-filters-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.filter-section {
background-color: rgba(var(--v-theme-surface-variant), 0.08);
}
</style>

View File

@@ -8,6 +8,7 @@ import vuetify from 'vite-plugin-vuetify'
import { VitePWA } from 'vite-plugin-pwa'
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
import { resolve } from 'node:path'
import federation from '@originjs/vite-plugin-federation'
// https://vitejs.dev/config/
export default defineConfig({
@@ -31,6 +32,19 @@ export default defineConfig({
VueI18n({
include: [resolve(__dirname, 'src/locales/*.ts')],
}),
federation({
name: 'MoviePilot',
filename: 'remoteEntry.js',
// @ts-ignore
remotes: {
// 动态remotes将在运行时注入
dummy: {
external: '',
format: 'var',
},
},
shared: ['vue', 'vuetify'],
}),
VitePWA({
injectRegister: 'script',
registerType: 'autoUpdate',
@@ -116,8 +130,8 @@ export default defineConfig({
],
},
{
'name': '电影订阅',
'url': './subscribe/movie',
'name': '探索',
'url': './discover',
'icons': [
{
'src': './clock-icon-192x192.png',
@@ -127,19 +141,8 @@ export default defineConfig({
],
},
{
'name': '电视剧订阅',
'url': './subscribe/tv',
'icons': [
{
'src': './clock-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
},
{
'name': '设置',
'url': './setting',
'name': '更多',
'url': './apps',
'icons': [
{
'src': './cog-icon-192x192.png',
@@ -165,11 +168,12 @@ export default defineConfig({
},
},
build: {
target: 'esnext',
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: false,
drop_debugger: true,
},
},
chunkSizeWarningLimit: 5000,

View File

@@ -1428,7 +1428,7 @@
"@jridgewell/gen-mapping" "^0.3.5"
"@jridgewell/trace-mapping" "^0.3.25"
"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0":
"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.13", "@jridgewell/sourcemap-codec@^1.4.14", "@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==
@@ -1516,6 +1516,14 @@
unimport "^5.0.0"
untyped "^2.0.0"
"@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"
"@parcel/watcher-android-arm64@2.5.1":
version "2.5.1"
resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz#507f836d7e2042f798c7d07ad19c3546f9848ac1"
@@ -3989,7 +3997,7 @@ estree-walker@^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.3:
estree-walker@^3.0.2, estree-walker@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d"
integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==
@@ -5260,6 +5268,13 @@ magic-string@^0.25.0, magic-string@^0.25.7:
dependencies:
sourcemap-codec "^1.4.8"
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, magic-string@^0.30.17:
version "0.30.17"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453"
@@ -6703,7 +6718,16 @@ std-env@^3.9.0:
resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.9.0.tgz#1a6f7243b339dca4c9fd55e1c7504c77ef23e8f1"
integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -6788,7 +6812,14 @@ stringify-object@^3.3.0:
is-obj "^1.0.1"
is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==