Compare commits

...

466 Commits
v2.6.4 ... v2

Author SHA1 Message Date
jxxghp
a9403c9c34 chore: bump version to 2.10.11 2026-05-07 08:23:20 +08:00
jxxghp
dc4914e3ca style: adjust downloader card API key field to span full width 2026-05-07 08:22:39 +08:00
jxxghp
f3dbc4afad feat: add qBittorrent API key setup support
Expose qBittorrent WebUI API Key fields in settings and setup so 5.2 users can connect without requiring username/password.

Refs jxxghp/MoviePilot#5724
2026-05-07 07:41:05 +08:00
jxxghp
e3e22aebd9 feat: replace log level chips with VSelect dropdown in LoggingView and adjust layout spacing 2026-05-06 13:04:35 +08:00
jxxghp
0ca2f20b24 refactor: update logging record layout to use block-level elements for better alignment and structure 2026-05-06 08:02:50 +08:00
jxxghp
14279c773d fix: update LoggingView layout to support responsive height for mobile devices 2026-05-05 12:37:30 +08:00
jxxghp
8372f63eb6 refactor: dynamic logging view height calculation and remove redundant LLM model refresh on settings save 2026-05-05 12:34:09 +08:00
jxxghp
b7b62d7922 feat: overhaul logging view with advanced filtering, grouped display, and real-time streaming controls 2026-05-05 11:53:21 +08:00
jxxghp
162cce1f50 feat: replace VSelect with VAutocomplete for LLM provider selection in settings 2026-05-04 20:04:14 +08:00
jxxghp
aa49c6ccbc refactor(llm): merge preset selection into base URL field
Use a single editable Base URL combobox for LLM providers so preset endpoints and manual input share one field, with preset types shown as subtitles.
2026-05-03 11:31:06 +08:00
jxxghp
a40e52079f 更新 package.json 2026-05-03 09:44:37 +08:00
jxxghp
c29e329548 feat(llm): add provider URL presets
Expose provider-specific preset endpoints in the setup and system settings flows so users can start from the correct base URL while still editing it manually.
2026-05-03 09:38:28 +08:00
mcgrady.sun
e2d26f6a25 fix(resource): 解决重新搜索按钮 review 问题
- 简化 refreshSearch:移除多余的 switchToOriginalResults 调用,
  直接置 showingAiResults=false,其余状态由 fetchData 内部重置
- 标题栏 v-if 去掉 !progressActive 条件,避免点击重新搜索时
  整个标题栏 unmount 导致按钮 :loading 不可见、页面跳动
2026-05-02 16:33:19 +08:00
mcgrady.sun
1752256868 feat(resource): 资源搜索结果页增加重新搜索按钮
- 在搜索结果页右侧操作区新增"重新搜索"按钮(mdi-refresh 图标)
- 点击后使用相同搜索参数重新触发请求;正在请求或加载中时按钮禁用
- 若当前展示的是 AI 推荐结果,先切回原始结果再重新搜索,避免状态不一致
- 同步补充 zh-CN / zh-TW / en-US 三份本地化文案
2026-05-02 16:33:19 +08:00
jxxghp
23d7f0dcc1 更新 package.json 2026-04-30 11:55:22 +08:00
jxxghp
288aeed178 chore: update LLM model hint examples in localization files 2026-04-30 11:29:17 +08:00
jxxghp
9a9a618136 refactor: extract LLM provider management logic into composable and add OAuth support for system settings 2026-04-30 09:49:05 +08:00
jxxghp
723eb319e1 feat: add batch AI reorganization support to Transfer History view 2026-04-29 20:37:52 +08:00
jxxghp
96684a8d13 feat: add configuration for AI voice input/output settings and models 2026-04-29 18:15:50 +08:00
jxxghp
fc9fe5e21e chore: bump version to 2.10.8 and adjust UI styling for scraping settings 2026-04-29 17:10:36 +08:00
jxxghp
24b763d808 refactor: comment out redundant refresh call in ShortcutBar message submission 2026-04-28 13:10:37 +08:00
jxxghp
f761cdff00 chore: bump version to 2.10.7 and improve message sending reliability with explicit refresh and type safety 2026-04-28 13:04:05 +08:00
jxxghp
b785769138 feat: add transparent theme support for AI agent settings card and adjust border opacity 2026-04-25 12:30:30 +08:00
jxxghp
6d1febd70a 更新 AccountSettingSystem.vue 2026-04-25 11:41:35 +08:00
jxxghp
bdbaf503ca feat: implement custom Jinja2 syntax highlighting for rename templates and notification templates using VAceEditor 2026-04-25 11:20:15 +08:00
jxxghp
f9e74cf436 style: group AI assistant settings in system page 2026-04-25 10:48:55 +08:00
jxxghp
e043669a10 更新 package.json 2026-04-24 20:25:09 +08:00
jxxghp
78d8fdba9d fix: restore setup agent grid layout 2026-04-24 20:20:36 +08:00
jxxghp
5c0f0386a6 chore: reorder llm thinking setting 2026-04-24 20:17:22 +08:00
jxxghp
30b39283b6 feat: add unified llm thinking level setting 2026-04-24 19:50:23 +08:00
jxxghp
de84c39d2f fix card action hit areas 2026-04-22 18:01:26 +08:00
jxxghp
65152e7e37 fix: 修复媒体服务器卡片关闭按钮被背景图遮挡的问题 2026-04-22 17:07:41 +08:00
jxxghp
ba343ce5fa style: adjust login page layout spacing and formatting for improved visual alignment 2026-04-21 22:20:04 +08:00
jxxghp
60495668a6 Simplify LLM settings connectivity test 2026-04-21 22:14:19 +08:00
jxxghp
f2ac624dbb fix: refine ai assistant settings feedback 2026-04-21 21:25:21 +08:00
jxxghp
6238849d3f refactor: optimize ai assistant settings actions 2026-04-21 21:11:29 +08:00
笨笨
82cb903c1f fix: localize llm test result labels 2026-04-21 20:41:39 +08:00
笨笨
5e5eb95b55 feat: add llm test button 2026-04-21 20:41:39 +08:00
jxxghp
74e6f8b03e bump version to 2.10.3 2026-04-21 08:55:50 +08:00
jxxghp
a2bf0d2b16 refine settings card layouts 2026-04-21 08:50:14 +08:00
jxxghp
7532d39978 style: update plugin repository icon and increase compact FAB icon size 2026-04-19 14:45:35 +08:00
jxxghp
5cc9bf7418 style: reduce compact-fab size and standardize padding across filter menus 2026-04-19 13:35:25 +08:00
jxxghp
20bdb940cd refactor: standardize floating action buttons with a compact stack layout and migrate menu items to key-based i18n resolution 2026-04-19 13:00:04 +08:00
jxxghp
e9b214cff8 refactor: enhance dynamic button system to support menus, reactive properties, and improved PWA floating action button integration 2026-04-19 12:29:02 +08:00
jxxghp
54f5fb2877 更新 login.vue 2026-04-19 07:21:25 +08:00
jxxghp
e86cb9e1cc Merge pull request #461 from InfinityPacer/codex/feat/local-plugin-paths 2026-04-19 07:07:55 +08:00
InfinityPacer
3f258b9016 fix(plugin): resort market list when statistics load 2026-04-19 04:21:00 +08:00
InfinityPacer
b54e144d0e feat(plugin): show local repo paths in repository filter 2026-04-19 04:20:49 +08:00
InfinityPacer
7b20a7b775 refactor(setting): rename local repo paths setting 2026-04-19 03:03:22 +08:00
InfinityPacer
df66b3e917 fix(plugin): local source label and detection 2026-04-19 02:54:09 +08:00
jxxghp
a919622d08 Document nettest target loading flow 2026-04-18 17:52:01 +08:00
jxxghp
2a9ce950b7 Use backend-managed nettest targets 2026-04-18 17:43:38 +08:00
InfinityPacer
48c12b765d feat(setting): expose local plugin paths 2026-04-18 03:11:55 +08:00
InfinityPacer
1120055eed feat(plugin): support local plugin sources 2026-04-18 03:01:16 +08:00
jxxghp
c66b6649e2 feat: enable drag sorting for plugin market repos 2026-04-17 21:01:33 +08:00
jxxghp
8479099926 fix: simplify plugin market repo display 2026-04-17 20:42:11 +08:00
jxxghp
cab65be1c9 feat: update plugin market settings UI layout and refine localization strings 2026-04-17 15:25:36 +08:00
jxxghp
6689e976c2 更新 package.json 2026-04-17 15:12:00 +08:00
jxxghp
712dfa3fe1 feat: improve transfer history footer actions and plugin market settings 2026-04-17 15:02:56 +08:00
jxxghp
346121f3c2 chore: bump version to 2.10.1 2026-04-16 19:51:02 +08:00
jxxghp
61c073ad6c refactor: remove recognize source selection and hardcode to themoviedb in setup wizard 2026-04-16 19:50:37 +08:00
jxxghp
4b3733bc19 feat: add site auth step to setup wizard 2026-04-16 19:21:17 +08:00
jxxghp
b29c6bd83f feat: add AI agent configuration step and expand basic settings with OCR and recognition source options 2026-04-16 17:36:27 +08:00
jxxghp
b40fc4bd30 更新 package.json 2026-04-16 10:42:02 +08:00
jxxghp
a225ba6075 feat: implement responsive filter panel with collapsible search for mobile layout 2026-04-15 17:59:35 +08:00
jxxghp
303fe39c01 更新 package.json 2026-04-15 17:17:09 +08:00
jxxghp
d343cbcf71 Add AI redo progress viewer 2026-04-15 17:10:18 +08:00
jxxghp
0eef8c5174 Optimize site resource dialog layout 2026-04-15 14:18:02 +08:00
jxxghp
46fe257585 feat: add LLM image input support toggle to system settings 2026-04-15 08:46:51 +08:00
jxxghp
f69a57863e feat: add AI-powered reorganization option to transfer history records 2026-04-14 15:39:15 +08:00
jxxghp
8876aadcfa 更新 package.json 2026-04-13 18:42:07 +08:00
jxxghp
485e9691a0 Merge pull request #460 from InfinityPacer/codex/fix/dev-version-check-skip 2026-04-13 06:55:33 +08:00
InfinityPacer
a0e7283ae6 fix(version): skip mismatch toast in dev and harden sw fallback 2026-04-12 23:38:56 +08:00
jxxghp
b44c0647f1 更新 package.json 2026-04-12 10:24:07 +08:00
jxxghp
7e60ab9064 Merge pull request #459 from PKC278/v2 2026-04-12 10:23:49 +08:00
PKC278
f05c1f42b5 fix: 修复推荐和探索页面电视剧已入库标识不显示的问题 2026-04-12 09:52:18 +08:00
jxxghp
672bbb4265 feat:资源渐进式搜索 2026-04-10 16:48:29 +08:00
jxxghp
10c1041b06 Improve resource search loading UI 2026-04-10 16:07:17 +08:00
jxxghp
59c73facfe fix: expand path directories on row click 2026-04-10 15:44:13 +08:00
jxxghp
ba7d4cd392 fix: avoid closing cron menu while selecting options 2026-04-10 15:33:08 +08:00
jxxghp
d76a50c216 fix media detail transparent backdrop 2026-04-10 14:33:38 +08:00
jxxghp
617223777b refactor: 统一过滤图标为 mdi-filter-multiple-outline,插件市场筛选改为下拉多选 2026-04-09 13:03:58 +08:00
jxxghp
6ef047050d refactor: 将订阅和插件过滤弹窗改为站点管理风格的下拉列表菜单 2026-04-09 12:28:30 +08:00
jxxghp
942ecc4c04 Merge pull request #458 from DDSRem-Dev/v2 2026-04-09 08:06:48 +08:00
DDSRem
e72f9a8374 feat(plugin): 侧栏全页 AppPage、多 nav_key 联邦加载与 sidebar_nav 缓存
- 新增路由 plugin-app 与壳页,按 nav_key 尝试 AppPage{Pascal}/AppPage/Page
- DefaultLayout 与 appcenter 合并插件侧栏项;plugin/sidebar_nav 经 Pinia 去重缓存
- 工具 pluginSidebarNav、联邦 loader 与文档/示例更新;登出时清空侧栏缓存

Made-with: Cursor
2026-04-09 07:59:40 +08:00
jxxghp
9cf782eb5b style: remove background color from search bar container 2026-04-08 15:15:16 +08:00
jxxghp
660338688a refactor: adjust TransferHistoryView layout offset and apply code style improvements 2026-04-08 13:47:04 +08:00
jxxghp
2d50bd7536 refactor: optimize useAvailableHeight to improve resize event handling and performance 2026-04-08 13:23:29 +08:00
jxxghp
b02a4f1347 refactor: extract available height calculation logic into a reusable useAvailableHeight composable 2026-04-08 13:08:51 +08:00
jxxghp
1748fdea34 feat: optimize search bar UI with responsive capsule trigger and mobile-friendly dialog footer 2026-04-08 10:09:38 +08:00
jxxghp
6bbaf43671 feat: redesign search dialog UI with a custom input layout and OS-aware keyboard shortcuts 2026-04-08 08:28:42 +08:00
jxxghp
4a66aaadad rollback footer 2026-04-07 13:18:27 +08:00
jxxghp
e2e239f6d9 fix: 消息中心纯文本换行丢失、登录页优化、底部导航液态玻璃效果 2026-04-07 13:10:19 +08:00
jxxghp
fe22403e66 feat: 新增文件整理失败智能接管设置项(AI_AGENT_RETRY_TRANSFER) 2026-04-03 13:39:57 +08:00
jxxghp
3313c71805 feat: 更新日志弹窗支持Markdown渲染 2026-04-03 07:10:12 +08:00
jxxghp
1e60e83514 feat: 新增智能体(Agent)消息类型,优化通知开关加载逻辑自动合并新增类型 2026-04-02 19:11:42 +08:00
jxxghp
9c893abcdf fix: 优化登录页面密码管理器自动填充 2026-03-30 12:56:17 +08:00
jxxghp
ead891ca2f 更新 AccountSettingSystem.vue 2026-03-27 22:22:17 +08:00
jxxghp
8713e3cc86 feat: Add AI agent verbose mode, rename scheduled wake setting to scheduled wake, and update system settings layout. 2026-03-27 20:59:53 +08:00
jxxghp
3cc83d10d3 refactor: Relocate scheduler service settings from the main settings page to a new dedicated system view accessible via the shortcut bar. 2026-03-25 13:37:59 +08:00
jxxghp
192ded374a feat:增加 AI_AGENT_JOB_INTERVAL 设置项 2026-03-25 13:06:44 +08:00
jxxghp
13997c7e74 Merge pull request #457 from wikrin/style/settings-ui 2026-03-20 21:30:54 +08:00
jxxghp
71b0dd4cc2 更新 package.json 2026-03-19 22:25:59 +08:00
Attente
a58a0cdffe refactor(AccountSettingSystem): 重构按钮图标结构样式 2026-03-19 21:47:52 +08:00
jxxghp
6aeb040db4 Merge pull request #456 from wikrin/refactor/scraping-switch-to-policy 2026-03-19 21:35:15 +08:00
Attente
fef20e361e refactor(setting): 更新刮削策略设置界面 2026-03-19 20:13:16 +08:00
jxxghp
a63a07701d 更新 package.json 2026-03-14 18:03:02 +08:00
jxxghp
5dd56f2db3 Merge pull request #455 from EkkoG/wechat_bot 2026-03-14 18:02:18 +08:00
EkkoG
275b095574 feat(wechat): implement AI bot mode configuration and localization updates
- Added functionality to enable AI bot mode for WeChat notifications, including default configuration settings.
- Introduced new input fields for bot-specific settings such as Bot ID, Bot Secret, and WebSocket URL.
- Updated localization files for English, Simplified Chinese, and Traditional Chinese to include new bot-related labels and hints.
2026-03-14 16:18:02 +08:00
jxxghp
05eae71fba Merge pull request #454 from YuF-9468/fix-issue-438-episode-zero 2026-03-13 22:39:25 +08:00
YuF-9468
777b3c9445 refactor(media): remove any cast for episode count fields 2026-03-13 15:31:24 +08:00
YuF-9468
a214168b1e fix(media): avoid false in-library badge for TV seasons with zero episodes 2026-03-13 14:38:58 +08:00
jxxghp
9d55d02557 Merge pull request #453 from DDSRem-Dev/v2 2026-03-11 15:28:30 +08:00
DDSRem
16c084ba80 fix(plugin): build remote entry URL with origin+pathname to fix subpath proxy 404
- Use pathBase (pathname) when building remoteEntry URL so it matches API request base
- Fixes plugin static assets 404 when app is under subpath (e.g. /mp/)

Made-with: Cursor
2026-03-11 15:12:42 +08:00
jxxghp
b0f4ccc186 Merge pull request #451 from WongWang/feat-plugin-priority 2026-03-10 12:55:00 +08:00
jxxghp
96d0606b4d chore: bump version to 2.9.14 2026-03-08 08:52:53 +08:00
jxxghp
450b9ec28a feat: Add QQ logo for qqbot notifications, update Ugreen logo to PNG, and adjust VImg styling in media server card. 2026-03-08 08:52:29 +08:00
jxxghp
2ccf03fc1b Merge pull request #452 from EkkoG/qqbot 2026-03-08 07:51:02 +08:00
EkkoG
38dfb3af07 feat: add QQ notification channel support with validation and localization 2026-03-07 23:21:09 +08:00
Castell
ae4c59bfdb feat: 新增优先使用插件识别的功能 2026-03-02 21:04:43 +08:00
jxxghp
c9f4fdbee8 Merge pull request #450 from baozaodetudou/v2 2026-03-01 08:08:35 +08:00
doumao
d21f461dda Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2026-02-28 22:58:29 +08:00
doumao
28a5a83315 feat: 前端支持绿联SSL证书校验开关配置 2026-02-28 22:54:27 +08:00
jxxghp
11d11b88bf Merge pull request #449 from baozaodetudou/v2 2026-02-28 22:45:12 +08:00
doumao
ff7658b5ba feat: 完成绿联媒体服务前端接入与展示优化 2026-02-28 22:09:09 +08:00
jxxghp
351faf2891 更新 package.json 2026-02-28 12:52:34 +08:00
jxxghp
7d66229bad Merge pull request #448 from wumode/fix-progress-displaying 2026-02-28 12:34:17 +08:00
wumode
2b08be1e7d fix(reorganize): add progress tracking for log transfer 2026-02-28 01:17:18 +08:00
wumode
8255cfd479 fix(reorganize): dynamically update progress SSE connection based on item path 2026-02-27 16:20:04 +08:00
jxxghp
f356bb4407 更新 package.json 2026-02-22 16:11:33 +08:00
jxxghp
07e60291a2 Merge pull request #446 from DDSRem-Dev/rtorrent 2026-02-22 16:11:12 +08:00
DDSRem
2dbe8e6685 feat(downloader): add rTorrent UI support
Add rTorrent as a downloader option in settings, setup wizard, and
downloader card with config form (host, username, password) and
ruTorrent logo. Include i18n translations for zh-CN, zh-TW, and en-US.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:12:56 +08:00
jxxghp
40f36b2afd Merge pull request #445 from cddjr/feat/media_card_list_deduplication 2026-02-17 07:15:17 +08:00
景大侠
d4260d5103 fix(types): 移除由提交 1dab013 引入的 episode_group 重复定义 2026-02-13 22:10:28 +08:00
景大侠
45f68bc936 feat(MediaCardListView): 添加去重逻辑以防止重复项 2026-02-13 19:08:20 +08:00
jxxghp
9469074837 v2.9.11 2026-02-12 07:01:38 +08:00
jxxghp
193807bb6f Merge pull request #444 from cddjr/fix_s0 2026-02-06 12:48:57 +08:00
景大侠
d4548db5b9 fix: 完善几处S00订阅相关的问题 2026-02-06 12:48:32 +08:00
jxxghp
29aaea6fe6 更新 package.json 2026-02-06 12:22:59 +08:00
jxxghp
369cc6438f Merge pull request #443 from jxxghp/copilot/fix-season-null-value 2026-02-06 12:22:30 +08:00
copilot-swe-agent[bot]
d80b39c77b Add package-lock.json to .gitignore and remove it from repo
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2026-02-06 04:07:50 +00:00
copilot-swe-agent[bot]
626725a8ca Fix: pass null instead of 0 for season parameter when subscribing to movies
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2026-02-06 04:07:13 +00:00
copilot-swe-agent[bot]
8be96358ae Initial plan 2026-02-06 04:01:58 +00:00
jxxghp
f2bfbfa3c5 更新 package.json 2026-02-02 12:36:27 +08:00
jxxghp
7c9ffd6abc Merge pull request #442 from CHANTXU64/v2 2026-02-02 12:36:11 +08:00
CHANTXU64
b370354287 fix: TMDB 剧集详情页支持显示第 0 季(特别篇)并将其排序到末尾 (jxxghp/MoviePilot#5444) 2026-02-02 10:20:40 +08:00
jxxghp
145d71e283 Merge pull request #441 from cddjr/feat_group_select 2026-01-31 13:28:40 +08:00
景大侠
eeea82d815 feat(TransferHistory): 增加分组选择功能 2026-01-31 11:29:03 +08:00
jxxghp
babd267bc4 更新 package.json 2026-01-29 22:22:42 +08:00
jxxghp
e136c931ac Merge pull request #440 from DDSRem-Dev/v2 2026-01-29 22:22:03 +08:00
DDSRem
ae00602345 feat: u115 support oauth 2026-01-29 21:49:17 +08:00
jxxghp
5382108ee7 Merge pull request #439 from DemoJameson/fix-nickname-2 2026-01-29 11:58:39 +08:00
DemoJameson
514063d3fb fix: 删除昵称后保存无效 2026-01-29 11:31:51 +08:00
jxxghp
b08f396fec Merge pull request #437 from DemoJameson/fix-nickname 2026-01-26 18:42:10 +08:00
DemoJameson
d37a7f06f1 fix: 个人信息页面不显示昵称 2026-01-26 13:34:22 +08:00
jxxghp
ad7bca3aae feat: 更新了分类配置的API端点,并为对话框添加了小屏幕全屏显示功能。 2026-01-26 12:52:46 +08:00
jxxghp
4fb70ba80e 更新 package.json 2026-01-25 15:57:11 +08:00
jxxghp
1225b2eb9e Merge pull request #435 from z-henry/v2
修复:按照指定剧集组订阅,日历读取不到对应的日期
2026-01-25 15:50:01 +08:00
jxxghp
24b2f103b9 Merge branch 'v2' into v2 2026-01-25 15:49:15 +08:00
jxxghp
0d304b58ca feat:二级分类设置界面 2026-01-25 09:40:50 +08:00
HenryZZZZZ
f419dbd794 Merge branch 'jxxghp:v2' into v2 2026-01-24 16:04:45 +08:00
jxxghp
7854cc81a8 fix message ui 2026-01-24 11:52:49 +08:00
jxxghp
9ad1bd29bd fix markdown ui 2026-01-23 22:46:25 +08:00
jxxghp
b88d4f0ecb feat:LLM上下文窗口设置 2026-01-23 22:35:03 +08:00
HenryZZZZZ
44168b62d2 Merge pull request #1 from z-henry/codex/update-api-call-for-episode-group
Add episode_group query param for TMDB season episode requests
2026-01-23 11:09:50 +08:00
HenryZZZZZ
1dab013436 Add episode_group param to TMDB episode requests 2026-01-23 11:08:55 +08:00
jxxghp
64a4a7aff5 feat: Add file transfer threads setting with UI and localization. 2026-01-21 20:25:54 +08:00
jxxghp
e43b545c89 更新 package.json 2026-01-20 21:21:42 +08:00
jxxghp
69fcde250e Merge pull request #434 from PKC278/v2 2026-01-20 21:21:24 +08:00
PKC278
63d6290166 fix(otp): 修正 OTP 关闭逻辑 2026-01-20 19:54:27 +08:00
PKC278
c1d759f3f3 fix(passkey): 加强无OTP注册PassKey的逻辑判断 2026-01-20 19:37:58 +08:00
PKC278
3a782bc69c fix(locales): 以zh-CN为基准,补充其他语言缺失字段 2026-01-20 18:33:08 +08:00
PKC278
bea752879c feat(passkey): 添加环境变量配置项,允许注册passkey时跳过验证是否已注册otp 2026-01-20 00:34:33 +08:00
jxxghp
a48fcb3819 Merge pull request #433 from PKC278/v2 2026-01-17 19:03:18 +08:00
PKC278
68a07bc952 fix(layout): 移除筛选栏sticky定位 2026-01-17 18:58:15 +08:00
jxxghp
828dba09b0 Merge pull request #432 from PKC278/v2 2026-01-17 07:49:46 +08:00
PKC278
0d2189e9e8 fix(layout): 修复Chrome 144+无法滚动问题 2026-01-17 02:13:29 +08:00
jxxghp
f0f0ab81e4 Merge pull request #431 from PKC278/v2 2026-01-16 23:28:52 +08:00
PKC278
64b5fa7038 fix(layout): 修复Chrome 144+滚动锁定问题,调整overflow属性 2026-01-16 22:45:43 +08:00
jxxghp
1d04c9b9c9 Merge pull request #430 from cddjr/fix_5364 2026-01-15 21:44:41 +08:00
景大侠
dee719ac25 修复 媒体整理按标题、大小无法正确排序的问题 2026-01-15 21:31:07 +08:00
jxxghp
ea676876f1 Merge pull request #429 from PKC278/v2 2026-01-15 16:47:26 +08:00
PKC278
c1a4d5d81e fix(resource): 修正Safari渲染抖动问题 2026-01-15 15:47:43 +08:00
jxxghp
95d88804e4 Merge pull request #428 from PKC278/v2 2026-01-15 11:04:10 +08:00
PKC278
1fa072790f fix: 修正部分样式 2026-01-15 10:57:26 +08:00
jxxghp
fe19c1183c Merge pull request #427 from PKC278/v2 2026-01-15 07:07:10 +08:00
PKC278
be40f55bd9 feat(search): 添加AI推荐功能并优化相关逻辑 2026-01-15 03:04:58 +08:00
PKC278
30a10eaf6d fix(passkey): 修复PassKey注册时的错误提示逻辑 2026-01-14 10:25:55 +08:00
jxxghp
3bc0c86df4 Merge pull request #426 from PKC278/v2 2026-01-12 11:28:41 +08:00
jxxghp
03c8726e6e Merge pull request #425 from HankunYu/v2 2026-01-12 11:28:13 +08:00
PKC278
de47491ded fix(login): 修复PassKey认证错误信息提示逻辑 2026-01-12 10:14:08 +08:00
HankunYu
c691cdaa0e Merge branch 'jxxghp:v2' into v2 2026-01-12 00:52:28 +00:00
HankunYu
53efdc2802 用户设置页面新增discord id设置 2026-01-12 00:48:24 +00:00
jxxghp
9644076463 更新 package.json 2026-01-12 06:59:30 +08:00
jxxghp
cb4e88f8aa Merge pull request #424 from PKC278/v2 2026-01-12 06:59:04 +08:00
PKC278
adc16fc58d fix(locales): 改进通行密钥描述 2026-01-12 00:58:13 +08:00
jxxghp
d6860a3e24 Merge pull request #423 from PKC278/v2 2026-01-11 20:29:31 +08:00
PKC278
7e6116de45 feat: 优化通行密钥错误提示与代码结构
- feat(login): 优化通行密钥(Passkey)登录逻辑,支持 Conditional UI 自动填充,并改进错误提示。
- feat(userProfile): 优化双重验证弹窗样式。
- feat(qrcode): 优化二维码生成逻辑与显示。
- feat(passkey): 优化通行密钥错误提示,添加最后使用时间显示。
2026-01-11 20:02:34 +08:00
jxxghp
1688a2ca25 feat:消息中心Markdown渲染 2026-01-10 10:16:13 +08:00
jxxghp
fe57acfce0 Merge pull request #422 from PKC278/v2 2026-01-09 16:15:12 +08:00
PKC278
1ae49b28b1 fix(login): 移除密码输入框 autocomplete 中冗余的 webauthn 选项 2026-01-09 12:31:37 +08:00
jxxghp
ef4e9c8b40 更新 package.json 2026-01-09 07:51:44 +08:00
jxxghp
5da0758e89 Merge pull request #421 from PKC278/v2 2026-01-09 07:51:26 +08:00
PKC278
816cab252d feat(login): 添加手动点击Passkey按钮的 AbortController 以防止重复点击 2026-01-08 23:42:52 +08:00
PKC278
843f638835 feat(auth): 添加 Passkey 条件 UI(conditional ui) 支持 2026-01-08 23:05:12 +08:00
PKC278
e4684b2e12 fix(login): 修改浅色主题下PassKey按钮样式,提高文字对比度 2026-01-08 18:51:53 +08:00
PKC278
c17365b6c9 feat(login): 优化表单自动填充 2026-01-08 16:24:09 +08:00
jxxghp
01835c0ac5 Merge pull request #420 from PKC278/v2 2026-01-06 15:19:24 +08:00
PKC278
e5749bd6ef address review comments for useVersionChecker
- Simplify props passing for VersionUpdateToast
- Remove redundant removeEventListener call
2026-01-06 15:07:20 +08:00
PKC278
689e58737b feat(service-worker): 兼容旧版前端监听逻辑 2026-01-06 14:10:57 +08:00
PKC278
38da061cf1 refactor(useVersionChecker): 优化版本检查逻辑和通知机制
feat(locales): 更新多语言版本信息
style(main.scss): 移除版本更新通知样式
2026-01-06 12:00:11 +08:00
jxxghp
e79940e52e 更新 package.json 2026-01-04 09:56:31 +08:00
jxxghp
88dd6068b6 Merge pull request #419 from PKC278/v2 2026-01-04 07:04:32 +08:00
PKC278
7dd10f9c96 fix(VersionUpdateToast): 优化消息样式和移动端适配 2026-01-03 23:28:29 +08:00
PKC278
94aaf83107 fix(index): 移除多余判断 2026-01-03 22:36:25 +08:00
PKC278
e84fc5f424 Update src/service-worker.ts
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-03 22:32:52 +08:00
PKC278
f342b08179 fix(service-worker): 优化缓存版本控制和监控缓存大小逻辑 2026-01-03 21:53:04 +08:00
PKC278
0fcad02f3b fix(VersionUpdateToast): 优化通知样式
fix(useVersionChecker): 优化处理逻辑
2026-01-03 20:15:05 +08:00
PKC278
43d2406ee9 feat(timeout): 添加多语言页面加载超时提示 2026-01-03 19:09:37 +08:00
PKC278
78e2d05730 feat(index): 添加页面加载超时提示,修改默认主题设置为跟随系统
fix(service-worker): 优化清理运行时缓存逻辑
2026-01-03 19:08:16 +08:00
PKC278
425bf808ed fix: 修复 i18n-menu 工具函数外部调用导致的运行时错误 2026-01-03 19:03:37 +08:00
PKC278
6d2916dc9f feat(pwa): 重构 Service Worker 及版本更新机制 2026-01-02 20:36:33 +08:00
jxxghp
2281e4224b Merge pull request #418 from PKC278/v2 2026-01-01 12:54:38 +08:00
PKC278
95282f9883 perf: 优化导航栏动画流畅度 2026-01-01 12:29:42 +08:00
jxxghp
b470f182c9 Merge pull request #417 from PKC278/v2 2025-12-31 22:19:27 +08:00
PKC278
0bba1068af revert(Footer): 回滚9284d48 Footer.vue 2025-12-31 22:14:13 +08:00
jxxghp
947a7d8296 更新 package.json 2025-12-30 07:01:48 +08:00
jxxghp
bd36cbf888 Merge pull request #416 from PKC278/v2 2025-12-30 07:01:22 +08:00
PKC278
d8fa47bff7 fix(aboutDialog): 修复关于页面在手机端可左右滑动的问题 2025-12-30 04:18:09 +08:00
PKC278
1132beea5e feat(aboutDialog): 添加清除缓存按钮 2025-12-30 04:01:11 +08:00
PKC278
2e3314e6c3 fix(type): 修复Axios请求类型声明 2025-12-30 03:43:20 +08:00
PKC278
daa8f857f8 fix(ui): 移除UI模式切换后自动刷新页面
fix(locales): 修改自动布局文本为更简洁的“自动”
2025-12-30 03:25:55 +08:00
PKC278
6d14271fe8 feat(aboutDialog): 添加浏览器缓存版本信息展示 2025-12-30 02:55:08 +08:00
PKC278
9284d48f67 fix(logo): 使用外链替换页面内联svg,修复safari浏览器logo显示不全问题
fix(footer): 改善底部导航栏的动画效果
2025-12-30 02:32:13 +08:00
PKC278
c5d1c5a468 fix(type): 修复类型检查错误 2025-12-30 01:47:45 +08:00
PKC278
b98512789f feat(uiMode): 添加UI模式手动切换功能 2025-12-30 01:42:43 +08:00
PKC278
6b8ed8d527 fix(vite): 消除编译警告 2025-12-30 00:54:13 +08:00
PKC278
ec4500dcef refactor(versionChecker): 重构版本检查功能并更新通知样式 2025-12-30 00:00:37 +08:00
jxxghp
288e63ce68 更新 package.json 2025-12-28 17:46:37 +08:00
jxxghp
b3885584bb Merge pull request #415 from PKC278/v2 2025-12-28 17:17:49 +08:00
PKC278
968b24be1e feat(globalSetting): 添加版本检查与通知功能 2025-12-28 16:35:16 +08:00
jxxghp
5a23c1783a 更新 UserProfileView.vue 2025-12-23 23:18:11 +08:00
jxxghp
ddeeb5a7c3 更新 UserProfileView.vue 2025-12-23 23:16:28 +08:00
jxxghp
0b9bbcc7b8 Merge pull request #414 from PKC278/v2 2025-12-23 23:03:10 +08:00
PKC278
022c8b4515 fix(icon): 更新apple-touch-icon
refactor(html): 移除不必要的预加载链接
2025-12-23 22:43:42 +08:00
jxxghp
be04991928 Merge pull request #413 from PKC278/v2 2025-12-23 17:37:30 +08:00
PKC278
34770567a5 fix(mfa): 修复双重验证漏洞 2025-12-23 15:15:41 +08:00
jxxghp
6154fc2157 Merge pull request #412 from PKC278/v2 2025-12-23 14:40:56 +08:00
PKC278
e77dcdd3d4 feat(passkey): 添加PassKey支持并优化双重验证登录逻辑 2025-12-23 13:53:55 +08:00
jxxghp
58a3532c1b 更新 package.json 2025-12-23 12:53:07 +08:00
jxxghp
116a5eeb43 Merge pull request #411 from HankunYu/v2 2025-12-23 12:52:04 +08:00
HankunYu
decd50cb40 更新Discord模块支持互动消息 2025-12-22 20:00:06 +00:00
HankunYu
355563244c 通知渠道增加Discord 2025-12-22 02:11:09 +00:00
jxxghp
51aad628b5 fix path mapping ui 2025-12-10 14:20:59 +08:00
jxxghp
7dd7a2cf34 Merge pull request #409 from stkevintan/download_uri 2025-12-08 18:45:56 +08:00
Kevin Tan
4c0ff7c7f2 Delete vite.config.ts.timestamp-1765185924563-2ee2d81ca5c1a.mjs 2025-12-08 18:27:26 +08:00
stkevintan
8aba3cbe00 fix key index issue 2025-12-08 17:43:49 +08:00
stkevintan
e21c3ec507 update naming 2025-12-08 17:26:26 +08:00
stkevintan
fdbb0b2ca8 fix: recommended 2025-12-08 17:25:25 +08:00
stkevintan
180195ab7d refine the logic of update storage path 2025-12-08 16:21:41 +08:00
stkevintan
8add4e6b46 implement path mapping UI 2025-12-08 14:01:42 +08:00
stkevintan
3d622d2efe add path mapping 2025-12-08 09:15:51 +08:00
jxxghp
bb7ed7b963 Merge pull request #408 from stkevintan/download_uri 2025-12-06 20:05:02 +08:00
stkevintan
d541ea41ad filter out undefined options 2025-12-06 20:02:50 +08:00
stkevintan
7c7ebc9eb7 display storage type on the download path 2025-12-06 19:49:03 +08:00
jxxghp
22275c3b12 更新 package.json 2025-12-06 03:51:19 +08:00
jxxghp
8744a34e8e Merge pull request #407 from stkevintan/file-browser-initial 2025-12-06 03:50:51 +08:00
stkevintan
e98836fd0e simplify sort on FileNavigator 2025-12-06 00:00:29 +08:00
stkevintan
feb62196a2 keep sort in sync 2025-12-05 23:50:37 +08:00
stkevintan
9fd29a2958 enhance the file browser 2025-12-05 23:42:24 +08:00
jxxghp
546c82ca40 更新 package.json 2025-11-25 15:54:10 +08:00
jxxghp
f132dc38f4 Merge pull request #404 from wikrin/heartbeat 2025-11-25 15:53:04 +08:00
jxxghp
58c70b8ca6 chore: bump version to 2.8.6 in package.json and add global AI assistant settings in locales and AccountSettingSystem.vue 2025-11-23 13:50:35 +08:00
Attente
147f55eefe feat(App): 添加心跳机制通过后端刷新资源访问令牌 2025-11-23 13:40:34 +08:00
jxxghp
229b7b0c12 chore: bump version to 2.8.5 in package.json 2025-11-20 19:37:58 +08:00
jxxghp
4b7b5ff8a4 fix #397 2025-11-20 19:37:33 +08:00
jxxghp
4906bde746 chore: bump version to 2.8.4 in package.json and refactor AccountSettingSystem.vue to streamline AI agent settings 2025-11-20 19:25:28 +08:00
jxxghp
a87a1a8988 Merge pull request #403 from madrays/v2 2025-11-20 19:11:51 +08:00
madrays
e05f45e681 增加自动拉取可用ai模型的易用性功能 2025-11-20 19:01:25 +08:00
jxxghp
b4acacea81 chore: bump version to 2.8.3 in package.json 2025-11-18 12:49:06 +08:00
jxxghp
fa9645b05b Merge pull request #402 from cddjr/trimemedia 2025-11-17 14:21:39 +08:00
景大侠
1ed4052814 fix #401 2025-11-17 14:08:51 +08:00
jxxghp
7dc814461f 更新 package.json 2025-11-16 06:31:32 +08:00
jxxghp
9154ec0e8c Merge pull request #400 from wikrin/cursor-move 2025-11-16 06:30:57 +08:00
jxxghp
3a2ea60583 Merge pull request #399 from wikrin/release_dates 2025-11-16 06:30:31 +08:00
Attente
b36bff3a1e feat(dashboard): 移除 Vue 渲染模式下的固定拖拽图标
更新`docs/module-federation-guide.md` 文档,使用 `v-hover` 实现仅在鼠标悬停时显示拖拽图标。
2025-11-15 18:03:29 +08:00
Attente
b3d8cbf280 feat: 为媒体信息添加数字/实体发行日期支持 2025-11-13 23:52:54 +08:00
jxxghp
38fb02d112 Merge pull request #398 from cddjr/trimemedia 2025-11-05 23:16:28 +08:00
景大侠
2597f893cd rename 2025-11-05 15:26:34 +08:00
景大侠
ebdd036654 避免飞牛媒体库的图片地址携带敏感数据 2025-11-05 15:15:36 +08:00
景大侠
5032f0e6a9 fix 飞牛影视无法显示图片
图片接口增加Cookies参数
2025-11-04 11:32:41 +08:00
jxxghp
ad963d718d refactor: Remove unused AI agent subheader from account settings 2025-11-01 10:39:25 +08:00
jxxghp
69d314bce3 feat: Add AI agent settings and localization support for LLM configuration 2025-10-31 11:46:45 +08:00
jxxghp
4a7425a947 feat: Add download count formatting function and update card components to use it 2025-10-18 20:13:41 +08:00
jxxghp
c172ac0d5c Merge pull request #395 from jxxghp/cursor/add-default-all-filter-for-subscription-styles-ad8f 2025-09-16 13:38:33 +08:00
Cursor Agent
01a66493a8 feat: Add "All" option to genre filter
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 05:37:47 +00:00
jxxghp
188f8b3faa 更新缓存版本至v13 2025-09-16 13:14:17 +08:00
jxxghp
ebcf5fad71 Merge pull request #394 from jxxghp/cursor/update-subscription-sorting-and-scoring-6aa9 2025-09-16 12:26:44 +08:00
Cursor Agent
d1a656db82 Refactor: Move sort filter to top in subscribe views
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 04:25:38 +00:00
Cursor Agent
4f6a11fd7c Refactor subscribe views to use VChipGroup for sorting
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 04:25:02 +00:00
jxxghp
1d09a946bb Merge pull request #393 from jxxghp/cursor/add-sorting-to-subscription-filters-b700 2025-09-16 12:02:21 +08:00
Cursor Agent
6c4eb7edbd Add sorting options to subscribe views and locales
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 03:46:35 +00:00
jxxghp
4f9f669ac6 Merge pull request #392 from jxxghp/cursor/translate-missing-string-and-adjust-slider-max-value-4d93 2025-09-16 11:12:31 +08:00
Cursor Agent
f9e0e78473 Refactor: Remove rating input, display max rating
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 03:10:14 +00:00
Cursor Agent
b004facfca Refactor: Improve rating filter UI and update locale text
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 02:36:16 +00:00
jxxghp
fb6ee2910f 更新 package.json 2025-09-16 09:00:54 +08:00
jxxghp
3fedc9b730 Merge pull request #391 from jxxghp/cursor/update-popular-subscriptions-api-with-filters-9c20 2025-09-16 08:47:41 +08:00
Cursor Agent
b260427312 feat: Add filtering and genre selection to subscribe share
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 00:46:08 +00:00
Cursor Agent
dd1447e93c feat: Add minSubscribers translation to zh-TW locale
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 00:20:27 +00:00
Cursor Agent
dbcc213562 feat: Add subscribe filtering and localization
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-16 00:17:40 +00:00
jxxghp
1c019cd5c8 重构离线页面组件 2025-09-13 14:00:03 +08:00
jxxghp
e37bde77a1 fix https://github.com/jxxghp/MoviePilot/issues/4922 2025-09-13 10:18:41 +08:00
jxxghp
57bf0d2021 优化快捷访问组件的滚动管理 2025-09-12 20:57:29 +08:00
jxxghp
88b00f7069 更新viewport设置 2025-09-12 08:25:21 +08:00
jxxghp
7b08cbb2f7 优化进度对话框 2025-09-11 20:33:14 +08:00
jxxghp
97c0ec184d Fix: Center cache statistics on mobile (#389)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-11 18:15:38 +08:00
jxxghp
d18c845088 Refactor cache view for better mobile responsiveness (#388)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-11 18:05:23 +08:00
jxxghp
a64d97774d 优化关于对话框和快捷栏的布局 2025-09-11 17:36:02 +08:00
jxxghp
2ddc51aa4f 调整词表、缓存、关于功能的位置 2025-09-11 15:29:24 +08:00
jxxghp
28afe2a922 统一图标导入方式 2025-09-11 15:03:12 +08:00
jxxghp
c2e97bf191 调整 Vite 配置,增加最大缓存文件大小至 10MB,以支持更大的文件。 2025-09-11 14:40:34 +08:00
jxxghp
c922752a1f Merge pull request #387 from jxxghp/setup-wizard
Setup wizard
2025-09-11 14:32:30 +08:00
jxxghp
08f36a74ca 增强配置向导功能 2025-09-11 14:30:52 +08:00
jxxghp
d7809dd00c 调整配置向导的布局,增加右侧按钮组 2025-09-11 12:36:12 +08:00
jxxghp
27582004da 增强配置向导功能 2025-09-11 08:31:13 +08:00
jxxghp
3d6a176cde 提升配置向导的样式,增加z-index和阴影效果 2025-09-10 17:10:01 +08:00
jxxghp
4a2073a038 优化配置向导 2025-09-10 16:56:06 +08:00
jxxghp
c8a65ecbe4 修复配置向导中的用户信息保存逻辑 2025-09-10 15:23:48 +08:00
jxxghp
3750d5cba0 增强配置向导功能 2025-09-10 14:46:02 +08:00
jxxghp
55b383780e Split setup vue into view components (#386)
* Refactor: Extract setup wizard into composable and components

This commit refactors the setup wizard by extracting its logic into a composable function `useSetupWizard` and breaking down the UI into individual components for better organization and reusability.

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

* Refactor: Move setup wizard components to separate files

This commit refactors the setup wizard by extracting individual steps into their own Vue components. This improves code organization and maintainability.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-09-10 11:48:09 +08:00
jxxghp
6aec0ddf88 更新配置向导 2025-09-10 08:51:05 +08:00
jxxghp
7c8e94d1df 更新用户配置 2025-09-10 08:30:19 +08:00
jxxghp
5ecbf626c8 更新用户配置向导 2025-09-09 20:31:00 +08:00
jxxghp
584f580e3b 实现配置向导功能 2025-09-09 13:50:37 +08:00
jxxghp
280de47dac 新增配置向导 2025-09-09 12:43:53 +08:00
jxxghp
c7c05f5897 Merge pull request #385 from cddjr/fix_calendar 2025-09-08 21:39:39 +08:00
景大侠
bb86180582 修复日历可能会空白的问题 2025-09-08 21:33:37 +08:00
jxxghp
aff228edd3 更新 Footer 组件以支持动态显示导航 2025-09-08 19:19:31 +08:00
jxxghp
f65ae6d703 更新 DefaultLayout.vue 2025-09-08 18:38:52 +08:00
jxxghp
0fccc06883 修改 ProgressDialog 组件 2025-09-08 17:38:44 +08:00
jxxghp
8652966645 调整 index.html 和默认布局样式,修改溢出属性以改善页面滚动体验 2025-09-08 16:49:58 +08:00
jxxghp
6d84eb9f09 更新 package.json 版本号至 2.8.0 2025-09-08 16:13:10 +08:00
jxxghp
1a3dccac29 更新 index.html 2025-09-08 16:12:28 +08:00
jxxghp
fa8de34fc5 更新页面样式 2025-09-08 08:49:00 +08:00
jxxghp
10cfd6be80 更新 service-worker.ts 2025-09-02 13:47:57 +08:00
jxxghp
a390b36e7c 更新 package.json 版本号至 2.7.9 2025-09-02 12:35:41 +08:00
jxxghp
d6b5994e22 添加搜索时间间隔选项 2025-09-02 11:48:24 +08:00
jxxghp
08611a97e7 Merge pull request #384 from Aqr-K/feat-v2.7.8-filelist-case-insensitive 2025-08-31 07:51:18 +08:00
Aqr-K
35bbb44ce3 更新 FileList.vue 2025-08-30 21:08:17 +08:00
Aqr-K
8ff879661a fix(file): Simplify the selectMode button. 2025-08-30 20:56:58 +08:00
Aqr-K
a8f01f099d feat(file): Add an ignoreCase button. 2025-08-30 20:54:17 +08:00
jxxghp
040ab1096b 更新 package.json 版本号至 2.7.8 2025-08-28 08:22:51 +08:00
jxxghp
0cbdf24315 fix https://github.com/jxxghp/MoviePilot/issues/4849 2025-08-27 12:32:19 +08:00
jxxghp
164ea79bd1 Use body-lock for quick access background scroll (#382)
* Checkpoint before follow-up message

Co-authored-by: jxxghp <jxxghp@live.cn>

* Replace custom BodyLock with body-scroll-lock library

Co-authored-by: jxxghp <jxxghp@live.cn>

* Fix scroll behavior in QuickAccess panel with targeted scroll disabling

Co-authored-by: jxxghp <jxxghp@live.cn>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: jxxghp <jxxghp@live.cn>
2025-08-25 23:10:15 +08:00
jxxghp
97f3435bb3 Prevent scroll when QuickAccess overlay is open (#381)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: jxxghp <jxxghp@live.cn>
2025-08-25 22:46:28 +08:00
jxxghp
63b108ff6b 更新 package.json 2025-08-25 22:19:02 +08:00
jxxghp
b0880cb369 更新 types.ts 2025-08-25 21:48:55 +08:00
jxxghp
5f70ee8e18 更新缓存版本至 v1.1.0 2025-08-25 13:18:38 +08:00
jxxghp
4c64f7a2c3 优化 HTML 结构,调整 CSS 样式 2025-08-25 13:03:45 +08:00
jxxghp
262927e459 fix:整理中的所以取消 2025-08-24 18:50:07 +08:00
jxxghp
b16c566004 fix ui 2025-08-24 18:32:29 +08:00
jxxghp
1af82dbee6 优化 TransferQueueDialog 组件,合并相同 title_year 的媒体记录和任务 2025-08-24 18:25:59 +08:00
jxxghp
2e9a5a4e13 更新 TransferQueueDialog 组件 2025-08-24 18:18:53 +08:00
jxxghp
b455f603dc 调整 TransferQueueDialog 组件 2025-08-24 18:11:36 +08:00
jxxghp
37c0c3e339 优化 TransferQueueDialog 组件的 SSE 连接管理 2025-08-24 18:02:15 +08:00
jxxghp
b6cb341082 fix 整理进度显示 2025-08-24 17:50:03 +08:00
jxxghp
1af1a06700 优化 SSE 管理器 2025-08-24 17:34:43 +08:00
jxxghp
79e4ecfdbe 引入 crypto-js 库以计算文件路径的 MD5 值 2025-08-24 17:05:36 +08:00
jxxghp
1585271e37 为 TransferQueueDialog 组件优化 SSE 监听管理 2025-08-24 16:16:33 +08:00
jxxghp
c240b171e4 更新 package.json 版本号至 2.7.6 2025-08-24 13:03:25 +08:00
jxxghp
9c405e90ac 重构 TransferQueueDialog 组件,添加整体和当前文件进度管理 2025-08-24 13:02:40 +08:00
jxxghp
3ec3212ca5 更新 service-worker.ts 2025-08-24 08:16:14 +08:00
jxxghp
b1289f6177 实现订阅批量管理功能 2025-08-23 21:20:09 +08:00
jxxghp
64b7ba48c8 fix ios 2025-08-23 20:39:40 +08:00
jxxghp
f093053ea4 优化对话框状态管理 2025-08-23 19:32:23 +08:00
jxxghp
9faa0ded59 为对话框组件添加防止滚动穿透的样式 2025-08-23 19:14:12 +08:00
jxxghp
0f7dafeb23 控制合集搜索项的显示 2025-08-23 19:03:50 +08:00
jxxghp
472d1960d9 重构对话框组件,将所有 DialogWrapper 替换为 VDialog,并更新缓存版本至 v1.1.0 2025-08-23 18:55:34 +08:00
jxxghp
6e50acf106 更新 service-worker.ts 2025-08-23 10:22:31 +08:00
jxxghp
a3fb4b1534 更新 package.json 版本号至 2.7.5 2025-08-23 08:56:14 +08:00
jxxghp
382cae32a2 fix site import dialog 2025-08-23 08:47:16 +08:00
jxxghp
0aa4851f8e Merge pull request #380 from jxxghp/cursor/implement-site-batch-import-and-export-2694 2025-08-23 07:32:40 +08:00
Cursor Agent
65271e6d13 Remove package-lock.json from version control
Co-authored-by: jxxghp <jxxghp@live.cn>
2025-08-22 23:13:47 +00:00
Cursor Agent
671cf8d588 Refactor site import/export feature with improved toast notifications
Co-authored-by: jxxghp <jxxghp@live.cn>
2025-08-22 23:12:01 +00:00
Cursor Agent
afc7c81028 Add site batch import/export functionality with preview and validation
Co-authored-by: jxxghp <jxxghp@live.cn>
2025-08-22 23:09:03 +00:00
jxxghp
c330aee560 增强消息视图的SSE连接管理 2025-08-21 09:22:57 +08:00
jxxghp
eafe63c886 更新 package.json 2025-08-20 10:34:29 +08:00
jxxghp
53206d05b8 更新 service-worker.ts 2025-08-20 10:34:10 +08:00
jxxghp
af085d457e 更新 PluginCardListView.vue 2025-08-20 10:33:52 +08:00
jxxghp
fb36033939 修复数据库类型判断 2025-08-19 13:18:02 +08:00
jxxghp
584e7672df 更新版本号至2.7.3 2025-08-19 13:08:23 +08:00
jxxghp
d4f7a5a1c0 fix https://github.com/jxxghp/MoviePilot/issues/4769 2025-08-17 11:38:17 +08:00
jxxghp
2a9ea81ad4 feat: 优化SSE连接延迟,添加初始化状态提示 2025-08-17 08:39:02 +08:00
jxxghp
276948dd68 feat: 修复成功率计算和统计总览功能 2025-08-12 15:28:58 +08:00
jxxghp
990c5583f2 移除不必要的 TMDB 图片域名选项 2025-08-12 14:27:01 +08:00
jxxghp
644f1b5640 Merge pull request #377 from Sowevo/v2 2025-08-12 06:52:26 +08:00
sowevo
5261fbe870 🚨 0 2025-08-12 05:44:27 +08:00
sowevo
e4f2d85e2b 🚨 多余参数 2025-08-12 05:18:23 +08:00
sowevo
8e3ccdc24a feat: 透明倒影 2025-08-12 05:09:58 +08:00
sowevo
cd6d93affd feat: 透明背景 2025-08-12 04:32:51 +08:00
sowevo
6096ab0c9b feat: 调整间距 2025-08-12 04:31:19 +08:00
sowevo
0a87bb1db1 canvas固定宽和高 2025-08-12 04:14:13 +08:00
jxxghp
a19042c655 在设置中添加浏览器仿真选项 2025-08-11 21:35:20 +08:00
jxxghp
a889687a6a 更新 package.json 2025-08-10 18:16:09 +08:00
jxxghp
e1cdc715aa 更新 GitHub Actions 配置,启用最新版本标记功能 2025-08-06 16:37:09 +08:00
jxxghp
a82b3a0a29 优化消息处理逻辑 2025-08-05 15:47:46 +08:00
jxxghp
d93a71f0be 更新 TorrentRowListView.vue 2025-08-03 11:56:32 +08:00
jxxghp
899dc765bc 更新 TorrentCardListView.vue 2025-08-03 11:55:53 +08:00
jxxghp
449490e52d 更新 SiteStatisticsDialog.vue 2025-08-02 14:53:49 +08:00
jxxghp
5541d7974e 更新 SiteStatisticsDialog.vue 2025-08-02 14:34:59 +08:00
jxxghp
ae3eb36183 添加站点耗时统计信息展示 2025-08-02 14:20:17 +08:00
jxxghp
d57e9a397c 优化样式以支持动态颜色显示。 2025-08-02 11:12:27 +08:00
jxxghp
9d4fd16d81 优化透明主题下的模糊度和透明度设置 2025-07-29 11:49:59 +08:00
jxxghp
3b16e7a123 优化透明主题的模糊度和透明度设置 2025-07-29 09:46:23 +08:00
jxxghp
1c4a2176e9 实现透明主题的透明度和模糊度设置功能 2025-07-29 08:20:16 +08:00
jxxghp
62f9243714 更新 service-worker.ts 2025-07-29 07:05:17 +08:00
jxxghp
03bd23d314 更新文件系统资源检查的相关提示信息 2025-07-26 23:11:29 +08:00
jxxghp
27497d1812 更新 SiteAddEditDialog.vue 2025-07-26 08:34:22 +08:00
jxxghp
f36c1bd2b5 整合主题管理器,优化主题切换逻辑 2025-07-25 13:39:47 +08:00
jxxghp
cf72b2cdb9 更新加载动画的样式和逻辑。 2025-07-23 20:33:02 +08:00
jxxghp
44f6950fea Merge pull request #376 from wumode/fix_recommend
fix: 修复推荐页面外部推荐源URL参数拼接问题
2025-07-23 20:24:32 +08:00
wumode
308ddfedea fix: 修复推荐页面外部推荐源URL参数拼接问题 2025-07-23 20:12:36 +08:00
jxxghp
ac7c330e2f 优化工作流卡片和对话框中的事件类型显示逻辑 2025-07-23 15:33:43 +08:00
jxxghp
1bde3492da 更新 package.json 2025-07-23 12:01:03 +08:00
jxxghp
f884518df3 优化工作流任务卡片的状态显示 2025-07-23 11:52:54 +08:00
jxxghp
1f7f9ce9db 新增工作流触发类型和事件类型支持 2025-07-22 20:58:55 +08:00
jxxghp
58acde2292 优化支持站点的显示逻辑 2025-07-21 12:49:55 +08:00
jxxghp
4e0fe2f449 更新 AccountSettingAbout.vue 2025-07-21 12:38:37 +08:00
jxxghp
536793ab25 新增支持站点折叠功能,并更新相关国际化文本 2025-07-21 11:53:29 +08:00
jxxghp
23a48e07a2 优化订阅列表视图的状态筛选逻辑 2025-07-21 09:57:10 +08:00
jxxghp
1e55557154 优化订阅列表视图的状态筛选逻辑 2025-07-21 09:53:38 +08:00
jxxghp
752231086d 新增订阅功能的状态筛选选项 2025-07-21 09:38:59 +08:00
jxxghp
6f315a408a 移除站点链接的 href 属性 2025-07-20 15:40:58 +08:00
jxxghp
6fa4caa85e fix https://github.com/jxxghp/MoviePilot/issues/4635 2025-07-20 12:34:22 +08:00
jxxghp
1b36c1752f 优化消息弹窗的滚动逻辑 2025-07-20 08:39:33 +08:00
jxxghp
cd58498971 加载消息时按时间排序以确保最新消息在最后 2025-07-20 08:32:10 +08:00
jxxghp
1586137a5d 优化离线状态管理逻辑 2025-07-20 08:25:20 +08:00
jxxghp
6cb8bf74df 在滚动锁定功能中添加事件传播停止,以增强用户体验 2025-07-19 17:45:43 +08:00
jxxghp
787802d0db 优化模块测试视图 2025-07-19 08:55:08 +08:00
jxxghp
b4ad39db12 优化全局滚动锁定功能 2025-07-18 16:39:25 +08:00
jxxghp
c13edbe017 更新 package.json 2025-07-18 11:07:29 +08:00
jxxghp
7546da4f90 新增订阅分享页面及相关搜索功能 2025-07-18 11:05:05 +08:00
jxxghp
76b9a8d9e7 新增支持站点查看功能 2025-07-17 20:46:46 +08:00
jxxghp
d6d52338e9 优化排名展示效果 2025-07-16 12:59:09 +08:00
jxxghp
caa67a0f49 新增订阅分享统计功能 2025-07-16 09:37:34 +08:00
jxxghp
6ddc3ea996 fix #375 2025-07-15 20:25:42 +08:00
jxxghp
7edbf7c724 更新用户资料和账户设置中的链接 2025-07-15 17:31:15 +08:00
jxxghp
4f233ca886 更新 package.json 版本号至 2.6.6 2025-07-15 14:54:49 +08:00
jxxghp
457831536a 移除AccountSettingSite.vue中的USER_AGENT字段 2025-07-14 12:30:54 +08:00
jxxghp
ccef0d87db 更新缓存版本至v1.0.3 2025-07-13 13:52:16 +08:00
jxxghp
584d290283 增强全局滚动锁定功能 2025-07-13 13:46:28 +08:00
jxxghp
2ab14fa33b fix 2025-07-13 13:35:25 +08:00
jxxghp
f0317e1d74 为明亮主题优化Footer组件的背景色透明度 2025-07-13 13:32:05 +08:00
jxxghp
17a206e0f4 更新 DownloadingCard.vue 2025-07-13 11:40:23 +08:00
jxxghp
8ea352cc2f 优化DownloadingCard组件 2025-07-13 11:31:26 +08:00
jxxghp
0f10920898 fix #374 2025-07-13 11:22:27 +08:00
jxxghp
eb098ca775 增强滚动锁定功能 2025-07-13 09:46:38 +08:00
jxxghp
e25caddfef 更新 package.json 2025-07-12 15:15:48 +08:00
jxxghp
c74cf6cf6e 移除构建Plex深度链接时的警告弹窗 2025-07-12 15:13:18 +08:00
jxxghp
ce2d04fa64 更新Plex深度链接构建逻辑 2025-07-12 15:04:36 +08:00
jxxghp
40a4e29c7e 重构深度链接功能 2025-07-12 14:57:03 +08:00
jxxghp
60385715e6 新增媒体服务器深度链接功能 2025-07-12 13:47:00 +08:00
jxxghp
3cce92e83d 优化媒体查询条件,增强响应式样式支持 2025-07-12 13:16:30 +08:00
jxxghp
602b0067d2 Merge pull request #373 from jtcymc/v2 2025-07-12 07:17:57 +08:00
shaw
51d07db99b refactor(dialog): 将日志输出级别从 log 改为 warn
- 在 SubscribeEditDialog.vue 和 SubscribeSeasonDialog.vue 组件中- 当 tmdbid 未设置或为空时,使用 console.warn替代 console.log
- 此修改提高了日志的可见性和严重性级别,以便更好地提醒开发者注意潜在问题
2025-07-12 00:01:55 +08:00
shaw
33d121fd64 fix(dialog): 修复剧集分组查询时 TMDBID 未设置或为空的问题
- 在 SubscribeEditDialog 和 SubscribeSeasonDialog 组件中添加了对 TMDBID 的空值检查
- 如果 TMDBID 未设置或为空,将不会执行剧集分组查询,避免出现错误
2025-07-11 23:57:01 +08:00
211 changed files with 27411 additions and 7228 deletions

View File

@@ -57,7 +57,7 @@ jobs:
name: ${{ env.frontend_version }}
draft: false
prerelease: false
make_latest: false
make_latest: true
files: |
dist.zip
env:

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@ dist
dist-ssr
dev-dist
*.local
package-lock.json
/cypress/videos/
/cypress/screenshots/

1
components.d.ts vendored
View File

@@ -10,7 +10,6 @@ declare module 'vue' {
export interface GlobalComponents {
ConfirmDialog: typeof import('./src/@core/components/ConfirmDialog.vue')['default']
DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default']
DialogWrapper: typeof import('./src/@core/components/DialogWrapper.vue')['default']
ErrorHeader: typeof import('./src/@core/components/ErrorHeader.vue')['default']
ExistIcon: typeof import('./src/@core/components/ExistIcon.vue')['default']
LoadingBanner: typeof import('./src/@core/components/LoadingBanner.vue')['default']

View File

@@ -16,13 +16,17 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
## 3. 核心概念
每个插件需要提供三个标准组件:
每个 Vue 联邦插件需要提供下列标准组件`AppPage` 为可选,用于主界面侧栏全页入口)
| 组件名称 | 文件名 | 用途 |
|---------|-------|------|
| Page | Page.vue | 插件详情页面 |
| Config | Config.vue | 插件配置页面 |
| Dashboard | Dashboard.vue | 仪表组件 |
| 组件名称 | 暴露名 | 文件名 | 用途 |
|---------|--------|--------|------|
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
| Config | `./Config` | Config.vue | 插件配置页面 |
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
主应用在侧栏全页路由中按 `nav_key` 解析暴露名(如 `AppPageSettings`),再回退 `AppPage``Page``nav_key``main` 时仅尝试 `AppPage``Page`
## 4. 快速开始
@@ -56,6 +60,8 @@ export default defineConfig({
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
'./AppPage': './src/components/AppPage.vue',
'./AppPageSettings': './src/components/AppPageSettings.vue',
},
shared: {
vue: {
@@ -245,17 +251,110 @@ const props = defineProps({
<template>
<div class="dashboard-widget">
<!-- 仪表板内容 -->
<v-card>
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
<v-card-text>
<!-- 组件内容 -->
</v-card-text>
</v-card>
<v-hover>
<!-- 仪表板内容 -->
<template #default="{ isHovering, props: hoverProps }">
<v-card v-bind="hoverProps">
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
<v-card-text>
<!-- 组件内容 -->
</v-card-text>
<!-- 只在悬停时显示拖拽图标 -->
<div v-show="isHovering" class="absolute right-5 top-5">
<v-icon class="cursor-move">mdi-drag</v-icon>
</div>
</v-card>
</template>
</v-hover>
</div>
</template>
```
### 5.4 AppPage 组件(侧栏全页)
用于主应用左侧导航中的独立页面(路由 `#/plugin-app/:pluginId/:navKey?`),占据默认布局下的主内容区;与 `Page` 不同,不嵌在插件管理弹窗中。
主应用传入的 props
| 属性 | 说明 |
|------|------|
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
| `pluginId` | 当前插件 ID |
```vue
<script setup lang="ts">
const props = defineProps({
api: { type: Object, default: () => ({}) },
navKey: { type: String, default: 'main' },
pluginId: { type: String, default: '' },
})
const emit = defineEmits(['action'])
</script>
<template>
<div class="pa-4">
<div class="text-h6 mb-2">侧栏全页示例{{ pluginId }} / {{ navKey }}</div>
<v-btn size="small" @click="emit('action')">通知主应用</v-btn>
</div>
</template>
```
#### 后端:注册侧栏入口
插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致:
| 字段 | 说明 |
|------|------|
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
| `title` | 侧栏显示标题 |
| `icon` | MDI 图标名,如 `mdi-rss` |
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
| `permission` | 可选:`subscribe` / `discovery` / `search` / `manage` / `admin`,与主应用菜单权限一致 |
| `order` | 可选:同组内排序,数值越小越靠前 |
```python
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
return [
{
"nav_key": "main",
"title": "示例订阅页",
"icon": "mdi-rss",
"section": "subscribe",
"permission": "subscribe",
"order": 10,
}
]
```
#### 同一插件多个全页界面(多 `nav_key`
`get_sidebar_nav` 中**返回多条**记录,每条使用不同的 `nav_key` / `title` / `section` 等,侧栏与「更多」中会出现多个入口,路由形如 `#/plugin-app/<插件ID>/<nav_key>`
前端加载远程组件的顺序为:
| `nav_key` | 依次尝试的联邦暴露名 |
|-----------|----------------------|
| `main` 或省略 | `./AppPage``./Page` |
| 其它(如 `settings``my_tool` | `./AppPage{PascalCase}``./AppPage``./Page` |
`PascalCase` 规则:按 `-``_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings``my_tool``./AppPageMyTool`
**两种实现方式(二选一或混用):**
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
2. **多文件**:为某个入口单独暴露 `./AppPageSettings.vue` 等,主应用会优先加载对应模块,失败再回退到 `AppPage`
`vite.config` 多暴露示例:
```typescript
exposes: {
'./AppPage': './src/components/AppPage.vue',
'./AppPageSettings': './src/components/AppPageSettings.vue',
// ...
}
```
## 6. 构建和部署
### 构建项目

View File

@@ -1,6 +1,6 @@
# MoviePilot 插件远程组件示例
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例实现了三个标准组件Page详情页面、Config配置页面和Dashboard仪表板组件)。
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例包含 Page、Config、Dashboard、AppPage以及可选的 `AppPageSettings``nav_key=settings` 时由主应用优先加载,用于演示「一插件多全页界面」)。
## 1. 开发环境准备
@@ -28,7 +28,9 @@ plugin-component/
│ ├── components/
│ │ ├── Page.vue # 插件详情页面组件
│ │ ├── Config.vue # 插件配置页面组件
│ │ ── Dashboard.vue # 插件仪表板组件
│ │ ── Dashboard.vue # 插件仪表板组件
│ │ ├── AppPage.vue # 侧栏全页主内容区nav_key=main
│ │ └── AppPageSettings.vue # 可选第二全页nav_key=settings
│ ├── App.vue # 本地开发入口组件
│ └── main.js # 本地开发入口文件
├── vite.config.js # Vite和模块联邦配置

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
/**
* 侧栏全页:在主应用 #/plugin-app/:pluginId/:navKey 中渲染,占据主内容区。
* 需在插件后端实现 get_sidebar_nav 才会出现在侧栏。
*/
const props = defineProps({
api: {
type: Object,
default: () => ({}),
},
navKey: {
type: String,
default: 'main',
},
pluginId: {
type: String,
default: '',
},
})
const emit = defineEmits(['action'])
</script>
<template>
<div class="plugin-app-page pa-4">
<div class="text-h6 mb-2">AppPage侧栏全页</div>
<div class="text-body-2 text-medium-emphasis mb-4">
pluginId: {{ pluginId }} · navKey: {{ navKey }}
</div>
<v-btn size="small" variant="tonal" @click="emit('action')">action</v-btn>
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
/**
* 示例nav_key=settings 时主应用会优先加载 AppPageSettings再回退 AppPage。
*/
const props = defineProps({
api: { type: Object, default: () => ({}) },
navKey: { type: String, default: 'settings' },
pluginId: { type: String, default: '' },
})
</script>
<template>
<div class="pa-4">
<div class="text-subtitle-1">Settings 子界面AppPageSettings</div>
<div class="text-caption text-medium-emphasis">navKey={{ navKey }} · pluginId={{ pluginId }}</div>
</div>
</template>

View File

@@ -12,6 +12,8 @@ export default defineConfig({
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
'./AppPage': './src/components/AppPage.vue',
'./AppPageSettings': './src/components/AppPageSettings.vue',
},
shared: {
vue: {

View File

@@ -1,7 +1,8 @@
<!DOCTYPE html>
<html lang="zh-CN" style="
overflow: hidden auto;
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
min-block-size: 100vh;
min-block-size: 100dvh;
--safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-top: env(safe-area-inset-top);
background: var(--initial-loader-bg, #fff);
@@ -12,7 +13,7 @@
<meta charset="UTF-8" />
<!-- 核心viewport设置 - 针对PWA优化 -->
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no, interactive-widget=resizes-content" />
<!-- 防止缩放和选择,提供原生应用体验 -->
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
@@ -33,7 +34,7 @@
<!-- iOS Safari PWA 优化 -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
<!-- iOS Safari 全屏模式 -->
@@ -90,13 +91,13 @@
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<!-- 预加载关键资源 -->
<link rel="preload" href="/logo.png" as="image" />
<link rel="modulepreload" href="/src/main.ts" />
<!-- 内联关键CSS -->
<style>
/* 关键路径CSS - 从loader.css内联 */
#app {
min-block-size: 100%;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
#loading-bg {
position: fixed;
z-index: 99999;
@@ -114,14 +115,12 @@
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
}
/* 添加logo完成动画 - 放大虚化效果 */
.loading-complete .loading-logo {
filter: blur(10px);
opacity: 0;
transform: scale(1.5);
}
/* 添加加载背景消失动画 - 放大虚化效果 */
.loading-complete {
filter: blur(15px);
opacity: 0;
@@ -140,7 +139,6 @@
transition: opacity 0.6s ease;
}
/* 完成时隐藏加载动画 */
.loading-complete .loading {
opacity: 0;
}
@@ -194,15 +192,66 @@
transform: rotate(1turn);
}
}
/* 超时通知样式 */
#loading-timeout {
position: absolute;
z-index: 2500;
display: none;
inset-block-end: 20px;
inset-inline-start: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 12px 24px;
border-radius: 12px;
font-size: 14px;
font-family: sans-serif;
text-align: center;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
white-space: nowrap;
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
#timeout-btn {
color: var(--initial-loader-color, #9155FD);
text-decoration: none;
font-weight: bold;
margin-inline-start: 8px;
border-bottom: 1px solid var(--initial-loader-color, #9155FD);
}
</style>
<!-- 初始化脚本 -->
<script>
// 检测系统主题是否为深色模式
function checkPrefersColorSchemeIsDark() {
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches
} catch (e) {
return false
}
}
// 主题色彩初始化
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
if (loaderColor) document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
if (primaryColor) document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
let primaryColor = localStorage.getItem('materio-initial-loader-color')
// 检查主题设置
const savedTheme = localStorage.getItem('theme') || 'auto'
const isAutoTheme = savedTheme === 'auto'
// 如果是自动主题或者没有保存的背景色,根据系统主题设置背景色
if (isAutoTheme || !loaderColor) {
loaderColor = checkPrefersColorSchemeIsDark() ? '#0E1116' : '#FFFFFF'
}
if (!primaryColor) {
primaryColor = '#9155FD'
}
// 应用主题色彩
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
// 状态栏适配
if (window.navigator.standalone) {
@@ -212,7 +261,8 @@
// 安全区域适配
function updateSafeArea() {
const safeAreaTop = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)')
const safeAreaBottom = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)')
const safeAreaBottom = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)',
)
if (safeAreaTop) document.documentElement.style.setProperty('--safe-area-top', safeAreaTop)
if (safeAreaBottom) document.documentElement.style.setProperty('--safe-area-bottom', safeAreaBottom)
@@ -221,124 +271,92 @@
updateSafeArea()
window.addEventListener('resize', updateSafeArea)
window.addEventListener('orientationchange', updateSafeArea)
// 清除缓存处理逻辑
window.clearAndReload = async function() {
try {
// 1. 清除所有缓存
if ('caches' in window) {
const cacheNames = await caches.keys()
await Promise.all(cacheNames.map(name => caches.delete(name)))
console.log('[VersionChecker] 已清除所有缓存')
}
// 2. 注销 Service Worker
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map(registration => registration.unregister()))
console.log('[VersionChecker] 已注销所有 Service Worker')
}
} catch (e) {
console.error('[VersionChecker] 清除缓存时出错:', e)
} finally {
// 3. 重载页面
const url = new URL(window.location.href)
url.searchParams.set('_t', Date.now().toString())
window.location.replace(url.pathname + url.search + url.hash)
}
};
setTimeout(function() {
const timeoutEl = document.getElementById('loading-timeout');
if (timeoutEl) {
// 适配多语言
const lang = navigator.language || 'zh-CN';
const messages = {
'zh-CN': {
text: '页面加载似乎遇到了阻碍,请尝试',
btn: '清除缓存'
},
'zh-TW': {
text: '頁面載入似乎遇到了阻礙,請嘗試',
btn: '清除快取'
},
'en-US': {
text: 'Page loading seems to be blocked, please try',
btn: 'Clear Cache'
}
};
// 默认匹配前缀,如 en-GB 匹配 en-US 的逻辑
let msg = messages['zh-CN'];
if (lang.startsWith('zh-TW') || lang.startsWith('zh-HK')) {
msg = messages['zh-TW'];
} else if (lang.startsWith('en')) {
msg = messages['en-US'];
}
const textNode = document.createTextNode(msg.text + ' ');
const btnLink = document.createElement('a');
btnLink.href = 'javascript:void(0)';
btnLink.id = 'timeout-btn';
btnLink.onclick = window.clearAndReload;
btnLink.textContent = msg.btn;
timeoutEl.innerHTML = '';
timeoutEl.appendChild(textNode);
timeoutEl.appendChild(btnLink);
timeoutEl.style.display = 'block';
}
}, 15000); // 15秒后显示超时提示
</script>
</head>
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch;">
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch">
<div id="loading-bg">
<div class="loading-logo">
<!-- Logo -->
<svg width="160px" height="160px" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
<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="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>
</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>
<img src="/logo.svg" alt="MoviePilot" width="160px" height="160px" />
</div>
<div class="loading">
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
</div>
<!-- 超时提示 - 默认隐藏 -->
<div id="loading-timeout"></div>
</div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>

View File

@@ -1,11 +1,12 @@
{
"name": "moviepilot",
"version": "2.6.4",
"version": "2.10.11",
"private": true,
"type": "module",
"bin": "dist/service.js",
"scripts": {
"dev": "vite --host",
"prebuild": "npm run build:icons",
"build": "vite build",
"preview": "vite preview --port 5050",
"typecheck": "vue-tsc --noEmit",
@@ -27,6 +28,7 @@
"@fullcalendar/timegrid": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@iconify/utils": "^2.2.1",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
@@ -40,19 +42,23 @@
"ace-builds": "^1.37.4",
"apexcharts": "^4.0.0",
"axios": "^1.7.9",
"body-scroll-lock": "^3.1.5",
"colorthief": "^2.6.0",
"copy-to-clipboard": "^3.3.3",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"http-proxy-middleware": "^3.0.0",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",
"markdown-it": "^14.1.0",
"markdown-it-link-attributes": "^4.0.1",
"mousetrap": "^1.6.5",
"nprogress": "^0.2.0",
"pinia": "^3.0.1",
"pinia-plugin-persistedstate": "^4.2.0",
"qrcode.vue": "^3.6.0",
"qrcode": "^1.5.4",
"sass": "^1.83.4",
"tailwindcss": "^ 3.4.17",
"vue": "^3.5.13",
@@ -66,16 +72,23 @@
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@iconify-json/line-md": "^1.2.13",
"@iconify-json/lucide": "^1.2.85",
"@iconify-json/material-symbols": "^1.2.51",
"@iconify-json/mdi": "^1.1.52",
"@iconify/tools": "^4.0.4",
"@iconify/vue": "^4.3.0",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@originjs/vite-plugin-federation": "^1.4.1",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@types/body-scroll-lock": "^3.1.2",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2",
"@types/markdown-it-link-attributes": "^3.0.5",
"@types/mousetrap": "^1.6.15",
"@types/node": "^20.1.4",
"@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.6",
"@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
@@ -114,4 +127,4 @@
"workbox-window": "^7.3.0"
},
"packageManager": "yarn@1.22.18"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 102 KiB

53
public/logo.svg Normal file
View File

@@ -0,0 +1,53 @@
<svg width="3em" height="3em" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" 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="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>
</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>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -137,7 +137,7 @@
<div class="status-badge">
<span class="status-dot"></span>
<span>离线模式</span>
<span>离线状态</span>
</div>
</div>
@@ -157,4 +157,4 @@
}
</script>
</body>
</html>
</html>

1
shims.d.ts vendored
View File

@@ -12,3 +12,4 @@ declare module 'vue-prism-component' {
export default component
}
declare module 'vue-shepherd';
declare module 'colorthief';

View File

@@ -59,7 +59,7 @@ function handleCancel() {
</script>
<template>
<DialogWrapper :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" :max-width="width">
<VDialog :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" :max-width="width">
<VCard>
<VCardItem>
<div class="d-flex align-center justify-start mt-3">
@@ -82,5 +82,5 @@ function handleCancel() {
</VCardActions>
<VDialogCloseBtn @click="handleCancel" />
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -15,7 +15,7 @@ function onClick() {
<template>
<IconBtn
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3'"
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'"
@click.stop="onClick"
>
<VIcon icon="mdi-close" />

View File

@@ -1,70 +0,0 @@
<template>
<VDialog v-model="dialogModel" v-bind="$attrs" @update:model-value="handleDialogChange">
<slot />
</VDialog>
</template>
<script setup lang="ts">
import { computed, watch, onBeforeUnmount } from 'vue'
import { useScrollLockWithWatch } from '@/composables/useScrollLock'
// Props
interface Props {
modelValue?: boolean
// 滚动锁定配置
scrollLock?: boolean
preserveScrollPosition?: boolean
preventTouchScroll?: boolean
}
const props = withDefaults(defineProps<Props>(), {
scrollLock: true,
preserveScrollPosition: true,
preventTouchScroll: true,
})
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
// 计算属性
const dialogModel = computed({
get: () => props.modelValue || false,
set: (value: boolean) => emit('update:modelValue', value),
})
// 使用滚动锁定
const { isLocked, lockScroll, restoreScroll } = useScrollLockWithWatch(dialogModel, {
autoRestore: true,
preserveScrollPosition: props.preserveScrollPosition,
preventTouchScroll: props.preventTouchScroll,
})
// 处理弹窗状态变化
const handleDialogChange = (value: boolean) => {
emit('update:modelValue', value)
}
// 监听弹窗状态变化
watch(
dialogModel,
newValue => {
if (props.scrollLock) {
if (newValue) {
lockScroll()
} else {
restoreScroll()
}
}
},
{ immediate: true },
)
// 组件卸载时确保恢复滚动
onBeforeUnmount(() => {
if (isLocked.value) {
restoreScroll()
}
})
</script>

View File

@@ -46,10 +46,9 @@ $header: ".layout-navbar";
}
/* Ensure header styles are preserved when dialog is opened,
regardless of scroll state
but only if window was scrolled before dialog opened
*/
html.v-overlay-scroll-blocked &.window-scrolled.layout-navbar-fixed,
html.dialog-scroll-locked &.layout-navbar-fixed {
html.v-overlay-scroll-blocked &.window-scrolled.layout-navbar-fixed {
#{$header} {
padding-inline: 1rem;

View File

@@ -24,17 +24,16 @@ code {
position: relative;
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
@media (width >= 1280px) {
@media (width >= 1280px) and (hover: hover) {
background: rgba(var(--v-theme-background), 1);
.v-theme--transparent & {
backdrop-filter: blur(5px);
background: rgba(var(--v-theme-background), 0.1) !important;
backdrop-filter: blur(var(--transparent-blur-light, 5px));
background: rgba(var(--v-theme-background), var(--transparent-opacity-light, 0.1)) !important;
}
}
@media (width < 1280px) {
@media (width < 1280px), (hover: none) {
background: transparent;
&::before {
@@ -46,7 +45,7 @@ code {
inset-block-start: 0;
inset-inline: 0;
pointer-events: none;
transition: all 0.3s ease-in-out;
transition: padding 0.3s ease-in-out;
.v-theme--light & {
background: rgba(var(--v-theme-surface), 0.6);
@@ -61,8 +60,9 @@ code {
}
.v-theme--transparent & {
background: rgba(var(--v-theme-background), 0.3);
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
background: rgba(var(--v-theme-background), var(--transparent-opacity-heavy, 0.5));
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
@use "sass:map";
@use "vuetify/lib/styles/settings" as vuetify_settings;
@use "vuetify/lib/styles/settings/_index.sass" as vuetify_settings;
@use "@styles/variables/_vuetify.scss" as vuetify;
@mixin themed($property, $light-value, $dark-value) {

View File

@@ -1,5 +1,7 @@
.auth-wrapper {
min-block-size: calc(var(--vh, 1vh) * 100 + env(safe-area-inset-top) + env(safe-area-inset-bottom));
min-block-size: 100%;
min-block-size: 100vh;
min-block-size: 100dvh;
}
.auth-footer-mask {

View File

@@ -23,6 +23,13 @@ export function kFormatter(num: number) {
: Math.abs(num).toFixed(0).replace(regex, ',')
}
// 格式化下载量显示超过1000显示为x.xk格式
export function formatDownloadCount(num: number): string {
if (!num || num < 1000) return num?.toLocaleString() || '0'
return `${(num / 1000).toFixed(1)}k`
}
/**
* Format and return date in Humanize format
* Intl docs: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/format

View File

@@ -65,3 +65,6 @@ export function getQueryValue(key: string, url = window.location.href): string {
const res = reg.exec(url)
return res ? res[1] : ''
}
// 导出 navigator 相关函数
export { isMobileDevice, isIOSDevice, isAndroidDevice } from './navigator'

View File

@@ -35,6 +35,19 @@ export function urlBase64ToUint8Array(base64String: string) {
return outputArray
}
// Uint8Array 转 Base64URL
export function bufferToBase64Url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
// Base64URL 转 Uint8Array
export function base64UrlToUint8Array(base64Url: string): Uint8Array {
return Uint8Array.from(atob(base64Url.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
}
// 判断是否为PWA
export const isPWA = async (): Promise<boolean> => {
if ('serviceWorker' in navigator) {
@@ -84,3 +97,15 @@ export const isMobileDevice = (): boolean => {
return mobileRegex.test(userAgent) || hasTouchScreen || isMobileSize
}
// 检测是否为iOS设备
export const isIOSDevice = (): boolean => {
const userAgent = navigator.userAgent.toLowerCase()
return /iphone|ipad|ipod/.test(userAgent) && !(window as any).MSStream
}
// 检测是否为Android设备
export const isAndroidDevice = (): boolean => {
const userAgent = navigator.userAgent.toLowerCase()
return /android/.test(userAgent)
}

View File

@@ -92,6 +92,9 @@ const sources: BundleScriptConfig = {
// 'mdi:logout',
// 'octicon:book-24',
// 'octicon:code-square-24',
'lucide:sparkles',
'material-symbols:passkey',
'line-md:loading-twotone-loop',
],
json: [
@@ -154,7 +157,13 @@ const target = join(__dirname, 'icons-bundle.js');
// Sort icons by prefix
const organizedList = organizeIconsList(sources.icons)
for (const prefix in organizedList) {
const filename = require.resolve(`@iconify/json/json/${prefix}.json`)
let filename
try {
filename = require.resolve(`@iconify-json/${prefix}/icons.json`)
}
catch (err) {
filename = require.resolve(`@iconify/json/json/${prefix}.json`)
}
sourcesJSON.push({
filename,

View File

@@ -17,11 +17,34 @@ export default defineComponent({
syncRef(isOverlayNavActive, isLayoutOverlayVisible)
const scrollDistance = ref(window.scrollY)
const isDialogOpen = ref(false)
const wasScrolledBeforeDialog = ref(false)
// 监听弹窗状态变化
const checkDialogState = () => {
const wasDialogOpen = isDialogOpen.value
isDialogOpen.value = document.documentElement.classList.contains('v-overlay-scroll-blocked')
// 当弹窗刚打开时,记录当前的滚动状态
if (!wasDialogOpen && isDialogOpen.value) {
wasScrolledBeforeDialog.value = scrollDistance.value > 0
}
}
onMounted(() => {
window.addEventListener('scroll', () => {
scrollDistance.value = window.scrollY
})
// 初始检查弹窗状态
checkDialogState()
// 监听 DOM 变化以检测弹窗状态
const observer = new MutationObserver(checkDialogState)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
})
return () => {
@@ -88,9 +111,6 @@ export default defineComponent({
},
})
// 检查是否有弹窗打开通过CSS类名判断
const isDialogOpen = document.documentElement.classList.contains('dialog-scroll-locked')
return h(
'div',
{
@@ -99,7 +119,7 @@ export default defineComponent({
'layout-navbar-fixed',
mdAndDown.value && 'layout-overlay-nav',
route.meta.layoutWrapperClasses,
(scrollDistance.value || isDialogOpen) && 'window-scrolled',
(scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)) && 'window-scrolled',
],
},
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
@@ -122,7 +142,7 @@ export default defineComponent({
.layout-wrapper.layout-nav-type-vertical {
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
block-size: 100%;
min-block-size: 100%;
.layout-content-wrapper {
display: flex;
@@ -204,7 +224,9 @@ export default defineComponent({
.layout-page-content {
// display: flex;
overflow: hidden;
// 使用 clip 替代 hidden避免 Chrome 144+ 滚动锁定问题
overflow-x: clip;
overflow-y: auto;
.page-content-container {
inline-size: 100%;

View File

@@ -6,13 +6,15 @@
html {
background: rgb(var(--v-theme-background));
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
overflow-y: overlay;
min-block-size: 100vh;
min-block-size: 100dvh;
}
body {
background: rgb(var(--v-theme-background));
overscroll-behavior-y: contain;
// Chrome 144+ 兼容性:覆盖 Vuetify 的内联 overflow: hidden 样式
overflow: visible !important;
--webkit-overflow-scrolling: touch;
}
@@ -35,13 +37,15 @@ body,
.layout-page-content {
@include mixins.boxed-content(true);
overflow: hidden;
// Chrome 144+ 兼容性:使用 clip 替代 hidden避免滚动锁定问题
// overflow: hidden 在新版 Chrome 中可能意外阻止垂直滚动
overflow: clip;
flex-grow: 1;
// TODO: Use grid gutter variable here;
padding-block: 1.5rem;
padding-inline: 0.5rem;
padding-block-start: calc(env(safe-area-inset-top) + 4.5rem);
padding-inline: 0.5rem;
// display: flex;display

View File

@@ -7,5 +7,7 @@
html {
box-sizing: border-box;
min-height: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom))
min-block-size: 100%;
min-block-size: 100vh;
min-block-size: 100dvh;
}

View File

@@ -1,4 +1,4 @@
import type { ValidationRule } from 'vuetify/types/services/validation'
type ValidationRule = (value: any) => string | boolean
// 必输校验
export const requiredValidator: ValidationRule = (value: any) => {

View File

@@ -11,10 +11,11 @@ import { preloadImage } from './@core/utils/image'
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
import { themeManager } from '@/utils/themeManager'
// 生效主题
const { global: globalTheme } = useTheme()
let themeValue = localStorage.getItem('theme') || 'light'
let themeValue = localStorage.getItem('theme') || 'auto'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
@@ -37,6 +38,9 @@ const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
// 心跳检测
let heartbeatInterval: number | null = null
// ApexCharts 全局配置
declare global {
interface Window {
@@ -44,6 +48,33 @@ declare global {
}
}
// 启动心跳
const startHeartbeat = () => {
// 如果已经有心跳,则先停止
if (heartbeatInterval) {
stopHeartbeat()
}
// 开始心跳任务
heartbeatInterval = window.setInterval(async () => {
try {
if (isLogin.value) {
await api.get('dashboard/cpu')
}
} catch (error) {
console.warn('Heartbeat request failed:', error)
}
}, 5 * 60 * 1000)
}
// 停止心跳
const stopHeartbeat = () => {
if (heartbeatInterval) {
window.clearInterval(heartbeatInterval)
heartbeatInterval = null
}
}
// 配置 ApexCharts 全局选项
function configureApexCharts() {
if (typeof window !== 'undefined' && window.Apex) {
@@ -161,7 +192,11 @@ async function removeLoadingWithStateCheck() {
// 并行加载关键资源
await Promise.all([
globalSettingsStore.initialize().then(() => {
globalSettingsStore.initialize().then(async () => {
// 如果已登录,加载用户相关设置
if (isLogin.value) {
await globalSettingsStore.loadUserSettings()
}
globalLoadingStateManager.setLoadingState('global-settings', false)
}),
new Promise(resolve => {
@@ -206,12 +241,23 @@ async function loadBackgroundImages(retryCount = 0) {
}
onMounted(async () => {
// 移除URL中的时间戳参数
const url = new URL(window.location.href)
if (url.searchParams.has('_t')) {
url.searchParams.delete('_t')
const newUrl = url.pathname + url.search + url.hash
window.history.replaceState(null, '', newUrl)
}
// 配置 ApexCharts
configureApexCharts()
// 初始化data-theme属性
updateHtmlThemeAttribute(globalTheme.name.value)
// 初始化主题管理器 - 统一处理主题初始化
await themeManager.setTheme(themeValue)
// 监听主题变化
watch(
() => globalTheme.name.value,
@@ -230,11 +276,15 @@ onMounted(async () => {
ensureRenderComplete(() => {
nextTick(removeLoadingWithStateCheck)
})
// 启动心跳
startHeartbeat()
})
onUnmounted(() => {
// 清除背景轮换定时器
removeBackgroundTimer('background-rotation')
// 停止心跳
stopHeartbeat()
})
</script>

View File

@@ -44,6 +44,488 @@ import snippertsIniUrl from 'ace-builds/src-noconflict/snippets/ini?url'
import 'ace-builds/src-noconflict/ext-language_tools'
const aceModule = ace as typeof ace & {
define?: (moduleName: string, deps: string[], payload: (...args: any[]) => void) => void
}
function registerJinja2Mode() {
aceModule.define?.(
'ace/mode/jinja2_highlight_rules',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
const Jinja2HighlightRules = function (this: any) {
const tags =
'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'
const filters =
'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'
const functions = 'cycler|dict|joiner|lipsum|namespace|range'
const tests =
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
const operators = 'and|in|is|not|or'
const contextVariables =
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
const keywordMapper = this.createKeywordMapper(
{
'keyword.control.jinja2': tags,
'keyword.operator.jinja2': operators,
'support.function.jinja2': [filters, functions, tests].join('|'),
'constant.language.jinja2': 'false|False|none|None|null|true|True',
},
'identifier',
)
const jinjaExpressionRules = [
{
token: 'string',
regex: "'",
push: 'jinja2-qstring',
},
{
token: 'string',
regex: '"',
push: 'jinja2-qqstring',
},
{
token: 'constant.numeric',
regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b/,
},
{
token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],
regex: `(\\|)(\\s*)(${filters})\\b`,
},
{
token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],
regex: `(\\bis\\b)(\\s*)(${tests})\\b`,
},
{
token: ['support.function.jinja2', 'text', 'paren.lparen'],
regex: `\\b(${functions})(\\s*)(\\()`,
},
{
token: 'variable.language.jinja2',
regex: `\\b(?:${contextVariables})\\b`,
},
{
token: keywordMapper,
regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\b/,
},
{
token: 'keyword.operator.assignment.jinja2',
regex: /=|~/,
},
{
token: 'keyword.operator.comparison.jinja2',
regex: /==|!=|<=|>=|<|>/,
},
{
token: 'keyword.operator.arithmetic.jinja2',
regex: /\+|-|\/\/|\/|%|\*\*|\*/,
},
{
token: 'keyword.operator.other.jinja2',
regex: /\.{2}|\||:/,
},
{
token: 'punctuation.operator.jinja2',
regex: /[.,;?]/,
},
{
token: 'paren.lparen',
regex: /[\[({]/,
},
{
token: 'paren.rparen',
regex: /[\])}]/,
},
{
token: 'text',
regex: /\s+/,
},
]
this.$rules = {
start: [
{
token: 'comment.block.jinja2',
regex: /\{#-?/,
push: 'jinja2-comment',
},
{
token: 'constant.language.jinja2',
regex: /\{\{-?/,
push: 'jinja2-expression',
},
{
token: 'keyword.control.jinja2',
regex: /\{%-?/,
push: 'jinja2-statement',
},
],
'jinja2-comment': [
{
token: 'comment.block.jinja2',
regex: /-?#\}/,
next: 'pop',
},
{
defaultToken: 'comment.block.jinja2',
},
],
'jinja2-expression': [
{
token: 'constant.language.jinja2',
regex: /-?\}\}/,
next: 'pop',
},
...jinjaExpressionRules,
],
'jinja2-statement': [
{
token: 'keyword.control.jinja2',
regex: /-?%\}/,
next: 'pop',
},
...jinjaExpressionRules,
],
'jinja2-qqstring': [
{
token: 'constant.language.escape',
regex: /\\[\\"ntr]/,
},
{
token: 'string',
regex: '"',
next: 'pop',
},
{
defaultToken: 'string',
},
],
'jinja2-qstring': [
{
token: 'constant.language.escape',
regex: /\\[\\'ntr]/,
},
{
token: 'string',
regex: "'",
next: 'pop',
},
{
defaultToken: 'string',
},
],
}
this.normalizeRules()
}
oop.inherits(Jinja2HighlightRules, TextHighlightRules)
exports.Jinja2HighlightRules = Jinja2HighlightRules
},
)
aceModule.define?.(
'ace/mode/jinja2',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextMode = require('./text').Mode
const Jinja2HighlightRules = require('./jinja2_highlight_rules').Jinja2HighlightRules
const Mode = function (this: any) {
TextMode.call(this)
this.HighlightRules = Jinja2HighlightRules
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.$id = 'ace/mode/jinja2'
this.blockComment = { start: '{#', end: '#}' }
}).call(Mode.prototype)
exports.Mode = Mode
},
)
aceModule.define?.('ace/snippets/jinja2', ['require', 'exports', 'module'], (_require: any, exports: any) => {
exports.snippetText =
'snippet if\n\t{% if ${1:condition} %}\n\t\t${0}\n\t{% endif %}\n' +
'snippet for\n\t{% for ${1:item} in ${2:items} %}\n\t\t${0}\n\t{% endfor %}\n' +
'snippet var\n\t{{ ${1:name} }}\n'
exports.scope = 'jinja2'
})
aceModule.define?.(
'ace/mode/jinja2_json_highlight_rules',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
const Jinja2JsonHighlightRules = function (this: any) {
const tags =
'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'
const filters =
'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'
const functions = 'cycler|dict|joiner|lipsum|namespace|range'
const tests =
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
const operators = 'and|in|is|not|or'
const contextVariables =
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
const keywordMapper = this.createKeywordMapper(
{
'keyword.control.jinja2': tags,
'keyword.operator.jinja2': operators,
'support.function.jinja2': [filters, functions, tests].join('|'),
'constant.language.jinja2': 'false|False|none|None|null|true|True',
},
'identifier',
)
const jinjaRules = [
{
token: 'string',
regex: "'",
push: 'jinja2-json-qstring',
},
{
token: 'constant.language.escape',
regex: /\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\\/bfnrt])/,
},
{
token: 'constant.numeric',
regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b/,
},
{
token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],
regex: `(\\|)(\\s*)(${filters})\\b`,
},
{
token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],
regex: `(\\bis\\b)(\\s*)(${tests})\\b`,
},
{
token: ['support.function.jinja2', 'text', 'paren.lparen'],
regex: `\\b(${functions})(\\s*)(\\()`,
},
{
token: 'variable.language.jinja2',
regex: `\\b(?:${contextVariables})\\b`,
},
{
token: keywordMapper,
regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\b/,
},
{
token: 'keyword.operator.assignment.jinja2',
regex: /=|~/,
},
{
token: 'keyword.operator.comparison.jinja2',
regex: /==|!=|<=|>=|<|>/,
},
{
token: 'keyword.operator.arithmetic.jinja2',
regex: /\+|-|\/\/|\/|%|\*\*|\*/,
},
{
token: 'keyword.operator.other.jinja2',
regex: /\.{2}|\||:/,
},
{
token: 'punctuation.operator.jinja2',
regex: /[.,;?]/,
},
{
token: 'paren.lparen',
regex: /[\[({]/,
},
{
token: 'paren.rparen',
regex: /[\])}]/,
},
{
token: 'text',
regex: /\s+/,
},
]
this.$rules = {
start: [
{
token: 'variable',
regex: /"(?:(?:\\.)|(?:[^"\\]))*?"\s*(?=:)/,
},
{
token: 'string',
regex: '"',
push: 'json-string',
},
{
token: 'constant.numeric',
regex: /0[xX][0-9a-fA-F]+\b/,
},
{
token: 'constant.numeric',
regex: /[+-]?\d+(?:(?:\.\d*)?(?:[eE][+-]?\d+)?)?\b/,
},
{
token: 'constant.language.boolean',
regex: /(?:true|false|null)\b/,
},
{
token: 'text',
regex: /['](?:(?:\\.)|(?:[^'\\]))*?[']/,
},
{
token: 'comment',
regex: /\/\/.*$/,
},
{
token: 'comment.start',
regex: /\/\*/,
push: 'comment',
},
{
token: 'paren.lparen',
regex: /[[({]/,
},
{
token: 'paren.rparen',
regex: /[\])}]/,
},
{
token: 'punctuation.operator',
regex: /[:,]/,
},
{
token: 'text',
regex: /\s+/,
},
],
'json-string': [
{
token: 'constant.language.escape',
regex: /\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\\/bfnrt])/,
},
{
token: 'comment.block.jinja2',
regex: /\{#-?/,
push: 'jinja2-json-comment',
},
{
token: 'constant.language.jinja2',
regex: /\{\{-?/,
push: 'jinja2-json-expression',
},
{
token: 'keyword.control.jinja2',
regex: /\{%-?/,
push: 'jinja2-json-statement',
},
{
token: 'string',
regex: /"|$/,
next: 'pop',
},
{
defaultToken: 'string',
},
],
comment: [
{
token: 'comment.end',
regex: /\*\//,
next: 'pop',
},
{
defaultToken: 'comment',
},
],
'jinja2-json-comment': [
{
token: 'comment.block.jinja2',
regex: /-?#\}/,
next: 'pop',
},
{
defaultToken: 'comment.block.jinja2',
},
],
'jinja2-json-expression': [
{
token: 'constant.language.jinja2',
regex: /-?\}\}/,
next: 'pop',
},
...jinjaRules,
],
'jinja2-json-statement': [
{
token: 'keyword.control.jinja2',
regex: /-?%\}/,
next: 'pop',
},
...jinjaRules,
],
'jinja2-json-qstring': [
{
token: 'constant.language.escape',
regex: /\\[\\'ntr]/,
},
{
token: 'string',
regex: "'",
next: 'pop',
},
{
defaultToken: 'string',
},
],
}
this.normalizeRules()
}
oop.inherits(Jinja2JsonHighlightRules, TextHighlightRules)
exports.Jinja2JsonHighlightRules = Jinja2JsonHighlightRules
},
)
aceModule.define?.(
'ace/mode/jinja2_json',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_json_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextMode = require('./text').Mode
const Jinja2JsonHighlightRules = require('./jinja2_json_highlight_rules').Jinja2JsonHighlightRules
const Mode = function (this: any) {
TextMode.call(this)
this.HighlightRules = Jinja2JsonHighlightRules
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.lineCommentStart = '//'
this.blockComment = { start: '/*', end: '*/' }
this.$id = 'ace/mode/jinja2_json'
}).call(Mode.prototype)
exports.Mode = Mode
},
)
}
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
@@ -61,9 +543,10 @@ ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl)
ace.config.setModuleUrl('ace/mode/css_worker', workerCssUrl)
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/yaml', snippetsYamlUrl)
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
registerJinja2Mode()
ace.require('ace/ext/language_tools')

View File

@@ -52,6 +52,10 @@ export const downloaderOptions = [
value: 'transmission',
title: i18n.global.t('setting.system.transmission'),
},
{
value: 'rtorrent',
title: i18n.global.t('setting.system.rtorrent'),
},
]
export const downloaderDict = downloaderOptions.reduce((dict, item) => {
@@ -76,6 +80,10 @@ export const mediaServerOptions = [
value: 'trimemedia',
title: i18n.global.t('setting.system.trimeMedia'),
},
{
value: 'ugreen',
title: i18n.global.t('setting.system.ugreen'),
},
]
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {
@@ -274,6 +282,10 @@ export const notificationSwitchOptions = [
title: i18n.global.t('notificationSwitch.plugin'),
value: '插件',
},
{
title: i18n.global.t('notificationSwitch.agent'),
value: '智能体',
},
{
title: i18n.global.t('notificationSwitch.other'),
value: '其它',

View File

@@ -39,8 +39,9 @@ const globalOfflineStatus = useGlobalOfflineStatus()
// 添加响应拦截器
api.interceptors.response.use(
response => {
// 成功响应时,清除应用离线状态
// 成功响应时,清除应用离线状态并重置连续错误计数
globalOfflineStatus.setAppOffline(false)
globalOfflineStatus.resetConsecutiveErrors()
return response.data
},
error => {
@@ -57,7 +58,8 @@ api.interceptors.response.use(
if (error.code === 'ECONNABORTED') {
reason = 'Request timeout'
}
globalOfflineStatus.setAppOffline(true, reason)
// 记录网络错误,只有连续三次才会设置为离线模式
globalOfflineStatus.recordNetworkError(reason)
}
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {

View File

@@ -164,6 +164,10 @@ export interface WorkflowShare {
description?: string
// 定时器
timer?: string
// 触发类型timer-定时触发 event-事件触发 manual-手动触发
trigger_type?: string
// 事件类型当trigger_type为event时使用
event_type?: string
// 动作列表
actions?: any[]
// 动作流
@@ -310,6 +314,8 @@ export interface MediaInfo {
production_countries?: any[]
// 语种
spoken_languages?: string[]
// 数字/实体发行日期
release_dates?: MediaRelease[]
// 状态
status?: string
// 标签
@@ -364,6 +370,18 @@ export interface TmdbSeason {
vote_average?: number
}
// 发行信息
export interface MediaRelease {
// 发行日期
date: string
// 发行地区
iso_code: string
// 备注
note?: string
// 发行类型
type: number
}
// TMDB集信息
export interface TmdbEpisode {
// 上映日期
@@ -516,7 +534,7 @@ export interface SiteUserData {
// 用户名
username?: string
// 用户ID
userid?: number
userid?: string
// 用户等级
user_level?: string
// 加入时间
@@ -638,6 +656,17 @@ export interface Plugin {
page_open?: boolean
}
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
export interface PluginSidebarNavItem {
plugin_id: string
nav_key: string
title: string
icon: string
section: 'start' | 'discovery' | 'subscribe' | 'organize' | 'system'
permission?: 'subscribe' | 'discovery' | 'search' | 'manage' | 'admin' | null
order: number
}
// 渲染结构
export interface RenderProps {
component: string
@@ -843,6 +872,16 @@ export interface User {
nickname?: string
}
// 通行密钥
export interface PassKey {
id: number
name: string
created_at: string
last_used_at?: string
aaguid?: string
transports?: string
}
// 存储空间
export interface Storage {
// 总空间
@@ -857,8 +896,8 @@ export interface MediaStatistic {
movie_count: number
// 电视剧总数
tv_count: number
// 电视剧总集数
episode_count: number
// 电视剧总集数,未获取时为 null
episode_count: number | null
// 用户数量
user_count: number
}
@@ -986,6 +1025,10 @@ export interface MediaServerPlayItem {
link?: string
// 播放百分比
percent?: number
// 媒体服务器类型
server_type?: string
// 图片是否需要Cookies
use_cookies?: boolean
}
// 媒体服务器媒体库
@@ -1006,6 +1049,10 @@ export interface MediaServerLibrary {
image_list?: string[]
// 链接
link?: string
// 媒体服务器类型
server_type?: string
// 图片是否需要Cookies
use_cookies?: boolean
}
// 消息通知
@@ -1058,6 +1105,8 @@ export interface DownloaderConf {
config: { [key: string]: any }
// 是否启用
enabled: boolean
// 路径映射
path_mapping?: Array<[storagePath: string, downloadPath: string]>
}
// 通知配置
@@ -1096,7 +1145,7 @@ export interface StorageConf {
export interface MediaServerConf {
// 名称
name: string
// 类型 emby/jellyfin/plex
// 类型 emby/jellyfin/plex/trimemedia/ugreen
type: string
// 配置
config: { [key: string]: any }
@@ -1324,6 +1373,10 @@ export interface Workflow {
description?: string
// 定时器
timer?: string
// 触发类型timer-定时触发 event-事件触发 manual-手动触发
trigger_type?: string
// 事件类型当trigger_type为event时使用
event_type?: string
// 状态
state?: string
// 当前执行动作
@@ -1387,3 +1440,35 @@ export interface TorrentCacheData {
// 缓存数据
data: TorrentCacheItem[]
}
// 订阅分享统计
export interface SubscribeShareStatistics {
// 分享人
share_user?: string
// 分享数量
share_count?: number
// 总复用人次
total_reuse_count?: number
}
// 通用API响应
export interface ApiResponse<T = any> {
success: boolean
message?: string
data: T
}
// 分类规则
export interface CategoryRule {
genre_ids?: string
original_language?: string
production_countries?: string
origin_country?: string
release_year?: string
}
// 分类配置
export interface CategoryConfig {
movie?: { [key: string]: CategoryRule }
tv?: { [key: string]: CategoryRule }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -4,6 +4,14 @@ import FileToolbar from './filebrowser/FileToolbar.vue'
import FileNavigator from './filebrowser/FileNavigator.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { storageIconDict } from '@/api/constants'
import type { AxiosInstance } from 'axios'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
// LocalStorage keys
const SORT_KEY = 'fileBrowser.sort'
const SHOW_TREE_KEY = 'fileBrowser.showDirTree'
const NAV_WIDTH_KEY = 'fileBrowser.navigatorWidth'
// 输入参数
const props = defineProps({
@@ -11,7 +19,7 @@ const props = defineProps({
tree: Boolean,
endpoints: Object as PropType<EndPoints>,
axios: {
type: Function,
type: Object as PropType<AxiosInstance>,
required: true,
},
axiosconfig: Object,
@@ -27,6 +35,9 @@ const props = defineProps({
// 对外事件
const emit = defineEmits(['pathchanged'])
const route = useRoute()
const { appMode } = usePWA()
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
const fileIcons = {
// 压缩包
@@ -117,24 +128,47 @@ const fileIcons = {
other: 'mdi-file-outline',
}
function openNewFolderDialog() {
toolbarRef.value?.openNewFolderDialog()
}
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager')
useDynamicButton({
icon: 'mdi-folder-plus-outline',
onClick: openNewFolderDialog,
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
})
// 加载次数
const loading = ref(0)
// 当前存储
const activeStorage = ref('local')
// 刷新
const refreshPending = ref(false)
// 排序
const sort = ref('name')
// 排序 - 从localStorage恢复
const sort = ref(localStorage.getItem(SORT_KEY) || 'name')
// 是否显示目录树
const showDirTree = ref(false)
// 是否显示目录树 - 从localStorage恢复
const showDirTree = ref(localStorage.getItem(SHOW_TREE_KEY) === 'true')
// 拖动分隔条相关
const navigatorWidth = ref(280) // 初始宽度
// 拖动分隔条相关 - 从localStorage恢复宽度
const navigatorWidth = ref(parseInt(localStorage.getItem(NAV_WIDTH_KEY) || '280'))
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartWidth = ref(0)
watch(sort, (val) => {
localStorage.setItem(SORT_KEY, val)
})
watch(showDirTree, (val) => {
localStorage.setItem(SHOW_TREE_KEY, String(val))
})
watch(navigatorWidth, (val) => {
localStorage.setItem(NAV_WIDTH_KEY, String(val))
})
// 计算属性
const storagesArray = computed(() => {
return props.storages?.map(item => ({
@@ -144,15 +178,15 @@ const storagesArray = computed(() => {
}))
})
// 方法
function loadingChanged(loading: number) {
if (loading) loading++
else if (loading > 0) loading--
function loadingChanged(isLoading: number) {
if (isLoading) loading.value++
else if (loading.value > 0) loading.value--
}
// 存储切换
async function storageChanged(storage: string) {
activeStorage.value = storage
emit('pathchanged', { storage: storage, path: '/', fileid: 'root' })
}
@@ -235,14 +269,16 @@ function stopDrag() {
<template>
<div class="mx-auto" :loading="loading > 0">
<div v-if="activeStorage && item">
<div v-if="item">
<FileToolbar
ref="toolbarRef"
:sort="sort"
:item="item"
:itemstack="itemstack"
:storages="storagesArray"
:storage="activeStorage"
:endpoints="endpoints"
:axios="axios"
:show-new-folder-button="!showFloatingNewFolderAction"
@storagechanged="storageChanged"
@pathchanged="pathChanged"
@foldercreated="refreshPending = true"
@@ -251,7 +287,7 @@ function stopDrag() {
<div class="flex">
<FileNavigator
v-if="showDirTree"
:storage="activeStorage"
:storage="item.storage"
:currentPath="item.path"
:items="fileListItems"
:endpoints="endpoints"
@@ -266,7 +302,6 @@ function stopDrag() {
</div>
<FileList
:item="item"
:storage="activeStorage"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axios"
@@ -285,6 +320,18 @@ function stopDrag() {
</div>
</div>
</div>
<Teleport to="body" v-if="!appMode && showFloatingNewFolderAction">
<div class="compact-fab-stack">
<VFab
icon="mdi-folder-plus-outline"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openNewFolderDialog"
/>
</div>
</Teleport>
</template>
<style scoped>

View File

@@ -133,7 +133,7 @@ const instructions = computed(() => {
</Teleport>
<!-- 手动安装说明对话框 -->
<DialogWrapper v-model="showInstructions" max-width="500">
<VDialog v-model="showInstructions" max-width="500">
<VCard>
<VCardItem>
<VCardTitle class="d-flex align-center">
@@ -170,7 +170,7 @@ const instructions = computed(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>
<style scoped>

View File

@@ -1,5 +1,7 @@
<script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
@@ -9,21 +11,35 @@ const props = defineProps({
// 图片是否加载完成
const imageLoaded = ref(false)
const imageLoadError = ref(false)
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 图片加载失败响应
function imageErrorHandler() {
imageLoadError.value = true
}
// 跳转播放
function goPlay() {
if (props.media?.link) window.open(props.media?.link, '_blank')
async function goPlay() {
if (props.media?.link) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
}
}
// 计算图片地址
const getImgUrl = computed(() => {
const image = props.media?.image || ''
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
if (!image || imageLoadError.value) return noImage
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
const use_cookies = props.media?.use_cookies
if (use_cookies) {
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
}
return url
})
</script>
@@ -42,7 +58,7 @@ const getImgUrl = computed(() => {
@click="goPlay"
>
<template #image>
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler">
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />

View File

@@ -87,6 +87,12 @@ function saveRuleInfo() {
emit('done')
}
// 验证规则ID输入
function validateRuleId() {
// 只允许英文和数字,不允许空格
ruleInfo.value.id = ruleInfo.value.id.replace(/[^a-zA-Z0-9]/g, '')
}
// 按钮点击
function onClose() {
emit('close')
@@ -95,22 +101,24 @@ function onClose() {
<template>
<div>
<VCard variant="tonal" @click="openRuleInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VCard variant="tonal" class="app-card-shell" @click="openRuleInfoDialog">
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.rule.name }}</h5>
<div class="text-body-1 mb-3">{{ props.rule.id }}</div>
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<h5 class="app-card-summary__title text-h6">{{ props.rule.name }}</h5>
<div class="app-card-summary__subtitle text-body-1">{{ props.rule.id }}</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="filter_svg" contain class="app-card-summary__image" />
</div>
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
</VCardText>
</VCard>
<DialogWrapper
<VDialog
v-if="ruleInfoDialog"
v-model="ruleInfoDialog"
scrollable
@@ -138,6 +146,7 @@ function onClose() {
persistent-hint
active
prepend-inner-icon="mdi-identifier"
@input="validateRuleId"
/>
</VCol>
<VCol cols="12" md="6">
@@ -215,6 +224,6 @@ function onClose() {
}}</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -195,7 +195,7 @@ watch(
</script>
<template>
<VCard variant="tonal" :width="props.width" :height="props.height">
<VCard variant="tonal" class="app-card-shell" :width="props.width" :height="props.height">
<VDialogCloseBtn @click="onClose" />
<VCardItem>
<VTextField
@@ -204,8 +204,8 @@ watch(
:label="t('directory.alias')"
class="me-20 text-high-emphasis font-weight-bold"
/>
<span class="absolute top-3 right-12">
<IconBtn>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>

View File

@@ -4,12 +4,10 @@ import { formatFileSize } from '@/@core/utils/formatters'
import { DownloaderConf } from '@/api/types'
import { useToast } from 'vue-toastification'
import type { DownloaderInfo } from '@/api/types'
import qbittorrent_image from '@images/logos/qbittorrent.png'
import transmission_image from '@images/logos/transmission.png'
import custom_image from '@images/logos/downloader.png'
import { getLogoUrl } from '@/utils/imageUtils'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { downloaderDict } from '@/api/constants'
import { downloaderDict, storageAttributes } from '@/api/constants'
import { useDisplay } from 'vuetify'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
@@ -54,6 +52,54 @@ const download_rate = ref(0)
// 下载器详情弹窗
const downloaderInfoDialog = ref(false)
// 表单
const downloaderForm = ref()
// 路径前缀选项
const prefixOptions = computed(() => {
return storageAttributes.map(item => ({
title: t(`storage.${item.type}`),
value: item.type,
}))
})
function getStorageType(path: string) {
if (!path) return 'local'
// 查找匹配的存储类型
const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))
return storage?.type || 'local'
}
function storage2Prefix(storage: string) {
return storage === 'local' ? '' : storage + ':'
}
// 获取存储路径前后缀
function parseStoragePath(path: string): [prefix: string, suffix: string] {
if (!path) return ['', '']
const storage = getStorageType(path)
const prefix = storage2Prefix(storage)
return [prefix, path.slice(prefix.length)]
}
// 更新存储路径前缀
function updateStoragePrefix(row: PathMappingRow, storage: string) {
const [, currentSuffix] = parseStoragePath(row.storage)
const prefix = storage2Prefix(storage)
row.storage = prefix + currentSuffix
}
// 更新存储路径后缀
function updateStorageSuffix(row: PathMappingRow, suffix: string) {
const [currentPrefix] = parseStoragePath(row.storage)
row.storage = currentPrefix + suffix
}
const pathValidationRules = [
(v: string) => !!v || t('downloader.pathMappingRequired'),
(v: string) => v.startsWith('/') || t('downloader.pathMappingError'),
]
// 下载器详情
const downloaderInfo = ref<DownloaderConf>({
name: '',
@@ -61,8 +107,24 @@ const downloaderInfo = ref<DownloaderConf>({
default: false,
enabled: false,
config: {},
path_mapping: [],
})
// 路径映射行定义
interface PathMappingRow {
id: string
storage: string
download: string
}
// 路径映射行数据
const pathMappingRows = ref<PathMappingRow[]>([])
// 生成随机ID
function generateId() {
return Math.random().toString(36).substring(2, 9)
}
// 下载器是否应该刷新数据的计算属性
const shouldRefresh = computed(() => props.allowRefresh && props.downloader.enabled)
@@ -94,11 +156,24 @@ async function loadDownloaderInfo() {
function openDownloaderInfoDialog() {
// 深复制
downloaderInfo.value = cloneDeep(props.downloader)
// 初始化路径映射行数据
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
id: generateId(),
storage: item[0],
download: item[1],
}))
downloaderInfoDialog.value = true
}
// 保存详情数据
function saveDownloaderInfo() {
async function saveDownloaderInfo() {
// 表单校验
const { valid } = await downloaderForm.value?.validate()
if (!valid) return
// 同步路径映射数据
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
// 为空不保存,跳出警告框
if (!downloaderInfo.value.name) {
$toast.error(t('downloader.nameRequired'))
@@ -128,14 +203,30 @@ function saveDownloaderInfo() {
const getIcon = computed(() => {
switch (props.downloader.type) {
case 'qbittorrent':
return qbittorrent_image
return getLogoUrl('qbittorrent')
case 'transmission':
return transmission_image
return getLogoUrl('transmission')
case 'rtorrent':
return getLogoUrl('rtorrent')
default:
return custom_image
return getLogoUrl('downloader')
}
})
// 添加路径映射
function addPathMapping() {
pathMappingRows.value.push({
id: generateId(),
storage: '',
download: '',
})
}
// 移除路径映射
function removePathMapping(index: number) {
pathMappingRows.value.splice(index, 1)
}
// 按钮点击
function onClose() {
emit('close')
@@ -147,31 +238,33 @@ const { stop: stopRefresh } = useConditionalDataRefresh(
loadDownloaderInfo,
shouldRefresh, // 响应式条件只有当allowRefresh为true且downloader启用时才运行
3000, // 3秒间隔
true // 立即执行一次
true, // 立即执行一次
)
onUnmounted(() => {
stopRefresh()
})
</script>
<template>
<div>
<VHover v-slot="hover">
<VCard
v-bind="hover.props"
variant="tonal"
class="app-card-shell"
@click="openDownloaderInfoDialog"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<VDialogCloseBtn @click="onClose" />
<span class="absolute top-3 right-12">
<IconBtn>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VCardText class="flex justify-space-between align-center gap-4">
<div class="align-self-start flex-1">
<div class="flex items-center">
<VCardText class="app-card-summary app-card-summary--double-action">
<div class="app-card-summary__content">
<div class="app-card-summary__title-row">
<VBadge
v-if="props.downloader.default && props.downloader.enabled"
dot
@@ -179,24 +272,27 @@ onUnmounted(() => {
color="success"
class="me-1"
/>
<span class="text-h6">{{ downloader.name }}</span>
<span class="app-card-summary__title text-h6">{{ downloader.name }}</span>
</div>
<div v-if="downloaderDict[downloader.type] && props.downloader.enabled" class="mt-1 flex flex-wrap text-sm">
<span class="me-2">{{ `${formatFileSize(upload_rate, 1)}/s ` }}</span>
<span>{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
<div
v-if="downloaderDict[downloader.type] && props.downloader.enabled"
class="app-card-summary__meta text-sm"
>
<span class="app-card-summary__meta-item">{{ `${formatFileSize(upload_rate, 1)}/s` }}</span>
<span class="app-card-summary__meta-item">{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
</div>
<div v-else-if="!downloaderDict[downloader.type]" class="mt-1 flex flex-wrap text-sm">
<span class="me-2">自定义下载器</span>
<div v-else-if="!downloaderDict[downloader.type]" class="app-card-summary__subtitle text-sm">
自定义下载器
</div>
</div>
<div class="h-20">
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>
</VHover>
<DialogWrapper
<VDialog
v-if="downloaderInfoDialog"
v-model="downloaderInfoDialog"
scrollable
@@ -214,7 +310,7 @@ onUnmounted(() => {
<VDialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VForm ref="downloaderForm">
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
@@ -250,11 +346,23 @@ onUnmounted(() => {
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="downloaderInfo.config.apikey"
type="password"
:label="t('downloader.apiKey')"
:hint="t('downloader.qbittorrentApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-account"
@@ -266,6 +374,7 @@ onUnmounted(() => {
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-lock"
@@ -353,6 +462,51 @@ onUnmounted(() => {
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port/RPC2"
:hint="t('downloader.rtorrentHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
@@ -375,6 +529,89 @@ onUnmounted(() => {
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VDivider class="my-2">
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
</VDivider>
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
</div>
<VCard v-for="(row, index) in pathMappingRows" :key="row.id" variant="outlined" class="my-2">
<VCardText class="pa-3">
<VRow align="center" no-gutters>
<VCol cols="12" class="mb-2">
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
</div>
<VRow no-gutters>
<VCol cols="12" sm="4" class="pe-2">
<VSelect
:model-value="getStorageType(row.storage)"
:items="prefixOptions"
density="compact"
variant="outlined"
hide-details
@update:model-value="v => updateStoragePrefix(row, v)"
/>
</VCol>
<VCol cols="12" sm="8">
<VTextField
:model-value="parseStoragePath(row.storage)[1]"
:placeholder="'/path/to/storage'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
@update:model-value="v => updateStorageSuffix(row, v)"
/>
</VCol>
</VRow>
</VCol>
<VCol cols="12" class="mb-1">
<div class="d-flex align-center justify-center my-1">
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
</div>
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
</div>
<VTextField
v-model="row.download"
:placeholder="'/path/to/download'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
/>
</VCol>
<VCol cols="12" class="d-flex justify-end pt-1">
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</VCol>
</VRow>
</VCardText>
</VCard>
<VBtn
variant="tonal"
color="primary"
prepend-icon="mdi-plus-circle-outline"
@click="addPathMapping"
class="mt-1"
size="small"
>
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
@@ -383,6 +620,6 @@ onUnmounted(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -43,19 +43,14 @@ function imageLoadHandler() {
imageLoaded.value = true
}
// 计算文本类
function getTextClass() {
return imageLoaded.value ? 'text-white' : ''
}
// 下载状态控制
async function toggleDownload() {
const operation = isDownloading.value ? 'stop' : 'start'
try {
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`, {
params: {
name: props.downloaderName
}
name: props.downloaderName,
},
})
if (result.success) isDownloading.value = !isDownloading.value
@@ -67,7 +62,7 @@ async function toggleDownload() {
// 删除下截
async function deleteDownload() {
try {
await api.delete(`download/${props.info?.hash}`, {params: {name: props.downloaderName}})
await api.delete(`download/${props.info?.hash}`, { params: { name: props.downloaderName } })
cardState.value = false
} catch (error) {
console.error(error)
@@ -76,35 +71,52 @@ async function deleteDownload() {
</script>
<template>
<VCard v-if="cardState" :key="props.info?.hash">
<VCard v-if="cardState" :key="props.info?.hash" class="flex flex-col h-full" min-height="150">
<template #image>
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover class="brightness-50" @load="imageLoadHandler" />
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
<template #default>
<div class="absolute inset-0 outline-none downloading-card-background"></div>
</template>
</VImg>
</template>
<VCardTitle class="break-words whitespace-normal" :class="getTextClass()">
{{ props.info?.media.title || props.info?.name }}
{{
props.info?.media.episode
? `${props.info?.media.season} ${props.info?.media.episode}`
: props.info?.season_episode
}}
</VCardTitle>
<div>
<VCardTitle class="break-words whitespace-normal text-white">
{{ props.info?.media.title || props.info?.name }}
{{
props.info?.media.episode
? `${props.info?.media.season} ${props.info?.media.episode}`
: props.info?.season_episode
}}
</VCardTitle>
<VCardSubtitle class="break-words whitespace-normal" :class="getTextClass()">
{{ props.info?.title }}
</VCardSubtitle>
<VCardSubtitle class="break-words whitespace-normal text-white">
{{ props.info?.title }}
</VCardSubtitle>
<VCardText class="text-subtitle-1 pt-3 pb-1" :class="getTextClass()">
{{ getSpeedText() }}
</VCardText>
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
{{ getSpeedText() }}
</VCardText>
<VCardText v-if="getPercentage() > 0" :class="getTextClass()">
<VProgressLinear :model-value="getPercentage()" />
</VCardText>
<VCardText v-if="getPercentage() > 0" class="text-white">
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
</VCardText>
<VCardActions class="justify-space-between">
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
</VCardActions>
<VCardActions class="justify-space-between">
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
</VCardActions>
</div>
</VCard>
</template>
<style lang="scss" scoped>
.downloading-card-background {
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
</style>

View File

@@ -45,15 +45,15 @@ onMounted(() => {
</script>
<template>
<VCard variant="tonal">
<span class="absolute top-3 right-12">
<IconBtn>
<VCard variant="tonal" class="app-card-shell">
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardItem>
<VCardTitle>{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
<VCardTitle class="pr-8">{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
<VRow>
<VCol>
<VAutocomplete

View File

@@ -205,25 +205,27 @@ function onClose() {
<template>
<div>
<VCard variant="tonal" @click="opengroupInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VCard variant="tonal" class="app-card-shell" @click="opengroupInfoDialog">
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
<div class="text-body-1 mb-3">
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<h5 class="app-card-summary__title text-h6">{{ props.group.name }}</h5>
<div class="app-card-summary__subtitle text-body-1">
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
<span v-else>{{ props.group.category }}</span>
</div>
</div>
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="filter_group_svg" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>
<DialogWrapper
<VDialog
v-if="groupInfoDialog"
v-model="groupInfoDialog"
scrollable
@@ -308,7 +310,7 @@ function onClose() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"

View File

@@ -3,7 +3,8 @@ 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'
import trimemedia from '@images/logos/trimemedia.png'
import { getLogoUrl } from '@/utils/imageUtils'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
@@ -32,57 +33,67 @@ function imageLoadHandler() {
// 图片加载错误
function imageErrorHandler() {
imageError.value = true
imgUrl.value = getDefaultImage()
}
// 默认图片
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 if (props.media?.server === 'trimemedia') return trimemedia
if (props.media?.server_type === 'plex') return plex
else if (props.media?.server_type === 'emby') return emby
else if (props.media?.server_type === 'jellyfin') return jellyfin
else if (props.media?.server_type === 'trimemedia') return getLogoUrl('trimemedia')
else if (props.media?.server_type === 'ugreen') return getLogoUrl('ugreen')
else return plex
}
// 跳转播放
function goPlay() {
if (props.media?.link) window.open(props.media?.link, '_blank')
async function goPlay() {
if (props.media?.link) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
}
}
// 生成图片代理路径
function getImgUrl(url: string) {
if (!url) return getDefaultImage()
else return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
function getImgUrl(url: string, use_cookies?: boolean) {
if (!url || imageError.value) return getDefaultImage()
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
if (use_cookies) {
imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`
}
return imgurl
}
// 根据多张图片生成媒体库封面
async function drawImages(imageList: string[]) {
async function drawImages(imageList: string[], use_cookies?: boolean) {
// 图片
const IMAGES = imageList
const IMAGES = [...imageList]
if (IMAGES.length === 0) return getDefaultImage()
// 为所有图片添加system/img前缀
for (let i = 0; i < IMAGES.length; i++)
for (let i = 0; i < IMAGES.length; i++) {
IMAGES[i] = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(IMAGES[i])}`
if (use_cookies) {
IMAGES[i] += `&use_cookies=${encodeURIComponent(use_cookies)}`
}
}
// 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 POSTER_WIDTH = (canvas.width - 40) / 4 // 左右边框8px + 3个间隔24px = 40px
const POSTER_HEIGHT = 256 // 上方海报高256
const MARGIN_WIDTH = 8 // 左右间隔为8
const MARGIN_HEIGHT = 4 // 海报和倒影之间的间隔为4
const REFLECTION_HEIGHT = canvas.height - POSTER_HEIGHT - MARGIN_HEIGHT // 下方倒影使用剩余全部高度
// 获取画布上下文
const ctx = canvas.getContext('2d')
if (!ctx) return getDefaultImage()
// 设置背景色为黑色
ctx.fillStyle = '#000000'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 设置背景色为透明
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 绘制图片
async function drawImageWithReflection(imgSrc: string, index: number) {
@@ -101,36 +112,27 @@ async function drawImages(imageList: string[]) {
} catch (error) {
console.error(error)
ctx.fillStyle = '#e5e7eb'
ctx.fillRect(MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1), MARGIN_HEIGHT, POSTER_WIDTH, POSTER_HEIGHT)
ctx.fillRect(MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1), 0, POSTER_WIDTH, POSTER_HEIGHT)
return
}
const x = MARGIN_WIDTH * index + POSTER_WIDTH * (index - 1)
const y = MARGIN_HEIGHT
const y = 0 // 海报紧贴顶部
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,
)
ctx.drawImage(img, 0, 0, img.width, img.height, x, 0, POSTER_WIDTH, REFLECTION_HEIGHT)
const gradient = ctx.createLinearGradient(0, REFLECTION_SHOW_HEIGHT - REFLECTION_HEIGHT, 0, REFLECTION_HEIGHT)
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height - (POSTER_HEIGHT + MARGIN_HEIGHT))
gradient.addColorStop(0, 'rgba(0, 0, 0, 1)')
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.3)')
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.7)')
ctx.globalCompositeOperation = 'destination-out'
ctx.fillStyle = gradient
ctx.fillRect(x, 0, POSTER_WIDTH, REFLECTION_SHOW_HEIGHT)
ctx.fillRect(x, 0, POSTER_WIDTH, REFLECTION_HEIGHT)
ctx.restore()
}
@@ -145,8 +147,8 @@ async function drawImages(imageList: string[]) {
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 || '')
imgUrl.value = await drawImages(props.media?.image_list || [], props.media?.use_cookies)
else imgUrl.value = getImgUrl(props.media?.image || '', props.media?.use_cookies)
})
</script>
@@ -163,7 +165,7 @@ onMounted(async () => {
@click="goPlay"
>
<template #image>
<canvas ref="canvasRef" class="w-full h-full hidden" />
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
<template #placeholder>
<div class="w-full h-full">

View File

@@ -1,8 +1,6 @@
<script lang="ts" setup>
import noImage from '@images/no-image.jpeg'
import tmdbImage from '@images/logos/tmdb.png'
import doubanImage from '@images/logos/douban-black.png'
import bangumiImage from '@images/logos/bangumi.png'
import { getLogoUrl } from '@/utils/imageUtils'
import api from '@/api'
import { useToast } from 'vue-toastification'
import { formatSeason, formatRating } from '@/@core/utils/formatters'
@@ -20,9 +18,14 @@ import { hasPermission } from '@/utils/permission'
// 国际化
const { t } = useI18n()
interface MediaCardMedia extends MediaInfo {
total_episode?: number
episode_count?: number
}
// 输入参数
const props = defineProps({
media: Object as PropType<MediaInfo>,
media: Object as PropType<MediaCardMedia>,
width: String,
height: String,
})
@@ -64,9 +67,9 @@ const seasonsSelected = ref<MediaSeason[]>([])
// 来源角标字典
const sourceIconDict: { [key: string]: any } = {
themoviedb: tmdbImage,
douban: doubanImage,
bangumi: bangumiImage,
themoviedb: getLogoUrl('tmdb'),
douban: getLogoUrl('douban-black'),
bangumi: getLogoUrl('bangumi'),
}
// 绑定MediaCard元素
@@ -140,7 +143,7 @@ async function handleAddSubscribe() {
}
// 调用API添加订阅电视剧的话需要指定季
async function addSubscribe(season: number = 0, best_version: number = 0) {
async function addSubscribe(season: number | null = null, best_version: number = 0) {
// 开始处理
startNProgress()
try {
@@ -155,7 +158,7 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
doubanid: props.media?.douban_id,
bangumiid: props.media?.bangumi_id,
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
season,
season: props.media?.type === '电影' ? null : season,
best_version,
episode_group: episodeGroup.value,
})
@@ -185,8 +188,8 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
}
// 弹出添加订阅提示
function showSubscribeAddToast(result: boolean, title: string, season: number, message: string, best_version: number) {
if (season) title = `${title} ${formatSeason(season.toString())}`
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
let subname = t('subscribe.normalSub')
if (best_version > 0) subname = t('subscribe.versionSub')
@@ -224,7 +227,7 @@ async function removeSubscribe() {
// 查询当前媒体是否已订阅
async function handleCheckSubscribe() {
try {
const result = await checkSubscribe(props.media?.season)
const result = await checkSubscribe(props.media?.season ?? null)
if (result) isSubscribed.value = true
} catch (error) {
console.error(error)
@@ -251,7 +254,7 @@ async function handleCheckExists() {
}
// 调用API检查是否已订阅电视剧需要指定季
async function checkSubscribe(season = 0) {
async function checkSubscribe(season: number | null) {
try {
// AbortController 现在由全局请求优化器自动管理
const mediaid = getMediaId()
@@ -302,7 +305,7 @@ function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number
if (season && props.media?.tmdb_id)
// 全部存在时洗版
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
addSubscribe(season.season_number, best_version)
addSubscribe(season.season_number ?? null, best_version)
})
}

View File

@@ -47,10 +47,12 @@ function openTmdbPage(type: string, tmdbId: number) {
</div>
<div class="flex-grow">
<VCardItem class="pb-1">
<VCardTitle class="text-center text-md-left">
<div class="text-center text-md-left text-h6 font-weight-bold line-clamp-2 overflow-hidden text-ellipsis">
{{ context?.media_info?.title || context?.meta_info?.name }}
{{ context?.meta_info?.season_episode }}
</VCardTitle>
<span v-if="context?.meta_info?.season_episode" class="text-sm text-medium-emphasis align-top">
{{ context?.meta_info?.season_episode }}
</span>
</div>
<VCardSubtitle class="text-center text-md-left">
{{ context?.media_info?.year || context?.meta_info?.year }}
</VCardSubtitle>

View File

@@ -1,11 +1,7 @@
<script setup lang="ts">
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
import { useToast } from 'vue-toastification'
import emby_image from '@images/logos/emby.png'
import jellyfin_image from '@images/logos/jellyfin.png'
import plex_image from '@images/logos/plex.png'
import trimemedia_image from '@images/logos/trimemedia.png'
import custom_image from '@images/logos/mediaserver.png'
import { getLogoUrl } from '@/utils/imageUtils'
import api from '@/api'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
@@ -65,6 +61,12 @@ const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
},
])
const ugreenScanModeOptions = computed(() => [
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
])
// 媒体服务器详情弹窗
const mediaServerInfoDialog = ref(false)
@@ -81,6 +83,15 @@ function openMediaServerInfoDialog() {
loadLibrary(props.mediaserver.name)
// 深复制
mediaServerInfo.value = cloneDeep(props.mediaserver)
if (mediaServerInfo.value.type === 'ugreen') {
mediaServerInfo.value.config = mediaServerInfo.value.config || {}
if (!mediaServerInfo.value.config.scan_mode) {
mediaServerInfo.value.config.scan_mode = 'supplement_missing'
}
if (mediaServerInfo.value.config.verify_ssl === undefined) {
mediaServerInfo.value.config.verify_ssl = true
}
}
mediaServerInfoDialog.value = true
if (!props.mediaserver.sync_libraries) {
mediaServerInfo.value.sync_libraries = ['all']
@@ -109,15 +120,17 @@ function saveMediaServerInfo() {
const getIcon = computed(() => {
switch (props.mediaserver.type) {
case 'emby':
return emby_image
return getLogoUrl('emby')
case 'jellyfin':
return jellyfin_image
return getLogoUrl('jellyfin')
case 'trimemedia':
return trimemedia_image
return getLogoUrl('trimemedia')
case 'ugreen':
return getLogoUrl('ugreen')
case 'plex':
return plex_image
return getLogoUrl('plex')
default:
return custom_image
return getLogoUrl('mediaserver')
}
})
@@ -186,25 +199,31 @@ onMounted(() => {
</script>
<template>
<div>
<VCard variant="tonal" @click="openMediaServerInfoDialog">
<VCard variant="tonal" class="app-card-shell" @click="openMediaServerInfoDialog">
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<div class="text-h6 mb-1">{{ mediaserver.name }}</div>
<div v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled" class="text-sm mt-5 flex flex-wrap">
<span v-for="item in infoItems" :key="item.title" class="me-2 mb-1">
<VIcon rounded :icon="item.avatar" class="me-1" />{{ item.amount }}
<VCardText class="app-card-summary app-card-summary--single-action">
<div class="app-card-summary__content">
<div class="app-card-summary__title text-h6">{{ mediaserver.name }}</div>
<div
v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled"
class="grid min-h-6 grid-cols-3 gap-2 text-sm text-medium-emphasis"
>
<span v-for="item in infoItems" :key="item.title" class="flex min-w-0 items-center">
<VIcon rounded :icon="item.avatar" class="me-1 shrink-0" />
<span class="truncate">{{ item.amount }}</span>
</span>
</div>
<div v-else-if="!mediaServerDict[mediaserver.type]" class="text-sm mt-5 flex flex-wrap">
<span class="me-2 mb-1">自定义媒体服务器</span>
<div v-else-if="!mediaServerDict[mediaserver.type]" class="app-card-summary__subtitle text-sm">
自定义媒体服务器
</div>
</div>
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>
<DialogWrapper
<VDialog
v-if="mediaServerInfoDialog"
v-model="mediaServerInfoDialog"
scrollable
@@ -262,6 +281,16 @@ onMounted(() => {
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
:hint="t('mediaserver.usernameHint')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.apikey"
@@ -418,6 +447,95 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'ugreen'">
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="mediaServerInfo.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.config.username"
:label="t('mediaserver.username')"
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="mediaServerInfo.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(mediaServerInfo.name)"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="mediaServerInfo.config.scan_mode"
:label="t('mediaserver.scanMode')"
:items="ugreenScanModeOptions"
:hint="t('mediaserver.scanModeHint')"
persistent-hint
active
prepend-inner-icon="mdi-radar"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="mediaServerInfo.config.verify_ssl"
:label="t('mediaserver.verifySsl')"
:hint="t('mediaserver.verifySslHint')"
persistent-hint
color="primary"
inset
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'plex'">
<VCol cols="12" md="6">
<VTextField
@@ -506,6 +624,6 @@ onMounted(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup>
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
import { isNullOrEmptyObject } from '@/@core/utils'
import type { Message } from '@/api/types'
import { formatDateDifference } from '@core/utils/formatters'
@@ -19,6 +21,22 @@ const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 初始化 markdown-it
const md = new MarkdownIt({
html: true,
breaks: true,
linkify: true,
typographer: true,
})
// 插件:链接在新窗口打开
md.use(mdLinkAttributes, {
attrs: {
target: '_blank',
rel: 'noopener noreferrer',
},
})
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
@@ -42,10 +60,10 @@ function noteToJson() {
return {}
}
// 将\n转换为html属性的换行符
function replaceNewLine(value: string) {
// 渲染 Markdown
function renderMarkdown(value: string) {
if (!value) return ''
return value.replace(/\n/g, '<br/>')
return md.render(value)
}
</script>
@@ -85,19 +103,23 @@ function replaceNewLine(value: string) {
</VCardTitle>
<div
v-if="props.message?.text && props.message?.action === 0"
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
>
<p class="mb-0">{{ props.message?.text }}</p>
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
</div>
<VCardText v-if="props.message?.text && props.message?.action === 1" v-html="replaceNewLine(props.message?.text)" />
<VCardText
v-if="props.message?.text && props.message?.action === 1"
class="markdown-body"
v-html="renderMarkdown(props.message?.text)"
/>
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
<VList>
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
{{ key + 1 }}. {{ value.title_year }}
{{ Number(key) + 1 }}. {{ value.title_year }}
</VListItemTitle>
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
{{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
{{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
</VListItemTitle>
<VListItemSubtitle v-if="value.type">
类型:{{ value.type }} 评分:{{ value.vote_average }}
@@ -116,3 +138,89 @@ function replaceNewLine(value: string) {
}}</span>
</div>
</template>
<style lang="scss">
.markdown-body {
word-break: break-all;
p {
margin-block-end: 0.5rem;
}
p:last-child {
margin-block-end: 0;
}
a {
color: inherit;
text-decoration: underline;
}
ul {
list-style-type: disc;
margin-block-end: 0.5rem;
padding-inline-start: 1.5rem;
}
ol {
list-style-type: decimal;
margin-block-end: 0.5rem;
padding-inline-start: 1.5rem;
}
li {
display: list-item;
margin-block-end: 0.25rem;
}
code {
border-radius: 4px;
background-color: rgba(var(--v-border-color), 0.1);
font-family: monospace;
padding-block: 0.2rem;
padding-inline: 0.4rem;
}
pre {
overflow: auto;
padding: 1rem;
border-radius: 8px;
background-color: rgba(var(--v-border-color), 0.1);
margin-block-end: 0.5rem;
code {
padding: 0;
background-color: transparent;
}
}
blockquote {
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
font-style: italic;
margin-block-end: 0.5rem;
padding-inline-start: 1rem;
}
table {
border-collapse: collapse;
inline-size: 100%;
margin-block-end: 1rem;
th,
td {
padding: 0.5rem;
border: 1px solid rgba(var(--v-border-color), 0.1);
text-align: start;
}
th {
background-color: rgba(var(--v-border-color), 0.05);
}
}
img {
block-size: auto;
max-inline-size: 100%;
}
}
</style>

View File

@@ -1,12 +1,6 @@
<script setup lang="ts">
import { NotificationConf } from '@/api/types'
import wechat_image from '@images/logos/wechat.png'
import telegram_image from '@images/logos/telegram.webp'
import vocechat_image from '@images/logos/vocechat.png'
import synologychat_image from '@images/logos/synologychat.png'
import slack_image from '@images/logos/slack.webp'
import chrome_image from '@images/logos/chrome.png'
import custom_image from '@images/logos/notification.png'
import { getLogoUrl } from '@/utils/imageUtils'
import { useToast } from 'vue-toastification'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
@@ -52,9 +46,11 @@ const notificationInfo = ref<NotificationConf>({
const notificationTypeNames: { [key: string]: string } = {
wechat: t('notification.wechat.name'),
telegram: t('notification.telegram.name'),
qqbot: t('notification.qqbot.name'),
vocechat: t('notification.vocechat.name'),
synologychat: t('notification.synologychat.name'),
slack: t('notification.slack.name'),
discord: t('notification.discord.name'),
webpush: t('notification.webpush.name'),
custom: t('setting.notification.custom'),
}
@@ -68,13 +64,43 @@ const notificationTypes = [
{ value: '媒体服务器', title: t('notificationSwitch.mediaServer') },
{ value: '手动处理', title: t('notificationSwitch.manual') },
{ value: '插件', title: t('notificationSwitch.plugin') },
{ value: '智能体', title: t('notificationSwitch.agent') },
{ value: '其它', title: t('notificationSwitch.other') },
]
function ensureWechatConfigDefaults(notification: NotificationConf) {
if (notification.type !== 'wechat') {
return
}
if (!notification.config) {
notification.config = {}
}
if (!notification.config.WECHAT_MODE) {
notification.config.WECHAT_MODE = 'app'
}
if (!notification.config.WECHAT_BOT_WS_URL) {
notification.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'
}
}
const isWechatBotMode = computed({
get: () => notificationInfo.value.config?.WECHAT_MODE === 'bot',
set: value => {
if (!notificationInfo.value.config) {
notificationInfo.value.config = {}
}
notificationInfo.value.config.WECHAT_MODE = value ? 'bot' : 'app'
if (value && !notificationInfo.value.config.WECHAT_BOT_WS_URL) {
notificationInfo.value.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'
}
},
})
// 打开详情弹窗
function openNotificationInfoDialog() {
// 替换成深复制,避免修改时影响原数据
notificationInfo.value = cloneDeep(props.notification)
ensureWechatConfigDefaults(notificationInfo.value)
notificationInfoDialog.value = true
}
@@ -90,6 +116,7 @@ function saveNotificationInfo() {
$toast.error(t('notification.channel') + `${notificationInfo.value.name}` + t('common.exists'))
return
}
ensureWechatConfigDefaults(notificationInfo.value)
notificationInfoDialog.value = false
emit('change', notificationInfo.value, props.notification.name)
emit('done')
@@ -99,19 +126,23 @@ function saveNotificationInfo() {
const getIcon = computed(() => {
switch (props.notification.type) {
case 'wechat':
return wechat_image
return getLogoUrl('wechat')
case 'telegram':
return telegram_image
return getLogoUrl('telegram')
case 'qqbot':
return getLogoUrl('qq')
case 'vocechat':
return vocechat_image
return getLogoUrl('vocechat')
case 'synologychat':
return synologychat_image
return getLogoUrl('synologychat')
case 'slack':
return slack_image
return getLogoUrl('slack')
case 'discord':
return getLogoUrl('discord')
case 'webpush':
return chrome_image
return getLogoUrl('chrome')
default:
return custom_image
return getLogoUrl('notification')
}
})
@@ -122,26 +153,28 @@ function onClose() {
</script>
<template>
<div>
<VCard variant="tonal" @click="openNotificationInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VCard variant="tonal" class="app-card-shell" @click="openNotificationInfoDialog">
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<div class="flex items-center">
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<div class="app-card-summary__title-row">
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
<span class="text-h6">{{ props.notification.name }}</span>
<span class="app-card-summary__title text-h6">{{ props.notification.name }}</span>
</div>
<div class="text-body-1 mb-3">{{ notificationTypeNames[notification.type] }}</div>
<div class="app-card-summary__subtitle text-body-1">{{ notificationTypeNames[notification.type] }}</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
</div>
<VImg :src="getIcon" cover class="mt-7 me-1" max-width="3rem" />
</VCardText>
</VCard>
<DialogWrapper
<VDialog
v-if="notificationInfoDialog"
v-model="notificationInfoDialog"
scrollable
@@ -190,69 +223,129 @@ function onClose() {
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_CORPID"
:label="t('notification.wechat.corpId')"
:hint="t('notification.wechat.corpIdHint')"
<VSwitch
v-model="isWechatBotMode"
:label="t('notification.wechat.useBotMode')"
:hint="t('notification.wechat.useBotModeHint')"
persistent-hint
prepend-inner-icon="mdi-domain"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_ID"
:label="t('notification.wechat.appId')"
:hint="t('notification.wechat.appIdHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_SECRET"
:label="t('notification.wechat.appSecret')"
:hint="t('notification.wechat.appSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_PROXY"
:label="t('notification.wechat.proxy')"
:hint="t('notification.wechat.proxyHint')"
persistent-hint
prepend-inner-icon="mdi-server-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_TOKEN"
:label="t('notification.wechat.token')"
:hint="t('notification.wechat.tokenHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
:label="t('notification.wechat.encodingAesKey')"
:hint="t('notification.wechat.encodingAesKeyHint')"
persistent-hint
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ADMINS"
:label="t('notification.wechat.admins')"
:placeholder="t('notification.wechat.adminsPlaceholder')"
:hint="t('notification.wechat.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
color="primary"
/>
</VCol>
<template v-if="isWechatBotMode">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_BOT_ID"
:label="t('notification.wechat.botId')"
:hint="t('notification.wechat.botIdHint')"
persistent-hint
prepend-inner-icon="mdi-robot"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_BOT_SECRET"
:label="t('notification.wechat.botSecret')"
:hint="t('notification.wechat.botSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_BOT_CHAT_ID"
:label="t('notification.wechat.botChatId')"
:placeholder="t('notification.wechat.botChatIdPlaceholder')"
:hint="t('notification.wechat.botChatIdHint')"
persistent-hint
prepend-inner-icon="mdi-chat-processing"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_BOT_WS_URL"
:label="t('notification.wechat.botWsUrl')"
:hint="t('notification.wechat.botWsUrlHint')"
persistent-hint
prepend-inner-icon="mdi-lan-connect"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ADMINS"
:label="t('notification.wechat.admins')"
:placeholder="t('notification.wechat.adminsPlaceholder')"
:hint="t('notification.wechat.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
</template>
<template v-else>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_CORPID"
:label="t('notification.wechat.corpId')"
:hint="t('notification.wechat.corpIdHint')"
persistent-hint
prepend-inner-icon="mdi-domain"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_ID"
:label="t('notification.wechat.appId')"
:hint="t('notification.wechat.appIdHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_SECRET"
:label="t('notification.wechat.appSecret')"
:hint="t('notification.wechat.appSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_PROXY"
:label="t('notification.wechat.proxy')"
:hint="t('notification.wechat.proxyHint')"
persistent-hint
prepend-inner-icon="mdi-server-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_TOKEN"
:label="t('notification.wechat.token')"
:hint="t('notification.wechat.tokenHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
:label="t('notification.wechat.encodingAesKey')"
:hint="t('notification.wechat.encodingAesKeyHint')"
persistent-hint
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ADMINS"
:label="t('notification.wechat.admins')"
:placeholder="t('notification.wechat.adminsPlaceholder')"
:hint="t('notification.wechat.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
</template>
</VRow>
<VRow v-else-if="notificationInfo.type == 'telegram'">
<VCol cols="12" md="6">
@@ -356,6 +449,47 @@ function onClose() {
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'discord'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.DISCORD_BOT_TOKEN"
:label="t('notification.discord.botToken')"
:hint="t('notification.discord.botTokenHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.DISCORD_GUILD_ID"
:label="t('notification.discord.guildId')"
:placeholder="t('notification.discord.guildIdPlaceholder')"
:hint="t('notification.discord.guildIdHint')"
persistent-hint
prepend-inner-icon="mdi-pound"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.DISCORD_CHANNEL_ID"
:label="t('notification.discord.channelId')"
:placeholder="t('notification.discord.channelIdPlaceholder')"
:hint="t('notification.discord.channelIdHint')"
persistent-hint
prepend-inner-icon="mdi-pound-box"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'synologychat'">
<VCol cols="12" md="6">
<VTextField
@@ -426,6 +560,56 @@ function onClose() {
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'qqbot'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.QQ_APP_ID"
:label="t('notification.qqbot.appId')"
:hint="t('notification.qqbot.appIdHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.QQ_APP_SECRET"
:label="t('notification.qqbot.appSecret')"
:hint="t('notification.qqbot.appSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.QQ_OPENID"
:label="t('notification.qqbot.openId')"
:placeholder="t('notification.qqbot.openIdPlaceholder')"
:hint="t('notification.qqbot.openIdHint')"
persistent-hint
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.QQ_GROUP_OPENID"
:label="t('notification.qqbot.groupOpenId')"
:placeholder="t('notification.qqbot.groupOpenIdPlaceholder')"
:hint="t('notification.qqbot.groupOpenIdHint')"
persistent-hint
prepend-inner-icon="mdi-account-group"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'webpush'">
<VCol cols="12" md="6">
<VTextField
@@ -476,6 +660,6 @@ function onClose() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -3,9 +3,10 @@ import { useToast } from 'vue-toastification'
import VersionHistory from '../misc/VersionHistory.vue'
import api from '@/api'
import type { Plugin } from '@/api/types'
import noImage from '@images/logos/plugin.png'
import { getLogoUrl } from '@/utils/imageUtils'
import { getDominantColor } from '@/@core/utils/image'
import { isNullOrEmptyObject } from '@/@core/utils'
import { formatDownloadCount } from '@/@core/utils/formatters'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
@@ -103,10 +104,12 @@ async function installPlugin() {
// 计算图标路径
const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
if (imageLoadError.value) return getLogoUrl('plugin')
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}&cache=true`
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.plugin_icon,
)}&cache=true`
return `./plugin_icon/${props.plugin?.plugin_icon}`
})
@@ -115,6 +118,9 @@ const iconPath: Ref<string> = computed(() => {
function visitPluginPage() {
// 将raw.githubusercontent.com转换为项目地址
let repoUrl = props.plugin?.repo_url
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
repoUrl = props.plugin?.author_url
}
if (repoUrl) {
if (repoUrl.includes('raw.githubusercontent.com')) {
if (!repoUrl.endsWith('/')) repoUrl += '/'
@@ -242,7 +248,7 @@ const dropdownItems = ref([
</div>
<div v-if="props.count" class="ms-2 flex-shrink-0 download-count align-middle items-center">
<VIcon size="small" icon="mdi-download" />
<span class="text-sm">{{ props.count?.toLocaleString() }}</span>
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
</div>
</div>
<div class="absolute bottom-0 right-0">
@@ -267,15 +273,15 @@ const dropdownItems = ref([
<!-- 安装插件进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<DialogWrapper v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
</VCard>
</DialogWrapper>
</VDialog>
<!-- 插件详情-->
<DialogWrapper v-if="detailDialog" v-model="detailDialog" max-width="30rem">
<VDialog v-if="detailDialog" v-model="detailDialog" max-width="30rem">
<VCard>
<VDialogCloseBtn @click="detailDialog = false" />
<VCardText>
@@ -325,7 +331,7 @@ const dropdownItems = ref([
}}</VBtn>
<div class="text-xs mt-2" v-if="props.count">
<VIcon icon="mdi-fire" />{{
t('plugin.totalDownloads', { count: props.count?.toLocaleString() })
t('plugin.totalDownloads', { count: formatDownloadCount(props.count) })
}}
</div>
</div>
@@ -335,6 +341,6 @@ const dropdownItems = ref([
</VCol>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -4,8 +4,9 @@ import { useConfirm } from '@/composables/useConfirm'
import api from '@/api'
import type { Plugin } from '@/api/types'
import { isNullOrEmptyObject } from '@core/utils'
import noImage from '@images/logos/plugin.png'
import { getLogoUrl } from '@/utils/imageUtils'
import { getDominantColor } from '@/@core/utils/image'
import { formatDownloadCount } from '@/@core/utils/formatters'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
@@ -167,7 +168,7 @@ async function showPluginConfig() {
// 计算图标路径
const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) return noImage
if (imageLoadError.value) return getLogoUrl('plugin')
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
@@ -492,7 +493,7 @@ watch(
</div>
<span v-if="props.count" class="ms-2 flex-shrink-0 download-count items-center align-middle">
<VIcon size="small" icon="mdi-download" />
<span class="text-sm">{{ props.count?.toLocaleString() }}</span>
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
</span>
</div>
<div class="absolute bottom-0 right-0">
@@ -547,7 +548,7 @@ watch(
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<DialogWrapper v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable :fullscreen="!display.mdAndUp.value">
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
@@ -562,16 +563,16 @@ watch(
</VBtn>
</VCardItem>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 实时日志弹窗 -->
<DialogWrapper
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="60rem"
:fullscreen="!display.mdAndUp.value"
>
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="72rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="loggingDialog = false" />
<VCardItem>
@@ -587,14 +588,14 @@ watch(
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VCardText class="pa-0">
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 插件分身对话框 -->
<DialogWrapper
<VDialog
v-if="pluginCloneDialog"
v-model="pluginCloneDialog"
width="600"
@@ -700,7 +701,7 @@ watch(
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -350,7 +350,7 @@ const dropdownItems = ref([
</VHover>
<!-- 重命名对话框 -->
<DialogWrapper v-if="renameDialog" v-model="renameDialog" max-width="400">
<VDialog v-if="renameDialog" v-model="renameDialog" max-width="400">
<VCard>
<VCardItem>
<template #prepend>
@@ -374,10 +374,10 @@ const dropdownItems = ref([
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
<!-- 设置对话框 -->
<DialogWrapper
<VDialog
v-if="settingDialog"
v-model="settingDialog"
max-width="600"
@@ -480,7 +480,7 @@ const dropdownItems = ref([
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import type { PropType } from 'vue'
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
@@ -27,12 +28,19 @@ function getChipColor(type: string) {
const getImgUrl = computed(() => {
if (imageLoadError.value) return noImage
const image = props.media?.image || ''
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
const use_cookies = props.media?.use_cookies
if (use_cookies) {
url += `&use_cookies=${encodeURIComponent(use_cookies)}`
}
return url
})
// 跳转播放
function goPlay(isHovering: boolean | null = false) {
if (props.media?.link && isHovering) window.open(props.media?.link, '_blank')
async function goPlay(isHovering: boolean | null = false) {
if (props.media?.link && isHovering) {
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
}
}
</script>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import noImage from '@images/logos/site.webp'
import { getLogoUrl } from '@/utils/imageUtils'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
@@ -62,7 +62,7 @@ async function getSiteIcon() {
try {
siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon
if (!siteIcon.value) {
siteIcon.value = noImage
siteIcon.value = getLogoUrl('site')
}
} catch (error) {
console.error(error)
@@ -216,11 +216,17 @@ onMounted(() => {
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
<!-- 主体部分 -->
<div class="relative flex-1 flex flex-col p-3 z-1">
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
<!-- 顶部图标和站点名称 -->
<div class="flex items-center mb-1">
<div class="mb-1 flex min-w-0 items-center gap-2">
<!-- 站点图标 -->
<VAvatar tile rounded="lg" size="32" class="me-2" :class="{ 'cursor-move': display.mdAndUp.value }">
<VAvatar
tile
rounded="lg"
size="32"
class="shrink-0"
:class="{ 'cursor-move': display.mdAndUp.value }"
>
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
<template #placeholder>
<div class="w-full h-full">
@@ -231,11 +237,11 @@ onMounted(() => {
</VAvatar>
<!-- 站点名称和特性图标 -->
<div class="flex-1 min-w-0 flex items-center">
<h3 class="text-lg font-semibold leading-tight truncate">{{ cardProps.site?.name }}</h3>
<div class="flex min-w-0 flex-1 items-center gap-2">
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
<!-- 站点特性图标 -->
<div class="flex items-center gap-2 ml-auto mr-10">
<div class="ml-auto flex shrink-0 items-center gap-2">
<div v-if="cardProps.site?.limit_interval" class="hover:bg-primary/8 transition-colors">
<VIcon icon="mdi-speedometer" size="16" color="primary" class="opacity-85 hover:opacity-100" />
</div>
@@ -254,7 +260,7 @@ onMounted(() => {
<!-- 中间部分网址 -->
<div class="my-3">
<div class="text-sm text-medium-emphasis truncate" @click.stop="openSitePage">
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="openSitePage">
{{ cardProps.site?.url }}
</div>
</div>

View File

@@ -220,7 +220,7 @@ function onClose() {
@close="smbConfigDialog = false"
@done="handleDone"
/>
<DialogWrapper
<VDialog
v-if="customConfigDialog"
v-model="customConfigDialog"
scrollable
@@ -263,6 +263,6 @@ function onClose() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</div>
</template>

View File

@@ -21,6 +21,14 @@ const { t } = useI18n()
// 输入参数
const props = defineProps({
media: Object as PropType<Subscribe>,
batchMode: {
type: Boolean,
default: false,
},
selected: {
type: Boolean,
default: false,
},
})
// 从 provide 中获取全局设置
@@ -29,7 +37,7 @@ const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
const emit = defineEmits(['remove', 'save', 'select'])
// 确认框
const createConfirm = useConfirm()
@@ -297,6 +305,17 @@ function onSubscribeEditRemove() {
subscribeEditDialog.value = false
emit('remove')
}
// 处理卡片点击事件
function handleCardClick() {
if (props.batchMode) {
// 批量模式下触发选择事件
emit('select')
} else {
// 非批量模式下打开编辑弹窗
editSubscribeDialog()
}
}
</script>
<template>
@@ -308,6 +327,7 @@ function onSubscribeEditRemove() {
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
}"
>
<VCard
@@ -319,8 +339,8 @@ function onSubscribeEditRemove() {
}"
rounded="0"
min-height="150"
@click="editSubscribeDialog"
:ripple="false"
@click="handleCardClick"
:ripple="!props.batchMode"
>
<div class="me-n3 absolute top-1 right-4">
<IconBtn>

View File

@@ -136,8 +136,8 @@ onMounted(() => {
<!-- 媒体标题 -->
<VCardItem class="pt-3 pb-0">
<div class="d-flex flex-row flex-wrap justify-start mb-2 pr-8">
<span class="text-h6 font-weight-bold text-truncate me-2">
<div class="d-flex flex-row flex-wrap justify-start align-center mb-2 pr-8">
<span class="text-h6 font-weight-bold me-2">
{{ media?.title ?? meta?.name }}
</span>
<VChip
@@ -183,14 +183,14 @@ onMounted(() => {
<!-- 种子内容 -->
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
<!-- 种子标题 -->
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1" :title="torrent?.title">
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1 break-all" :title="torrent?.title">
{{ torrent?.title }}
</div>
<!-- 种子描述 -->
<div
v-if="meta?.subtitle || torrent?.description"
class="text-body-2 text-medium-emphasis mb-2"
class="text-body-2 text-medium-emphasis mb-2 break-all"
:title="meta?.subtitle || torrent?.description"
>
{{ meta?.subtitle || torrent?.description }}
@@ -278,7 +278,7 @@ onMounted(() => {
</VCard>
<!-- 更多来源对话框 -->
<DialogWrapper v-model="showMoreTorrents" max-width="25rem" location="center">
<VDialog v-model="showMoreTorrents" max-width="25rem" location="center">
<VCard>
<VCardTitle class="py-3 d-flex align-center">
<span>其他来源</span>
@@ -361,7 +361,7 @@ onMounted(() => {
</VList>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
<AddDownloadDialog
v-if="addDownloadDialog"
@@ -418,7 +418,7 @@ onMounted(() => {
}
.chip-web-source {
background-color: #8000FF;
background-color: #8000ff;
color: white;
}

View File

@@ -121,16 +121,26 @@ onMounted(() => {
</div>
<template v-slot:prepend>
<div class="d-flex flex-column align-center pr-3">
<VImg v-if="siteIcon" :src="siteIcon" :alt="torrent?.site_name" class="rounded mb-1" width="32" height="32" />
<VAvatar v-else size="24" class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold">
<div class="d-flex flex-column align-center pr-3" :title="torrent?.site_name">
<VImg
v-if="siteIcon"
:src="siteIcon"
:alt="torrent?.site_name"
class="rounded mb-1 site-icon"
width="32"
height="32"
/>
<VAvatar
v-else
size="32"
class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold site-icon"
>
{{ torrent?.site_name?.substring(0, 1) }}
</VAvatar>
<div class="font-weight-bold text-body-2 text-center d-none d-sm-block">{{ torrent?.site_name }}</div>
</div>
</template>
<VListItemTitle>
<VListItemTitle class="whitespace-normal">
<div class="d-flex flex-row flex-wrap align-center mb-2">
<span class="text-h6 font-weight-bold me-2">{{ media?.title ?? meta?.name }}</span>
<VChip
@@ -143,12 +153,12 @@ onMounted(() => {
</VChip>
</div>
<div class="text-subtitle-2 font-weight-medium mb-2" :title="torrent?.title">
<div class="text-subtitle-2 font-weight-medium mb-2 break-all" :title="torrent?.title">
{{ torrent?.title }}
</div>
<div
class="text-body-2 text-medium-emphasis mb-2"
class="text-body-2 text-medium-emphasis mb-2 break-all"
:title="meta?.subtitle || torrent?.description || '暂无描述'"
>
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
@@ -332,4 +342,12 @@ onMounted(() => {
background-color: #9c27b0;
color: white;
}
.site-icon {
transition: transform 0.2s ease;
}
.site-icon:hover {
transform: scale(1.1);
}
</style>

View File

@@ -6,6 +6,10 @@ import ForkWorkflowDialog from '../dialog/ForkWorkflowDialog.vue'
// 输入参数
const props = defineProps({
workflow: Object as PropType<WorkflowShare>,
eventTypes: {
type: Array as PropType<Array<{ title: string; value: string }>>,
default: () => [],
},
})
// 定义删除事件
@@ -135,6 +139,7 @@ function doDelete() {
v-if="forkWorkflowDialog"
v-model="forkWorkflowDialog"
:workflow="props.workflow"
:event-types="props.eventTypes"
@close="forkWorkflowDialog = false"
@fork="finishForkWorkflow"
@delete="doDelete"

View File

@@ -16,6 +16,10 @@ const props = defineProps({
required: true,
type: Object as PropType<Workflow>,
},
eventTypes: {
type: Array as PropType<Array<{ title: string; value: string }>>,
default: () => [],
},
})
// 定义事件
@@ -39,6 +43,12 @@ const shareDialog = ref(false)
// 加载中
const loading = ref(false)
// 根据事件类型值获取显示文本
const getEventTypeText = (eventTypeValue: string) => {
const eventType = props.eventTypes.find(item => item.value === eventTypeValue)
return eventType ? eventType.title : eventTypeValue
}
// 编辑任务
function handleEdit(item: Workflow) {
editDialog.value = true
@@ -165,11 +175,36 @@ async function handleReset(item: Workflow) {
// 计算状态颜色
const resolveStatusVariant = (status: string | undefined) => {
if (status === 'S') return { color: 'success', text: t('workflow.task.status.success') }
else if (status === 'R') return { color: 'primary', text: t('workflow.task.status.running') }
else if (status === 'F') return { color: 'error', text: t('workflow.task.status.failed') }
else if (status === 'P') return { color: 'secondary', text: t('workflow.task.status.paused') }
else return { color: 'info', text: t('workflow.task.status.waiting') }
if (status === 'S')
return {
color: 'success',
bgColor: 'linear-gradient(to bottom right, rgba(76, 175, 80, 0.9), rgba(76, 175, 80, 0.7))',
text: t('workflow.task.status.success'),
}
else if (status === 'R')
return {
color: 'primary',
bgColor: 'linear-gradient(to bottom right, rgba(33, 150, 243, 0.9), rgba(33, 150, 243, 0.7))',
text: t('workflow.task.status.running'),
}
else if (status === 'F')
return {
color: 'error',
bgColor: 'linear-gradient(to bottom right, rgba(244, 67, 54, 0.9), rgba(244, 67, 54, 0.7))',
text: t('workflow.task.status.failed'),
}
else if (status === 'P')
return {
color: 'warning',
bgColor: 'linear-gradient(to bottom right, rgba(255, 152, 0, 0.9), rgba(255, 152, 0, 0.7))',
text: t('workflow.task.status.paused'),
}
else
return {
color: 'info',
bgColor: 'linear-gradient(to bottom right, rgba(33, 150, 243, 0.9), rgba(33, 150, 243, 0.7))',
text: t('workflow.task.status.waiting'),
}
}
// 计算当前动作占比
@@ -190,11 +225,9 @@ const resolveProgress = (item: Workflow) => {
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<VCardItem
class="px-2"
:class="{
'py-0': workflow?.description,
'py-2': !workflow?.description,
[`bg-${resolveStatusVariant(workflow?.state).color}`]: true,
class="px-2 py-2"
:style="{
background: resolveStatusVariant(workflow?.state).bgColor,
}"
>
<template #prepend>
@@ -209,9 +242,8 @@ const resolveProgress = (item: Workflow) => {
</VAvatar>
</template>
<VCardTitle class="text-white text-lg">
{{ workflow?.name }}
<span :title="workflow?.description">{{ workflow?.name }}</span>
</VCardTitle>
<VCardSubtitle class="text-white">{{ workflow?.description }}</VCardSubtitle>
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
@@ -272,15 +304,28 @@ const resolveProgress = (item: Workflow) => {
</VCardItem>
<VDivider />
<VCardText class="pa-3">
<div class="d-flex flex-column gap-y-2">
<div class="d-flex flex-column gap-y-3">
<div class="d-flex flex-wrap gap-x-3">
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.timer') }}</div>
<h5 class="text-lg">{{ workflow?.timer }}</h5>
<div class="mb-1">{{ t('workflow.task.info.trigger') }}</div>
<h5>
<span v-if="workflow?.trigger_type === 'timer' || !workflow?.trigger_type">
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
{{ workflow?.timer }}
</span>
<span v-else-if="workflow?.trigger_type === 'event'">
<VIcon icon="mdi-calendar-check" size="small" class="me-1" />
{{ getEventTypeText(workflow?.event_type || '') }}
</span>
<span v-else-if="workflow?.trigger_type === 'manual'">
<VIcon icon="mdi-hand-pointing-up" size="small" class="me-1" />
{{ t('workflow.task.info.manualTrigger') }}
</span>
</h5>
</div>
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.status') }}</div>
<h5 class="text-lg" :class="`text-${resolveStatusVariant(workflow?.state).color}`">
<h5 :class="`text-${resolveStatusVariant(workflow?.state).color}`">
{{ resolveStatusVariant(workflow?.state).text }}
</h5>
</div>
@@ -289,14 +334,14 @@ const resolveProgress = (item: Workflow) => {
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.actionCount') }}</div>
<div>
<VAvatar size="28" color="primary" variant="tonal">
<span class="text-sm">{{ workflow?.actions?.length }}</span>
<VAvatar size="24" color="primary" variant="tonal">
<span class="text-xs">{{ workflow?.actions?.length }}</span>
</VAvatar>
</div>
</div>
<div class="flex-1">
<div class="mb-1">{{ t('workflow.task.info.runCount') }}</div>
<h5 class="text-lg">{{ workflow?.run_count }}</h5>
<h5>{{ workflow?.run_count }}</h5>
</div>
</div>
<div class="d-flex flex-wrap gap-x-3">

View File

@@ -0,0 +1,528 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import { clearCachesAndServiceWorker, reloadWithTimestamp } from '@/composables/useVersionChecker'
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 国际化
const { t } = useI18n()
// APP版本
const appVersion = __APP_VERSION__
// 定义事件
const emit = defineEmits(['close'])
// 显示器
const display = useDisplay()
// 初始化 markdown-it
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
})
// 插件:链接在新窗口打开
md.use(mdLinkAttributes, {
attrs: {
target: '_blank',
rel: 'noopener noreferrer',
},
})
// 系统环境变量
const systemEnv = ref<any>({})
// 所有Release
const allRelease = ref<any>([])
// 支持站点
const supportingSites = ref<any>({})
// 支持站点折叠状态
const sitesExpanded = ref(false)
// 去重后的支持站点
const uniqueSupportingSites = computed(() => {
const sitesMap = new Map()
Object.entries(supportingSites.value).forEach(([domain, site]: [string, any]) => {
if (!sitesMap.has(site.name)) {
sitesMap.set(site.name, {
name: site.name,
urls: [{ domain, url: site.url }],
})
} else {
sitesMap.get(site.name).urls.push({ domain, url: site.url })
}
})
return Array.from(sitesMap.values())
})
// 显示的支持站点折叠时只显示前5个
const displayedSites = computed(() => {
if (sitesExpanded.value) {
return uniqueSupportingSites.value
}
return uniqueSupportingSites.value.slice(0, 5)
})
// 变更日志对话框
const releaseDialog = ref(false)
// 最新版本
const latestRelease = ref('')
// 变更日志对话框标题
const releaseDialogTitle = ref('')
// 变更日志对话框内容
const releaseDialogBody = ref('')
// 打开日志对话框
function showReleaseDialog(title: string, body: string) {
releaseDialogTitle.value = title
releaseDialogBody.value = body ? md.render(body) : ''
releaseDialog.value = true
}
// 查询系统环境变量
async function querySystemEnv() {
try {
const result: { [key: string]: any } = await api.get('system/env')
systemEnv.value = result.data
} catch (error) {
console.log(error)
}
}
// 查询所有Release
async function queryAllRelease() {
try {
const result: { [key: string]: any } = await api.get('system/versions')
allRelease.value = result.data ?? []
// 最新版本
if (allRelease.value.length > 0) latestRelease.value = allRelease.value[0].tag_name
} catch (error) {
console.log(error)
}
}
// 查询支持站点
async function querySupportingSites() {
try {
supportingSites.value = await api.get('site/supporting')
} catch (error) {
console.log(error)
}
}
// 切换站点列表展开状态
function toggleSitesExpanded() {
sitesExpanded.value = !sitesExpanded.value
}
// 计算发布时间
function releaseTime(releaseDate: string) {
// 上一次更新时间
return formatDateDifference(releaseDate)
}
// 强制清除缓存
async function clearCache() {
await clearCachesAndServiceWorker()
// 刷新页面,添加时间戳参数以强制更新
reloadWithTimestamp()
}
onMounted(() => {
querySystemEnv()
queryAllRelease()
querySupportingSites()
})
</script>
<template>
<VDialog max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-information" class="me-2" />
{{ t('setting.about.title') }}
</VCardTitle>
<VDialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText>
<div class="px-3">
<div class="section">
<div class="section border-gray-800">
<dl>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.softwareVersion') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow flex flex-row items-center truncate">
<code class="truncate">{{ systemEnv.VERSION }}</code>
<a
v-if="latestRelease === systemEnv.VERSION"
href="https://github.com/jxxghp/MoviePilot/releases"
target="_blank"
rel="noopener noreferrer"
>
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap bg-green-500 bg-opacity-80 border border-green-500 !text-green-100 ml-2 !cursor-pointer transition hover:bg-green-400"
>
{{ t('setting.about.latest') }}
</span>
</a>
</span>
</dd>
</div>
</div>
<div v-if="systemEnv.FRONTEND_VERSION">
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.frontendVersion') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow flex flex-row items-center truncate">
<code class="truncate">{{ systemEnv.FRONTEND_VERSION }}</code>
</span>
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.browserVersion') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow flex flex-row items-center truncate">
<code class="truncate">{{ appVersion }}</code>
<VBtn
size="x-small"
variant="tonal"
class="ms-2"
@click="clearCache"
>
<template #prepend>
<VIcon icon="mdi-refresh" size="14" />
</template>
{{ t('setting.about.clearCache') }}
</VBtn>
</span>
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.authVersion') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow flex flex-row items-center truncate">
<code class="truncate">{{ systemEnv.AUTH_VERSION }}</code>
</span>
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.indexerVersion') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow flex flex-row items-center truncate">
<code class="truncate">{{ systemEnv.INDEXER_VERSION }}</code>
</span>
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.configDir') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow break-all">
<code>{{ systemEnv.CONFIG_DIR }}</code>
</span>
</dd>
</div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.dataDir') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow break-all"
><code>{{ t('setting.about.dataDirectory') }}</code></span
>
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.timezone') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow break-all">
<code>{{ systemEnv.TZ }}</code>
</span>
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.supportingSites') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 mt-1 ms-1">
<VChip v-for="site in displayedSites" :key="site.name" variant="outlined" size="small">
<span class="truncate max-w-32">{{ site.name }}</span>
</VChip>
<VChip
v-if="!sitesExpanded && uniqueSupportingSites.length > 5"
variant="tonal"
size="small"
@click="toggleSitesExpanded"
>
<span> {{ uniqueSupportingSites.length }}+ ...</span>
</VChip>
<VChip
v-if="sitesExpanded && uniqueSupportingSites.length > 5"
variant="tonal"
size="small"
@click="toggleSitesExpanded"
>
<span>< {{ t('setting.about.collapse') }}</span>
</VChip>
</div>
</div>
</dd>
</div>
</div>
</dl>
</div>
</div>
<div class="section">
<div>
<h3 class="heading">{{ t('setting.about.support') }}</h3>
</div>
<div class="section border-t border-gray-800">
<dl>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.documentation') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow break-all">
<a
href="https://movie-pilot.org"
target="_blank"
rel="noreferrer"
class="text-indigo-500 transition duration-300 hover:underline"
>
https://movie-pilot.org
</a>
</span>
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.feedback') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow break-all">
<a
href="https://github.com/jxxghp/MoviePilot/issues/new/choose"
target="_blank"
rel="noreferrer"
class="text-indigo-500 transition duration-300 hover:underline"
>
https://github.com/jxxghp/MoviePilot/issues/new/choose
</a>
</span>
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.channel') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow break-all">
<a
href="https://t.me/moviepilot_channel"
target="_blank"
rel="noreferrer"
class="text-indigo-500 transition duration-300 hover:underline"
>
https://t.me/moviepilot_channel
</a>
</span>
</dd>
</div>
</div>
</dl>
</div>
</div>
<div class="section">
<div>
<h3 class="heading">{{ t('setting.about.versions') }}</h3>
<div class="section space-y-3">
<div>
<div
v-for="release in allRelease"
:key="release.tag_name"
class="mb-3 flex w-full flex-col space-y-3 rounded-md px-4 py-2 ring-1 ring-gray-400 sm:flex-row sm:space-y-0 sm:space-x-3"
>
<div class="flex w-full flex-grow items-center justify-start space-x-2 truncate sm:justify-start">
<span class="truncate text-lg font-bold">
<span class="mr-2 whitespace-nowrap text-xs font-normal">{{
releaseTime(release.published_at)
}}</span>
{{ release.tag_name }}
</span>
<span
v-if="release.tag_name === latestRelease"
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-green-500 bg-opacity-80 border border-green-500 !text-green-100"
>
{{ t('setting.about.latestVersion') }}
</span>
<span
v-if="release.tag_name === systemEnv.VERSION"
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100"
>
{{ t('setting.about.currentVersion') }}
</span>
</div>
<VBtn @click.stop="showReleaseDialog(release.tag_name, release.body)">
<template #prepend>
<VIcon icon="mdi-text-box-outline" />
</template>
{{ t('setting.about.viewChangelog') }}
</VBtn>
</div>
</div>
</div>
</div>
</div>
</div>
</VCardText>
</VCard>
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard>
<VCardItem>
<VDialogCloseBtn @click="releaseDialog = false" />
<VCardTitle>{{ releaseDialogTitle }} {{ t('setting.about.changelog') }}</VCardTitle>
</VCardItem>
<VCardText class="markdown-body" v-html="releaseDialogBody" />
</VCard>
</VDialog>
</VDialog>
</template>
<style type="scss" scoped>
.heading {
font-size: 1.5rem;
font-weight: 700;
line-height: 2rem;
--tw-text-opacity: 1;
}
.section {
margin-block: 0.5rem 2.5rem;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
margin-block: 0.5rem;
font-weight: 600;
}
.markdown-body :deep(h1) {
font-size: 1.5rem;
}
.markdown-body :deep(h2) {
font-size: 1.25rem;
}
.markdown-body :deep(h3) {
font-size: 1.1rem;
}
.markdown-body :deep(ul),
.markdown-body :deep(ol) {
padding-inline-start: 1.5rem;
margin-block: 0.5rem;
}
.markdown-body :deep(li) {
margin-block: 0.25rem;
}
.markdown-body :deep(p) {
margin-block: 0.5rem;
}
.markdown-body :deep(a) {
color: rgb(99 102 241);
text-decoration: none;
}
.markdown-body :deep(a:hover) {
text-decoration: underline;
}
.markdown-body :deep(code) {
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.875em;
background-color: rgba(127, 127, 127, 0.15);
}
.markdown-body :deep(pre) {
padding: 0.75rem 1rem;
margin-block: 0.5rem;
overflow-x: auto;
border-radius: 0.375rem;
background-color: rgba(127, 127, 127, 0.15);
}
.markdown-body :deep(pre code) {
padding: 0;
background-color: transparent;
}
.markdown-body :deep(blockquote) {
padding-inline-start: 1rem;
margin-block: 0.5rem;
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
color: rgba(127, 127, 127, 0.8);
}
.markdown-body :deep(hr) {
margin-block: 1rem;
border: none;
border-block-start: 1px solid rgba(127, 127, 127, 0.3);
}
.markdown-body :deep(table) {
width: 100%;
margin-block: 0.5rem;
border-collapse: collapse;
}
.markdown-body :deep(th),
.markdown-body :deep(td) {
padding: 0.4rem 0.75rem;
border: 1px solid rgba(127, 127, 127, 0.3);
}
.markdown-body :deep(th) {
font-weight: 600;
background-color: rgba(127, 127, 127, 0.1);
}
.markdown-body :deep(img) {
max-width: 100%;
height: auto;
}
</style>

View File

@@ -6,10 +6,20 @@ import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } fr
import { formatFileSize } from '@/@core/utils/formatters'
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
import { useI18n } from 'vue-i18n'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import { numberValidator } from '@/@validators'
import { useGlobalSettingsStore } from '@/stores'
// 多语言支持
const { t } = useI18n()
// 从 provide 中获取全局设置
const globalSettingsStore = useGlobalSettingsStore()
const globalSettings = globalSettingsStore.globalSettings
// 当前识别类型
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
// 输入参数
const props = defineProps({
title: String,
@@ -38,6 +48,18 @@ const directories = ref<TransferDirectoryConf[]>([])
// 是否正在加载
const loading = ref(false)
// 是否显示高级选项
const showAdvancedOptions = ref(false)
// TMDB ID
const tmdbid = ref<number | undefined>(undefined)
// 豆瓣ID
const doubanId = ref<string | undefined>(undefined)
// TMDB选择对话框
const mediaSelectorDialog = ref(false)
// 计算按钮图标
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
@@ -56,9 +78,21 @@ async function loadDirectories() {
}
}
function convertToUri(item: TransferDirectoryConf) {
if (!item.download_path) {
return undefined
}
if (item.storage === 'local') {
return item.download_path
}
return item.storage + ':' + item.download_path
}
// 获取保存目录
const targetDirectories = computed(() => {
const downloadDirectories = directories.value.map(item => item.download_path)
const downloadDirectories = directories.value
.map(item => convertToUri(item))
.filter((item): item is string => item !== undefined)
return [...new Set(downloadDirectories)]
})
@@ -96,6 +130,14 @@ async function addDownload() {
payload.media_in = props.media
}
// 添加媒体ID辅助识别
if (tmdbid.value) {
payload.tmdbid = tmdbid.value
}
if (doubanId.value) {
payload.doubanid = doubanId.value
}
const endpoint = props.media ? 'download/' : 'download/add'
result = await api.post(endpoint, payload)
@@ -132,7 +174,7 @@ onMounted(() => {
})
</script>
<template>
<DialogWrapper max-width="35rem" scrollable>
<VDialog max-width="35rem" scrollable>
<VCard>
<VCardItem class="py-2">
<template #prepend>
@@ -181,7 +223,6 @@ onMounted(() => {
<VSelect
v-model="selectedDownloader"
:items="downloaderOptions"
size="small"
:label="t('dialog.addDownload.downloader')"
variant="underlined"
:placeholder="t('dialog.addDownload.defaultPlaceholder')"
@@ -194,7 +235,6 @@ onMounted(() => {
v-model="selectedDirectory"
:items="targetDirectories"
:label="t('dialog.addDownload.saveDirectory')"
size="small"
:placeholder="t('dialog.addDownload.autoPlaceholder')"
variant="underlined"
density="comfortable"
@@ -202,6 +242,53 @@ onMounted(() => {
/>
</VCol>
</VRow>
<VRow class="px-5 mt-2">
<VCol cols="12">
<VBtn
variant="text"
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click="showAdvancedOptions = !showAdvancedOptions"
>
{{
showAdvancedOptions
? t('dialog.addDownload.hideAdvancedOptions')
: t('dialog.addDownload.showAdvancedOptions')
}}
</VBtn>
</VCol>
</VRow>
<VRow v-show="showAdvancedOptions" class="px-5">
<VCol cols="12">
<VTextField
v-if="mediaSource === 'themoviedb'"
v-model="tmdbid"
:label="t('dialog.reorganize.tmdbId')"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
prepend-inner-icon="mdi-identifier"
variant="underlined"
density="comfortable"
@click:append-inner="mediaSelectorDialog = true"
/>
<VTextField
v-else
v-model="doubanId"
:label="t('dialog.reorganize.doubanId')"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
append-inner-icon="mdi-magnify"
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
prepend-inner-icon="mdi-identifier"
variant="underlined"
density="comfortable"
@click:append-inner="mediaSelectorDialog = true"
/>
</VCol>
</VRow>
</VCardText>
<VCardText class="text-center">
<VBtn variant="elevated" :disabled="loading" @click="addDownload" :prepend-icon="icon" class="px-5">
@@ -209,5 +296,15 @@ onMounted(() => {
</VBtn>
</VCardText>
</VCard>
</DialogWrapper>
<!-- 媒体ID选择器 -->
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
<MediaIdSelector
v-if="mediaSource === 'themoviedb'"
v-model="tmdbid"
@close="mediaSelectorDialog = false"
:type="mediaSource"
/>
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
</VDialog>
</VDialog>
</template>

View File

@@ -70,7 +70,7 @@ async function savaAlistConfig() {
</script>
<template>
<DialogWrapper width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
@@ -143,5 +143,5 @@ async function savaAlistConfig() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -110,7 +110,7 @@ onUnmounted(() => {
</script>
<template>
<DialogWrapper width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
@@ -148,5 +148,5 @@ onUnmounted(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -0,0 +1,663 @@
<script setup lang="ts">
import draggable from 'vuedraggable'
import api from '@/api'
import type { CategoryConfig } from '@/api/types'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 定义输入参数
defineProps<{
modelValue?: boolean
}>()
// 定义事件
const emit = defineEmits(['close', 'save'])
const activeTab = ref('movie')
const loading = ref(false)
const saving = ref(false)
const toast = useToast()
const { t } = useI18n()
const generateId = () => {
return 'id-' + Math.random().toString(36).substr(2, 9) + '-' + Date.now()
}
interface CategoryItem {
id: string
name: string
rule: any
}
const movieList = ref<CategoryItem[]>([])
const tvList = ref<CategoryItem[]>([])
// TMDB 类型映射
const genreOptions = [
{ title: '动作 (Action)', value: '28' },
{ title: '冒险 (Adventure)', value: '12' },
{ title: '动画 (Animation)', value: '16' },
{ title: '喜剧 (Comedy)', value: '35' },
{ title: '犯罪 (Crime)', value: '80' },
{ title: '纪录 (Documentary)', value: '99' },
{ title: '剧情 (Drama)', value: '18' },
{ title: '家庭 (Family)', value: '10751' },
{ title: '奇幻 (Fantasy)', value: '14' },
{ title: '历史 (History)', value: '36' },
{ title: '恐怖 (Horror)', value: '27' },
{ title: '音乐 (Music)', value: '10402' },
{ title: '悬疑 (Mystery)', value: '9648' },
{ title: '爱情 (Romance)', value: '10749' },
{ title: '科幻 (SF)', value: '878' },
{ title: '电视电影', value: '10770' },
{ title: '惊悚 (Thriller)', value: '53' },
{ title: '战争 (War)', value: '10752' },
{ title: '西部 (Western)', value: '37' },
{ title: '儿童 (Kids)', value: '10762' },
{ title: '新闻 (News)', value: '10763' },
{ title: '真人秀 (Reality)', value: '10764' },
{ title: '科幻/奇幻 (Sci-Fi)', value: '10765' },
{ title: '肥皂剧 (Soap)', value: '10766' },
{ title: '访谈 (Talk)', value: '10767' },
{ title: '战争/政治', value: '10768' },
]
// 语种选项 (original_language)
const languageOptions = [
{ title: '中文', value: 'zh' },
{ title: '中文', value: 'cn' },
{ title: '英语 (English)', value: 'en' },
{ title: '日语 (Japanese)', value: 'ja' },
{ title: '韩语 (Korean)', value: 'ko' },
{ title: '法语 (French)', value: 'fr' },
{ title: '德语 (German)', value: 'de' },
{ title: '西班牙语 (Spanish)', value: 'es' },
{ title: '意大利语 (Italian)', value: 'it' },
{ title: '葡萄牙语 (Portuguese)', value: 'pt' },
{ title: '俄语 (Russian)', value: 'ru' },
{ title: '阿拉伯语', value: 'ar' },
{ title: '泰语 (Thai)', value: 'th' },
{ title: '越南语 (Vietnamese)', value: 'vi' },
{ title: '印地语 (Hindi)', value: 'hi' },
{ title: '土耳其语 (Turkish)', value: 'tr' },
{ title: '荷兰语 (Dutch)', value: 'nl' },
{ title: '波兰语 (Polish)', value: 'pl' },
{ title: '瑞典语 (Swedish)', value: 'sv' },
{ title: '丹麦语 (Danish)', value: 'da' },
{ title: '挪威语 (Norwegian)', value: 'nb' },
{ title: '芬兰语 (Finnish)', value: 'fi' },
{ title: '希腊语 (Greek)', value: 'el' },
{ title: '捷克语 (Czech)', value: 'cs' },
{ title: '匈牙利语 (Hungarian)', value: 'hu' },
{ title: '罗马尼亚语 (Romanian)', value: 'ro' },
{ title: '乌克兰语 (Ukrainian)', value: 'uk' },
{ title: '印度尼西亚语 (Indonesian)', value: 'id' },
{ title: '马来语 (Malay)', value: 'ms' },
{ title: '希伯来语 (Hebrew)', value: 'he' },
]
// 国家/地区选项 (origin_country/production_countries)
const countryOptions = [
{ title: '中国大陆 (CN)', value: 'CN' },
{ title: '中国香港 (HK)', value: 'HK' },
{ title: '中国台湾 (TW)', value: 'TW' },
{ title: '美国 (US)', value: 'US' },
{ title: '英国 (GB)', value: 'GB' },
{ title: '日本 (JP)', value: 'JP' },
{ title: '韩国 (KR)', value: 'KR' },
{ title: '法国 (FR)', value: 'FR' },
{ title: '德国 (DE)', value: 'DE' },
{ title: '意大利 (IT)', value: 'IT' },
{ title: '西班牙 (ES)', value: 'ES' },
{ title: '加拿大 (CA)', value: 'CA' },
{ title: '澳大利亚 (AU)', value: 'AU' },
{ title: '俄罗斯 (RU)', value: 'RU' },
{ title: '印度 (IN)', value: 'IN' },
{ title: '泰国 (TH)', value: 'TH' },
{ title: '新加坡 (SG)', value: 'SG' },
{ title: '马来西亚 (MY)', value: 'MY' },
{ title: '越南 (VN)', value: 'VN' },
{ title: '菲律宾 (PH)', value: 'PH' },
{ title: '巴西 (BR)', value: 'BR' },
{ title: '墨西哥 (MX)', value: 'MX' },
{ title: '阿根廷 (AR)', value: 'AR' },
{ title: '荷兰 (NL)', value: 'NL' },
{ title: '比利时 (BE)', value: 'BE' },
{ title: '瑞士 (CH)', value: 'CH' },
{ title: '瑞典 (SE)', value: 'SE' },
{ title: '挪威 (NO)', value: 'NO' },
{ title: '丹麦 (DK)', value: 'DK' },
{ title: '波兰 (PL)', value: 'PL' },
{ title: '捷克 (CZ)', value: 'CZ' },
{ title: '土耳其 (TR)', value: 'TR' },
{ title: '以色列 (IL)', value: 'IL' },
{ title: '埃及 (EG)', value: 'EG' },
{ title: '南非 (ZA)', value: 'ZA' },
{ title: '新西兰 (NZ)', value: 'NZ' },
]
const fetchConfig = async () => {
loading.value = true
try {
const res: any = await api.get('media/category/config')
if (res && res.data) {
parseConfig(res.data)
}
} catch (e) {
console.error(e)
toast.error(t('setting.category.loadFailed'))
} finally {
loading.value = false
}
}
const parseConfig = (data: CategoryConfig) => {
// 将对象 { "Name": { ... } } 转换为数组 [ { id: uuid, name: "Name", rule: { ... } } ]
movieList.value = []
if (data.movie) {
for (const [key, value] of Object.entries(data.movie)) {
// 为了UI一致性处理 genre_ids 为数组或字符串,但 API 发送的是字符串
const rule = { ...value }
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
// UI 多选预期为数组,检查输入。实际上 VAutocomplete 多选预期数组。我们需要将字符串分割为数组。
// @ts-ignore
rule.genre_ids = rule.genre_ids.split(',')
} else {
// @ts-ignore
rule.genre_ids = []
}
// 处理语种
if (rule.original_language && typeof rule.original_language === 'string') {
// @ts-ignore
rule.original_language = rule.original_language.split(',')
} else {
// @ts-ignore
rule.original_language = []
}
// 处理制片国家/地区
if (rule.production_countries && typeof rule.production_countries === 'string') {
// @ts-ignore
rule.production_countries = rule.production_countries.split(',')
} else {
// @ts-ignore
rule.production_countries = []
}
movieList.value.push({
id: generateId(),
name: key,
rule: rule as any,
})
}
}
tvList.value = []
if (data.tv) {
for (const [key, value] of Object.entries(data.tv)) {
const rule = { ...value }
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
// @ts-ignore
rule.genre_ids = rule.genre_ids.split(',')
} else {
// @ts-ignore
rule.genre_ids = []
}
// 处理语种
if (rule.original_language && typeof rule.original_language === 'string') {
// @ts-ignore
rule.original_language = rule.original_language.split(',')
} else {
// @ts-ignore
rule.original_language = []
}
// 处理发行国家/地区
if (rule.origin_country && typeof rule.origin_country === 'string') {
// @ts-ignore
rule.origin_country = rule.origin_country.split(',')
} else {
// @ts-ignore
rule.origin_country = []
}
tvList.value.push({
id: generateId(),
name: key,
rule: rule as any,
})
}
}
}
const addMovieItem = () => {
movieList.value.push({
id: generateId(),
name: '新分类',
rule: { genre_ids: [] as any },
})
}
const removeMovieItem = (index: number) => {
movieList.value.splice(index, 1)
}
const addTvItem = () => {
tvList.value.push({
id: generateId(),
name: '新分类',
rule: { genre_ids: [] as any },
})
}
const removeTvItem = (index: number) => {
tvList.value.splice(index, 1)
}
const saveConfig = async () => {
saving.value = true
try {
// 将数组转换回对象
const payload: CategoryConfig = {
movie: {},
tv: {},
}
movieList.value.forEach(item => {
if (item.name) {
const rule = { ...item.rule }
// 将 genre_ids 数组转换回字符串
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
rule.genre_ids = rule.genre_ids.join(',')
} else {
// @ts-ignore
rule.genre_ids = null
}
// 将 original_language 数组转换回字符串
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
rule.original_language = rule.original_language.join(',')
} else {
rule.original_language = undefined
}
// 将 production_countries 数组转换回字符串
if (Array.isArray(rule.production_countries) && rule.production_countries.length > 0) {
rule.production_countries = rule.production_countries.join(',')
} else {
rule.production_countries = undefined
}
// 清理空字符串
if (!rule.release_year) rule.release_year = undefined
// @ts-ignore
payload.movie[item.name] = rule
}
})
tvList.value.forEach(item => {
if (item.name) {
const rule = { ...item.rule }
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
rule.genre_ids = rule.genre_ids.join(',')
} else {
// @ts-ignore
rule.genre_ids = null
}
// 将 original_language 数组转换回字符串
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
rule.original_language = rule.original_language.join(',')
} else {
rule.original_language = undefined
}
// 将 origin_country 数组转换回字符串
if (Array.isArray(rule.origin_country) && rule.origin_country.length > 0) {
rule.origin_country = rule.origin_country.join(',')
} else {
rule.origin_country = undefined
}
// 清理空字符串
if (!rule.release_year) rule.release_year = undefined
// @ts-ignore
payload.tv[item.name] = rule
}
})
const res: any = await api.post('media/category/config', payload)
if (res && res.success) {
toast.success(t('setting.category.saveSuccess'))
emit('save')
emit('close')
} else {
toast.error(t('setting.category.saveFailed', { message: res.message || 'Error' }))
}
} catch (e) {
console.error(e)
toast.error(t('setting.category.saveFailed', { message: 'Network or Config Error' }))
} finally {
saving.value = false
}
}
onMounted(() => {
fetchConfig()
})
</script>
<template>
<VDialog :model-value="modelValue" max-width="1000" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem class="py-3">
<template #prepend>
<VIcon icon="mdi-shape-outline" class="me-2" />
</template>
<VCardTitle>
{{ t('setting.category.title') }}
</VCardTitle>
<VCardSubtitle>
{{ t('setting.category.subtitle') }}
</VCardSubtitle>
</VCardItem>
<VCardText>
<VTabs v-model="activeTab" show-arrows class="mb-4">
<VTab value="movie">
<VIcon icon="mdi-movie-outline" class="me-2" />
{{ t('setting.category.movie') }}
</VTab>
<VTab value="tv">
<VIcon icon="mdi-television" class="me-2" />
{{ t('setting.category.tv') }}
</VTab>
</VTabs>
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 300px">
<VProgressCircular indeterminate color="primary" size="64" />
</div>
<VWindow v-else v-model="activeTab" class="disable-tab-transition" :touch="false">
<VWindowItem value="movie">
<draggable v-model="movieList" handle=".drag-handle" item-key="id" animation="200">
<template #item="{ element, index }">
<VCard variant="tonal" class="mb-4 category-item">
<VCardText class="pa-4">
<div class="d-flex align-center mb-5">
<VTextField
v-model="element.name"
:label="t('setting.category.name')"
density="comfortable"
hide-details
variant="plain"
class="font-bold"
prepend-inner-icon="mdi-tag-outline"
/>
<VSpacer />
<VBtn
icon="mdi-drag-vertical"
variant="text"
size="small"
class="drag-handle me-2"
color="primary"
/>
<VBtn
icon="mdi-delete-outline"
color="error"
variant="text"
size="small"
@click="removeMovieItem(index)"
/>
</div>
<VRow>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.genre_ids"
:items="genreOptions"
:label="t('setting.category.genre')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-movie-filter-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.production_countries"
:items="countryOptions"
:label="t('setting.category.country')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-earth"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.original_language"
:items="languageOptions"
:label="t('setting.category.language')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-translate"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="element.rule.release_year"
:label="t('setting.category.year')"
:placeholder="t('setting.category.yearPlaceholder')"
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-calendar-range"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
</template>
</draggable>
<VBtn
block
variant="outlined"
size="large"
prepend-icon="mdi-plus-circle-outline"
class="mt-2 add-category-btn"
@click="addMovieItem"
>
{{ t('setting.category.addMovie') }}
</VBtn>
</VWindowItem>
<VWindowItem value="tv">
<draggable v-model="tvList" handle=".drag-handle" item-key="id" animation="200">
<template #item="{ element, index }">
<VCard variant="tonal" class="mb-4 category-item">
<VCardText class="pa-4">
<div class="d-flex align-center mb-5">
<VTextField
v-model="element.name"
:label="t('setting.category.name')"
density="comfortable"
hide-details
variant="plain"
class="font-bold"
prepend-inner-icon="mdi-tag-outline"
/>
<VSpacer />
<VBtn
icon="mdi-drag-vertical"
variant="text"
size="small"
class="drag-handle me-2"
color="primary"
/>
<VBtn
icon="mdi-delete-outline"
color="error"
variant="text"
size="small"
@click="removeTvItem(index)"
/>
</div>
<VRow>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.genre_ids"
:items="genreOptions"
:label="t('setting.category.genre')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-movie-filter-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.origin_country"
:items="countryOptions"
:label="t('setting.category.country')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-earth"
/>
</VCol>
<VCol cols="12" md="6">
<VAutocomplete
v-model="element.rule.original_language"
:items="languageOptions"
:label="t('setting.category.language')"
item-title="title"
item-value="value"
multiple
chips
closable-chips
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-translate"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="element.rule.release_year"
:label="t('setting.category.year')"
:placeholder="t('setting.category.yearPlaceholder')"
density="comfortable"
variant="outlined"
persistent-hint
prepend-inner-icon="mdi-calendar-range"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
</template>
</draggable>
<VBtn
block
variant="outlined"
size="large"
prepend-icon="mdi-plus-circle-outline"
class="mt-2 add-category-btn"
@click="addTvItem"
>
{{ t('setting.category.addTv') }}
</VBtn>
</VWindowItem>
</VWindow>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn variant="text" @click="emit('close')">
{{ t('common.cancel') }}
</VBtn>
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.drag-handle {
cursor: grab;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.drag-handle:hover {
opacity: 1;
}
.drag-handle:active {
cursor: grabbing;
}
.category-item {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
}
.category-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.add-category-btn {
border-style: dashed !important;
transition: all 0.2s ease;
}
.add-category-btn:hover {
border-style: solid !important;
transform: translateY(-1px);
}
.disable-tab-transition > * {
transition: none !important;
}
</style>

View File

@@ -170,9 +170,8 @@ onMounted(() => {
})
</script>
<template>
<DialogWrapper max-width="40rem" scrollable>
<VDialog max-width="40rem" scrollable>
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardText>
<VCol>
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
@@ -285,6 +284,7 @@ onMounted(() => {
</div>
</VCol>
</VCardText>
<VDialogCloseBtn @click="emit('close')" />
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -13,6 +13,10 @@ const { t } = useI18n()
// 输入参数
const props = defineProps({
workflow: Object as PropType<WorkflowShare>,
eventTypes: {
type: Array as PropType<Array<{ title: string; value: string }>>,
default: () => [],
},
})
// 定义事件
@@ -32,6 +36,12 @@ const processing = ref(false)
// 删除中
const deleting = ref(false)
// 根据事件类型值获取显示文本
const getEventTypeText = (eventTypeValue: string) => {
const eventType = props.eventTypes.find(item => item.value === eventTypeValue)
return eventType ? eventType.title : eventTypeValue
}
// 流程图相关
const { nodes, edges } = useVueFlow()
@@ -146,7 +156,7 @@ async function doDelete() {
}
</script>
<template>
<DialogWrapper max-width="40rem" scrollable>
<VDialog max-width="40rem" scrollable>
<VCard>
<VCardText>
<VCol>
@@ -190,10 +200,23 @@ async function doDelete() {
<span class="text-body-1"> {{ props.workflow?.share_user }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0" v-if="props.workflow?.timer">
<VListItem class="ps-0" v-if="props.workflow?.trigger_type || props.workflow?.timer">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('workflow.timer') }}</span>
<span class="text-body-1"> {{ props.workflow?.timer }}</span>
<span class="font-weight-medium">{{ t('workflow.trigger') }}</span>
<span class="text-body-1">
<span v-if="props.workflow?.trigger_type === 'timer' || !props.workflow?.trigger_type">
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
{{ props.workflow?.timer }}
</span>
<span v-else-if="props.workflow?.trigger_type === 'event'">
<VIcon icon="mdi-calendar-check" size="small" class="me-1" />
{{ getEventTypeText(props.workflow?.event_type || '') }}
</span>
<span v-else-if="props.workflow?.trigger_type === 'manual'">
<VIcon icon="mdi-hand-pointing-up" size="small" class="me-1" />
{{ t('workflow.manualTrigger') }}
</span>
</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0" v-if="parsedWorkflow?.actions">
@@ -243,7 +266,7 @@ async function doDelete() {
</VCardText>
<VDialogCloseBtn @click="emit('close')" />
</VCard>
</DialogWrapper>
</VDialog>
</template>
<style lang="scss">

View File

@@ -24,7 +24,7 @@ function handleImport() {
</script>
<template>
<DialogWrapper width="40rem" scrollable max-height="85vh">
<VDialog width="40rem" scrollable max-height="85vh">
<VCard>
<VCardItem>
<template #prepend>
@@ -43,5 +43,5 @@ function handleImport() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -15,12 +15,12 @@ defineProps({
const emit = defineEmits(['close'])
</script>
<template>
<DialogWrapper max-width="50rem">
<VDialog max-width="50rem">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
<MediaInfoCard :context="context" />
</VCardItem>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -0,0 +1,235 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import QRCode from 'qrcode'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import type { ApiResponse, PassKey } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
interface Props {
modelValue: boolean
isOtp: boolean
passkeyList?: PassKey[]
}
const props = withDefaults(defineProps<Props>(), {
passkeyList: () => [],
})
const emit = defineEmits(['update:modelValue', 'update:isOtp', 'verifyPassword'])
const { t } = useI18n()
const display = useDisplay()
const $toast = useToast()
const globalSettingsStore = useGlobalSettingsStore()
// 内部状态
const show = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
// otp uri
const otpUri = ref('')
// otp secret
const secret = ref('')
// 确认双重验证密码
const otpPassword = ref('')
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
// 二维码图片 base64
const qrCodeImage = ref('')
// 二维码信息
const qrCode = ref('')
// 为当前用户获取Otp Uri
async function getOtpUri() {
// 如果已经启用OTP只打开对话框不生成新的二维码
if (props.isOtp) {
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
qrCodeImage.value = ''
return
}
// 未启用OTP生成新的二维码
try {
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
uri: string
secret: string
}>
if (result.success) {
otpUri.value = result.data.uri
secret.value = result.data.secret
qrCode.value = result.data.uri
// 生成二维码图片
qrCodeImage.value = await QRCode.toDataURL(result.data.uri, {
width: 200,
margin: 1,
})
} else {
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
$toast.error(t('profile.otpGenerateFailed', { message: error instanceof Error ? error.message : String(error) }))
}
}
// 启用Otp
async function judgeOtpPassword() {
if (!otpPassword.value) {
$toast.error(t('profile.otpCodeRequired'))
return
}
try {
const result = (await api.post('mfa/otp/verify', {
uri: otpUri.value,
otpPassword: otpPassword.value,
})) as ApiResponse
if (result.success) {
$toast.success(t('profile.otpEnableSuccess'))
show.value = false
emit('update:isOtp', true)
} else {
$toast.error(t('profile.otpEnableFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
$toast.error(t('profile.otpEnableFailed', { message: error instanceof Error ? error.message : String(error) }))
}
}
// 关闭当前用户的双重验证
function disableOtp() {
// 如果已绑定PassKey不允许关闭OTP
if (props.passkeyList && props.passkeyList.length > 0 && !allowPasskeyWithoutOtp.value) {
$toast.error(t('profile.disableOtpWithPasskeyError'))
return
}
emit('verifyPassword', {
title: t('profile.disableTwoFactor'),
text: t('profile.confirmToDisableOtp'),
callback: async (password: string) => {
try {
const result = (await api.post('mfa/otp/disable', {
password,
})) as ApiResponse
if (result.success) {
emit('update:isOtp', false)
$toast.success(t('profile.otpDisableSuccess'))
show.value = false
} else {
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
}
} catch (error) {
console.error(error)
$toast.error(t('profile.otpDisableFailed', { message: error instanceof Error ? error.message : String(error) }))
}
},
})
}
// 监听弹窗打开,自动获取 URI
watch(
() => props.modelValue,
val => {
if (val) {
getOtpUri()
otpPassword.value = ''
} else {
// 弹窗关闭时,清空数据
qrCodeImage.value = ''
qrCode.value = ''
otpUri.value = ''
secret.value = ''
otpPassword.value = ''
}
},
)
</script>
<template>
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-cellphone-key" class="me-2" />
{{ props.isOtp && !qrCode ? t('profile.authenticatorManagement') : t('profile.setupAuthenticator') }}
</VCardTitle>
<VDialogCloseBtn @click="show = false" />
</VCardItem>
<VDivider />
<VCardText>
<p class="mb-6">
{{ t('profile.authenticatorAppDescription') }}
</p>
<!-- 如果已启用OTP显示清除界面 -->
<template v-if="props.isOtp && !qrCode">
<VAlert type="success" variant="tonal" class="mb-4">
{{ t('profile.authenticatorEnabled') }}
</VAlert>
<p class="mb-6">
{{ t('profile.clearAuthenticatorTip') }}
</p>
<div class="d-flex justify-end flex-wrap gap-4">
<VBtn variant="outlined" color="secondary" @click="show = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn color="error" @click="disableOtp">
<template #prepend>
<VIcon icon="mdi-delete" />
</template>
{{ t('profile.clearAuthenticator') }}
</VBtn>
</div>
</template>
<!-- 设置新的OTP -->
<template v-else>
<div class="my-6 rounded text-center p-3 border" style="width: fit-content; margin: 0 auto">
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
</div>
</template>
</VImg>
</div>
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<template #prepend />
</VAlert>
<VForm @submit.prevent="judgeOtpPassword">
<VTextField
v-model="otpPassword"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
:label="t('profile.enterVerificationCode')"
class="mb-8"
variant="outlined"
prepend-inner-icon="mdi-shield-key"
/>
<div class="d-flex justify-end flex-wrap gap-4">
<VBtn variant="outlined" color="secondary" @click="show = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn type="submit">
<template #prepend>
<VIcon icon="mdi-check" />
</template>
{{ t('common.confirm') }}
</VBtn>
</div>
</VForm>
</template>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,321 @@
<script lang="ts" setup>
import { bufferToBase64Url, base64UrlToUint8Array } from '@/@core/utils/navigator'
import { useToast } from 'vue-toastification'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { formatDateDifference } from '@core/utils/formatters'
import api from '@/api'
import type { ApiResponse, PassKey } from '@/api/types'
import { useGlobalSettingsStore } from '@/stores'
interface Props {
modelValue: boolean
isOtp: boolean
}
// WebAuthn 相关接口定义
interface PublicKeyCredentialDescriptorJSON {
id: string
type: 'public-key'
transports?: AuthenticatorTransport[]
}
const props = defineProps<Props>()
const emit = defineEmits(['update:modelValue', 'update:passkeyList', 'verifyPassword'])
const { t, locale } = useI18n()
const display = useDisplay()
const $toast = useToast()
const globalSettingsStore = useGlobalSettingsStore()
// 内部状态
const show = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
// PassKey列表
const passkeyList = ref<PassKey[]>([])
// PassKey注册loading
const passkeyRegistering = ref(false)
// PassKey名称
const passkeyName = ref('')
// PassKey challenge
const passkeyChallenge = ref('')
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
const canRegisterPasskey = computed(() => props.isOtp || allowPasskeyWithoutOtp.value)
// 格式化日期
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString(locale.value)
}
// 获取PassKey列表
async function fetchPassKeyList() {
try {
const result = (await api.get('mfa/passkey/list')) as ApiResponse<PassKey[]>
if (result.success) {
passkeyList.value = result.data || []
emit('update:passkeyList', passkeyList.value)
}
} catch (error) {
console.error(error)
}
}
// 注册PassKey
async function registerPassKey() {
if (!passkeyName.value) {
$toast.error(t('profile.passkeyNameRequired'))
return
}
// 检查浏览器环境
if (!window.PublicKeyCredential) {
if (!window.isSecureContext) {
$toast.error(t('login.passkeySecureContextRequired'))
} else {
$toast.error(t('login.passkeyNotSupported'))
}
return
}
passkeyRegistering.value = true
try {
// 1. 开始注册
const startResult = (await api.post('mfa/passkey/register/start', {
name: passkeyName.value,
})) as ApiResponse<{ options: string; challenge: string }>
if (!startResult.success) {
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
return
}
const { options, challenge } = startResult.data
const publicKeyOptions = JSON.parse(options)
passkeyChallenge.value = challenge
// 2. 调用WebAuthn API
const credential = (await navigator.credentials.create({
publicKey: {
...publicKeyOptions,
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
user: {
...publicKeyOptions.user,
id: base64UrlToUint8Array(publicKeyOptions.user.id),
},
excludeCredentials: publicKeyOptions.excludeCredentials?.map((cred: PublicKeyCredentialDescriptorJSON) => ({
...cred,
id: base64UrlToUint8Array(cred.id),
})),
},
})) as PublicKeyCredential
if (!credential) {
$toast.error(t('profile.passkeyRegisterCancelled'))
return
}
// 3. 转换credential为可传输格式
const response = credential.response as AuthenticatorAttestationResponse
const credentialJSON = {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufferToBase64Url(response.attestationObject),
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
transports: typeof response.getTransports === 'function' ? response.getTransports() : [],
},
}
// 4. 完成注册
const finishResult = (await api.post('mfa/passkey/register/finish', {
credential: credentialJSON,
challenge: passkeyChallenge.value,
name: passkeyName.value,
})) as ApiResponse
if (finishResult.success) {
$toast.success(t('profile.passkeyRegisterSuccess'))
passkeyName.value = ''
await fetchPassKeyList()
} else {
$toast.error(finishResult.message || t('profile.passkeyRegisterFailed'))
}
} catch (error: any) {
console.error('PassKey注册失败:', error)
if (error.name === 'NotAllowedError') {
$toast.error(t('profile.passkeyRegisterCancelled'))
} else if (error.name === 'NotSupportedError') {
$toast.error(t('login.passkeyNotSupported'))
} else if (error.message?.includes('start failed')) {
$toast.error(t('login.passkeyLoginStartFailed'))
} else if (error.response) {
$toast.error(error.response.data?.detail || t('profile.passkeyRegisterFailed'))
} else {
$toast.error(error.message || t('profile.passkeyRegisterFailed'))
}
} finally {
passkeyRegistering.value = false
}
}
// 删除PassKey
async function deletePassKey(passkeyId: number) {
emit('verifyPassword', {
title: t('profile.deletePasskey'),
text: t('profile.confirmToDeletePasskey'),
callback: async (password: string) => {
try {
const result = (await api.post('mfa/passkey/delete', {
passkey_id: passkeyId,
password,
})) as ApiResponse
if (result.success) {
$toast.success(t('profile.passkeyDeleteSuccess'))
await fetchPassKeyList()
} else {
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
}
} catch (error) {
console.error(error)
$toast.error(t('profile.passkeyDeleteFailed'))
}
},
})
}
// 监听弹窗打开,自动加载列表
watch(
() => props.modelValue,
val => {
if (val) {
fetchPassKeyList()
passkeyName.value = ''
} else {
// 弹窗关闭时,清空数据
passkeyName.value = ''
passkeyChallenge.value = ''
passkeyList.value = []
}
},
)
</script>
<template>
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="material-symbols:passkey" class="me-2" />
{{ t('profile.passkeyManagement') }}
</VCardTitle>
<VDialogCloseBtn @click="show = false" />
</VCardItem>
<VDivider />
<VCardText>
<p class="mb-6">
{{ t('profile.passkeyAppDescription') }}
</p>
<!-- 安全警告 -->
<VAlert type="warning" variant="tonal" class="mb-6" icon="mdi-alert">
<i18n-t keypath="profile.passkeyDomainWarning" tag="span">
<template #domain>
<b>{{ t('profile.accessDomain') }}</b>
</template>
</i18n-t>
</VAlert>
<!-- 注册新通行密钥 -->
<VCard v-if="canRegisterPasskey" variant="tonal" class="mb-6">
<VCardText>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registerNewPasskey') }}</h5>
<p class="mb-4">{{ t('profile.passkeyDescription') }}</p>
<VForm @submit.prevent="registerPassKey">
<VTextField
v-model="passkeyName"
:label="t('profile.passkeyName')"
:placeholder="t('profile.passkeyNamePlaceholder')"
class="mb-4"
variant="outlined"
prepend-inner-icon="mdi-form-textbox"
/>
<VBtn color="primary" type="submit" :loading="passkeyRegistering" prepend-icon="mdi-plus">
{{ t('profile.registerPasskey') }}
</VBtn>
</VForm>
</VCardText>
</VCard>
<!-- 未启用 OTP 提示 -->
<VAlert v-else type="error" variant="tonal" class="mb-6" icon="mdi-shield-lock">
<i18n-t keypath="profile.otpRequiredForPasskey" tag="span">
<template #otp>
<b>{{ t('profile.otpAuthenticator') }}</b>
</template>
</i18n-t>
</VAlert>
<!-- 已注册的通行密钥列表 -->
<div v-if="passkeyList.length > 0" class="mt-6 px-4">
<div
v-for="passkey in passkeyList"
:key="passkey.id"
class="py-4 d-flex align-center justify-space-between border-b last:border-0"
>
<div>
<div class="text-body-1 font-weight-bold mb-1">{{ passkey.name }}</div>
<div class="text-caption text-disabled d-flex flex-wrap gap-x-3">
<span>{{ t('profile.createdAt') }} {{ formatDate(passkey.created_at) }}</span>
<span v-if="passkey.last_used_at">
{{ t('profile.lastUsedAt') }} {{ formatDateDifference(passkey.last_used_at) }}
</span>
</div>
</div>
<div>
<VBtn
variant="flat"
color="error"
size="small"
class="rounded delete-btn"
@click="deletePassKey(passkey.id)"
>
<VIcon icon="mdi-trash-can-outline" size="20" />
</VBtn>
</div>
</div>
</div>
<VAlert v-else type="info" variant="tonal" class="mt-6">
{{ t('profile.noPasskeys') }}
</VAlert>
</VCardText>
<VCardActions class="justify-end px-6 pb-4">
<VBtn variant="outlined" @click="show = false">{{ t('common.close') }}</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.v-btn.delete-btn {
min-width: 45px;
padding: 0;
background-color: rgba(var(--v-theme-error), 0.1);
color: rgb(var(--v-theme-error));
transition: all 0.2s ease;
}
.v-btn.delete-btn:hover {
background-color: rgba(var(--v-theme-error), 0.2);
color: rgb(var(--v-theme-error));
}
</style>

View File

@@ -148,7 +148,7 @@ onBeforeMount(async () => {
})
</script>
<template>
<DialogWrapper scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 -->
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
<VDialogCloseBtn @click="emit('close')" />
@@ -187,5 +187,5 @@ onBeforeMount(async () => {
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</DialogWrapper>
</VDialog>
</template>

View File

@@ -124,7 +124,7 @@ onMounted(() => {
})
</script>
<template>
<DialogWrapper scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 -->
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
<VDialogCloseBtn @click="emit('close')" />
@@ -160,5 +160,5 @@ onMounted(() => {
/>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -1,51 +1,36 @@
<script lang="ts" setup>
import api from '@/api'
import draggable from 'vuedraggable'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
const $toast = useToast()
// 插件仓库设置字符串
const repoString = ref('')
// 用于显示的仓库地址数组
const repoArray = ref<string[]>([])
const repoList = ref<string[]>([])
const newRepoUrl = ref('')
const editingIndex = ref<number | null>(null)
const editingUrl = ref('')
// 计算属性:在数组和换行符分隔的字符串之间转换
const displayRepos = computed({
get: () => repoArray.value.join('\n'),
set: (value: string) => {
repoArray.value = value.split('\n').filter((repo: string) => repo.trim() !== '')
},
})
// 定义事件
const emit = defineEmits(['save', 'close'])
// 查询已设置的插件仓库
async function queryMarketRepoSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
if (result && result.data && result.data.value) {
repoString.value = result.data.value
repoArray.value = result.data.value.split(',').filter((repo: string) => repo.trim() !== '')
repoList.value = result.data.value.split(',').filter((repo: string) => repo.trim() !== '')
}
} catch (error) {
console.log(error)
}
}
// 保存设置
async function saveHandle() {
try {
// 将数组转换为逗号分隔的字符串
const repoStringToSave = repoArray.value.join(',')
const repoStringToSave = repoList.value.join(',')
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)
if (result.success) {
@@ -57,14 +42,84 @@ async function saveHandle() {
}
}
function addRepo() {
const url = newRepoUrl.value.trim()
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
return
}
if (repoList.value.includes(url)) {
$toast.error(t('dialog.pluginMarketSetting.duplicateUrl'))
return
}
repoList.value.push(url)
newRepoUrl.value = ''
}
function removeRepo(index: number) {
repoList.value.splice(index, 1)
}
function startEdit(index: number) {
editingIndex.value = index
editingUrl.value = repoList.value[index]
}
function saveEdit() {
if (editingIndex.value === null) return
const url = editingUrl.value.trim()
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
return
}
repoList.value[editingIndex.value] = url
editingIndex.value = null
editingUrl.value = ''
}
function cancelEdit() {
editingIndex.value = null
editingUrl.value = ''
}
function formatRepoDisplay(url: string) {
try {
const parsedUrl = new URL(url)
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean)
if (
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname)
&& pathSegments.length >= 2
) {
return `${pathSegments[0]}/${pathSegments[1].replace(/\.git$/, '')}`
}
} catch {
// Ignore malformed URLs and fall back to the original value.
}
return url
}
function repoItemKey(repo: string) {
return repo
}
onMounted(() => {
queryMarketRepoSetting()
})
</script>
<template>
<DialogWrapper width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard class="plugin-market-dialog-card">
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-store-cog" class="me-2" />
@@ -73,21 +128,127 @@ onMounted(() => {
<VDialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText class="pt-2">
<VTextarea
v-model="displayRepos"
:placeholder="t('dialog.pluginMarketSetting.repoPlaceholder')"
:hint="t('dialog.pluginMarketSetting.repoHint')"
persistent-hint
auto-grow
/>
<VCardText class="plugin-market-dialog-body pt-4">
<div class="plugin-market-input mb-4">
<VTextField
v-model="newRepoUrl"
density="compact"
:placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')"
prepend-inner-icon="mdi-link-plus"
clearable
@keyup.enter="addRepo"
>
<template #append>
<VBtn icon="mdi-plus" variant="text" color="primary" @click="addRepo" />
</template>
</VTextField>
</div>
<div class="plugin-market-list-wrap">
<VList v-if="repoList.length > 0" class="px-0">
<draggable
v-model="repoList"
:item-key="repoItemKey"
handle=".drag-handle"
animation="200"
:disabled="editingIndex !== null"
>
<template #item="{ element: repo, index }">
<div>
<VListItem class="py-2">
<template #prepend>
<VBtn
icon="mdi-drag-vertical"
size="small"
variant="text"
color="primary"
class="drag-handle me-2"
:disabled="editingIndex !== null"
/>
</template>
<VListItemTitle v-if="editingIndex !== index">
<span class="text-truncate" :title="repo">{{ formatRepoDisplay(repo) }}</span>
</VListItemTitle>
<VTextField
v-else
v-model="editingUrl"
density="compact"
variant="outlined"
hide-details
@keyup.enter="saveEdit"
@keyup.escape="cancelEdit"
/>
<template #append v-if="editingIndex !== index">
<div class="d-flex align-center">
<IconBtn icon="mdi-pencil" size="small" variant="text" @click="startEdit(index)" />
<IconBtn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="removeRepo(index)"
/>
</div>
</template>
<template #append v-else>
<div class="d-flex align-center">
<IconBtn icon="mdi-check" size="small" variant="text" color="success" @click="saveEdit" />
<IconBtn icon="mdi-close" size="small" variant="text" @click="cancelEdit" />
</div>
</template>
</VListItem>
<VDivider v-if="index < repoList.length - 1" class="mx-4" />
</div>
</template>
</draggable>
</VList>
<div v-else class="text-center text-medium-emphasis py-8">
<VIcon icon="mdi-folder-open-outline" size="48" class="mb-2" />
<div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div>
</div>
</div>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn @click="saveHandle" prepend-icon="mdi-content-save-check" class="px-5 me-3">
<VBtn
@click="saveHandle"
prepend-icon="mdi-content-save-check"
class="px-5 me-3"
:disabled="repoList.length === 0"
>
{{ t('dialog.pluginMarketSetting.save') }}
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>
<style scoped lang="scss">
.plugin-market-dialog-card {
display: flex;
flex-direction: column;
}
.plugin-market-dialog-body {
display: flex;
overflow: hidden;
flex: 1;
flex-direction: column;
min-block-size: 0;
}
.plugin-market-input {
flex-shrink: 0;
}
.plugin-market-list-wrap {
flex: 1;
min-block-size: 0;
overflow-y: auto;
}
</style>

View File

@@ -10,12 +10,12 @@ const props = defineProps({
</script>
<template>
<!-- Progress Dialog -->
<DialogWrapper :scrim="false" width="25rem">
<VDialog :scrim="false" width="25rem">
<VCard elevation="3" color="primary">
<VCardText class="text-center">
{{ props.text || t('dialog.progress.processing') }}
<VProgressLinear color="white" class="mb-0 mt-1" :model-value="props.value" indeterminate />
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -57,7 +57,7 @@ async function handleReset() {
</script>
<template>
<DialogWrapper width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
@@ -99,5 +99,5 @@ async function handleReset() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -10,6 +10,7 @@ import { FileItem, StorageConf, TransferDirectoryConf, TransferForm } from '@/ap
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import CryptoJS from 'crypto-js'
// 国际化
const { t } = useI18n()
@@ -63,6 +64,9 @@ const progressText = ref(t('dialog.reorganize.processing'))
// 整理进度
const progressValue = ref(0)
// 进度SSE连接
const progressSSE = ref<any>(null)
// 所有存储
const storages = ref<StorageConf[]>([])
@@ -200,25 +204,31 @@ function handleProgressMessage(event: MessageEvent) {
}
}
// 使用优化的进度SSE连接
const progressSSE = useProgressSSE(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`,
handleProgressMessage,
'reorganize-progress',
progressActive
)
// 使用SSE监听加载进度
function startLoadingProgress() {
function startLoadingProgress(key: string) {
progressText.value = t('dialog.reorganize.processing')
progressActive.value = true
progressSSE.start()
// 如果已经有连接,先停止
if (progressSSE.value) {
progressSSE.value.stop()
}
const url = `${import.meta.env.VITE_API_BASE_URL}system/progress/${key}`
// 创建新的SSE连接
progressSSE.value = useProgressSSE(url, handleProgressMessage, `reorganize-progress-${key}`, progressActive)
progressSSE.value.start()
}
// 停止监听加载进度
function stopLoadingProgress() {
progressActive.value = false
progressSSE.stop()
if (progressSSE.value) {
progressSSE.value.stop()
progressSSE.value = null
}
}
// 整理文件
@@ -228,25 +238,30 @@ async function transfer(background: boolean = false) {
// 显示进度条
progressDialog.value = true
if (!background) {
// 开始监听进度
startLoadingProgress()
}
// 文件整理
if (props.items) {
for (const item of props.items) {
if (!background) {
// 如果是文件计算MD5
const key = item.type === 'dir' ? 'filetransfer' : CryptoJS.MD5(item.path).toString()
// 开始监听进度
startLoadingProgress(key)
}
await handleTransfer(item, background)
}
}
// 日志整理
if (props.logids) {
if (!background) {
// 为日志整理任务开启进度监听
startLoadingProgress('filetransfer')
}
for (const logid of props.logids) {
await handleTransferLog(logid, background)
}
}
if (!background) {
// 停止监听进度
stopLoadingProgress()
@@ -269,7 +284,7 @@ onUnmounted(() => {
</script>
<template>
<DialogWrapper scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<template #prepend> <VIcon icon="mdi-folder-move" class="me-2" /> </template>
@@ -487,7 +502,7 @@ onUnmounted(() => {
<!-- 手动整理进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
<!-- TMDB ID搜索框 -->
<DialogWrapper v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
<MediaIdSelector
v-if="mediaSource === 'themoviedb'"
v-model="transferForm.tmdbid"
@@ -500,6 +515,6 @@ onUnmounted(() => {
@close="mediaSelectorDialog = false"
:type="mediaSource"
/>
</DialogWrapper>
</DialogWrapper>
</VDialog>
</VDialog>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -56,7 +56,7 @@ const filteredSites = computed(() => {
</script>
<template>
<!-- Site Selection Dialog -->
<DialogWrapper max-width="40rem" fullscreen-mobile>
<VDialog max-width="40rem" fullscreen-mobile>
<VCard class="site-dialog">
<VCardItem>
<template #prepend>
@@ -169,7 +169,7 @@ const filteredSites = computed(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>
<style scoped>
.site-checkbox-wrapper {

View File

@@ -140,14 +140,14 @@ onMounted(async () => {
await fetchSiteInfo()
if (siteForm.value.limit_interval || siteForm.value.limit_count || siteForm.value.limit_seconds)
isLimit.value = true
if (siteForm.value.apikey) siteType.value = 'api'
if (siteForm.value.apikey || siteForm.value.token) siteType.value = 'api'
}
await loadDownloaderSetting()
})
</script>
<template>
<DialogWrapper scrollable :close-on-back="false" eager max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable :close-on-back="false" eager max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem :class="props.oper === 'add' ? 'py-3' : 'py-2'">
<template #prepend>
@@ -203,7 +203,7 @@ onMounted(async () => {
prepend-inner-icon="mdi-rss"
/>
</VCol>
<VCol cols="12" md="3">
<VCol cols="6" md="3">
<VTextField
v-model="siteForm.timeout"
:label="t('site.fields.timeout')"
@@ -224,15 +224,15 @@ onMounted(async () => {
</VCol>
</VRow>
<VTabs v-model="siteType" show-arrows class="v-tabs-pill mt-3">
<VTab selected-class="v-tab--selected">
<VTab value="cookie" selected-class="v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-cookie" value="cookie" />
<VIcon size="20" start icon="mdi-cookie" />
Cookie
</div>
</VTab>
<VTab selected-class="v-tab--selected">
<VTab value="api" selected-class="v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-api" value="api" />
<VIcon size="20" start icon="mdi-api" />
API
</div>
</VTab>
@@ -350,5 +350,5 @@ onMounted(async () => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -71,7 +71,7 @@ async function updateSiteCookie() {
}
</script>
<template>
<DialogWrapper max-width="30rem" scrollable>
<VDialog max-width="30rem" scrollable>
<!-- Dialog Content -->
<VCard :title="t('dialog.siteCookieUpdate.title')">
<VDialogCloseBtn @click="emit('close')" />
@@ -114,5 +114,5 @@ async function updateSiteCookie() {
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</DialogWrapper>
</VDialog>
</template>

View File

@@ -0,0 +1,423 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import type { Site } from '@/api/types'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 提示框
const $toast = useToast()
// 注册事件
const emit = defineEmits(['update:modelValue', 'import-success'])
// 界面阶段枚举
enum ImportStage {
SELECT_FILE = 'select_file', // 选择文件阶段
PREVIEW_FILE = 'preview_file', // 文件预览阶段
IMPORTING = 'importing', // 正在导入阶段
IMPORT_COMPLETE = 'import_complete', // 导入完成阶段
}
// 当前阶段
const currentStage = ref<ImportStage>(ImportStage.SELECT_FILE)
// 是否拖拽中
const isDragging = ref(false)
// 导入的文件数据
const importData = ref<Site[]>([])
// 导入进度
const importProgress = ref(0)
// 预览数据
const previewData = ref<Site[]>([])
// 选中的文件
const selectedFile = ref<File | null>(null)
// 导入错误信息
const importErrors = ref<Array<{ site: Site; error: string }>>([])
// 导入成功的站点
const importSuccesses = ref<Site[]>([])
// 是否显示错误详情
const showErrorDetails = ref(false)
// 处理拖拽事件
function handleDragOver(event: DragEvent) {
event.preventDefault()
isDragging.value = true
}
function handleDragLeave(event: DragEvent) {
event.preventDefault()
isDragging.value = false
}
async function handleDrop(event: DragEvent) {
event.preventDefault()
isDragging.value = false
const files = event.dataTransfer?.files
if (files && files.length > 0) {
const file = files[0]
if (file.type === 'application/json' || file.name.endsWith('.json')) {
selectedFile.value = file
await processFile(file)
} else {
$toast.error(t('site.messages.invalidFileType'))
}
}
}
// 处理文件
async function processFile(file: File) {
try {
const text = await file.text()
const data = JSON.parse(text)
if (Array.isArray(data)) {
importData.value = data
previewData.value = data.slice(0, 5) // 只显示前5个站点作为预览
currentStage.value = ImportStage.PREVIEW_FILE
} else {
$toast.error(t('site.messages.invalidFileFormat'))
}
} catch (error) {
console.error('Parse file error:', error)
$toast.error(t('site.messages.parseFileError'))
}
}
// 验证站点数据
function validateSiteData(site: any): boolean {
const requiredFields = ['name', 'domain', 'url']
return requiredFields.every(field => site[field])
}
// 批量导入站点
async function importSites() {
if (importData.value.length === 0) {
$toast.error(t('site.messages.noDataToImport'))
return
}
// 验证数据
const validSites = importData.value.filter(validateSiteData)
if (validSites.length === 0) {
$toast.error(t('site.messages.noValidData'))
return
}
if (validSites.length !== importData.value.length) {
$toast.warning(t('site.messages.someInvalidData', { valid: validSites.length, total: importData.value.length }))
}
// 进入导入阶段
currentStage.value = ImportStage.IMPORTING
startNProgress()
importProgress.value = 0
try {
let successCount = 0
let failCount = 0
importErrors.value = [] // 清空之前的错误信息
importSuccesses.value = [] // 清空之前的成功信息
for (let i = 0; i < validSites.length; i++) {
const site = validSites[i]
try {
// 移除id字段避免冲突
const { id, ...siteData } = site
const result: { success: boolean; message?: string } = await api.post('site/', siteData)
if (result.success) {
// 记录成功的站点
successCount++
importSuccesses.value.push(site)
} else {
failCount++
// 记录失败信息
importErrors.value.push({
site,
error: result.message || t('site.messages.importFailed'),
})
}
} catch (error) {
console.error(`Import site ${site.name} failed:`, error)
failCount++
// 记录错误信息
importErrors.value.push({
site,
error: error instanceof Error ? error.message : t('site.messages.importFailed'),
})
}
// 更新进度
importProgress.value = Math.round(((i + 1) / validSites.length) * 100)
}
// 进入完成阶段
currentStage.value = ImportStage.IMPORT_COMPLETE
// 显示导入结果
if (failCount === 0 && successCount > 0) {
// 全部成功,直接关闭对话框
$toast.success(t('site.messages.importSuccess', { count: successCount }))
closeDialog(true)
} else if (successCount === 0 && failCount > 0) {
// 全部失败的情况
$toast.error(t('site.messages.importAllFailed', { count: failCount }))
showErrorDetails.value = true
} else {
// 部分成功部分失败的情况
$toast.error(t('site.messages.importPartialFailed', { success: successCount, failed: failCount }))
showErrorDetails.value = true
}
} catch (error) {
console.error('Import sites failed:', error)
$toast.error(t('site.messages.importFailed'))
// 出错时回到预览阶段
currentStage.value = ImportStage.PREVIEW_FILE
} finally {
doneNProgress()
}
}
// 重置到文件选择阶段
function resetToFileSelection() {
currentStage.value = ImportStage.SELECT_FILE
importData.value = []
previewData.value = []
importProgress.value = 0
isDragging.value = false
selectedFile.value = null
importErrors.value = []
importSuccesses.value = []
showErrorDetails.value = false
}
// 关闭对话框
function closeDialog(success: boolean = false) {
if (success) {
emit('import-success')
}
emit('update:modelValue', false)
}
// 监听文件选择
watch(selectedFile, async newFile => {
if (newFile) {
await processFile(newFile)
}
})
</script>
<template>
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-upload" class="me-2" />
</template>
<VCardTitle>{{ t('site.actions.import') }}</VCardTitle>
<VCardSubtitle>{{ t('site.hints.import') }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="closeDialog" />
<VDivider />
<VCardText>
<!-- 阶段1选择文件阶段 -->
<div v-if="currentStage === ImportStage.SELECT_FILE" class="upload-area">
<div
class="upload-zone"
:class="{ 'dragging': isDragging }"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<VFileInput
v-model="selectedFile"
accept=".json"
:label="t('site.fields.selectFile')"
:hint="t('site.hints.selectFile')"
persistent-hint
prepend-icon="mdi-file-upload"
/>
<div class="text-center mt-4">
<VIcon icon="mdi-cloud-upload" size="48" color="primary" />
<p class="text-body-1 mt-2">{{ t('site.hints.dragDropFile') }}</p>
<p class="text-caption text-medium-emphasis">{{ t('site.hints.supportedFormat') }}</p>
</div>
</div>
</div>
<!-- 阶段2文件预览阶段 -->
<div v-if="currentStage === ImportStage.PREVIEW_FILE" class="preview-area">
<VAlert
type="info"
variant="tonal"
class="mb-4"
:text="t('site.messages.previewData', { count: importData.length })"
/>
<!-- 预览列表 -->
<VCard variant="outlined" class="mb-4">
<VCardTitle class="text-subtitle-1">
{{ t('site.preview.title') }} ({{
t('site.preview.showing', { count: previewData.length, total: importData.length })
}})
</VCardTitle>
<VCardText>
<VList>
<VListItem
v-for="(site, index) in previewData"
:key="index"
:class="{ 'border-error': !validateSiteData(site) }"
>
<template #prepend>
<VIcon
:icon="validateSiteData(site) ? 'mdi-check-circle' : 'mdi-alert-circle'"
:color="validateSiteData(site) ? 'success' : 'error'"
/>
</template>
<VListItemTitle>{{ site.name || t('site.preview.unnamed') }}</VListItemTitle>
<VListItemSubtitle>{{ site.url || t('site.preview.noUrl') }}</VListItemSubtitle>
<template #append>
<VChip v-if="!validateSiteData(site)" size="small" color="error" variant="tonal">
{{ t('site.preview.invalid') }}
</VChip>
</template>
</VListItem>
</VList>
</VCardText>
</VCard>
<!-- 操作按钮 -->
<div class="d-flex justify-end gap-2">
<VBtn variant="text" @click="resetToFileSelection">
{{ t('common.reset') }}
</VBtn>
<VBtn color="primary" @click="importSites" :disabled="importData.length === 0">
{{ t('site.actions.startImport') }}
</VBtn>
</div>
</div>
<!-- 阶段3正在导入阶段 -->
<div v-if="currentStage === ImportStage.IMPORTING" class="importing-area">
<VAlert
type="info"
variant="tonal"
class="mb-4"
:text="t('site.messages.importing', { progress: importProgress })"
/>
<!-- 导入进度 -->
<VCard variant="outlined" class="mb-4">
<VCardTitle class="text-subtitle-1">
{{ t('site.messages.importing', { progress: importProgress }) }}
</VCardTitle>
<VCardText>
<VProgressLinear v-model="importProgress" color="primary" height="8" rounded class="mb-2" />
<p class="text-caption text-center">{{ importProgress }}%</p>
</VCardText>
</VCard>
</div>
<!-- 阶段4导入完成阶段 -->
<div v-if="currentStage === ImportStage.IMPORT_COMPLETE" class="result-area">
<!-- 成功导入的站点 -->
<div v-if="importSuccesses.length > 0" class="success-sites mb-4">
<VAlert
type="success"
variant="tonal"
class="mb-4"
:text="t('site.messages.importSuccess', { count: importSuccesses.length })"
/>
</div>
<!-- 错误详情 -->
<div v-if="showErrorDetails && importErrors.length > 0" class="error-details">
<VAlert
type="error"
variant="tonal"
class="mb-4"
:text="t('site.messages.importErrors', { count: importErrors.length })"
/>
<VCard variant="outlined" class="mb-4">
<VCardTitle class="text-subtitle-1 d-flex align-center justify-space-between">
{{ t('site.errors.title') }}
</VCardTitle>
<!-- 错误信息详情 -->
<VExpansionPanels class="mt-4">
<VExpansionPanel v-for="(error, index) in importErrors" :key="index">
<VExpansionPanelTitle>
{{ error.site.name || t('site.preview.unnamed') }} - {{ t('site.errors.details') }}
</VExpansionPanelTitle>
<VExpansionPanelText>
<VAlert type="error" variant="text" :text="error.error" class="mb-0" />
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</VCard>
</div>
<!-- 操作按钮 -->
<div class="d-flex justify-end gap-2">
<VBtn variant="text" @click="resetToFileSelection">
{{ t('common.reset') }}
</VBtn>
<VBtn color="primary" @click="closeDialog(false)">
{{ t('common.close') }}
</VBtn>
</div>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
.upload-area {
padding: 2rem;
}
.upload-zone {
padding: 2rem;
border: 2px dashed #ccc;
border-radius: 8px;
text-align: center;
transition: all 0.3s ease;
}
.upload-zone.dragging {
border-color: rgb(var(--v-theme-primary));
background-color: rgba(var(--v-theme-primary), 0.05);
}
.error-details {
margin-block: 1rem;
margin-inline: 0;
}
.error-details .v-expansion-panels {
background: transparent;
}
.border-success {
border-inline-start: 4px solid rgb(var(--v-theme-success));
}
.border-error {
border-inline-start: 4px solid rgb(var(--v-theme-error));
}
</style>

View File

@@ -1,13 +1,16 @@
<script setup lang="ts">
import { Site } from '@/api/types'
import api from '@/api'
import type { TorrentInfo, SiteCategory } from '@/api/types'
import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
import { formatFileSize } from '@core/utils/formatters'
import { useDisplay } from 'vuetify'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
const { t, locale } = useI18n()
// 响应式断点
const display = useDisplay()
// 输入参数
const props = defineProps({
@@ -23,6 +26,30 @@ const selectCategory = ref<number[]>([])
// 全部分类
const siteCategoryList = ref<SiteCategory[]>()
// 注册事件
const emit = defineEmits(['close'])
// 数据列表
const resourceDataList = ref<TorrentInfo[]>([])
// 每页条数
const resourceItemsPerPage = ref(25)
// 当前页
const resourcePage = ref(1)
// 加载状态
const resourceLoading = ref(false)
// 移动端搜索栏是否展开
const mobileSearchExpanded = ref(false)
// 种子元数据
const torrent = ref<TorrentInfo>()
// 添加下载对话框
const addDownloadDialog = ref(false)
// 分类选项
const categoryOptions = computed(() => {
return siteCategoryList.value?.map(item => {
@@ -30,77 +57,85 @@ const categoryOptions = computed(() => {
})
})
// 注册事件
const emit = defineEmits(['close'])
// 数据列表
const resourceDataList = ref<TorrentInfo[]>([])
// 搜索
const resourceSearch = ref('')
// 总条数
const resourceTotalItems = ref(0)
// 每页条数
const resourceItemsPerPage = ref(25)
// 加载状态
const resourceLoading = ref(false)
// 种子元数据
const torrent = ref<TorrentInfo>()
const resourceTotalItems = computed(() => resourceDataList.value.length)
// 资源浏览表头
const resourceHeaders = [
const resourceHeaders = computed(() => [
{ title: t('dialog.siteResource.titleColumn'), key: 'title', sortable: false },
{ title: t('dialog.siteResource.timeColumn'), key: 'pubdate', sortable: true },
{ title: t('dialog.siteResource.sizeColumn'), key: 'size', sortable: true },
{ title: t('dialog.siteResource.seedersColumn'), key: 'seeders', sortable: true },
{ title: t('dialog.siteResource.peersColumn'), key: 'peers', sortable: true },
{ title: '', key: 'actions', sortable: false },
]
])
// 输入框标签
const keywordFieldLabel = computed(() => {
return keyword.value ? '' : t('dialog.siteResource.searchKeyword')
})
const categoryFieldLabel = computed(() => {
return selectCategory.value.length > 0 ? '' : t('dialog.siteResource.resourceCategory')
})
// 结果统计文案
const resultSummaryText = computed(() => {
if (locale.value.startsWith('zh')) {
return `${resourceTotalItems.value} 条结果`
}
return `${resourceTotalItems.value} results`
})
// 是否小屏幕
const isMobileLayout = computed(() => display.smAndDown.value)
// 移动端分页数据
const mobileResourceList = computed(() => resourceDataList.value)
// 打开种子详情页面
function openTorrentDetail(page_url: string) {
if (!page_url) return
window.open(page_url, '_blank')
}
// 下载种子文件
async function downloadTorrentFile(enclosure: string) {
if (!enclosure) return
window.open(enclosure, '_blank')
}
// 促销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'
if (downloadVolume < 1) return 'text-white bg-green-500'
if (uploadVolume !== 1) return 'text-white bg-sky-500'
return 'text-white bg-gray-500'
}
// 添加下载
async function addDownload(_torrent: any) {
async function addDownload(_torrent: TorrentInfo) {
torrent.value = _torrent
addDownloadDialog.value = true
}
// 添加下载对话框
const addDownloadDialog = ref(false)
// 添加下载成功
function addDownloadSuccess(url: string) {
function addDownloadSuccess(_url: string) {
addDownloadDialog.value = false
}
// 添加下载失败
function addDownloadError(error: string) {
function addDownloadError(_error: string) {
addDownloadDialog.value = false
}
// 调用API查询站点资源
async function getResourceList() {
resourceLoading.value = true
resourcePage.value = 1
try {
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
params: {
@@ -111,7 +146,12 @@ async function getResourceList() {
} catch (error) {
console.error(error)
}
resourceLoading.value = false
if (isMobileLayout.value) {
mobileSearchExpanded.value = false
}
}
// 加载站点分类
@@ -123,16 +163,44 @@ async function getSiteCategoryList() {
}
}
// 装载时查询站点图标
watch([resourceItemsPerPage, resourceTotalItems, () => display.mdAndUp.value], () => {
if (display.mdAndUp.value) {
const maxPage = Math.max(1, Math.ceil(resourceTotalItems.value / resourceItemsPerPage.value))
if (resourcePage.value > maxPage) {
resourcePage.value = maxPage
}
return
}
})
watch(
() => display.mdAndUp.value,
isDesktop => {
if (isDesktop) {
mobileSearchExpanded.value = false
}
},
)
function toggleMobileSearch() {
mobileSearchExpanded.value = !mobileSearchExpanded.value
}
function closeMobileSearch() {
mobileSearchExpanded.value = false
}
// 装载时查询站点分类和资源
onMounted(() => {
getSiteCategoryList()
getResourceList()
})
</script>
<template>
<DialogWrapper scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
<VCard>
<!-- Toolbar -->
<VDialog scrollable :fullscreen="display.smAndDown.value" max-width="92rem" transition="dialog-bottom-transition">
<VCard class="site-resource-dialog">
<div>
<VToolbar color="primary" density="comfortable">
<VToolbarTitle>{{ t('dialog.siteResource.browseTitle', { name: props.site?.name }) }}</VToolbarTitle>
@@ -144,45 +212,153 @@ onMounted(() => {
</VToolbarItems>
</VToolbar>
</div>
<div class="p-3">
<VRow>
<VCol cols="6" md="5">
<VTextField
v-model="keyword"
size="small"
density="compact"
:label="t('dialog.siteResource.searchKeyword')"
clearable
prepend-inner-icon="mdi-magnify"
/>
</VCol>
<VCol cols="6" md="5">
<VSelect
v-model="selectCategory"
:items="categoryOptions"
size="small"
density="compact"
chips
:label="t('dialog.siteResource.resourceCategory')"
multiple
clearable
prepend-inner-icon="mdi-folder"
/>
</VCol>
<VCol cols="12" md="2" class="text-center">
<VBtn variant="tonal" block prepend-icon="mdi-magnify" @click="getResourceList">
{{ t('dialog.siteResource.search') }}
<div class="pa-3 pb-2">
<template v-if="!isMobileLayout">
<VSheet class="site-resource-filter-panel" rounded="lg" border>
<div class="site-resource-filter-panel__inner">
<VRow class="site-resource-filter-row">
<VCol cols="12" md="4">
<VTextField
v-model="keyword"
class="site-resource-filter-input"
size="small"
density="compact"
variant="solo-filled"
flat
:label="keywordFieldLabel"
clearable
prepend-inner-icon="mdi-magnify"
hide-details
@keyup.enter="getResourceList"
/>
</VCol>
<VCol cols="12" md="5">
<VSelect
v-model="selectCategory"
:items="categoryOptions"
class="site-resource-filter-input"
size="small"
density="compact"
variant="solo-filled"
flat
chips
:label="categoryFieldLabel"
multiple
clearable
prepend-inner-icon="mdi-folder"
hide-details
/>
</VCol>
<VCol cols="12" md="3" class="d-flex align-center">
<VBtn
color="primary"
variant="flat"
block
size="default"
rounded="lg"
prepend-icon="mdi-magnify"
class="site-resource-search-btn"
@click="getResourceList"
>
{{ t('dialog.siteResource.search') }}
</VBtn>
</VCol>
</VRow>
<div
v-if="resourceTotalItems > 0"
class="d-flex justify-space-between align-center flex-wrap gap-2 mt-3"
>
<div class="text-body-2 text-medium-emphasis">
{{ resultSummaryText }}
</div>
<VChip size="small" color="primary" variant="tonal" class="site-resource-result-chip">
{{ resourceTotalItems }}
</VChip>
</div>
</div>
</VSheet>
</template>
<template v-else>
<div class="site-resource-mobile-search">
<VBtn
icon
variant="text"
color="primary"
class="site-resource-mobile-search__toggle"
@click="toggleMobileSearch"
>
<VIcon icon="mdi-magnify" />
</VBtn>
</VCol>
</VRow>
<div v-if="resourceTotalItems > 0" class="text-body-2 text-medium-emphasis">
{{ resultSummaryText }}
</div>
</div>
<VExpandTransition>
<div v-if="mobileSearchExpanded" class="mt-2">
<VSheet class="site-resource-filter-panel" rounded="lg" border>
<div class="site-resource-filter-panel__inner">
<VRow class="site-resource-filter-row">
<VCol cols="12">
<VTextField
v-model="keyword"
class="site-resource-filter-input"
size="small"
density="compact"
variant="solo-filled"
flat
:label="keywordFieldLabel"
clearable
prepend-inner-icon="mdi-magnify"
hide-details
autofocus
@keyup.enter="getResourceList"
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="selectCategory"
:items="categoryOptions"
class="site-resource-filter-input"
size="small"
density="compact"
variant="solo-filled"
flat
chips
:label="categoryFieldLabel"
multiple
clearable
prepend-inner-icon="mdi-folder"
hide-details
/>
</VCol>
<VCol cols="12" class="d-flex gap-2">
<VBtn color="primary" variant="flat" block rounded="lg" class="site-resource-search-btn" @click="getResourceList">
{{ t('dialog.siteResource.search') }}
</VBtn>
<VBtn variant="text" rounded="lg" @click="closeMobileSearch">
{{ t('common.cancel') }}
</VBtn>
</VCol>
</VRow>
</div>
</VSheet>
</div>
</VExpandTransition>
</template>
</div>
<VCardText class="px-0 py-0 my-0">
<VCardText class="site-resource-content px-0 py-0 my-0">
<VDataTable
v-if="display.mdAndUp.value"
v-model:page="resourcePage"
v-model:items-per-page="resourceItemsPerPage"
:headers="resourceHeaders"
:items="resourceDataList"
:items-length="resourceTotalItems"
:search="resourceSearch"
:loading="resourceLoading"
density="compact"
item-value="title"
@@ -191,60 +367,69 @@ onMounted(() => {
hover
:items-per-page-text="t('dialog.siteResource.itemsPerPage')"
:loading-text="t('dialog.siteResource.loading')"
class="h-full"
:items-per-page-options="[10, 25, 50, 100]"
height="100%"
class="h-full site-resource-table"
>
<template #item.title="{ item }">
<a href="javascript:void(0)" @click.stop="addDownload(item)">
<div class="text-high-emphasis pt-1">
<button type="button" class="site-resource-title-btn text-start" @click.stop="addDownload(item)">
<div class="text-high-emphasis pt-1 font-weight-medium">
{{ item.title }}
</div>
<div class="text-sm my-1">
<div v-if="item.description" class="text-sm my-1 text-medium-emphasis">
{{ 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>
<div class="mt-2">
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&amp;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>
</div>
</button>
</template>
<template #item.pubdate="{ item }">
<div>{{ item.date_elapsed }}</div>
<div class="text-sm">
<div class="text-sm text-medium-emphasis">
{{ 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>
@@ -268,11 +453,119 @@ onMounted(() => {
</IconBtn>
</div>
</template>
<template #no-data>{{ t('dialog.siteResource.noData') }}</template>
</VDataTable>
<div v-else class="site-resource-mobile">
<div v-if="resourceLoading" class="px-4 py-6">
<VProgressLinear color="primary" indeterminate rounded />
<div class="text-center text-body-2 text-medium-emphasis mt-3">
{{ t('dialog.siteResource.loading') }}
</div>
</div>
<div v-else-if="mobileResourceList.length > 0" class="px-3 pb-4">
<VCard
v-for="(item, index) in mobileResourceList"
:key="item.page_url || item.enclosure || `${item.title}-${index}`"
class="mb-3"
>
<VCardText class="pa-4">
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
<div class="text-body-1 font-weight-medium text-high-emphasis">
{{ item.title }}
</div>
<div
v-if="item.description"
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
>
{{ item.description }}
</div>
</button>
<div class="mt-3">
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&amp;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, chipIndex) in item.labels"
:key="chipIndex"
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>
</div>
<div class="site-resource-card__meta mt-4">
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
</div>
</div>
<div class="site-resource-card__actions mt-4">
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
{{ t('actionStep.addDownload') }}
</VBtn>
<div class="site-resource-card__secondary-actions mt-2">
<VBtn
variant="tonal"
prepend-icon="mdi-open-in-new"
@click="openTorrentDetail(item.page_url || '')"
>
{{ t('common.viewDetails') }}
</VBtn>
<VBtn
v-if="item.enclosure?.startsWith('http')"
variant="tonal"
prepend-icon="mdi-tray-arrow-down"
@click="downloadTorrentFile(item.enclosure)"
>
{{ t('dialog.siteResource.downloadTorrent') }}
</VBtn>
</div>
</div>
</VCardText>
</VCard>
</div>
<div v-else class="px-4 py-10 text-center text-medium-emphasis">
{{ t('dialog.siteResource.noData') }}
</div>
</div>
</VCardText>
</VCard>
<!-- 添加下载对话框 -->
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
@@ -281,11 +574,164 @@ onMounted(() => {
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</DialogWrapper>
</VDialog>
</template>
<style lang="scss" scoped>
.site-resource-dialog {
display: flex;
flex-direction: column;
overflow: hidden;
}
.site-resource-filter-row {
align-items: center;
}
.site-resource-filter-panel {
border-color: rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
background:
radial-gradient(circle at top left, rgba(var(--v-theme-primary), 0.06), transparent 40%),
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.93));
box-shadow: 0 10px 24px rgba(15, 23, 42, 4%);
}
.site-resource-filter-panel__inner {
padding: 0.75rem 0.85rem;
}
.site-resource-filter-input :deep(.v-field) {
border-radius: 0.75rem;
background: rgba(var(--v-theme-surface), 0.92);
box-shadow: inset 0 0 0 1px rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.8));
}
.site-resource-filter-input :deep(.v-field__prepend-inner) {
color: rgba(var(--v-theme-primary), 0.85);
}
.site-resource-search-btn {
box-shadow: 0 8px 18px rgba(var(--v-theme-primary), 0.18);
letter-spacing: 0.02em;
min-block-size: 40px;
}
.site-resource-result-chip {
font-weight: 600;
}
.site-resource-mobile-search {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.site-resource-mobile-search__toggle {
flex: 0 0 auto;
}
.site-resource-title-btn {
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
inline-size: 100%;
}
.site-resource-content {
flex: 1 1 auto;
min-block-size: 0;
overflow: hidden;
}
.site-resource-table {
block-size: 100%;
}
.site-resource-table :deep(.v-data-table) {
display: flex;
flex-direction: column;
block-size: 100%;
}
.site-resource-table :deep(.v-data-table__wrapper) {
flex: 1 1 auto;
min-block-size: 0;
}
.site-resource-table :deep(.v-table__wrapper) {
flex: 1 1 auto;
min-block-size: 0;
}
.site-resource-table :deep(.v-data-table-footer) {
flex: 0 0 auto;
}
.v-table th {
white-space: nowrap;
}
.site-resource-card__description {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.site-resource-card__meta {
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.site-resource-card__meta-item {
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.7));
border-radius: 0.6rem;
background: rgba(var(--v-theme-surface), 0.78);
min-block-size: 0;
padding-block: 0.55rem;
padding-inline: 0.65rem;
}
.site-resource-card__meta-item :deep(.text-caption) {
font-size: 0.72rem !important;
line-height: 1.2;
}
.site-resource-card__meta-item :deep(.text-body-2) {
font-size: 0.82rem !important;
line-height: 1.25;
}
.site-resource-card__secondary-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.site-resource-card__secondary-actions :deep(.v-btn) {
flex: 1 1 12rem;
}
@media (width >= 960px) {
.site-resource-dialog {
block-size: min(88vh, 960px);
}
}
@media (width <= 959px) {
.site-resource-dialog {
border-radius: 0;
}
.site-resource-filter-panel__inner {
padding: 0.7rem 0.75rem;
}
.site-resource-mobile-search {
min-block-size: 2.5rem;
}
}
</style>

View File

@@ -0,0 +1,478 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import api from '@/api'
import type { Site, SiteStatistic } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
sites: {
type: Array as PropType<Site[]>,
default: () => [],
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue'])
// 站点统计数据
const siteStats = ref<SiteStatistic[]>([])
// 是否加载中
const loading = ref(false)
// 当前选中的站点
const selectedSite = ref<Site | null>(null)
// 耗时记录详情弹窗
const detailDialog = ref(false)
// 获取站点统计数据
async function fetchSiteStats() {
try {
loading.value = true
const response = await api.get('site/statistic')
siteStats.value = Array.isArray(response) ? response : response.data || []
loading.value = false
} catch (error) {
console.error('Failed to fetch site statistics:', error)
loading.value = false
}
}
// 根据站点域名获取统计数据
function getSiteStats(domain: string): SiteStatistic | undefined {
return siteStats.value.find(stat => stat.domain === domain)
}
// 获取站点连接状态
function getConnectionStatus(stats: SiteStatistic | undefined): string {
if (!stats || Object.keys(stats).length === 0) {
return 'unknown'
}
if (stats.lst_state === 1) {
return 'failed'
} else if (stats.lst_state === 0) {
if (!stats.seconds) return 'unknown'
if (stats.seconds >= 5) return 'slow'
return 'connected'
}
return 'unknown'
}
// 获取状态颜色
function getStatusColor(status: string): string {
switch (status) {
case 'connected':
return 'success'
case 'slow':
return 'warning'
case 'failed':
return 'error'
default:
return 'secondary'
}
}
// 获取状态图标
function getStatusIcon(status: string): string {
switch (status) {
case 'connected':
return 'mdi-wifi'
case 'slow':
return 'mdi-wifi-strength-2'
case 'failed':
return 'mdi-wifi-off'
default:
return 'mdi-help-circle'
}
}
// 获取状态文本
function getStatusText(status: string): string {
switch (status) {
case 'connected':
return t('site.connectionNormal')
case 'slow':
return t('site.connectionSlow')
case 'failed':
return t('site.connectionFailed')
default:
return t('site.connectionUnknown')
}
}
// 获取耗时颜色
function getTimeColor(seconds: number | undefined): string {
if (!seconds) return 'secondary'
if (seconds < 2) return 'success'
if (seconds < 5) return 'warning'
return 'error'
}
// 获取成功率(与列表/概览口径一致)
function getSuccessRate(stats: SiteStatistic | undefined): string {
if (!stats) return '-'
const success = Number(stats.success ?? 0)
const fail = Number(stats.fail ?? 0)
const total = success + fail
if (total <= 0) return '-'
return String(Math.round((success / total) * 100))
}
// 解析耗时记录
function parseTimeRecords(note: any): Array<{ time: string; duration: number }> {
if (!note) return []
try {
// note可能是字符串或对象如果是字符串则解析
const records = typeof note === 'string' ? JSON.parse(note) : note
if (typeof records === 'object' && records !== null) {
const result = Object.entries(records)
.map(([time, duration]) => ({
time,
duration: Number(duration) || 0,
}))
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
.slice(0, 10) // 只显示最近10条记录
return result
}
} catch (error) {
console.error('Failed to parse time records:', error)
}
return []
}
// 查看详情
function viewDetail(site: Site) {
selectedSite.value = site
detailDialog.value = true
}
// 关闭弹窗
function closeDialog() {
emit('update:modelValue', false)
}
// 计算属性:按平均耗时排序的站点列表
const sortedSites = computed(() => {
return props.sites
.map(site => {
const stats = getSiteStats(site.domain)
return {
site,
stats,
status: getConnectionStatus(stats),
avgTime: stats?.seconds || 0,
}
})
.sort((a, b) => {
// 先按状态排序connected > slow > failed > unknown
const statusOrder = { connected: 0, slow: 1, failed: 2, unknown: 3 }
const statusDiff =
statusOrder[a.status as keyof typeof statusOrder] - statusOrder[b.status as keyof typeof statusOrder]
if (statusDiff !== 0) return statusDiff
// 再按平均耗时排序
return a.avgTime - b.avgTime
})
})
// 统计总览(与列表口径一致)
const overviewCounts = computed(() => {
const items = sortedSites.value
const total = items.length
const connected = items.filter(i => i.status === 'connected').length
const slow = items.filter(i => i.status === 'slow').length
const failed = items.filter(i => i.status === 'failed').length
const unknown = total - connected - slow - failed
return { total, connected, slow, failed, unknown }
})
onMounted(() => {
fetchSiteStats()
})
</script>
<template>
<VDialog max-width="50rem" :fullscreen="display.smAndDown.value" scrollable>
<VCard>
<!-- 标题栏 -->
<VCardItem>
<VDialogCloseBtn @click="closeDialog" />
<template #prepend>
<VIcon icon="mdi-chart-line" class="me-2" />
</template>
<VCardTitle>
{{ t('site.statistics') }}
</VCardTitle>
</VCardItem>
<VDivider />
<!-- 内容区域 -->
<VCardText class="pa-0">
<LoadingBanner v-if="loading" class="my-8" />
<div v-else class="site-statistics-content">
<!-- 统计概览 -->
<div class="statistics-overview pa-4">
<div class="d-flex flex-wrap gap-4">
<div class="stat-card">
<div class="stat-number">{{ overviewCounts.total }}</div>
<div class="stat-label">{{ t('site.totalSites') }}</div>
</div>
<div class="stat-card">
<div class="stat-number success--text">{{ overviewCounts.connected }}</div>
<div class="stat-label">{{ t('site.normalSites') }}</div>
</div>
<div class="stat-card">
<div class="stat-number warning--text">{{ overviewCounts.slow }}</div>
<div class="stat-label">{{ t('site.slowSites') }}</div>
</div>
<div class="stat-card">
<div class="stat-number error--text">{{ overviewCounts.failed }}</div>
<div class="stat-label">{{ t('site.failedSites') }}</div>
</div>
</div>
</div>
<!-- 站点列表 -->
<div class="sites-list">
<div
v-for="item in sortedSites"
:key="item.site.id"
class="site-item pa-4 border-b"
:class="`border-${getStatusColor(item.status)}`"
>
<div class="d-flex align-center justify-space-between">
<!-- 左侧站点信息 -->
<div class="d-flex align-center flex-1 min-w-0">
<!-- 状态指示器 -->
<div class="status-indicator me-3" :class="getStatusColor(item.status)">
<VIcon :icon="getStatusIcon(item.status)" size="20" />
</div>
<!-- 站点名称和状态 -->
<div class="flex-1 min-w-0">
<div class="d-flex align-center">
<h4 class="text-h6 mb-1 truncate">{{ item.site.name }}</h4>
<VChip :color="getStatusColor(item.status)" size="small" class="ml-2" variant="tonal">
{{ getStatusText(item.status) }}
</VChip>
</div>
<div class="text-caption text-medium-emphasis">{{ item.site.domain }}</div>
</div>
</div>
<!-- 右侧统计信息 -->
<div class="d-flex align-center gap-4">
<!-- 平均耗时 -->
<div class="text-center">
<div class="text-h6 font-weight-bold" :class="`text-${getTimeColor(item.stats?.seconds)}`">
{{ item.stats?.seconds || '-' }}s
</div>
<div class="text-caption text-medium-emphasis">{{ t('site.averageTime') }}</div>
</div>
<!-- 成功率 -->
<div class="text-center">
<div class="text-h6 font-weight-bold">{{ getSuccessRate(item.stats) }}%</div>
<div class="text-caption text-medium-emphasis">{{ t('site.successRate') }}</div>
</div>
<!-- 详情按钮 -->
<VBtn icon variant="text" size="small" @click="viewDetail(item.site)">
<VIcon icon="mdi-information-outline" />
</VBtn>
</div>
</div>
</div>
</div>
</div>
</VCardText>
</VCard>
<!-- 详情弹窗 -->
<VDialog v-model="detailDialog" :max-width="display.mdAndUp.value ? 600 : '95%'" scrollable>
<VCard v-if="selectedSite">
<VCardItem class="py-3">
<template #prepend>
<VIcon icon="mdi-information-outline" class="me-2" />
</template>
<VCardTitle> {{ selectedSite.name }} - {{ t('site.timeRecords') }} </VCardTitle>
<VDialogCloseBtn @click="detailDialog = false" />
</VCardItem>
<VDivider />
<VCardText>
<div v-if="getSiteStats(selectedSite.domain)">
<div class="mb-4">
<h5 class="text-h6 mb-2">{{ t('site.statistics') }}</h5>
<div class="d-flex flex-wrap gap-4">
<div class="stat-item">
<span class="stat-label">{{ t('site.successCount') }}:</span>
<span class="stat-value success--text">
{{ getSiteStats(selectedSite.domain)?.success || 0 }}
</span>
</div>
<div class="stat-item">
<span class="stat-label">{{ t('site.failCount') }}:</span>
<span class="stat-value error--text">
{{ getSiteStats(selectedSite.domain)?.fail || 0 }}
</span>
</div>
<div class="stat-item">
<span class="stat-label">{{ t('site.averageTime') }}:</span>
<span class="stat-value" :class="`text-${getTimeColor(getSiteStats(selectedSite.domain)?.seconds)}`">
{{ getSiteStats(selectedSite.domain)?.seconds || '-' }}s
</span>
</div>
<div class="stat-item">
<span class="stat-label">{{ t('site.lastAccess') }}:</span>
<span class="stat-value">
{{ getSiteStats(selectedSite.domain)?.lst_mod_date || '-' }}
</span>
</div>
</div>
</div>
<div>
<h5 class="text-h6 mb-2">{{ t('site.recentTimeRecords') }}</h5>
<div class="time-records">
<div
v-for="(record, index) in parseTimeRecords(getSiteStats(selectedSite.domain)?.note)"
:key="index"
class="time-record-item pa-3 border rounded mb-2"
:class="`border-${getTimeColor(record.duration)}`"
>
<div class="d-flex justify-space-between align-center">
<div>
<div class="text-body-2 font-weight-medium">{{ record.time }}</div>
<div class="text-caption text-medium-emphasis">{{ t('site.accessTime') }}</div>
</div>
<div class="text-end">
<div class="text-h6 font-weight-bold" :class="`text-${getTimeColor(record.duration)}`">
{{ record.duration }}s
</div>
<div class="text-caption text-medium-emphasis">{{ t('site.responseTime') }}</div>
</div>
</div>
</div>
<div
v-if="parseTimeRecords(getSiteStats(selectedSite.domain)?.note).length === 0"
class="text-center pa-4"
>
<VIcon icon="mdi-information-outline" size="48" color="secondary" class="mb-2" />
<div class="text-body-1 text-medium-emphasis">{{ t('site.noTimeRecords') }}</div>
</div>
</div>
</div>
</div>
</VCardText>
</VCard>
</VDialog>
</VDialog>
</template>
<style scoped>
.statistics-overview {
background: linear-gradient(135deg, var(--v-theme-surface) 0%, var(--v-theme-surface-variant) 100%);
border-block-end: 1px solid var(--v-border-color);
}
.stat-card {
padding: 16px;
border: 1px solid var(--v-border-color);
border-radius: 8px;
background: var(--v-theme-surface);
min-inline-size: 100px;
text-align: center;
}
.stat-number {
font-size: 24px;
font-weight: bold;
line-height: 1;
margin-block-end: 4px;
}
.stat-label {
color: var(--v-theme-on-surface-variant);
font-size: 12px;
}
.sites-list {
background: var(--v-theme-surface);
}
.site-item {
transition: background-color 0.2s ease;
}
.site-item:hover {
background: var(--v-theme-surface-variant);
}
.status-indicator {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--v-theme-surface-variant);
block-size: 40px;
inline-size: 40px;
}
.status-indicator.success {
background: rgba(var(--v-theme-success), 0.1);
color: rgb(var(--v-theme-success));
}
.status-indicator.warning {
background: rgba(var(--v-theme-warning), 0.1);
color: rgb(var(--v-theme-warning));
}
.status-indicator.error {
background: rgba(var(--v-theme-error), 0.1);
color: rgb(var(--v-theme-error));
}
.status-indicator.secondary {
background: rgba(var(--v-theme-secondary), 0.1);
color: rgb(var(--v-theme-secondary));
}
.stat-item {
display: flex;
align-items: center;
gap: 8px;
}
.stat-item .stat-label {
color: var(--v-theme-on-surface-variant);
font-weight: 500;
}
.stat-value {
font-weight: bold;
}
.time-records {
max-block-size: 300px;
overflow-y: auto;
}
.time-record-item {
transition: all 0.2s ease;
}
</style>

View File

@@ -287,11 +287,11 @@ onBeforeMount(() => {
</script>
<template>
<DialogWrapper scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle
>{{ t('dialog.siteUserData.title') }} - {{ props.site?.name }}
<VCardTitle>
{{ t('dialog.siteUserData.title') }} - {{ props.site?.name }}
<IconBtn @click.stop="refreshSiteData" color="info"><VIcon icon="mdi-refresh" /></IconBtn>
</VCardTitle>
<VDialogCloseBtn @click="emit('close')" />
@@ -484,5 +484,5 @@ onBeforeMount(() => {
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="t('dialog.siteUserData.refreshing')" />
</DialogWrapper>
</VDialog>
</template>

View File

@@ -50,7 +50,7 @@ async function saveSmbConfig() {
</script>
<template>
<DialogWrapper width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
@@ -127,5 +127,5 @@ async function saveSmbConfig() {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -99,6 +99,10 @@ function episodeGroupItemProps(item: { title: string; subtitle: string }) {
// 查询所有剧集组
async function getEpisodeGroups() {
if (!subscribeForm.value.tmdbid) {
console.warn('tmdbid is not set or is empty')
return
}
try {
episodeGroups.value = await api.get(`media/groups/${subscribeForm.value.tmdbid}`)
} catch (error) {
@@ -280,10 +284,10 @@ onMounted(() => {
</script>
<template>
<DialogWrapper scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<VDialogCloseBtn @click="emit('close')" />
<VDialogCloseBtn @click="emit('close')" />
<template #prepend>
<VIcon icon="mdi-clipboard-list-outline" class="me-2" />
</template>
@@ -539,5 +543,5 @@ onMounted(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -85,7 +85,7 @@ onBeforeMount(() => {
})
</script>
<template>
<DialogWrapper scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="my-2">
<VDialogCloseBtn @click="emit('close')" />
@@ -206,7 +206,7 @@ onBeforeMount(() => {
</div>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
</template>
<style lang="scss" scoped>

View File

@@ -146,7 +146,7 @@ function getMediaTypeText(type: string | undefined) {
</script>
<template>
<DialogWrapper scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="mx-auto" width="100%">
<VCardItem>
<VCardTitle>{{ t('dialog.subscribeHistory.title', { type: getMediaTypeText(props.type) }) }}</VCardTitle>
@@ -220,5 +220,5 @@ function getMediaTypeText(type: string | undefined) {
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</DialogWrapper>
</VDialog>
</template>

View File

@@ -81,6 +81,10 @@ function getMediaId() {
// 查询所有剧集组
async function getEpisodeGroups() {
if (!props.media?.tmdb_id) {
console.warn('tmdbid is not set or is empty')
return
}
try {
episodeGroups.value = await api.get(`media/groups/${props.media?.tmdb_id}`)
} catch (error) {

View File

@@ -55,7 +55,7 @@ const $toast = useToast()
</script>
<template>
<DialogWrapper scrollable max-width="30rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="30rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<template #prepend>
@@ -112,5 +112,5 @@ const $toast = useToast()
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -0,0 +1,477 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import api from '@/api'
import type { SubscribeShareStatistics } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay, useTheme } from 'vuetify'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 主题
const theme = useTheme()
// 定义事件
const emit = defineEmits(['close'])
// 统计数据
const statistics = ref<SubscribeShareStatistics[]>([])
// 是否加载中
const loading = ref(false)
// 获取统计数据
async function fetchStatistics() {
try {
loading.value = true
const data: SubscribeShareStatistics[] = await api.get('subscribe/share/statistics')
statistics.value = data
} catch (error) {
console.error('获取分享统计数据失败:', error)
} finally {
loading.value = false
}
}
// 计算排名
const rankedStatistics = computed(() => {
return statistics.value
.sort((a, b) => (b.total_reuse_count || 0) - (a.total_reuse_count || 0))
.map((item, index) => ({
...item,
rank: index + 1,
}))
})
// 获取排名样式
function getRankStyle(rank: number) {
if (rank === 1) {
return {
background: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)',
color: '#fff',
fontWeight: 'bold',
}
} else if (rank === 2) {
return {
background: 'linear-gradient(135deg, #CD7F32 0%, #B8860B 100%)',
color: '#fff',
fontWeight: 'bold',
}
} else if (rank === 3) {
return {
background: 'linear-gradient(135deg, #C0C0C0 0%, #A0A0A0 100%)',
color: '#fff',
fontWeight: 'bold',
}
}
return {}
}
// 获取前三名文字颜色
function getPodiumTextColor() {
return theme.global.current.value.dark ? '#fff' : '#000'
}
// 获取前三名统计背景样式
function getPodiumStatStyle() {
const isDark = theme.global.current.value.dark
return {
border: `1px solid ${isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'}`,
background: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
}
}
// 获取前三名区域背景样式
function getPodiumAreaBackgroundStyle() {
const isDark = theme.global.current.value.dark
return {
background: isDark
? 'linear-gradient(135deg, rgba(255, 215, 0, 0.25) 0%, rgba(255, 69, 0, 0.2) 25%, rgba(255, 20, 147, 0.15) 50%, rgba(138, 43, 226, 0.1) 75%, rgba(0, 191, 255, 0.08) 100%), linear-gradient(to bottom, transparent 0%, transparent 70%, rgba(255, 215, 0, 0.1) 85%, transparent 100%)'
: 'linear-gradient(135deg, rgba(255, 215, 0, 0.2) 0%, rgba(255, 69, 0, 0.15) 25%, rgba(255, 20, 147, 0.12) 50%, rgba(138, 43, 226, 0.08) 75%, rgba(0, 191, 255, 0.05) 100%), linear-gradient(to bottom, transparent 0%, transparent 70%, rgba(255, 215, 0, 0.08) 85%, transparent 100%)',
border: 'none',
borderRadius: '0',
padding: '32px 24px 48px 24px',
margin: '0 -24px 0 -',
boxShadow: isDark
? '0 16px 48px rgba(255, 215, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1)'
: '0 16px 48px rgba(255, 215, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.3)',
position: 'relative' as const,
overflow: 'hidden',
}
}
// 获取排名图标
function getRankIcon(rank: number) {
if (rank === 1) return 'mdi-trophy'
if (rank === 2) return 'mdi-medal-outline'
if (rank === 3) return 'mdi-medal'
return ''
}
// 组件挂载时获取数据
onMounted(() => {
fetchStatistics()
})
</script>
<template>
<VDialog scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-chart-line" class="me-2" />
</template>
<VCardTitle>{{ t('subscribe.shareStatistics') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText class="pa-0">
<LoadingBanner v-if="loading" class="mt-4" />
<div v-else-if="rankedStatistics.length === 0" class="text-center py-8">
<VIcon icon="mdi-chart-line" size="64" color="grey" class="mb-4" />
<div class="text-h6 text-grey">{{ t('subscribe.noStatisticsData') }}</div>
</div>
<div v-else>
<!-- 前三名特殊展示 -->
<div class="podium-area" :style="getPodiumAreaBackgroundStyle()">
<!-- 装饰性背景元素 -->
<div class="podium-decoration">
<div class="decoration-circle decoration-1"></div>
<div class="decoration-circle decoration-2"></div>
<div class="decoration-circle decoration-3"></div>
</div>
<div class="text-h6 mb-4 text-center podium-title">{{ t('subscribe.ranking') }}</div>
<!-- 大屏幕横向排列 -->
<div class="d-none d-md-flex justify-center align-center gap-4 flex-wrap">
<!-- 第二名 -->
<div v-if="rankedStatistics[1]" class="text-center">
<div class="rank-circle mb-2" :style="getRankStyle(2)">
<VIcon :icon="getRankIcon(2)" size="24" />
</div>
<div class="text-h6 font-weight-bold" :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[1].share_user || '未知' }}
</div>
<div class="d-flex align-center justify-center gap-2 mt-1">
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[1].share_count || 0
}}</span>
</div>
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[1].total_reuse_count || 0
}}</span>
</div>
</div>
</div>
<!-- 第一名 -->
<div v-if="rankedStatistics[0]" class="text-center">
<div class="rank-circle mb-2 first-place" :style="getRankStyle(1)">
<VIcon :icon="getRankIcon(1)" size="32" />
</div>
<div class="text-h5 font-weight-bold" :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[0].share_user || '未知' }}
</div>
<div class="d-flex align-center justify-center gap-3 mt-1">
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[0].share_count || 0
}}</span>
</div>
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[0].total_reuse_count || 0
}}</span>
</div>
</div>
</div>
<!-- 第三名 -->
<div v-if="rankedStatistics[2]" class="text-center">
<div class="rank-circle mb-2" :style="getRankStyle(3)">
<VIcon :icon="getRankIcon(3)" size="24" />
</div>
<div class="text-h6 font-weight-bold" :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[2].share_user || '未知' }}
</div>
<div class="d-flex align-center justify-center gap-2 mt-1">
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[2].share_count || 0
}}</span>
</div>
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span class="font-weight-bold" :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[2].total_reuse_count || 0
}}</span>
</div>
</div>
</div>
</div>
<!-- 小屏幕垂直排列 -->
<div class="d-flex d-md-none flex-column align-center gap-4">
<!-- 第一名 -->
<div v-if="rankedStatistics[0]" class="text-center">
<div class="rank-circle mb-2 first-place" :style="getRankStyle(1)">
<VIcon :icon="getRankIcon(1)" size="32" />
</div>
<div class="text-h5 font-weight-bold" :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[0].share_user || '未知' }}
</div>
<div class="d-flex align-center justify-center gap-3 mt-1">
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span :style="{ color: getPodiumTextColor() }">{{ rankedStatistics[0].share_count || 0 }}</span>
</div>
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[0].total_reuse_count || 0
}}</span>
</div>
</div>
</div>
<!-- 第二名 -->
<div v-if="rankedStatistics[1]" class="text-center">
<div class="rank-circle mb-2" :style="getRankStyle(2)">
<VIcon :icon="getRankIcon(2)" size="24" />
</div>
<div class="text-h6 font-weight-bold" :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[1].share_user || '未知' }}
</div>
<div class="d-flex align-center justify-center gap-2 mt-1">
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span :style="{ color: getPodiumTextColor() }">{{ rankedStatistics[1].share_count || 0 }}</span>
</div>
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span :style="{ color: getPodiumTextColor() }">{{
rankedStatistics[1].total_reuse_count || 0
}}</span>
</div>
</div>
</div>
<!-- 第三名 -->
<div v-if="rankedStatistics[2]" class="text-center">
<div class="rank-circle mb-2" :style="getRankStyle(3)">
<VIcon :icon="getRankIcon(3)" size="24" />
</div>
<div class="text-h6 font-weight-bold" :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[2].share_user || '未知' }}
</div>
<div class="d-flex align-center justify-center gap-2 mt-1">
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-share-outline" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[2].share_count || 0 }}
</span>
</div>
<div class="d-flex align-center podium-stat" :style="getPodiumStatStyle()">
<VIcon icon="mdi-fire" size="14" :color="getPodiumTextColor()" class="mr-1" />
<span :style="{ color: getPodiumTextColor() }">
{{ rankedStatistics[2].total_reuse_count || 0 }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 完整排行榜 -->
<VList class="bg-transparent px-3">
<VListItem
v-for="item in rankedStatistics.filter(item => item.rank > 3)"
:key="item.share_user"
class="mb-2 rounded-lg"
>
<VListItemTitle class="font-weight-bold text-h6 mb-1">
{{ item.share_user || '未知' }}
</VListItemTitle>
<VListItemSubtitle class="d-flex align-center gap-3 mt-1">
<div class="stat-badge share-badge">
<VIcon icon="mdi-share-outline" size="14" color="primary" class="mr-1" />
<span class="text-primary font-weight-bold">{{ item.share_count || 0 }}</span>
<span class="text-grey text-caption ml-1">{{ t('subscribe.shareCount') }}</span>
</div>
<div class="stat-badge reuse-badge">
<VIcon icon="mdi-fire" size="14" color="warning" class="mr-1" />
<span class="text-warning font-weight-bold">{{ item.total_reuse_count || 0 }}</span>
<span class="text-grey text-caption ml-1">{{ t('subscribe.totalReuseCount') }}</span>
</div>
</VListItemSubtitle>
<template #append>
<div class="text-right">
<div
class="text-h6 font-weight-bold"
:style="{ color: item.rank <= 3 ? 'var(--v-primary-base)' : 'inherit' }"
>
#{{ item.rank }}
</div>
</div>
</template>
</VListItem>
</VList>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
.rank-circle {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
block-size: 60px;
inline-size: 60px;
margin-block: 0;
margin-inline: auto;
}
.first-place {
block-size: 80px;
box-shadow: 0 4px 12px rgba(255, 215, 0, 30%);
inline-size: 80px;
}
.rank-badge {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
block-size: 32px;
inline-size: 32px;
}
.stat-badge {
display: flex;
align-items: center;
border: 1px solid rgba(var(--v-theme-outline), 0.2);
border-radius: 6px;
background: rgba(var(--v-theme-surface), 0.8);
padding-block: 4px;
padding-inline: 8px;
transition: all 0.2s ease;
}
.share-badge {
border-inline-start: 3px solid rgb(var(--v-theme-primary));
}
.reuse-badge {
border-inline-start: 3px solid rgb(var(--v-theme-warning));
}
.podium-stat {
border-radius: 6px;
backdrop-filter: blur(4px);
padding-block: 4px;
padding-inline: 8px;
transition: all 0.2s ease;
}
.podium-stat:hover {
transform: scale(1.05);
}
/* 前三名区域样式 */
.podium-area {
position: relative;
z-index: 1;
}
.podium-title {
position: relative;
z-index: 2;
color: #fff !important;
font-weight: bold;
text-shadow: 0 2px 4px rgba(0, 0, 0, 30%);
}
/* 装饰性元素 */
.podium-decoration {
position: absolute;
z-index: 0;
inset: 0;
pointer-events: none;
}
.decoration-circle {
position: absolute;
border-radius: 50%;
animation: float 6s ease-in-out infinite;
background: radial-gradient(circle, rgba(255, 255, 255, 10%) 0%, transparent 70%);
}
.decoration-1 {
animation-delay: 0s;
block-size: 80px;
inline-size: 80px;
inset-block-start: 10%;
inset-inline-start: 10%;
}
.decoration-2 {
animation-delay: 2s;
block-size: 60px;
inline-size: 60px;
inset-block-start: 20%;
inset-inline-end: 15%;
}
.decoration-3 {
animation-delay: 4s;
block-size: 40px;
inline-size: 40px;
inset-block-end: 20%;
inset-inline-start: 20%;
}
@keyframes float {
0%,
100% {
opacity: 0.6;
transform: translateY(0) rotate(0deg);
}
50% {
opacity: 1;
transform: translateY(-10px) rotate(180deg);
}
}
/* 增强前三名文字效果 */
.podium-area .text-h6,
.podium-area .text-h5 {
font-weight: bold;
text-shadow: 0 2px 4px rgba(0, 0, 0, 30%);
}
.podium-area .rank-circle {
border: 2px solid rgba(255, 255, 255, 20%);
box-shadow: 0 8px 24px rgba(0, 0, 0, 30%);
}
.podium-area .first-place {
border: 3px solid rgba(255, 215, 0, 50%);
box-shadow: 0 12px 32px rgba(255, 215, 0, 40%);
}
</style>

View File

@@ -1,10 +1,12 @@
<script lang="ts" setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import { FileItem, TransferQueue } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import CryptoJS from 'crypto-js'
// 多语言支持
const { t } = useI18n()
@@ -18,11 +20,14 @@ const emit = defineEmits(['close'])
// 数据列表
const dataList = ref<TransferQueue[]>([])
// 整进度文本
const progressText = ref(t('dialog.transferQueue.processing'))
// 整进度相关 - 根据完成的文件计算
const overallProgress = ref({
value: 0,
text: t('dialog.transferQueue.processing'),
})
// 整理进度
const progressValue = ref(0)
// 文件进度映射
const fileProgressMap = ref<Map<string, { enable: boolean; value: number }>>(new Map())
// 数据可刷新标志
const refreshFlag = ref(false)
@@ -33,6 +38,9 @@ const progressActive = ref(false)
// 活动标签
const activeTab = ref('')
// 定时器引用
const queueTimer = ref<NodeJS.Timeout | null>(null)
// 状态标签
const stateDict: { [key: string]: string } = {
'waiting': t('dialog.transferQueue.waitingState'),
@@ -50,9 +58,18 @@ function getStateColor(state: string) {
else return 'error'
}
// 从dataList中提取所有的媒体信息
// 从dataList中提取所有的媒体信息合并相同title_year的记录
const mediaList = computed(() => {
return dataList.value.map(item => item.media)
const mediaMap = new Map<string, any>()
dataList.value.forEach(item => {
const titleYear = item.media.title_year || ''
if (!mediaMap.has(titleYear)) {
mediaMap.set(titleYear, item.media)
}
})
return Array.from(mediaMap.values())
})
// 按media计算总数和完成数返回 x/x
@@ -66,17 +83,49 @@ function getMediaCount(title_year: string) {
return `${completed} / ${total}`
}
// 根据媒体信息获取对应的整理任务
// 根据媒体信息获取对应的整理任务合并相同title_year的所有任务
const activeTasks = computed(() => {
return dataList.value.find(item => item.media.title_year === activeTab.value)?.tasks
const tasks = dataList.value.filter(item => item.media.title_year === activeTab.value).flatMap(item => item.tasks)
return tasks
})
// 根据媒体title_year获取对应的任务列表
function getTasksByMedia(title_year: string) {
return dataList.value.filter(item => item.media.title_year === title_year).flatMap(item => item.tasks)
}
// 计算整体进度
const overallProgressComputed = computed(() => {
if (dataList.value.length === 0) return 0
const allTasks = dataList.value.flatMap(item => item.tasks)
const totalTasks = allTasks.length
const completedTasks = allTasks.filter(task => task.state === 'completed').length
return totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0
})
// 获取文件进度
function getFileProgress(filePath: string) {
return fileProgressMap.value.get(filePath) || { enable: false, value: 0 }
}
// 调用API获取队列信息
async function get_transfer_queue() {
try {
dataList.value = await api.get('transfer/queue')
if (dataList.value.length > 0) {
if (!activeTab.value || activeTasks.value?.length == 0) activeTab.value = dataList.value[0].media.title_year || ''
// 如果有数据且SSE未启动则启动SSE监听
if (!progressActive.value) {
startLoadingProgress()
}
} else {
// 如果没有数据停止SSE监听
if (progressActive.value) {
stopLoadingProgress()
}
}
} catch (error) {
console.error(error)
@@ -93,85 +142,164 @@ async function remove_queue_task(fileitem: FileItem) {
}
}
// 进度SSE消息处理函数
function handleProgressMessage(event: MessageEvent) {
const progress = JSON.parse(event.data)
if (progress) {
if (!progress.enable) {
progressText.value = t('dialog.transferQueue.processing')
progressValue.value = 0
if (refreshFlag.value) {
refreshFlag.value = false
get_transfer_queue()
}
return
}
progressText.value = progress.text
progressValue.value = progress.value
if (progress.value >= 100 && refreshFlag.value) {
refreshFlag.value = false
get_transfer_queue()
} else {
if (progress.value > 0 && refreshFlag.value && progress.text?.includes('整理完成')) {
refreshFlag.value = false
get_transfer_queue()
} else {
refreshFlag.value = true
// 文件进度SSE消息处理函数
function createFileProgressHandler(filePath: string) {
return function handleFileProgressMessage(event: MessageEvent) {
try {
const progress = JSON.parse(event.data)
if (progress) {
fileProgressMap.value.set(filePath, {
enable: progress.enable || false,
value: progress.value || 0,
})
}
} catch (error) {
console.error('解析文件进度消息失败:', error)
}
}
}
// 使用优化的进度SSE连接
const progressSSE = useProgressSSE(
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`,
handleProgressMessage,
'transfer-queue-progress',
progressActive
// 文件进度SSE连接映射
const fileProgressSSEMap = ref<Map<string, any>>(new Map())
// 启动文件进度监听
function startFileProgress(filePath: string) {
if (fileProgressSSEMap.value.has(filePath)) {
return // 已经存在连接
}
// filePath计算md5
const filePathMd5 = CryptoJS.MD5(filePath).toString()
// 使用包含文件路径的唯一监听器ID
const uniqueListenerId = `transfer-queue-file-progress-${filePathMd5}`
const fileProgressUrl = `${import.meta.env.VITE_API_BASE_URL}system/progress/${filePathMd5}`
const fileProgressSSE = useProgressSSE(
fileProgressUrl,
createFileProgressHandler(filePath),
uniqueListenerId,
progressActive,
)
fileProgressSSE.start()
fileProgressSSEMap.value.set(filePath, fileProgressSSE)
}
// 停止所有文件进度监听
function stopAllFileProgress() {
fileProgressSSEMap.value.forEach((sse, filePath) => {
sse.stop()
})
fileProgressSSEMap.value.clear()
fileProgressMap.value.clear()
}
// 监听队列变化自动管理文件进度SSE
watch(
dataList,
newDataList => {
// 获取当前正在运行的文件路径集合
const currentRunningFiles = new Set<string>()
newDataList.forEach(item => {
item.tasks.forEach(task => {
if (task.state === 'running') {
currentRunningFiles.add(task.fileitem.path)
}
})
})
// 获取当前已建立SSE连接的文件路径集合
const currentSSEFiles = new Set(fileProgressSSEMap.value.keys())
// 停止不再需要的SSE连接
currentSSEFiles.forEach(filePath => {
if (!currentRunningFiles.has(filePath)) {
const sse = fileProgressSSEMap.value.get(filePath)
if (sse) {
sse.stop()
fileProgressSSEMap.value.delete(filePath)
}
// 清除对应的进度数据
fileProgressMap.value.delete(filePath)
}
})
// 为新的运行中文件建立SSE连接
currentRunningFiles.forEach(filePath => {
if (!fileProgressSSEMap.value.has(filePath)) {
startFileProgress(filePath)
}
})
},
{ deep: true },
)
// 使用SSE监听加载进度
function startLoadingProgress() {
progressText.value = t('dialog.transferQueue.processing')
overallProgress.value.text = t('dialog.transferQueue.processing')
progressActive.value = true
progressSSE.start()
}
// 停止监听加载进度
function stopLoadingProgress() {
progressActive.value = false
progressSSE.stop()
// 只有在没有数据时才停止所有文件进度监听
if (dataList.value.length === 0) {
stopAllFileProgress()
}
}
// 启动定时获取队列
function startQueueTimer() {
// 清除可能存在的定时器
if (queueTimer.value) {
clearInterval(queueTimer.value)
}
// 立即执行一次
get_transfer_queue()
// 设置3秒定时器
queueTimer.value = setInterval(() => {
get_transfer_queue()
}, 3000)
}
// 停止定时获取队列
function stopQueueTimer() {
if (queueTimer.value) {
clearInterval(queueTimer.value)
queueTimer.value = null
}
}
onMounted(() => {
get_transfer_queue()
startLoadingProgress()
startQueueTimer()
})
onUnmounted(() => {
stopQueueTimer()
stopLoadingProgress()
})
</script>
<template>
<DialogWrapper scrollable max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VCard class="mx-auto" width="100%">
<VCardItem>
<VCardTitle>{{ t('dialog.transferQueue.title') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VProgressLinear
v-if="dataList.length > 0 && progressValue > 0"
:value="progressValue"
color="primary"
indeterminate
/>
<VCardItem v-if="dataList.length > 0 && progressValue > 0" class="text-center pt-2">
<span class="text-sm">{{ progressText }}</span>
</VCardItem>
<VCardText v-if="dataList.length === 0" class="text-center"> {{ t('dialog.transferQueue.noTasks') }} </VCardText>
<VCardText>
<!-- 整体进度显示 -->
<VProgressLinear v-if="dataList.length > 0" :model-value="overallProgressComputed" color="primary" />
<VDivider v-else />
<VCardText v-if="dataList.length === 0" class="text-center">
{{ t('dialog.transferQueue.noTasks') }}
</VCardText>
<VCardText v-if="dataList.length > 0">
<VTabs v-model="activeTab" show-arrows class="v-tabs-pill" stacked>
<VTab
v-for="media in mediaList"
@@ -185,16 +313,34 @@ onUnmounted(() => {
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
<VWindowItem v-for="media in mediaList" :value="media.title_year">
<VList>
<VListItem v-for="task in activeTasks">
<VListItem v-for="task in getTasksByMedia(media.title_year || '')" :key="task.fileitem.path">
<VListItemTitle>{{ task.fileitem.name }}</VListItemTitle>
<VListItemSubtitle>
<VListItemSubtitle class="py-1">
{{ t('dialog.transferQueue.sizeTitle') }}{{ formatFileSize(task.fileitem.size || 0) }}
<VChip size="small" :color="getStateColor(task.state)" class="ms-2">
<VChip size="small" :color="getStateColor(task.state)" class="mx-2">
{{ stateDict[task.state] }}
</VChip>
</VListItemSubtitle>
<!-- 文件进度显示 -->
<div v-if="task.state === 'running' && getFileProgress(task.fileitem.path).enable" class="mt-2">
<VProgressLinear
:model-value="getFileProgress(task.fileitem.path).value"
color="success"
class="mb-1"
:height="3"
/>
<div class="text-xs text-medium-emphasis text-center">
{{ getFileProgress(task.fileitem.path).value.toFixed(1) }}%
</div>
</div>
<template #append>
<IconBtn size="small" icon="mdi-cancel" @click="remove_queue_task(task.fileitem)" />
<IconBtn
size="small"
icon="mdi-cancel"
@click="remove_queue_task(task.fileitem)"
:disabled="task.state === 'completed'"
/>
</template>
</VListItem>
</VList>
@@ -202,5 +348,5 @@ onUnmounted(() => {
</VWindow>
</VCardText>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -1,16 +1,22 @@
<script lang="ts" setup>
import api from '@/api'
import QrcodeVue from 'qrcode.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 常量定义
const AUTH_WINDOW_WIDTH = 600
const AUTH_WINDOW_HEIGHT = 700
const POLL_INTERVAL = 2000
const AUTH_STATUS_SUCCESS = 2
const AUTH_STATUS_FAILED = -1
// 显示器宽度
const display = useDisplay()
// 多语言支持
const { t } = useI18n()
// 定义输入
// Props 定义
const props = defineProps({
conf: {
type: Object as PropType<{ [key: string]: any }>,
@@ -18,24 +24,40 @@ const props = defineProps({
},
})
// 定义事件
// Events 定义
const emit = defineEmits(['done', 'close'])
// 二维码内容
const qrCodeContent = ref('')
// 响应式状态
const authUrl = ref('')
const authState = ref('')
const text = ref('')
const alertType = ref<'success' | 'info' | 'error' | 'warning'>('info')
// 下方的提示信息
const text = ref(t('dialog.u115Auth.scanQrCode'))
// 授权窗口引用
let authWindow: Window | null = null
let pollTimer: NodeJS.Timeout | undefined
// 提醒类型
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
// 清理资源
function cleanup() {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = undefined
}
if (authWindow && !authWindow.closed) {
authWindow.close()
authWindow = null
}
}
// timeout定时器
let timeoutTimer: NodeJS.Timeout | undefined = undefined
// 设置提示消息
function setMessage(type: typeof alertType.value, message: string) {
alertType.value = type
text.value = message
}
// 完成
async function handleDone() {
clearTimeout(timeoutTimer)
// 完成授权
function handleDone() {
cleanup()
emit('done')
}
@@ -44,108 +66,185 @@ async function handleReset() {
try {
const result: { [key: string]: any } = await api.get('/storage/reset/u115')
if (result.success) {
// 重置成功
alertType.value = 'success'
setMessage('success', t('dialog.u115Auth.authSuccess'))
handleDone()
} else {
alertType.value = 'error'
text.value = result.message
}
} catch (e) {
console.error(e)
else {
setMessage('error', result.message || t('dialog.u115Auth.authFailed'))
}
}
}
// 调用/u115/qrcode api生成二维码
async function getQrcode() {
try {
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
if (result.success && result.data) {
qrCodeContent.value = result.data.codeContent
timeoutTimer = setTimeout(checkQrcode, 3000)
} else {
text.value = result.message
}
} catch (e) {
console.error(e)
catch (error) {
console.error('Reset failed:', error)
setMessage('error', t('dialog.u115Auth.authFailed'))
}
}
// 调用/aliyun/check api验证二维码
async function checkQrcode() {
// 获取授权URL
async function fetchAuthUrl() {
try {
const result: { [key: string]: any } = await api.get('/storage/auth_url/u115')
if (result.success && result.data) {
authUrl.value = result.data.authUrl
authState.value = result.data.state
}
else {
setMessage('error', result.message || t('dialog.u115Auth.urlFetchFailed'))
}
}
catch (error) {
console.error('Fetch auth URL failed:', error)
setMessage('error', t('dialog.u115Auth.urlFetchFailed'))
}
}
// 打开授权窗口
function openAuthWindow() {
if (!authUrl.value) {
setMessage('error', t('dialog.u115Auth.urlEmpty'))
return
}
const left = (window.screen.width - AUTH_WINDOW_WIDTH) / 2
const top = (window.screen.height - AUTH_WINDOW_HEIGHT) / 2
const features = [
`width=${AUTH_WINDOW_WIDTH}`,
`height=${AUTH_WINDOW_HEIGHT}`,
`left=${left}`,
`top=${top}`,
'toolbar=no',
'location=no',
'status=no',
'menubar=no',
'scrollbars=yes',
'resizable=yes',
].join(',')
authWindow = window.open(authUrl.value, '115授权', features)
if (authWindow) {
setMessage('info', t('dialog.u115Auth.authorizing'))
pollTimer = setTimeout(checkAuthStatus, POLL_INTERVAL)
}
else {
setMessage('error', t('dialog.u115Auth.popupBlocked'))
}
}
// 检查授权状态
async function checkAuthStatus() {
try {
const result: { [key: string]: any } = await api.get('/storage/check/u115')
if (result.success && result.data) {
const status = result.data.status
text.value = result.data.tip
if (status == 0) {
alertType.value = 'info'
// 新建、待扫码
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else if (status == 1) {
// 已扫码
alertType.value = 'info'
text.value = t('dialog.u115Auth.scanned')
clearTimeout(timeoutTimer)
timeoutTimer = setTimeout(checkQrcode, 3000)
} else if (status == 2) {
// 已确认完成
alertType.value = 'success'
const { status, tip } = result.data
if (status === AUTH_STATUS_SUCCESS) {
// 授权成功
setMessage('success', t('dialog.u115Auth.authSuccess'))
handleDone()
} else {
// 过期或者已取消
alertType.value = 'error'
return
}
} else {
alertType.value = 'error'
text.value = result.message
if (status === AUTH_STATUS_FAILED) {
// 授权失败或过期
setMessage('error', tip || t('dialog.u115Auth.authFailed'))
cleanup()
return
}
// status === 0 或 1继续等待
}
} catch (e) {
console.error(e)
}
catch (error) {
console.error('Check auth status failed:', error)
}
// 检查窗口是否被用户关闭
if (authWindow?.closed) {
setMessage('warning', t('dialog.u115Auth.authCanceled'))
cleanup()
return
}
// 继续轮询
pollTimer = setTimeout(checkAuthStatus, POLL_INTERVAL)
}
onMounted(async () => {
await getQrcode()
// 生命周期钩子
onMounted(() => {
fetchAuthUrl()
})
onUnmounted(() => {
if (timeoutTimer) clearTimeout(timeoutTimer)
cleanup()
})
</script>
<template>
<DialogWrapper width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn @click="emit('close')" />
<VCardItem>
<template #prepend>
<VIcon icon="mdi-qrcode" class="me-2" />
<VIcon icon="mdi-shield-key" class="me-2" />
</template>
<VCardTitle>
{{ t('dialog.u115Auth.loginTitle') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="pt-2 flex flex-col items-center justify-center">
<div class="mt-6 rounded text-center p-3 border">
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
<!-- 授权按钮 -->
<div class="mt-6 mb-4 text-center">
<VBtn
size="x-large"
color="primary"
prepend-icon="mdi-login"
:disabled="!authUrl"
class="px-8"
@click="openAuthWindow"
>
{{ t('dialog.u115Auth.openAuthWindow') }}
</VBtn>
</div>
<div>
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
<!-- 状态提示 -->
<div v-if="text" class="w-full">
<VAlert
variant="tonal"
:type="alertType"
:text="text"
class="my-4 text-center"
>
<template #prepend />
</VAlert>
</div>
</VCardText>
<VCardActions>
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
<VBtn
color="error"
prepend-icon="mdi-restore"
class="px-5 me-3"
@click="handleReset"
>
{{ t('dialog.u115Auth.reset') }}
</VBtn>
<VSpacer />
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
<VBtn
prepend-icon="mdi-check"
class="px-5 me-3"
@click="handleDone"
>
{{ t('dialog.u115Auth.complete') }}
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

View File

@@ -93,6 +93,7 @@ const userForm = ref<ExtendedUser>({
wechat_userid: null,
telegram_userid: null,
slack_userid: null,
discord_userid: null,
vocechat_userid: null,
synologychat_userid: null,
},
@@ -198,6 +199,7 @@ async function fetchUserInfo() {
userForm.value = await api.get(`user/${props.username}`)
if (userForm.value) {
userForm.value.avatar = userForm.value.avatar || avatar1
userForm.value.nickname = userForm.value.settings?.nickname ?? ''
currentAvatar.value = userForm.value.avatar
currentUserName.value = userForm.value.name
userName.value = userForm.value.name
@@ -272,12 +274,10 @@ async function updateUser() {
}
// 将nickname保存到settings中后端可以直接处理JSON对象
if (userForm.value.nickname) {
if (!userForm.value.settings) {
userForm.value.settings = {}
}
userForm.value.settings.nickname = userForm.value.nickname
if (!userForm.value.settings) {
userForm.value.settings = {}
}
userForm.value.settings.nickname = userForm.value.nickname ?? ''
const oldUserName = userForm.value.name
userForm.value.name = currentUserName.value
@@ -366,7 +366,7 @@ onMounted(() => {
</script>
<template>
<DialogWrapper scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem :class="props.oper === 'add' ? 'py-3' : 'py-2'">
<template #prepend>
@@ -521,6 +521,15 @@ onMounted(() => {
prepend-inner-icon="mdi-slack"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.discord_userid"
density="comfortable"
clearable
:label="t('dialog.userAddEdit.discord')"
prepend-inner-icon="mdi-discord"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.vocechat_userid"
@@ -619,5 +628,5 @@ onMounted(() => {
</VBtn>
</VCardActions>
</VCard>
</DialogWrapper>
</VDialog>
</template>

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