Compare commits

..

187 Commits

Author SHA1 Message Date
jxxghp
1847bc90cf 更新 package.json 2024-04-15 06:46:27 +08:00
jxxghp
899aaae47c Merge pull request #97 from BlueflameLi/main 2024-04-15 06:44:58 +08:00
BlueflameLi
bcc05086a4 fix 历史记录切换每页条数时重复发送一次请求 2024-04-15 02:12:27 +08:00
BlueflameLi
d2cc547875 fix [错误报告]:历史记录默认一页50条但实际默认一页10条 #96 2024-04-15 02:05:31 +08:00
jxxghp
c6127f440e feat 更新时查看更新说明 2024-04-13 18:36:01 +08:00
jxxghp
c2849ad49f fix toast z-index 2024-04-13 18:16:28 +08:00
jxxghp
9c6ba294f9 fix ui 2024-04-13 09:20:59 +08:00
jxxghp
b2a8707e91 fix ui 2024-04-13 09:18:25 +08:00
jxxghp
193e3085a9 feat:下载状态标记 2024-04-13 09:01:18 +08:00
jxxghp
2401f38e9f fix ui 2024-04-12 21:31:24 +08:00
jxxghp
4ea65727a1 更新 package.json 2024-04-12 19:41:25 +08:00
jxxghp
3d6cfe260c fix ui 2024-04-12 18:26:00 +08:00
jxxghp
4c33a09c3c Merge pull request #95 from dh336699/feature-issue-1851 2024-04-12 15:21:04 +08:00
jxxghp
8d25743680 fix 2024-04-12 12:56:45 +08:00
hao.dai
4bc5b763a2 Merge branch 'main' of https://github.com/jxxghp/MoviePilot-Frontend into feature-issue-1851 2024-04-11 09:50:06 +08:00
jxxghp
00ea179c90 fix ui 2024-04-10 18:57:36 +08:00
hao.dai
a17d40d2d0 feat: 1.历史记录新增根据本地数据进行文件夹筛选 2.全量数据切换虚拟表格组件提升性能 2024-04-10 18:52:45 +08:00
jxxghp
62ddd703f1 feat:支持查看插件更新记录 2024-04-10 16:45:17 +08:00
jxxghp
77ab0ccae2 feat:搜索支持指定季 2024-04-10 14:46:36 +08:00
jxxghp
f377ac3fcc fix search 2024-04-09 13:20:54 +08:00
jxxghp
a81becd77b fix 2024-04-07 11:53:47 +08:00
jxxghp
a004f1c758 fix 2024-04-07 11:29:49 +08:00
jxxghp
b19b015986 fix PerfectScrollbar 2024-04-07 11:15:18 +08:00
jxxghp
3f0c1213ad upgrade packages 2024-04-07 10:54:12 +08:00
jxxghp
f3a781d857 fix:减少无效请求 2024-04-07 08:28:37 +08:00
jxxghp
a07a32f648 fix ui 2024-04-06 16:07:18 +08:00
jxxghp
1e1117b187 fix:优化文件管理性能 2024-04-06 09:25:46 +08:00
jxxghp
0e161b1735 fix filemanager ui 2024-04-06 09:04:02 +08:00
jxxghp
98cbb8dc29 fix ui 2024-04-05 23:50:48 +08:00
jxxghp
9c17d2d335 release 2024-04-05 23:33:54 +08:00
jxxghp
d4ea7f48c0 fix ui 2024-04-05 23:27:19 +08:00
jxxghp
3ecfe3ba94 Merge branch 'main' of https://github.com/jxxghp/MoviePilot-Frontend 2024-04-05 22:34:36 +08:00
jxxghp
a959594348 feat:插件搜索 2024-04-05 22:34:34 +08:00
jxxghp
faa1027c04 Merge pull request #93 from hotlcc/develop-低版本浏览器兼容性 2024-04-05 12:17:07 +08:00
Allen
6f68732ef9 低版本浏览器数组不支持at函数问题修复 2024-04-05 04:11:50 +00:00
jxxghp
3a4e936938 fix datatable 2024-04-03 11:55:37 +08:00
jxxghp
8b4b79fa10 fix name 2024-04-02 16:36:27 +08:00
jxxghp
ce0dda0455 fix ui 2024-04-02 13:17:40 +08:00
jxxghp
fd1ee398c4 update vuetify => 3.5.7 2024-04-02 11:37:25 +08:00
jxxghp
1488017bf2 fix #1797 2024-04-02 10:26:15 +08:00
jxxghp
367f4236ad fix #92 2024-04-02 08:26:58 +08:00
jxxghp
ec202f22e8 Merge pull request #92 from thsrite/main 2024-04-01 19:10:57 +08:00
thsrite
08081da29e fix 组件复用 2024-04-01 14:27:41 +08:00
thsrite
5511424bd6 feat 设置订阅默认规则 2024-04-01 13:30:51 +08:00
jxxghp
9f2c848413 fix hint 2024-03-31 19:23:11 +08:00
jxxghp
d5efe2b499 feat:增加设置项提示 2024-03-31 09:36:31 +08:00
jxxghp
9b989fc40f feat:插件配置保存动画 2024-03-31 08:24:02 +08:00
jxxghp
567e85d1f8 更新 package.json 2024-03-30 09:50:04 +08:00
jxxghp
af00036e7f fix login 2024-03-29 11:13:46 +08:00
jxxghp
e58e6e2a3e feat:OTP默认不显示 2024-03-29 10:59:26 +08:00
jxxghp
370039664f fix ui 2024-03-28 19:18:37 +08:00
jxxghp
d244363321 fix #91 otp ui 2024-03-28 19:04:15 +08:00
jxxghp
984d502259 Merge pull request #91 from z3shan33/main
feat #1763 用户可在个人设置中自行开启二次验证
2024-03-28 16:58:10 +08:00
zss
34b418af96 feat #1763 2024-03-28 16:35:20 +08:00
jxxghp
eee0c0c878 fix ui 2024-03-26 15:59:37 +08:00
jxxghp
952ef368ab fix plugin card 2024-03-26 12:37:52 +08:00
jxxghp
dfaa789f7c release 2024-03-25 18:32:14 +08:00
jxxghp
74dd549ffb plugin statistic 2024-03-25 18:31:58 +08:00
jxxghp
4d8f369ba0 fix icon 2024-03-21 21:31:23 +08:00
jxxghp
824e2d72c7 fix icon 2024-03-21 16:30:48 +08:00
jxxghp
143aa79797 fix defer 2024-03-19 12:53:23 +08:00
jxxghp
0e1120f407 fix 减少无效查询 2024-03-19 12:34:21 +08:00
jxxghp
c8dbb9672a fix 2024-03-19 11:47:00 +08:00
jxxghp
372b74776f fix bug 2024-03-18 23:42:24 +08:00
jxxghp
abcc3c6411 fix 2024-03-18 23:31:21 +08:00
jxxghp
ec4ab8762c add Bangumi 2024-03-18 19:03:13 +08:00
jxxghp
bc93de8ff2 fix SSE 2024-03-18 11:42:56 +08:00
jxxghp
b426f3c6f2 更新 PluginAppCard.vue 2024-03-16 22:04:51 +08:00
jxxghp
99d9bb29ce 更新 PluginAppCard.vue 2024-03-16 21:55:59 +08:00
jxxghp
5e109c666b fix plugin card 2024-03-16 21:39:05 +08:00
jxxghp
0bed216735 fix 消息控重 2024-03-16 21:07:09 +08:00
jxxghp
d55bb8d336 fix label 2024-03-16 19:44:10 +08:00
jxxghp
7c32b3edf0 Merge pull request #88 from lingjiameng/main 2024-03-16 18:51:56 +08:00
jxxghp
121cb7e442 v1.7.3 2024-03-16 18:28:32 +08:00
ljmeng
dec3e1ea92 更新注释 2024-03-16 18:26:11 +08:00
ljmeng
664b6610f3 支持本地CookieCloud服务器 2024-03-16 18:23:24 +08:00
jxxghp
44163f0fb2 fix bug 2024-03-16 17:20:49 +08:00
jxxghp
d43865fcad fix message ui 2024-03-16 17:16:10 +08:00
jxxghp
fed92f3853 fix message ui 2024-03-16 16:47:41 +08:00
jxxghp
823d2a816e fix 2024-03-16 08:40:44 +08:00
jxxghp
046c21edf6 add message view 2024-03-15 18:15:31 +08:00
jxxghp
8236d80b42 feat:优化插件升级使用体验 2024-03-12 21:31:56 +08:00
jxxghp
90e7eb1c79 Merge pull request #86 from WangEdward/main 2024-03-11 16:32:30 +08:00
WangEdward
ef09868af1 fix: display search_imdbid status 2024-03-11 16:30:24 +08:00
jxxghp
028981e3ae 更新 package.json 2024-03-10 21:07:17 +08:00
jxxghp
e8a6274cf6 Merge pull request #85 from honue/main 2024-03-10 17:16:54 +08:00
honue
ffd0265526 设置每周一为第一天,当天背景突显 2024-03-10 16:49:40 +08:00
jxxghp
13d7344bc0 fix bug 2024-03-09 20:53:37 +08:00
jxxghp
2ad36f92c5 feat:下载器多选 2024-03-09 18:52:48 +08:00
jxxghp
36b02f4423 release 2024-03-09 17:35:45 +08:00
jxxghp
01df990aa8 fix #1638 2024-03-09 17:05:44 +08:00
jxxghp
49b71fcf5d fix #1640 2024-03-09 16:49:48 +08:00
jxxghp
9e43d77ac4 feat:新增官种优先级规则 2024-03-09 09:15:03 +08:00
jxxghp
3ab9af720b Merge pull request #84 from WangEdward/main 2024-03-08 22:11:13 +08:00
WangEdward
abae304f87 chore: add half-increments for v-rating 2024-03-08 21:23:25 +08:00
jxxghp
659d8bff66 fix 下载及订阅用户匹配 2024-03-08 15:27:51 +08:00
jxxghp
1786e10101 fix:修改编译策略,避免一直loading 2024-03-08 14:18:30 +08:00
jxxghp
bda7f929e7 try fix loading 2024-03-08 13:31:38 +08:00
jxxghp
c309f80a94 更新 ModuleTestView.vue 2024-03-06 22:05:12 +08:00
jxxghp
97c987c561 fix ui 2024-03-06 21:52:20 +08:00
jxxghp
48949104e0 feat:目录检测 2024-03-06 21:41:06 +08:00
jxxghp
a38cc4fe34 add min_seeders 2024-03-06 20:15:23 +08:00
jxxghp
495dfbcb28 feat:add VoceChat 2024-03-06 15:55:01 +08:00
jxxghp
6e4dbd912b feat:健康检查 2024-03-06 13:27:17 +08:00
jxxghp
82904d956d thetvdb network test 2024-03-06 11:16:12 +08:00
jxxghp
ec7118b376 fix #82 后台登录成功但前端报错 2024-03-05 20:21:05 +08:00
jxxghp
058b32a263 release 2024-03-05 17:27:27 +08:00
jxxghp
e7b960838e Merge pull request #82 from honue/main 2024-03-05 15:24:46 +08:00
honue
14e776a287 登录后返回原始页面功能 2024-03-05 15:20:27 +08:00
jxxghp
73f11b920f Merge pull request #81 from WangEdward/main
feat: add search_imdbid
2024-03-05 12:57:44 +08:00
jxxghp
5c93040a8e fanart nettest 2024-03-05 12:57:02 +08:00
WangEdward
a517769e8a feat: add search_imdbid 2024-03-04 21:36:06 +08:00
jxxghp
4bb59a9f05 fix 2024-03-03 09:02:41 +08:00
jxxghp
b37879d2d4 release 2024-03-03 08:48:04 +08:00
jxxghp
05defc39d7 rollback VTooltip 2024-03-03 08:47:20 +08:00
jxxghp
18bfad07d2 fix 2024-03-02 09:58:42 +08:00
jxxghp
b83591255d Merge pull request #80 from maicss/main 2024-03-02 09:14:56 +08:00
maicss
804350bc81 feat: search box add border for contrast 2024-03-01 23:33:50 +08:00
maicss
46e1cae0bb feat: add session valid chack 2024-03-01 23:33:50 +08:00
maicss
81062d4580 fix: enhance some interactive experiences 2024-03-01 23:33:50 +08:00
maicss
55481db2ee Merge branch 'jxxghp:main' into main 2024-03-01 20:16:10 +08:00
jxxghp
ecdd12f5a9 fix 2024-02-29 20:21:30 +08:00
jxxghp
ef92cdc183 fix torrent type 2024-02-29 16:15:28 +08:00
jxxghp
08f4a6cf2c Merge pull request #78 from maicss/maicss 2024-02-29 11:16:48 +08:00
maicss
38889acb4e fix: toast message not visible
When install plugin failed, it will yield an error toast. But attribute `z-index` of toast is `1090`, while `v-dialog` is `2400`
2024-02-29 09:51:20 +08:00
maicss
c0517cd29a fix: toast message not visible
When install plugin failed, `z-index` of toast is `1090` while `v-dialog` is `2400`.
2024-02-29 09:48:23 +08:00
jxxghp
084449ccf3 rebuild 2024-02-28 18:54:35 +08:00
jxxghp
0e8203ae03 fix app 2024-02-27 19:25:12 +08:00
jxxghp
236440be52 fix form 2024-02-27 15:23:07 +08:00
jxxghp
6f7e4bb272 release 2024-02-26 13:21:37 +08:00
jxxghp
38dcd3635a fix text 2024-02-25 17:17:53 +08:00
jxxghp
a3f3330dad fix 2024-02-25 08:58:46 +08:00
jxxghp
bbc6c57c08 Merge pull request #77 from cikezhu/main 2024-02-24 06:47:02 +08:00
叮叮当
2f36a8edef fix 2024-02-23 22:27:01 +08:00
叮叮当
df637fb887 定时作业添加提供者条目 2024-02-23 22:26:16 +08:00
jxxghp
be74c92a35 更新 package.json 2024-02-23 16:22:57 +08:00
jxxghp
a219a64e20 Merge pull request #76 from alkalixin/main 2024-02-23 16:22:19 +08:00
alkalixin
25c22a276a 给部分按钮增加浮动提示 2024-02-23 15:07:01 +08:00
jxxghp
6e6be057ca fix ui 2024-02-23 10:45:21 +08:00
jxxghp
af69efa48b feat:插件日志单独查看 2024-02-22 13:32:23 +08:00
jxxghp
c551083fa4 优化插件页面加载速度 2024-02-21 17:50:02 +08:00
jxxghp
9767feed29 更新 package.json 2024-02-18 15:19:52 +08:00
jxxghp
4392818e92 Merge pull request #75 from honue/main 2024-02-18 15:17:53 +08:00
honue
8d22bafeb6 订阅卡片增加剧集详情跳转功能 2024-02-18 15:08:22 +08:00
jxxghp
89ddd1fb78 更新 package.json 2024-02-17 13:19:04 +08:00
jxxghp
24513fa22b Merge pull request #74 from honue/main 2024-02-17 13:18:46 +08:00
honue
cddde0c2a0 fix 2024-02-17 13:09:48 +08:00
jxxghp
9c674e0018 更新 package.json 2024-02-15 21:06:19 +08:00
jxxghp
0c6476d283 更新 AccountSettingSystem.vue 2024-02-15 21:05:58 +08:00
jxxghp
bf0c529a59 fix ui 2024-02-15 20:01:51 +08:00
jxxghp
877bb4d4a2 fix ui 2024-02-15 18:58:22 +08:00
jxxghp
dc4db0b2b3 fix settings 2024-02-15 18:17:36 +08:00
jxxghp
a738d4a3b9 feat:系统设置面板 2024-02-15 15:04:59 +08:00
jxxghp
e9866a04df v1.6.4 2024-02-14 21:07:09 +08:00
jxxghp
4f5193d602 feat:更新Cookie支持两步验证 2024-02-14 21:06:42 +08:00
jxxghp
37b92c55ba feat:文件管理手动刮削 2024-02-10 19:32:49 +08:00
jxxghp
9299f1bcb6 release 2024-02-10 09:48:45 +08:00
jxxghp
7fe12192df add apexcharts 2024-02-10 09:36:54 +08:00
jxxghp
1169644ab3 fix render 2024-02-09 08:43:36 +08:00
jxxghp
6f7770ed43 fix 2024-02-08 20:58:41 +08:00
jxxghp
8059fd6f90 fix 2024-02-08 20:57:09 +08:00
jxxghp
556dbd8d78 fix bug 2024-02-08 20:07:23 +08:00
jxxghp
6695fd8c14 add ace-editor 2024-02-08 19:56:28 +08:00
jxxghp
3ab0229275 fix ui 2024-02-08 13:43:37 +08:00
jxxghp
99467127a0 更新 package.json 2024-02-08 13:28:11 +08:00
jxxghp
90d73b7bd5 Merge pull request #73 from cikezhu/main 2024-02-08 13:27:40 +08:00
叮叮当
2e326e1798 fix 全部日志url反向代理时被serviceWorker拦截 2024-02-08 12:45:52 +08:00
jxxghp
251eac93c7 更新 package.json 2024-02-08 07:11:49 +08:00
jxxghp
c74d70808c Merge pull request #72 from cikezhu/main 2024-02-08 07:10:48 +08:00
叮叮当
e63b2d7152 新窗口打开全部日志 2024-02-08 00:06:52 +08:00
jxxghp
16b29b56a5 更新 package.json 2024-01-29 23:11:57 +08:00
jxxghp
6d79c4fe2f Merge pull request #71 from cikezhu/main 2024-01-29 23:11:34 +08:00
叮叮当
4b1fb60ee3 fix 媒体信息页面person跳转问题 2024-01-29 23:01:22 +08:00
jxxghp
1d2be54f9e 更新 package.json 2024-01-29 11:05:38 +08:00
jxxghp
83547e32db Merge pull request #70 from cikezhu/main 2024-01-29 11:05:10 +08:00
叮叮当
70ddb929f2 fix plugin_icon 2024-01-28 00:47:37 +08:00
叮叮当
8b22961394 支持基于路径的反向代理 2024-01-27 14:47:00 +08:00
jxxghp
c15d42c179 Merge pull request #69 from falling/main 2024-01-24 18:43:37 +08:00
falling
098e473cab 从qbt后台的返回值来更新下载状态 2024-01-21 19:48:47 +08:00
jxxghp
f6f3d9368a fix bug 2024-01-08 13:22:42 +08:00
jxxghp
9558a420e9 fix image proxy 2024-01-08 12:25:06 +08:00
jxxghp
4d3b69ca34 更新 package.json 2024-01-08 11:33:32 +08:00
jxxghp
fdcc4a44c8 Merge pull request #68 from jjjokin/feat-auto-switch-theme 2024-01-08 11:01:26 +08:00
jokin
5de0494538 增加主题自适应选项 2024-01-07 22:17:11 +08:00
jxxghp
2045f833e4 fix bug 2024-01-06 11:07:23 +08:00
jxxghp
cc4f89aac1 fix 2024-01-06 11:02:03 +08:00
jxxghp
1c2f2c17d4 fix plex image 2024-01-06 10:58:35 +08:00
98 changed files with 8314 additions and 4924 deletions

View File

@@ -1 +1 @@
VITE_API_BASE_URL=/api/v1/
VITE_API_BASE_URL=api/v1/

View File

@@ -24,7 +24,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: '18'
node-version: '20'
cache: 'yarn'
- name: Download Icons

View File

@@ -21,14 +21,9 @@
}
],
"rules": {
"max-line-length": [
120,
{
"ignore": "comments"
}
],
"liberty/use-logical-spec": true,
"selector-class-pattern": null,
"color-function-notation": null
}
}
},
"fix": true
}

View File

@@ -53,6 +53,7 @@
"stylelint",
"touchless",
"triggerer",
"unref",
"vuetify"
],
// Extension: Comment Anchors
@@ -104,4 +105,4 @@
]
},
"vue3snippets.enable-compile-vue-file-on-did-save-code": false
}
}

View File

@@ -1,6 +1,6 @@
# MoviePilot-Frontend
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目。
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目NodeJS版本>= `v20.12.1`
## 推荐的IDE设置

322
auto-imports.d.ts vendored
View File

@@ -1,6 +1,7 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
@@ -41,6 +42,7 @@ declare global {
const h: typeof import('vue')['h']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
const injectLocal: typeof import('@vueuse/core')['injectLocal']
const isDefined: typeof import('@vueuse/core')['isDefined']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
@@ -77,6 +79,7 @@ declare global {
const onUpdated: typeof import('vue')['onUpdated']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive']
@@ -105,6 +108,7 @@ declare global {
const toReactive: typeof import('@vueuse/core')['toReactive']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
@@ -143,6 +147,7 @@ declare global {
const useCeil: typeof import('@vueuse/math')['useCeil']
const useClamp: typeof import('@vueuse/math')['useClamp']
const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
@@ -308,11 +313,13 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, InjectionKey, PropType, Ref, VNode } from 'vue'
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
import('vue')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
@@ -351,6 +358,7 @@ declare module 'vue' {
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
@@ -387,6 +395,7 @@ declare module 'vue' {
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
@@ -415,6 +424,7 @@ declare module 'vue' {
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']>
@@ -453,6 +463,316 @@ declare module 'vue' {
readonly useCeil: UnwrapRef<typeof import('@vueuse/math')['useCeil']>
readonly useClamp: UnwrapRef<typeof import('@vueuse/math')['useClamp']>
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
readonly useCycleList: UnwrapRef<typeof import('@vueuse/core')['useCycleList']>
readonly useDark: UnwrapRef<typeof import('@vueuse/core')['useDark']>
readonly useDateFormat: UnwrapRef<typeof import('@vueuse/core')['useDateFormat']>
readonly useDebounce: UnwrapRef<typeof import('@vueuse/core')['useDebounce']>
readonly useDebounceFn: UnwrapRef<typeof import('@vueuse/core')['useDebounceFn']>
readonly useDebouncedRefHistory: UnwrapRef<typeof import('@vueuse/core')['useDebouncedRefHistory']>
readonly useDeviceMotion: UnwrapRef<typeof import('@vueuse/core')['useDeviceMotion']>
readonly useDeviceOrientation: UnwrapRef<typeof import('@vueuse/core')['useDeviceOrientation']>
readonly useDevicePixelRatio: UnwrapRef<typeof import('@vueuse/core')['useDevicePixelRatio']>
readonly useDevicesList: UnwrapRef<typeof import('@vueuse/core')['useDevicesList']>
readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']>
readonly useDocumentVisibility: UnwrapRef<typeof import('@vueuse/core')['useDocumentVisibility']>
readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']>
readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>
readonly useElementBounding: UnwrapRef<typeof import('@vueuse/core')['useElementBounding']>
readonly useElementByPoint: UnwrapRef<typeof import('@vueuse/core')['useElementByPoint']>
readonly useElementHover: UnwrapRef<typeof import('@vueuse/core')['useElementHover']>
readonly useElementSize: UnwrapRef<typeof import('@vueuse/core')['useElementSize']>
readonly useElementVisibility: UnwrapRef<typeof import('@vueuse/core')['useElementVisibility']>
readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
readonly useFileDialog: UnwrapRef<typeof import('@vueuse/core')['useFileDialog']>
readonly useFileSystemAccess: UnwrapRef<typeof import('@vueuse/core')['useFileSystemAccess']>
readonly useFloor: UnwrapRef<typeof import('@vueuse/math')['useFloor']>
readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>
readonly useIntersectionObserver: UnwrapRef<typeof import('@vueuse/core')['useIntersectionObserver']>
readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']>
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
readonly useMath: UnwrapRef<typeof import('@vueuse/math')['useMath']>
readonly useMax: UnwrapRef<typeof import('@vueuse/math')['useMax']>
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
readonly useMin: UnwrapRef<typeof import('@vueuse/math')['useMin']>
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']>
readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']>
readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']>
readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>
readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
readonly usePrecision: UnwrapRef<typeof import('@vueuse/math')['usePrecision']>
readonly usePreferredColorScheme: UnwrapRef<typeof import('@vueuse/core')['usePreferredColorScheme']>
readonly usePreferredContrast: UnwrapRef<typeof import('@vueuse/core')['usePreferredContrast']>
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useProjection: UnwrapRef<typeof import('@vueuse/math')['useProjection']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useRound: UnwrapRef<typeof import('@vueuse/math')['useRound']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
readonly useScroll: UnwrapRef<typeof import('@vueuse/core')['useScroll']>
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStore: UnwrapRef<typeof import('vuex')['useStore']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']>
readonly useTextareaAutosize: UnwrapRef<typeof import('@vueuse/core')['useTextareaAutosize']>
readonly useThrottle: UnwrapRef<typeof import('@vueuse/core')['useThrottle']>
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
readonly useTimestamp: UnwrapRef<typeof import('@vueuse/core')['useTimestamp']>
readonly useTitle: UnwrapRef<typeof import('@vueuse/core')['useTitle']>
readonly useToNumber: UnwrapRef<typeof import('@vueuse/core')['useToNumber']>
readonly useToString: UnwrapRef<typeof import('@vueuse/core')['useToString']>
readonly useToggle: UnwrapRef<typeof import('@vueuse/core')['useToggle']>
readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>
readonly useTrunc: UnwrapRef<typeof import('@vueuse/math')['useTrunc']>
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>
readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']>
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>
readonly useVirtualList: UnwrapRef<typeof import('@vueuse/core')['useVirtualList']>
readonly useWakeLock: UnwrapRef<typeof import('@vueuse/core')['useWakeLock']>
readonly useWebNotification: UnwrapRef<typeof import('@vueuse/core')['useWebNotification']>
readonly useWebSocket: UnwrapRef<typeof import('@vueuse/core')['useWebSocket']>
readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>
readonly useWebWorkerFn: UnwrapRef<typeof import('@vueuse/core')['useWebWorkerFn']>
readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']>
readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']>
readonly watchDebounced: UnwrapRef<typeof import('@vueuse/core')['watchDebounced']>
readonly watchDeep: UnwrapRef<typeof import('@vueuse/core')['watchDeep']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchIgnorable: UnwrapRef<typeof import('@vueuse/core')['watchIgnorable']>
readonly watchImmediate: UnwrapRef<typeof import('@vueuse/core')['watchImmediate']>
readonly watchOnce: UnwrapRef<typeof import('@vueuse/core')['watchOnce']>
readonly watchPausable: UnwrapRef<typeof import('@vueuse/core')['watchPausable']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
readonly watchThrottled: UnwrapRef<typeof import('@vueuse/core')['watchThrottled']>
readonly watchTriggerable: UnwrapRef<typeof import('@vueuse/core')['watchTriggerable']>
readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']>
readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']>
}
}
declare module '@vue/runtime-core' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']>
readonly computedInject: UnwrapRef<typeof import('@vueuse/core')['computedInject']>
readonly computedWithControl: UnwrapRef<typeof import('@vueuse/core')['computedWithControl']>
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
readonly createGenericProjection: UnwrapRef<typeof import('@vueuse/math')['createGenericProjection']>
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
readonly createLogger: UnwrapRef<typeof import('vuex')['createLogger']>
readonly createNamespacedHelpers: UnwrapRef<typeof import('vuex')['createNamespacedHelpers']>
readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createStore: UnwrapRef<typeof import('vuex')['createStore']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly debouncedRef: UnwrapRef<typeof import('@vueuse/core')['debouncedRef']>
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly logicAnd: UnwrapRef<typeof import('@vueuse/math')['logicAnd']>
readonly logicNot: UnwrapRef<typeof import('@vueuse/math')['logicNot']>
readonly logicOr: UnwrapRef<typeof import('@vueuse/math')['logicOr']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly mapActions: UnwrapRef<typeof import('vuex')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('vuex')['mapGetters']>
readonly mapMutations: UnwrapRef<typeof import('vuex')['mapMutations']>
readonly mapState: UnwrapRef<typeof import('vuex')['mapState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>
readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly reactiveComputed: UnwrapRef<typeof import('@vueuse/core')['reactiveComputed']>
readonly reactiveOmit: UnwrapRef<typeof import('@vueuse/core')['reactiveOmit']>
readonly reactivePick: UnwrapRef<typeof import('@vueuse/core')['reactivePick']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']>
readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']>
readonly refDefault: UnwrapRef<typeof import('@vueuse/core')['refDefault']>
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
readonly throttledRef: UnwrapRef<typeof import('@vueuse/core')['throttledRef']>
readonly throttledWatch: UnwrapRef<typeof import('@vueuse/core')['throttledWatch']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']>
readonly tryOnMounted: UnwrapRef<typeof import('@vueuse/core')['tryOnMounted']>
readonly tryOnScopeDispose: UnwrapRef<typeof import('@vueuse/core')['tryOnScopeDispose']>
readonly tryOnUnmounted: UnwrapRef<typeof import('@vueuse/core')['tryOnUnmounted']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
readonly useAbs: UnwrapRef<typeof import('@vueuse/math')['useAbs']>
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>
readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>
readonly useArrayFind: UnwrapRef<typeof import('@vueuse/core')['useArrayFind']>
readonly useArrayFindIndex: UnwrapRef<typeof import('@vueuse/core')['useArrayFindIndex']>
readonly useArrayFindLast: UnwrapRef<typeof import('@vueuse/core')['useArrayFindLast']>
readonly useArrayIncludes: UnwrapRef<typeof import('@vueuse/core')['useArrayIncludes']>
readonly useArrayJoin: UnwrapRef<typeof import('@vueuse/core')['useArrayJoin']>
readonly useArrayMap: UnwrapRef<typeof import('@vueuse/core')['useArrayMap']>
readonly useArrayReduce: UnwrapRef<typeof import('@vueuse/core')['useArrayReduce']>
readonly useArraySome: UnwrapRef<typeof import('@vueuse/core')['useArraySome']>
readonly useArrayUnique: UnwrapRef<typeof import('@vueuse/core')['useArrayUnique']>
readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']>
readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useAverage: UnwrapRef<typeof import('@vueuse/math')['useAverage']>
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>
readonly useBreakpoints: UnwrapRef<typeof import('@vueuse/core')['useBreakpoints']>
readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']>
readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']>
readonly useCached: UnwrapRef<typeof import('@vueuse/core')['useCached']>
readonly useCeil: UnwrapRef<typeof import('@vueuse/math')['useCeil']>
readonly useClamp: UnwrapRef<typeof import('@vueuse/math')['useClamp']>
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>

4
components.d.ts vendored
View File

@@ -3,11 +3,9 @@
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {}
declare module '@vue/runtime-core' {
declare module 'vue' {
export interface GlobalComponents {
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default']

View File

@@ -1,214 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="initial-scale=1, viewport-fit=cover, width=device-width, user-scalable=no" />
<title>MoviePilot</title>
<meta name="Robots" content="noindex,nofollow,noarchive" />
<meta name="referrer" content="origin" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="manifest.json" crossorigin="use-credentials" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="MoviePilot" />
<meta name="description" content="MoviePilot" />
<meta name="format-detection" content="telephone=no" />
<meta name="referrer" content="never" />
<meta name="msapplication-TileColor" content="#7D34FD" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#28243D" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<link rel="stylesheet" type="text/css" href="/loader.css" />
</head>
<body>
<div id="loading-bg">
<div class="loading-logo">
<!-- Logo -->
<svg
width="100px"
height="100px"
viewBox="0 0 192 192"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
>
<g transform="matrix(1,0,0,1,-2606,-236)">
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
<rect x="0" y="0" width="192" height="192" style="fill: none" />
<g transform="matrix(-0.800798,0.462341,-0.769972,-1.33363,1869.11,-896.718)">
<head>
<meta http-equiv="pragma" content="no-cache">
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="expires" content="0">
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="initial-scale=1, viewport-fit=cover, width=device-width, user-scalable=no" />
<title>MoviePilot</title>
<meta name="Robots" content="noindex,nofollow,noarchive" />
<meta name="referrer" content="origin" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.jpg" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="manifest.json" crossorigin="use-credentials" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="MoviePilot" />
<meta name="description" content="MoviePilot" />
<meta name="format-detection" content="telephone=no" />
<meta name="referrer" content="never" />
<meta name="msapplication-TileColor" content="#7D34FD" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#28243D" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
<meta name="HandheldFriendly" content="True" />
<meta name="MobileOptimized" content="320" />
<link rel="stylesheet" type="text/css" href="/loader.css" />
</head>
<body>
<div id="loading-bg">
<div class="loading-logo">
<!-- Logo -->
<svg width="100px" height="100px" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
<g transform="matrix(1,0,0,1,-2606,-236)">
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
<rect x="0" y="0" width="192" height="192" style="fill: none" />
<g transform="matrix(-0.800798,0.462341,-0.769972,-1.33363,1869.11,-896.718)">
<path
d="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z"
style="fill: url(#_Linear1)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z"
style="fill: url(#_Linear2)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z"
style="fill: rgb(141, 81, 249)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z"
style="fill: url(#_Linear3)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z"
style="fill: rgb(165, 118, 255)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z"
style="fill: url(#_Linear4)" />
</g>
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
<path
d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z"
style="fill: rgb(141, 81, 249)" />
</g>
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
<path
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
style="fill: rgb(104, 0, 197)" />
<clipPath id="_clip5">
<path
d="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z"
style="fill: url(#_Linear1)"
/>
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z"
style="fill: url(#_Linear2)"
/>
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z"
style="fill: rgb(141, 81, 249)"
/>
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z"
style="fill: url(#_Linear3)"
/>
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z"
style="fill: rgb(165, 118, 255)"
/>
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z"
style="fill: url(#_Linear4)"
/>
</g>
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
<path
d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z"
style="fill: rgb(141, 81, 249)"
/>
</g>
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
<path
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
style="fill: rgb(104, 0, 197)"
/>
<clipPath id="_clip5">
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z" />
</clipPath>
<g clip-path="url(#_clip5)">
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
<path
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
/>
</clipPath>
<g clip-path="url(#_clip5)">
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
<path
d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z"
style="fill: url(#_Linear6)"
/>
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path
d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z"
style="fill: rgb(141, 81, 249)"
/>
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path
d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z"
style="fill: url(#_Radial7)"
/>
</g>
d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z"
style="fill: url(#_Linear6)" />
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path
d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z"
style="fill: rgb(141, 81, 249)" />
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path
d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z"
style="fill: url(#_Radial7)" />
</g>
</g>
</g>
</g>
<defs>
<linearGradient
id="_Linear1"
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)"
>
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
</linearGradient>
<linearGradient
id="_Linear2"
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)"
>
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
</linearGradient>
<linearGradient
id="_Linear3"
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)"
>
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</linearGradient>
<linearGradient
id="_Linear4"
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)"
>
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</linearGradient>
<linearGradient
id="_Linear6"
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)"
>
<stop offset="0" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
<stop offset="0.51" style="stop-color: rgb(110, 38, 217); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(91, 0, 197); stop-opacity: 1" />
</linearGradient>
<radialGradient
id="_Radial7"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)"
>
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</radialGradient>
</defs>
</svg>
</div>
<div class="loading">
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
</div>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)">
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
</linearGradient>
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)">
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
</linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)">
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</linearGradient>
<linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)">
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</linearGradient>
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)">
<stop offset="0" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
<stop offset="0.51" style="stop-color: rgb(110, 38, 217); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(91, 0, 197); stop-opacity: 1" />
</linearGradient>
<radialGradient id="_Radial7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)">
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</radialGradient>
</defs>
</svg>
</div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
<div class="loading">
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
</div>
</div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
if (loaderColor) document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
if (loaderColor)
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
if (primaryColor) document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
</script>
</body>
</html>
if (primaryColor)
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
</script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "1.5.7-2",
"version": "1.8.1-1",
"private": true,
"bin": "dist/service.js",
"scripts": {
@@ -21,43 +21,47 @@
"dependencies": {
"@casl/ability": "^6.2.0",
"@casl/vue": "^2.2.0",
"@floating-ui/dom": "1.2.8",
"@floating-ui/dom": "1.6.3",
"@iconify/utils": "^2.1.22",
"@vueuse/core": "^10.1.2",
"@vueuse/math": "^10.1.2",
"ace-builds": "^1.32.6",
"apexcharts-clevision": "^3.28.5",
"axios": "1.4.0",
"axios": "1.6.8",
"axios-mock-adapter": "^1.21.4",
"chart.js": "^4.1.2",
"colorthief": "^2.4.0",
"express": "^4.18.2",
"express-http-proxy": "^2.0.0",
"jwt-decode": "^3.1.2",
"jwt-decode": "^4.0.0",
"nprogress": "^0.2.0",
"postcss-purgecss": "^5.0.0",
"prismjs": "^1.29.0",
"pull-refresh-vue3": "^0.3.1",
"qrcode.vue": "^3.4.1",
"roboto-fontface": "^0.10.0",
"sass": "^1.59.3",
"tailwindcss": "^3.3.2",
"unplugin-vue-define-options": "^1.3.5",
"vite-plugin-pwa": "^0.16.4",
"vite-plugin-pwa": "^0.19.8",
"vue": "^3.3.2",
"vue-chartjs": "^5.2.0",
"vue-flatpickr-component": "11.0.3",
"vue-flatpickr-component": "11.0.5",
"vue-i18n": "^9.2.2",
"vue-prism-component": "^2.0.0",
"vue-router": "^4.2.0",
"vue-toast-notification": "^3",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.4.1",
"vue3-perfect-scrollbar": "^1.6.0",
"vuetify": "3.3.5",
"vue3-perfect-scrollbar": "^2.0.0",
"vuetify": "3.5.14",
"vuetify-use-dialog": "^0.6.0",
"vuex": "^4.1.0",
"vuex-persistedstate": "^4.1.0",
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@antfu/eslint-config-vue": "^0.38.6",
"@antfu/eslint-config-vue": "^0.43.1",
"@fullcalendar/core": "^6.1.8",
"@fullcalendar/daygrid": "^6.1.8",
"@fullcalendar/interaction": "^6.1.7",
@@ -65,43 +69,44 @@
"@fullcalendar/timegrid": "^6.1.7",
"@fullcalendar/vue3": "^6.1.8",
"@iconify-json/mdi": "^1.1.52",
"@iconify/tools": "^2.2.0",
"@iconify/tools": "^4.0.4",
"@iconify/vue": "4.1.1",
"@intlify/unplugin-vue-i18n": "^0.10.0",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@types/lodash": "^4.14.197",
"@types/node": "^20.1.4",
"@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^5.59.5",
"@typescript-eslint/parser": "^5.59.5",
"@vitejs/plugin-vue": "^4.2.3",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^3.0.0",
"autoprefixer": "^10.4.14",
"eslint": "^8.40.0",
"eslint": "^9.0.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.0.1",
"eslint-plugin-regex": "^1.10.0",
"eslint-plugin-sonarjs": "^0.19.0",
"eslint-plugin-unicorn": "^47.0.0",
"eslint-plugin-sonarjs": "^0.25.1",
"eslint-plugin-unicorn": "^52.0.0",
"eslint-plugin-vue": "^9.12.0",
"postcss": "^8.4.24",
"lodash": "^4.17.21",
"postcss": "8",
"postcss-html": "^1.5.0",
"stylelint": "14.15.0",
"stylelint-config-idiomatic-order": "9.0.0",
"stylelint-config-standard-scss": "6.1.0",
"stylelint-use-logical-spec": "4.1.0",
"type-fest": "^3.10.0",
"stylelint": "16.3.1",
"stylelint-config-idiomatic-order": "10.0.0",
"stylelint-config-standard-scss": "13.1.0",
"stylelint-use-logical-spec": "5.0.1",
"type-fest": "^4.15.0",
"typescript": "^5.0.4",
"unplugin-auto-import": "^0.15.1",
"unplugin-vue-components": "^0.24.1",
"vite": "^4.3.5",
"vite-plugin-pages": "^0.29.0",
"vite-plugin-vue-layouts": "^0.8.0",
"vite-plugin-vuetify": "1.0.2",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.2.8",
"vite-plugin-pages": "^0.32.1",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.3",
"vue-shepherd": "^3.0.0",
"vue-tsc": "^1.6.5"
"vue-tsc": "^2.0.10"
},
"packageManager": "yarn@1.22.18",
"resolutions": {

View File

@@ -75,6 +75,26 @@ http {
# 超时设置
proxy_read_timeout 600s;
}
location /cookiecloud {
# 后端cookiecloud地址
proxy_pass http://backend_api;
rewrite ^.+mock-server/?(.*)$ /$1 break;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_redirect off;
proxy_set_header Connection "";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Nginx-Proxy true;
# 超时设置
proxy_read_timeout 600s;
}
}
upstream backend_api {

View File

@@ -25,6 +25,16 @@ app.use(
})
);
// 配置代理中间件将CookieCloud请求转发给后端API
app.use(
'/cookiecloud',
proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, {
// 路径加上 /cookiecloud 前缀
proxyReqPathResolver: (req) => {
return `/cookiecloud${req.url}`
}
})
);
// 处理根路径的请求
app.get('/', (req, res) => {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref } from 'vue'
import { useTheme } from 'vuetify'
import type { ThemeSwitcherTheme } from '@layouts/types'
@@ -20,26 +20,30 @@ const {
{ initialValue: savedTheme.value },
)
function changeTheme() {
const nextTheme = getNextThemeName()
globalTheme.name.value = nextTheme
savedTheme.value = nextTheme
localStorage.setItem('theme', nextTheme)
function updateTheme() {
const autoTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
const theme = currentThemeName.value === 'auto' ? autoTheme : currentThemeName.value
globalTheme.name.value = theme
savedTheme.value = theme
// 修改载入时背景色
localStorage.setItem('materio-initial-loader-bg', globalTheme.current.value.colors.background)
themeTransition()
}
// Update icon if theme is changed from other sources
// 监听系统主题变化
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme)
watch(
() => globalTheme.name.value,
(val) => {
currentThemeName.value = val
},
() => currentThemeName.value,
() => updateTheme(),
)
function changeTheme() {
const nextTheme = getNextThemeName()
currentThemeName.value = nextTheme
localStorage.setItem('theme', nextTheme)
}
// Apply saved theme on page load
// onMounted(() => {
// globalTheme.name.value = savedTheme.value

View File

@@ -0,0 +1,18 @@
/**
* 浏览器兼容性处理
*/
/**
* 修复低版本Safari等浏览器数组不支持at函数的问题
*/
export function fixArrayAt() {
if (!Array.prototype.at) {
Array.prototype.at = function(index: number) {
if (index >= 0) {
return this[index]
} else {
return this[this.length + index]
}
}
}
}

View File

@@ -147,3 +147,23 @@ export function formatEp(nums: number[]): string {
return formattedRanges.join('、')
}
// 将yyyy-mm-dd hh:mm:ss转换为时间差1小时前1天前
export function formatDateDifference(dateString: string): string {
const date = new Date(dateString)
const currentDate = new Date()
const timeDifference = currentDate.getTime() - date.getTime()
const secondsDifference = Math.floor(timeDifference / 1000)
const minutesDifference = Math.floor(secondsDifference / 60)
const hoursDifference = Math.floor(minutesDifference / 60)
const daysDifference = Math.floor(hoursDifference / 24)
if (daysDifference > 0)
return `${daysDifference}天前`
else if (hoursDifference > 0)
return `${hoursDifference}小时前`
else if (minutesDifference > 0)
return `${minutesDifference}分钟前`
else
return '刚刚'
}

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup>
import type { Component } from 'vue'
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { useDisplay } from 'vuetify'
import logo from '@images/logo.svg?raw'
@@ -85,6 +84,7 @@ function handleNavScroll(evt: Event) {
.visible {
visibility: visible !important;
}
// 👉 Vertical Nav
.layout-vertical-nav {
position: fixed;
@@ -96,8 +96,8 @@ function handleNavScroll(evt: Event) {
inset-block-start: 0;
inset-inline-start: 0;
transition: transform 0.25s ease-in-out, inline-size 0.25s ease-in-out, box-shadow 0.25s ease-in-out;
will-change: transform, inline-size;
visibility: hidden;
will-change: transform, inline-size;
&:not(.overlay-nav) {
visibility: visible;

View File

@@ -19,7 +19,7 @@ $layout-horizontal-nav-layout-navbar-z-index: 11 !default;
$layout-boxed-content-width: 90rem !default;
// 👉Footer
$layout-vertical-nav-footer-height: 3.5rem !default;
$layout-vertical-nav-footer-height: 0rem !default;
// 👉 Layout overlay
$layout-overlay-z-index: 11 !default;

View File

@@ -1,3 +1,2 @@
@use "_global";
@use "vue3-perfect-scrollbar/dist/vue3-perfect-scrollbar.min.css";
@use "_classes";

View File

@@ -1,15 +1,21 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useTheme } from 'vuetify'
import store from './store'
// 第一时间应用主题
const { global: globalTheme } = useTheme()
globalTheme.name.value = localStorage.getItem('theme') || 'light'
import store from './store'
// 提示框
const $toast = useToast()
// 设置主题
function setTheme() {
const { global: globalTheme } = useTheme()
let theme = localStorage.getItem('theme') || 'light'
if (theme === 'auto')
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
globalTheme.name.value = theme
}
// SSE持续接收消息
function startSSEMessager() {
const token = store.state.auth.token
@@ -32,6 +38,7 @@ function startSSEMessager() {
// 页面加载时,加载当前用户数据
onBeforeMount(async () => {
setTheme()
startSSEMessager()
})
</script>

54
src/ace-config.ts Normal file
View File

@@ -0,0 +1,54 @@
import ace from 'ace-builds'
import modeJsonUrl from 'ace-builds/src-noconflict/mode-json?url'
import modeJavascriptUrl from 'ace-builds/src-noconflict/mode-javascript?url'
import modeHtmlUrl from 'ace-builds/src-noconflict/mode-html?url'
import modeYamlUrl from 'ace-builds/src-noconflict/mode-yaml?url'
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
import themeMonokaiUrl from 'ace-builds/src-noconflict/theme-monokai?url'
import workerBaseUrl from 'ace-builds/src-noconflict/worker-base?url'
import workerJsonUrl from 'ace-builds/src-noconflict/worker-json?url'
import workerJavascriptUrl from 'ace-builds/src-noconflict/worker-javascript?url'
import workerHtmlUrl from 'ace-builds/src-noconflict/worker-html?url'
import workerYamlUrl from 'ace-builds/src-noconflict/worker-yaml?url'
import snippetsHtmlUrl from 'ace-builds/src-noconflict/snippets/html?url'
import snippetsJsUrl from 'ace-builds/src-noconflict/snippets/javascript?url'
import snippetsYamlUrl from 'ace-builds/src-noconflict/snippets/yaml?url'
import snippetsJsonUrl from 'ace-builds/src-noconflict/snippets/json?url'
import 'ace-builds/src-noconflict/ext-language_tools'
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
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/theme/github', themeGithubUrl)
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
ace.config.setModuleUrl('ace/mode/base', workerBaseUrl)
ace.config.setModuleUrl('ace/mode/json_worker', workerJsonUrl)
ace.config.setModuleUrl('ace/mode/javascript_worker', workerJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html_worker', workerHtmlUrl)
ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl)
ace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl)
ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
ace.require('ace/ext/language_tools')

View File

@@ -80,6 +80,9 @@ export interface Subscribe {
// 是否洗版数字或者boolean
best_version: any
// 使用 imdbid 搜索
search_imdbid?: any
// 当前优先级
current_priority: number
@@ -178,6 +181,9 @@ export interface MediaInfo {
// 豆瓣ID
douban_id?: string
// Bangumi ID
bangumi_id?: string
// 媒体原语种
original_language?: string
@@ -279,6 +285,9 @@ export interface MediaInfo {
// 下一集
next_episode_to_air?: object
// 别名
names?: string[]
}
// TMDB季信息
@@ -419,6 +428,28 @@ export interface DoubanPerson {
}
// Bangumi人物信息
export interface BangumiPerson {
// ID
id?: number
// 名称
name?: string
// 类型
type?: number
// 角色
career?: string[]
// images large/normal
images?: { [key: string]: string }
// 关系
relation?: string
}
// 站点
export interface Site {
@@ -510,8 +541,11 @@ export interface DownloadingInfo {
// 媒体信息
media: { [key: string]: any }
// 下载用户
// 下载用户ID
userid?: string
// 下载用户名称
username?: string
}
// 缺失剧集信息
@@ -578,6 +612,9 @@ export interface Plugin {
// 插件仓库地址
repo_url?: string
// 变更历史
history?: { [key: string]: string }
}
// 种子信息
@@ -658,6 +695,9 @@ export interface TorrentInfo {
// 剩余免费时间
freedate_diff: string
// 种子类型
category: string
}
// 识别元数据
@@ -793,18 +833,37 @@ export interface Context {
// 用户信息
export interface User {
// 用户ID
id: number
// 用户名称
name: string
// 用户密码
password: string
// 用户邮箱
email: string
// 是否激活
is_active: boolean
// 是否管理员
is_superuser: boolean
// 头像
avatar: string
// 是否开启双重验证
is_otp: boolean
}
// 存储空间
export interface Storage {
// 总空间
total_storage: number
// 已使用空间
used_storage: number
}
@@ -877,6 +936,9 @@ export interface ScheduleInfo {
// 名称
name: string
// 提供者
provider: string
// 状态
status: string
@@ -895,6 +957,7 @@ export interface NotificationSwitch {
telegram: boolean
slack: boolean
synologychat: boolean
vocechat: boolean
}
// 环境设置
@@ -905,45 +968,132 @@ export interface Setting {
// 文件浏览接口
export interface EndPoints {
// 文件列表
list: any
// 创建目录
mkdir: any
// 删除文件
delete: any
// 下载文件
download: any
// 图片预览
image: any
// 重命名
rename: any
}
// 文件浏览项目
export interface FileItem {
// 类型
type: string
// 文件名
name: string
// 文件名不含扩展名
basename: string
// 文件路径
path: string
// 文件扩展名
extension: string
// 文件大小
size: number
// 文件子元素
children: FileItem[]
// 文件创建时间
modify_time: number
}
// 媒体服务器播放条目
export interface MediaServerPlayItem {
// ID
id?: string | number
// 标题
title: string
// 副标题
subtitle?: string
// 类型
type?: string
// 海报
image?: string
// 链接
link?: string
// 播放百分比
percent?: number
}
// 媒体服务器媒体库
export interface MediaServerLibrary {
// 服务器名称
server: string
// ID
id?: string | number
// 名称
name: string
// 路径
path?: string
// 类型
type?: string
// 图片
image?: string
// 图片列表
image_list?: string[]
// 链接
link?: string
}
// 消息通知
export interface Message {
// 消息类型
mtype?: string
// 消息标题
title?: string
// 消息内容
text?: string
// 消息链接
link?: string
// 消息图片
image?: string
// 消息时间
date?: string
// 登记时间
reg_time?: string
// 用户ID
userid?: string
// 消息方向0-接收1-发送
action?: number
// JSON
note?: string
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

View File

@@ -4,7 +4,6 @@ import axios from 'axios'
import List from './filebrowser/List.vue'
import Toolbar from './filebrowser/Toolbar.vue'
import Tree from './filebrowser/Tree.vue'
import type { EndPoints } from '@/api/types'
// 输入参数
@@ -70,12 +69,10 @@ const storagesArray = computed(() => {
// 方法
function loadingChanged(loading: number) {
if (loading) {
if (loading)
loading++
}
else if (loading > 0) {
else if (loading > 0)
loading--
}
}
function storageChanged(storage: string) {
@@ -114,38 +111,20 @@ onMounted(() => {
@foldercreated="refreshPending = true"
@sortchanged="sortChanged"
/>
<VRow no-gutters>
<VCol v-if="tree" sm="auto" class="d-none d-md-block">
<Tree
:path="path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
/>
</VCol>
<VDivider v-if="tree" vertical />
<VCol>
<List
:path="path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
:sort="sort"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@filedeleted="refreshPending = true"
@renamed="refreshPending = true"
/>
</VCol>
</VRow>
<List
:path="path"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axiosInstance"
:refreshpending="refreshPending"
:sort="sort"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@filedeleted="refreshPending = true"
@renamed="refreshPending = true"
/>
</div>
</VCard>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import miscpose from '@images/pages/pose-fs-9.png'
import image from '@images/misc/teamwork.png'
const props = defineProps<Props>()
@@ -11,25 +11,24 @@ interface Props {
</script>
<template>
<div class="flex flex-col">
<ErrorHeader
:error-code="props.errorCode"
:error-title="props.errorTitle"
:error-description="props.errorDescription"
/>
<VEmptyState
:image="image"
size="250"
>
<template #title>
<div class="mt-8 text-2xl">
{{ props.errorTitle }}
</div>
</template>
<!-- 👉 Image -->
<div class="text-center">
<VImg
:src="miscpose"
class="mx-auto pt-10"
max-width="250"
cover
/>
<template #text>
<div class="text-subtitle">
{{ props.errorDescription }}
</div>
</template>
<template #actions>
<slot name="button" />
</div>
</div>
</template>
</VEmptyState>
</template>
<style lang="scss">
</style>

View File

@@ -25,7 +25,7 @@ function goPlay() {
// 计算图片地址
const getImgUrl = computed(() => {
const image = props.media?.image || ''
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(image)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(image)}/0`
})
</script>

View File

@@ -0,0 +1,95 @@
<script lang="ts" setup>
import personIcon from '@images/misc/person-icon.png'
import type { BangumiPerson } from '@/api/types'
const personProps = defineProps({
person: Object as PropType<BangumiPerson>,
width: String,
height: String,
})
// 当前人物
const personInfo = ref(personProps.person)
// 人物图片是否加载
const isImageLoaded = ref(false)
// 人物图片地址
function getPersonImage() {
if (!personInfo.value?.images)
return personIcon
return personInfo.value?.images?.medium
}
// 使用、拼装人物角色
function getPersonCharacter() {
if (!personInfo.value?.career)
return ''
return personInfo.value?.career.join('、')
}
// 打开人物详情
function goPersonDetail() {
if (!personInfo.value?.id)
return
window.open(`https://bangumi.tv/person/${personInfo.value?.id}`, '_blank')
}
</script>
<template>
<VHover v-bind="personProps">
<template #default="hover">
<VCard
v-bind="hover.props"
:height="personProps.height"
:width="personProps.width"
class="rounded-lg"
:class="{
'transition transform-cpu duration-300 scale-105': hover.isHovering,
}"
@click.stop="goPersonDetail"
>
<div
class="person-card relative transform-gpu cursor-pointer rounded shadow ring-1 transition duration-150 ease-in-out scale-100 ring-gray-700"
>
<div style="padding-bottom: 150%;">
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
<div class="relative mt-2 mb-4 flex h-1/2 w-full justify-center">
<VAvatar
size="120"
:class="{
'ring-1 ring-gray-700': isImageLoaded,
}"
>
<VImg
v-img
:src="getPersonImage()"
cover
@load="isImageLoaded = true"
/>
</VAvatar>
</div>
<div class="w-full truncate text-center font-bold">
{{ personInfo?.name }}
</div>
<div class="overflow-hidden whitespace-normal text-center text-sm" style=" display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical;-webkit-line-clamp: 2;">
{{ getPersonCharacter() }}
</div>
<div class="absolute bottom-0 left-0 right-0 h-12 rounded-b" />
</div>
</div>
</div>
</VCard>
</template>
</VHover>
</template>
<style lang="scss">
.person-card {
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-theme-surface)) 60%);
}
.person-card:hover {
background-image: linear-gradient(45deg, rgb(var(--v-theme-background)), rgb(var(--v-custom-background)) 60%);
}
</style>

View File

@@ -23,6 +23,11 @@ function getSpeedText() {
// 下载状态
const isDownloading = ref(props.info?.state === 'downloading')
// 监听props.info?.state的变化
watch(() => props.info?.state, (newValue) => {
isDownloading.value = newValue === 'downloading'
})
// 图片是否加载完成
const imageLoaded = ref(false)

View File

@@ -36,6 +36,7 @@ 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 ' },

View File

@@ -1,5 +1,8 @@
<script lang="ts" setup>
import type { MediaServerLibrary } from '@/api/types'
import plex from '@images/misc/plex.png'
import emby from '@images/misc/emby.png'
import jellyfin from '@images/misc/jellyfin.png'
// 输入参数
const props = defineProps({
@@ -8,25 +11,150 @@ const props = defineProps({
height: String,
})
// canvas
const canvasRef = ref<HTMLCanvasElement>()
// 图片地址
const imgUrl = ref('')
// 图片是否加载完成
const imageLoaded = ref(false)
// 图片是否加载错误
const imageError = ref(false)
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 计算图片地址
const getImgUrl = computed(() => {
const image = props.media?.image || props.media?.image_list?.[0] || ''
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(image)}`
})
// 图片加载错误
function imageErrorHandler() {
imageError.value = true
}
// 默认图片
function getDefaultImage() {
if (props.media?.server === 'plex')
return plex
else if (props.media?.server === 'emby')
return emby
else if (props.media?.server === 'jellyfin')
return jellyfin
else
return plex
}
// 跳转播放
function goPlay() {
if (props.media?.link)
window.open(props.media?.link, '_blank')
}
// 生成图片代理路径
function getImgUrl(url: string) {
if (!url)
return getDefaultImage()
else
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(url)}/0`
}
// 根据多张图片生成媒体库封面
async function drawImages(imageList: string[]) {
// 图片
const IMAGES = imageList
if (IMAGES.length === 0)
return getDefaultImage()
// 为所有图片添加system/img前缀
for (let i = 0; i < IMAGES.length; i++)
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(IMAGES[i])}/0`
// canvas
const canvas = canvasRef.value
if (!canvas)
return getDefaultImage()
// 画布参数
const POSTER_WIDTH = (canvas.width - 32) / 4
const POSTER_HEIGHT = canvas.height * 0.75 - 8
const MARGIN_WIDTH = 4
const MARGIN_HEIGHT = 4
const REFLECTION_HEIGHT = POSTER_HEIGHT / 2
const REFLECTION_SHOW_HEIGHT = canvas.height / 4
// 获取画布上下文
const ctx = canvas.getContext('2d')
if (!ctx)
return getDefaultImage()
// 设置背景色为黑色
ctx.fillStyle = '#000000'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 绘制图片
async function drawImageWithReflection(imgSrc: string, index: number) {
if (!canvas)
return
if (!ctx)
return
const img = new Image()
img.setAttribute('crossorigin', 'anonymous')
img.src = imgSrc
await new Promise(resolve => img.onload = resolve)
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
const y = MARGIN_HEIGHT
ctx.drawImage(img, x, y, POSTER_WIDTH, POSTER_HEIGHT)
ctx.save()
ctx.translate(0, canvas.height)
ctx.scale(1, -1)
ctx.drawImage(
img,
0,
0,
img.width,
img.height,
x,
REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT,
POSTER_WIDTH,
REFLECTION_HEIGHT,
)
const gradient = ctx.createLinearGradient(
0,
REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT,
0,
REFLECTION_HEIGHT,
)
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)')
ctx.fillStyle = gradient
ctx.fillRect(x, 0, POSTER_WIDTH, REFLECTION_SHOW_HEIGHT)
ctx.restore()
}
// 绘制多张图片
const loopCount = Math.min(4, IMAGES.length)
for (let i = 0; i < loopCount; i++)
await drawImageWithReflection(IMAGES[i], i + 1)
// 转换为图片地址
return canvas.toDataURL('image/png')
}
onMounted(async () => {
if (props.media?.image_list && props.media?.image_list.length > 0)
imgUrl.value = await drawImages(props.media?.image_list || [])
else
imgUrl.value = getImgUrl(props.media?.image || '')
})
</script>
<template>
@@ -46,11 +174,13 @@ function goPlay() {
@click="goPlay"
>
<template #image>
<canvas ref="canvasRef" class="w-full h-full hidden" />
<VImg
:src="getImgUrl"
:src="imgUrl"
aspect-ratio="2/3"
cover
@load="imageLoadHandler"
@error="imageErrorHandler"
>
<template #placeholder>
<div class="w-full h-full">

View File

@@ -16,11 +16,6 @@ const props = defineProps({
height: String,
})
// 订阅规则
const subscribeRules = ref({
show_edit_dialog: false,
})
// 提示框
const $toast = useToast()
@@ -57,6 +52,15 @@ const seasonInfos = ref<TmdbSeason[]>([])
// 选中的订阅季
const seasonsSelected = ref<TmdbSeason[]>([])
// 获得mediaid
function getMediaId() {
return props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}`
: props.media?.douban_id
? `douban:${props.media?.douban_id}`
: `bangumi:${props.media?.bangumi_id}`
}
// 订阅弹窗选择的多季
function subscribeSeasons() {
subscribeSeasonDialog.value = false
@@ -131,6 +135,7 @@ async function addSubscribe(season = 0) {
year: props.media?.year,
tmdbid: props.media?.tmdb_id,
doubanid: props.media?.douban_id,
bangumiid: props.media?.bangumi_id,
season,
best_version,
})
@@ -151,9 +156,12 @@ async function addSubscribe(season = 0) {
)
// 弹出订阅编辑弹窗
if (result.success && seasonsSelected.value.length <= 1 && subscribeRules.value.show_edit_dialog) {
subscribeId.value = result.data.id
subscribeEditDialog.value = true
if (result.success && seasonsSelected.value.length <= 1) {
const show_edit_dialog = await querySubscribeRules()
if (show_edit_dialog) {
subscribeId.value = result.data.id
subscribeEditDialog.value = true
}
}
}
catch (error) {
@@ -186,9 +194,7 @@ async function removeSubscribe() {
// 开始处理
startNProgress()
try {
const mediaid = props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}`
: `douban:${props.media?.douban_id}`
const mediaid = getMediaId()
const result: { [key: string]: any } = await api.delete(
`subscribe/media/${mediaid}`,
@@ -249,9 +255,7 @@ async function handleCheckExists() {
// 调用API检查是否已订阅电视剧需要指定季
async function checkSubscribe(season = 0) {
try {
const mediaid = props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}`
: `douban:${props.media?.douban_id}`
const mediaid = getMediaId()
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
@@ -314,11 +318,12 @@ async function querySubscribeRules() {
'system/setting/DefaultFilterRules',
)
if (result.data?.value)
subscribeRules.value = result.data?.value
return result.data.value.show_edit_dialog
}
catch (error) {
console.log(error)
}
return false
}
// 爱心订阅按钮响应
@@ -362,11 +367,7 @@ function goMediaDetail() {
router.push({
path: '/media',
query: {
mediaid: `${
props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}`
: `douban:${props.media?.douban_id}`
}`,
mediaid: getMediaId(),
type: props.media?.type,
},
})
@@ -377,13 +378,10 @@ function handleSearch() {
router.push({
path: '/resource',
query: {
keyword: `${
props.media?.tmdb_id
? `tmdb:${props.media?.tmdb_id}`
: `douban:${props.media?.douban_id}`
}`,
keyword: getMediaId(),
type: props.media?.type,
area: 'title',
season: props.media?.season,
},
})
}
@@ -392,7 +390,6 @@ function handleSearch() {
onBeforeMount(() => {
handleCheckSubscribe()
handleCheckExists()
querySubscribeRules()
})
// 计算图片地址
@@ -442,6 +439,7 @@ function getYear(airDate: string) {
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goMediaDetail"
>
<VImg
aspect-ratio="2/3"
@@ -457,60 +455,60 @@ function getYear(airDate: string) {
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
<!-- 类型角标 -->
<VChip
v-show="isImageLoaded"
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.type }}
</VChip>
<!-- 本地存在标识 -->
<ExistIcon v-if="isExists" />
<!-- 评分角标 -->
<VChip
v-if="isImageLoaded && props.media?.vote_average && !isExists"
variant="elevated"
size="small"
:class="getChipColor('rating')"
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.vote_average }}
</VChip>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError"
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
@click.stop="goMediaDetail"
>
<span class="font-bold">{{ props.media?.year }}</span>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
{{ props.media?.overview }}
</p>
<div class="flex align-center justify-between">
<IconBtn
icon="mdi-magnify"
color="white"
@click.stop="handleSearch"
/>
<IconBtn
icon="mdi-heart"
:color="isSubscribed ? 'error' : 'white'"
@click.stop="handleSubscribe"
/>
</div>
</VCardText>
</VImg>
<!-- 类型角标 -->
<VChip
v-show="isImageLoaded"
variant="elevated"
size="small"
:class="getChipColor(props.media?.type || '')"
class="absolute left-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.type }}
</VChip>
<!-- 本地存在标识 -->
<ExistIcon v-if="isExists" />
<!-- 评分角标 -->
<VChip
v-if="isImageLoaded && props.media?.vote_average && !isExists"
variant="elevated"
size="small"
:class="getChipColor('rating')"
class="absolute right-2 top-2 bg-opacity-80 shadow-md text-white font-bold"
>
{{ props.media?.vote_average }}
</VChip>
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError"
class="w-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
>
<span class="font-bold">{{ props.media?.year }}</span>
<h1 class="mb-1 text-white font-extrabold text-xl line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.title }}
</h1>
<p class="leading-4 line-clamp-4 overflow-hidden text-ellipsis ...">
{{ props.media?.overview }}
</p>
<div class="flex align-center justify-between">
<IconBtn
icon="mdi-magnify"
color="white"
@click.stop="handleSearch"
/>
<IconBtn
icon="mdi-heart"
:color="isSubscribed ? 'error' : 'white'"
@click.stop="handleSubscribe"
/>
</div>
</VCardText>
</VCard>
</template>
</VHover>
<!-- 订阅季弹窗 -->
<VBottomSheet
v-if="subscribeSeasonDialog"
v-model="subscribeSeasonDialog"
inset
scrollable
@@ -594,6 +592,7 @@ function getYear(airDate: string) {
</VBottomSheet>
<!-- 订阅编辑弹窗 -->
<SubscribeEditForm
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="subscribeId"
@close="subscribeEditDialog = false"

View File

@@ -0,0 +1,112 @@
<script lang="ts" setup>
import type { Message } from '@/api/types'
import { formatDateDifference } from '@core/utils/formatters'
// 输入参数
const props = defineProps({
message: Object as PropType<Message>,
width: String,
height: String,
})
// 图片是否加载完成
const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
}
// 链接打开新窗口
function openLink() {
if (props.message?.link)
window.open(props.message.link, '_blank')
}
// 将note转换为json
function noteToJson() {
if (props.message?.note) {
try {
return JSON.parse(props.message.note)
}
catch (error) {
console.error(error)
}
}
return {}
}
// 将\n转换为html属性的换行符
function replaceNewLine(value: string) {
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"
>
<VImg
:src="props.message?.image"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</div>
<VCardTitle v-if="props.message?.title" class="whitespace-break-spaces">
{{ props.message?.title }}
</VCardTitle>
<VAlert
v-if="props.message?.text && props.message?.action === 0"
variant="tonal"
type="success"
>
<template #prepend />
{{ props.message?.text }}
</VAlert>
<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
>
<VListItemTitle v-if="value.title_year" class="font-bold">
{{ key + 1 }}. {{ value.title_year }}
</VListItemTitle>
<VListItemTitle v-if="value.enclosure" class="font-bold whitespace-break-spaces">
{{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} {{ value.seeders }}
</VListItemTitle>
<VListItemSubtitle v-if="value.type">
类型{{ value.type }} 评分{{ value.vote_average }}
</VListItemSubtitle>
<VListItemSubtitle v-if="value.enclosure" class="whitespace-break-spaces">
{{ value.description }}
</VListItemSubtitle>
</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>
</template>

View File

@@ -1,15 +1,18 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import VersionHistory from '../misc/VersionHistory.vue'
import api from '@/api'
import type { Plugin } from '@/api/types'
import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image'
import { isNullOrEmptyObject } from '@/@core/utils'
// 输入参数
const props = defineProps({
plugin: Object as PropType<Plugin>,
width: String,
height: String,
count: Number,
})
// 定义触发的自定义事件
@@ -36,6 +39,9 @@ const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 更新日志弹窗
const releaseDialog = ref(false)
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
@@ -49,7 +55,7 @@ async function installPlugin() {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在安装 ${props.plugin?.plugin_name} ${props?.plugin?.plugin_version} 插件...`
progressText.value = `正在安装 ${props.plugin?.plugin_name} v${props?.plugin?.plugin_version} ...`
const result: { [key: string]: any } = await api.get(
`plugin/install/${props.plugin?.id}`,
@@ -85,9 +91,9 @@ const iconPath: Ref<string> = computed(() => {
return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}/1`
return `/plugin_icon/${props.plugin?.plugin_icon}`
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
// 访问插件页面
@@ -117,15 +123,29 @@ function visitPluginPage() {
window.open(repoUrl, '_blank')
}
// 显示更新日志
function showUpdateHistory() {
releaseDialog.value = true
}
// 弹出菜单
const dropdownItems = ref([
{
title: '查看详情',
title: '项目主页',
value: 1,
show: true,
props: {
prependIcon: 'mdi-information-outline',
prependIcon: 'mdi-github',
click: visitPluginPage,
},
}, {
title: '更新说明',
value: 2,
show: !isNullOrEmptyObject(props.plugin?.history || {}),
props: {
prependIcon: 'mdi-update',
click: showUpdateHistory,
},
},
])
</script>
@@ -150,6 +170,7 @@ const dropdownItems = ref([
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
v-show="item.show"
:key="i"
variant="plain"
@click="item.props.click"
@@ -163,15 +184,6 @@ const dropdownItems = ref([
</VMenu>
</IconBtn>
</div>
<div
v-if="props.plugin?.has_update"
class="me-n3 absolute top-0 left-1"
>
<VIcon
icon="mdi-new-box"
class="text-white"
/>
</div>
<VAvatar
size="8rem"
>
@@ -186,20 +198,28 @@ const dropdownItems = ref([
/>
</VAvatar>
</div>
<VCardTitle>{{ props.plugin?.plugin_name }}</VCardTitle>
<VCardText>
<VCardTitle>
{{ props.plugin?.plugin_name }}
<span class="text-sm text-gray-500">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
<VCardText class="pb-2">
{{ props.plugin?.plugin_desc }}
</VCardText>
<VCardText>
作者<a
:href="props.plugin?.author_url"
target="_blank"
@click.stop
>
{{ props.plugin?.plugin_author }}
</a><br>
版本{{ props.plugin?.plugin_version }}
<VCardText class="flex items-center justify-start pb-2">
<span>
<VIcon icon="mdi-account" class="me-1" />
<a
:href="props.plugin?.author_url"
target="_blank"
@click.stop
>
{{ props.plugin?.plugin_author }}
</a>
</span>
<span v-if="props.count" class="ms-3">
<VIcon icon="mdi-download" />
<span class="text-sm ms-1 mt-1">{{ props.count?.toLocaleString() }}</span>
</span>
</VCardText>
</VCard>
<!-- 安装插件进度框 -->
@@ -221,6 +241,19 @@ const dropdownItems = ref([
</VCardText>
</VCard>
</VDialog>
<!-- 更新日志 -->
<VDialog
v-if="releaseDialog"
v-model="releaseDialog"
width="600"
scrollable
>
<VCard>
<DialogCloseBtn @click="releaseDialog = false" />
<VCardTitle>{{ props.plugin?.plugin_name }} 更新说明</VCardTitle>
<VersionHistory :history="props.plugin?.history" />
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>

View File

@@ -1,23 +1,28 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { useConfirm } from 'vuetify-use-dialog'
import { VIcon } from 'vuetify/lib/components/index.mjs'
import api from '@/api'
import type { Plugin } from '@/api/types'
import FormRender from '@/components/render/FormRender.vue'
import PageRender from '@/components/render/PageRender.vue'
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'
// 输入参数
const props = defineProps({
plugin: Object as PropType<Plugin>,
count: Number, // 下载次数
action: Boolean, // 动作标识
width: String,
height: String,
})
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
const emit = defineEmits(['remove', 'save', 'actionDone'])
// 背景颜色
const backgroundColor = ref('#28A9E1')
@@ -40,12 +45,18 @@ const pluginConfigDialog = ref(false)
// 插件配置表单数据
const pluginConfigForm = ref({})
// 进度框
const progressDialog = ref(false)
// 插件表单配置项
let pluginFormItems = reactive([])
// 插件数据页面
const pluginInfoDialog = ref(false)
// 进度框文本
const progressText = ref('正在更新插件...')
// 插件数据页面配置项
let pluginPageItems = reactive([])
@@ -55,6 +66,17 @@ const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 更新日志弹窗
const releaseDialog = ref(false)
// 监听动作标识如为true则打开详情
watch(() => props.action, (newAction, oldAction) => {
if (newAction && !oldAction) {
openPluginDetail()
emit('actionDone')
}
})
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
@@ -63,6 +85,16 @@ async function imageLoaded() {
backgroundColor.value = await getDominantColor(imageElement)
}
// 显示更新日志
function showUpdateHistory() {
// 检查当前版本是否有更新日志
if (isNullOrEmptyObject(props.plugin?.history)) {
updatePlugin()
} else{
releaseDialog.value = true
}
}
// 调用API卸载插件
async function uninstallPlugin() {
const isConfirmed = await createConfirm({
@@ -82,7 +114,12 @@ async function uninstallPlugin() {
return
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在卸载 ${props.plugin?.plugin_name} ...`
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
// 隐藏等待提示框
progressDialog.value = false
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 已卸载`)
@@ -139,15 +176,20 @@ async function loadPluginConf() {
// 调用API保存配置数据
async function savePluginConf() {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在保存 ${props.plugin?.plugin_name} 配置...`
try {
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
progressDialog.value = false
pluginConfigDialog.value = false
$toast.success(`插件 ${props.plugin?.plugin_name} 配置已保存`)
// 通知父组件刷新
emit('save')
}
else {
progressDialog.value = false
$toast.error(`插件 ${props.plugin?.plugin_name} 配置保存失败:${result.message}}`)
}
}
@@ -181,9 +223,9 @@ const iconPath: Ref<string> = computed(() => {
return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}/1`
return `/plugin_icon/${props.plugin?.plugin_icon}`
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
// 重置插件
@@ -220,11 +262,62 @@ async function resetPlugin() {
}
}
// 更新插件
async function updatePlugin() {
try {
releaseDialog.value = false
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在更新 ${props.plugin?.plugin_name} ...`
const result: { [key: string]: any } = await api.get(
`plugin/install/${props.plugin?.id}`,
{
params: {
repo_url: props.plugin?.repo_url,
force: true,
},
},
)
// 隐藏等待提示框
progressDialog.value = false
if (result.success) {
$toast.success(`插件 ${props.plugin?.plugin_name} 更新成功!`)
// 通知父组件刷新
emit('save')
}
else {
$toast.error(`插件 ${props.plugin?.plugin_name} 更新失败:${result.message}`)
}
}
catch (error) {
console.error(error)
}
}
// 访问作者主页
function visitAuthorPage() {
window.open(props.plugin?.author_url, '_blank')
}
// 查看日志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`
window.open(url, '_blank')
}
// 打开插件详情
function openPluginDetail() {
if (props.plugin?.has_page)
showPluginInfo()
else
showPluginConfig()
}
// 弹出菜单
const dropdownItems = ref([
{
@@ -246,8 +339,18 @@ const dropdownItems = ref([
},
},
{
title: '重置',
title: '更新',
value: 3,
show: props.plugin?.has_update,
props: {
prependIcon: 'mdi-arrow-up-circle-outline',
color: 'success',
click: showUpdateHistory,
},
},
{
title: '重置',
value: 4,
show: true,
props: {
prependIcon: 'mdi-cancel',
@@ -257,7 +360,7 @@ const dropdownItems = ref([
},
{
title: '卸载',
value: 4,
value: 5,
show: true,
props: {
prependIcon: 'mdi-trash-can-outline',
@@ -265,9 +368,20 @@ const dropdownItems = ref([
click: uninstallPlugin,
},
},
{
title: '查看日志',
value: 6,
show: true,
props: {
prependIcon: 'mdi-file-document-outline',
click: () => {
openLoggerWindow()
},
},
},
{
title: '作者主页',
value: 4,
value: 7,
show: true,
props: {
prependIcon: 'mdi-home-circle-outline',
@@ -275,6 +389,13 @@ const dropdownItems = ref([
},
},
])
// 监听插件状态变化
watch(() => props.plugin?.has_update, (newHasUpdate, oldHasUpdate) => {
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
if (updateItemIndex !== -1)
dropdownItems.value[updateItemIndex].show = newHasUpdate
})
</script>
<template>
@@ -283,17 +404,21 @@ const dropdownItems = ref([
v-if="isVisible"
:width="props.width"
:height="props.height"
@click="() => {
if (props.plugin?.has_page)
showPluginInfo()
else
showPluginConfig()
}"
@click="openPluginDetail"
>
<div
class="relative pa-4 text-center card-cover-blurred"
:style="{ background: `${backgroundColor}` }"
>
<div
v-if="props.plugin?.has_update"
class="me-n3 absolute top-0 left-1"
>
<VIcon
icon="mdi-new-box"
class="text-white"
/>
</div>
<div class="me-n3 absolute top-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" class="text-white" />
@@ -333,10 +458,14 @@ const dropdownItems = ref([
/>
</VAvatar>
</div>
<span v-if="props.count" class="absolute bottom-1 right-2 flex items-center">
<VIcon icon="mdi-fire" />
<span class="text-sm ms-1">{{ props.count?.toLocaleString() }}</span>
</span>
<VCardItem class="py-2">
<VCardTitle class="flex items-center flex-row">
<VBadge v-if="props.plugin?.state" dot inline color="success" class="me-1 mb-1" />
{{ props.plugin?.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">{{ props.plugin?.plugin_version }}</span>
{{ props.plugin?.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ props.plugin?.plugin_version }}</span>
</VCardTitle>
</VCardItem>
<VCardText>
@@ -411,6 +540,49 @@ const dropdownItems = ref([
</VCardActions>
</VCard>
</VDialog>
<!-- 更新插件进度框 -->
<VDialog
v-model="progressDialog"
:scrim="false"
width="25rem"
>
<VCard
color="primary"
>
<VCardText class="text-center">
{{ progressText }}
<VProgressLinear
indeterminate
color="white"
class="mb-0 mt-1"
/>
</VCardText>
</VCard>
</VDialog>
<!-- 更新日志 -->
<VDialog
v-if="releaseDialog"
v-model="releaseDialog"
width="600"
scrollable
>
<VCard>
<DialogCloseBtn @click="releaseDialog = false" />
<VCardTitle>{{ props.plugin?.plugin_name }} 更新说明</VCardTitle>
<VersionHistory :history="props.plugin?.history" />
<VCardText>
<VBtn
@click="updatePlugin"
block
>
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
更新到最新版本
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>

View File

@@ -31,7 +31,7 @@ const getImgUrl = computed(() => {
if (imageLoadError.value)
return noImage
const image = props.media?.image || ''
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(image)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(image)}/0`
})
// 跳转播放

View File

@@ -2,10 +2,10 @@
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import SiteAddEditForm from '../form/SiteAddEditForm.vue'
import { formatFileSize } from '@core/utils/formatters'
import SiteTorrentTable from '../table/SiteTorrentTable.vue'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Site, TorrentInfo } from '@/api/types'
import type { Site } from '@/api/types'
import ExistIcon from '@core/components/ExistIcon.vue'
// 输入参数
@@ -51,47 +51,13 @@ const progressDialog = ref(false)
// 进度文本
const progressText = ref('请稍候 ...')
// 资源浏览表头
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 },
]
// 数据列表
const resourceDataList = ref<TorrentInfo[]>([])
// 搜索
const resourceSearch = ref('')
// 加载状态
const resourceLoading = ref(false)
// 总条数
const resourceTotalItems = ref(0)
// 每页条数
const resourceItemsPerPage = ref(25)
// 用户名密码表单
const userPwForm = ref({
username: '',
password: '',
code: '',
})
// 打开种子详情页面
function openTorrentDetail(page_url: string) {
window.open(page_url, '_blank')
}
// 下载种子文件
async function downloadTorrentFile(enclosure: string) {
window.open(enclosure, '_blank')
}
// 查询站点图标
async function getSiteIcon() {
try {
@@ -130,7 +96,6 @@ async function handleSiteUpdate() {
// 打开资源浏览弹窗
async function handleResourceBrowse() {
resourceDialog.value = true
getResourceList()
}
// 调用API更新站点Cookie UA
@@ -152,6 +117,7 @@ async function updateSiteCookie() {
params: {
username: userPwForm.value.username,
password: userPwForm.value.password,
code: userPwForm.value.code,
},
},
)
@@ -169,30 +135,6 @@ async function updateSiteCookie() {
}
}
// 促销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'
}
// 调用API查询站点资源
async function getResourceList() {
resourceLoading.value = true
try {
resourceDataList.value = await api.get(`site/resource/${cardProps.site?.id}`)
resourceLoading.value = false
}
catch (error) {
console.error(error)
}
}
// 打开站点页面
function openSitePage() {
window.open(cardProps.site?.url, '_blank')
@@ -208,7 +150,7 @@ onMounted(() => {
<VCard
:height="cardProps.height"
:width="cardProps.width"
:flat="!cardProps.site?.is_active"
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
class="overflow-hidden"
@click="siteEditDialog = true"
>
@@ -335,7 +277,7 @@ onMounted(() => {
<VRow>
<VCol
cols="12"
md="6"
md="4"
>
<VTextField
v-model="userPwForm.username"
@@ -345,7 +287,7 @@ onMounted(() => {
</VCol>
<VCol
cols="12"
md="6"
md="4"
>
<VTextField
v-model="userPwForm.password"
@@ -359,6 +301,15 @@ onMounted(() => {
@keydown.enter="updateSiteCookie"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="userPwForm.code"
label="两步验证"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
@@ -375,6 +326,7 @@ onMounted(() => {
</VCard>
</VDialog>
<SiteAddEditForm
v-if="siteEditDialog"
v-model="siteEditDialog"
:siteid="cardProps.site?.id"
@save="siteEditDialog = false; emit('update')"
@@ -383,130 +335,17 @@ onMounted(() => {
/>
<!-- 站点资源弹窗 -->
<VDialog
v-if="resourceDialog"
v-model="resourceDialog"
max-width="80rem"
scrollable
z-index="1010"
>
<!-- Dialog Content -->
<VCard :title="`浏览站点 - ${cardProps.site?.name}`">
<DialogCloseBtn @click="resourceDialog = false" />
<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
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
>
<template #item.title="{ item }">
<div class="text-high-emphasis pt-1">
{{ item.raw.title }}
</div>
<div class="text-sm my-1">
{{ item.raw.description }}
</div>
<VChip
v-if="item.raw?.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&R
</VChip>
<VChip
v-if="item.raw?.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ item.raw?.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in item.raw?.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip
v-if="item.raw?.downloadvolumefactor !== 1 || item.raw?.uploadvolumefactor !== 1"
:class="
getVolumeFactorClass(item.raw?.downloadvolumefactor, item.raw?.uploadvolumefactor)
"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ item.raw?.volume_factor }}
</VChip>
</template>
<template #item.pubdate="{ item }">
<div>{{ item.raw.date_elapsed }}</div>
<div class="text-sm">
{{ item.raw.pubdate }}
</div>
</template>
<template #item.size="{ item }">
<div class="text-nowrap whitespace-nowrap">
{{ formatFileSize(item.raw.size) }}
</div>
</template>
<template #item.seeders="{ item }">
<div>{{ item.raw.seeders }}</div>
</template>
<template #item.peers="{ item }">
<div>{{ item.raw.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.raw.page_url)"
>
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="item.raw.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile(item.raw.enclosure)"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
<template #no-data>
没有数据
</template>
</VDataTable>
<SiteTorrentTable :site="cardProps.site?.id" />
</VCardText>
</VCard>
</VDialog>

View File

@@ -1,10 +1,11 @@
<script lang="ts" setup>
<script lang='ts' setup>
import { useToast } from 'vue-toast-notification'
import SubscribeEditForm from '../form/SubscribeEditForm.vue'
import { calculateTimeDifference } from '@/@core/utils'
import { formatSeason } from '@/@core/utils/formatters'
import api from '@/api'
import type { Subscribe } from '@/api/types'
import router from '@/router'
// 输入参数
const props = defineProps({
@@ -55,7 +56,7 @@ function getPercentage() {
return Math.round(
(((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0))
/ (props.media?.total_episode ?? 1))
* 100,
* 100,
)
}
@@ -126,8 +127,28 @@ const dropdownItems = ref([
},
},
{
title: '取消订阅',
title: '查看详情',
value: 3,
props: {
prependIcon: 'mdi-open-in-new',
click: () => {
router.push({
path: '/media',
query: {
mediaid: `${
props.media?.tmdbid
? `tmdb:${props.media?.tmdbid}`
: `douban:${props.media?.doubanid}`
}`,
type: props.media?.type,
},
})
},
},
},
{
title: '取消订阅',
value: 4,
props: {
prependIcon: 'mdi-trash-can-outline',
color: 'error',
@@ -162,7 +183,7 @@ const dropdownItems = ref([
</template>
<VCardTitle :class="getTextClass()">
{{ props.media?.name }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : "") }}
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
</VCardTitle>
<template #append>
<div class="me-n3">
@@ -252,7 +273,8 @@ const dropdownItems = ref([
<VIcon
icon="mdi-download"
class="me-1"
/> {{ lastUpdateText }}
/>
{{ lastUpdateText }}
</VCardText>
<VProgressLinear
v-if="getPercentage() > 0"
@@ -263,6 +285,7 @@ const dropdownItems = ref([
</VCard>
<!-- 订阅编辑弹窗 -->
<SubscribeEditForm
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="props.media?.id"
@remove="() => { emit('remove');subscribeEditDialog = false; }"

View File

@@ -93,7 +93,7 @@ onMounted(() => {
single-line
placeholder="电影或电视剧名称"
variant="solo"
append-inner-icon="mdi-magnify"
prepend-inner-icon="mdi-magnify"
flat
class="mx-1"
:loading="loading"
@@ -101,7 +101,7 @@ onMounted(() => {
@keydown.enter="searchMedias"
/>
</VToolbar>
<DialogCloseBtn @click="() => { emit('close') }" />
<VList
v-if="items.length > 0"
lines="three"
@@ -131,7 +131,6 @@ onMounted(() => {
</VListItemTitle>
<VListItemSubtitle class="mt-2" v-html="item.overview" />
</VListItem>
<VDivider v-if="i < items.length - 1" class="mt-1" inset />
</template>
</VList>
</VCard>

View File

@@ -5,7 +5,7 @@ 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 } from '@/api/types'
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
// 输入参数
const props = defineProps({
@@ -36,6 +36,9 @@ const meta = ref(props.torrent?.meta_info)
// 站点图标
const siteIcon = ref('')
// 存储是否已经下载过的记录
const downloaded = ref<String[]>([])
// 查询站点图标
async function getSiteIcon() {
try {
@@ -76,7 +79,7 @@ async function handleAddDownload(_site: any = undefined,
}
// 添加下载
async function addDownload(_media: any, _torrent: any) {
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress()
try {
const result: { [key: string]: any } = await api.post('download/', {
@@ -87,6 +90,7 @@ async function addDownload(_media: any, _torrent: any) {
if (result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
downloaded.value.push(_torrent?.enclosure || '')
}
else {
// 添加下载失败
@@ -131,6 +135,7 @@ onMounted(() => {
<VCard
:width="props.width"
:height="props.height"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'elevated'"
@click="handleAddDownload"
>
<template

View File

@@ -5,7 +5,7 @@ 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 } from '@/api/types'
import type { Context, MediaInfo, TorrentInfo } from '@/api/types'
// 输入参数
const props = defineProps({
@@ -33,6 +33,9 @@ const meta = ref(props.torrent?.meta_info)
// 站点图标
const siteIcon = ref('')
// 存储是否已经下载过的记录
const downloaded = ref<String[]>([])
// 查询站点图标
async function getSiteIcon() {
try {
@@ -73,7 +76,7 @@ async function handleAddDownload(_site: any = undefined,
}
// 添加下载
async function addDownload(_media: any, _torrent: any) {
async function addDownload(_media: MediaInfo, _torrent: TorrentInfo) {
startNProgress()
try {
const result: { [key: string]: any } = await api.post('download/', {
@@ -84,6 +87,7 @@ async function addDownload(_media: any, _torrent: any) {
if (result.success) {
// 添加下载成功
$toast.success(`${_torrent?.site_name} ${_torrent?.title} 添加下载成功!`)
downloaded.value.push(_torrent?.enclosure || '')
}
else {
// 添加下载失败
@@ -125,7 +129,10 @@ onMounted(() => {
</script>
<template>
<VListItem @click="handleAddDownload">
<VListItem
@click="handleAddDownload"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
>
<template
v-if="!showMoreTorrents"
#prepend

View File

@@ -73,6 +73,9 @@ const nameTestResult = ref<Context>()
// 识别结果对话框
const nameTestDialog = ref(false)
// 延迟加载
const defer = (_: number) => true
// 目录过滤
const dirs = computed(() =>
items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)),
@@ -267,6 +270,28 @@ async function recognize(path: string) {
}
}
// 调用API刮削
async function scrape(path: string) {
try {
// 显示进度条
progressDialog.value = true
progressText.value = `正在刮削 ${path} ...`
const result: { [key: string]: any } = await api.get('media/scrape', {
params: {
path,
},
})
// 关闭进度条
progressDialog.value = false
if (!result.success)
$toast.error(result.message)
else
$toast.success(`${path}削刮完成!`)
}
catch (error) {
console.error(error)
}
}
// 弹出菜单
const dropdownItems = ref([
{
@@ -279,8 +304,17 @@ const dropdownItems = ref([
},
},
}, {
title: '重命名',
title: '刮削',
value: 2,
props: {
prependIcon: 'mdi-auto-fix',
click: (_item: FileItem) => {
scrape(_item.path || '')
},
},
}, {
title: '重命名',
value: 3,
props: {
prependIcon: 'mdi-rename',
click: showRenmae,
@@ -288,7 +322,7 @@ const dropdownItems = ref([
},
{
title: '整理',
value: 3,
value: 4,
props: {
prependIcon: 'mdi-folder-arrow-right',
click: showTransfer,
@@ -296,7 +330,7 @@ const dropdownItems = ref([
},
{
title: '删除',
value: 4,
value: 5,
props: {
prependIcon: 'mdi-delete-outline',
color: 'error',
@@ -312,6 +346,36 @@ onMounted(() => {
<template>
<VCard class="d-flex flex-column">
<VToolbar v-if="!loading" density="compact" flat color="gray">
<VTextField
v-if="!isFile"
v-model="filter"
hide-details
flat
density="compact"
variant="solo-filled"
placeholder="搜索 ..."
prepend-inner-icon="mdi-filter-outline"
class="me-2"
rounded="0"
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="isFile" @click="recognize(inProps.path || '')">
<VIcon color="primary">
mdi-text-recognition
</VIcon>
</IconBtn>
<IconBtn v-if="isFile" @click="download(inProps.path || '')">
<VIcon color="primary">
mdi-download
</VIcon>
</IconBtn>
<IconBtn v-if="!isFile" @click="load">
<VIcon color="primary">
mdi-refresh
</VIcon>
</IconBtn>
</VToolbar>
<VCardText
v-if="loading"
class="text-center flex flex-col items-center"
@@ -343,113 +407,92 @@ onMounted(() => {
<VImg :src="getImgLink(path)" max-width="100%" max-height="100%" />
</VCardText>
<VCardText v-else-if="dirs.length || files.length" class="p-0">
<VList v-if="dirs.length" subheader>
<VListSubheader>目录</VListSubheader>
<VListItem
v-for="(item, index) in dirs"
:key="index"
class="px-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon icon="mdi-folder-outline" />
<VList subheader>
<VVirtualScroll class="virtual-scroll-div" :items="[...dirs, ...files]">
<template #default="{ item }">
<VHover>
<template #default="hover">
<VListItem
v-bind="hover.props"
class="px-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon v-if="inProps.icons && item.extension" :icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other" />
<VIcon v-else icon="mdi-folder-outline" />
</template>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle v-if="item.size">
{{ formatBytes(item.size) }}
</VListItemSubtitle>
<template #append>
<IconBtn class="d-sm-none">
<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.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<span v-if="hover.isHovering" class="flex">
<VTooltip text="识别">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="刮削">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="scrape(item.path)">
<VIcon icon="mdi-auto-fix" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="重命名">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="整理">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="删除">
<template #activator="{ props }">
<IconBtn v-bind="props" class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</template>
</VTooltip>
</span>
</template>
</VListItem>
</template>
</VHover>
</template>
<VListItemTitle v-text="item.name" />
<template #append>
<IconBtn class="d-sm-none">
<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.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</template>
</VListItem>
</VList>
<VDivider v-if="dirs.length && files.length" />
<VList v-if="files.length" subheader>
<VListSubheader>文件</VListSubheader>
<VListItem
v-for="(item, index) in files"
:key="index"
class="pl-3 pe-1"
@click="changePath(item.path)"
>
<template #prepend>
<VIcon v-if="inProps.icons" :icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other" />
</template>
<VListItemTitle v-text="item.name" />
<VListItemSubtitle> {{ formatBytes(item.size) }}</VListItemSubtitle>
<template #append>
<IconBtn class="d-sm-none">
<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.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="recognize(item.path)">
<VIcon icon="mdi-text-recognition" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showRenmae(item)">
<VIcon icon="mdi-rename" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="showTransfer(item)">
<VIcon icon="mdi-folder-arrow-right" />
</IconBtn>
<IconBtn class="d-none d-sm-block" @click.stop="deleteItem(item)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</template>
</VListItem>
</VVirtualScroll>
</VList>
</VCardText>
<VCardText
@@ -464,39 +507,10 @@ onMounted(() => {
>
空目录
</VCardText>
<VDivider v-if="path" />
<VToolbar v-if="!loading" density="compact" flat color="gray">
<VTextField
v-if="!isFile"
v-model="filter"
hide-details
flat
density="compact"
variant="solo-filled"
placeholder="搜索 ..."
prepend-inner-icon="mdi-filter-outline"
class="me-2"
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="isFile" @click="recognize(inProps.path || '')">
<VIcon color="primary">
mdi-text-recognition
</VIcon>
</IconBtn>
<IconBtn v-if="isFile" @click="download(inProps.path || '')">
<VIcon color="primary">
mdi-download
</VIcon>
</IconBtn>
<IconBtn v-if="!isFile" @click="load">
<VIcon color="primary">
mdi-refresh
</VIcon>
</IconBtn>
</VToolbar>
</VCard>
<!-- 重命名弹窗 -->
<VDialog
v-if="renamePopper"
v-model="renamePopper"
max-width="50rem"
>
@@ -522,6 +536,7 @@ onMounted(() => {
</VDialog>
<!-- 文件整理弹窗 -->
<ReorganizeForm
v-if="transferPopper"
v-model="transferPopper"
:path="currentItem?.path"
@done="transferPopper = false; load()"
@@ -549,6 +564,7 @@ onMounted(() => {
</VDialog>
<!-- 识别结果对话框 -->
<VDialog
v-if="nameTestDialog"
v-model="nameTestDialog"
width="50rem"
>
@@ -563,9 +579,14 @@ onMounted(() => {
<style lang="scss" scoped>
.v-card {
height: 100%;
block-size: 100%;
}
.v-toolbar{
background: rgb(var(--v-table-header-background));
}
.virtual-scroll-div {
block-size: calc(100vh - 14rem);
}
</style>

View File

@@ -144,19 +144,31 @@ const sortIcon = computed(() => {
</template>
</VToolbarItems>
<div class="flex-grow-1" />
<IconBtn @click="changeSort">
<VIcon :icon="sortIcon" />
</IconBtn>
<IconBtn v-if="pathSegments.length > 0" @click="goUp">
<VIcon icon="mdi-arrow-up-bold-outline" />
</IconBtn>
<VTooltip text="调整排序">
<template #activator="{ props }">
<IconBtn v-bind="props" @click="changeSort">
<VIcon :icon="sortIcon" />
</IconBtn>
</template>
</VTooltip>
<VTooltip text="返回上一级" v-if="pathSegments.length > 0">
<template #activator="{ props }">
<IconBtn v-bind="props" @click="goUp">
<VIcon icon="mdi-arrow-up-bold-outline" />
</IconBtn>
</template>
</VTooltip>
<VDialog
v-model="newFolderPopper"
max-width="50rem"
>
<template #activator="{ props }">
<IconBtn title="新建文件夹" v-bind="props">
<VIcon icon="mdi-folder-plus-outline" />
<IconBtn v-bind="props">
<VTooltip text="新建文件夹">
<template #activator="{ props: _props }">
<VIcon v-bind="_props" icon="mdi-folder-plus-outline" />
</template>
</VTooltip>
</IconBtn>
</template>
<VCard title="新建文件夹">

View File

@@ -1,203 +0,0 @@
<script lang="ts" setup>
import type { Axios } from 'axios'
import type { EndPoints, FileItem } from '@/api/types'
// 输入参数
const props = defineProps({
icons: Object,
storage: String,
path: String,
endpoints: Object as PropType<EndPoints>,
axios: Object as PropType<Axios>,
refreshpending: Boolean,
})
// 对外事件
const emit = defineEmits(['pathchanged', 'loading', 'refreshed'])
// 变量
const open = ref<string[]>([])
// 活跃的文件夹
const active = ref<string[]>([])
// 内容
const items = ref<FileItem[]>([])
// 过滤
const filter = ref('')
// 方法
function init() {
open.value = []
items.value = [{
type: 'dir',
path: '/',
basename: 'root',
extension: '',
name: 'root',
children: [],
size: 0,
modify_time: 0,
}]
}
// 调用API读取文件夹
async function readFolder(item: FileItem) {
emit('loading', true)
const url = props.endpoints?.list.url
.replace(/{storage}/g, props.storage)
.replace(/{path}/g, item.path)
const config = {
url,
method: props.endpoints?.list.method || 'get',
}
const response: FileItem[] = await props.axios?.request(config) ?? []
item.children = response.map((item: FileItem) => {
if (item.type === 'dir')
item.children = []
return item
})
emit('loading', false)
}
// 选中变化
function activeChanged(_active: string[]) {
let path = ''
if (active.value.length)
path = active.value[0]
if (props.path !== path)
emit('pathchanged', path)
}
// 查找文件
function findItem(path: string) {
const stack: FileItem[] = []
stack.push(items.value[0])
while (stack.length > 0) {
const node = stack.pop()
if (node?.path === path) {
return node
}
else if (node?.children && node.children.length) {
for (const element of node.children)
stack.push(element)
}
}
return null
}
// 监听存储空间变量
watch(() => props.storage, () => {
init()
})
// 监听路径变化
watch(
() => props.path,
() => {
if (props.path) {
active.value = [props.path]
if (!open.value.includes(props.path))
open.value.push(props.path)
}
})
// 监听 refreshPending
watch(
() => props.refreshpending,
async () => {
if (props.refreshpending && props.path) {
const item = findItem(props.path)
if (item) {
await readFolder(item)
emit('refreshed')
}
}
},
)
onMounted(() => {
init()
})
</script>
<template>
<VCard flat width="250" min-height="500" class="d-flex flex-column folders-tree-card">
<div class="grow scroll-x">
<VTreeview
:open="open"
:active="active"
:items="items"
:search="filter"
:load-children="readFolder"
item-key="path"
item-text="basename"
dense
activatable
transition
class="folders-tree"
@update:active="activeChanged"
>
<template #prepend="{ item, open }">
<VIcon
v-if="item.type === 'dir'"
>
{{ open ? 'mdi-folder-open-outline' : 'mdi-folder-outline' }}
</VIcon>
<VIcon v-else-if="props.icons" :icon="props.icons[item.extension.toLowerCase()] || props.icons.other" />
</template>
<template #label="{ item }">
{{ item.basename }}
<VBtn
v-if="item.type === 'dir'"
icon
class="ml-1"
@click.stop="readFolder(item)"
>
<VIcon class="pa-0 mdi-18px" color="grey lighten-1">
mdi-refresh
</VIcon>
</VBtn>
</template>
</VTreeview>
</div>
<VDivider />
<VToolbar
density="compact"
>
<VBtn icon @click="init">
<VIcon icon="mdi-collapse-all-outline" />
</VBtn>
</VToolbar>
</VCard>
</template>
<style lang="scss" scoped>
.folders-tree-card {
height: 100%;
.scroll-x {
overflow-x: auto;
}
::v-deep .folders-tree {
width: fit-content;
min-width: 250px;
.v-treeview-node {
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, 0.02);
}
}
}
}
.v-toolbar{
background: rgb(var(--v-table-header-background));
}
</style>

View File

@@ -161,6 +161,7 @@ async function transfer() {
v-model="transferForm.target"
label="目的路径"
placeholder="留空自动"
hint="留空将自动整理到媒体库目录"
/>
</VCol>
<VCol
@@ -204,6 +205,7 @@ async function transfer() {
placeholder="留空自动识别"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
hint="点击图标按名称搜索,留空将自动重新识别"
@click:append-inner="tmdbSelectorDialog = true"
/>
</VCol>
@@ -225,6 +227,7 @@ async function transfer() {
v-model="transferForm.episode_format"
label="集数定位"
placeholder="使用{ep}定位集数"
hint="使用{ep}定位文件名中的集数部分,其余相同部分直接填写,不同部分使用{a}进行忽略,例如:{a}葬送的芙莉莲_Sousou no Frieren 第{ep}话{b}"
/>
</VCol>
<VCol cols="12" md="4">
@@ -232,6 +235,7 @@ async function transfer() {
v-model="transferForm.episode_detail"
label="指定集数"
placeholder="起始集,终止集如1或1,2"
hint="直接指定集数或者范围,格式:起始集,终止集如1或1,2"
/>
</VCol>
<VCol cols="12" md="4">
@@ -239,6 +243,7 @@ async function transfer() {
v-model="transferForm.episode_part"
label="指定Part"
placeholder="如part1"
hint="指定集数的Part如part1"
/>
</VCol>
<VCol cols="12" md="4">
@@ -246,6 +251,7 @@ async function transfer() {
v-model.number="transferForm.episode_offset"
label="集数偏移"
placeholder="如-10"
hint="对集数进行偏移运算,如-10表示文件名中的集数减10为整理后集数"
/>
</VCol>
<VCol cols="12" md="4">
@@ -254,6 +260,7 @@ async function transfer() {
label="最小文件大小MB"
:rules="[numberValidator]"
placeholder="0"
hint="最小文件大小,小于此大小的文件将被忽略不进行整理"
/>
</VCol>
</VRow>
@@ -297,6 +304,7 @@ async function transfer() {
v-model="tmdbSelectorDialog"
width="40rem"
scrollable
max-height="85vh"
>
<TmdbSelectorCard
v-model="transferForm.tmdbid"

View File

@@ -121,6 +121,9 @@ async function updateSiteInfo() {
<template>
<VDialog
scrollable
:close-on-back="false"
persistent
eager
max-width="60rem"
>
<VCard
@@ -139,6 +142,7 @@ async function updateSiteInfo() {
v-model="siteForm.url"
label="站点地址"
:rules="[requiredValidator]"
hint="格式http://www.example.com/"
/>
</VCol>
<VCol
@@ -150,6 +154,7 @@ async function updateSiteInfo() {
label="优先级"
:items="priorityItems"
:rules="[requiredValidator]"
hint="站点资源下载优先级,优先级数字越小越优先下载"
/>
</VCol>
<VCol
@@ -168,18 +173,21 @@ async function updateSiteInfo() {
<VTextField
v-model="siteForm.rss"
label="RSS地址"
hint="订阅模式为站点RSS时将会使用此地址获取站点种子资源该地址一般会自动获取也可手动补充"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="siteForm.cookie"
label="站点Cookie"
hint="浏览器打开站点首页打开开发人员工具刷新页面后在网络选项中找到首页地址在请求头中获取Cookie信息"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
hint="在开发人员工具网络请求头中获取User-Agent信息需与站点Cookie配套使用"
/>
</VCol>
</VRow>
@@ -192,6 +200,7 @@ async function updateSiteInfo() {
v-model="siteForm.limit_interval"
label="单位周期(秒)"
:rules="[numberValidator]"
hint="设定站点限流的单位周期单位为秒0为不限流"
/>
</VCol>
<VCol
@@ -202,6 +211,7 @@ async function updateSiteInfo() {
v-model="siteForm.limit_count"
label="访问次数"
:rules="[numberValidator]"
hint="设定单位周期内站点允许的访问次数0为不限制"
/>
</VCol>
<VCol
@@ -212,6 +222,7 @@ async function updateSiteInfo() {
v-model="siteForm.limit_seconds"
label="访问间隔(秒)"
:rules="[numberValidator]"
hint="设定单位周期内每次站点访问需间隔时间单位为秒0为不限制"
/>
</VCol>
</VRow>
@@ -223,6 +234,7 @@ async function updateSiteInfo() {
<VSwitch
v-model="siteForm.proxy"
label="代理"
hint="站点是否需要代理访问,需要设置好代理服务器信息"
/>
</VCol>
<VCol
@@ -232,6 +244,7 @@ async function updateSiteInfo() {
<VSwitch
v-model="siteForm.render"
label="仿真"
hint="站点是否需要使用浏览器模拟访问,开启可以一定程度上提升连通性,但会大大增加站点请求时间"
/>
</VCol>
</VRow>

View File

@@ -7,6 +7,8 @@ import type { Site, Subscribe } from '@/api/types'
// 输入参数
const props = defineProps({
subid: Number,
default: Boolean,
type: String,
})
// 定义触发的自定义事件
@@ -30,6 +32,7 @@ const subscribeForm = ref<Subscribe>({
total_episode: 0,
start_episode: 0,
best_version: 0,
search_imdbid: 0,
sites: [],
type: '',
name: '',
@@ -62,6 +65,50 @@ async function updateSubscribeInfo() {
}
}
// 设置用户设置的默认订阅规则
async function saveDefaultSubscribeConfig() {
try {
let subscribe_config_url = ''
if (props.type === '电影')
subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else
subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.post(
subscribe_config_url,
subscribeForm.value)
if (result.success)
$toast.success(`${props.type}订阅默认规则保存成功`)
else
$toast.error(`${props.type}订阅默认规则保存失败!`)
// 通知父组件刷新
emit('save')
}
catch (error) {
console.log(error)
}
}
// 查询用户设置的默认订阅规则
async function queryDefaultSubscribeConfig() {
try {
let subscribe_config_url = ''
if (props.type === '电影')
subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else
subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.get(subscribe_config_url)
if (result.data.value)
subscribeForm.value = result.data?.value ?? ''
}
catch (error) {
console.log(error)
}
}
// 获取站点列表数据
async function loadSites() {
try {
@@ -99,6 +146,7 @@ async function getSubscribeInfo() {
)
subscribeForm.value = result
subscribeForm.value.best_version = subscribeForm.value.best_version === 1
subscribeForm.value.search_imdbid = subscribeForm.value.search_imdbid === 1
}
catch (e) {
console.log(e)
@@ -206,11 +254,13 @@ const effectOptions = ref([
},
])
watchEffect(() => {
if (props.subid) {
getSiteList()
onMounted(() => {
getSiteList()
if (props.subid)
getSubscribeInfo()
}
if (props.default)
queryDefaultSubscribeConfig()
})
</script>
@@ -220,7 +270,7 @@ watchEffect(() => {
max-width="60rem"
>
<VCard
:title="`编辑订阅 - ${subscribeForm.name} ${subscribeForm.season ? `第 ${subscribeForm.season} 季` : ''}`"
:title="`${props.default ? `设置${props.type}默认订阅规则` : `编辑订阅 - ${subscribeForm.name} ${subscribeForm.season ? `第 ${subscribeForm.season} 季` : ''}`}`"
class="rounded-t"
>
<VCardText class="pt-2">
@@ -232,8 +282,10 @@ watchEffect(() => {
md="8"
>
<VTextField
v-if="!props.default"
v-model="subscribeForm.keyword"
label="搜索关键词"
hint="设定搜索关键词后将使用此关键词搜索站点资源否则自动使用themoviedb中的名称搜索"
/>
</VCol>
<VCol
@@ -245,6 +297,7 @@ watchEffect(() => {
v-model="subscribeForm.total_episode"
label="总集数"
:rules="[numberValidator]"
hint="设定剧集的总集数以应对themoviedb中剧集信息未维护完整导致提前结束订阅的情况"
/>
</VCol>
<VCol
@@ -256,6 +309,7 @@ watchEffect(() => {
v-model="subscribeForm.start_episode"
label="开始集数"
:rules="[numberValidator]"
hint="只订阅下载此集数及之后的剧集"
/>
</VCol>
</VRow>
@@ -299,6 +353,7 @@ watchEffect(() => {
<VTextField
v-model="subscribeForm.include"
label="包含(关键字、正则式)"
hint="支持正则表达式,多个关键字用 | 分隔表示或"
/>
</VCol>
<VCol
@@ -308,6 +363,7 @@ watchEffect(() => {
<VTextField
v-model="subscribeForm.exclude"
label="排除(关键字、正则式)"
hint="支持正则表达式,多个关键字用 | 分隔表示或"
/>
</VCol>
<VCol
@@ -320,17 +376,18 @@ watchEffect(() => {
chips
label="订阅站点"
multiple
hint="只订阅选中的订阅站点,不选则订阅所有可订阅站点"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="subscribeForm.save_path"
label="保存路径"
hint="指定该订阅的下载保存路径,留空自动使用设定的下载目录"
/>
</VCol>
</VRow>
@@ -342,6 +399,17 @@ watchEffect(() => {
<VSwitch
v-model="subscribeForm.best_version"
label="洗版"
hint="开启后不管媒体库是否存在,均会根据洗版优先级进行过滤下载,直到下载到了最高优先级的资源为止"
/>
</VCol>
<VCol
cols="12"
md="4"
>
<VSwitch
v-model="subscribeForm.search_imdbid"
label="使用 ImdbID 搜索"
hint="开启后将使用 ImdbID 搜索资源,搜索结果更精确,但不是所有站点都支持"
/>
</VCol>
</VRow>
@@ -349,13 +417,13 @@ watchEffect(() => {
</VCardText>
<VCardActions>
<VBtn color="error" @click="removeSubscribe">
<VBtn v-if="!props.default" color="error" @click="removeSubscribe">
取消订阅
</VBtn>
<VSpacer />
<VBtn
variant="tonal"
@click="updateSubscribeInfo"
@click="`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`"
>
保存
</VBtn>

View File

@@ -0,0 +1,24 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
// 输入参数
const props = defineProps({
history: Object as PropType<{ [key: string]: string }>,
})
</script>
<template>
<VCardItem>
<VList>
<VListItem
v-for="(value, key) in props.history"
:key="key"
>
<VListItemTitle>{{ key }}</VListItemTitle>
<div class="text-gray-500">
{{ value }}
</div>
</VListItem>
</VList>
</VCardItem>
</template>

View File

@@ -32,23 +32,58 @@ const formData = ref<any>(elementProps.form || {})
<template>
<Component
:is="formItem.component"
v-if="!formItem.html"
v-if="!formItem.html && !!formItem.props?.modelvalue"
v-bind="formItem.props"
v-model="formData[formItem.props?.model || '']"
v-model:value="formData[formItem.props?.modelvalue]"
>
{{ formItem.text }}
<FormRender
<template
v-for="(innerItem, innerIndex) in (formItem.content || [])"
:key="innerIndex"
v-model="formData[innerItem.props?.model || '']"
:config="innerItem"
:form="formData"
/>
>
<FormRender
v-if="!!innerItem.props?.modelvalue"
v-model:value="formData[innerItem.props?.modelvalue]"
:config="innerItem"
:form="formData"
/>
<FormRender
v-else
v-model="formData[innerItem.props?.model]"
:config="innerItem"
:form="formData"
/>
</template>
</Component>
<Component
:is="formItem.component"
v-if="formItem.html"
v-else-if="formItem.html"
v-bind="formItem.props"
v-html="formItem.html"
/>
<Component
:is="formItem.component"
v-else
v-bind="formItem.props"
v-model="formData[formItem.props?.model]"
>
{{ formItem.text }}
<template
v-for="(innerItem, innerIndex) in (formItem.content || [])"
:key="innerIndex"
>
<FormRender
v-if="!!innerItem.props?.modelvalue"
v-model:value="formData[innerItem.props?.modelvalue]"
:config="innerItem"
:form="formData"
/>
<FormRender
v-else
v-model="formData[innerItem.props?.model]"
:config="innerItem"
:form="formData"
/>
</template>
</Component>
</template>

View File

@@ -0,0 +1,245 @@
<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} ?`,
confirmationText: '确认',
cancellationText: '取消',
dialogProps: {
maxWidth: '50rem',
},
confirmationButtonProps: {
variant: 'tonal',
},
})
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

@@ -44,7 +44,7 @@ const superUser = store.state.auth.superUser
</IconBtn>
<!-- 👉 Shortcuts -->
<ShortcutBar />
<ShortcutBar v-if="superUser" />
<!-- 👉 Theme -->
<NavbarThemeSwitcher class="me-2" />

View File

@@ -14,6 +14,10 @@ const themes: ThemeSwitcherTheme[] = [
name: 'purple',
icon: 'mdi-brightness-4',
},
{
name: 'auto',
icon: 'mdi-brightness-auto',
},
]
</script>

View File

@@ -84,7 +84,7 @@ function openSearchDialog() {
<VTextField
key="search_navbar"
v-model="searchWord"
class="d-none d-lg-block text-disabled"
class="d-none d-lg-block text-disabled search-box"
density="compact"
variant="solo"
label="搜索电影、电视剧"
@@ -98,3 +98,9 @@ function openSearchDialog() {
/>
</span>
</template>
<style lang="scss">
.search-box div.v-input__control div[role="textbox"] {
border: 1px solid rgb(var(--v-theme-background));
}
</style>

View File

@@ -3,6 +3,10 @@ import NameTestView from '@/views/system/NameTestView.vue'
import NetTestView from '@/views/system/NetTestView.vue'
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'
// App捷径
const appsMenu = ref(false)
@@ -18,6 +22,57 @@ const loggingDialog = ref(false)
// 过滤规则弹窗
const ruleTestDialog = ref(false)
// 系统健康检查弹窗
const systemTestDialog = ref(false)
// 消息中心弹窗
const messageDialog = ref(false)
// 输入消息
const user_message = ref('')
// 发送按钮是否可用
const sendButtonDisabled = ref(false)
// 聊天容器
const chatContainer = ref<HTMLDivElement>()
// 滚动到底部
function scrollMessageToEnd() {
nextTick(() => {
if (chatContainer.value) {
const scrollDiv = chatContainer.value.$el
scrollDiv.scrollTop = scrollDiv.scrollHeight
}
})
}
// 拼接全部日志url
function allLoggingUrl() {
const token = store.state.auth.token
return `${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}&length=-1`
}
// 发送消息
async function sendMessage() {
if (user_message.value) {
try {
sendButtonDisabled.value = true
await api.post(`message/web?text=${user_message.value}`)
user_message.value = ''
sendButtonDisabled.value = false
scrollMessageToEnd()
}
catch (error) {
console.error(error)
}
}
}
onMounted(() => {
scrollMessageToEnd()
})
</script>
<template>
@@ -74,23 +129,23 @@ const ruleTestDialog = ref(false)
</VCol>
<VCol
cols="6"
class="text-center cursor-pointer pa-0 shortcut-icon"
class="text-center cursor-pointer pa-0 shortcut-icon border-e"
@click="() => {}"
>
<VListItem
class="pa-4"
@click="netTestDialog = true"
@click="ruleTestDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-network-outline" />
<VIcon icon="mdi-filter-cog-outline" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">
网络
优先级
</h6>
<span class="text-sm">测试网速连通性</span>
<span class="text-sm">优先级规则测试</span>
</VListItem>
</VCol>
</VRow>
@@ -113,7 +168,51 @@ const ruleTestDialog = ref(false)
<h6 class="text-base font-weight-medium mt-2 mb-0">
日志
</h6>
<span class="text-sm">系统实时日志</span>
<span class="text-sm">实时日志</span>
</VListItem>
</VCol>
<VCol
cols="6"
class="text-center cursor-pointer pa-0 shortcut-icon"
@click="() => {}"
>
<VListItem
class="pa-4"
@click="netTestDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-network-outline" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">
网络
</h6>
<span class="text-sm">网速连通性测试</span>
</VListItem>
</VCol>
</VRow>
<VRow class="ma-0 mt-n1 border-t">
<VCol
cols="6"
class="text-center cursor-pointer pa-0 shortcut-icon border-e"
@click="() => {}"
>
<VListItem
class="pa-4"
@click="systemTestDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-cog-outline" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">
系统
</h6>
<span class="text-sm">健康检查</span>
</VListItem>
</VCol>
<VCol
@@ -123,18 +222,18 @@ const ruleTestDialog = ref(false)
>
<VListItem
class="pa-4"
@click="ruleTestDialog = true"
@click="messageDialog = true"
>
<VAvatar
size="48"
variant="tonal"
>
<VIcon icon="mdi-filter-cog-outline" />
<VIcon icon="mdi-message-outline" />
</VAvatar>
<h6 class="text-base font-weight-medium mt-2 mb-0">
优先级
消息
</h6>
<span class="text-sm">优先级规则测试</span>
<span class="text-sm">消息中心</span>
</VListItem>
</VCol>
</VRow>
@@ -143,6 +242,7 @@ const ruleTestDialog = ref(false)
</VMenu>
<!-- 名称测试弹窗 -->
<VDialog
v-if="nameTestDialog"
v-model="nameTestDialog"
max-width="50rem"
>
@@ -155,6 +255,7 @@ const ruleTestDialog = ref(false)
</VDialog>
<!-- 网络测试弹窗 -->
<VDialog
v-if="netTestDialog"
v-model="netTestDialog"
max-width="35rem"
>
@@ -167,12 +268,24 @@ const ruleTestDialog = ref(false)
</VDialog>
<!-- 实时日志弹窗 -->
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
class="w-full lg:w-4/5"
scrollable
>
<VCard title="实时日志">
<VCard>
<DialogCloseBtn @click="loggingDialog = false" />
<VCardItem>
<VCardTitle class="inline-flex">
实时日志
<a class="mx-2 inline-flex items-center justify-center" :href="allLoggingUrl()" target="_blank">
<div class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700">
<VIcon icon="mdi-open-in-new" />
<span class="ms-1">在新窗口中打开</span>
</div>
</a>
</VCardTitle>
</VCardItem>
<VCardText>
<LoggingView />
</VCardText>
@@ -180,6 +293,7 @@ const ruleTestDialog = ref(false)
</VDialog>
<!-- 规则测试弹窗 -->
<VDialog
v-if="ruleTestDialog"
v-model="ruleTestDialog"
max-width="50rem"
scrollable
@@ -191,4 +305,56 @@ const ruleTestDialog = ref(false)
</VCardText>
</VCard>
</VDialog>
<!-- 系统健康检查弹窗 -->
<VDialog
v-if="systemTestDialog"
v-model="systemTestDialog"
max-width="50rem"
scrollable
>
<VCard title="系统健康检查">
<DialogCloseBtn @click="systemTestDialog = false" />
<VCardText>
<ModuleTestView />
</VCardText>
</VCard>
</VDialog>
<!-- 消息中心弹窗 -->
<VDialog
v-if="messageDialog"
v-model="messageDialog"
max-width="60rem"
scrollable
>
<VCard title="消息中心">
<DialogCloseBtn @click="messageDialog = false" />
<VCardText ref="chatContainer">
<MessageView @scroll="scrollMessageToEnd" />
</VCardText>
<VCardItem>
<VTextField
v-model="user_message"
placeholder="输入消息或命令"
outlined
hide-details
single-line
clearable
density="compact"
:disabled="sendButtonDisabled"
@keydown.enter="sendMessage"
>
<template #append>
<VBtn
color="primary"
:disabled="sendButtonDisabled"
@click="sendMessage"
>
发送
</VBtn>
</template>
</VTextField>
</VCardItem>
</VCard>
</VDialog>
</template>

View File

@@ -1,14 +1,26 @@
<script lang="ts" setup>
import DefaultLayoutWithVerticalNav from './components/DefaultLayoutWithVerticalNav.vue'
import api from '@/api'
const router = useRouter()
const route = useRoute()
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
api.get('user/current')
.catch(() => {
router.replace('/login')
})
}
})
</script>
<template>
<DefaultLayoutWithVerticalNav>
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" v-if="$route.meta.keepAlive" :key="$route.fullPath" />
<component :is="Component" v-if="route.meta.keepAlive" :key="route.fullPath" />
</keep-alive>
<component :is="Component" v-if="!$route.meta.keepAlive" :key="$route.fullPath" />
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
</router-view>
</DefaultLayoutWithVerticalNav>
</template>

View File

@@ -1,7 +1,10 @@
import { VAceEditor } from 'vue3-ace-editor'
import { createApp } from 'vue'
import '@/@iconify/icons-bundle'
import ToastPlugin from 'vue-toast-notification'
import VuetifyUseDialog from 'vuetify-use-dialog'
import './ace-config'
import VueApexCharts from 'vue3-apexcharts'
import { removeEl } from './@core/utils/dom'
import App from '@/App.vue'
import vuetify from '@/plugins/vuetify'
@@ -12,13 +15,19 @@ import '@core/scss/template/index.scss'
import '@layouts/styles/index.scss'
import '@styles/styles.scss'
import 'vue-toast-notification/dist/theme-bootstrap.css'
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar';
import 'vue3-perfect-scrollbar/style.css';
loadFonts()
// Create vue app
// 创建Vue实例
const app = createApp(App)
// Use plugins Mount vue app
// 注册全局组件
app.component('VAceEditor', VAceEditor)
.component('VApexChart', VueApexCharts)
// 注册插件
app
.use(vuetify)
.use(router)
@@ -27,5 +36,6 @@ app
position: 'bottom-right',
})
.use(VuetifyUseDialog)
.use(PerfectScrollbarPlugin)
.mount('#app')
.$nextTick(() => removeEl('#loading-bg'))

View File

@@ -54,6 +54,16 @@ function setDashboardConfig() {
</script>
<template>
<!-- 底部操作按钮 -->
<VFab
icon="mdi-view-dashboard-edit"
location="bottom end"
size="x-large"
fixed
app
appear
@click="dialog = true"
/>
<VRow class="match-height">
<VCol
v-if="config.storage"
@@ -132,17 +142,13 @@ function setDashboardConfig() {
<MediaServerLatest />
</VCol>
</VRow>
<!-- 底部操作按钮 -->
<span class="fixed right-5 bottom-5">
<VBtn icon="mdi-view-dashboard-edit" class="me-2" color="primary" size="x-large" @click="dialog = true" />
</span>
<!-- 弹窗根据配置生成选项 -->
<VDialog
v-model="dialog"
max-width="600"
scrollable
>
<VCard title="设置仪表">
<VCard title="设置仪表">
<VCardText>
<VRow>
<VCol
@@ -168,6 +174,7 @@ function setDashboardConfig() {
<VSpacer />
<VBtn
color="primary"
variant="tonal"
@click="setDashboardConfig"
>
保存

View File

@@ -13,6 +13,7 @@ const store = useStore()
const form = ref({
username: '',
password: '',
otp_password: '',
remember: true,
})
@@ -30,6 +31,12 @@ const backgroundImageUrl = ref('')
// 背景图片加载状态
const isImageLoaded = ref(false)
// 是否开启双重验证
const isOTP = ref(false)
// 用户名称输入框
const usernameInput = ref()
// 获取背景图片
async function fetchBackgroundImage() {
api
@@ -42,19 +49,38 @@ async function fetchBackgroundImage() {
})
}
// 查询是否开启双重验证
async function fetchOTP() {
const userid = usernameInput.value?.value
if (!userid) {
isOTP.value = false
return
}
api
.get(`/user/otp/${userid}`)
.then((response: any) => {
isOTP.value = response.success
})
.catch((error: any) => {
console.log(error)
})
}
// 登录获取token事件
function login() {
errorMessage.value = ''
// 进行表单校验
if (!form.value.username || !form.value.password)
if (!form.value.username || !form.value.password || (isOTP.value && !form.value.otp_password)) {
errorMessage.value = '请输入完整信息'
return
}
// 用户名密码
const formData = new FormData()
formData.append('username', form.value.username)
formData.append('password', form.value.password)
formData.append('otp_password', form.value.otp_password)
// 请求token
api
@@ -77,21 +103,21 @@ function login() {
store.dispatch('auth/updateUserName', username)
store.dispatch('auth/updateAvatar', avatar)
// 跳转到首页
router.push('/')
// 跳转到首页或回原始页面
router.push(store.state.auth.originalPath ?? '/')
})
.catch((error: any) => {
// 登录失败,显示错误提示
if (!error.response)
errorMessage.value = '登录失败,请检查网络连接'
else if (error.response.status === 401)
errorMessage.value = '登录失败,请检查用户名密码是否正确'
errorMessage.value = '登录失败,请检查用户名密码或双重验证是否正确'
else if (error.response.status === 403)
errorMessage.value = '登录失败,您没有权限访问'
else if (error.response.status === 500)
errorMessage.value = '登录失败,服务器错误'
else
errorMessage.value = `登录失败 ${error.response.status},请检查用户名和密码是否正确`
errorMessage.value = `登录失败 ${error.response.status},请检查用户名、密码或双重验证码是否正确`
})
}
@@ -148,13 +174,14 @@ onMounted(() => {
<!-- username -->
<VCol cols="12">
<VTextField
ref="usernameInput"
v-model="form.username"
label="用户名"
type="text"
:rules="[requiredValidator]"
@input="fetchOTP"
/>
</VCol>
<!-- password -->
<VCol cols="12">
<VTextField
@@ -167,23 +194,24 @@ onMounted(() => {
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
<div
v-if="errorMessage"
class="text-error mt-1"
>
{{ errorMessage }}
</div>
</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 mt-1 mb-4">
<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
@@ -192,6 +220,12 @@ onMounted(() => {
>
登录
</VBtn>
<div
v-if="errorMessage"
class="text-error mt-2 text-shadow"
>
{{ errorMessage }}
</div>
</VCol>
</VRow>
</VForm>

View File

@@ -16,6 +16,12 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
title="正在热映"
/>
<MediaCardSlideView
apipath="bangumi/calendar"
linkurl="/browse/bangumi/calendar?title=Bangumi每日放送"
title="Bangumi每日放送"
/>
<MediaCardSlideView
apipath="tmdb/movies"
linkurl="/browse/tmdb/movies?title=热门电影"

View File

@@ -18,6 +18,9 @@ const type = route.query?.type?.toString() ?? ''
// 搜索字段
const area = route.query?.area?.toString() ?? ''
// 搜索季
const season = route.query?.season?.toString() ?? ''
// 视图类型从localStorage中读取
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
@@ -36,6 +39,12 @@ const progressValue = ref(0)
// 加载进度SSE
const progressEventSource = ref<EventSource>()
// 错误标题
const errorTitle = ref('没有数据')
// 错误描述
const errorDescription = ref('未搜索到任何资源')
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = '正在搜索,请稍候...'
@@ -75,13 +84,19 @@ async function fetchData() {
else {
startLoadingProgress()
// 优先按TMDBID精确查询
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:')) {
dataList.value = await api.get(`search/media/${keyword}`, {
if (keyword?.startsWith('tmdb:') || keyword?.startsWith('douban:') || keyword?.startsWith('bangumi:')) {
const result: {[key: string]: any} = await api.get(`search/media/${keyword}`, {
params: {
mtype: type,
area,
season,
},
})
if (result.success){
dataList.value = result.data
} else {
errorDescription.value = result.message
}
}
else {
// 按标题模糊查询
@@ -112,9 +127,8 @@ onMounted(() => {
</div>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
error-title="没有资源"
error-description="没有搜索到符合条件的资源"
:error-title="errorTitle"
:error-description="errorDescription"
/>
<div v-if="dataList.length > 0">
<TorrentRowListView
@@ -127,20 +141,24 @@ onMounted(() => {
/>
</div>
<!-- 视图切换 -->
<span v-if="dataList.length > 0" class="fixed right-5 bottom-5">
<VBtn
v-if="viewType === 'list'"
size="x-large"
icon="mdi-view-grid"
color="primary"
@click="setViewType('card')"
/>
<VBtn
v-else
size="x-large"
icon="mdi-view-list"
color="primary"
@click="setViewType('list')"
/>
</span>
<VFab
v-if="viewType === 'list'"
icon="mdi-view-grid"
location="bottom end"
size="x-large"
fixed
app
appear
@click="setViewType('card')"
/>
<VFab
v-else
icon="mdi-view-list"
location="bottom end"
size="x-large"
fixed
app
appear
@click="setViewType('list')"
/>
</template>

View File

@@ -8,6 +8,7 @@ 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'
const route = useRoute()
@@ -20,6 +21,11 @@ const tabs = [
icon: 'mdi-account',
tab: 'account',
},
{
title: '系统',
icon: 'mdi-cog',
tab: 'system',
},
{
title: '站点',
icon: 'mdi-web',
@@ -83,6 +89,13 @@ const tabs = [
</transition>
</VWindowItem>
<!-- 系统 -->
<VWindowItem value="system">
<transition name="fade-slide" appear>
<AccountSettingSystem />
</transition>
</VWindowItem>
<!-- 站点 -->
<VWindowItem value="site">
<transition name="fade-slide" appear>

View File

@@ -80,6 +80,7 @@ export default {
// set v-rating default color to primary
color: 'rgba(var(--v-theme-on-background),0.23)',
activeColor: 'warning',
halfIncrements: true,
},
VProgressCircular: {
// set v-progress-circular default color to primary

View File

@@ -1,4 +1,4 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHashHistory } from 'vue-router'
import { configureNProgress, doneNProgress, startNProgress } from '@/api/nprogress'
import store from '@/store'
@@ -7,7 +7,7 @@ configureNProgress()
// Router
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
history: createWebHashHistory(import.meta.env.BASE_URL),
scrollBehavior(to, from, savedPosition) {
// 如果页面有缓存那么恢复其位置, 否则始终滚动到顶部
if (to.meta.keepAlive && savedPosition)
@@ -162,6 +162,7 @@ router.beforeEach((to, from, next) => {
const isAuthenticated = store.state.auth.token !== null
if (to.meta.requiresAuth && !isAuthenticated) {
store.state.auth.originalPath = to.fullPath
next('/login')
}
else {

View File

@@ -7,6 +7,7 @@ interface AuthState {
superUser: boolean
userName: string
avatar: string
originalPath: string | null
}
// 定义根状态类型
@@ -23,6 +24,7 @@ const authModule: Module<AuthState, RootState> = {
superUser: false,
userName: '',
avatar: '',
originalPath: null,
},
mutations: {
setToken(state, token: string) {

View File

@@ -5,39 +5,37 @@
#nprogress .bar {
background: rgb(var(--v-theme-primary)) !important;
top: env(safe-area-inset-top) !important;
inset-block-start: env(safe-area-inset-top) !important;
}
#nprogress .peg {
box-shadow: 0 0 10px rgb(var(--v-theme-primary)), 0 0 5px rgb(var(--v-theme-primary)) !important;
-webkit-transform: rotate(0deg) translate(0px, -1px);
-ms-transform: rotate(0deg) translate(0px, -1px);
transform: rotate(0deg) translate(0px, -1px);
transform: rotate(0deg) translate(0, -1px);
}
.v-toast--bottom {
margin-bottom: env(safe-area-inset-bottom);
z-index: 2500;
margin-block-end: env(safe-area-inset-bottom);
}
.v-toast--top {
margin-top: env(safe-area-inset-top);
z-index: 2500;
margin-block-start: env(safe-area-inset-top);
}
.v-dialog > .v-overlay__content {
margin-top: calc(env(safe-area-inset-top) + 1rem);
max-height: calc(100% - env(safe-area-inset-top) - 1rem);
margin-block-start: calc(env(safe-area-inset-top) + 1rem);
max-block-size: calc(100% - env(safe-area-inset-top) - 1rem);
}
.v-dialog > .v-overlay__content{
width: calc(100% - 1rem);
inline-size: calc(100% - 1rem);
}
.v-dialog--fullscreen > .v-overlay__content{
margin-top: env(safe-area-inset-top);
max-height: calc(100% - env(safe-area-inset-top));
width: 100%;
inline-size: 100%;
margin-block-start: env(safe-area-inset-top);
max-block-size: calc(100% - env(safe-area-inset-top));
}
/* router view transition fade-slide */
@@ -62,21 +60,20 @@
}
.text-moviepilot {
background-clip: text;
background-image: linear-gradient(to bottom right,var(--tw-gradient-stops));
color: transparent;
--tw-gradient-from: #818cf8;
--tw-gradient-to: rgba(129,140,248,0);
--tw-gradient-to: rgba(129,140,248,0%);
--tw-gradient-stops: var(--tw-gradient-from),var(--tw-gradient-to);
--tw-gradient-to: #c084fc;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.slider-header {
position: relative;
margin-top: 1.5rem;
margin-bottom: 1rem;
display: flex;
margin-block: 1.5rem 1rem;
}
.slider-title {
@@ -87,25 +84,27 @@
line-height: 1.75rem;
}
@media (min-width: 640px){
@media (width >= 640px){
.slider-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.5rem;
line-height: 2.25rem;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// 美化滚动条
::-webkit-scrollbar {
width: 8px;
height: 8px;
block-size: 8px;
inline-size: 8px;
}
::-webkit-scrollbar-thumb {
border-radius: 3px;
background: rgb(var(--v-theme-perfect-scrollbar-thumb));
-webkit-box-shadow: inset 0 0 10px rgba(0,0,0,0.2);
box-shadow: inset 0 0 10px rgba(0,0,0,20%);
@media(hover){
&:hover{
background: #a1a1a1;
@@ -120,10 +119,14 @@
.backdrop-blur {
--tw-backdrop-blur: blur(8px)!important;
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important;
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)!important;
}
.v-toolbar{
background: rgb(var(--v-table-header-background));
}
.v-toast {
z-index: 2500 !important;
}

View File

@@ -44,7 +44,7 @@ onMounted(fetchData)
<template #content>
<template
v-for="data in dataList"
:key="data.tmdb_id || data.douban_id"
:key="data.tmdb_id || data.douban_id || data.bangumi_id"
>
<MediaCard
:media="data"

View File

@@ -51,6 +51,15 @@ const subscribeRules = ref({
show_edit_dialog: false,
})
// 获得mediaid
function getMediaId() {
return mediaDetail.value?.tmdb_id
? `tmdb:${mediaDetail.value?.tmdb_id}`
: mediaDetail.value?.douban_id
? `douban:${mediaDetail.value?.douban_id}`
: `bangumi:${mediaDetail.value?.bangumi_id}`
}
// 调用API查询详情
async function getMediaDetail() {
if (mediaProps.mediaid && mediaProps.type) {
@@ -60,7 +69,7 @@ async function getMediaDetail() {
},
})
isRefreshed.value = true
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id)
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id && !mediaDetail.value.bangumi_id)
return
// 检查存在状态
@@ -113,7 +122,7 @@ async function checkExists() {
// 查询当前媒体是否已订阅
async function checkSubscribe(season = 0) {
try {
const mediaid = mediaDetail.value.tmdb_id ? `tmdb:${mediaDetail.value.tmdb_id}` : `douban:${mediaDetail.value.douban_id}`
const mediaid = getMediaId()
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
@@ -198,6 +207,7 @@ async function addSubscribe(season = 0) {
year: mediaDetail.value?.year,
tmdbid: mediaDetail.value?.tmdb_id,
doubanid: mediaDetail.value?.douban_id,
bangumiid: mediaDetail.value?.bangumi_id,
season,
best_version,
})
@@ -253,9 +263,7 @@ async function removeSubscribe(season: number) {
// 开始处理
startNProgress()
try {
const mediaid = mediaDetail.value?.tmdb_id
? `tmdb:${mediaDetail.value?.tmdb_id}`
: `douban:${mediaDetail.value?.douban_id}`
const mediaid = getMediaId()
const result: { [key: string]: any } = await api.delete(
`subscribe/media/${mediaid}`,
@@ -330,6 +338,11 @@ function getTvdbLink() {
return `https://www.thetvdb.com/series/${mediaDetail.value.tvdb_id}`
}
// 拼装Bangumi地址
function getBangumiLink() {
return `https://bgm.tv/subject/${mediaDetail.value.bangumi_id}`
}
// 拼装集图片地址
function getEpisodeImage(stillPath: string) {
if (!stillPath)
@@ -405,13 +418,14 @@ function joinArray(arr: string[]) {
// 开始搜索
function handleSearch(area: string) {
const keyword = mediaDetail.value.tmdb_id ? `tmdb:${mediaDetail.value.tmdb_id}` : `douban:${mediaDetail.value.douban_id}`
const keyword = getMediaId()
router.push({
path: '/resource',
query: {
keyword,
type: mediaDetail.value.type,
area,
season: mediaDetail.value.season,
},
})
}
@@ -453,7 +467,7 @@ onBeforeMount(() => {
color="primary"
/>
</div>
<div v-if="mediaDetail.tmdb_id || mediaDetail.douban_id" class="max-w-8xl mx-auto px-4">
<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">
<div class="vue-media-back absolute left-0 top-0 w-full h-96">
<VImg class="h-96" :src="mediaDetail.backdrop_path || mediaDetail.poster_path" cover />
@@ -492,7 +506,7 @@ onBeforeMount(() => {
</span>
</div>
<div class="media-actions">
<VBtn v-if="mediaDetail.tmdb_id || mediaDetail.douban_id" variant="tonal" color="info" class="mb-2">
<VBtn v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id" variant="tonal" color="info" class="mb-2">
<template #prepend>
<VIcon icon="mdi-magnify" />
</template>
@@ -518,7 +532,7 @@ onBeforeMount(() => {
</VList>
</VMenu>
</VBtn>
<VBtn v-if="mediaDetail.type === '电影' || mediaDetail.douban_id" class="ms-2 mb-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
<VBtn v-if="mediaDetail.type === '电影' || mediaDetail.douban_id || mediaDetail.bangumi_id" class="ms-2 mb-2" :color="getSubscribeColor" variant="tonal" @click="handleSubscribe(0)">
<template #prepend>
<VIcon :icon="getSubscribeIcon" />
</template>
@@ -544,7 +558,9 @@ onBeforeMount(() => {
<ul v-if="mediaDetail.tmdb_id" class="media-crew">
<li v-for="director in mediaDetail.directors" :key="director.id">
<span>{{ director.job }}</span>
<a class="crew-name" :href="`person?personid=${director.id}`" target="_blank">{{ director.name }}</a>
<RouterLink :to="`/person?personid=${director.id}`" class="crew-name" target="_blank">
{{ director.name }}
</RouterLink>
</li>
</ul>
<ul v-if="!mediaDetail.tmdb_id && mediaDetail.douban_id" class="media-crew">
@@ -578,6 +594,12 @@ onBeforeMount(() => {
<span class="ms-1">TheTvDb</span>
</div>
</a>
<a v-if="mediaDetail.bangumi_id" class="mb-2 mr-2 inline-flex last:mr-0" :href="getBangumiLink()" target="_blank">
<div class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700">
<VIcon icon="mdi-link" />
<span class="ms-1">Bangumi</span>
</div>
</a>
</div>
<h2 v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="py-4">
@@ -738,6 +760,33 @@ onBeforeMount(() => {
</div>
</div>
</div>
<div v-else-if="mediaDetail.bangumi_id" class="media-overview-right">
<div class="media-facts">
<div v-if="mediaDetail.vote_average" class="media-ratings">
<VRating
v-model="mediaDetail.vote_average"
density="compact"
length="10"
class="ma-2"
readonly
/>
</div>
<div v-if="mediaDetail.bangumi_id" class="media-fact">
<span>ID</span>
<span class="media-fact-value">{{ mediaDetail.bangumi_id }}</span>
</div>
<div v-if="mediaDetail.original_title" class="media-fact">
<span>原始标题</span>
<span class="media-fact-value">{{ mediaDetail.original_title }}</span>
</div>
<div v-if="mediaDetail.release_date" class="media-fact border-b-0">
<span>上映日期</span>
<span class="media-fact-value">
{{ mediaDetail.release_date }}
</span>
</div>
</div>
</div>
</div>
<div v-if="mediaDetail.tmdb_id">
<PersonCardSlideView
@@ -755,6 +804,14 @@ onBeforeMount(() => {
type="douban"
/>
</div>
<div v-else-if="mediaDetail.bangumi_id">
<PersonCardSlideView
:apipath="`bangumi/credits/${mediaDetail.bangumi_id}`"
:linkurl="`/credits/bangumi/credits/${mediaDetail.bangumi_id}?title=演员阵容&type=bangumi`"
title="演员阵容"
type="bangumi"
/>
</div>
<div v-if="mediaDetail.tmdb_id">
<MediaCardSlideView
:apipath="`tmdb/recommend/${mediaDetail.tmdb_id}/${mediaProps.type}`"
@@ -769,6 +826,13 @@ onBeforeMount(() => {
title="推荐"
/>
</div>
<div v-else-if="mediaDetail.bangumi_id">
<MediaCardSlideView
:apipath="`bangumi/recommend/${mediaDetail.bangumi_id}`"
:linkurl="`/browse/bangumi/recommend/${mediaDetail.bangumi_id}?title=推荐`"
title="推荐"
/>
</div>
<div v-if="mediaDetail.tmdb_id">
<MediaCardSlideView
:apipath="`tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}`"
@@ -779,7 +843,7 @@ onBeforeMount(() => {
</div>
</div>
<NoDataFound
v-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && isRefreshed"
v-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && !mediaDetail.bangumi_id && isRefreshed"
error-code="500"
error-title="出错啦"
error-description="未识别到媒体信息"
@@ -827,7 +891,7 @@ onBeforeMount(() => {
padding-block-start: 1rem;
}
@media (min-width: 1280px) {
@media (width >= 1280px) {
.media-header {
flex-direction: row;
align-items: flex-end;
@@ -837,65 +901,66 @@ onBeforeMount(() => {
.media-overview {
display: flex;
flex-direction: column;
padding-top: 2rem;
padding-bottom: 1rem;
padding-block: 2rem 1rem;
}
@media (min-width: 1024px) {
@media (width >= 1024px) {
.media-overview {
flex-direction: row;
}
}
.media-poster {
width: 8rem;
overflow: hidden;
border-radius: .25rem;
--tw-shadow: 0 1px 3px 0 rgba(0, 0, 0, .1), 0 1px 2px -1px rgba(0, 0, 0, .1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
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 (min-width: 1280px) {
@media (width >= 1280px) {
.media-poster {
margin-right: 1rem;
width: 13rem;
inline-size: 13rem;
margin-inline-end: 1rem;
}
}
@media (min-width: 768px) {
@media (width >= 768px) {
.media-poster {
width: 11rem;
border-radius: .5rem;
--tw-shadow: 0 25px 50px -12px rgba(0, 0, 0, .25);
--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);
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 {
margin-top: 1rem;
display: flex;
flex: 1 1 0%;
flex-direction: column;
margin-block-start: 1rem;
text-align: center;
}
@media (min-width: 1280px) {
@media (width >= 1280px) {
.media-title {
margin-right: 1rem;
margin-top: 0;
text-align: left;
margin-block-start: 0;
margin-inline-end: 1rem;
text-align: start;
}
}
.media-title>h1 {
font-size: 1.5rem;
line-height: 2rem;
font-weight: 700;
line-height: 2rem;
}
@media (min-width: 1280px) {
@media (width >= 1280px) {
.media-title>h1 {
font-size: 2.25rem;
line-height: 2.5rem;
@@ -903,23 +968,23 @@ onBeforeMount(() => {
}
ul.media-crew {
margin-top: 1.5rem;
display: grid;
grid-template-columns: repeat(2,minmax(0,1fr));
gap: 1.5rem;
grid-template-columns: repeat(2,minmax(0,1fr));
margin-block-start: 1.5rem;
}
@media (min-width: 640px) {
@media (width >= 640px) {
ul.media-crew {
grid-template-columns: repeat(3,minmax(0,1fr));
}
}
ul.media-crew>li {
grid-column: span 1/span 1;
display: flex;
flex-direction: column;
font-weight: 700;
grid-column: span 1/span 1;
}
a.crew-name {
@@ -927,27 +992,27 @@ a.crew-name {
}
.media-status {
margin-bottom: .5rem;
margin-block-end: .5rem;
}
.media-attributes {
margin-top: .25rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
margin-block-start: .25rem;
}
@media (min-width: 1280px) {
@media (width >= 1280px) {
.media-attributes {
margin-top: 0;
justify-content: flex-start;
font-size: 1rem;
line-height: 1.5rem;
margin-block-start: 0;
}
}
@media (min-width: 640px) {
@media (width >= 640px) {
.media-attributes {
font-size: .875rem;
line-height: 1.25rem;
@@ -956,21 +1021,21 @@ a.crew-name {
.media-actions {
position: relative;
margin-top: 1rem;
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
align-items: center;
justify-content: center;
margin-block-start: 1rem;
}
@media (min-width: 1280px) {
@media (width >= 1280px) {
.media-actions {
margin-top: 0;
margin-block-start: 0;
}
}
@media (min-width: 640px) {
@media (width >= 640px) {
.media-actions {
flex-wrap: nowrap;
justify-content: flex-end;
@@ -981,42 +1046,45 @@ a.crew-name {
flex: 1 1 0%;
}
@media (min-width: 1024px) {
@media (width >= 1024px) {
.media-overview-left {
margin-right: 2rem;
margin-inline-end: 2rem;
}
}
.media-overview-right {
margin-top: 2rem;
width: 100%;
inline-size: 100%;
margin-block-start: 2rem;
}
@media (min-width: 1024px) {
@media (width >= 1024px) {
.media-overview-right {
margin-top: 0;
width: 20rem;
inline-size: 20rem;
margin-block-start: 0;
}
}
.media-facts {
border-radius: 0.5rem;
border-width: 1px;
--tw-border-opacity: 1;
border-color: rgb(55 65 81/var(--tw-border-opacity));
--tw-bg-opacity: 1;
border-radius: 0.5rem;
font-size: .875rem;
line-height: 1.25rem;
font-weight: 700;
line-height: 1.25rem;
--tw-border-opacity: 1;
--tw-bg-opacity: 1;
--tw-text-opacity: 1;
}
.media-ratings {
border-bottom-width: 1px;
--tw-border-opacity: 1;
border-color: rgb(55 65 81/var(--tw-border-opacity));
padding: 0.5rem 1rem;
border-block-end-width: 1px;
font-weight: 500;
padding-block: 0.5rem;
padding-inline: 1rem;
--tw-border-opacity: 1;
}
.media-ratings {
@@ -1028,19 +1096,21 @@ a.crew-name {
.media-fact {
display: flex;
justify-content: space-between;
border-bottom-width: 1px;
--tw-border-opacity: 1;
border-color: rgb(55 65 81/var(--tw-border-opacity));
padding: 0.5rem 1rem;
border-block-end-width: 1px;
padding-block: 0.5rem;
padding-inline: 1rem;
--tw-border-opacity: 1;
}
.media-overview h2 {
font-size: 1.25rem;
line-height: 1.75rem;
font-weight: 700;
line-height: 1.75rem;
}
@media (min-width: 640px) {
@media (width >= 640px) {
.media-overview h2 {
font-size: 1.5rem;
line-height: 2rem;
@@ -1048,13 +1118,13 @@ a.crew-name {
}
.tagline {
margin-bottom: 1rem;
font-size: 1.25rem;
line-height: 1.75rem;
font-style: italic;
line-height: 1.75rem;
margin-block-end: 1rem;
}
@media (min-width: 1024px) {
@media (width >= 1024px) {
.tagline {
font-size: 1.5rem;
line-height: 2rem;

View File

@@ -3,6 +3,7 @@ import TmdbPersonCard from '@/components/cards/TmdbPersonCard.vue'
import api from '@/api'
import SlideView from '@/components/slide/SlideView.vue'
import DoubanPersonCard from '@/components/cards/DoubanPersonCard.vue'
import BangumiPersonCard from '@/components/cards/BangumiPersonCard.vue'
// 输入参数
const props = defineProps({
@@ -59,6 +60,12 @@ onMounted(fetchData)
height="15rem"
width="10rem"
/>
<BangumiPersonCard
v-if="props.type === 'bangumi'"
:person="data"
height="15rem"
width="10rem"
/>
</template>
</template>
</SlideView>

View File

@@ -68,6 +68,14 @@ function initOptions(data: Context) {
optionValue(resolutionFilterOptions.value, meta_info?.resource_pix)
}
// 对季过滤选项进行排序
const sortSeasonFilterOptions = computed(() => {
return seasonFilterOptions.value.sort((a, b) => {
// 按字符串升序排序
return a.localeCompare(b, 'zh-Hans-CN', { sensitivity: 'accent' })
})
})
// 计算分组后的列表
onMounted(() => {
// 数据分组
@@ -154,7 +162,7 @@ watchEffect(() => {
<VCol v-if="seasonFilterOptions.length > 0" cols="6" md="">
<VSelect
v-model="filterForm.season"
:items="seasonFilterOptions"
:items="sortSeasonFilterOptions"
size="small"
density="compact"
chips

View File

@@ -104,16 +104,16 @@ onMounted(() => {
<template>
<VRow>
<VCol>
<VList v-if="dataList.length === 0" lines="three" class="rounded">
<VList v-if="dataList.length === 0" lines="three" class="rounded p-0">
<VListItem>
<VListItemTitle>没有附合当前过滤条件的资源</VListItemTitle>
</VListItem>
</VList>
<div>
<VList v-if="dataList.length !== 0" lines="three" class="rounded p-0">
<div v-for="(item, index) in dataList" :key="`${index}_${item.torrent_info.title}_${item.torrent_info.site}`">
<TorrentItem v-if="defer(index)" :torrent="item" />
</div>
</div>
</VList>
</VCol>
<VCol xl="2" md="3" class="d-none d-md-block">
<VList lines="one" class="rounded">

View File

@@ -1,44 +1,149 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { Plugin } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
import PluginCard from '@/components/cards/PluginCard.vue'
import noImage from '@images/logos/plugin.png'
// 数据列表
// 已安装插件列表
const dataList = ref<Plugin[]>([])
// 未安装插件列表
const uninstalledList = ref<Plugin[]>([])
// 是否刷新过
const isRefreshed = ref(false)
// APP市场是否加载完成
const isAppMarketLoaded = ref(false)
// APP市场窗口
const PluginAppDialog = ref(false)
// 获取已安装的插件列表
const getInstalledPluginList = computed(() => {
return dataList.value.filter(item => item.installed)
})
// 插件安装统计
const PluginStatistics = ref<{ [key: string]: number }>({})
// 获取未安装或者有更新的插件列表
const getUninstalledPluginList = computed(() => {
return dataList.value.filter(item => !item.installed || item.has_update)
})
// 搜索窗口
const SearchDialog = ref(false)
// 搜索关键字
const keyword = ref('')
// 每一个插件的图标加载状态
const pluginIconLoaded = ref<{ [key: string]: boolean }>({})
// 每一个插件的动作标识
const pluginActions = ref<{ [key: string]: boolean }>({})
// 提示框
const $toast = useToast()
// 进度框
const progressDialog = ref(false)
// 进度框文本
const progressText = ref('正在安装插件...')
// 关闭插件市场窗口
function pluginDialogClose() {
PluginAppDialog.value = false
}
// 安装插件
async function installPlugin(item: Plugin) {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = `正在安装 ${item?.plugin_name} v${item?.plugin_version} ...`
const result: { [key: string]: any } = await api.get(
`plugin/install/${item?.id}`,
{
params: {
repo_url: item?.repo_url,
force: item?.has_update,
},
},
)
// 隐藏等待提示框
progressDialog.value = false
if (result.success) {
$toast.success(`插件 ${item?.plugin_name} 安装成功!`)
// 刷新
refreshData()
}
else {
$toast.error(`插件 ${item?.plugin_name} 安装失败:${result.message}`)
}
}
catch (error) {
console.error(error)
}
}
// 打开插件搜索结果
function openPlugin(item: Plugin) {
// 如果是已安装插件则打开插件详情
if (item.installed === true) {
// 标记插件动作
pluginActions.value[item.id || '0'] = true
}
else {
// 如果是未安装插件则安装
installPlugin(item)
}
closeSearchDialog()
}
// 关闭插件搜索窗口
function closeSearchDialog() {
SearchDialog.value = false
}
// 插件图标加载错误
function pluginIconError(item: Plugin) {
pluginIconLoaded.value[item.id || '0'] = false
}
// 插件图标地址
function pluginIcon(item: Plugin) {
// 如果图片加载错误
if (pluginIconLoaded.value[item.id || '0'] === false)
return noImage
// 如果是网络图片则使用代理后返回
if (item?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(item?.plugin_icon)}/1`
return `./plugin_icon/${item?.plugin_icon}`
}
// 过滤插件
const filterPlugins = computed(() => {
const all_list = [...dataList.value, ...uninstalledList.value]
return all_list.filter((item: Plugin) => {
return item.plugin_name?.includes(keyword.value) || item.plugin_desc?.includes(keyword.value)
})
})
// 新安装了插件
function pluginInstalled() {
fetchData()
pluginDialogClose()
refreshData()
}
// 获取插件列表数据
async function fetchData() {
async function fetchInstalledPlugins() {
try {
dataList.value = await api.get('plugin/')
dataList.value = await api.get('plugin/', {
params: {
state: 'installed',
},
})
isRefreshed.value = true
}
catch (error) {
@@ -46,8 +151,63 @@ async function fetchData() {
}
}
// 获取未安装插件列表数据
async function fetchUninstalledPlugins() {
try {
uninstalledList.value = await api.get('plugin/', {
params: {
state: 'market',
},
})
// 设置APP市场加载完成
isAppMarketLoaded.value = true
// 设置更新状态
for (const uninstalled of uninstalledList.value) {
for (const data of dataList.value) {
if (uninstalled.id === data.id) {
data.has_update = true
data.repo_url = uninstalled.repo_url
data.history = uninstalled.history
}
}
}
}
catch (error) {
console.error(error)
}
}
// 加载插件统计数据
async function getPluginStatistics() {
try {
PluginStatistics.value = await api.get('plugin/statistic')
}
catch (error) {
console.error(error)
}
}
// 加载所有数据
function refreshData() {
fetchInstalledPlugins()
fetchUninstalledPlugins()
}
// 对uninstalledList进行排序按PluginStatistics倒序
const sortedUninstalledList = computed(() => {
const list = uninstalledList.value.filter(item => !item.has_update)
if (PluginStatistics.value.length === 0)
return list
return list.sort((a, b) => {
return PluginStatistics.value[b.id || '0'] - PluginStatistics.value[a.id || '0']
})
})
// 加载时获取数据
onBeforeMount(fetchData)
onBeforeMount(() => {
refreshData()
getPluginStatistics()
})
</script>
<template>
@@ -63,41 +223,45 @@ onBeforeMount(fetchData)
/>
</div>
<div
v-if="getInstalledPluginList.length > 0"
v-if="dataList.length > 0"
class="grid gap-4 grid-plugin-card"
>
<PluginCard
v-for="data in getInstalledPluginList"
:key="data.id"
v-for="data in dataList"
:key="`${data.id}_v${data.plugin_version}`"
:count="PluginStatistics[data.id || '0']"
:plugin="data"
@remove="fetchData"
@save="fetchData"
:action="pluginActions[data.id || '0']"
@remove="refreshData"
@save="refreshData"
@action-done="pluginActions[data.id || '0'] = false"
/>
</div>
<NoDataFound
v-if="getInstalledPluginList.length === 0 && isRefreshed"
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
error-title="没有安装插件"
error-description="点击右下角按钮前往插件市场安装插件"
/>
<!-- App市场 -->
<VFab
icon="mdi-store-plus"
location="bottom end"
size="x-large"
fixed
app
appear
@click="PluginAppDialog = true"
/>
<VDialog
v-if="PluginAppDialog"
v-model="PluginAppDialog"
fullscreen
scrollable
:scrim="false"
:z-index="1010"
transition="dialog-bottom-transition"
>
<!-- Dialog Activator -->
<template #activator="{ props }">
<VBtn
icon="mdi-store-plus"
v-bind="props"
size="x-large"
class="fixed right-5 bottom-5"
/>
</template>
<!-- Dialog Content -->
<VCard>
<!-- Toolbar -->
@@ -121,16 +285,28 @@ onBeforeMount(fetchData)
</VToolbar>
</div>
<VCardText>
<div class="grid gap-4 grid-plugin-card">
<div
v-if="!isAppMarketLoaded"
class="mt-12 w-full text-center text-gray-500 text-sm flex flex-col items-center"
>
<VProgressCircular
v-if="!isAppMarketLoaded"
size="48"
indeterminate
color="primary"
/>
</div>
<div v-if="isAppMarketLoaded" class="grid gap-4 grid-plugin-card">
<PluginAppCard
v-for="data in getUninstalledPluginList"
:key="data.id"
v-for="data in sortedUninstalledList"
:key="`${data.id}_v${data.plugin_version}`"
:plugin="data"
:count="PluginStatistics[data.id || '0']"
@install="pluginInstalled"
/>
</div>
<NoDataFound
v-if="getUninstalledPluginList.length === 0 && isRefreshed"
v-if="uninstalledList.length === 0 && isAppMarketLoaded"
error-code="404"
error-title="没有未安装插件"
error-description="所有可用插件均已安装"
@@ -138,11 +314,106 @@ onBeforeMount(fetchData)
</VCardText>
</VCard>
</VDialog>
<!-- 插件搜索 -->
<VFab
icon="mdi-magnify"
color="info"
location="bottom end"
class="mb-2"
size="x-large"
fixed
app
appear
@click="SearchDialog = true"
/>
<VDialog
v-if="SearchDialog"
v-model="SearchDialog"
scrollable
:z-index="1010"
max-width="40rem"
max-height="85vh"
>
<VCard
class="mx-auto"
width="100%"
>
<VToolbar flat class="p-0">
<VTextField
v-model="keyword"
label="搜索插件"
single-line
placeholder="插件名称或描述"
variant="solo"
prepend-inner-icon="mdi-magnify"
flat
class="mx-1"
/>
</VToolbar>
<DialogCloseBtn @click="closeSearchDialog" />
<VList
v-if="filterPlugins.length > 0"
lines="two"
>
<template v-for="(item, i) in filterPlugins" :key="i">
<VListItem
@click="openPlugin(item)"
>
<template #prepend>
<VAvatar>
<VImg
:src="pluginIcon(item)"
@error="pluginIconError(item)"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
</div>
</template>
</VImg>
</VAvatar>
</template>
<VListItemTitle>
{{ item.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ item?.plugin_version }}</span>
<VIcon
v-if="item.installed"
color="success"
icon="mdi-check-circle"
class="ms-2"
size="small"
/>
</VListItemTitle>
<VListItemSubtitle class="mt-2" v-html="item.plugin_desc" />
</VListItem>
</template>
</VList>
</VCard>
</VDialog>
<!-- 安装插件进度框 -->
<VDialog
v-model="progressDialog"
:scrim="false"
width="25rem"
>
<VCard
color="primary"
>
<VCardText class="text-center">
{{ progressText }}
<VProgressLinear
indeterminate
color="white"
class="mb-0 mt-1"
/>
</VCardText>
</VCard>
</VDialog>
</template>
<style lang="scss">
.grid-plugin-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
padding-block-end: 1rem;
}
</style>

View File

@@ -6,10 +6,6 @@ import NoDataFound from '@/components/NoDataFound.vue'
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
import store from '@/store'
// 从Vuex Store中获取用户信息
const superUser = store.state.auth.superUser
const userName = store.state.auth.userName
// 定时器
let refreshTimer: NodeJS.Timer | null = null
@@ -42,10 +38,13 @@ function onRefresh() {
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
const filteredDataList = computed(() => {
// 从Vuex Store中获取用户信息
const superUser = store.state.auth.superUser
const userName = store.state.auth.userName
if (superUser)
return dataList.value
else
return dataList.value.filter(data => data.userid === userName)
return dataList.value.filter(data => data.userid === userName || data.username === userName)
})
// 加载时获取数据

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { debounce, cloneDeep } from 'lodash'
import { ref, unref } from 'vue'
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { TransferHistory } from '@/api/types'
import ReorganizeForm from '@/components/form/ReorganizeForm.vue'
import { fixArrayAt } from '@/@core/utils/compatibility'
// 提示框
const $toast = useToast()
@@ -57,11 +59,22 @@ const headers = [
},
]
const pageRange = [
{title: '10', value: 10},
{title: '25', value: 25},
{title: '50', value: 50},
{title: '100', value: 100},
{title: 'All', value: -1}]
// 数据列表
const dataList = ref<TransferHistory[]>([])
// 本地数据根据文件夹筛选
const backupList = ref<TransferHistory[]>([])
// 搜索
const search = ref()
//文件夹搜索
const searchFolder = ref()
// 搜索提示词列表
const searchHintList = ref<string[]>([])
@@ -93,8 +106,41 @@ const deleteConfirmDialog = ref(false)
// 确认框标题
const confirmTitle = ref('')
// 分页提示
const pageTip = computed(() => {
const begin = unref(itemsPerPage) * (unref(currentPage) - 1) + 1
const end = unref(itemsPerPage) * unref(currentPage) === -1 ? 'ALL' : unref(itemsPerPage) * unref(currentPage)
return {
begin,
end
}
})
// 分页总数
const totalPage = computed(() => {
const total = Math.ceil(unref(totalItems) /unref(itemsPerPage))
return total
})
// 切换页签
watch(
() => currentPage.value,
async () => {
await fetchData({ page: currentPage.value, itemsPerPage: itemsPerPage.value })
})
const handleSearchFolder = debounce(() => {
if (!unref(searchFolder)) {
dataList.value = unref(backupList)
} else {
dataList.value = unref(backupList).filter((data: any) => data.src.includes(unref(searchFolder)) || data.dest.includes(unref(searchFolder)))
}
}, 100)
// 获取订阅列表数据
async function fetchData({ page, itemsPerPage }: { page: number; itemsPerPage: number }) {
loading.value = true
try {
currentPage.value = page
@@ -108,6 +154,7 @@ async function fetchData({ page, itemsPerPage }: { page: number; itemsPerPage: n
})
dataList.value = result.data.list
backupList.value = cloneDeep(unref(dataList))
totalItems.value = result.data.total
searchHintList.value = ['失败', '成功', ...new Set(dataList.value.map(item => item.title || ''))].filter(
title => title !== '',
@@ -304,17 +351,35 @@ const dropdownItems = ref([
},
},
])
// 修复低版本Safari等浏览器数组不支持at函数的问题
fixArrayAt()
</script>
<template>
<VCard class="pb-5">
<VCard>
<VCardItem>
<VCardTitle>
<VRow>
<VCol cols="4" md="6">
历史记录
</VCol>
<VCol cols="8" md="6">
<VCol cols="8" md="6" class="flex">
<VCombobox
key="search_navbar"
v-model="searchFolder"
class="text-disabled mr-3 d-none d-md-block"
density="compact"
label="目录筛选"
prepend-inner-icon="mdi-folder-search"
variant="solo-filled"
single-line
hide-details
flat
rounded
clearable
@update:search="handleSearchFolder"
/>
<VCombobox
key="search_navbar"
v-model="search"
@@ -322,7 +387,7 @@ const dropdownItems = ref([
class="text-disabled"
density="compact"
label="搜索标题、状态"
append-inner-icon="mdi-magnify"
prepend-inner-icon="mdi-text-search"
variant="solo-filled"
single-line
hide-details
@@ -335,48 +400,50 @@ const dropdownItems = ref([
</VCardTitle>
</VCardItem>
<VDataTableServer
v-if="itemsPerPage !== -1"
:items-per-page="itemsPerPage"
v-model="selected"
v-model:items-per-page="itemsPerPage"
:headers="headers"
:items="dataList"
:items-length="totalItems"
:search="search"
:loading="loading"
density="compact"
item-value="id"
:item-value="'id' + Math.random()*1000"
return-object
fixed-header
show-select
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
class="data-table-div"
:hide-default-footer="true"
:disable-pagination="true"
@update:options="fetchData"
>
<template #item.title="{ item }">
<div class="d-flex align-center">
<VAvatar>
<VIcon :icon="getIcon(item.value.type || '')" />
<VIcon :icon="getIcon(item.type || '')" />
</VAvatar>
<div class="d-flex flex-column ms-1">
<span class="d-block whitespace-nowrap text-high-emphasis">
{{ item.value.title }} {{ item.value.seasons }}{{ item.value.episodes }}
<span class="d-block text-high-emphasis">
{{ item?.title }} {{ item?.seasons }}{{ item?.episodes }}
</span>
<small>{{ item.value.category }}</small>
<small>{{ item?.category }}</small>
</div>
</div>
</template>
<template #item.src="{ item }">
<small>{{ item.value.src }} <br>=> {{ item.value.dest }}</small>
<small>{{ item?.src }} <br>=> {{ item?.dest }}</small>
</template>
<template #item.mode="{ item }">
<VChip variant="outlined" color="primary" size="small">
{{ TransferDict[item.value.mode] }}
{{ TransferDict[item?.mode || ''] }}
</VChip>
</template>
<template #item.status="{ item }">
<VChip v-if="item.value.status" color="success" size="small">
<VChip v-if="item?.status" color="success" size="small">
成功
</VChip>
<v-tooltip v-else :text="item.value.errmsg">
<v-tooltip v-else :text="item?.errmsg">
<template #activator="{ props }">
<VChip v-bind="props" color="error" size="small">
失败
@@ -385,7 +452,7 @@ const dropdownItems = ref([
</v-tooltip>
</template>
<template #item.date="{ item }">
<small>{{ item.value.date }}</small>
<small>{{ item?.date }}</small>
</template>
<template #item.actions="{ item }">
<IconBtn>
@@ -397,7 +464,7 @@ const dropdownItems = ref([
:key="i"
variant="plain"
:base-color="menu.props.color"
@click="menu.props.click(item.value)"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
@@ -411,13 +478,108 @@ const dropdownItems = ref([
<template #no-data>
没有数据
</template>
<template v-slot:bottom>
<div/>
</template>
</VDataTableServer>
<VDataTableVirtual
v-else
v-model="selected"
:headers="headers"
:items="dataList"
:search="search"
:loading="loading"
density="compact"
return-object
fixed-header
show-select
loading-text="加载中..."
class="data-table-div"
@update:options="fetchData"
>
<template #item.title="{ item }">
<div class="d-flex align-center">
<VAvatar>
<VIcon :icon="getIcon(item.type || '')" />
</VAvatar>
<div class="d-flex flex-column ms-1">
<span class="d-block text-high-emphasis">
{{ item?.title }} {{ item?.seasons }}{{ item?.episodes }}
</span>
<small>{{ item?.category }}</small>
</div>
</div>
</template>
<template #item.src="{ item }">
<small>{{ item?.src }} <br>=> {{ item?.dest }}</small>
</template>
<template #item.mode="{ item }">
<VChip variant="outlined" color="primary" size="small">
{{ TransferDict[item?.mode || ''] }}
</VChip>
</template>
<template #item.status="{ item }">
<VChip v-if="item?.status" color="success" size="small">
成功
</VChip>
<v-tooltip v-else :text="item?.errmsg">
<template #activator="{ props }">
<VChip v-bind="props" color="error" size="small">
失败
</VChip>
</template>
</v-tooltip>
</template>
<template #item.date="{ item }">
<small>{{ item?.date }}</small>
</template>
<template #item.actions="{ item }">
<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.props.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<template #no-data>
没有数据
</template>
</VDataTableVirtual>
<div class="flex items-center justify-end">
<div class="w-auto">
<VSelect
v-model="itemsPerPage"
:items="pageRange"
density="compact"
variant="solo"
flat
size="small"
/>
</div>
<div class="w-auto text-sm">{{pageTip.begin}}-{{pageTip.end}} / {{totalItems}}</div>
<VPagination
v-model="currentPage"
show-first-last-page
:length="totalPage"
@next="currentPage + 1"
@prev="currentPage - 1"
>
</VPagination>
</div>
</VCard>
<!-- 底部操作按钮 -->
<span v-if="selected.length > 0" class="fixed right-5 bottom-5">
<VBtn icon="mdi-redo-variant" class="me-2" color="primary" size="x-large" @click="retransferBatch" />
<VBtn icon="mdi-trash-can-outline" color="error" size="x-large" @click="removeHistoryBatch" />
</span>
<!-- 底部弹窗 -->
<VBottomSheet v-model="deleteConfirmDialog" inset>
<VCard class="text-center rounded-t">
@@ -443,6 +605,7 @@ const dropdownItems = ref([
</VBottomSheet>
<!-- 文件整理弹窗 -->
<ReorganizeForm
v-if="redoDialog"
v-model="redoDialog"
:logids="redoIds"
:target="redoTarget"
@@ -461,10 +624,39 @@ const dropdownItems = ref([
"
@close="redoDialog = false"
/>
<!-- 底部操作按钮 -->
<span>
<VFab
v-if="selected.length > 0"
icon="mdi-trash-can-outline"
color="error"
location="bottom end"
size="x-large"
fixed
app
appear
@click="removeHistoryBatch"
/>
<VFab
v-if="selected.length > 0"
class="mb-2"
icon="mdi-redo-variant"
location="bottom end"
size="x-large"
fixed
app
appear
@click="retransferBatch"
/>
</span>
</template>
<style lang="scss">
.v-table th {
white-space: nowrap;
}
.data-table-div {
block-size: calc(100vh - 15.5rem);
}
</style>

View File

@@ -1,5 +1,7 @@
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import QrcodeVue from 'qrcode.vue'
import { VForm } from 'vuetify/lib/components/index.mjs'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { User } from '@/api/types'
@@ -19,6 +21,18 @@ const refInputEl = ref<HTMLElement>()
// 新增用户窗口
const addUserDialog = ref(false)
// 开启双重验证窗口
const otpDialog = ref(false)
// otp uri
const otpUri = ref('')
// otp secret
const secret = ref('')
// 确认双重验证密码
const otpPassword = ref('')
// 新增用户表单
const userForm = reactive({
name: '',
@@ -35,11 +49,15 @@ const accountInfo = ref<User>({
is_active: false,
is_superuser: false,
avatar: '',
is_otp: false,
})
// 所有用户信息
const allUsers = ref<User[]>([])
// 二维码信息
const qrCode = ref('')
// changeAvatar function
function changeAvatar(file: Event) {
const fileReader = new FileReader()
@@ -65,7 +83,7 @@ function resetAvatar() {
async function loadAccountInfo() {
try {
const user: User = await api.get('user/current')
console.log(user)
accountInfo.value = user
if (!accountInfo.value.avatar)
accountInfo.value.avatar = avatar1
@@ -167,6 +185,65 @@ async function addUser() {
}
}
// 为当前用户获取Otp Uri
async function getOtpUri() {
try {
const result: { [key: string]: any } = await api.post('user/otp/generate')
if (result.success) {
otpUri.value = result.data.uri
secret.value = result.data.secret
qrCode.value = result.data.uri
otpDialog.value = true
}
else {
$toast.error(`获取otp uri失败${result.message}`)
}
}
catch (error) {
console.log(error)
}
}
// 关闭当前用户的双重验证
async function disableOtp() {
try {
const result: { [key: string]: any } = await api.post('user/otp/disable')
if (result.success) {
accountInfo.value.is_otp = false
$toast.success('关闭登录双重验证成功!')
}
else {
$toast.error(`关闭otp失败${result.message}`)
}
}
catch (error) {
console.log(error)
}
}
// 启用Otp
async function judgeOtpPassword() {
if (!otpPassword.value) {
$toast.error('请填写6位验证码')
return
}
try {
const result: { [key: string]: any } = await api.post('user/otp/judge', { uri: otpUri.value, otpPassword: otpPassword.value })
if (result.success) {
$toast.success('开启登录双重验证成功!')
otpDialog.value = false
accountInfo.value.is_otp = true
}
else {
$toast.error(`开启otp失败${result.message}`)
}
}
catch (error) {
console.log(error)
}
}
// 加载当前用户数据
onMounted(() => {
loadAccountInfo()
@@ -196,9 +273,8 @@ onMounted(() => {
>
<VIcon
icon="mdi-cloud-upload-outline"
class="d-sm-none"
/>
<span class="d-none d-sm-block">上传头像</span>
<span class="d-none d-sm-block ms-2">上传头像</span>
</VBtn>
<input
@@ -216,11 +292,21 @@ onMounted(() => {
variant="tonal"
@click="resetAvatar"
>
<span class="d-none d-sm-block">重置</span>
<VIcon
icon="mdi-refresh"
class="d-sm-none"
/>
<span class="d-none d-sm-block ms-2">重置</span>
</VBtn>
<VBtn
:color="accountInfo.is_otp ? 'error' : 'info'"
variant="tonal"
@click.stop="accountInfo.is_otp ? disableOtp() : getOtpUri()"
>
<VIcon
icon="mdi-account-key"
/>
<span class="d-none d-sm-block ms-2">{{ accountInfo.is_otp ? "关闭验证" : "双重验证" }}</span>
</VBtn>
</div>
@@ -268,11 +354,9 @@ onMounted(() => {
<VTextField
v-model="newPassword"
:type="isNewPasswordVisible ? 'text' : 'password'"
:append-inner-icon="
isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
"
:append-inner-icon="isNewPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
label="新密码"
autocomplete="new-password"
autocomplete=""
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
/>
</VCol>
@@ -369,7 +453,7 @@ onMounted(() => {
</td>
<td>{{ user.is_superuser ? "是" : "否" }}</td>
<td>
<IconBtn v-show="accountInfo.is_superuser && accountInfo.name != user.name">
<IconBtn v-show="accountInfo.is_superuser && accountInfo.name !== user.name">
<VIcon icon="mdi-dots-vertical" />
<VMenu
activator="parent"
@@ -409,11 +493,12 @@ onMounted(() => {
</VCard>
</VCol>
</VRow>
<!-- 站点编辑弹窗 -->
<!-- =弹窗 -->
<VDialog
v-model="addUserDialog"
max-width="50rem"
persistent
z-index="1010"
>
<!-- Dialog Content -->
<VCard title="新增用户">
@@ -469,4 +554,60 @@ onMounted(() => {
</VCardActions>
</VCard>
</VDialog>
<!-- 双重验证弹窗 -->
<VDialog
v-model="otpDialog"
max-width="45rem"
persistent
z-index="1010"
>
<!-- 开启双重验证弹窗内容 -->
<VCard>
<DialogCloseBtn @click="otpDialog = false" />
<VCardText>
<h4 class="text-h4 text-center mb-6 mt-5">
登录双重验证
</h4><h5 class="text-h5 font-weight-medium mb-2">
身份验证器
</h5>
<p class="mb-6">
使用像Google AuthenticatorMicrosoft AuthenticatorAuthy或1Password这样的身份验证器应用程序扫描二维码它将为您生成一个6位数的代码供您在下方输入
</p>
<div class="my-6">
<QrcodeVue class="mx-auto" :value="qrCode" :size="200" max-width="25rem" />
</div>
<VAlert
:title="secret"
variant="tonal"
type="warning"
class="my-4"
text="如果您在使用二维码时遇到困难,请在您的应用程序中选择手动输入以上代码。"
>
<template #prepend />
</VAlert>
<VForm>
<VTextField
v-model="otpPassword"
type="text"
label="输入验证码以确认开启双重验证"
autocomplete=""
class="mb-8"
variant="outlined"
/>
<div class="d-flex justify-end flex-wrap gap-4">
<VBtn variant="outlined" color="secondary" @click="otpDialog = false">
取消
</VBtn>
<VBtn @click="judgeOtpPassword">
确定
<template #append>
<VIcon icon="mdi-check" />
</template>
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -5,6 +5,59 @@ import type { NotificationSwitch } from '@/api/types'
const messagemTypes = ref<NotificationSwitch[]>([])
// 选中的消息渠道
const selectedChannels = ref([])
// 消息渠道标签页
const messagerTab = ref('wechat')
// 消息设置
const notificationSettings = ref({
WECHAT_CORPID: '',
WECHAT_APP_SECRET: '',
WECHAT_APP_ID: '',
WECHAT_PROXY: '',
WECHAT_TOKEN: '',
WECHAT_ENCODING_AESKEY: '',
WECHAT_ADMINS: '',
TELEGRAM_TOKEN: '',
TELEGRAM_CHAT_ID: '',
TELEGRAM_USERS: '',
TELEGRAM_ADMINS: '',
SLACK_OAUTH_TOKEN: '',
SLACK_APP_TOKEN: '',
SLACK_CHANNEL: '',
SYNOLOGYCHAT_WEBHOOK: '',
SYNOLOGYCHAT_TOKEN: '',
VOCECHAT_HOST: '',
VOCECHAT_API_KEY: '',
VOCECHAT_CHANNEL_ID: '',
})
// 消息渠道
const NotificationChannels = [
{
title: '微信',
value: 'wechat',
},
{
title: 'Telegram',
value: 'telegram',
},
{
title: 'Slack',
value: 'slack',
},
{
title: 'SynologyChat',
value: 'synologychat',
},
{
title: 'VoceChat',
value: 'vocechat',
},
]
// 提示框
const $toast = useToast()
@@ -32,87 +85,426 @@ async function saveNotificationSwitchs() {
$toast.success('保存通知消息设置成功')
else
$toast.error('保存通知消息设置失败!')
// messagemTypes.value = messagemTypes.value
}
catch (error) {
console.log(error)
}
}
// 调用API查询消息渠道设置
async function loadNotificationSettings() {
try {
const result1: { [key: string]: any } = await api.get('system/setting/MESSAGER')
if (result1.success)
selectedChannels.value = result1.data?.value?.split(',')
const result2: { [key: string]: any } = await api.get('system/env')
if (result2.success) {
const {
WECHAT_CORPID,
WECHAT_APP_SECRET,
WECHAT_APP_ID,
WECHAT_PROXY,
WECHAT_TOKEN,
WECHAT_ENCODING_AESKEY,
WECHAT_ADMINS,
TELEGRAM_TOKEN,
TELEGRAM_CHAT_ID,
TELEGRAM_USERS,
TELEGRAM_ADMINS,
SLACK_OAUTH_TOKEN,
SLACK_APP_TOKEN,
SLACK_CHANNEL,
SYNOLOGYCHAT_WEBHOOK,
SYNOLOGYCHAT_TOKEN,
VOCECHAT_HOST,
VOCECHAT_API_KEY,
VOCECHAT_CHANNEL_ID,
} = result2.data
notificationSettings.value = {
WECHAT_CORPID,
WECHAT_APP_SECRET,
WECHAT_APP_ID,
WECHAT_PROXY,
WECHAT_TOKEN,
WECHAT_ENCODING_AESKEY,
WECHAT_ADMINS,
TELEGRAM_TOKEN,
TELEGRAM_CHAT_ID,
TELEGRAM_USERS,
TELEGRAM_ADMINS,
SLACK_OAUTH_TOKEN,
SLACK_APP_TOKEN,
SLACK_CHANNEL,
SYNOLOGYCHAT_WEBHOOK,
SYNOLOGYCHAT_TOKEN,
VOCECHAT_HOST,
VOCECHAT_API_KEY,
VOCECHAT_CHANNEL_ID,
}
}
}
catch (error) {
console.log(error)
}
}
// 调用API保存消息渠道设置
async function saveNotificationSettings() {
try {
const result1: { [key: string]: any } = await api.post(
'system/setting/MESSAGER',
selectedChannels.value.join(','),
)
const result2: { [key: string]: any } = await api.post(
'system/env',
notificationSettings.value,
)
if (result1.success && result2.success) {
$toast.success('保存通知渠道设置成功')
reloadModule()
}
else { $toast.error('保存通知渠道设置失败!') }
}
catch (error) {
console.log(error)
}
}
// 调用API接口重新加载模块
async function reloadModule() {
try {
const result: { [key: string]: any } = await api.get('system/reload')
if (result.success)
$toast.success('重新加载模块成功')
else
$toast.error('重新加载模块失败!')
}
catch (error) {
console.log(error)
}
}
// 加载数据
onMounted(() => {
loadNotificationSwitchs()
loadNotificationSettings()
})
</script>
<template>
<VCard title="消息通知">
<VCardText> 对应消息类型只会发送给选中的消息渠道 </VCardText>
<VRow>
<VCol cols="12">
<VCard title="通知渠道">
<VCardSubtitle>只有选中的渠道才会发送消息</VCardSubtitle>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="selectedChannels"
multiple
chips
:items="NotificationChannels"
label="当前使用通知渠道"
hint="选中的渠道才会按消息类型的设定发送消息"
/>
</VCol>
</VRow>
<VRow>
<VCol>
<VTabs
v-model="messagerTab"
stacked
>
<VTab value="wechat">
微信
</VTab>
<VTab value="telegram">
Telegram
</VTab>
<VTab value="slack">
Slack
</VTab>
<VTab value="synologychat">
SynologyChat
</VTab>
<VTab value="vocechat">
VoceChat
</VTab>
</VTabs>
<VWindow
v-model="messagerTab"
class="mt-5 disable-tab-transition"
:touch="false"
>
<VWindowItem value="wechat">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_CORPID"
label="企业ID"
hint="登录企业微信后台,在 https://work.weixin.qq.com/wework_admin/frame#profile 中查看"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_APP_SECRET"
label="应用Secret"
hint="在企业微信中创建应用查看应用的Secret"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_APP_ID"
label="应用 AgentId"
hint="在企业微信中创建应用查看应用的AgentId"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_PROXY"
label="代理地址"
hint="由于微信官方限制2022年6月20日后创建的企业微信应用需要有固定的公网IP地址并加入IP白名单后才能接收消息使用有固定公网IP的代理服务器转发可解决该问题代理服务器需自行搭建搭建方法参考项目主页说明不使用代理需保留默认值"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_TOKEN"
label="Token"
hint="在微信企业应用管理后台-接收消息设置页面生成"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_ENCODING_AESKEY"
label="EncodingAESKey"
hint="在微信企业应用管理后台-接收消息设置页面生成所有信息填入完成后保存然后再在企业微信应用消息接收服务中输入回调地址http(s)://domain:port/api/v1/message/"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.WECHAT_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="只有在白名单中的用户才能使用菜单管理功能,不填写则所有用户都能使用,菜单会自动生成,不需要手动创建"
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="telegram">
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.TELEGRAM_TOKEN"
label="Bot Token"
hint="Telegram机器人的token关注BotFather创建机器人并获取token格式为123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.TELEGRAM_CHAT_ID"
label="Chat ID"
hint="接受消息通知的用户、群组或频道Chat ID关注@getidsbot获取"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.TELEGRAM_USERS"
label="用户白名单"
placeholder="多个用,分隔"
hint="只有在白名单中的用户才能使用Telegram机器人不填写则所有用户都能使用多个用户用英文,分隔"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.TELEGRAM_ADMINS"
label="管理员白名单"
placeholder="多个用,分隔"
hint="只有在白名单中的用户才能使用管理功能,不填写则所有用户都能使用,多个用户用英文,分隔。菜单会自动生成,不需要手动创建"
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="slack">
<VForm>
<VRow>
<VCol cols="12" md="5">
<VTextField
v-model="notificationSettings.SLACK_OAUTH_TOKEN"
label="Slack Bot User OAuth Token"
placeholder="xoxb-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="在 https://api.slack.com/apps 中创建应用查看OAuth & Permissions页面中的Bot User OAuth Token"
/>
</VCol>
<VCol cols="12" md="5">
<VTextField
v-model="notificationSettings.SLACK_APP_TOKEN"
label="Slack App-Level Token"
placeholder="xapp-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx"
hint="在 https://api.slack.com/apps 中创建应用查看OAuth & Permissions页面中的App-Level Token"
/>
</VCol>
<VCol cols="12" md="2">
<VTextField
v-model="notificationSettings.SLACK_CHANNEL"
label="频道名称"
placeholder="全体"
hint="消息发送到的频道名称,不填写则发送到全体频道"
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="synologychat">
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.SYNOLOGYCHAT_WEBHOOK"
label="机器人传入URL"
hint="在Synology Chat中创建机器人获取机器人传入URL"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationSettings.SYNOLOGYCHAT_TOKEN"
label="令牌"
hint="在Synology Chat中创建机器人获取机器人令牌"
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="vocechat">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.VOCECHAT_HOST"
label="地址"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.VOCECHAT_API_KEY"
label="机器人密钥"
hint="在VoceChat中创建机器人获取机器人密钥"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="notificationSettings.VOCECHAT_CHANNEL_ID"
label="频道ID"
placeholder="不包含#号"
hint="在VoceChat中创建频道获取频道ID不包含#号"
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
</VWindow>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn
mtype="submit"
@click="saveNotificationSettings"
>
保存
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VCard title="消息类型">
<VCardSubtitle> 对应消息类型只会发送给选中的消息渠道 </VCardSubtitle>
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">
消息类型
</th>
<th scope="col">
微信
</th>
<th scope="col">
Telegram
</th>
<th scope="col">
Slack
</th>
<th scope="col">
SynologyChat
</th>
<th scope="col">
VoceChat
</th>
</tr>
</thead>
<tbody>
<tr
v-for="message in messagemTypes"
:key="message.mtype"
>
<td>
{{ message.mtype }}
</td>
<td>
<VCheckbox v-model="message.wechat" />
</td>
<td>
<VCheckbox v-model="message.telegram" />
</td>
<td>
<VCheckbox v-model="message.slack" />
</td>
<td>
<VCheckbox v-model="message.synologychat" />
</td>
<td>
<VCheckbox v-model="message.vocechat" />
</td>
</tr>
<tr v-if="messagemTypes.length === 0">
<td
colspan="6"
class="text-center"
>
没有设置任何通知渠道
</td>
</tr>
</tbody>
</VTable>
<VDivider />
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">
消息类型
</th>
<th scope="col">
微信
</th>
<th scope="col">
Telegram
</th>
<th scope="col">
Slack
</th>
<th scope="col">
SynologyChat
</th>
</tr>
</thead>
<tbody>
<tr
v-for="message in messagemTypes"
:key="message.mtype"
>
<td>
{{ message.mtype }}
</td>
<td>
<VCheckbox v-model="message.wechat" />
</td>
<td>
<VCheckbox v-model="message.telegram" />
</td>
<td>
<VCheckbox v-model="message.slack" />
</td>
<td>
<VCheckbox v-model="message.synologychat" />
</td>
</tr>
<tr v-if="messagemTypes.length === 0">
<td
colspan="4"
class="text-center"
>
没有设置任何通知渠道
</td>
</tr>
</tbody>
</VTable>
<VDivider />
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn
mtype="submit"
@click="saveNotificationSwitchs"
>
保存
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn
mtype="submit"
@click="saveNotificationSwitchs"
>
保存
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>

View File

@@ -390,6 +390,7 @@ onMounted(() => {
v-model="defaultFilterRules.include"
type="text"
label="包含(关键字、正则式)"
hint="支持正式表达式,多个关键字用 | 分隔表示或"
/>
</VCol>
<VCol cols="12" md="6">
@@ -397,6 +398,7 @@ onMounted(() => {
v-model="defaultFilterRules.exclude"
type="text"
label="排除(关键字、正则式)"
hint="支持正式表达式,多个关键字用 | 分隔表示或"
/>
</VCol>
</VRow>

View File

@@ -78,11 +78,14 @@ onUnmounted(() => {
<template>
<VCard title="定时作业">
<VCardText> 手动执行不会影响作业正常的时间表 </VCardText>
<VCardSubtitle> 包含系统内置服务以及插件提供的服务手动执行不会影响作业正常的时间表 </VCardSubtitle>
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">
提供者
</th>
<th scope="col">
任务名称
</th>
@@ -100,6 +103,9 @@ onUnmounted(() => {
v-for="scheduler in schedulerList"
:key="scheduler.id"
>
<td>
{{ scheduler.provider }}
</td>
<td>
{{ scheduler.name }}
</td>

View File

@@ -17,12 +17,33 @@ const resetSitesDisabled = ref(false)
// 种子优先规则
const selectedTorrentPriority = ref<string>('seeder')
// CookieCloud设置项
const cookieCloudSetting = ref({
COOKIECLOUD_HOST: '',
COOKIECLOUD_KEY: '',
COOKIECLOUD_PASSWORD: '',
COOKIECLOUD_INTERVAL: 0,
USER_AGENT: '',
COOKIECLOUD_ENABLE_LOCAL: '',
})
// 种子优先规则下拉框
const TorrentPriorityItems = [
{ title: '站点优先', value: 'site' },
{ title: '做种数优先', value: 'seeder' },
]
// 同步间隔下拉框
const CookieCloudIntervalItems = [
{ title: '每小时', value: 60 },
{ title: '每6小时', value: 360 },
{ title: '每12小时', value: 720 },
{ title: '每天', value: 1440 },
{ title: '每周', value: 10080 },
{ title: '每月', value: 43200 },
{ title: '永不', value: 0 },
]
// 重置站点
async function resetSites() {
try {
@@ -77,13 +98,128 @@ async function saveTorrentPriority() {
}
}
// 加载CookieCloud设置
async function loadCookieCloudSettings() {
try {
const result: { [key: string]: any } = await api.get('system/env')
if (result.success) {
const {
COOKIECLOUD_HOST,
COOKIECLOUD_KEY,
COOKIECLOUD_PASSWORD,
COOKIECLOUD_INTERVAL,
USER_AGENT,
COOKIECLOUD_ENABLE_LOCAL,
} = result.data
cookieCloudSetting.value = {
COOKIECLOUD_HOST,
COOKIECLOUD_KEY,
COOKIECLOUD_PASSWORD,
COOKIECLOUD_INTERVAL,
USER_AGENT,
COOKIECLOUD_ENABLE_LOCAL,
}
}
}
catch (error) {
console.log(error)
}
}
// 调用API保存CookieCloud设置
async function saveCookieCloudetting() {
try {
const result: { [key: string]: any } = await api.post(
'system/env',
cookieCloudSetting.value,
)
if (result.success)
$toast.success('保存站点同步设置成功')
else
$toast.error('保存站点同步设置失败!')
}
catch (error) {
console.log(error)
}
}
// 加载数据
onMounted(() => {
queryTorrentPriority()
loadCookieCloudSettings()
})
</script>
<template>
<VRow>
<VCol cols="12">
<VCard title="站点同步">
<VCardSubtitle> 从CookieCloud快速同步站点数据 </VCardSubtitle>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VCheckbox
v-model="cookieCloudSetting.COOKIECLOUD_ENABLE_LOCAL"
label="启用本地CookieCloud服务器"
hint="启用后将使用内建CookieCloud服务同步站点数据服务地址为http://localhost:3000/cookiecloud"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="cookieCloudSetting.COOKIECLOUD_HOST"
label="远程CookieCloud服务器地址"
placeholder="https://movie-pilot.org/cookiecloud"
:disabled="!!cookieCloudSetting.COOKIECLOUD_ENABLE_LOCAL"
hint="格式https://movie-pilot.org/cookiecloud"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cookieCloudSetting.COOKIECLOUD_KEY"
label="用户KEY"
hint="在CookieCloud浏览器插件中生成"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cookieCloudSetting.COOKIECLOUD_PASSWORD"
type="password"
label="端对端加密密码"
hint="在CookieCloud浏览器插件中生成"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="cookieCloudSetting.COOKIECLOUD_INTERVAL"
label="自动同步间隔"
:items="CookieCloudIntervalItems"
hint="设置定时从CookieCloud服务器同步站点Cookie到MoviePilot的时间周期"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="cookieCloudSetting.USER_AGENT"
label="浏览器User-Agent"
hint="设置为CookieCloud插件所在的浏览器的User-Agent用于模拟浏览器请求正确填写后有助于提升站点访问成功率"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardItem>
<VBtn
type="submit"
@click="saveCookieCloudetting"
>
保存
</VBtn>
</VCardItem>
</VCard>
</VCol>
<VCol cols="12">
<VCard title="下载优先规则">
<VCardSubtitle> 按站点或做种数量优先下载 </VCardSubtitle>
@@ -94,8 +230,8 @@ onMounted(() => {
<VSelect
v-model="selectedTorrentPriority"
:items="TorrentPriorityItems"
label="优先规则"
outlined
label="当前使用下载优先规则"
hint="站点优先:优先下载站点优先级最高的站点的种子;做种数优先:优先下载做种数量最多的种子。注意下载优先级仍然低于搜索和订阅中设定的优先级规则"
/>
</VCol>
</VRow>
@@ -115,7 +251,11 @@ onMounted(() => {
<VCard title="站点重置">
<VCardText>
<div>
<VCheckbox v-model="isConfirmResetSites" label="确认删除所有站点数据并重新同步。" />
<VCheckbox
v-model="isConfirmResetSites"
label="确认删除所有站点数据并重新同步。"
hint="删除所有站点数据并重新同步站点图标短时间内会因数缓存而混乱重启或者等待2两时自动恢复。"
/>
</div>
<VBtn :disabled="!isConfirmResetSites || resetSitesDisabled" color="error" class="mt-3" @click="resetSites">

View File

@@ -1,4 +1,4 @@
<script lang="ts" setup>
<script lang='ts' setup>
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
@@ -32,15 +32,42 @@ const selectedRssSites = ref<number[]>([])
// 当前规则类型
const currentRuleType = ref('SubscribeFilterRules')
// 是否开启订阅定时搜索
const enableIntervalSearch = ref(false)
// 包含与排除规则
const defaultFilterRules = ref({
include: '',
exclude: '',
movie_size: '',
tv_size: '',
min_seeders: 0,
show_edit_dialog: false,
})
// 订阅模式选择项
const subscribeModeItems = [
{ title: '自动', value: 'spider' },
{ title: '站点RSS', value: 'rss' },
]
// 选择的订阅模式
const selectedSubscribeMode = ref('spider')
// RSS运行周期选择项
const rssIntervalItems = [
{ title: '5分钟', value: 5 },
{ title: '10分钟', value: 10 },
{ title: '20分钟', value: 20 },
{ title: '半小时', value: 30 },
{ title: '1小时', value: 60 },
{ title: '12小时', value: 720 },
{ title: '1天', value: 1440 },
]
// 选择的RSS运行周期
const selectedRssInterval = ref<number>(5)
// 导入代码弹窗
const importCodeDialog = ref(false)
@@ -62,9 +89,26 @@ async function querySelectedRssSites() {
// 保存用户选中的订阅站点
async function saveSelectedRssSites() {
try {
const result: { [key: string]: any } = await api.post('system/setting/RssSites', selectedRssSites.value)
const result1: { [key: string]: any } = await api.post(
'system/setting/RssSites',
selectedRssSites.value)
if (result.success)
const result2: { [key: string]: any } = await api.post(
'system/setting/SUBSCRIBE_SEARCH',
enableIntervalSearch.value ? 'True' : 'False',
)
const result3: { [key: string]: any } = await api.post(
'system/setting/SUBSCRIBE_MODE',
selectedSubscribeMode.value,
)
const result4: { [key: string]: any } = await api.post(
'system/setting/SUBSCRIBE_RSS_INTERVAL',
selectedRssInterval.value,
)
if (result1.success && result2.success && result3.success && result4.success)
$toast.success('订阅站点保存成功')
else
$toast.error('订阅站点保存失败!')
@@ -82,6 +126,19 @@ async function querySites() {
// 过滤站点,只有启用的站点才显示
allSites.value = data.filter(item => item.is_active)
querySelectedRssSites()
// 查询订阅搜索开关
const result: { [key: string]: any } = await api.get('system/setting/SUBSCRIBE_SEARCH')
if (result.success)
enableIntervalSearch.value = result.data?.value
// 查询订阅模式
const result2: { [key: string]: any } = await api.get('system/setting/SUBSCRIBE_MODE')
if (result2.success)
selectedSubscribeMode.value = result2.data?.value
// 查询站点RSS周期
const result3: { [key: string]: any } = await api.get('system/setting/SUBSCRIBE_RSS_INTERVAL')
if (result3.success)
selectedRssInterval.value = result3.data?.value
}
catch (error) {
console.log(error)
@@ -346,7 +403,37 @@ onMounted(() => {
</VChip>
</VChipGroup>
</VCardItem>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="selectedSubscribeMode"
:items="subscribeModeItems"
label="订阅模式"
hint="自动系统自动爬取站点首页资源站点RSS使用站点RSS订阅资源站点RSS会自动获取也可手动在站点管理中补全"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="selectedRssInterval"
:items="rssIntervalItems"
label="站点RSS周期"
hint="设置站点RSS运行周期在订阅模式为站点RSS时生效"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="enableIntervalSearch"
label="开启订阅定时搜索"
hint="开启后系统每隔24小时将按名称搜索全站补全订阅可能漏掉的资源"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardItem>
<VBtn type="submit" @click="saveSelectedRssSites">
保存
@@ -386,7 +473,7 @@ onMounted(() => {
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在正常订阅时默认使用的优先级未在优先级中的资源将不会自动下载 </VCardSubtitle>
<VCardSubtitle> 设置在正常订阅时默认使用的优先级未在优先级中的资源将不会自动下载</VCardSubtitle>
<VCardItem>
<div class="grid gap-3 grid-filterrule-card">
<FilterRuleCard
@@ -452,7 +539,7 @@ onMounted(() => {
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在订阅洗版时使用的优先级匹配优先级1时洗版完成 </VCardSubtitle>
<VCardSubtitle> 设置在订阅洗版时使用的优先级匹配优先级1时洗版完成</VCardSubtitle>
<VCardItem>
<div class="grid gap-3 grid-filterrule-card">
<FilterRuleCard
@@ -488,7 +575,7 @@ onMounted(() => {
</VCol>
<VCol cols="12">
<VCard title="默认过滤规则">
<VCardSubtitle> 设置在订阅时默认使用的过滤规则 </VCardSubtitle>
<VCardSubtitle> 设置在订阅时默认使用的过滤规则</VCardSubtitle>
<VCardText>
<VForm>
<VRow>
@@ -497,6 +584,7 @@ onMounted(() => {
v-model="defaultFilterRules.include"
type="text"
label="包含(关键字、正则式)"
hint="支持正式表达式,多个关键字用 | 分隔表示或"
/>
</VCol>
<VCol cols="12" md="6">
@@ -504,28 +592,41 @@ onMounted(() => {
v-model="defaultFilterRules.exclude"
type="text"
label="排除(关键字、正则式)"
hint="支持正式表达式,多个关键字用 | 分隔表示或"
/>
</VCol>
<VCol cols="12" md="6">
<VCol cols="12" md="4">
<VTextField
v-model="defaultFilterRules.movie_size"
type="text"
label="电影文件大小GB"
placeholder="0-30"
hint="格式0-30表示0到30GB之间的资源"
/>
</VCol>
<VCol cols="12" md="6">
<VCol cols="12" md="4">
<VTextField
v-model="defaultFilterRules.tv_size"
type="text"
label="剧集单集文件大小GB"
placeholder="0-10"
hint="格式0-10表示0到10GB之间的资源"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="defaultFilterRules.min_seeders"
type="text"
label="最小做种数"
placeholder="0"
hint="小于该值的资源将被过滤掉0表示不过滤"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="defaultFilterRules.show_edit_dialog"
label="订阅时编辑更多规则"
hint="开启后,添加订阅时将自动弹出订阅编辑框,要设置更多订阅选项"
/>
</VCol>
</VRow>
@@ -555,7 +656,7 @@ onMounted(() => {
</VDialog>
</template>
<style lang="scss">
<style lang='scss'>
.grid-filterrule-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
padding-block-end: 1rem;

View File

@@ -0,0 +1,783 @@
<!-- eslint-disable sonarjs/no-duplicate-string -->
<script lang="ts" setup>
import { useToast } from 'vue-toast-notification'
import { VRow } from 'vuetify/lib/components/index.mjs'
import api from '@/api'
import { requiredValidator } from '@/@validators'
// 选中的媒体服务器
const selectedMediaServers = ref([])
// 选中的下载器
const selectedDownloaders = ref([])
// 下载器选中标签页
const downloaderTab = ref('qbittorrent')
// 媒体服务器选中标签页
const mediaserverTab = ref('emby')
// 媒体库设置项
const mediaSettings = ref({
SCRAP_METADATA: true,
DOWNLOAD_PATH: '',
DOWNLOAD_MOVIE_PATH: '',
DOWNLOAD_TV_PATH: '',
DOWNLOAD_ANIME_PATH: '',
DOWNLOAD_CATEGORY: false,
TRANSFER_TYPE: 'copy',
OVERWRITE_MODE: 'size',
LIBRARY_PATH: '',
LIBRARY_MOVIE_NAME: '',
LIBRARY_TV_NAME: '',
LIBRARY_ANIME_NAME: '',
LIBRARY_CATEGORY: false,
})
// 下载器设置项
const downloaderSettings = ref({
DOWNLOADER_MONITOR: true,
TORRENT_TAG: '',
QB_HOST: '',
QB_USER: '',
QB_PASSWORD: '',
QB_CATEGORY: false,
QB_SEQUENTIAL: false,
QB_FORCE_RESUME: false,
TR_HOST: '',
TR_USER: '',
TR_PASSWORD: '',
})
// 媒体服务器设置项
const mediaServerSettings = ref({
MEDIASERVER_SYNC_INTERVAL: 6,
MEDIASERVER_SYNC_BLACKLIST: '',
EMBY_HOST: '',
EMBY_PLAY_HOST: '',
EMBY_API_KEY: '',
JELLYFIN_HOST: '',
JELLYFIN_PLAY_HOST: '',
JELLYFIN_API_KEY: '',
PLEX_HOST: '',
PLEX_PLAY_HOST: '',
PLEX_TOKEN: '',
})
// 下载器字典项
const Downloaders = [
{
title: 'Qbittorrent',
value: 'qbittorrent',
},
{
title: 'Transmission',
value: 'transmission',
},
]
// 媒体服务器字典项
const MediaServers = [
{
title: 'Emby',
value: 'emby',
},
{
title: 'Jellyfin',
value: 'jellyfin',
},
{
title: 'Plex',
value: 'plex',
},
]
// 转移方式字典
const transferTypeItems = [
{ title: '硬链接', value: 'link' },
{ title: '复制', value: 'copy' },
{ title: '移动', value: 'move' },
{ title: '软链接', value: 'softlink' },
{ title: 'rclone复制', value: 'rclone_copy' },
{ title: 'rclone移动', value: 'rclone_move' },
]
// 覆盖模式字典
const overwriteModeItems = [
{ title: '从不覆盖', value: 'never' },
{ title: '按大小覆盖', value: 'size' },
{ title: '总是覆盖', value: 'always' },
{ title: '仅保留最新版本', value: 'latest' },
]
// 媒体库同步周期字典
const syncIntervalItems = [
{ title: '从不', value: 0 },
{ title: '每小时', value: 1 },
{ title: '每6小时', value: 6 },
{ title: '每12小时', value: 12 },
{ title: '每天', value: 24 },
{ title: '每周', value: 168 },
]
// 提示框
const $toast = useToast()
// 加载媒体库设置
async function loadMediaSettings() {
try {
const result: { [key: string]: any } = await api.get('system/env')
if (result.success) {
const {
SCRAP_METADATA,
DOWNLOAD_PATH,
DOWNLOAD_MOVIE_PATH,
DOWNLOAD_TV_PATH,
DOWNLOAD_ANIME_PATH,
DOWNLOAD_CATEGORY,
TRANSFER_TYPE,
OVERWRITE_MODE,
LIBRARY_PATH,
LIBRARY_MOVIE_NAME,
LIBRARY_TV_NAME,
LIBRARY_ANIME_NAME,
LIBRARY_CATEGORY,
} = result.data
mediaSettings.value = {
SCRAP_METADATA,
DOWNLOAD_PATH,
DOWNLOAD_MOVIE_PATH,
DOWNLOAD_TV_PATH,
DOWNLOAD_ANIME_PATH,
DOWNLOAD_CATEGORY,
TRANSFER_TYPE,
OVERWRITE_MODE,
LIBRARY_PATH,
LIBRARY_MOVIE_NAME,
LIBRARY_TV_NAME,
LIBRARY_ANIME_NAME,
LIBRARY_CATEGORY,
}
}
}
catch (error) {
console.log(error)
}
}
// 调用API保存媒体设置
async function saveMediaSetting() {
try {
const result: { [key: string]: any } = await api.post(
'system/env',
mediaSettings.value,
)
if (result.success)
$toast.success('保存媒体库设置成功')
else
$toast.error('保存媒体库设置失败!')
}
catch (error) {
console.log(error)
}
}
// 调用API查询下载器设置
async function loadDownloaderSetting() {
try {
const result1: { [key: string]: any } = await api.get('system/setting/DOWNLOADER')
if (result1.success)
selectedDownloaders.value = result1.data?.value?.split(',')
const result2: { [key: string]: any } = await api.get('system/env')
if (result2.success) {
const {
DOWNLOADER_MONITOR,
TORRENT_TAG,
QB_HOST,
QB_USER,
QB_PASSWORD,
QB_CATEGORY,
QB_SEQUENTIAL,
QB_FORCE_RESUME,
TR_HOST,
TR_USER,
TR_PASSWORD,
} = result2.data
downloaderSettings.value = {
DOWNLOADER_MONITOR,
TORRENT_TAG,
QB_HOST,
QB_USER,
QB_PASSWORD,
QB_CATEGORY,
QB_SEQUENTIAL,
QB_FORCE_RESUME,
TR_HOST,
TR_USER,
TR_PASSWORD,
}
}
}
catch (error) {
console.log(error)
}
}
// 调用API保存下载器设置
async function saveDownloaderSetting() {
try {
const result1: { [key: string]: any } = await api.post(
'system/setting/DOWNLOADER',
selectedDownloaders.value.join(','),
)
const result2: { [key: string]: any } = await api.post(
'system/env',
downloaderSettings.value,
)
if (result1.success && result2.success) {
$toast.success('保存下载器设置成功')
reloadModule()
}
else { $toast.error('保存下载器设置失败!') }
}
catch (error) {
console.log(error)
}
}
// 调用API查询媒体服务器设置
async function loadMediaServerSetting() {
try {
const result1: { [key: string]: any } = await api.get('system/setting/MEDIASERVER')
if (result1.success)
selectedMediaServers.value = result1.data?.value?.split(',')
const result2: { [key: string]: any } = await api.get('system/env')
if (result2.success) {
const {
MEDIASERVER_SYNC_INTERVAL,
MEDIASERVER_SYNC_BLACKLIST,
EMBY_HOST,
EMBY_PLAY_HOST,
EMBY_API_KEY,
JELLYFIN_HOST,
JELLYFIN_PLAY_HOST,
JELLYFIN_API_KEY,
PLEX_HOST,
PLEX_PLAY_HOST,
PLEX_TOKEN,
} = result2.data
mediaServerSettings.value = {
MEDIASERVER_SYNC_INTERVAL,
MEDIASERVER_SYNC_BLACKLIST,
EMBY_HOST,
EMBY_PLAY_HOST,
EMBY_API_KEY,
JELLYFIN_HOST,
JELLYFIN_PLAY_HOST,
JELLYFIN_API_KEY,
PLEX_HOST,
PLEX_PLAY_HOST,
PLEX_TOKEN,
}
}
}
catch (error) {
console.log(error)
}
}
// 调用API保存媒体服务器设置
async function saveMediaServerSetting() {
try {
const result1: { [key: string]: any } = await api.post(
'system/setting/MEDIASERVER',
selectedMediaServers.value.join(','),
)
const result2: { [key: string]: any } = await api.post(
'system/env',
mediaServerSettings.value,
)
if (result1.success && result2.success) {
$toast.success('保存媒体服务器设置成功')
reloadModule()
}
else { $toast.error('保存媒体服务器设置失败!') }
}
catch (error) {
console.log(error)
}
}
// 调用API接口重新加载模块
async function reloadModule() {
try {
const result: { [key: string]: any } = await api.get('system/reload')
if (result.success)
$toast.success('重新加载模块成功')
else
$toast.error('重新加载模块失败!')
}
catch (error) {
console.log(error)
}
}
// 加载数据
onMounted(() => {
loadDownloaderSetting()
loadMediaServerSetting()
loadMediaSettings()
})
</script>
<template>
<VRow>
<VCol cols="12">
<VCard title="下载器">
<VCardSubtitle>只有选中的第1个下载器才会被默认使用</VCardSubtitle>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="selectedDownloaders"
multiple
chips
:items="Downloaders"
label="当前使用下载器"
hint="MoviePilot自动添加的下载任务将使用选中的第1个下载器"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderSettings.TORRENT_TAG"
label="下载器种子标签"
hint="设置种子标签用于区分MoviePilot添加的下载任务默认标签为`MOVIEPILOT`"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderSettings.DOWNLOADER_MONITOR"
label="监控默认下载器"
hint="监控选中的第1个下载器当任务下载完成时自动整理文件到媒体库"
/>
</VCol>
</VRow>
<VRow>
<VCol>
<VTabs
v-model="downloaderTab"
stacked
>
<VTab value="qbittorrent">
Qbittorrent
</VTab>
<VTab value="transmission">
Transmission
</VTab>
</VTabs>
<VWindow
v-model="downloaderTab"
class="mt-5 disable-tab-transition"
:touch="false"
>
<VWindowItem value="qbittorrent">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="downloaderSettings.QB_HOST"
label="地址"
placeholder="IP:PORT"
hint="格式IP:PORT如启用了HTTPS请使用https://IP:PORT"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="downloaderSettings.QB_USER"
label="用户名"
placeholder="admin"
hint="QB的登录用户名"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="downloaderSettings.QB_PASSWORD"
type="password"
label="密码"
hint="QB的登录密码"
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="downloaderSettings.QB_CATEGORY"
label="自动分类管理"
hint="开启后下载目录将由QB控制自动下载到分类到目录此时MoviePilot的下载目录设定无效需在QB中提前创建分类"
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="downloaderSettings.QB_SEQUENTIAL"
label="顺序下载"
hint="开启后QB将按照文件顺序依次下载"
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="downloaderSettings.QB_FORCE_RESUME"
label="强制继续"
hint="开启后QB将设置为强制继续、强制上传模式带[F]标识)"
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="transmission">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="downloaderSettings.TR_HOST"
label="地址"
placeholder="IP:PORT"
hint="格式IP:PORT如启用了HTTPS请使用https://IP:PORT"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="downloaderSettings.TR_USER"
label="用户名"
placeholder="admin"
hint="TR的登录用户名"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="downloaderSettings.TR_PASSWORD"
type="password"
label="密码"
hint="TR的登录密码"
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
</VWindow>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn
mtype="submit"
@click="saveDownloaderSetting"
>
保存
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VCard title="媒体服务器">
<VCardSubtitle>只有选中的媒体服务器才会被默认使用</VCardSubtitle>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="4">
<VSelect
v-model="selectedMediaServers"
multiple
chips
:items="MediaServers"
label="当前使用媒体服务器"
hint="媒体服务器用于搜索下载等判断库中是否已存在,以避免重复下载"
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="mediaServerSettings.MEDIASERVER_SYNC_INTERVAL"
:items="syncIntervalItems"
label="同步周期"
hint="设置后数据将定时同步到MoviePilot数据库以便展示媒体库是否存在标识"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.MEDIASERVER_SYNC_BLACKLIST"
label="媒体库同步黑名单"
placeholder="使用,分隔"
hint="设置不同步数据的媒体库名称,使用,分隔,如:电影,电视剧"
/>
</VCol>
</VRow>
<VRow>
<VCol>
<VTabs
v-model="mediaserverTab"
stacked
>
<VTab value="emby">
Emby
</VTab>
<VTab value="jellyfin">
Jellyfin
</VTab>
<VTab value="plex">
Plex
</vtab>
</VTabs>
<VWindow
v-model="mediaserverTab"
class="mt-5 disable-tab-transition"
:touch="false"
>
<VWindowItem value="emby">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.EMBY_HOST"
label="地址"
placeholder="IP:PORT"
hint="格式IP:PORT 或 http(s)://IP:PORT/"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.EMBY_PLAY_HOST"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="格式http(s)://domain:port设置后跳转Emby时将优先使用此地址"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.EMBY_API_KEY"
label="API密钥"
hint="Emby的API密钥在 Emby设置->高级->API 密钥 中生成"
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="jellyfin">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.JELLYFIN_HOST"
label="地址"
placeholder="IP:PORT"
hint="格式IP:PORT 或 http(s)://IP:PORT/"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.JELLYFIN_PLAY_HOST"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="格式http(s)://domain:port设置后跳转Jellyfin时将优先使用此地址"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.JELLYFIN_API_KEY"
label="API密钥"
hint="Jellyfin的API密钥在 Jellyfin设置->高级->API 密钥 中生成"
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
<VWindowItem value="plex">
<VForm>
<VRow>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.PLEX_HOST"
label="地址"
placeholder="IP:PORT"
hint="格式IP:PORT 或 http(s)://IP:PORT/"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.PLEX_PLAY_HOST"
label="外网播放地址"
placeholder="http(s)://domain:port"
hint="格式http(s)://domain:port设置后跳转Plex时将优先使用此地址"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="mediaServerSettings.PLEX_TOKEN"
label="API密钥"
hint="Plex网页Url中的X-Plex-Token通过浏览器F12->网络从请求URL中获取"
/>
</VCol>
</VRow>
</VForm>
</VWindowItem>
</VWindow>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn
mtype="submit"
@click="saveMediaServerSetting"
>
保存
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VCard title="媒体库">
<VCardSubtitle>设置下载目录媒体库目录以及整理方式</VCardSubtitle>
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.DOWNLOAD_PATH"
label="下载目录"
:rules="[requiredValidator]"
hint="MoviePilot添加的下载任务的默认保存目录必须设置"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.DOWNLOAD_MOVIE_PATH"
label="电影下载目录"
hint="为电影设置单独的下载保存目录,不设置则使用下载目录"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.DOWNLOAD_TV_PATH"
label="电视剧下载目录"
hint="为电视剧设置单独的下载保存目录,不设置则使用下载目录"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.DOWNLOAD_ANIME_PATH"
label="动漫下载目录"
hint="为动漫设置单独的下载保存目录,不设置则使用下载目录"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="mediaSettings.DOWNLOAD_CATEGORY"
label="下载目录自动分类"
hint="开启后,下载任务保存目录将根据二级分类策略自动分类存放到下载目录的二级子目录中,二级分类策略需要编辑配置文件目录下的`category.yml`文件,插件市场有提供文件编辑插件"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSelect
v-model="mediaSettings.TRANSFER_TYPE"
:items="transferTypeItems"
label="整理方式"
hint="硬链接需要确保下载目录和媒体库目录不跨盘、不跨共享目录、不分别映射rclone需要手动在容器中完成配置且配置名为`MP`"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="mediaSettings.OVERWRITE_MODE"
:items="overwriteModeItems"
label="覆盖模式"
hint="从不覆盖:不覆盖已存在的文件;按大小覆盖:大文件将覆盖小文件;总是覆盖:总是覆盖已存在的文件;仅保留最新版本:保留最新版本的文件,删除其它版本的文件"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="mediaSettings.SCRAP_METADATA"
label="自动刮削媒体信息"
hint="开启后,整理完成后将自动刮削媒体信息,如海报、简介等"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.LIBRARY_PATH"
label="媒体库目录"
placeholder="多个目录使用,分隔"
:rules="[requiredValidator]"
hint="整理完成后的媒体文件存放的根目录,所有整理场景下未设定目的目录时都将整理到该目录下,必须设置"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.LIBRARY_MOVIE_NAME"
label="电影目录名称"
placeholder="电影"
hint="设置电影的存放一级目录名称,不设置则使用使用`电影`做为目录名称"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.LIBRARY_TV_NAME"
label="电视剧目录名称"
placeholder="电视剧"
hint="设置电视剧的存放一级目录名称,不设置则使用使用`电视剧`做为目录名称"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaSettings.LIBRARY_ANIME_NAME"
label="动漫目录名称"
placeholder="动漫"
hint="设置动漫的存放一级目录名称,不设置则使用使用`动漫`做为目录名称"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="mediaSettings.LIBRARY_CATEGORY"
label="媒体库目录自动分类"
hint="开启后,整理完成后的媒体文件将根据二级分类策略自动分类存放到媒体库一级目录的二级子目录中,二级分类策略需要编辑配置文件目录下的`category.yml`文件,插件市场有提供文件编辑插件"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn
mtype="submit"
@click="saveMediaSetting"
>
保存
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>

View File

@@ -167,6 +167,7 @@ onMounted(() => {
v-model="customIdentifiers"
auto-grow
placeholder="支持正则表达式,特殊字符需要\转义,一行为一组"
hint="支持正则表达式,特殊字符需要\转义,一行为一组"
/>
</VCardItem>
<VCardItem>
@@ -204,6 +205,7 @@ onMounted(() => {
v-model="customReleaseGroups"
auto-grow
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
hint="支持正则表达式,特殊字符需要\转义,一行代表一个制作组/字幕组"
/>
</VCardItem>
<VCardItem>
@@ -224,6 +226,7 @@ onMounted(() => {
v-model="customization"
auto-grow
placeholder="多个匹配对象请换行分隔,支持正则表达式,特殊字符注意转义"
hint="多个匹配对象请换行分隔,支持正则表达式,特殊字符注意转义"
/>
</VCardItem>
<VCardItem>
@@ -244,6 +247,7 @@ onMounted(() => {
v-model="transferExcludeWords"
auto-grow
placeholder="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
hint="支持正则表达式,特殊字符需要\转义,一行代表一个屏蔽词"
/>
</VCardItem>
<VCardItem>

View File

@@ -4,6 +4,7 @@ import type { Site } from '@/api/types'
import SiteCard from '@/components/cards/SiteCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import SiteAddEditForm from '@/components/form/SiteAddEditForm.vue'
import { useDefer } from '@/@core/utils/dom'
// 数据列表
const dataList = ref<Site[]>([])
@@ -14,11 +15,15 @@ const isRefreshed = ref(false)
// 新增站点对话框
const siteAddDialog = ref(false)
// 延迟加载
let defer = (_: number) => true
// 获取站点列表数据
async function fetchData() {
try {
dataList.value = await api.get('site/')
isRefreshed.value = true
defer = useDefer(dataList.value.length)
}
catch (error) {
console.error(error)
@@ -45,13 +50,18 @@ onBeforeMount(fetchData)
v-if="dataList.length > 0"
class="grid gap-3 grid-site-card"
>
<SiteCard
v-for="data in dataList"
:key="data.id"
:site="data"
@remove="fetchData"
@update="fetchData"
/>
<div
v-for="(data, index) in dataList"
:key="index"
>
<SiteCard
v-if="defer(index)"
:key="data.id"
:site="data"
@remove="fetchData"
@update="fetchData"
/>
</div>
</div>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
@@ -60,14 +70,18 @@ onBeforeMount(fetchData)
error-description="已添加并支持的站点将会在这里显示"
/>
<!-- 新增站点按钮 -->
<VBtn
<VFab
icon="mdi-plus"
location="bottom end"
size="x-large"
class="fixed right-5 bottom-5"
oper="add"
fixed
app
appear
@click="siteAddDialog = true"
/>
<!-- 新增站点弹窗 -->
<SiteAddEditForm
v-if="siteAddDialog"
v-model="siteAddDialog"
oper="add"
@save="siteAddDialog = false; fetchData()"

View File

@@ -20,6 +20,7 @@ const calendarOptions: Ref<CalendarOptions> = ref({
],
initialView: 'dayGridMonth',
weekends: true,
firstDay: 1,
headerToolbar: {
left: 'prev',
center: 'title',
@@ -197,6 +198,11 @@ onMounted(() => {
--fc-event-border-color: currentcolor;
}
// 当天背景渐变
.fc-day-today {
background-image: linear-gradient(to bottom, #AF85FD ,rgba(var(--v-theme-on-surface), 0.04));
}
.v-application .fc a {
color: inherit;
}

View File

@@ -4,6 +4,7 @@ import api from '@/api'
import type { Subscribe } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
import SubscribeEditForm from '@/components/form/SubscribeEditForm.vue'
import store from '@/store'
// 输入参数
@@ -11,16 +12,15 @@ const props = defineProps({
type: String,
})
// 从Vuex Store中获取用户信息
const superUser = store.state.auth.superUser
const userName = store.state.auth.userName
// 是否刷新过
const isRefreshed = ref(false)
// 数据列表
const dataList = ref<Subscribe[]>([])
// 弹窗
const subscribeEditDialog = ref(false)
// 获取订阅列表数据
async function fetchData() {
try {
@@ -47,10 +47,13 @@ function onRefresh() {
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
const filteredDataList = computed(() => {
// 从Vuex Store中获取用户信息
const superUser = store.state.auth.superUser
const userName = store.state.auth.userName
if (superUser)
return dataList.value.filter(data => data.type === props.type)
else
return dataList.value.filter(data => data.type === props.type && data.username === userName)
return dataList.value.filter(data => data.type === props.type && (data.username === userName))
})
</script>
@@ -89,6 +92,25 @@ const filteredDataList = computed(() => {
error-description="请通过搜索添加电影电视剧订阅"
/>
</PullRefresh>
<!-- 底部操作按钮 -->
<VFab
icon="mdi-file-document-edit"
location="bottom end"
size="x-large"
fixed
app
appear
@click="subscribeEditDialog = true"
/>
<!-- 订阅编辑弹窗 -->
<SubscribeEditForm
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:default="true"
:type="props.type"
@save="subscribeEditDialog = false"
@close="subscribeEditDialog = false"
/>
</template>
<style lang="scss">

View File

@@ -4,11 +4,14 @@ import store from '@/store'
// 日志列表
const logs = ref<string[]>([])
// SSE消息对象
let eventSource: EventSource | null = null
// SSE持续获取日志
function startSSELogging() {
const token = store.state.auth.token
if (token) {
const eventSource = new EventSource(
eventSource = new EventSource(
`${import.meta.env.VITE_API_BASE_URL}system/logging?token=${token}`,
)
@@ -17,10 +20,6 @@ function startSSELogging() {
if (message)
logs.value.push(message)
})
onBeforeUnmount(() => {
eventSource.close()
})
}
}
@@ -34,7 +33,7 @@ function extractLogDetailsFromLogs(logs: string[]): { level: string; time: strin
const matches = RegExp(logPattern).exec(log)
if (matches && matches.length === 5) {
const [_, level, time, program, content] = matches
logDetails.push({ level, time, program, content })
logDetails.unshift({ level, time, program, content })
}
}
@@ -65,6 +64,11 @@ const extractLogDetails = computed(() => {
onMounted(() => {
startSSELogging()
})
onBeforeUnmount(() => {
if (eventSource)
eventSource.close()
})
</script>
<template>

View File

@@ -0,0 +1,156 @@
<script lang="ts" setup>
import store from '@/store'
import type { Message } from '@/api/types'
import MessageCard from '@/components/cards/MessageCard.vue'
import api from '@/api'
// 定义事件
const emit = defineEmits(['scroll'])
// 消息列表
const messages = ref<Message[]>([])
// 当前页数据
const currData = ref<Message[]>([])
// 是否完成加载
const isLoaded = ref(false)
// 是否加载中
const loading = ref(false)
// 当前页码
const page = ref(1)
// 存量消息最新时间
const lastTime = ref('')
// SSE消息对象
let eventSource: EventSource | null = null
// 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}&role=user`,
)
eventSource.addEventListener('message', (event) => {
const message = event.data
if (message) {
const object = JSON.parse(message)
if (compareTime(object.date, lastTime.value) <= 0)
return
messages.value.push(object)
emit('scroll')
}
})
}
}
// 调用API加载存量消息
async function loadMessages({ done }: { done: any }) {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
// 设置加载中
loading.value = true
try {
currData.value = await api.get('message/web', {
params: {
page: page.value,
size: 20,
},
})
if (currData.value.length > 0) {
// 取最后一条时间为存量消息最新时间
lastTime.value = currData.value[currData.value.length - 1].reg_time ?? ''
// 合并数据
messages.value = [...currData.value, ...messages.value]
// 加载完成
done('ok')
if (page.value === 1) {
// 滚动到底部
emit('scroll')
// 监听SSE消息
startSSEMessager()
}
// 页码+1
page.value++
}
else {
done('ok')
// 监听SSE消息
startSSEMessager()
}
loading.value = false
isLoaded.value = true
}
catch (error) {
console.error(error)
}
}
// 比较yyyy-MM-dd HH:mm:ss时间大小
function compareTime(time1: string, time2: string) {
if (!time1)
return -1
if (!time2)
return 1
return new Date(time1).getTime() - new Date(time2).getTime()
}
onBeforeUnmount(() => {
if (eventSource)
eventSource.close()
})
</script>
<template>
<VInfiniteScroll
mode="intersect"
side="start"
:items="messages"
class="overflow-hidden"
@load="loadMessages"
>
<template #loading>
<VProgressCircular
v-if="loading"
indeterminate
size="48"
class="mb-5"
color="primary"
/>
</template>
<div>
<VRow
v-for="(msg, index) in messages"
:key="index"
:class="{
'justify-end': msg.action === 0,
'justify-start': msg.action === 1,
}"
>
<VCol
cols="10"
lg="6"
xl="4"
style="position: relative;"
>
<MessageCard
:message="msg"
/>
</VCol>
</VRow>
</div>
<div
v-if="messages.length === 0 && isLoaded && !loading"
class="w-full text-center flex flex-col items-center"
>
<span class="mb-3">当前没有消息</span>
</div>
</VInfiniteScroll>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import api from '@/api'
// 定义所有的模块ID、名称列表
const modules = ref([
{ id: 'FileTransferModule', name: '媒体目录', state: '', errmsg: '', loading: false },
{ id: 'IndexerModule', name: '站点索引', state: '', errmsg: '', loading: false },
{ id: 'DoubanModule', name: '豆瓣', state: '', errmsg: '', loading: false },
{ id: 'TheMovieDbModule', name: 'TheMovieDb', state: '', errmsg: '', loading: false },
{ id: 'TheTvDbModule', name: 'TheTvDb', state: '', errmsg: '', loading: false },
{ id: 'FanartModule', name: 'Fanart', state: '', errmsg: '', loading: false },
{ id: 'EmbyModule', name: 'Emby', state: '', errmsg: '', loading: false },
{ id: 'JellyfinModule', name: 'Jellyfin', state: '', errmsg: '', loading: false },
{ id: 'PlexModule', name: 'Plex', state: '', errmsg: '', loading: false },
{ id: 'WechatModule', name: '微信', state: '', errmsg: '', loading: false },
{ id: 'TelegramModule', name: 'Telegram', state: '', errmsg: '', loading: false },
{ id: 'SlackModule', name: 'Slack', state: '', errmsg: '', loading: false },
{ id: 'SynologyChatModule', name: 'Synology Chat', state: '', errmsg: '', loading: false },
{ id: 'VoceChatModule', name: 'VoceChat', state: '', errmsg: '', loading: false },
{ id: 'QbittorrentModule', name: 'Qbittorrent', state: '', errmsg: '', loading: false },
{ id: 'TransmissionModule', name: 'Transmission', state: '', errmsg: '', loading: false },
])
// 调用API测试模块
async function moduleTest(index: number) {
try {
const target = modules.value[index]
const moduleid = target.id
target.loading = true
const result: { [key: string]: any } = await api.get(`system/moduletest/${moduleid}`)
target.loading = false
if (result.success) {
target.state = 'success'
target.name = `${target.name} - 正常`
}
else if (result.message?.includes('模块未加载')) {
target.state = ''
target.name = `${target.name} - 未启用`
}
else {
target.state = 'error'
target.name = `${target.name} - 错误!`
target.errmsg = result.message
}
}
catch (error) {
console.error(error)
}
}
// 加载
onMounted(async () => {
// 逐个检查所有模块
for (let i = 0; i < modules.value.length; i++)
await moduleTest(i)
})
</script>
<template>
<VAlert
v-for="(module, index) in modules"
:key="index"
:type="module.state"
:title="module.name"
class="mb-2"
variant="tonal"
>
{{ module.errmsg }}
<template #append>
<VProgressCircular
v-if="module.loading"
indeterminate
/>
</template>
</VAlert>
</template>

View File

@@ -6,6 +6,8 @@ import slack from '@images/logos/slack.png'
import telegram from '@images/logos/telegram.webp'
import tmdb from '@images/logos/tmdb.png'
import wechat from '@images/logos/wechat.png'
import fanart from '@images/logos/fanart.webp'
import tvdb from '@images/logos/thetvdb.jpeg'
interface Status {
OK: string
@@ -57,6 +59,26 @@ const targets = ref<Address[]>([
message: '未测试',
btndisable: false,
},
{
image: tvdb,
name: 'api.thetvdb.com',
url: 'https://api.thetvdb.com/series/81189',
proxy: true,
status: 'Normal',
time: '',
message: '未测试',
btndisable: false,
},
{
image: fanart,
name: 'webservice.fanart.tv',
url: 'https://webservice.fanart.tv',
proxy: true,
status: 'Normal',
time: '',
message: '未测试',
btndisable: false,
},
{
image: telegram,
name: 'api.telegram.org',

View File

@@ -9,6 +9,7 @@ import vuetify from 'vite-plugin-vuetify'
// https://vitejs.dev/config/
export default defineConfig({
base: './',
plugins: [
vue(),
vueJsx(),
@@ -27,7 +28,16 @@ export default defineConfig({
imports: ['vue', 'vue-router', '@vueuse/core', '@vueuse/math', 'vuex'],
vueTemplate: true,
}),
VitePWA({ registerType: 'autoUpdate', injectRegister: 'script', manifest: false }),
VitePWA({
registerType: 'autoUpdate',
injectRegister: 'script',
manifest: false,
workbox: {
navigateFallbackDenylist: [
/.*\/api\/v\d+\/system\/logging.*/,
],
},
}),
],
define: { 'process.env': {} },
resolve: {
@@ -44,6 +54,12 @@ export default defineConfig({
build: {
chunkSizeWarningLimit: 5000,
cssCodeSplit: false,
rollupOptions: {
output: {
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
},
},
},
optimizeDeps: {
exclude: ['vuetify'],

6656
yarn.lock

File diff suppressed because it is too large Load Diff