Compare commits

..

327 Commits
main ... v2.0.5

Author SHA1 Message Date
jxxghp
5f0c3b3639 chore(package): 更新版本号至 2.0.5 2024-11-16 09:47:27 +08:00
jxxghp
414fb8afd1 feat(subscribe): 添加下载器选项到订阅设置 2024-11-16 08:59:49 +08:00
jxxghp
58fbaaa8f4 Merge pull request #237 from wikrin/downloader 2024-11-16 07:54:24 +08:00
Attente
040790a672 fix 资源搜索下载时设置的下载器不生效的问题 2024-11-16 01:41:53 +08:00
Attente
bf36e39f3b feat(site): 添加站点自定义下载器功能 2024-11-16 00:28:56 +08:00
jxxghp
a780946915 Merge pull request #236 from Ricca111111/mpf 2024-11-15 06:42:56 +08:00
jxxghp
1d537c2799 Merge pull request #235 from Aqr-K/v2-settings-rule 2024-11-15 06:41:10 +08:00
Ricca
6a3e383f30 modify FilterRuleGroupCard.vue 2024-11-15 01:33:10 +08:00
Aqr-K
cb72c6b586 错误传参 2024-11-14 23:46:26 +08:00
Aqr-K
384e1a63b3 fix(settings): bug
- 移除空值转换
2024-11-14 23:43:25 +08:00
Aqr-K
e6357d0a54 fix(settings): bug 2024-11-14 23:09:19 +08:00
jxxghp
a0ebb42e1e fix: 调整 AddDownloadDialog 组件标题顺序以更好地显示种子来源 2024-11-14 20:25:10 +08:00
jxxghp
324fec8f94 fix: 更新 RcloneConfigDialog 组件标题为 RClone配置 2024-11-14 19:58:03 +08:00
jxxghp
226efc3d85 feat: 更新 AddDownloadDialog 组件以显示种子信息和文件大小,并优化布局 2024-11-14 18:59:26 +08:00
jxxghp
e785997d99 feat: 更新存储选项以包含图标并简化存储逻辑 2024-11-14 17:21:48 +08:00
jxxghp
7998b51e6b chore: 更新版本号至 2.0.4 2024-11-14 17:14:59 +08:00
jxxghp
e54384fcd7 fix: 更新 StorageCard 组件以正确显示未配置状态 2024-11-14 17:10:36 +08:00
jxxghp
39946cad1b fix: 优化 FileList 组件中的文件和目录图标显示逻辑 2024-11-14 14:38:27 +08:00
jxxghp
6041ae9344 feat: 在 FileBrowser 组件中添加 AList 存储选项 2024-11-14 14:21:27 +08:00
jxxghp
dc9fda8d86 feat: 添加 AList 存储选项及配置对话框 2024-11-14 12:56:12 +08:00
jxxghp
7dd3877955 fix: 更新 DirectoryCard.vue 中的自动整理方式下拉字典 2024-11-14 08:06:49 +08:00
jxxghp
5386fc54ff Merge pull request #233 from amtoaer/v2 2024-11-14 06:50:00 +08:00
amtoaer
c3839f092f fix: 修复站点数据显示错误 2024-11-14 01:50:58 +08:00
jxxghp
4c8207ef9a 更新 DefaultLayout.vue 2024-11-12 23:12:27 +08:00
jxxghp
539a7de1ad Update DefaultLayout.vue to fix conditional rendering of the back button 2024-11-12 20:42:02 +08:00
jxxghp
935b2c4edb 更新 package.json 2024-11-12 18:27:30 +08:00
jxxghp
e1a03166b0 Merge pull request #232 from InfinityPacer/v2 2024-11-12 18:14:35 +08:00
InfinityPacer
c7be304085 feat(db): add support for SQLite WAL mode 2024-11-12 17:23:57 +08:00
jxxghp
2f8c815053 Update SearchBarView.vue to remove unused code for displaying useful menus and plugins 2024-11-12 15:03:09 +08:00
jxxghp
249e1c6ebd Update AccountSettingSite.vue to add option for reading site messages during data refresh 2024-11-12 13:58:22 +08:00
jxxghp
22c97d1c01 更新 ReorganizeDialog.vue 2024-11-12 12:15:28 +08:00
jxxghp
ff3d45ec91 Update SiteUserDataDialog.vue to add refresh functionality 2024-11-12 09:52:24 +08:00
jxxghp
4caf671e1c Update hint for resource size range in CustomRuleCard.vue 2024-11-09 18:00:58 +08:00
jxxghp
741876dcaa 更新 package.json 2024-11-08 12:49:43 +08:00
jxxghp
5c6f32a7db Update TorrentItem.vue to display site name in subtitle 2024-11-07 20:10:46 +08:00
jxxghp
80b24cbfbc Update StorageCard.vue to improve download handling 2024-11-07 20:09:46 +08:00
jxxghp
8afed9768d Update StorageCard.vue to display a more informative toast message 2024-11-07 19:18:43 +08:00
jxxghp
1f4dacff02 Merge pull request #230 from thsrite/v2 2024-11-06 10:53:46 +08:00
thsrite
a046c0ec45 fix 导入自定义规则 && 优先级规则组时保留原有 2024-11-06 09:27:17 +08:00
jxxghp
82d0fd2b11 Bump version to 2.0.1 2024-11-05 21:21:53 +08:00
jxxghp
e2fb55d910 Merge pull request #227 from InfinityPacer/v2 2024-11-05 15:50:43 +08:00
InfinityPacer
7754c41d34 fix API_TOKEN length 2024-11-05 15:35:10 +08:00
InfinityPacer
eea30c3a0d fix FANART_ENABLE 2024-11-05 15:27:18 +08:00
jxxghp
bfe41a0642 refactor(setting): 补充设置项 2024-11-05 14:54:18 +08:00
jxxghp
4ba0151c42 refactor(setting): 重构设置界面布局 2024-11-05 13:13:33 +08:00
jxxghp
98bdfb160e Merge pull request #226 from Aqr-K/v2-settings
feat(user): New avatar file add webp format support
2024-11-04 12:44:40 +08:00
jxxghp
6327649501 refactor(setting): 移除防抖时间 2024-11-04 12:43:52 +08:00
Aqr-K
6937f5e1b1 feat(user): New avatar file add webp format support
- 新头像增加 `webp` 格式支持
2024-11-04 12:39:38 +08:00
jxxghp
e3ce4196fe fix 内置过滤规则 2024-11-04 12:15:19 +08:00
jxxghp
bb67a051c2 feat(plugin): 添加插件市场设置窗口
该提交添加了一个新的组件PluginMarketSettingDialog.vue,用于插件市场的设置窗口。该窗口可以通过点击插件市场设置图标打开,并提供了保存设置的功能。

该提交还在PluginCardListView.vue中引入了PluginMarketSettingDialog组件,并在点击插件市场设置图标时打开该窗口。

该提交的目的是为了提供一个方便的界面,让用户可以设置插件市场的仓库地址。
2024-11-04 11:27:43 +08:00
jxxghp
812dd1f184 feat(dialog): Update SubscribeEditDialog.vue
- Add conditional rendering for certain form fields based on the 'default' prop value
- Improve user experience by showing relevant form fields only when necessary
2024-11-04 10:54:34 +08:00
jxxghp
37d6612434 Merge pull request #225 from Aqr-K/v2-settings
feat(settings): 配置中心基本功能内置化,修复部分bug
2024-11-04 10:20:16 +08:00
Aqr-K
9cbafdfab8 feat(user): Add file type check and size determination
- 增加 文件类型检查
- 增加 文件大小限制,800KB
2024-11-03 18:28:07 +08:00
Aqr-K
1c4d806e58 feat(settings): add AccountSettingTransfer.vue
- 增加 整理标签页,增加相关设置功能。
2024-11-02 10:54:27 +08:00
Aqr-K
aba2ee29dd feat(settings): AccountSettingSearch
- 增加 整合多名称资源搜索结果、下载站点字幕、交互式搜索自动下载用户
2024-11-02 10:53:02 +08:00
Aqr-K
51deb29145 style(aettings): AdvancedSystemSettingsDialog
- 调整开关宽度
2024-11-02 04:51:44 +08:00
jxxghp
1f7a677db3 Refactor login page styles for better alignment and responsiveness 2024-11-01 12:30:50 +08:00
Aqr-K
0fb0652919 统一settings的区域规范 2024-10-31 22:58:57 +08:00
Aqr-K
39c7e723ba feat(settings): AccountSettingRule
- 增加对于 内置规则 的判断,避免与内置规则使用同一名称与ID。
2024-10-31 22:42:07 +08:00
Aqr-K
a9ddf159cc 同步 2024-10-31 22:08:34 +08:00
Aqr-K
22b93e1ae3 remove(setting)
- 移除测试版的通过规则命中,来设置保存按钮禁用方法。
2024-10-31 20:08:50 +08:00
Aqr-K
93b83048cf feat(settings): AccountSettingSearch
- 增加防抖。
2024-10-31 20:06:50 +08:00
Aqr-K
1c18f3a4f2 style(settings)
- 修改目录的保存与新增按钮的间距,保证与其他标签页的宽度相同。
- 统一全部 serrings 的保存按钮的规范。
2024-10-31 18:55:08 +08:00
Aqr-K
b5a01a7a42 feat(settings): AccountSettingDirectory
- 增加防抖。
2024-10-31 18:22:59 +08:00
Aqr-K
caf211c34e style(settings)
- 同步卡片的logo显示距离
2024-10-31 18:20:20 +08:00
Aqr-K
8b79c70be7 fix(settings): AccountSettingSystem
- 删除开发残留的敏感 consle.log
2024-10-31 18:09:39 +08:00
Aqr-K
6a4a218152 fix(settings): AccountSettingSite bug
- 拆分 CC 与 站点刷新,解决CC保存时,站点刷新也会被提交保存的问题。
2024-10-31 18:05:30 +08:00
Aqr-K
6bc420d57f feat(settings): AccountSettingService
- 增加防抖。
- `card` 从父组件获取到的值改为深复制,解决 `card` 内修改数据,会直接导致在父组件中同步更新的问题。
2024-10-31 17:53:45 +08:00
Aqr-K
db0325a59c feat(settings): AccountSettingRule
- 增加防抖。
- `card` 从父组件获取到的值改为深复制,解决 `card` 内修改数据,会直接导致在父组件中同步更新的问题。
- 修复 规则id与name 只缺少一项时,仍能正常确定的问题。
- 保存前增加一次检查,避免通过分享导入的规则存在重名与空名引发的错误。
2024-10-31 17:08:23 +08:00
Aqr-K
eab2f0df20 feat(settings): AccountSettingNotification
- 增加防抖。
- `card` 从父组件获取到的值改为深复制,解决 `card` 内修改数据,会直接导致在父组件中同步更新的问题。
- 微调图标位置。
2024-10-31 16:46:05 +08:00
jxxghp
2d1fbff2c5 更新 AccountSettingRule.vue 2024-10-31 13:50:18 +08:00
jxxghp
75c3ac71ae Merge pull request #223 from thsrite/v2 2024-10-31 12:13:16 +08:00
thsrite
61ffd222cc fix jxxghp/MoviePilot#2979 2024-10-31 09:21:42 +08:00
jxxghp
3499327984 fix #222 2024-10-31 08:22:14 +08:00
Aqr-K
c90ed003f7 feat(settings): add systemSettingsDialog
- 增加 AdvancedNetworkSettingsDialog 与 AdvancedSystemSettingsDialog,适配 system 预设的高级设置弹窗。
2024-10-31 04:15:12 +08:00
Aqr-K
bd9169bcd1 feat(settings): add new AccountSettingsSystem.vue
- 将 原system更名为service,原service更名为scheduler
- 增加新的 AccountSettingSystem.vue。
- 调整 menu.ts 与settings.vue,适配新的 system 标签页
2024-10-31 03:38:01 +08:00
jxxghp
0c46ab7d5a Refactor labels and hints in AccountSettingSubscribe.vue 2024-10-30 18:54:25 +08:00
jxxghp
3bc464011a Merge pull request #221 from thsrite/v2
fix 增加开启检查本地媒体库是否存在资源开关,按需开启
2024-10-30 18:51:59 +08:00
jxxghp
74fe67fe4d Refactor background image rotation in login.vue 2024-10-30 18:49:24 +08:00
thsrite
48fcce54dc fix 增加开启检查本地媒体库是否存在资源开关,按需开启 2024-10-30 16:20:34 +08:00
Aqr-K
b91be6bb2f Merge branch 'jxxghp:v2' into v2 2024-10-30 15:47:24 +08:00
jxxghp
181ad39e18 Refactor PluginCard.vue and login.vue components 2024-10-30 13:20:22 +08:00
jxxghp
4af6e5e91f Merge pull request #220 from thsrite/v2 2024-10-30 12:03:26 +08:00
thsrite
d67c6acfa2 fix 已安装插件显示作者头像 2024-10-30 09:52:05 +08:00
jxxghp
f4633a5832 Refactor button layout in AccountSettingRule.vue 2024-10-30 07:07:04 +08:00
jxxghp
36841f6f8f Merge pull request #219 from thsrite/v2 2024-10-29 13:26:07 +08:00
thsrite
86b4df871a fix 导入自定义规则时资源体积未导入 2024-10-29 12:47:25 +08:00
jxxghp
03ad8cc9e8 Merge pull request #218 from thsrite/v2 2024-10-29 08:01:44 +08:00
thsrite
de39ffa260 Merge remote-tracking branch 'origin/v2' into v2 2024-10-28 13:59:55 +08:00
thsrite
becccb8368 format 2024-10-28 13:59:08 +08:00
thsrite
1b75bb2cec format 2024-10-28 13:52:19 +08:00
thsrite
2a9f9b725e fix log 2024-10-28 12:47:15 +08:00
thsrite
5f15e84065 feat 自定义规则 && 优先级规则组 整体导入导出 2024-10-28 12:36:03 +08:00
jxxghp
deeb5f9d62 Merge pull request #217 from wikrin/v2 2024-10-26 08:10:40 +08:00
jxxghp
b715198a02 Merge pull request #216 from InfinityPacer/v2 2024-10-26 08:10:25 +08:00
Attente
005b1a9715 fix: 同步后端修正msg => message 2024-10-26 07:16:57 +08:00
InfinityPacer
d120bb794c fix(test): ensure rule group is specified before testing priority 2024-10-26 00:06:37 +08:00
jxxghp
e625d56c65 Merge pull request #215 from boeto/v2 2024-10-25 17:49:10 +08:00
machine
2d1d19e457 fix: 无法导出规则分享 2024-10-25 17:42:20 +08:00
boeto
c5d0a7fd74 Merge pull request #1 from jxxghp/v2
pr
2024-10-25 17:36:10 +08:00
Aqr-K
c72fcbd10d feat: 网络增加高级设置弹窗 2024-10-25 11:31:10 +08:00
jxxghp
5f388c8b09 Merge pull request #214 from InfinityPacer/v2 2024-10-25 06:59:20 +08:00
InfinityPacer
b02c3c8e5c fix(dashboard): filter and load only enabled media servers 2024-10-25 00:32:33 +08:00
Aqr-K
1d9e0eb3a3 Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2-config 2024-10-24 17:17:44 +08:00
Aqr-K
0a5b553bb8 feat: 增加 network 标签页,调整标签对齐方式 2024-10-24 17:16:47 +08:00
jxxghp
865d57b4d3 refactor: 调整网格布局样式 2024-10-24 11:13:55 +08:00
jxxghp
8e40c38730 Merge pull request #213 from InfinityPacer/v2 2024-10-24 06:53:29 +08:00
InfinityPacer
ddee496c73 fix(wallpapers): remove cache 2024-10-24 00:02:29 +08:00
jxxghp
9c4d12d18b 更新 main.ts 2024-10-23 20:06:41 +08:00
jxxghp
6efa0e307e refactor: 调整导入顺序和删除无用代码 2024-10-23 19:44:09 +08:00
jxxghp
f0ac2d739d refactor: 更新构建工作流,仅在v2分支上的package.json更改时触发构建 2024-10-23 16:31:31 +08:00
jxxghp
1cb78b4ccd refactor: 调整SubscribeShareCard.vue中的卡片布局,将修复了文本溢出的问题。 2024-10-23 16:30:02 +08:00
jxxghp
fc6f41a549 refactor: 调整卡片布局的列宽为22rem 2024-10-23 16:21:35 +08:00
jxxghp
db86d075f0 Merge pull request #212 from thsrite/v2 2024-10-23 16:09:05 +08:00
jxxghp
3db4e12bb2 refactor: 添加了卡版折叠状态和展开按钮。 2024-10-23 16:07:48 +08:00
thsrite
f4a7372b4f feat 通知支持拖拽调整顺序 2024-10-23 16:03:57 +08:00
jxxghp
877d2f77bd refactor: 更新规则组选择功能
调整了快捷栏中的规则组选择功能,将原来的“优先级”改为“规则”,并更新了相关文本。

修改文件:
- src/layouts/components/ShortcutBar.vue
2024-10-23 15:38:57 +08:00
jxxghp
02334489ed feat: 添加规则组选择功能
为规则测试页面添加了规则组选择功能,用户可以从下拉列表中选择规则组进行测试。

- 添加了规则组选择表单项
- 加载规则组列表的函数
- 调用API识别时传递规则组名称

Fixes #209
2024-10-23 15:35:42 +08:00
jxxghp
cd714d954f Merge pull request #211 from thsrite/v2 2024-10-23 14:56:49 +08:00
thsrite
9e4655070c feat 自定义规则 && 优先级规则组支持拖拽调整顺序 2024-10-23 14:33:49 +08:00
jxxghp
f84d69feb7 Merge pull request #210 from thsrite/v2 2024-10-23 13:11:49 +08:00
thsrite
3c91ad2f59 feat 目录自定义是否通知 2024-10-23 12:57:48 +08:00
jxxghp
873848b9a7 Merge pull request #209 from thsrite/v2 2024-10-23 11:44:45 +08:00
thsrite
f906a172dd feat 目录监控可选监控模式 2024-10-23 11:34:09 +08:00
jxxghp
6b9c74dcea Merge pull request #205 from Aqr-K/dev-login 2024-10-22 12:31:17 +08:00
jxxghp
539cf9ada4 fix build 2024-10-22 11:54:36 +08:00
jxxghp
a69d2dfd71 build beta 2024-10-22 11:37:32 +08:00
jxxghp
3f9b9a6903 fix 普通用户权限视图 2024-10-22 10:43:50 +08:00
Aqr-K
9949a16f34 fix: bug
- 修复超管判断条件。
2024-10-22 01:24:57 +08:00
jxxghp
7e30cf40a9 Merge pull request #208 from boeto/dev 2024-10-22 00:04:35 +08:00
machine
fba0df8cb9 fix: 订阅历史记录不请求后端 2024-10-21 22:46:04 +08:00
machine
7a97005524 fix: 订阅历史记录不请求后端 2024-10-21 22:44:15 +08:00
jxxghp
ee8b57da91 Merge pull request #207 from thsrite/dev 2024-10-21 13:37:14 +08:00
thsrite
e63f19a00d fix 先选择媒体库存储,再选择整理方式 2024-10-21 13:34:49 +08:00
jxxghp
deabf23475 fix ui layout 2024-10-19 20:19:30 +08:00
Aqr-K
0b3fc938ae Update UserCard.vue 2024-10-19 13:44:19 +08:00
jxxghp
bdd0cdbe55 Refactor DefaultLayout component to conditionally show back button based on app mode and screen size 2024-10-19 12:28:00 +08:00
jxxghp
ae261cb684 Refactor Footer component to use responsive display and inject app mode 2024-10-19 12:24:30 +08:00
Aqr-K
0036a895e9 feat(login): add userID
- 修改按钮判断的逻辑,将 `userName` 替换成 `userID` 判断;解决不管是主程序还是插件修改用户名,都会存在的条件判断导致的渲染异常显示。(拆分自user的pr)
2024-10-19 11:58:01 +08:00
jxxghp
f317d15580 Refactor DirectoryCard and PathField components 2024-10-19 11:53:10 +08:00
jxxghp
76a487854b Refactor navigator utility functions and add isPWA check 2024-10-19 10:39:31 +08:00
jxxghp
b3f616ddc6 Refactor SubscribeFilesDialog component to improve layout and text size handling 2024-10-19 08:10:05 +08:00
jxxghp
9b19cbefc8 fix user ui 2024-10-19 07:58:46 +08:00
jxxghp
a4ba6b947b Refactor font size in VerticalNav component 2024-10-18 17:47:34 +08:00
jxxghp
fb510ff180 Refactor permission utility function 2024-10-18 14:24:16 +08:00
jxxghp
2710cbc85a Refactor SubscribeShareCard component to improve layout and text overflow handling 2024-10-18 14:02:05 +08:00
jxxghp
b82f17bcf1 Merge pull request #203 from Aqr-K/dev-user
fix(user)
2024-10-18 14:01:47 +08:00
Aqr-K
f9c33394a9 Merge branch 'dev-user' of https://github.com/Aqr-K/MoviePilot-Frontend into dev-user 2024-10-18 12:58:50 +08:00
Aqr-K
0a15a6eb64 fix(user)
- `更新` 与 `新建` 用户时,增加防抖;
- 去除 `UserProfileView` ( `个人信息` )中的冗余代码。
- `个人信息` 增加提交时的 `保存` 按钮的文本变化与点击禁用。
2024-10-18 12:58:45 +08:00
Aqr-K
45777c01ee fix(user)
- `更新` 与 `新建` 用户时,增加500ms防抖;
- 去除 `UserProfileView` 中的冗余代码。
- `个人信息` 增加提交时的 `按钮文本`变化与点击禁用。
2024-10-18 12:54:07 +08:00
jxxghp
b331cc55ce Merge pull request #202 from InfinityPacer/dev 2024-10-18 11:56:13 +08:00
jxxghp
74ef5a8083 Merge pull request #201 from Aqr-K/dev-user 2024-10-18 11:55:30 +08:00
Aqr-K
8dd82aacf2 Update UserProfileView.vue 2024-10-18 11:06:15 +08:00
Aqr-K
35d130a01b Merge branch 'dev' into dev-user 2024-10-18 11:03:01 +08:00
InfinityPacer
15319bf586 style(VSelect): add clearable option 2024-10-18 10:55:38 +08:00
jxxghp
832cae635e Merge pull request #200 from InfinityPacer/dev 2024-10-18 06:56:06 +08:00
Aqr-K
1c83752f56 feat(user): add username modification function. 2024-10-18 02:50:52 +08:00
jxxghp
7973457417 refactor: 优化站点卡片组件 2024-10-17 21:39:26 +08:00
InfinityPacer
8083e94ecd fix(message): add delay to prevent 403 2024-10-17 16:59:51 +08:00
jxxghp
b3485af14c fix: 修复站点数据展示 2024-10-17 16:10:33 +08:00
jxxghp
01eaef2bf9 feat:站点数据展示 2024-10-17 12:15:49 +08:00
jxxghp
3e241cf8bc Merge pull request #199 from Aqr-K/dev-user 2024-10-17 06:55:52 +08:00
Aqr-K
2df4dc0535 fix(user): bug 2024-10-17 01:47:18 +08:00
Aqr-K
135a1e3d52 style(user): No line breaks.
- 禁止 `电影订阅` 与 `电视剧订阅` 名称在不同的设备比例下,会出现换行的情况。
2024-10-16 23:20:04 +08:00
Aqr-K
4366fdd4a6 fix(user): bug
- 通过 `用户管理` 修改头像后,切换到 `个人信息` 时头像不同步。
2024-10-16 23:13:59 +08:00
Aqr-K
a47d3f10f9 feat(user): UserSettings function adjustment.
- 增加更新头像时,立刻同步更新localStorage;解决当前头像替换后,必须重新登录才能刷新localStorage的问题。
- 删除个人信息页面上传图片时,立刻触发更新的功能,统一保存后再更新。
- 增加 `还原当前头像` 按钮,允许 `上传新头像`、`重置默认头像` 后,进行回退;解决重置后后悔了,但其他参数已经填写时,必须刷新页面才能还原当前头像的问题。
2024-10-16 22:48:23 +08:00
jxxghp
004c9eadd5 feat:优化站点卡片 2024-10-16 18:35:27 +08:00
jxxghp
b483a5f4e8 refactor(cards): update MediaServerCard.vue 2024-10-16 15:45:25 +08:00
jxxghp
06e0f4234f refactor(setting): update TorrentPriorityItems in AccountSettingRule.vue 2024-10-16 15:23:54 +08:00
jxxghp
e9a5c0ae69 refactor(setting): remove unnecessary function call in AccountSettingDirectory.vue 2024-10-15 20:18:38 +08:00
jxxghp
0c17702f65 Merge pull request #198 from InfinityPacer/dev 2024-10-14 06:45:14 +08:00
InfinityPacer
ddf682d66a feat(security): update douban image proxy URL 2024-10-14 01:35:27 +08:00
jxxghp
018c5f857b feat(downloading): add NoDataFound component for empty downloaders
Add the NoDataFound component to the downloading page to display a 404 error message when there are no enabled downloaders. This component will show an error code, title, and description to guide users on how to configure and enable downloaders in the settings.

Closes #197
2024-10-12 12:30:56 +08:00
jxxghp
b5d89ff082 Merge pull request #197 from InfinityPacer/dev 2024-10-10 16:53:47 +08:00
InfinityPacer
54046a4717 feat(vite): add server proxy to handle CORS for API requests 2024-10-10 15:41:02 +08:00
InfinityPacer
505773043b feat(security): remove unnecessary token 2024-10-10 15:40:06 +08:00
jxxghp
e9bb811244 Update version number in VerticalNav component 2024-10-10 14:42:43 +08:00
jxxghp
6ef6ea1479 fix ui 2024-10-10 13:09:20 +08:00
jxxghp
93bd4002db fix ui 2024-10-10 12:59:53 +08:00
jxxghp
b9ec829747 fix ui 2024-10-09 20:44:30 +08:00
jxxghp
f307327af3 add subscribe share cards 2024-10-09 19:47:31 +08:00
jxxghp
936be9928d fix ui 2024-10-09 17:07:18 +08:00
jxxghp
b639369846 支持更多订阅自定义属性 2024-10-09 15:20:25 +08:00
jxxghp
5577e4cf62 Merge pull request #196 from InfinityPacer/dev 2024-10-09 06:44:42 +08:00
InfinityPacer
63206fea2e fix(download): support downloader and save_path parameters 2024-10-09 02:32:02 +08:00
jxxghp
40727dac2d Merge pull request #194 from InfinityPacer/dev 2024-10-06 17:52:47 +08:00
InfinityPacer
d703909177 refactor(search): optimize sorting logic for season filter options 2024-10-06 17:37:01 +08:00
jxxghp
fc61060b7f Merge pull request #193 from InfinityPacer/dev 2024-10-02 20:19:05 +08:00
jxxghp
73e21e77ec Merge pull request #192 from Aqr-K/dev-downloader 2024-10-02 20:17:57 +08:00
InfinityPacer
6be05819b0 fix(dashboard): handle MediaServerLatest.vue rendering failures 2024-10-02 11:39:02 +08:00
Aqr-K
0e116ad1b9 feat(downloader): Default downloader automatic selection and checking
- 增加保存时的默认下载器不存在的自动选择与去重
2024-10-02 02:36:55 +08:00
jxxghp
016c232ef2 Merge pull request #191 from InfinityPacer/dev 2024-10-01 20:36:37 +08:00
InfinityPacer
9856419292 feat(downloader): support first_last_piece 2024-10-01 18:35:57 +08:00
jxxghp
cf3a204eac refactor: Remove unused import in SiteTorrentTable.vue
Remove the unused import of MediaInfo in SiteTorrentTable.vue to improve code cleanliness and reduce potential confusion.
2024-09-30 16:00:32 +08:00
jxxghp
dc3e364b90 fix ui 2024-09-30 11:11:01 +08:00
jxxghp
d22ef17b95 Merge pull request #190 from Aqr-K/dev-cards 2024-09-27 00:17:04 +08:00
Aqr-K
4126692c5a style: Unified card style.
- 统一禁止全部弹窗式设置的点击功能区以外区域的close功能。
2024-09-27 00:03:05 +08:00
Aqr-K
d22f1c97ae feat: Add duplicate name judgment for notification
- 增加通知渠道的重名判断
2024-09-27 00:00:11 +08:00
jxxghp
735023330a Merge pull request #189 from Aqr-K/dev-directory 2024-09-26 23:33:10 +08:00
Aqr-K
6301cb287e 更新 DirectoryCard.vue 2024-09-26 20:58:42 +08:00
Aqr-K
85ebb0242a Merge branch 'dev' of https://github.com/jxxghp/MoviePilot-Frontend into dev 2024-09-26 20:34:02 +08:00
Aqr-K
81a670d608 feat: Automatically generate optional transferType
- 自动结合两个储存方式,生成出可选的整理方式,降低使用门槛
2024-09-26 20:27:42 +08:00
Aqr-K
a547e5c34b feat: Add new card with duplicate name judgment
- 给添加新卡片时,自动生成的名称增加一层重名判断,避免出现重名。
- 目录卡片特化处理,在保存时,增加一层重名检查。
2024-09-26 20:24:28 +08:00
jxxghp
cf6b6dd4dd Refactor AccountSettingSearch.vue to update the label for filter rule group to "优先级规则组"
Fix AccountSettingSite.vue to set COOKIECLOUD_ENABLE_LOCAL to false by default
Refactor AccountSettingSubscribe.vue to add support for selecting best version rule group for subscription filtering
2024-09-26 12:49:39 +08:00
jxxghp
574464c1ea Refactor AddDownloadDialog.vue component and update download confirmation dialog UI 2024-09-24 12:07:48 +08:00
jxxghp
816dfa4e3b Refactor SiteTorrentTable.vue and AddDownloadDialog.vue components 2024-09-23 21:04:37 +08:00
jxxghp
9d7e52c25e Refactor AccountSettingNotification.vue, AccountSettingRule.vue, and AccountSettingSystem.vue
Remove unused event listeners and save functions in various components.
2024-09-23 08:07:20 +08:00
jxxghp
d41b6ca459 fix RuleGroupCard 2024-09-21 21:28:57 +08:00
jxxghp
4d1b5209e7 fix FileList.vue 2024-09-21 19:59:48 +08:00
jxxghp
7da21f23aa fix profile 2024-09-21 19:26:58 +08:00
jxxghp
40a9caceb8 add sitedata refresh setting 2024-09-21 19:23:01 +08:00
jxxghp
7e4f21ff33 add from_history 2024-09-21 19:11:24 +08:00
jxxghp
cd6f5090d7 fix bug 2024-09-21 17:53:01 +08:00
jxxghp
1efd0a3d5b fix TransferHistoryView 2024-09-21 17:33:32 +08:00
jxxghp
4434d7b8c9 fix ReoranizeDialog 2024-09-21 17:04:54 +08:00
jxxghp
24e184eace refactor: 优化ReorganizeDialog组件 2024-09-21 08:49:02 +08:00
jxxghp
8ccd9cfd85 Merge pull request #188 from Aqr-K/dev-downloader 2024-09-20 19:30:43 +08:00
Aqr-K
cb2c23dc96 refactor: Adjust the logical sequence
- 调整逻辑顺序,增加提示框显示。
- 禁用点击功能区以外区域自动退回上级功能。该退出方式下,会无法激活默认下载器的判断。
2024-09-20 19:22:06 +08:00
Aqr-K
29912cac8d style: The height of the cards is unified.
- 将tr与qb的卡片高度统一。
2024-09-20 19:00:29 +08:00
Aqr-K
6376a81c4a feat: Add the judgment of the switch startup for the default downloader
- 增加默认下载器开关启动判断,保证唯一性。
2024-09-20 18:38:02 +08:00
jxxghp
aff4b2f9b7 refactor: 优化ReorganizeDialog组件
为ReorganizeDialog组件进行优化,移除了props中的storage属性,并将其替换为target_storage和target_path属性。同时更新了相关的表单和逻辑处理。
2024-09-20 13:39:33 +08:00
jxxghp
153fe8fcd0 feat: 添加完成事件触发
为CustomRuleCard、FilterRuleGroupCard、NotificationChannelCard和AccountSettingDirectory组件添加done事件触发,以便在完成相关操作后通知其他组件。
2024-09-19 13:21:12 +08:00
jxxghp
95d8b3d1a6 fix #186 2024-09-18 18:14:33 +08:00
jxxghp
19ce869763 fix ui 2024-09-18 18:07:12 +08:00
jxxghp
e6b6d3ca27 fix bug 2024-09-18 08:29:06 +08:00
jxxghp
8e7be239ee Merge pull request #187 from InfinityPacer/dev 2024-09-16 21:53:27 +08:00
InfinityPacer
4bd97f9d81 fix: handle scenarios where avatar is empty 2024-09-16 17:09:51 +08:00
jxxghp
49d182eabc Merge pull request #185 from Aqr-K/dev 2024-09-15 06:37:26 +08:00
Aqr-K
9411a29adf feat: Control status permission judgment.
增加状态控制,如果编辑的是当前使用的用户,会隐藏状态控制栏
个人信息栏,增加分割线,做为功能区的显示区分
2024-09-14 22:46:17 +08:00
jxxghp
61bb96e1fe Merge pull request #184 from Aqr-K/dev 2024-09-14 20:56:13 +08:00
Aqr-K
6a6100a814 style: User style adjustment
隐藏已有用户二次编辑中的用户名;补全新增用户界面的默认头像显示;增加分割线。
2024-09-14 20:38:33 +08:00
jxxghp
40fcf9d0cc Merge pull request #183 from Aqr-K/dev 2024-09-14 17:59:59 +08:00
Aqr-K
65946c55d1 fix bug 2024-09-14 17:46:22 +08:00
Aqr-K
e2b4df3dcf feat: Add duplicate name judgment and null value judgment
调整部分样式,并给下载器、媒体服务器、自定义规则、优先级规则组,名称与ID增加重名警告和空值警告,
2024-09-14 17:45:07 +08:00
jxxghp
04fee167b9 auto build 2024-09-14 14:57:06 +08:00
jxxghp
243c273084 fix file preview 2024-09-14 14:27:49 +08:00
jxxghp
b43cf4dd5d fix bug 2024-09-14 13:07:42 +08:00
jxxghp
cf9c38fdd5 fix https://github.com/jxxghp/MoviePilot/pull/2712
fix https://github.com/jxxghp/MoviePilot/pull/2711
2024-09-14 11:17:00 +08:00
jxxghp
6e4e6df08f Merge pull request #181 from thsrite/dev 2024-09-14 11:05:42 +08:00
thsrite
7b5630223d fix 正在下载显示种子大小 2024-09-14 11:03:30 +08:00
jxxghp
3d985decbc Merge pull request #180 from Aqr-K/dev 2024-09-14 06:32:56 +08:00
Aqr-K
dbe23eaac7 style: Optimize the progress bar and the display of remaining storage space.
优化进度条和剩余存储空间的显示。
2024-09-14 01:25:55 +08:00
jxxghp
e38df0f319 refactor: Update FilterRuleGroupCard.vue to clear selected media category when media type changes 2024-09-12 15:53:33 +08:00
jxxghp
c2ac66fdbf refactor: Update ModuleTestView.vue to handle empty result message 2024-09-12 15:14:09 +08:00
jxxghp
5ad25ff14d refactor: Update FilterRuleGroupCard.vue to add support for selecting media categories 2024-09-12 12:52:34 +08:00
jxxghp
04e1b527b5 refactor: Update MediaServerLibrary.vue to load media server library with hidden parameter 2024-09-12 08:24:48 +08:00
jxxghp
09210f98e9 refactor: Update MediaServerCard.vue to load and display media libraries dynamically 2024-09-12 08:17:00 +08:00
jxxghp
bfe228a367 refactor: Update saveDashboardConfig function to use stringified JSON for enableConfig and orderObj 2024-09-11 12:41:57 +08:00
jxxghp
a01978196d refactor: Update action-gh-release to v2 in build workflow 2024-09-11 08:22:59 +08:00
jxxghp
f795481895 refactor: Update FileBrowser.vue and FileBrowserView.vue to support multiple storage configurations 2024-09-11 08:15:48 +08:00
jxxghp
83e199c1ea refactor: fix media server libraries 2024-09-11 08:05:15 +08:00
jxxghp
8734e7fc1b Merge pull request #178 from InfinityPacer/dev 2024-09-11 06:42:10 +08:00
InfinityPacer
b48e4adacd fix(PluginCard): improve reset plugin configuration and data prompt 2024-09-11 00:37:20 +08:00
jxxghp
a45e2b120e fix dashboard cards 2024-09-10 21:29:56 +08:00
jxxghp
52b6f103a5 fix bug 2024-09-10 11:21:57 +08:00
jxxghp
927f4a366c refactor: Update tag_name in build workflow to include 'dev_' prefix 2024-09-09 16:30:14 +08:00
jxxghp
b28347d191 refactor: Add reloadSystem function to saveDirectories, saveNotificationSetting, saveDownloaderSetting, and saveMediaServerSetting 2024-09-09 09:52:49 +08:00
jxxghp
df057ebe4d refactor: Update MessageCard.vue to conditionally render VCardTitle component based on message properties 2024-09-09 09:12:50 +08:00
jxxghp
aa7b4a0e94 refactor: Update MessageCard.vue to conditionally render VCardTitle component based on message properties 2024-09-09 09:10:48 +08:00
jxxghp
ca9d44f55f refactor: Enable lazy loading for downloading tabs 2024-09-09 08:34:32 +08:00
jxxghp
247631fd68 refactor: Add lazy loading for downloading tabs 2024-09-09 08:33:29 +08:00
jxxghp
3357928e80 feat: Update user storage options in FileBrowserView 2024-09-09 08:16:22 +08:00
jxxghp
fc263d79a8 fix usercard 2024-09-08 15:14:53 +08:00
jxxghp
ee10616acf feat: Add allowRefresh prop to DownloaderCard component 2024-09-08 13:57:58 +08:00
jxxghp
30c3ad6c90 refactor: Update image URLs to use globalSettings.TMDB_IMAGE_DOMAIN 2024-09-08 13:05:14 +08:00
jxxghp
5ad6d6d904 Merge pull request #177 from InfinityPacer/dev 2024-09-08 08:23:22 +08:00
InfinityPacer
e2c7fc0af0 chore: remove unnecessary preload for index.js 2024-09-08 02:08:16 +08:00
jxxghp
172fb06d8e Merge pull request #174 from InfinityPacer/dev 2024-09-05 06:55:22 +08:00
InfinityPacer
634522d27b fix(build): ensure app is mounted after global settings are loaded 2024-09-02 20:18:37 +08:00
InfinityPacer
03b14a0fb5 fix(build): wrap top-level await in async function for browser compatibility 2024-09-02 20:04:40 +08:00
jxxghp
ec54ec2607 login wallpapers cache 2024-08-29 16:15:42 +08:00
jxxghp
340bb08f2a feat:media image cache 2024-08-29 15:27:49 +08:00
jxxghp
022487a877 style: Optimize image URL handling in MediaDetailView 2024-08-29 08:40:56 +08:00
jxxghp
6ec1bbe1ae style: Update globalSettings injection in multiple components 2024-08-29 08:36:29 +08:00
jxxghp
9d55f8ab24 sync main 2024-08-19 12:26:10 +08:00
jxxghp
fc61f3fca1 style: Update UserCard.vue to include user subscription counts and user management actions 2024-08-18 11:44:34 +08:00
jxxghp
cca3368d8f style: Update AccountSettingSystem.vue to include change events for downloader and media server cards 2024-08-16 13:42:12 +08:00
jxxghp
57f6547b91 style: Update DownloaderCard and MediaServerCard to improve UI consistency 2024-08-16 12:35:40 +08:00
jxxghp
200b22cf0c style: Update storage card to include progress bar color based on usage 2024-08-16 11:59:38 +08:00
jxxghp
e9b8f3138c style: Add support for INI files in ACE editor 2024-08-16 11:35:53 +08:00
jxxghp
dd9663451e style: Update storage card to include Rclone configuration dialog and improve UI consistency 2024-08-16 11:31:04 +08:00
jxxghp
78e0e7dba1 style: Update PluginCard to remove redundant code and improve UI consistency 2024-08-16 10:09:26 +08:00
jxxghp
b94fb70e02 style: Update storage card to include authentication dialogs for Aliyun and U115 storage types 2024-08-15 16:15:49 +08:00
jxxghp
e94c149cd1 style: Update storage card to query storage information on mount 2024-08-15 15:28:01 +08:00
jxxghp
94ba3c4514 style: Update grid-customrule-card to use larger minimum width for columns 2024-08-15 11:45:45 +08:00
jxxghp
c129a37ccf style: Update PluginAppCard and PluginCard to improve UI consistency 2024-08-12 18:09:53 +08:00
jxxghp
6608a4266b style: Update DownloaderCard and MediaServerCard to improve UI consistency 2024-08-12 11:02:29 +08:00
jxxghp
809bfbb42a style: Update formatFileSize function to accept decimals parameter 2024-08-12 08:18:15 +08:00
jxxghp
676ff8789b style: Update FilterRuleCard and FilterRuleGroupCard to include custom_rules prop 2024-08-12 08:02:35 +08:00
jxxghp
3b1a9bd0c4 style: Update FilterRuleGroupCard.vue to use updated filter group SVG icon 2024-08-11 17:43:19 +08:00
jxxghp
202b9dc3bc style: Update CustomRuleCard.vue and FilterRuleGroupCard.vue to include filter icons 2024-08-11 17:39:20 +08:00
jxxghp
ce96deb224 style: Update CustomRuleCard.vue to include rule ID field 2024-08-11 17:23:16 +08:00
jxxghp
14afe59eeb style: Update CustomRuleCard.vue to include rule name field 2024-08-11 17:07:51 +08:00
jxxghp
790a8bdb9a style: Update AccountSettingNotification.vue, AccountSettingSystem.vue, MediaServerCard.vue, and DownloaderCard.vue to include default values for config and name properties 2024-08-11 16:06:48 +08:00
jxxghp
8bd0f7a589 style: Update styles.scss and types.ts to include config property in DownloaderConf and MediaServerConf 2024-08-11 15:09:56 +08:00
jxxghp
235eb82c45 style: Update CustomRuleCard.vue to include publish_time field 2024-08-05 18:15:04 +08:00
jxxghp
f043447e4f style: Update FileBrowserView.vue to include storage property in operItem and itemstack 2024-07-26 22:05:01 +08:00
jxxghp
e92a74a088 style: Update DownloaderCard.vue to include draggable icon button 2024-07-26 21:18:38 +08:00
jxxghp
799a385ff9 style: Update DownloaderCard, MediaServerCard, and NotificationChannelCard components to include close button functionality 2024-07-26 21:15:17 +08:00
jxxghp
2c74dc0ccd style: Update NotificationChannelCard to include web push notification support 2024-07-26 12:49:32 +08:00
jxxghp
c191b12514 style: Update MediaServerCard and NotificationChannelCard to use dynamic icons based on server and notification types 2024-07-26 09:00:41 +08:00
jxxghp
2c9e593af0 style: Add new downloader and update DownloaderCard.vue to display downloader information 2024-07-25 11:07:14 +08:00
jxxghp
f1dbab7d55 style: Update NetTestView to use webp format for Slack logo 2024-07-25 08:18:52 +08:00
jxxghp
ea77d7e76d style: add divider to DirectoryCard.vue for monitor type 2024-07-25 08:09:30 +08:00
jxxghp
64d8e3b1e1 style: save directories in AccountSettingDirectory.vue 2024-07-24 18:08:04 +08:00
jxxghp
bd4975d180 style: Update grid-template-columns in grid-directory-card to use a minimum width of 24rem 2024-07-24 18:05:51 +08:00
jxxghp
2a916a099c style: Update StorageCard component to display storage icons based on storage type 2024-07-24 16:52:01 +08:00
jxxghp
bc084922f7 style: add name field to StorageConf interface and update StorageCard component to display storage name 2024-07-20 09:36:46 +08:00
jxxghp
42f755b755 style: update UserCard to display movie and TV show subscription counts 2024-07-20 08:55:05 +08:00
jxxghp
7f2c629305 style: update MessageCard to use VCard component for consistent styling 2024-07-14 19:30:06 +08:00
jxxghp
6136095e0f style: improve LoggingView table layout and styling, add refreshing indicator using LoadingBanner component 2024-07-14 18:48:36 +08:00
jxxghp
0a34e07cc5 style: update LoggingView to use LoadingBanner component for refreshing indicator 2024-07-14 17:56:51 +08:00
jxxghp
71c6f4483f style: update LoggingView table layout and styling 2024-07-14 17:53:58 +08:00
jxxghp
731a74905c style: truncate plugin card descriptions in PluginAppCard and PluginCard 2024-07-14 17:25:56 +08:00
jxxghp
8b0e47103c style: add text-shadow to plugin card descriptions 2024-07-14 17:19:36 +08:00
jxxghp
4da24e27a4 fix plugins 2024-07-14 17:16:44 +08:00
jxxghp
169f1b327b fix icon 2024-07-14 12:28:37 +08:00
jxxghp
360f9afb54 change plugincard style 2024-07-14 12:24:19 +08:00
jxxghp
0e45a59860 fix 2024-07-14 11:09:29 +08:00
jxxghp
cfc2e407a4 fix settings layout 2024-07-14 11:07:17 +08:00
jxxghp
a467fdb43f fix 2024-07-09 20:06:48 +08:00
jxxghp
474db2be0d fix profile 2024-07-09 19:13:30 +08:00
jxxghp
e946037c57 fix user 2024-07-09 07:59:39 +08:00
jxxghp
b2e1fe314f fix user 2024-07-06 17:53:51 +08:00
jxxghp
81fb44da80 Merge pull request #164 from jxxghp/main
merge
2024-07-01 11:24:12 +08:00
jxxghp
de2ce12163 Merge pull request #163 from jxxghp/main
marge
2024-06-28 11:34:29 +08:00
jxxghp
f4b2ed4f7d fix build 2024-06-24 17:13:11 +08:00
129 changed files with 9405 additions and 4248 deletions

View File

@@ -1,2 +1,2 @@
VITE_API_BASE_URL=http://localhost:3001/api/v1/
VITE_API_BASE_URL=/api/v1/
VITE_PUBLIC_VAPID_KEY=BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM

View File

@@ -1,12 +1,12 @@
name: Build Moviepilot-Frontend
name: Build Moviepilot-Frontend v2
on:
workflow_dispatch:
push:
branches:
- main
- v2
paths:
- package.json
- 'package.json'
jobs:
build:
@@ -42,13 +42,21 @@ jobs:
echo "$frontend_version" > dist/version.txt
zip -r dist.zip dist
- name: Delete Release
uses: dev-drprasad/delete-tag-and-release@v1.1
with:
tag_name: ${{ env.frontend_version }}
delete_release: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Generate Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.frontend_version }}
name: ${{ env.frontend_version }}
draft: false
prerelease: false
make_latest: false
files: |
dist.zip
env:

View File

@@ -29,7 +29,6 @@
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<link rel="stylesheet" type="text/css" href="/loader.css" />
<link rel="preload" href="index.js" as="script">
</head>
<body>

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "1.9.17",
"version": "2.0.5",
"private": true,
"bin": "dist/service.js",
"scripts": {

View File

@@ -114,10 +114,8 @@ function changeTheme(theme: string) {
currentThemeName.value = nextTheme
// 保存主题到服务端
try {
api.post('/user/config/theme', nextTheme, {
headers: {
'Content-Type': 'text/plain',
},
api.post('/user/config/Layout', {
theme: nextTheme
})
} catch (e) {
console.error('保存主题到服务端失败')

View File

@@ -60,19 +60,25 @@ export const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : valu
export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
// 格式化为xx[TGMK]B
export function formatFileSize(bytes: number) {
if (bytes < 0) throw new Error('字节数不能为负数。')
export function formatFileSize(bytes: number, decimals = 2, prefix = false) {
// 负数标记
let negative = false
let size = bytes
if (bytes < 0) {
negative = true
size = Math.abs(bytes)
}
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`
if (negative) return `-${size.toFixed(decimals)} ${units[unitIndex]}`
else
return prefix ? `+${size.toFixed(decimals)} ${units[unitIndex]}` : `${size.toFixed(decimals)} ${units[unitIndex]}`
}
// 将时间秒格式化为时分秒

View File

@@ -2,8 +2,7 @@
export async function getClipboardContent() {
if (navigator.clipboard && window.isSecureContext) {
return await navigator.clipboard.readText()
}
else {
} else {
const input = document.createElement('textarea')
document.body.appendChild(input)
input.select()
@@ -18,11 +17,12 @@ export async function getClipboardContent() {
export async function copyToClipboard(content: string) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(content)
}
else {
} else {
const input = document.createElement('textarea')
input.value = content
document.body.appendChild(input)
// 阻止事件冒泡到其他元素,确保 focusin 事件只在 textarea 元素上处理,不会影响其他元素
input.addEventListener('focusin', e => e.stopPropagation())
input.select()
document.execCommand('copy')
document.body.removeChild(input)
@@ -42,3 +42,12 @@ export function urlBase64ToUint8Array(base64String: string) {
}
return outputArray
}
// 判断是否为PWA
export const isPWA = async (): Promise<boolean> => {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations()
return registrations.length > 0
}
return (window.navigator as any).standalone === true
}

View File

@@ -53,8 +53,8 @@ function handleNavScroll(evt: Event) {
<RouterLink to="/" class="app-logo d-flex align-center app-title-wrapper">
<div class="d-flex" v-html="logo" />
<h1 class="font-weight-bold leading-normal text-2xl">
MOVIEPILOT
<h1 class="font-weight-bold leading-normal text-xl">
MOVIEPILOT <span class="text-sm text-gray-500">v2</span>
</h1>
</RouterLink>
</slot>

View File

@@ -122,8 +122,9 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
export interface NavMenu extends NavLink {
header: string
admin: boolean
description?: string
admin?: boolean
footer?: boolean
}
// 👉 Vertical nav group

View File

@@ -10,7 +10,7 @@ import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'
import modeCssUrl from 'ace-builds/src-noconflict/mode-css?url'
import modePythonUrl from 'ace-builds/src-noconflict/mode-python?url'
import modeIniUrl from 'ace-builds/src-noconflict/mode-ini?url'
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
@@ -40,7 +40,7 @@ import snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url'
import snippertsCssUrl from 'ace-builds/src-noconflict/snippets/css?url'
import snippetsPythonUrl from 'ace-builds/src-noconflict/snippets/python?url'
import snippertsIniUrl from 'ace-builds/src-noconflict/snippets/ini?url'
import 'ace-builds/src-noconflict/ext-language_tools'
@@ -49,7 +49,7 @@ ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
ace.config.setModuleUrl('ace/mode/css', modeCssUrl)
ace.config.setModuleUrl('ace/mode/python', modePythonUrl)
ace.config.setModuleUrl('ace/mode/ini', modeIniUrl)
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
@@ -64,6 +64,6 @@ ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
ace.config.setModuleUrl('ace/snippets/python', snippetsPythonUrl)
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
ace.require('ace/ext/language_tools')

73
src/api/constants.ts Normal file
View File

@@ -0,0 +1,73 @@
export const storageOptions = [
{
title: '本地',
value: 'local',
icon: 'mdi-folder-multiple-outline',
},
{
title: '阿里云盘',
value: 'alipan',
icon: 'mdi-cloud-outline',
},
{
title: '115网盘',
value: 'u115',
icon: 'mdi-cloud-outline',
},
{
title: 'RClone',
value: 'rclone',
icon: 'mdi-cloud-outline',
},
{
title: 'AList',
value: 'alist',
icon: 'mdi-cloud-outline',
},
]
export const innerFilterRules = [
{ title: '特效字幕', value: ' SPECSUB ' },
{ title: '中文字幕', value: ' CNSUB ' },
{ title: '国语配音', value: ' CNVOI ' },
{ title: '官种', value: ' GZ ' },
{ title: '排除: 国语配音', value: ' !CNVOI ' },
{ title: '粤语配音', value: ' HKVOI ' },
{ title: '排除: 粤语配音', value: ' !HKVOI ' },
{ title: '促销: 免费', value: ' FREE ' },
{ title: '分辨率: 4K', value: ' 4K ' },
{ title: '分辨率: 1080P', value: ' 1080P ' },
{ title: '分辨率: 720P', value: ' 720P ' },
{ title: '排除: 720P', value: ' !720P ' },
{ title: '质量: 蓝光原盘', value: ' BLU ' },
{ title: '排除: 蓝光原盘', value: ' !BLU ' },
{ title: '质量: BLURAY', value: ' BLURAY ' },
{ title: '排除: BLURAY', value: ' !BLURAY ' },
{ title: '质量: UHD', value: ' UHD ' },
{ title: '排除: UHD', value: ' !UHD ' },
{ title: '质量: REMUX', value: ' REMUX ' },
{ title: '排除: REMUX', value: ' !REMUX ' },
{ title: '质量: WEB-DL', value: ' WEBDL ' },
{ title: '排除: WEB-DL', value: ' !WEBDL ' },
{ title: '质量: 60fps', value: ' 60FPS ' },
{ title: '排除: 60fps', value: ' !60FPS ' },
{ title: '编码: H265', value: ' H265 ' },
{ title: '排除: H265', value: ' !H265 ' },
{ title: '编码: H264', value: ' H264 ' },
{ title: '排除: H264', value: ' !H264 ' },
{ title: '效果: 杜比视界', value: ' DOLBY ' },
{ title: '排除: 杜比视界', value: ' !DOLBY ' },
{ title: '效果: 杜比全景声', value: ' ATMOS ' },
{ title: '排除: 杜比全景声', value: ' !ATMOS ' },
{ title: '效果: HDR', value: ' HDR ' },
{ title: '排除: HDR', value: ' !HDR ' },
{ title: '效果: SDR', value: ' SDR ' },
{ title: '排除: SDR', value: ' !SDR ' },
{ title: '效果: 3D', value: ' 3D ' },
{ title: '排除: 3D', value: ' !3D ' },
]
export const storageDict = storageOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)

View File

@@ -37,3 +37,13 @@ api.interceptors.response.use(
)
export default api
export async function fetchGlobalSettings() {
try {
const result: { [key: string]: any } = await api.get('system/global')
return result.data || {}
} catch (error) {
console.error('Failed to fetch global settings', error)
throw error
}
}

View File

@@ -1,5 +1,6 @@
// 订阅
export interface Subscribe {
// 订阅ID
id: number
// 订阅名称
name: string
@@ -65,12 +66,82 @@ export interface Subscribe {
show_edit_dialog: boolean
// 编辑框打开状态
page_open?: boolean
// 自定义识别词
custom_words?: string
// 自定义媒体类别
media_category?: string
// 过滤规则组
filter_groups?: string[]
// 下载器
downloader?: string
}
// 订阅分享
export interface SubscribeShare {
// 分享ID
id?: number
// 订阅ID
subscribe_id?: number
// 分享标题
share_title?: string
// 分享说明
share_comment?: string
// 分享人
share_user?: string
// 订阅名称
name?: string
// 订阅年份
year?: string
// 订阅类型 电影/电视剧
type?: string
// 搜索关键字
keyword?: string
// TMDB ID
tmdbid?: number
// 豆瓣ID
doubanid?: string
// 季号
season?: number
// 海报
poster?: string
// 背景图
backdrop?: string
// 评分
vote?: number
// 描述
description?: string
// 过滤规则
filter?: string
// 包含
include?: string
// 排除
exclude?: string
// 质量
quality?: string
// 分辨率
resolution?: string
// 特效
effect?: string
// 总集数
total_episode?: number
// 时间
date?: string
// 自定义识别词
custom_words?: string
// 自定义媒体类别
media_category?: string
// 复用次数
count?: number
}
// 历史记录
export interface TransferHistory {
// ID
id: number
// 源存储
src_storage?: string
// 目标存储
dest_storage?: string
// 源目录
src?: string
// 目的目录
@@ -318,6 +389,8 @@ export interface Site {
pri?: number
// RSS地址
rss?: string
// 下载器
downloader?: string
// Cookie
cookie?: string
// ApiKey
@@ -366,6 +439,48 @@ export interface SiteStatistic {
note?: string
}
// 站点用户数据
export interface SiteUserData {
// 站点域名
domain?: string
// 用户名
username?: string
// 用户ID
userid?: number
// 用户等级
user_level?: string
// 加入时间
join_at?: string
// 积分
bonus?: number // 默认为 0.0
// 上传量
upload?: number // 默认为 0
// 下载量
download?: number // 默认为 0
// 分享率
ratio?: number // 默认为 0
// 做种数
seeding?: number // 默认为 0
// 下载数
leeching?: number // 默认为 0
// 做种体积
seeding_size?: number // 默认为 0
// 下载体积
leeching_size?: number // 默认为 0
// 做种人数, 种子大小
seeding_info?: any[] // 默认为空数组
// 未读消息
message_unread?: number // 默认为 0
// 未读消息内容
message_unread_contents?: any[] // 默认为空数组
// 错误信息
err_msg?: string | null // 默认为 null
// 更新日期
updated_day?: string
// 更新时间
updated_time?: string
}
// 正在下载
export interface DownloadingInfo {
// HASH
@@ -394,6 +509,8 @@ export interface DownloadingInfo {
userid?: string
// 下载用户名称
username?: string
// 剩余时间
left_time?: string
}
// 缺失剧集信息
@@ -492,6 +609,8 @@ export interface TorrentInfo {
site_proxy: boolean
// 站点优先级
site_order: number
// 站点下载器
site_downloader?: string
// 种子名称
title?: string
// 种子副标题
@@ -642,6 +761,10 @@ export interface User {
avatar: string
// 是否开启双重验证
is_otp: boolean
// 用户权限 json
permissions: { [key: string]: any }
// 用户个性化设置 json
settings: { [key: string]: string | null }
}
// 存储空间
@@ -741,6 +864,8 @@ export interface EndPoints {
// 文件浏览项目
export interface FileItem {
// 存储
storage: string
// 类型 dir/file
type: string
// 文件名
@@ -843,22 +968,174 @@ export interface SystemNotification {
date: string
}
// 下载目录/媒体库目录
export interface MediaDirectory {
// 类型 download/library
type?: string
// 别名
name?: string
// 路径
path?: string
// 媒体类型 电影/电视剧
media_type?: string
// 媒体类别 动画电影/国产剧
category?: string
// 刮削媒体信息
scrape?: boolean
// 自动二级分类,未指定类别时自动分类
auto_category?: boolean
// 优先级
priority?: number
// 下载器配置
export interface DownloaderConf {
// 名称
name: string
// 类型 qbittorrent/transmission
type: string
// 是否默认
default: boolean
// 配置
config: { [key: string]: any }
// 是否启用
enabled: boolean
}
// 通知配置
export interface NotificationConf {
// 名称
name: string
// 类型 telegram/wechat/vocechat/synologychat
type: string
// 配置
config: { [key: string]: any }
// 场景开关
switchs?: string[]
// 是否启用
enabled: boolean
}
// 通知场景开关配置
export interface NotificationSwitchConf {
// 场景名称
type: string
// 通知范围 all/user/admin
action: string
}
// 存储配置
export interface StorageConf {
// 名称
name: string
// 类型 local/alipan/u115/rclone
type: string
// 配置
config?: { [key: string]: any }
}
// 媒体服务器配置
export interface MediaServerConf {
// 名称
name: string
// 类型 emby/jellyfin/plex
type: string
// 配置
config: { [key: string]: any }
// 是否启用
enabled: boolean
// 同步媒体体库列表
sync_libraries?: string[]
}
// 文件整理目录配置
export interface TransferDirectoryConf {
// 名称
name: string
// 优先级
priority: number
// 存储
storage: string
// 下载目录
download_path?: string
// 适用媒体类型
media_type?: string
// 适用媒体类别
media_category?: string
// 下载类型子目录
download_type_folder?: boolean
// 下载类别子目录
download_category_folder?: boolean
// 监控方式 downloader/monitorNone为不监控
monitor_type?: string
// 监控模式 fast/compatibility
monitor_mode?: string
// 整理方式 move/copy/link/softlink
transfer_type?: string
// 文件覆盖模式 always/size/never/latest
overwrite_mode?: string
// 整理到媒体库目录
library_path?: string
// 媒体库目录存储
library_storage?: string
// 智能重命名
renaming?: boolean
// 刮削
scraping?: boolean
// 媒体库类型子目录
library_type_folder?: boolean
// 媒体库类别子目录
library_category_folder?: boolean
// 是否发送通知
notify?: boolean
}
// 自定义规则项
export interface CustomRule {
// 规则ID
id: string
// 名称
name: string
// 包含
include?: string
// 排除
exclude?: string
// 大小范围
size_range?: string
// 最少做种人数
seeders?: string
// 发布时间
publish_time?: string
}
// 过滤规则组
export interface FilterRuleGroup {
// 名称
name: string
// 规则串
rule_string?: string
// 适用类媒体类型 None-全部 电影/电视剧
media_type?: string
// # 适用媒体类别 None-全部 对应二级分类
category?: string
}
export interface SubscribeDownloadFileInfo {
// 种子名称
torrent_title?: string
// 站点名称
site_name?: string
// 下载器
downloader?: string
// hash
hash?: string
// 文件路径
file_path?: string
}
export interface SubscribeLibraryFileInfo {
// 存储
storage?: string
// 文件路径
file_path?: string
}
export interface SubscribeEpisodeInfo {
// 标题
title?: string
// 描述
description?: string
// 背景图
backdrop?: string
// 下载文件信息
download?: SubscribeDownloadFileInfo[]
// 媒体库文件信息
library?: SubscribeLibraryFileInfo[]
}
export interface SubscrbieInfo {
// 订阅信息
subscribe: Subscribe
// 集信息 {集号: {download: 文件路径library: 文件路径, backdrop: url, title: 标题, description: 描述}}
episodes: Record<number, SubscribeEpisodeInfo>
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -0,0 +1,10 @@
<svg width="1252" height="1252" xmlns="http://www.w3.org/2000/svg" version="1.1">
<g>
<g id="#70c6beff">
<path id="svg_2" d="m634.37,138.38c11.88,-1.36 24.25,1.3 34.18,8.09c14.96,9.66 25.55,24.41 34.49,39.51c40.59,68.03 81.45,135.91 122.02,203.96c54.02,90.99 108.06,181.97 161.94,273.06c37.28,63 74.65,125.96 112.18,188.82c24.72,41.99 50.21,83.54 73.84,126.16c10.18,17.84 15.77,38.44 14.93,59.03c-0.59,15.92 -3.48,32.28 -11.84,46.08c-11.73,19.46 -31.39,33.2 -52.71,40.36c-11.37,4.09 -23.3,6.87 -35.43,6.89c-132.32,-0.05 -264.64,0.04 -396.95,0.03c-11.38,-0.29 -22.95,-1.6 -33.63,-5.72c-7.81,-3.33 -15.5,-7.43 -21.61,-13.42c-10.43,-10.32 -17.19,-24.96 -15.38,-39.83c0.94,-10.39 3.48,-20.64 7.76,-30.16c4.15,-9.77 9.99,-18.67 15.06,-27.97c22.13,-39.47 45.31,-78.35 69.42,-116.65c7.72,-12.05 14.44,-25.07 25.12,-34.87c11.35,-10.39 25.6,-18.54 41.21,-19.6c12.55,-0.52 24.89,3.82 35.35,10.55c11.8,6.92 21.09,18.44 24.2,31.88c4.49,17.01 -0.34,34.88 -7.55,50.42c-8.09,17.65 -19.62,33.67 -25.81,52.18c-1.13,4.21 -2.66,9.52 0.48,13.23c3.19,3 7.62,4.18 11.77,5.22c12,2.67 24.38,1.98 36.59,2.06c45,-0.01 90,0 135,0c8.91,-0.15 17.83,0.3 26.74,-0.22c6.43,-0.74 13.44,-1.79 18.44,-6.28c3.3,-2.92 3.71,-7.85 2.46,-11.85c-2.74,-8.86 -7.46,-16.93 -12.12,-24.89c-119.99,-204.91 -239.31,-410.22 -360.56,-614.4c-3.96,-6.56 -7.36,-13.68 -13.03,-18.98c-2.8,-2.69 -6.95,-4.22 -10.77,-3.11c-3.25,1.17 -5.45,4.03 -7.61,6.57c-5.34,6.81 -10.12,14.06 -14.51,21.52c-20.89,33.95 -40.88,68.44 -61.35,102.64c-117.9,198.43 -235.82,396.85 -353.71,595.29c-7.31,13.46 -15.09,26.67 -23.57,39.43c-7.45,10.96 -16.49,21.23 -28.14,27.83c-13.73,7.94 -30.69,11.09 -46.08,6.54c-11.23,-3.47 -22.09,-9.12 -30.13,-17.84c-10.18,-10.08 -14.69,-24.83 -14.17,-38.94c0.52,-14.86 5.49,-29.34 12.98,-42.1c71.58,-121.59 143.62,-242.92 215.93,-364.09c37.2,-62.8 74.23,-125.69 111.64,-188.36c37.84,-63.5 75.77,-126.94 113.44,-190.54c21.02,-35.82 42.19,-71.56 64.28,-106.74c6.79,-11.15 15.58,-21.15 26.16,-28.85c8.68,-5.92 18.42,-11 29.05,-11.94z" fill="#70c6be"/>
</g>
<g id="#1ba0d8ff">
<path id="svg_3" d="m628.35,608.38c17.83,-2.87 36.72,1.39 51.5,11.78c11.22,8.66 19.01,21.64 21.26,35.65c1.53,10.68 0.49,21.75 -3.44,31.84c-3.02,8.73 -7.35,16.94 -12.17,24.81c-68.76,115.58 -137.5,231.17 -206.27,346.75c-8.8,14.47 -16.82,29.47 -26.96,43.07c-7.37,9.11 -16.58,16.85 -27.21,21.89c-22.47,11.97 -51.79,4.67 -68.88,-13.33c-8.66,-8.69 -13.74,-20.63 -14.4,-32.84c-0.98,-12.64 1.81,-25.42 7.53,-36.69c5.03,-10.96 10.98,-21.45 17.19,-31.77c30.22,-50.84 60.17,-101.84 90.3,-152.73c41.24,-69.98 83.16,-139.55 124.66,-209.37c4.41,-7.94 9.91,-15.26 16.09,-21.9c8.33,-8.46 18.9,-15.3 30.8,-17.16z" fill="#1ba0d8"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 20.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="&#1057;&#1083;&#1086;&#1081;_1" x="0px" y="0px" viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve">
<linearGradient id="SVGID_1__48343" gradientUnits="userSpaceOnUse" x1="39" y1="23.25" x2="39" y2="33.0008" spreadMethod="reflect">
<stop offset="0" style="stop-color:#6DC7FF"/>
<stop offset="1" style="stop-color:#E6ABFF"/>
</linearGradient>
<circle style="fill:url(#SVGID_1__48343);" cx="39" cy="28" r="4"/>
<linearGradient id="SVGID_2__48343" gradientUnits="userSpaceOnUse" x1="32" y1="6.75" x2="32" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<path style="fill:url(#SVGID_2__48343);" d="M58,13c0-2.757-2.243-5-5-5H19c-2.757,0-5,2.243-5,5v33H6v5c0,2.757,2.243,5,5,5h34 c2.757,0,5-2.243,5-5V18h8V13z M11,54c-1.654,0-3-1.346-3-3v-3h32v3c0,1.125,0.374,2.164,1.002,3H11z M48,51c0,1.654-1.346,3-3,3 s-3-1.346-3-3v-5H16V13c0-1.654,1.346-3,3-3h30.026C48.391,10.838,48,11.87,48,13V51z M56,16h-6v-3c0-1.654,1.346-3,3-3s3,1.346,3,3 V16z"/>
<linearGradient id="SVGID_3__48343" gradientUnits="userSpaceOnUse" x1="39" y1="6.75" x2="39" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<path style="fill:url(#SVGID_3__48343);" d="M39,23c-2.757,0-5,2.243-5,5s2.243,5,5,5s5-2.243,5-5S41.757,23,39,23z M39,31 c-1.654,0-3-1.346-3-3s1.346-3,3-3s3,1.346,3,3S40.654,31,39,31z"/>
<linearGradient id="SVGID_4__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="20" y="23" style="fill:url(#SVGID_4__48343);" width="10" height="2"/>
<linearGradient id="SVGID_5__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="20" y="27" style="fill:url(#SVGID_5__48343);" width="10" height="2"/>
<linearGradient id="SVGID_6__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="20" y="31" style="fill:url(#SVGID_6__48343);" width="10" height="2"/>
<linearGradient id="SVGID_7__48343" gradientUnits="userSpaceOnUse" x1="25" y1="6.75" x2="25" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="20" y="35" style="fill:url(#SVGID_7__48343);" width="10" height="2"/>
<linearGradient id="SVGID_8__48343" gradientUnits="userSpaceOnUse" x1="39" y1="6.75" x2="39" y2="58.039" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<rect x="34" y="35" style="fill:url(#SVGID_8__48343);" width="10" height="2"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 20.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="&#1057;&#1083;&#1086;&#1081;_1" x="0px" y="0px" viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve">
<linearGradient id="SVGID_1__52535" gradientUnits="userSpaceOnUse" x1="21.9994" y1="11.6667" x2="21.9994" y2="18.5839" spreadMethod="reflect">
<stop offset="0" style="stop-color:#6DC7FF"/>
<stop offset="1" style="stop-color:#E6ABFF"/>
</linearGradient>
<circle style="fill:url(#SVGID_1__52535);" cx="21.999" cy="14.998" r="3"/>
<linearGradient id="SVGID_2__52535" gradientUnits="userSpaceOnUse" x1="35.9994" y1="4.1667" x2="35.9994" y2="15.8334" spreadMethod="reflect">
<stop offset="0" style="stop-color:#6DC7FF"/>
<stop offset="1" style="stop-color:#E6ABFF"/>
</linearGradient>
<circle style="fill:url(#SVGID_2__52535);" cx="35.999" cy="9.998" r="4"/>
<linearGradient id="SVGID_3__52535" gradientUnits="userSpaceOnUse" x1="32" y1="20.7501" x2="32" y2="58.7632" spreadMethod="reflect">
<stop offset="0" style="stop-color:#1A6DFF"/>
<stop offset="1" style="stop-color:#C822FF"/>
</linearGradient>
<path style="fill:url(#SVGID_3__52535);" d="M47.003,21H16.996C15.344,21,14,22.344,14,23.995V25v0.998v1.261 c0,0.717,0.257,1.41,0.722,1.95l10.556,12.315C25.743,42.068,26,42.763,26,43.479v6.964c0,0.652,0.32,1.264,0.854,1.634l8.016,5.569 c0.341,0.236,0.737,0.356,1.136,0.356c0.316,0,0.634-0.076,0.926-0.229C37.591,57.428,38,56.751,38,56.007V43.479 c0-0.716,0.257-1.409,0.722-1.953L49.277,29.21C49.743,28.668,50,27.975,50,27.259v-1.258V25v-1.005C50,22.344,48.655,21,47.003,21z M37.204,40.225c-0.447,0.521-0.762,1.129-0.963,1.775H33v2h3l0.001,2H33v2h3.003l0.002,2H34v2h2.007l0.003,4.002L28,50.442v-6.964 c0-1.193-0.428-2.35-1.205-3.255L17.176,29h29.648L37.204,40.225z M48,26.001C48,26.552,47.552,27,47,27H17.002 C16.449,27,16,26.551,16,25.998V25v-1.005C16,23.446,16.447,23,16.996,23h30.007C47.553,23,48,23.446,48,23.995V25V26.001z"/>
<linearGradient id="SVGID_4__52535" gradientUnits="userSpaceOnUse" x1="41.9994" y1="17.3333" x2="41.9994" y2="21.3333" spreadMethod="reflect">
<stop offset="0" style="stop-color:#6DC7FF"/>
<stop offset="1" style="stop-color:#E6ABFF"/>
</linearGradient>
<path style="fill:url(#SVGID_4__52535);" d="M44.999,21c0-2-1.343-3-3-3s-3,1-3,3H44.999z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -2,15 +2,12 @@
import type { Axios } from 'axios'
import FileList from './filebrowser/FileList.vue'
import FileToolbar from './filebrowser/FileToolbar.vue'
import type { EndPoints, FileItem } from '@/api/types'
import api from '@/api'
import AliyunAuthDialog from './dialog/AliyunAuthDialog.vue'
import U115AuthDialog from './dialog/U115AuthDialog.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { storageOptions } from '@/api/constants'
// 输入参数
const props = defineProps({
storages: String,
storages: Array as PropType<StorageConf[]>,
tree: Boolean,
endpoints: Object as PropType<EndPoints>,
axios: {
@@ -22,49 +19,101 @@ const props = defineProps({
type: Object as PropType<FileItem>,
required: true,
},
itemstack: Array as PropType<FileItem[]>,
itemstack: {
type: Array as PropType<FileItem[]>,
default: () => [],
},
})
// 对外事件
const emit = defineEmits(['pathchanged'])
const availableStorages = [
{
name: '本地',
code: 'local',
icon: 'mdi-folder-multiple-outline',
},
{
name: '阿里云盘',
code: 'aliyun',
icon: 'mdi-cloud-outline',
},
{
name: '115网盘',
code: 'u115',
icon: 'mdi-cloud-outline',
},
]
const fileIcons = {
// 压缩包
zip: 'mdi-folder-zip-outline',
rar: 'mdi-folder-zip-outline',
bak: 'mdi-folder-zip-outline',
tar: 'mdi-folder-zip-outline',
gz: 'mdi-folder-zip-outline',
bz2: 'mdi-folder-zip-outline',
// 开发
htm: 'mdi-language-html5',
html: 'mdi-language-html5',
vue: 'mdi-vuejs',
js: 'mdi-nodejs',
ts: 'mdi-language-typescript',
json: 'mdi-file-document-outline',
css: 'mdi-language-css3',
scss: 'mdi-language-css3',
less: 'mdi-language-css3',
php: 'mdi-language-php',
py: 'mdi-language-python',
java: 'mdi-language-java',
go: 'mdi-language-go',
c: 'mdi-language-c',
cpp: 'mdi-language-cpp',
h: 'mdi-language-c',
cs: 'mdi-language-csharp',
sql: 'mdi-database',
sh: 'mdi-language-bash',
bat: 'mdi-language-bash',
ps1: 'mdi-language-powershell',
// markdown
md: 'mdi-language-markdown-outline',
pdf: 'mdi-file-pdf',
png: 'mdi-file-image',
jpg: 'mdi-file-image',
jpeg: 'mdi-file-image',
markdown: 'mdi-language-markdown-outline',
// 图片
png: 'mdi-file-png-box',
jpg: 'mdi-file-jpg-box',
jpeg: 'mdi-file-jpg-box',
gif: 'mdi-file-gif-box',
bmp: 'mdi-file-image-box',
webp: 'mdi-file-image-box',
ico: 'mdi-file-image-box',
svg: 'mdi-file-image-box',
// 视频
mp4: 'mdi-filmstrip',
mkv: 'mdi-filmstrip',
avi: 'mdi-filmstrip',
wmv: 'mdi-filmstrip',
mov: 'mdi-filmstrip',
flv: 'mdi-filmstrip',
rmvb: 'mdi-filmstrip',
// 文档
txt: 'mdi-file-document-outline',
env: 'mdi-file-cog-outline',
yml: 'mdi-file-cog-outline',
yaml: 'mdi-file-cog-outline',
conf: 'mdi-file-cog-outline',
log: 'mdi-file-document-outline',
csv: 'mdi-file-delimited',
// office
xls: 'mdi-file-excel',
xlsx: 'mdi-file-excel',
doc: 'mdi-file-word',
docx: 'mdi-file-word',
ppt: 'mdi-file-powerpoint',
pptx: 'mdi-file-powerpoint',
pdf: 'mdi-file-pdf',
// 音频
mp2: 'mdi-music',
mp3: 'mdi-music',
m4a: 'mdi-music',
wma: 'mdi-music',
aac: 'mdi-music',
ogg: 'mdi-music',
flac: 'mdi-music',
wav: 'mdi-music',
// 字体
ttf: 'mdi-format-font',
otf: 'mdi-format-font',
woff: 'mdi-format-font',
woff2: 'mdi-format-font',
eot: 'mdi-format-font',
// 字幕
srt: 'mdi-subtitles-outline',
ass: 'mdi-subtitles-outline',
sub: 'mdi-subtitles-outline',
// 其他
other: 'mdi-file-outline',
}
@@ -76,19 +125,11 @@ const activeStorage = ref('local')
const refreshPending = ref(false)
// 排序
const sort = ref('name')
// 阿里云盘认证对话框
const aliyunAuthDialog = ref(false)
// 阿里云盘用户信息
const aliyunUserInfo = ref<{ [key: string]: any }>({})
// 115网盘认证对话框
const u115AuthDialog = ref(false)
// 115网盘用户信息
const u115UserInfo = ref<{ [key: string]: any }>({})
// 计算属性
const storagesArray = computed(() => {
const storageCodes = props.storages?.split(',')
return availableStorages.filter(item => storageCodes?.includes(item.code))
const storageCodes = props.storages?.map(item => item.type)
return storageOptions.filter(item => storageCodes?.includes(item.value))
})
// 方法
@@ -97,47 +138,10 @@ function loadingChanged(loading: number) {
else if (loading > 0) loading--
}
// 查询阿里云
async function loadAliyunUserInfo() {
try {
const result: { [key: string]: any } = await api.get('aliyun/userinfo')
if (result.success) {
aliyunUserInfo.value = result
}
} catch (error) {
console.log(error)
}
}
// 查询115
async function loadU115UserInfo() {
try {
const result: { [key: string]: any } = await api.get('u115/storage')
if (result.success) {
u115UserInfo.value = result
}
} catch (error) {
console.log(error)
}
}
// 存储切换
async function storageChanged(storage: string) {
if (storage == 'aliyun') {
await loadAliyunUserInfo()
if (isNullOrEmptyObject(aliyunUserInfo.value)) {
aliyunAuthDialog.value = true
return
}
} else if (storage == 'u115') {
await loadU115UserInfo()
if (isNullOrEmptyObject(u115UserInfo.value)) {
u115AuthDialog.value = true
return
}
}
activeStorage.value = storage
emit('pathchanged', { path: '/', fileid: 'root' })
emit('pathchanged', { storage: storage, path: '/', fileid: 'root' })
}
// 路径变化
@@ -150,18 +154,6 @@ function sortChanged(s: string) {
sort.value = s
refreshPending.value = true
}
// aliyun认证完成
function aliyunAuthDone() {
aliyunAuthDialog.value = false
activeStorage.value = 'aliyun'
}
// u115认证完成
function u115AuthDone() {
u115AuthDialog.value = false
activeStorage.value = 'u115'
}
</script>
<template>
@@ -195,11 +187,4 @@ function u115AuthDone() {
/>
</div>
</VCard>
<AliyunAuthDialog
v-if="aliyunAuthDialog"
v-model="aliyunAuthDialog"
@close="aliyunAuthDialog = false"
@done="aliyunAuthDone"
/>
<U115AuthDialog v-if="u115AuthDialog" v-model="u115AuthDialog" @close="u115AuthDialog = false" @done="u115AuthDone" />
</template>

View File

@@ -73,9 +73,3 @@ const getImgUrl = computed(() => {
</template>
</VHover>
</template>
<style lang="scss">
.text-shadow {
text-shadow: 1px 1px #777;
}
</style>

View File

@@ -0,0 +1,193 @@
<script lang="ts" setup>
import { CustomRule } from '@/api/types'
import { useToast } from 'vue-toast-notification'
import filter_svg from '@images/svg/filter.svg'
import { cloneDeep } from 'lodash'
import { innerFilterRules } from '@/api/constants'
// 输入参数
const props = defineProps({
// 单条规则
rule: {
type: Object as PropType<CustomRule>,
required: true,
},
// 所有规则
rules: {
type: Array as PropType<CustomRule[]>,
required: true,
},
})
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 规则详情弹窗
const ruleInfoDialog = ref(false)
// 规则详情
const ruleInfo = ref<CustomRule>({
id: '',
name: '',
include: '',
exclude: '',
size_range: '',
seeders: '',
publish_time: '',
})
// 打开详情弹窗
function openRuleInfoDialog() {
// 深复制
ruleInfo.value = cloneDeep(props.rule)
ruleInfoDialog.value = true
}
// 保存详情数据
function saveRuleInfo() {
// 有空值
if (!ruleInfo.value.id || !ruleInfo.value.name) {
if (!ruleInfo.value.id && !ruleInfo.value.name) {
$toast.error('规则ID和规则名称不能为空')
}
return
}
// 检查ID是否在内置的规则中
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
$toast.error('当前规则ID已被内置规则占用')
return
}
// 检查规则名称是否在内置的规则中
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
$toast.error('当前规则名称已被内置规则占用')
return
}
// ID已存在
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
$toast.error(`规则ID【${ruleInfo.value.id}】已存在`)
return
}
// 规则名称已存在
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
$toast.error(`规则名称【${ruleInfo.value.name}】已存在`)
return
}
// 保存数据
ruleInfoDialog.value = false
emit('change', ruleInfo.value, props.rule.id)
emit('done')
}
// 按钮点击
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" @click="openRuleInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.rule.id }}</h5>
<div class="text-body-1 mb-3">{{ props.rule.name }}</div>
</div>
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-model="ruleInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.rule.id} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.id"
label="规则ID"
placeholder="必填不可与其他规则ID重名"
hint="字符与数字组合,不能含空格"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.name"
label="规则名称"
placeholder="必填;不可与其他规则名称重名"
hint="使用别名便于区分规则"
persistent-hint
active
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.include"
placeholder="关键字/正则表达式"
label="包含"
hint="必须包含的关键字或正则表达式,多个值使用|分隔"
persistent-hint
active
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.exclude"
placeholder="关键字/正则表达式"
label="排除"
hint="不能包含的关键字或正则表达式,多个值使用|分隔"
persistent-hint
active
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.size_range"
placeholder="0/1-10"
label="资源体积MB"
hint="最小资源文件体积或体积范围(剧集计算单集平均大小)"
persistent-hint
active
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.seeders"
placeholder="0/1-10"
label="做种人数"
hint="最小做种人数或做种人数范围"
persistent-hint
active
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.publish_time"
placeholder="0"
label="发布时间(分钟)"
hint="距离资源发布的最小时间间隔"
persistent-hint
active
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveRuleInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 确定 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -1,12 +1,15 @@
<script lang="ts" setup>
import type { MediaDirectory } from '@/api/types'
import { VTextField } from 'vuetify/lib/components/index.mjs'
import type { TransferDirectoryConf } from '@/api/types'
import { VDivider, VSpacer, VTextField } from 'vuetify/lib/components/index.mjs'
import api from '@/api'
import { nextTick } from 'vue'
import { storageOptions } from '@/api/constants'
// 输入参数
const props = defineProps({
type: String, // download/library
directory: {
type: Object as PropType<MediaDirectory>,
type: Object as PropType<TransferDirectoryConf>,
required: true, // 必填参数
},
categories: {
@@ -17,8 +20,14 @@ const props = defineProps({
height: String,
})
// 路径
const path = ref<string>('')
// 下载路径
const downloadPath = ref<string>('')
// 媒体库路径
const libraryPath = ref<string>('')
// 卡版是否折叠状态
const isCollapsed = ref(true)
// 类型下拉字典
const typeItems = [
@@ -27,6 +36,93 @@ const typeItems = [
{ title: '电视剧', value: '电视剧' },
]
// 自动整理方式下拉字典
const transferSourceItems = [
{ title: '不整理', value: '' },
{ title: '下载器监控', value: 'downloader' },
{ title: '目录监控', value: 'monitor' },
{ title: '手动整理', value: 'manual' },
]
// 监控模式下拉字典
const MonitorModeItems = [
{ title: '性能模式', value: 'fast' },
{ title: '兼容模式', value: 'compatibility' },
]
// 整理方式下拉字典
const transferTypeItems = ref<{ title: string; value: string }[]>([])
// 调用API查询支持的整理方式
async function loadTransferTypeItems() {
// 参数不全时不查询
if (!props.directory.library_storage || !props.directory.storage) return
try {
// 下载器储存整理方法
const storage_res = await api.get(`storage/transtype/${props.directory.storage}`)
const storage_transtype = (storage_res as any).transtype
// 媒体库储存整理方法
const library_storage_res = await api.get(`storage/transtype/${props.directory.library_storage}`)
const library_storage_transtype = (library_storage_res as any).transtype
// 为空终止
if (!library_storage_transtype || !storage_transtype) return
// 取并集
const transtype: { [key: string]: string } = {}
Object.keys(storage_transtype).forEach(key => {
if (key in library_storage_transtype) {
transtype[key] = storage_transtype[key]
}
})
// 非空时设置整理方式下拉字典
if (transtype && Object.keys(transtype).length > 0) {
transferTypeItems.value = Object.keys(transtype).map(key => ({
title: transtype[key],
value: key,
}))
// 如果整理方式下拉字典不为空且当前值不在新的transferTypeItems里则设置整理方式为第一个
if (
transferTypeItems.value.length > 0 &&
!transferTypeItems.value.find(item => item.value === props.directory.transfer_type)
) {
nextTick(() => {
props.directory.transfer_type = transferTypeItems.value[0].value
})
}
// 如果整理方式下拉字典为空,清空整理方式
if (transferTypeItems.value.length === 0) {
props.directory.transfer_type = ''
}
} else {
// 无可用整理方式,清除已选值
transferTypeItems.value = []
props.directory.transfer_type = ''
}
} catch (e) {
console.log(e)
}
}
// 整理方式无数据提示
const computedNoDataText = computed(() => {
if (!props.directory.library_storage && !props.directory.storage) {
return '无可用整理方式!请先选择下载器储存与媒体库储存!'
} else if (!props.directory.library_storage) {
return '无可用整理方式!请先选择媒体库储存!'
} else if (!props.directory.storage) {
return '无可用整理方式!请先选择下载器储存!'
} else {
return '选择的存储没有支持的整理方法!'
}
})
// 覆盖模式下拉字典
const overwriteModeItems = [
{ title: '从不', value: 'never' },
{ title: '总是', value: 'always' },
{ title: '按文件大小', value: 'size' },
{ title: '仅保留最新版本', value: 'latest' },
]
// 定义触发的自定义事件
const emit = defineEmits(['close', 'changed', 'update:modelValue'])
@@ -35,10 +131,22 @@ function onClose() {
emit('close')
}
// 路径更新
function updatePath(value: string) {
path.value = value
emit('update:modelValue', value)
// 下载路径更新
function updateDownloadPath(value: string) {
downloadPath.value = value
emit('update:modelValue', {
download: downloadPath.value,
library: libraryPath.value,
})
}
// 媒体库路径更新
function updateLibraryPath(value: string) {
libraryPath.value = value
emit('update:modelValue', {
download: downloadPath.value,
library: libraryPath.value,
})
}
// 根据选中的媒体类型,获取对应的媒体类别
@@ -47,6 +155,32 @@ const getCategories = computed(() => {
if (!props.categories || !props.categories[props.directory?.media_type ?? '']) return default_value
return default_value.concat(props.categories[props.directory.media_type ?? ''])
})
// 监听 下载储存与媒体库储存 变化,重新加载整理方式下拉字典
watch(
[() => props.directory.library_storage, () => props.directory.storage],
([newLibraryStorage, newStorage], [oldLibraryStorage, oldStorage]) => {
if (newLibraryStorage !== oldLibraryStorage || newStorage !== oldStorage) {
loadTransferTypeItems()
}
},
{ immediate: true },
)
// 媒体类别和类型变更非空时将按类型分类和按类别分类置为false
watch(
[() => props.directory.media_type, () => props.directory.media_category],
([newMediaType, newMediaCategory], [oldMediaType, oldMediaCategory]) => {
if (newMediaType && newMediaType !== oldMediaType) {
props.directory.download_type_folder = false
props.directory.library_type_folder = false
}
if (newMediaCategory && newMediaCategory !== oldMediaCategory) {
props.directory.download_category_folder = false
props.directory.library_category_folder = false
}
},
)
</script>
<template>
@@ -65,40 +199,127 @@ const getCategories = computed(() => {
</IconBtn>
</span>
</VCardItem>
<VCardText>
<VCardText v-if="!isCollapsed">
<VForm>
<VRow>
<VCol>
<VPathField @update:modelValue="updatePath">
<template #activator="{ menuprops }">
<VTextField v-model="props.directory.path" v-bind="menuprops" variant="underlined" label="路径" />
</template>
</VPathField>
</VCol>
</VRow>
<VRow>
<VCol cols="4">
<VCol cols="6">
<VSelect
v-model="props.directory.media_type"
variant="underlined"
:items="typeItems"
label="媒体类型"
@update:modelValue="props.directory.category = ''"
@update:modelValue="props.directory.media_category = ''"
/>
</VCol>
<VCol>
<VSelect v-model="props.directory.category" variant="underlined" :items="getCategories" label="媒体类别" />
<VCol cols="6">
<VSelect
v-model="props.directory.media_category"
variant="underlined"
:items="getCategories"
label="媒体类别"
/>
</VCol>
<VCol cols="4">
<VSelect v-model="props.directory.storage" variant="underlined" :items="storageOptions" label="下载存储" />
</VCol>
<VCol cols="8">
<VPathField @update:modelValue="updateDownloadPath" :storage="props.directory.storage">
<template #activator="{ menuprops }">
<VTextField
v-model="props.directory.download_path"
v-bind="menuprops"
variant="underlined"
label="下载目录"
/>
</template>
</VPathField>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
<VSwitch v-model="props.directory.download_type_folder" label="按类型分类"></VSwitch>
</VCol>
<VCol cols="6" v-if="!props.directory.media_category || props.directory.media_category === ''">
<VSwitch v-model="props.directory.download_category_folder" label="按类别分类"></VSwitch>
</VCol>
</VRow>
<VDivider v-if="$props.directory.monitor_type" class="my-3 bg-primary" />
<VRow>
<VCol v-if="!props.directory.category || props.directory.category === ''">
<VSwitch v-model="props.directory.auto_category" label="自动分类"></VSwitch>
<VCol>
<VSelect
v-model="props.directory.monitor_type"
variant="underlined"
:items="transferSourceItems"
label="自动整理"
/>
</VCol>
<VCol v-if="type === 'library'">
<VSwitch v-model="props.directory.scrape" label="刮削元数据"></VSwitch>
</VRow>
<VRow v-if="$props.directory.monitor_type">
<VCol cols="12" v-if="$props.directory.monitor_type == 'monitor'">
<VSelect
v-model="props.directory.monitor_mode"
variant="underlined"
:items="MonitorModeItems"
label="监控模式"
/>
</VCol>
<VCol cols="4">
<VSelect
v-model="props.directory.library_storage"
variant="underlined"
:items="storageOptions"
label="媒体库存储"
/>
</VCol>
<VCol cols="8">
<VPathField @update:modelValue="updateLibraryPath" :storage="props.directory.library_storage">
<template #activator="{ menuprops }">
<VTextField
v-model="props.directory.library_path"
v-bind="menuprops"
variant="underlined"
label="媒体库目录"
/>
</template>
</VPathField>
</VCol>
<VCol cols="4">
<VSelect
v-model="props.directory.transfer_type"
variant="underlined"
:items="transferTypeItems"
label="整理方式"
:no-data-text="computedNoDataText"
/>
</VCol>
<VCol cols="8">
<VSelect
v-model="props.directory.overwrite_mode"
variant="underlined"
:items="overwriteModeItems"
label="覆盖模式"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
<VSwitch v-model="props.directory.library_type_folder" label="按类型分类"></VSwitch>
</VCol>
<VCol cols="6" v-if="!props.directory.media_category || props.directory.media_category === ''">
<VSwitch v-model="props.directory.library_category_folder" label="按类别分类"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.renaming" label="智能重命名"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.scraping" label="刮削元数据"></VSwitch>
</VCol>
<VCol cols="6">
<VSwitch v-model="props.directory.notify" label="发送通知"></VSwitch>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="text-center py-0">
<VSpacer />
<VBtn :icon="isCollapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'" @click.stop="isCollapsed = !isCollapsed" />
<VSpacer />
</VCardActions>
</VCard>
</template>

View File

@@ -0,0 +1,319 @@
<script setup lang="ts">
import api from '@/api'
import { formatFileSize } from '@/@core/utils/formatters'
import { DownloaderConf } from '@/api/types'
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 {cloneDeep} from "lodash";
// 定义输入
const props = defineProps({
// 单个下载器
downloader: {
type: Object as PropType<DownloaderConf>,
required: true,
},
// 是否允许刷新数据
allowRefresh: {
type: Boolean,
default: true,
},
// 所有下载器
downloaders: {
type: Array as PropType<DownloaderConf[]>,
required: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['close', 'done', 'change'])
// 提示框
const $toast = useToast()
// timeout定时器
let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 上传速率
const upload_rate = ref(0)
// 下载速度
const download_rate = ref(0)
// 下载器详情弹窗
const downloaderInfoDialog = ref(false)
// 下载器详情
const downloaderInfo = ref<DownloaderConf>({
name: '',
type: '',
default: false,
enabled: false,
config: {},
})
// 调用API查询下载器数据
async function loadDownloaderInfo() {
if (!props.allowRefresh) {
return
}
try {
const res: DownloaderInfo = await api.get('dashboard/downloader', {
params: {
name: props.downloader.name,
},
})
if (res) {
upload_rate.value = res.upload_speed
download_rate.value = res.download_speed
// 定时查询
clearTimeout(timeoutTimer)
if (props.downloader.enabled) {
timeoutTimer = setTimeout(loadDownloaderInfo, 3000)
}
}
} catch (e) {
console.log(e)
}
}
// 打开详情弹窗
function openDownloaderInfoDialog() {
// 深复制
downloaderInfo.value = cloneDeep(props.downloader)
downloaderInfoDialog.value = true
}
// 保存详情数据
function saveDownloaderInfo() {
// 为空不保存,跳出警告框
if (!downloaderInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
return
}
// 重名判断
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
$toast.error(`${downloaderInfo.value.name}】已存在,请替换为其他名称`)
return
}
// 默认下载器去重
if (downloaderInfo.value.default) {
props.downloaders.forEach(item => {
if (item.default && item !== props.downloader) {
item.default = false
$toast.info(`${item.name}】存在默认下载器,已替换成【${downloaderInfo.value.name}`)
}
})
}
// 执行保存
downloaderInfoDialog.value = false
emit('change', downloaderInfo.value, props.downloader.name)
emit('done')
}
// 根据存储类型选择图标
const getIcon = computed(() => {
switch (props.downloader.type) {
case 'qbittorrent':
return qbittorrent_image
case 'transmission':
return transmission_image
default:
return qbittorrent_image
}
})
// 按钮点击
function onClose() {
emit('close')
}
onMounted(async () => {
if (props.downloader.enabled) {
await loadDownloaderInfo()
}
})
onUnmounted(() => {
if (timeoutTimer) clearTimeout(timeoutTimer)
})
</script>
<template>
<div>
<VCard variant="tonal" @click="openDownloaderInfoDialog">
<DialogCloseBtn @click="onClose" />
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VCardText class="flex justify-space-between align-center gap-4">
<div class="align-self-start flex-1">
<div class="flex items-center">
<VBadge
v-if="props.downloader.default && props.downloader.enabled"
dot
inline
color="success"
class="me-1"
/>
<span class="text-h6">{{ downloader.name }}</span>
</div>
<div class="mt-1 flex flex-wrap text-sm" v-if="props.downloader.enabled">
<span class="me-2">{{ `${formatFileSize(upload_rate, 1)}/s ` }}</span>
<span>{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
</div>
</div>
<div class="h-20">
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
</div>
</VCardText>
</VCard>
<VDialog v-model="downloaderInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.downloader.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.enabled" label="启用下载器" />
</VCol>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.default" label="默认下载器" :disabled="!downloaderInfo.enabled" />
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="下载器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
label="用户名"
placeholder="admin"
hint="登录使用的用户名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
label="密码"
hint="登录使用的密码"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.category"
label="自动分类管理"
hint="由下载器自动管理分类和下载目录"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.sequentail"
label="顺序下载"
hint="按顺序依次下载文件"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.force_resume"
label="强制继续"
hint="强制继续、强制上传模式"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.first_last_piece"
label="优先首尾文件"
hint="优先下载首尾文件块"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'transmission'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="下载器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
label="用户名"
placeholder="admin"
hint="登录使用的用户名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
label="密码"
hint="登录使用的密码"
persistent-hint
active
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveDownloaderInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import api from '@/api'
import type { DownloadingInfo } from '@/api/types'
import { formatFileSize } from '@/@core/utils/formatters'
// 输入参数
const props = defineProps({
@@ -17,16 +18,21 @@ function getPercentage() {
// 速度
function getSpeedText() {
return `${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${props.info?.left_time}`
return `${formatFileSize(props.info?.size || 0)} ${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${
props.info?.left_time
}`
}
// 下载状态
const isDownloading = ref(props.info?.state === 'downloading')
// 监听props.info?.state的变化
watch(() => props.info?.state, (newValue) => {
isDownloading.value = newValue === 'downloading'
})
watch(
() => props.info?.state,
newValue => {
isDownloading.value = newValue === 'downloading'
},
)
// 图片是否加载完成
const imageLoaded = ref(false)
@@ -45,14 +51,10 @@ function getTextClass() {
async function toggleDownload() {
const operation = isDownloading.value ? 'stop' : 'start'
try {
const result: { [key: string]: any } = await api.get(
`download/${operation}/${props.info?.hash}`,
)
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`)
if (result.success)
isDownloading.value = !isDownloading.value
}
catch (error) {
if (result.success) isDownloading.value = !isDownloading.value
} catch (error) {
console.error(error)
}
}
@@ -62,67 +64,42 @@ async function deleteDownload() {
try {
await api.delete(`download/${props.info?.hash}`)
cardState.value = false
}
catch (error) {
} catch (error) {
console.error(error)
}
}
</script>
<template>
<VCard
v-if="cardState"
:key="props.info?.hash"
>
<VCard v-if="cardState" :key="props.info?.hash">
<template #image>
<VImg
:src="props.info?.media.image"
aspect-ratio="2/3"
cover
class="brightness-50"
@load="imageLoadHandler"
/>
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover class="brightness-50" @load="imageLoadHandler" />
</template>
<VCardTitle
class="break-words whitespace-normal"
:class="getTextClass()"
>
<VCardTitle class="break-words whitespace-normal" :class="getTextClass()">
{{ props.info?.media.title || props.info?.name }}
{{ props.info?.media.episode ? `${props.info?.media.season} ${props.info?.media.episode}` : props.info?.season_episode }}
{{
props.info?.media.episode
? `${props.info?.media.season} ${props.info?.media.episode}`
: props.info?.season_episode
}}
</VCardTitle>
<VCardSubtitle
class="break-words whitespace-normal"
:class="getTextClass()"
>
<VCardSubtitle class="break-words whitespace-normal" :class="getTextClass()">
{{ props.info?.title }}
</VCardSubtitle>
<VCardText
class="text-subtitle-1 pt-3 pb-1"
:class="getTextClass()"
>
<VCardText class="text-subtitle-1 pt-3 pb-1" :class="getTextClass()">
{{ getSpeedText() }}
</VCardText>
<VCardText
v-if="getPercentage() > 0"
:class="getTextClass()"
>
<VCardText v-if="getPercentage() > 0" :class="getTextClass()">
<VProgressLinear :model-value="getPercentage()" />
</VCardText>
<VCardActions class="justify-space-between">
<VBtn
:icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`"
@click="toggleDownload"
/>
<VBtn
color="error"
icon="mdi-trash-can-outline"
@click="deleteDownload"
/>
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
</VCardActions>
</VCard>
</template>

View File

@@ -1,11 +1,13 @@
<script lang="ts" setup>
import { innerFilterRules } from '@/api/constants'
import { CustomRule } from '@/api/types'
import { cloneDeep } from 'lodash'
// 输入参数
const props = defineProps({
pri: String,
maxpri: String,
rules: Array as PropType<string[]>,
width: String,
height: String,
custom_rules: Array as PropType<CustomRule[]>,
})
// 定义触发的自定义事件
@@ -21,59 +23,25 @@ function filtersChanged(value: string[]) {
emit('changed', props.pri, value)
}
// 清洗规则中的换行符和多余空格,并在前后添加空格
const cleanedRules = computed(() => {
return props.rules.map(rule => {
rule = rule ?? ''
return ` ${rule.replace(/[\r\n]/g, '').replace(/\s+/g, '')} `
})
})
// 过滤规则下拉框
const selectFilterOptions = ref<{ [key: string]: string }[]>([
{ title: '特效字幕', value: ' SPECSUB ' },
{ title: '中文字幕', value: ' CNSUB ' },
{ title: '国语配音', value: ' CNVOI ' },
{ title: '官种', value: ' GZ ' },
{ title: '排除: 国语配音', value: ' !CNVOI ' },
{ title: '粤语配音', value: ' HKVOI ' },
{ title: '排除: 粤语配音', value: ' !HKVOI ' },
{ title: '促销: 免费', value: ' FREE ' },
{ title: '分辨率: 4K', value: ' 4K ' },
{ title: '分辨率: 1080P', value: ' 1080P ' },
{ title: '分辨率: 720P', value: ' 720P ' },
{ title: '排除: 720P', value: ' !720P ' },
{ title: '质量: 蓝光原盘', value: ' BLU ' },
{ title: '排除: 蓝光原盘', value: ' !BLU ' },
{ title: '质量: BLURAY', value: ' BLURAY ' },
{ title: '排除: BLURAY', value: ' !BLURAY ' },
{ title: '质量: UHD', value: ' UHD ' },
{ title: '排除: UHD', value: ' !UHD ' },
{ title: '质量: REMUX', value: ' REMUX ' },
{ title: '排除: REMUX', value: ' !REMUX ' },
{ title: '质量: WEB-DL', value: ' WEBDL ' },
{ title: '排除: WEB-DL', value: ' !WEBDL ' },
{ title: '质量: 60fps', value: ' 60FPS ' },
{ title: '排除: 60fps', value: ' !60FPS ' },
{ title: '编码: H265', value: ' H265 ' },
{ title: '排除: H265', value: ' !H265 ' },
{ title: '编码: H264', value: ' H264 ' },
{ title: '排除: H264', value: ' !H264 ' },
{ title: '效果: 杜比视界', value: ' DOLBY ' },
{ title: '排除: 杜比视界', value: ' !DOLBY ' },
{ title: '效果: 杜比全景声', value: ' ATMOS ' },
{ title: '排除: 杜比全景声', value: ' !ATMOS ' },
{ title: '效果: HDR', value: ' HDR ' },
{ title: '排除: HDR', value: ' !HDR ' },
{ title: '效果: SDR', value: ' SDR ' },
{ title: '排除: SDR', value: ' !SDR ' },
{ title: '效果: 3D', value: ' 3D ' },
{ title: '排除: 3D', value: ' !3D ' },
])
const selectFilterOptions = ref<{ [key: string]: string }[]>([])
onMounted(() => {
selectFilterOptions.value = cloneDeep(innerFilterRules)
if (props.custom_rules) {
console.log(props.custom_rules)
props.custom_rules.map(rule => {
selectFilterOptions.value.push({
title: rule.name,
value: rule.id,
})
})
}
})
</script>
<template>
<VCard variant="tonal" :width="props.width" :height="props.height">
<VCard variant="tonal">
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
@@ -85,12 +53,13 @@ const selectFilterOptions = ref<{ [key: string]: string }[]>([
<VRow>
<VCol>
<VSelect
v-model="cleanedRules"
v-model="props.rules"
variant="underlined"
:items="selectFilterOptions"
chips
label=""
multiple
clearable
@update:modelValue="filtersChanged"
/>
</VCol>

View File

@@ -0,0 +1,286 @@
<script lang="ts" setup>
import draggable from 'vuedraggable'
import { copyToClipboard } from '@/@core/utils/navigator'
import { CustomRule, FilterRuleGroup } from '@/api/types'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import { useToast } from 'vue-toast-notification'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
import filter_group_svg from '@images/svg/filter-group.svg'
import { cloneDeep } from 'lodash'
// 输入参数
const props = defineProps({
// 单个规则组
group: {
type: Object as PropType<FilterRuleGroup>,
required: true,
},
// 所有规则组
groups: {
type: Array as PropType<FilterRuleGroup[]>,
required: true,
},
// 媒体类型字典
categories: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
// 自定义规则列表
custom_rules: Array as PropType<CustomRule[]>,
})
// 规则卡片类型
interface FilterCard {
// 优先级
pri: string
// 已选规则
rules: string[]
}
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 规则详情弹窗
const groupInfoDialog = ref(false)
// 规则详情
const groupInfo = ref<FilterRuleGroup>({
name: props.group?.name ?? '',
rule_string: props.group?.rule_string ?? '',
media_type: props.group?.media_type ?? '',
category: props.group?.category ?? '',
})
// 媒体类型字典
const mediaTypeItems = [
{ title: '通用', value: '' },
{ title: '电影', value: '电影' },
{ title: '电视剧', value: '电视剧' },
]
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: '全部', value: '' }]
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
return default_value
}
return default_value.concat(props.categories[groupInfo.value.media_type] || [])
})
// 规则组规则卡片列表
const filterRuleCards = ref<FilterCard[]>([])
// 导入代码弹窗
const importCodeDialog = ref(false)
// 导入的代码
const importCodeString = ref('')
// 更新规则卡片的值
function updateFilterCardValue(pri: string, rules: string[]) {
const card = filterRuleCards.value.find(card => card.pri === pri)
if (card && Array.isArray(rules)) card.rules = rules
}
// 移除卡片
function filterCardClose(pri: string) {
filterRuleCards.value = filterRuleCards.value
.filter(card => card.pri !== pri)
.map((card, index) => {
card.pri = (index + 1).toString()
return card
})
}
// 分享规则
function shareRules() {
if (filterRuleCards.value.length === 0) return
const value = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
try {
copyToClipboard(value)
$toast.success('优先级规则已复制到剪贴板')
} catch (error) {
$toast.error('优先级规则复制失败!')
}
}
// 导入规则
async function importRules() {
importCodeString.value = ''
importCodeDialog.value = true
}
// 监听导入代码变化
watchEffect(() => {
if (!importCodeString.value) return
if (!importCodeString.value.startsWith(' ')) importCodeString.value = ` ${importCodeString.value}`
if (!importCodeString.value.endsWith(' ')) importCodeString.value = `${importCodeString.value} `
const groups = importCodeString.value.split('>')
filterRuleCards.value = groups.map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
})
// 增加卡片
function addFilterCard() {
const pri = (filterRuleCards.value.length + 1).toString()
const newCard: FilterCard = { pri, rules: [] }
filterRuleCards.value.push(newCard)
}
// 根据列表的拖动顺序更新优先级
function dragOrderEnd() {
filterRuleCards.value.forEach((card, index) => {
card.pri = (index + 1).toString()
})
}
// 打开详情弹窗
function opengroupInfoDialog() {
groupInfo.value = cloneDeep(props.group)
if (props.group.rule_string) {
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
}
groupInfoDialog.value = true
}
// 保存详情数据
function saveGroupInfo() {
if (!groupInfo.value.name.trim()) {
$toast.error('规则组名称不能为空')
return
}
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
$toast.error(`规则组名称【${groupInfo.value.name}】已存在,请替换`)
return
}
groupInfoDialog.value = false
groupInfo.value.rule_string = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
emit('change', groupInfo.value, props.group.name)
emit('done')
}
// 按钮点击
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" @click="opengroupInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
<div class="text-body-1 mb-3">
<span v-if="!props.group.category">{{ props.group.media_type || '通用' }}</span>
<span v-else>{{ props.group.category }}</span>
</div>
</div>
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-model="groupInfoDialog" scrollable max-width="80rem" persistent>
<VCard :title="`${props.group.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="groupInfo.name"
label="规则组名称"
placeholder="必填;不可与其他规则组重名"
hint="自定义规则组名称"
persistent-hint
active
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
v-model="groupInfo.media_type"
label="适用媒体类型"
:items="mediaTypeItems"
hint="选择规则组适用的媒体类型"
persistent-hint
active
/>
</VCol>
<VCol cols="6" md="3">
<VSelect
v-model="groupInfo.category"
:items="getCategories"
label="适用媒体类别"
hint="选择规则组适用的媒体类别"
persistent-hint
active
/>
</VCol>
</VRow>
</VCardText>
<VCardText>
<draggable
v-model="filterRuleCards"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="dragOrderEnd"
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
>
<template #item="{ element }">
<FilterRuleCard
:pri="element.pri"
:maxpri="filterRuleCards.length.toString()"
:rules="element.rules"
:custom_rules="props.custom_rules"
@changed="updateFilterCardValue"
@close="filterCardClose(element.pri)"
/>
</template>
</draggable>
<div class="text-center" v-if="filterRuleCards.length == 0">请添加或导入规则</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn color="primary" variant="tonal" @click="addFilterCard">
<VIcon icon="mdi-plus" />
</VBtn>
<VBtn color="success" variant="tonal" @click="importRules">
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" variant="tonal" @click="shareRules">
<VIcon icon="mdi-share" />
</VBtn>
<VSpacer />
<VBtn @click="saveGroupInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5"> 确定 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<VDialog v-model="importCodeDialog" width="60rem" scrollable>
<ImportCodeDialog v-model="importCodeString" title="导入优先级规则" @close="importCodeDialog = false" />
</VDialog>
</div>
</template>

View File

@@ -171,9 +171,3 @@ onMounted(async () => {
</template>
</VHover>
</template>
<style lang="scss">
.text-shadow {
text-shadow: 1px 1px #777;
}
</style>

View File

@@ -19,6 +19,9 @@ const props = defineProps({
height: String,
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
const store = useStore()
// 提示框
@@ -366,17 +369,19 @@ onBeforeMount(() => {
const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}douban/img?imgurl=${encodeURIComponent(url)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
})
// 拼装季图片地址
function getSeasonPoster(posterPath: string) {
if (!posterPath) return ''
return `https://image.tmdb.org/t/p/w500${posterPath}`
return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`
}
// 将yyyy-mm-dd转换为yyyy年mm月dd日
@@ -385,12 +390,18 @@ function formatAirDate(airDate: string) {
const date = new Date(airDate.replaceAll(/-/g, '/'))
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
}
// 从yyyy-mm-dd中提取年份
function getYear(airDate: string) {
if (!airDate) return ''
const date = new Date(airDate.replaceAll(/-/g, '/'))
return date.getFullYear()
}
// 移除订阅
function onRemoveSubscribe() {
subscribeEditDialog.value = false
}
</script>
<template>
@@ -405,7 +416,7 @@ function getYear(airDate: string) {
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goMediaDetail(hover.isHovering)"
@click.stop="goMediaDetail(hover.isHovering ?? false)"
>
<VImg
aspect-ratio="2/3"
@@ -537,12 +548,7 @@ function getYear(airDate: string) {
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="
() => {
subscribeEditDialog = false
handleCheckSubscribe()
}
"
@remove="onRemoveSubscribe"
/>
</template>

View File

@@ -0,0 +1,349 @@
<script setup lang="ts">
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
import { useToast } from 'vue-toast-notification'
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 api from '@/api'
import { cloneDeep } from 'lodash'
// 定义输入
const props = defineProps({
// 单个媒体服务器
mediaserver: {
type: Object as PropType<MediaServerConf>,
required: true,
},
// 所有媒体服务器
mediaservers: {
type: Array as PropType<MediaServerConf[]>,
required: true,
},
})
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'done', 'change'])
// 媒体统计数据
const infoItems = ref([
{
avatar: 'mdi-movie-roll',
title: '电影',
amount: '0',
},
{
avatar: 'mdi-television-box',
title: '电视剧',
amount: '0',
},
{
avatar: 'mdi-account',
title: '用户',
amount: '0',
},
])
// 同步媒体库选项
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
{
title: '全部',
value: 'all',
},
])
// 媒体服务器详情弹窗
const mediaServerInfoDialog = ref(false)
// 媒体服务器详情
const mediaServerInfo = ref<MediaServerConf>({
name: '',
type: '',
enabled: false,
config: {},
})
// 打开详情弹窗
function openMediaServerInfoDialog() {
loadLibrary(props.mediaserver.name)
// 深复制
mediaServerInfo.value = cloneDeep(props.mediaserver)
mediaServerInfoDialog.value = true
if (!props.mediaserver.sync_libraries) {
mediaServerInfo.value.sync_libraries = ['all']
}
}
// 保存详情数据
function saveMediaServerInfo() {
// 为空不保存,跳出警告框
if (!mediaServerInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
return
}
// 重名判断
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
$toast.error(`${mediaServerInfo.value.name}】已存在,请替换为其他名称`)
return
}
// 执行保存
mediaServerInfoDialog.value = false
emit('change', mediaServerInfo.value, props.mediaserver.name)
emit('done')
}
// 根据存储类型选择图标
const getIcon = computed(() => {
switch (props.mediaserver.type) {
case 'emby':
return emby_image
case 'jellyfin':
return jellyfin_image
default:
return plex_image
}
})
// 按钮点击
function onClose() {
emit('close')
}
// 调用API加载媒体统计数据
async function loadMediaStatistic() {
try {
const res: MediaStatistic = await api.get('dashboard/statistic', {
params: {
name: props.mediaserver.name,
},
})
if (res) {
infoItems.value = [
{
avatar: 'mdi-movie-roll',
title: '电影',
amount: res.movie_count.toLocaleString(),
},
{
avatar: 'mdi-television-box',
title: '电视剧',
amount: res.tv_count.toLocaleString(),
},
{
avatar: 'mdi-account',
title: '用户',
amount: res.user_count.toLocaleString(),
},
]
}
} catch (e) {
console.log(e)
}
}
// 调用API查询媒体库
async function loadLibrary(server: string) {
try {
const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })
if (result && result.length > 0) {
librariesOptions.value = result.map(item => ({
title: item.name,
value: item.id?.toString(),
}))
} else {
librariesOptions.value = []
}
librariesOptions.value.unshift({
title: '全部',
value: 'all',
})
} catch (e) {
console.log(e)
}
}
onMounted(() => {
loadMediaStatistic()
})
</script>
<template>
<div>
<VCard variant="tonal" @click="openMediaServerInfoDialog">
<DialogCloseBtn @click="onClose" />
<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">
<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>
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
</VCardText>
</VCard>
<VDialog v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.mediaserver.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="mediaServerInfo.enabled" label="启用媒体服务器" />
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'emby'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
label="API密钥"
hint="Emby设置->高级->API密钥中生成的密钥"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'jellyfin'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
label="API密钥"
hint="Jellyfin设置->高级->API密钥中生成的密钥"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'plex'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
label="名称"
placeholder="必填;不可与其他名称重名"
hint="媒体服务器的别名"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
label="地址"
placeholder="http(s)://ip:port"
hint="服务端地址格式http(s)://ip:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.play_host"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="跳转播放页面使用的地址格式http(s)://domain:port"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.token"
label="X-Plex-Token"
hint="浏览器F12->网络从Plex请求URL中获取的X-Plex-Token"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VSelect
v-model="mediaServerInfo.sync_libraries"
label="同步媒体库"
:items="librariesOptions"
chips
multiple
clearable
hint="只有选中的媒体库才会被同步"
persistent-hint
active
append-inner-icon="mdi-refresh"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveMediaServerInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -22,8 +22,7 @@ async function imageLoaded() {
// 链接打开新窗口
function openLink() {
if (props.message?.link)
window.open(props.message.link, '_blank')
if (props.message?.link) window.open(props.message.link, '_blank')
}
// 将note转换为json
@@ -31,9 +30,8 @@ function noteToJson() {
if (props.message?.note) {
try {
return JSON.parse(props.message.note)
}
catch (error) {
console.error(error)
} catch (error) {
return props.message.note
}
}
return {}
@@ -41,23 +39,14 @@ function noteToJson() {
// 将\n转换为html属性的换行符
function replaceNewLine(value: string) {
if (!value)
return ''
if (!value) return ''
return value.replace(/\n/g, '<br/>')
}
</script>
<template>
<VCard
:width="props.width"
:height="props.height"
variant="tonal"
@click="openLink"
>
<div
v-if="props.message?.image"
class="relative text-center card-cover-blurred"
>
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink">
<div v-if="props.message?.image" class="relative text-center card-cover-blurred">
<VImg
:src="props.message?.image"
aspect-ratio="4/3"
@@ -67,28 +56,25 @@ function replaceNewLine(value: string) {
@error="imageLoadError = true"
/>
</div>
<VCardTitle v-if="props.message?.title" class="whitespace-break-spaces">
<div
v-if="props.message?.title && !props.message?.image && !props.message?.note"
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
>
<p class="mb-0">{{ props.message?.title }}</p>
</div>
<VCardTitle v-else-if="props.message?.title">
{{ props.message?.title }}
</VCardTitle>
<VAlert
<div
v-if="props.message?.text && props.message?.action === 0"
variant="tonal"
type="success"
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
>
<template #prepend />
{{ props.message?.text }}
</VAlert>
<VCardText
v-if="props.message?.text && props.message?.action === 1"
v-html="replaceNewLine(props.message?.text)"
/>
<p class="mb-0">{{ props.message?.text }}</p>
</div>
<VCardText v-if="props.message?.text && props.message?.action === 1" v-html="replaceNewLine(props.message?.text)" />
<VCardText v-if="props.message?.note">
<VList>
<VListItem
v-for="(value, key) in noteToJson()"
:key="key"
two-line
>
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
<VListItemTitle v-if="value.title_year" class="font-bold">
{{ key + 1 }}. {{ value.title_year }}
</VListItemTitle>
@@ -104,9 +90,11 @@ function replaceNewLine(value: string) {
</VListItem>
</VList>
</VCardText>
<div class="text-end">
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
<span class="text-sm italic me-2">{{ formatDateDifference(props.message?.reg_time || props.message?.date || '') }}</span>
</div>
</VCard>
<div class="text-end">
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
<span class="text-sm italic me-2">{{
formatDateDifference(props.message?.reg_time || props.message?.date || '')
}}</span>
</div>
</template>

View File

@@ -0,0 +1,401 @@
<script setup lang="ts">
import { NotificationConf } from '@/api/types'
import wechat_image from '@images/logos/wechat.png'
import telegram_image from '@images/logos/telegram.webp'
import vocechat_image from '@images/logos/vocechat.png'
import synologychat_image from '@images/logos/synologychat.png'
import slack_image from '@images/logos/slack.webp'
import chrome_image from '@images/logos/chrome.png'
import { useToast } from 'vue-toast-notification'
import { cloneDeep } from "lodash"
// 定义输入
const props = defineProps({
// 单个通知
notification: {
type: Object as PropType<NotificationConf>,
required: true,
},
// 所有通知
notifications: {
type: Array as PropType<NotificationConf[]>,
required: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 提示框
const $toast = useToast()
// 通知详情弹窗
const notificationInfoDialog = ref(false)
// 通知详情
const notificationInfo = ref<NotificationConf>({
name: '',
type: '',
enabled: false,
config: {},
})
// 各通知类型的名称字典
const notificationTypeNames: { [key: string]: string } = {
wechat: '企业微信',
telegram: 'Telegram',
vocechat: 'VoceChat',
synologychat: 'Synology Chat',
slack: 'Slack',
webpush: 'WebPush',
}
// 消息类型下拉字典
const notificationTypes = [
{ value: '资源下载', title: '资源下载' },
{ value: '整理入库', title: '整理入库' },
{ value: '订阅', title: '订阅' },
{ value: '站点', title: '站点' },
{ value: '媒体服务器', title: '媒体服务器' },
{ value: '手动处理', title: '手动处理' },
{ value: '插件', title: '插件' },
{ value: '其它', title: '其它' },
]
// 打开详情弹窗
function openNotificationInfoDialog() {
// 替换成深复制,避免修改时影响原数据
notificationInfo.value = cloneDeep(props.notification)
console.log(`当前卡片的通知信息:${JSON.stringify(notificationInfo.value)}`)
notificationInfoDialog.value = true
}
// 保存详情数据
function saveNotificationInfo() {
// 为空不保存,跳出警告框
if (!notificationInfo.value.name) {
$toast.error('名称不能为空,请输入后再确定')
return
}
// 重名判断
if (props.notifications.some(item => item.name === notificationInfo.value.name && item !== props.notification)) {
$toast.error(`通知渠道【${notificationInfo.value.name}】已存在,请替换`)
return
}
notificationInfoDialog.value = false
emit('change', notificationInfo.value, props.notification.name)
emit('done')
}
// 根据存储类型选择图标
const getIcon = computed(() => {
switch (props.notification.type) {
case 'wechat':
return wechat_image
case 'telegram':
return telegram_image
case 'vocechat':
return vocechat_image
case 'synologychat':
return synologychat_image
case 'slack':
return slack_image
case 'webpush':
return chrome_image
default:
return wechat_image
}
})
// 按钮点击
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" @click="openNotificationInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<DialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<div class="flex items-center">
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
<span class="text-h6">{{ props.notification.name }}</span>
</div>
<div class="text-body-1 mb-3">{{ notificationTypeNames[notification.type] }}</div>
</div>
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" />
</VCardText>
</VCard>
<VDialog v-model="notificationInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.notification.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="notificationInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="notificationInfo.enabled" label="启用通知" />
</VCol>
<VCol cols="12">
<VSelect
v-model="notificationInfo.switchs"
:items="notificationTypes"
label="消息类型"
hint="开启通知的消息类型"
multiple
clearable
chips
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'wechat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_CORPID"
label="企业ID"
hint="企业微信后台企业信息中的企业ID"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_ID"
label="应用 AgentId"
hint="企业微信自建应用的AgentId"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_SECRET"
label="应用 Secret"
hint="企业微信自建应用的Secret"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_PROXY"
label="代理地址"
hint="微信消息的转发代理地址2022年6月20日后创建的自建应用才需要不使用代理时需要保留默认值"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_TOKEN"
label="Token"
hint="微信企业自建应用->API接收消息配置中的Token"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
label="EncodingAESKey"
hint="微信企业自建应用->API接收消息配置中的EncodingAESKey"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'telegram'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_TOKEN"
label="Bot Token"
hint="Telegram机器人token格式123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_CHAT_ID"
label="Chat ID"
hint="接受消息通知的用户、群组或频道Chat ID"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_USERS"
label="用户白名单"
placeholder="多个用,分隔"
hint="可使用Telegram机器人的用户ID清单多个用户用,分隔,不填写则所有用户都能使用"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="可使用管理菜单及命令的用户ID列表多个ID使用,分隔"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'slack'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_OAUTH_TOKEN"
label="Slack Bot User OAuth Token"
placeholder="xoxb-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="Slack应用`OAuth & Permissions`页面中的`Bot User OAuth Token`"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_APP_TOKEN"
label="Slack App-Level Token"
placeholder="xapp-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="Slack应用`OAuth & Permissions`页面中的`App-Level Token`"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_CHANNEL"
label="频道名称"
placeholder="全体"
hint="消息发送频道,默认`全体`"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'synologychat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SYNOLOGYCHAT_WEBHOOK"
label="机器人传入URL"
hint="Synology Chat机器人传入URL"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SYNOLOGYCHAT_TOKEN"
label="令牌"
hint="Synology Chat机器人令牌"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'vocechat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_HOST"
label="地址"
hint="VoceChat服务端地址格式http(s)://ip:port"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_API_KEY"
label="机器人密钥"
hint="VoceChat机器人密钥"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_CHANNEL_ID"
label="频道ID"
placeholder="不包含#号"
hint="VoceChat的频道ID不包含#号"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'webpush'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
label="名称"
placeholder="别名"
hint="通知渠道的别名"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WEBPUSH_USERNAME"
label="登录用户名"
hint="只有对应的用户登录后才会推送消息"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveNotificationInfo" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
确定
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -9,6 +9,9 @@ const personProps = defineProps({
height: String,
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 当前人物
const personInfo = ref(personProps.person)
@@ -17,22 +20,26 @@ const isImageLoaded = ref(false)
// 人物图片地址
function getPersonImage() {
let url = ''
if (personProps.person?.source === 'themoviedb') {
if (!personInfo.value?.profile_path) return personIcon
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`
url = `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`
} else if (personProps.person?.source === 'douban') {
if (!personInfo.value?.avatar) return personIcon
if (typeof personInfo.value?.avatar === 'object') {
return personInfo.value?.avatar?.normal
url = personInfo.value?.avatar?.normal
} else {
return personInfo.value?.avatar
url = personInfo.value?.avatar
}
} else if (personProps.person?.source === 'bangumi') {
if (!personInfo.value?.images) return personIcon
return personInfo.value?.images?.medium
url = personInfo.value?.images?.medium
} else {
return personIcon
}
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}
// 人物姓名

View File

@@ -177,7 +177,7 @@ const dropdownItems = ref([
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
></div>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">

View File

@@ -10,7 +10,6 @@ import VersionHistory from '@/components/misc/VersionHistory.vue'
import { isNullOrEmptyObject } from '@core/utils'
import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image'
import store from '@/store'
import { useDisplay } from 'vuetify'
import ProgressDialog from '../dialog/ProgressDialog.vue'
@@ -62,6 +61,9 @@ const pluginInfoDialog = ref(false)
// 进度框文本
const progressText = ref('正在更新插件...')
// 用户头像是否加载完成
const isAvatarLoaded = ref(false)
// 插件数据页面配置项
let pluginPageItems = ref([])
@@ -216,11 +218,19 @@ const iconPath: Ref<string> = computed(() => {
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
// 插件作者头像路径
const authorPath: Ref<string> = computed(() => {
// 网络图片则使用代理后返回
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.author_url + '.png',
)}`
})
// 重置插件
async function resetPlugin() {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认重置插件 ${props.plugin?.plugin_name}配置数据?`,
content: `此操作将恢复插件 ${props.plugin?.plugin_name}默认设置,并清除所有相关数据,确定要继续吗?`,
})
if (!isConfirmed) return
@@ -277,10 +287,9 @@ function visitAuthorPage() {
// 查看日志URL
function openLoggerWindow() {
const token = store.state.auth.token
const url = `${
import.meta.env.VITE_API_BASE_URL
}system/logging?token=${token}&length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
window.open(url, '_blank')
}
@@ -411,7 +420,7 @@ watch(
>
<div
class="absolute inset-0 bg-cover bg-center"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.7)' }"
:style="{ background: `${backgroundColor}`, filter: 'brightness(0.5)' }"
/>
<div class="relative flex-1 min-w-0">
<VCardTitle class="text-white px-2 text-shadow whitespace-nowrap overflow-hidden text-ellipsis">
@@ -438,8 +447,10 @@ watch(
</div>
</div>
<VCardText class="flex flex-none align-self-baseline py-3 w-full align-end">
<span>
<VIcon icon="mdi-github" class="me-1" />
<span class="author-info">
<VImg :src="authorPath" class="author-avatar" @load="isAvatarLoaded = true">
<VIcon v-if="!isAvatarLoaded" icon="mdi-github" class="me-1" />
</VImg>
<a :href="props.plugin?.author_url" target="_blank" @click.stop>
{{ props.plugin?.plugin_author }}
</a>
@@ -515,4 +526,17 @@ watch(
content: '';
inset: 0;
}
.author-info {
display: flex;
align-items: center;
}
.author-avatar {
border-radius: 50%;
block-size: 24px;
inline-size: 24px;
margin-inline-end: 8px;
object-fit: cover;
}
</style>

View File

@@ -2,30 +2,24 @@
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
import SiteTorrentTable from '../table/SiteTorrentTable.vue'
import { requiredValidator } from '@/@validators'
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'
import SiteResourceDialog from '../dialog/SiteResourceDialog.vue'
import SiteCookieUpdateDialog from '../dialog/SiteCookieUpdateDialog.vue'
import api from '@/api'
import type { Site, SiteStatistic } from '@/api/types'
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useDisplay } from 'vuetify'
import ProgressDialog from '../dialog/ProgressDialog.vue'
// 显示器宽度
const display = useDisplay()
import { VCardActions, VExpandTransition, VProgressLinear, VSpacer } from 'vuetify/lib/components/index.mjs'
import { formatFileSize } from '@/@core/utils/formatters'
// 输入参数
const cardProps = defineProps({
site: Object as PropType<Site>,
width: String,
height: String,
data: Object as PropType<SiteUserData>,
})
// 定义触发的自定义事件
const emit = defineEmits(['update', 'remove'])
// 密码输入
const isPasswordVisible = ref(false)
// 图标
const siteIcon = ref<string>('')
@@ -38,9 +32,6 @@ const testButtonText = ref('测试')
// 测试按钮可用性
const testButtonDisable = ref(false)
// 更新按钮可用性
const updateButtonDisable = ref(false)
// 更新站点Cookie UA弹窗
const siteCookieDialog = ref(false)
@@ -50,18 +41,11 @@ const siteEditDialog = ref(false)
// 资源浏览弹窗
const resourceDialog = ref(false)
// 进度条
const progressDialog = ref(false)
// 用户数据弹窗
const siteUserDataDialog = ref(false)
// 进度文本
const progressText = ref('请稍候 ...')
// 用户名密码表单
const userPwForm = ref({
username: '',
password: '',
code: '',
})
// 站点操作显示
const siteActionShow = ref(false)
// 站点使用统计
const siteStats = ref<SiteStatistic>({})
@@ -113,34 +97,9 @@ async function handleResourceBrowse() {
resourceDialog.value = true
}
// 调用API更新站点Cookie UA
async function updateSiteCookie() {
try {
if (!userPwForm.value.username || !userPwForm.value.password) return
// 更新按钮状态
siteCookieDialog.value = false
updateButtonDisable.value = true
progressDialog.value = true
progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...`
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
params: {
username: userPwForm.value.username,
password: userPwForm.value.password,
code: userPwForm.value.code,
},
})
if (result.success) $toast.success(`${cardProps.site?.name} 更新Cookie & UA 成功!`)
else $toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
progressDialog.value = false
updateButtonDisable.value = false
} catch (error) {
console.error(error)
}
// 打开站点用户数据弹窗
async function handleSiteUserData() {
siteUserDataDialog.value = true
}
// 打开站点页面
@@ -162,9 +121,10 @@ const statColor = computed(() => {
}
})
// 监听resourceDialog如果为false则重新查询站点使用统计
watch(resourceDialog, value => {
if (!value) getSiteStats()
// 计算上传量和下载量的百分比
const getPercentage = computed(() => {
if (cardProps.data?.upload === 0) return 100
return ((cardProps.data?.download ?? 0) / ((cardProps.data?.download ?? 0) + (cardProps.data?.upload ?? 0))) * 100
})
// 保存站点
@@ -173,6 +133,18 @@ function saveSite() {
emit('update')
}
// 更新站点Cookie UA后的回调
function onSiteCookieUpdated() {
siteCookieDialog.value = false
getSiteStats()
}
// 资源浏览弹窗关闭后的回调
function onSiteResourceDone() {
resourceDialog.value = false
getSiteStats()
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
@@ -183,8 +155,6 @@ onMounted(() => {
<template>
<div>
<VCard
:height="cardProps.height"
:width="cardProps.width"
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
class="overflow-hidden"
@click="siteEditDialog = true"
@@ -194,7 +164,7 @@ onMounted(() => {
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VCardItem style="padding-block-end: 0;">
<VCardItem style="padding-block-end: 0">
<VCardTitle class="font-bold">
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
</VCardTitle>
@@ -202,7 +172,7 @@ onMounted(() => {
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
</VCardSubtitle>
</VCardItem>
<VCardText class="py-2" style="block-size: 36px;">
<VCardText class="py-1">
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
@@ -224,67 +194,61 @@ onMounted(() => {
</template>
</VTooltip>
</VCardText>
<VDivider />
<VCardActions>
<VBtn v-if="!cardProps.site?.public" :disabled="updateButtonDisable" @click.stop="handleSiteUpdate">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
更新
</VBtn>
<VBtn :disabled="testButtonDisable" @click.stop="testSite">
<template #prepend>
<VIcon icon="mdi-link" />
</template>
{{ testButtonText }}
</VBtn>
<VBtn @click.stop="handleResourceBrowse">
<template #prepend>
<VIcon icon="mdi-web" />
</template>
浏览
</VBtn>
<VBtn
:icon="siteActionShow ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click.stop="siteActionShow = !siteActionShow"
/>
<span class="text-sm">
{{ formatFileSize(cardProps.data?.upload || 0) }} / {{ formatFileSize(cardProps.data?.download || 0) }}
</span>
<VSpacer />
</VCardActions>
<VDivider class="mb-1" v-if="siteActionShow" />
<VExpandTransition>
<div v-show="siteActionShow" class="py-1 pe-12">
<VBtn v-if="!cardProps.site?.public" @click.stop="handleSiteUpdate" variant="text">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
更新
</VBtn>
<VBtn :disabled="testButtonDisable" @click.stop="testSite" variant="text">
<template #prepend>
<VIcon icon="mdi-link" />
</template>
{{ testButtonText }}
</VBtn>
<VBtn @click.stop="handleResourceBrowse" variant="text">
<template #prepend>
<VIcon icon="mdi-web" />
</template>
浏览
</VBtn>
<VBtn @click.stop="handleSiteUserData" variant="text">
<template #prepend>
<VIcon icon="mdi-chart-bell-curve" />
</template>
数据
</VBtn>
</div>
</VExpandTransition>
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
<span class="absolute top-1 right-8">
<VIcon class="cursor-move">mdi-drag</VIcon>
</span>
<div class="w-full absolute bottom-0" v-if="(cardProps.data?.upload || cardProps.data?.download || 0) > 0">
<VProgressLinear :model-value="getPercentage" bg-color="success" color="warning" bg-opacity="0.5" height="3" />
</div>
</VCard>
<!-- 更新站点Cookie & UA弹窗 -->
<VDialog v-model="siteCookieDialog" max-width="50rem">
<!-- Dialog Content -->
<VCard title="更新站点Cookie & UA">
<DialogCloseBtn @click="siteCookieDialog = false" />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="userPwForm.password"
label="密码"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
@keydown.enter="updateSiteCookie"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.code" label="两步验证" />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="updateSiteCookie" prepend-icon="mdi-refresh" class="px-5"> 开始更新 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<SiteCookieUpdateDialog
v-if="siteCookieDialog"
v-model="siteCookieDialog"
:site="cardProps.site"
@close="siteCookieDialog = false"
@done="onSiteCookieUpdated"
/>
<!-- 站点编辑弹窗 -->
<SiteAddEditDialog
v-if="siteEditDialog"
@@ -294,30 +258,19 @@ onMounted(() => {
@remove="emit('remove')"
@close="siteEditDialog = false"
/>
<!-- 站点数据弹窗 -->
<SiteUserDataDialog
v-if="siteUserDataDialog"
v-model="siteUserDataDialog"
:site="cardProps.site"
@close="siteUserDataDialog = false"
/>
<!-- 站点资源弹窗 -->
<VDialog
<SiteResourceDialog
v-if="resourceDialog"
v-model="resourceDialog"
max-width="80rem"
scrollable
z-index="1010"
:fullscreen="!display.mdAndUp.value"
>
<VCard :title="`浏览站点 - ${cardProps.site?.name}`">
<DialogCloseBtn @click="resourceDialog = false" />
<VDivider />
<VCardText class="pt-2">
<SiteTorrentTable :site="cardProps.site?.id" />
</VCardText>
</VCard>
</VDialog>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
:site="cardProps.site"
@close="onSiteResourceDone"
/>
</div>
</template>
<style lang="scss" scoped>
.v-table th {
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { StorageConf } from '@/api/types'
import { formatBytes } from '@core/utils/formatters'
import storage_png from '@images/misc/storage.png'
import alipan_png from '@images/misc/alipan.webp'
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 api from '@/api'
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
import RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'
import AlistConfigDialog from '../dialog/AlistConfigDialog.vue'
import { useToast } from 'vue-toast-notification'
import { isNullOrEmptyObject } from '@/@core/utils'
// 定义输入
const props = defineProps({
storage: {
type: Object as PropType<StorageConf>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['done'])
// 提示信息
const $toast = useToast()
// 存储总空间
const total = ref(0)
// 存储可用空间
const available = ref(0)
// 储存已用空间
const used = computed(() => {
return total.value - available.value
})
// 阿里云盘认证对话框
const aliyunAuthDialog = ref(false)
// 115网盘认证对话框
const u115AuthDialog = ref(false)
// Rclone配置对话框
const rcloneConfigDialog = ref(false)
// AList配置对话框
const aListConfigDialog = ref(false)
// 打开存储对话框
function openStorageDialog() {
switch (props.storage.type) {
case 'alipan':
aliyunAuthDialog.value = true
break
case 'u115':
u115AuthDialog.value = true
break
case 'rclone':
rcloneConfigDialog.value = true
break
case 'alist':
aListConfigDialog.value = true
break
default:
$toast.info('此存储类型无需配置参数,请直接配置目录!')
break
}
}
// 根据存储类型选择图标
const getIcon = computed(() => {
switch (props.storage.type) {
case 'local':
return storage_png
case 'alipan':
return alipan_png
case 'u115':
return u115_png
case 'rclone':
return rclone_png
case 'alist':
return alist_png
default:
return storage_png
}
})
// 计算进度条颜色
const progressColor = computed(() => {
if (usage.value > 90) {
return 'error'
} else if (usage.value > 70) {
return 'warning'
} else {
return 'success'
}
})
// 计算存储使用率
const usage = computed(() => {
return Math.round((used.value / (total.value || 1)) * 1000) / 10
})
// 查询存储信息
async function queryStorage() {
try {
const data: { total: number; available: number } = await api.get(`storage/usage/${props.storage.type}`)
total.value = data.total
available.value = data.available
} catch (error) {
console.error(error)
}
}
// 完成配置后的处理
function handleDone() {
aliyunAuthDialog.value = false
u115AuthDialog.value = false
rcloneConfigDialog.value = false
aListConfigDialog.value = false
emit('done')
}
onMounted(() => {
queryStorage()
})
</script>
<template>
<VCard variant="tonal" @click="openStorageDialog">
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">未配置</div>
</div>
<VImg :src="getIcon" cover class="mt-5" 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" />
</div>
</VCard>
<AliyunAuthDialog
v-if="aliyunAuthDialog"
v-model="aliyunAuthDialog"
@close="aliyunAuthDialog = false"
@done="handleDone"
/>
<U115AuthDialog v-if="u115AuthDialog" v-model="u115AuthDialog" @close="u115AuthDialog = false" @done="handleDone" />
<RcloneConfigDialog
v-if="rcloneConfigDialog"
v-model="rcloneConfigDialog"
:conf="props.storage.config || {}"
@close="rcloneConfigDialog = false"
@done="handleDone"
/>
<AlistConfigDialog
v-if="aListConfigDialog"
v-model="aListConfigDialog"
:conf="props.storage.config || {}"
@close="aListConfigDialog = false"
@done="handleDone"
/>
</template>

View File

@@ -2,8 +2,9 @@
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import { formatDateDifference } from '@/@core/utils/formatters'
import { formatSeason } from '@/@core/utils/formatters'
import SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue'
import SubscribeShareDialog from '../dialog/SubscribeShareDialog.vue'
import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
import api from '@/api'
import type { Subscribe } from '@/api/types'
import router from '@/router'
@@ -13,6 +14,9 @@ const props = defineProps({
media: Object as PropType<Subscribe>,
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
@@ -28,6 +32,12 @@ const imageLoaded = ref(false)
// 订阅弹窗
const subscribeEditDialog = ref(false)
// 订阅文件信息弹窗
const subscribeFilesDialog = ref(false)
// 分享订阅弹窗
const subscribeShareDialog = ref(false)
// 上一次更新时间
const lastUpdateText = ref(props.media && props.media.last_update ? formatDateDifference(props.media.last_update) : '')
@@ -36,13 +46,6 @@ function imageLoadHandler() {
imageLoaded.value = true
}
// 根据 type 返回不同的图标
function getIcon() {
if (props.media?.type === '电影') return 'mdi-movie-open'
else if (props.media?.type === '电视剧') return 'mdi-television-play'
else return 'mdi-help-circle'
}
// 计算百分比
function getPercentage() {
if (props.media?.total_episode === 0) return 0
@@ -99,12 +102,17 @@ async function resetSubscribe() {
}
}
// 分享订阅
async function shareSubscribe() {
subscribeShareDialog.value = true
}
// 编辑订阅响应
async function editSubscribeDialog() {
subscribeEditDialog.value = true
}
// 查看详情
// 查看媒体详情
async function viewMediaDetail() {
router.push({
path: '/media',
@@ -115,6 +123,11 @@ async function viewMediaDetail() {
})
}
// 查看文件详情
async function viewSubscribeFiles() {
subscribeFilesDialog.value = true
}
// 弹出菜单
const dropdownItems = ref([
{
@@ -134,16 +147,24 @@ const dropdownItems = ref([
},
},
{
title: '查看详情',
title: '详情',
value: 3,
props: {
prependIcon: 'mdi-open-in-new',
prependIcon: 'mdi-information-outline',
click: viewMediaDetail,
},
},
{
title: '重置',
title: '文件',
value: 4,
props: {
prependIcon: 'mdi-file-document-outline',
click: viewSubscribeFiles,
},
},
{
title: '重置',
value: 5,
props: {
prependIcon: 'mdi-restore-alert',
click: resetSubscribe,
@@ -151,9 +172,19 @@ const dropdownItems = ref([
},
show: props.media?.type === '电视剧',
},
{
title: '分享',
value: 6,
props: {
prependIcon: 'mdi-share',
click: shareSubscribe,
color: 'success',
},
show: props.media?.type === '电视剧',
},
{
title: '取消订阅',
value: 5,
value: 7,
props: {
prependIcon: 'mdi-trash-can-outline',
color: 'error',
@@ -169,6 +200,36 @@ watch(
if (newOpenState) editSubscribeDialog()
},
)
// 计算backdrop图片地址
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 订阅编辑保存
function onSubscribeEditSave() {
subscribeEditDialog.value = false
emit('save')
}
// 订阅编辑取消
function onSubscribeEditRemove() {
subscribeEditDialog.value = false
emit('remove')
}
</script>
<template>
@@ -208,13 +269,7 @@ watch(
</IconBtn>
</div>
<template #image>
<VImg
:src="props.media?.backdrop || props.media?.poster"
aspect-ratio="3/2"
cover
@load="imageLoadHandler"
position="top"
>
<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" />
@@ -226,7 +281,7 @@ watch(
<div>
<VCardText class="flex items-center">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" v-if="imageLoaded">
<VImg :src="props.media?.poster" aspect-ratio="2/3" cover @click.stop="viewMediaDetail">
<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" />
@@ -282,20 +337,25 @@ watch(
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="props.media?.id"
@remove="
() => {
emit('remove')
subscribeEditDialog = false
}
"
@save="
() => {
emit('save')
subscribeEditDialog = false
}
"
@remove="onSubscribeEditRemove"
@save="onSubscribeEditSave"
@close="subscribeEditDialog = false"
/>
<!-- 订阅文件信息弹窗 -->
<SubscribeFilesDialog
v-if="subscribeFilesDialog"
v-model="subscribeFilesDialog"
:subid="props.media?.id"
@close="subscribeFilesDialog = false"
/>
<!-- 分享订阅弹窗 -->
<SubscribeShareDialog
v-if="subscribeShareDialog"
v-model="subscribeShareDialog"
:sub="props.media"
@close="subscribeShareDialog = false"
/>
</template>
<style lang="scss">
.subscribe-card-background {

View File

@@ -0,0 +1,180 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { SubscribeShare } from '@/api/types'
import router from '@/router'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
// 输入参数
const props = defineProps({
media: Object as PropType<SubscribeShare>,
})
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 图片是否加载完成
const imageLoaded = ref(false)
// 订阅编辑弹窗
const subscribeEditDialog = ref(false)
// 订阅ID
const subscribeId = ref<number>()
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 分享时间
const dateText = ref(props.media && props.media?.date ? formatDateDifference(props.media.date) : '')
// 计算backdrop图片地址
const backdropUrl = computed(() => {
const url = props.media?.backdrop || props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 计算海报图片地址
const posterUrl = computed(() => {
const url = props.media?.poster
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 查看媒体详情
async function viewMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: `${props.media?.tmdbid ? `tmdb:${props.media?.tmdbid}` : `douban:${props.media?.doubanid}`}`,
type: props.media?.type,
},
})
}
// 复用订阅
async function forkSubscribe() {
// 开始处理
startNProgress()
try {
// 确认
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认添加来自 ${props.media?.share_user} 分享的订阅:${props.media?.share_title}`,
})
if (!isConfirmed) return
// 请求API
const result: { [key: string]: any } = await api.post('subscribe/fork', props.media)
// 订阅状态
if (result.success) {
$toast.success(`${props.media?.share_title} 添加订阅成功!`)
// 弹出订阅编辑弹窗
subscribeId.value = result.data.id
subscribeEditDialog.value = true
} else {
$toast.error(`${props.media?.share_title} 添加订阅失败:${result.message}`)
}
} catch (error) {
console.error(error)
} finally {
doneNProgress()
}
}
</script>
<template>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
}"
min-height="170"
@click="forkSubscribe"
>
<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>
<VCardText class="flex items-center pb-1">
<div class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md shadow-lg" 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>
<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>
</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="error" 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>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="subscribeEditDialog = false"
/>
</template>
<style lang="scss">
.subscribe-card-background {
background-image: linear-gradient(90deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
</style>

View File

@@ -1,11 +1,10 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
import type { Context } from '@/api/types'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数
const props = defineProps({
@@ -15,12 +14,6 @@ const props = defineProps({
height: String,
})
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 更多来源界面
const showMoreTorrents = ref(false)
@@ -33,11 +26,29 @@ const media = ref(props.torrent?.media_info)
// 识别元数据
const meta = ref(props.torrent?.meta_info)
// 当前下载项
const downloadItem = ref(props.torrent)
// 站点图标
const siteIcon = ref('')
// 存储是否已经下载过的记录
const downloaded = ref<String[]>([])
const downloaded = ref<string[]>([])
// 添加下载对话框
const addDownloadDialog = ref(false)
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
// 添加下载成功
downloaded.value.push(url)
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
}
// 查询站点图标
async function getSiteIcon() {
@@ -49,50 +60,12 @@ async function getSiteIcon() {
}
// 询问并添加下载
async function handleAddDownload(_site: any = undefined, _media: any = undefined, _torrent: any = undefined) {
if (!_media || !_torrent || !_site) {
_site = torrent.value?.site_name
_media = media.value
_torrent = torrent.value
async function handleAddDownload(item: Context | null = null) {
if (item && !isNullOrEmptyObject(item)) {
downloadItem.value = item
}
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认下载【${_site}${_torrent?.title} ?`,
})
if (!isConfirmed) return
addDownload(_media, _torrent)
}
// 添加下载
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress()
try {
let result: { [key: string]: any }
if (_media) {
result = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
} else {
result = await api.post('download/add', _torrent)
}
if (result && result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
downloaded.value.push(_torrent?.enclosure || '')
} else {
// 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败:${result?.message}`)
}
} catch (error) {
console.error(error)
}
doneNProgress()
// 打开下载对话框
addDownloadDialog.value = true
}
// 打开种子详情页面
@@ -120,127 +93,137 @@ onMounted(() => {
</script>
<template>
<VCard
:width="props.width"
:height="props.height"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
@click="handleAddDownload"
>
<template v-if="!showMoreTorrents" #image>
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VCardItem class="py-1">
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VCardTitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail()">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<div>
<VCard
:width="props.width"
:height="props.height"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
@click="handleAddDownload(props.torrent)"
>
<template v-if="!showMoreTorrents" #image>
<VAvatar class="absolute right-2 bottom-2 rounded" variant="flat" rounded="0">
<VImg :src="siteIcon" />
</VAvatar>
</template>
</VCardItem>
<VCardText class="text-subtitle-2">
{{ torrent?.title }}
</VCardText>
<VCardText>{{ torrent?.description }}</VCardText>
<VCardItem v-if="torrent?.labels" class="pb-3 pt-0 pe-12">
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.edition }}
</VChip>
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.resource_pix }}
</VChip>
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
{{ meta?.video_encode }}
</VChip>
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
{{ meta?.resource_team }}
</VChip>
<VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ torrent?.volume_factor }}
</VChip>
</VCardItem>
<VCardActions>
<VBtn v-if="props.more && props.more.length > 0" @click.stop="showMoreTorrents = !showMoreTorrents">
<VCardItem class="py-1">
<VCardTitle class="break-words overflow-visible whitespace-break-spaces">
{{ media?.title ?? meta?.name }} {{ meta?.season_episode }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VCardTitle>
<template #append>
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail()">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
更多来源
</VBtn>
</VCardActions>
<VExpandTransition>
<div v-show="showMoreTorrents">
<VDivider />
<VChipGroup class="p-3" column>
<VChip
v-for="(item, index) in props.more"
:key="index"
@click.stop="handleAddDownload(item.torrent_info?.site_name, item.media_info, item.torrent_info)"
>
<template #append>
<VBadge color="primary" :content="`↑${item.torrent_info?.seeders}`" inline size="small" />
<VBadge
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
:content="item.torrent_info?.volume_factor"
inline
size="small"
/>
</template>
{{ item.torrent_info.site_name }}
</VChip>
</VChipGroup>
</div>
</VExpandTransition>
</VCard>
</VCardItem>
<VCardText class="text-subtitle-2">
{{ torrent?.title }}
</VCardText>
<VCardText>{{ torrent?.site_name }}{{ torrent?.description }}</VCardText>
<VCardItem v-if="torrent?.labels" class="pb-3 pt-0 pe-12">
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.edition }}
</VChip>
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.resource_pix }}
</VChip>
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
{{ meta?.video_encode }}
</VChip>
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
{{ meta?.resource_team }}
</VChip>
<VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ torrent?.volume_factor }}
</VChip>
</VCardItem>
<VCardActions>
<VBtn v-if="props.more && props.more.length > 0" @click.stop="showMoreTorrents = !showMoreTorrents">
<template #append>
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" />
</template>
更多来源
</VBtn>
</VCardActions>
<VExpandTransition>
<div v-show="showMoreTorrents">
<VDivider />
<VChipGroup class="p-3" column>
<VChip v-for="(item, index) in props.more" :key="index" @click.stop="handleAddDownload(item)">
<template #append>
<VBadge color="primary" :content="`↑${item.torrent_info?.seeders}`" inline size="small" />
<VBadge
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
:content="item.torrent_info?.volume_factor"
inline
size="small"
/>
</template>
{{ item.torrent_info.site_name }}
</VChip>
</VChipGroup>
</div>
</VExpandTransition>
</VCard>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:title="`${downloadItem?.media_info?.title_year || downloadItem?.meta_info?.name} ${
downloadItem?.meta_info?.season_episode
}`"
:media="downloadItem?.media_info"
:torrent="downloadItem?.torrent_info"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</div>
</template>

View File

@@ -1,23 +1,15 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
import type { Context } from '@/api/types'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
// 输入参数
const props = defineProps({
torrent: Object as PropType<Context>,
})
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 更多来源界面
const showMoreTorrents = ref(false)
@@ -34,7 +26,10 @@ const meta = ref(props.torrent?.meta_info)
const siteIcon = ref('')
// 存储是否已经下载过的记录
const downloaded = ref<String[]>([])
const downloaded = ref<string[]>([])
// 添加下载对话框
const addDownloadDialog = ref(false)
// 查询站点图标
async function getSiteIcon() {
@@ -46,50 +41,21 @@ async function getSiteIcon() {
}
// 询问并添加下载
async function handleAddDownload(_site: any = undefined, _media: any = undefined, _torrent: any = undefined) {
if (!_media || !_torrent || !_site) {
_site = torrent.value?.site_name
_media = media.value
_torrent = torrent.value
}
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认下载【${_site}${_torrent?.title} ?`,
})
if (!isConfirmed) return
addDownload(_media, _torrent)
async function handleAddDownload() {
// 打开下载对话框
addDownloadDialog.value = true
}
// 添加下载
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress()
try {
let result: { [key: string]: any }
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
// 添加下载成功
downloaded.value.push(url)
}
if (_media) {
result = await api.post('download/', {
media_in: _media,
torrent_in: _torrent,
})
} else {
result = await api.post('download/add', _torrent)
}
if (result && result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 下载成功!`)
downloaded.value.push(_torrent?.enclosure || '')
} else {
// 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 下载失败:${result?.message}`)
}
} catch (error) {
console.error(error)
}
doneNProgress()
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
}
// 打开种子详情页面
@@ -117,88 +83,101 @@ onMounted(() => {
</script>
<template>
<VListItem @click="handleAddDownload" :variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'">
<template v-if="!showMoreTorrents" #prepend>
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
{{ torrent?.title }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
<VListItemSubtitle>
{{ torrent?.description }}
</VListItemSubtitle>
<div v-if="torrent?.labels" class="pt-2">
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.edition }}
</VChip>
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.resource_pix }}
</VChip>
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
{{ meta?.video_encode }}
</VChip>
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
{{ meta?.resource_team }}
</VChip>
<VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ torrent?.volume_factor }}
</VChip>
</div>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail()">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
<div>
<VListItem
@click="handleAddDownload"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
>
<template v-if="!showMoreTorrents" #prepend>
<VAvatar class="rounded" variant="flat" @click.stop="openTorrentDetail">
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VListItemTitle class="break-words overflow-visible whitespace-break-spaces">
{{ torrent?.title }}
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
<VListItemSubtitle> {{ torrent?.site_name }}{{ torrent?.description }} </VListItemSubtitle>
<div v-if="torrent?.labels" class="pt-2">
<VChip v-if="torrent?.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="torrent?.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ torrent?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in torrent?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip v-if="meta?.edition" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.edition }}
</VChip>
<VChip v-if="meta?.resource_pix" variant="elevated" size="small" class="me-1 mb-1 text-white bg-red-500">
{{ meta?.resource_pix }}
</VChip>
<VChip v-if="meta?.video_encode" variant="elevated" size="small" class="me-1 mb-1 text-white bg-orange-500">
{{ meta?.video_encode }}
</VChip>
<VChip v-if="torrent?.size" variant="elevated" size="small" class="me-1 mb-1 text-white bg-yellow-500">
{{ formatFileSize(torrent?.size) }}
</VChip>
<VChip v-if="meta?.resource_team" variant="elevated" size="small" class="me-1 mb-1 text-white bg-cyan-500">
{{ meta?.resource_team }}
</VChip>
<VChip
v-if="torrent?.downloadvolumefactor !== 1 || torrent?.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(torrent?.downloadvolumefactor, torrent?.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ torrent?.volume_factor }}
</VChip>
</div>
</template>
</VListItem>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail()">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="props.torrent?.torrent_info?.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile()"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:title="`${media?.title_year || meta?.name} ${meta?.season_episode}`"
:media="media"
:torrent="torrent"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</div>
</template>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import api from '@/api'
import { Subscribe, User } from '@/api/types'
import store from '@/store'
import avatar1 from '@images/avatars/avatar-1.png'
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
// 定义输入变量
const props = defineProps({
// 用户信息
user: {
type: Object as PropType<User>,
required: true,
},
// 所有用户
users: {
type: Array as PropType<User[]>,
required: true,
},
})
// 当前用户的ID
const currentLoginUserId = computed(() => store.state.auth.userID)
// 当前用户是否是管理员
const currentUserIsSuperuser = computed(() => store.state.auth.superUser)
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
// 确认框
const createConfirm = useConfirm()
// 用户信息弹窗
const userEditDialog = ref(false)
// 提示框
const $toast = useToast()
// 用户电影订阅数量
const movieSubscriptions = ref(0)
// 用户电视剧订阅数量
const tvShowSubscriptions = ref(0)
// 按用户查询订阅数量
async function fetchSubscriptions() {
try {
const result: Subscribe[] = await api.get(`subscribe/user/${props.user.name}`)
if (result) {
movieSubscriptions.value = result.filter(item => item.type === '电影').length
tvShowSubscriptions.value = result.filter(item => item.type === '电视剧').length
}
} catch (error) {
console.log(error)
}
}
// 删除用户
async function removeUser() {
if (props.user.id === currentLoginUserId.value) {
$toast.error('不能删除当前登录用户!')
return
}
try {
const isConfirmed = await createConfirm({
title: '注意',
content: `删除用户 ${props.user?.name} 的所有数据,是否确认?`,
})
if (!isConfirmed) return
const result: { [key: string]: any } = await api.delete(`user/id/${props.user.id}`)
if (result.success) {
$toast.success('用户删除成功')
emit('remove')
} else {
$toast.error('用户删除失败!')
}
} catch (error) {
console.log(error)
}
}
// 编辑用户
function editUser() {
userEditDialog.value = true
}
// 用户重新完成时
function onUserUpdate() {
userEditDialog.value = false
emit('save')
}
onMounted(() => {
fetchSubscriptions()
})
</script>
<template>
<VCard>
<VCardText class="text-center pt-10 pb-3">
<VAvatar variant="flat" size="100" rounded>
<VImg :src="user.avatar || avatar1" alt="avatar" />
</VAvatar>
<h5 class="text-h5 mt-3">{{ user.name }}</h5>
<VChip size="small" class="mt-3" :class="{ 'text-error': user.is_superuser }">
{{ user.is_superuser ? '管理员' : '普通用户' }}
</VChip>
</VCardText>
<VCardText class="flex justify-center gap-6 pb-5">
<div class="d-flex align-center">
<VAvatar size="40" color="primary" rounded variant="tonal" class="me-4">
<VIcon size="24" icon="mdi-movie-open-outline"></VIcon>
</VAvatar>
<div>
<div class="text-h6">{{ movieSubscriptions }}</div>
<div class="text-sm text-no-wrap">电影订阅</div>
</div>
</div>
<div class="d-flex align-center">
<VAvatar size="40" color="primary" rounded variant="tonal" class="me-4">
<VIcon size="24" icon="mdi-television"></VIcon>
</VAvatar>
<div>
<div class="text-h6">{{ tvShowSubscriptions }}</div>
<div class="text-sm text-no-wrap">电视剧订阅</div>
</div>
</div>
</VCardText>
<VCardText class="pb-6">
<VDivider class="my-2">
<h5 class="text-h6">详情</h5>
</VDivider>
<VList lines="one">
<VListItem>
<VListItemTitle class="text-sm">
<span class="font-weight-medium">邮箱</span><span class="text-body-1"> {{ user.email }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<VListItemTitle class="text-sm">
<span class="font-weight-medium">状态</span
><span class="text-body-1">
<VChip size="small" :class="{ 'text-success': user.is_active }" variant="tonal">
{{ user.is_active ? '激活' : '已停用' }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
<VListItem>
<VListItemTitle class="text-sm">
<span class="font-weight-medium">双重认证</span
><span class="text-body-1">
<VChip size="small" :class="{ 'text-success': user.is_otp }" variant="tonal">
{{ user.is_otp ? '已启用' : '未启用' }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
<VCardText class="flex flex-row justify-center">
<VBtn
v-if="currentUserIsSuperuser"
color="primary"
class="me-4"
@click="editUser"
>
编辑
</VBtn>
<VBtn
v-if="currentUserIsSuperuser && props.user.id != currentLoginUserId"
color="error"
variant="outlined"
@click="removeUser"
>
删除
</VBtn>
</VCardText>
</VCard>
<!-- 用户编辑弹窗 -->
<UserAddEditDialog
v-if="userEditDialog"
v-model="userEditDialog"
:username="props.user?.name"
:usernames="props.users.map(item => item.name)"
oper="edit"
@save="onUserUpdate"
@close="userEditDialog = false"
/>
</template>

View File

@@ -0,0 +1,198 @@
<script setup lang="ts">
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'
import { formatFileSize } from '@/@core/utils/formatters'
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
// 输入参数
const props = defineProps({
title: String,
media: Object as PropType<MediaInfo>,
torrent: Object as PropType<TorrentInfo>,
})
// 提示框
const $toast = useToast()
// 选择的下载器
const selectedDownloader = ref<string | null>(null)
// 选择的保存目录
const selectedDirectory = ref<string | null>(null)
// 定义成功和失败事件
const emit = defineEmits(['done', 'error', 'close'])
// 所有目录设置
const directories = ref<TransferDirectoryConf[]>([])
// 是否正在加载
const loading = ref(false)
// 计算按钮图标
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
// 计算按钮文字
const buttonText = computed(() => (loading.value ? '下载中...' : '开始下载'))
// 加载目录设置
async function loadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Directories')
directories.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 获取保存目录
const targetDirectories = computed(() => {
const downloadDirectories = directories.value.map(item => item.download_path)
return [...new Set(downloadDirectories)]
})
// 下载器
const downloaders = ref<DownloaderConf[]>([])
// 调用API查询下载器设置
async function loadDownloaderSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Downloaders')
downloaders.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 下载器可选项
const downloaderOptions = computed(() => {
return downloaders.value.map(item => ({
title: item.name,
value: item.name,
}))
})
// 添加下载
async function addDownload() {
startNProgress()
loading.value = true
try {
let result: { [key: string]: any }
const payload: any = {
torrent_in: props.torrent,
downloader: selectedDownloader.value,
save_path: selectedDirectory.value,
}
if (props.media) {
payload.media_in = props.media
}
const endpoint = props.media ? 'download/' : 'download/add'
result = await api.post(endpoint, payload)
if (result && result.success) {
// 添加下载成功
$toast.success(`${props.torrent?.site_name} ${props.torrent?.title} 下载成功!`)
// 下载成功,返回链接
emit('done', props.torrent?.enclosure)
} else {
// 添加下载失败
$toast.error(`${props.torrent?.site_name} ${props.torrent?.title} 下载失败:${result?.message}`)
// 下载失败,返回错误原因
emit('error', result?.message)
}
} catch (error) {
console.error(error)
}
loading.value = false
doneNProgress()
}
onMounted(() => {
loadDirectories()
loadDownloaderSetting()
})
</script>
<template>
<VDialog max-width="45rem" scrollable>
<VCard>
<VCardItem>
<VCardTitle v-if="title">{{ torrent?.site_name }} - {{ title }}</VCardTitle>
<VCardTitle v-else>确认下载</VCardTitle>
<DialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText>
<VList lines="one">
<VListItem>
<template #prepend>
<VIcon icon="mdi-web"></VIcon>
</template>
<VListItemTitle>
<span class="whitespace-break-spaces me-2">{{ torrent?.title }}</span>
<span class="text-green-700 ms-2 text-sm">{{ torrent?.seeders }}</span>
<span class="text-orange-700 ms-2 text-sm">{{ torrent?.peers }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon icon="mdi-subtitles-outline"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-1 whitespace-break-spaces">{{ torrent?.description }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon icon="mdi-database"></VIcon>
</template>
<VListItemTitle>
<span class="text-body-1">
<VChip variant="tonal" label>
{{ formatFileSize(torrent?.size || 0) }}
</VChip>
</span>
</VListItemTitle>
</VListItem>
</VList>
<VRow>
<VCol cols="12" md="4">
<VSelect
v-model="selectedDownloader"
:items="downloaderOptions"
label="指定下载器"
variant="underlined"
placeholder="留空默认"
/>
</VCol>
<VCol cols="12" md="8">
<VCombobox
v-model="selectedDirectory"
:items="targetDirectories"
label="指定保存目录"
placeholder="留空自动匹配"
variant="underlined"
/>
</VCol>
</VRow>
</VCardText>
<VCardText class="text-center">
<VBtn
variant="elevated"
:disabled="loading"
@click="addDownload"
:prepend-icon="icon"
class="px-5"
size="large"
>
{{ buttonText }}
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,60 @@
<script lang="ts" setup>
import api from '@/api'
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['done', 'close'])
// 完成
async function handleDone() {
await savaAlistConfig()
emit('done')
}
// 保存rclone设置
async function savaAlistConfig() {
try {
await api.post(`storage/save/alist`, props.conf)
} catch (e) {
console.error(e)
}
}
</script>
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="AList配置" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="props.conf.url" hint="AList服务地址" label="地址" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="props.conf.username" hint="AList登录用户名" label="用户名" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="props.conf.password"
hint="AList登录密码"
label="密码"
persistent-hint
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -31,7 +31,7 @@ async function handleDone() {
// 调用/aliyun/qrcode api生成二维码
async function getQrcode() {
try {
const result: { [key: string]: any } = await api.get('/aliyun/qrcode')
const result: { [key: string]: any } = await api.get('/storage/qrcode/alipan')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
ck.value = result.data.ck
@@ -47,11 +47,8 @@ async function getQrcode() {
// 调用/aliyun/check api验证二维码
async function checkQrcode() {
try {
const result: { [key: string]: any } = await api.get('/aliyun/check', {
params: {
ck: ck.value,
t: t.value,
},
const result: { [key: string]: any } = await api.get('/storage/check/alipan', {
params: { ck: ck.value, t: t.value },
})
if (result.success && result.data) {
const qrCodeStatus = result.data.qrCodeStatus

View File

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

View File

@@ -0,0 +1,68 @@
<script lang="ts" setup>
import api from '@/api'
import { useToast } from 'vue-toast-notification'
// 输入参数
const props = defineProps({
title: String,
})
const $toast = useToast()
// 插件仓库设置字符串
const repoString = ref('')
// 定义事件
const emit = defineEmits(['save', 'close'])
// 查询已设置的插件仓库
async function queryMarketRepoSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
if (result && result.data && result.data.value) repoString.value = result.data.value
} catch (error) {
console.log(error)
}
}
// 保存设置
async function saveHandle() {
try {
// 用户名密码
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoString.value)
if (result.success) {
$toast.success('插件仓库保存成功')
emit('save')
} else $toast.error('插件仓库保存失败!')
} catch (error) {
console.log(error)
}
}
onMounted(() => {
queryMarketRepoSetting()
})
</script>
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="插件仓库设置" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">
<VTextarea
v-model="repoString"
placeholder="格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/"
hint="多个地址使用逗号分隔仅支持Github仓库"
persistent-hint
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="saveHandle" prepend-icon="mdi-content-save-check" class="px-5 me-3">
保存
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,66 @@
<script lang="ts" setup>
import api from '@/api'
// 定义输入
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
})
if (!props.conf.filepath) {
props.conf.filepath = '/moviepilot/.config/rclone/rclone.conf'
}
if (!props.conf.content) {
props.conf.content = '# 请在此处填写rclone配置文件内容 \n# 请参考 https://rclone.org/docs/ \n# 存储节点名必须为MP'
}
// 定义事件
const emit = defineEmits(['done', 'close'])
// 完成
async function handleDone() {
await savaRcloneConfig()
emit('done')
}
// 保存rclone设置
async function savaRcloneConfig() {
try {
await api.post(`storage/save/rclone`, props.conf)
} catch (e) {
console.error(e)
}
}
</script>
<template>
<VDialog width="50rem" scrollable max-height="85vh">
<VCard title="RClone配置" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField v-model="props.conf.filepath" label="rclone配置文件路径" />
</VCol>
<VCol cols="12">
<VAceEditor
v-model:value="props.conf.content"
lang="ini"
theme="monokai"
style="block-size: 30rem"
class="rounded"
>
</VAceEditor>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3"> 完成 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -1,27 +1,30 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import store from '@/store'
import api from '@/api'
import { storageOptions } from '@/api/constants'
import { numberValidator } from '@/@validators'
import { useDisplay } from 'vuetify'
import ProgressDialog from './ProgressDialog.vue'
import { FileItem, MediaDirectory } from '@/api/types'
import { FileItem, TransferDirectoryConf } from '@/api/types'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
storage: {
type: String,
default: () => 'local',
},
logids: Array<number>,
items: Array<FileItem>,
target: String,
target_storage: String,
target_path: String,
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 当前识别类型
const mediaSource = ref(globalSettings.data?.RECOGNIZE_SOURCE || 'themoviedb')
// 定义事件
const emit = defineEmits(['done', 'close'])
@@ -33,9 +36,6 @@ const seasonItems = ref(
})),
)
// 当前识别类型
const mediaSource = ref('themoviedb')
// 提示框
const $toast = useToast()
@@ -67,13 +67,10 @@ const dialogTitle = computed(() => {
// 表单
const transferForm = reactive({
storage: props.storage,
fileitem: {},
logid: 0,
path: '',
drive_id: '',
fileid: '',
filetype: '',
target: props.target ?? null,
target_storage: props.target_storage ?? 'local',
target_path: props.target_path ?? null,
tmdbid: null,
doubanid: null,
season: null,
@@ -85,23 +82,35 @@ const transferForm = reactive({
episode_offset: null,
min_filesize: 0,
scrape: false,
from_history: false,
})
// 所有媒体库目录
const libraryDirectories = ref<MediaDirectory[]>([])
const directories = ref<TransferDirectoryConf[]>([])
// 查询目录
async function loadDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Directories')
directories.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 目的目录下拉框
const targetDirectories = computed(() => {
const directories = libraryDirectories.value.map(item => item.path)
return [...new Set(directories)]
const libraryDirectories = directories.value.map(item => item.library_path)
return [...new Set(libraryDirectories)]
})
// 监听目的路径变化,自动查询目录的刮削配置
// 监听目的路径变化,自动查询目录削配置
watch(transferForm, async () => {
if (transferForm.target) {
const directory = libraryDirectories.value.find(item => item.path === transferForm.target)
if (transferForm.target_path) {
const directory = directories.value.find(item => item.library_path === transferForm.target_path)
if (directory) {
transferForm.scrape = directory.scrape ?? false
transferForm.scrape = directory.scraping ?? false
transferForm.transfer_type = directory.transfer_type ?? ''
}
}
})
@@ -109,12 +118,7 @@ watch(transferForm, async () => {
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
const token = store.state.auth.token
progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer?token=${token}`,
)
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
@@ -162,13 +166,10 @@ async function transfer() {
// 整理文件
async function handleTransfer(item: FileItem) {
transferForm.path = item.path
transferForm.fileid = item.fileid || ''
transferForm.drive_id = item.drive_id || ''
transferForm.filetype = item.type || 'dir'
transferForm.fileitem = item
transferForm.logid = 0
try {
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { params: transferForm })
const result: { [key: string]: any } = await api.post('transfer/manual', transferForm)
if (!result.success) $toast.error(`文件 ${item.path} 整理失败:${result.message}`)
} catch (e) {
console.log(e)
@@ -178,39 +179,17 @@ async function handleTransfer(item: FileItem) {
// 整理日志
async function handleTransferLog(logid: number) {
transferForm.logid = logid
transferForm.fileitem = {}
try {
const result: { [key: string]: any } = await api.post('transfer/manual', {}, { params: transferForm })
const result: { [key: string]: any } = await api.post('transfer/manual', transferForm)
if (!result.success) $toast.error(`历史记录 ${logid} 重新整理失败:${result.message}`)
} catch (e) {
console.log(e)
}
}
// 调用API加载当前系统环境设置
async function loadSystemSettings() {
try {
const result: { [key: string]: any } = await api.get('system/env')
if (result) mediaSource.value = result.data?.RECOGNIZE_SOURCE || 'themoviedb'
} catch (e) {
console.error(e)
}
}
// 查询媒体库目录
async function loadLibraryDirectories() {
try {
const result: { [key: string]: any } = await api.get('system/setting/LibraryDirectories')
if (result.success && result.data?.value) {
libraryDirectories.value = result.data.value
}
} catch (error) {
console.log(error)
}
}
onMounted(() => {
loadSystemSettings()
loadLibraryDirectories()
loadDirectories()
})
</script>
@@ -222,17 +201,17 @@ onMounted(() => {
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol v-if="props.storage == 'local'" cols="12" md="8">
<VCombobox
v-model="transferForm.target"
:items="targetDirectories"
label="目的路径"
<VCol cols="12" md="6">
<VSelect
v-model="transferForm.target_storage"
:items="storageOptions"
label="目的存储"
placeholder="留空自动"
hint="整理目的路径,留空将自动匹配"
hint="整理目的存储"
persistent-hint
/>
</VCol>
<VCol v-if="props.storage == 'local'" cols="12" md="4">
<VCol cols="12" md="6">
<VSelect
v-model="transferForm.transfer_type"
label="整理方式"
@@ -242,13 +221,21 @@ onMounted(() => {
{ title: '复制', value: 'copy' },
{ title: '硬链接', value: 'link' },
{ title: '软链接', value: 'softlink' },
{ title: 'Rclone复制', value: 'rclone_copy' },
{ title: 'Rclone移动', value: 'rclone_move' },
]"
hint="文件操作整理方式"
persistent-hint
/>
</VCol>
<VCol cols="12" md="12">
<VCombobox
v-model="transferForm.target_path"
:items="targetDirectories"
label="目的路径"
placeholder="留空自动"
hint="整理目的路径,留空将自动匹配"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
@@ -331,7 +318,7 @@ onMounted(() => {
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model.number="transferForm.episode_offset"
v-model="transferForm.episode_offset"
label="集数偏移"
placeholder="如-10"
hint="集数偏移运算,如-10或EP*2"
@@ -358,6 +345,14 @@ onMounted(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6" v-if="props.logids">
<VSwitch
v-model="transferForm.from_history"
label="复用历史识别信息"
hint="使用历史记录中已识别的媒体信息"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>

View File

@@ -40,6 +40,12 @@ const siteForm = ref<Site>({
// 提示框
const $toast = useToast()
// 维护类型
const siteType = ref('cookie')
// 是否限流
const isLimit = ref(false)
// 状态下拉项
const statusItems = [
{ title: '启用', value: true },
@@ -54,10 +60,24 @@ const priorityItems = ref(
})),
)
// 监控输入参数
watchEffect(async () => {
if (props.siteid) fetchSiteInfo()
})
// 下载器选项
const downloaderOptions = ref<{ title: string; value: string }[]>([])
async function loadDownloaderSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Downloaders')
const downloaders = result.data?.value ?? []
downloaderOptions.value = [
{ title: '默认', value: null },
...downloaders.map((item: { name: any }) => ({
title: item.name,
value: item.name,
})),
]
} catch (error) {
console.error('加载下载器设置失败:', error)
}
}
// 查询站点信息
async function fetchSiteInfo() {
@@ -111,6 +131,15 @@ async function deleteSiteInfo() {
async function updateSiteInfo() {
startNProgress()
try {
if (isLimit.value) {
siteForm.value.limit_interval = siteForm.value.limit_interval || 0
siteForm.value.limit_count = siteForm.value.limit_count || 0
siteForm.value.limit_seconds = siteForm.value.limit_seconds || 0
} else {
siteForm.value.limit_interval = 0
siteForm.value.limit_count = 0
siteForm.value.limit_seconds = 0
}
const result: { [key: string]: any } = await api.put('site/', siteForm.value)
if (result.success) {
$toast.success(`${siteForm.value?.name} 更新成功!`)
@@ -124,6 +153,16 @@ async function updateSiteInfo() {
}
doneNProgress()
}
onMounted(async () => {
if (props.oper !== 'add') {
await fetchSiteInfo()
if (siteForm.value.limit_interval || siteForm.value.limit_count || siteForm.value.limit_seconds)
isLimit.value = true
if (siteForm.value.apikey) siteType.value = 'api'
}
await loadDownloaderSetting()
})
</script>
<template>
@@ -167,7 +206,7 @@ async function updateSiteInfo() {
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="9">
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.rss"
label="RSS地址"
@@ -178,35 +217,78 @@ async function updateSiteInfo() {
<VCol cols="12" md="3">
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间" persistent-hint />
</VCol>
<VCol cols="12">
<VTextarea v-model="siteForm.cookie" label="站点Cookie" hint="站点请求头中的Cookie信息" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.token"
label="请求头Authorization"
hint="站点请求头中的Authorization信息特殊站点需要"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.apikey"
label="令牌API Key"
hint="站点的访问API Key特殊站点需要"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
hint="获取Cookie的浏览器对应的User-Agent"
<VCol cols="6" md="3">
<VSelect
v-model="siteForm.downloader"
label="下载器"
:items="downloaderOptions"
hint="此站点使用的下载器"
persistent-hint
/>
</VCol>
</VRow>
<VTabs v-model="siteType" show-arrows class="v-tabs-pill mt-3">
<VTab selected-class="v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-cookie" value="cookie" />
Cookie
</div>
</VTab>
<VTab selected-class="v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-api" value="api" />
API
</div>
</VTab>
</VTabs>
<VWindow v-model="siteType" class="my-3 disable-tab-transition" :touch="false">
<VWindowItem value="cookie">
<VRow>
<VCol cols="12">
<VTextarea
v-model="siteForm.cookie"
label="站点Cookie"
hint="站点请求头中的Cookie信息"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
hint="获取Cookie的浏览器对应的User-Agent"
persistent-hint
/>
</VCol>
</VRow>
</VWindowItem>
<VWindowItem value="api">
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.token"
label="请求头Authorization"
hint="站点请求头中的Authorization信息特殊站点需要"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.apikey"
label="令牌API Key"
hint="站点的访问API Key特殊站点需要"
persistent-hint
/>
</VCol>
</VRow>
</VWindowItem>
</VWindow>
<VRow>
<VCol cols="12" md="4">
<VSwitch v-model="isLimit" label="限制站点访问频率" />
</VCol>
</VRow>
<VRow v-if="isLimit">
<VCol cols="12" md="4">
<VTextField
v-model="siteForm.limit_interval"
@@ -237,10 +319,15 @@ async function updateSiteInfo() {
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="siteForm.proxy" label="代理" hint="使用代理服务器访问该站点" persistent-hint />
<VSwitch v-model="siteForm.proxy" label="使用代理访问" hint="使用代理服务器访问该站点" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VSwitch v-model="siteForm.render" label="仿真" hint="使用浏览器模拟真实访问该站点" persistent-hint />
<VSwitch
v-model="siteForm.render"
label="浏览器仿真"
hint="使用浏览器模拟真实访问该站点"
persistent-hint
/>
</VCol>
</VRow>
</VForm>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import api from '@/api'
import { Site } from '@/api/types'
import { requiredValidator } from '@/@validators'
import { useToast } from 'vue-toast-notification'
import ProgressDialog from '../dialog/ProgressDialog.vue'
// 输入参数
const cardProps = defineProps({
site: Object as PropType<Site>,
})
// 定义触发的自定义事件
const emit = defineEmits(['close', 'done'])
// 提示框
const $toast = useToast()
// 用户名密码表单
const userPwForm = ref({
username: '',
password: '',
code: '',
})
// 密码输入
const isPasswordVisible = ref(false)
// 更新按钮可用性
const updateButtonDisable = ref(false)
// 进度条
const progressDialog = ref(false)
// 进度文本
const progressText = ref('请稍候 ...')
// 调用API更新站点Cookie UA
async function updateSiteCookie() {
try {
if (!userPwForm.value.username || !userPwForm.value.password) return
// 更新按钮状态
updateButtonDisable.value = true
progressDialog.value = true
progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...`
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
params: {
username: userPwForm.value.username,
password: userPwForm.value.password,
code: userPwForm.value.code,
},
})
if (result.success) {
$toast.success(`${cardProps.site?.name} 更新Cookie & UA 成功!`)
emit('done')
} else $toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
progressDialog.value = false
updateButtonDisable.value = false
} catch (error) {
console.error(error)
}
}
</script>
<template>
<VDialog max-width="50rem">
<!-- Dialog Content -->
<VCard title="更新站点Cookie & UA">
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="userPwForm.password"
label="密码"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
@keydown.enter="updateSiteCookie"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.code" label="两步验证" />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="elevated"
@click="updateSiteCookie"
:disabled="updateButtonDisable"
prepend-icon="mdi-refresh"
class="px-5"
>
开始更新
</VBtn>
</VCardActions>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</VDialog>
</template>

View File

@@ -0,0 +1,222 @@
<script setup lang="ts">
import { Site } from '@/api/types'
import { useDisplay } from 'vuetify'
import api from '@/api'
import type { TorrentInfo } from '@/api/types'
import { formatFileSize } from '@core/utils/formatters'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
site: Object as PropType<Site>,
})
// 注册事件
const emit = defineEmits(['close'])
// 数据列表
const resourceDataList = ref<TorrentInfo[]>([])
// 搜索
const resourceSearch = ref('')
// 总条数
const resourceTotalItems = ref(0)
// 每页条数
const resourceItemsPerPage = ref(25)
// 加载状态
const resourceLoading = ref(false)
// 种子元数据
const torrent = ref<TorrentInfo>()
// 资源浏览表头
const resourceHeaders = [
{ title: '标题', key: 'title', sortable: false },
{ title: '时间', key: 'pubdate', sortable: true },
{ title: '大小', key: 'size', sortable: true },
{ title: '做种', key: 'seeders', sortable: true },
{ title: '下载', key: 'peers', sortable: true },
{ title: '', key: 'actions', sortable: false },
]
// 打开种子详情页面
function openTorrentDetail(page_url: string) {
window.open(page_url, '_blank')
}
// 下载种子文件
async function downloadTorrentFile(enclosure: string) {
window.open(enclosure, '_blank')
}
// 调用API查询站点资源
async function getResourceList() {
resourceLoading.value = true
try {
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`)
resourceLoading.value = false
} catch (error) {
console.error(error)
}
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) return 'text-white bg-lime-500'
else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
else return 'text-white bg-gray-500'
}
// 添加下载
async function addDownload(_torrent: any) {
torrent.value = _torrent
addDownloadDialog.value = true
}
// 添加下载对话框
const addDownloadDialog = ref(false)
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
}
// 装载时查询站点图标
onMounted(() => {
getResourceList()
})
</script>
<template>
<VDialog max-width="80rem" scrollable z-index="1010" :fullscreen="!display.mdAndUp.value">
<VCard :title="`浏览 - ${props.site?.name}`">
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText class="pt-2">
<VDataTable
v-model:items-per-page="resourceItemsPerPage"
:headers="resourceHeaders"
:items="resourceDataList"
:items-length="resourceTotalItems"
:search="resourceSearch"
:loading="resourceLoading"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
>
<template #item.title="{ item }">
<a href="javascript:void(0)" @click.stop="addDownload(item)">
<div class="text-high-emphasis pt-1">
{{ item.title }}
</div>
<div class="text-sm my-1">
{{ item.description }}
</div>
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ item.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in item.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ item.volume_factor }}
</VChip>
</a>
</template>
<template #item.pubdate="{ item }">
<div>{{ item.date_elapsed }}</div>
<div class="text-sm">
{{ item.pubdate }}
</div>
</template>
<template #item.size="{ item }">
<div class="text-nowrap whitespace-nowrap">
{{ formatFileSize(item.size) }}
</div>
</template>
<template #item.seeders="{ item }">
<div>{{ item.seeders }}</div>
</template>
<template #item.peers="{ item }">
<div>{{ item.peers }}</div>
</template>
<template #item.actions="{ item }">
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail(item.page_url || '')">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="item.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile(item.enclosure)"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
<template #no-data> 没有数据 </template>
</VDataTable>
</VCardText>
</VCard>
<!-- 添加下载对话框 -->
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:torrent="torrent"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</VDialog>
</template>
<style lang="scss" scoped>
.v-table th {
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,463 @@
<script lang="ts" setup>
import type { Site, SiteUserData } from '@/api/types'
import api from '@/api'
import { useDisplay, useTheme } from 'vuetify'
import { VAvatar, VCardText, VIcon } from 'vuetify/lib/components/index.mjs'
import { formatFileSize } from '@/@core/utils/formatters'
import VueApexCharts from 'vue3-apexcharts'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
site: Object as PropType<Site>,
})
// 注册事件
const emit = defineEmits(['close'])
// 进度框
const progressDialog = ref(false)
const vuetifyTheme = useTheme()
const currentTheme = controlledComputed(
() => vuetifyTheme.name.value,
() => vuetifyTheme.current.value.colors,
)
// 站点数据列表
const siteDatas = ref<SiteUserData[]>([])
// 最新一天的数据
const siteData = computed(() => siteDatas.value[siteDatas.value.length - 1])
// 站点数据列表中的上传量、下载量数据生成图形使用的数据
const historySeries = computed(() => {
return [
{
name: '上传量',
data: siteDatas.value.map(item => Math.round((item.upload ?? 0) / 1024 / 1024 / 1024)),
},
{
name: '下载量',
data: siteDatas.value.map(item => Math.round((item.download ?? 0) / 1024 / 1024 / 1024)),
},
]
})
// 图形选项
const historyChartOptions = computed(() => {
return {
chart: {
type: 'area',
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: true },
dataLabels: {
enabled: true,
},
zoom: {
autoScaleYaxis: true,
},
},
tooltip: {
enabled: true,
tooltip: {
x: {
format: 'dd MMM yyyy',
},
},
},
grid: {
xaxis: {
lines: { show: false },
},
yaxis: {
title: {
text: 'GB',
},
lines: { show: true },
},
},
stroke: {
width: 3,
lineCap: 'butt',
curve: 'smooth',
},
colors: [currentTheme.value.success, currentTheme.value.warning],
markers: {
size: 0,
style: 'hollow',
},
xaxis: {
type: 'category',
categories: siteDatas.value.map(item => item.updated_day),
labels: {
show: true,
formatter: function (val: string) {
return new Date(val).toLocaleDateString('zh-CN')
},
},
},
yaxis: {
title: {
text: 'GB',
},
labels: {
formatter: function (val: number) {
return val.toLocaleString()
},
},
},
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.5,
opacityTo: 0.7,
stops: [0, 100],
},
},
}
})
// 做种分布列seeding_info的格式为[[x, y], [x, y], ...]x为做种数y为做种体积做种体积需要转换为GB
const seedingSeries = computed(() => {
return [
{
name: '体积',
data: siteData.value?.seeding_info?.map(item => [item[0] ?? 0, Math.round((item[1] ?? 0) / 1024 / 1024 / 1024)]),
},
]
})
// 做种分布图形选项
const seedingChartOptions = computed(() => {
return {
chart: {
type: 'scatter',
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: true },
zoom: {
autoScaleYaxis: true,
},
},
tooltip: {
enabled: true,
x: {
formatter: function (val: number) {
return '数量:' + val.toLocaleString()
},
},
},
grid: {
xaxis: {
lines: { show: true },
},
yaxis: {
lines: { show: true },
},
},
colors: [currentTheme.value.primary],
xaxis: {
type: 'numeric',
labels: {
show: true,
formatter: function (val: number) {
return Math.round(val).toLocaleString()
},
},
title: {
text: '数量',
},
tickAmount: 10,
},
yaxis: {
title: {
text: 'GB',
},
labels: {
formatter: function (val: number) {
return val.toLocaleString() + ' GB'
},
},
},
}
})
// 根据传入属性,计算列表数据中第一条与第二条的差值,如果没有第二条则差值为全部
const diffData: { [key: string]: any } = computed(() => {
if (siteDatas.value.length < 2) {
return siteData.value
}
const first = siteDatas.value[siteDatas.value.length - 1]
const second = siteDatas.value[siteDatas.value.length - 2]
return {
bonus: (first.bonus ?? 0) - (second.bonus ?? 0),
ratio: (first.ratio ?? 0) - (second.ratio ?? 0),
upload: (first.upload ?? 0) - (second.upload ?? 0),
download: (first.download ?? 0) - (second.download ?? 0),
seeding: (first.seeding ?? 0) - (second.seeding ?? 0),
seeding_size: (first.seeding_size ?? 0) - (second.seeding_size ?? 0),
}
})
// 格式化差值
function getDiffString(diff: number | undefined, format: boolean = true) {
if (diff === undefined) {
return '0'
}
if (format) {
return diff >= 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()
}
return diff >= 0 ? `+${diff}` : diff
}
// 根据差值的正负,返回不同的样式
function getDiffClass(diff: number | undefined) {
if (diff === undefined) {
return ''
}
if (diff == 0) {
return ''
}
return diff > 0 ? 'text-success' : 'text-error'
}
// 查询站点用户数据
async function fetchSiteUserData() {
try {
const result: { [key: string]: any } = await api.get(`site/userdata/${props.site?.id}`)
if (result.success) {
siteDatas.value = result.data.sort((a: { updated_day: any }, b: { updated_day: any }) =>
(a.updated_day || '').localeCompare(b.updated_day || ''),
)
}
} catch (error) {
console.error(error)
}
}
// 刷新站点数据
async function refreshSiteData(){
progressDialog.value = true
try {
const result: { [key: string]: any } = await api.post(`site/userdata/${props.site?.id}`)
if (result.success){
await fetchSiteUserData()
}
} catch (error) {
console.log(error)
}
progressDialog.value = false
}
onBeforeMount(async () => {
await fetchSiteUserData()
})
</script>
<template>
<VDialog scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard class="rounded-t">
<VCardItem>
<VCardTitle>{{ `数据 - ${props.site?.name}` }}
<IconBtn @click.stop="refreshSiteData" color="info"><VIcon icon="mdi-refresh"</VIcon></IconBtn>
</VCardTitle>
<DialogCloseBtn @click="emit('close')" />
</VCardItem>
<VCardText>
<VRow class="match-height">
<!-- 用户信息 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">用户等级</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.user_level || '无' }}
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-account"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 积分 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">积分</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.bonus?.toLocaleString() }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.bonus)">
({{ getDiffString(diffData?.bonus) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-scoreboard"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 分享率 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">分享率</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.ratio }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.ratio)">
({{ getDiffString(diffData?.ratio) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-percent"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 总上传量 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">总上传量</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ formatFileSize(siteData?.upload || 0) }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.upload)">
({{ formatFileSize(diffData?.upload || 0, 2, true) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-upload"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 总下载量 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">总下载量</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ formatFileSize(siteData?.download || 0) }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.download)">
({{ formatFileSize(diffData?.download || 0, 2, true) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-download"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 总做种数 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">总做种数</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.seeding?.toLocaleString() }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.seeding)">
({{ getDiffString(diffData?.seeding) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-seed"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 总做种体积 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">总做种体积</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ formatFileSize(siteData?.seeding_size || 0) }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.seeding_size)">
({{ formatFileSize(diffData?.seeding_size || 0, 2, true) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-database"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 加入时间 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">加入时间</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.join_at?.split(' ')[0] }}
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-calendar"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<VRow>
<VCol>
<VCard title="历史流量">
<VCardText>
<VueApexCharts type="line" :options="historyChartOptions" :series="historySeries" :height="300" />
</VCardText>
</VCard>
</VCol>
</VRow>
<VRow>
<VCol>
<VCard title="做种分布">
<VCardText>
<VueApexCharts type="scatter" :options="seedingChartOptions" :series="seedingSeries" :height="300" />
</VCardText>
</VCard>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" text="正在刷新站点数据..." />
</VDialog>
</template>

View File

@@ -2,9 +2,10 @@
import { useToast } from 'vue-toast-notification'
import { numberValidator } from '@/@validators'
import api from '@/api'
import type { MediaDirectory, Site, Subscribe } from '@/api/types'
import type { FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useConfirm } from 'vuetify-use-dialog'
import { VTextarea, VTextField } from 'vuetify/lib/components/index.mjs'
// 显示器宽度
const display = useDisplay()
@@ -22,38 +23,33 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save', 'close'])
const activeTab = ref('basic')
// 站点数据列表
const siteList = ref<Site[]>([])
// 下载目录列表
const downloadDirectories = ref<MediaDirectory[]>([])
const downloadDirectories = ref<TransferDirectoryConf[]>([])
// 站点选择下载框
const selectSitesOptions = ref<{ [key: number]: string }[]>([])
// 所有规则组列表
const filterRuleGroups = ref<FilterRuleGroup[]>([])
// 订阅编辑表单
const subscribeForm = ref<Subscribe>({
id: props.subid ?? 0,
keyword: '',
quality: '',
resolution: '',
effect: '',
include: '',
exclude: '',
total_episode: 0,
start_episode: 0,
best_version: 0,
search_imdbid: 0,
sites: [],
type: '',
name: '',
year: '',
type: '',
tmdbid: 0,
state: '',
last_update: '',
username: '',
sites: [],
best_version: undefined,
current_priority: 0,
save_path: undefined,
date: '',
show_edit_dialog: false,
})
@@ -61,6 +57,43 @@ const subscribeForm = ref<Subscribe>({
// 提示框
const $toast = useToast()
// 下载器选项
const downloaderOptions = ref<{ title: string; value: string }[]>([])
async function loadDownloaderSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Downloaders')
const downloaders = result.data?.value ?? []
downloaderOptions.value = [
{ title: '默认', value: null },
...downloaders.map((item: { name: any }) => ({
title: item.name,
value: item.name,
})),
]
} catch (error) {
console.error('加载下载器设置失败:', error)
}
}
// 加载规则组
async function queryFilterRuleGroups() {
try {
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
filterRuleGroups.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 过滤规则组选择项
const filterRuleGroupOptions = computed(() => {
return filterRuleGroups.value.map(item => ({
title: item.name,
value: item.name,
}))
})
// 调用API修改订阅
async function updateSubscribeInfo() {
try {
@@ -162,6 +195,7 @@ async function removeSubscribe() {
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
if (result.success) {
$toast.success(`订阅 ${subscribeForm.value.name} 已取消!`)
// 通知父组件刷新
emit('remove')
}
@@ -185,7 +219,7 @@ async function loadDownloadDirectories() {
// 保存目录下拉框
const targetDirectories = computed(() => {
// 去重后的下载目录
const directories = downloadDirectories.value.map(item => item.path)
const directories = downloadDirectories.value.map(item => item.download_path)
return [...new Set(directories)]
})
@@ -274,8 +308,10 @@ const effectOptions = ref([
])
onMounted(() => {
queryFilterRuleGroups()
loadDownloadDirectories()
getSiteList()
loadDownloaderSetting()
if (props.subid) getSubscribeInfo()
if (props.default) queryDefaultSubscribeConfig()
})
@@ -291,134 +327,199 @@ onMounted(() => {
}`"
class="rounded-t"
>
<VDivider />
<VCardText>
<DialogCloseBtn @click="emit('close')" />
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="8">
<VTextField
v-if="!props.default"
v-model="subscribeForm.keyword"
label="搜索关键词"
hint="指定搜索站点时使用的关键词"
persistent-hint
/>
</VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
<VTextField
v-model="subscribeForm.total_episode"
label="总集数"
:rules="[numberValidator]"
hint="剧集总集数"
persistent-hint
/>
</VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="2">
<VTextField
v-model="subscribeForm.start_episode"
label="开始集数"
:rules="[numberValidator]"
hint="开始订阅集数"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.quality"
label="质量"
:items="qualityOptions"
hint="订阅资源质量"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.resolution"
label="分辨率"
:items="resolutionOptions"
hint="订阅资源分辨率"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.effect"
label="特效"
:items="effectOptions"
hint="订阅资源特效"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="subscribeForm.include"
label="包含(关键字、正则式)"
hint="包含规则,支持正则表达式"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="subscribeForm.exclude"
label="排除(关键字、正则式)"
hint="排除规则,支持正则表达式"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.sites"
:items="selectSitesOptions"
chips
label="订阅站点"
multiple
hint="订阅的站点范围,不选使用系统设置"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VCombobox
v-model="subscribeForm.save_path"
:items="targetDirectories"
label="保存路径"
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.best_version"
label="洗版"
hint="根据洗版优先级进行洗版订阅"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.search_imdbid"
label="使用 ImdbID 搜索"
hint="开使用 ImdbID 精确搜索资源"
persistent-hint
/>
</VCol>
<VCol v-if="props.default" cols="12" md="4">
<VSwitch
v-model="subscribeForm.show_edit_dialog"
label="订阅时编辑更多规则"
hint="添加订阅时显示此编辑订阅对话框"
persistent-hint
/>
</VCol>
</VRow>
<VTabs v-model="activeTab" show-arrows>
<VTab value="basic">
<div>基础</div>
</VTab>
<VTab value="advance">
<div>进阶</div>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="basic">
<div>
<VRow v-if="!props.default">
<VCol cols="12" md="4">
<VTextField
v-model="subscribeForm.keyword"
label="搜索关键词"
hint="指定搜索站点时使用的关键词"
persistent-hint
/>
</VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="4">
<VTextField
v-model="subscribeForm.total_episode"
label="总集数"
:rules="[numberValidator]"
hint="剧集总集数"
persistent-hint
/>
</VCol>
<VCol v-if="subscribeForm.type === '电视剧'" cols="12" md="4">
<VTextField
v-model="subscribeForm.start_episode"
label="开始集数"
:rules="[numberValidator]"
hint="开始订阅集数"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.quality"
label="质量"
:items="qualityOptions"
hint="订阅资源质量"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.resolution"
label="分辨率"
:items="resolutionOptions"
hint="订阅资源分辨率"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="subscribeForm.effect"
label="特效"
:items="effectOptions"
hint="订阅资源特效"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VSelect
v-model="subscribeForm.sites"
:items="selectSitesOptions"
chips
label="订阅站点"
multiple
clearable
hint="订阅的站点范围,不选使用系统设置"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="subscribeForm.downloader"
:items="downloaderOptions"
label="下载器"
hint="指定该订阅使用的下载器,留空自动使用默认下载器"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VCombobox
v-model="subscribeForm.save_path"
:items="targetDirectories"
label="保存路径"
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.best_version"
label="洗版"
hint="根据洗版优先级进行洗版订阅"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.search_imdbid"
label="使用 ImdbID 搜索"
hint="开使用 ImdbID 精确搜索资源"
persistent-hint
/>
</VCol>
<VCol v-if="props.default" cols="12" md="4">
<VSwitch
v-model="subscribeForm.show_edit_dialog"
label="订阅时编辑更多规则"
hint="添加订阅时显示此编辑订阅对话框"
persistent-hint
/>
</VCol>
</VRow>
</div>
</VWindowItem>
<VWindowItem value="advance">
<div>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="subscribeForm.include"
label="包含(关键字、正则式)"
hint="包含规则,支持正则表达式"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="subscribeForm.exclude"
label="排除(关键字、正则式)"
hint="排除规则,支持正则表达式"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="subscribeForm.filter_groups"
:items="filterRuleGroupOptions"
chips
multiple
clearable
label="优先级规则组"
hint="按选定的过滤规则组对订阅进行过滤"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6" v-if="!props.default">
<VTextField
v-model="subscribeForm.media_category"
label="自定义类别"
hint="指定类别名称,留空自动识别"
persistent-hint
/>
</VCol>
</VRow>
<VRow v-if="!props.default">
<VCol cols="12">
<VTextarea
v-model="subscribeForm.custom_words"
label="自定义识别词"
hint="只对该订阅使用的识别词"
persistent-hint
placeholder="屏蔽词
被替换词 => 替换词
前定位词 <> 后定位词 >> 集偏移量EP
被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量EP
其中替换词支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID识别其中s、e为季数和集数可选"
/>
</VCol>
</VRow>
</div>
</VWindowItem>
</VWindow>
</VForm>
</VCardText>
<VCardActions class="pt-3">

View File

@@ -0,0 +1,301 @@
<script setup lang="ts">
import api from '@/api'
import { SubscrbieInfo } from '@/api/types'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
//定义输入参数
const props = defineProps({
subid: Number,
})
const activeTab = ref('download')
// 定义触发的自定义事件
const emit = defineEmits(['close'])
// 订阅文件信息
const subScribeInfo = ref<SubscrbieInfo>()
// 下载文件表头
const downloadHeaders = [
{ title: '集', key: 'episode_number', sortable: true },
{ title: '种子', key: 'torrent_title', sortable: true },
{ title: '文件', key: 'file_path', sortable: true },
]
// 媒体库文件表头
const libraryHeaders = [
{ title: '集', key: 'episode_number', sortable: true },
{ title: '文件', key: 'file_path', sortable: true },
]
// 调用API查询订阅文件信息
async function loadSubscribeFilesInfo() {
try {
subScribeInfo.value = await api.get(`subscribe/files/${props.subid}`)
} catch (e) {
console.log(e)
}
}
// 计算下载文件列表
const downloadInfos = computed(() => {
return Object.keys(subScribeInfo.value?.episodes ?? {}).map((key: any) => {
const item = subScribeInfo.value?.episodes[key]
return {
episode_number: key,
title: item?.title,
download: item?.download ?? [],
}
})
})
// 总集数
const totalCount = computed(() => {
return Object.keys(subScribeInfo.value?.episodes ?? {}).length
})
// 计算媒体库文件列表
const libraryInfos = computed(() => {
return Object.keys(subScribeInfo.value?.episodes ?? {}).map((key: any) => {
const item = subScribeInfo.value?.episodes[key]
return {
episode_number: key,
title: item?.title,
library: item?.library ?? [],
}
})
})
onBeforeMount(() => {
loadSubscribeFilesInfo()
})
</script>
<template>
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard class="rounded-t">
<VCardItem class="my-2">
<DialogCloseBtn @click="emit('close')" />
</VCardItem>
<VCardText>
<div class="media-page">
<div class="media-header">
<div class="media-poster">
<VImg
:src="subScribeInfo?.subscribe?.poster"
cover
class="object-cover aspect-w-2 aspect-h-3 ring-1 ring-gray-500"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="media-title">
<h1 class="d-flex flex-column flex-lg-row align-baseline justify-center justify-lg-start">
<div class="align-self-center align-self-lg-end">
{{ subScribeInfo?.subscribe?.name }}
</div>
<div v-if="subScribeInfo?.subscribe?.season" class="text-lg align-self-center align-self-lg-end ms-3">
{{ subScribeInfo?.subscribe?.season }}
</div>
</h1>
<div>{{ subScribeInfo?.subscribe?.year }}</div>
<div class="media-overview">
<div class="media-overview-left">
<p>{{ subScribeInfo?.subscribe?.description }}</p>
</div>
</div>
</div>
</div>
</div>
<div class="mt-7">
<VTabs v-model="activeTab" show-arrows class="v-tabs-pill">
<VTab value="download" selected-class="v-slide-group-item--active v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-download" />
下载文件
</div>
</VTab>
<VTab value="library" selected-class="v-slide-group-item--active v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-filmstrip-box-multiple" />
媒体库文件
</div>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem value="download">
<transition name="fade-slide" appear>
<VDataTable
items-per-page="50"
:headers="downloadHeaders"
:items="downloadInfos"
:items-length="totalCount"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
>
<template #item.episode_number="{ item }">
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
</template>
<template #item.torrent_title="{ item }">
<div class="text-xs" v-for="file in item.download">
{{ file.site_name }}{{ file.torrent_title }}
</div>
</template>
<template #item.file_path="{ item }">
<div class="text-xs" v-for="file in item.download">{{ file.file_path }}</div>
</template>
<template #no-data> 没有数据 </template>
</VDataTable>
</transition>
</VWindowItem>
<VWindowItem value="library">
<transition name="fade-slide" appear>
<VDataTable
items-per-page="50"
:headers="libraryHeaders"
:items="libraryInfos"
:items-length="totalCount"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
>
<template #item.episode_number="{ item }">
<div class="text-high-emphasis pt-1">{{ item.episode_number }}. {{ item.title }}</div>
</template>
<template #item.file_path="{ item }">
<div class="text-xs" v-for="file in item.library">{{ file.file_path }}</div>
</template>
<template #no-data> 没有数据 </template>
</VDataTable>
</transition>
</VWindowItem>
</VWindow>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss">
.vue-media-back {
background-image: linear-gradient(
180deg,
rgba(var(--v-theme-background), 0) 50%,
rgba(var(--v-theme-background), 1) 100%
),
linear-gradient(90deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%),
linear-gradient(270deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%);
box-shadow: 0 0 0 2px rgb(var(--v-theme-background));
margin-block-start: calc(-70px - env(safe-area-inset-top));
}
.media-page {
position: relative;
background-position: 50%;
background-size: cover;
margin-block-start: calc(-4rem - env(safe-area-inset-top));
padding-block-start: calc(4rem + env(safe-area-inset-top));
padding-inline: 1rem;
}
.media-header {
display: flex;
flex-direction: column;
align-items: center;
padding-block-start: 1rem;
}
@media (width >= 1280px) {
.media-header {
flex-direction: row;
align-items: flex-end;
}
}
.media-overview {
display: flex;
flex-direction: column;
padding-block: 1rem 1rem;
}
@media (width >= 1024px) {
.media-overview {
flex-direction: row;
}
}
.media-poster {
overflow: hidden;
border-radius: 0.25rem;
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
inline-size: 8rem;
--tw-shadow: 0 1px 3px 0 rgba(0, 0, 0, 10%), 0 1px 2px -1px rgba(0, 0, 0, 10%);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
}
@media (width >= 1280px) {
.media-poster {
inline-size: 13rem;
margin-inline-end: 1rem;
}
}
@media (width >= 768px) {
.media-poster {
border-radius: 0.5rem;
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
inline-size: 11rem;
--tw-shadow: 0 25px 50px -12px rgba(0, 0, 0, 25%);
--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);
}
}
.media-title {
display: flex;
flex: 1 1 0%;
flex-direction: column;
margin-block-start: 1rem;
text-align: center;
}
@media (width >= 1280px) {
.media-title {
margin-block-start: 0;
margin-inline-end: 1rem;
text-align: start;
}
}
.media-title > h1 {
font-size: 1.5rem;
font-weight: 700;
line-height: 2rem;
}
@media (width >= 1280px) {
.media-title > h1 {
font-size: 2.25rem;
line-height: 2.5rem;
}
}
</style>

View File

@@ -145,65 +145,69 @@ const dropdownItems = ref([
}
"
/>
<!-- <VList lines="two" v-if="historyList.length > 0"> -->
<VList lines="two">
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-hidden" @load="loadHistory">
<template #loading>
<LoadingBanner />
</template>
<template #empty />
<template v-for="(item, i) in historyList" :key="i">
<VListItem>
<template #prepend>
<VImg
height="75"
width="50"
:src="item.poster"
aspect-ratio="2/3"
class="object-cover rounded shadow ring-gray-500 me-3"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</template>
<VListItemTitle v-if="item.type == '电视剧'">
{{ item.name }} <span class="text-sm"> {{ item.season }} </span>
</VListItemTitle>
<VListItemTitle v-else>
{{ item.name }}
</VListItemTitle>
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
<template v-if="historyList.length > 0">
<template v-for="(item, i) in historyList" :key="i">
<VListItem>
<template #prepend>
<VImg
height="75"
width="50"
:src="item.poster"
aspect-ratio="2/3"
class="object-cover rounded shadow ring-gray-500 me-3"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</template>
<VListItemTitle v-if="item.type == '电视剧'">
{{ item.name }} <span class="text-sm"> {{ item.season }} </span>
</VListItemTitle>
<VListItemTitle v-else>
{{ item.name }}
</VListItemTitle>
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
variant="plain"
:base-color="menu.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
</template>
</template>
</VInfiniteScroll>
</VList>
<VCardText v-if="historyList.length === 0" class="text-center"> 没有已完成的订阅 </VCardText>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />

View File

@@ -0,0 +1,92 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Subscribe, SubscribeShare } from '@/api/types'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
sub: Object as PropType<Subscribe>,
})
// 定义触发的自定义事件
const emit = defineEmits(['close'])
// 订阅编辑表单
const shareForm = ref<SubscribeShare>({
subscribe_id: props.sub?.id ?? 0,
})
// 分享订阅
async function doShare() {
if (!shareForm.value.share_title || !shareForm.value.share_comment || !shareForm.value.share_user) return
try {
const result: { [key: string]: any } = await api.post('subscribe/share', shareForm.value)
// 提示
if (result.success) {
$toast.success(`${props.sub?.name} 分享成功!`)
// 通知父组件刷新
emit('close')
} else {
$toast.error(`${props.sub?.name} 分享失败:${result.message}`)
}
} catch (e) {
console.log(e)
}
}
// 提示框
const $toast = useToast()
</script>
<template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard
:title="`分享订阅 - ${props.sub?.name} ${props.sub?.season ? `第 ${props.sub?.season} 季` : ''}`"
class="rounded-t"
>
<VCardText>
<DialogCloseBtn @click="emit('close')" />
<VForm @submit.prevent="() => {}" class="pt-2">
<VRow>
<VCol cols="12">
<VTextField
v-model="shareForm.share_title"
label="标题"
hint="给分享取一个便于识别的名称"
:rules="[requiredValidator]"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="shareForm.share_comment"
label="说明"
:rules="[requiredValidator]"
hint="关于该订阅的说明"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="shareForm.share_user"
label="分享用户"
:rules="[requiredValidator]"
hint="分享人的昵称"
persistent-hint
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn variant="elevated" @click="doShare" prepend-icon="mdi-share" class="px-5"> 确认分享 </VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -25,7 +25,7 @@ async function handleDone() {
// 调用/aliyun/qrcode api生成二维码
async function getQrcode() {
try {
const result: { [key: string]: any } = await api.get('/u115/qrcode')
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
} else {
@@ -39,7 +39,7 @@ async function getQrcode() {
// 调用/aliyun/check api验证二维码
async function checkQrcode() {
try {
const result: { [key: string]: any } = await api.get('/u115/check')
const result: { [key: string]: any } = await api.get('/storage/check/u115')
if (result.success && result.data) {
const status = result.data.status
text.value = result.data.tip

View File

@@ -0,0 +1,423 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import type { User } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import api from '@/api'
import { useDisplay } from 'vuetify'
import avatar1 from '@images/avatars/avatar-1.png'
import store from '@/store'
// 显示器宽度
const display = useDisplay()
const refInputEl = ref<HTMLElement>()
const isNewPasswordVisible = ref(false)
const isConfirmPasswordVisible = ref(false)
const newPassword = ref('')
const confirmPassword = ref('')
// 输入参数
const props = defineProps({
username: String,
usernames: Array,
oper: String,
})
// 当前登录用户名称
const currentLoginUser = store.state.auth.userName
// 用户名
const userName = ref('')
// 当前头像缓存
const currentAvatar = ref(avatar1)
// 用户名缓存
const currentUserName = ref('')
// 注册事件
const emit = defineEmits(['save', 'close'])
// 创建新用户按钮运行状态
const isAdding = ref(false)
// 更新用户消息按钮运行状态
const isUpdating = ref(false)
// 提示框
const $toast = useToast()
// 状态下拉项
const statusItems = [
{ title: '激活', value: 1 },
{ title: '已停用', value: 0 },
]
// 用户编辑表单数据
const userForm = ref<User>({
id: 0,
name: props.username ?? '',
password: '',
email: '',
is_active: true,
is_superuser: false,
avatar: avatar1,
is_otp: false,
permissions: {},
settings: {
wechat_userid: null,
telegram_userid: null,
slack_userid: null,
vocechat_userid: null,
synologychat_userid: null,
},
})
// 更新头像
function changeAvatar(file: Event) {
const fileReader = new FileReader()
const { files } = file.target as HTMLInputElement
if (files && files.length > 0) {
const selectedFile = files[0]
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const maxSize = 800 * 1024
// 检查文件是否为图片
if (!allowedTypes.includes(selectedFile.type)) {
$toast.error('上传的文件不符合要求,请重新选择头像')
return
}
// 检查文件大小
if (selectedFile.size > maxSize) {
$toast.error('文件大小不得大于800KB')
return
}
fileReader.readAsDataURL(selectedFile)
fileReader.onload = () => {
if (typeof fileReader.result === 'string') {
currentAvatar.value = fileReader.result
$toast.success('新头像上传成功,待保存后生效!')
}
}
}
}
// 重置默认头像
function resetDefaultAvatar() {
currentAvatar.value = avatar1
$toast.success('已重置为默认头像,待保存后生效!')
}
// 还原当前头像
function restoreCurrentAvatar() {
currentAvatar.value = userForm.value.avatar
$toast.success('已还原当前使用头像!')
}
// 查询用户信息
async function fetchUserInfo() {
try {
userForm.value = await api.get(`user/${props.username}`)
if (userForm.value) {
userForm.value.avatar = userForm.value.avatar || avatar1
currentAvatar.value = userForm.value.avatar
currentUserName.value = userForm.value.name
userName.value = userForm.value.name
}
} catch (error) {
console.error(error)
}
}
// 调用API 新增用户
async function addUser() {
if (isAdding.value) {
$toast.error(`正在创建【${userForm.value.name}】用户,请稍后`)
return
}
if (!currentUserName.value) {
$toast.error('用户名不能为空')
return
} else userForm.value.name = currentUserName.value
// 重名检查
if (props.usernames && props.usernames.includes(userForm.value.name)) {
$toast.error('用户名已存在')
return
}
if (!userForm.value?.name || !newPassword.value) return
if (newPassword.value || confirmPassword.value) {
if (newPassword.value !== confirmPassword.value) {
$toast.error('两次输入的密码不一致')
return
}
userForm.value.password = newPassword.value
}
isAdding.value = true
startNProgress()
try {
const result: { [key: string]: string } = await api.post('user/', userForm.value)
if (result.success) {
$toast.success(`用户【${userForm.value.name}】创建成功`)
emit('save')
} else {
$toast.error(`创建用户失败:${result.message}`)
// 清除用户名
userForm.value.name = ''
}
} catch (error) {
console.error(error)
}
doneNProgress()
isAdding.value = false
}
// 调用API更新用户信息
async function updateUser() {
if (isUpdating.value) {
$toast.error(`正在更新【${userForm.value.name}】用户,请稍后`)
return
}
if (!currentUserName.value) {
$toast.error('用户名不能为空')
return
}
if (newPassword.value || confirmPassword.value) {
if (newPassword.value !== confirmPassword.value) {
$toast.error('两次输入的密码不一致')
return
}
userForm.value.password = newPassword.value
}
const oldUserName = userForm.value.name
userForm.value.name = currentUserName.value
const oldAvatar = userForm.value.avatar
userForm.value.avatar = currentAvatar.value
isUpdating.value = true
startNProgress()
try {
const result: { [key: string]: any } = await api.put('user/', userForm.value)
if (result.success) {
if (oldUserName !== currentUserName.value) {
$toast.success(`${oldUserName}】更名【${currentUserName.value}】, 更新成功!`)
// 如果是当前登录用户,更新当前用户名称显示
if (isCurrentUser.value) store.commit('auth/setUserName', currentUserName.value)
} else {
$toast.success(`${userForm.value?.name}】更新成功!`)
}
// 更新本地头像显示
if (oldAvatar !== currentAvatar.value && isCurrentUser.value) {
store.commit('auth/setAvatar', currentAvatar.value)
}
emit('save')
} else {
if (oldUserName !== currentUserName.value) {
$toast.error(`${oldUserName}】更名【${currentUserName.value}】, 更新失败:${result.message}`)
currentUserName.value = oldUserName
} else {
$toast.error(`${userForm.value?.name}】更新失败:${result.message}`)
}
}
//失败缓存值还原
currentUserName.value = userForm.value.name
userForm.value.name = oldUserName
currentAvatar.value = userForm.value.avatar
userForm.value.avatar = oldAvatar
userForm.value.password = ''
} catch (error) {
$toast.error(`${userForm.value?.name}】更新失败!`)
console.error(error)
}
doneNProgress()
isUpdating.value = false
}
// 用户状态转换true/false转换为1/0
const userStatus = computed({
get: () => (userForm.value.is_active ? 1 : 0),
set: (value: number) => {
userForm.value.is_active = value === 1
},
})
// 计算是否有用户管理权限
const canControl = computed(() => {
// 新增用户时,有权限
if (props.oper === 'add') {
return true
} else {
// 调用isCurrentUser函数判断是否为当前用户
return !isCurrentUser.value
}
})
// 检查是否为当前用户
const isCurrentUser = computed(() => {
return props.username === currentLoginUser
})
onMounted(() => {
if (props.oper !== 'add') {
fetchUserInfo()
}
})
</script>
<template>
<VDialog scrollable :close-on-back="false" persistent eager max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard
:title="`${props.oper === 'add' ? '新增' : '编辑'}用户${props.oper !== 'add' ? ` - ${userName}` : ''}`"
class="rounded-t"
>
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText class="d-flex">
<!-- 👉 Avatar -->
<VAvatar rounded="lg" size="100" class="me-6" :image="currentAvatar" />
<!-- 👉 Upload Photo -->
<form class="d-flex flex-column justify-center gap-5">
<div class="d-flex flex-wrap gap-2">
<VBtn color="primary" @click="refInputEl?.click()">
<VIcon icon="mdi-cloud-upload-outline" />
<span v-if="display.mdAndUp.value" class="ms-2">上传新头像</span>
</VBtn>
<input ref="refInputEl" type="file" name="file" accept=".jpeg,.png,.jpg,GIF" hidden @input="changeAvatar" />
<VBtn type="reset" color="info" variant="tonal" @click="restoreCurrentAvatar" v-if="props.oper !== 'add'">
<VIcon icon="mdi-refresh" />
<span v-if="display.mdAndUp.value" class="ms-2">重置</span>
</VBtn>
<VBtn
type="reset"
:color="props.oper === 'add' ? 'info' : 'error'"
variant="tonal"
@click="resetDefaultAvatar"
>
<VIcon icon="mdi-image-sync-outline" />
<span v-if="display.mdAndUp.value" class="ms-2">默认</span>
</VBtn>
</div>
<p class="text-body-1 mb-0">允许 JPGPNGGIFWEBP 格式 最大尺寸 800KB</p>
</form>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}" class="mt-3">
<VDivider class="my-10">
<span>用户基础设置</span>
</VDivider>
<VRow>
<VCol md="6" cols="12">
<VTextField
v-model="currentUserName"
density="comfortable"
:readonly="props.oper !== 'add'"
label="用户名"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="userForm.email" density="comfortable" clearable label="邮箱" type="email" />
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="newPassword"
density="comfortable"
:type="isNewPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
clearable
label="密码"
autocomplete=""
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
/>
</VCol>
<VCol cols="12" md="6">
<!-- 👉 confirm password -->
<VTextField
v-model="confirmPassword"
density="comfortable"
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
clearable
label="确认密码"
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
/>
</VCol>
<VCol cols="12" md="6" v-if="canControl">
<VSelect
v-model="userStatus"
:items="statusItems"
item-text="title"
item-value="value"
label="状态"
dense
/>
</VCol>
</VRow>
<VDivider class="my-10">
<span>消息账号绑定</span>
</VDivider>
<VRow>
<VCol cols="12" md="6">
<VTextField v-model="userForm.settings.wechat_userid" density="comfortable" clearable label="微信用户" />
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.telegram_userid"
density="comfortable"
clearable
label="Telegram用户"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField v-model="userForm.settings.slack_userid" density="comfortable" clearable label="Slack用户" />
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.vocechat_userid"
density="comfortable"
clearable
label="VoceChat用户"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.synologychat_userid"
density="comfortable"
clearable
label="SynologyChat用户"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn
v-if="props.oper === 'add'"
:disabled="isAdding"
color="primary"
variant="elevated"
@click="addUser"
prepend-icon="mdi-plus"
class="px-5"
>
<span v-if="isAdding">创建中...</span>
<span v-else>创建</span>
</VBtn>
<VBtn
v-else
:disabled="isUpdating"
color="primary"
variant="elevated"
@click="updateUser"
prepend-icon="mdi-content-save"
class="px-5"
>
<span v-if="isUpdating">更新中...</span>
<span v-else>更新</span>
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -6,7 +6,6 @@ import { useToast } from 'vue-toast-notification'
import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
import { formatBytes } from '@core/utils/formatters'
import type { Context, EndPoints, FileItem } from '@/api/types'
import store from '@/store'
import api from '@/api'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
@@ -16,9 +15,7 @@ import { useDisplay } from 'vuetify'
const display = useDisplay()
// APP
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
const appMode = inject('pwaMode') && display.mdAndDown.value
// 输入参数
const inProps = defineProps({
@@ -105,18 +102,19 @@ const dirs = computed(() => items.value.filter(item => item.type === 'dir' && it
// 文件过滤
const files = computed(() => items.value.filter(item => item.type === 'file' && item.name.includes(filter.value)))
// 是否目录
const isDir = computed(() => inProps.item.path?.endsWith('/'))
// 是否文件
const isFile = computed(() => !isDir.value)
const isFile = computed(() => inProps.item.type == 'file')
// 需要整理的文件项
const transferItems = ref<FileItem[]>([])
// 当前图片地址
const currentImgLink = ref('')
// 大小控制
const scrollStyle = computed(() => {
return appMode.value
return appMode
? 'height: calc(100vh - 15.5rem - env(safe-area-inset-bottom) - 3.5rem)'
: 'height: calc(100vh - 14.5rem - env(safe-area-inset-bottom)'
})
@@ -124,7 +122,7 @@ const scrollStyle = computed(() => {
// 是否为图片文件
const isImage = computed(() => {
const ext = inProps.item.path?.split('.').pop()?.toLowerCase()
return ['png', 'jpg', 'jpeg', 'gif', 'bmp'].includes(ext ?? '')
return ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(ext ?? '')
})
// 调整选择模式
@@ -139,9 +137,7 @@ async function list_files() {
emit('loading', true)
// 参数
const url = inProps.endpoints?.list.url
.replace(/{storage}/g, inProps.storage)
.replace(/{sort}/g, inProps.sort || 'name')
const url = inProps.endpoints?.list.url.replace(/{sort}/g, inProps.sort || 'name')
const config: AxiosRequestConfig<FileItem> = {
url,
@@ -169,7 +165,7 @@ async function deleteItem(item: FileItem, confirm: boolean = true) {
emit('loading', true)
// 请求API
const url = inProps.endpoints?.delete.url.replace(/{storage}/g, inProps.storage)
const url = inProps.endpoints?.delete.url
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.delete.method || 'post',
@@ -234,23 +230,51 @@ function listItemClick(item: FileItem) {
// 新窗口中下载文件
async function download(item: FileItem) {
const url = inProps.endpoints?.download.url.replace(/{storage}/g, inProps.storage)
const filterEntries = Object.entries(item).filter(([key, value]) => !['children', 'thumbnail'].includes(key) && value)
const queryParams = filterEntries.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')
window.open(
`${import.meta.env.VITE_API_BASE_URL}${url.slice(1)}?${queryParams}&token=${store.state.auth.token}`,
'_blank',
)
const url = inProps.endpoints?.download.url
// 下载文件
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.download.method || 'post',
data: item,
responseType: 'blob',
}
// 加载数据
const result: Blob = await inProps.axios.request(config)
if (result) {
const downloadUrl = URL.createObjectURL(result)
window.open(downloadUrl, '_blank')
}
}
// 获取图片地址
function getImgLink(item: FileItem) {
let url = inProps.endpoints?.image.url.replace(/{storage}/g, inProps.storage)
const filterEntries = Object.entries(item).filter(([key, value]) => !['children', 'thumbnail'].includes(key) && value)
const queryParams = filterEntries.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')
return `${import.meta.env.VITE_API_BASE_URL}${url.slice(1)}?${queryParams}&token=${store.state.auth.token}`
async function getImgLink(item: FileItem) {
let url = inProps.endpoints?.image.url
// 下载文件
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.image.method || 'post',
data: item,
responseType: 'blob',
}
// 加载二进制数据
const result: Blob = await inProps.axios.request(config)
if (result) {
// 创建图片地址
currentImgLink.value = URL.createObjectURL(result)
}
}
// 如果当前是图片且是文件,则获取图片地址
watch(
() => inProps.item,
async () => {
if (isImage.value && isFile.value) {
await getImgLink(inProps.item)
}
},
{ immediate: true },
)
// 显示重命名弹窗
function showRenmae(item: FileItem) {
currentItem.value = item
@@ -300,9 +324,7 @@ async function rename() {
}
// 调API
let url = inProps.endpoints?.rename.url
.replace(/{storage}/g, inProps.storage)
.replace(/{newname}/g, encodeURIComponent(newName.value))
let url = inProps.endpoints?.rename.url.replace(/{newname}/g, encodeURIComponent(newName.value))
if (renameAll.value) {
url += '&recursive=true'
}
@@ -496,12 +518,7 @@ async function batchScrape() {
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '请稍候 ...'
const token = store.state.auth.token
progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename?token=${token}`,
)
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {
@@ -583,7 +600,7 @@ onMounted(() => {
</VCardText>
<!-- 图片 -->
<VCardText v-else-if="isFile && isImage && items.length > 0" class="grow d-flex justify-center align-center">
<VImg :src="getImgLink(items[0])" max-width="100%" max-height="100%" />
<VImg :src="currentImgLink" max-width="100%" max-height="100%" />
</VCardText>
<!-- 目录和文件列表 -->
<VCardText v-else-if="dirs.length || files.length" class="p-0">
@@ -602,7 +619,8 @@ onMounted(() => {
v-if="inProps.icons && item.extension"
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
/>
<VIcon v-else icon="mdi-folder-outline" />
<VIcon v-else-if="item.type == 'dir'" icon="mdi-folder-outline" />
<VIcon v-else icon="mdi-file-outline" />
</template>
</template>
<VListItemTitle v-text="item.name" />
@@ -709,8 +727,8 @@ onMounted(() => {
<ReorganizeDialog
v-if="transferPopper"
v-model="transferPopper"
:storage="inProps.storage"
:items="transferItems"
:target_storage="inProps.storage"
@done="transferDone"
@close="transferPopper = false"
/>

View File

@@ -63,7 +63,7 @@ const pathSegments = computed(() => {
// 当前存储
const storageObject = computed(() => {
return inProps.storages?.find(item => item.code === inProps.storage)
return inProps.storages?.find(item => item.value === inProps.storage)
})
// 切换存储
@@ -88,9 +88,7 @@ function goUp() {
// 创建目录
async function mkdir() {
emit('loading', true)
const url = inProps.endpoints?.mkdir.url
.replace(/{storage}/g, inProps.storage)
.replace(/{name}/g, newFolderName.value)
const url = inProps.endpoints?.mkdir.url.replace(/{name}/g, newFolderName.value)
const config: AxiosRequestConfig<FileItem> = {
url,
@@ -129,19 +127,19 @@ const sortIcon = computed(() => {
<VListItem
v-for="(item, index) in storages"
:key="index"
:disabled="item.code === storageObject?.code"
@click="changeStorage(item.code)"
:disabled="item.value === storageObject?.value"
@click="changeStorage(item.value)"
>
<template #prepend>
<Icon :icon="item.icon" />
</template>
<VListItemTitle>{{ item.name }}</VListItemTitle>
<VListItemTitle>{{ item.title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
<VBtn variant="text" :input-value="item.path === '/'" class="px-1" @click="changePath(inProps.itemstack[0])">
<VIcon :icon="storageObject?.icon" class="mr-2" />
{{ storageObject?.name }}
{{ storageObject?.title }}
</VBtn>
<template v-for="(segment, index) in pathSegments" :key="index">
<VBtn

View File

@@ -8,6 +8,11 @@ const props = defineProps({
root: {
type: String,
default: '/',
required: true,
},
storage: {
type: String,
default: 'local',
},
})
@@ -26,21 +31,19 @@ const treeItems = ref<FileItem[]>([
name: '/',
path: props.root,
children: [],
type: '',
type: 'dir',
basename: props.root,
extension: '',
size: 0,
modify_time: 0,
fileid: '',
parent_fileid: '',
storage: props.storage,
},
])
// 拉取子目录
async function fetchDirs(item: any) {
return api
.get('/local/listdir?path=' + item.path)
.post('/storage/list', item)
.then((data: any) => {
// 只添加目录到子目录
data = data.filter((i: any) => i.type === 'dir')
item.children.push(...data)
})
.catch(err => console.warn(err))
@@ -60,9 +63,24 @@ watch(activedDirs, newVal => {
emit('update:modelValue', selectedPath)
})
onMounted(() => {
fetchDirs(treeItems.value[0])
})
// 监听存储变化
watch(
() => props.storage,
async newVal => {
treeItems.value = [
{
name: '/',
path: props.root,
children: [],
type: 'dir',
basename: props.root,
storage: newVal,
},
]
openedDirs.value = []
activedDirs.value = []
},
)
</script>
<template>

View File

@@ -1,206 +0,0 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import api from '@/api'
import type { TorrentInfo } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { formatFileSize } from '@core/utils/formatters'
// 输入参数
const props = defineProps({
site: Number,
width: String,
height: String,
})
// 提示框
const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 数据列表
const resourceDataList = ref<TorrentInfo[]>([])
// 搜索
const resourceSearch = ref('')
// 总条数
const resourceTotalItems = ref(0)
// 每页条数
const resourceItemsPerPage = ref(25)
// 加载状态
const resourceLoading = ref(false)
// 资源浏览表头
const resourceHeaders = [
{ title: '标题', key: 'title', sortable: false },
{ title: '时间', key: 'pubdate', sortable: true },
{ title: '大小', key: 'size', sortable: true },
{ title: '做种', key: 'seeders', sortable: true },
{ title: '下载', key: 'peers', sortable: true },
{ title: '', key: 'actions', sortable: false },
]
// 打开种子详情页面
function openTorrentDetail(page_url: string) {
window.open(page_url, '_blank')
}
// 下载种子文件
async function downloadTorrentFile(enclosure: string) {
window.open(enclosure, '_blank')
}
// 调用API查询站点资源
async function getResourceList() {
resourceLoading.value = true
try {
resourceDataList.value = await api.get(`site/resource/${props.site}`)
resourceLoading.value = false
} catch (error) {
console.error(error)
}
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) return 'text-white bg-lime-500'
else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
else return 'text-white bg-gray-500'
}
// 添加下载
async function addDownload(_torrent: any) {
const isConfirmed = await createConfirm({
title: '确认',
content: `是否确认下载【${_torrent.site_name}${_torrent?.title} ?`,
})
if (!isConfirmed) return
startNProgress()
try {
const result: { [key: string]: any } = await api.post('download/add', _torrent)
if (result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
} else {
// 添加下载失败
$toast.error(`${_torrent?.site_name} ${_torrent?.title} 添加下载失败:${result.message || '未知错误'}`)
}
} catch (error) {
console.error(error)
}
doneNProgress()
}
// 装载时查询站点图标
onMounted(() => {
getResourceList()
})
</script>
<template>
<VDataTable
v-model:items-per-page="resourceItemsPerPage"
:headers="resourceHeaders"
:items="resourceDataList"
:items-length="resourceTotalItems"
:search="resourceSearch"
:loading="resourceLoading"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
>
<template #item.title="{ item }">
<a href="javascript:void(0)" @click.stop="addDownload(item)">
<div class="text-high-emphasis pt-1">
{{ item.title }}
</div>
<div class="text-sm my-1">
{{ item.description }}
</div>
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ item.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in item.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ item.volume_factor }}
</VChip>
</a>
</template>
<template #item.pubdate="{ item }">
<div>{{ item.date_elapsed }}</div>
<div class="text-sm">
{{ item.pubdate }}
</div>
</template>
<template #item.size="{ item }">
<div class="text-nowrap whitespace-nowrap">
{{ formatFileSize(item.size) }}
</div>
</template>
<template #item.seeders="{ item }">
<div>{{ item.seeders }}</div>
</template>
<template #item.peers="{ item }">
<div>{{ item.peers }}</div>
</template>
<template #item.actions="{ item }">
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail(item.page_url || '')">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="item.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile(item.enclosure)"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
<template #no-data> 没有数据 </template>
</VDataTable>
</template>

View File

@@ -14,22 +14,44 @@ import { NavMenu } from '@/@layouts/types'
import { useDisplay } from 'vuetify'
const display = useDisplay()
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
const appMode = inject('pwaMode')
// Vuex Storesuperuser
const superUser = store.state.auth.superUser
//
let superUser = store.state.auth.superUser
//
const startMenus = ref<NavMenu[]>([])
//
const discoveryMenus = ref<NavMenu[]>([])
//
const subscribeMenus = ref<NavMenu[]>([])
//
const organizeMenus = ref<NavMenu[]>([])
//
const systemMenus = ref<NavMenu[]>([])
//
const getMenuList = (header: string) => {
return SystemNavMenus.filter((item: NavMenu) => item.header === header && (!item.admin || superUser))
return SystemNavMenus.filter((item: NavMenu) => item.header === header && (superUser || !item.admin))
}
//
function goBack() {
history.back()
}
onMounted(() => {
//
startMenus.value = getMenuList('开始')
discoveryMenus.value = getMenuList('发现')
subscribeMenus.value = getMenuList('订阅')
organizeMenus.value = getMenuList('整理')
systemMenus.value = getMenuList('系统')
})
</script>
<template>
@@ -42,7 +64,7 @@ function goBack() {
<VIcon icon="mdi-menu" />
</IconBtn>
<!-- 👉 Back Button -->
<IconBtn v-if="appMode" class="ms-n2" @click="goBack">
<IconBtn v-if="appMode && display.mdAndDown.value" class="ms-n2" @click="goBack">
<VIcon icon="mdi-arrow-left" size="32" />
</IconBtn>
<!-- 👉 Search Bar -->
@@ -61,36 +83,39 @@ function goBack() {
</template>
<template #vertical-nav-content>
<VerticalNavLink v-for="item in getMenuList('开始')" :item="item" />
<VerticalNavLink v-for="item in startMenus" :item="item" />
<!-- 👉 发现 -->
<VerticalNavSectionTitle
v-if="discoveryMenus.length > 0"
:item="{
heading: '发现',
}"
/>
<VerticalNavLink v-for="item in getMenuList('发现')" :item="item" />
<VerticalNavLink v-for="item in discoveryMenus" :item="item" />
<!-- 👉 订阅 -->
<VerticalNavSectionTitle
v-if="subscribeMenus.length > 0"
:item="{
heading: '订阅',
}"
/>
<VerticalNavLink v-for="item in getMenuList('订阅')" :item="item" />
<VerticalNavLink v-for="item in subscribeMenus" :item="item" />
<!-- 👉 整理 -->
<VerticalNavSectionTitle
v-if="organizeMenus.length > 0"
:item="{
heading: '整理',
}"
/>
<VerticalNavLink v-for="item in getMenuList('整理')" :item="item" />
<VerticalNavLink v-for="item in organizeMenus" :item="item" />
<!-- 👉 系统 -->
<VerticalNavSectionTitle
v-if="superUser"
v-if="systemMenus.length > 0"
:item="{
heading: '系统',
}"
/>
<VerticalNavLink v-for="item in getMenuList('系统')" :item="item" />
<VerticalNavLink v-for="item in systemMenus" :item="item" />
</template>
<template #after-vertical-nav-items />

View File

@@ -2,9 +2,7 @@
import { useDisplay } from 'vuetify'
const display = useDisplay()
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
const appMode = inject('pwaMode') && display.mdAndDown.value
const route = useRoute()

View File

@@ -2,7 +2,6 @@
import * as Mousetrap from 'mousetrap'
import SearchBarView from '@/views/system/SearchBarView.vue'
import { useDisplay } from 'vuetify'
import { ref, computed } from 'vue'
const display = useDisplay()
@@ -21,7 +20,6 @@ function openSearchDialog() {
function isMac() {
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
}
// 计算属性:根据操作系统显示不同的按键提示
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
</script>
@@ -40,7 +38,6 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
<!-- 搜索弹窗 -->
<SearchBarView v-model="searchDialog" v-if="searchDialog" @close="searchDialog = false" />
</template>
<style type="scss" scoped>
.meta-key {
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
@@ -49,4 +46,4 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
padding-block: 0.1rem;
padding-inline: 0.25rem;
}
</style>
</style>

View File

@@ -5,7 +5,6 @@ import LoggingView from '@/views/system/LoggingView.vue'
import RuleTestView from '@/views/system/RuleTestView.vue'
import ModuleTestView from '@/views/system/ModuleTestView.vue'
import MessageView from '@/views/system/MessageView.vue'
import store from '@/store'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { getQueryValue } from '@/@core/utils'
@@ -55,8 +54,7 @@ function scrollMessageToEnd() {
// 拼接全部日志url
function allLoggingUrl() {
const token = store.state.auth.token
return `${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}&length=-1`
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
}
// 发送消息
@@ -145,8 +143,8 @@ onMounted(() => {
<VAvatar size="48" variant="tonal">
<VIcon icon="mdi-filter-cog-outline" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">优先级</h6>
<span class="text-sm">优先级规则测试</span>
<h6 class="text-base font-weight-medium mt-2 mb-0">规则</h6>
<span class="text-sm">规则测试</span>
</VListItem>
</VCol>
</VRow>
@@ -206,6 +204,7 @@ onMounted(() => {
<VDialog v-if="netTestDialog" v-model="netTestDialog" max-width="35rem" max-height="85vh" scrollable>
<VCard title="网络测试">
<DialogCloseBtn @click="netTestDialog = false" />
<VDivider />
<VCardText>
<NetTestView />
</VCardText>
@@ -234,6 +233,7 @@ onMounted(() => {
</a>
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<LoggingView />
</VCardText>
@@ -241,7 +241,7 @@ onMounted(() => {
</VDialog>
<!-- 规则测试弹窗 -->
<VDialog v-if="ruleTestDialog" v-model="ruleTestDialog" max-width="50rem" scrollable>
<VCard title="优先级测试">
<VCard title="规则测试">
<DialogCloseBtn @click="ruleTestDialog = false" />
<VCardText>
<RuleTestView />
@@ -252,6 +252,7 @@ onMounted(() => {
<VDialog v-if="systemTestDialog" v-model="systemTestDialog" max-width="35rem" max-height="85vh" scrollable>
<VCard title="系统健康检查">
<DialogCloseBtn @click="systemTestDialog = false" />
<VDivider />
<VCardText>
<ModuleTestView />
</VCardText>
@@ -267,23 +268,20 @@ onMounted(() => {
>
<VCard title="消息中心">
<DialogCloseBtn @click="messageDialog = false" />
<VDivider />
<VCardText ref="chatContainer">
<MessageView @scroll="scrollMessageToEnd" />
</VCardText>
<VCardItem>
<VTextField
v-model="user_message"
variant="solo"
placeholder="输入消息或命令"
outlined
hide-details
single-line
clearable
density="compact"
:disabled="sendButtonDisabled"
@keydown.enter="sendMessage"
>
<template #append>
<template #append-inner>
<VBtn color="primary" :disabled="sendButtonDisabled" @click="sendMessage"> 发送 </VBtn>
</template>
</VTextField>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import store from '@/store'
import { formatDateDifference } from '@core/utils/formatters'
import { SystemNotification } from '@/api/types'
@@ -17,9 +16,9 @@ const appsMenu = ref(false)
// SSE持续接收消息
function startSSEMessager() {
const token = store.state.auth.token
if (token) {
eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/message?token=${token}`)
// 延迟 3 秒启动 SSE避免相关认证信息尚未写入 Cookie 导致 403
setTimeout(() => {
eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/message`)
eventSource.addEventListener('message', event => {
if (event.data) {
const noti: SystemNotification = JSON.parse(event.data)
@@ -28,7 +27,7 @@ function startSSEMessager() {
// TODO 在顶部显示消息汽泡
}
})
}
}, 3000)
}
// 页面加载时,加载当前用户数据

View File

@@ -6,9 +6,6 @@ import router from '@/router'
import avatar1 from '@images/avatars/avatar-1.png'
import api from '@/api'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useDisplay } from 'vuetify'
const display = useDisplay()
// Vuex Store
const store = useStore()
@@ -59,27 +56,16 @@ async function restart() {
}
}
// 是否精简模式
const isCompactMode = ref(localStorage.getItem('MP_APPMODE') != '0')
// 从Vuex Store中获取信息
const superUser = store.state.auth.superUser
const userName = store.state.auth.userName
const avatar = store.state.auth.avatar
// 监听精简模式切换
watch(isCompactMode, value => {
localStorage.setItem('MP_APPMODE', value ? '1' : '0')
//刷新页面
location.reload()
})
const superUser = computed(() => store.state.auth.superUser)
const userName = computed(() => store.state.auth.userName)
const avatar = computed(() => store.state.auth.avatar || avatar1)
</script>
<template>
<VAvatar class="cursor-pointer ms-3" color="primary" variant="tonal">
<VImg :src="avatar ?? avatar1" />
<VImg :src="avatar" />
<!-- SECTION Menu -->
<VMenu activator="parent" width="230" location="bottom end" offset="14px">
<VList>
<!-- 👉 User Avatar & Name -->
@@ -87,7 +73,7 @@ watch(isCompactMode, value => {
<template #prepend>
<VListItemAction start>
<VAvatar color="primary" variant="tonal">
<VImg :src="avatar ?? avatar1" />
<VImg :src="avatar" />
</VAvatar>
</VListItemAction>
</template>
@@ -98,24 +84,14 @@ watch(isCompactMode, value => {
<VListItemSubtitle>{{ userName }}</VListItemSubtitle>
</VListItem>
<!-- Divider -->
<VDivider v-if="display.mdAndDown.value" class="my-2" />
<!-- 👉 AppMode -->
<VListItem v-if="display.mdAndDown.value">
<template #prepend>
<VSwitch class="me-2" v-model="isCompactMode"></VSwitch>
</template>
<VListItemTitle>App模式</VListItemTitle>
</VListItem>
<VDivider class="my-2" />
<!-- 👉 Profile -->
<VListItem v-if="superUser" link @click="router.push('/setting?tab=account')">
<VListItem link @click="router.push('/profile')">
<template #prepend>
<VIcon class="me-2" icon="mdi-account-outline" size="22" />
</template>
<VListItemTitle>设定</VListItemTitle>
<VListItemTitle>个人信息</VListItemTitle>
</VListItem>
<!-- 👉 FAQ -->
@@ -123,7 +99,7 @@ watch(isCompactMode, value => {
<template #prepend>
<VIcon class="me-2" icon="mdi-help-circle-outline" size="22" />
</template>
<VListItemTitle>帮助</VListItemTitle>
<VListItemTitle>帮助文档</VListItemTitle>
</VListItem>
<!-- Divider -->

View File

@@ -1,18 +1,18 @@
<script lang="ts" setup>
import DefaultLayoutWithVerticalNav from './components/DefaultLayoutWithVerticalNav.vue'
import DefaultLayout from './components/DefaultLayout.vue'
const route = useRoute()
</script>
<template>
<DefaultLayoutWithVerticalNav>
<DefaultLayout>
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" v-if="route.meta.keepAlive" :key="route.fullPath" />
</keep-alive>
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
</router-view>
</DefaultLayoutWithVerticalNav>
</DefaultLayout>
</template>
<style lang="scss">

View File

@@ -1,14 +1,16 @@
import '@/@core/utils/compatibility'
import './ace-config'
import '@/@iconify/icons-bundle'
import '@/plugins/webfontloader'
import App from '@/App.vue'
import vuetify from '@/plugins/vuetify'
import router from '@/router'
import store from '@/store'
import { VAceEditor } from 'vue3-ace-editor'
import { createApp } from 'vue'
import { removeEl } from './@core/utils/dom'
import { fetchGlobalSettings } from './api'
import { isPWA } from './@core/utils/navigator'
import './ace-config'
import { VAceEditor } from 'vue3-ace-editor'
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
import { VTreeview } from 'vuetify/labs/VTreeview'
import ToastPlugin from 'vue-toast-notification'
@@ -32,50 +34,65 @@ import 'vue3-perfect-scrollbar/style.css'
// 创建Vue实例
const app = createApp(App)
// 注册全局组件
app
.component('VAceEditor', VAceEditor)
.component('VApexChart', VueApexCharts)
.component('VDialogCloseBtn', DialogCloseBtn)
.component('VMediaCard', MediaCard)
.component('VPosterCard', PosterCard)
.component('VBackdropCard', BackdropCard)
.component('VPersonCard', PersonCard)
.component('VMediaInfoCard', MediaInfoCard)
.component('VTorrentCard', TorrentCard)
.component('VMediaIdSelector', MediaIdSelector)
.component('VTreeview', VTreeview)
.component('VPathField', PathField)
async function initializeApp() {
try {
// 是否为PWA
const pwaMode = await isPWA()
app.provide('pwaMode', pwaMode)
// 全局设置
const globalSettings = await fetchGlobalSettings()
app.provide('globalSettings', globalSettings)
} catch (error) {
console.error('Failed to initialize app', error)
}
}
// 注册
app
.use(vuetify)
.use(router)
.use(store)
.use(ToastPlugin, {
position: 'bottom-right',
})
.use(VuetifyUseDialog, {
confirmDialog: {
dialogProps: {
maxWidth: '40rem',
// 注册全局组
initializeApp().then(() => {
app
.component('VAceEditor', VAceEditor)
.component('VApexChart', VueApexCharts)
.component('VDialogCloseBtn', DialogCloseBtn)
.component('VMediaCard', MediaCard)
.component('VPosterCard', PosterCard)
.component('VBackdropCard', BackdropCard)
.component('VPersonCard', PersonCard)
.component('VMediaInfoCard', MediaInfoCard)
.component('VTorrentCard', TorrentCard)
.component('VMediaIdSelector', MediaIdSelector)
.component('VTreeview', VTreeview)
.component('VPathField', PathField)
// 注册插件
app
.use(vuetify)
.use(router)
.use(store)
.use(ToastPlugin, {
position: 'bottom-right',
})
.use(VuetifyUseDialog, {
confirmDialog: {
dialogProps: {
maxWidth: '40rem',
},
confirmationButtonProps: {
variant: 'elevated',
color: 'primary',
class: 'me-3 px-5',
'prepend-icon': 'mdi-check',
},
cancellationButtonProps: {
variant: 'outlined',
color: 'secondary',
class: 'me-3',
},
confirmationText: '确认',
cancellationText: '取消',
},
confirmationButtonProps: {
variant: 'elevated',
color: 'primary',
class: 'me-3 px-5',
'prepend-icon': 'mdi-check',
},
cancellationButtonProps: {
variant: 'outlined',
color: 'secondary',
class: 'me-3',
},
confirmationText: '确认',
cancellationText: '取消',
},
})
.use(PerfectScrollbarPlugin)
.use(VueApexCharts)
.mount('#app')
.$nextTick(() => removeEl('#loading-bg'))
})
.use(PerfectScrollbarPlugin)
.use(VueApexCharts)
.mount('#app')
.$nextTick(() => removeEl('#loading-bg'))
})

View File

@@ -12,7 +12,7 @@ const appOrder = ref<string[]>([])
// 根据分类获取菜单列表
const getMenuList = () => {
return SystemNavMenus.filter((item: NavMenu) => !item.admin || superUser)
return SystemNavMenus.filter((item: NavMenu) => (!item.admin || superUser) && !item.footer)
}
// APP列表
@@ -48,7 +48,7 @@ onMounted(() => {
:component-data="{ 'class': 'ma-0 mt-n1' }"
>
<template #item="{ element }">
<VCol cols="6" md="4" lg="3" class="text-center cursor-pointer shortcut-icon select-none">
<VCol cols="6" md="3" lg="2" class="text-center cursor-pointer shortcut-icon select-none">
<VCard class="pa-4" :to="element.to" variant="flat">
<VAvatar size="64" variant="text">
<VIcon size="48" :icon="element.icon" color="primary" />

View File

@@ -9,9 +9,7 @@ import { useDisplay } from 'vuetify'
// APP
const display = useDisplay()
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
const appMode = inject('pwaMode') && display.mdAndDown.value
// 从Vuex Store中获取superuser信息
const superUser = store.state.auth.superUser
@@ -192,25 +190,21 @@ function sortDashboardConfigs() {
// 设置项目
async function saveDashboardConfig() {
// 启用配置
const data = JSON.stringify(enableConfig.value)
localStorage.setItem('MP_DASHBOARD', data)
const enableString = JSON.stringify(enableConfig.value)
localStorage.setItem('MP_DASHBOARD', enableString)
// 顺序配置从dashboardConfigs中提取
const order = JSON.stringify(dashboardConfigs.value.map(item => ({ id: item.id, key: item.key })))
localStorage.setItem('MP_DASHBOARD_ORDER', order)
const orderObj = dashboardConfigs.value.map(item => ({ id: item.id, key: item.key }))
const orderString = JSON.stringify(orderObj)
localStorage.setItem('MP_DASHBOARD_ORDER', orderString)
// 是否拉升高度
localStorage.setItem('MP_DASHBOARD_ELEVATED', isElevated.value.toString())
// 保存到服务端
try {
await api.post('/user/config/Dashboard', data, {
headers: {
'Content-Type': 'application/json',
},
})
await api.post('/user/config/DashboardOrder', order, {
headers: {
'Content-Type': 'application/json',
},
})
await api.post('/user/config/Dashboard', enableConfig.value)
await api.post('/user/config/DashboardOrder', orderObj)
} catch (error) {
console.error(error)
}

View File

@@ -1,7 +1,61 @@
<script setup lang="ts">
import api from '@/api'
import { DownloaderConf } from '@/api/types'
import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
import router from '@/router'
import NoDataFound from '@/components/NoDataFound.vue'
const route = useRoute()
const activeTab = ref(route.query.tab)
// 下载器
const downloaders = ref<DownloaderConf[]>([])
// 获取启用的下载器
const enabledDownloaders = computed(() => downloaders.value.filter(item => item.enabled))
// 调用API查询下载器设置
async function loadDownloaderSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/Downloaders')
if (result.data?.value && result.data.value.length > 0) {
downloaders.value = result.data?.value ?? []
if (!activeTab.value) activeTab.value = downloaders.value[0].name
}
} catch (error) {
console.log(error)
}
}
function jumpTab(tab: string) {
router.push('/subscribe/movie?tab=' + tab)
}
onMounted(() => {
loadDownloaderSetting()
})
</script>
<template>
<DownloadingListView />
<div v-if="enabledDownloaders.length > 0">
<VTabs v-model="activeTab">
<VTab v-for="item in enabledDownloaders" :value="item.name" @to="jumpTab(item.name)">
<span class="mx-5">{{ item.name }}</span>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem v-for="item in enabledDownloaders" :value="item.name">
<transition name="fade-slide" appear>
<DownloadingListView :name="item.name" />
</transition>
</VWindowItem>
</VWindow>
</div>
<NoDataFound
v-else
error-code="404"
error-title="没有下载器"
error-description="请先在设置中正确配置并启用下载器"
/>
</template>

View File

@@ -31,14 +31,9 @@ const isPasswordVisible = ref(false)
// 错误信息
const errorMessage = ref('')
// 背景图片
const backgroundImageUrl = ref('')
// 所有的背景图片
// 背景图片 URL 和预加载 URL
const backgroundImages = ref<string[]>([])
// 背景图片加载状态
const isImageLoaded = ref(false)
const activeImageIndex = ref(0)
// 是否开启双重验证
const isOTP = ref(false)
@@ -53,9 +48,6 @@ let intervalTimer: NodeJS.Timeout | null = null
async function fetchBackgroundImage() {
try {
backgroundImages.value = await api.get('/login/wallpapers')
if (backgroundImages.value && backgroundImages.value.length > 0) {
backgroundImageUrl.value = backgroundImages.value[0]
}
} catch (e) {
console.log(e)
}
@@ -80,9 +72,9 @@ const fetchOTP = debounce(async () => {
// 获取用户主题配置
async function fetchThemeConfig() {
const response = await api.get('/user/config/theme')
const response = await api.get('/user/config/Layout')
if (response && response.data && response.data.value) {
return response.data.value
return response.data.value?.theme
}
return null
}
@@ -159,13 +151,24 @@ function login() {
// 获取token
const token = response.access_token
const superUser = response.super_user
const userID = response.user_id
const userName = response.user_name
const avatar = response.avatar
const level = response.level
const remember = form.value.remember
const permissions = response.permissions
// 更新token和remember状态到Vuex Store
store.dispatch('auth/login', { token, remember, superUser, userName, avatar, level })
store.dispatch('auth/login', {
token,
remember,
superUser,
userID,
userName,
avatar,
level,
permissions,
})
// 登录后处理
afterLogin(superUser)
@@ -180,6 +183,13 @@ function login() {
})
}
// 初始化背景图片轮循
function startBackgroundRotation() {
intervalTimer = setInterval(() => {
activeImageIndex.value = (activeImageIndex.value + 1) % backgroundImages.value.length
}, 5000) // 每5秒切换一次图片
}
// 自动登录
onMounted(async () => {
// 从Vuex Store中获取token和remember状态
@@ -192,14 +202,9 @@ onMounted(async () => {
} else {
// 获取背景图片
await fetchBackgroundImage()
// 每隔5秒更换一次背景图片
intervalTimer = setInterval(() => {
if (backgroundImages.value.length > 0) {
const index = Math.floor(Math.random() * backgroundImages.value.length)
backgroundImageUrl.value = backgroundImages.value[index]
}
}, 5000)
if (backgroundImages.value.length > 1) {
startBackgroundRotation()
}
}
})
@@ -209,78 +214,76 @@ onUnmounted(() => {
</script>
<template>
<template v-for="image in backgroundImages">
<div v-if="backgroundImageUrl == image" class="absolute inset-0">
<VImg :src="image" class="w-full h-full" cover position="center top" @load="isImageLoaded = true">
<template #placeholder>
<VSkeletonLoader v-if="!isImageLoaded" class="object-cover" />
</template>
<!-- 当前背景图片 -->
<div class="relative flex min-h-screen flex-col bg-gray-900 items-center justify-center">
<div>
<div
v-for="(imageUrl, index) in backgroundImages"
class="absolute-top-shift absolute inset-0 bg-cover bg-center transition-opacity duration-300 ease-in"
:class="{ 'opacity-100': index === activeImageIndex, 'opacity-0': index !== activeImageIndex }"
>
<VImg :src="imageUrl" class="absolute inset-0 transition-opacity duration-1000" cover position="center top" />
<div
class="absolute inset-0"
style="background-image: linear-gradient(rgba(45, 55, 72, 33%) 0%, rgb(26, 32, 46) 100%)"
style="background-image: linear-gradient(rgba(45, 55, 72, 47%) 0%, rgb(26, 32, 46) 100%)"
/>
</VImg>
</div>
</div>
<!-- 登录表单 -->
<div class="auth-wrapper d-flex align-center justify-center">
<VCard class="auth-card px-7 py-3 w-full h-full rounded-lg opacity-85" max-width="24rem">
<VCardItem class="justify-center">
<template #prepend>
<div class="d-flex pe-0">
<VImg :src="logo" width="64" height="64" />
</div>
</template>
<VCardTitle class="font-weight-bold text-2xl text-uppercase"> MoviePilot </VCardTitle>
</VCardItem>
<VCardText>
<VForm ref="refForm" @submit.prevent="() => {}">
<VRow>
<!-- username -->
<VCol cols="12">
<VTextField
ref="usernameInput"
v-model="form.username"
label="用户名"
type="text"
:rules="[requiredValidator]"
@input="fetchOTP"
/>
</VCol>
<!-- password -->
<VCol cols="12">
<VTextField
v-model="form.password"
label="密码"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12">
<VTextField v-if="isOTP" v-model="form.otp_password" label="双重验证码" type="input" />
<!-- remember me checkbox -->
<div class="d-flex align-center justify-space-between flex-wrap">
<VCheckbox v-model="form.remember" label="保持登录" required />
</div>
</VCol>
<VCol cols="12">
<!-- login button -->
<VBtn block type="submit" @click="login"> 登录 </VBtn>
<div v-if="errorMessage" class="text-error mt-2 text-shadow">
{{ errorMessage }}
</div>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</div>
</template>
<div class="auth-wrapper d-flex align-center justify-center pa-4">
<VCard
class="auth-card px-7 py-3 w-full h-full rounded-lg"
:class="{ 'opacity-85': isImageLoaded }"
max-width="24rem"
>
<VCardItem class="justify-center">
<template #prepend>
<div class="d-flex pe-0">
<VImg :src="logo" width="64" height="64" />
</div>
</template>
<VCardTitle class="font-weight-semibold text-2xl text-uppercase"> MoviePilot </VCardTitle>
</VCardItem>
<VCardText>
<VForm ref="refForm" @submit.prevent="() => {}">
<VRow>
<!-- username -->
<VCol cols="12">
<VTextField
ref="usernameInput"
v-model="form.username"
label="用户名"
type="text"
:rules="[requiredValidator]"
@input="fetchOTP"
/>
</VCol>
<!-- password -->
<VCol cols="12">
<VTextField
v-model="form.password"
label="密码"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12">
<VTextField v-if="isOTP" v-model="form.otp_password" label="双重验证码" type="input" />
<!-- remember me checkbox -->
<div class="d-flex align-center justify-space-between flex-wrap">
<VCheckbox v-model="form.remember" label="保持登录" required />
</div>
</VCol>
<VCol cols="12">
<!-- login button -->
<VBtn block type="submit" @click="login"> 登录 </VBtn>
<div v-if="errorMessage" class="text-error mt-2 text-shadow">
{{ errorMessage }}
</div>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</div>
</template>
@@ -290,4 +293,13 @@ onUnmounted(() => {
.v-card-item__prepend {
padding-inline-end: 0 !important;
}
.absolute-top-shift {
inset-block-start: calc(-4rem - env(safe-area-inset-top));
}
.auth-wrapper {
overflow: hidden;
block-size: auto;
}
</style>

9
src/pages/profile.vue Normal file
View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import UserProfileView from '@/views/user/UserProfileView.vue'
</script>
<template>
<div>
<UserProfileView />
</div>
</template>

View File

@@ -2,16 +2,13 @@
import NoDataFound from '@/components/NoDataFound.vue'
import api from '@/api'
import type { Context } from '@/api/types'
import store from '@/store'
import TorrentCardListView from '@/views/discover/TorrentCardListView.vue'
import TorrentRowListView from '@/views/discover/TorrentRowListView.vue'
import { useDisplay } from 'vuetify'
// APP
const display = useDisplay()
const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
const appMode = inject('pwaMode') && display.mdAndDown.value
// 路由参数
const route = useRoute()
@@ -55,12 +52,7 @@ const errorDescription = ref('未搜索到任何资源')
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '正在搜索,请稍候...'
const token = store.state.auth.token
progressEventSource.value = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/progress/search?token=${token}`,
)
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/search`)
progressEventSource.value.onmessage = event => {
const progress = JSON.parse(event.data)
if (progress) {

View File

@@ -1,16 +1,16 @@
<script lang="ts" setup>
import { useRoute } from 'vue-router'
import router from '@/router'
import AccountSettingAccount from '@/views/setting/AccountSettingAccount.vue'
import AccountSettingNotification from '@/views/setting/AccountSettingNotification.vue'
import AccountSettingSite from '@/views/setting/AccountSettingSite.vue'
import AccountSettingWords from '@/views/setting/AccountSettingWords.vue'
import AccountSettingAbout from '@/views/setting/AccountSettingAbout.vue'
import AccountSettingSearch from '@/views/setting/AccountSettingSearch.vue'
import AccountSettingSubscribe from '@/views/setting/AccountSettingSubscribe.vue'
import AccountSettingService from '@/views/setting/AccountSettingService.vue'
import AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'
import AccountSettingScheduler from '@/views/setting/AccountSettingScheduler.vue'
import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
import { SettingTabs } from '@/router/menu'
const route = useRoute()
@@ -32,7 +32,7 @@ function jumpTab(tab: string) {
@click="jumpTab(item.tab)"
selected-class="v-slide-group-item--active v-tab--selected"
>
<div>
<div class="flex align-center">
<VIcon size="20" start :icon="item.icon" />
{{ item.title }}
</div>
@@ -40,16 +40,7 @@ function jumpTab(tab: string) {
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<!-- 用户 -->
<VWindowItem value="account">
<transition name="fade-slide" appear>
<div>
<AccountSettingAccount />
</div>
</transition>
</VWindowItem>
<!-- 连接 -->
<!-- 系统 -->
<VWindowItem value="system">
<transition name="fade-slide" appear>
<div>
@@ -76,6 +67,13 @@ function jumpTab(tab: string) {
</transition>
</VWindowItem>
<!-- 规则 -->
<VWindowItem value="rule">
<transition name="fade-slide" appear>
<AccountSettingRule />
</transition>
</VWindowItem>
<!-- 搜索 -->
<VWindowItem value="search">
<transition name="fade-slide" appear>
@@ -95,10 +93,10 @@ function jumpTab(tab: string) {
</VWindowItem>
<!-- 服务 -->
<VWindowItem value="service">
<VWindowItem value="scheduler">
<transition name="fade-slide" appear>
<div>
<AccountSettingService />
<AccountSettingScheduler />
</div>
</transition>
</VWindowItem>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
import { SubscribeMovieTabs } from '@/router/menu'
import router from '@/router'
@@ -21,6 +22,9 @@ function jumpTab(tab: string) {
<VTab v-for="item in SubscribeMovieTabs" :value="item.tab" @to="jumpTab(item.tab)">
<span class="mx-5">{{ item.title }}</span>
</VTab>
<VTab v-if="subType == '电视剧'" value="share" @to="jumpTab('share')">
<span class="mx-5">订阅分享</span>
</VTab>
</VTabs>
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
@@ -34,6 +38,11 @@ function jumpTab(tab: string) {
<SubscribePopularView :type="subType" />
</transition>
</VWindowItem>
<VWindowItem value="share">
<transition name="fade-slide" appear>
<SubscribeShareView />
</transition>
</VWindowItem>
</VWindow>
</div>
</template>

9
src/pages/user.vue Normal file
View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import UserListView from '@/views/user/UserListView.vue'
</script>
<template>
<div>
<UserListView />
</div>
</template>

View File

@@ -90,6 +90,22 @@ const router = createRouter({
requiresAuth: true,
},
},
{
path: '/user',
component: () => import('../pages/user.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
{
path: '/profile',
component: () => import('../pages/profile.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
{
path: '/plugins',
component: () => import('../pages/plugin.vue'),

View File

@@ -6,6 +6,7 @@ export const SystemNavMenus = [
to: '/dashboard',
header: '开始',
admin: false,
footer: true,
},
{
title: '推荐',
@@ -13,6 +14,7 @@ export const SystemNavMenus = [
to: '/ranking',
header: '发现',
admin: false,
footer: true,
},
{
title: '资源搜索',
@@ -28,6 +30,7 @@ export const SystemNavMenus = [
to: '/subscribe/movie',
header: '订阅',
admin: false,
footer: true,
},
{
title: '电视剧',
@@ -36,6 +39,7 @@ export const SystemNavMenus = [
to: '/subscribe/tv',
header: '订阅',
admin: false,
footer: true,
},
{
title: '日历',
@@ -80,6 +84,13 @@ export const SystemNavMenus = [
header: '系统',
admin: true,
},
{
title: '用户管理',
icon: 'mdi-account-group',
to: '/user',
header: '系统',
admin: true,
},
{
title: '设定',
icon: 'mdi-cog',
@@ -121,52 +132,52 @@ export const UserfulMenus = [
// 设定标签页
export const SettingTabs = [
{
title: '用户',
icon: 'mdi-account',
tab: 'account',
description: '个人信息、用户管理、修改密码、双重认证',
},
{
title: '连接',
title: '系统',
icon: 'mdi-server-network',
tab: 'system',
description: '下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex',
description: '基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex',
},
{
title: '目录',
title: '存储 & 目录',
icon: 'mdi-folder',
tab: 'directory',
description: '下载目录、媒体库目录、整理模式',
description: '下载目录、媒体库目录、整理、刮削',
},
{
title: '站点',
icon: 'mdi-web',
tab: 'site',
description: '站点同步、下载优先规则、站点重置',
description: '站点同步、站点数据刷新、站点重置',
},
{
title: '搜索',
title: '规则',
icon: 'mdi-filter',
tab: 'rule',
description: '自定义规则、优先级规则组、下载规则',
},
{
title: '搜索 & 下载',
icon: 'mdi-magnify',
tab: 'search',
description: '媒体数据源TheMovieDb、豆瓣、Bangumi、搜索站点、搜索优先级、默认过滤规则',
description: '搜索数据源TheMovieDb、豆瓣、Bangumi下载任务标签、搜索站点',
},
{
title: '订阅',
icon: 'mdi-rss',
tab: 'subscribe',
description: '订阅站点、订阅模式、订阅优先级、洗版优先级、默认过滤规则',
description: '订阅站点、订阅模式、订阅规则、洗版规则',
},
{
title: '服务',
icon: 'mdi-list-box',
tab: 'service',
tab: 'scheduler',
description: '定时作业',
},
{
title: '通知',
icon: 'mdi-bell',
tab: 'notification',
description: '通知渠道微信、Telegram、Slack、SynologyChat、VoceChat)、消息类型',
description: '通知渠道微信、Telegram、Slack、SynologyChat、VoceChat、WebPush、消息发送范围',
},
{
title: '词表',
@@ -178,6 +189,7 @@ export const SettingTabs = [
title: '关于',
icon: 'mdi-information',
tab: 'about',
description: '软件版本',
},
]

View File

@@ -5,10 +5,12 @@ interface AuthState {
token: string | null
remember: boolean
superUser: boolean
userID: number
userName: string
avatar: string
originalPath: string | null
level: number
permissions: { [key: string]: any }
}
// 定义根状态类型
@@ -16,17 +18,19 @@ interface RootState {
auth: AuthState
}
// 导出模块
// 用户信息模块
const authModule: Module<AuthState, RootState> = {
namespaced: true,
state: {
token: null,
remember: false,
superUser: false,
userName: '',
avatar: '',
originalPath: null,
level: 1,
token: null, // 用户令牌
remember: false, // 记住我
superUser: false, // 超级管理员
userID: 999, // 用户ID
userName: '', // 用户名
avatar: '', // 头像
originalPath: null, // 原始路径
level: 1, // 用户认证等级 1-未认证 2-已认证
permissions: {},
},
mutations: {
setToken(state, token: string) {
@@ -41,6 +45,9 @@ const authModule: Module<AuthState, RootState> = {
setSuperUser(state, superUser: boolean) {
state.superUser = superUser
},
setUserID(state, userID: number) {
state.userID = userID
},
setUserName(state, userName: string) {
state.userName = userName
},
@@ -53,15 +60,20 @@ const authModule: Module<AuthState, RootState> = {
setLevel(state, level: number) {
state.level = level
},
setPermissions(state, permissions: object) {
state.permissions = permissions
},
},
actions: {
login({ commit }, { token, remember, superUser, userName, avatar, level }) {
login({ commit }, { token, remember, superUser, userID, userName, avatar, level, permissions }) {
commit('setToken', token)
commit('setRemember', remember)
commit('setSuperUser', superUser)
commit('setUserID', userID)
commit('setUserName', userName)
commit('setAvatar', avatar)
commit('setLevel', level)
commit('setPermissions', permissions)
},
logout({ commit }) {
commit('clearToken')
@@ -72,10 +84,12 @@ const authModule: Module<AuthState, RootState> = {
getToken: state => state.token,
getRemember: state => state.remember,
getSuperUser: state => state.superUser,
getUserID: state => state.userID,
getUserName: state => state.userName,
getAvatar: state => state.avatar,
getOriginalPath: state => state.originalPath,
getLevel: state => state.level,
getPermissions: state => state.permissions,
},
}

View File

@@ -176,11 +176,26 @@
padding-block-end: 1rem;
}
.grid-customrule-card {
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
padding-block-end: 1rem;
}
.grid-subscribe-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
padding-block-end: 1rem;
}
.grid-user-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
padding-block-end: 1rem;
}
.grid-app-card {
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
.v-tabs:not(.v-tabs-pill).v-tabs--horizontal {
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
@@ -209,3 +224,17 @@
background-color: transparent !important;
}
}
.text-shadow {
text-shadow: 1px 1px #777;
}
.card-cover-blurred::before {
position: absolute;
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
background: rgba(29, 39, 59, 48%);
content: '';
inset: 0;
}

View File

@@ -59,7 +59,6 @@ onMounted(() => {
<VProgressLinear :model-value="usedPercent" color="primary" />
</p>
</VCardText>
<!-- Trophy -->
<VImg :src="trophy" class="trophy" />
</VCard>

View File

@@ -1,40 +1,64 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import api from '@/api'
import type { MediaServerPlayItem } from '@/api/types'
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
import PosterCard from '@/components/cards/PosterCard.vue'
// 最近入库列表
const latestList = ref<MediaServerPlayItem[]>([])
const latestList = ref<{ [key: string]: MediaServerPlayItem[] }>({})
// 调用API查询
async function loadLatest() {
// 所有媒体服务器设置
const mediaServers = ref<MediaServerConf[]>([])
// 调用API查询媒体服务器设置
async function loadMediaServerSetting() {
try {
latestList.value = await api.get('mediaserver/latest')
} catch (e) {
console.log(e)
const response: { data: { value: MediaServerConf[] } } = await api.get('system/setting/MediaServers')
mediaServers.value = response.data?.value ?? []
} catch (error) {
console.log('加载媒体服务器设置失败:', error)
}
}
onMounted(() => {
loadLatest()
// 调用API查询最近入库
async function loadLatest(server: string) {
try {
const response: MediaServerPlayItem[] = await api.get('mediaserver/latest', { params: { server } })
// 仅在有数据时赋值
if (response && response.length > 0) {
latestList.value[server] = response
}
} catch (e) {
console.log(`加载媒体服务器 "${server}" 的最近入库失败:`, e)
}
}
onMounted(async () => {
await loadMediaServerSetting()
const enabledServers = mediaServers.value.filter(server => server.enabled)
for (const server of enabledServers) {
loadLatest(server.name)
}
})
</script>
<template>
<VHover>
<template #default="hover">
<VCard v-bind="hover.props">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle >最近添加</VCardTitle>
</VCardItem>
<div>
<VHover v-for="(data, name) in latestList" :key="name">
<template #default="hover">
<VCard v-bind="hover.props">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>最近添加 - {{ name }}</VCardTitle>
</VCardItem>
<div v-if="latestList.length > 0" class="grid gap-4 grid-media-card mx-3 mb-3" tabindex="0">
<PosterCard v-for="data in latestList" :key="data.id" :media="data" />
</div>
</VCard>
</template>
</VHover>
<div class="grid gap-4 grid-media-card mx-3 mb-3" tabindex="0">
<PosterCard v-for="item in data" :key="item.id" :media="item" />
</div>
</VCard>
</template>
</VHover>
</div>
</template>

View File

@@ -1,38 +1,59 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaServerPlayItem } from '@/api/types'
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
import LibraryCard from '@/components/cards/LibraryCard.vue'
// 媒体库列表
const libraryList = ref<MediaServerPlayItem[]>([])
const libraryList = ref<MediaServerLibrary[]>([])
// 所有媒体服务器设置
const mediaServers = ref<MediaServerConf[]>([])
// 调用API查询媒体服务器设置
async function loadMediaServerSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/MediaServers')
mediaServers.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 调用API查询
async function loadLibrary() {
async function loadLibrary(server: string) {
try {
libraryList.value = await api.get('mediaserver/library')
const result: MediaServerLibrary[] = await api.get('mediaserver/library', {
params: { server: server, hidden: true },
})
if (result && result.length > 0) {
libraryList.value = libraryList.value.concat(result)
}
} catch (e) {
console.log(e)
}
}
onMounted(() => {
loadLibrary()
onMounted(async () => {
await loadMediaServerSetting()
const enabledServers = mediaServers.value.filter(server => server.enabled)
for (const server of enabledServers) {
loadLibrary(server.name)
}
})
</script>
<template>
<VHover>
<VHover v-if="libraryList.length > 0">
<template #default="hover">
<VCard v-bind="hover.props">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle >我的媒体库</VCardTitle>
<VCardTitle>我的媒体库</VCardTitle>
</VCardItem>
<div v-if="libraryList.length > 0" class="grid gap-4 grid-backdrop-card mx-3" tabindex="0">
<LibraryCard v-for="data in libraryList" :key="data.id" :media="data" height="10rem" />
<div class="grid gap-4 grid-backdrop-card mx-3" tabindex="0">
<LibraryCard v-for="item in libraryList" :key="item.id" :media="item" height="10rem" />
</div>
</VCard>
</template>

View File

@@ -1,27 +1,47 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaServerPlayItem } from '@/api/types'
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
import BackdropCard from '@/components/cards/BackdropCard.vue'
// 继续播放列表
const playingList = ref<MediaServerPlayItem[]>([])
// 调用API查询
async function loadPlayingList() {
// 所有媒体服务器设置
const mediaServers = ref<MediaServerConf[]>([])
// 调用API查询媒体服务器设置
async function loadMediaServerSetting() {
try {
playingList.value = await api.get('mediaserver/playing')
const result: { [key: string]: any } = await api.get('system/setting/MediaServers')
mediaServers.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 调用API查询
async function loadPlayingList(server: string) {
try {
const result: MediaServerPlayItem[] = await api.get('mediaserver/playing', { params: { server } })
if (result && result.length > 0) {
playingList.value = playingList.value.concat(result)
}
} catch (e) {
console.log(e)
}
}
onMounted(() => {
loadPlayingList()
onMounted(async () => {
await loadMediaServerSetting()
const enabledServers = mediaServers.value.filter(server => server.enabled)
for (const server of enabledServers) {
loadPlayingList(server.name)
}
})
</script>
<template>
<VHover>
<VHover v-if="playingList.length > 0">
<template #default="hover">
<VCard v-bind="hover.props">
<VCardItem>
@@ -31,8 +51,8 @@ onMounted(() => {
<VCardTitle>继续观看</VCardTitle>
</VCardItem>
<div v-if="playingList.length > 0" class="grid gap-4 grid-backdrop-card mx-3" tabindex="0">
<BackdropCard v-for="data in playingList" :key="data.id" :media="data" height="10rem" />
<div class="grid gap-4 grid-backdrop-card mx-3" tabindex="0">
<BackdropCard v-for="item in playingList" :key="item.id" :media="item" height="10rem" />
</div>
</VCard>
</template>

View File

@@ -17,6 +17,9 @@ const mediaProps = defineProps({
type: String,
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
const store = useStore()
// 提示框
@@ -310,15 +313,40 @@ function getBangumiLink() {
// 拼装集图片地址
function getEpisodeImage(stillPath: string) {
if (!stillPath) return ''
return `https://image.tmdb.org/t/p/w500${stillPath}`
return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${stillPath}`
}
// TMDB图片转换为w500大小
function getW500Image(url = '') {
if (!url) return ''
return url.replace('original', 'w500')
url = url.replace('original', 'w500')
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}
// 计算Poster地址
const getPosterUrl: Ref<string> = computed(() => {
const url = mediaDetail.value.poster_path ?? ''
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
})
// 计算backdrop地址
const getBackdropUrl: Ref<string> = computed(() => {
const url = mediaDetail.value.backdrop_path ?? ''
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 获取发行国家名称
const getProductionCountries = computed(() => {
return mediaDetail.value.production_countries?.map(country => country.name)
@@ -415,6 +443,13 @@ async function queryDefaultSubscribeConfig() {
return false
}
// 删除订阅处理
function onSubscribeEditRemove() {
subscribeEditDialog.value = false
if (mediaDetail.value.type === '电影') checkMovieSubscribed()
else checkSeasonsSubscribed()
}
onBeforeMount(() => {
getMediaDetail()
})
@@ -423,9 +458,9 @@ onBeforeMount(() => {
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<div v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id" class="max-w-8xl mx-auto px-4">
<template v-if="mediaDetail.backdrop_path || mediaDetail.poster_path">
<template v-if="getBackdropUrl || getPosterUrl">
<div class="vue-media-back absolute left-0 top-0 w-full h-96">
<VImg class="h-96" position="top" :src="mediaDetail.backdrop_path || mediaDetail.poster_path" cover />
<VImg class="h-96" position="top" :src="getBackdropUrl || getPosterUrl" cover />
</div>
<div class="vue-media-back absolute left-0 top-0 w-full h-96" />
</template>
@@ -866,13 +901,7 @@ onBeforeMount(() => {
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="
() => {
subscribeEditDialog = false
if (mediaDetail.type === '电影') checkMovieSubscribed()
else checkSeasonsSubscribed()
}
"
@remove="onSubscribeEditRemove"
/>
</template>

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