Compare commits

...

270 Commits
v2.8.7 ... 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
135 changed files with 16087 additions and 5370 deletions

1
.gitignore vendored
View File

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

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: {
@@ -264,6 +270,91 @@ const props = defineProps({
</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

@@ -34,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 全屏模式 -->
@@ -91,14 +91,9 @@
<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" />
<style>
#app {
block-size: 100%;
overflow: auto;
min-block-size: 100%;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
@@ -197,6 +192,35 @@
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>
@@ -214,7 +238,7 @@
let primaryColor = localStorage.getItem('materio-initial-loader-color')
// 检查主题设置
const savedTheme = localStorage.getItem('theme')
const savedTheme = localStorage.getItem('theme') || 'auto'
const isAutoTheme = savedTheme === 'auto'
// 如果是自动主题或者没有保存的背景色,根据系统主题设置背景色
@@ -247,6 +271,73 @@
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>
@@ -254,114 +345,15 @@
<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>

View File

@@ -1,11 +1,12 @@
{
"name": "moviepilot",
"version": "2.8.7",
"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",
@@ -51,11 +52,13 @@
"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",
@@ -69,6 +72,9 @@
"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",
@@ -77,9 +83,12 @@
"@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",

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

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

@@ -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,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

@@ -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) {

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

@@ -142,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;
@@ -224,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

@@ -13,6 +13,8 @@ html {
body {
background: rgb(var(--v-theme-background));
overscroll-behavior-y: contain;
// Chrome 144+ 兼容性:覆盖 Vuetify 的内联 overflow: hidden 样式
overflow: visible !important;
--webkit-overflow-scrolling: touch;
}
@@ -35,7 +37,9 @@ 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;

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

@@ -15,7 +15,7 @@ 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
@@ -192,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 => {
@@ -237,6 +241,14 @@ 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()

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

@@ -656,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
@@ -861,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 {
// 总空间
@@ -875,8 +896,8 @@ export interface MediaStatistic {
movie_count: number
// 电视剧总数
tv_count: number
// 电视剧总集数
episode_count: number
// 电视剧总集数,未获取时为 null
episode_count: number | null
// 用户数量
user_count: number
}
@@ -1084,6 +1105,8 @@ export interface DownloaderConf {
config: { [key: string]: any }
// 是否启用
enabled: boolean
// 路径映射
path_mapping?: Array<[storagePath: string, downloadPath: string]>
}
// 通知配置
@@ -1122,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 }
@@ -1427,3 +1450,25 @@ export interface SubscribeShareStatistics {
// 总复用人次
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

@@ -1,5 +1,6 @@
<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({
@@ -10,12 +11,18 @@ const props = defineProps({
// 图片是否加载完成
const imageLoaded = ref(false)
const imageLoadError = ref(false)
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 图片加载失败响应
function imageErrorHandler() {
imageLoadError.value = true
}
// 跳转播放
async function goPlay() {
if (props.media?.link) {
@@ -26,6 +33,7 @@ async function goPlay() {
// 计算图片地址
const getImgUrl = computed(() => {
const image = props.media?.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) {
@@ -50,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

@@ -101,19 +101,21 @@ 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>
<VDialog

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

@@ -7,7 +7,7 @@ import type { DownloaderInfo } from '@/api/types'
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'
@@ -52,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: '',
@@ -59,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)
@@ -92,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'))
@@ -129,11 +206,27 @@ const getIcon = computed(() => {
return getLogoUrl('qbittorrent')
case 'transmission':
return getLogoUrl('transmission')
case 'rtorrent':
return getLogoUrl('rtorrent')
default:
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')
@@ -152,24 +245,26 @@ 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
@@ -177,18 +272,21 @@ 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>
@@ -212,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')" />
@@ -248,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"
@@ -264,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"
@@ -351,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
@@ -373,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">

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,22 +205,24 @@ 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>
<VDialog

View File

@@ -33,6 +33,7 @@ function imageLoadHandler() {
// 图片加载错误
function imageErrorHandler() {
imageError.value = true
imgUrl.value = getDefaultImage()
}
// 默认图片
@@ -41,6 +42,7 @@ function getDefaultImage() {
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
}
@@ -53,7 +55,7 @@ async function goPlay() {
// 生成图片代理路径
function getImgUrl(url: string, use_cookies?: boolean) {
if (!url) return getDefaultImage()
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)}`
@@ -64,7 +66,7 @@ function getImgUrl(url: string, use_cookies?: boolean) {
// 根据多张图片生成媒体库封面
async function drawImages(imageList: string[], use_cookies?: boolean) {
// 图片
const IMAGES = imageList
const IMAGES = [...imageList]
if (IMAGES.length === 0) return getDefaultImage()
// 为所有图片添加system/img前缀

View File

@@ -18,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,
})
@@ -138,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 {
@@ -153,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,
})
@@ -183,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')
@@ -222,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)
@@ -249,7 +254,7 @@ async function handleCheckExists() {
}
// 调用API检查是否已订阅电视剧需要指定季
async function checkSubscribe(season = 0) {
async function checkSubscribe(season: number | null) {
try {
// AbortController 现在由全局请求优化器自动管理
const mediaid = getMediaId()
@@ -300,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

@@ -61,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)
@@ -77,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']
@@ -110,6 +125,8 @@ const getIcon = computed(() => {
return getLogoUrl('jellyfin')
case 'trimemedia':
return getLogoUrl('trimemedia')
case 'ugreen':
return getLogoUrl('ugreen')
case 'plex':
return getLogoUrl('plex')
default:
@@ -182,21 +199,27 @@ 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>
@@ -424,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

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

@@ -46,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'),
}
@@ -62,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
}
@@ -84,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')
@@ -96,12 +129,16 @@ const getIcon = computed(() => {
return getLogoUrl('wechat')
case 'telegram':
return getLogoUrl('telegram')
case 'qqbot':
return getLogoUrl('qq')
case 'vocechat':
return getLogoUrl('vocechat')
case 'synologychat':
return getLogoUrl('synologychat')
case 'slack':
return getLogoUrl('slack')
case 'discord':
return getLogoUrl('discord')
case 'webpush':
return getLogoUrl('chrome')
default:
@@ -116,22 +153,24 @@ 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>
@@ -184,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">
@@ -350,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
@@ -420,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

View File

@@ -118,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 += '/'

View File

@@ -566,13 +566,13 @@ watch(
</VDialog>
<!-- 实时日志弹窗 -->
<VDialog
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>
@@ -588,7 +588,7 @@ watch(
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VCardText class="pa-0">
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>

View File

@@ -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

@@ -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 }}

View File

@@ -140,7 +140,7 @@ onMounted(() => {
</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
@@ -153,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 || '暂无描述' }}

View File

@@ -1,18 +1,39 @@
<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>({})
@@ -66,7 +87,7 @@ const releaseDialogBody = ref('')
// 打开日志对话框
function showReleaseDialog(title: string, body: string) {
releaseDialogTitle.value = title
releaseDialogBody.value = body.replaceAll('\r\n', '<br />')
releaseDialogBody.value = body ? md.render(body) : ''
releaseDialog.value = true
}
@@ -115,6 +136,13 @@ function releaseTime(releaseDate: string) {
return formatDateDifference(releaseDate)
}
// 强制清除缓存
async function clearCache() {
await clearCachesAndServiceWorker()
// 刷新页面,添加时间戳参数以强制更新
reloadWithTimestamp()
}
onMounted(() => {
querySystemEnv()
queryAllRelease()
@@ -170,6 +198,27 @@ onMounted(() => {
</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>
@@ -194,7 +243,7 @@ onMounted(() => {
<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 undefined">
<span class="flex-grow break-all">
<code>{{ systemEnv.CONFIG_DIR }}</code>
</span>
</dd>
@@ -202,7 +251,7 @@ onMounted(() => {
<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 undefined"
<span class="flex-grow break-all"
><code>{{ t('setting.about.dataDirectory') }}</code></span
>
</dd>
@@ -212,7 +261,7 @@ onMounted(() => {
<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 undefined">
<span class="flex-grow break-all">
<code>{{ systemEnv.TZ }}</code>
</span>
</dd>
@@ -261,7 +310,7 @@ onMounted(() => {
<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 undefined">
<span class="flex-grow break-all">
<a
href="https://movie-pilot.org"
target="_blank"
@@ -278,7 +327,7 @@ onMounted(() => {
<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 undefined">
<span class="flex-grow break-all">
<a
href="https://github.com/jxxghp/MoviePilot/issues/new/choose"
target="_blank"
@@ -295,7 +344,7 @@ onMounted(() => {
<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 undefined">
<span class="flex-grow break-all">
<a
href="https://t.me/moviepilot_channel"
target="_blank"
@@ -361,7 +410,7 @@ onMounted(() => {
<VDialogCloseBtn @click="releaseDialog = false" />
<VCardTitle>{{ releaseDialogTitle }} {{ t('setting.about.changelog') }}</VCardTitle>
</VCardItem>
<VCardText v-html="releaseDialogBody" />
<VCardText class="markdown-body" v-html="releaseDialogBody" />
</VCard>
</VDialog>
</VDialog>
@@ -379,4 +428,101 @@ onMounted(() => {
.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

@@ -78,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)]
})
@@ -211,7 +223,6 @@ onMounted(() => {
<VSelect
v-model="selectedDownloader"
:items="downloaderOptions"
size="small"
:label="t('dialog.addDownload.downloader')"
variant="underlined"
:placeholder="t('dialog.addDownload.defaultPlaceholder')"
@@ -224,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"
@@ -236,7 +246,6 @@ onMounted(() => {
<VCol cols="12">
<VBtn
variant="text"
size="small"
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click="showAdvancedOptions = !showAdvancedOptions"
>
@@ -260,7 +269,6 @@ onMounted(() => {
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
prepend-inner-icon="mdi-identifier"
size="small"
variant="underlined"
density="comfortable"
@click:append-inner="mediaSelectorDialog = true"
@@ -275,7 +283,6 @@ onMounted(() => {
:hint="t('dialog.reorganize.mediaIdHint')"
persistent-hint
prepend-inner-icon="mdi-identifier"
size="small"
variant="underlined"
density="comfortable"
@click:append-inner="mediaSelectorDialog = true"

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

@@ -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

@@ -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,6 +42,76 @@ 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()
})
@@ -64,7 +119,7 @@ onMounted(() => {
<template>
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<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>
</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,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()

File diff suppressed because it is too large Load Diff

View File

@@ -140,7 +140,7 @@ 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()
})
@@ -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>

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>
<VDialog 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"
@@ -285,7 +578,160 @@ onMounted(() => {
</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

@@ -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,73 +66,118 @@ 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>
@@ -118,31 +185,63 @@ onUnmounted(() => {
<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>

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
@@ -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"

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { AxiosRequestConfig } from 'axios'
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
import type { PropType } from 'vue'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from 'vue-toastification'
@@ -13,6 +13,7 @@ import MediaInfoDialog from '../dialog/MediaInfoDialog.vue'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { usePWA } from '@/composables/usePWA'
import { useAvailableHeight } from '@/composables/useAvailableHeight'
// 国际化
const { t } = useI18n()
@@ -23,13 +24,16 @@ const display = useDisplay()
const { appMode } = usePWA()
// 计算列表可用高度
// componentOffset = FileToolbar(48) + FileList操作栏(40) + VCard边距(4) = 92
const { availableHeight: listAvailableHeight } = useAvailableHeight(92, 300)
// 输入参数
const inProps = defineProps({
icons: Object,
storage: String,
endpoints: Object as PropType<EndPoints>,
axios: {
type: Function,
type: Object as PropType<AxiosInstance>,
required: true,
},
refreshpending: Boolean,
@@ -144,29 +148,7 @@ const transferItems = ref<FileItem[]>([])
// 当前图片地址
const currentImgLink = ref('')
// 计算列表可用高度
const listAvailableHeight = computed(() => {
// 获取视口高度
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
// navbar高度
const navbarHeight = 72
// 工具栏高度(包含搜索框和按钮)
const toolbarHeight = 64
// 底部导航栏高度
const footerHeight = appMode.value ? 80 : 16
// 安全区域高度
const safeAreaHeight =
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-area-inset-bottom')) ||
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-area-inset-top')) ||
0
// 计算可用高度,预留一些边距
const availableHeight = viewportHeight - navbarHeight - toolbarHeight - footerHeight - safeAreaHeight - 40
// 确保最小高度
return Math.max(availableHeight, 300)
})
// 是否为图片文件
const isImage = computed(() => {
@@ -183,6 +165,8 @@ function changeSelectMode() {
// 调API加载文件夹内的内容
async function list_files() {
loading.value = true
const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/');
const prevURI = takeURISnapshot();
emit('loading', true)
// 参数
@@ -195,7 +179,12 @@ async function list_files() {
}
// 加载数据
items.value = (await inProps.axios.request(config)) ?? []
const data = ((await inProps.axios.request<FileItem[], FileItem[]>(config))) ?? []
// 如果当前路径已经变化,则放弃此次加载结果
if (prevURI !== takeURISnapshot()) {
return;
}
items.value = data
emit('loading', false)
loading.value = false
@@ -294,7 +283,7 @@ async function download(item: FileItem) {
responseType: 'blob',
}
// 加载数据
const result: Blob = await inProps.axios.request(config)
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
if (result) {
const downloadUrl = URL.createObjectURL(result)
window.open(downloadUrl, '_blank')
@@ -312,7 +301,7 @@ async function getImgLink(item: FileItem) {
responseType: 'blob',
}
// 加载二进制数据
const result: Blob = await inProps.axios.request(config)
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
if (result) {
// 创建图片地址
currentImgLink.value = URL.createObjectURL(result)
@@ -389,7 +378,7 @@ async function rename() {
method: inProps.endpoints?.rename.method || 'post',
data: currentItem.value,
}
const result: { [key: string]: any } = await inProps.axios?.request(config)
const result: { [key: string]: any } = (await inProps.axios?.request<any, { [key: string]: any }>(config))
if (!result.success) {
$toast.error(result.message)
}
@@ -446,9 +435,9 @@ watch(
},
)
// 监听item变化或者storage变化
// 监听item变化
watch(
[() => inProps.item, () => inProps.storage],
[() => inProps.item],
async () => {
// 清空列表
items.value = []
@@ -550,7 +539,7 @@ async function scrape(item: FileItem, confirm: boolean = true) {
progressDialog.value = true
progressText.value = t('file.scraping', { path: item.path })
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.storage}`, item)
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.item.storage}`, item)
// 关闭进度条
progressDialog.value = false
@@ -808,7 +797,7 @@ onMounted(() => {
v-if="transferPopper"
v-model="transferPopper"
:items="transferItems"
:target_storage="inProps.storage"
:target_storage="inProps.item.storage"
@done="transferDone"
@close="transferPopper = false"
/>

View File

@@ -2,47 +2,27 @@
import type { PropType } from 'vue'
import type { FileItem } from '@/api/types'
import { useDisplay } from 'vuetify'
import type { AxiosRequestConfig } from 'axios'
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useAvailableHeight } from '@/composables/useAvailableHeight'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
const { appMode } = usePWA()
// 计算列表可用高度
const availableHeight = computed(() => {
// 获取视口高度
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
// navbar高度
const navbarHeight = 72
// 工具栏高度
const toolbarHeight = 25
// 底部导航栏高度
const footerHeight = appMode.value ? 80 : 16
// 安全区域高度
const safeAreaHeight =
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-area-inset-bottom')) ||
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-area-inset-top')) ||
0
// 计算可用高度,预留一些边距
const availableHeight = viewportHeight - navbarHeight - toolbarHeight - footerHeight - safeAreaHeight - 40
// 确保最小高度
return Math.max(availableHeight, 300)
})
// componentOffset = FileToolbar(48) = 48
const { availableHeight } = useAvailableHeight(48, 300)
// 输入参数
const props = defineProps({
storage: {
type: String,
default: 'local',
required: true,
},
currentPath: {
type: String,
@@ -54,7 +34,7 @@ const props = defineProps({
},
endpoints: Object,
axios: {
type: Function,
type: Object as PropType<AxiosInstance>,
required: true,
},
})
@@ -131,7 +111,7 @@ async function loadSubdirectories(path: string) {
data: fakeItem,
}
const result = await props.axios?.request(config)
const result = (await props.axios?.request(config))
if (result && Array.isArray(result)) {
// 过滤出目录项
const dirs = result.filter(item => item.type === 'dir')
@@ -223,7 +203,7 @@ watch(
watch(
() => props.items,
newItems => {
if (newItems && newItems.length > 0) {
if (newItems) {
// 过滤出目录项
const dirs = newItems.filter(item => item.type === 'dir')
@@ -283,9 +263,6 @@ onMounted(async () => {
await loadRootDirectories()
})
onActivated(() => {
updateHeight()
})
</script>
<template>
@@ -309,7 +286,6 @@ onActivated(() => {
<span>{{ t('file.rootDirectory') }}</span>
</div>
</div>
<!-- 加载根目录 -->
<div v-if="loading['/']" class="tree-loading">
<VProgressCircular indeterminate size="24" color="primary" class="ma-2" />

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { AxiosRequestConfig } from 'axios'
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
import type { EndPoints, FileItem } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
@@ -13,7 +13,6 @@ const display = useDisplay()
// 输入参数
const inProps = defineProps({
storages: Array as PropType<any[]>,
storage: String,
item: {
type: Object as PropType<FileItem>,
required: true,
@@ -24,9 +23,17 @@ const inProps = defineProps({
},
endpoints: Object as PropType<EndPoints>,
axios: {
type: Function,
type: Object as PropType<AxiosInstance>,
required: true,
},
sort: {
type: String,
default: 'name',
},
showNewFolderButton: {
type: Boolean,
default: true,
},
})
// 对外事件
@@ -38,15 +45,10 @@ const newFolderPopper = ref(false)
// 新建文件名称
const newFolderName = ref('')
// 排序方式
const sort = ref('name')
// 调整排序方式
function changeSort() {
if (sort.value === 'name') sort.value = 'time'
else sort.value = 'name'
emit('sortchanged', sort.value)
const newSort = inProps.sort === 'name' ? 'time' : 'name'
emit('sortchanged', newSort)
}
// 计算PATH面包屑
@@ -67,12 +69,12 @@ const pathSegments = computed(() => {
// 当前存储
const storageObject = computed(() => {
return inProps.storages?.find(item => item.value === inProps.storage)
return inProps.storages?.find(item => item.value === inProps.item.storage)
})
// 切换存储
function changeStorage(code: string) {
if (inProps.storage !== code) {
if (inProps.item.storage!== code) {
emit('storagechanged', code)
}
}
@@ -111,11 +113,20 @@ async function mkdir() {
emit('foldercreated')
}
function openNewFolderDialog() {
newFolderName.value = ''
newFolderPopper.value = true
}
// 计算排序图标
const sortIcon = computed(() => {
if (sort.value === 'time') return 'mdi-sort-clock-ascending-outline'
if (inProps.sort === 'time') return 'mdi-sort-clock-ascending-outline'
else return 'mdi-sort-alphabetical-ascending'
})
defineExpose({
openNewFolderDialog,
})
</script>
<template>
@@ -167,9 +178,9 @@ const sortIcon = computed(() => {
</IconBtn>
<!-- 新建文件夹 -->
<VDialog v-model="newFolderPopper" max-width="35rem">
<template #activator="{ props }">
<IconBtn>
<VIcon v-bind="props" icon="mdi-folder-plus-outline" />
<template v-if="showNewFolderButton" #activator="{ props }">
<IconBtn v-bind="props">
<VIcon icon="mdi-folder-plus-outline" />
</IconBtn>
</template>
<VCard>

View File

@@ -0,0 +1,817 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useEventListener } from '@vueuse/core'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
// 定义输入参数
const props = defineProps<{
// 筛选表单
filterForm: Record<string, string[]>
// 筛选选项
filterOptions: Record<string, string[]>
// 排序字段
sortField: string
// 排序方向
sortType: 'asc' | 'desc'
// 筛选后的总数量
totalFilteredCount: number
// 过滤项标题映射
filterTitles: Record<string, string>
// 排序标题映射
sortTitles: Record<string, string>
// 是否启用滚动动画
enableAnimation?: boolean
}>()
// 定义事件
const emit = defineEmits<{
'update:sortField': [value: string]
'update:sortType': [value: 'asc' | 'desc']
'update:filterForm': [key: string, values: string[]]
'selectAll': [key: string]
'clearFilter': [key: string]
'clearAllFilters': []
'removeFilter': [key: string, value: string]
}>()
// 过滤菜单相关
const filterMenuOpen = ref(false)
const currentFilter = ref('site')
const currentFilterTitle = computed(() => props.filterTitles[currentFilter.value])
const currentFilterOptions = computed(() => {
return props.filterOptions[currentFilter.value]
})
// 添加全部筛选菜单相关
const allFilterMenuOpen = ref(false)
// 计算已选择的过滤条件数量
const getFilterCount = computed(() => {
let count = 0
for (const key in props.filterForm) {
count += props.filterForm[key].length
}
return count
})
// 计算已选择的过滤条件
const getSelectedFilters = computed(() => {
const filters: Record<string, string[]> = {}
for (const key in props.filterForm) {
if (props.filterForm[key].length > 0) {
filters[key] = [...props.filterForm[key]]
}
}
return filters
})
// 给定过滤类型返回不同图标
function getFilterIcon(key: string) {
const icons: Record<string, string> = {
site: 'mdi-server-network',
season: 'mdi-television-classic',
freeState: 'mdi-gift-outline',
resolution: 'mdi-monitor-screenshot',
videoCode: 'mdi-video-vintage',
edition: 'mdi-quality-high',
releaseGroup: 'mdi-account-group-outline',
}
return icons[key] || 'mdi-filter-variant'
}
// 开关全部筛选菜单
function toggleAllFilterMenu() {
allFilterMenuOpen.value = !allFilterMenuOpen.value
}
// 添加toggleFilterMenu函数
function toggleFilterMenu(key: string) {
if (currentFilter.value === key && filterMenuOpen.value) {
filterMenuOpen.value = false
} else {
currentFilter.value = key
filterMenuOpen.value = true
}
}
// 处理筛选值变化
function handleFilterChange(key: string, values: string[]) {
emit('update:filterForm', key, values)
}
// 全选某个过滤项
function selectAll(key: string) {
emit('selectAll', key)
}
// 清除某个过滤项
function clearFilter(key: string) {
emit('clearFilter', key)
}
// 清除所有过滤条件
function clearAllFilters() {
emit('clearAllFilters')
}
// 移除单个过滤条件
function removeFilter(key: string, value: string) {
emit('removeFilter', key, value)
}
// 滚动条引用
const filterBarRef = ref<HTMLElement>()
/**
* 自定义平滑滚动
* @param element 元素
* @param target 目标位置
* @param duration 持续时间(ms)
*/
function smoothScroll(element: HTMLElement, target: number, duration: number) {
const start = element.scrollLeft
const change = target - start
let startTime: number | null = null
function animate(currentTime: number) {
if (startTime === null) startTime = currentTime
const timeElapsed = currentTime - startTime
const progress = Math.min(timeElapsed / duration, 1)
// 使用 ease-in-out 缓动函数
const ease = progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress
element.scrollLeft = start + change * ease
if (timeElapsed < duration) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
}
// 初始滚动动画
onMounted(() => {
if (filterBarRef.value) {
useEventListener(filterBarRef, 'wheel', (e: WheelEvent) => {
if (e.deltaY !== 0) {
e.preventDefault()
filterBarRef.value!.scrollLeft += e.deltaY
}
})
}
if (props.enableAnimation === false) return
nextTick(() => {
setTimeout(() => {
const el = filterBarRef.value
if (el && el.clientWidth > 0 && el.scrollWidth > el.clientWidth) {
// 检查当前视口范围内的最后一个元素(即右侧边缘处的元素)
const containerRect = el.getBoundingClientRect()
const children = Array.from(el.children) as HTMLElement[]
const lastInViewport = children
.filter(c => {
const rect = c.getBoundingClientRect()
return rect.left < containerRect.right
})
.pop()
if (lastInViewport) {
const rect = lastInViewport.getBoundingClientRect()
const visibleWidth = Math.min(rect.right, containerRect.right) - rect.left
const visibleRatio = visibleWidth / rect.width
// 判断是否是列表最后一个元素
const isLastItem = lastInViewport === children[children.length - 1]
// 1. 如果是最后一个元素且显示比例超过80%,说明基本已经展示完了,不需要动画
if (isLastItem && visibleRatio > 0.8) {
return
}
// 2. 如果视口内最后一个元素显示比例在30%到80%之间(明显的截断状态),用户能感知到后面还有内容,不需要滚动提示
// 比例过小(<0.3)可能看不清,非最后一个元素且比例过大(>0.8)可能误以为是结尾,这两种情况都需要提示
if (visibleRatio > 0.3 && visibleRatio < 0.8) {
return
}
}
// 滚动到底部 (1100ms)
smoothScroll(el, el.scrollWidth - el.clientWidth, 1100)
// 短暂停止后滚动回顶部 (1100ms)
setTimeout(() => {
smoothScroll(el, 0, 1100)
}, 1600)
}
}, 500)
})
})
</script>
<template>
<!-- PC端头部和筛选栏 -->
<div class="search-header d-none d-sm-block">
<VCard class="view-header mb-3">
<div class="d-flex align-center pa-3">
<!-- 固定位置资源数量和排序 -->
<div class="d-flex align-center flex-shrink-0">
<VChip
color="primary"
variant="flat"
size="small"
class="search-count me-3 flex-shrink-0"
prepend-icon="mdi-magnify"
>
{{ totalFilteredCount }} {{ t('torrent.resources') }}
</VChip>
<VBtn variant="text" size="small" class="sort-btn" :color="undefined">
<template #prepend>
<VIcon :icon="sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending'" class="me-1" />
</template>
<span class="text-subtitle-2">{{ sortTitles[sortField] }}</span>
<VIcon icon="mdi-chevron-down" size="16" class="ms-1" />
<VMenu activator="parent" transition="slide-y-transition">
<VList density="compact" min-width="120" class="sort-menu-list">
<!-- 升序/降序 选项 -->
<VListItem
value="asc"
:active="sortType === 'asc'"
color="primary"
@click="emit('update:sortType', 'asc')"
class="px-3"
>
<template #prepend>
<VIcon icon="mdi-sort-ascending" size="small" class="me-2" />
</template>
<VListItemTitle>{{ t('common.ascending') }}</VListItemTitle>
</VListItem>
<VListItem
value="desc"
:active="sortType === 'desc'"
color="primary"
@click="emit('update:sortType', 'desc')"
class="px-3"
>
<template #prepend>
<VIcon icon="mdi-sort-descending" size="small" class="me-2" />
</template>
<VListItemTitle>{{ t('common.descending') }}</VListItemTitle>
</VListItem>
<VDivider class="my-1" />
<!-- 排序字段选项 -->
<VListItem
v-for="(title, key) in sortTitles"
:key="key"
:value="key"
:active="sortField === key"
color="primary"
@click="emit('update:sortField', key as string)"
class="px-3"
>
<VListItemTitle>{{ title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
<div class="filter-divider"></div>
</div>
<!-- 滚动区域筛选条件 -->
<div class="filter-bar" ref="filterBarRef">
<!-- 筛选按钮 -->
<VBtn
v-for="(title, key) in filterTitles"
v-show="filterOptions[key].length > 0"
:key="key"
variant="tonal"
size="small"
:color="filterForm[key].length > 0 ? 'primary' : undefined"
:prepend-icon="getFilterIcon(key)"
class="filter-btn"
rounded="pill"
>
{{ title }}
<VChip v-if="filterForm[key].length > 0" size="small" color="primary" class="ms-1" variant="elevated">
{{ filterForm[key].length }}
</VChip>
<VMenu activator="parent" :close-on-content-click="false" scrim>
<VCard max-width="20rem">
<VCardText class="filter-menu-content">
<div class="flex justify-between">
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
{{ t('torrent.selectAll') }}
</VBtn>
<VBtn
v-if="filterForm[key].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(key)"
>
{{ t('torrent.clear') }}
</VBtn>
</div>
<VChipGroup
:model-value="filterForm[key]"
@update:model-value="(val: string[]) => handleFilterChange(key, val)"
column
multiple
class="filter-options"
>
<VChip
v-for="option in filterOptions[key]"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
</VCard>
</VMenu>
</VBtn>
<!-- 全部筛选按钮 -->
<VBtn
variant="tonal"
size="small"
color="primary"
class="filter-btn me-2"
prepend-icon="mdi-filter-variant"
rounded="pill"
@click="toggleAllFilterMenu"
>
{{ t('torrent.allFilters') }}
<VChip v-if="getFilterCount > 0" size="small" color="primary" class="ms-1" variant="elevated">
{{ getFilterCount }}
</VChip>
</VBtn>
</div>
</div>
<div v-if="getFilterCount > 0" class="selected-filters">
<div class="d-flex align-center">
<div class="d-flex flex-wrap align-center flex-grow-1">
<template v-for="(values, key) in getSelectedFilters" :key="key">
<VChip
v-for="(value, index) in values"
:key="`${key}-${index}`"
color="primary"
size="small"
closable
variant="elevated"
class="me-1 mb-1 mt-1 filter-tag"
@click:close="removeFilter(key as string, value)"
>
<VIcon size="small" :icon="getFilterIcon(key as string)" class="me-1"></VIcon>
<strong>{{ filterTitles[key as string] }}:</strong> {{ value }}
</VChip>
</template>
</div>
<VSpacer />
<!-- 清除全部筛选按钮 -->
<VBtn
v-if="getFilterCount > 0"
variant="text"
size="small"
color="error"
@click="clearAllFilters"
class="ms-2 flex-shrink-0"
prepend-icon="mdi-close-circle-outline"
>
{{ t('torrent.clearFilters') }}
</VBtn>
</div>
</div>
</VCard>
</div>
<!-- 移动端头部和筛选区域 -->
<VCard class="d-block d-sm-none search-header-mobile mb-3">
<div class="view-header">
<div class="d-flex align-center flex-wrap pa-2">
<div class="d-flex align-center w-100">
<VChip
color="primary"
variant="elevated"
size="small"
class="search-count me-auto"
prepend-icon="mdi-magnify"
>
{{ totalFilteredCount }} {{ t('torrent.resources') }}
</VChip>
<!-- 排序选择 -->
<VBtn variant="text" size="small" class="sort-btn mobile-sort-btn" :color="undefined">
<template #prepend>
<VIcon :icon="sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending'" class="me-1" />
</template>
<span class="text-subtitle-2">{{ sortTitles[sortField] }}</span>
<VIcon icon="mdi-chevron-down" size="16" class="ms-1" />
<VMenu activator="parent" transition="slide-y-transition">
<VList density="compact" min-width="120" class="sort-menu-list">
<!-- 升序/降序 选项 -->
<VListItem
value="asc"
:active="sortType === 'asc'"
color="primary"
@click="emit('update:sortType', 'asc')"
class="px-3"
>
<template #prepend>
<VIcon icon="mdi-sort-ascending" size="small" class="me-2" />
</template>
<VListItemTitle>{{ t('common.ascending') }}</VListItemTitle>
</VListItem>
<VListItem
value="desc"
:active="sortType === 'desc'"
color="primary"
@click="emit('update:sortType', 'desc')"
class="px-3"
>
<template #prepend>
<VIcon icon="mdi-sort-descending" size="small" class="me-2" />
</template>
<VListItemTitle>{{ t('common.descending') }}</VListItemTitle>
</VListItem>
<VDivider class="my-1" />
<!-- 排序字段选项 -->
<VListItem
v-for="(title, key) in sortTitles"
:key="key"
:value="key"
:active="sortField === key"
color="primary"
@click="emit('update:sortField', key as string)"
class="px-3"
>
<VListItemTitle>{{ title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
</div>
<!-- 筛选图标按钮区域 -->
<div class="filter-buttons-grid w-100 mt-2">
<VBtn
v-for="(title, key) in filterTitles"
v-show="filterOptions[key].length > 0"
:key="key"
variant="text"
color="primary"
class="filter-btn-mobile"
@click="toggleFilterMenu(key)"
>
<VIcon :icon="getFilterIcon(key)" class="filter-icon me-1"></VIcon>
<span class="filter-label">
{{ title }}
</span>
<VBadge
v-if="filterForm[key].length > 0"
:content="filterForm[key].length"
color="primary"
location="top end"
offset-x="-10"
offset-y="-10"
></VBadge>
</VBtn>
<!-- 全部筛选按钮 -->
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
<span class="filter-label">
{{ t('torrent.allFilters') }}
</span>
<VBadge
v-if="getFilterCount > 0"
:content="getFilterCount"
color="primary"
location="top end"
offset-x="-10"
offset-y="-10"
></VBadge>
</VBtn>
</div>
</div>
</div>
</VCard>
<!-- 全部筛选弹窗 -->
<VDialog
v-model="allFilterMenuOpen"
max-width="50rem"
location="center"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
<VCardTitle class="py-3 d-flex align-center">
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
<span>{{ t('torrent.allFilters') }}</span>
<VSpacer />
<VBtn
v-if="getFilterCount > 0"
class="me-10"
variant="text"
size="small"
color="error"
@click="clearAllFilters"
>
{{ t('torrent.clearAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<div class="all-filters-grid">
<VCard
v-for="(title, key) in filterTitles"
variant="tonal"
:key="key"
class="filter-section"
v-show="filterOptions[key].length > 0"
>
<VCardItem class="py-2">
<template #prepend>
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
</template>
<VCardTitle>{{ title }}</VCardTitle>
<template #append>
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
{{ t('torrent.selectAll') }}
</VBtn>
<VBtn
v-if="filterForm[key].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(key)"
>
{{ t('torrent.clear') }}
</VBtn>
</template>
</VCardItem>
<VCardText>
<VChipGroup
:model-value="filterForm[key]"
@update:model-value="(val: string[]) => handleFilterChange(key, val)"
column
multiple
class="filter-options"
>
<VChip
v-for="option in filterOptions[key]"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
</VCard>
</div>
</VCardText>
</VCard>
</VDialog>
<!-- 筛选弹窗 -->
<VDialog v-model="filterMenuOpen" max-width="25rem" max-height="85vh" location="center" scrollable>
<VCard>
<VCardTitle class="py-3 d-flex align-center">
<VIcon :icon="getFilterIcon(currentFilter)" class="me-2"></VIcon>
<span>{{ currentFilterTitle }}</span>
<VSpacer />
<VBtn
v-if="filterForm[currentFilter].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(currentFilter)"
>
{{ t('torrent.clear') }}
</VBtn>
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)">
{{ t('torrent.selectAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<VChipGroup
:model-value="filterForm[currentFilter]"
@update:model-value="(val: string[]) => handleFilterChange(currentFilter, val)"
column
multiple
class="filter-options"
>
<VChip
v-for="option in currentFilterOptions"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="filterMenuOpen = false">
{{ t('torrent.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.search-header,
.search-header-mobile {
width: 100%;
max-width: 100%;
}
.view-header {
overflow: hidden;
}
.search-count {
font-weight: 500;
}
.sort-btn {
height: 32px !important;
font-weight: 500;
padding-inline: 12px 6px !important;
}
.sort-btn .v-icon {
color: rgba(var(--v-theme-on-surface), 0.6);
}
.sort-btn :deep(.v-btn__prepend) {
margin-inline-end: 2px !important;
}
.sort-menu-list {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
}
.sort-menu-list :deep(.v-list-item__prepend > .v-icon) {
margin-inline-end: 0px !important;
}
.filter-bar {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
overflow-x: auto;
flex: 1;
width: 0;
min-width: 0;
scrollbar-width: none;
-ms-overflow-style: none;
}
.filter-bar::-webkit-scrollbar {
display: none;
}
.filter-bar > * {
flex-shrink: 0;
}
.filter-divider {
background-color: rgba(var(--v-theme-on-surface), 0.12);
block-size: 24px;
inline-size: 1px;
margin-block: 0;
margin-inline: 8px;
}
.filter-btn {
min-inline-size: 0;
transition: opacity 0.2s;
}
.filter-btn:hover {
opacity: 0.8;
}
.filter-menu-content {
max-block-size: 50vh;
overflow-y: auto;
}
.filter-options {
display: flex;
flex-wrap: wrap;
}
.filter-chip {
border: 1px solid rgba(var(--v-theme-primary), 0.2);
margin: 4px;
background-color: rgba(var(--v-theme-primary), 0.1) !important;
color: rgba(var(--v-theme-on-surface), 0.9) !important;
font-weight: 500;
transition: all 0.2s ease;
}
.filter-chip:hover {
background-color: rgba(var(--v-theme-primary), 0.15) !important;
}
.filter-chip.v-chip--selected {
background-color: rgba(var(--v-theme-primary), 0.85) !important;
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
color: rgb(var(--v-theme-on-primary)) !important;
font-weight: 600;
}
.filter-tag {
font-weight: 500;
transition: all 0.2s;
}
.filter-tag:hover {
opacity: 0.8;
}
.selected-filters {
overflow: hidden;
background-color: rgba(var(--v-theme-surface-variant), 0.08);
padding-block: 8px;
padding-inline: 12px;
}
.filter-buttons-grid {
display: grid;
gap: 4px;
grid-template-columns: repeat(3, 1fr);
}
.filter-btn-mobile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
background-color: rgba(var(--v-theme-surface), 0.5);
block-size: auto;
min-block-size: 48px;
padding-block: 4px;
padding-inline: 0;
}
.filter-icon {
font-size: 18px;
margin-block-end: 2px;
}
.filter-label {
font-size: 0.8rem;
text-align: center;
}
.all-filters-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.filter-section {
background-color: rgba(var(--v-theme-surface-variant), 0.08);
}
</style>

View File

@@ -8,7 +8,44 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const menu = ref(false)
const currentCron = ref(props.modelValue)
const menuRoot = ref<HTMLElement>()
const instance = getCurrentInstance()
const menuContentClass = `cron-input-menu-${instance?.uid ?? 'default'}`
const menuContentSelector = `.${menuContentClass}`
function isCronMenuTarget(target: EventTarget | null) {
if (!(target instanceof Element)) return false
if (menuRoot.value?.contains(target)) return true
const menuContent = document.querySelector(menuContentSelector)
if (menuContent?.contains(target)) return true
const overlayId = target.closest('.v-overlay')?.getAttribute('id')
if (!overlayId || !menuContent) return false
return Array.from(menuContent.querySelectorAll('[aria-owns]')).some(
activator => activator.getAttribute('aria-owns') === overlayId,
)
}
function closeOnOutsidePointerDown(event: PointerEvent) {
if (!menu.value || isCronMenuTarget(event.target)) return
menu.value = false
}
onMounted(() => {
document.addEventListener('pointerdown', closeOnOutsidePointerDown, true)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', closeOnOutsidePointerDown, true)
})
watch(currentCron, newVal => {
emit('update:modelValue', newVal)
@@ -23,8 +60,13 @@ watch(
</script>
<template>
<div>
<VMenu :close-on-content-click="false" content-class="cursor-default" persistent>
<div ref="menuRoot">
<VMenu
v-model="menu"
:close-on-content-click="false"
:content-class="['cursor-default', menuContentClass]"
persistent
>
<template v-slot:activator="{ props }">
<slot name="activator" :menuprops="props" />
</template>

View File

@@ -103,8 +103,21 @@ const selectedPath = computed(() => {
return ''
})
function isFileItem(value: unknown): value is FileItem {
return typeof value === 'object' && value !== null && 'path' in value && 'type' in value
}
function activateDir({ id }: { id: unknown }) {
const item = isFileItem(id) ? id : typeof id === 'string' ? findPath(treeItems.value[0], id) : null
if (!item || item.type !== 'dir') return
activedDirs.value = [item]
}
watch(activedDirs, newVal => {
if (!newVal.length) return
emit('update:modelValue', selectedPath.value)
})
@@ -165,8 +178,10 @@ watch(
activatable
return-object
max-height="20rem"
open-on-click
expand-icon="mdi-folder"
collapse-icon="mdi-folder-open"
@click:open="activateDir"
/>
</VMenu>
</div>

View File

@@ -1,5 +1,28 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
// 初始化 markdown-it
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
})
// 插件:链接在新窗口打开
md.use(mdLinkAttributes, {
attrs: {
target: '_blank',
rel: 'noopener noreferrer',
},
})
// 渲染 Markdown
function renderMarkdown(value: string) {
if (!value) return ''
return md.render(value)
}
// 输入参数
const props = defineProps({
@@ -14,10 +37,79 @@ const props = defineProps({
<VListItemTitle class="font-bold text-lg">
{{ key }}
</VListItemTitle>
<div class="text-gray-500">
{{ value }}
</div>
<div class="markdown-body text-gray-500" v-html="renderMarkdown(value)" />
</VListItem>
</VList>
</VCardText>
</template>
<style scoped>
.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);
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="version-update-toast">
<span class="message">{{ message }}</span>
<button v-if="refreshText" class="refresh-button" @click="handleRefresh">
{{ refreshText }}
</button>
<div v-else class="spinner"></div>
</div>
</template>
<script setup lang="ts">
// 接收 props
interface Props {
message: string
refreshText?: string
onRefresh?: () => void
}
const props = defineProps<Props>()
const handleRefresh = () => {
if (props.onRefresh) {
props.onRefresh()
} else {
window.location.reload()
}
}
</script>
<style scoped>
.version-update-toast {
display: flex;
align-items: center;
gap: 12px;
}
.message {
flex: 1;
word-break: break-all;
line-height: 1.4;
}
.refresh-button {
padding: 6px 16px;
background-color: #fff;
color: #333;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
transition: all 0.2s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.refresh-button:hover {
background-color: #f5f5f5;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.refresh-button:active {
transform: scale(0.98);
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,87 @@
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { usePWA } from '@/composables/usePWA'
/**
* 计算页面内容的可用高度,自动适配 iOS 安全区域和底部 Dock 栏。
*
* 通过 DOM 测量获取布局的实际 padding含 safe-area-inset-top/bottom
* 以及 Footer Dock 的实际高度,确保在任何设备上都不会被 Dock 遮挡。
*
* 计算公式: viewport - layoutPaddingTop - layoutPaddingBottom - footerDock - componentOffset
*
* @param componentOffset - 组件内部额外占用的空间(工具栏、分页栏等,默认 64
* @param minHeight - 最小高度(默认 300
*/
export function useAvailableHeight(
componentOffset: number = 64,
minHeight: number = 300,
) {
const { appMode } = usePWA()
// 响应式测量值
const viewportHeight = ref(window.innerHeight || document.documentElement.clientHeight)
const layoutPaddingTop = ref(72)
const layoutPaddingBottom = ref(24)
const footerDockMeasuredHeight = ref(0)
function updateMeasurements() {
viewportHeight.value = window.innerHeight || document.documentElement.clientHeight
// 测量 .layout-page-content 的实际 padding含 env(safe-area-inset-top) 等)
const layoutEl = document.querySelector('.layout-page-content') as HTMLElement | null
if (layoutEl) {
const style = getComputedStyle(layoutEl)
layoutPaddingTop.value = parseFloat(style.paddingTop) || 72
layoutPaddingBottom.value = parseFloat(style.paddingBottom) || 24
}
// 直接查询 Footer Dock DOM无论 appMode 状态
// Dock 通过 Teleport 挂载到 body存在即测量不存在即为 0
const footerEl = document.querySelector('.footer-nav-container') as HTMLElement | null
footerDockMeasuredHeight.value = footerEl ? footerEl.offsetHeight : 0
}
// appMode 异步变化时PWA 检测完成、屏幕尺寸变化等Dock 会出现/消失
// 需要等 DOM 更新后重新测量
watch(appMode, () => {
nextTick(updateMeasurements)
})
onMounted(() => {
nextTick(updateMeasurements)
window.addEventListener('resize', updateMeasurements)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateMeasurements)
}
})
onUnmounted(() => {
window.removeEventListener('resize', updateMeasurements)
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', updateMeasurements)
}
})
const availableHeight = computed(() => {
const vh = viewportHeight.value
// 布局顶部 padding含 safe-area-inset-top + navbar 高度)
const topPadding = layoutPaddingTop.value
// 布局底部 padding
const bottomPadding = layoutPaddingBottom.value
// 底部 Dock 栏遮挡高度(通过 DOM 测量,含 safe-area-inset-bottom
const footerDockHeight = footerDockMeasuredHeight.value
const available = vh - topPadding - bottomPadding - footerDockHeight - componentOffset
return Math.max(available, minHeight)
})
return {
availableHeight,
viewportHeight,
}
}

View File

@@ -1,12 +1,43 @@
import { ref, inject, nextTick, onMounted, onActivated, onDeactivated, onUnmounted } from 'vue'
import {
computed,
inject,
nextTick,
onActivated,
onDeactivated,
onMounted,
onUnmounted,
ref,
unref,
watch,
type ComputedRef,
type Ref,
} from 'vue'
// 声明全局变量类型
declare global {
interface Window {
__VUE_INJECT_DYNAMIC_BUTTON__?: (button: any) => void
__VUE_UNINJECT_DYNAMIC_BUTTON__?: () => void
}
}
type MaybeRefValue<T> = T | Ref<T> | ComputedRef<T>
export interface DynamicButtonMenuItem {
title?: string
titleKey?: string
titleParams?: Record<string, unknown>
icon?: string
color?: string
action: () => void
}
function resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined): T | undefined
function resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined, fallback: T): T
function resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined, fallback?: T) {
return value !== undefined ? unref(value) : fallback
}
/**
* 动态按钮钩子函数
*
@@ -23,12 +54,14 @@ declare global {
* })
*/
export function useDynamicButton(options: {
icon: string
onClick: () => void
icon: MaybeRefValue<string>
onClick?: () => void
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
show?: MaybeRefValue<boolean>
autoRegister?: boolean // 是否自动注册默认为true
}) {
// 提取配置
const { icon, onClick, autoRegister = true } = options
const { icon, onClick, menuItems, show, autoRegister = true } = options
// 动态按钮相关
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
@@ -36,22 +69,42 @@ export function useDynamicButton(options: {
// 按钮注册状态
const dynamicButtonRegistered = ref(false)
const componentActive = ref(false)
const resolvedIcon = computed(() => resolveMaybeRef(icon, 'mdi-plus'))
const resolvedShow = computed(() => resolveMaybeRef(show, true))
const resolvedMenuItems = computed(() => resolveMaybeRef(menuItems))
function buildDynamicButton() {
const buttonMenuItems = resolvedMenuItems.value
return {
icon: resolvedIcon.value,
action: onClick || (() => {}),
show: resolvedShow.value,
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
}
}
// 注册动态按钮
function setupDynamicButton() {
// 避免重复注册
if (dynamicButtonRegistered.value) return
if (!componentActive.value) return
const button = buildDynamicButton()
if (!button.show) {
cleanupDynamicButton()
return
}
// 确保注册方法存在
if (!registerDynamicButton) {
// 尝试获取全局注册方法
const tryUseGlobalMethod = () => {
if (!componentActive.value) return false
if (typeof window !== 'undefined' && window.__VUE_INJECT_DYNAMIC_BUTTON__) {
window.__VUE_INJECT_DYNAMIC_BUTTON__({
icon,
action: onClick,
show: true,
})
window.__VUE_INJECT_DYNAMIC_BUTTON__(button)
dynamicButtonRegistered.value = true
return true
}
@@ -68,11 +121,9 @@ export function useDynamicButton(options: {
// 如果注册方法存在,直接注册
nextTick(() => {
registerDynamicButton({
icon,
action: onClick,
show: true,
})
if (!componentActive.value) return
registerDynamicButton(button)
dynamicButtonRegistered.value = true
})
}
@@ -82,17 +133,24 @@ export function useDynamicButton(options: {
if (unregisterDynamicButton && dynamicButtonRegistered.value) {
unregisterDynamicButton()
dynamicButtonRegistered.value = false
return
}
if (typeof window !== 'undefined' && window.__VUE_UNINJECT_DYNAMIC_BUTTON__) {
window.__VUE_UNINJECT_DYNAMIC_BUTTON__()
dynamicButtonRegistered.value = false
}
}
// 暴露方法:手动打开对话框
function openDialog() {
onClick()
onClick?.()
}
// 生命周期钩子
if (autoRegister) {
onMounted(() => {
componentActive.value = true
// 延迟执行确保Footer组件已加载
setTimeout(() => {
setupDynamicButton()
@@ -100,18 +158,27 @@ export function useDynamicButton(options: {
})
onActivated(() => {
componentActive.value = true
// 重置注册状态,确保每次激活时都重新注册
dynamicButtonRegistered.value = false
setupDynamicButton()
})
onDeactivated(() => {
componentActive.value = false
cleanupDynamicButton()
})
onUnmounted(() => {
componentActive.value = false
cleanupDynamicButton()
})
watch([resolvedIcon, resolvedShow, resolvedMenuItems], () => {
if (!componentActive.value) return
setupDynamicButton()
}, { deep: true })
}
// 返回控制函数和状态

View File

@@ -0,0 +1,60 @@
import type { Ref } from 'vue'
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
/**
* 无限滚动 composable
* 用于管理分页显示和无限滚动加载
* @param sourceData - 源数据(响应式引用)
* @param pageSize - 每页显示数量默认20
*/
export function useInfiniteScroll<T>(
sourceData: Ref<T[]>,
pageSize: number = 20
) {
// 显示用的数据列表
const displayDataList = ref<T[]>([])
// 剩余数据列表(用于无限滚动)
const remainingDataList = ref<T[]>([]) as Ref<T[]>
// 初始化数据
function initData() {
if (sourceData.value?.length) {
// 显示前 pageSize 个
displayDataList.value = sourceData.value.slice(0, pageSize) as T[]
// 保存剩余数据
remainingDataList.value = sourceData.value.slice(pageSize) as T[]
} else {
displayDataList.value = []
remainingDataList.value = []
}
}
// 加载更多
function loadMore({ done }: { done: (status: InfiniteScrollStatus) => void }) {
// 从 remainingDataList 中获取最前面的 pageSize 个元素
const itemsToMove = remainingDataList.value.splice(0, pageSize) as T[]
;(displayDataList.value as T[]).push(...itemsToMove)
done('ok')
}
// 重置数据
function reset() {
displayDataList.value = []
remainingDataList.value = []
}
// 监听源数据变化,重新初始化
watch(sourceData, () => {
initData()
}, { deep: true, immediate: true })
return {
displayDataList,
remainingDataList,
initData,
loadMore,
reset,
}
}

View File

@@ -0,0 +1,380 @@
import { computed, onBeforeUnmount, ref, type Ref } from 'vue'
import api from '@/api'
export interface LlmProviderAuthMethod {
id: string
type: string
label: string
description?: string
}
export interface LlmProviderAuthStatus {
connected: boolean
type?: string
label?: string
expires_at?: number | null
updated_at?: number | null
}
export interface LlmProviderUrlPreset {
label: string
value: string
}
export interface LlmProviderUrlPresetItem {
title: string
value: string
subtitle?: string
}
export interface LlmProvider {
id: string
name: string
runtime: string
default_base_url: string
base_url_presets?: LlmProviderUrlPreset[]
base_url_editable: boolean
requires_base_url: boolean
supports_api_key: boolean
api_key_label: string
api_key_hint: string
supports_model_refresh: boolean
oauth_methods: LlmProviderAuthMethod[]
description?: string
auth_status: LlmProviderAuthStatus
}
export interface LlmModel {
id: string
name: string
family?: string
context_tokens?: number | null
input_tokens?: number | null
output_tokens?: number | null
context_tokens_k?: number | null
supports_reasoning?: boolean
supports_tools?: boolean
supports_image_input?: boolean
supports_audio_input?: boolean
transport?: string
source?: string
release_date?: string | null
status?: string | null
}
export interface LlmProviderAuthSession {
session_id: string
provider_id: string
flow_type: string
status: string
message?: string
authorize_url?: string
verification_url?: string
user_code?: string
instructions?: string
interval_seconds?: number
expires_at?: number
}
interface UseLlmProviderDirectoryOptions {
provider: Ref<string>
apiKey: Ref<string>
baseUrl: Ref<string>
model: Ref<string>
maxContextTokens?: Ref<number>
authConnected?: Ref<boolean>
}
function normalizeValue(value: unknown) {
return String(value ?? '').trim()
}
export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions) {
const providers = ref<LlmProvider[]>([])
const models = ref<LlmModel[]>([])
const loadingProviders = ref(false)
const loadingModels = ref(false)
const authDialogVisible = ref(false)
const authPolling = ref(false)
const authPopupBlocked = ref(false)
const authSession = ref<LlmProviderAuthSession | null>(null)
let pollTimer: number | null = null
const selectedProvider = computed(
() => providers.value.find(item => item.id === normalizeValue(options.provider.value)) || null,
)
const selectedModel = computed(
() => models.value.find(item => item.id === normalizeValue(options.model.value)) || null,
)
const providerItems = computed(() => providers.value.map(item => ({ title: item.name, value: item.id })))
const baseUrlPresetItems = computed<LlmProviderUrlPresetItem[]>(() =>
(selectedProvider.value?.base_url_presets || []).map(item => ({
title: item.value,
value: item.value,
subtitle: item.label,
})),
)
const providerConnected = computed(() => Boolean(selectedProvider.value?.auth_status?.connected))
const showBaseUrlField = computed(
() => Boolean(selectedProvider.value && (selectedProvider.value.oauth_methods || []).length === 0),
)
const showApiKeyField = computed(() => selectedProvider.value?.supports_api_key !== false)
const hasUsableCredential = computed(() => {
if (providerConnected.value) return true
return Boolean(normalizeValue(options.apiKey.value))
})
const canRefreshModels = computed(() => {
if (!selectedProvider.value?.supports_model_refresh) return false
if (!hasUsableCredential.value) return false
if (selectedProvider.value.requires_base_url && !normalizeValue(options.baseUrl.value)) return false
return true
})
function clearPollTimer() {
if (pollTimer !== null) {
window.clearTimeout(pollTimer)
pollTimer = null
}
}
function syncAuthConnected() {
if (options.authConnected) {
options.authConnected.value = providerConnected.value
}
}
function ensureBaseUrl(reset = false) {
const provider = selectedProvider.value
if (!provider) return
const currentBaseUrl = normalizeValue(options.baseUrl.value)
const defaultBaseUrl = provider.default_base_url || ''
if (reset) {
options.baseUrl.value = defaultBaseUrl
return
}
if (!currentBaseUrl && defaultBaseUrl) {
options.baseUrl.value = defaultBaseUrl
}
}
function handleProviderSelection(resetBaseUrl = true) {
ensureBaseUrl(resetBaseUrl)
options.apiKey.value = ''
if (options.maxContextTokens) {
options.maxContextTokens.value = 64
}
models.value = []
options.model.value = ''
syncAuthConnected()
}
function applyModelMetadata(modelId?: string) {
const targetId = normalizeValue(modelId ?? options.model.value)
if (!targetId) return null
const matched = models.value.find(item => item.id === targetId) || null
if (matched?.context_tokens_k && options.maxContextTokens) {
// models.dev / provider 返回的是精确 token这里回填到现有的 K 单位配置。
options.maxContextTokens.value = matched.context_tokens_k
}
return matched
}
function updateProviderAuthStatus(providerId: string, authStatus?: LlmProviderAuthStatus) {
if (!authStatus) return
const index = providers.value.findIndex(item => item.id === providerId)
if (index === -1) return
providers.value[index] = {
...providers.value[index],
auth_status: authStatus,
}
syncAuthConnected()
}
async function loadProviders(preserveBaseUrl = true) {
loadingProviders.value = true
try {
const result: { [key: string]: any } = await api.get('llm/providers')
if (!result.success) {
throw new Error(result.message || 'Load LLM providers failed')
}
providers.value = Array.isArray(result.data) ? result.data : []
if (!selectedProvider.value && providers.value.length > 0) {
options.provider.value = providers.value[0].id
}
ensureBaseUrl(!preserveBaseUrl)
syncAuthConnected()
return providers.value
} finally {
loadingProviders.value = false
}
}
async function loadModels(forceRefresh = false) {
if (!selectedProvider.value) return []
loadingModels.value = true
try {
const result: { [key: string]: any } = await api.get('llm/models', {
params: {
provider: normalizeValue(options.provider.value),
api_key: normalizeValue(options.apiKey.value) || undefined,
base_url: normalizeValue(options.baseUrl.value) || undefined,
force_refresh: forceRefresh,
},
})
if (!result.success) {
throw new Error(result.message || 'Load LLM models failed')
}
const payload = result.data || {}
models.value = Array.isArray(payload.models) ? payload.models : []
updateProviderAuthStatus(normalizeValue(options.provider.value), payload.auth_status)
const currentModelId = normalizeValue(options.model.value)
const matchedModel = currentModelId
? models.value.find(item => item.id === currentModelId)
: null
if (matchedModel) {
applyModelMetadata(matchedModel.id)
} else if (models.value.length > 0) {
options.model.value = models.value[0].id
applyModelMetadata(models.value[0].id)
}
return models.value
} finally {
loadingModels.value = false
}
}
function openAuthPage() {
const session = authSession.value
const targetUrl = session?.authorize_url || session?.verification_url
if (!targetUrl) return
const popup = window.open(targetUrl, '_blank', 'noopener,noreferrer,width=960,height=780')
authPopupBlocked.value = !popup
}
async function pollAuthSession() {
if (!authSession.value) return null
authPolling.value = true
clearPollTimer()
try {
const result: { [key: string]: any } = await api.post(
`llm/provider-auth/${authSession.value.session_id}/poll`,
)
if (!result.success) {
throw new Error(result.message || 'Poll LLM auth failed')
}
authSession.value = {
...authSession.value,
...result.data,
}
const nextSession = authSession.value
if (!nextSession) return null
if (nextSession.status === 'pending') {
pollTimer = window.setTimeout(
() => pollAuthSession().catch(() => undefined),
Math.max(nextSession.interval_seconds || 5, 1) * 1000,
)
return nextSession
}
await loadProviders()
if (nextSession.status === 'authorized') {
await loadModels(true).catch(() => undefined)
}
return nextSession
} finally {
authPolling.value = false
}
}
async function startAuth(methodId: string) {
if (!selectedProvider.value) {
throw new Error('LLM provider is required')
}
const result: { [key: string]: any } = await api.post('llm/provider-auth/start', {
provider: normalizeValue(options.provider.value),
method: methodId,
})
if (!result.success) {
throw new Error(result.message || 'Start LLM auth failed')
}
authSession.value = {
status: 'pending',
provider_id: normalizeValue(options.provider.value),
...result.data,
}
authDialogVisible.value = true
authPopupBlocked.value = false
openAuthPage()
pollTimer = window.setTimeout(() => pollAuthSession().catch(() => undefined), 1200)
return authSession.value
}
async function disconnectAuth() {
if (!selectedProvider.value) return false
const result: { [key: string]: any } = await api.delete(
`llm/provider-auth/${normalizeValue(options.provider.value)}`,
)
if (!result.success) {
throw new Error(result.message || 'Disconnect LLM auth failed')
}
await loadProviders()
return true
}
function closeAuthDialog() {
authDialogVisible.value = false
clearPollTimer()
}
onBeforeUnmount(() => {
clearPollTimer()
})
return {
providers,
providerItems,
baseUrlPresetItems,
models,
selectedProvider,
selectedModel,
loadingProviders,
loadingModels,
providerConnected,
showBaseUrlField,
showApiKeyField,
hasUsableCredential,
canRefreshModels,
authDialogVisible,
authPolling,
authPopupBlocked,
authSession,
handleProviderSelection,
applyModelMetadata,
loadProviders,
loadModels,
openAuthPage,
startAuth,
pollAuthSession,
disconnectAuth,
closeAuthDialog,
}
}

View File

@@ -12,6 +12,16 @@ const globalPwaStatus = ref<{
const globalLoading = ref(false)
let initPromise: Promise<void> | null = null
// UI模式设置
export type UIMode = 'auto' | 'desktop' | 'app'
const uiMode = ref<UIMode>((localStorage.getItem('ui-mode') as UIMode) || 'auto')
// 设置UI模式
function setUIMode(mode: UIMode) {
uiMode.value = mode
localStorage.setItem('ui-mode', mode)
}
// 全局初始化函数
async function initializePWAGlobally() {
if (initPromise) return initPromise
@@ -50,6 +60,8 @@ export function usePWA() {
})
const appMode = computed(() => {
if (uiMode.value === 'app') return true
if (uiMode.value === 'desktop') return false
return pwaMode.value && display.mdAndDown.value
})
@@ -70,6 +82,8 @@ export function usePWA() {
pwaMode,
appMode,
pwaStatus,
uiMode,
setUIMode,
loading: globalLoading,
initializePWA: initializePWAGlobally,
}

View File

@@ -236,16 +236,15 @@ export function usePullDownGesture(options: PullDownOptions = {}) {
}
}
// PWA状态确定后一次性决定是否添加事件监听器
// 监听 appMode 变化动态添加/移除事件监听器
onMounted(() => {
// 等待PWA检测完成后添加事件监听器
const stopWatcher = watch(
watch(
appMode,
newValue => {
if (newValue) {
addEventListeners()
// PWA状态确定后停止监听
stopWatcher()
} else {
removeEventListeners()
}
},
{ immediate: true },

View File

@@ -13,9 +13,16 @@ export interface WizardData {
username: string
password: string
confirmPassword: string
recognizeSource: string
ocrHost: string
proxyHost: string
githubToken: string
}
siteAuth: {
auxiliaryAuthEnable: boolean
site: string
params: Record<string, string | number>
}
storage: {
downloadPath: string
libraryPath: string
@@ -41,6 +48,32 @@ export interface WizardData {
config: any
switchs: any[]
}
agent: {
enabled: boolean
global: boolean
verbose: boolean
provider: string
authConnected: boolean
model: string
thinkingLevel: string
supportImageInput: boolean
supportAudioInputOutput: boolean
apiKey: string
baseUrl: string
maxContextTokens: number
voiceApiKey: string
voiceBaseUrl: string
voiceSttModel: string
voiceTtsModel: string
voiceTtsVoice: string
voiceLanguage: string
voiceReplyWithText: boolean
jobInterval: number
retryTransfer: boolean
recommendEnabled: boolean
recommendUserPreference: string
recommendMaxItems: number
}
preferences: {
quality: string
subtitle: string
@@ -67,9 +100,14 @@ export interface ConnectivityTestState {
}
export interface ValidationErrorState {
siteAuth: {
site: boolean
[key: string]: boolean
}
downloader: {
name: boolean
host: boolean
apikey: boolean
username: boolean
password: boolean
}
@@ -85,11 +123,45 @@ export interface ValidationErrorState {
name: boolean
[key: string]: boolean
}
agent: {
provider: boolean
apiKey: boolean
model: boolean
maxContextTokens: boolean
recommendMaxItems: boolean
}
}
function normalizeThinkingLevelValue(value?: unknown) {
const normalized = String(value ?? '').trim().toLowerCase()
if (!normalized) return ''
const aliasMap: Record<string, string> = {
none: 'off',
disabled: 'off',
disable: 'off',
enabled: 'auto',
enable: 'auto',
default: 'auto',
dynamic: 'auto',
}
return aliasMap[normalized] || normalized
}
function resolveThinkingLevelValue(data?: Record<string, any>) {
const explicit = normalizeThinkingLevelValue(data?.LLM_THINKING_LEVEL)
if (explicit) return explicit
const legacyEffort = normalizeThinkingLevelValue(data?.LLM_REASONING_EFFORT)
if (data?.LLM_DISABLE_THINKING === true) return 'off'
if (data?.LLM_DISABLE_THINKING === false) return legacyEffort || 'auto'
return legacyEffort || 'off'
}
// 全局状态,所有组件共享
const currentStep = ref(1)
const totalSteps = 6
const totalSteps = 8
// 加载状态
const isLoading = ref(false)
@@ -97,6 +169,22 @@ const isLoading = ref(false)
// 选中的预设规则
const selectedPreset = ref('')
// 可认证站点列表
const authSites = ref<{
[key: string]: {
name: string
icon: string
params: {
[key: string]: {
name: string
type: string
placeholder?: string
tooltip?: string
}
}
}
}>({})
// 向导数据
const wizardData = ref<WizardData>({
basic: {
@@ -105,9 +193,16 @@ const wizardData = ref<WizardData>({
username: '',
password: '',
confirmPassword: '',
recognizeSource: 'themoviedb',
ocrHost: '',
proxyHost: '',
githubToken: '',
},
siteAuth: {
auxiliaryAuthEnable: false,
site: '',
params: {},
},
storage: {
downloadPath: '',
libraryPath: '',
@@ -133,6 +228,32 @@ const wizardData = ref<WizardData>({
config: {},
switchs: [],
},
agent: {
enabled: false,
global: false,
verbose: false,
provider: 'deepseek',
authConnected: false,
model: 'deepseek-chat',
thinkingLevel: 'off',
supportImageInput: true,
supportAudioInputOutput: false,
apiKey: '',
baseUrl: 'https://api.deepseek.com',
maxContextTokens: 64,
voiceApiKey: '',
voiceBaseUrl: '',
voiceSttModel: 'gpt-4o-mini-transcribe',
voiceTtsModel: 'gpt-4o-mini-tts',
voiceTtsVoice: 'alloy',
voiceLanguage: 'zh',
voiceReplyWithText: false,
jobInterval: 0,
retryTransfer: false,
recommendEnabled: false,
recommendUserPreference: '',
recommendMaxItems: 50,
},
preferences: {
quality: '4K',
subtitle: 'chinese',
@@ -151,9 +272,13 @@ const connectivityTest = ref<ConnectivityTestState>({
// 验证错误状态
const validationErrors = ref<ValidationErrorState>({
siteAuth: {
site: false,
},
downloader: {
name: false,
host: false,
apikey: false,
username: false,
password: false,
},
@@ -168,6 +293,13 @@ const validationErrors = ref<ValidationErrorState>({
notification: {
name: false,
},
agent: {
provider: false,
apiKey: false,
model: false,
maxContextTokens: false,
recommendMaxItems: false,
},
})
export function useSetupWizard() {
@@ -181,6 +313,7 @@ export function useSetupWizard() {
downloader: {
'qbittorrent': 'QbittorrentModule',
'transmission': 'TransmissionModule',
'rtorrent': 'RtorrentModule',
},
// 媒体服务器映射
mediaServer: {
@@ -188,6 +321,7 @@ export function useSetupWizard() {
'jellyfin': 'JellyfinModule',
'plex': 'PlexModule',
'trimemedia': 'TrimeMediaModule',
'ugreen': 'UgreenModule',
},
// 通知映射
notification: {
@@ -195,6 +329,7 @@ export function useSetupWizard() {
'wechat': 'WechatModule',
'slack': 'SlackModule',
'synologychat': 'SynologyChatModule',
'qqbot': 'QQBotModule',
'vocechat': 'VoceChatModule',
'webpush': 'WebPushModule',
},
@@ -203,20 +338,24 @@ export function useSetupWizard() {
// 步骤标题
const stepTitles = computed(() => [
t('setupWizard.basic.title'),
t('setupWizard.siteAuth.title'),
t('setupWizard.storage.title'),
t('setupWizard.downloader.title'),
t('setupWizard.mediaServer.title'),
t('setupWizard.notification.title'),
t('setupWizard.agent.title'),
t('setupWizard.preferences.title'),
])
// 步骤描述
const stepDescriptions = computed(() => [
t('setupWizard.basic.description'),
t('setupWizard.siteAuth.description'),
t('setupWizard.storage.description'),
t('setupWizard.downloader.description'),
t('setupWizard.mediaServer.description'),
t('setupWizard.notification.description'),
t('setupWizard.agent.description'),
t('setupWizard.preferences.description'),
])
@@ -323,9 +462,13 @@ export function useSetupWizard() {
// 清除验证错误状态
function clearValidationErrors() {
validationErrors.value.siteAuth = {
site: false,
}
validationErrors.value.downloader = {
name: false,
host: false,
apikey: false,
username: false,
password: false,
}
@@ -340,6 +483,54 @@ export function useSetupWizard() {
validationErrors.value.notification = {
name: false,
}
validationErrors.value.agent = {
provider: false,
apiKey: false,
model: false,
maxContextTokens: false,
recommendMaxItems: false,
}
}
// 验证用户站点认证字段
function validateSiteAuthFields(): { isValid: boolean; errors: string[] } {
const errors: string[] = []
clearValidationErrors()
if (!wizardData.value.siteAuth.site) {
return {
isValid: true,
errors,
}
}
const selectedSite = authSites.value[wizardData.value.siteAuth.site]
if (!selectedSite) {
errors.push(t('setupWizard.siteAuth.siteConfigNotExist'))
validationErrors.value.siteAuth.site = true
return {
isValid: false,
errors,
}
}
const fields = Object.keys(selectedSite.params || {}).filter(key => {
return selectedSite.params[key]?.name && selectedSite.params[key]?.type
})
fields.forEach(key => {
const fieldKey = `${wizardData.value.siteAuth.site.toUpperCase()}_${key.toUpperCase()}`
const value = wizardData.value.siteAuth.params[fieldKey]
if (value === undefined || value === null || value === '') {
errors.push(t('setupWizard.siteAuth.fieldRequired', { name: selectedSite.params[key].name }))
validationErrors.value.siteAuth[fieldKey] = true
}
})
return {
isValid: errors.length === 0,
errors,
}
}
// 验证下载器字段
@@ -360,7 +551,20 @@ export function useSetupWizard() {
}
// 根据下载器类型验证其他必输项
if (wizardData.value.downloader.type === 'qbittorrent' || wizardData.value.downloader.type === 'transmission') {
if (wizardData.value.downloader.type === 'qbittorrent') {
const hasApiKey = !!wizardData.value.downloader.config?.apikey?.trim()
if (!hasApiKey && !wizardData.value.downloader.config?.username?.trim()) {
errors.push(t('downloader.usernameRequired'))
validationErrors.value.downloader.username = true
}
if (!hasApiKey && !wizardData.value.downloader.config?.password?.trim()) {
errors.push(t('downloader.passwordRequired'))
validationErrors.value.downloader.password = true
}
} else if (
wizardData.value.downloader.type === 'transmission'
|| wizardData.value.downloader.type === 'rtorrent'
) {
if (!wizardData.value.downloader.config?.username?.trim()) {
errors.push(t('downloader.usernameRequired'))
validationErrors.value.downloader.username = true
@@ -405,7 +609,7 @@ export function useSetupWizard() {
errors.push(t('mediaserver.tokenRequired'))
validationErrors.value.mediaServer.token = true
}
} else if (wizardData.value.mediaServer.type === 'trimemedia') {
} else if (wizardData.value.mediaServer.type === 'trimemedia' || wizardData.value.mediaServer.type === 'ugreen') {
if (!wizardData.value.mediaServer.config?.username?.trim()) {
errors.push(t('mediaserver.usernameRequired'))
validationErrors.value.mediaServer.username = true
@@ -486,6 +690,65 @@ export function useSetupWizard() {
validationErrors.value.notification.VOCECHAT_API_KEY = true
}
break
case 'webpush':
if (!config.WEBPUSH_USERNAME?.trim()) {
errors.push(t('notification.webpush.usernameRequired'))
validationErrors.value.notification.WEBPUSH_USERNAME = true
}
break
case 'qqbot':
if (!config.QQ_APP_ID?.trim()) {
errors.push(t('notification.qqbot.appIdRequired'))
validationErrors.value.notification.QQ_APP_ID = true
}
if (!config.QQ_APP_SECRET?.trim()) {
errors.push(t('notification.qqbot.appSecretRequired'))
validationErrors.value.notification.QQ_APP_SECRET = true
}
break
}
return {
isValid: errors.length === 0,
errors,
}
}
// 验证智能助手字段
function validateAgentFields(): { isValid: boolean; errors: string[] } {
const errors: string[] = []
clearValidationErrors()
if (!wizardData.value.agent.enabled) {
return {
isValid: true,
errors,
}
}
if (!wizardData.value.agent.provider?.trim()) {
errors.push(t('setupWizard.agent.providerRequired'))
validationErrors.value.agent.provider = true
}
if (!wizardData.value.agent.apiKey?.trim() && !wizardData.value.agent.authConnected) {
errors.push(t('setupWizard.agent.authOrApiKeyRequired'))
validationErrors.value.agent.apiKey = true
}
if (!wizardData.value.agent.model?.trim()) {
errors.push(t('setupWizard.agent.modelRequired'))
validationErrors.value.agent.model = true
}
if (!wizardData.value.agent.maxContextTokens || wizardData.value.agent.maxContextTokens < 1) {
errors.push(t('setupWizard.agent.maxContextTokensRequired'))
validationErrors.value.agent.maxContextTokens = true
}
if (wizardData.value.agent.recommendEnabled && (!wizardData.value.agent.recommendMaxItems || wizardData.value.agent.recommendMaxItems < 1)) {
errors.push(t('setupWizard.agent.recommendMaxItemsRequired'))
validationErrors.value.agent.recommendMaxItems = true
}
return {
@@ -520,6 +783,13 @@ export function useSetupWizard() {
break
case 2: // 存储设置
if (wizardData.value.siteAuth.site) {
const validation = validateSiteAuthFields()
errors.push(...validation.errors)
}
break
case 3: // 存储设置
if (!wizardData.value.storage.downloadPath) {
errors.push(t('setupWizard.storage.downloadPathRequired'))
}
@@ -528,7 +798,7 @@ export function useSetupWizard() {
}
break
case 3: // 下载器设置
case 4: // 下载器设置
if (wizardData.value.downloader.type) {
// 如果选择了下载器,则验证必输项
const validation = validateDownloaderFields()
@@ -536,7 +806,7 @@ export function useSetupWizard() {
}
break
case 4: // 媒体服务器设置
case 5: // 媒体服务器设置
if (wizardData.value.mediaServer.type) {
// 如果选择了媒体服务器,则验证必输项
const validation = validateMediaServerFields()
@@ -544,7 +814,7 @@ export function useSetupWizard() {
}
break
case 5: // 通知设置
case 6: // 通知设置
if (wizardData.value.notification.type) {
// 如果选择了通知,则验证必输项
const validation = validateNotificationFields()
@@ -552,7 +822,14 @@ export function useSetupWizard() {
}
break
case 6: // 偏好设置
case 7: // 智能助手设置
if (wizardData.value.agent.enabled) {
const validation = validateAgentFields()
errors.push(...validation.errors)
}
break
case 8: // 偏好设置
// 偏好设置有默认值,不需要验证
break
}
@@ -567,12 +844,14 @@ export function useSetupWizard() {
function shouldPerformTest(step: number): boolean {
switch (step) {
case 2: // 存储目录测试 - 总是需要测试
return false
case 3: // 存储目录测试 - 总是需要测试
return true
case 3: // 下载器测试 - 只有选择了下载器才测试
case 4: // 下载器测试 - 只有选择了下载器才测试
return !!wizardData.value.downloader.type
case 4: // 媒体服务器测试 - 只有选择了媒体服务器才测试
case 5: // 媒体服务器测试 - 只有选择了媒体服务器才测试
return !!wizardData.value.mediaServer.type
case 5: // 消息通知测试 - 只有选择了通知才测试
case 6: // 消息通知测试 - 只有选择了通知才测试
return !!wizardData.value.notification.type
default:
return false
@@ -592,15 +871,17 @@ export function useSetupWizard() {
switch (step) {
case 2: // 存储目录测试
break
case 3: // 存储目录测试
testResult = await testStorageConnectivity()
break
case 3: // 下载器测试
case 4: // 下载器测试
testResult = await testDownloaderConnectivity()
break
case 4: // 媒体服务器测试
case 5: // 媒体服务器测试
testResult = await testMediaServerConnectivity()
break
case 5: // 消息通知测试
case 6: // 消息通知测试
testResult = await testNotificationConnectivity()
break
}
@@ -783,18 +1064,21 @@ export function useSetupWizard() {
validation.errors.forEach(error => {
$toast.error(error)
})
return
return false
}
// 保存当前步骤的设置
await saveCurrentStepSettings()
const saved = await saveCurrentStepSettings()
if (!saved) {
return false
}
// 检查是否需要进行测试
const needsTest = shouldPerformTest(currentStep.value)
if (needsTest) {
const testResult = await testConnectivity(currentStep.value)
if (!testResult) {
return
return false
}
}
@@ -803,6 +1087,8 @@ export function useSetupWizard() {
currentStep.value++
connectivityTest.value.showResult = false
}
return true
}
// 上一步
@@ -818,35 +1104,38 @@ export function useSetupWizard() {
try {
switch (currentStep.value) {
case 1:
await saveBasicSettings()
break
return await saveBasicSettings()
case 2:
await saveStorageSettings()
break
return await saveSiteAuthSettings()
case 3:
await saveDownloaderSettings()
break
return await saveStorageSettings()
case 4:
await saveMediaServerSettings()
break
return await saveDownloaderSettings()
case 5:
await saveNotificationSettings()
break
return await saveMediaServerSettings()
case 6:
await savePreferenceSettings()
break
return await saveNotificationSettings()
case 7:
return await saveAgentSettings()
case 8:
return await savePreferenceSettings()
}
} catch (error) {
console.error('Save current step settings failed:', error)
$toast.error(t('setupWizard.saveStepFailed'))
return false
}
return true
}
// 完成向导
async function completeWizard() {
try {
// 先处理下一步(保存当前步骤设置)
await nextStep()
const saved = await nextStep()
if (!saved) {
return
}
// 保存设置向导完成状态
await saveSetupWizardState()
@@ -899,6 +1188,8 @@ export function useSetupWizard() {
const basicSettings = {
APP_DOMAIN: wizardData.value.basic.appDomain,
API_TOKEN: wizardData.value.basic.apiToken,
RECOGNIZE_SOURCE: 'themoviedb',
OCR_HOST: wizardData.value.basic.ocrHost,
PROXY_HOST: wizardData.value.basic.proxyHost,
GITHUB_TOKEN: wizardData.value.basic.githubToken,
}
@@ -906,21 +1197,23 @@ export function useSetupWizard() {
// 保存基础设置
const response: { [key: string]: any } = await api.post('system/env', basicSettings)
if (!response.success) {
return
return false
}
// 如果输入了密码,验证密码一致性
if (wizardData.value.basic.password) {
if (wizardData.value.basic.password !== wizardData.value.basic.confirmPassword) {
$toast.error(t('dialog.userAddEdit.passwordMismatch'))
return
return false
}
// 更新用户密码
await updateUserPassword()
}
return true
} catch (error) {
console.error('Save basic settings failed:', error)
$toast.error(t('setupWizard.saveBasicSettingsFailed'))
return false
}
}
@@ -959,9 +1252,44 @@ export function useSetupWizard() {
}
await api.post('system/setting/Directories', [directory])
return true
} catch (error) {
console.error('Save storage settings failed:', error)
$toast.error(t('setupWizard.saveStorageSettingsFailed'))
return false
}
}
// 保存用户站点认证设置
async function saveSiteAuthSettings() {
try {
const envResponse: { [key: string]: any } = await api.post('system/env', {
AUXILIARY_AUTH_ENABLE: wizardData.value.siteAuth.auxiliaryAuthEnable,
})
if (!envResponse.success) {
return false
}
if (!wizardData.value.siteAuth.site) {
return true
}
const response: { [key: string]: any } = await api.post('site/auth', {
site: wizardData.value.siteAuth.site,
params: wizardData.value.siteAuth.params,
})
if (!response.success) {
$toast.error(t('setupWizard.saveSiteAuthSettingsFailed', { message: response.message }))
return false
}
return true
} catch (error) {
console.error('Save site auth settings failed:', error)
$toast.error(t('setupWizard.saveSiteAuthSettingsFailed', { message: (error as Error).message || '' }))
return false
}
}
@@ -981,13 +1309,16 @@ export function useSetupWizard() {
}
await api.post('system/setting/Downloaders', [downloader])
return true
} catch (error) {
console.error('Save downloader settings failed:', error)
$toast.error(t('setupWizard.saveDownloaderSettingsFailed'))
return false
}
} else {
// 没有选择下载器时,清空现有配置
console.log('No downloader selected, skipping save')
return true
}
}
@@ -1008,13 +1339,16 @@ export function useSetupWizard() {
}
await api.post('system/setting/MediaServers', [mediaServer])
return true
} catch (error) {
console.error('Save media server settings failed:', error)
$toast.error(t('setupWizard.saveMediaServerSettingsFailed'))
return false
}
} else {
// 没有选择媒体服务器时,清空现有配置
console.log('No media server selected, skipping save')
return true
}
}
@@ -1035,13 +1369,55 @@ export function useSetupWizard() {
}
await api.post('system/setting/Notifications', [notification])
return true
} catch (error) {
console.error('Save notification settings failed:', error)
$toast.error(t('setupWizard.saveNotificationSettingsFailed'))
return false
}
} else {
// 没有选择通知时,清空现有配置
console.log('No notification selected, skipping save')
return true
}
}
// 保存智能助手设置
async function saveAgentSettings() {
try {
const agentSettings = {
AI_AGENT_ENABLE: wizardData.value.agent.enabled,
AI_AGENT_GLOBAL: wizardData.value.agent.enabled ? wizardData.value.agent.global : false,
AI_AGENT_VERBOSE: wizardData.value.agent.enabled ? wizardData.value.agent.verbose : false,
LLM_PROVIDER: wizardData.value.agent.provider,
LLM_MODEL: wizardData.value.agent.model,
LLM_THINKING_LEVEL: wizardData.value.agent.thinkingLevel,
LLM_SUPPORT_IMAGE_INPUT: wizardData.value.agent.supportImageInput,
LLM_SUPPORT_AUDIO_INPUT_OUTPUT: wizardData.value.agent.supportAudioInputOutput,
LLM_API_KEY: wizardData.value.agent.apiKey,
LLM_BASE_URL: wizardData.value.agent.baseUrl || null,
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
AI_VOICE_API_KEY: wizardData.value.agent.voiceApiKey || null,
AI_VOICE_BASE_URL: wizardData.value.agent.voiceBaseUrl || null,
AI_VOICE_STT_MODEL: wizardData.value.agent.voiceSttModel,
AI_VOICE_TTS_MODEL: wizardData.value.agent.voiceTtsModel,
AI_VOICE_TTS_VOICE: wizardData.value.agent.voiceTtsVoice,
AI_VOICE_LANGUAGE: wizardData.value.agent.voiceLanguage,
AI_VOICE_REPLY_WITH_TEXT: wizardData.value.agent.voiceReplyWithText,
AI_AGENT_JOB_INTERVAL: wizardData.value.agent.enabled ? wizardData.value.agent.jobInterval : 0,
AI_AGENT_RETRY_TRANSFER: wizardData.value.agent.enabled ? wizardData.value.agent.retryTransfer : false,
AI_RECOMMEND_ENABLED:
wizardData.value.agent.enabled && wizardData.value.agent.recommendEnabled,
AI_RECOMMEND_USER_PREFERENCE: wizardData.value.agent.recommendUserPreference,
AI_RECOMMEND_MAX_ITEMS: wizardData.value.agent.recommendMaxItems,
}
await api.post('system/env', agentSettings)
return true
} catch (error) {
console.error('Save agent settings failed:', error)
$toast.error(t('setupWizard.saveAgentSettingsFailed'))
return false
}
}
@@ -1070,9 +1446,11 @@ export function useSetupWizard() {
console.error('Save rule sequences failed:', error)
}
}
return true
} catch (error) {
console.error('Save preference settings failed:', error)
$toast.error(t('setupWizard.savePreferenceSettingsFailed'))
return false
}
}
@@ -1104,12 +1482,40 @@ export function useSetupWizard() {
if (result.data.PROXY_HOST) {
wizardData.value.basic.proxyHost = result.data.PROXY_HOST
}
if (result.data.OCR_HOST) {
wizardData.value.basic.ocrHost = result.data.OCR_HOST
}
if (result.data.GITHUB_TOKEN) {
wizardData.value.basic.githubToken = result.data.GITHUB_TOKEN
}
wizardData.value.siteAuth.auxiliaryAuthEnable = Boolean(result.data.AUXILIARY_AUTH_ENABLE)
if (result.data.SUPERUSER) {
wizardData.value.basic.username = result.data.SUPERUSER
}
wizardData.value.agent.enabled = Boolean(result.data.AI_AGENT_ENABLE)
wizardData.value.agent.global = Boolean(result.data.AI_AGENT_GLOBAL)
wizardData.value.agent.verbose = Boolean(result.data.AI_AGENT_VERBOSE)
wizardData.value.agent.provider = result.data.LLM_PROVIDER || 'deepseek'
wizardData.value.agent.authConnected = false
wizardData.value.agent.model = result.data.LLM_MODEL || ''
wizardData.value.agent.thinkingLevel = resolveThinkingLevelValue(result.data)
wizardData.value.agent.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true
wizardData.value.agent.supportAudioInputOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT_OUTPUT)
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
wizardData.value.agent.voiceApiKey = result.data.AI_VOICE_API_KEY || ''
wizardData.value.agent.voiceBaseUrl = result.data.AI_VOICE_BASE_URL || ''
wizardData.value.agent.voiceSttModel = result.data.AI_VOICE_STT_MODEL || 'gpt-4o-mini-transcribe'
wizardData.value.agent.voiceTtsModel = result.data.AI_VOICE_TTS_MODEL || 'gpt-4o-mini-tts'
wizardData.value.agent.voiceTtsVoice = result.data.AI_VOICE_TTS_VOICE || 'alloy'
wizardData.value.agent.voiceLanguage = result.data.AI_VOICE_LANGUAGE || 'zh'
wizardData.value.agent.voiceReplyWithText = Boolean(result.data.AI_VOICE_REPLY_WITH_TEXT)
wizardData.value.agent.jobInterval = result.data.AI_AGENT_JOB_INTERVAL || 0
wizardData.value.agent.retryTransfer = Boolean(result.data.AI_AGENT_RETRY_TRANSFER)
wizardData.value.agent.recommendEnabled = Boolean(result.data.AI_RECOMMEND_ENABLED)
wizardData.value.agent.recommendUserPreference = result.data.AI_RECOMMEND_USER_PREFERENCE || ''
wizardData.value.agent.recommendMaxItems = result.data.AI_RECOMMEND_MAX_ITEMS || 50
// 如果没有API Token则创建一个随机的
if (!wizardData.value.basic.apiToken) {
@@ -1121,6 +1527,28 @@ export function useSetupWizard() {
}
}
// 加载用户站点认证列表
async function loadAuthSites() {
try {
authSites.value = (await api.get('site/auth')) || {}
} catch (error) {
console.log('Load auth sites failed:', error)
}
}
// 加载用户站点认证设置
async function loadSiteAuthSettings() {
try {
const result: { [key: string]: any } = await api.get('system/setting/UserSiteAuthParams')
if (result.success && result.data?.value) {
wizardData.value.siteAuth.site = result.data.value.site || ''
wizardData.value.siteAuth.params = result.data.value.params || {}
}
} catch (error) {
console.log('Load site auth settings failed:', error)
}
}
// 加载存储设置
async function loadStorageSettings() {
try {
@@ -1190,6 +1618,8 @@ export function useSetupWizard() {
isLoading.value = true
try {
await loadSystemSettings()
await loadAuthSites()
await loadSiteAuthSettings()
await loadStorageSettings()
await loadDownloaderSettings()
await loadMediaServerSettings()
@@ -1206,6 +1636,7 @@ export function useSetupWizard() {
stepTitles,
stepDescriptions,
wizardData,
authSites,
selectedPreset,
connectivityTest,
validationErrors,
@@ -1220,9 +1651,11 @@ export function useSetupWizard() {
selectPreset,
updatePreferences,
validateCurrentStep,
validateSiteAuthFields,
validateDownloaderFields,
validateMediaServerFields,
validateNotificationFields,
validateAgentFields,
clearValidationErrors,
testConnectivity,
nextStep,

View File

@@ -0,0 +1,502 @@
import type { Context } from '@/api/types'
import { cloneDeepWith } from 'lodash-es'
import { useI18n } from 'vue-i18n'
// 卡片视图的分组数据类型
interface SearchTorrent extends Context {
more?: Array<Context>
}
interface GroupedItem {
data: SearchTorrent
originalIndex: number
}
// 筛选状态类型
export interface FilterState {
filterForm: Record<string, string[]>
filterOptions: Record<string, string[]>
sortField: string
sortType: 'asc' | 'desc'
}
// useTorrentFilter composable
export function useTorrentFilter() {
const { t } = useI18n()
// 过滤表单
const filterForm: Record<string, string[]> = reactive({
site: [] as string[],
season: [] as string[],
releaseGroup: [] as string[],
videoCode: [] as string[],
freeState: [] as string[],
edition: [] as string[],
resolution: [] as string[],
})
// 统一存储过滤选项
const filterOptions: Record<string, string[]> = reactive({
site: [] as string[],
season: [] as string[],
freeState: [] as string[],
edition: [] as string[],
resolution: [] as string[],
videoCode: [] as string[],
releaseGroup: [] as string[],
})
// 排序字段
const sortField = ref('default')
// 排序方向
const sortType = ref<'asc' | 'desc'>('desc')
// 过滤项映射
const filterTitles: Record<string, string> = {
site: t('torrent.filterSite'),
season: t('torrent.filterSeason'),
freeState: t('torrent.filterFreeState'),
videoCode: t('torrent.filterVideoCode'),
edition: t('torrent.filterEdition'),
resolution: t('torrent.filterResolution'),
releaseGroup: t('torrent.filterReleaseGroup'),
}
// 排序中文名
const sortTitles: Record<string, string> = {
default: t('torrent.sortDefault'),
site: t('torrent.sortSite'),
size: t('torrent.sortSize'),
seeder: t('torrent.sortSeeder'),
publishTime: t('torrent.sortPublishTime'),
}
// 筛选后数据的原始索引列表
const filteredIndices = ref<number[]>([])
// 筛选后的总数量
const totalFilteredCount = ref(0)
// 初始化过滤选项
function initOptions(data: Context) {
const { torrent_info, meta_info } = data
const optionValue = (options: Array<string>, value: string | undefined) => {
if (value && !options.includes(value)) {
options.push(value)
// 如果是season选项立即触发重新计算
if (options === filterOptions.season) {
sortSeasonOptions()
}
}
}
optionValue(filterOptions.site, torrent_info?.site_name)
optionValue(filterOptions.season, meta_info?.season_episode)
optionValue(filterOptions.releaseGroup, meta_info?.resource_team)
optionValue(filterOptions.videoCode, meta_info?.video_encode)
optionValue(filterOptions.freeState, torrent_info?.volume_factor)
optionValue(filterOptions.edition, meta_info?.edition)
optionValue(filterOptions.resolution, meta_info?.resource_pix)
}
// 直接对季集选项进行排序的函数
function sortSeasonOptions() {
if (filterOptions.season.length <= 1) {
return
}
const parsedOptions = filterOptions.season.map((option, index) => {
const match = option.match(/^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$/)
if (!match) {
return {
original: option,
seasonNum: 0,
episodeNum: 0,
maxEpisodeNum: 0,
isWholeSeason: false,
index,
}
}
const seasonNum = parseInt(match[1], 10)
const episodeNum = match[3] ? parseInt(match[3], 10) : 0
const maxEpisodeNum = match[4] ? parseInt(match[4], 10) : episodeNum
const isWholeSeason = !match[3]
return {
original: option,
seasonNum,
episodeNum,
maxEpisodeNum,
isWholeSeason,
index,
}
})
const wholeSeasons = parsedOptions.filter(item => item.isWholeSeason)
const episodes = parsedOptions.filter(item => !item.isWholeSeason)
wholeSeasons.sort((a, b) => {
if (a.seasonNum !== b.seasonNum) {
return b.seasonNum - a.seasonNum
}
return a.index - b.index
})
episodes.sort((a, b) => {
if (a.seasonNum !== b.seasonNum) {
return b.seasonNum - a.seasonNum
}
const aMaxEp = a.maxEpisodeNum || a.episodeNum
const bMaxEp = b.maxEpisodeNum || b.episodeNum
if (aMaxEp !== bMaxEp) {
return bMaxEp - aMaxEp
}
if (a.episodeNum !== b.episodeNum) {
return b.episodeNum - a.episodeNum
}
return a.index - b.index
})
const sortedOptions = [...wholeSeasons, ...episodes].map(item => item.original)
filterOptions.season = sortedOptions
}
// 匹配过滤函数
const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value))
// 筛选列表视图数据(不分组)
function filterRowData(items: Context[] | undefined): Context[] {
// 重置状态
filteredIndices.value = []
// 清空并重新初始化过滤选项
for (const key in filterOptions) {
filterOptions[key] = []
}
if (!items?.length) {
totalFilteredCount.value = 0
return []
}
// 首先收集所有过滤选项
items.forEach(data => {
initOptions(data)
})
// 筛选数据
let filteredData: Context[] = []
items.forEach((data, index) => {
const { meta_info, torrent_info } = data
if (
match(filterForm.site, torrent_info.site_name) &&
match(filterForm.freeState, torrent_info.volume_factor) &&
match(filterForm.season, meta_info.season_episode) &&
match(filterForm.releaseGroup, meta_info.resource_team) &&
match(filterForm.videoCode, meta_info.video_encode) &&
match(filterForm.resolution, meta_info.resource_pix) &&
match(filterForm.edition, meta_info.edition)
) {
filteredData.push(data)
filteredIndices.value.push(index)
}
})
totalFilteredCount.value = filteredData.length
// 排序
filteredData = sortData(filteredData)
// 确保季集选项排序
if (filterOptions.season.length > 0) {
sortSeasonOptions()
}
return filteredData
}
// 筛选卡片视图数据(分组)
function filterCardData(items: Context[] | undefined): SearchTorrent[] {
// 重置状态
filteredIndices.value = []
// 清空并重新初始化过滤选项
for (const key in filterOptions) {
filterOptions[key] = []
}
if (!items?.length) {
totalFilteredCount.value = 0
return []
}
// 数据分组
const groupMap = new Map<string, GroupedItem[]>()
items.forEach((item, index) => {
const { torrent_info, meta_info } = item
// init options
initOptions(item)
// group data
const key = `${meta_info.name}_${meta_info.resource_pix}_${meta_info.edition}_${meta_info.resource_team}_${meta_info.season_episode}_${torrent_info.size}`
const groupedItem = { data: item, originalIndex: index }
if (groupMap.has(key)) {
const group = groupMap.get(key)
group?.push(groupedItem)
} else {
groupMap.set(key, [groupedItem])
}
})
// 筛选数据
const filteredData: SearchTorrent[] = []
let matchCount = 0
// 临时存储:每个分组的第一个原始索引
const groupIndexMap = new Map<SearchTorrent, number>()
groupMap.forEach(value => {
if (value.length > 0) {
const matchData = value.filter(item => {
const { meta_info, torrent_info } = item.data
return (
match(filterForm.site, torrent_info.site_name) &&
match(filterForm.freeState, torrent_info.volume_factor) &&
match(filterForm.season, meta_info.season_episode) &&
match(filterForm.releaseGroup, meta_info.resource_team) &&
match(filterForm.videoCode, meta_info.video_encode) &&
match(filterForm.resolution, meta_info.resource_pix) &&
match(filterForm.edition, meta_info.edition)
)
})
if (matchData.length > 0) {
matchCount += matchData.length
const firstItem = matchData[0]
const firstData = cloneDeepWith(firstItem.data) as SearchTorrent
if (matchData.length > 1) firstData.more = matchData.slice(1).map(x => x.data)
filteredData.push(firstData)
// 存储该分组的第一个原始索引
groupIndexMap.set(firstData, firstItem.originalIndex)
}
}
})
totalFilteredCount.value = matchCount
// 排序数据
const sortedData = sortCardData(filteredData)
// 在排序后重新构建 filteredIndices保持与排序后顺序一致
filteredIndices.value = sortedData.map(item => groupIndexMap.get(item) || 0)
// 确保季集选项排序
if (filterOptions.season.length > 0) {
sortSeasonOptions()
}
return sortedData
}
// 排序列表数据
function sortData(data: Context[]): Context[] {
const sortOrder = sortType.value === 'asc' ? 1 : -1
return data.sort((a, b) => {
let result = 0
switch (sortField.value) {
case 'site':
result = (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
break
case 'size':
result = a.torrent_info.size - b.torrent_info.size
break
case 'seeder':
result = a.torrent_info.seeders - b.torrent_info.seeders
break
case 'publishTime':
result = new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
break
case 'default':
default:
result = a.torrent_info.pri_order - b.torrent_info.pri_order
break
}
return result * sortOrder
})
}
// 排序卡片数据
function sortCardData(data: SearchTorrent[]): SearchTorrent[] {
if (sortField.value === 'default') {
return data
}
const sortOrder = sortType.value === 'asc' ? 1 : -1
return data.sort((a, b) => {
let result = 0
switch (sortField.value) {
case 'site':
result = (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
break
case 'size':
result = (Number(a.torrent_info.size) || 0) - (Number(b.torrent_info.size) || 0)
break
case 'seeder':
result = (Number(a.torrent_info.seeders) || 0) - (Number(b.torrent_info.seeders) || 0)
break
case 'publishTime':
result = new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
break
}
return result * sortOrder
})
}
// 计算已选择的过滤条件数量
const getFilterCount = computed(() => {
let count = 0
for (const key in filterForm) {
count += filterForm[key].length
}
return count
})
// 计算已选择的过滤条件
const getSelectedFilters = computed(() => {
const filters: Record<string, string[]> = {}
for (const key in filterForm) {
if (filterForm[key].length > 0) {
filters[key] = [...filterForm[key]]
}
}
return filters
})
// 移除单个过滤条件
function removeFilter(key: string, value: string) {
const index = filterForm[key].indexOf(value)
if (index !== -1) {
filterForm[key].splice(index, 1)
}
}
// 清除所有过滤条件
function clearAllFilters() {
for (const key in filterForm) {
filterForm[key] = []
}
}
// 清除某个过滤项
function clearFilter(key: string) {
filterForm[key] = []
}
// 全选某个过滤项
function selectAll(key: string) {
filterForm[key] = [...filterOptions[key]]
}
// 给定过滤类型返回不同图标
function getFilterIcon(key: string) {
const icons: Record<string, string> = {
site: 'mdi-server-network',
season: 'mdi-television-classic',
freeState: 'mdi-gift-outline',
resolution: 'mdi-monitor-screenshot',
videoCode: 'mdi-video-vintage',
edition: 'mdi-quality-high',
releaseGroup: 'mdi-account-group-outline',
}
return icons[key] || 'mdi-filter-variant'
}
// 处理排序图标点击
const handleSortIconClick = () => {
sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'
}
// 获取筛选后的原始索引列表
function getFilteredIndices() {
return filteredIndices.value
}
// 检查是否有活动的筛选条件
function hasActiveFilters() {
for (const key in filterForm) {
if (filterForm[key] && filterForm[key].length > 0) {
return true
}
}
return false
}
// 获取当前筛选条件
function getFilterForm() {
const filters: Record<string, string[]> = {}
for (const key in filterForm) {
filters[key] = [...filterForm[key]]
}
return filters
}
// 设置筛选条件
function setFilterForm(filters: Record<string, string[]>) {
for (const key in filterForm) {
filterForm[key] = filters[key] ? [...filters[key]] : []
}
}
// 获取完整的筛选状态
function getFilterState(): FilterState {
return {
filterForm: getFilterForm(),
filterOptions: { ...filterOptions },
sortField: sortField.value,
sortType: sortType.value,
}
}
// 设置完整的筛选状态
function setFilterState(state: FilterState) {
setFilterForm(state.filterForm)
sortField.value = state.sortField
sortType.value = state.sortType
}
return {
// 状态
filterForm,
filterOptions,
sortField,
sortType,
filteredIndices,
totalFilteredCount,
// 标题映射
filterTitles,
sortTitles,
// 计算属性
getFilterCount,
getSelectedFilters,
// 筛选方法
filterRowData,
filterCardData,
// 操作方法
removeFilter,
clearAllFilters,
clearFilter,
selectAll,
getFilterIcon,
handleSortIconClick,
// 状态管理方法
getFilteredIndices,
hasActiveFilters,
getFilterForm,
setFilterForm,
getFilterState,
setFilterState,
sortSeasonOptions,
}
}

View File

@@ -0,0 +1,174 @@
import { ref, h } from 'vue'
import { useToast } from 'vue-toastification'
import { Workbox } from 'workbox-window'
import i18n from '@/plugins/i18n'
import VersionUpdateToast from '@/components/toast/VersionUpdateToast.vue'
// 全局状态
const currentVersion = ref(__APP_VERSION__)
let isUpdateToastShown = false
let wb: Workbox | null = null
/**
* 普通刷新页面
*/
export const reloadPage = (): void => {
window.location.reload()
}
/**
* 刷新页面并添加时间戳
*/
export const reloadWithTimestamp = (): void => {
const url = new URL(window.location.href)
url.searchParams.set('_t', Date.now().toString())
window.location.replace(url.pathname + url.search + url.hash)
}
/**
* 清除所有缓存和 Service Worker
*/
export const clearCachesAndServiceWorker = async (): Promise<void> => {
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 (error) {
console.error('[VersionChecker] 清除缓存失败:', error)
}
}
/**
* 清除缓存并刷新
*/
const clearCacheAndReload = async (): Promise<void> => {
await clearCachesAndServiceWorker()
reloadWithTimestamp()
}
/**
* 版本检查 Composable
*
* 功能:
* - 使用 Workbox 监听 Service Worker 更新
* - 检查浏览器版本与服务端版本是否一致
* - 显示持久化更新通知
*/
export function useVersionChecker() {
const toast = useToast()
/**
* 显示版本更新通知
* @param message 通知消息文本
* @param refreshText 按钮文本,不传则不显示按钮
* @param onRefresh 按钮点击事件
*/
const showUpdateNotification = (message: string, refreshText?: string, onRefresh?: () => void): void => {
if (isUpdateToastShown) return
isUpdateToastShown = true
const component = h(VersionUpdateToast, {
message,
refreshText,
onRefresh,
})
toast.info(component, {
timeout: false, // 不自动消失
closeButton: false,
closeOnClick: false,
draggable: false,
})
}
// 初始化 Workbox
if (!wb && 'serviceWorker' in navigator) {
wb = new Workbox('/service-worker.js')
// Service Worker 激活事件 (install -> activate)
wb.addEventListener('activated', event => {
// 只有在更新时才显示通知
if (event.isUpdate) {
console.log('[VersionChecker] Service Worker 更新已就绪,等待用户刷新')
showUpdateNotification(i18n.global.t('common.swUpdateReady'), i18n.global.t('common.refresh'), reloadPage)
}
})
// 注册 Service Worker
wb.register()
}
/**
* 检查版本并在需要时显示更新通知
* @param latestVersion 服务端返回的最新版本号
*/
const checkVersion = async (latestVersion: string): Promise<void> => {
// 如果已经显示过通知,说明已经检查过了
if (isUpdateToastShown) return
// 版本一致,无需操作
if (latestVersion === currentVersion.value) {
console.log('[VersionChecker] 版本号一致,无需操作')
return
}
console.log(`[VersionChecker] 检测到版本不一致: ${currentVersion.value} -> ${latestVersion}`)
// 尝试触发 Service Worker 更新检查
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (registration) {
console.log('[VersionChecker] 触发 Service Worker 更新检查...')
// 标记是否发现更新
let updateFound = false
const onUpdateFound = () => {
updateFound = true
}
// 监听 updatefound 事件
registration.addEventListener('updatefound', onUpdateFound, { once: true })
// 等待检查完成
await registration.update()
// 检查是否有更新正在进行
// 如果发现更新,或者正在安装/等待中,则直接返回(交由 SW activated 事件处理)
if (updateFound || registration.installing || registration.waiting) {
console.log('[VersionChecker] Service Worker 更新中...')
return
}
console.log('[VersionChecker] SW 无更新,但版本号不一致,可能是缓存问题')
}
} catch (error) {
console.log('[VersionChecker] Service Worker 更新检查失败:', error)
// 失败继续向下执行,显示通知
}
} else {
console.log('[VersionChecker] 无 Service Worker, 直接显示通知')
}
// 最终兜底:显示版本不一致通知(清除缓存)
showUpdateNotification(
i18n.global.t('common.versionMismatch'),
i18n.global.t('common.clearCache'),
clearCacheAndReload,
)
}
return {
checkVersion,
}
}

View File

@@ -9,8 +9,9 @@ import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import QuickAccess from '@/layouts/components/QuickAccess.vue'
import HeaderTab from '@/layouts/components/HeaderTab.vue'
import { useUserStore } from '@/stores'
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
import { getNavMenus } from '@/router/i18n-menu'
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
import { NavMenu } from '@/@layouts/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
@@ -30,6 +31,7 @@ const route = useRoute()
// 用户 Store
const userStore = useUserStore()
const pluginSidebarNavStore = usePluginSidebarNavStore()
// 响应式的超级用户状态
const superUser = computed(() => userStore.superUser)
@@ -197,7 +199,7 @@ const {
// 根据分类获取菜单列表
const getMenuList = (header: string) => {
// 使用国际化菜单
const menus = getNavMenus()
const menus = getNavMenus(t)
const filteredMenus = filterMenusByPermission(menus, userPermissions.value)
return filteredMenus.filter((item: NavMenu) => item.header === header)
}
@@ -229,7 +231,34 @@ function handlePluginClick() {
showPluginQuickAccess.value = false
}
onMounted(() => {
function appendPluginSidebarMenus() {
for (const { navMenu, section } of filterPluginSidebarNavEntries(
pluginSidebarNavStore.items,
t,
userPermissions.value,
)) {
switch (section) {
case 'start':
startMenus.value.push(navMenu)
break
case 'discovery':
discoveryMenus.value.push(navMenu)
break
case 'subscribe':
subscribeMenus.value.push(navMenu)
break
case 'organize':
organizeMenus.value.push(navMenu)
break
case 'system':
default:
systemMenus.value.push(navMenu)
break
}
}
}
onMounted(async () => {
// 获取菜单列表
startMenus.value = getMenuList(t('menu.start'))
discoveryMenus.value = getMenuList(t('menu.discovery'))
@@ -237,6 +266,9 @@ onMounted(() => {
organizeMenus.value = getMenuList(t('menu.organize'))
systemMenus.value = getMenuList(t('menu.system'))
await pluginSidebarNavStore.ensureSidebarNav()
appendPluginSidebarMenus()
// 监听全局未读消息事件
const unsubscribe = onUnreadMessage(handleUnreadMessage)

View File

@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores'
import { filterMenusByPermission } from '@/utils/permission'
import { usePWA } from '@/composables/usePWA'
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
// 是否显示的输入参数
defineProps({
@@ -15,7 +16,6 @@ defineProps({
},
})
const display = useDisplay()
// PWA模式检测
const { appMode } = usePWA()
@@ -50,7 +50,7 @@ const userPermissions = computed(() => {
// 获取导航菜单
const navMenus = computed(() => {
const allMenus = getNavMenus()
const allMenus = getNavMenus(t)
return filterMenusByPermission(allMenus, userPermissions.value)
})
@@ -121,6 +121,7 @@ interface DynamicButton {
action: () => void
show: boolean
routePath?: string // 添加路径属性,用于标识哪个路由注册的
menuItems?: DynamicButtonMenuItem[]
}
// 提供动态按钮注册和获取的方法
@@ -142,11 +143,13 @@ const unregisterDynamicButton = () => {
if (typeof window !== 'undefined') {
// 确保在浏览器环境中
;(window as any).__VUE_INJECT_DYNAMIC_BUTTON__ = registerDynamicButton
;(window as any).__VUE_UNINJECT_DYNAMIC_BUTTON__ = unregisterDynamicButton
}
// 提供给其他组件使用
provide('registerDynamicButton', registerDynamicButton)
provide('unregisterDynamicButton', unregisterDynamicButton)
provide('dynamicButton', dynamicButton)
// 在组件销毁时清理
onUnmounted(() => {
@@ -154,6 +157,7 @@ onUnmounted(() => {
// 清理全局方法
if (typeof window !== 'undefined') {
delete (window as any).__VUE_INJECT_DYNAMIC_BUTTON__
delete (window as any).__VUE_UNINJECT_DYNAMIC_BUTTON__
}
})
@@ -166,71 +170,122 @@ const showDynamicButton = computed(() => {
(!dynamicButton.value.routePath || dynamicButton.value.routePath === route.path)
)
})
const hasDynamicButtonMenu = computed(() => Boolean(dynamicButton.value?.menuItems?.length))
const legacyDynamicMenuTitleKeyMap: Record<string, string> = {
'components.subscribeHistory.title': 'dialog.subscribeHistory.title',
'components.subscribeEdit.titleDefault': 'dialog.subscribeEdit.titleDefault',
'components.transferQueue.title': 'dialog.transferQueue.title',
'components.pluginMarketSetting.title': 'dialog.pluginMarketSetting.title',
}
function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
if (item.titleKey) {
return t(item.titleKey, item.titleParams as any)
}
if (!item.title) {
return ''
}
const normalizedTitleKey = legacyDynamicMenuTitleKeyMap[item.title] || item.title
const looksLikeI18nKey = /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)+$/i.test(normalizedTitleKey)
return looksLikeI18nKey ? t(normalizedTitleKey, item.titleParams as any) : item.title
}
</script>
<template>
<Teleport v-if="appMode && showNav" to="body">
<div class="footer-nav-container">
<VCard elevation="3" class="footer-nav-card border" rounded="pill" :class="{ 'shift-left': showDynamicButton }">
<VCardText class="footer-card-content">
<!-- 添加指示器 -->
<div ref="indicator" class="nav-indicator"></div>
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
<!-- 遍历底部菜单项 -->
<VBtn
v-for="menu in footerMenus"
:key="menu.to"
:to="menu.to"
:variant="currentMenu === menu.to ? 'text' : 'plain'"
color="primary"
:ripple="false"
class="footer-nav-btn"
rounded="pill"
:class="{ 'footer-nav-btn-active': currentMenu === menu.to }"
:value="menu.to"
>
<div class="btn-content">
<VIcon :icon="menu.icon" size="32"></VIcon>
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
</div>
</VBtn>
<!-- 更多按钮 -->
<VBtn
:variant="currentMenu === '/apps' ? 'text' : 'plain'"
color="primary"
:ripple="false"
to="/apps"
rounded="pill"
class="footer-nav-btn"
:class="{ 'footer-nav-btn-active': currentMenu === '/apps' }"
value="/apps"
>
<div class="btn-content">
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
</div>
</VBtn>
</VBtnToggle>
</VCardText>
</VCard>
<Transition name="fade-slide">
<VCard v-if="showDynamicButton" elevation="3" class="footer-nav-card dynamic-btn-card border" rounded="pill">
<TransitionGroup name="footer-nav" tag="div" class="footer-nav-group">
<VCard key="main-nav" elevation="3" class="footer-nav-card border" rounded="pill">
<VCardText class="footer-card-content">
<!-- 各页面的动态按钮 -->
<VBtn
icon
variant="text"
:ripple="false"
@click="dynamicButton?.action()"
rounded="pill"
class="footer-nav-btn"
>
<VIcon color="secondary" :icon="dynamicButton?.icon || 'mdi-plus'" size="28"></VIcon>
</VBtn>
<!-- 添加指示器 -->
<div ref="indicator" class="nav-indicator"></div>
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
<!-- 遍历底部菜单项 -->
<VBtn
v-for="menu in footerMenus"
:key="menu.to"
:to="menu.to"
:variant="currentMenu === menu.to ? 'text' : 'plain'"
color="primary"
:ripple="false"
class="footer-nav-btn"
rounded="pill"
:class="{ 'footer-nav-btn-active': currentMenu === menu.to }"
:value="menu.to"
>
<div class="btn-content">
<VIcon :icon="menu.icon" size="32"></VIcon>
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
</div>
</VBtn>
<!-- 更多按钮 -->
<VBtn
:variant="currentMenu === '/apps' ? 'text' : 'plain'"
color="primary"
:ripple="false"
to="/apps"
rounded="pill"
class="footer-nav-btn"
:class="{ 'footer-nav-btn-active': currentMenu === '/apps' }"
value="/apps"
>
<div class="btn-content">
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
</div>
</VBtn>
</VBtnToggle>
</VCardText>
</VCard>
</Transition>
<VCard
v-if="showDynamicButton"
key="dynamic-btn"
elevation="3"
class="footer-nav-card dynamic-btn-card border"
rounded="pill"
>
<VCardText class="footer-card-content">
<!-- 各页面的动态按钮 -->
<div class="dynamic-btn-activator">
<VBtn
icon
variant="text"
:ripple="false"
@click="!hasDynamicButtonMenu && dynamicButton?.action()"
rounded="pill"
class="footer-nav-btn"
>
<VIcon
color="secondary"
:icon="hasDynamicButtonMenu ? 'mdi-chevron-up' : dynamicButton?.icon || 'mdi-plus'"
size="28"
></VIcon>
</VBtn>
<VMenu v-if="hasDynamicButtonMenu" activator="parent" location="top end" close-on-content-click>
<VList>
<VListItem
v-for="(item, index) in dynamicButton?.menuItems"
:key="item.titleKey || item.title || index"
:base-color="item.color"
@click="item.action()"
>
<template #prepend>
<VIcon v-if="item.icon" :icon="item.icon" />
</template>
<VListItemTitle>{{ resolveDynamicMenuItemTitle(item) }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</div>
</VCardText>
</VCard>
</TransitionGroup>
</div>
</Teleport>
</template>
@@ -246,6 +301,12 @@ const showDynamicButton = computed(() => {
inset-inline: 0;
padding-block-end: calc(6px + env(safe-area-inset-bottom, 0px));
pointer-events: none;
}
.footer-nav-group {
display: flex;
align-items: center;
justify-content: center;
// 按钮卡片之间的间距
> .v-card + .v-card {
@@ -260,6 +321,7 @@ const showDynamicButton = computed(() => {
background-color: rgba(var(--v-theme-surface), 0.6);
pointer-events: auto;
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
will-change: transform, max-inline-size, opacity;
// 透明主题下的特殊样式
.v-theme--transparent & {
@@ -267,10 +329,6 @@ const showDynamicButton = computed(() => {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
}
&.shift-left {
transform: translateX(0);
}
.v-btn-toggle {
block-size: auto;
min-block-size: 56px;
@@ -327,6 +385,7 @@ const showDynamicButton = computed(() => {
.dynamic-btn-card {
block-size: auto;
inline-size: auto;
max-inline-size: 60px;
min-block-size: 0;
.footer-card-content {
@@ -349,23 +408,25 @@ const showDynamicButton = computed(() => {
}
}
// 淡入滑动动画
.fade-slide-enter-active {
// 底部导航动画
.footer-nav-enter-active,
.footer-nav-leave-active {
overflow: hidden;
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
}
.fade-slide-leave-active {
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
}
.fade-slide-enter-from {
.footer-nav-enter-from,
.footer-nav-leave-to {
padding: 0 !important;
border-width: 0 !important;
margin-inline-start: 0 !important;
max-inline-size: 0 !important;
opacity: 0;
transform: translateX(20px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(20px);
.footer-nav-move {
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
}
@keyframes fade-in {

View File

@@ -27,25 +27,65 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
</script>
<template>
<!-- 👉 Search Icon -->
<div class="d-flex align-center cursor-pointer ms-lg-n2" style="user-select: none">
<IconBtn @click="openSearchDialog">
<VIcon icon="ri-search-line" />
</IconBtn>
<span v-if="display.lgAndUp.value" class="flex align-center text-disabled ms-2" @click="openSearchDialog">
<span class="me-3">{{ t('common.search') }}</span>
<span class="meta-key">{{ metaKey }}</span>
</span>
<!-- 小屏仅图标按钮 -->
<IconBtn v-if="!display.mdAndUp.value" @click="openSearchDialog">
<VIcon icon="mdi-magnify" />
</IconBtn>
<!-- 中屏及以上胶囊搜索触发栏 -->
<div v-else class="search-trigger" @click="openSearchDialog">
<VIcon icon="mdi-magnify" size="18" class="search-trigger-icon" />
<span class="search-trigger-text">{{ t('common.search') }}</span>
<kbd class="search-trigger-kbd">{{ metaKey }}</kbd>
</div>
<!-- 搜索弹窗 -->
<SearchBarDialog v-model="searchDialog" v-if="searchDialog" @close="searchDialog = false" />
</template>
<style type="scss" scoped>
.meta-key {
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 6px;
block-size: 1.75rem;
padding-block: 0.1rem;
padding-inline: 0.25rem;
<style scoped>
.search-trigger {
display: flex;
align-items: center;
gap: 8px;
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 22px;
block-size: 36px;
cursor: pointer;
padding-inline: 12px;
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
user-select: none;
}
.search-trigger:hover {
border-color: rgba(var(--v-theme-on-surface), 0.22);
background-color: rgba(var(--v-theme-on-surface), 0.06);
box-shadow: 0 1px 4px rgba(0, 0, 0, 4%);
}
.search-trigger-icon {
color: rgba(var(--v-theme-on-surface), 0.4);
flex-shrink: 0;
}
.search-trigger-text {
color: rgba(var(--v-theme-on-surface), 0.4);
font-size: 13.5px;
line-height: 1;
white-space: nowrap;
}
.search-trigger-kbd {
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 5px;
background-color: rgba(var(--v-theme-on-surface), 0.04);
color: rgba(var(--v-theme-on-surface), 0.4);
font-family: inherit;
font-size: 11px;
font-weight: 500;
line-height: 1;
margin-inline-start: 4px;
padding-block: 3px;
padding-inline: 5px;
}
</style>

View File

@@ -7,12 +7,19 @@ import ModuleTestView from '@/views/system/ModuleTestView.vue'
import MessageView from '@/views/system/MessageView.vue'
import WordsView from '@/views/system/WordsView.vue'
import CacheView from '@/views/system/CacheView.vue'
import AccountSettingService from '@/views/system/ServiceView.vue'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { getQueryValue } from '@/@core/utils'
import { useI18n } from 'vue-i18n'
import { clearAppBadge } from '@/utils/badge'
type MessageViewExpose = {
pauseSSE?: () => void
resumeSSE?: () => void
refreshLatestMessages?: () => Promise<void> | void
}
// 国际化
const { t } = useI18n()
@@ -49,6 +56,9 @@ const wordsDialog = ref(false)
// 缓存管理弹窗
const cacheDialog = ref(false)
// 定时服务弹窗
const schedulerDialog = ref(false)
// 输入消息
const user_message = ref('')
@@ -59,7 +69,7 @@ const sendButtonDisabled = ref(false)
const messageDialogRef = ref<any>(null)
// 消息视图引用
const messageViewRef = ref<any>(null)
const messageViewRef = ref<MessageViewExpose | null>(null)
// 滚动容器引用
const messageContentRef = ref<any>()
@@ -108,6 +118,13 @@ const shortcuts = [
dialog: 'cache',
dialogRef: cacheDialog,
},
{
title: t('shortcut.scheduler.title'),
subtitle: t('shortcut.scheduler.subtitle'),
icon: 'mdi-list-box',
dialog: 'scheduler',
dialogRef: schedulerDialog,
},
{
title: t('shortcut.system.title'),
subtitle: t('shortcut.system.subtitle'),
@@ -142,9 +159,7 @@ async function openMessageDialog() {
}, 600)
// 等待对话框打开后恢复SSE连接
nextTick(() => {
if (messageViewRef.value && typeof messageViewRef.value.resumeSSE === 'function') {
messageViewRef.value.resumeSSE()
}
messageViewRef.value?.resumeSSE?.()
})
}
@@ -192,16 +207,23 @@ function allLoggingUrl() {
// 发送消息
async function sendMessage() {
if (user_message.value) {
try {
sendButtonDisabled.value = true
await api.post(`message/web?text=${user_message.value}`)
user_message.value = ''
sendButtonDisabled.value = false
forceScrollToEnd() // 发送消息后强制滚动到底部
} catch (error) {
console.error(error)
}
const messageText = user_message.value.trim()
if (!messageText) {
return
}
try {
sendButtonDisabled.value = true
await api.post(`message/web?text=${encodeURIComponent(messageText)}`)
user_message.value = ''
// 发送成功后主动同步最新一页消息避免SSE短暂断流时界面停留在旧状态。
// await messageViewRef.value?.refreshLatestMessages?.()
forceScrollToEnd() // 发送消息后强制滚动到底部
} catch (error) {
console.error(error)
} finally {
sendButtonDisabled.value = false
}
}
@@ -217,7 +239,7 @@ defineExpose({
// 监听消息对话框状态变化
watch(messageDialog, newValue => {
if (!newValue && messageViewRef.value && typeof messageViewRef.value.pauseSSE === 'function') {
if (!newValue && messageViewRef.value?.pauseSSE) {
// 对话框关闭时暂停SSE连接
messageViewRef.value.pauseSSE()
}
@@ -275,10 +297,10 @@ onMounted(() => {
item.dialog === 'message'
? openMessageDialog()
: item.dialog === 'words'
? openDialog(item.dialogRef)
: item.dialog === 'cache'
? openDialog(item.dialogRef)
: openDialog(item.dialogRef)
? openDialog(item.dialogRef)
: item.dialog === 'cache'
? openDialog(item.dialogRef)
: openDialog(item.dialogRef)
"
>
<VAvatar variant="text" size="48" rounded="lg">
@@ -339,13 +361,13 @@ onMounted(() => {
</VCard>
</VDialog>
<!-- 实时日志弹窗 -->
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="70rem"
:fullscreen="!display.mdAndUp.value"
>
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="80rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="loggingDialog = false" />
<VCardItem>
@@ -361,7 +383,7 @@ onMounted(() => {
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VCardText class="pa-0">
<LoggingView logfile="moviepilot.log" />
</VCardText>
</VCard>
@@ -420,6 +442,29 @@ onMounted(() => {
</VCardText>
</VCard>
</VDialog>
<!-- 定时服务弹窗 -->
<VDialog
v-if="schedulerDialog"
v-model="schedulerDialog"
max-width="60rem"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<VCardTitle>
<VIcon icon="mdi-list-box" class="me-2" />
{{ t('shortcut.scheduler.subtitle') }}
</VCardTitle>
<VCardSubtitle>{{ t('setting.scheduler.subtitle') }}</VCardSubtitle>
<VDialogCloseBtn @click="schedulerDialog = false" />
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<AccountSettingService />
</VCardText>
</VCard>
</VDialog>
<!-- 系统健康检查弹窗 -->
<VDialog
v-if="systemTestDialog"

View File

@@ -16,6 +16,7 @@ import { saveLocalTheme } from '@/@core/utils/theme'
import type { ThemeSwitcherTheme } from '@layouts/types'
import { useConfirm } from '@/composables/useConfirm'
import { themeManager } from '@/utils/themeManager'
import { usePWA, type UIMode } from '@/composables/usePWA'
// 认证 Store
const authStore = useAuthStore()
@@ -27,6 +28,8 @@ const globalSettingsStore = useGlobalSettingsStore()
const { t } = useI18n()
// 显示器
const display = useDisplay()
// PWA
const { uiMode, setUIMode } = usePWA()
// 提示框
const $toast = useToast()
@@ -40,6 +43,9 @@ const siteAuthDialog = ref(false)
// 自定义CSS弹窗
const cssDialog = ref(false)
// UI模式菜单是否显示
const showUIModeMenu = ref(false)
// 主题菜单是否显示
const showThemeMenu = ref(false)
@@ -233,9 +239,40 @@ const isAdvancedMode = computed(() => {
return globalSettingsStore.get('ADVANCED_MODE') !== false
})
// UI模式相关
const uiModes = computed(() => [
{
name: 'auto',
title: t('theme.autoUI'),
icon: 'mdi-devices',
},
{
name: 'desktop',
title: t('pwa.platforms.desktop'),
icon: 'mdi-monitor',
},
{
name: 'app',
title: t('pwa.platforms.mobile'),
icon: 'mdi-cellphone',
},
])
// 切换UI模式
function changeUIMode(mode: UIMode) {
setUIMode(mode)
showUIModeMenu.value = false
}
// 获取当前UI模式图标
const getUIModeIcon = computed(() => {
const mode = uiModes.value.find(m => m.name === uiMode.value)
return mode?.icon || 'mdi-devices'
})
// 主题相关功能
const { name: themeName, global: globalTheme } = useTheme()
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
const savedTheme = ref(localStorage.getItem('theme') ?? 'auto')
const currentThemeName = ref(savedTheme.value)
const themes: ThemeSwitcherTheme[] = [
@@ -546,6 +583,41 @@ onUnmounted(() => {
<VListItemTitle>{{ t('user.siteAuth') }}</VListItemTitle>
</VListItem>
<!-- 👉 UI模式设置 - 使用嵌套菜单 -->
<VMenu location="end" offset-x min-width="200" v-model="showUIModeMenu" :close-on-content-click="true">
<template v-slot:activator="{ props: menuProps }">
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
<template #prepend>
<VIcon :icon="getUIModeIcon" />
</template>
<VListItemTitle>{{ t('common.uiMode') }}</VListItemTitle>
<VListItemSubtitle>
{{ uiModes.find(m => m.name === uiMode)?.title || t('theme.autoUI') }}
</VListItemSubtitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
</template>
</VListItem>
</template>
<VList>
<VListItem
v-for="mode in uiModes"
:key="mode.name"
@click="changeUIMode(mode.name as UIMode)"
:active="uiMode === mode.name"
class="mb-1"
>
<template #prepend>
<VIcon :icon="mode.icon" />
</template>
<VListItemTitle>{{ mode.title }}</VListItemTitle>
<template #append v-if="uiMode === mode.name">
<VIcon icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
</VList>
</VMenu>
<!-- 👉 主题设置 - 使用嵌套菜单 -->
<VMenu location="end" offset-x min-width="200" v-model="showThemeMenu" :close-on-content-click="true">
<template v-slot:activator="{ props: menuProps }">
@@ -553,9 +625,10 @@ onUnmounted(() => {
<template #prepend>
<VIcon :icon="getThemeIcon" />
</template>
<VListItemTitle>
{{ themes.find(t => t.name === currentThemeName)?.title || t('common.theme') }}
</VListItemTitle>
<VListItemTitle>{{ t('common.theme') }}</VListItemTitle>
<VListItemSubtitle>
{{ themes.find(t => t.name === currentThemeName)?.title || t('theme.auto') }}
</VListItemSubtitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
</template>

View File

@@ -30,6 +30,7 @@ export default {
saving: 'Saving',
reset: 'Reset',
theme: 'Theme',
uiMode: 'UI Layout',
language: 'Language',
pleaseWait: 'Please wait...',
viewDetails: 'View Details',
@@ -45,6 +46,7 @@ export default {
unsubscribe: 'Unsubscribe',
media: 'Media',
unknown: 'Unknown',
notFetched: 'Not Fetched',
notice: 'Notice',
itemsPerPage: 'Items per page',
pageText: '{0}-{1} of {2}',
@@ -66,6 +68,12 @@ export default {
serviceUnavailable: 'Service Unavailable',
status: 'Status',
preset: 'Preset',
refresh: 'Refresh',
swUpdateReady: 'New version is ready, please refresh the page to get the latest features',
ascending: 'Ascending',
descending: 'Descending',
versionMismatch: 'The browser cache version is inconsistent with the server version, please try to clear the cache',
clearCache: 'Clear Cache',
},
mediaType: {
movie: 'Movie',
@@ -82,6 +90,7 @@ export default {
mediaServer: 'Media Server',
manual: 'Manual',
plugin: 'Plugin',
agent: 'Agent',
other: 'Other',
},
actionStep: {
@@ -129,6 +138,7 @@ export default {
light: 'Light',
dark: 'Dark',
auto: 'Follow System',
autoUI: 'Auto',
transparent: 'Transparent',
purple: 'Purple',
custom: 'Custom Style',
@@ -238,16 +248,33 @@ export default {
wallpapers: 'Wallpapers',
username: 'Username',
password: 'Password',
otpCode: 'Two-Factor Code',
otpCode: 'Verification Code',
stayLoggedIn: 'Stay Logged In',
login: 'Login',
networkError: 'Login failed, please check your network connection!',
authFailure: 'Login failed, please check your username, password or two-factor authentication!',
authFailure: 'Login failed, please check your username, password or secondary verification!',
permissionDenied: 'Login failed, you do not have permission to access!',
noPermission: 'Login failed, you have no functional permissions, please contact the administrator!',
serverError: 'Login failed, server error!',
loginFailed: 'Login Failed',
checkCredentials: 'Please check your username, password or two-factor authentication code!',
secondaryVerification: 'Secondary Verification',
orDivider: 'OR',
loginWithPasskey: 'Login with Passkey',
loginWithOtp: 'Login with OTP',
orUsePasskey: 'Or use Passkey for verification',
verifyWithPasskey: 'Verify with Passkey',
otpPlaceholder: 'Enter 6-digit code',
passkeyLoginStartFailed: 'Failed to start Passkey authentication',
passkeyNotSelected: 'No Passkey selected',
passkeyLoginFailed: 'Passkey login failed',
passkeyAuthCanceled: 'Passkey authentication canceled',
passkeyNotSupported: 'Current browser does not support Passkeys',
passkeySecureContextRequired: 'Passkey requires HTTPS secure connection',
passkeyVerifyFailed: 'Passkey verification failed',
passkeyVerifyFailedRetry: 'Passkey verification failed, please try again',
mfa: {
selectVerificationMethod: 'Please select a verification method',
},
},
menu: {
start: 'Start',
@@ -290,7 +317,8 @@ export default {
settingTabs: {
system: {
title: 'System',
description: 'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, Jellyfin, Plex)',
description:
'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, Jellyfin, Plex, TrimeMedia, Ugreen)',
},
directory: {
title: 'Storage & Directories',
@@ -380,7 +408,7 @@ export default {
username: 'Username',
usernameHint: 'Username for system login',
password: 'Password',
passwordHint: 'Password for system login',
passwordHint: 'Please enter your login password',
confirmPassword: 'Confirm Password',
confirmPasswordHint: 'Please enter the password again to confirm',
role: 'Role',
@@ -409,6 +437,8 @@ export default {
config: 'Configuration',
wechat: {
name: 'WeChat Work',
useBotMode: 'Use AI Bot',
useBotModeHint: 'Enable WebSocket bot mode with fixed dmPolicy=open and groupPolicy=disabled',
corpId: 'Corp ID',
corpIdHint: 'Corp ID in WeChat Work backend enterprise information',
corpIdRequired: 'Corp ID cannot be empty',
@@ -425,6 +455,16 @@ export default {
tokenHint: 'Token in WeChat Work self-built app -> API message receiving configuration',
encodingAesKey: 'EncodingAESKey',
encodingAesKeyHint: 'EncodingAESKey in WeChat Work self-built app -> API message receiving configuration',
botId: 'Bot ID',
botIdHint: 'Bot ID of the WeChat Work AI bot',
botSecret: 'Bot Secret',
botSecretHint: 'WebSocket secret of the WeChat Work AI bot',
botChatId: 'Default Target',
botChatIdHint:
'Use user userid; for proactive group messages use group:chatid. Leave empty to notify known interacted users',
botChatIdPlaceholder: 'userid or group:chatid',
botWsUrl: 'WebSocket URL',
botWsUrlHint: 'WebSocket endpoint for the WeChat Work AI bot, usually the default value',
admins: 'Admin Whitelist',
adminsHint: 'User IDs that can use admin menu and commands, separated by commas',
adminsPlaceholder: 'User IDs list, separated by commas',
@@ -458,6 +498,18 @@ export default {
channelHint: 'Channel to send messages, default is "all"',
channelRequired: 'Channel Name cannot be empty',
},
discord: {
name: 'Discord',
botToken: 'Bot Token',
botTokenHint: 'Discord Bot Token (enable Message Content Intent in Dev Portal)',
botTokenRequired: 'Bot Token is required',
guildId: 'Guild ID',
guildIdHint: 'Optional, restrict to a specific guild; leave blank to use any joined guild',
guildIdPlaceholder: '123456789012345678',
channelId: 'Channel ID',
channelIdHint: 'Optional, default broadcast channel; leave blank to auto-pick a writable channel',
channelIdPlaceholder: '123456789012345678',
},
synologychat: {
name: 'Synology Chat',
webhook: 'Webhook URL',
@@ -483,6 +535,21 @@ export default {
usernameHint: 'Only push messages to the corresponding logged-in user',
usernameRequired: 'Username cannot be empty',
},
qqbot: {
name: 'QQ',
appId: 'App ID',
appIdHint: 'QQ Open Platform bot App ID',
appIdRequired: 'App ID cannot be empty',
appSecret: 'App Secret',
appSecretHint: 'QQ Open Platform bot App Secret',
appSecretRequired: 'App Secret cannot be empty',
openId: 'User OpenID',
openIdHint: 'Default recipient openid (C2C), user must have interacted with bot before',
openIdPlaceholder: '32-char hex',
groupOpenId: 'Group OpenID',
groupOpenIdHint: 'Default group openid (group chat), use either this or User OpenID',
groupOpenIdPlaceholder: 'Group openid',
},
},
shortcut: {
title: 'Shortcuts',
@@ -518,6 +585,10 @@ export default {
title: 'Cache',
subtitle: 'Manage Cache',
},
scheduler: {
title: 'Services',
subtitle: 'Scheduled Services',
},
},
workflow: {
components: 'Action Components',
@@ -924,6 +995,10 @@ export default {
searching: 'Searching, please wait...',
noData: 'No Data',
noResourceFound: 'No resources found',
aiRecommend: 'AI Recommendation',
reRecommend: 'Regenerate Recommendation',
aiRecommendError: 'AI Recommendation Failed',
refreshSearch: 'Re-search',
},
browse: {
actor: 'Actor',
@@ -1159,6 +1234,17 @@ export default {
content: 'Content',
refreshing: 'Refreshing',
initializing: 'Initializing',
searchPlaceholder: 'Search logs',
allLevels: 'All Levels',
followTail: 'Follow latest logs',
wrapLines: 'Wrap lines',
pauseStream: 'Pause stream',
resumeStream: 'Resume stream',
waitingForLogs: 'Waiting for logs...',
paused: 'Paused',
connected: 'Live',
lineCount: 'Showing {visible}/{total} lines',
jumpToLatest: 'Jump to latest ({count})',
},
moduleTest: {
normal: 'Normal',
@@ -1197,6 +1283,7 @@ export default {
title: 'About MoviePilot',
softwareVersion: 'Software Version',
frontendVersion: 'Frontend Version',
browserVersion: 'Browser Cached Version',
authVersion: 'Auth Resource Version',
indexerVersion: 'Indexer Resource Version',
configDir: 'Config Directory',
@@ -1216,6 +1303,7 @@ export default {
dataDirectory: '/moviepilot',
expand: 'Expand',
collapse: 'Collapse',
clearCache: 'Clear Cache',
},
system: {
custom: 'Custom',
@@ -1247,21 +1335,103 @@ export default {
aiAgent: 'Enable AI Assistant',
aiAgentEnable: 'Enable AI Assistant',
aiAgentEnableHint: 'Enable AI assistant functionality, requires LLM configuration',
aiAgentSectionTitle: 'AI Assistant Configuration',
aiAgentSectionDesc:
'After enabling it, you can use the Agent in message conversations and optionally turn on transfer-failure takeover and AI recommendations.',
llmProvider: 'LLM Provider',
llmProviderHint: 'Select the LLM service provider to use',
llmModel: 'LLM Model Name',
llmModelHint: 'Specify the LLM model to use, such as gpt-3.5-turbo, deepseek-chat, etc.',
llmModelHint: 'Specify the LLM model to use, such as deepseek-v4-flash, gpt-5.4, etc.',
llmModelResolvedHint: 'Max context has been auto-filled to {context}K from the model catalog. Source: {source}',
llmThinking: 'Thinking Mode / Depth',
llmThinkingHint:
'Thinking depth: off/auto/minimal/low/medium/high/max/xhigh. Unsupported levels will be mapped to the nearest provider-supported value.',
llmThinkingLevelOff: 'Off (off)',
llmThinkingLevelAuto: 'Auto (auto)',
llmThinkingLevelMinimal: 'Minimal (minimal)',
llmThinkingLevelLow: 'Low (low)',
llmThinkingLevelMedium: 'Medium (medium)',
llmThinkingLevelHigh: 'High (high)',
llmThinkingLevelMax: 'Max (max)',
llmThinkingLevelXhigh: 'XHigh (xhigh)',
llmSupportImageInput: 'Model Supports Image Input',
llmSupportImageInputHint:
'When enabled, message images are sent to the LLM as multimodal image input. When disabled, images are saved locally as attachments and only the file path is passed to the AI assistant.',
llmSupportAudioInputOutput: 'Support Audio Input and Output',
llmSupportAudioInputOutputHint:
'When enabled, the AI assistant can transcribe incoming audio messages and reply with voice on supported channels.',
llmMaxContextTokens: 'LLM Max Context Tokens (K)',
llmMaxContextTokensHint:
'Set the maximum number of context tokens (in thousands) for the LLM. Exceeding this limit will trigger context trimming.',
llmApiKey: 'LLM API Key',
llmApiKeyHint: 'API key from the LLM service provider for authentication',
llmApiKeyPlaceholder: 'Please enter API key',
llmBaseUrl: 'LLM Base URL',
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
llmProviderAuth: 'Provider Authorization',
llmProviderAuthHint:
'Providers that support account authorization can complete sign-in here and reuse the saved auth state.',
llmProviderConnectedAs: 'Connected as: {label}',
llmProviderDisconnect: 'Disconnect Authorization',
llmProviderDisconnected: 'Provider authorization disconnected',
llmProviderAuthDialogTitle: 'Provider Authorization',
llmProviderPopupBlocked:
'The browser blocked the authorization popup. Use the button below to continue manually.',
llmProviderDeviceCode: 'Device Code',
llmProviderOpenAuthPage: 'Open Authorization Page',
llmProviderCheckAuthStatus: 'Check Authorization Status',
aiVoiceApiKey: 'Audio API Key',
aiVoiceApiKeyHint:
'API key used for audio transcription and speech synthesis. Falls back to the current LLM API key when left blank.',
aiVoiceBaseUrl: 'Audio Base URL',
aiVoiceBaseUrlHint:
'Base URL used for audio transcription and speech synthesis. Falls back to the current LLM base URL when left blank.',
aiVoiceSttModel: 'Audio Transcription Model',
aiVoiceSttModelHint: 'Model name used to convert audio content into text.',
aiVoiceTtsModel: 'Speech Synthesis Model',
aiVoiceTtsModelHint: 'Model name used to convert text content into speech.',
aiVoiceTtsVoice: 'Voice Preset',
aiVoiceTtsVoiceHint: 'Speaker or voice preset used for speech synthesis.',
aiVoiceLanguage: 'Recognition Language',
aiVoiceLanguageHint:
'Default language for audio transcription, such as zh or en. Leave blank to use the backend default.',
aiVoiceReplyWithText: 'Include Text with Voice Replies',
aiVoiceReplyWithTextHint: 'When sending a voice reply, also include the text version of the response.',
llmTestAction: 'Test Call',
llmTestSuccessToast: 'LLM test call succeeded',
llmTestFailedToast: 'LLM test call failed',
llmTestFailedToastWithMessage: 'LLM test call failed: {message}',
aiAgentGlobal: 'Global AI Assistant',
aiAgentGlobalHint: 'Enable global AI assistant functionality, all message conversations will be answered by the AI agent without using the /ai command',
aiAgentGlobalHint:
'Enable global AI assistant functionality, all message conversations will be answered by the AI agent without using the /ai command',
aiAgentJobInterval: 'Scheduled Wake',
aiAgentJobIntervalHint:
'Set the check interval for scheduled wake. Select "Disabled" to disable scheduled tasks.',
aiAgentVerbose: 'Verbose Mode',
aiAgentVerboseHint: 'When enabled, tool call process will be displayed in AI agent responses',
aiAgentJobIntervalDisabled: 'Disabled',
aiAgentJobInterval1h: '1 Hour',
aiAgentJobInterval3h: '3 Hours',
aiAgentJobInterval6h: '6 Hours',
aiAgentJobInterval12h: '12 Hours',
aiAgentJobInterval24h: '24 Hours',
aiAgentJobInterval1w: '1 Week',
aiAgentJobInterval1M: '1 Month',
advancedSettings: 'Advanced Settings',
advancedSettingsDesc: 'System advanced settings, only need to be adjusted in special cases',
downloaders: 'Downloaders',
downloadersDesc: 'Only the default downloader will be used by default.',
aiAgentRetryTransfer: 'AI Takeover on Transfer Failure',
aiAgentRetryTransferHint:
'When enabled, the AI assistant will automatically take over and retry when file transfer/organization fails, using AI capabilities to resolve recognition and matching issues.',
aiRecommendEnabled: 'AI Search Recommendation',
aiRecommendEnabledHint:
'Enable AI search recommendation. When enabled, an AI recommendation button will be displayed on the search result page, recommending resources based on user preferences.',
aiRecommendUserPreference: 'User Preference',
aiRecommendUserPreferenceHint: 'Set user preferences for AI recommendation, e.g., 4K WEB-DL Dolby Vision',
aiRecommendMaxItems: 'AI Recommendation Analysis Limit',
aiRecommendMaxItemsHint:
'Limit the number of search results sent to the AI assistant for analysis. More items mean slower analysis and more token consumption. It is recommended to manually filter to a general range before using AI recommendation.',
mediaServers: 'Media Servers',
mediaServersDesc: 'All enabled media servers will be used.',
trimeMedia: 'TrimeMedia',
@@ -1284,9 +1454,11 @@ export default {
reloading: 'Applying configuration...',
qbittorrent: 'Qbittorrent',
transmission: 'Transmission',
rtorrent: 'rTorrent',
emby: 'Emby',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: 'Ugreen',
reloadSuccess: 'System configuration has taken effect',
reloadFailed: 'Failed to reload system!',
auxAuthEnable: 'User Auxiliary Authentication',
@@ -1329,6 +1501,9 @@ export default {
fanartEnableHint: 'Use image data from fanart.tv',
fanartLang: 'Fanart Language',
fanartLangHint: 'Set language preference for Fanart images, ordered by priority when multiple selected',
recognizePluginFirst: 'Prioritize Plugin Recognition',
recognizePluginFirstHint:
'Prioritize calling plugins for media recognition. If a plugin matches, native recognition will be skipped',
githubProxy: 'Github Acceleration Proxy',
githubProxyPlaceholder: 'Leave empty for no proxy',
githubProxyHint: 'Use proxy to accelerate Github access speed',
@@ -1360,9 +1535,14 @@ export default {
logFileFormatHint: 'Set the output format of log files to customize the displayed content of logs',
pluginAutoReload: 'Plugin Hot Reload',
pluginAutoReloadHint: 'Automatically reload after modifying plugin files, used when developing plugins',
pluginLocalRepoPaths: 'Local Plugin Repository Paths',
pluginLocalRepoPathsHint:
'Local plugin repository directories. Separate multiple directories with commas. Relative and absolute paths are supported.',
encodingDetectionPerformanceMode: 'Encoding Detection Performance Mode',
encodingDetectionPerformanceModeHint:
'Prioritize detection efficiency, but may reduce encoding detection accuracy',
transferThreads: 'File Transfer Threads',
transferThreadsHint: 'Multi-threaded file transfer can improve speed but may increase system resource usage',
tokenizedSearch: 'Tokenized Search',
tokenizedSearchHint:
'Improve organization history search precision, but may increase performance overhead and unexpected results',
@@ -1441,6 +1621,11 @@ export default {
episodeThumb: 'Thumb',
scrapingSwitchSaveFailed: 'Scraping switch settings save failed: {message}',
scrapingSwitchSaveError: 'Scraping switch settings save failed',
policy: {
skipDesc: 'Skip scraping, this file will not be generated',
missingOnlyDesc: 'Scrape only if missing, existing file remains unchanged',
overwriteDesc: 'Always scrape, existing file will be overwritten',
},
},
site: {
siteSync: 'Site Synchronization',
@@ -1538,6 +1723,7 @@ export default {
synologyChat: 'SynologyChat',
voceChat: 'VoceChat',
webPush: 'WebPush',
qq: 'QQ',
custom: 'Custom Notification',
},
words: {
@@ -1623,6 +1809,7 @@ export default {
storage: 'Storage',
storageDesc: 'Set up local or cloud storage.',
directory: 'Directory',
mediaType: 'Media Type',
directoryDesc: 'Set up media file organization directory structure, matching in sequence.',
organizeAndScrap: 'Organization & Scraping',
organizeAndScrapDesc: 'Set rename format, scraping options, etc.',
@@ -1644,6 +1831,25 @@ export default {
storageSaveSuccess: 'Storage settings saved successfully',
storageSaveFailed: 'Failed to save storage settings!',
},
category: {
title: 'Category Policy',
subtitle: 'Configure media auto-categorization rules by type, language, region, etc.',
movie: 'Movies',
tv: 'TV Shows',
name: 'Category Name (Directory)',
genre: 'Genre',
language: 'Language',
languagePlaceholder: 'e.g., en,fr,zh (comma separated)',
country: 'Country/Region',
countryPlaceholder: 'e.g., US,CN,JP',
year: 'Year',
yearPlaceholder: 'e.g., 2023, 2020-2024',
addMovie: 'Add Movie Category',
addTv: 'Add TV Category',
saveSuccess: 'Category policy saved successfully',
loadFailed: 'Failed to load category configuration',
saveFailed: 'Save failed: {message}',
},
rule: {
customRules: 'Custom Rules',
customRulesDesc: 'Custom priority rule items',
@@ -1739,7 +1945,7 @@ export default {
},
cache: {
title: 'Cache Management',
subtitle: 'Manage torrent cache data',
subtitle: 'Manage cached site resources',
totalCount: 'Total Count',
siteCount: 'Site Count',
filterByTitle: 'Filter by Title',
@@ -1835,6 +2041,7 @@ export default {
wechat: 'WeChat UserID',
telegram: 'Telegram UserID',
slack: 'Slack UserID',
discord: 'Discord UserID',
vocechat: 'VoceChat UserID',
synologyChat: 'SynologyChat UserID',
webPush: 'WebPush',
@@ -1872,7 +2079,7 @@ export default {
},
searchBar: {
search: 'Search',
searchPlaceholder: 'Search features, subscriptions, settings...',
searchPlaceholder: 'Search movies, TV shows and more...',
recentSearches: 'Recent Searches',
noRecentSearches: 'No recent search history',
functions: 'Functions',
@@ -1892,6 +2099,9 @@ export default {
searchInSites: 'Search for torrent resources in sites',
relatedResources: 'Related Resources',
searchTip: 'You can search for movies, TV shows, actors, resources, etc.',
emptySearchHint: 'Enter keywords to search',
escClose: 'Close',
openSearch: 'Open search',
},
searchSite: {
selectSites: 'Select Sites',
@@ -1948,9 +2158,15 @@ export default {
'Before sharing, please ensure the workflow does not contain sensitive information such as PassKey in RSS links to avoid information leakage.',
},
u115Auth: {
loginTitle: '115 Cloud Login',
scanQrCode: 'Please scan with WeChat or 115 client',
scanned: 'Scanned, please confirm login',
loginTitle: '115 Cloud Authorization',
openAuthWindow: 'Open Authorization Window',
authorizing: 'Please complete authorization in the new window...',
authSuccess: 'Authorization successful!',
authFailed: 'Authorization failed or expired',
authCanceled: 'Authorization canceled, please try again',
urlEmpty: 'Authorization URL is empty',
urlFetchFailed: 'Failed to fetch authorization URL',
popupBlocked: 'Unable to open authorization window, please check browser popup settings',
complete: 'Complete',
reset: 'Reset',
},
@@ -2103,6 +2319,10 @@ export default {
repoUrl: 'Plugin Repository URL',
repoPlaceholder: 'Format: https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
repoHint: 'Multiple URLs separated by lines, only Github repositories are supported',
urlPlaceholder: 'Enter plugin repository URL',
noRepos: 'No plugin repository URLs',
invalidUrl: 'Please enter a valid URL',
duplicateUrl: 'This URL already exists',
close: 'Close',
save: 'Save',
saveSuccess: 'Plugin repository saved successfully',
@@ -2453,6 +2673,7 @@ export default {
settings: 'Settings',
projectHome: 'Project Home',
updateHistory: 'Update History',
local: 'Local',
installToLocal: 'Install to Local',
totalDownloads: 'Total {count} downloads',
viewData: 'View Data',
@@ -2525,6 +2746,7 @@ export default {
noRecentPlugins: 'None',
},
profile: {
disableOtpWithPasskeyError: 'Please delete all Passkeys before clearing the authenticator!',
personalInfo: 'Personal Information',
uploadNewAvatar: 'Upload New Avatar',
avatarFormatError: 'The uploaded file does not meet requirements, please select a new avatar',
@@ -2545,21 +2767,60 @@ export default {
wechatUser: 'WeChat User',
telegramUser: 'Telegram User',
slackUser: 'Slack User',
discordUser: 'Discord User',
vocechatUser: 'VoceChat User',
synologychatUser: 'SynologyChat User',
doubanUser: 'Douban User',
twoFactorAuthentication: 'Two-Factor Authentication',
setupAuthenticator: 'Setup Authenticator',
authenticatorManagement: 'Authenticator Management',
authenticatorEnabled: 'You have enabled authenticator two-factor authentication',
clearAuthenticatorTip: 'To set up a new authenticator, please clear the current configuration first.',
clearAuthenticator: 'Clear Authenticator',
enableTwoFactor: 'Enable Two-Factor Authentication',
disableTwoFactor: 'Disable Two-Factor Authentication',
setupMfa: 'Setup Two-Factor Authentication',
enableMfa: 'Enable Two-Factor Authentication',
useAuthenticator: 'Use Authenticator',
usePasskey: 'Use Passkey',
enabled: 'Enabled',
keysCount: '{count} keys',
passkeyManagement: 'Passkey Management',
registerNewPasskey: 'Register New Passkey',
passkeyDescription: 'Passkeys allow you to sign in quickly and securely without a password.',
passkeyAppDescription:
'Passkeys are a simpler, more secure way to sign in, serving as an alternative to passwords. You can authenticate using passkey-supported apps like iCloud Keychain, Bitwarden, or hardware keys.',
passkeyName: 'Passkey Name',
passkeyNamePlaceholder: 'e.g.: iPhone, Windows Hello',
registerPasskey: 'Register Passkey',
createdAt: 'Created',
lastUsedAt: 'Last used',
noPasskeys: 'You havent registered any passkeys yet',
passkeyNameRequired: 'Please enter a passkey name',
passkeyRegisterSuccess: 'Passkey registered successfully',
passkeyRegisterFailed: 'Registration failed',
passkeyRegisterCancelled: 'Registration cancelled',
passkeyDeleteSuccess: 'Passkey deleted',
passkeyDeleteFailed: 'Delete failed',
deletePasskey: 'Delete Passkey',
passkeyDomainWarning:
'The availability of PassKeys is closely related to the {domain}. In a public network environment, please make sure to configure the correct access domain name in "Basic Settings". Domain changes or configuration errors will cause the PassKey to be unusable.',
otpRequiredForPasskey:
'For security reasons, you must first enable {otp} before you can register a PassKey. This is to ensure that you can still log in to your account via OTP code if the PassKey becomes invalid due to domain configuration changes.',
accessDomain: 'access domain name',
otpAuthenticator: 'OTP Authenticator',
otpGenerateFailed: 'Failed to get OTP URI: {message}!',
otpDisableSuccess: 'Two-factor authentication disabled successfully!',
otpDisableFailed: 'Failed to disable OTP: {message}!',
otpCodeRequired: 'Please enter the 6-digit verification code',
otpEnableSuccess: 'Two-factor authentication enabled successfully!',
otpEnableFailed: 'Failed to enable OTP: {message}!',
authenticatorApp: 'Authenticator App',
otpDisableRestrictedByPasskey:
'You have registered Passkeys. Please delete all Passkeys before disabling OTP verification.',
confirmToDisableOtp:
'For security reasons, verifying your login password is required to disable two-factor authentication.',
confirmToDeletePasskey: 'For security reasons, verifying your login password is required to delete a Passkey.',
authenticatorAppDescription:
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code. It will generate a 6-digit code for you to enter below.',
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code and generate a 6-digit code.',
secretKeyTip:
"If you're having trouble with the QR code, select manual entry in your app and enter the code above.",
enterVerificationCode: 'Enter verification code to confirm enabling two-factor authentication',
@@ -2602,10 +2863,19 @@ export default {
loading: 'Loading...',
pageSize: 'Items Per Page',
pageInfo: '{begin} - {end} / {total}',
aiRedoDisabled: 'Please enable the AI assistant in system settings first',
aiRedoQueued: 'Assistant organize task submitted: {title}',
aiRedoFailed: 'Failed to submit assistant organize task',
actions: {
aiRedo: 'Assistant Organize',
aiRedoPending: 'Assistant Organizing...',
batchAiRedo: 'Assistant Batch Organize',
redo: 'Reorganize',
delete: 'Delete',
batchRedo: 'Batch Reorganize',
batchDelete: 'Batch Delete',
},
batchOperationTitle: 'Batch Operation',
progress: {
processing: 'Processing',
pleaseWait: 'Please wait...',
@@ -2660,10 +2930,13 @@ export default {
type: 'Type',
enabled: 'Enabled',
customTypeHint: 'Custom downloader type, for plugin scenarios',
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 or SCGI: scgi://ip:port',
default: 'Default',
host: 'Host',
apiKey: 'API Key',
username: 'Username',
password: 'Password',
qbittorrentApiKeyHint: 'For qBittorrent 5.2+, you can use the WebUI API Key directly. When set, API Key auth is preferred.',
category: 'Auto Category Management',
sequentail: 'Sequential Download',
force_resume: 'Force Resume',
@@ -2676,6 +2949,11 @@ export default {
hostRequired: 'Host cannot be empty',
usernameRequired: 'Username cannot be empty',
passwordRequired: 'Password cannot be empty',
pathMapping: 'Path Mapping',
pathMappingRequired: 'Path cannot be empty',
pathMappingError: 'Must start with /',
storagePath: 'Storage Path',
downloadPath: 'Download Path',
},
filterRule: {
title: 'Filter Rule',
@@ -2724,6 +3002,15 @@ export default {
password: 'Password',
syncLibraries: 'Sync Libraries',
syncLibrariesHint: 'Only selected libraries will be synchronized',
scanMode: 'Scan Mode',
scanModeHint: 'Applies to full-library and targeted refresh: New & Modified / Supplement Missing / Full Override',
verifySsl: 'Verify SSL Certificate',
verifySslHint: 'When enabled, HTTPS certificates are verified; disable for self-signed certificates',
scanModeOptions: {
newAndModified: 'New & Modified',
supplementMissing: 'Supplement Missing',
fullOverride: 'Full Override',
},
hostRequired: 'Host cannot be empty',
apiKeyRequired: 'API Key cannot be empty',
tokenRequired: 'Token cannot be empty',
@@ -2960,6 +3247,28 @@ export default {
unsupportedDownloaderType: 'Unsupported downloader type: {type}',
unsupportedMediaServerType: 'Unsupported media server type: {type}',
unsupportedNotificationType: 'Unsupported notification type: {type}',
storageTestFailed: 'Storage test failed',
downloaderTestFailed: 'Downloader test failed',
downloaderNotSelected: 'No downloader selected',
mediaServerTestFailed: 'Media server test failed',
mediaServerNotSelected: 'No media server selected',
notificationTestFailed: 'Notification test failed',
notificationNotSelected: 'No notification type selected',
saveStepFailed: 'Failed to save step settings',
basicSettingsSaved: 'Basic settings saved successfully',
saveBasicSettingsFailed: 'Failed to save basic settings',
storageSettingsSaved: 'Storage settings saved successfully',
saveStorageSettingsFailed: 'Failed to save storage settings',
downloaderSettingsSaved: 'Downloader settings saved successfully',
saveDownloaderSettingsFailed: 'Failed to save downloader settings',
mediaServerSettingsSaved: 'Media server settings saved successfully',
saveMediaServerSettingsFailed: 'Failed to save media server settings',
notificationSettingsSaved: 'Notification settings saved successfully',
saveNotificationSettingsFailed: 'Failed to save notification settings',
saveSiteAuthSettingsFailed: 'Failed to save user site authentication settings: {message}',
saveAgentSettingsFailed: 'Failed to save AI assistant settings',
preferenceSettingsSaved: 'Preference settings saved successfully',
savePreferenceSettingsFailed: 'Failed to save preference settings',
passwordUpdateSuccess: 'Password updated successfully',
userCreateSuccess: 'User created successfully',
passwordUpdateFailed: 'Failed to update password',
@@ -2979,6 +3288,18 @@ export default {
confirmPasswordHint: 'Confirm new password',
apiTokenRequired: 'API Token is required',
},
siteAuth: {
title: 'User Authentication',
description: 'Configure site authentication and auxiliary authentication',
info: 'User Site Authentication',
infoDesc:
'Completing site authentication unlocks site capabilities and some plugin permissions. This step is optional and can also be configured later from the user menu.',
selectSiteHint: 'Choose a supported auth site and fill in the required credentials for that site',
submitHint:
'When you click Next, the wizard will immediately validate against the selected auth site and save the current parameters on success.',
siteConfigNotExist: 'Authentication site configuration does not exist',
fieldRequired: 'Please enter {name}',
},
storage: {
title: 'Storage',
description: 'Configure download directory and media library directory',
@@ -3011,7 +3332,8 @@ export default {
title: 'Media Server',
description: 'Configure media server',
info: 'Media Server Configuration',
infoDesc: 'Configure media server for media library management, can choose Emby, Jellyfin or Plex etc.',
infoDesc:
'Configure media server for media library management, can choose Emby, Jellyfin, Plex, TrimeMedia or Ugreen.',
type: 'Media Server Type',
typeHint: 'Select the type of media server to use',
name: 'Server Name',
@@ -3042,6 +3364,19 @@ export default {
senderPassword: 'Sender Password',
receiverEmail: 'Receiver Email',
},
agent: {
title: 'AI Assistant',
description: 'Configure the Agent assistant and LLM parameters',
info: 'AI Assistant Configuration',
infoDesc:
'After enabling it, you can use the Agent in message conversations and optionally turn on transfer-failure takeover and AI recommendations.',
providerRequired: 'LLM provider is required',
apiKeyRequired: 'LLM API key is required',
authOrApiKeyRequired: 'Provide an LLM API key or complete provider authorization first',
modelRequired: 'LLM model name is required',
maxContextTokensRequired: 'LLM max context tokens must be greater than 0',
recommendMaxItemsRequired: 'AI recommendation analysis limit must be greater than 0',
},
preferences: {
title: 'Resource Preferences',
description: 'Set resource download preferences',

View File

@@ -30,6 +30,7 @@ export default {
saving: '保存中',
reset: '重置',
theme: '主题',
uiMode: '界面布局',
language: '语言',
pleaseWait: '请稍候...',
viewDetails: '查看详情',
@@ -45,6 +46,7 @@ export default {
unsubscribe: '取消订阅',
media: '媒体',
unknown: '未知',
notFetched: '未获取',
notice: '注意',
itemsPerPage: '每页条数',
pageText: '{0}-{1} 共 {2} 条',
@@ -66,6 +68,12 @@ export default {
serviceUnavailable: '服务不可用',
status: '状态',
preset: '预设',
refresh: '刷新',
swUpdateReady: '新版本已就绪,请刷新页面以获取最新功能',
ascending: '升序',
descending: '降序',
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
clearCache: '清除缓存',
},
mediaType: {
movie: '电影',
@@ -82,6 +90,7 @@ export default {
mediaServer: '媒体服务器',
manual: '手动处理',
plugin: '插件',
agent: '智能体',
other: '其它',
},
actionStep: {
@@ -129,6 +138,7 @@ export default {
light: '浅色',
dark: '深色',
auto: '跟随系统',
autoUI: '自动',
transparent: '透明',
purple: '幻紫',
custom: '附加样式',
@@ -237,16 +247,33 @@ export default {
wallpapers: '壁纸',
username: '用户名',
password: '密码',
otpCode: '双重验证码',
otpCode: '验证码',
stayLoggedIn: '保持登录',
login: '登录',
networkError: '登录失败,请检查网络连接!',
authFailure: '登录失败,请检查用户名、密码或双重验证是否正确!',
authFailure: '登录失败,请检查用户名、密码或二次验证是否正确!',
permissionDenied: '登录失败,您没有权限访问!',
noPermission: '登录失败,您没有任何功能权限,请联系管理员!',
serverError: '登录失败,服务器错误!',
loginFailed: '登录失败',
checkCredentials: '请检查用户名、密码或双重验证码是否正确!',
secondaryVerification: '二次验证',
orDivider: '或',
loginWithPasskey: '使用通行密钥登录',
loginWithOtp: '使用验证码登录',
orUsePasskey: '或使用通行密钥进行验证',
verifyWithPasskey: '使用通行密钥验证',
otpPlaceholder: '请输入6位验证码',
passkeyLoginStartFailed: '启动通行密钥认证失败',
passkeyNotSelected: '未选择通行密钥',
passkeyLoginFailed: '通行密钥登录失败',
passkeyAuthCanceled: '通行密钥认证被取消',
passkeyNotSupported: '当前浏览器不支持通行密钥',
passkeySecureContextRequired: '通行密钥需要 HTTPS 安全连接',
passkeyVerifyFailed: '通行密钥验证失败',
passkeyVerifyFailedRetry: '通行密钥验证失败,请重试',
mfa: {
selectVerificationMethod: '请选择验证方式',
},
},
menu: {
start: '开始',
@@ -289,7 +316,7 @@ export default {
settingTabs: {
system: {
title: '系统',
description: '基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex',
description: '基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex、飞牛影视、绿联影视',
},
directory: {
title: '存储 & 目录',
@@ -379,7 +406,7 @@ export default {
username: '用户名',
usernameHint: '用于登录系统的用户名',
password: '密码',
passwordHint: '用于登录系统的密码',
passwordHint: '请输入登录密码',
confirmPassword: '确认密码',
confirmPasswordHint: '请再次输入密码以确认',
role: '角色',
@@ -408,6 +435,8 @@ export default {
config: '配置',
wechat: {
name: '企业微信',
useBotMode: '使用智能机器人',
useBotModeHint: '开启后使用智能机器人长连接,固定 dmPolicy=open、groupPolicy=disabled',
corpId: '企业ID',
corpIdHint: '企业微信后台企业信息中的企业ID',
corpIdRequired: '企业ID不能为空',
@@ -423,6 +452,15 @@ export default {
tokenHint: '微信企业自建应用->API接收消息配置中的Token',
encodingAesKey: 'EncodingAESKey',
encodingAesKeyHint: '微信企业自建应用->API接收消息配置中的EncodingAESKey',
botId: '机器人 BotID',
botIdHint: '企业微信智能机器人的 BotID',
botSecret: '机器人 Secret',
botSecretHint: '企业微信智能机器人长连接专用 Secret',
botChatId: '默认通知目标',
botChatIdHint: '可填写用户 userid如需主动发群消息可填写 group:群聊chatid不填则默认发给已互动用户',
botChatIdPlaceholder: 'userid 或 group:chatid',
botWsUrl: '长连接地址',
botWsUrlHint: '企业微信智能机器人 WebSocket 地址,通常使用默认值',
admins: '管理员白名单',
adminsHint: '可使用管理菜单及命令的用户ID列表多个ID使用,分隔',
adminsPlaceholder: '用户ID列表多个ID使用,分隔',
@@ -456,6 +494,18 @@ export default {
channelHint: '消息发送频道,默认`全体`',
channelRequired: '频道名称不能为空',
},
discord: {
name: 'Discord',
botToken: 'Bot Token',
botTokenHint: 'Discord Bot Token需在开发者后台开启 Message Content Intent',
botTokenRequired: 'Bot Token不能为空',
guildId: '服务器 ID',
guildIdHint: '可选,限制使用的服务器;为空则使用已加入的任意服务器',
guildIdPlaceholder: '123456789012345678',
channelId: '频道 ID',
channelIdHint: '可选,默认广播频道;为空则自动选择可发送消息的频道',
channelIdPlaceholder: '123456789012345678',
},
synologychat: {
name: 'Synology Chat',
webhook: '机器人传入URL',
@@ -481,6 +531,21 @@ export default {
usernameHint: '只有对应的用户登录后才会推送消息',
usernameRequired: '用户名不能为空',
},
qqbot: {
name: 'QQ',
appId: 'AppID',
appIdHint: 'QQ 开放平台机器人 AppID',
appIdRequired: 'AppID 不能为空',
appSecret: 'AppSecret',
appSecretHint: 'QQ 开放平台机器人 AppSecret',
appSecretRequired: 'AppSecret 不能为空',
openId: '用户 OpenID',
openIdHint: '默认接收者 openid单聊用户需曾与机器人交互过',
openIdPlaceholder: '32位十六进制',
groupOpenId: '群组 OpenID',
groupOpenIdHint: '默认群组 openid群聊与用户 OpenID 二选一',
groupOpenIdPlaceholder: '群组 openid',
},
},
shortcut: {
title: '捷径',
@@ -516,6 +581,10 @@ export default {
title: '缓存',
subtitle: '管理缓存',
},
scheduler: {
title: '服务',
subtitle: '定时服务',
},
},
workflow: {
components: '动作组件',
@@ -921,6 +990,10 @@ export default {
searching: '正在搜索,请稍候...',
noData: '没有数据',
noResourceFound: '未搜索到任何资源',
aiRecommend: '智能推荐',
reRecommend: '重新生成推荐',
aiRecommendError: '智能推荐失败',
refreshSearch: '重新搜索',
},
browse: {
actor: '演员',
@@ -1156,6 +1229,17 @@ export default {
content: '内容',
refreshing: '正在刷新',
initializing: '正在初始化',
searchPlaceholder: '搜索日志内容',
allLevels: '全部级别',
followTail: '跟随最新日志',
wrapLines: '自动换行',
pauseStream: '暂停日志流',
resumeStream: '恢复日志流',
waitingForLogs: '等待日志输出...',
paused: '已暂停',
connected: '实时更新中',
lineCount: '显示 {visible}/{total} 行',
jumpToLatest: '查看最新 ({count})',
},
moduleTest: {
normal: '正常',
@@ -1194,6 +1278,7 @@ export default {
title: '关于 MoviePilot',
softwareVersion: '软件版本',
frontendVersion: '前端版本',
browserVersion: '浏览器缓存版本',
authVersion: '认证资源版本',
indexerVersion: '站点资源版本',
configDir: '配置目录',
@@ -1201,7 +1286,7 @@ export default {
timezone: '时区',
latest: '最新',
supportingSites: '支持站点',
support: '支',
support: '支',
documentation: '文档',
feedback: '问题反馈',
channel: '发布频道',
@@ -1213,6 +1298,7 @@ export default {
dataDirectory: '/moviepilot',
expand: '展开',
collapse: '收起',
clearCache: '清除缓存',
},
system: {
custom: '自定义',
@@ -1243,21 +1329,95 @@ export default {
aiAgent: '启用智能助手',
aiAgentEnable: '启用智能助手',
aiAgentEnableHint: '启用后可使用智能助手功能需要配置LLM相关参数',
aiAgentSectionTitle: '智能助手配置',
aiAgentSectionDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
llmProvider: 'LLM提供商',
llmProviderHint: '选择使用的LLM服务提供商',
llmModel: 'LLM模型名称',
llmModelHint: '指定使用的LLM模型gpt-3.5-turbo、deepseek-chat等',
llmModelHint: '指定使用的LLM模型deepseek-v4-flash、gpt-5.4等',
llmModelResolvedHint: '已根据模型目录自动回填最大上下文为 {context}K来源{source}',
llmThinking: '思考模式 / 深度',
llmThinkingHint:
'思考深度off/auto/minimal/low/medium/high/max/xhigh不支持的级别会按 provider 能力自动映射到最近值',
llmThinkingLevelOff: '关闭 (off)',
llmThinkingLevelAuto: '自动 (auto)',
llmThinkingLevelMinimal: '最小 (minimal)',
llmThinkingLevelLow: '低 (low)',
llmThinkingLevelMedium: '中 (medium)',
llmThinkingLevelHigh: '高 (high)',
llmThinkingLevelMax: '极高 (max)',
llmThinkingLevelXhigh: '超高 (xhigh)',
llmSupportImageInput: '模型支持图片输入',
llmSupportImageInputHint:
'启用后,消息中的图片会按多模态图片发送给 LLM关闭后图片会作为附件保存到本地并将文件路径提供给智能助手处理',
llmSupportAudioInputOutput: '支持音频输入输出',
llmSupportAudioInputOutputHint:
'启用后,智能助手可以转写用户发送的音频消息,并在支持的渠道上回复语音',
llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',
llmMaxContextTokensHint:
'设定 LLM 记录会话历史的最大 Token 数量上限(千),超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',
llmApiKey: 'LLM API密钥',
llmApiKeyHint: 'LLM服务提供商的API密钥用于身份验证',
llmApiKeyPlaceholder: '请输入API密钥',
llmBaseUrl: 'LLM基础URL',
llmBaseUrlHint: 'LLM API的基础URL地址用于自定义API端点',
llmProviderAuth: '提供商授权',
llmProviderAuthHint: '支持账号登录授权的提供商,可以直接在这里完成登录并复用授权状态。',
llmProviderConnectedAs: '当前已连接:{label}',
llmProviderDisconnect: '断开授权',
llmProviderDisconnected: '已断开提供商授权',
llmProviderAuthDialogTitle: '提供商授权',
llmProviderPopupBlocked: '浏览器拦截了授权窗口,请手动点击下方按钮继续。',
llmProviderDeviceCode: '设备码',
llmProviderOpenAuthPage: '打开授权页面',
llmProviderCheckAuthStatus: '检查授权状态',
aiVoiceApiKey: '音频 API密钥',
aiVoiceApiKeyHint: '音频转写与语音合成使用的 API 密钥,留空时回退到当前 LLM API 密钥',
aiVoiceBaseUrl: '音频基础URL',
aiVoiceBaseUrlHint: '音频转写与语音合成接口的基础URL留空时回退到当前 LLM 基础 URL',
aiVoiceSttModel: '音频转写模型',
aiVoiceSttModelHint: '用于将音频内容转换为文字的模型名称',
aiVoiceTtsModel: '语音合成模型',
aiVoiceTtsModelHint: '用于将文字内容转换为语音的模型名称',
aiVoiceTtsVoice: '语音音色',
aiVoiceTtsVoiceHint: '语音合成使用的发音人或音色标识',
aiVoiceLanguage: '识别语言',
aiVoiceLanguageHint: '音频转写默认语言,例如 zh、en留空时按后端默认处理',
aiVoiceReplyWithText: '语音回复附带文字',
aiVoiceReplyWithTextHint: '发送语音回复时,同时附带一份文字内容',
llmTestAction: '测试调用',
llmTestSuccessToast: 'LLM 调用测试成功',
llmTestFailedToast: 'LLM 调用测试失败',
llmTestFailedToastWithMessage: 'LLM 调用测试失败:{message}',
aiAgentGlobal: '全局智能助手',
aiAgentGlobalHint: '启用全局智能助手功能,所有消息对话均使用智能体回答而不用使用/ai命令',
aiAgentJobInterval: '定时唤醒',
aiAgentJobIntervalHint: '设置定时唤醒的检查间隔,选择"不启用"则不执行定时任务',
aiAgentVerbose: '啰嗦模式',
aiAgentVerboseHint: '开启后会在智能体回复时显示工具调用过程',
aiAgentJobIntervalDisabled: '不启用',
aiAgentJobInterval1h: '1小时',
aiAgentJobInterval3h: '3小时',
aiAgentJobInterval6h: '6小时',
aiAgentJobInterval12h: '12小时',
aiAgentJobInterval24h: '24小时',
aiAgentJobInterval1w: '1周',
aiAgentJobInterval1M: '1个月',
advancedSettings: '高级设置',
advancedSettingsDesc: '系统进阶设置,特殊情况下才需要调整',
downloaders: '下载器',
downloadersDesc: '只有默认下载器才会被默认使用。',
aiAgentRetryTransfer: '文件整理失败智能接管',
aiAgentRetryTransferHint:
'启用后当文件整理失败时智能助手将自动接管并尝试重新整理利用AI能力解决识别和匹配问题',
aiRecommendEnabled: '搜索结果智能推荐',
aiRecommendEnabledHint:
'启用搜索结果智能推荐功能,开启后将在搜索结果页面显示智能推荐按钮,可根据用户偏好智能推荐资源',
aiRecommendUserPreference: '用户偏好',
aiRecommendUserPreferenceHint: '设置智能推荐时的用户偏好例如4K WEB-DL Dolby Vision',
aiRecommendMaxItems: '智能推荐分析条目上限',
aiRecommendMaxItemsHint:
'限制发送给智能助手进行分析的搜索结果数量,数量越多分析越慢且消耗 Token 越多,建议先手动筛选,筛选出大致范围后再进行智能推荐',
mediaServers: '媒体服务器',
mediaServersDesc: '所有启用的媒体服务器都会被使用。',
trimeMedia: '飞牛影视',
@@ -1280,9 +1440,11 @@ export default {
reloading: '正在应用配置...',
qbittorrent: 'Qbittorrent',
transmission: 'Transmission',
rtorrent: 'rTorrent',
emby: 'Emby',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '绿联影视',
reloadSuccess: '系统配置已生效',
reloadFailed: '重载系统失败!',
auxAuthEnable: '用户辅助认证',
@@ -1322,6 +1484,8 @@ export default {
fanartEnableHint: '使用 fanart.tv 的图片数据',
fanartLang: 'Fanart语言',
fanartLangHint: '设置Fanart图片的语言偏好多选时按优先级顺序排列',
recognizePluginFirst: "优先使用插件识别",
recognizePluginFirstHint: "优先调用插件识别媒体信息,若插件命中则不再调用原生识别",
githubProxy: 'Github加速代理',
githubProxyPlaceholder: '留空表示不使用代理',
githubProxyHint: '使用代理加速Github访问速度',
@@ -1352,8 +1516,12 @@ export default {
logFileFormatHint: '设置日志文件的输出格式,用于自定义日志的显示内容',
pluginAutoReload: '插件热加载',
pluginAutoReloadHint: '修改插件文件后自动重新加载,开发插件时使用',
pluginLocalRepoPaths: '本地插件仓库路径',
pluginLocalRepoPathsHint: '本地插件仓库目录,多个目录用英文逗号分隔,支持相对路径和绝对路径',
encodingDetectionPerformanceMode: '编码探测性能模式',
encodingDetectionPerformanceModeHint: '优先提升探测效率,但可能降低编码探测的准确性',
transferThreads: '文件整理线程数',
transferThreadsHint: '多线程整理文件可以提高速度,但可能增加系统资源占用',
tokenizedSearch: '分词搜索',
tokenizedSearchHint: '提升整理历史记录搜索精度,但可能增加性能开销和意外结果',
tmdbLanguage: {
@@ -1430,6 +1598,11 @@ export default {
episodeThumb: '缩略图',
scrapingSwitchSaveFailed: '刮削开关设置保存失败:{message}',
scrapingSwitchSaveError: '刮削开关设置保存失败',
policy: {
skipDesc: '跳过刮削,不生成该文件',
missingOnlyDesc: '仅在缺失时刮削,已存在则保持不变',
overwriteDesc: '始终刮削,已存在则覆盖',
}
},
site: {
siteSync: '站点同步',
@@ -1524,6 +1697,7 @@ export default {
synologyChat: 'SynologyChat',
voceChat: 'VoceChat',
webPush: 'WebPush',
qq: 'QQ',
custom: '自定义通知',
},
words: {
@@ -1623,6 +1797,25 @@ export default {
storageSaveSuccess: '存储设置保存成功',
storageSaveFailed: '存储设置保存失败!',
},
category: {
title: '分类策略',
subtitle: '配置媒体自动分类规则,按类型、语言、地区等条件自动归类',
movie: '电影 (Movie)',
tv: '电视剧 (TV)',
name: '分类名称 (目录名)',
genre: '内容类型 (Genre)',
language: '语种 (Language)',
languagePlaceholder: '如: zh,cn,en (使用逗号分隔)',
country: '国家/地区 (Country)',
countryPlaceholder: '如: US,CN,JP',
year: '年份 (Year)',
yearPlaceholder: '如: 2023, 2020-2024',
addMovie: '添加电影分类',
addTv: '添加电视剧分类',
saveSuccess: '分类策略保存成功',
loadFailed: '加载分类配置失败',
saveFailed: '保存失败: {message}',
},
rule: {
customRules: '自定义规则',
customRulesDesc: '自定义优先级规则项',
@@ -1811,6 +2004,7 @@ export default {
wechat: '微信ID',
telegram: 'Telegram ID',
slack: 'Slack ID',
discord: 'Discord ID',
vocechat: 'VoceChat ID',
synologyChat: 'SynologyChat ID',
webPush: 'WebPush',
@@ -1848,7 +2042,7 @@ export default {
},
searchBar: {
search: '搜索',
searchPlaceholder: '搜索功能、订阅、设置...',
searchPlaceholder: '搜索电影、剧集以及更多...',
recentSearches: '最近搜索',
noRecentSearches: '没有最近搜索记录',
functions: '功能',
@@ -1868,6 +2062,9 @@ export default {
searchInSites: '在站点中搜索种子资源',
relatedResources: '相关资源',
searchTip: '可搜索电影、电视剧、演员、资源等',
emptySearchHint: '输入关键字开始搜索',
escClose: '关闭',
openSearch: '打开搜索',
},
searchSite: {
selectSites: '选择站点',
@@ -1921,9 +2118,15 @@ export default {
securityWarningMessage: '分享前请确保工作流没有敏感信息比如RSS链接中的PassKey等避免产生信息泄露。',
},
u115Auth: {
loginTitle: '115网盘登录',
scanQrCode: '请使用微信或115客户端扫码',
scanned: '已扫码,请确认登录',
loginTitle: '115网盘授权',
openAuthWindow: '打开授权窗口',
authorizing: '请在新窗口中完成授权...',
authSuccess: '授权成功!',
authFailed: '授权失败或已过期',
authCanceled: '授权已取消,请重试',
urlEmpty: '授权URL为空',
urlFetchFailed: '获取授权URL失败',
popupBlocked: '无法打开授权窗口,请检查浏览器弹窗设置',
complete: '完成',
reset: '重置',
},
@@ -2075,6 +2278,10 @@ export default {
repoUrl: '插件仓库地址',
repoPlaceholder: '格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
repoHint: '多个地址使用换行分隔仅支持Github仓库',
urlPlaceholder: '输入插件仓库地址',
noRepos: '暂无插件仓库地址',
invalidUrl: '请输入有效的URL地址',
duplicateUrl: '该地址已存在',
close: '关闭',
save: '保存',
saveSuccess: '插件仓库保存成功',
@@ -2425,6 +2632,7 @@ export default {
settings: '设置',
projectHome: '项目主页',
updateHistory: '更新说明',
local: '本地',
installToLocal: '安装到本地',
totalDownloads: '共 {count} 次下载',
viewData: '查看数据',
@@ -2494,6 +2702,7 @@ export default {
noRecentPlugins: '无',
},
profile: {
disableOtpWithPasskeyError: '请先删除所有通行密钥后再清除身份验证器!',
personalInfo: '个人信息',
uploadNewAvatar: '上传新头像',
avatarFormatError: '上传的文件不符合要求,请重新选择头像',
@@ -2514,21 +2723,58 @@ export default {
wechatUser: '微信用户',
telegramUser: 'Telegram用户',
slackUser: 'Slack用户',
discordUser: 'Discord用户',
vocechatUser: 'VoceChat用户',
synologychatUser: 'SynologyChat用户',
doubanUser: '豆瓣用户',
twoFactorAuthentication: '登录双重验证',
setupAuthenticator: '设置身份验证',
authenticatorManagement: '身份验证器管理',
authenticatorEnabled: '您已启用身份验证器双重验证',
clearAuthenticatorTip: '如需设置新的身份验证器,请先清除当前配置。',
clearAuthenticator: '清除身份验证器',
enableTwoFactor: '开启双重验证',
disableTwoFactor: '关闭双重验证',
setupMfa: '设置双重验证',
enableMfa: '开启双重验证',
useAuthenticator: '使用身份验证器',
usePasskey: '使用通行密钥',
enabled: '已启用',
keysCount: '{count} 个密钥',
passkeyManagement: '通行密钥管理',
registerNewPasskey: '注册新通行密钥',
passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。',
passkeyAppDescription:
'通行密钥是一种更简单、更安全的登录方式,可以替代密码进行登录。您可以使用 iCloud 钥匙串、Bitwarden 等支持通行密钥的应用程序或硬件密钥完成验证。',
passkeyName: '通行密钥名称',
passkeyNamePlaceholder: '例如iPhone、Windows Hello',
registerPasskey: '注册通行密钥',
createdAt: '创建于',
lastUsedAt: '最后使用时间',
noPasskeys: '您还没有注册任何通行密钥',
passkeyNameRequired: '请输入通行密钥名称',
passkeyRegisterSuccess: '通行密钥注册成功',
passkeyRegisterFailed: '注册失败',
passkeyRegisterCancelled: '注册被取消',
passkeyDeleteSuccess: '通行密钥已删除',
passkeyDeleteFailed: '删除失败',
deletePasskey: '删除通行密钥',
passkeyDomainWarning:
'通行密钥PassKey的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
otpRequiredForPasskey:
'为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
accessDomain: '访问域名',
otpAuthenticator: 'OTP 身份验证器',
otpGenerateFailed: '获取otp uri失败{message}',
otpDisableSuccess: '关闭登录双重验证成功!',
otpDisableFailed: '关闭otp失败{message}',
otpCodeRequired: '请填写6位验证码',
otpEnableSuccess: '开启登录双重验证成功!',
otpEnableFailed: '开启otp失败{message}',
authenticatorApp: '身份验证',
otpDisableRestrictedByPasskey: '您已注册通行密钥,请先删除所有通行密钥再关闭 OTP 验证',
confirmToDisableOtp: '为了安全起见,关闭双重验证需要验证您的登录密码。',
confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。',
authenticatorAppDescription:
'使用Google Authenticator、Microsoft Authenticator、Authy1Password这样的身份验证器应用程序,扫描二维码。它将为您生成一个6位数的代码供您在下方输入。',
'使用 Google Authenticator、Microsoft Authenticator、Authy1Password验证器应用扫描二维码,获取 6 位验证码。',
secretKeyTip: '如果您在使用二维码时遇到困难,请在您的应用程序中选择手动输入以上代码。',
enterVerificationCode: '输入验证码以确认开启双重验证',
avatarFormatTip: '允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
@@ -2570,10 +2816,19 @@ export default {
loading: '加载中...',
pageSize: '每页条数',
pageInfo: '{begin} - {end} / {total}',
aiRedoDisabled: '请先在系统设置中启用 AI 智能助手',
aiRedoQueued: '已提交智能助手整理任务:{title}',
aiRedoFailed: '提交智能助手整理任务失败',
actions: {
aiRedo: '智能助手整理',
aiRedoPending: '智能助手整理中...',
batchAiRedo: '智能助手批量整理',
redo: '重新整理',
delete: '删除',
batchRedo: '批量重新整理',
batchDelete: '批量删除',
},
batchOperationTitle: '批量操作',
progress: {
processing: '处理中',
pleaseWait: '请稍候...',
@@ -2628,10 +2883,13 @@ export default {
type: '类型',
enabled: '启用',
customTypeHint: '自定义下载器类型,用于插件等场景',
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port',
default: '默认',
host: '地址',
apiKey: 'API Key',
username: '用户名',
password: '密码',
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key填写后将优先使用 API Key 登录。',
category: '自动分类管理',
sequentail: '顺序下载',
force_resume: '强制继续',
@@ -2644,6 +2902,11 @@ export default {
hostRequired: '地址不能为空',
usernameRequired: '用户名不能为空',
passwordRequired: '密码不能为空',
pathMapping: '路径映射',
pathMappingRequired: '路径不能为空',
pathMappingError: '必须以 / 开头',
storagePath: '存储路径',
downloadPath: '下载路径',
},
filterRule: {
title: '过滤规则',
@@ -2692,6 +2955,15 @@ export default {
password: '密码',
syncLibraries: '同步媒体库',
syncLibrariesHint: '只有选中的媒体库才会被同步',
scanMode: '扫描模式',
scanModeHint: '用于全库刷新和按库刷新:新添加和修改 / 补充缺失 / 覆盖扫描',
verifySsl: '校验 SSL 证书',
verifySslHint: '开启后会校验 HTTPS 证书;如使用自签名证书可关闭',
scanModeOptions: {
newAndModified: '新添加和修改',
supplementMissing: '补充缺失',
fullOverride: '覆盖扫描',
},
nameExists: '【{name}】已存在,请替换为其他名称',
hostRequired: '地址不能为空',
apiKeyRequired: 'API密钥不能为空',
@@ -2945,6 +3217,8 @@ export default {
saveMediaServerSettingsFailed: '保存媒体服务器设置失败',
notificationSettingsSaved: '通知设置保存成功',
saveNotificationSettingsFailed: '保存通知设置失败',
saveSiteAuthSettingsFailed: '保存用户站点认证设置失败:{message}',
saveAgentSettingsFailed: '保存智能助手设置失败',
preferenceSettingsSaved: '偏好设置保存成功',
savePreferenceSettingsFailed: '保存偏好设置失败',
passwordUpdateSuccess: '密码更新成功',
@@ -2966,6 +3240,16 @@ export default {
confirmPasswordHint: '确认新密码',
apiTokenRequired: 'API Token不能为空',
},
siteAuth: {
title: '用户认证',
description: '配置用户站点认证与辅助认证',
info: '用户站点认证说明',
infoDesc: '完成站点认证后可解锁站点能力与部分插件权限。此步骤可选,后续也可在个人菜单中继续配置。',
selectSiteHint: '选择一个支持认证的站点,并填写该站点要求的认证参数',
submitHint: '点击下一步时将立即向认证站点发起校验,认证成功后会保存当前参数。',
siteConfigNotExist: '认证站点配置不存在',
fieldRequired: '请输入{name}',
},
storage: {
title: '存储',
description: '配置下载目录和媒体库目录',
@@ -2998,7 +3282,7 @@ export default {
title: '媒体服务器',
description: '配置媒体服务器',
info: '媒体服务器配置说明',
infoDesc: '配置媒体服务器用于媒体库管理可选择Emby、JellyfinPlex',
infoDesc: '配置媒体服务器用于媒体库管理可选择Emby、JellyfinPlex、飞牛影视或绿联影视',
type: '媒体服务器类型',
typeHint: '选择要使用的媒体服务器类型',
name: '服务器名称',
@@ -3029,6 +3313,18 @@ export default {
senderPassword: '发送密码',
receiverEmail: '接收邮箱',
},
agent: {
title: '智能助手',
description: '配置 Agent 助手与 LLM 参数',
info: '智能助手配置说明',
infoDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
providerRequired: 'LLM 提供商不能为空',
apiKeyRequired: 'LLM API 密钥不能为空',
authOrApiKeyRequired: '请填写 LLM API 密钥或先完成提供商授权',
modelRequired: 'LLM 模型名称不能为空',
maxContextTokensRequired: 'LLM 最大上下文 Token 数量必须大于 0',
recommendMaxItemsRequired: '智能推荐分析条目上限必须大于 0',
},
preferences: {
title: '资源偏好',
description: '设置资源下载偏好',

View File

@@ -30,6 +30,7 @@ export default {
saving: '保存中',
reset: '重置',
theme: '主題',
uiMode: '界面佈局',
language: '語言',
pleaseWait: '請稍候...',
viewDetails: '查看詳情',
@@ -45,6 +46,7 @@ export default {
unsubscribe: '取消訂閱',
media: '媒體',
unknown: '未知',
notFetched: '未獲取',
notice: '注意',
itemsPerPage: '每頁條數',
pageText: '{0}-{1} 共 {2} 條',
@@ -66,6 +68,12 @@ export default {
serviceUnavailable: '服務不可用',
status: '狀態',
preset: '預設',
refresh: '刷新',
swUpdateReady: '新版本已就緒,請刷新頁面以獲取最新功能',
ascending: '升序',
descending: '降序',
versionMismatch: '瀏覽器快取版本與服務端版本不一致,請嘗試清除快取',
clearCache: '清除快取',
},
mediaType: {
movie: '電影',
@@ -82,6 +90,7 @@ export default {
mediaServer: '媒體伺服器',
manual: '手動處理',
plugin: '插件',
agent: '智能體',
other: '其它',
},
actionStep: {
@@ -129,6 +138,7 @@ export default {
light: '淺色',
dark: '深色',
auto: '跟隨系統',
autoUI: '自動',
transparent: '透明',
purple: '幻紫',
custom: '附加樣式',
@@ -151,7 +161,6 @@ export default {
subscribeMovie: '電影訂閱',
subscribeTv: '電視劇訂閱',
settings: '設置',
language: '語言設置',
selectLanguage: '選擇語言',
logout: '退出登錄',
restarting: '正在重啟...',
@@ -238,16 +247,33 @@ export default {
wallpapers: '壁紙',
username: '用戶名',
password: '密碼',
otpCode: '雙重驗證碼',
otpCode: '驗證碼',
stayLoggedIn: '保持登錄',
login: '登錄',
networkError: '登錄失敗,請檢查網絡連接!',
authFailure: '登錄失敗,請檢查用戶名、密碼或雙重驗證是否正確!',
authFailure: '登錄失敗,請檢查用戶名、密碼或二次驗證是否正確!',
permissionDenied: '登錄失敗,您沒有權限訪問!',
serverError: '登錄失敗,服務器錯誤!',
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
loginFailed: '登錄失敗',
checkCredentials: '請檢查用戶名、密碼或雙重驗證碼是否正確!',
secondaryVerification: '二次驗證',
orDivider: '或',
loginWithPasskey: '使用通行密鑰登錄',
loginWithOtp: '使用驗證碼登錄',
orUsePasskey: '或使用通行密鑰進行驗證',
verifyWithPasskey: '使用通行密鑰驗證',
otpPlaceholder: '請輸入6位驗證碼',
passkeyLoginStartFailed: '啟動通行密鑰驗證失敗',
passkeyNotSelected: '未選擇通行密鑰',
passkeyLoginFailed: '通行密鑰登錄失敗',
passkeyAuthCanceled: '通行密鑰驗證被取消',
passkeyNotSupported: '當前瀏覽器不支援通行密鑰',
passkeySecureContextRequired: '通行密鑰需要 HTTPS 安全連接',
passkeyVerifyFailed: '通行密鑰驗证失敗',
passkeyVerifyFailedRetry: '通行密鑰驗证失敗,請重試',
mfa: {
selectVerificationMethod: '請選擇驗证方式',
},
},
menu: {
start: '開始',
@@ -290,7 +316,8 @@ export default {
settingTabs: {
system: {
title: '系統',
description: '基礎設置、下載器Qbittorrent、Transmission、媒體服務器Emby、Jellyfin、Plex',
description:
'基礎設置、下載器Qbittorrent、Transmission、媒體服務器Emby、Jellyfin、Plex、飛牛影視、綠聯影視',
},
directory: {
title: '存儲 & 目錄',
@@ -380,7 +407,7 @@ export default {
username: '用戶名',
usernameHint: '用於登入系統的用戶名',
password: '密碼',
passwordHint: '用於登入系統的密碼',
passwordHint: '請輸入登入密碼',
confirmPassword: '確認密碼',
confirmPasswordHint: '請再次輸入密碼以確認',
role: '角色',
@@ -409,18 +436,32 @@ export default {
config: '配置',
wechat: {
name: '企業微信',
useBotMode: '使用智能機器人',
useBotModeHint: '開啟後使用智能機器人長連線,固定 dmPolicy=open、groupPolicy=disabled',
corpId: '企業ID',
corpIdHint: '企業微信後台企業信息中的企業ID',
corpIdRequired: '企業ID不能為空',
appId: '應用 AgentId',
appIdHint: '企業微信自建應用的AgentId',
appIdRequired: '應用AgentId不能為空',
appSecret: '應用 Secret',
appSecretHint: '企業微信自建應用的Secret',
appSecretRequired: '應用Secret不能為空',
proxy: '代理地址',
proxyHint: '微信消息的轉發代理地址2022年6月20日後創建的自建應用才需要不使用代理時需要保留默認值',
token: 'Token',
tokenHint: '微信企業自建應用->API接收消息配置中的Token',
encodingAesKey: 'EncodingAESKey',
encodingAesKeyHint: '微信企業自建應用->API接收消息配置中的EncodingAESKey',
botId: '機器人 BotID',
botIdHint: '企業微信智能機器人的 BotID',
botSecret: '機器人 Secret',
botSecretHint: '企業微信智能機器人長連線專用 Secret',
botChatId: '預設通知目標',
botChatIdHint: '可填寫使用者 userid如需主動發群消息可填寫 group:群聊chatid不填則預設發給已互動使用者',
botChatIdPlaceholder: 'userid 或 group:chatid',
botWsUrl: '長連線地址',
botWsUrlHint: '企業微信智能機器人 WebSocket 位址,通常使用預設值',
admins: '管理員白名單',
adminsHint: '可使用管理菜單及命令的用戶ID列表多個ID使用,分隔',
adminsPlaceholder: '用戶ID列表多個ID使用,分隔',
@@ -429,28 +470,48 @@ export default {
name: 'Telegram',
token: 'Bot Token',
tokenHint: 'Telegram機器人token格式123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
tokenRequired: 'Bot Token不能為空',
chatId: 'Chat ID',
chatIdHint: '接受消息通知的用戶、群組或頻道Chat ID',
chatIdRequired: 'Chat ID不能為空',
users: '用戶白名單',
usersHint: '可使用Telegram機器人的用戶ID清單多個用戶用,分隔,不填寫則所有用戶都能使用',
admins: '管理員白名單',
adminsHint: '可使用管理菜單及命令的用戶ID列表多個ID使用,分隔',
adminsPlaceholder: '用戶ID列表多個ID使用,分隔',
usersPlaceholder: '用戶ID列表多個ID使用,分隔',
apiUrl: '代理API地址',
apiUrlHint: '自定義代理API地址格式https://api.telegram.org',
apiUrlPlaceholder: 'https://api.telegram.org',
},
slack: {
name: 'Slack',
oauthToken: 'Slack Bot User OAuth Token',
oauthTokenHint: 'Slack應用`OAuth & Permissions`頁面中的`Bot User OAuth Token`',
oauthTokenRequired: 'OAuth Token不能為空',
appToken: 'Slack App-Level Token',
appTokenHint: 'Slack應用`OAuth & Permissions`頁面中的`App-Level Token`',
channel: '頻道名稱',
channelHint: '消息發送頻道,默認`全體`',
channelRequired: '頻道名稱不能為空',
},
discord: {
name: 'Discord',
botToken: 'Bot Token',
botTokenHint: 'Discord Bot Token需在開發者後台開啟 Message Content Intent',
botTokenRequired: 'Bot Token不能為空',
guildId: '伺服器 ID',
guildIdHint: '可選,限制使用的伺服器;空白則使用已加入的任意伺服器',
guildIdPlaceholder: '123456789012345678',
channelId: '頻道 ID',
channelIdHint: '可選,預設廣播頻道;空白則自動選擇可發送消息的頻道',
channelIdPlaceholder: '123456789012345678',
},
synologychat: {
name: 'Synology Chat',
webhook: '機器人傳入URL',
webhookHint: 'Synology Chat機器人傳入URL',
webhookRequired: 'Webhook URL不能為空',
token: '令牌',
tokenHint: 'Synology Chat機器人令牌',
},
@@ -458,8 +519,10 @@ export default {
name: 'VoceChat',
host: '地址',
hostHint: 'VoceChat服務端地址格式http(s)://ip:port',
hostRequired: '地址不能為空',
apiKey: '機器人密鑰',
apiKeyHint: 'VoceChat機器人密鑰',
apiKeyRequired: 'API密鑰不能為空',
channelId: '頻道ID',
channelIdHint: 'VoceChat的頻道ID不包含#號',
},
@@ -467,6 +530,22 @@ export default {
name: 'WebPush',
username: '登錄用戶名',
usernameHint: '只有對應的用戶登錄後才會推送消息',
usernameRequired: '用戶名不能為空',
},
qqbot: {
name: 'QQ',
appId: 'AppID',
appIdHint: 'QQ 開放平台機器人 AppID',
appIdRequired: 'AppID 不能為空',
appSecret: 'AppSecret',
appSecretHint: 'QQ 開放平台機器人 AppSecret',
appSecretRequired: 'AppSecret 不能為空',
openId: '用戶 OpenID',
openIdHint: '默認接收者 openid單聊用戶需曾與機器人交互過',
openIdPlaceholder: '32位十六進制',
groupOpenId: '群組 OpenID',
groupOpenIdHint: '默認群組 openid群聊與用戶 OpenID 二選一',
groupOpenIdPlaceholder: '群組 openid',
},
},
shortcut: {
@@ -503,6 +582,10 @@ export default {
title: '緩存',
subtitle: '管理緩存',
},
scheduler: {
title: '服務',
subtitle: '定時服務',
},
},
workflow: {
components: '動作組件',
@@ -908,6 +991,10 @@ export default {
searching: '正在搜索,請稍候...',
noData: '沒有數據',
noResourceFound: '未搜索到任何資源',
aiRecommend: '智能推薦',
reRecommend: '重新生成推薦',
aiRecommendError: '智能推薦失敗',
refreshSearch: '重新搜尋',
},
browse: {
actor: '演員',
@@ -1144,6 +1231,17 @@ export default {
content: '內容',
refreshing: '正在刷新',
initializing: '正在初始化',
searchPlaceholder: '搜索日誌內容',
allLevels: '全部級別',
followTail: '跟隨最新日誌',
wrapLines: '自動換行',
pauseStream: '暫停日誌流',
resumeStream: '恢復日誌流',
waitingForLogs: '等待日誌輸出...',
paused: '已暫停',
connected: '實時更新中',
lineCount: '顯示 {visible}/{total} 行',
jumpToLatest: '查看最新 ({count})',
},
moduleTest: {
normal: '正常',
@@ -1182,6 +1280,7 @@ export default {
title: '關於 MoviePilot',
softwareVersion: '軟件版本',
frontendVersion: '前端版本',
browserVersion: '瀏覽器緩存版本',
authVersion: '認證資源版本',
indexerVersion: '站點資源版本',
configDir: '配置目錄',
@@ -1201,6 +1300,7 @@ export default {
dataDirectory: '/moviepilot',
expand: '展開',
collapse: '收起',
clearCache: '清除快取',
},
system: {
custom: '自定義',
@@ -1231,21 +1331,95 @@ export default {
aiAgent: '啟用智能助手',
aiAgentEnable: '啟用智能助手',
aiAgentEnableHint: '啟用後可使用智能助手功能需要配置LLM相關參數',
aiAgentSectionTitle: '智能助手配置',
aiAgentSectionDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
llmProvider: 'LLM提供商',
llmProviderHint: '選擇使用的LLM服務提供商',
llmModel: 'LLM模型名稱',
llmModelHint: '指定使用的LLM模型gpt-3.5-turbo、deepseek-chat等',
llmModelHint: '指定使用的LLM模型deepseek-v4-flash、gpt-5.4等',
llmModelResolvedHint: '已根據模型目錄自動回填最大上下文為 {context}K來源{source}',
llmThinking: '思考模式 / 深度',
llmThinkingHint:
'思考深度off/auto/minimal/low/medium/high/max/xhigh不支援的級別會按 provider 能力自動映射到最近值',
llmThinkingLevelOff: '關閉 (off)',
llmThinkingLevelAuto: '自動 (auto)',
llmThinkingLevelMinimal: '最小 (minimal)',
llmThinkingLevelLow: '低 (low)',
llmThinkingLevelMedium: '中 (medium)',
llmThinkingLevelHigh: '高 (high)',
llmThinkingLevelMax: '極高 (max)',
llmThinkingLevelXhigh: '超高 (xhigh)',
llmSupportImageInput: '模型支援圖片輸入',
llmSupportImageInputHint:
'啟用後,消息中的圖片會按多模態圖片發送給 LLM關閉後圖片會作為附件保存到本地並將檔案路徑提供給智能助手處理',
llmSupportAudioInputOutput: '支援音頻輸入輸出',
llmSupportAudioInputOutputHint:
'啟用後,智能助手可以轉寫用戶發送的音頻消息,並在支援的渠道上回覆語音',
llmMaxContextTokens: 'LLM 最大上下文 Token 數量 (K)',
llmMaxContextTokensHint:
'設定 LLM 記錄會話歷史的最大 Token 數量上限(千),超出後將自動修整歷史記錄以節省 Token 消耗及防止超出 LLM 限制',
llmApiKey: 'LLM API密鑰',
llmApiKeyHint: 'LLM服務提供商的API密鑰用於身份驗證',
llmApiKeyPlaceholder: '請輸入API密鑰',
llmBaseUrl: 'LLM基礎URL',
llmBaseUrlHint: 'LLM API的基礎URL地址用於自定義API端點',
llmProviderAuth: '提供商授權',
llmProviderAuthHint: '支援帳號登入授權的提供商,可以直接在這裡完成登入並重用授權狀態。',
llmProviderConnectedAs: '目前已連接:{label}',
llmProviderDisconnect: '斷開授權',
llmProviderDisconnected: '已斷開提供商授權',
llmProviderAuthDialogTitle: '提供商授權',
llmProviderPopupBlocked: '瀏覽器攔截了授權視窗,請手動點擊下方按鈕繼續。',
llmProviderDeviceCode: '設備碼',
llmProviderOpenAuthPage: '開啟授權頁面',
llmProviderCheckAuthStatus: '檢查授權狀態',
aiVoiceApiKey: '音頻 API密鑰',
aiVoiceApiKeyHint: '音頻轉寫與語音合成使用的 API 密鑰,留空時回退到當前 LLM API 密鑰',
aiVoiceBaseUrl: '音頻基礎URL',
aiVoiceBaseUrlHint: '音頻轉寫與語音合成接口的基礎URL留空時回退到當前 LLM 基礎 URL',
aiVoiceSttModel: '音頻轉寫模型',
aiVoiceSttModelHint: '用於將音頻內容轉換為文字的模型名稱',
aiVoiceTtsModel: '語音合成模型',
aiVoiceTtsModelHint: '用於將文字內容轉換為語音的模型名稱',
aiVoiceTtsVoice: '語音音色',
aiVoiceTtsVoiceHint: '語音合成使用的發音人或音色標識',
aiVoiceLanguage: '識別語言',
aiVoiceLanguageHint: '音頻轉寫預設語言,例如 zh、en留空時按後端預設處理',
aiVoiceReplyWithText: '語音回覆附帶文字',
aiVoiceReplyWithTextHint: '發送語音回覆時,同時附帶一份文字內容',
llmTestAction: '測試調用',
llmTestSuccessToast: 'LLM 調用測試成功',
llmTestFailedToast: 'LLM 調用測試失敗',
llmTestFailedToastWithMessage: 'LLM 調用測試失敗:{message}',
aiAgentGlobal: '全局智能助手',
aiAgentGlobalHint: '啟用全局智能助手功能,所有消息對話均使用智能體回答而不用使用/ai命令',
aiAgentJobInterval: '定時喚醒',
aiAgentJobIntervalHint: '設置定時喚醒的檢查間隔,選擇「不啟用」則不執行定時任務',
aiAgentVerbose: '囉嗦模式',
aiAgentVerboseHint: '開啟後會在智能體回覆時顯示工具調用過程',
aiAgentJobIntervalDisabled: '不啟用',
aiAgentJobInterval1h: '1小時',
aiAgentJobInterval3h: '3小時',
aiAgentJobInterval6h: '6小時',
aiAgentJobInterval12h: '12小時',
aiAgentJobInterval24h: '24小時',
aiAgentJobInterval1w: '1週',
aiAgentJobInterval1M: '1個月',
advancedSettings: '高級設置',
advancedSettingsDesc: '系統進階設置,特殊情況下才需要調整',
downloaders: '下載器',
downloadersDesc: '只有默認下載器才會被默認使用。',
aiAgentRetryTransfer: '檔案整理失敗智能接管',
aiAgentRetryTransferHint:
'啟用後當檔案整理失敗時智能助手將自動接管並嘗試重新整理利用AI能力解決識別和匹配問題',
aiRecommendEnabled: '搜索結果智能推薦',
aiRecommendEnabledHint:
'啟用搜索結果智能推薦功能,開啟後將在搜索結果頁面顯示智能推薦按鈕,可根據用戶偏好智能推薦資源',
aiRecommendUserPreference: '用戶偏好',
aiRecommendUserPreferenceHint: '設置智能推薦時的用戶偏好例如4K WEB-DL Dolby Vision',
aiRecommendMaxItems: '智能推薦分析條目上限',
aiRecommendMaxItemsHint:
'限制發送給智能助手進行分析的搜索結果數量,數量越多分析越慢且消耗 Token 越多,建議先手動篩選,篩選出大致範圍後再進行智能推薦',
mediaServers: '媒體服務器',
mediaServersDesc: '所有啟用的媒體服務器都會被使用。',
trimeMedia: '飛牛影視',
@@ -1268,9 +1442,11 @@ export default {
reloading: '正在應用配置...',
qbittorrent: 'Qbittorrent',
transmission: 'Transmission',
rtorrent: 'rTorrent',
emby: 'Emby',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '綠聯影視',
reloadSuccess: '系統配置已生效',
reloadFailed: '重載系統失敗!',
auxAuthEnable: '用戶輔助認證',
@@ -1310,6 +1486,8 @@ export default {
fanartEnableHint: '使用 fanart.tv 的圖片數據',
fanartLang: 'Fanart語言',
fanartLangHint: '設定Fanart圖片的語言偏好多選時按優先級順序排列',
recognizePluginFirst: '優先使用插件識別',
recognizePluginFirstHint: '優先調用插件識別媒體信息,若插件命中則不再調用原生識別',
githubProxy: 'Github加速代理',
githubProxyPlaceholder: '留空表示不使用代理',
githubProxyHint: '使用代理加速Github訪問速度',
@@ -1340,8 +1518,12 @@ export default {
logFileFormatHint: '設置日誌文件的輸出格式,用於自定義日誌的顯示內容',
pluginAutoReload: '插件熱加載',
pluginAutoReloadHint: '修改插件文件後自動重新加載,開發插件時使用',
pluginLocalRepoPaths: '本地插件倉庫路徑',
pluginLocalRepoPathsHint: '本地插件倉庫目錄,多個目錄用英文逗號分隔,支持相對路徑和絕對路徑',
encodingDetectionPerformanceMode: '編碼探測性能模式',
encodingDetectionPerformanceModeHint: '優先提升探測效率,但可能降低編碼探測的準確性',
transferThreads: '文件整理線程數',
transferThreadsHint: '多線程整理文件可以提高速度,但可能增加系統資源佔用',
tokenizedSearch: '分詞搜索',
tokenizedSearchHint: '提升整理歷史記錄搜索精度,但可能增加性能開銷和意外結果',
tmdbLanguage: {
@@ -1418,6 +1600,11 @@ export default {
episodeThumb: '縮略圖',
scrapingSwitchSaveFailed: '刮削開關設定保存失敗:{message}',
scrapingSwitchSaveError: '刮削開關設定保存失敗',
policy: {
skipDesc: '跳過刮削,不生成該文件',
missingOnlyDesc: '僅在缺失時刮削,已存在則保持不變',
overwriteDesc: '始終刮削,已存在則覆蓋',
},
},
site: {
siteSync: '站點同步',
@@ -1512,6 +1699,7 @@ export default {
synologyChat: 'SynologyChat',
voceChat: 'VoceChat',
webPush: 'WebPush',
qq: 'QQ',
custom: '自定義通知',
},
words: {
@@ -1590,6 +1778,7 @@ export default {
storage: '存儲',
storageDesc: '設置本地或網盤存儲',
directory: '目錄',
mediaType: '媒體類型',
directoryDesc: '設置媒體文件整理目錄結構,按先後順序依次匹配。',
organizeAndScrap: '整理 & 刮削',
organizeAndScrapDesc: '設置重命名格式、刮削選項等。',
@@ -1610,6 +1799,25 @@ export default {
storageSaveSuccess: '存儲設置保存成功',
storageSaveFailed: '存儲設置保存失敗!',
},
category: {
title: '分類策略',
subtitle: '配置媒體自動分類規則,按類型、語言、地區等條件自動歸類',
movie: '電影 (Movie)',
tv: '電視劇 (TV)',
name: '分類名稱 (目錄名)',
genre: '內容類型 (Genre)',
language: '語種 (Language)',
languagePlaceholder: '如: zh,cn,en (使用逗號分隔)',
country: '國家/地區 (Country)',
countryPlaceholder: '如: US,CN,JP',
year: '年份 (Year)',
yearPlaceholder: '如: 2023, 2020-2024',
addMovie: '添加電影分類',
addTv: '添加電視劇分類',
saveSuccess: '分類策略保存成功',
loadFailed: '加載分類配置失敗',
saveFailed: '保存失敗: {message}',
},
rule: {
customRules: '自定義規則',
customRulesDesc: '自定義優先級規則項',
@@ -1648,8 +1856,8 @@ export default {
importHasId: '導入失敗發現有規則存在相同ID可能屬於自定義規則',
},
scheduler: {
scheduledTasks: '定時作業',
scheduledTasksDesc: '包含系統內置服務以及插件提供的服務',
title: '定時作業',
subtitle: '包含系統內置服務以及插件提供的服務',
provider: '提供者',
taskName: '任務名稱',
taskStatus: '任務狀態',
@@ -1701,9 +1909,10 @@ export default {
settingsSaveFailed: '訂閱基礎設置保存失敗!',
},
cache: {
title: '緩存',
description: '種子緩存、圖片文件緩存管理',
title: '緩存管理',
subtitle: '管理緩存的站點資源',
totalCount: '總條數',
siteCount: '站點數',
filterByTitle: '按標題篩選',
filterBySite: '按站點篩選',
selectSite: '選擇站點',
@@ -1797,6 +2006,7 @@ export default {
wechat: '微信UserID',
telegram: 'Telegram UserID',
slack: 'Slack UserID',
discord: 'Discord UserID',
vocechat: 'VoceChat UserID',
synologyChat: 'SynologyChat UserID',
webPush: 'WebPush',
@@ -1834,7 +2044,7 @@ export default {
},
searchBar: {
search: '搜索',
searchPlaceholder: '搜索功能、訂閱、設置...',
searchPlaceholder: '搜索電影、劇集以及更多...',
recentSearches: '最近搜索',
noRecentSearches: '沒有最近搜索記錄',
functions: '功能',
@@ -1854,6 +2064,9 @@ export default {
searchInSites: '在站點中搜索種子資源',
relatedResources: '相關資源',
searchTip: '可搜索電影、電視劇、演員、資源等',
emptySearchHint: '輸入關鍵字開始搜索',
escClose: '關閉',
openSearch: '打開搜索',
},
searchSite: {
selectSites: '選擇站點',
@@ -1907,9 +2120,15 @@ export default {
securityWarningMessage: '分享前請確保工作流沒有敏感資訊比如RSS連結中的PassKey等避免產生資訊洩露。',
},
u115Auth: {
loginTitle: '115網盤登錄',
scanQrCode: '請使用微信或115客戶端掃碼',
scanned: '已掃碼,請確認登錄',
loginTitle: '115網盤授權',
openAuthWindow: '打開授權窗口',
authorizing: '請在新窗口中完成授權...',
authSuccess: '授權成功!',
authFailed: '授權失敗或已過期',
authCanceled: '授權已取消,請重試',
urlEmpty: '授權URL為空',
urlFetchFailed: '獲取授權URL失敗',
popupBlocked: '無法打開授權窗口,請檢查瀏覽器彈窗設置',
complete: '完成',
reset: '重置',
},
@@ -2061,6 +2280,10 @@ export default {
repoUrl: '插件倉庫地址',
repoPlaceholder: '格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
repoHint: '多個地址使用换行分隔僅支援Github倉庫',
urlPlaceholder: '輸入插件倉庫地址',
noRepos: '暫無插件倉庫地址',
invalidUrl: '請輸入有效的URL地址',
duplicateUrl: '該地址已存在',
close: '關閉',
save: '儲存',
saveSuccess: '插件倉庫儲存成功',
@@ -2411,6 +2634,7 @@ export default {
settings: '設置',
projectHome: '項目主頁',
updateHistory: '更新說明',
local: '本地',
installToLocal: '安裝到本地',
totalDownloads: '共 {count} 次下載',
viewData: '查看數據',
@@ -2480,6 +2704,7 @@ export default {
noRecentPlugins: '無',
},
profile: {
disableOtpWithPasskeyError: '請先刪除所有通行密鑰後再清除身份驗證器!',
personalInfo: '個人信息',
uploadNewAvatar: '上傳新頭像',
avatarFormatError: '上傳的文件不符合要求,請重新選擇頭像',
@@ -2500,21 +2725,58 @@ export default {
wechatUser: '微信用戶',
telegramUser: 'Telegram用戶',
slackUser: 'Slack用戶',
discordUser: 'Discord用戶',
vocechatUser: 'VoceChat用戶',
synologychatUser: 'SynologyChat用戶',
doubanUser: '豆瓣用戶',
twoFactorAuthentication: '登錄雙重驗證',
setupAuthenticator: '設置身份驗證',
authenticatorManagement: '身份驗證器管理',
authenticatorEnabled: '您已啟用身份驗證器雙重驗證',
clearAuthenticatorTip: '如需設置新的身份驗證器,請先清除當前配置。',
clearAuthenticator: '清除身份驗證器',
enableTwoFactor: '開啟雙重驗證',
disableTwoFactor: '關閉雙重驗證',
setupMfa: '設置雙重驗證',
enableMfa: '開啟雙重驗證',
useAuthenticator: '使用身份驗證器',
usePasskey: '使用通行密鑰',
enabled: '已啟用',
keysCount: '{count} 個密鑰',
passkeyManagement: '通行密鑰管理',
registerNewPasskey: '註冊新通行密鑰',
passkeyDescription: '通行密鑰可以讓您無需密碼即可快速安全地登入。',
passkeyAppDescription:
'通行密鑰是一種更簡單、更安全的登入方式,可以替代密碼進行登入。您可以使用 iCloud 鑰匙圈、Bitwarden 等支援通行密鑰的應用程式或硬體金鑰完成驗證。',
passkeyName: '通行密鑰名稱',
passkeyNamePlaceholder: '例如iPhone、Windows Hello',
registerPasskey: '註冊通行密鑰',
createdAt: '建立於',
lastUsedAt: '最後使用時間',
noPasskeys: '您還沒有註冊任何通行密鑰',
passkeyNameRequired: '請輸入通行密鑰名稱',
passkeyRegisterSuccess: '通行密鑰註冊成功',
passkeyRegisterFailed: '註冊失敗',
passkeyRegisterCancelled: '註冊被取消',
passkeyDeleteSuccess: '通行密鑰已刪除',
passkeyDeleteFailed: '刪除失敗',
deletePasskey: '刪除通行密鑰',
passkeyDomainWarning:
'通行密鑰PassKey的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。',
otpRequiredForPasskey:
'為了安全起見,您必須先啟用 {otp} 驗證碼,然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時,您仍能通過 OTP 碼登入帳戶。',
accessDomain: '訪問域名',
otpAuthenticator: 'OTP 身份驗證器',
otpGenerateFailed: '獲取otp uri失敗{message}',
otpDisableSuccess: '關閉登錄雙重驗證成功!',
otpDisableFailed: '關閉otp失敗{message}',
otpCodeRequired: '請填寫6位驗證碼',
otpEnableSuccess: '開啟登錄雙重驗證成功!',
otpEnableFailed: '開啟otp失敗{message}',
authenticatorApp: '身份驗證',
otpDisableRestrictedByPasskey: '您已註冊通行密鑰,請先刪除所有通行密鑰再關閉 OTP 驗證',
confirmToDisableOtp: '為了安全起見,關閉雙重驗證需要驗證您的登錄密碼。',
confirmToDeletePasskey: '為了安全起見,刪除通行密鑰需要驗證您的登錄密碼。',
authenticatorAppDescription:
'使用Google Authenticator、Microsoft Authenticator、Authy1Password這樣的身份驗證器應用程掃描二維碼。它將為您生成一個6位數的代碼供您在下方輸入。',
'使用 Google Authenticator、Microsoft Authenticator、Authy1Password驗證器應用程式掃描 QR Code取得 6 位數驗證碼。',
secretKeyTip: '如果您在使用二維碼時遇到困難,請在您的應用程序中選擇手動輸入以上代碼。',
enterVerificationCode: '輸入驗證碼以確認開啟雙重驗證',
avatarFormatTip: '允許 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
@@ -2556,10 +2818,19 @@ export default {
loading: '加載中...',
pageSize: '每頁條數',
pageInfo: '{begin} - {end} / {total}',
aiRedoDisabled: '請先在系統設置中啟用 AI 智能助手',
aiRedoQueued: '已提交智能助手整理任務:{title}',
aiRedoFailed: '提交智能助手整理任務失敗',
actions: {
aiRedo: '智能助手整理',
aiRedoPending: '智能助手整理中...',
batchAiRedo: '智能助手批量整理',
redo: '重新整理',
delete: '刪除',
batchRedo: '批量重新整理',
batchDelete: '批量刪除',
},
batchOperationTitle: '批量操作',
progress: {
processing: '處理中',
pleaseWait: '請稍候...',
@@ -2613,11 +2884,14 @@ export default {
name: '名稱',
type: '類型',
customTypeHint: '自定義下載器類型,用於插件等場景',
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port',
enabled: '啟用',
default: '預設',
host: '地址',
apiKey: 'API Key',
username: '用戶名',
password: '密碼',
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key填寫後將優先使用 API Key 登入。',
category: '自動分類管理',
sequentail: '順序下載',
force_resume: '強制繼續',
@@ -2630,6 +2904,11 @@ export default {
hostRequired: '地址不能為空',
usernameRequired: '用戶名不能為空',
passwordRequired: '密碼不能為空',
pathMapping: '路徑映射',
pathMappingRequired: '路徑不能為空',
pathMappingError: '必須以 / 開頭',
storagePath: '存儲路徑',
downloadPath: '下載路徑',
},
filterRule: {
title: '過濾規則',
@@ -2683,6 +2962,15 @@ export default {
password: '密碼',
syncLibraries: '同步媒體庫',
syncLibrariesHint: '只有選中的媒體庫才會被同步',
scanMode: '掃描模式',
scanModeHint: '用於全庫刷新和按庫刷新:新添加和修改 / 補充缺失 / 覆蓋掃描',
verifySsl: '校驗 SSL 憑證',
verifySslHint: '開啟後會校驗 HTTPS 憑證;如使用自簽憑證可關閉',
scanModeOptions: {
newAndModified: '新添加和修改',
supplementMissing: '補充缺失',
fullOverride: '覆蓋掃描',
},
nameExists: '【{name}】已存在,請替換為其他名稱',
},
bangumi: {
@@ -2913,6 +3201,28 @@ export default {
unsupportedDownloaderType: '不支援的下載器類型: {type}',
unsupportedMediaServerType: '不支援的媒體服務器類型: {type}',
unsupportedNotificationType: '不支援的通知類型: {type}',
storageTestFailed: '存儲目錄測試失敗',
downloaderTestFailed: '下載器測試失敗',
downloaderNotSelected: '未選擇下載器',
mediaServerTestFailed: '媒體服務器測試失敗',
mediaServerNotSelected: '未選擇媒體服務器',
notificationTestFailed: '消息通知測試失敗',
notificationNotSelected: '未選擇通知類型',
saveStepFailed: '保存步驟設置失敗',
basicSettingsSaved: '基礎設置保存成功',
saveBasicSettingsFailed: '保存基礎設置失敗',
storageSettingsSaved: '存儲設置保存成功',
saveStorageSettingsFailed: '保存存儲設置失敗',
downloaderSettingsSaved: '下載器設置保存成功',
saveDownloaderSettingsFailed: '保存下載器設置失敗',
mediaServerSettingsSaved: '媒體服務器設置保存成功',
saveMediaServerSettingsFailed: '保存媒體服務器設置失敗',
notificationSettingsSaved: '通知設置保存成功',
saveNotificationSettingsFailed: '保存通知設置失敗',
saveSiteAuthSettingsFailed: '保存用戶站點認證設置失敗:{message}',
saveAgentSettingsFailed: '保存智能助手設置失敗',
preferenceSettingsSaved: '偏好設置保存成功',
savePreferenceSettingsFailed: '保存偏好設置失敗',
passwordUpdateSuccess: '密碼更新成功',
userCreateSuccess: '使用者建立成功',
passwordUpdateFailed: '密碼更新失敗',
@@ -2932,6 +3242,16 @@ export default {
confirmPasswordHint: '確認新密碼',
apiTokenRequired: 'API Token 不能為空',
},
siteAuth: {
title: '用戶認證',
description: '配置用戶站點認證與輔助認證',
info: '用戶站點認證說明',
infoDesc: '完成站點認證後可解鎖站點能力與部分插件權限。此步驟可選,後續也可在個人選單中繼續配置。',
selectSiteHint: '選擇一個支援認證的站點,並填寫該站點要求的認證參數',
submitHint: '點擊下一步時將立即向認證站點發起校驗,認證成功後會保存當前參數。',
siteConfigNotExist: '認證站點配置不存在',
fieldRequired: '請輸入{name}',
},
storage: {
title: '儲存',
description: '設定下載目錄和媒體庫目錄',
@@ -2964,7 +3284,7 @@ export default {
title: '媒體伺服器',
description: '設定媒體伺服器',
info: '媒體伺服器設定說明',
infoDesc: '設定媒體伺服器用於媒體庫管理可選擇Emby、JellyfinPlex',
infoDesc: '設定媒體伺服器用於媒體庫管理可選擇Emby、JellyfinPlex、飛牛影視或綠聯影視',
type: '媒體伺服器類型',
typeHint: '選擇要使用的媒體伺服器類型',
name: '伺服器名稱',
@@ -2995,6 +3315,18 @@ export default {
senderPassword: '發送密碼',
receiverEmail: '接收信箱',
},
agent: {
title: '智能助手',
description: '配置 Agent 助手與 LLM 參數',
info: '智能助手配置說明',
infoDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
providerRequired: 'LLM 提供商不能為空',
apiKeyRequired: 'LLM API 密鑰不能為空',
authOrApiKeyRequired: '請填寫 LLM API 密鑰或先完成提供商授權',
modelRequired: 'LLM 模型名稱不能為空',
maxContextTokensRequired: 'LLM 最大上下文 Token 數量必須大於 0',
recommendMaxItemsRequired: '智能推薦分析條目上限必須大於 0',
},
preferences: {
title: '資源偏好',
description: '設定資源下載偏好',

View File

@@ -1,15 +1,16 @@
<script setup lang="ts">
import { NavMenu } from '@/@layouts/types'
import { getNavMenus } from '@/router/i18n-menu'
import { useUserStore } from '@/stores'
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
import { filterMenusByPermission } from '@/utils/permission'
// 国际化
const { t } = useI18n()
// 从 Store 中获取用户信息
const userStore = useUserStore()
const pluginSidebarNavStore = usePluginSidebarNavStore()
// 获取用户权限信息
const userPermissions = computed(() => ({
@@ -20,14 +21,22 @@ const userPermissions = computed(() => ({
// 应用分组以header分组
const appGroups = ref<Record<string, NavMenu[]>>({})
// 根据header属性对应用进行分类
function categorizeApps() {
// 获取所有菜单并根据权限过滤
const allMenus = getNavMenus()
// 根据header属性对应用进行分类(含插件侧栏项,与桌面端侧栏一致)
async function categorizeApps() {
const allMenus = getNavMenus(t)
const filteredMenus = filterMenusByPermission(allMenus, userPermissions.value)
const menus = filteredMenus.filter((item: NavMenu) => !item.footer)
let menus = filteredMenus.filter((item: NavMenu) => !item.footer)
await pluginSidebarNavStore.ensureSidebarNav()
if (pluginSidebarNavStore.items.length > 0) {
const pluginNavMenus = filterPluginSidebarNavEntries(
pluginSidebarNavStore.items,
t,
userPermissions.value,
).map(e => e.navMenu)
menus = [...menus, ...pluginNavMenus]
}
// 按header属性分组
const groupedMenus: Record<string, NavMenu[]> = {}
menus.forEach(menu => {
@@ -38,11 +47,9 @@ function categorizeApps() {
groupedMenus[header].push(menu)
})
// 将分组结果赋值给响应式变量
appGroups.value = groupedMenus
}
// 页面加载时对应用进行分类
onMounted(() => {
categorizeApps()
})
@@ -60,7 +67,7 @@ onMounted(() => {
<VList lines="one" class="settings-list">
<VListItem
v-for="(app, appIndex) in apps"
:key="appIndex"
:key="`${header}-${appIndex}-${String(app.to)}`"
:to="app.to || ''"
color="primary"
class="settings-list-item"

View File

@@ -376,16 +376,15 @@ onDeactivated(() => {
<!-- 底部操作按钮只在非移动设备上显示 -->
<Teleport to="body" v-if="route.path === '/dashboard'">
<VFab
v-if="!appMode"
icon="mdi-view-dashboard-edit"
location="bottom"
size="x-large"
fixed
app
appear
@click="dialog = true"
/>
<div v-if="!appMode" class="compact-fab-stack">
<VFab
icon="mdi-view-dashboard-edit"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="dialog = true"
/>
</div>
</Teleport>
<!-- 弹窗根据配置生成选项 -->

View File

@@ -58,7 +58,7 @@ function initializeColors() {
// 初始化发现标签
function initDiscoverTabs() {
const tabs = getDiscoverTabs()
const tabs = getDiscoverTabs(t)
for (const tab of tabs) {
discoverTabs.value.push({
name: tab.name,

View File

@@ -1,18 +1,18 @@
<script setup lang="ts">
import { debounce } from 'lodash-es'
import { VForm } from 'vuetify/components/VForm'
import { useAuthStore, useUserStore } from '@/stores'
import { useAuthStore, useUserStore, useGlobalSettingsStore } from '@/stores'
import { authState, userState } from '@/stores/types'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import router from '@/router'
import logo from '@images/logo.png'
import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
import { bufferToBase64Url, base64UrlToUint8Array, urlBase64ToUint8Array } from '@/@core/utils/navigator'
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
import { useTheme } from 'vuetify'
import { getNavMenus } from '@/router/i18n-menu'
import { filterMenusByPermission } from '@/utils/permission'
import type { ApiResponse } from '@/api/types'
// 国际化
const { t } = useI18n()
@@ -20,9 +20,11 @@ const { t } = useI18n()
const authStore = useAuthStore()
//用户 Store
const userStore = useUserStore()
// 全局设置 Store
const globalSettingsStore = useGlobalSettingsStore()
// 获取有权限的菜单
const navMenus = getNavMenus()
const navMenus = computed(() => getNavMenus(t))
// 表单
const form = ref({
@@ -43,6 +45,12 @@ const errorMessage = ref('')
// 是否开启双重验证
const isOTP = ref(false)
// 二次验证对话框
const mfaDialog = ref(false)
// MFA PassKey loading
const mfaPasskeyLoading = ref(false)
// 用户名称输入框
const usernameInput = ref()
@@ -66,6 +74,223 @@ const locales = Object.values(SUPPORTED_LOCALES)
// 登录按钮 loading
const loading = ref(false)
// PassKey 登录按钮 loading
const passkeyLoading = ref(false)
// Conditional UI 的 AbortController
let conditionalAbortController: AbortController | null = null
// 手动模式的 AbortController用于防止重复点击
let manualAbortController: AbortController | null = null
// 标记当前是否有手动模式的 PassKey 请求正在进行
let isManualPassKeyActive = false
// PassKey 认证核心函数 - 处理 WebAuthn 认证流程
interface PassKeyAuthOptions {
username?: string // 可选的用户名,用于 MFA 场景
isConditional?: boolean // 是否为 Conditional UI 模式
signal?: AbortSignal // AbortController 信号
}
// PassKey API 响应类型
interface PassKeyStartResponse {
options: string // JSON 字符串
challenge: string
}
interface PassKeyFinishResponse {
access_token: string
super_user: boolean
user_id: number
user_name: string
avatar: string
level: number
permissions: Record<string, boolean>
wizard: boolean
}
async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promise<PassKeyFinishResponse> {
const { username, isConditional = false, signal } = options
// 1. 开始认证流程
const startResponse = (await api.post(
'/mfa/passkey/authenticate/start',
username ? { username } : {},
)) as ApiResponse<PassKeyStartResponse>
if (!startResponse.success) {
throw new Error(startResponse.message || 'PassKey start failed')
}
const { options: optionsStr, challenge } = startResponse.data
const publicKeyOptions = JSON.parse(optionsStr)
// 2. 调用WebAuthn API
const credentialRequestOptions: CredentialRequestOptions = {
publicKey: {
...publicKeyOptions,
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToUint8Array(cred.id),
})),
},
}
// 如果是 Conditional UI 模式,添加 mediation 和 signal
if (isConditional) {
credentialRequestOptions.mediation = 'conditional'
if (signal) {
credentialRequestOptions.signal = signal
}
}
const credential = await navigator.credentials.get(credentialRequestOptions)
// Conditional UI 模式下,用户选择通行密钥后才显示 loading
if (isConditional) {
passkeyLoading.value = true
}
if (!credential) {
throw new Error('No credential selected')
}
// 3. 转换credential为可传输格式
const publicKeyCredential = credential as PublicKeyCredential
const assertionResponse = publicKeyCredential.response as AuthenticatorAssertionResponse
const credentialJSON = {
id: publicKeyCredential.id,
rawId: bufferToBase64Url(publicKeyCredential.rawId),
type: publicKeyCredential.type,
response: {
authenticatorData: bufferToBase64Url(assertionResponse.authenticatorData),
clientDataJSON: bufferToBase64Url(assertionResponse.clientDataJSON),
signature: bufferToBase64Url(assertionResponse.signature),
userHandle: assertionResponse.userHandle ? bufferToBase64Url(assertionResponse.userHandle) : null,
},
}
// 4. 完成认证
const finishResponse = (await api.post('/mfa/passkey/authenticate/finish', {
credential: credentialJSON,
challenge: challenge,
})) as PassKeyFinishResponse
if (!finishResponse || !finishResponse.access_token) {
throw new Error('PassKey finish failed: No access token')
}
return finishResponse
}
// 统一处理 PassKey 认证流程
async function handlePassKeyAuth(
authOptions: PassKeyAuthOptions,
setLoading: (loading: boolean) => void,
onSuccess: (response: PassKeyFinishResponse) => Promise<void>,
) {
const { isConditional = false } = authOptions
errorMessage.value = ''
// 检查浏览器环境
if (!window.PublicKeyCredential) {
if (!isConditional) {
if (!window.isSecureContext) {
errorMessage.value = t('login.passkeySecureContextRequired')
} else {
errorMessage.value = t('login.passkeyNotSupported')
}
}
return
}
// 如果是手动触发(非 Conditional UI)
if (!isConditional) {
// 取消之前的 Conditional UI 请求
if (conditionalAbortController) {
conditionalAbortController.abort()
conditionalAbortController = null
}
// 取消之前的手动请求(防止重复点击)
if (manualAbortController) {
manualAbortController.abort()
}
// 创建新的 AbortController
manualAbortController = new AbortController()
// 标记手动请求为活跃状态,并立即设置 loading
isManualPassKeyActive = true
setLoading(true)
}
try {
const finishResponse = await authenticateWithPassKey({
...authOptions,
signal:
isConditional && conditionalAbortController
? conditionalAbortController.signal
: !isConditional && manualAbortController
? manualAbortController.signal
: undefined,
})
await onSuccess(finishResponse)
} catch (error: any) {
// Conditional UI 模式下:
// 1. 如果 loading 为 false说明错误发生在用户选择密钥之前如初始化失败、用户取消等此时应静默
// 2. 如果是 AbortError始终静默
if (isConditional && (!passkeyLoading.value || error.name === 'AbortError')) {
console.warn('[PassKey] Conditional UI silenced error:', error)
return
}
// 手动模式下的 AbortError 也应该静默(用户重复点击导致)
if (!isConditional && error.name === 'AbortError') {
console.warn('[PassKey] Manual request aborted (likely due to rapid clicking):', error)
return
}
// 设置错误信息
if (error.name === 'NotAllowedError') {
errorMessage.value = t('login.passkeyAuthCanceled')
} else if (error.name === 'NotSupportedError') {
errorMessage.value = t('login.passkeyNotSupported')
} else if (error.message?.includes('start failed')) {
errorMessage.value = t('login.passkeyLoginStartFailed')
} else {
errorMessage.value = t('login.authFailure')
}
} finally {
// 清除 loading 状态
if (!isConditional) {
// 手动模式:始终清除,并取消手动活跃标记
isManualPassKeyActive = false
setLoading(false)
manualAbortController = null
} else {
// Conditional UI 模式:只有在没有手动请求活跃时才清除
if (!isManualPassKeyActive && passkeyLoading.value) {
passkeyLoading.value = false
}
}
}
}
// 使用PassKey登录 (支持 Conditional UI)
async function loginWithPassKey(isConditional = false) {
await handlePassKeyAuth(
{ isConditional },
val => (passkeyLoading.value = val),
async response => {
await handleLoginSuccess(response)
},
)
}
// 切换语言
async function switchLanguage(locale: SupportedLocale) {
await setI18nLanguage(locale)
@@ -73,23 +298,6 @@ async function switchLanguage(locale: SupportedLocale) {
langMenu.value = false
}
// 查询是否开启双重验证
const fetchOTP = debounce(async () => {
const userid = usernameInput.value?.value
if (!userid) {
isOTP.value = false
return
}
api
.get(`/user/otp/${userid}`)
.then((response: any) => {
isOTP.value = response.success
})
.catch((error: any) => {
console.log(error)
})
}, 500)
// 订阅推送通知
async function subscribeForPushNotifications() {
if ('serviceWorker' in navigator && 'PushManager' in window) {
@@ -110,7 +318,7 @@ async function subscribeForPushNotifications() {
try {
await api.post('/message/webpush/subscribe', subscription)
} catch (e) {
console.log(e)
console.error(e)
}
}
}
@@ -132,84 +340,128 @@ async function afterLogin(superuser: boolean, userPayload: userState, filteredMe
// 订阅推送通知
if (superuser) await subscribeForPushNotifications()
// 登录按钮 loading
loading.value = false
}
// 处理登录成功
async function handleLoginSuccess(response: any) {
const userPayload: userState = {
superUser: response.super_user,
userID: response.user_id,
userName: response.user_name,
avatar: response.avatar,
level: response.level,
permissions: response.permissions,
wizard: response.wizard,
}
const userPermissions = {
is_superuser: userPayload.superUser,
...userPayload.permissions,
}
const filteredMenus = filterMenusByPermission(navMenus.value, userPermissions)
if (filteredMenus.length === 0) {
errorMessage.value = t('login.noPermission')
return
}
const authPayLoad: authState = {
token: response.access_token,
remember: form.value.remember,
}
authStore.login(authPayLoad)
userStore.loginUser(userPayload)
// 登录后加载用户相关的全局设置
await globalSettingsStore.loadUserSettings()
await afterLogin(userPayload.superUser, userPayload, filteredMenus)
}
// 登录获取token事件
function login() {
async function login() {
errorMessage.value = ''
// 进行表单校验
if (!form.value.username || !form.value.password || (isOTP.value && !form.value.otp_password)) {
if (!form.value.username || !form.value.password) {
return
}
// 登录按钮 loading
loading.value = true
// 用户名密码
const formData = new FormData()
try {
// 用户名密码
const formData = new FormData()
formData.append('username', form.value.username)
formData.append('password', form.value.password)
formData.append('otp_password', form.value.otp_password)
formData.append('username', form.value.username)
formData.append('password', form.value.password)
formData.append('otp_password', form.value.otp_password)
// 请求token
api
.post('/login/access-token', formData, {
// 请求token
const response: any = await api.post('/login/access-token', formData, {
headers: {
Accept: 'application/json', // 设置 Accept 类型
},
})
.then((response: any) => {
const userPayload: userState = {
superUser: response.super_user,
userID: response.user_id,
userName: response.user_name,
avatar: response.avatar,
level: response.level,
permissions: response.permissions,
wizard: response.widzard,
}
// 在保存用户信息之前检查权限
const userPermissions = {
is_superuser: userPayload.superUser,
...userPayload.permissions,
}
await handleLoginSuccess(response)
} catch (error: any) {
// 登录失败,显示错误提示
if (!error.response) {
errorMessage.value = t('login.networkError')
return
}
const filteredMenus = filterMenusByPermission(navMenus, userPermissions)
// 如果用户没有任何可用菜单,拒绝登录
if (filteredMenus.length === 0) {
// 显示错误信息
errorMessage.value = t('login.noPermission')
loading.value = false
return
}
switch (error.response.status) {
case 401:
// 401错误可能是需要MFA或者认证失败
// 检查响应头是否有MFA要求标识
if (error.response.headers?.['x-mfa-required'] === 'true' && !form.value.otp_password) {
// 需要MFA验证弹出对话框
isOTP.value = true
mfaDialog.value = true
return
}
// 不需要MFA或已填写OTP但认证失败
errorMessage.value = t('login.authFailure')
// 认证失败后清空OTP密码防止下次点击不弹出对话框
form.value.otp_password = ''
break
case 403:
errorMessage.value = t('login.permissionDenied')
break
case 500:
errorMessage.value = t('login.serverError')
break
default:
errorMessage.value = `${t('login.authFailure')} (Status: ${error.response.status})`
}
} finally {
loading.value = false
}
}
// 权限检查通过,保存用户信息
const authPayLoad: authState = {
token: response.access_token,
remember: form.value.remember,
}
// 使用OTP码继续登录
function loginWithOTP() {
mfaDialog.value = false
login()
}
authStore.login(authPayLoad)
userStore.loginUser(userPayload)
// 使用PassKey进行MFA验证
async function verifyWithPassKey() {
if (!form.value.username) return
// 登录后处理
afterLogin(userPayload.superUser, userPayload, filteredMenus)
})
.catch((error: any) => {
// 登录失败,显示错误提示
if (!error.response) errorMessage.value = t('login.networkError')
else if (error.response.status === 401) errorMessage.value = t('login.authFailure')
else if (error.response.status === 403) errorMessage.value = t('login.permissionDenied')
else if (error.response.status === 500) errorMessage.value = t('login.serverError')
else errorMessage.value = `${t('login.loginFailed')} ${error.response.status}${t('login.checkCredentials')}`
// 登录按钮 loading
loading.value = false
})
await handlePassKeyAuth(
{ username: form.value.username },
val => (mfaPasskeyLoading.value = val),
async response => {
// 关闭MFA对话框
mfaDialog.value = false
await handleLoginSuccess(response)
},
)
}
// 自动登录
@@ -221,6 +473,51 @@ onMounted(async () => {
// 如果token存在且保持登录状态为true则跳转到首页
if (token && remember) {
router.push('/')
return
}
// 初始化 Conditional UI 的 PassKey 自动填充
await initConditionalPasskey()
})
// 初始化 Conditional UI 的 PassKey 自动填充
async function initConditionalPasskey() {
// 检查浏览器是否支持 WebAuthn 和 Conditional UI
if (!window.PublicKeyCredential || !PublicKeyCredential.isConditionalMediationAvailable) {
return
}
try {
const available = await PublicKeyCredential.isConditionalMediationAvailable()
if (!available) {
return
}
// 安全防御:如果已存在 controller先 abort 掉旧的,防止重复调用产生幽灵请求
if (conditionalAbortController) {
conditionalAbortController.abort()
conditionalAbortController = null
}
// 创建 AbortController 用于取消请求
conditionalAbortController = new AbortController()
// 启动 Conditional UI 模式的 PassKey 认证
await loginWithPassKey(true)
} catch (error) {
console.error('[PassKey] Failed to initialize Conditional UI:', error)
}
}
// 组件卸载时清理
onUnmounted(() => {
if (conditionalAbortController) {
conditionalAbortController.abort()
conditionalAbortController = null
}
if (manualAbortController) {
manualAbortController.abort()
manualAbortController = null
}
})
</script>
@@ -229,9 +526,9 @@ onMounted(async () => {
<!-- 登录页面容器 -->
<div class="relative flex min-h-screen flex-col items-center justify-center">
<!-- 登录表单 -->
<div class="auth-wrapper d-flex align-center justify-center">
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
<VCard
class="auth-card px-7 py-3 w-full h-full"
class="auth-card px-7 pt-3 w-full h-full"
:class="{ 'glass-effect': !isTransparentTheme }"
max-width="24rem"
border
@@ -242,7 +539,7 @@ onMounted(async () => {
<VImg :src="logo" width="64" height="64" />
</div>
</template>
<VCardTitle class="font-weight-bold text-2xl text-uppercase"> MoviePilot </VCardTitle>
<VCardTitle class="font-weight-bold text-3xl text-uppercase"> MoviePilot </VCardTitle>
<!-- 语言切换按钮 -->
<template #append>
@@ -274,7 +571,7 @@ onMounted(async () => {
</template>
</VCardItem>
<VCardText>
<VForm ref="refForm" autocomplete="on" @submit.prevent="() => {}">
<VForm ref="refForm" autocomplete="on" @submit.prevent="login">
<VRow>
<!-- username -->
<VCol cols="12">
@@ -284,9 +581,10 @@ onMounted(async () => {
:label="t('login.username')"
type="text"
name="username"
id="username"
autocomplete="username"
:rules="[requiredValidator]"
@input="fetchOTP"
hide-details
/>
</VCol>
<!-- password -->
@@ -295,15 +593,16 @@ onMounted(async () => {
v-model="form.password"
:label="t('login.password')"
:type="isPasswordVisible ? 'text' : 'password'"
name="current-password"
name="password"
id="password"
autocomplete="current-password"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
hide-details
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12">
<VTextField v-if="isOTP" v-model="form.otp_password" :label="t('login.otpCode')" type="input" />
<VCol cols="12" class="py-0">
<!-- remember me checkbox -->
<div class="d-flex align-center justify-space-between flex-wrap">
<VCheckbox v-model="form.remember" :label="t('login.stayLoggedIn')" required />
@@ -311,9 +610,27 @@ onMounted(async () => {
</VCol>
<VCol cols="12">
<!-- login button -->
<VBtn block type="submit" @click="login" prepend-icon="mdi-login" :loading="loading">
<VBtn block type="submit" prepend-icon="mdi-login" :loading="loading" size="large">
{{ t('login.login') }}
</VBtn>
<!-- or divider -->
<div class="or-divider my-4">
<span class="or-divider-text">{{ t('login.orDivider') }}</span>
</div>
<!-- passkey login button -->
<VBtn
block
variant="outlined"
color="success"
class="passkey-btn"
prepend-icon="material-symbols:passkey"
:loading="passkeyLoading"
@click="loginWithPassKey(false)"
>
{{ t('login.loginWithPasskey') }}
</VBtn>
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</VAlert>
@@ -323,6 +640,64 @@ onMounted(async () => {
</VCardText>
</VCard>
</div>
<!-- MFA二次验证对话框 -->
<VDialog v-model="mfaDialog" max-width="400" persistent>
<VCard>
<VCardTitle class="text-h5 text-center mt-4 pb-2">{{ t('login.secondaryVerification') }}</VCardTitle>
<VCardText class="pt-0">
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
<!-- TOTP验证 -->
<VCard variant="tonal" class="mb-3">
<VCardText>
<VForm @submit.prevent="loginWithOTP">
<VTextField
v-model="form.otp_password"
:label="t('login.otpCode')"
:placeholder="t('login.otpPlaceholder')"
type="text"
name="otp"
id="otp"
autocomplete="one-time-code"
inputmode="numeric"
prepend-inner-icon="mdi-shield-key"
class="mb-2"
/>
<VBtn block type="submit" color="primary" :disabled="!form.otp_password">
{{ t('login.loginWithOtp') }}
</VBtn>
</VForm>
</VCardText>
</VCard>
<!-- PassKey验证 -->
<VCard variant="tonal">
<VCardText>
<p class="text-body-2 mb-2">{{ t('login.orUsePasskey') }}</p>
<VBtn
block
variant="tonal"
color="success"
class="passkey-btn"
prepend-icon="material-symbols:passkey"
:loading="mfaPasskeyLoading"
@click="verifyWithPassKey"
>
{{ t('login.verifyWithPasskey') }}
</VBtn>
</VCardText>
</VCard>
<!-- 错误提示 -->
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</VAlert>
<VBtn block variant="text" class="mt-4" @click="mfaDialog = false">{{ t('common.cancel') }}</VBtn>
</VCardText>
</VCard>
</VDialog>
</div>
</template>
@@ -348,4 +723,31 @@ onMounted(async () => {
backdrop-filter: blur(10px) !important;
background: rgba(var(--v-theme-surface), 0.7) !important;
}
.or-divider {
position: relative;
display: flex;
align-items: center;
text-align: center;
&::before,
&::after {
flex: 1;
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
content: '';
}
.or-divider-text {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8125rem;
padding-inline: 12px;
white-space: nowrap;
}
}
.v-theme--light {
.passkey-btn.v-btn--variant-outlined {
color: rgb(86, 170, 0) !important;
}
}
</style>

50
src/pages/plugin-app.vue Normal file
View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { Component } from 'vue'
import api from '@/api'
import { loadRemoteAppPageComponent } from '@/utils/federationLoader'
const route = useRoute()
const pluginId = computed(() => route.params.pluginId as string)
const navKey = computed(() => (route.params.navKey as string) || 'main')
const RemoteView = shallowRef<Component | null>(null)
const loadError = ref(false)
watch(
[pluginId, navKey],
async ([pid, nk]) => {
loadError.value = false
if (!pid) {
RemoteView.value = null
return
}
try {
RemoteView.value = (await loadRemoteAppPageComponent(pid, nk)) as Component
} catch (e) {
console.error(e)
RemoteView.value = null
loadError.value = true
}
},
{ immediate: true },
)
</script>
<template>
<div class="plugin-app-page">
<VAlert v-if="loadError" type="error" class="ma-4" title="组件加载错误">
无法加载插件全页组件多入口时请暴露 AppPage AppPage{Pascal}见文档并确认插件已启用
</VAlert>
<VSkeletonLoader v-else-if="!RemoteView" class="ma-4" type="article, article, article" />
<component
v-else
:is="RemoteView"
:key="`${pluginId}-${navKey}`"
:api="api"
:nav-key="navKey"
:plugin-id="pluginId"
@action="() => {}"
/>
</div>
</template>

View File

@@ -231,12 +231,23 @@ registerHeaderTab({
],
})
// 页面是否准备就绪
const isReady = ref(false)
// 定时器
let timer: ReturnType<typeof setTimeout>
onBeforeMount(async () => {
await loadConfig()
initializeColors()
})
onMounted(async () => {
// 延迟渲染内容,避免阻塞页面切换动画
timer = setTimeout(() => {
isReady.value = true
}, 400)
await loadExtraRecommendSources()
// 为新增的数据源也生成颜色
extraRecommendSources.value.forEach(source => {
@@ -246,6 +257,10 @@ onMounted(async () => {
})
})
onUnmounted(() => {
if (timer) clearTimeout(timer)
})
onActivated(async () => {
await loadExtraRecommendSources()
})
@@ -256,10 +271,16 @@ onActivated(async () => {
<!-- 滚动内容区域 -->
<div class="recommend-content">
<TransitionGroup name="fade">
<MediaCardSlideView v-for="item in filteredViews" :key="item.title" v-bind="item" class="content-group" />
<MediaCardSlideView
v-for="item in filteredViews"
:key="item.title"
v-bind="item"
:ready="isReady"
class="content-group"
/>
</TransitionGroup>
<div v-if="filteredViews.length === 0" class="empty-category">
<div v-if="isReady && filteredViews.length === 0" class="empty-category">
<VIcon icon="mdi-alert-circle-outline" size="large" class="empty-icon" />
<p class="empty-text">{{ t('recommend.noCategoryContent') }}</p>
<VBtn color="primary" variant="tonal" size="small" @click="dialog = true">

File diff suppressed because it is too large Load Diff

View File

@@ -6,16 +6,16 @@ import AccountSettingSite from '@/views/setting/AccountSettingSite.vue'
import AccountSettingSearch from '@/views/setting/AccountSettingSearch.vue'
import AccountSettingSubscribe from '@/views/setting/AccountSettingSubscribe.vue'
import AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'
import AccountSettingService from '@/views/setting/AccountSettingService.vue'
import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
import { getSettingTabs } from '@/router/i18n-menu'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
const { t } = useI18n()
const route = useRoute()
const activeTab = ref((route.query.tab as string) || '')
const settingTabs = computed(() => getSettingTabs())
const settingTabs = computed(() => getSettingTabs(t))
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
@@ -92,15 +92,6 @@ onMounted(() => {
</transition>
</VWindowItem>
<!-- 服务 -->
<VWindowItem value="scheduler">
<transition name="fade-slide" appear>
<div>
<AccountSettingService />
</div>
</transition>
</VWindowItem>
<!-- 通知 -->
<VWindowItem value="notification">
<transition name="fade-slide" appear>

View File

@@ -4,10 +4,12 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useSetupWizard } from '@/composables/useSetupWizard'
import BasicSettingsStep from '@/views/setup/BasicSettingsStep.vue'
import SiteAuthSettingsStep from '@/views/setup/SiteAuthSettingsStep.vue'
import StorageSettingsStep from '@/views/setup/StorageSettingsStep.vue'
import DownloaderSettingsStep from '@/views/setup/DownloaderSettingsStep.vue'
import MediaServerSettingsStep from '@/views/setup/MediaServerSettingsStep.vue'
import NotificationSettingsStep from '@/views/setup/NotificationSettingsStep.vue'
import AgentSettingsStep from '@/views/setup/AgentSettingsStep.vue'
import PreferencesSettingsStep from '@/views/setup/PreferencesSettingsStep.vue'
import ConnectivityTest from '@/views/setup/ConnectivityTest.vue'
import { useDisplay } from 'vuetify'
@@ -101,28 +103,38 @@ onMounted(async () => {
<BasicSettingsStep />
</VStepperWindowItem>
<!-- 步骤2存储目录 -->
<!-- 步骤2用户认证 -->
<VStepperWindowItem :value="2">
<SiteAuthSettingsStep />
</VStepperWindowItem>
<!-- 步骤3存储目录 -->
<VStepperWindowItem :value="3">
<StorageSettingsStep />
</VStepperWindowItem>
<!-- 步骤3下载器 -->
<VStepperWindowItem :value="3">
<!-- 步骤4下载器 -->
<VStepperWindowItem :value="4">
<DownloaderSettingsStep />
</VStepperWindowItem>
<!-- 步骤4媒体服务器 -->
<VStepperWindowItem :value="4">
<!-- 步骤5媒体服务器 -->
<VStepperWindowItem :value="5">
<MediaServerSettingsStep />
</VStepperWindowItem>
<!-- 步骤5通知 -->
<VStepperWindowItem :value="5">
<!-- 步骤6通知 -->
<VStepperWindowItem :value="6">
<NotificationSettingsStep />
</VStepperWindowItem>
<!-- 步骤6资源偏好 -->
<VStepperWindowItem :value="6">
<!-- 步骤7智能助手 -->
<VStepperWindowItem :value="7">
<AgentSettingsStep />
</VStepperWindowItem>
<!-- 步骤8资源偏好 -->
<VStepperWindowItem :value="8">
<PreferencesSettingsStep />
</VStepperWindowItem>
</VStepperWindow>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { debounce } from 'lodash-es'
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
@@ -6,6 +7,9 @@ import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
import SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { useUserStore } from '@/stores'
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
@@ -13,18 +17,20 @@ import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
const { appMode } = usePWA()
const subType = route.meta.subType?.toString()
const subId = ref(route.query.id as string)
const activeTab = ref((route.query.tab as string) || '')
const shareViewKey = ref(0)
const subscribeListViewRef = ref<InstanceType<typeof SubscribeListView> | null>(null)
// 获取标签页
const subscribeTabs = computed(() => {
if (subType === '电影') {
return getSubscribeMovieTabs()
return getSubscribeMovieTabs(t)
} else {
return getSubscribeTvTabs()
return getSubscribeTvTabs(t)
}
})
@@ -48,17 +54,12 @@ const subscribeStatusFilter = ref<string | null>(null)
// 分享搜索词
const shareKeyword = ref('')
// 搜索分享
const searchShares = () => {
searchShareDialog.value = false
shareViewKey.value++
}
const shareKeywordInput = ref('')
// 筛选选项
const filterOptions = computed(() => {
const baseOptions = [
{ value: 'all', label: t('common.all'), icon: 'mdi-format-list-bulleted' },
{ value: 'all', label: t('common.all'), icon: 'mdi-filter-multiple-outline' },
{ value: 'best_version', label: t('subscribe.bestVersion'), icon: 'mdi-refresh', color: 'warning' },
]
@@ -82,17 +83,123 @@ const filterOptions = computed(() => {
]
})
// 计算筛选按钮颜色
// 当前选中的筛选选项
const currentFilter = computed(() => {
return filterOptions.value.find(option => option.value === (subscribeStatusFilter.value || 'all'))
})
// 计算筛选按钮颜色 - 有名称筛选或状态筛选时高亮
const filterButtonColor = computed(() => {
if (subscribeFilter.value || (subscribeStatusFilter.value && subscribeStatusFilter.value !== 'all')) {
return 'primary'
return currentFilter.value?.color || 'primary'
}
return 'gray'
})
// 选择筛选选项
function selectFilter(value: string) {
subscribeStatusFilter.value = value
filterSubscribeDialog.value = false
}
// VMenu activator选择器
const filterActivator = computed(() => '[data-menu-activator="filter-btn"]')
const searchActivator = computed(() => '[data-menu-activator="search-btn"]')
const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub')
const showSubscribeHistoryAction = computed(() => showDefaultRuleAction.value && userStore.superUser)
const showShareStatisticsAction = computed(() => activeTab.value === 'share')
function openDefaultRuleDialog() {
subscribeEditDialog.value = true
}
function openSubscribeHistoryDialog() {
subscribeListViewRef.value?.openHistoryDialog()
}
function openShareStatisticsDialog() {
shareStatisticsDialog.value = true
}
const shareKeywordUpdater = debounce((keyword: string) => {
shareKeyword.value = keyword.trim()
}, 300)
watch(shareKeywordInput, newKeyword => {
shareKeywordUpdater(newKeyword || '')
})
watch(activeTab, newTab => {
if (newTab !== 'share') {
searchShareDialog.value = false
}
})
onUnmounted(() => {
shareKeywordUpdater.cancel()
})
const subscribeDynamicMenuItems = computed(() => {
if (!appMode.value) return undefined
if (activeTab.value === 'mysub') {
const items: Array<{
titleKey: string
titleParams?: Record<string, unknown>
icon: string
action: () => void
}> = []
if (showSubscribeHistoryAction.value) {
items.push({
titleKey: 'dialog.subscribeHistory.title',
titleParams: { type: subType },
icon: 'mdi-history',
action: openSubscribeHistoryDialog,
})
}
items.push({
titleKey: 'dialog.subscribeEdit.titleDefault',
icon: 'mdi-clipboard-edit-outline',
action: openDefaultRuleDialog,
})
return items.length > 1 ? items : undefined
}
return undefined
})
const subscribeDynamicIcon = computed(() => {
if (showShareStatisticsAction.value) return 'mdi-chart-line'
if (showSubscribeHistoryAction.value) return 'mdi-history'
return 'mdi-clipboard-edit-outline'
})
function handleSubscribeDynamicAction() {
if (showShareStatisticsAction.value) {
openShareStatisticsDialog()
return
}
if (showSubscribeHistoryAction.value) {
openSubscribeHistoryDialog()
return
}
if (showDefaultRuleAction.value) {
openDefaultRuleDialog()
}
}
useDynamicButton({
icon: subscribeDynamicIcon,
onClick: handleSubscribeDynamicAction,
menuItems: subscribeDynamicMenuItems,
show: computed(() => appMode.value && (showDefaultRuleAction.value || showShareStatisticsAction.value)),
})
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
@@ -126,37 +233,16 @@ registerHeaderTab({
show: computed(() => activeTab.value === 'mysub'),
},
{
icon: 'mdi-chart-line',
icon: 'mdi-filter-multiple-outline',
variant: 'text',
color: 'gray',
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'statistics-btn',
action: () => {
shareStatisticsDialog.value = true
},
show: computed(() => activeTab.value === 'share'),
},
{
icon: 'mdi-movie-search-outline',
variant: 'text',
color: computed(() => (shareKeyword.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'search-btn',
dataAttr: 'share-filter-btn',
action: () => {
searchShareDialog.value = true
},
show: computed(() => activeTab.value === 'share'),
},
{
icon: 'mdi-clipboard-edit-outline',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
subscribeEditDialog.value = true
},
show: computed(() => activeTab.value === 'mysub'),
},
],
})
@@ -176,6 +262,7 @@ onMounted(() => {
<transition name="fade-slide" appear>
<div>
<SubscribeListView
ref="subscribeListViewRef"
:type="subType"
:subid="subId"
:keyword="subscribeFilter"
@@ -194,50 +281,58 @@ onMounted(() => {
<VWindowItem value="share">
<transition name="fade-slide" appear>
<div>
<SubscribeShareView :keyword="shareKeyword" :key="shareViewKey" />
<SubscribeShareView :keyword="shareKeyword" />
</div>
</transition>
</VWindowItem>
</VWindow>
<!-- 订阅过滤弹窗 -->
<!-- 订阅过滤下拉菜单 -->
<Teleport to="body" v-if="filterSubscribeDialog">
<VMenu
v-model="filterSubscribeDialog"
width="25rem"
:close-on-content-click="false"
:activator="filterActivator"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
{{ t('subscribe.filterSubscriptions') }}
</VCardTitle>
<VDialogCloseBtn @click="filterSubscribeDialog = false" />
</VCardItem>
<VCardText>
<VRow>
<!-- 名称筛选 -->
<VCol cols="6">
<VTextField v-model="subscribeFilter" :label="t('subscribe.name')" clearable density="comfortable" />
</VCol>
<!-- 状态筛选 -->
<VCol cols="6">
<VSelect
v-model="subscribeStatusFilter"
:items="filterOptions"
item-title="label"
item-value="value"
:label="t('common.status')"
density="comfortable"
clearable
<VCard min-width="220">
<!-- 名称搜索 -->
<div class="pa-3">
<VTextField
v-model="subscribeFilter"
:placeholder="t('subscribe.name')"
prepend-inner-icon="mdi-magnify"
density="compact"
variant="outlined"
hide-details
clearable
/>
</div>
<VDivider class="mt-2" />
<!-- 状态筛选列表 -->
<VList density="compact" class="px-2 py-1">
<VListSubheader>{{ t('common.status') }}</VListSubheader>
<VListItem
v-for="option in filterOptions"
:key="option.value"
:active="(subscribeStatusFilter || 'all') === option.value"
@click="selectFilter(option.value)"
density="compact"
>
<template #prepend>
<VIcon :icon="option.icon" :color="option.color" size="small" />
</template>
<VListItemTitle>{{ option.label }}</VListItemTitle>
<template #append>
<VIcon
v-if="(subscribeStatusFilter || 'all') === option.value"
icon="mdi-check"
color="primary"
size="small"
/>
</VCol>
</VRow>
</VCardText>
</template>
</VListItem>
</VList>
</VCard>
</VMenu>
</Teleport>
@@ -246,30 +341,56 @@ onMounted(() => {
<Teleport to="body" v-if="searchShareDialog">
<VMenu
v-model="searchShareDialog"
width="25rem"
:close-on-content-click="false"
:activator="searchActivator"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
{{ t('subscribe.searchShares') }}
</VCardTitle>
<VDialogCloseBtn @click="searchShareDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="shareKeyword" :label="t('subscribe.keyword')" clearable density="comfortable">
<template #append>
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
</template>
</VTextField>
</VCardText>
<VCard min-width="260" max-width="320">
<div class="pa-3">
<VTextField
v-model="shareKeywordInput"
:placeholder="t('subscribe.keyword')"
prepend-inner-icon="mdi-magnify"
density="compact"
variant="outlined"
hide-details
clearable
/>
</div>
</VCard>
</VMenu>
</Teleport>
<Teleport to="body" v-if="!appMode && route.path.startsWith(`/subscribe/${subType === '电影' ? 'movie' : 'tv'}`)">
<div class="compact-fab-stack">
<VFab
v-if="showSubscribeHistoryAction"
icon="mdi-history"
color="info"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="openSubscribeHistoryDialog"
/>
<VFab
v-if="showDefaultRuleAction"
icon="mdi-clipboard-edit-outline"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openDefaultRuleDialog"
/>
<VFab
v-if="showShareStatisticsAction"
icon="mdi-chart-line"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openShareStatisticsDialog"
/>
</div>
</Teleport>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"

View File

@@ -1,42 +1,66 @@
<script setup lang="ts">
import { debounce } from 'lodash-es'
import WorkflowListView from '@/views/workflow/WorkflowListView.vue'
import WorkflowShareView from '@/views/workflow/WorkflowShareView.vue'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { getWorkflowTabs } from '@/router/i18n-menu'
// 国际化
const { t } = useI18n()
const route = useRoute()
const { appMode } = usePWA()
const activeTab = ref((route.query.tab as string) || 'list')
const shareViewKey = ref(0)
const listViewKey = ref(0)
const workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null)
// 获取标签页
const workflowTabs = computed(() => {
return getWorkflowTabs()
return getWorkflowTabs(t)
})
// 新增工作流对话框
const addWorkflowDialog = ref(false)
// 分享搜索词
const shareKeyword = ref('')
const shareKeywordInput = ref('')
// 搜索分享对话框
const searchShareDialog = ref(false)
// 搜索分享激活器
const searchActivator = computed(() => '[data-menu-activator="search-btn"]')
const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
// 搜索分享
const searchShares = () => {
shareViewKey.value++
function openAddWorkflowDialog() {
workflowListViewRef.value?.openAddDialog()
}
const shareKeywordUpdater = debounce((keyword: string) => {
shareKeyword.value = keyword.trim()
}, 300)
watch(shareKeywordInput, newKeyword => {
shareKeywordUpdater(newKeyword || '')
})
watch(activeTab, newTab => {
if (newTab !== 'share') {
searchShareDialog.value = false
}
})
onUnmounted(() => {
shareKeywordUpdater.cancel()
})
useDynamicButton({
icon: 'mdi-plus',
onClick: openAddWorkflowDialog,
show: computed(() => appMode.value && activeTab.value === 'list'),
})
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
@@ -46,11 +70,11 @@ registerHeaderTab({
modelValue: activeTab,
appendButtons: [
{
icon: 'mdi-search',
icon: 'mdi-filter-multiple-outline',
variant: 'text',
color: computed(() => (shareKeyword.value ? 'primary' : 'gray')),
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
class: 'settings-icon-button',
dataAttr: 'search-btn',
dataAttr: 'share-filter-btn',
show: computed(() => activeTab.value === 'share'),
action: () => {
searchShareDialog.value = true
@@ -74,54 +98,54 @@ onMounted(() => {
<VWindowItem value="list">
<transition name="fade-slide" appear>
<div>
<WorkflowListView :key="listViewKey" />
<WorkflowListView ref="workflowListViewRef" :key="listViewKey" />
</div>
</transition>
</VWindowItem>
<VWindowItem value="share">
<transition name="fade-slide" appear>
<div>
<WorkflowShareView :keyword="shareKeyword" :key="shareViewKey" @update="listViewKey++" />
<WorkflowShareView :keyword="shareKeyword" @update="listViewKey++" />
</div>
</transition>
</VWindowItem>
</VWindow>
<!-- 新增工作流对话框 -->
<WorkflowAddEditDialog
v-if="addWorkflowDialog"
v-model="addWorkflowDialog"
@close="addWorkflowDialog = false"
@save="addWorkflowDialog = false"
/>
<!-- 搜索工作流分享弹窗 -->
<Teleport to="body" v-if="searchShareDialog">
<VMenu
v-model="searchShareDialog"
width="25rem"
:close-on-content-click="false"
:activator="searchActivator"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
{{ t('workflow.searchShares') }}
</VCardTitle>
<VDialogCloseBtn @click="searchShareDialog = false" />
</VCardItem>
<VCardText>
<VTextField v-model="shareKeyword" :label="t('workflow.searchShares')" clearable density="comfortable">
<template #append>
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
</template>
</VTextField>
</VCardText>
<VCard min-width="260" max-width="320">
<div class="pa-3">
<VTextField
v-model="shareKeywordInput"
:placeholder="t('workflow.searchShares')"
prepend-inner-icon="mdi-magnify"
density="compact"
variant="outlined"
hide-details
clearable
/>
</div>
</VCard>
</VMenu>
</Teleport>
<Teleport to="body" v-if="!appMode && route.path === '/workflow' && activeTab === 'list'">
<div class="compact-fab-stack">
<VFab
icon="mdi-plus"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openAddWorkflowDialog"
/>
</div>
</Teleport>
</div>
</template>

View File

@@ -1,5 +1,5 @@
import { Icon } from '@iconify/vue'
import { aliases } from 'vuetify/lib/iconsets/mdi'
import { aliases } from 'vuetify/iconsets/mdi'
const alertTypeIcon = {
success: 'mdi-check-circle-outline',

View File

@@ -1,9 +1,8 @@
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
import type { Composer } from 'vue-i18n'
// 构建路由菜单,每次调用时使用当前的语言环境
export function getNavMenus() {
const { t } = useI18n()
export function getNavMenus(t: Composer['t']) {
const globalSettingsStore = useGlobalSettingsStore()
// 检查是否为高级模式
@@ -148,9 +147,7 @@ export function getNavMenus() {
}
// 获取设置标签页
export function getSettingTabs() {
const { t } = useI18n()
export function getSettingTabs(t: Composer['t']) {
return [
{
title: t('settingTabs.system.title'),
@@ -188,12 +185,6 @@ export function getSettingTabs() {
tab: 'subscribe',
description: t('settingTabs.subscribe.description'),
},
{
title: t('settingTabs.scheduler.title'),
icon: 'mdi-list-box',
tab: 'scheduler',
description: t('settingTabs.scheduler.description'),
},
{
title: t('settingTabs.notification.title'),
icon: 'mdi-bell',
@@ -204,9 +195,7 @@ export function getSettingTabs() {
}
// 获取电影订阅标签页
export function getSubscribeMovieTabs() {
const { t } = useI18n()
export function getSubscribeMovieTabs(t: Composer['t']) {
return [
{
title: t('subscribeTabs.movie.mysub'),
@@ -222,9 +211,7 @@ export function getSubscribeMovieTabs() {
}
// 获取电视剧订阅标签页
export function getSubscribeTvTabs() {
const { t } = useI18n()
export function getSubscribeTvTabs(t: Composer['t']) {
return [
{
title: t('subscribeTabs.tv.mysub'),
@@ -245,9 +232,7 @@ export function getSubscribeTvTabs() {
}
// 获取插件标签页
export function getPluginTabs() {
const { t } = useI18n()
export function getPluginTabs(t: Composer['t']) {
return [
{
title: t('pluginTabs.installed'),
@@ -263,9 +248,7 @@ export function getPluginTabs() {
}
// 获取发现标签页
export function getDiscoverTabs() {
const { t } = useI18n()
export function getDiscoverTabs(t: Composer['t']) {
return [
{
name: t('discoverTabs.themoviedb'),
@@ -286,9 +269,7 @@ export function getDiscoverTabs() {
}
// 获取工作流标签页
export function getWorkflowTabs() {
const { t } = useI18n()
export function getWorkflowTabs(t: Composer['t']) {
return [
{
title: t('workflowTabs.list'),
@@ -302,3 +283,20 @@ export function getWorkflowTabs() {
},
]
}
/** 插件侧栏分组(与后端 get_sidebar_nav 的 section 一致) */
export type PluginSidebarSection = 'start' | 'discovery' | 'subscribe' | 'organize' | 'system'
/**
* 将插件声明的 section 映射为与 getNavMenus 一致的已翻译 header用于 NavMenu.header
*/
export function pluginSidebarSectionToHeaderKey(section: string, t: Composer['t']): string {
const map: Record<string, string> = {
start: 'menu.start',
discovery: 'menu.discovery',
subscribe: 'menu.subscribe',
organize: 'menu.organize',
system: 'menu.system',
}
return t(map[section] ?? 'menu.system')
}

View File

@@ -140,6 +140,15 @@ const router = createRouter({
requiresAuth: true,
},
},
{
path: '/plugin-app/:pluginId/:navKey?',
name: 'plugin-app',
component: () => import('../pages/plugin-app.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
{
path: '/setting',
component: () => import('../pages/setting.vue'),

View File

@@ -1,32 +1,56 @@
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { registerRoute, setCatchHandler } from 'workbox-routing'
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
import * as navigationPreload from 'workbox-navigation-preload'
// Service Worker 类型声明
declare let self: ServiceWorkerGlobalScope & {
__WB_MANIFEST: Array<{ url: string; revision?: string }>
readonly __WB_MANIFEST: Array<{ url: string; revision?: string }>
}
// 缓存版本控制
const CACHE_VERSION = 'v13'
const CACHE_NAMES = {
appShell: `app-shell-${CACHE_VERSION}`,
static: `static-resources-${CACHE_VERSION}`,
images: `image-cache-${CACHE_VERSION}`,
fonts: `font-cache-${CACHE_VERSION}`,
api: `api-cache-${CACHE_VERSION}`,
tmdb: `tmdb-image-cache-${CACHE_VERSION}`,
pages: `pages-cache-${CACHE_VERSION}`,
const RESOURCE_VERSION = 'V2'
// 开发态 dev-sw 可能拿不到 Vite define 注入;仅在开发环境做 dev 兜底
const hasAppVersion = typeof __APP_VERSION__ !== 'undefined'
const hasBuildTime = typeof __BUILD_TIME__ !== 'undefined'
const isDev = import.meta.env.DEV
if (!isDev && (!hasAppVersion || !hasBuildTime)) {
throw new Error('[SW] Missing __APP_VERSION__ or __BUILD_TIME__ in production build')
}
// 缓存大小限制
const CACHE_SIZE_LIMITS = {
appShell: { maxEntries: 10, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天
static: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }, // 30天
images: { maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 }, // 30天
fonts: { maxEntries: 50, maxAgeSeconds: 365 * 24 * 60 * 60 }, // 1年
api: { maxEntries: 500, maxAgeSeconds: 24 * 60 * 60 }, // 24小时
tmdb: { maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天
pages: { maxEntries: 50, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天
}
const appVersion = hasAppVersion ? __APP_VERSION__ : 'dev'
const buildTime = hasBuildTime ? __BUILD_TIME__ : 'dev'
const CACHE_VERSION = `${appVersion}-${buildTime}`
// 启用导航预载
navigationPreload.enable()
// 自动清理旧的预缓存
cleanupOutdatedCaches()
// 预缓存并路由
precacheAndRoute(self.__WB_MANIFEST)
// 监听安装事件
self.addEventListener('install', () => {
// 强制等待中的 Service Worker 立即激活
self.skipWaiting()
})
// 监听激活事件
self.addEventListener('activate', event => {
// 让 Service Worker 立即接管页面
event.waitUntil(
(async () => {
await self.clients.claim()
// 清理旧版本的运行时缓存
await cleanupRuntimeCaches(true)
})(),
)
})
// 通知选项
const options = {
@@ -38,100 +62,229 @@ const options = {
// 存储未读消息数量的键名
const UNREAD_COUNT_KEY = 'mp_unread_count'
// 从IndexedDB获取未读消息数量
async function getStoredUnreadCount(): Promise<number> {
try {
const count = await get(UNREAD_COUNT_KEY)
return count || 0
} catch (error) {
console.error('Failed to get stored unread count:', error)
return 0
// --- 缓存策略配置 ---
// 导航请求与 App Shell - 优先网络
registerRoute(
({ request, url }) => request.mode === 'navigate' || url.pathname === '/' || url.pathname === '/index.html',
new NetworkFirst({
cacheName: `app-shell-${CACHE_VERSION}`,
plugins: [
new ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天
}),
],
}),
)
// 静态资源 (JS, CSS, HTML) - 优先缓存
registerRoute(
({ request }) => ['style', 'script', 'worker'].includes(request.destination),
new StaleWhileRevalidate({
cacheName: `static-resources-${CACHE_VERSION}`,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
}),
)
// 图片资源 - 优先缓存
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: `image-cache-${RESOURCE_VERSION}`,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30天
}),
],
}),
)
// 字体资源 - 优先缓存
registerRoute(
({ request }) => request.destination === 'font',
new CacheFirst({
cacheName: `font-cache-${RESOURCE_VERSION}`,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1年
}),
],
}),
)
// TMDB 图片 - 优先缓存
registerRoute(
({ url }) => url.hostname === 'image.tmdb.org',
new CacheFirst({
cacheName: `tmdb-image-cache-${RESOURCE_VERSION}`,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 300,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天
}),
],
}),
)
// API GET 请求 - 优先网络
registerRoute(
({ url, request }) =>
url.pathname.includes('/api/v1/') &&
request.method === 'GET' &&
!url.pathname.includes('/api/v1/system/message') && // SSE实时消息流
!url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流
!url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流
!url.pathname.includes('/api/v1/message/') && // 用户消息接口
!url.pathname.includes('/api/v1/system/global') && // 系统配置接口
!url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口
!url.pathname.includes('/api/v1/dashboard/'), // Dashboard实时监控数据
new NetworkFirst({
cacheName: `api-cache-${CACHE_VERSION}`,
networkTimeoutSeconds: 5,
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200],
}),
new ExpirationPlugin({
maxEntries: 500,
maxAgeSeconds: 24 * 60 * 60, // 24小时
}),
],
}),
)
// 设置默认离线页面
setCatchHandler(async ({ request }) => {
if (request?.destination === 'document') {
return (await caches.match('/offline.html')) || Response.error()
}
return Response.error()
})
// --- 辅助函数 (通知与徽章) ---
// 清理运行时缓存
async function cleanupRuntimeCaches(onlyOld: boolean = false) {
const cacheNames = await caches.keys()
const runtimeCachePrefixes = [
'app-shell',
'static-resources',
'image-cache',
'font-cache',
'api-cache',
'tmdb-image-cache',
]
// 当前版本的缓存全名
const currentCacheNames = [
`app-shell-${CACHE_VERSION}`,
`static-resources-${CACHE_VERSION}`,
`image-cache-${RESOURCE_VERSION}`,
`font-cache-${RESOURCE_VERSION}`,
`tmdb-image-cache-${RESOURCE_VERSION}`,
`api-cache-${CACHE_VERSION}`,
]
await Promise.all(
cacheNames.map(cacheName => {
const isRuntimeCache = runtimeCachePrefixes.some(prefix => cacheName.startsWith(prefix))
if (isRuntimeCache) {
if (!onlyOld || !currentCacheNames.includes(cacheName)) {
console.log('[SW] Deleting runtime cache:', cacheName)
return caches.delete(cacheName)
}
}
return Promise.resolve()
}),
)
}
// 保存未读消息数量到IndexedDB
async function setStoredUnreadCount(count: number): Promise<void> {
try {
await set(UNREAD_COUNT_KEY, count)
} catch (error) {
console.error('Failed to set stored unread count:', error)
}
}
// 简单的IndexedDB包装器
// 简单的 IndexedDB 包装器 (用于未读计数)
async function openDB(): Promise<IDBDatabase> {
// Bump the version to add the new "sync" store while keeping existing data intact
return new Promise((resolve, reject) => {
const request = indexedDB.open('mp_badge_db', 2)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = event => {
const db = (event.target as IDBOpenDBRequest).result
// Badge store (existing)
if (!db.objectStoreNames.contains('badge')) {
db.createObjectStore('badge')
}
// Dedicated store for offline-sync items
if (!db.objectStoreNames.contains('sync')) {
db.createObjectStore('sync')
}
}
})
}
// 获取IndexedDB中的数据
async function get(key: string, storeName: string = 'badge'): Promise<any> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction([storeName], 'readonly')
const store = tx.objectStore(storeName)
const request = store.get(key)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
})
try {
const db = await openDB()
return new Promise((resolve, reject) => {
if (!db.objectStoreNames.contains(storeName)) {
resolve(null)
return
}
const tx = db.transaction([storeName], 'readonly')
const store = tx.objectStore(storeName)
const request = store.get(key)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
})
} catch (e) {
return null
}
}
// 保存数据到IndexedDB
async function set(key: string, value: any, storeName: string = 'badge'): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction([storeName], 'readwrite')
const store = tx.objectStore(storeName)
store.put(value, key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
try {
const db = await openDB()
return new Promise((resolve, reject) => {
if (!db.objectStoreNames.contains(storeName)) {
console.warn(`Store ${storeName} not found`)
resolve()
return
}
const tx = db.transaction([storeName], 'readwrite')
const store = tx.objectStore(storeName)
store.put(value, key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
} catch (e) {
console.error(`[SW] Failed to set IndexedDB key "${key}" in store "${storeName}":`, e)
}
}
// 删除IndexedDB中的数据确保事务完成
async function del(key: string, storeName: string = 'badge'): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction([storeName], 'readwrite')
const store = tx.objectStore(storeName)
store.delete(key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
async function getStoredUnreadCount(): Promise<number> {
const count = await get(UNREAD_COUNT_KEY)
return typeof count === 'number' ? count : 0
}
async function setStoredUnreadCount(count: number): Promise<void> {
await set(UNREAD_COUNT_KEY, count)
}
// 更新桌面图标徽章
async function updateBadge(count: number) {
if ('setAppBadge' in navigator) {
if ('setAppBadge' in self.navigator) {
try {
if (count > 0) {
await navigator.setAppBadge!(count)
await self.navigator.setAppBadge(count)
} else {
await navigator.clearAppBadge!()
await self.navigator.clearAppBadge()
}
} catch (error) {
console.error('Failed to update app badge:', error)
@@ -139,11 +292,10 @@ async function updateBadge(count: number) {
}
}
// 清除桌面图标徽章
async function clearBadge() {
if ('clearAppBadge' in navigator) {
if ('clearAppBadge' in self.navigator) {
try {
await navigator.clearAppBadge!()
await self.navigator.clearAppBadge()
await setStoredUnreadCount(0)
} catch (error) {
console.error('Failed to clear app badge:', error)
@@ -151,352 +303,91 @@ async function clearBadge() {
}
}
// 清理旧版本缓存
async function deleteOldCaches() {
const cacheWhitelist = Object.values(CACHE_NAMES)
const cacheNames = await caches.keys()
await Promise.all(
cacheNames.map(async cacheName => {
if (!cacheWhitelist.includes(cacheName)) {
console.log('Deleting old cache:', cacheName)
return caches.delete(cacheName)
}
}),
)
}
// 获取缓存大小
async function getCacheSize(cacheName: string): Promise<number> {
if (!('estimate' in navigator.storage)) {
return 0
}
try {
const cache = await caches.open(cacheName)
const keys = await cache.keys()
let totalSize = 0
for (const request of keys) {
const response = await cache.match(request)
if (response) {
const blob = await response.blob()
totalSize += blob.size
}
}
return totalSize
} catch (error) {
console.error('Failed to get cache size:', error)
return 0
}
}
// 监控缓存大小
async function monitorCacheSize() {
const cacheSizes: Record<string, number> = {}
let totalSize = 0
let calculatedTotalSize = 0
for (const [key, cacheName] of Object.entries(CACHE_NAMES)) {
const size = await getCacheSize(cacheName)
cacheSizes[key] = size
totalSize += size
}
try {
const cacheNames = await caches.keys()
// 发送缓存统计信息给客户端
const clients = await self.clients.matchAll()
clients.forEach(client => {
client.postMessage({
type: 'CACHE_SIZE_UPDATE',
data: {
cacheSizes,
totalSize,
totalSizeMB: (totalSize / 1024 / 1024).toFixed(2),
},
})
})
// 并行处理所有缓存
await Promise.all(
cacheNames.map(async cacheName => {
const cache = await caches.open(cacheName)
const requests = await cache.keys()
let cacheSize = 0
return { cacheSizes, totalSize }
}
// 清理过期缓存条目
async function cleanupExpiredCaches() {
for (const [key, cacheName] of Object.entries(CACHE_NAMES)) {
const limit = CACHE_SIZE_LIMITS[key as keyof typeof CACHE_SIZE_LIMITS]
if (!limit) continue
try {
const cache = await caches.open(cacheName)
const keys = await cache.keys()
// 如果缓存条目超过限制,删除最老的条目
if (keys.length > limit.maxEntries) {
const deleteCount = keys.length - limit.maxEntries
console.log(`Cleaning up ${deleteCount} entries from ${cacheName}`)
// 删除最老的条目(假设数组开头是最老的)
for (let i = 0; i < deleteCount; i++) {
await cache.delete(keys[i])
// 遍历请求以获取响应头部,避免 matchAll 一次性加载大量响应对象到内存
for (const request of requests) {
const response = await cache.match(request)
if (response) {
const contentLength = response.headers.get('content-length')
if (contentLength) {
cacheSize += parseInt(contentLength, 10)
}
}
}
}
} catch (error) {
console.error(`Failed to cleanup cache ${cacheName}:`, error)
cacheSizes[cacheName] = cacheSize
}),
)
calculatedTotalSize = Object.values(cacheSizes).reduce((acc, size) => acc + size, 0)
// 获取系统级存储估算
let quota = 0
let usage = 0
if (self.navigator.storage && self.navigator.storage.estimate) {
const estimate = await self.navigator.storage.estimate()
quota = estimate.quota || 0
usage = estimate.usage || 0
}
}
}
// 安装事件
self.addEventListener('install', () => {
// 强制等待中的Service Worker立即成为活动的Service Worker
self.skipWaiting()
})
// 激活事件
self.addEventListener('activate', event => {
event.waitUntil(
(async () => {
// 启用导航预载功能以提高性能
if ('navigationPreload' in self.registration) {
await self.registration.navigationPreload.enable()
}
// 清理旧版本的缓存
await deleteOldCaches()
// 清理过期的缓存条目
await cleanupExpiredCaches()
// 监控缓存大小
await monitorCacheSize()
})(),
)
// 告诉活动的Service Worker立即控制页面
self.clients.claim()
})
// 处理API请求当离线时发送消息到客户端
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
// 处理API请求
if (event.request.url.includes('/api/v1/')) {
// GET请求尝试从缓存返回
if (event.request.method === 'GET') {
event.respondWith(
(async () => {
try {
// 尝试网络请求
const networkResponse = await fetch(event.request)
return networkResponse
} catch (error) {
// 网络错误时,通知客户端当前处于离线状态
if (self.clients) {
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'OFFLINE_STATUS',
offline: true,
})
})
})
}
// 尝试返回缓存的响应
const cache = await caches.open(CACHE_NAMES.api)
const cachedResponse = await cache.match(event.request)
if (cachedResponse) {
return cachedResponse
}
// 如果没有缓存,抛出错误
throw error
}
})(),
)
// 构造结果:满足 useCacheManager.ts 的需求
const result = {
cacheSizes,
// 优先使用准确的 usage (真实磁盘占用),如果不可用则退回到计算值
totalSize: usage || calculatedTotalSize,
totalSizeMB: ((usage || calculatedTotalSize) / 1024 / 1024).toFixed(2),
// 额外信息保留,供未来扩展
quota,
usage,
quotaMB: (quota / 1024 / 1024).toFixed(2),
usageMB: (usage / 1024 / 1024).toFixed(2),
calculatedTotalSize,
}
// POST/PUT/DELETE请求离线时加入同步队列
else if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(event.request.method)) {
event.respondWith(
(async () => {
try {
// 尝试网络请求
const networkResponse = await fetch(event.request)
return networkResponse
} catch (error) {
// 网络错误时,加入同步队列
await addToSyncQueue(event.request)
// 通知客户端请求已加入队列
if (self.clients) {
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'REQUEST_QUEUED',
url: event.request.url,
method: event.request.method,
})
})
})
}
// 返回一个假的成功响应
return new Response(
JSON.stringify({
success: true,
queued: true,
message: '请求已加入离线队列,将在网络恢复后自动同步',
}),
{
status: 202,
headers: { 'Content-Type': 'application/json' },
},
)
}
})(),
)
}
return
}
})
// 后台同步队列
const syncQueue: Array<{
id: string
url: string
method: string
data?: any
timestamp: number
}> = []
// 添加请求到同步队列
async function addToSyncQueue(request: Request) {
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const url = request.url
const method = request.method
let data: any = null
if (method !== 'GET' && method !== 'HEAD') {
try {
data = await request.clone().text()
} catch (e) {
console.error('Failed to read request body:', e)
}
}
const syncItem = {
id,
url,
method,
data,
timestamp: Date.now(),
}
// 保存到IndexedDB (使用专用的 "sync" store)
await set(id, syncItem, 'sync')
syncQueue.push(syncItem)
// 注册后台同步
if ('sync' in self.registration) {
await self.registration.sync.register('sync-data')
}
}
// 执行同步队列中的请求
async function processSyncQueue() {
const db = await openDB()
// 先用只读事务获取所有同步项
const items: Array<any> = await new Promise((resolve, reject) => {
const tx = db.transaction(['sync'], 'readonly')
const store = tx.objectStore('sync')
const req = store.getAll()
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
// 收集需要删除的项目ID
const itemsToDelete: string[] = []
const itemsToDeleteExpired: string[] = []
for (const syncItem of items) {
const key = syncItem.id
try {
// 构建请求
const init: RequestInit = {
method: syncItem.method,
headers: {
'Content-Type': 'application/json',
},
}
if (syncItem.data) {
init.body = syncItem.data
}
// 发送请求
const response = await fetch(syncItem.url, init)
if (response.ok) {
// 成功后标记为需要删除
itemsToDelete.push(key)
// 通知客户端同步成功
const clients = await self.clients.matchAll()
clients.forEach(client => {
client.postMessage({
type: 'SYNC_SUCCESS',
syncId: syncItem.id,
url: syncItem.url,
})
})
} else {
throw new Error(`HTTP ${response.status}`)
}
} catch (error) {
console.error('Sync failed for item:', key, error)
// 如果该同步项已存在超过 24 小时,则标记为需要删除
if (Date.now() - syncItem.timestamp > 24 * 60 * 60 * 1000) {
itemsToDeleteExpired.push(key)
}
}
}
// 批量删除所有成功处理的项目和过期项目
const allItemsToDelete = [...itemsToDelete, ...itemsToDeleteExpired]
if (allItemsToDelete.length > 0) {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(['sync'], 'readwrite')
const store = tx.objectStore('sync')
// 批量删除所有标记的项目
allItemsToDelete.forEach(id => {
store.delete(id)
// 发送缓存统计信息给客户端
const clients = await self.clients.matchAll()
clients.forEach(client => {
client.postMessage({
type: 'CACHE_SIZE_UPDATE',
data: result,
})
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
return result
} catch (error) {
console.error('Failed to monitor cache size:', error)
return {
cacheSizes: {},
totalSize: 0,
totalSizeMB: '0.00',
quota: 0,
usage: 0,
quotaMB: '0.00',
usageMB: '0.00',
}
}
}
// 初始化 Workbox
cleanupOutdatedCaches()
precacheAndRoute(self.__WB_MANIFEST)
// --- 事件监听 ---
// 监听 sync 事件,处理后台同步
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === 'sync-data') {
event.waitUntil(processSyncQueue())
}
})
// 监听 push 事件,显示通知
// 监听 push 事件
self.addEventListener('push', function (event) {
if (!event.data) {
return
}
// 解析获取推送消息
let payload
try {
payload = event.data?.json()
@@ -505,7 +396,7 @@ self.addEventListener('push', function (event) {
title: event.data?.text(),
}
}
// 根据推送消息生成桌面通知并展现出来
try {
const content = {
body: payload.body || '',
@@ -515,7 +406,6 @@ self.addEventListener('push', function (event) {
actions: options.actions,
}
// 增加未读消息计数并持久化存储
event.waitUntil(
(async () => {
const currentCount = await getStoredUnreadCount()
@@ -525,11 +415,11 @@ self.addEventListener('push', function (event) {
})(),
)
} catch (e) {
// 静默处理错误
// 忽略错误
}
})
// 监听通知点击事件
// 监听通知点击
self.addEventListener('notificationclick', function (event) {
const info = event.notification
if (event.action === 'close') {
@@ -539,10 +429,9 @@ self.addEventListener('notificationclick', function (event) {
}
})
// 监听来自主应用的消息,用于清除徽章或更新徽章数量
// 监听消息
self.addEventListener('message', function (event) {
if (event.data && event.data.type === 'CLEAR_BADGE') {
// 清除徽章
clearBadge()
.then(() => {
event.ports[0]?.postMessage({ success: true })
@@ -551,7 +440,6 @@ self.addEventListener('message', function (event) {
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
})
} else if (event.data && event.data.type === 'UPDATE_BADGE') {
// 更新徽章数量
const count = event.data.count || 0
setStoredUnreadCount(count)
.then(() => updateBadge(count))
@@ -562,25 +450,27 @@ self.addEventListener('message', function (event) {
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
})
} else if (event.data && event.data.type === 'GET_UNREAD_COUNT') {
// 获取未读消息数量
getStoredUnreadCount()
.then(count => {
event.ports[0]?.postMessage({ count })
})
.catch(error => {
.catch(() => {
event.ports[0]?.postMessage({ count: 0 })
})
} else if (event.data && event.data.type === 'CLEANUP_CACHES') {
// 手动触发缓存清理
Promise.all([deleteOldCaches(), cleanupExpiredCaches(), monitorCacheSize()])
.then(([, , cacheInfo]) => {
// 手动清理: 清理所有运行时缓存
const performCleanup = async () => {
await cleanupRuntimeCaches(false)
return await monitorCacheSize()
}
performCleanup()
.then(cacheInfo => {
event.ports[0]?.postMessage({ success: true, cacheInfo })
})
.catch(error => {
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
})
} else if (event.data && event.data.type === 'GET_CACHE_INFO') {
// 获取缓存信息
monitorCacheSize()
.then(cacheInfo => {
event.ports[0]?.postMessage({ success: true, cacheInfo })
@@ -588,5 +478,7 @@ self.addEventListener('message', function (event) {
.catch(error => {
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
})
} else if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import type { authState } from '@/stores/types'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
export const useAuthStore = defineStore('auth', {
state: (): authState => ({
@@ -31,6 +32,7 @@ export const useAuthStore = defineStore('auth', {
logout() {
this.clearToken()
this.setOriginalPath(null)
usePluginSidebarNavStore().reset()
},
},

View File

@@ -1,6 +1,8 @@
import { defineStore } from 'pinia'
import type { globalSettingsState } from '@/stores/types'
import { fetchGlobalSettings } from '@/utils/globalSetting'
import { useVersionChecker } from '@/composables/useVersionChecker'
import api from '@/api'
export const useGlobalSettingsStore = defineStore('globalSettings', {
state: (): globalSettingsState => ({
@@ -18,6 +20,20 @@ export const useGlobalSettingsStore = defineStore('globalSettings', {
const result = await fetchGlobalSettings()
this.data = result || {}
this.initialized = true
// 检查版本更新
if (result.FRONTEND_VERSION) {
const isBackendDev = Boolean(result.BACKEND_DEV)
const skipVersionCheck = import.meta.env.DEV || isBackendDev
if (skipVersionCheck) {
console.log('[VersionChecker] 开发环境下跳过版本一致性检查')
return
}
const { checkVersion } = useVersionChecker()
await checkVersion(result.FRONTEND_VERSION)
}
} catch (error) {
console.error('Failed to initialize global settings', error)
} finally {
@@ -25,6 +41,19 @@ export const useGlobalSettingsStore = defineStore('globalSettings', {
}
},
// 登录后加载用户相关设置
async loadUserSettings() {
try {
const result: { [key: string]: any } = await api.get('system/global/user')
if (result.success && result.data) {
// 合并用户设置到现有数据
this.data = { ...this.data, ...result.data }
}
} catch (error) {
console.error('Failed to load user settings', error)
}
},
setData(data: { [key: string]: any }) {
this.data = data
this.initialized = true

View File

@@ -13,5 +13,6 @@ export default pinia
import { useAuthStore } from './auth'
import { useUserStore } from './user'
import { useGlobalSettingsStore } from './global'
import { usePluginSidebarNavStore } from './pluginSidebarNav'
export { useAuthStore, useUserStore, useGlobalSettingsStore }
export { useAuthStore, useUserStore, useGlobalSettingsStore, usePluginSidebarNavStore }

View File

@@ -0,0 +1,49 @@
import { defineStore } from 'pinia'
import api from '@/api'
import type { PluginSidebarNavItem } from '@/api/types'
/**
* 缓存 GET plugin/sidebar_nav 结果,供 DefaultLayout 与 appcenter 等共用,避免重复请求。
*/
export const usePluginSidebarNavStore = defineStore('pluginSidebarNav', {
state: () => ({
items: [] as PluginSidebarNavItem[],
/** 是否已成功拉取过一次(含空数组) */
loaded: false,
/** 并发去重:同一时刻只进行一次请求 */
inflight: null as Promise<void> | null,
}),
actions: {
/**
* 确保侧栏导航数据已加载;已缓存则直接返回,并发调用共享同一请求。
* @param force 为 true 时忽略缓存重新请求(如登出后再登录可配合 reset + ensure
*/
async ensureSidebarNav(force = false): Promise<void> {
if (!force && this.loaded) {
return
}
if (this.inflight) {
return this.inflight
}
this.inflight = (async () => {
try {
const res = await api.get('plugin/sidebar_nav')
this.items = Array.isArray(res) ? res : []
} catch {
this.items = []
} finally {
this.loaded = true
this.inflight = null
}
})()
return this.inflight
},
reset() {
this.items = []
this.loaded = false
this.inflight = null
},
},
})

View File

@@ -48,6 +48,143 @@ html.v-overlay-scroll-blocked body {
}
}
// 应用类信息卡片:固定右侧媒体槽位,避免图片被左侧文字挤压变形
.app-card-shell {
position: relative;
block-size: 100%;
}
// 保证卡片右上角的浮动操作区始终高于可点击的卡片内容层,避免误触发详情打开。
.app-card-top-action {
z-index: 2;
}
.app-card-summary {
position: relative;
display: flex;
overflow: hidden;
align-items: stretch;
justify-content: flex-start;
block-size: 7.5rem;
min-block-size: 7.5rem;
}
.app-card-summary__content {
position: relative;
z-index: 1;
display: flex;
flex: 1 1 auto;
flex-direction: column;
justify-content: center;
min-inline-size: 0;
padding-block: 0.25rem 0.5rem;
row-gap: 0.25rem;
}
.app-card-summary__title-row {
display: flex;
align-items: flex-start;
column-gap: 0.25rem;
min-inline-size: 0;
}
.app-card-summary__title-row > .v-badge {
flex-shrink: 0;
align-self: center;
}
.app-card-summary__subtitle,
.app-card-summary__meta-item {
overflow: hidden;
min-inline-size: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-card-summary__title {
display: -webkit-box;
overflow: hidden;
flex: 0 0 auto;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 1.35;
max-block-size: calc(1.35em * 2);
min-inline-size: 0;
text-overflow: ellipsis;
white-space: normal;
word-break: break-word;
}
.app-card-summary__title-row .app-card-summary__title {
flex: 1 1 auto;
}
.app-card-summary__meta {
display: flex;
overflow: hidden;
align-items: center;
column-gap: 0.5rem;
min-block-size: 1.5rem;
min-inline-size: 0;
}
.app-card-summary--single-action .app-card-summary__content {
padding-inline-end: 3.75rem;
}
.app-card-summary--double-action .app-card-summary__content {
padding-inline-end: 5rem;
}
.app-card-summary--title-subtitle {
padding-block: 0.75rem !important;
}
.app-card-summary--title-subtitle .app-card-summary__content {
justify-content: space-between;
block-size: 100%;
padding-block: 0;
}
.app-card-summary--title-subtitle .app-card-summary__title {
flex: 0 1 auto;
}
.app-card-summary--title-subtitle .app-card-summary__subtitle {
flex-shrink: 0;
}
.app-card-summary__media {
position: absolute;
z-index: 0;
display: flex;
align-items: flex-end;
justify-content: flex-end;
inset-block-end: 0.75rem;
inset-inline-end: 1rem;
pointer-events: none;
}
.app-card-summary--single-action .app-card-summary__media,
.app-card-summary--double-action .app-card-summary__media {
inset-inline-end: 1rem;
}
.app-card-summary__image {
flex-shrink: 0;
block-size: 3.5rem;
inline-size: 3.5rem;
max-block-size: 3.5rem;
max-inline-size: 3.5rem;
min-block-size: 3.5rem;
min-inline-size: 3.5rem;
}
.app-card-summary__image .v-img__img {
object-fit: contain;
}
// Toast通知样式
.Vue-Toastification__container {
z-index: 2500;
@@ -74,17 +211,17 @@ html.v-overlay-scroll-blocked body {
// 路由过渡动画
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.6s;
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(-45px);
transform: translateX(20px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(45px);
transform: translateX(20px);
}
// 网格布局样式
@@ -238,6 +375,122 @@ html.v-overlay-scroll-blocked body {
opacity:0.75;
}
// 紧凑型悬浮操作按钮
.compact-fab-stack {
position: fixed;
z-index: 1100;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
inset-block-end: max(1rem, calc(env(safe-area-inset-bottom) + 1rem));
inset-inline-end: max(1rem, calc(env(safe-area-inset-right) + 1rem));
pointer-events: none;
}
.compact-fab-stack > * {
pointer-events: auto;
}
.compact-fab-stack--history {
inset-block-end: max(4.5rem, calc(env(safe-area-inset-bottom) + 4.5rem));
}
.compact-fab.v-fab {
display: inline-flex;
overflow: visible;
flex: none;
min-inline-size: 0 !important;
pointer-events: auto;
}
.compact-fab .v-fab__container {
position: static;
display: inline-flex;
overflow: visible;
margin: 0 !important;
}
.compact-fab .v-btn {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
backdrop-filter: blur(14px);
box-shadow:
0 16px 34px rgb(15 23 42 / 16%),
0 6px 16px rgb(15 23 42 / 10%);
opacity: 0.98;
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
filter 0.18s ease,
opacity 0.18s ease;
}
.compact-fab--primary .v-btn {
block-size: 3rem !important;
box-shadow:
0 20px 40px rgb(15 23 42 / 20%),
0 8px 18px rgb(15 23 42 / 12%);
inline-size: 3rem !important;
}
.compact-fab--secondary .v-btn {
block-size: 3rem !important;
inline-size: 3rem !important;
}
.compact-fab--primary .v-icon {
font-size: 1.75rem !important;
}
.compact-fab--secondary .v-icon {
font-size: 1.75rem !important;
}
@media (hover: hover) {
.compact-fab .v-btn:hover {
box-shadow:
0 22px 42px rgb(15 23 42 / 22%),
0 8px 18px rgb(15 23 42 / 12%);
filter: saturate(1.03);
transform: translateY(-2px);
}
.compact-fab--primary .v-btn:hover {
box-shadow:
0 26px 46px rgb(15 23 42 / 24%),
0 10px 22px rgb(15 23 42 / 14%);
}
}
.compact-fab .v-btn:active {
box-shadow:
0 10px 22px rgb(15 23 42 / 16%),
0 3px 8px rgb(15 23 42 / 10%);
transform: translateY(0) scale(0.98);
}
@media (width <= 768px) {
.compact-fab-stack {
gap: 0.625rem;
inset-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.875rem));
inset-inline-end: max(0.875rem, calc(env(safe-area-inset-right) + 0.875rem));
}
.compact-fab-stack--history {
inset-block-end: max(4rem, calc(env(safe-area-inset-bottom) + 4rem));
}
.compact-fab--primary .v-btn {
block-size: 3.5rem !important;
inline-size: 3.5rem !important;
}
.compact-fab--secondary .v-btn {
block-size: 3rem !important;
inline-size: 3rem !important;
}
}
.apexcharts-title-text {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
}
@@ -311,7 +564,28 @@ html.v-overlay-scroll-blocked body {
.settings-icon-button {
flex-shrink: 0;
min-inline-size: auto;
border-radius: 0.95rem;
block-size: 2.75rem;
inline-size: 2.75rem;
margin-inline-start: 0.25rem;
min-inline-size: 2.75rem;
}
.settings-icon-button .v-icon {
font-size: 1.35rem;
}
@media (width <= 768px) {
.settings-icon-button {
border-radius: 0.825rem;
block-size: 2.5rem;
inline-size: 2.5rem;
min-inline-size: 2.5rem;
}
.settings-icon-button .v-icon {
font-size: 1.25rem;
}
}
.v-infinite-scroll__side {

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