Compare commits

..

151 Commits

Author SHA1 Message Date
InfinityPacer
4378c4843b feat: 增强登录页交互式 3D Logo (#536)
* feat(login): add interactive optical logo lab

* feat(login): add prismatic logo component

* fix(login): refine optical logo colors and rendering

* feat(login): add matte logo material and sizing

* feat(login): add logo palette toggle

* feat(login): add configurable logo presets

* fix(login): ignore lab route after authentication

* fix(login): keep prism idle motion
2026-07-17 12:33:26 +08:00
jxxghp
5676e0b9b1 feat: 优化识别结果展示,调整图标和标题样式 2026-07-17 09:51:54 +08:00
jxxghp
38a841dc9c feat: 添加自定义识别词功能,支持保存和管理识别规则 2026-07-17 09:47:15 +08:00
jxxghp
6e92673438 feat: 更新金属材质设置,优化视觉效果和层次感 2026-07-17 09:04:21 +08:00
jxxghp
5e2585b210 feat: 添加媒体详情查看功能和相关计算属性 2026-07-17 08:42:22 +08:00
InfinityPacer
c3c323b018 test(subscribe): cover list management workflows (#535) 2026-07-17 06:53:56 +08:00
jxxghp
5390af50ac feat: 重新设计捷径测试结果页面 (#534) 2026-07-16 21:41:10 +08:00
jxxghp
c0f58c7df4 feat: 更新3D金属Logo材质和光照设置,提升视觉效果 2026-07-16 20:27:47 +08:00
kuke2733
9bdf102f7a fix(subscribe): improve mobile files dialog layout (#533) 2026-07-16 19:26:29 +08:00
jxxghp
52df6bef38 fix: adjust logo bevel thickness and depth for improved appearance 2026-07-16 17:40:09 +08:00
kuke2733
c5da08e388 feat(subscribe): “我的订阅”中“文件统计弹窗”支持媒体服务器入库展示 (#531) 2026-07-16 17:39:33 +08:00
InfinityPacer
8a069db55e test(subscribe): cover subscription management flows (#530) 2026-07-16 17:37:43 +08:00
jxxghp
9900042bc3 feat: enhance 3D MetalLogo component with rounded shapes and improved materials 2026-07-16 17:32:00 +08:00
jxxghp
e4f02a326f feat: add 3D MetalLogo component and update dependencies 2026-07-16 15:34:44 +08:00
InfinityPacer
199693abf8 test: introduce frontend unit testing foundation (#529) 2026-07-16 14:53:18 +08:00
jxxghp
32b0c1c00c fix: widen plugin market mobile filters 2026-07-16 12:44:20 +08:00
InfinityPacer
e4d0afc380 ci(pr-agent): use shared review runner (#528) 2026-07-16 06:26:25 +08:00
InfinityPacer
8e89078055 fix(dashboard): refine responsive recommendation controls (#527) 2026-07-16 06:25:50 +08:00
jxxghp
2e0a582d22 fix: scope mobile form layout styles 2026-07-15 13:53:13 +08:00
jxxghp
9c2c4d9fc4 Improve responsive input layouts and mobile control widths 2026-07-15 13:44:12 +08:00
jxxghp
4e0191ba5c fix(calendar): deepen today highlight 2026-07-15 12:41:33 +08:00
jxxghp
9247850411 Update the rule test empty-state icon 2026-07-15 12:38:18 +08:00
jxxghp
29b4854dae fix: allow wrapping mobile multi-select values 2026-07-15 12:06:01 +08:00
jxxghp
338af516be fix: stabilize mobile shortcut validation styles 2026-07-15 10:57:54 +08:00
jxxghp
ea892a31c0 feat: enhance recognition and rule testing features
- Updated maxWidth for NameTestView and RuleTestView dialogs to improve layout.
- Added new localization strings for recognition and rule testing in English, Simplified Chinese, and Traditional Chinese.
- Refactored NameTestView to include detailed analysis steps and summary items for better user feedback.
- Improved error handling and loading states in both NameTestView and RuleTestView.
- Redesigned form layouts for NameTestView and RuleTestView to enhance user experience and accessibility.
- Added computed properties for better data handling and display in the recognition and rule testing processes.
2026-07-15 09:11:27 +08:00
jxxghp
c0f3814560 Add responsive input separators for stacked mobile fields 2026-07-15 08:14:23 +08:00
jxxghp
15f984ba44 fix: ellipsize mobile input values 2026-07-14 22:21:49 +08:00
jxxghp
362c601c7e fix: remove mobile input dividers 2026-07-14 22:06:17 +08:00
jxxghp
4f0204d465 fix: normalize mobile input dividers 2026-07-14 22:03:07 +08:00
jxxghp
d92e79169c Reduce vertical spacing for responsive input rows 2026-07-14 21:53:16 +08:00
jxxghp
563849aab3 fix: hide trailing mobile input divider 2026-07-14 21:36:55 +08:00
jxxghp
b0a27c6405 fix: optimize mobile input layout 2026-07-14 21:25:27 +08:00
jxxghp
b664e0b447 更新 package.json 2026-07-14 13:54:01 +08:00
jxxghp
d625d1ad46 fix: 完善移动端表单响应式布局 2026-07-14 13:51:50 +08:00
jxxghp
cec9563963 feat: 添加响应式输入适配器,优化小屏幕录入控件布局 2026-07-14 12:45:11 +08:00
InfinityPacer
2df3ce1b86 ci(pr-agent): publish native code reviews (#526) 2026-07-13 18:41:32 +08:00
jxxghp
2a2ed9fc6a fix: 修复背景任务图标显示逻辑,优化任务状态展示 2026-07-13 13:58:08 +08:00
jxxghp
0921932601 feat: 优化定时服务状态展示,增强用户体验 2026-07-13 13:45:16 +08:00
jxxghp
50b8ea1bc9 fix: 移除固定表头,优化移动端缓存面板样式 2026-07-13 13:27:51 +08:00
jxxghp
f88ae5643c feat: 使用 ProgressiveCardGrid 组件优化移动端缓存和历史记录展示 2026-07-13 13:19:58 +08:00
jxxghp
cf5f4e5b6a feat: 优化移动端缓存管理界面,增强用户体验 2026-07-13 12:57:11 +08:00
jxxghp
a8db14a069 feat: 支持多数据源识别缓存管理 2026-07-13 12:33:45 +08:00
jxxghp
a20977e522 fix: 优化识别缓存移动端滚动 2026-07-13 11:03:09 +08:00
jxxghp
7efc32637b feat: 支持多类缓存管理 2026-07-13 09:48:08 +08:00
秋澪Akimio
5d1445fe64 fix: 修复识别词编辑器滚动闪烁 (#525) 2026-07-12 14:06:53 +08:00
秋澪Akimio
2070240b50 feat: highlight word list comments (#524) 2026-07-12 12:23:13 +08:00
InfinityPacer
1d130c99a3 docs(plugin): document federation watch workflow (#523) 2026-07-11 19:52:16 +08:00
InfinityPacer
7861afb2d7 feat(plugin): support responsive federated config controls (#521) 2026-07-11 18:16:31 +08:00
jxxghp
5c4511f935 Deduplicate offline connection prompts by status 2026-07-10 07:40:25 +08:00
jxxghp
212ba94e11 chore: update version to 2.14.3 2026-07-09 19:34:35 +08:00
jxxghp
f863a09212 Refine dashboard layout drafts and episode status badges 2026-07-09 19:33:27 +08:00
jxxghp
728ced475b feat: add computed dialog subtitle for download confirmation 2026-07-08 17:19:47 +08:00
InfinityPacer
a192e00db5 ci(pr-agent): simplify review workflow (#518) 2026-07-08 12:51:49 +08:00
jxxghp
85e6b17db7 feat: add agent MCP settings 2026-07-08 08:44:33 +08:00
InfinityPacer
156d835d6d ci(pr-agent): align inline review workflow (#517) 2026-07-08 07:05:24 +08:00
jxxghp
b27eeea6c0 Improve frontend dialog and layout behavior 2026-07-07 08:54:09 +08:00
jxxghp
39a9fe0541 fix: wrap words guide hint 2026-07-06 23:17:35 +08:00
jxxghp
92d153760b fix: sync media type when selecting media 2026-07-06 22:38:10 +08:00
jxxghp
ff9dc26040 feat: support backend i18n responses 2026-07-06 19:13:25 +08:00
jxxghp
269da6eefe fix(login): remove required attribute from stay logged in checkbox 2026-07-06 13:50:41 +08:00
jxxghp
817bc1019d Improve episode group carousel navigation 2026-07-05 20:39:06 +08:00
InfinityPacer
de8c22c180 fix(ui): improve fab layering and cookiecloud hint (#515) 2026-07-05 16:36:55 +08:00
jxxghp
8f074fb381 feat: add llm temperature setting 2026-07-05 13:04:51 +08:00
jxxghp
e16870859d fix: 优化媒体详情按钮布局 2026-07-05 11:56:11 +08:00
InfinityPacer
e1ba13abf2 feat(settings): expose cookiecloud upload secret (#514) 2026-07-05 09:46:14 +08:00
jxxghp
8d1f226035 Delete .codex-qa directory 2026-07-03 21:46:15 +08:00
jxxghp
5367a07a90 fix: remove redundant episode status icon 2026-07-03 21:40:52 +08:00
jxxghp
8e681dbbe7 fix: prevent nested scrolling in words shortcut 2026-07-03 20:32:16 +08:00
jxxghp
c26c3c4284 Remove playing backdrop status badge 2026-07-03 19:51:35 +08:00
jxxghp
461ed560f0 Improve logging dialogs and word rules layout 2026-07-03 19:45:58 +08:00
jxxghp
dd27bcdaca 更新 package.json 2026-07-03 18:47:06 +08:00
jxxghp
c076be868a feat: 桌面端日志级别改为按钮组 2026-07-03 18:06:57 +08:00
jxxghp
d2634095e4 feat: 增加仪表盘配置的更新时间戳和保存队列功能 2026-07-03 17:13:44 +08:00
jxxghp
a5a3e1e1e4 Add new animations and styles for robot actions
- Introduced new SCSS files for various robot actions including shy, sit, sleep, spin-cheer, stretch, and wave.
- Implemented keyframe animations for each action to enhance the robot's interactivity.
- Created a shared stylesheet for common styles and transitions.
- Updated the main CSS file to include all new action styles.
2026-07-03 14:15:10 +08:00
jxxghp
09a20967c0 feat: 增加宠物随机动作和相应的动画效果 2026-07-03 13:32:39 +08:00
jxxghp
9a2af0a412 feat: add agent pet types and state management for idle actions
- Introduced new types for agent pet actions and intents in `types.ts`.
- Implemented `useAgentPetMachine` to manage idle action scheduling and execution.
- Added functionality to handle random actions based on interaction states.
2026-07-03 13:19:30 +08:00
jxxghp
6bbac7db7d refactor: 重新设计词表页面 2026-07-03 08:37:44 +08:00
jxxghp
b212e066b6 添加 .codex-qa 到 .gitignore 2026-07-02 20:29:31 +08:00
jxxghp
f7d354fca4 Refine mobile subscribe cards and grouped list styling 2026-07-02 20:28:03 +08:00
jxxghp
693b081ecf 删除设计 QA 文档 2026-07-02 19:33:14 +08:00
jxxghp
d5f0fed954 Polish app center grouped menu styling 2026-07-02 19:32:39 +08:00
jxxghp
67ef98bfee Hide dashboard recommendation detail panel 2026-07-02 19:08:07 +08:00
jxxghp
1094d80b9c Refine episode group subscriptions and mobile carousel 2026-07-02 16:34:38 +08:00
jxxghp
98e6481812 Refine dashboard recommendations and transparent cards 2026-07-02 15:47:47 +08:00
jxxghp
7db088eb79 更新 package.json 2026-07-02 14:35:38 +08:00
jxxghp
dec154c042 Rename playback status label to continue watching 2026-07-02 12:42:56 +08:00
jxxghp
d06ce5d984 Refine offline status toasts and theme styling 2026-07-02 12:36:49 +08:00
jxxghp
af604d0c5c 增强离线状态管理,添加服务探测功能和连接状态提示 2026-07-02 11:56:34 +08:00
jxxghp
fa39c3750c Limit dashboard recommendations to TMDB sources 2026-07-02 10:51:43 +08:00
jxxghp
ce64fb03ce Add media recommendations dashboard and episode groups 2026-07-01 17:35:35 +08:00
jxxghp
764f14a7f4 Highlight today in full calendar with primary color 2026-07-01 10:37:13 +08:00
jxxghp
621200b3b2 Refine subscribe files dialog layout 2026-07-01 09:57:53 +08:00
jxxghp
60c46ebbaf Make search site dialog fullscreen on small screens 2026-07-01 09:48:41 +08:00
InfinityPacer
4fe65b770e perf(app): throttle inactive realtime blur rendering (#511) 2026-07-01 06:01:50 +08:00
jxxghp
c010162e54 fix subscribe files duplicate stats 2026-06-30 22:26:11 +08:00
jxxghp
5cc4ea7740 fix dashboard auto height growth 2026-06-30 22:22:47 +08:00
jxxghp
8a19fbc584 fix(subscribe): 调整统计卡片移动端布局 2026-06-30 21:33:57 +08:00
jxxghp
24412989bb fix(subscribe): 修复文件弹窗移动端布局 2026-06-30 21:20:41 +08:00
jxxghp
55d0693e41 fix(subscribe): 优化文件统计弹窗移动端样式 2026-06-30 20:49:52 +08:00
jxxghp
0459a2d108 feat(subscribe): 更新下拉菜单项并添加媒体详情和文件统计本地化字符串 2026-06-30 20:31:27 +08:00
jxxghp
624b55576d Refine login glass effect and plugin menu icon 2026-06-30 20:23:23 +08:00
jxxghp
c848b90599 Split movie release dates and polish poster radius 2026-06-30 20:13:37 +08:00
jxxghp
79bc99e624 feat(theme): 使用按钮切换替代标签,优化订阅文件选项卡样式 2026-06-30 20:02:32 +08:00
jxxghp
850f7ee9be Update subscription file terminology and add new localization strings
- Changed title from 'Downloaded Files' to 'Subscription Files' in English, Simplified Chinese, and Traditional Chinese locales.
- Added new localization strings related to subscription files, including status messages and file management terms across all three language files.
2026-06-30 20:02:32 +08:00
InfinityPacer
4cc105287f fix(auth): decouple webpush subscription from login (#510) 2026-06-30 19:59:51 +08:00
InfinityPacer
527c0845bc fix(theme): stabilize transparent background transitions (#509) 2026-06-30 19:16:31 +08:00
jxxghp
2a3ef1a1f6 fix theme border surfaces 2026-06-30 19:05:26 +08:00
jxxghp
7539fa26aa feat(theme): 更新主题定制器边框样式,优化视觉效果
fix(locales): 修改英文和中文边框相关文本
style(app): 调整应用界面边框和阴影样式
2026-06-30 18:42:39 +08:00
jxxghp
3547c146a8 Use overlay shadow for pull indicator 2026-06-30 18:03:32 +08:00
jxxghp
da630b0966 feat(theme): 添加主题定制器的圆角和阴影示例,优化透明主题下的视觉效果 2026-06-30 17:43:07 +08:00
InfinityPacer
cff4c1fecc fix(theme): restore transparent glass overlays (#508) 2026-06-30 14:47:42 +08:00
jxxghp
3f525c6457 fix: adjust dialog fullscreen breakpoint by width 2026-06-30 14:17:04 +08:00
jxxghp
8c1eb3e981 feat: add fullscreen support to TransparencySettingsDialog based on display size 2026-06-30 13:59:53 +08:00
InfinityPacer
effc83b9a1 feat: add transparent glass quality options (#507)
* feat(theme): add lightweight transparent glass mode

* perf(theme): reduce realtime glass backdrop filters

* fix(theme): align transparency presets
2026-06-30 13:39:22 +08:00
jxxghp
39ce8d93dd feat: add rounded corners to weekly bar chart for improved aesthetics and Safari compatibility 2026-06-30 13:37:00 +08:00
jxxghp
fa32ac9f48 feat: enhance compact FAB styles with backdrop filter and improved hover effects 2026-06-30 13:15:46 +08:00
jxxghp
ab243bbbb7 refactor: streamline transfer queue logic and improve styling 2026-06-30 12:45:10 +08:00
jxxghp
abcd3f5e81 refactor: simplify transfer queue header layout and styles 2026-06-30 12:41:53 +08:00
jxxghp
92bf1ca790 refine transfer queue layout 2026-06-30 12:07:36 +08:00
jxxghp
faadf39b80 feat: enhance transfer queue localization and UI improvements 2026-06-30 11:30:48 +08:00
jxxghp
878c3c98b2 fix: use native login inputs 2026-06-30 06:51:57 +08:00
jxxghp
2d8ae9f462 Refine folded FAB appearance 2026-06-30 06:24:36 +08:00
jxxghp
00051a0eac Fix dashboard settings and FAB folding 2026-06-30 06:21:37 +08:00
jxxghp
88726c0f12 Update dashboard localization labels 2026-06-29 22:37:44 +08:00
jxxghp
79bc9f0ff0 Make dashboard media grids responsive to screen size 2026-06-29 22:20:31 +08:00
jxxghp
4492e211bb 添加重新加载仪表板网格小部件的功能,以清理 GridStack 内部响应式布局缓存 2026-06-29 22:09:13 +08:00
jxxghp
bb5299c812 Fix dashboard media grid layout persistence and capacity 2026-06-29 22:07:31 +08:00
jxxghp
f54a5285d6 Polish library cards and storage dashboard 2026-06-29 21:47:37 +08:00
jxxghp
03b80d6434 添加 PlayingBackdropCard 组件并更新相关视图,优化媒体服务器设置和播放列表的加载逻辑 2026-06-29 21:20:00 +08:00
jxxghp
a916a1569d Fix dashboard task status and recent imports count 2026-06-29 20:34:03 +08:00
jxxghp
5c27c32914 Use dynamic viewport height for panels 2026-06-29 20:06:42 +08:00
jxxghp
c57a8b7b48 Switch panels to svh and native theme scrolling 2026-06-29 19:56:18 +08:00
jxxghp
0aa7d37e45 Fix panel viewport sizing on mobile 2026-06-29 19:54:06 +08:00
jxxghp
d3b3b08318 Preserve full path cache key for browse page 2026-06-29 19:40:31 +08:00
jxxghp
5127ab6c08 优化探索标签排序弹窗样式 2026-06-29 19:12:48 +08:00
jxxghp
a3810fb9e2 style(NotificationTemplateEditorDialog): adjust overflow property and update min-block-size for template editor
style(SearchBarDialog): change search icon and adjust dropdown width
style(common.scss): refine hover effects and update shadow settings for card components
style(AccountSettingNotification): improve layout and spacing for notification settings
2026-06-29 18:02:38 +08:00
InfinityPacer
f05eac0b93 fix(agent): stabilize assistant entry dragging (#505) 2026-06-29 15:50:29 +08:00
jxxghp
ef170b54c6 style(SearchBarDialog): adjust search desktop activator dimensions and disable specificity warning 2026-06-29 13:55:05 +08:00
jxxghp
536844d291 refactor(dashboard): remove unused color management logic 2026-06-29 13:48:59 +08:00
jxxghp
21d4f9fd73 Guard dashboard profile switches and tighten chart axis padding 2026-06-29 09:36:18 +08:00
jxxghp
2aae0f08ad 删除设计 QA 文档 2026-06-29 08:50:47 +08:00
jxxghp
248984261e Refine dashboard scheduler task layout 2026-06-29 08:49:52 +08:00
jxxghp
82ab594e0c fix(charts): disable zoom interactions 2026-06-29 08:08:53 +08:00
jxxghp
5ca374349d fix(dashboard): refine scheduler task list 2026-06-29 07:54:35 +08:00
jxxghp
7452e18fc3 feat(dashboard): refine network and scheduler visuals 2026-06-29 07:37:03 +08:00
jxxghp
75dffdb7c2 更新 package.json 2026-06-29 06:58:57 +08:00
jxxghp
a3d74929b3 feat(dashboard): add memory and scheduler progress interfaces, enhance dashboard layout and responsiveness 2026-06-28 22:59:36 +08:00
jxxghp
fd9c314d55 Refine card hover borders across subtitle and torrent cards 2026-06-28 21:39:29 +08:00
jxxghp
d02e98736f feat(calendar): enhance loading behavior and mobile layout for subscriptions 2026-06-28 21:22:43 +08:00
jxxghp
2e46ebe353 更新 package.json 2026-06-28 17:54:28 +08:00
jxxghp
fffb526a87 feat(dashboard): enhance media statistics and memory analytics
- Added monthly media counts (movies, TV shows, episodes) to the AnalyticsMediaStatistic component.
- Improved layout and styling for better responsiveness in the dashboard summary.
- Updated AnalyticsMemory component to display total and available memory with enhanced visuals.
- Refactored AnalyticsScheduler to include transfer queue management and improved task display.
- Enhanced AnalyticsSpeed component with a more structured speed overview.
- Introduced new components for quick actions and recent imports in the dashboard.
- Added system information display with runtime and version details in DashboardSystemInfo.
- Created a new composable for shortcut tools to streamline dialog management.
2026-06-28 17:48:50 +08:00
172 changed files with 29437 additions and 5436 deletions

1
.env.test Normal file
View File

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

View File

@@ -1,9 +1,8 @@
name: PR Agent
name: PR-Agent
on:
pull_request_target:
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
# pull_request_target 允许 fork PR 使用仓库 secrets因此 workflow 只运行固定 digest 的 PR-Agent 容器。
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
types:
- opened
- reopened
@@ -11,24 +10,17 @@ on:
- review_requested
- synchronize
issue_comment:
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
# issue_comment 同时覆盖普通 issue因此 job 里还会再判断是否属于 PR。
types:
- created
- edited
permissions:
# 读取仓库内容和 PR diff。
contents: read
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
pull-requests: write
# PR 评论在 GitHub API 中属于 issue comments手动命令和总结评论需要该权限。
issues: write
jobs:
pr-agent:
name: PR-Agent review and describe
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
if: >-
github.event.sender.type != 'Bot' &&
(
@@ -36,88 +28,25 @@ jobs:
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
(
github.event.comment.body == '/review' ||
startsWith(github.event.comment.body, '/review ') ||
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ') ||
github.event.comment.body == '/improve' ||
startsWith(github.event.comment.body, '/improve ') ||
github.event.comment.body == '/ask' ||
startsWith(github.event.comment.body, '/ask ')
)
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association)
)
)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Run PR-Agent
id: pragent
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
uses: docker://pragent/pr-agent:0.37.0-github_action@sha256:4ec7bac814050a1bc8c96ab2fab6b7b0f65df0049a5ec43f3fee1a0b551c28ca
- name: Run PR Review
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
env:
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
GITHUB_TOKEN: ${{ github.token }}
# 仓库设置中添加的 SecretSettings -> Secrets and variables -> Actions。
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
# 模型、输出语言和大 diff 处理策略。
config.model: "gpt-5.5"
config.fallback_models: '["gpt-5.4"]'
config.reasoning_effort: "xhigh"
config.ai_timeout: "900"
config.response_language: "zh-CN"
config.large_patch_policy: "clip"
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
config.ignore_pr_labels: '["skip pr-agent"]'
# pull_request_target 事件默认自动执行 /review 和 /describe/improve 保持手动触发。
github_action_config.auto_review: "true"
github_action_config.auto_describe: "true"
github_action_config.auto_improve: "false"
# 允许触发自动工具的 PR 动作。包含 synchronize便于新 commit 推送后刷新结果。
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
# 保留 action outputs便于后续 workflow 编排或排查。
github_action_config.enable_output: "true"
# /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略。
pr_description.generate_ai_title: "false"
pr_description.publish_labels: "false"
pr_description.enable_pr_diagram: "false"
pr_description.collapsible_file_list: "adaptive"
pr_description.add_original_user_description: "true"
# /review 输出策略,聚焦维护者需要处理的风险和缺口。
pr_reviewer.extra_instructions: |
请用中文输出。
优先指出 P0/P1 风险,避免纠结纯格式问题。
重点检查安全、权限、状态一致性、异步/缓存、副作用和测试缺口。
pr_reviewer.num_max_findings: "5"
pr_reviewer.persistent_comment: "true"
pr_reviewer.publish_output_no_suggestions: "true"
pr_reviewer.require_tests_review: "true"
pr_reviewer.require_security_review: "true"
pr_reviewer.require_estimate_effort_to_review: "true"
pr_reviewer.require_can_be_split_review: "true"
pr_reviewer.require_todo_scan: "false"
pr_reviewer.enable_review_labels_effort: "false"
pr_reviewer.enable_review_labels_security: "true"
# /improve 和 /ask 的手动命令策略。
pr_code_suggestions.focus_only_on_problems: "true"
pr_code_suggestions.suggestions_score_threshold: "7"
pr_code_suggestions.commitable_code_suggestions: "false"
pr_questions.use_conversation_history: "true"
# 可选成本和噪音控制:
# github_action_config.auto_improve: "true"
# config.verbosity_level: "1"
# pr_reviewer.num_max_findings: "3"
PRR_AUTO_REVIEW_SCOPE: all
PRR_ALLOWED_ASSOCIATIONS: '["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'
PRR_DISABLED_COMMANDS: '["/improve"]'
PRR_SKIP_LABEL: skip pr-agent
PRR_SKIP_TITLE_PATTERN: '^(?:\[Auto\]|Auto)'
config.response_language: zh-CN

37
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Frontend Tests
on:
pull_request:
branches:
- v2
permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '24'
cache: yarn
cache-dependency-path: yarn.lock
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Typecheck
run: yarn typecheck
- name: Unit tests with coverage
run: yarn test:coverage

2
.gitignore vendored
View File

@@ -12,6 +12,7 @@ node_modules
dist
dist-ssr
dev-dist
coverage
*.local
package-lock.json
@@ -38,3 +39,4 @@ public/plugin_icon/**
docs-lock/
.trae/
output/
.codex-qa/

View File

@@ -40,6 +40,15 @@ yarn dev
yarn build
```
### 单元测试
```sh
yarn test:run
yarn test:coverage
```
测试文件组织、共享测试设施、HTTP mock、覆盖率门禁和新增用例规范见[单元测试架构](docs/testing.md)。
### 静态运行
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件nginx配置参考 `public/nginx.conf`

View File

@@ -449,21 +449,16 @@ shared: {
}
```
### 8.3 开发环境测试
### 8.3 本地监听构建
开发期间可以使用以下配置在本地测试
插件前端可使用 Vite 的监听构建模式
```typescript
// vite.config.ts
export default defineConfig({
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
}
})
```bash
yarn dev
```
`dev` 脚本配置为 `vite build --watch` 后,源码变化会自动重新构建。使用本地插件仓并启用 `DEV``PLUGIN_AUTO_RELOAD`MoviePilot 会同步新的构建产物;刷新页面即可看到修改。
## 9. 示例代码
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目

94
docs/testing.md Normal file
View File

@@ -0,0 +1,94 @@
# 单元测试架构
MoviePilot-Frontend 使用 Vitest 运行单元测试和组件测试,使用 jsdom 提供 DOM 环境。测试代码参与 TypeScript 类型检查,但不作为生产构建入口。
## 测试类型
- 单元测试覆盖纯函数、store、composable、路由规则和独立模块的输入、输出及副作用。
- 组件测试挂载 Vue 组件或页面,覆盖 props、emits、用户交互、可见 DOM、Router、Pinia、HTTP 请求和生命周期清理。
- PWA、Service Worker、模块联邦远程入口、真实布局、拖拽和浏览器原生能力由真实浏览器验证不由 jsdom 测试单独证明。
## 目录结构
业务 spec 与源码共置在对应责任域的 `__tests__/` 目录中,文件名与被测源码保持一致并使用 `*.spec.ts`
```text
src/
├── pages/
│ ├── recommend.vue
│ └── __tests__/recommend.spec.ts
├── stores/
│ ├── auth.ts
│ └── __tests__/auth.spec.ts
├── utils/
│ ├── permission.ts
│ └── __tests__/permission.spec.ts
└── views/dashboard/
├── MediaRecommend.vue
└── __tests__/MediaRecommend.spec.ts
```
跨业务 spec 复用的测试设施位于 `tests/`
```text
tests/
├── setup.ts
└── support/
├── render.ts
├── factories/
└── msw/
├── server.ts
└── handlers/
```
- `tests/setup.ts` 注册 DOM matcher、MSW 生命周期、浏览器 API stub 和每例清理逻辑。
- `tests/support/render.ts` 提供带 Vuetify、i18n、Router 和 Pinia 的标准渲染入口。
- `tests/support/factories/` 按业务对象提供最小有效测试数据工厂。
- `tests/support/msw/handlers/` 按业务域定义 HTTP handler`server.ts` 只负责 MSW server 实例。
- spec 通过 `@tests/*` 访问共享测试设施,通过 `@/*` 访问生产源码。
## 工具职责
- Vitest 提供 runner、断言、mock、fake timers 和覆盖率执行入口。
- Vue Test Utils 用于 Vue 特有的 props、emits、slots 和局部组件控制。
- Testing Library、jest-dom 和 user-event 用于按角色、可访问名称和用户操作验证可见行为。
- MSW 在 HTTP 边界拦截真实 API 客户端请求。未声明请求会使测试失败,测试不得访问真实后端或外网。
- `@pinia/testing` 用于依赖 store 的组件测试store 自身使用真实 `createPinia()` 测试。
## 编写规范
- 一个 spec 对应一个主要源码文件;测试名称描述可观察行为或业务规则。
- 组件测试断言可见 DOM、emits、路由、请求和持久化结果不读取组件私有状态或私有方法。
- 纯逻辑优先直接调用依赖生命周期、provide 或 inject 的 composable 通过宿主组件挂载。
- HTTP handler 和 factory 按业务域拆分,不建立包含所有接口或所有数据字段的全局万能 mock。
- 只在与当前断言无关或无法由 jsdom 正确执行时 stub 子组件、浏览器能力或第三方重型组件。
- 每个用例保持独立不依赖文件执行顺序timer、mock、storage、DOM 和未完成请求由全局 setup 恢复。
- 不使用大面积快照或覆盖率占位用例。
## 新增测试
1. 在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`
2. 纯函数、store 和无渲染模块直接使用 VitestVue 组件使用标准渲染入口。
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
5. 核心覆盖范围发生变化时,同步更新 `vite.config.ts``coverage.include`
6. 提交前运行测试、覆盖率、类型检查、lint 和生产构建。
## 配置边界
Vitest 只收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。
当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%Branches 不低于 75%。覆盖率报告写入 `coverage/`
## 命令与 CI
```sh
yarn test # watch 模式
yarn test:run # 单次运行
yarn test:coverage # 单次运行并检查覆盖率
yarn typecheck
yarn lint
yarn build
```
Pull Request 测试工作流使用 Node 24 LTS 和 frozen lockfile依次执行类型检查和覆盖率门禁。现有 lint 基线问题按仓库当前维护约定单独处理,新增测试代码不得引入新的 lint 错误。

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.13.16",
"version": "2.14.4",
"private": true,
"type": "module",
"bin": "dist/service.js",
@@ -9,6 +9,9 @@
"prebuild": "npm run build:icons",
"build": "vite build",
"preview": "vite preview --port 5050",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
@@ -20,6 +23,10 @@
"dist/**/*"
]
},
"resolutions": {
"vitest/**/vite": "5.4.18",
"vitest/vite": "5.4.18"
},
"dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
@@ -50,6 +57,7 @@
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"gridstack": "^12.6.0",
"gsap": "^3.15.0",
"http-proxy-middleware": "^3.0.0",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",
@@ -57,11 +65,12 @@
"markdown-it-link-attributes": "^4.0.1",
"mousetrap": "^1.6.5",
"nprogress": "^0.2.0",
"pinia": "^3.0.1",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.2.0",
"qrcode": "^1.5.4",
"sass": "^1.83.4",
"tailwindcss": "^ 3.4.17",
"three": "^0.185.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"vue-toastification": "^2.0.0-rc.5",
@@ -82,7 +91,12 @@
"@iconify/vue": "^4.3.0",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@originjs/vite-plugin-federation": "^1.4.1",
"@pinia/testing": "1.0.3",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@testing-library/dom": "9.3.4",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/user-event": "14.6.1",
"@testing-library/vue": "8.1.0",
"@types/body-scroll-lock": "^3.1.2",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2",
@@ -91,11 +105,15 @@
"@types/node": "^20.1.4",
"@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.6",
"@types/three": "^0.185.1",
"@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitest/coverage-v8": "3.2.7",
"@vue/compiler-dom": "3.5.13",
"@vue/test-utils": "2.4.11",
"autoprefixer": "^10.4.14",
"eslint": "^9.18.0",
"eslint-import-resolver-typescript": "^3.5.1",
@@ -105,6 +123,8 @@
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-vue": "^9.12.0",
"jsdom": "26.1.0",
"msw": "2.15.0",
"postcss": "^8.5.1",
"postcss-html": "^1.5.0",
"stylelint": "^16.13.2",
@@ -123,6 +143,7 @@
"vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.4",
"vitest": "3.2.7",
"vue-shepherd": "^4.1.0",
"vue-tsc": "^2.0.10",
"workbox-build": "^7.3.0",

View File

@@ -3,6 +3,9 @@
// 👉 Apex chart
.apexcharts-canvas {
// 图表仅保留纵向页面滚动,禁止双指缩放等触摸手势。
touch-action: pan-y;
// For RTL alignment
.apexcharts-yaxis-texts-g {
text-align: start;

View File

@@ -1,6 +1,6 @@
import type { Component, Ref, VNode } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { UserPermissionKey } from '@/utils/permission'
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
import { ContentWidth, FooterType, NavbarType } from './enums'
export interface UserConfig {
@@ -113,14 +113,18 @@ export interface NavLinkProps {
rel?: ATagRelAttrValues
}
export type NavMenuIconColor = 'primary' | 'info' | 'success' | 'warning' | 'secondary'
export interface NavLink extends NavLinkProps, Partial<AclProperties> {
title: string
full_title?: string
icon?: unknown
iconColor?: NavMenuIconColor
badgeContent?: string
badgeClass?: string
disable?: boolean
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
export interface NavMenuTabItem {
@@ -128,6 +132,8 @@ export interface NavMenuTabItem {
icon?: string
tab: string
description?: string
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
export interface NavMenu extends NavLink {

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useTheme } from 'vuetify'
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
import api from '@/api'
import api, { type ConnectionAwareRequestConfig } from '@/api'
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
import { SupportedLocale } from '@/types/i18n'
@@ -12,13 +12,25 @@ import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundMan
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
import {
applyStoredTransparencySettings,
TRANSPARENCY_SETTINGS_CHANGED_EVENT,
type TransparencyGlassQuality,
type TransparencySettings,
} from '@/composables/useTransparencySettings'
import { completeLaunchLoading } from '@/composables/useLaunchLoading'
import { usePWA } from '@/composables/usePWA'
import { themeManager } from '@/utils/themeManager'
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
import { configureApexChartsTheme } from '@/utils/apexCharts'
import {
useGlobalOfflineStatus,
type ConnectionFailureReason,
} from '@/composables/useOfflineStatus'
const LOGIN_WALLPAPER_ROUTE = '/login'
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 180_000
// 生效主题
const vuetifyTheme = useTheme()
@@ -48,6 +60,7 @@ const authStore = useAuthStore()
const isLogin = computed(() => authStore.token)
const route = useRoute()
const { initializePWA } = usePWA()
const offlineStatus = useGlobalOfflineStatus()
// 全局设置store
const globalSettingsStore = useGlobalSettingsStore()
@@ -58,66 +71,233 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
// 背景图片
const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const previousImageIndex = ref<number | null>(null)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
const shouldLoadBackgroundImages = computed(
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
)
const transparentBackgroundBlur = ref(16)
const transparencyGlassQuality = ref<TransparencyGlassQuality>(
localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight',
)
const shouldRenderGlobalBlurLayer = computed(
() =>
shouldUseTransparentBackgroundTreatment.value &&
transparentBackgroundBlur.value > 0 &&
transparencyGlassQuality.value === 'realtime',
)
const isRenderThrottled = ref(document.visibilityState === 'hidden')
let backgroundRetryTimer: number | null = null
let backgroundRequestController: AbortController | null = null
let backgroundCrossfadeTimer: number | null = null
let authenticatedStateTimer: number | null = null
let windowBlurRenderThrottleTimer: number | null = null
function getStoredNumber(key: string, fallback: number, min: number, max: number) {
const parsed = Number.parseFloat(localStorage.getItem(key) || '')
if (!Number.isFinite(parsed)) return fallback
// 读取并同步透明主题背景设置到根组件响应式状态。
function applyTransparentBackgroundSettings() {
const settings = applyStoredTransparencySettings()
return Math.min(max, Math.max(min, parsed))
transparentBackgroundBlur.value = settings.backgroundBlur
transparencyGlassQuality.value = settings.glassQuality
}
function applyTransparentBackgroundSettings() {
document.documentElement.style.setProperty(
'--transparent-background-poster-opacity',
(1 - getStoredNumber('transparency-background-poster-opacity', 0, 0, 1)).toString(),
)
document.documentElement.style.setProperty(
'--transparent-background-blur',
`${getStoredNumber('transparency-background-blur', 16, 0, 30)}px`,
)
// 响应透明主题设置变更事件,刷新背景模糊和玻璃质量。
function handleTransparencySettingsChanged(event: Event) {
const { backgroundBlur, glassQuality } = (event as CustomEvent<TransparencySettings>).detail
transparentBackgroundBlur.value = backgroundBlur
transparencyGlassQuality.value = glassQuality
}
applyTransparentBackgroundSettings()
// 心跳检测
let heartbeatInterval: number | null = null
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
function clearWindowBlurRenderThrottleTimer() {
if (windowBlurRenderThrottleTimer) {
window.clearTimeout(windowBlurRenderThrottleTimer)
windowBlurRenderThrottleTimer = null
}
}
// 启动心跳
const startHeartbeat = () => {
// 如果已经有心跳,则先停止
if (heartbeatInterval) {
stopHeartbeat()
function restoreForegroundRendering() {
const wasRenderThrottled = isRenderThrottled.value
clearWindowBlurRenderThrottleTimer()
isRenderThrottled.value = false
if (wasRenderThrottled && backgroundImages.value.length > 1) {
startBackgroundRotation()
rotateBackgroundImage()
}
}
function throttleBackgroundRendering() {
clearWindowBlurRenderThrottleTimer()
resetBackgroundCrossfade()
isRenderThrottled.value = true
}
function handleWindowBlurRenderThrottle() {
clearWindowBlurRenderThrottleTimer()
if (document.visibilityState === 'hidden') {
throttleBackgroundRendering()
return
}
// 开始心跳任务
heartbeatInterval = window.setInterval(async () => {
try {
if (isLogin.value) {
await api.get('system/ping')
}
} catch (error) {
console.warn('Heartbeat request failed:', error)
windowBlurRenderThrottleTimer = window.setTimeout(() => {
if (document.visibilityState === 'visible' && !document.hasFocus()) {
isRenderThrottled.value = true
}
windowBlurRenderThrottleTimer = null
}, WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS)
}
function handleWindowFocusRenderThrottle() {
if (document.visibilityState === 'visible') {
restoreForegroundRendering()
}
}
let heartbeatInterval: number | null = null
let connectionRetryTimer: number | null = null
let connectionProbePromise: Promise<boolean> | null = null
let connectionProbeFailures = 0
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
const SERVER_PROBE_TIMEOUT_MS = 8_000
const SERVER_PROBE_FAILURE_THRESHOLD = 2
const SERVER_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000] as const
/** 清除等待中的服务重连任务。 */
function clearConnectionRetryTimer() {
if (!connectionRetryTimer) return
window.clearTimeout(connectionRetryTimer)
connectionRetryTimer = null
}
/** 根据浏览器状态和请求错误判断本次探测失败原因。 */
function resolveProbeFailureReason(error: unknown): ConnectionFailureReason {
if (!offlineStatus.browserOnline.value) return 'browser-offline'
const errorCode = (error as { code?: string } | null)?.code
if (errorCode === 'ECONNABORTED' || errorCode === 'ETIMEDOUT') return 'timeout'
return 'server-unreachable'
}
/** 按退避间隔安排下一次 MoviePilot 服务探测。 */
function scheduleConnectionRetry() {
clearConnectionRetryTimer()
const retryIndex = Math.min(Math.max(connectionProbeFailures - 1, 0), SERVER_RETRY_DELAYS_MS.length - 1)
connectionRetryTimer = window.setTimeout(() => {
connectionRetryTimer = null
void probeServerConnection()
}, SERVER_RETRY_DELAYS_MS[retryIndex])
}
/** 使用后端 ping 接口执行去重后的权威服务连通性探测。 */
async function probeServerConnection(showChecking = false): Promise<boolean> {
if (!isLogin.value) return false
if (connectionProbePromise) return connectionProbePromise
clearConnectionRetryTimer()
if (showChecking) offlineStatus.markConnectionChecking(offlineStatus.connectionReason.value ?? undefined)
const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value
const probePromise = (async () => {
try {
await api.get(
'system/ping',
{
skipConnectionTracking: true,
timeout: SERVER_PROBE_TIMEOUT_MS,
} as ConnectionAwareRequestConfig,
)
connectionProbeFailures = 0
return true
} catch (error) {
if (!isLogin.value) {
offlineStatus.markServerOnline()
return false
}
// 探测期间若已有其他接口成功,则以更新的成功响应为准,避免旧失败覆盖新状态。
if (offlineStatus.serverSuccessSequence.value > successSequenceAtProbeStart) {
connectionProbeFailures = 0
return true
}
connectionProbeFailures += 1
const failureReason = resolveProbeFailureReason(error)
if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) {
offlineStatus.markServerOffline(failureReason)
} else {
offlineStatus.markConnectionChecking(failureReason)
}
scheduleConnectionRetry()
return false
}
})()
connectionProbePromise = probePromise
try {
return await probePromise
} finally {
if (connectionProbePromise === probePromise) connectionProbePromise = null
}
}
/** 启动即时服务探测和五分钟在线心跳。 */
function startHeartbeat() {
if (heartbeatInterval) window.clearInterval(heartbeatInterval)
void probeServerConnection()
heartbeatInterval = window.setInterval(async () => {
if (isLogin.value) await probeServerConnection()
}, 5 * 60 * 1000)
}
// 停止心跳
const stopHeartbeat = () => {
/** 停止心跳和等待中的自动重连任务。 */
function stopHeartbeat() {
if (heartbeatInterval) {
window.clearInterval(heartbeatInterval)
heartbeatInterval = null
}
clearConnectionRetryTimer()
connectionProbeFailures = 0
}
watch(
() => offlineStatus.connectionCheckRequestId.value,
() => {
if (isLogin.value) void probeServerConnection(true)
},
)
watch(
() => offlineStatus.connectionStatus.value,
status => {
if (status !== 'online') return
connectionProbeFailures = 0
clearConnectionRetryTimer()
},
)
watch(
() => offlineStatus.browserOnline.value,
browserIsOnline => {
if (!isLogin.value) return
offlineStatus.requestConnectionCheck(browserIsOnline ? undefined : 'browser-offline')
},
)
// 更新data-theme属性以便CSS选择器能正确匹配
function updateHtmlThemeAttribute(themeName: string) {
document.documentElement.setAttribute('data-theme', themeName)
@@ -125,6 +305,7 @@ function updateHtmlThemeAttribute(themeName: string) {
syncRootLaunchPalette()
}
// 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。
function syncThemePreferenceFromStorage() {
themeValue = localStorage.getItem('theme') || 'auto'
@@ -148,22 +329,60 @@ function syncThemePreferenceFromStorage() {
})
}
// 系统配色变化时,在自动主题模式下刷新当前实际主题。
function handleSystemThemeChange() {
if ((localStorage.getItem('theme') || 'auto') === 'auto') {
syncThemePreferenceFromStorage()
}
}
/** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
function handleVisibilityThemeSync() {
if (document.visibilityState === 'visible') {
restoreForegroundRendering()
syncThemePreferenceFromStorage()
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
} else {
throttleBackgroundRendering()
}
}
/** 页面从缓存或重新聚焦恢复时刷新主题偏好和异常连接状态。 */
function handlePageShowThemeSync() {
if (document.visibilityState === 'visible') {
restoreForegroundRendering()
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
}
syncThemePreferenceFromStorage()
}
// 清理背景图交叉淡入淡出定时器。
function clearBackgroundCrossfadeTimer() {
if (backgroundCrossfadeTimer) {
window.clearTimeout(backgroundCrossfadeTimer)
backgroundCrossfadeTimer = null
}
}
// 重置背景图交叉淡入淡出状态。
function resetBackgroundCrossfade() {
clearBackgroundCrossfadeTimer()
previousImageIndex.value = null
}
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
function activateBackgroundImage(nextIndex: number) {
if (nextIndex === activeImageIndex.value) return
clearBackgroundCrossfadeTimer()
previousImageIndex.value = activeImageIndex.value
activeImageIndex.value = nextIndex
backgroundCrossfadeTimer = window.setTimeout(() => {
previousImageIndex.value = null
backgroundCrossfadeTimer = null
}, BACKGROUND_CROSSFADE_DURATION_MS)
}
// 获取背景图片
async function fetchBackgroundImages() {
try {
@@ -172,6 +391,7 @@ async function fetchBackgroundImages() {
backgroundImages.value = await api.get(`/login/wallpapers`, {
signal: backgroundRequestController.signal,
})
resetBackgroundCrossfade()
activeImageIndex.value = 0
} catch (e) {
throw e
@@ -180,6 +400,8 @@ async function fetchBackgroundImages() {
// 背景图片轮换函数
function rotateBackgroundImage() {
if (isRenderThrottled.value) return
if (backgroundImages.value.length > 1) {
// 计算下一个图片索引
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
@@ -187,7 +409,7 @@ function rotateBackgroundImage() {
preloadImage(backgroundImages.value[nextIndex]).then(success => {
// 只有图片成功加载才切换
if (success) {
activeImageIndex.value = nextIndex
activateBackgroundImage(nextIndex)
}
})
}
@@ -212,6 +434,7 @@ function startBackgroundRotation() {
}
}
// 停止登录页或透明主题背景图加载、重试和轮播。
function stopBackgroundLoading() {
backgroundRequestController?.abort()
backgroundRequestController = null
@@ -221,9 +444,11 @@ function stopBackgroundLoading() {
backgroundRetryTimer = null
}
resetBackgroundCrossfade()
removeBackgroundTimer('background-rotation')
}
// 初始化登录后的全局设置和用户设置状态。
async function initializeAuthenticatedState() {
if (!isLogin.value) return
@@ -236,6 +461,7 @@ async function initializeAuthenticatedState() {
}
}
// 延迟初始化登录态数据,避开登录成功后的即时路由跳转窗口。
function scheduleAuthenticatedStateInitialization() {
if (authenticatedStateTimer) {
window.clearTimeout(authenticatedStateTimer)
@@ -360,6 +586,9 @@ onMounted(async () => {
document.addEventListener('visibilitychange', handleVisibilityThemeSync)
window.addEventListener('pageshow', handlePageShowThemeSync)
window.addEventListener('focus', handlePageShowThemeSync)
window.addEventListener('focus', handleWindowFocusRenderThrottle)
window.addEventListener('blur', handleWindowBlurRenderThrottle)
window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
watch(
@@ -395,6 +624,7 @@ onMounted(async () => {
authenticatedStateTimer = null
}
stopHeartbeat()
offlineStatus.markServerOnline()
}
})
})
@@ -406,6 +636,7 @@ onUnmounted(() => {
window.clearTimeout(authenticatedStateTimer)
authenticatedStateTimer = null
}
clearWindowBlurRenderThrottleTimer()
// 停止心跳
stopHeartbeat()
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
@@ -413,26 +644,33 @@ onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
window.removeEventListener('pageshow', handlePageShowThemeSync)
window.removeEventListener('focus', handlePageShowThemeSync)
window.removeEventListener('focus', handleWindowFocusRenderThrottle)
window.removeEventListener('blur', handleWindowBlurRenderThrottle)
window.removeEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
})
</script>
<template>
<div class="app-wrapper">
<div class="app-wrapper" :class="{ 'app-wrapper--render-throttled': isRenderThrottled }">
<!-- 透明主题背景 -->
<div
v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)"
class="background-container"
:class="{ 'is-transparent-theme': isTransparentTheme && isLogin }"
:class="{
'is-transparent-theme': shouldUseTransparentBackgroundTreatment,
'is-transparent-glass-lightweight':
shouldUseTransparentBackgroundTreatment && transparencyGlassQuality === 'lightweight',
}"
>
<div
v-for="(imageUrl, index) in backgroundImages"
:key="`bg-${index}-${loginStateKey}`"
class="background-image"
:class="{ 'active': index === activeImageIndex }"
:class="{ 'active': index === activeImageIndex, 'previous': index === previousImageIndex }"
:style="{ 'backgroundImage': `url(${imageUrl})` }"
/>
<!-- 全局磨砂层 -->
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
</div>
<!-- 页面内容 -->
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
@@ -486,18 +724,36 @@ onUnmounted(() => {
}
&.active {
z-index: 2;
opacity: 1;
}
&.previous {
z-index: 1;
}
}
.background-container.is-transparent-theme .background-image.active {
opacity: var(--transparent-background-poster-opacity, 1);
}
.background-container.is-transparent-glass-lightweight .background-image.active,
.background-container.is-transparent-glass-lightweight .background-image.previous {
filter: blur(var(--transparent-background-blur, 16px));
transform: scale(1.03);
}
.background-container.is-transparent-glass-lightweight .background-image.active::after,
.background-container.is-transparent-glass-lightweight .background-image.previous::after {
background:
linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%),
rgba(128, 128, 128, 30%);
}
/* 全局磨砂层 */
.global-blur-layer {
position: absolute;
z-index: 1;
z-index: 3;
backdrop-filter: blur(var(--transparent-background-blur, 16px));
background-color: rgba(128, 128, 128, 30%);
block-size: 100%;
@@ -506,6 +762,22 @@ onUnmounted(() => {
inset-inline-start: 0;
}
.app-wrapper--render-throttled {
.global-blur-layer {
backdrop-filter: none;
}
.login-bg-decor *,
.login-logo,
.login-logo-wrapper,
.login-logo-wrapper::before,
.login-title,
.login-subtitle,
.agent-assistant-fab * {
animation-play-state: paused !important;
}
}
/* 登录页壁纸在 VApp 外层渲染,登录页 VApp 需要透明才能露出壁纸。 */
.app-shell--login-wallpaper.v-application {
background: transparent !important;

View File

@@ -530,6 +530,56 @@ function registerJinja2Mode() {
)
}
function registerWordListMode() {
aceModule.define?.(
'ace/mode/word_list_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 WordListHighlightRules = function (this: any) {
this.$rules = {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
],
}
this.normalizeRules()
}
oop.inherits(WordListHighlightRules, TextHighlightRules)
exports.WordListHighlightRules = WordListHighlightRules
},
)
aceModule.define?.(
'ace/mode/word_list',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextMode = require('./text').Mode
const WordListHighlightRules = require('./word_list_highlight_rules').WordListHighlightRules
const Mode = function (this: any) {
TextMode.call(this)
this.HighlightRules = WordListHighlightRules
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.$id = 'ace/mode/word_list'
}).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)
@@ -555,4 +605,5 @@ ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
registerJinja2Mode()
registerWordListMode()
ace.require('ace/ext/language_tools')

View File

@@ -1,14 +1,19 @@
import axios from 'axios'
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios'
import router from '@/router'
import { useAuthStore } from '@/stores'
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
import { getCurrentLocale } from '@/plugins/i18n'
// 创建axios实例
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
})
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
skipConnectionTracking?: boolean
}
// 声明全局变量类型
declare global {
interface Window {
@@ -30,42 +35,68 @@ api.interceptors.request.use(config => {
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
const locale = getCurrentLocale()
config.headers['X-MoviePilot-Locale'] = locale
config.headers['Accept-Language'] = locale
return config
})
// 离线状态管理
const globalOfflineStatus = useGlobalOfflineStatus()
/** 将 Axios 连接错误归类为全局服务探测可识别的原因。 */
function resolveConnectionFailureReason(error: AxiosError): 'network-error' | 'timeout' | null {
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout'
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') {
return 'network-error'
}
return null
}
interface LocalizedApiPayload {
detail?: unknown
detail_i18n?: unknown
message?: unknown
message_i18n?: unknown
}
/** 前端展示默认使用后端提供的多语言消息,同时不改变后端接口兼容字段。 */
function normalizeLocalizedMessage(payload: any): any {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload
const localizedPayload = payload as LocalizedApiPayload
if (typeof localizedPayload.message_i18n === 'string' && localizedPayload.message_i18n) {
localizedPayload.message = localizedPayload.message_i18n
}
if (typeof localizedPayload.detail_i18n === 'string' && localizedPayload.detail_i18n) {
localizedPayload.detail = localizedPayload.detail_i18n
}
return payload
}
// 添加响应拦截器
api.interceptors.response.use(
response => {
// 成功响应时,清除应用离线状态并重置连续错误计数
globalOfflineStatus.setAppOffline(false)
globalOfflineStatus.resetConsecutiveErrors()
return response.data
// 任意 API 成功响应都可以证明 MoviePilot 服务当前可达。
globalOfflineStatus.markServerOnline()
return normalizeLocalizedMessage(response.data)
},
error => {
(error: AxiosError) => {
if (!error.response) {
// 网络错误或请求超时 - 通知离线状态管理系统
const isNetworkError =
error.code === 'NETWORK_ERROR' ||
error.code === 'ERR_NETWORK' ||
error.code === 'ECONNABORTED' ||
error.name === 'NetworkError'
const requestConfig = error.config as ConnectionAwareRequestConfig | undefined
const failureReason = resolveConnectionFailureReason(error)
if (isNetworkError) {
let reason = 'Network connection failed'
if (error.code === 'ECONNABORTED') {
reason = 'Request timeout'
}
// 记录网络错误,只有连续三次才会设置为离线模式
globalOfflineStatus.recordNetworkError(reason)
// 普通请求失败只触发权威探测;探测请求自身失败由心跳管理器处理,避免递归。
if (!requestConfig?.skipConnectionTracking && failureReason) {
globalOfflineStatus.reportNetworkError(failureReason)
}
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
// 网络连接问题
return Promise.reject(new Error('Network connection failed, please check your network status'))
} else if (error.code === 'ECONNABORTED') {
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
// 请求超时
return Promise.reject(new Error('Request timeout, please try again later'))
} else if (error.name === 'AbortError') {
@@ -75,12 +106,15 @@ api.interceptors.response.use(
// 其他网络错误
return Promise.reject(new Error(error.message || 'Network error'))
} else if (error.response.status === 403) {
normalizeLocalizedMessage(error.response.data)
// 认证 Store
const authStore = useAuthStore()
// 清除登录状态信息
authStore.logout()
// token验证失败跳转到登录页面
router.push('/login')
} else {
normalizeLocalizedMessage(error.response.data)
}
return Promise.reject(error)

View File

@@ -1000,6 +1000,12 @@ export interface MediaStatistic {
episode_count: number | null
// 用户数量
user_count: number
// 本月新增电影数量
movie_count_month: number
// 本月新增电视剧数量
tv_count_month: number
// 本月新增剧集数量
episode_count_month: number
}
// 后台进程
@@ -1020,6 +1026,18 @@ export interface Process {
memory: number
}
// 仪表板系统摘要
export interface DashboardSystemInfo {
// 主机名称
hostname: string
// 操作系统名称
operating_system: string
// MoviePilot 运行时间,单位秒
runtime: number
// MoviePilot 后端版本
version: string
}
// 下载器信息
export interface DownloaderInfo {
// 下载速度
@@ -1034,18 +1052,86 @@ export interface DownloaderInfo {
free_space: number
}
// 仪表板系统内存信息
export interface DashboardMemoryInfo {
// 总内存字节数
total: number
// 已使用内存字节数,不包含缓存
used: number
// 缓存与缓冲区占用字节数
cached: number
// 可用内存字节数
available: number
// 已使用内存占总内存百分比,不包含缓存
usage: number
}
// 定时服务进度信息
export interface ScheduleProgress {
// ID
id?: string
// 名称
name?: string
// 多语言名称
name_i18n?: string
// 提供者
provider?: string
// 多语言提供者
provider_i18n?: string
// 是否正在执行
enable?: boolean
// 当前完成百分比
value?: number
// 当前进度文本
text?: string
// 多语言进度文本
text_i18n?: string
// 执行状态
status?: string
// 最近一次执行是否成功
success?: boolean
// 最近一次开始时间
started_at?: string
// 最近一次结束时间
finished_at?: string
// 最近一次错误信息
error?: string
// 多语言错误信息
error_i18n?: string
// 扩展数据
data?: Record<string, unknown>
}
// 定时服务信息
export interface ScheduleInfo {
// ID
id: string
// 名称
name: string
// 多语言名称
name_i18n?: string
// 提供者
provider: string
// 多语言提供者
provider_i18n?: string
// 状态
status: string
// 多语言状态
status_i18n?: string
// 下次运行时间
next_run: string
// 多语言下次运行时间
next_run_i18n?: string
// 当前完成百分比
progress?: number
// 进度文本
progress_text?: string
// 多语言进度文本
progress_text_i18n?: string
// 是否正在更新进度
progress_enable?: boolean
// 进度详情
progress_detail?: ScheduleProgress
}
// 消息通知
@@ -1151,6 +1237,8 @@ export interface MediaServerLibrary {
path?: string
// 类型
type?: string
// 库内媒体数量
item_count?: number
// 图片
image?: string
// 图片列表
@@ -1337,6 +1425,28 @@ export interface FilterRuleGroup {
category?: string
}
// 规则测试结果
export interface RuleTestData {
// 原始标题
title?: string
// 原始副标题
subtitle?: string
// 规则组名称
rulegroup_name?: string
// 规则组详情
rulegroup?: FilterRuleGroup | null
// 识别元数据
meta_info?: MetaInfo | null
// 媒体信息
media_info?: MediaInfo | null
// 种子信息
torrent_info?: TorrentInfo | null
// 优先级
priority?: number | null
// 是否命中过滤规则
matched?: boolean
}
// 订阅下载文件详情
export interface SubscribeDownloadFileInfo {
// 种子名称
@@ -1357,6 +1467,12 @@ export interface SubscribeLibraryFileInfo {
storage?: string
// 文件路径
file_path?: string
// 媒体服务器名称
server?: string
// 媒体服务器类型emby、jellyfin、plex 等
server_type?: string
// 媒体服务器条目 ID
itemid?: string
}
// 订阅集详情
@@ -1635,6 +1751,40 @@ export interface TorrentCacheData {
data: TorrentCacheItem[]
}
// 媒体识别缓存项
export interface RecognitionCacheItem {
// 缓存键
key: string
// TMDB ID0 表示未识别
tmdb_id?: number
// 豆瓣 ID0 表示未识别
douban_id?: string | number
// 当前识别数据源对应的统一 ID
recognition_id?: string
// 识别后的标题
title: string
// 识别后的年份
year: string
// 媒体类型
media_type: string
// TMDB 海报相对路径
poster_path?: string
// TMDB 背景图相对路径
backdrop_path?: string
}
// 媒体识别缓存数据
export interface RecognitionCacheData {
// 缓存总数
count: number
// 已识别数量
recognized: number
// 未识别数量
unrecognized: number
// 缓存数据
data: RecognitionCacheItem[]
}
// 订阅分享统计
export interface SubscribeShareStatistics {
// 分享人
@@ -1649,6 +1799,7 @@ export interface SubscribeShareStatistics {
export interface ApiResponse<T = any> {
success: boolean
message?: string
message_i18n?: string
data: T
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import mdLinkAttributes from 'markdown-it-link-attributes'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useAuthStore, useUserStore } from '@/stores'
import { getCurrentLocale } from '@/plugins/i18n'
type AgentMessageRole = 'user' | 'assistant'
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
@@ -88,6 +89,7 @@ interface AgentStreamEvent {
choice?: Omit<AgentChoiceCard, 'status'>
content?: string
message?: string
message_i18n?: string
message_id?: string
target_message?: Partial<AgentChatMessage> & { id?: string }
session_id?: string
@@ -622,19 +624,16 @@ function dedupeHistorySessions(sessions: AgentSessionHistoryItem[]) {
// 调用智能助手接口,并统一处理鉴权和标准响应格式。
async function fetchAgentApi(path: string, options: RequestInit = {}) {
const headers = new Headers(options.headers || {})
if (authStore.token) headers.set('Authorization', `Bearer ${authStore.token}`)
const response = await fetch(resolveApiUrl(path), {
...options,
headers,
headers: buildAgentRequestHeaders(options.headers),
credentials: 'include',
})
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
const result = await response.json()
if (!result?.success) throw new Error(result?.message || t('agentAssistant.error'))
if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.error'))
return result.data
}
@@ -981,6 +980,31 @@ function resolveApiUrl(path: string) {
return `${baseUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}`
}
// 构造智能助手 fetch 请求头,补齐 Axios 拦截器无法覆盖的鉴权和语言信息。
function buildAgentRequestHeaders(headers?: HeadersInit) {
const requestHeaders = new Headers(headers || {})
const locale = getCurrentLocale()
if (authStore.token) requestHeaders.set('Authorization', `Bearer ${authStore.token}`)
requestHeaders.set('X-MoviePilot-Locale', locale)
requestHeaders.set('Accept-Language', locale)
return requestHeaders
}
// 解析智能助手 fetch 失败响应,优先使用后端返回的本地化错误文本。
async function resolveAgentResponseErrorMessage(response: Response) {
try {
const payload = await response.clone().json()
const message = payload?.detail_i18n || payload?.message_i18n || payload?.detail || payload?.message
if (typeof message === 'string' && message) return message
} catch {
// 非 JSON 错误响应保留 HTTP 状态文本,避免吞掉原始错误。
}
return `${response.status} ${response.statusText}`.trim()
}
// 消息主列表使用原生滚动,避免流式回复时 JS 滚动库频繁测量影响手感。
function getMessageScrollerElement() {
return messageListRef.value
@@ -1194,7 +1218,7 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
case 'error':
assistantMessage.status = 'error'
// 后端流式错误已经以 AI 消息展示,避免底部提示条重复且持续占位。
assistantMessage.content ||= event.message || t('agentAssistant.error')
assistantMessage.content ||= event.message_i18n || event.message || t('agentAssistant.error')
emit('assistant-preview', assistantMessage.content)
markToolsDone(assistantMessage)
break
@@ -1370,17 +1394,15 @@ async function uploadAgentAttachment(file: File) {
const response = await fetch(resolveApiUrl('message/agent/upload'), {
method: 'POST',
headers: {
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
},
headers: buildAgentRequestHeaders(),
body: formData,
credentials: 'include',
})
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
const result = await response.json()
if (!result?.success) throw new Error(result?.message || t('agentAssistant.uploadFailed'))
if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.uploadFailed'))
return result.data as AgentMessageAttachment & AgentOutgoingFile
}
@@ -1452,10 +1474,9 @@ async function streamAgentMessage(
try {
const response = await fetch(resolveApiUrl('message/agent/stream'), {
method: 'POST',
headers: {
headers: buildAgentRequestHeaders({
'Content-Type': 'application/json',
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
},
}),
body: JSON.stringify({
text: content,
display_text: displayContent || content,
@@ -1473,7 +1494,7 @@ async function streamAgentMessage(
})
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`.trim())
throw new Error(await resolveAgentResponseErrorMessage(response))
}
await readAgentStream(response, assistantMessage)
@@ -1698,10 +1719,9 @@ async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceC
try {
const response = await fetch(resolveApiUrl('message/agent/callback'), {
method: 'POST',
headers: {
headers: buildAgentRequestHeaders({
'Content-Type': 'application/json',
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
},
}),
body: JSON.stringify({
session_id: sessionId.value,
callback_data: button.callback_data,
@@ -1711,10 +1731,10 @@ async function handleChoiceClick(message: AgentChatMessage, choice: AgentChoiceC
credentials: 'include',
})
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
if (!response.ok) throw new Error(await resolveAgentResponseErrorMessage(response))
const result = await response.json()
if (!result?.success) throw new Error(result?.message || t('agentAssistant.choiceExpired'))
if (!result?.success) throw new Error(result?.message_i18n || result?.message || t('agentAssistant.choiceExpired'))
const agentMessage = String(result.data?.message || '')
if (result.data?.traditional) {
@@ -2362,7 +2382,7 @@ onScopeDispose(() => {
.agent-assistant-shell {
position: relative;
display: grid;
block-size: 100%;
block-size: 100vh;
grid-template-rows: auto minmax(0, 1fr);
min-block-size: 0;
@@ -2372,6 +2392,12 @@ onScopeDispose(() => {
--agent-assistant-panel-blur: 10px;
}
@supports (block-size: 100dvh) {
.agent-assistant-shell {
block-size: 100dvh;
}
}
.agent-assistant-header {
display: flex;
align-items: center;
@@ -2660,6 +2686,7 @@ onScopeDispose(() => {
-ms-overflow-style: none;
overflow-y: auto;
overscroll-behavior: contain;
overscroll-behavior-y: contain;
padding-block: 1rem;
padding-inline: 1rem;
scroll-behavior: auto;

View File

@@ -8,11 +8,13 @@ const panelOpen = ref(false)
const thinking = ref(false)
const entryRef = ref<AgentAssistantEntryRef | null>(null)
// 打开 Agent 面板并清空入口预览气泡。
function openPanel() {
panelOpen.value = true
entryRef.value?.clearBubbles()
}
// 在面板关闭时展示助手回复预览。
function handleAssistantPreview(value: string) {
if (panelOpen.value) return

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import CssRobotRenderer from './renderers/CssRobotRenderer.vue'
import type { AgentPetActionName, AgentPetIntent, AgentPetRendererKind } from './types'
const props = withDefaults(
defineProps<{
action?: AgentPetActionName | null
intent?: AgentPetIntent
renderer?: AgentPetRendererKind
thinking?: boolean
}>(),
{
action: null,
intent: 'idle',
renderer: 'css-robot',
thinking: false,
},
)
</script>
<template>
<CssRobotRenderer
v-if="props.renderer === 'css-robot'"
:action="props.action"
:intent="props.intent"
:thinking="props.thinking"
/>
</template>

View File

@@ -0,0 +1,156 @@
import type { AgentPetActionDefinition, AgentPetActionName } from './types'
export const AGENT_PET_RANDOM_ACTION_MIN_DELAY = 8000
export const AGENT_PET_RANDOM_ACTION_MAX_DELAY = 18000
export const AGENT_PET_RANDOM_ACTIONS = [
'wave',
'sit',
'eye-roll',
'faint',
'disassemble',
'happy-jump',
'sleep',
'stretch',
'peek',
'scan',
'charge',
'spin-cheer',
'shy',
'confused',
] as const
export const AGENT_PET_ACTIONS: Record<AgentPetActionName, AgentPetActionDefinition> = {
wave: {
name: 'wave',
intent: 'reaction',
clip: 'agent-fab-action-wave',
duration: 2450,
priority: 1,
interruptible: true,
},
sit: {
name: 'sit',
intent: 'idle',
clip: 'agent-fab-action-sit',
duration: 4200,
priority: 1,
interruptible: true,
},
'eye-roll': {
name: 'eye-roll',
intent: 'reaction',
clip: 'agent-fab-action-eye-roll',
duration: 1900,
priority: 1,
interruptible: true,
},
faint: {
name: 'faint',
intent: 'reaction',
clip: 'agent-fab-action-faint',
duration: 4800,
priority: 1,
interruptible: true,
},
disassemble: {
name: 'disassemble',
intent: 'reaction',
clip: 'agent-fab-action-disassemble',
duration: 6200,
priority: 1,
interruptible: true,
},
'happy-jump': {
name: 'happy-jump',
intent: 'success',
clip: 'agent-fab-action-happy-jump',
duration: 5200,
priority: 1,
interruptible: true,
},
sleep: {
name: 'sleep',
intent: 'sleeping',
clip: 'agent-fab-action-sleep',
duration: 5800,
priority: 1,
interruptible: true,
},
stretch: {
name: 'stretch',
intent: 'idle',
clip: 'agent-fab-action-stretch',
duration: 4400,
priority: 1,
interruptible: true,
},
peek: {
name: 'peek',
intent: 'reaction',
clip: 'agent-fab-action-peek',
duration: 2800,
priority: 1,
interruptible: true,
},
scan: {
name: 'scan',
intent: 'thinking',
clip: 'agent-fab-action-scan',
duration: 3200,
priority: 1,
interruptible: true,
},
charge: {
name: 'charge',
intent: 'thinking',
clip: 'agent-fab-action-charge',
duration: 4800,
priority: 1,
interruptible: true,
},
'spin-cheer': {
name: 'spin-cheer',
intent: 'success',
clip: 'agent-fab-action-spin-cheer',
duration: 3600,
priority: 1,
interruptible: true,
},
shy: {
name: 'shy',
intent: 'reaction',
clip: 'agent-fab-action-shy',
duration: 3000,
priority: 1,
interruptible: true,
},
confused: {
name: 'confused',
intent: 'reaction',
clip: 'agent-fab-action-confused',
duration: 3400,
priority: 1,
interruptible: true,
},
}
/** 获取指定宠物动作的播放时长。 */
export function getAgentPetActionDuration(action: AgentPetActionName) {
return AGENT_PET_ACTIONS[action].duration
}
/** 生成下一次空闲趣味动作的随机等待时间。 */
export function getAgentPetRandomActionDelay() {
return (
AGENT_PET_RANDOM_ACTION_MIN_DELAY +
Math.round(Math.random() * (AGENT_PET_RANDOM_ACTION_MAX_DELAY - AGENT_PET_RANDOM_ACTION_MIN_DELAY))
)
}
/** 从动作池中选择一个不同于上一次的趣味动作。 */
export function pickAgentPetRandomAction(lastAction: AgentPetActionName | null): AgentPetActionName {
const candidates = AGENT_PET_RANDOM_ACTIONS.filter(action => action !== lastAction)
return candidates[Math.floor(Math.random() * candidates.length)] || AGENT_PET_RANDOM_ACTIONS[0]
}

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { AgentPetActionName, AgentPetIntent } from '../types'
const props = withDefaults(
defineProps<{
action?: AgentPetActionName | null
intent?: AgentPetIntent
thinking?: boolean
}>(),
{
action: null,
intent: 'idle',
thinking: false,
},
)
</script>
<template>
<span
class="agent-assistant-fab__bot"
:data-agent-pet-action="props.action || undefined"
:data-agent-pet-intent="props.intent"
:data-agent-pet-thinking="props.thinking ? 'true' : undefined"
aria-hidden="true"
>
<span class="agent-assistant-fab__antenna" />
<span class="agent-assistant-fab__head">
<span class="agent-assistant-fab__face">
<span class="agent-assistant-fab__eye agent-assistant-fab__eye--left" />
<span class="agent-assistant-fab__eye agent-assistant-fab__eye--right" />
<span class="agent-assistant-fab__smile" />
</span>
</span>
<span class="agent-assistant-fab__body">
<span class="agent-assistant-fab__core" />
</span>
<span class="agent-assistant-fab__arm agent-assistant-fab__arm--left" />
<span class="agent-assistant-fab__arm agent-assistant-fab__arm--right" />
<span class="agent-assistant-fab__leg agent-assistant-fab__leg--left" />
<span class="agent-assistant-fab__leg agent-assistant-fab__leg--right" />
</span>
</template>
<style src="./cssRobot.scss" lang="scss"></style>

View File

@@ -0,0 +1,100 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-charge .agent-assistant-fab__bot {
animation: agent-fab-action-charge-bot 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-charge .agent-assistant-fab__antenna {
animation: agent-fab-action-charge-antenna 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-charge .agent-assistant-fab__face {
animation: agent-fab-action-charge-face 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-charge .agent-assistant-fab__core {
animation: agent-fab-action-charge-core 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-charge .agent-assistant-fab__arm--left,
.agent-assistant-fab.is-action-charge .agent-assistant-fab__arm--right {
animation: agent-fab-action-charge-arm 4.8s ease-in-out both;
}
@keyframes agent-fab-action-charge-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
18%,
42%,
66% {
transform: translateY(0.1rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 2deg));
}
30%,
54%,
78% {
transform: translateY(-0.12rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 2deg));
}
}
@keyframes agent-fab-action-charge-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
30%,
54%,
78% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.16rem)) rotate(-20deg)
scale(1.12);
}
}
@keyframes agent-fab-action-charge-face {
0%,
100% {
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
}
20%,
84% {
box-shadow:
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
0 0 0.9rem rgba(166, 118, 255, 56%);
}
}
@keyframes agent-fab-action-charge-core {
0%,
100% {
opacity: 0.78;
transform: scale(1);
}
20%,
40%,
60%,
80% {
opacity: 1;
transform: scale(1.32);
}
}
@keyframes agent-fab-action-charge-arm {
0%,
100% {
transform: rotate(0deg);
}
22%,
82% {
transform: translateY(0.12rem) scaleY(0.9);
}
}

View File

@@ -0,0 +1,101 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-confused .agent-assistant-fab__antenna {
animation: agent-fab-action-confused-antenna 3.4s ease-in-out both;
}
.agent-assistant-fab.is-action-confused .agent-assistant-fab__head {
animation: agent-fab-action-confused-head 3.4s ease-in-out both;
}
.agent-assistant-fab.is-action-confused .agent-assistant-fab__eye {
animation:
agent-fab-blink 4.8s ease-in-out infinite,
agent-fab-action-confused-eye 0.85s ease-in-out 4;
}
.agent-assistant-fab.is-action-confused .agent-assistant-fab__arm--left {
animation: agent-fab-action-confused-arm-left 3.4s ease-in-out both;
}
.agent-assistant-fab.is-action-confused .agent-assistant-fab__arm--right {
animation: agent-fab-action-confused-arm-right 3.4s ease-in-out both;
}
@keyframes agent-fab-action-confused-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
20% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(58deg);
}
48% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(-28deg);
}
74% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(42deg);
}
}
@keyframes agent-fab-action-confused-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
20% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-10deg);
}
48% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(10deg);
}
74% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.03rem)) rotate(-6deg);
}
}
@keyframes agent-fab-action-confused-eye {
0%,
100% {
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
}
30% {
transform: translate(0.22rem, -0.16rem);
}
60% {
transform: translate(-0.22rem, 0.08rem);
}
}
@keyframes agent-fab-action-confused-arm-left {
0%,
100% {
transform: rotate(17deg);
}
24%,
74% {
transform: translate(-0.12rem, -0.12rem) rotate(88deg);
}
}
@keyframes agent-fab-action-confused-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
24%,
74% {
transform: translate(0.16rem, -0.08rem) rotate(-42deg);
}
}

View File

@@ -0,0 +1,195 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__bot {
animation: agent-fab-action-disassemble-bot 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__antenna {
animation: agent-fab-action-disassemble-antenna 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__head {
animation: agent-fab-action-disassemble-head 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__body {
animation: agent-fab-action-disassemble-body 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__arm--left {
animation: agent-fab-action-disassemble-arm-left 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__arm--right {
animation: agent-fab-action-disassemble-arm-right 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__leg--left {
animation: agent-fab-action-disassemble-leg-left 6.2s ease-in-out both;
}
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__leg--right {
animation: agent-fab-action-disassemble-leg-right 6.2s ease-in-out both;
}
@keyframes agent-fab-action-disassemble-bot {
/* 34%-70% 让散落状态停留片刻,再用 84% 的轻微过冲表现回装吸附感。 */
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
12% {
transform: translateY(-0.08rem) scale(var(--agent-assistant-bot-scale)) rotate(-5deg);
}
24%,
72% {
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale)) rotate(0deg);
}
86% {
transform: translateY(-0.04rem) scale(var(--agent-assistant-bot-scale)) rotate(3deg);
}
}
@keyframes agent-fab-action-disassemble-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
16% {
transform: translate(0.02rem, -0.14rem) rotate(44deg);
}
32%,
70% {
transform: translate(0.96rem, 3.42rem) rotate(268deg);
}
84% {
transform: translate(-0.05rem, -0.08rem) rotate(8deg);
}
}
@keyframes agent-fab-action-disassemble-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
16% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.24rem)) rotate(-8deg);
}
32%,
70% {
transform: translate(-1.18rem, 2.72rem) rotate(-46deg);
}
84% {
transform: translate(0.08rem, -0.1rem) rotate(6deg);
}
}
@keyframes agent-fab-action-disassemble-body {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
}
18% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.08rem)) rotate(4deg);
}
34%,
70% {
transform: translate(0.26rem, 1.56rem) rotate(18deg);
}
84% {
transform: translate(-0.05rem, -0.08rem) rotate(-5deg);
}
}
@keyframes agent-fab-action-disassemble-arm-left {
0%,
100% {
transform: rotate(17deg);
}
18% {
transform: translate(-0.16rem, -0.08rem) rotate(78deg);
}
34%,
70% {
transform: translate(-1.72rem, 1.84rem) rotate(248deg);
}
84% {
transform: translate(0.08rem, -0.04rem) rotate(-4deg);
}
}
@keyframes agent-fab-action-disassemble-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
18% {
transform: translate(0.16rem, -0.08rem) rotate(-78deg);
}
34%,
70% {
transform: translate(1.58rem, 1.72rem) rotate(-238deg);
}
84% {
transform: translate(-0.08rem, -0.04rem) rotate(4deg);
}
}
@keyframes agent-fab-action-disassemble-leg-left {
0%,
100% {
transform: rotate(0deg);
}
20% {
transform: translate(-0.12rem, 0.18rem) rotate(34deg);
}
34%,
70% {
transform: translate(-0.98rem, 0.96rem) rotate(112deg);
}
84% {
transform: translate(0.07rem, -0.08rem) rotate(-8deg);
}
}
@keyframes agent-fab-action-disassemble-leg-right {
0%,
100% {
transform: rotate(0deg);
}
20% {
transform: translate(0.12rem, 0.18rem) rotate(-34deg);
}
34%,
70% {
transform: translate(0.92rem, 1.04rem) rotate(-118deg);
}
84% {
transform: translate(-0.07rem, -0.08rem) rotate(8deg);
}
}

View File

@@ -0,0 +1,50 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-eye-roll .agent-assistant-fab__head {
animation: agent-fab-action-eye-roll-head 1.9s ease-in-out both;
}
.agent-assistant-fab.is-action-eye-roll .agent-assistant-fab__eye {
animation:
agent-fab-blink 4.8s ease-in-out infinite,
agent-fab-action-eye-roll 0.95s ease-in-out 2;
}
@keyframes agent-fab-action-eye-roll-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
24% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-6deg);
}
52% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(5deg);
}
78% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.03rem)) rotate(-3deg);
}
}
@keyframes agent-fab-action-eye-roll {
0%,
100% {
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
}
22% {
transform: translate(0.22rem, -0.2rem);
}
48% {
transform: translate(0, -0.3rem);
}
72% {
transform: translate(-0.22rem, -0.2rem);
}
}

View File

@@ -0,0 +1,138 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-faint .agent-assistant-fab__bot {
animation: agent-fab-action-faint-bot 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__antenna {
animation: agent-fab-action-faint-antenna 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__head {
animation: agent-fab-action-faint-head 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__body {
animation: agent-fab-action-faint-body 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__arm--left {
animation: agent-fab-action-faint-arm-left 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__arm--right {
animation: agent-fab-action-faint-arm-right 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__leg--left {
animation: agent-fab-action-faint-leg-left 4.8s ease-in-out both;
}
.agent-assistant-fab.is-action-faint .agent-assistant-fab__leg--right {
animation: agent-fab-action-faint-leg-right 4.8s ease-in-out both;
}
@keyframes agent-fab-action-faint-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
26% {
transform: translateY(-0.1rem) scale(var(--agent-assistant-bot-scale)) rotate(12deg);
}
34%,
86% {
transform: translate(0.58rem, 1.08rem) scale(var(--agent-assistant-bot-scale)) rotate(98deg);
}
}
@keyframes agent-fab-action-faint-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
34%,
86% {
transform: translate(0.08rem, 0.02rem) rotate(-18deg);
}
}
@keyframes agent-fab-action-faint-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
28% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(9deg);
}
34%,
86% {
transform: translate(0.04rem, 0.02rem) rotate(-3deg);
}
}
@keyframes agent-fab-action-faint-body {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
}
34%,
86% {
transform: translate(0.04rem, 0.02rem) scaleY(0.9);
}
}
@keyframes agent-fab-action-faint-arm-left {
0%,
100% {
transform: rotate(17deg);
}
34%,
86% {
transform: translate(-0.12rem, 0.16rem) rotate(118deg);
}
}
@keyframes agent-fab-action-faint-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
34%,
86% {
transform: translate(0.12rem, 0.16rem) rotate(-118deg);
}
}
@keyframes agent-fab-action-faint-leg-left {
0%,
100% {
transform: rotate(0deg);
}
34%,
86% {
transform: translate(-0.24rem, -0.02rem) rotate(82deg);
}
}
@keyframes agent-fab-action-faint-leg-right {
0%,
100% {
transform: rotate(0deg);
}
34%,
86% {
transform: translate(0.24rem, -0.02rem) rotate(-82deg);
}
}

View File

@@ -0,0 +1,309 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__bot {
animation: agent-fab-action-happy-jump-bot 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__antenna {
animation: agent-fab-action-happy-jump-antenna 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__head {
animation: agent-fab-action-happy-jump-head 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__eye {
animation: agent-fab-action-happy-jump-eye 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__smile {
animation: agent-fab-action-happy-jump-smile 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__body {
animation: agent-fab-action-happy-jump-body 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__arm--left {
z-index: 6;
animation: agent-fab-action-happy-jump-arm-left 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__arm--right {
z-index: 6;
animation: agent-fab-action-happy-jump-arm-right 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__leg--left {
animation: agent-fab-action-happy-jump-leg-left 5.2s ease-in-out both;
}
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__leg--right {
animation: agent-fab-action-happy-jump-leg-right 5.2s ease-in-out both;
}
@keyframes agent-fab-action-happy-jump-bot {
/* 三段蓄力、起跳、落地,让开心连跳比单纯上下移动更像真人动作。 */
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
9%,
37%,
65% {
transform: translateY(0.22rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 3deg));
}
20%,
48%,
76% {
transform: translateY(-0.76rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 4deg));
}
29%,
57%,
85% {
transform: translateY(0.1rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 2deg));
}
92% {
transform: translateY(-0.1rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 1deg));
}
}
@keyframes agent-fab-action-happy-jump-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
9%,
37%,
65% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(34deg);
}
20%,
48%,
76% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.2rem)) rotate(-18deg);
}
29%,
57%,
85% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.04rem)) rotate(38deg);
}
}
@keyframes agent-fab-action-happy-jump-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
9%,
37%,
65% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(-4deg);
}
20%,
48%,
76% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.16rem)) rotate(7deg);
}
29%,
57%,
85% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.04rem)) rotate(-5deg);
}
92% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(2deg);
}
}
@keyframes agent-fab-action-happy-jump-eye {
0%,
100% {
border-block-end-width: 0.15rem;
opacity: 1;
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1, 1);
}
8%,
36%,
64%,
92% {
border-block-end-width: 0.18rem;
transform: translate(0, -0.06rem) scale(1.08, 0.72);
}
20%,
48%,
76% {
border-block-end-width: 0.19rem;
transform: translate(0, -0.12rem) scale(1.16, 0.58);
}
}
@keyframes agent-fab-action-happy-jump-smile {
0%,
100% {
opacity: 0;
transform: translateX(-50%) scale(0.72);
}
6%,
90% {
opacity: 1;
}
9%,
37%,
65% {
transform: translateX(-50%) translateY(0.02rem) scale(0.92, 0.82);
}
20%,
48%,
76% {
transform: translateX(-50%) translateY(-0.04rem) scale(1.22, 1.08);
}
}
@keyframes agent-fab-action-happy-jump-body {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
}
9%,
37%,
65% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.18rem)) scaleY(0.84)
rotate(-2deg);
}
20%,
48%,
76% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) - 0.1rem)) scaleY(1.08)
rotate(4deg);
}
29%,
57%,
85% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.08rem)) scaleY(0.94)
rotate(-3deg);
}
}
@keyframes agent-fab-action-happy-jump-arm-left {
0%,
100% {
transform: rotate(17deg);
}
9%,
37%,
65% {
transform: translate(-0.08rem, 0.06rem) rotate(54deg);
}
20%,
48%,
76% {
transform: translate(-0.24rem, -0.38rem) rotate(152deg);
}
29%,
57%,
85% {
transform: translate(-0.1rem, -0.02rem) rotate(96deg);
}
}
@keyframes agent-fab-action-happy-jump-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
9%,
37%,
65% {
transform: translate(0.08rem, 0.06rem) rotate(-54deg);
}
20%,
48%,
76% {
transform: translate(0.24rem, -0.38rem) rotate(-152deg);
}
29%,
57%,
85% {
transform: translate(0.1rem, -0.02rem) rotate(-96deg);
}
}
@keyframes agent-fab-action-happy-jump-leg-left {
0%,
100% {
transform: rotate(0deg);
}
9%,
37%,
65% {
transform: translate(0.08rem, -0.22rem) rotate(82deg);
}
20%,
48%,
76% {
transform: translate(-0.18rem, 0.08rem) rotate(-32deg);
}
29%,
57%,
85% {
transform: translate(0.12rem, -0.08rem) rotate(44deg);
}
}
@keyframes agent-fab-action-happy-jump-leg-right {
0%,
100% {
transform: rotate(0deg);
}
9%,
37%,
65% {
transform: translate(-0.08rem, -0.22rem) rotate(-82deg);
}
20%,
48%,
76% {
transform: translate(0.18rem, 0.08rem) rotate(32deg);
}
29%,
57%,
85% {
transform: translate(-0.12rem, -0.08rem) rotate(-44deg);
}
}

View File

@@ -0,0 +1,106 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-peek .agent-assistant-fab__bot {
animation: agent-fab-action-peek-bot 2.8s ease-in-out both;
}
.agent-assistant-fab.is-action-peek .agent-assistant-fab__antenna {
animation: agent-fab-action-peek-antenna 2.8s ease-in-out both;
}
.agent-assistant-fab.is-action-peek .agent-assistant-fab__head {
animation: agent-fab-action-peek-head 2.8s ease-in-out both;
}
.agent-assistant-fab.is-action-peek .agent-assistant-fab__eye {
animation: agent-fab-action-peek-eye 2.8s ease-in-out both;
}
.agent-assistant-fab.is-action-peek .agent-assistant-fab__arm--left {
z-index: 6;
animation: agent-fab-action-peek-arm-left 2.8s ease-in-out both;
}
.agent-assistant-fab.is-action-peek .agent-assistant-fab__arm--right {
animation: agent-fab-action-peek-arm-right 2.8s ease-in-out both;
}
@keyframes agent-fab-action-peek-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
26%,
78% {
transform: translateX(-0.48rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 8deg));
}
48% {
transform: translateX(-0.58rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 11deg));
}
}
@keyframes agent-fab-action-peek-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
26%,
78% {
transform: translate(-0.12rem, -0.06rem) rotate(-12deg);
}
}
@keyframes agent-fab-action-peek-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
26%,
78% {
transform: translate(calc(var(--agent-assistant-head-x) - 0.18rem), calc(var(--agent-assistant-head-y) - 0.04rem))
rotate(-10deg);
}
}
@keyframes agent-fab-action-peek-eye {
0%,
100% {
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
}
26%,
78% {
transform: translate(-0.18rem, -0.02rem) scale(1.12, 1);
}
}
@keyframes agent-fab-action-peek-arm-left {
0%,
100% {
transform: rotate(17deg);
}
26%,
78% {
transform: translate(-0.24rem, -0.12rem) rotate(116deg);
}
}
@keyframes agent-fab-action-peek-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
26%,
78% {
transform: translate(0.04rem, 0.06rem) rotate(-34deg);
}
}

View File

@@ -0,0 +1,84 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-scan .agent-assistant-fab__head {
animation: agent-fab-action-scan-head 3.2s ease-in-out both;
}
.agent-assistant-fab.is-action-scan .agent-assistant-fab__face {
animation: agent-fab-action-scan-face 3.2s ease-in-out both;
}
.agent-assistant-fab.is-action-scan .agent-assistant-fab__eye {
animation: agent-fab-action-scan-eye 3.2s ease-in-out both;
}
.agent-assistant-fab.is-action-scan .agent-assistant-fab__core {
animation: agent-fab-action-scan-core 3.2s ease-in-out both;
}
@keyframes agent-fab-action-scan-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
24% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(-8deg);
}
52% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(8deg);
}
78% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(-4deg);
}
}
@keyframes agent-fab-action-scan-face {
0%,
100% {
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
}
18%,
78% {
box-shadow:
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
0 0 0.72rem rgba(114, 255, 240, 48%);
}
}
@keyframes agent-fab-action-scan-eye {
0%,
100% {
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
}
18% {
transform: translate(-0.28rem, -0.02rem) scale(1.1, 0.9);
}
46% {
transform: translate(0.28rem, -0.02rem) scale(1.1, 0.9);
}
74% {
transform: translate(0, -0.18rem) scale(1.18, 0.72);
}
}
@keyframes agent-fab-action-scan-core {
0%,
100% {
opacity: 0.78;
transform: scale(1);
}
25%,
75% {
opacity: 1;
transform: scale(1.24);
}
}

View File

@@ -0,0 +1,14 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__antenna,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__head,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__face,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__body,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__core,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__arm,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__leg,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__eye,
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__smile {
transition: none;
}

View File

@@ -0,0 +1,88 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-shy .agent-assistant-fab__bot {
animation: agent-fab-action-shy-bot 3s ease-in-out both;
}
.agent-assistant-fab.is-action-shy .agent-assistant-fab__head {
animation: agent-fab-action-shy-head 3s ease-in-out both;
}
.agent-assistant-fab.is-action-shy .agent-assistant-fab__eye {
animation: agent-fab-action-shy-eye 3s ease-in-out both;
}
.agent-assistant-fab.is-action-shy .agent-assistant-fab__arm--left {
z-index: 6;
animation: agent-fab-action-shy-arm-left 3s ease-in-out both;
}
.agent-assistant-fab.is-action-shy .agent-assistant-fab__arm--right {
z-index: 6;
animation: agent-fab-action-shy-arm-right 3s ease-in-out both;
}
@keyframes agent-fab-action-shy-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
24%,
78% {
transform: translateX(0.16rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 7deg));
}
}
@keyframes agent-fab-action-shy-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
24%,
78% {
transform: translate(calc(var(--agent-assistant-head-x) + 0.08rem), calc(var(--agent-assistant-head-y) + 0.14rem))
rotate(10deg);
}
}
@keyframes agent-fab-action-shy-eye {
0%,
100% {
opacity: 1;
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
}
24%,
78% {
opacity: 0.78;
transform: translate(0, 0.08rem) scale(0.86, 0.48);
}
}
@keyframes agent-fab-action-shy-arm-left {
0%,
100% {
transform: rotate(17deg);
}
24%,
78% {
transform: translate(0.02rem, -0.16rem) rotate(118deg);
}
}
@keyframes agent-fab-action-shy-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
24%,
78% {
transform: translate(-0.02rem, -0.16rem) rotate(-118deg);
}
}

View File

@@ -0,0 +1,116 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-sit .agent-assistant-fab__bot {
animation: agent-fab-action-sit-bot 4.2s ease-in-out both;
}
.agent-assistant-fab.is-action-sit .agent-assistant-fab__head {
animation: agent-fab-action-sit-head 4.2s ease-in-out both;
}
.agent-assistant-fab.is-action-sit .agent-assistant-fab__body {
animation: agent-fab-action-sit-body 4.2s ease-in-out both;
}
.agent-assistant-fab.is-action-sit .agent-assistant-fab__arm--left {
animation: agent-fab-action-sit-arm-left 4.2s ease-in-out both;
}
.agent-assistant-fab.is-action-sit .agent-assistant-fab__arm--right {
animation: agent-fab-action-sit-arm-right 4.2s ease-in-out both;
}
.agent-assistant-fab.is-action-sit .agent-assistant-fab__leg--left {
z-index: 5;
animation: agent-fab-action-sit-leg-left 4.2s ease-in-out both;
}
.agent-assistant-fab.is-action-sit .agent-assistant-fab__leg--right {
z-index: 5;
animation: agent-fab-action-sit-leg-right 4.2s ease-in-out both;
}
@keyframes agent-fab-action-sit-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
22%,
84% {
transform: translateY(0.36rem) scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
}
@keyframes agent-fab-action-sit-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
22%,
84% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(2deg);
}
}
@keyframes agent-fab-action-sit-body {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
}
22%,
84% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.22rem)) scaleY(0.82);
}
}
@keyframes agent-fab-action-sit-arm-left {
0%,
100% {
transform: rotate(17deg);
}
22%,
84% {
transform: translate(0.08rem, 0.22rem) rotate(76deg);
}
}
@keyframes agent-fab-action-sit-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
22%,
84% {
transform: translate(-0.08rem, 0.22rem) rotate(-76deg);
}
}
@keyframes agent-fab-action-sit-leg-left {
0%,
100% {
transform: rotate(0deg);
}
22%,
84% {
transform: translate(0.18rem, -0.18rem) rotate(94deg);
}
}
@keyframes agent-fab-action-sit-leg-right {
0%,
100% {
transform: rotate(0deg);
}
22%,
84% {
transform: translate(-0.18rem, -0.18rem) rotate(-94deg);
}
}

View File

@@ -0,0 +1,144 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__bot {
animation: agent-fab-action-sleep-bot 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__antenna {
animation: agent-fab-action-sleep-antenna 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__head {
animation: agent-fab-action-sleep-head 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__eye {
animation: agent-fab-action-sleep-eye 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__body {
animation: agent-fab-action-sleep-body 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__arm--left {
animation: agent-fab-action-sleep-arm-left 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__arm--right {
animation: agent-fab-action-sleep-arm-right 5.8s ease-in-out both;
}
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__leg {
animation: agent-fab-action-sleep-leg 5.8s ease-in-out both;
}
@keyframes agent-fab-action-sleep-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
22%,
82% {
transform: translateY(0.42rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 8deg));
}
46%,
64% {
transform: translateY(0.36rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 10deg));
}
}
@keyframes agent-fab-action-sleep-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
22%,
82% {
opacity: 0.62;
transform: translate(0.1rem, 0.16rem) rotate(78deg) scale(0.78);
}
}
@keyframes agent-fab-action-sleep-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
22%,
82% {
transform: translate(calc(var(--agent-assistant-head-x) - 0.12rem), calc(var(--agent-assistant-head-y) + 0.22rem))
rotate(-12deg);
}
}
@keyframes agent-fab-action-sleep-eye {
0%,
100% {
border-block-end-width: 0.15rem;
opacity: 1;
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
}
18%,
86% {
border-block-end-width: 0.1rem;
opacity: 0.62;
transform: translate(0, 0.1rem) scale(1.18, 0.24);
}
}
@keyframes agent-fab-action-sleep-body {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
}
22%,
82% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.26rem)) scaleY(0.78);
}
}
@keyframes agent-fab-action-sleep-arm-left {
0%,
100% {
transform: rotate(17deg);
}
22%,
82% {
transform: translate(0.12rem, 0.2rem) rotate(92deg);
}
}
@keyframes agent-fab-action-sleep-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
22%,
82% {
transform: translate(-0.12rem, 0.2rem) rotate(-92deg);
}
}
@keyframes agent-fab-action-sleep-leg {
0%,
100% {
transform: rotate(0deg);
}
22%,
82% {
opacity: 0.35;
transform: translateY(-0.16rem) scale(0.76);
}
}

View File

@@ -0,0 +1,130 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__bot {
animation: agent-fab-action-spin-cheer-bot 3.6s ease-in-out both;
}
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__antenna {
animation: agent-fab-action-spin-cheer-antenna 3.6s ease-in-out both;
}
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__head {
animation: agent-fab-action-spin-cheer-head 3.6s ease-in-out both;
}
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__eye {
animation: agent-fab-action-spin-cheer-eye 3.6s ease-in-out both;
}
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__smile {
animation: agent-fab-action-spin-cheer-smile 3.6s ease-in-out both;
}
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__arm--left {
z-index: 6;
animation: agent-fab-action-spin-cheer-arm-left 3.6s ease-in-out both;
}
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__arm--right {
z-index: 6;
animation: agent-fab-action-spin-cheer-arm-right 3.6s ease-in-out both;
}
@keyframes agent-fab-action-spin-cheer-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
18% {
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 7deg));
}
48% {
transform: translateY(-0.58rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 184deg));
}
78% {
transform: translateY(-0.14rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 356deg));
}
}
@keyframes agent-fab-action-spin-cheer-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
22%,
78% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.18rem)) rotate(-28deg);
}
}
@keyframes agent-fab-action-spin-cheer-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
22%,
78% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.12rem)) rotate(8deg);
}
}
@keyframes agent-fab-action-spin-cheer-eye {
0%,
100% {
border-block-end-width: 0.15rem;
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
}
20%,
80% {
border-block-end-width: 0.19rem;
transform: translate(0, -0.12rem) scale(1.18, 0.58);
}
}
@keyframes agent-fab-action-spin-cheer-smile {
0%,
100% {
opacity: 0;
transform: translateX(-50%) scale(0.72);
}
16%,
84% {
opacity: 1;
transform: translateX(-50%) translateY(-0.03rem) scale(1.24, 1.08);
}
}
@keyframes agent-fab-action-spin-cheer-arm-left {
0%,
100% {
transform: rotate(17deg);
}
20%,
80% {
transform: translate(-0.28rem, -0.44rem) rotate(148deg);
}
}
@keyframes agent-fab-action-spin-cheer-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
20%,
80% {
transform: translate(0.28rem, -0.44rem) rotate(-148deg);
}
}

View File

@@ -0,0 +1,146 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__bot {
animation: agent-fab-action-stretch-bot 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__antenna {
animation: agent-fab-action-stretch-antenna 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__head {
animation: agent-fab-action-stretch-head 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__body {
animation: agent-fab-action-stretch-body 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__arm--left {
z-index: 6;
animation: agent-fab-action-stretch-arm-left 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__arm--right {
z-index: 6;
animation: agent-fab-action-stretch-arm-right 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__leg--left {
animation: agent-fab-action-stretch-leg-left 4.4s ease-in-out both;
}
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__leg--right {
animation: agent-fab-action-stretch-leg-right 4.4s ease-in-out both;
}
@keyframes agent-fab-action-stretch-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
18% {
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale)) scaleY(0.92)
rotate(var(--agent-assistant-robot-tilt));
}
44%,
70% {
transform: translateY(-0.34rem) scale(var(--agent-assistant-bot-scale)) scaleY(1.06)
rotate(calc(var(--agent-assistant-robot-tilt) + 2deg));
}
86% {
transform: translateY(0.08rem) scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
}
@keyframes agent-fab-action-stretch-antenna {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
44%,
70% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.2rem)) rotate(-24deg);
}
}
@keyframes agent-fab-action-stretch-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
44%,
70% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.28rem)) rotate(5deg);
}
}
@keyframes agent-fab-action-stretch-body {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
}
18% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.12rem)) scaleY(0.88);
}
44%,
70% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) - 0.08rem)) scaleY(1.12);
}
}
@keyframes agent-fab-action-stretch-arm-left {
0%,
100% {
transform: rotate(17deg);
}
44%,
70% {
transform: translate(-0.24rem, -0.7rem) rotate(168deg);
}
}
@keyframes agent-fab-action-stretch-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
44%,
70% {
transform: translate(0.24rem, -0.7rem) rotate(-168deg);
}
}
@keyframes agent-fab-action-stretch-leg-left {
0%,
100% {
transform: rotate(0deg);
}
44%,
70% {
transform: translate(-0.18rem, 0.02rem) rotate(-18deg);
}
}
@keyframes agent-fab-action-stretch-leg-right {
0%,
100% {
transform: rotate(0deg);
}
44%,
70% {
transform: translate(0.18rem, 0.02rem) rotate(18deg);
}
}

View File

@@ -0,0 +1,87 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab.is-action-wave .agent-assistant-fab__bot {
animation: agent-fab-action-wave-bot 2.3s ease-in-out both;
}
.agent-assistant-fab.is-action-wave .agent-assistant-fab__head {
animation: agent-fab-action-wave-head 2.3s ease-in-out both;
}
.agent-assistant-fab.is-action-wave .agent-assistant-fab__arm--left {
z-index: 6;
animation: agent-fab-action-wave-arm-left 2.3s ease-in-out both;
transform-origin: top center;
}
.agent-assistant-fab.is-action-wave .agent-assistant-fab__arm--right {
animation: agent-fab-action-wave-arm-right 2.3s ease-in-out both;
}
@keyframes agent-fab-action-wave-bot {
0%,
100% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
}
22%,
66% {
transform: scale(var(--agent-assistant-bot-scale)) rotate(calc(var(--agent-assistant-robot-tilt) - 4deg));
}
}
@keyframes agent-fab-action-wave-head {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
30%,
66% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.1rem)) rotate(-4deg);
}
}
@keyframes agent-fab-action-wave-arm-left {
0%,
100% {
transform: rotate(17deg);
}
16% {
transform: translate(-0.08rem, -0.06rem) rotate(64deg);
}
30% {
transform: translate(-0.26rem, -0.28rem) rotate(132deg);
}
44% {
transform: translate(-0.18rem, -0.2rem) rotate(92deg);
}
58% {
transform: translate(-0.26rem, -0.28rem) rotate(132deg);
}
72% {
transform: translate(-0.1rem, -0.08rem) rotate(64deg);
}
88% {
transform: translate(-0.02rem, -0.02rem) rotate(28deg);
}
}
@keyframes agent-fab-action-wave-arm-right {
0%,
100% {
transform: rotate(-17deg);
}
30%,
70% {
transform: rotate(-28deg) translateY(0.06rem);
}
}

View File

@@ -0,0 +1,15 @@
@use './shared';
@use './wave';
@use './sit';
@use './eye-roll';
@use './faint';
@use './disassemble';
@use './happy-jump';
@use './sleep';
@use './stretch';
@use './peek';
@use './scan';
@use './charge';
@use './spin-cheer';
@use './shy';
@use './confused';

View File

@@ -0,0 +1,2 @@
@use './cssRobotBase';
@use './actions/cssRobotActions';

View File

@@ -0,0 +1,484 @@
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab__bot,
.agent-assistant-fab__bot span {
box-sizing: border-box;
}
.agent-assistant-fab__bot {
position: absolute;
display: block;
block-size: 4.7rem;
filter: drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow));
inline-size: 3.85rem;
inset-block-end: 0.1rem;
inset-inline-end: 1.42rem;
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
transform-origin: 50% 72%;
transition:
inset-inline-end 0.24s ease,
filter 0.18s ease,
transform 0.14s ease;
}
.agent-assistant-fab__antenna {
position: absolute;
z-index: 3;
display: block;
border-radius: 999px;
animation: agent-fab-antenna-idle 3.9s ease-in-out infinite;
background: var(--agent-assistant-robot-outline);
block-size: 0.66rem;
inline-size: 0.18rem;
inset-block-start: 0.72rem;
inset-inline-start: 2.62rem;
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
transform-origin: bottom center;
transition:
opacity 0.2s ease,
transform 0.22s ease;
}
.agent-assistant-fab__antenna::after {
position: absolute;
border: 2px solid var(--agent-assistant-robot-outline);
border-radius: 999px;
background: var(--agent-assistant-robot-shell-start);
block-size: 0.38rem;
content: '';
inline-size: 0.38rem;
inset-block-start: -0.34rem;
inset-inline-start: -0.13rem;
}
.agent-assistant-fab__head {
position: absolute;
z-index: 4;
display: block;
border: 2px solid var(--agent-assistant-robot-outline);
border-radius: 11px;
animation: agent-fab-head-idle 4.6s ease-in-out infinite;
background: linear-gradient(
145deg,
var(--agent-assistant-robot-shell-start) 0%,
var(--agent-assistant-robot-shell-end) 100%
);
block-size: 2.05rem;
box-shadow:
inset 0 -0.2rem 0 rgba(54, 0, 126, 26%),
inset 0.15rem 0.14rem 0 rgba(255, 255, 255, 24%);
inline-size: 2.82rem;
inset-block-start: 1.42rem;
inset-inline-start: 0.88rem;
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y));
transform-origin: 50% 85%;
}
.agent-assistant-fab__face {
position: absolute;
display: block;
overflow: hidden;
border: 2px solid var(--agent-assistant-robot-outline-soft);
border-radius: 8px;
background: linear-gradient(
180deg,
var(--agent-assistant-robot-face-start) 0%,
var(--agent-assistant-robot-face-end) 100%
);
block-size: 1.28rem;
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
inline-size: 2.1rem;
inset-block-start: 0.33rem;
inset-inline-start: 0.25rem;
}
.agent-assistant-fab__eye {
position: absolute;
display: block;
border-radius: 0 0 999px 999px;
animation: agent-fab-blink 4.8s ease-in-out infinite;
block-size: 0.42rem;
border-block-end: 0.15rem solid var(--agent-assistant-robot-eye);
inline-size: 0.42rem;
inset-block-start: 0.36rem;
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
/* 触屏设备没有连续 hover 轨迹,给眼神位移补过渡避免点按时瞬移。 */
transition: transform 0.2s ease-out;
}
.agent-assistant-fab__eye--left {
inset-inline-start: 0.43rem;
}
.agent-assistant-fab__eye--right {
inset-inline-end: 0.43rem;
}
.agent-assistant-fab__smile {
position: absolute;
display: block;
border-radius: 0 0 999px 999px;
block-size: 0.32rem;
border-block-end: 0.13rem solid var(--agent-assistant-robot-eye);
inline-size: 0.7rem;
inset-block-start: 0.75rem;
inset-inline-start: 50%;
opacity: 0;
transform: translateX(-50%) scale(0.72);
transform-origin: center top;
}
.agent-assistant-fab__body {
position: absolute;
z-index: 3;
display: block;
border: 2px solid var(--agent-assistant-robot-outline);
border-radius: 0.65rem 0.65rem 0.55rem 0.55rem;
animation: agent-fab-body-idle 4.2s ease-in-out infinite;
background: linear-gradient(
145deg,
var(--agent-assistant-robot-shell-mid) 0%,
var(--agent-assistant-robot-shell-end) 82%
);
block-size: 1.34rem;
box-shadow:
inset 0 -0.18rem 0 rgba(54, 0, 126, 24%),
inset 0.16rem 0.12rem 0 rgba(255, 255, 255, 24%);
inline-size: 1.88rem;
inset-block-start: 3.24rem;
inset-inline-start: 1.32rem;
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
transform-origin: 50% 18%;
transition:
opacity 0.2s ease,
transform 0.22s ease;
}
.agent-assistant-fab__core {
position: absolute;
display: block;
background: transparent;
block-size: 100%;
box-shadow: none;
inline-size: 100%;
inset: 0;
pointer-events: none;
}
.agent-assistant-fab__core::before {
position: absolute;
background: var(--agent-assistant-robot-play);
block-size: 0.46rem;
clip-path: polygon(18% 8%, 18% 92%, 90% 50%);
content: '';
inline-size: 0.48rem;
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-42%, -50%);
}
.agent-assistant-fab__arm,
.agent-assistant-fab__leg {
position: absolute;
z-index: 2;
display: block;
border: 2px solid var(--agent-assistant-robot-outline);
background: linear-gradient(
160deg,
var(--agent-assistant-robot-shell-mid) 0%,
var(--agent-assistant-robot-outline-soft) 100%
);
box-shadow: inset 0 -0.12rem 0 rgba(54, 0, 126, 24%);
transition:
opacity 0.2s ease,
transform 0.22s ease;
}
.agent-assistant-fab__arm {
border-radius: 999px;
block-size: 1rem;
inline-size: 0.46rem;
inset-block-start: 3.3rem;
}
.agent-assistant-fab__arm--left {
animation: agent-fab-arm-left-idle 3.8s ease-in-out infinite;
inset-inline-start: 0.9rem;
transform: rotate(17deg);
transform-origin: top center;
}
.agent-assistant-fab__arm--right {
animation: agent-fab-arm-right-idle 4.1s ease-in-out infinite;
inset-inline-start: 3.08rem;
transform: rotate(-17deg);
transform-origin: top center;
}
.agent-assistant-fab__leg {
border-radius: 0.35rem;
block-size: 0.66rem;
inline-size: 0.48rem;
inset-block-start: 4.36rem;
}
.agent-assistant-fab__leg--left {
animation: agent-fab-leg-left-idle 4.8s ease-in-out infinite;
inset-inline-start: 1.48rem;
transform-origin: top center;
}
.agent-assistant-fab__leg--right {
animation: agent-fab-leg-right-idle 4.8s ease-in-out 0.35s infinite;
inset-inline-start: 2.46rem;
transform-origin: top center;
}
@media (hover: hover) and (pointer: fine) {
.agent-assistant-fab.is-bubble-visible .agent-assistant-fab__bubble:hover {
box-shadow: var(--app-surface-hover-shadow);
}
.agent-assistant-fab__bubble:hover .agent-assistant-fab__bubble-close {
opacity: 1;
}
.agent-assistant-fab__trigger:hover .agent-assistant-fab__bot {
filter: drop-shadow(0 0.7rem 0.7rem var(--agent-assistant-robot-shadow-strong));
}
}
@media (hover: none), (pointer: coarse) {
.agent-assistant-fab__bubble-close {
opacity: 1;
}
}
.agent-assistant-fab__trigger:focus-visible .agent-assistant-fab__bot {
filter:
drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
drop-shadow(0 0 0.34rem rgba(var(--v-theme-primary), 0.55));
}
.agent-assistant-fab.is-pressed .agent-assistant-fab__bot {
transform: translateY(0.22rem) scale(var(--agent-assistant-bot-pressed-scale))
rotate(var(--agent-assistant-robot-tilt));
}
.agent-assistant-fab.is-thinking .agent-assistant-fab__face {
box-shadow:
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
0 0 0.65rem rgba(114, 255, 240, 50%);
}
.agent-assistant-fab.is-thinking .agent-assistant-fab__core {
animation: agent-fab-core-pulse 0.9s ease-in-out infinite;
}
.agent-assistant-fab.is-docked .agent-assistant-fab__bot {
inset-inline-end: -0.42rem;
transform: translateY(-0.2rem) scale(var(--agent-assistant-bot-scale)) rotate(-19deg);
}
.agent-assistant-fab.is-docked .agent-assistant-fab__eye {
transform: translate(calc(var(--agent-assistant-eye-x) * 0.24 - 0.22rem), calc(var(--agent-assistant-eye-y) * 0.24));
}
.agent-assistant-fab.is-docked .agent-assistant-fab__body,
.agent-assistant-fab.is-docked .agent-assistant-fab__leg {
opacity: 0;
transform: translateX(0.8rem) scale(0.72);
}
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--left,
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--right {
animation: none;
opacity: 0;
transform: translateX(0.8rem) scale(0.72);
}
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--left {
z-index: 5;
block-size: 0.95rem;
inline-size: 0.42rem;
inset-block-start: 3.62rem;
inset-inline-start: 1.86rem;
opacity: 1;
transform: rotate(78deg) scale(0.9);
}
.agent-assistant-fab.is-docked .agent-assistant-fab__antenna {
animation: none;
opacity: 0.75;
transform: translate(0.34rem, 0.02rem) rotate(2deg) scale(0.82);
}
@keyframes agent-fab-head-idle {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
}
50% {
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-1.8deg);
}
}
@keyframes agent-fab-body-idle {
0%,
100% {
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
}
50% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.04rem)) scaleY(0.97);
}
}
@keyframes agent-fab-antenna-idle {
0%,
100% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
}
50% {
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(15deg);
}
}
@keyframes agent-fab-arm-left-idle {
0%,
100% {
transform: rotate(17deg);
}
50% {
transform: rotate(12deg) translateY(0.05rem);
}
}
@keyframes agent-fab-arm-right-idle {
0%,
100% {
transform: rotate(-17deg);
}
50% {
transform: rotate(-11deg) translateY(0.05rem);
}
}
@keyframes agent-fab-leg-left-idle {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(4deg) translateY(0.03rem);
}
}
@keyframes agent-fab-leg-right-idle {
0%,
100% {
transform: rotate(0deg);
}
50% {
transform: rotate(-4deg) translateY(0.03rem);
}
}
@keyframes agent-fab-core-pulse {
0%,
100% {
opacity: 0.78;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.18);
}
}
@keyframes agent-fab-blink {
0%,
4%,
8%,
100% {
opacity: 1;
scale: 1 1;
}
6% {
opacity: 0.45;
scale: 1 0.15;
}
}
@media (width <= 600px) {
.agent-assistant-fab {
block-size: 6.65rem;
inline-size: min(12.4rem, calc(100vw - 1rem));
}
.agent-assistant-fab.is-docked {
inline-size: 3.45rem;
}
.agent-assistant-fab__bubbles {
inline-size: min(16.5rem, calc(100vw - 5.6rem));
max-block-size: min(30rem, calc(100vh - 9.2rem));
}
.agent-assistant-fab__bubble-stack {
gap: 0.38rem;
}
.agent-assistant-fab__bubble {
padding-block: 0.56rem;
padding-inline: 0.72rem 1.62rem;
}
.agent-assistant-fab__bubble-title {
font-size: 1.05rem;
}
.agent-assistant-fab__bubble > span {
font-size: 1rem;
}
.agent-assistant-fab__trigger {
block-size: 4.8rem;
inline-size: 5rem;
}
.agent-assistant-fab__bot {
inset-inline-end: 1.02rem;
transform-origin: 70% 78%;
}
.agent-assistant-fab {
--agent-assistant-bot-scale: 0.82;
--agent-assistant-bot-pressed-scale: 0.78;
}
.agent-assistant-fab.is-docked .agent-assistant-fab__bot {
inset-inline-end: -0.72rem;
transform: translateY(-0.16rem) scale(var(--agent-assistant-bot-scale)) rotate(-19deg);
}
}
@media (prefers-reduced-motion: reduce) {
.agent-assistant-fab,
.agent-assistant-fab * {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,41 @@
export type AgentPetActionName =
| 'wave'
| 'sit'
| 'eye-roll'
| 'faint'
| 'disassemble'
| 'happy-jump'
| 'sleep'
| 'stretch'
| 'peek'
| 'scan'
| 'charge'
| 'spin-cheer'
| 'shy'
| 'confused'
export type AgentPetIntent =
| 'idle'
| 'thinking'
| 'speaking'
| 'notify'
| 'success'
| 'warning'
| 'error'
| 'dragging'
| 'docked'
| 'sleeping'
| 'reaction'
export type AgentPetRendererKind = 'css-robot'
export interface AgentPetActionDefinition {
name: AgentPetActionName
intent: AgentPetIntent
clip: string
duration: number
priority: number
interruptible: boolean
cooldown?: number
next?: AgentPetActionName
}

View File

@@ -0,0 +1,127 @@
import { onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
import {
getAgentPetActionDuration,
getAgentPetRandomActionDelay,
pickAgentPetRandomAction,
} from './agentPetActions'
import type { AgentPetActionName } from './types'
interface AgentPetMachineOptions {
active: MaybeRefOrGetter<boolean>
thinking: MaybeRefOrGetter<boolean>
docked: Ref<boolean>
dragging: Ref<boolean>
pressed: Ref<boolean>
shouldAutoDock: () => boolean
scheduleAutoDock: () => void
}
/** 管理桌面宠物的空闲动作队列,避免入口组件直接维护动画计时器。 */
export function useAgentPetMachine(options: AgentPetMachineOptions) {
const currentAction = ref<AgentPetActionName | null>(null)
let lastAction: AgentPetActionName | null = null
let randomActionTimer: number | null = null
let actionEndTimer: number | null = null
/** 判断当前交互状态是否适合播放空闲趣味动作。 */
function canRunRandomAction() {
return (
toValue(options.active) &&
!options.docked.value &&
!options.dragging.value &&
!options.pressed.value &&
!toValue(options.thinking)
)
}
/** 清理等待中的随机动作计时器。 */
function clearRandomActionTimer() {
if (randomActionTimer === null) return
window.clearTimeout(randomActionTimer)
randomActionTimer = null
}
/** 清理正在播放动作的结束计时器。 */
function clearActionEndTimer() {
if (actionEndTimer === null) return
window.clearTimeout(actionEndTimer)
actionEndTimer = null
}
/** 停止当前动作并清理全部宠物动作计时器。 */
function clearAction() {
clearRandomActionTimer()
clearActionEndTimer()
currentAction.value = null
}
/** 完成当前动作后恢复空闲态,并按贴边规则决定是否自动收起。 */
function finishRandomAction() {
clearActionEndTimer()
currentAction.value = null
if (!options.docked.value && options.shouldAutoDock()) {
options.scheduleAutoDock()
return
}
scheduleRandomAction()
}
/** 立即播放指定动作,供通知、交互或外部 renderer 编排主动触发。 */
function playAction(action: AgentPetActionName) {
clearRandomActionTimer()
clearActionEndTimer()
currentAction.value = action
actionEndTimer = window.setTimeout(finishRandomAction, getAgentPetActionDuration(action))
}
/** 播放一个随机趣味动作,由 renderer 根据动作名呈现具体动画。 */
function runRandomAction() {
if (!canRunRandomAction()) return
const action = pickAgentPetRandomAction(lastAction)
lastAction = action
playAction(action)
}
/** 安排下一次随机动作,只在宠物可见且空闲时生效。 */
function scheduleRandomAction() {
clearRandomActionTimer()
if (!canRunRandomAction() || currentAction.value || actionEndTimer !== null) return
randomActionTimer = window.setTimeout(() => {
randomActionTimer = null
runRandomAction()
}, getAgentPetRandomActionDelay())
}
/** 根据入口交互状态同步随机动作队列。 */
function syncSchedule() {
if (canRunRandomAction()) {
if (!currentAction.value && randomActionTimer === null && actionEndTimer === null) scheduleRandomAction()
return
}
clearAction()
}
watch(
[() => toValue(options.active), () => toValue(options.thinking), options.docked, options.dragging, options.pressed],
syncSchedule,
)
onScopeDispose(clearAction)
return {
clearAction,
currentAction,
playAction,
scheduleRandomAction,
syncSchedule,
}
}

View File

@@ -0,0 +1,798 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import type { ApiResponse, RecognitionCacheData, RecognitionCacheItem } from '@/api/types'
import { useConfirm } from '@/composables/useConfirm'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
type RecognitionCacheSource = 'tmdb' | 'douban'
const MOBILE_CACHE_PAGE_SIZE = 20
const { t } = useI18n()
const display = useDisplay()
const createConfirm = useConfirm()
const $toast = useToast()
const globalSettingsStore = useGlobalSettingsStore()
const isMobile = computed(() => display.smAndDown.value)
const recognitionSource = computed<RecognitionCacheSource>(() =>
globalSettingsStore.globalSettings.RECOGNIZE_SOURCE === 'douban' ? 'douban' : 'tmdb',
)
const recognitionSourceName = computed(() =>
recognitionSource.value === 'douban'
? t('setting.cache.recognitionSource.douban')
: t('setting.cache.recognitionSource.themoviedb'),
)
const recognitionIdLabel = computed(() =>
recognitionSource.value === 'douban' ? t('setting.cache.doubanId') : t('setting.cache.tmdbId'),
)
const recognitionCacheEndpoint = computed(() => `${recognitionSource.value}/cache`)
const recognitionFilterPlaceholder = computed(() =>
t('setting.cache.filterRecognitionCache', { source: recognitionSourceName.value }),
)
const loading = ref(false)
const searchFilter = ref('')
const statusFilter = ref<RecognitionStatusFilter>('all')
const selectedItems = ref<string[]>([])
const cacheData = ref<RecognitionCacheData>({
count: 0,
recognized: 0,
unrecognized: 0,
data: [],
})
const mobileVisibleCount = ref(MOBILE_CACHE_PAGE_SIZE)
const mobileInfiniteKey = ref(0)
let cacheLoadRequestId = 0
const statusOptions = computed(() => [
{ title: t('setting.cache.allStatuses'), value: 'all' },
{ title: t('setting.cache.recognizedOnly'), value: 'recognized' },
{ title: t('setting.cache.unrecognizedOnly'), value: 'unrecognized' },
])
const tableHeaders = computed(() => [
{ title: '', key: 'data-table-select', sortable: false, width: '48px' },
{ title: t('setting.cache.poster'), key: 'poster', sortable: false, width: '76px' },
{ title: t('setting.cache.cacheKey'), key: 'key', sortable: true },
{ title: t('setting.cache.recognitionResult'), key: 'result', sortable: false, width: '220px' },
{ title: recognitionIdLabel.value, key: 'recognition_id', sortable: true, width: '120px' },
{ title: t('setting.cache.recognitionStatus'), key: 'status', sortable: true, width: '120px' },
{ title: t('setting.cache.actions'), key: 'actions', sortable: false, width: '72px' },
])
const filteredData = computed(() => {
const keyword = searchFilter.value.trim().toLowerCase()
return cacheData.value.data.filter(item => {
const matchesKeyword =
!keyword ||
[item.key, item.title, item.year, getRecognitionId(item)].some(value => value.toLowerCase().includes(keyword))
const matchesStatus =
statusFilter.value === 'all' || (statusFilter.value === 'recognized' ? isRecognized(item) : !isRecognized(item))
return matchesKeyword && matchesStatus
})
})
const mobileVisibleData = computed(() => filteredData.value.slice(0, mobileVisibleCount.value))
const mobileHasMore = computed(() => mobileVisibleData.value.length < filteredData.value.length)
/** 重置移动端分页,让筛选或刷新后的识别缓存从第一页开始展示。 */
function resetMobilePagination() {
mobileVisibleCount.value = MOBILE_CACHE_PAGE_SIZE
mobileInfiniteKey.value++
}
/** 追加移动端下一页识别缓存,并由虚拟滚动限制实际渲染节点。 */
function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) => void }) {
if (loading.value) {
done('ok')
return
}
if (!mobileHasMore.value) {
done('empty')
return
}
mobileVisibleCount.value = Math.min(mobileVisibleCount.value + MOBILE_CACHE_PAGE_SIZE, filteredData.value.length)
done(mobileHasMore.value ? 'ok' : 'empty')
}
/** 加载当前识别数据源对应的缓存列表。 */
async function loadCacheData(showSuccess = false) {
const requestId = ++cacheLoadRequestId
try {
loading.value = true
const response = (await api.get(recognitionCacheEndpoint.value)) as unknown as ApiResponse<RecognitionCacheData>
if (requestId !== cacheLoadRequestId) return
const responseData = response.data ?? { count: 0, recognized: 0, unrecognized: 0, data: [] }
cacheData.value = {
...responseData,
data: responseData.data.map(item => ({ ...item, recognition_id: getRecognitionId(item) })),
}
selectedItems.value = selectedItems.value.filter(key => cacheData.value.data.some(item => item.key === key))
resetMobilePagination()
if (showSuccess) $toast.success(t('setting.cache.listRefreshSuccess'))
} catch (error) {
if (requestId !== cacheLoadRequestId) return
console.error(error)
$toast.error(t('setting.cache.loadFailed'))
} finally {
if (requestId === cacheLoadRequestId) loading.value = false
}
}
/** 清空当前识别数据源的全部识别缓存。 */
async function clearAllCache() {
const confirmed = await createConfirm({
type: 'warn',
title: t('common.confirm'),
content: t('setting.cache.recognitionClearConfirm', { source: recognitionSourceName.value }),
})
if (!confirmed) return
try {
loading.value = true
const response = (await api.delete(recognitionCacheEndpoint.value)) as unknown as ApiResponse
if (!response.success) throw new Error(response.message)
$toast.success(response.message || t('setting.cache.clearSuccess'))
await loadCacheData()
selectedItems.value = []
} catch (error) {
console.error(error)
$toast.error(t('setting.cache.clearFailed'))
} finally {
loading.value = false
}
}
/** 请求当前识别数据源接口删除指定识别缓存。 */
async function deleteCacheItem(key: string) {
const response = (await api.delete(
`${recognitionCacheEndpoint.value}/${encodeURIComponent(key)}`,
)) as unknown as ApiResponse
if (!response.success) throw new Error(response.message)
}
/** 删除桌面端表格中选中的识别缓存。 */
async function deleteSelectedItems() {
if (selectedItems.value.length === 0) {
$toast.warning(t('setting.cache.selectDeleteWarning'))
return
}
const deleteCount = selectedItems.value.length
try {
loading.value = true
await Promise.all(selectedItems.value.map(deleteCacheItem))
$toast.success(t('setting.cache.deleteSelectedSuccess', { count: deleteCount }))
await loadCacheData()
selectedItems.value = []
} catch (error) {
console.error(error)
$toast.error(t('setting.cache.deleteSelectedFailed'))
} finally {
loading.value = false
}
}
/** 删除单条识别缓存。 */
async function deleteSingleItem(item: RecognitionCacheItem) {
try {
loading.value = true
await deleteCacheItem(item.key)
$toast.success(t('setting.cache.deleteSuccess'))
await loadCacheData()
} catch (error) {
console.error(error)
$toast.error(t('setting.cache.deleteFailed'))
} finally {
loading.value = false
}
}
/** 获取识别缓存海报的可展示地址。 */
function getPosterUrl(item: RecognitionCacheItem): string {
if (!item.poster_path) return ''
const sourceUrl = item.poster_path.startsWith('/')
? `https://${globalSettingsStore.globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w300${item.poster_path}`
: item.poster_path
return getDisplayImageUrl(sourceUrl, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
}
/** 获取当前识别数据源对应的媒体 ID。 */
function getRecognitionId(item: RecognitionCacheItem): string {
const recognitionId = recognitionSource.value === 'douban' ? item.douban_id : item.tmdb_id
return recognitionId ? String(recognitionId) : ''
}
/** 判断识别缓存条目是否包含有效媒体 ID。 */
function isRecognized(item: RecognitionCacheItem): boolean {
const recognitionId = getRecognitionId(item)
return Boolean(recognitionId && recognitionId !== '0')
}
/** 获取移动端识别缓存卡片的稳定渲染 key。 */
function getRecognitionCacheItemKey(item: RecognitionCacheItem): string {
return item.key
}
/** 获取本地化的媒体类型名称。 */
function getMediaTypeLabel(mediaType: string): string {
if (mediaType === 'movie') return t('setting.cache.mediaType.movie')
if (mediaType === 'tv') return t('setting.cache.mediaType.tv')
return t('setting.cache.mediaType.unknown')
}
/** 获取媒体类型对应的主题颜色。 */
function getMediaTypeColor(mediaType: string): string {
if (mediaType === 'movie') return 'primary'
if (mediaType === 'tv') return 'success'
return 'secondary'
}
/** 获取识别状态的本地化名称。 */
function getRecognitionStatusLabel(item: RecognitionCacheItem): string {
return isRecognized(item) ? t('setting.cache.recognized') : t('setting.cache.unrecognized')
}
onMounted(() => {
void loadCacheData()
})
watch([searchFilter, statusFilter], () => {
resetMobilePagination()
})
watch(recognitionSource, () => {
searchFilter.value = ''
statusFilter.value = 'all'
selectedItems.value = []
void loadCacheData()
})
</script>
<template>
<section class="recognition-cache-panel">
<div class="cache-panel-toolbar">
<div class="cache-panel-stats">
<div class="cache-panel-stat cache-panel-stat--primary">
<VIcon icon="mdi-database-outline" :size="isMobile ? 32 : 22" />
<div>
<strong>{{ cacheData.count }}</strong>
<span>{{ t('setting.cache.totalCount') }}</span>
</div>
</div>
<div class="cache-panel-stat cache-panel-stat--success">
<VIcon icon="mdi-check-decagram-outline" :size="isMobile ? 32 : 22" />
<div>
<strong>{{ cacheData.recognized }}</strong>
<span>{{ t('setting.cache.recognized') }}</span>
</div>
</div>
<div v-if="!isMobile" class="cache-panel-stat cache-panel-stat--warning">
<VIcon icon="mdi-help-circle-outline" size="22" />
<div>
<strong>{{ cacheData.unrecognized }}</strong>
<span>{{ t('setting.cache.unrecognized') }}</span>
</div>
</div>
</div>
<div v-if="!isMobile" class="cache-panel-actions">
<VBtn icon variant="text" color="primary" :loading="loading" @click="loadCacheData(true)">
<VIcon icon="mdi-refresh" />
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.refreshList') }}</VTooltip>
</VBtn>
<VBtn
icon
variant="text"
color="warning"
:disabled="selectedItems.length === 0"
:loading="loading"
@click="deleteSelectedItems"
>
<VIcon icon="mdi-delete-sweep-outline" />
<VTooltip activator="parent" location="bottom">
{{ t('setting.cache.deleteSelected') }} ({{ selectedItems.length }})
</VTooltip>
</VBtn>
<VBtn icon variant="text" color="error" :loading="loading" @click="clearAllCache">
<VIcon icon="mdi-delete-variant" />
<VTooltip activator="parent" location="bottom">{{ t('setting.cache.clearAll') }}</VTooltip>
</VBtn>
</div>
</div>
<div class="cache-panel-filters">
<VTextField
v-model="searchFilter"
class="cache-panel-filter"
:label="isMobile ? undefined : recognitionFilterPlaceholder"
:placeholder="isMobile ? recognitionFilterPlaceholder : undefined"
prepend-inner-icon="mdi-magnify"
variant="outlined"
:density="isMobile ? 'comfortable' : 'compact'"
:single-line="isMobile"
clearable
hide-details
/>
<VSelect
v-model="statusFilter"
class="cache-panel-filter"
:label="isMobile ? undefined : t('setting.cache.recognitionStatus')"
:placeholder="isMobile ? t('setting.cache.recognitionStatus') : undefined"
:items="statusOptions"
prepend-inner-icon="mdi-list-status"
variant="outlined"
:density="isMobile ? 'comfortable' : 'compact'"
:single-line="isMobile"
hide-details
/>
</div>
<div v-if="isMobile" class="cache-panel-mobile-actions">
<VBtn variant="tonal" color="primary" :loading="loading" prepend-icon="mdi-refresh" @click="loadCacheData(true)">
{{ t('setting.cache.refresh') }}
</VBtn>
<VBtn variant="tonal" color="error" :loading="loading" prepend-icon="mdi-delete-variant" @click="clearAllCache">
{{ t('setting.cache.clearAll') }}
</VBtn>
</div>
<template v-if="isMobile">
<VInfiniteScroll
v-if="mobileVisibleData.length > 0 || loading"
:key="mobileInfiniteKey"
mode="intersect"
side="end"
:items="mobileVisibleData"
class="recognition-cache-mobile-scroll"
@load="loadMoreMobileCache"
>
<template #loading>
<div class="cache-panel-load-state">
<VProgressCircular indeterminate color="primary" size="22" width="3" />
<span>{{ t('setting.cache.loadingMore') }}</span>
</div>
</template>
<template #empty />
<ProgressiveCardGrid
v-if="mobileVisibleData.length > 0"
:items="mobileVisibleData"
:columns="1"
:gap="10"
:estimated-item-height="152"
:overscan-rows="5"
:get-item-key="getRecognitionCacheItemKey"
>
<template #default="{ item }">
<article class="recognition-cache-mobile-item">
<div class="recognition-cache-poster">
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
<VIcon v-else icon="mdi-image-off-outline" size="28" />
</div>
<div class="recognition-cache-mobile-item__content">
<div class="recognition-cache-mobile-item__title">
{{ item.title || t('setting.cache.unrecognized') }}
</div>
<div class="recognition-cache-mobile-item__meta">
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
{{ getMediaTypeLabel(item.media_type) }}
</VChip>
<span v-if="item.year">{{ item.year }}</span>
<span v-if="isRecognized(item)">{{ recognitionIdLabel }} #{{ getRecognitionId(item) }}</span>
</div>
<div class="recognition-cache-mobile-item__key">{{ item.key }}</div>
</div>
<VBtn
icon
size="small"
variant="text"
color="error"
:aria-label="t('common.delete')"
@click="deleteSingleItem(item)"
>
<VIcon icon="mdi-delete-outline" size="20" />
</VBtn>
</article>
</template>
</ProgressiveCardGrid>
</VInfiniteScroll>
<div v-else class="cache-panel-empty">
<VIcon icon="mdi-database-search-outline" size="42" />
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
</div>
</template>
<VDataTable
v-else
v-model="selectedItems"
class="recognition-cache-table"
:headers="tableHeaders"
:items="filteredData"
:loading="loading"
item-value="key"
show-select
hover
:items-per-page-text="t('common.itemsPerPage')"
:no-data-text="t('common.noDataText')"
:loading-text="t('common.loadingText')"
>
<template #item.poster="{ item }">
<div class="recognition-cache-table__poster">
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
<VIcon v-else icon="mdi-image-off-outline" />
</div>
</template>
<template #item.key="{ item }">
<div class="recognition-cache-table__key">{{ item.key }}</div>
</template>
<template #item.result="{ item }">
<div class="recognition-cache-result">
<strong>{{ item.title || t('setting.cache.unrecognized') }}</strong>
<span v-if="item.year">{{ item.year }}</span>
<VChip size="x-small" variant="tonal" :color="getMediaTypeColor(item.media_type)">
{{ getMediaTypeLabel(item.media_type) }}
</VChip>
</div>
</template>
<template #item.recognition_id="{ item }">
<span v-if="isRecognized(item)" class="font-weight-medium">#{{ getRecognitionId(item) }}</span>
<span v-else class="text-medium-emphasis">-</span>
</template>
<template #item.status="{ item }">
<VChip size="small" variant="tonal" :color="isRecognized(item) ? 'success' : 'warning'">
{{ getRecognitionStatusLabel(item) }}
</VChip>
</template>
<template #item.actions="{ item }">
<VBtn icon size="small" variant="text" color="error" @click="deleteSingleItem(item)">
<VIcon icon="mdi-delete-outline" size="18" />
<VTooltip activator="parent" location="start">{{ t('common.delete') }}</VTooltip>
</VBtn>
</template>
<template #no-data>
<div class="cache-panel-empty">
<VIcon icon="mdi-database-search-outline" size="42" />
<strong>{{ t('setting.cache.noRecognitionCache', { source: recognitionSourceName }) }}</strong>
<span>{{ t('setting.cache.noRecognitionCacheHint') }}</span>
</div>
</template>
</VDataTable>
</section>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.recognition-cache-panel {
display: flex;
flex: 1 1 auto;
flex-direction: column;
padding: 20px;
gap: 16px;
min-block-size: 0;
overflow-y: auto;
}
.cache-panel-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.cache-panel-stats {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.cache-panel-stat {
display: flex;
align-items: center;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
gap: 10px;
min-block-size: 58px;
min-inline-size: 126px;
padding-block: 10px;
padding-inline: 14px;
}
.cache-panel-stat strong,
.cache-panel-stat span {
display: block;
}
.cache-panel-stat strong {
color: rgba(var(--v-theme-on-surface), 0.9);
font-size: 18px;
line-height: 1.15;
}
.cache-panel-stat span {
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 12px;
margin-block-start: 3px;
}
.cache-panel-stat--primary {
color: rgb(var(--v-theme-primary));
}
.cache-panel-stat--success {
color: rgb(var(--v-theme-success));
}
.cache-panel-stat--warning {
color: rgb(var(--v-theme-warning));
}
.cache-panel-actions {
display: flex;
flex: 0 0 auto;
gap: 2px;
}
.cache-panel-filters {
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr) minmax(180px, 0.35fr);
}
.cache-panel-filters :deep(.v-field) {
border-radius: var(--app-field-radius);
background: var(--app-grouped-list-background);
}
.cache-panel-mobile-actions {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cache-panel-mobile-actions :deep(.v-btn) {
min-block-size: 44px;
}
.recognition-cache-table {
overflow: hidden;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
box-shadow: var(--app-surface-shadow);
}
.recognition-cache-table__poster,
.recognition-cache-poster {
display: flex;
overflow: hidden;
align-items: center;
justify-content: center;
border-radius: var(--app-control-radius);
background: rgba(var(--v-theme-on-surface), 0.06);
color: rgba(var(--v-theme-on-surface), 0.36);
}
.recognition-cache-table__poster {
block-size: 62px;
inline-size: 44px;
margin-block: 4px;
}
.recognition-cache-table__poster :deep(.v-img),
.recognition-cache-poster :deep(.v-img) {
block-size: 100%;
inline-size: 100%;
}
.recognition-cache-table__key {
color: rgba(var(--v-theme-on-surface), 0.68);
font-family: monospace;
font-size: 12px;
max-inline-size: 36rem;
overflow-wrap: anywhere;
}
.recognition-cache-result {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.recognition-cache-result strong {
color: rgba(var(--v-theme-on-surface), 0.88);
inline-size: 100%;
}
.recognition-cache-result span {
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 12px;
}
.cache-panel-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
color: rgba(var(--v-theme-on-surface), 0.48);
gap: 8px;
min-block-size: 14rem;
text-align: center;
}
.cache-panel-empty strong {
color: rgba(var(--v-theme-on-surface), 0.78);
font-size: 15px;
}
.cache-panel-empty span {
font-size: 13px;
max-inline-size: 30rem;
}
@media (width <= 959.98px) {
.recognition-cache-panel {
block-size: 100%;
padding-block: 14px calc(18px + env(safe-area-inset-bottom));
padding-inline: 16px;
}
.cache-panel-toolbar {
align-items: flex-start;
}
.cache-panel-stats {
display: grid;
flex: 1 1 auto;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cache-panel-stat {
flex-direction: row;
align-items: center;
padding: 18px;
gap: 14px;
min-block-size: 92px;
min-inline-size: 0;
}
.cache-panel-stat strong {
font-size: 28px;
font-weight: 800;
line-height: 1.05;
white-space: nowrap;
}
.cache-panel-stat span {
font-size: 14px;
font-weight: 600;
margin-block-start: 8px;
}
.cache-panel-filters {
gap: 10px;
grid-template-columns: 1fr;
}
.cache-panel-filter :deep(.v-field__outline) {
color: rgba(var(--v-theme-on-surface), 0.18);
}
.cache-panel-filter :deep(.v-field__input) {
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 16px;
min-block-size: 54px;
}
.recognition-cache-mobile-scroll {
overflow: visible !important;
min-block-size: 20rem;
}
.recognition-cache-mobile-scroll :deep(.v-infinite-scroll__container),
.recognition-cache-mobile-scroll :deep(.progressive-card-grid),
.recognition-cache-mobile-scroll :deep(.progressive-card-grid__track) {
overflow: visible !important;
}
.recognition-cache-mobile-scroll :deep(.v-infinite-scroll__side) {
padding-block: 14px 2px;
}
.cache-panel-load-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 15px;
font-weight: 700;
gap: 8px;
min-block-size: 70px;
}
.recognition-cache-mobile-item {
display: grid;
align-items: start;
padding: 12px;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
gap: 12px;
grid-template-columns: 54px minmax(0, 1fr) 36px;
}
.recognition-cache-poster {
block-size: 78px;
inline-size: 54px;
}
.recognition-cache-mobile-item__content {
min-inline-size: 0;
}
.recognition-cache-mobile-item__title {
color: rgba(var(--v-theme-on-surface), 0.9);
font-size: 15px;
font-weight: 700;
overflow-wrap: anywhere;
}
.recognition-cache-mobile-item__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 12px;
gap: 7px;
margin-block-start: 7px;
}
.recognition-cache-mobile-item__key {
color: rgba(var(--v-theme-on-surface), 0.48);
font-family: monospace;
font-size: 11px;
line-height: 1.35;
margin-block-start: 8px;
overflow-wrap: anywhere;
}
}
@media (width <= 374.98px) {
.recognition-cache-panel {
padding-inline: 12px;
}
.cache-panel-stat {
padding: 12px;
gap: 10px;
}
}
</style>

View File

@@ -75,10 +75,12 @@ const transferSourceItems = computed(() => [
{ title: t('directory.manualTransfer'), value: 'manual' },
])
/** 判断存储类型是否具备预设强调色。 */
function hasKnownStorageType(storageType?: string): storageType is keyof typeof STORAGE_ACCENT_COLOR_MAP {
return !!storageType && Object.prototype.hasOwnProperty.call(STORAGE_ACCENT_COLOR_MAP, storageType)
}
/** 将十六进制颜色转换为 CSS RGB 通道字符串。 */
function hexToRgbString(hexColor: string) {
const normalizedColor = hexColor.replace('#', '')
const colorValue = Number.parseInt(normalizedColor, 16)
@@ -88,6 +90,7 @@ function hexToRgbString(hexColor: string) {
return `${(colorValue >> 16) & 255}, ${(colorValue >> 8) & 255}, ${colorValue & 255}`
}
/** 根据自定义存储序号选取离散的强调色。 */
function getCustomStoragePaletteColor(storageType?: string) {
const customStorageIndex = Math.max(Number(storageType?.match(/\d+$/)?.[0] ?? 1) - 1, 0)
const customStorageColors = ['#F97316', '#8B5CF6', '#06B6D4', '#84CC16', '#EC4899', '#14B8A6']
@@ -95,6 +98,7 @@ function getCustomStoragePaletteColor(storageType?: string) {
return customStorageColors[customStorageIndex % customStorageColors.length]
}
/** 获取指定存储类型在目录卡片中使用的强调色。 */
function getStorageAccentColor(storageType?: string) {
if (hasKnownStorageType(storageType)) return STORAGE_ACCENT_COLOR_MAP[storageType]
@@ -108,6 +112,7 @@ const directoryAccentStyle = computed(() => ({
'--app-card-accent-end-rgb': libraryAccentRgb.value,
}))
/** 根据目录两端的存储类型刷新卡片强调色。 */
function updateDirectoryAccentColors() {
const downloadStorage = props.directory.storage
const libraryStorage = props.directory.library_storage || props.directory.storage
@@ -270,6 +275,7 @@ watch(
v-model="props.directory.name"
variant="underlined"
:label="t('directory.alias')"
mobile-control-width="65%"
class="me-20 text-high-emphasis font-weight-bold"
/>
<span class="app-card-top-action absolute top-3 right-12">
@@ -287,6 +293,7 @@ watch(
variant="underlined"
:items="typeItems"
:label="t('directory.mediaType')"
mobile-control-width="65%"
@update:modelValue="props.directory.media_category = ''"
/>
</VCol>
@@ -296,6 +303,7 @@ watch(
variant="underlined"
:items="getCategories"
:label="t('directory.mediaCategory')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="4">
@@ -304,6 +312,7 @@ watch(
variant="underlined"
:items="resourceStorageOptions"
:label="t('directory.resourceStorage')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="8">
@@ -312,6 +321,7 @@ watch(
:storage="props.directory.storage"
variant="underlined"
:label="t('directory.resourceDirectory')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
@@ -332,6 +342,7 @@ watch(
variant="underlined"
:items="transferSourceItems"
:label="t('directory.autoTransfer')"
mobile-control-width="65%"
/>
</VCol>
</VRow>
@@ -342,6 +353,7 @@ watch(
variant="underlined"
:items="MonitorModeItems"
:label="t('directory.monitorMode')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="4">
@@ -350,6 +362,7 @@ watch(
variant="underlined"
:items="libraryStorageOptions"
:label="t('directory.libraryStorage')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="8">
@@ -358,6 +371,7 @@ watch(
:storage="props.directory.library_storage"
variant="underlined"
:label="t('directory.libraryDirectory')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="4">
@@ -367,6 +381,7 @@ watch(
:items="transferTypeItems"
:label="t('directory.transferType')"
:no-data-text="computedNoDataText"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="8">
@@ -375,6 +390,7 @@ watch(
variant="underlined"
:items="overwriteModeItems"
:label="t('directory.overwriteMode')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">

View File

@@ -17,12 +17,12 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['close', 'changed'])
// 按钮点击
/** 关闭当前优先级规则卡片。 */
function onClose() {
emit('close')
}
// 选项变化
/** 将当前优先级的规则选择结果通知父组件。 */
function filtersChanged(value: string[]) {
emit('changed', props.pri, value)
}
@@ -61,6 +61,7 @@ const selectFilterOptions = computed<{ [key: string]: string }[]>(() => {
:items="selectFilterOptions"
chips
:label="t('filterRule.rules')"
mobile-control-width="80%"
multiple
clearable
@update:modelValue="filtersChanged"

View File

@@ -31,18 +31,45 @@ const cardStyle = computed(() => ({
inlineSize: props.width || '100%',
}))
// 图片加载完成响应
// 媒体库内条目数量,兼容不同后端字段。
const libraryItemCount = computed(() => props.media?.item_count)
// 是否展示右上角数量折角。
const showCountCorner = computed(() => typeof libraryItemCount.value === 'number' && libraryItemCount.value >= 0)
// 右上角数量文案。
const countLabel = computed(() => {
if (!showCountCorner.value) return ''
return formatLibraryCount(libraryItemCount.value || 0)
})
/**
* 格式化右上角媒体库数量。
* @param count 媒体库内媒体数量
*/
function formatLibraryCount(count: number) {
if (count >= 10000) return `${(count / 10000).toFixed(1).replace(/\.0$/, '')}`
return `${count}`
}
/**
* 标记封面加载完成。
*/
function imageLoadHandler() {
imageLoaded.value = true
}
// 图片加载错误
/**
* 标记封面加载失败并切换默认图。
*/
function imageErrorHandler() {
imageError.value = true
imgUrl.value = getDefaultImage()
}
// 默认图片
/**
* 获取媒体服务器默认封面图。
*/
function getDefaultImage() {
if (props.media?.server_type === 'plex') return plex
else if (props.media?.server_type === 'emby') return emby
@@ -53,14 +80,20 @@ function getDefaultImage() {
else return plex
}
// 跳转播放
/**
* 跳转到媒体服务器媒体库页面。
*/
async function goPlay() {
if (props.media) {
await openMediaServerItem(props.media)
}
}
// 生成图片代理路径
/**
* 生成图片代理路径。
* @param url 原始图片地址
* @param use_cookies 是否携带 Cookie 代理图片
*/
function getImgUrl(url: string, use_cookies?: boolean) {
if (!url || imageError.value) return getDefaultImage()
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
@@ -70,7 +103,11 @@ function getImgUrl(url: string, use_cookies?: boolean) {
return imgurl
}
// 根据多张图片生成媒体库封面
/**
* 根据多张图片生成媒体库封面。
* @param imageList 媒体库封面候选图列表
* @param use_cookies 是否携带 Cookie 代理图片
*/
async function drawImages(imageList: string[], use_cookies?: boolean) {
// 图片
const IMAGES = [...imageList]
@@ -102,7 +139,11 @@ async function drawImages(imageList: string[], use_cookies?: boolean) {
// 设置背景色为透明
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 绘制图片
/**
* 绘制单张海报及其倒影。
* @param imgSrc 海报图片地址
* @param index 海报位置
*/
async function drawImageWithReflection(imgSrc: string, index: number) {
if (!canvas) return
@@ -189,13 +230,13 @@ onMounted(async () => {
</div>
</template>
<template #default>
<VCardText
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
>
<h1 class="mb-1 text-white text-shadow font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
{{ props.media?.name }}
</h1>
</VCardText>
<div class="library-card-shade" aria-hidden="true" />
<div v-if="showCountCorner" class="library-card-count-corner">
<span>{{ countLabel }}</span>
</div>
<div class="library-card-label">
<span>{{ props.media?.name }}</span>
</div>
</template>
</VImg>
</template>
@@ -218,6 +259,88 @@ onMounted(async () => {
inline-size: 100%;
}
.library-card-shade {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(2, 6, 23, 0%) 0%, rgba(2, 6, 23, 2%) 56%, rgba(2, 6, 23, 28%) 100%),
linear-gradient(90deg, rgba(2, 6, 23, 8%) 0%, rgba(2, 6, 23, 0%) 42%, rgba(2, 6, 23, 10%) 100%);
pointer-events: none;
}
.library-card-label {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
background: rgba(6, 10, 17, 58%);
block-size: 1.75rem;
border-block-start: 1px solid rgba(255, 255, 255, 12%);
border-end-end-radius: var(--app-surface-radius);
border-end-start-radius: var(--app-surface-radius);
color: #fff;
font-size: 0.75rem;
font-weight: 700;
inset-block-end: 0;
inset-inline: 0;
line-height: 1;
padding-inline: 0.75rem;
text-align: center;
text-shadow: 0 1px 4px rgba(0, 0, 0, 48%);
}
.library-card-label span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-card-count-corner {
position: absolute;
block-size: 3.35rem;
color: #fff;
inline-size: 3.35rem;
inset-block-start: 0;
inset-inline-end: 0;
pointer-events: none;
}
.library-card-count-corner::before {
position: absolute;
background: rgba(8, 13, 22, 72%);
block-size: 100%;
clip-path: polygon(100% 0, 100% 100%, 0 0);
content: "";
inset-block-start: 0;
inset-inline-end: 0;
inline-size: 100%;
}
.library-card-count-corner::after {
position: absolute;
background: rgba(var(--v-theme-primary), 62%);
block-size: 100%;
clip-path: polygon(100% 0, 100% 100%, 0 0);
content: "";
inset-block-start: 0;
inset-inline-end: 0;
inline-size: 100%;
}
.library-card-count-corner span {
position: absolute;
inset-block-start: 0.63rem;
inset-inline-end: 0.18rem;
font-size: 0.625rem;
font-weight: 800;
line-height: 1;
text-shadow: 0 1px 4px rgba(0, 0, 0, 44%);
transform: rotate(45deg);
transform-origin: center;
white-space: nowrap;
z-index: 1;
}
.library-card-placeholder,
.library-card-skeleton {
block-size: 100%;

View File

@@ -0,0 +1,365 @@
<script lang="ts" setup>
import type { MediaServerPlayItem } from '@/api/types'
import noImage from '@images/no-image.jpeg'
import { openMediaServerItem } from '@/utils/appDeepLink'
// 输入参数
const props = defineProps({
media: Object as PropType<MediaServerPlayItem>,
width: String,
height: String,
})
// 图片是否加载完成
const imageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 卡片内联尺寸,便于仪表盘网格按高度进行虚拟布局。
const cardStyle = computed(() => ({
aspectRatio: props.height ? undefined : '16 / 10',
blockSize: props.height,
inlineSize: props.width || '100%',
}))
// 规范化后的播放进度,避免后端异常值撑破进度条。
const progressValue = computed(() => {
const percent = Number(props.media?.percent ?? 0)
if (Number.isNaN(percent)) return 0
return Math.min(Math.max(percent, 0), 100)
})
// 是否存在可续播进度。
const hasProgress = computed(() => progressValue.value > 0)
// 右上角进度标签。
const progressLabel = computed(() => (hasProgress.value ? `${Math.round(progressValue.value)}%` : 'NEW'))
// 副信息兜底,避免空副标题导致卡片底部信息层过空。
const subtitleText = computed(() => props.media?.subtitle || props.media?.type || '继续观看')
// 根据播放进度生成简短提示。
const progressHint = computed(() => {
if (!hasProgress.value) return '未开始'
if (progressValue.value >= 90) return '快看完'
if (progressValue.value <= 10) return '刚开始'
return '继续上次进度'
})
// 计算图片代理地址。
const imageUrl = 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 useCookies = props.media?.use_cookies
if (useCookies) {
url += `&use_cookies=${encodeURIComponent(useCookies)}`
}
return url
})
/**
* 标记封面加载完成。
*/
function imageLoadHandler() {
imageLoaded.value = true
}
/**
* 标记封面加载失败并切换到默认图。
*/
function imageErrorHandler() {
imageLoadError.value = true
}
/**
* 跳转到媒体服务器播放页面。
*/
async function goPlay() {
if (props.media) {
await openMediaServerItem(props.media)
}
}
</script>
<template>
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="playing-card-hover-area">
<VCard
:style="cardStyle"
class="playing-card app-hover-lift-card"
:class="{
'app-hover-lift-card--hovering playing-card--hovering': hover.isHovering,
'playing-card--loaded': imageLoaded,
}"
role="button"
tabindex="0"
@click="goPlay"
@keyup.enter="goPlay"
@keyup.space="goPlay"
>
<VImg
:src="imageUrl"
class="playing-card__image"
:class="{ 'playing-card__image--loaded': imageLoaded }"
cover
@load="imageLoadHandler"
@error="imageErrorHandler"
>
<template #placeholder>
<div class="playing-card__placeholder">
<VSkeletonLoader class="playing-card__skeleton" />
</div>
</template>
</VImg>
<div class="playing-card__scrim" />
<div class="playing-card__bottom-scrim" />
<div class="playing-card__percent">
{{ progressLabel }}
</div>
<div class="playing-card__play" aria-hidden="true">
<VIcon icon="mdi-play" size="26" />
</div>
<div class="playing-card__content">
<div class="playing-card__title">
{{ props.media?.title }}
</div>
<div class="playing-card__subtitle">
{{ subtitleText }}
</div>
<div class="playing-card__meta">
<span>{{ progressHint }}</span>
<span v-if="props.media?.server_type" class="playing-card__server">
{{ props.media.server_type }}
</span>
</div>
</div>
<div class="playing-card__progress" aria-hidden="true">
<div class="playing-card__progress-track">
<div class="playing-card__progress-bar" :style="{ inlineSize: `${progressValue}%` }" />
</div>
</div>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.playing-card-hover-area {
block-size: 100%;
inline-size: 100%;
}
.playing-card {
position: relative;
overflow: hidden;
border: 1px solid rgba(var(--v-border-color), 0.16);
background: rgb(var(--v-theme-surface));
color: #fff;
cursor: pointer;
isolation: isolate;
}
.playing-card__image {
block-size: 100%;
inline-size: 100%;
}
.playing-card__placeholder,
.playing-card__skeleton {
block-size: 100%;
inline-size: 100%;
}
.playing-card__image :deep(.v-img__img) {
filter: saturate(0.96) brightness(0.92);
opacity: 0;
transform: scale(1.01);
transition:
opacity 0.2s ease,
transform 0.35s ease;
}
.playing-card__image--loaded :deep(.v-img__img) {
opacity: 1;
}
.playing-card--hovering .playing-card__image :deep(.v-img__img) {
transform: scale(1.05);
}
.playing-card__scrim,
.playing-card__bottom-scrim {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
}
.playing-card__scrim {
background:
linear-gradient(90deg, rgba(0, 0, 0, 24%) 0%, rgba(0, 0, 0, 4%) 48%, rgba(0, 0, 0, 28%) 100%),
linear-gradient(180deg, rgba(0, 0, 0, 8%) 0%, rgba(0, 0, 0, 0%) 42%);
}
.playing-card__bottom-scrim {
background:
linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
}
.playing-card__percent,
.playing-card__play,
.playing-card__content,
.playing-card__progress {
position: absolute;
z-index: 2;
}
.playing-card__percent {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 18%);
border-radius: 999px;
backdrop-filter: blur(12px);
background: rgba(255, 255, 255, 16%);
block-size: 26px;
font-size: 0.75rem;
font-weight: 800;
inline-size: 52px;
inset-block-start: 12px;
inset-inline-end: 12px;
}
.playing-card__play {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255, 255, 255, 92%);
block-size: 40px;
box-shadow: 0 8px 18px rgba(0, 0, 0, 24%);
color: rgb(11, 15, 20);
inline-size: 40px;
inset-block-end: 46px;
inset-inline-end: 18px;
transition:
background-color 0.2s ease,
transform 0.2s ease;
}
.playing-card--hovering .playing-card__play {
background: #fff;
transform: scale(1.06);
}
.playing-card__content {
inset-block-end: 18px;
inset-inline: 16px 66px;
min-inline-size: 0;
text-shadow: 0 2px 8px rgba(0, 0, 0, 45%);
}
.playing-card__title {
overflow: hidden;
font-size: 1.08rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.playing-card__subtitle {
overflow: hidden;
margin-block-start: 4px;
color: rgba(255, 255, 255, 86%);
font-size: 0.78rem;
font-weight: 600;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.playing-card__meta {
display: flex;
overflow: hidden;
align-items: center;
justify-content: space-between;
margin-block-start: 7px;
color: rgba(255, 255, 255, 70%);
font-size: 0.72rem;
gap: 10px;
line-height: 1.2;
white-space: nowrap;
}
.playing-card__server {
overflow: hidden;
max-inline-size: 6rem;
text-overflow: ellipsis;
text-transform: uppercase;
}
.playing-card__progress {
inset-block-end: 6px;
inset-inline: 16px;
}
.playing-card__progress-track {
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 24%);
block-size: 5px;
}
.playing-card__progress-bar {
border-radius: inherit;
background: linear-gradient(90deg, rgb(var(--v-theme-success)) 0%, rgba(255, 255, 255, 92%) 100%);
block-size: 100%;
min-inline-size: 8px;
transition: inline-size 0.25s ease;
}
@media (max-width: 600px) {
.playing-card__content {
inset-inline-end: 58px;
}
.playing-card__title {
font-size: 1rem;
}
.playing-card__play {
block-size: 36px;
inline-size: 36px;
inset-inline-end: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.playing-card__image :deep(.v-img__img),
.playing-card__play,
.playing-card__progress-bar {
transition: none;
}
.playing-card--hovering .playing-card__image :deep(.v-img__img),
.playing-card--hovering .playing-card__play {
transform: none;
}
}
</style>

View File

@@ -92,6 +92,17 @@ const rightBottomStateDisplay = computed(() => {
return null
})
// 移动端紧凑卡片的状态展示,颜色统一映射到 Vuetify 全局主题 token。
const compactStateDisplay = computed(() => {
if (subscribeState.value === 'S') {
return { color: 'secondary', icon: 'mdi-pause-circle-outline', label: t('subscribe.cardStatePaused') }
}
if (subscribeState.value === 'P') {
return { color: 'info', icon: 'mdi-timer-sand', label: t('subscribe.cardStatePending') }
}
return { color: 'primary', icon: 'mdi-rss', label: t('subscribe.subscribing') }
})
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
const bestVersionBadge = computed(() => {
if (!isEnabledFlag(props.media?.best_version)) return null
@@ -290,22 +301,6 @@ const dropdownItems = computed(() => [
click: searchSubscribe,
},
},
{
title: t('common.details'),
value: 3,
props: {
prependIcon: 'mdi-information-outline',
click: viewMediaDetail,
},
},
{
title: t('common.files'),
value: 4,
props: {
prependIcon: 'mdi-file-document-outline',
click: viewSubscribeFiles,
},
},
{
title: subscribeState.value === 'S' ? t('common.enable') : t('common.pause'),
value: 5,
@@ -334,6 +329,22 @@ const dropdownItems = computed(() => [
},
show: props.media?.type === '电视剧',
},
{
title: t('subscribe.mediaDetail'),
value: 3,
props: {
prependIcon: 'mdi-information-outline',
click: viewMediaDetail,
},
},
{
title: t('subscribe.fileStatistics'),
value: 4,
props: {
prependIcon: 'mdi-file-document-outline',
click: viewSubscribeFiles,
},
},
{
title: t('common.unsubscribe'),
value: 8,
@@ -415,7 +426,7 @@ function handleCardClick() {
>
<VCard
:key="props.media?.id"
class="flex flex-col h-full overflow-hidden"
class="subscribe-card flex flex-col h-full overflow-hidden"
:class="{
'subscribe-card-paused': subscribeState === 'S',
'subscribe-card-pending-tint': subscribeState === 'P',
@@ -423,45 +434,143 @@ function handleCardClick() {
}"
min-height="150"
@click="handleCardClick"
:ripple="!props.batchMode && !props.sortable"
:ripple="display.smAndUp.value && !props.batchMode && !props.sortable"
>
<div
v-if="bestVersionBadge && imageLoaded"
class="best-version-badge"
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
>
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
</div>
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
<IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
<div
v-if="bestVersionBadge && imageLoaded"
class="best-version-badge"
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
>
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
</div>
<div v-if="!props.sortable && display.smAndUp.value" class="me-n3 absolute top-1 right-4">
<IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<template #image v-if="display.smAndUp.value">
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<template #default>
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</template>
</VImg>
</template>
<template v-if="display.xs.value">
<div class="subscribe-card-mobile-media">
<VImg
:src="backdropUrl || posterUrl"
:aspect-ratio="2"
cover
position="top"
@load="imageLoadHandler"
>
<template #placeholder>
<VSkeletonLoader class="h-full w-full" />
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<template #image>
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</VImg>
<div
v-if="props.media?.username || lastUpdateText"
class="subscribe-card-mobile-image-meta"
:class="{ 'subscribe-card-mobile-image-meta--with-badge': bestVersionBadge }"
>
<div
v-if="props.media?.username"
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__user"
:title="props.media?.username"
>
<VIcon icon="mdi-account" size="14" />
<span>{{ props.media?.username }}</span>
</div>
<div
v-if="lastUpdateText"
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__updated"
>
<VIcon icon="mdi-download" size="14" />
<span>{{ lastUpdateText }}</span>
</div>
</div>
</template>
<template #default>
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</template>
</VImg>
</template>
<div>
</div>
<div class="subscribe-card-mobile-body">
<div class="subscribe-card-mobile-title">
{{ props.media?.name }}
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
</div>
<div class="subscribe-card-mobile-footer">
<div class="subscribe-card-mobile-meta">
<div
class="subscribe-card-mobile-state"
:style="{ color: `rgb(var(--v-theme-${compactStateDisplay.color}))` }"
:title="compactStateDisplay.label"
:aria-label="compactStateDisplay.label"
>
<VIcon :icon="compactStateDisplay.icon" size="18" />
<span v-if="subscribeProgressText" class="subscribe-card-mobile-progress-text">
{{ subscribeProgressText }}
</span>
</div>
<IconBtn
v-if="!props.sortable"
class="subscribe-card-mobile-menu"
size="small"
@click.stop
>
<VIcon icon="mdi-dots-horizontal" size="20" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem
v-if="item.show !== false"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<div v-if="props.media?.total_episode" class="subscribe-card-mobile-progress">
<VProgressLinear
:model-value="getPercentage()"
:bg-color="compactStateDisplay.color"
:color="compactStateDisplay.color"
bg-opacity="0.18"
height="4"
rounded
/>
</div>
</div>
</div>
</template>
<div v-else>
<VCardText class="flex items-center pt-3 pb-2">
<div
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
@@ -568,7 +677,7 @@ function handleCardClick() {
color="success"
/>
</div>
</div>
</div>
</VCard>
</div>
</div>
@@ -588,6 +697,155 @@ function handleCardClick() {
border-radius: var(--app-surface-radius);
}
.subscribe-card {
border: var(--app-card-light-border);
}
.subscribe-card-mobile-media {
position: relative;
overflow: hidden;
aspect-ratio: 2 / 1;
flex-shrink: 0;
inline-size: 100%;
}
.subscribe-card-mobile-media .v-img {
block-size: 100%;
}
.subscribe-card-mobile-image-meta {
position: absolute;
z-index: 2;
font-size: 0.6875rem;
font-weight: 500;
inset: 0;
pointer-events: none;
}
.subscribe-card-mobile-image-meta__item {
position: absolute;
display: flex;
min-inline-size: 0;
align-items: center;
gap: 0.25rem;
color: rgba(255, 255, 255, 0.88);
isolation: isolate;
line-height: 1.2;
padding-block: 0.0625rem;
padding-inline: 0.125rem;
}
.subscribe-card-mobile-image-meta__item::before {
position: absolute;
z-index: -1;
border-radius: 0.4rem;
background: rgba(0, 0, 0, 0.36);
content: '';
filter: blur(3px);
inset: -0.25rem -0.55rem;
}
.subscribe-card-mobile-image-meta__user {
max-inline-size: calc(100% - 1rem);
inset-block-start: 0.5rem;
inset-inline-start: 0.5rem;
transition: inset-block-start 0.2s ease;
}
.subscribe-card-mobile-image-meta--with-badge .subscribe-card-mobile-image-meta__user {
max-inline-size: calc(100% - 4.25rem);
inset-block-start: 0.5rem;
}
.subscribe-card-mobile-image-meta__user span {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subscribe-card-mobile-image-meta__updated {
flex-shrink: 0;
color: rgba(255, 255, 255, 0.76);
inset-block-end: 0.5rem;
inset-inline-end: 0.5rem;
}
.subscribe-card-mobile-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.subscribe-card-mobile-title {
display: -webkit-box;
overflow: hidden;
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.subscribe-card-mobile-footer {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-block-start: auto;
}
.subscribe-card-mobile-meta {
display: flex;
min-inline-size: 0;
min-block-size: 2rem;
align-items: center;
gap: 0.25rem;
justify-content: space-between;
}
.subscribe-card-mobile-state {
display: flex;
min-inline-size: 0;
align-items: center;
flex: 1 1 auto;
gap: 0.35rem;
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.25;
white-space: nowrap;
}
.subscribe-card-mobile-state span {
overflow: hidden;
text-overflow: ellipsis;
}
.subscribe-card-mobile-progress-text {
flex-shrink: 0;
}
.subscribe-card-mobile-menu {
block-size: 2rem;
min-block-size: 2rem;
inline-size: 2rem;
min-inline-size: 2rem;
flex: 0 0 2rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.subscribe-card-mobile-progress {
display: flex;
block-size: 4px;
inline-size: 100%;
}
.subscribe-card-mobile-progress .v-progress-linear {
flex: 1 1 auto;
}
.subscribe-card-shell--selected::after {
position: absolute;
z-index: 5;
@@ -621,7 +879,7 @@ function handleCardClick() {
position: absolute;
z-index: 3;
border-radius: inherit;
box-shadow: inset 0 0 48px rgba(56, 189, 248, 40%); // sky-400
box-shadow: inset 0 0 48px rgba(var(--v-theme-info), 0.28);
content: '';
inset: 0;
pointer-events: none;
@@ -653,4 +911,30 @@ function handleCardClick() {
background: rgba(255, 255, 255, 22%);
box-shadow: 0 2px 8px rgba(255, 255, 255, 15%);
}
@media (width <= 599px) {
.subscribe-card {
min-block-size: 0 !important;
}
.subscribe-card-paused {
opacity: 1;
}
.subscribe-card-paused .subscribe-card-mobile-media {
filter: saturate(0.65);
opacity: 0.58;
}
.best-version-badge {
inset-inline-start: auto;
inset-inline-end: 0.5rem;
}
.subscribe-card-pending-tint::after {
box-shadow:
inset 0 0 0 1px rgba(var(--v-theme-info), 0.28),
inset 0 -4rem 5rem rgba(var(--v-theme-info), 0.08);
}
}
</style>

View File

@@ -208,15 +208,12 @@ watch(
inline-size: 100%;
}
.subtitle-card-hover-area:hover .subtitle-card {
transform: translate3d(0, -0.25rem, 0);
}
.subtitle-card {
border: 1px solid transparent;
border: var(--app-card-light-border);
}
.subtitle-card-hover-area:hover .subtitle-card {
border-color: rgba(var(--v-theme-primary), 0.3);
transform: translate3d(0, -0.25rem, 0);
}
</style>

View File

@@ -211,15 +211,12 @@ watch(
inline-size: 100%;
}
.subtitle-item-hover-area:hover .subtitle-item {
transform: translate3d(0, -0.25rem, 0);
}
.subtitle-item {
border: 1px solid transparent;
border: var(--app-card-light-border);
}
.subtitle-item-hover-area:hover .subtitle-item {
border-color: rgba(var(--v-theme-primary), 0.3);
transform: translate3d(0, -0.25rem, 0);
}
</style>

View File

@@ -321,17 +321,14 @@ watch(
inline-size: 100%;
}
.torrent-card-hover-area:hover .torrent-card {
transform: translate3d(0, -0.25rem, 0);
}
/* 卡片悬停效果 */
.torrent-card {
border: 1px solid transparent;
border: var(--app-card-light-border);
}
.torrent-card-hover-area:hover .torrent-card {
border-color: rgba(var(--v-theme-primary), 0.3);
transform: translate3d(0, -0.25rem, 0);
}
/* 优惠标签样式 */

View File

@@ -267,16 +267,13 @@ watch(
inline-size: 100%;
}
.torrent-item-hover-area:hover .torrent-item {
transform: translate3d(0, -0.25rem, 0);
}
.torrent-item {
border: 1px solid transparent;
border: var(--app-card-light-border);
}
.torrent-item-hover-area:hover .torrent-item {
border-color: rgba(var(--v-theme-primary), 0.3);
transform: translate3d(0, -0.25rem, 0);
}
.chip-season {

View File

@@ -68,6 +68,14 @@ const buttonText = computed(() =>
loading.value ? t('dialog.addDownload.downloading') : t('dialog.addDownload.startDownload'),
)
// 下载确认副标题,未传媒体标题时回退到种子标题。
const dialogSubtitle = computed(() => {
const siteName = props.torrent?.site_name?.trim()
const displayTitle = props.title?.trim() || props.torrent?.title?.trim()
return [siteName, displayTitle].filter(Boolean).join(' - ')
})
// 加载目录设置
async function loadDirectories() {
try {
@@ -78,6 +86,7 @@ async function loadDirectories() {
}
}
// 将下载目录配置转换为下载器可识别的存储路径。
function convertToUri(item: TransferDirectoryConf) {
if (!item.download_path) {
return undefined
@@ -181,7 +190,7 @@ onMounted(() => {
<VIcon icon="mdi-monitor-arrow-down-variant" class="me-2" />
</template>
<VCardTitle>{{ t('dialog.addDownload.confirmDownload') }}</VCardTitle>
<VCardSubtitle>{{ torrent?.site_name }} - {{ title }}</VCardSubtitle>
<VCardSubtitle>{{ dialogSubtitle }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />

View File

@@ -0,0 +1,449 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
type AgentMcpTransport = 'stdio' | 'sse' | 'http' | 'streamable_http'
interface AgentMcpServer {
id: string
name: string
enabled: boolean
transport: AgentMcpTransport
description?: string | null
command?: string | null
args: string[]
env: Record<string, string>
url?: string | null
headers: Record<string, string>
timeout: number
tool_prefix?: string | null
require_admin: boolean
}
interface EditableAgentMcpServer extends AgentMcpServer {
argsText: string
envText: string
headersText: string
}
interface AgentMcpToolInfo {
name: string
agent_tool_name: string
description?: string
}
interface AgentMcpTestState {
loading: boolean
success?: boolean
message?: string
tools?: AgentMcpToolInfo[]
}
const props = defineProps<{
modelValue: boolean
servers: AgentMcpServer[]
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'saved', value: AgentMcpServer[]): void
}>()
const { t } = useI18n()
const toast = useToast()
const display = useDisplay()
const saving = ref(false)
const localServers = ref<EditableAgentMcpServer[]>([])
const testStates = ref<Record<string, AgentMcpTestState>>({})
const dialogVisible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const transportItems = computed(() => [
{ title: t('setting.system.aiAgentMcpTransportStdio'), value: 'stdio' },
{ title: t('setting.system.aiAgentMcpTransportSse'), value: 'sse' },
{ title: t('setting.system.aiAgentMcpTransportHttp'), value: 'http' },
])
function createServerId() {
return `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
function dictToLines(value?: Record<string, string>) {
return Object.entries(value || {})
.map(([key, item]) => `${key}=${item}`)
.join('\n')
}
function linesToDict(value: string) {
return String(value || '')
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.reduce<Record<string, string>>((result, line) => {
const separatorIndex = line.indexOf('=')
const key = separatorIndex >= 0 ? line.slice(0, separatorIndex).trim() : line
if (!key) return result
result[key] = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : ''
return result
}, {})
}
function toEditableServer(server: AgentMcpServer): EditableAgentMcpServer {
return {
id: server.id || createServerId(),
name: server.name || t('setting.system.aiAgentMcpUnnamedServer'),
enabled: server.enabled !== false,
transport: server.transport || 'stdio',
description: server.description || '',
command: server.command || '',
args: [...(server.args || [])],
env: { ...(server.env || {}) },
url: server.url || '',
headers: { ...(server.headers || {}) },
timeout: Number(server.timeout || 30),
tool_prefix: server.tool_prefix || '',
require_admin: server.require_admin !== false,
argsText: (server.args || []).join('\n'),
envText: dictToLines(server.env),
headersText: dictToLines(server.headers),
}
}
function toServerPayload(server: EditableAgentMcpServer): AgentMcpServer {
return {
id: server.id || createServerId(),
name: String(server.name || '').trim() || t('setting.system.aiAgentMcpUnnamedServer'),
enabled: Boolean(server.enabled),
transport: server.transport || 'stdio',
description: String(server.description || '').trim() || null,
command: String(server.command || '').trim() || null,
args: String(server.argsText || '')
.split('\n')
.map(item => item.trim())
.filter(Boolean),
env: linesToDict(server.envText),
url: String(server.url || '').trim() || null,
headers: linesToDict(server.headersText),
timeout: Math.max(1, Number(server.timeout || 30)),
tool_prefix: String(server.tool_prefix || '').trim() || null,
require_admin: Boolean(server.require_admin),
}
}
function resetLocalServers() {
localServers.value = (props.servers || []).map(toEditableServer)
testStates.value = {}
}
function addServer() {
localServers.value.push(
toEditableServer({
id: createServerId(),
name: t('setting.system.aiAgentMcpNewServer'),
enabled: true,
transport: 'stdio',
description: '',
command: '',
args: [],
env: {},
url: '',
headers: {},
timeout: 30,
tool_prefix: '',
require_admin: true,
}),
)
}
function removeServer(server: EditableAgentMcpServer) {
localServers.value = localServers.value.filter(item => item.id !== server.id)
}
async function testServer(server: EditableAgentMcpServer) {
const payload = toServerPayload(server)
testStates.value[payload.id] = { loading: true }
try {
const result: { [key: string]: any } = await api.post('message/agent/mcp/servers/test', { server: payload })
testStates.value[payload.id] = {
loading: false,
success: Boolean(result.success),
message: result.message || result.data?.message || '',
tools: result.data?.tools || [],
}
} catch (error) {
testStates.value[payload.id] = {
loading: false,
success: false,
message: error instanceof Error ? error.message : String(error),
tools: [],
}
}
}
async function saveServers() {
saving.value = true
try {
const servers = localServers.value.map(toServerPayload)
const result: { [key: string]: any } = await api.post('message/agent/mcp/servers', { servers })
if (result.success) {
toast.success(t('setting.system.aiAgentMcpSaveSuccess'))
emit('saved', servers)
dialogVisible.value = false
return
}
toast.error(result.message || t('setting.system.aiAgentMcpSaveFailed'))
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
} finally {
saving.value = false
}
}
watch(
() => props.modelValue,
value => {
if (value) resetLocalServers()
},
)
watch(
() => props.servers,
() => {
if (props.modelValue) resetLocalServers()
},
{ deep: true },
)
</script>
<template>
<VDialog v-model="dialogVisible" scrollable max-width="72rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-server-network" class="me-2" />
</template>
<VCardTitle>{{ t('setting.system.aiAgentMcpDialogTitle') }}</VCardTitle>
<VCardSubtitle>{{ t('setting.system.aiAgentMcpDialogDesc') }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="dialogVisible = false" />
<VCardText>
<div class="d-flex justify-end mb-4">
<VBtn color="success" variant="tonal" prepend-icon="mdi-plus" @click="addServer">
{{ t('setting.system.aiAgentMcpAddServer') }}
</VBtn>
</div>
<VAlert v-if="localServers.length === 0" type="info" variant="tonal">
{{ t('setting.system.aiAgentMcpEmpty') }}
</VAlert>
<VExpansionPanels v-else variant="accordion" class="agent-mcp-panels">
<VExpansionPanel v-for="server in localServers" :key="server.id">
<VExpansionPanelTitle>
<div class="agent-mcp-panel-title">
<VIcon :icon="server.enabled ? 'mdi-lan-connect' : 'mdi-lan-disconnect'" />
<span>{{ server.name || t('setting.system.aiAgentMcpUnnamedServer') }}</span>
<VChip size="small" variant="tonal">{{ server.transport }}</VChip>
</div>
</VExpansionPanelTitle>
<VExpansionPanelText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="server.name"
:label="t('setting.system.aiAgentMcpName')"
prepend-inner-icon="mdi-label-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="server.transport"
:label="t('setting.system.aiAgentMcpTransport')"
:items="transportItems"
prepend-inner-icon="mdi-transit-connection-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="server.enabled"
:label="t('setting.system.aiAgentMcpEnabled')"
:hint="t('setting.system.aiAgentMcpEnabledHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="server.require_admin"
:label="t('setting.system.aiAgentMcpRequireAdmin')"
:hint="t('setting.system.aiAgentMcpRequireAdminHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="server.timeout"
:label="t('setting.system.aiAgentMcpTimeout')"
type="number"
min="1"
prepend-inner-icon="mdi-timer-sand"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="server.tool_prefix"
:label="t('setting.system.aiAgentMcpToolPrefix')"
:hint="t('setting.system.aiAgentMcpToolPrefixHint')"
persistent-hint
prepend-inner-icon="mdi-form-textbox"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="server.description"
:label="t('setting.system.aiAgentMcpDescription')"
rows="1"
auto-grow
prepend-inner-icon="mdi-text-box-outline"
/>
</VCol>
<template v-if="server.transport === 'stdio'">
<VCol cols="12" md="6">
<VTextField
v-model="server.command"
:label="t('setting.system.aiAgentMcpCommand')"
placeholder="npx"
prepend-inner-icon="mdi-console"
/>
</VCol>
<VCol cols="12" md="6">
<VTextarea
v-model="server.argsText"
:label="t('setting.system.aiAgentMcpArgs')"
:hint="t('setting.system.aiAgentMcpArgsHint')"
persistent-hint
rows="2"
auto-grow
prepend-inner-icon="mdi-format-list-bulleted"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="server.envText"
:label="t('setting.system.aiAgentMcpEnv')"
:hint="t('setting.system.aiAgentMcpKeyValueHint')"
persistent-hint
rows="2"
auto-grow
prepend-inner-icon="mdi-code-braces"
/>
</VCol>
</template>
<template v-else>
<VCol cols="12">
<VTextField
v-model="server.url"
:label="t('setting.system.aiAgentMcpUrl')"
placeholder="http://127.0.0.1:3001/api/v1/mcp"
prepend-inner-icon="mdi-link-variant"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="server.headersText"
:label="t('setting.system.aiAgentMcpHeaders')"
:hint="t('setting.system.aiAgentMcpKeyValueHint')"
persistent-hint
rows="2"
auto-grow
prepend-inner-icon="mdi-format-align-left"
/>
</VCol>
</template>
</VRow>
<VAlert
v-if="testStates[server.id]?.message"
:type="testStates[server.id]?.success ? 'success' : 'error'"
variant="tonal"
density="comfortable"
class="mt-3"
>
<div>{{ testStates[server.id]?.message }}</div>
<div v-if="testStates[server.id]?.tools?.length" class="agent-mcp-tool-list mt-2">
<VChip
v-for="tool in testStates[server.id]?.tools"
:key="tool.agent_tool_name"
size="small"
variant="tonal"
>
{{ tool.agent_tool_name }}
</VChip>
</div>
</VAlert>
<div class="agent-mcp-actions mt-4">
<VBtn
color="info"
variant="tonal"
prepend-icon="mdi-connection"
:loading="testStates[server.id]?.loading"
@click="testServer(server)"
>
{{ t('setting.system.aiAgentMcpTest') }}
</VBtn>
<VBtn color="error" variant="text" prepend-icon="mdi-delete-outline" @click="removeServer(server)">
{{ t('common.delete') }}
</VBtn>
</div>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</VCardText>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
prepend-icon="mdi-content-save"
class="px-5"
:loading="saving"
@click="saveServers"
>
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>
.agent-mcp-panel-title {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.agent-mcp-actions,
.agent-mcp-tool-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
</style>

View File

@@ -9,7 +9,6 @@ type UnknownRecord = Record<string, any>
const props = withDefaults(
defineProps<{
colors?: Record<string, string>
enabled: Record<string, boolean>
elevated?: boolean
hint: string
@@ -24,7 +23,6 @@ const props = withDefaults(
valueGetter?: (item: UnknownRecord) => string
}>(),
{
colors: () => ({}),
elevated: false,
labelGetter: undefined,
modelValue: true,
@@ -63,17 +61,42 @@ const elevatedValue = computed({
})
watch(
() => [props.enabled, props.elevated, props.items],
() => props.enabled,
() => {
resetLocalSettings()
},
{ deep: true, immediate: true },
)
watch(
() => props.elevated,
value => {
localElevated.value = value
},
)
watch(
() => props.items.map(item => getItemValue(item)).join('\u0000'),
() => {
syncLocalEnabledItems()
},
)
// 重置弹窗内部设置副本,避免直接修改父级 props。
function resetLocalSettings() {
localEnabled.value = { ...props.enabled }
localElevated.value = props.elevated
syncLocalEnabledItems()
}
// 同步新增设置项的默认状态,避免刷新列表时覆盖用户正在编辑的本地选择。
function syncLocalEnabledItems() {
props.items.forEach(item => {
const key = getItemValue(item)
if (key && !(key in localEnabled.value)) {
localEnabled.value[key] = Boolean(props.enabled[key])
}
})
}
// 获取设置项的稳定键值。
@@ -129,12 +152,13 @@ function submitSettings() {
<VCardText>
<p class="settings-hint">{{ props.hint }}</p>
<div class="settings-grid">
<div
<button
v-for="item in props.items"
:key="getItemValue(item)"
type="button"
class="setting-item"
:class="{ 'enabled': localEnabled[getItemValue(item)] }"
:style="{ '--item-color': props.colors[getItemValue(item)] }"
:aria-pressed="Boolean(localEnabled[getItemValue(item)])"
@click="toggleItem(item)"
>
<div class="setting-item-inner">
@@ -147,7 +171,7 @@ function submitSettings() {
</div>
<span class="setting-label">{{ getItemLabel(item) }}</span>
</div>
</div>
</button>
</div>
<p v-if="props.switchLabel" class="mt-3">
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
@@ -186,43 +210,48 @@ function submitSettings() {
.settings-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.setting-label {
flex: 1;
color: rgba(var(--v-theme-on-surface), 0.8);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
font-weight: 550;
line-height: 1.35;
text-align: start;
transition: color 0.2s ease;
}
.setting-item {
appearance: none;
position: relative;
overflow: hidden;
border-radius: var(--app-surface-radius);
background-color: rgba(var(--v-theme-surface-variant), 0.3);
min-block-size: 48px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 10px;
background-color: rgba(var(--v-theme-on-surface), 0.04);
cursor: pointer;
font: inherit;
padding-block: 10px;
padding-inline: 12px;
transition: all 0.2s ease;
transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
}
.setting-item::before {
position: absolute;
background: linear-gradient(90deg, var(--item-color, rgb(var(--v-theme-primary))) 0%, transparent 100%);
background: rgb(var(--v-theme-primary));
content: '';
inline-size: 3px;
inset-block: 0;
inset-inline-start: 0;
opacity: 0.3;
opacity: 0;
transition: opacity 0.2s ease;
}
.setting-item.enabled {
border-color: rgba(var(--v-theme-primary), 0.4);
border-color: rgba(var(--v-theme-primary), 0.3);
background-color: rgba(var(--v-theme-primary), 0.08);
}
@@ -230,6 +259,24 @@ function submitSettings() {
opacity: 1;
}
.setting-item:hover {
border-color: rgba(var(--v-theme-primary), 0.32);
background-color: rgba(var(--v-theme-primary), 0.06);
}
.setting-item:active {
transform: scale(0.99);
}
.setting-item:focus-visible {
outline: 3px solid rgba(var(--v-theme-primary), 0.28);
outline-offset: 2px;
}
.setting-item.enabled .setting-label {
color: rgb(var(--v-theme-on-surface));
}
.setting-item-inner {
display: flex;
align-items: center;
@@ -240,4 +287,11 @@ function submitSettings() {
display: flex;
flex-shrink: 0;
}
@media (width <= 760px) {
.settings-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -9,12 +9,10 @@ const display = useDisplay()
const props = withDefaults(
defineProps<{
colors?: Record<string, string>
modelValue?: boolean
tabs: DiscoverSource[]
}>(),
{
colors: () => ({}),
modelValue: true,
},
)
@@ -55,9 +53,16 @@ function submitOrder() {
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="35rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VDialog
v-if="visible"
v-model="visible"
width="35rem"
class="settings-dialog"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard class="settings-card">
<VCardItem class="settings-card-header">
<VCardTitle>
<VIcon icon="mdi-order-alphabetical-ascending" size="small" class="me-2" />
{{ t('discover.setTabOrder') }}
@@ -72,14 +77,11 @@ function submitOrder() {
handle=".cursor-move"
item-key="mediaid_prefix"
tag="div"
:animation="180"
:component-data="{ 'class': 'settings-grid' }"
>
<template #item="{ element }">
<VCard
variant="text"
class="setting-item enabled"
:style="{ '--item-color': props.colors[element.mediaid_prefix] }"
>
<VCard variant="text" class="setting-item enabled">
<div class="setting-item-inner">
<span class="setting-label">{{ element.name }}</span>
<VIcon icon="mdi-drag" class="drag-icon cursor-move" />
@@ -102,6 +104,11 @@ function submitOrder() {
</template>
<style scoped>
.settings-card-header {
padding-block: 16px;
padding-inline: 20px;
}
.settings-hint {
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: 0.9rem;
@@ -110,50 +117,86 @@ function submitOrder() {
.settings-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.setting-item {
position: relative;
overflow: hidden;
background-color: rgba(var(--v-theme-primary), 0.08);
min-block-size: 48px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 10px;
background-color: rgba(var(--v-theme-on-surface), 0.04);
cursor: grab;
padding-block: 10px;
padding-inline: 12px;
transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
}
.setting-item::before {
position: absolute;
background-color: var(--item-color, #4caf50);
background-color: rgb(var(--v-theme-primary));
block-size: 100%;
content: '';
inline-size: 4px;
inline-size: 3px;
inset-block-start: 0;
inset-inline-start: 0;
opacity: 0;
transition: opacity 0.2s ease;
}
.setting-item.enabled {
border-color: rgba(var(--v-theme-primary), 0.3);
background-color: rgba(var(--v-theme-primary), 0.08);
}
.setting-item.enabled::before {
opacity: 1;
}
.setting-item:hover {
border-color: rgba(var(--v-theme-primary), 0.32);
background-color: rgba(var(--v-theme-primary), 0.06);
}
.setting-item:active {
cursor: grabbing;
transform: scale(0.99);
}
.setting-item-inner {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
}
.setting-label {
flex: 1;
color: rgba(var(--v-theme-primary), 0.9);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
font-weight: 550;
line-height: 1.35;
transition: color 0.2s ease;
}
.setting-item.enabled .setting-label {
color: rgb(var(--v-theme-on-surface));
}
.drag-icon {
opacity: 0.5;
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.52);
transition: color 0.2s ease;
}
@media (width <= 600px) {
.setting-item:hover .drag-icon {
color: rgb(var(--v-theme-primary));
}
@media (width <= 760px) {
.settings-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -105,9 +105,9 @@ function submitTemplate() {
<style scoped>
.notification-template-editor-dialog {
display: flex;
overflow: hidden;
flex-direction: column;
max-block-size: calc(100dvh - 2rem);
overflow: hidden;
}
.template-editor-header {
@@ -147,8 +147,8 @@ function submitTemplate() {
.template-ace-editor {
flex: 1 1 auto;
min-block-size: 0;
block-size: auto;
min-block-size: 0;
}
.template-editor-actions {

View File

@@ -1,171 +0,0 @@
<script setup lang="ts">
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
const props = withDefaults(
defineProps<{
modelValue?: boolean
type?: 'offline' | 'online'
}>(),
{
modelValue: true,
type: 'offline',
},
)
const { t } = useI18n()
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
// 重试连接
const retrying = ref(false)
/** 尝试请求静态资源来触发网络状态重新检测。 */
async function handleRetry() {
if (retrying.value) return
retrying.value = true
try {
await fetch('/favicon.ico?' + new Date().getTime(), {
method: 'HEAD',
cache: 'no-cache',
})
setTimeout(() => {
retrying.value = false
}, 1000)
} catch (error) {
retrying.value = false
}
}
// 状态文本
const statusText = computed(() => {
if (props.type === 'online') {
return t('app.onlineMessage')
}
return getOfflineMessage()
})
// 图标
const statusIcon = computed(() => {
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
})
// 颜色主题
const colorTheme = computed(() => {
return props.type === 'online' ? 'success' : 'error'
})
</script>
<template>
<VDialog :model-value="props.modelValue" persistent max-width="420" scrollable>
<VCard class="offline-dialog">
<div class="status-icon-wrapper">
<div class="status-icon-bg">
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
</div>
</div>
<VCardText class="text-center">
<h2 class="offline-title mb-4">
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
</h2>
<p class="offline-message mb-6">
{{ statusText }}
</p>
<div class="action-section mb-6">
<VBtn
v-if="props.type === 'offline'"
:loading="retrying"
:color="colorTheme"
size="default"
variant="flat"
@click="handleRetry"
>
<VIcon icon="mdi-refresh" class="me-2" />
{{ retrying ? t('common.checking') : t('common.retry') }}
</VBtn>
</div>
<div class="status-indicators">
<VChip
:color="isOnline ? 'success' : 'error'"
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
variant="tonal"
size="small"
class="me-2"
>
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
</VChip>
<VChip
:color="canPerformNetworkAction ? 'success' : 'warning'"
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
variant="tonal"
size="small"
>
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
</VChip>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
.status-icon-wrapper {
padding-block: 24px 0;
padding-inline: 24px;
text-align: center;
}
.status-icon-bg {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
animation: icon-pulse 3s ease-in-out infinite;
background: rgba(var(--v-theme-surface-variant), 0.5);
block-size: 80px;
inline-size: 80px;
margin-block: 0;
margin-inline: auto;
}
.status-icon-bg::before {
position: absolute;
z-index: -1;
border-radius: 50%;
animation: icon-glow 2s ease-in-out infinite alternate;
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
content: '';
inset: -3px;
opacity: 0.1;
}
@keyframes icon-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@keyframes icon-glow {
0% {
opacity: 0.1;
transform: scale(1);
}
100% {
opacity: 0.3;
transform: scale(1.1);
}
}
</style>

View File

@@ -46,6 +46,14 @@ const isRefreshed = ref(false)
// 渲染模式: 'vuetify' 或 'vue'
const renderMode = ref('vuetify')
// 插件未声明布局偏好时沿用标准配置弹窗宽度。
const dialogMaxWidth = ref('60rem')
interface PluginConfigLayout {
/** 插件配置界面期望的最大宽度,使用合法 CSS 尺寸。 */
maxWidth?: string
}
// Vue 模式:动态加载的组件
const dynamicComponent = defineAsyncComponent({
// 工厂函数
@@ -89,6 +97,7 @@ async function loadPluginUIData() {
pluginFormItems = []
pluginConfigForm.value = {}
renderMode.value = 'vuetify'
dialogMaxWidth.value = '60rem'
try {
// 获取UI定义
@@ -123,6 +132,12 @@ function handleVueComponentSave(newConfig: Record<string, any>) {
savePluginConf()
}
// 联邦配置组件可按自身布局密度覆盖宿主弹窗宽度。
function handleVueComponentLayout(layout?: PluginConfigLayout | null) {
const maxWidth = typeof layout?.maxWidth === 'string' ? layout.maxWidth.trim() : ''
dialogMaxWidth.value = maxWidth || '60rem'
}
// 调用API保存配置数据
async function savePluginConf() {
// 显示等待提示框
@@ -148,7 +163,7 @@ onBeforeMount(async () => {
})
</script>
<template>
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
<!-- Vuetify 渲染模式 -->
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
<VDialogCloseBtn @click="emit('close')" />
@@ -192,6 +207,7 @@ onBeforeMount(async () => {
:initial-config="pluginConfigForm"
:api="api"
@save="handleVueComponentSave"
@layout="handleVueComponentLayout"
@switch="emit('switch')"
@close="emit('close')"
/>

View File

@@ -51,8 +51,14 @@ function downloadLogger() {
</script>
<template>
<VDialog v-if="visible" v-model="visible" scrollable max-width="72rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialog
v-if="visible"
v-model="visible"
:scrollable="display.mdAndUp.value"
max-width="72rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard class="logging-dialog-card">
<VDialogCloseBtn v-model="visible" />
<VCardItem>
<VCardTitle class="d-inline-flex">
@@ -75,9 +81,29 @@ function downloadLogger() {
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<VCardText class="logging-dialog-content pa-0">
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
@media (width <= 960px) {
.logging-dialog-card {
display: flex;
flex-direction: column;
overflow: hidden;
block-size: 100%;
}
.logging-dialog-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
inline-size: 100%;
min-block-size: 0;
overflow: hidden;
}
}
</style>

View File

@@ -10,6 +10,7 @@ import {
ManualTransferPayload,
ManualTransferPreviewData,
ManualTransferPreviewItem,
MediaInfo,
StorageConf,
TransferDirectoryConf,
TransferForm,
@@ -123,6 +124,20 @@ interface TargetDirectoryOption {
const AUTO_TARGET_PATH_VALUE = '__moviepilot_auto_target_path__'
// 媒体类型映射到手动整理接口接受的类型名。
function resolveTransferMediaType(type?: string) {
const normalizedType = type?.trim().toLowerCase()
if (!normalizedType) return undefined
const movieTypes = ['电影', 'movie']
if (movieTypes.includes(normalizedType)) return '电影'
const tvTypes = ['电视剧', 'tv', 'series']
if (tvTypes.includes(normalizedType)) return '电视剧'
return undefined
}
// 生成文件项稳定键,用于去重和状态同步。
function getFileItemKey(item?: FileItem) {
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
@@ -298,6 +313,14 @@ const transferForm = reactive<TransferForm>({
episode_group: null,
})
// 处理媒体搜索结果选择,同步搜索结果中已识别的媒体类型。
function handleMediaSelected(item: Pick<MediaInfo, 'type'>) {
const typeName = resolveTransferMediaType(item.type)
if (!typeName) return
transferForm.type_name = typeName
}
// 所有媒体库目录
const directories = ref<TransferDirectoryConf[]>([])
@@ -1019,6 +1042,19 @@ function mergePreviewData(target: ManualTransferPreviewData, incoming?: ManualTr
}
}
// 从标准响应中提取可展示的整理预览数据,优先保留顶层本地化消息。
function resolvePreviewResponseData(result: ApiResponse<ManualTransferPreviewData>) {
if (!result.data) return result.data
const message = result.message_i18n || result.message || result.data.message
if (!message || message === result.data.message) return result.data
return {
...result.data,
message,
}
}
// 预览整理结果
async function previewTransfer() {
if (!props.logids && !normalizedItems.value.length) return
@@ -1046,7 +1082,7 @@ async function previewTransfer() {
}),
)
} else {
mergePreviewData(mergedPreviewData, result.data)
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
}
} catch (err: any) {
console.warn(`预览请求异常: ${err?.message}`)
@@ -1078,7 +1114,7 @@ async function previewTransfer() {
return
}
mergePreviewData(mergedPreviewData, result.data)
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
} catch (err: any) {
console.warn(`预览请求异常: ${err?.message}`)
mergePreviewData(
@@ -1114,7 +1150,7 @@ async function previewTransfer() {
return
}
mergePreviewData(mergedPreviewData, result.data)
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
} catch (err: any) {
console.warn(`预览请求异常: ${err?.message}`)
mergePreviewData(
@@ -1196,7 +1232,7 @@ async function handleTransferLog(logid: number, background: boolean = false) {
function handleProgressMessage(event: MessageEvent) {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressText.value = progress.text_i18n || progress.text
progressValue.value = progress.value
}
}
@@ -1714,12 +1750,14 @@ onUnmounted(() => {
v-if="mediaSource === 'themoviedb'"
v-model="transferForm.tmdbid"
@close="mediaSelectorDialog = false"
@select="handleMediaSelected"
:type="mediaSource"
/>
<MediaIdSelector
v-else
v-model="transferForm.doubanid"
@close="mediaSelectorDialog = false"
@select="handleMediaSelected"
:type="mediaSource"
/>
</VDialog>

View File

@@ -7,6 +7,7 @@ import { useUserStore, useGlobalSettingsStore } from '@/stores'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { VDialog, VMenu } from 'vuetify/components'
import { buildUserPermissionContext, hasPermission, filterMenusByPermission } from '@/utils/permission'
// 显示器宽度
@@ -15,10 +16,16 @@ const display = useDisplay()
// 多语言支持
const { t } = useI18n()
// 定义props接收modelValue
const props = defineProps<{
modelValue: boolean
}>()
// 定义 props接收浮层状态及是否显示响应式入口。
const props = withDefaults(
defineProps<{
modelValue: boolean
showActivator?: boolean
}>(),
{
showActivator: false,
},
)
// 路由
const router = useRouter()
@@ -78,16 +85,33 @@ const dialog = computed({
set: val => emit('update:modelValue', val),
})
// 桌面使用锚定下拉,小屏继续使用原有搜索弹窗。
const searchOverlay = computed(() => (display.mdAndUp.value ? VMenu : VDialog))
const searchOverlayProps = computed(() =>
display.mdAndUp.value
? {
closeOnContentClick: false,
location: 'bottom start' as const,
offset: 8,
scrollStrategy: 'reposition' as const,
}
: {
fullscreen: true,
maxWidth: '40rem',
scrollable: true,
},
)
// 搜索词
const searchWord = ref<string | null>(null)
// ref
const searchWordInput = ref<HTMLElement | null>(null)
// 当前尺寸下可见的搜索输入框。
const searchWordInput = ref<HTMLInputElement | null>(null)
// 近期搜索词条
const recentSearches = ref<string[]>([])
// 检测操作系统是否是Mac
/** 检测操作系统是否为 macOS。 */
function isMac() {
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
}
@@ -95,7 +119,7 @@ function isMac() {
// 计算属性:根据操作系统显示不同的按键提示
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
// 保存近期搜索到本地
/** 将有效关键词保存近期搜索记录。 */
function saveRecentSearches(keyword: string) {
if (!keyword) return
if (recentSearches.value.includes(keyword)) return
@@ -103,7 +127,7 @@ function saveRecentSearches(keyword: string) {
localStorage.setItem('MP_RecentSearches', JSON.stringify(recentSearches.value))
}
// 从本地加载近期搜索
/** 从本地存储加载并裁剪近期搜索记录。 */
function loadRecentSearches() {
const recentSearchesStr = localStorage.getItem('MP_RecentSearches')
if (recentSearchesStr) {
@@ -115,7 +139,7 @@ function loadRecentSearches() {
}
}
// 所有菜单功能
/** 获取可参与全局搜索的导航菜单和设置入口。 */
function getMenus(): NavMenu[] {
let menus: NavMenu[] = []
// 导航菜单
@@ -129,6 +153,7 @@ function getMenus(): NavMenu[] {
header: item.header,
admin: item.admin,
permission: item.permission,
feature: item.feature,
}),
)
// 设置标签页
@@ -170,7 +195,7 @@ const matchedMenuItems = computed(() => {
// 所有插件(已安装)
const pluginItems = ref<Plugin[]>([])
// 获取插件列表数据
/** 加载已安装插件,供搜索结果匹配。 */
async function fetchInstalledPlugins() {
try {
pluginItems.value = await api.get('plugin/', {
@@ -194,7 +219,7 @@ const matchedPluginItems = computed(() => {
})
})
// 获取订阅列表数据
/** 加载订阅列表,供搜索结果匹配。 */
async function fetchSubscribes() {
try {
SubscribeItems.value = await api.get('subscribe/')
@@ -203,7 +228,7 @@ async function fetchSubscribes() {
}
}
// 从接口加载用户站点偏好设置
/** 从接口加载用户站点搜索偏好。 */
const loadUserSitePreferences = async () => {
try {
const result = await api.get('system/setting/public/IndexerSites')
@@ -216,7 +241,7 @@ const loadUserSitePreferences = async () => {
}
}
// 查询所有站点
/** 查询所有启用站点,并初始化默认选择。 */
async function queryAllSites() {
try {
const data: Site[] = await api.get('site/')
@@ -231,7 +256,7 @@ async function queryAllSites() {
}
}
// 打开站点选择对话框
/** 打开指定资源类型的站点选择对话框。 */
const openSiteDialog = (type: 'torrent' | 'subtitle' = 'torrent') => {
siteSearchType.value = type
chooseSiteDialog.value = true
@@ -247,7 +272,7 @@ const matchedSubscribeItems = computed(() => {
})
})
// 搜索多站点
/** 使用选中的站点执行当前资源类型搜索。 */
function searchSites(sites: number[]) {
chooseSiteDialog.value = false
selectedSites.value = sites
@@ -258,7 +283,7 @@ function searchSites(sites: number[]) {
searchTorrent()
}
// 搜索资源
/** 使用当前关键词搜索站点资源。 */
function searchTorrent() {
if (!searchWord.value || !hasSearchPermission.value) return
// 记录搜索词
@@ -273,12 +298,10 @@ function searchTorrent() {
sites: selectedSites.value.join(','),
},
})
// 关闭搜索对话框
dialog.value = false
emit('close')
closeSearch()
}
// 搜索字幕资源
/** 使用当前关键词搜索字幕资源。 */
function searchSubtitle() {
if (!searchWord.value || !hasSearchPermission.value) return
saveRecentSearches(searchWord.value)
@@ -291,11 +314,10 @@ function searchSubtitle() {
sites: selectedSites.value.join(','),
},
})
dialog.value = false
emit('close')
closeSearch()
}
// 跳转媒体搜索页面
/** 跳转到指定类型的媒体搜索结果页。 */
function searchMedia(searchType: string) {
// 搜索类型 media/person
if (!searchWord.value || !hasDiscoveryPermission.value) return
@@ -307,10 +329,10 @@ function searchMedia(searchType: string) {
type: searchType,
},
})
emit('close')
closeSearch()
}
// 跳转到历史记录页面
/** 跳转到包含当前关键词的历史记录页。 */
function searchHistory() {
if (!searchWord.value) return
saveRecentSearches(searchWord.value)
@@ -320,10 +342,10 @@ function searchHistory() {
search: searchWord.value,
},
})
emit('close')
closeSearch()
}
// 跳转到订阅分享页面
/** 跳转到包含当前关键词的订阅分享页。 */
function searchSubscribeShares() {
if (!searchWord.value) return
saveRecentSearches(searchWord.value)
@@ -333,10 +355,10 @@ function searchSubscribeShares() {
keyword: searchWord.value,
},
})
emit('close')
closeSearch()
}
// 跳转插件页面
/** 打开匹配插件的已安装详情。 */
function showPlugin(pluginId: string) {
router.push({
path: `/plugins/`,
@@ -345,16 +367,16 @@ function showPlugin(pluginId: string) {
id: pluginId,
},
})
emit('close')
closeSearch()
}
// 跳转菜单页面
/** 跳转到匹配的功能菜单。 */
function goPage(to: string) {
router.push(to)
emit('close')
closeSearch()
}
// 跳转订阅页面
/** 根据订阅类型跳转到对应订阅详情。 */
function goSubscribe(subscribe: Subscribe) {
if (subscribe.type === '电影') {
router.push({
@@ -371,13 +393,29 @@ function goSubscribe(subscribe: Subscribe) {
},
})
}
closeSearch()
}
/** 关闭搜索浮层并通知外层入口同步状态。 */
function closeSearch() {
dialog.value = false
emit('close')
}
/** 聚焦当前可见输入框,供快捷键入口复用。 */
function focusSearchInput() {
searchWordInput.value?.focus()
}
watch(dialog, async isOpen => {
if (!isOpen) return
await nextTick()
focusSearchInput()
})
defineExpose({ focusSearchInput })
onMounted(() => {
setTimeout(() => {
searchWordInput.value?.focus()
}, 500)
// 根据权限加载不同的数据
if (hasAdminPermission.value) {
fetchInstalledPlugins()
@@ -395,10 +433,20 @@ onMounted(() => {
})
</script>
<template>
<VDialog v-model="dialog" max-width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard class="search-dialog">
<!-- 搜索输入框区域 -->
<div class="search-header">
<component :is="searchOverlay" v-model="dialog" v-bind="searchOverlayProps">
<template v-if="showActivator" #activator="{ props: activatorProps }">
<!-- 小屏入口保持图标形态点击后打开全屏搜索弹窗 -->
<IconBtn
v-if="!display.mdAndUp.value"
v-bind="activatorProps"
class="search-icon-trigger"
:aria-label="t('dialog.searchBar.openSearch')"
>
<VIcon class="search-icon-trigger__icon" icon="mdi-magnify" />
</IconBtn>
<!-- 中屏及以上常驻搜索输入框输入时直接在下方展示同一组选项 -->
<div v-else v-bind="activatorProps" class="search-desktop-activator">
<div class="search-input-wrapper">
<VIcon icon="mdi-magnify" size="22" class="search-input-icon" />
<input
@@ -408,7 +456,26 @@ onMounted(() => {
class="search-native-input"
:placeholder="t('dialog.searchBar.searchPlaceholder')"
@keydown.enter="searchMedia('media')"
@keydown.escape="emit('close')"
@keydown.escape.stop="closeSearch"
/>
<kbd class="search-shortcut-badge">{{ metaKey }}</kbd>
</div>
</div>
</template>
<VCard class="search-dialog" :class="{ 'search-dialog--dropdown': display.mdAndUp.value }">
<!-- 弹窗模式保留原有搜索输入区 -->
<div v-if="!display.mdAndUp.value" class="search-header">
<div class="search-input-wrapper">
<VIcon icon="mdi-text" size="22" class="search-input-icon" />
<input
ref="searchWordInput"
v-model="searchWord"
type="text"
class="search-native-input"
:placeholder="t('dialog.searchBar.searchPlaceholder')"
@keydown.enter="searchMedia('media')"
@keydown.escape.stop="closeSearch"
/>
<VBtn icon size="small" variant="text" class="search-submit-btn" @click="searchMedia('media')">
<VIcon icon="mdi-magnify" size="20" />
@@ -697,26 +764,14 @@ onMounted(() => {
</div>
</div>
<!-- 底部区域 -->
<!-- 桌面端快捷键提示 -->
<div v-if="display.mdAndUp.value" class="search-footer">
<div class="shortcut-group">
<kbd>Esc</kbd>
<span class="shortcut-label">{{ t('dialog.searchBar.escClose') }}</span>
</div>
<div class="shortcut-group">
<kbd>{{ metaKey }}</kbd>
<span class="shortcut-label">{{ t('dialog.searchBar.openSearch') }}</span>
</div>
</div>
<!-- 移动端关闭图标 -->
<div v-else class="search-footer-mobile">
<VBtn icon variant="tonal" @click="emit('close')">
<!-- 弹窗形态保留底部关闭按钮桌面下拉不显示页脚 -->
<div v-if="!display.mdAndUp.value" class="search-footer-mobile">
<VBtn icon variant="tonal" @click="closeSearch">
<VIcon icon="mdi-close" size="20" />
</VBtn>
</div>
</VCard>
</VDialog>
</component>
<!-- 站点选择对话框 -->
<SearchSiteDialog
@@ -731,12 +786,46 @@ onMounted(() => {
</template>
<style scoped>
/* stylelint-disable no-descending-specificity */
.search-dialog {
display: flex;
overflow: hidden;
flex-direction: column;
}
.search-dialog--dropdown {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
inline-size: min(32rem, calc(100vw - 2rem));
max-block-size: min(72vh, 42rem);
}
.search-desktop-activator {
flex: 0 1 22rem;
inline-size: clamp(15rem, 32vw, 22rem);
max-inline-size: calc(100vw - 10rem);
}
.search-desktop-activator .search-input-wrapper {
border-color: rgba(var(--v-theme-on-surface), 0.12);
background: rgba(var(--v-theme-surface), 0.72);
block-size: 42px;
padding-inline: 14px 8px;
}
html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
.v-theme--transparent .search-desktop-activator .search-input-wrapper {
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
}
.search-icon-trigger {
flex: 0 0 auto;
}
.search-icon-trigger__icon {
transform: scaleX(-1);
}
/* 搜索头部区域 */
.search-header {
padding-block: 16px 12px;
@@ -748,18 +837,18 @@ onMounted(() => {
display: flex;
align-items: center;
border: 1.5px solid rgba(var(--v-theme-primary), 0.4);
border-radius: 28px;
border-radius: var(--app-vuetify-rounded-pill);
background-color: rgba(var(--v-theme-surface-variant), 0.04);
block-size: 48px;
padding-inline: 14px 6px;
transition:
border-color 0.2s ease,
border-radius 0.2s ease,
box-shadow 0.2s ease;
}
.search-input-wrapper:focus-within {
border-color: rgb(var(--v-theme-primary));
box-shadow: 0 0 0 3px rgba(var(--v-theme-on-surface), 0.04);
}
.search-input-icon {
@@ -797,13 +886,24 @@ onMounted(() => {
background-color: rgba(var(--v-theme-on-surface), 0.12) !important;
}
.search-shortcut-badge {
flex-shrink: 0;
margin-inline-start: 10px;
white-space: nowrap;
}
/* 主内容区域 */
.search-content {
max-block-size: 600px;
min-block-size: 150px;
overscroll-behavior-y: contain;
overflow-y: auto;
}
.search-dialog--dropdown .search-content {
max-block-size: min(58vh, 34rem);
}
.search-list {
background: transparent !important;
}
@@ -848,28 +948,13 @@ onMounted(() => {
.recent-searches-section {
inline-size: 100%;
padding-block-start: 1rem;
}
.empty-hint {
text-align: center;
}
/* 底部快捷键提示 */
.search-footer {
display: flex;
align-items: center;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
gap: 16px;
padding-block: 10px;
padding-inline: 16px;
}
.shortcut-group {
display: flex;
align-items: center;
gap: 6px;
}
kbd {
border: 1px solid rgba(var(--v-theme-on-surface), 0.15);
border-radius: 5px;
@@ -883,11 +968,6 @@ kbd {
padding-inline: 6px;
}
.shortcut-label {
color: rgba(var(--v-theme-on-surface), 0.45);
font-size: 12px;
}
/* 移动端底部关闭图标 */
.search-footer-mobile {
display: flex;
@@ -912,10 +992,5 @@ kbd {
.search-native-input {
font-size: 14px;
}
.search-footer {
padding-block: 8px;
padding-inline: 12px;
}
}
</style>

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import type { Site } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 多语言支持
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
const props = defineProps({
sites: {
type: Array as PropType<Site[]>,
@@ -84,7 +88,7 @@ const filteredSites = computed(() => {
</script>
<template>
<!-- Site Selection Dialog -->
<VDialog max-width="40rem" fullscreen-mobile>
<VDialog max-width="40rem" :fullscreen="!display.smAndUp.value">
<VCard class="site-dialog">
<VCardItem>
<template #prepend>

View File

@@ -42,8 +42,14 @@ function allLoggingDownloadUrl() {
</script>
<template>
<VDialog v-if="visible" v-model="visible" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialog
v-if="visible"
v-model="visible"
:scrollable="display.mdAndUp.value"
max-width="80rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard class="logging-dialog-card">
<VDialogCloseBtn v-model="visible" />
<VCardItem>
<VCardTitle class="d-inline-flex">
@@ -66,9 +72,29 @@ function allLoggingDownloadUrl() {
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<VCardText class="logging-dialog-content pa-0">
<LoggingView logfile="moviepilot.log" />
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
@media (width <= 960px) {
.logging-dialog-card {
display: flex;
flex-direction: column;
overflow: hidden;
block-size: 100%;
}
.logging-dialog-content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
inline-size: 100%;
min-block-size: 0;
overflow: hidden;
}
}
</style>

View File

@@ -104,6 +104,21 @@ const bodyClasses = computed(() => [
}
@media (max-width: 959.98px) {
.words-shortcut-dialog-card {
display: flex;
overflow: hidden !important;
flex-direction: column;
}
.words-shortcut-dialog-body {
display: flex;
overflow: hidden !important;
flex: 1 1 auto;
inline-size: 100%;
min-block-size: 0;
padding: 0 !important;
}
.scheduler-shortcut-dialog-card--transparent {
background: transparent !important;
background-color: transparent !important;
@@ -114,13 +129,8 @@ const bodyClasses = computed(() => [
display: flex;
overflow: hidden;
flex-direction: column;
background: rgb(var(--v-theme-surface));
}
html[data-theme='transparent'] .cache-shortcut-dialog-card,
.v-theme--transparent .cache-shortcut-dialog-card {
backdrop-filter: blur(var(--transparent-blur, 10px));
background: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
}
.cache-shortcut-dialog-body {

View File

@@ -2,7 +2,7 @@
import api from '@/api'
import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
import { formatFileSize } from '@core/utils/formatters'
import { useDisplay } from 'vuetify'
import { useDisplay, useTheme } from 'vuetify'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useI18n } from 'vue-i18n'
@@ -13,6 +13,9 @@ const { t, locale } = useI18n()
// 响应式断点
const display = useDisplay()
// 当前主题
const theme = useTheme()
// 输入参数
const props = defineProps({
site: Object as PropType<Site>,
@@ -92,9 +95,13 @@ const resultSummaryText = computed(() => {
// 是否小屏幕
const isMobileLayout = computed(() => display.smAndDown.value)
// 是否透明主题
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
// 移动端分页数据
const mobileResourceList = computed(() => resourceDataList.value)
// 获取资源项唯一标识
function getResourceItemKey(item: TorrentInfo, index: number) {
return item.page_url || item.enclosure || `${item.title}-${item.pubdate || ''}-${index}`
}
@@ -188,10 +195,12 @@ watch(
},
)
// 切换移动端搜索栏
function toggleMobileSearch() {
mobileSearchExpanded.value = !mobileSearchExpanded.value
}
// 关闭移动端搜索栏
function closeMobileSearch() {
mobileSearchExpanded.value = false
}
@@ -480,7 +489,11 @@ onMounted(() => {
:get-item-key="getResourceItemKey"
>
<template #default="{ item }">
<VCard class="site-resource-card" variant="flat">
<VCard
class="site-resource-card"
:class="{ 'site-resource-card--transparent': isTransparentTheme }"
variant="flat"
>
<VCardText class="pa-3">
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
<div class="site-resource-card__title text-body-1 font-weight-medium text-high-emphasis">
@@ -746,7 +759,7 @@ onMounted(() => {
background: var(--site-resource-card-bg);
}
:global(html[data-theme="transparent"]) .site-resource-card {
.site-resource-card--transparent {
--site-resource-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity));
backdrop-filter: blur(var(--transparent-blur));

View File

@@ -64,8 +64,10 @@ const historyChartOptions = computed(() => {
enabled: true,
},
zoom: {
autoScaleYaxis: true,
enabled: false,
allowMouseWheelZoom: false,
},
selection: { enabled: false },
},
theme: {
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
@@ -156,8 +158,10 @@ const seedingChartOptions = computed(() => {
background: currentTheme.value.surface, // 新增背景色同步
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
zoom: {
autoScaleYaxis: true,
enabled: false,
allowMouseWheelZoom: false,
},
selection: { enabled: false },
},
theme: {
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式

View File

@@ -10,6 +10,17 @@ import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constant
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { formatSeason } from '@/@core/utils/formatters'
// 从变更请求异常中提取可展示消息,并为非标准错误提供稳定兜底。
function getRequestErrorMessage(error: unknown, fallback: string) {
if (typeof error === 'object' && error !== null) {
const responseMessage = (error as { response?: { data?: { message?: unknown } } }).response?.data?.message
if (typeof responseMessage === 'string' && responseMessage) return responseMessage
}
if (error instanceof Error && error.message) return error.message
return fallback
}
// i18n
const { t } = useI18n()
const userStore = useUserStore()
@@ -104,6 +115,12 @@ function getSubscribeDisplayName() {
return `${name} ${formatSeason(season.toString())}`
}
function getDefaultSubscribeTypeName() {
if (props.type === '电影') return t('mediaType.movie')
if (props.type === '电视剧') return t('mediaType.tv')
return props.type ?? ''
}
// 剧集组选项属性
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
return {
@@ -162,18 +179,30 @@ const filterRuleGroupOptions = computed(() => {
// 调用API修改订阅
async function updateSubscribeInfo() {
const displayName = getSubscribeDisplayName()
try {
const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value)
// 提示
if (result.success) {
$toast.success(`${getSubscribeDisplayName()} 更新成功!`)
$toast.success(t('dialog.subscribeEdit.updateSuccess', { name: displayName }))
// 通知父组件刷新
emit('save')
emit('save', subscribeForm.value)
} else {
$toast.error(`${getSubscribeDisplayName()} 更新失败:${result.message}`)
$toast.error(
t('dialog.subscribeEdit.updateFailed', {
name: displayName,
message: result.message ?? t('subscribe.requestFailed'),
}),
)
}
} catch (e) {
console.log(e)
$toast.error(
t('dialog.subscribeEdit.updateFailed', {
name: displayName,
message: getRequestErrorMessage(e, t('subscribe.requestFailed')),
}),
)
}
}
@@ -181,19 +210,33 @@ async function updateSubscribeInfo() {
async function saveDefaultSubscribeConfig() {
if (!canAdmin.value) return
const typeName = getDefaultSubscribeTypeName()
try {
let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)
if (result.success) $toast.success(`${props.type}订阅默认规则保存成功`)
else $toast.error(`${props.type}订阅默认规则保存失败!`)
// 通知父组件刷新
emit('save')
if (result.success) {
$toast.success(t('dialog.subscribeEdit.defaultSaveSuccess', { type: typeName }))
// 通知父组件刷新
emit('save', subscribeForm.value)
} else {
$toast.error(
t('dialog.subscribeEdit.defaultSaveFailed', {
type: typeName,
message: result.message ?? t('subscribe.requestFailed'),
}),
)
}
} catch (error) {
console.log(error)
$toast.error(
t('dialog.subscribeEdit.defaultSaveFailed', {
type: typeName,
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
}),
)
}
}
@@ -262,16 +305,28 @@ async function removeSubscribe() {
})
if (!isConfirmed) return
const displayName = getSubscribeDisplayName()
try {
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
if (result.success) {
$toast.success(`订阅 ${getSubscribeDisplayName()} 已取消!`)
$toast.success(`${displayName} ${t('subscribe.cancelSuccess')}`)
// 通知父组件刷新
emit('remove')
} else {
$toast.error(
`${displayName} ${t('subscribe.cancelFailed', {
message: result.message ?? t('subscribe.requestFailed'),
})}`,
)
}
} catch (e) {
console.log(e)
$toast.error(
`${displayName} ${t('subscribe.cancelFailed', {
message: getRequestErrorMessage(e, t('subscribe.requestFailed')),
})}`,
)
}
}
@@ -289,8 +344,10 @@ async function loadDownloadDirectories() {
// 保存目录下拉框
const targetDirectories = computed(() => {
// 去重后的下载目录
return downloadDirectories.value.map(item => item.download_path)
const paths = downloadDirectories.value
.map(item => item.download_path?.trim())
.filter((path): path is string => Boolean(path))
return [...new Set(paths)]
})
// 仅电视剧订阅支持全集洗版,电影保持原有洗版逻辑

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,7 @@ const emit = defineEmits(['subscribe', 'close'])
const props = defineProps({
media: Object as PropType<MediaInfo>,
selectedSeason: Number,
initialEpisodeGroup: String,
subscribedSeasons: Array as PropType<number[]>,
subscribedSeasonModes: Object as PropType<SeasonSubscribeModes>,
defaultSubscribeMode: String as PropType<SubscribeMode>,
@@ -64,7 +65,14 @@ const isRefreshed = ref(false)
const episodeGroups = ref<{ [key: string]: any }[]>([])
// 当前选择剧集组
const episodeGroup = ref('')
const episodeGroup = ref(props.initialEpisodeGroup ?? '')
// 剧集组横向轨道
const episodeGroupRail = ref<HTMLElement | null>(null)
// 剧集组轨道左右滚动状态
const canScrollEpisodeGroupsBackward = ref(false)
const canScrollEpisodeGroupsForward = ref(false)
const subscribeModeOptions = computed<SubscribeModeOption[]>(() => [
{
@@ -84,12 +92,14 @@ const subscribeModeOptions = computed<SubscribeModeOption[]>(() => [
},
])
// 获取订阅模式的主题色。
function getSubscribeModeColor(mode: SubscribeMode) {
if (mode === 'normal') return 'primary'
if (mode === 'best_version') return 'warning'
return 'success'
}
// 校验弹窗输入是否为支持的订阅模式。
function isSubscribeMode(value: unknown): value is SubscribeMode {
return value === 'normal' || value === 'best_version' || value === 'best_version_full'
}
@@ -170,6 +180,8 @@ async function getEpisodeGroups() {
episodeGroups.value = await api.get(`media/groups/${props.media?.tmdb_id}`)
} catch (error) {
console.error(error)
} finally {
nextTick(updateEpisodeGroupScrollState)
}
}
@@ -265,14 +277,42 @@ function getYear(airDate: string) {
return date.getFullYear()
}
// 切换当前剧集组并清空上一组的派生数据。
function setEpisodeGroup(value: string) {
if (episodeGroup.value === value) return
seasonsNotExisted.value = {}
seasonInfos.value = []
episodeGroup.value = value
nextTick(updateEpisodeGroupScrollState)
}
// 刷新剧集组横向轨道的左右滚动按钮状态。
function updateEpisodeGroupScrollState() {
const rail = episodeGroupRail.value
if (!rail) {
canScrollEpisodeGroupsBackward.value = false
canScrollEpisodeGroupsForward.value = false
return
}
const maxScrollLeft = Math.max(rail.scrollWidth - rail.clientWidth, 0)
canScrollEpisodeGroupsBackward.value = rail.scrollLeft > 4
canScrollEpisodeGroupsForward.value = rail.scrollLeft < maxScrollLeft - 4
}
// 按一屏内可辨识的距离横向滚动剧集组轨道。
function scrollEpisodeGroups(direction: 'backward' | 'forward') {
const rail = episodeGroupRail.value
if (!rail) return
rail.scrollBy({
behavior: 'smooth',
left: direction === 'backward' ? -Math.max(rail.clientWidth * 0.72, 240) : Math.max(rail.clientWidth * 0.72, 240),
})
}
// 提交当前剧集组下选中的季及其订阅模式。
function subscribeSeasons() {
const selectedSeasons = seasonInfos.value.filter(item => {
const seasonNumber = item.season_number ?? null
@@ -289,6 +329,7 @@ function subscribeSeasons() {
)
}
// 写入指定季的订阅模式。
function setSeasonMode(season: number, mode: SubscribeMode) {
seasonModes.value = {
...seasonModes.value,
@@ -296,22 +337,26 @@ function setSeasonMode(season: number, mode: SubscribeMode) {
}
}
// 处理用户手动切换指定季的订阅模式。
function updateSeasonMode(season: number, mode: unknown) {
if (!isSubscribeMode(mode)) return
manuallySelectedModeSeasons.value.add(season)
setSeasonMode(season, mode)
}
// 根据入库状态和系统配置计算指定季的默认订阅模式。
function getDefaultSeasonMode(season: number) {
if (!seasonsNotExisted.value[season]) return 'best_version_full'
return props.defaultSubscribeMode ?? 'normal'
}
// 确保指定季已初始化订阅模式。
function ensureSeasonMode(season: number) {
if (!seasonModes.value[season]) setSeasonMode(season, props.subscribedSeasonModes?.[season] ?? getDefaultSeasonMode(season))
}
// 在入库状态刷新后同步尚未手动修改的默认模式。
function syncDefaultSeasonModes() {
seasonsSelected.value.forEach(season => {
if (subscribedSeasonSet.value.has(season) || manuallySelectedModeSeasons.value.has(season)) return
@@ -319,14 +364,17 @@ function syncDefaultSeasonModes() {
})
}
// 判断指定季是否已有订阅。
function isSeasonSubscribed(season: number) {
return subscribedSeasonSet.value.has(season)
}
// 判断指定季是否在本次提交选择中。
function isSeasonSelected(season: number) {
return selectedSeasonSet.value.has(season)
}
// 设置指定季的选择状态并同步其订阅模式。
function setSeasonSelected(season: number, selected: boolean | null) {
const nextSeasons = new Set(seasonsSelected.value)
if (selected) {
@@ -338,10 +386,12 @@ function setSeasonSelected(season: number, selected: boolean | null) {
seasonsSelected.value = [...nextSeasons].sort((a, b) => a - b)
}
// 切换指定季的选择状态。
function toggleSeasonSelected(season: number) {
setSeasonSelected(season, !isSeasonSelected(season))
}
// 将入口预选季和已有订阅同步到当前剧集组的可见季列表。
function syncSelectedSeason() {
if (!seasonInfos.value.length) return
@@ -380,11 +430,19 @@ watch(() => props.subscribedSeasons, syncSelectedSeason)
watch(() => props.subscribedSeasonModes, syncSelectedSeason)
watch(episodeGroupOptions, () => nextTick(updateEpisodeGroupScrollState), { flush: 'post' })
onMounted(async () => {
getMediaSeasons()
window.addEventListener('resize', updateEpisodeGroupScrollState)
// 自定义剧集组由 watchEffect 首次加载,避免默认季数据异步覆盖它。
if (!episodeGroup.value) getMediaSeasons()
getEpisodeGroups()
checkSeasonsNotExists()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateEpisodeGroupScrollState)
})
</script>
<template>
@@ -408,20 +466,44 @@ onMounted(async () => {
<div class="subscribe-season-group-label">
{{ t('dialog.subscribeSeason.selectGroup') }}
</div>
<div class="subscribe-season-group-options">
<div class="subscribe-season-group-rail-shell">
<button
v-for="group in episodeGroupOptions"
:key="group.value || 'default'"
v-if="mdAndUp && canScrollEpisodeGroupsBackward"
type="button"
class="subscribe-season-group-option"
:class="{ 'subscribe-season-group-option--active': episodeGroup === group.value }"
@click="setEpisodeGroup(group.value)"
class="subscribe-season-group-nav subscribe-season-group-nav--backward"
:aria-label="t('media.episodeGroups.previous')"
@click="scrollEpisodeGroups('backward')"
>
<VIcon :icon="group.icon" size="small" />
<span class="subscribe-season-group-text">
<span class="subscribe-season-group-title">{{ group.title }}</span>
<span class="subscribe-season-group-subtitle">{{ group.subtitle }}</span>
</span>
<VIcon icon="mdi-chevron-left" />
</button>
<div
ref="episodeGroupRail"
class="subscribe-season-group-options"
@scroll.passive="updateEpisodeGroupScrollState"
>
<button
v-for="group in episodeGroupOptions"
:key="group.value || 'default'"
type="button"
class="subscribe-season-group-option"
:class="{ 'subscribe-season-group-option--active': episodeGroup === group.value }"
@click="setEpisodeGroup(group.value)"
>
<VIcon :icon="group.icon" size="small" />
<span class="subscribe-season-group-text">
<span class="subscribe-season-group-title">{{ group.title }}</span>
<span class="subscribe-season-group-subtitle">{{ group.subtitle }}</span>
</span>
</button>
</div>
<button
v-if="mdAndUp && canScrollEpisodeGroupsForward"
type="button"
class="subscribe-season-group-nav subscribe-season-group-nav--forward"
:aria-label="t('media.episodeGroups.next')"
@click="scrollEpisodeGroups('forward')"
>
<VIcon icon="mdi-chevron-right" />
</button>
</div>
</div>
@@ -561,27 +643,39 @@ onMounted(async () => {
line-height: 1rem;
}
.subscribe-season-group-rail-shell {
position: relative;
min-inline-size: 0;
}
.subscribe-season-group-options {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-block: 0.125rem 0.375rem;
scrollbar-width: thin;
scroll-behavior: smooth;
scroll-snap-type: inline proximity;
scrollbar-width: none;
}
.subscribe-season-group-options::-webkit-scrollbar {
display: none;
}
.subscribe-season-group-option {
display: inline-flex;
flex: 0 0 auto;
flex: 0 0 12rem;
align-items: center;
backdrop-filter: var(--app-grouped-list-backdrop-filter, blur(var(--transparent-blur-light, 6px)));
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
background: rgba(var(--v-theme-surface), 0.72);
background: rgba(var(--v-theme-surface), 0.82);
color: rgb(var(--v-theme-on-surface));
gap: 0.5rem;
max-inline-size: 16rem;
min-inline-size: 11rem;
min-inline-size: 0;
padding-block: 0.5rem;
padding-inline: 0.75rem;
scroll-snap-align: start;
text-align: start;
transition:
border-color 0.16s ease,
@@ -591,15 +685,26 @@ onMounted(async () => {
.subscribe-season-group-option:hover {
border-color: rgba(var(--v-theme-primary), 0.45);
background: rgba(var(--v-theme-primary), 0.08);
background: rgba(var(--v-theme-surface), 0.9);
box-shadow: 0 6px 18px rgba(var(--v-theme-on-surface), 0.08);
}
.subscribe-season-group-option--active {
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.14);
background: rgba(var(--v-theme-primary), 0.18);
color: rgb(var(--v-theme-primary));
}
.subscribe-season-group-option--active:hover {
background: rgba(var(--v-theme-primary), 0.24);
}
.subscribe-season-group-option:focus-visible,
.subscribe-season-group-nav:focus-visible {
outline: 2px solid rgba(var(--v-theme-primary), 0.45);
outline-offset: 2px;
}
.subscribe-season-group-text {
display: grid;
min-inline-size: 0;
@@ -628,6 +733,42 @@ onMounted(async () => {
color: rgba(var(--v-theme-primary), 0.82);
}
.subscribe-season-group-nav {
position: absolute;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
backdrop-filter: var(--app-grouped-list-backdrop-filter, blur(var(--transparent-blur-light, 6px)));
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 9999px;
background: rgba(var(--v-theme-surface), 0.96);
block-size: 2.5rem;
color: rgb(var(--v-theme-on-surface));
inline-size: 2.5rem;
inset-block-start: 50%;
transform: translateY(-55%);
transition:
border-color 0.16s ease,
background-color 0.16s ease,
color 0.16s ease;
}
.subscribe-season-group-nav:hover {
border-color: rgba(var(--v-theme-primary), 0.45);
background: rgba(var(--v-theme-surface), 0.96);
box-shadow: 0 6px 18px rgba(var(--v-theme-on-surface), 0.12);
color: rgb(var(--v-theme-primary));
}
.subscribe-season-group-nav--backward {
inset-inline-start: -0.5rem;
}
.subscribe-season-group-nav--forward {
inset-inline-end: -0.5rem;
}
.subscribe-season-list {
display: grid;
gap: 0.5rem;
@@ -690,8 +831,13 @@ onMounted(async () => {
}
.subscribe-season-group-option {
max-inline-size: 12rem;
min-inline-size: 9.5rem;
flex-basis: 10rem;
}
}
@media (hover: none) and (pointer: coarse) {
.subscribe-season-group-nav {
display: none;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import { useTransparencySettings } from '@/composables/useTransparencySettings'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = withDefaults(
defineProps<{
@@ -38,15 +42,17 @@ const {
onBackgroundBlurChange,
onBackgroundPosterOpacityChange,
onBlurChange,
onGlassQualityChange,
onOpacityChange,
resetTransparencySettings,
transparencyBlur,
transparencyGlassQuality,
transparencyOpacity,
} = useTransparencySettings()
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="30rem">
<VDialog v-if="visible" v-model="visible" max-width="30rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
@@ -118,6 +124,34 @@ const {
/>
</div>
<div>
<span class="text-body-2 d-block mb-2">{{ t('theme.transparencyGlassQuality') }}</span>
<VBtnToggle
v-model="transparencyGlassQuality"
mandatory
divided
density="comfortable"
variant="outlined"
color="primary"
class="w-full"
@update:model-value="onGlassQualityChange"
>
<VBtn value="lightweight" class="flex-1">
{{ t('theme.transparencyGlassQualityLightweight') }}
</VBtn>
<VBtn value="realtime" class="flex-1">
{{ t('theme.transparencyGlassQualityRealtime') }}
</VBtn>
</VBtnToggle>
<p class="text-caption text-medium-emphasis mt-2 mb-0">
{{
transparencyGlassQuality === 'realtime'
? t('theme.transparencyGlassQualityRealtimeHint')
: t('theme.transparencyGlassQualityLightweightHint')
}}
</p>
</div>
<div>
<span class="text-body-2 d-block mb-2">{{ t('common.preset') }}</span>
<VBtnGroup density="compact" variant="outlined" class="w-full">

View File

@@ -7,6 +7,14 @@ import { useDisplay } from 'vuetify'
import avatar1 from '@images/avatars/avatar-1.png'
import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import {
USER_PERMISSION_FEATURES,
buildDefaultFeaturePermissions,
normalizeUserPermissions,
type UserPermissionCategoryKey,
type UserPermissionFeatureKey,
type UserPermissions,
} from '@/utils/permission'
// 多语言支持
const { t } = useI18n()
@@ -65,14 +73,6 @@ interface ExtendedUser extends User {
nickname?: string
}
// 权限类型定义
interface UserPermissions {
discovery: boolean // 发现权限
search: boolean // 搜索权限
subscribe: boolean // 订阅权限
manage: boolean // 管理权限
}
// 用户编辑表单数据
const userForm = ref<ExtendedUser>({
id: 0,
@@ -88,6 +88,7 @@ const userForm = ref<ExtendedUser>({
search: true,
subscribe: true,
manage: false,
features: buildDefaultFeaturePermissions(),
},
settings: {
wechat_userid: null,
@@ -127,34 +128,115 @@ const permissionOptions = [
description: t('dialog.userAddEdit.permissions.manageDesc'),
icon: 'mdi-cog-outline',
},
]
] as const
const activePermissionCategory = ref<UserPermissionCategoryKey>('discovery')
// 权限状态计算属性
const userPermissions = computed({
get: () => {
const permissions = userForm.value.permissions as UserPermissions
return {
discovery: permissions?.discovery ?? true,
search: permissions?.search ?? true,
subscribe: permissions?.subscribe ?? true,
manage: permissions?.manage ?? false,
}
return normalizeUserPermissions(userForm.value.permissions as Partial<UserPermissions>)
},
set: (value: UserPermissions) => {
userForm.value.permissions = value
},
})
// 切换权限状态
function togglePermission(key: keyof UserPermissions) {
const permissionFeatureOptions = computed(() =>
USER_PERMISSION_FEATURES.map(feature => ({
...feature,
title: t(feature.titleKey),
description: t(feature.descriptionKey),
})),
)
const activePermissionOption = computed(() =>
permissionOptions.find(option => option.key === activePermissionCategory.value) ?? permissionOptions[0],
)
const activePermissionFeatures = computed(() =>
permissionFeatureOptions.value.filter(feature => feature.permission === activePermissionCategory.value),
)
const enabledCategoryCount = computed(
() => permissionOptions.filter(option => userPermissions.value[option.key]).length,
)
const enabledFeatureCount = computed(
() =>
permissionFeatureOptions.value.filter(
feature => userPermissions.value[feature.permission] && isFeatureEnabled(feature.key),
).length,
)
/** 切换当前查看的权限分类。 */
function selectPermissionCategory(key: UserPermissionCategoryKey) {
activePermissionCategory.value = key
}
/** 切换权限分类启用状态,并聚焦到该分类的功能列表。 */
function togglePermission(key: UserPermissionCategoryKey) {
const currentPermissions = userPermissions.value
userPermissions.value = {
...currentPermissions,
[key]: !currentPermissions[key],
}
activePermissionCategory.value = key
}
// 更新头像
/** 判断功能项是否已启用,缺省功能按启用处理以兼容旧权限数据。 */
function isFeatureEnabled(key: UserPermissionFeatureKey) {
return userPermissions.value.features?.[key] !== false
}
/** 切换单个功能项的启用状态。 */
function togglePermissionFeature(key: UserPermissionFeatureKey) {
userPermissions.value = {
...userPermissions.value,
features: {
...(userPermissions.value.features ?? {}),
[key]: !isFeatureEnabled(key),
},
}
}
/** 统计指定分类下已经勾选的功能数量。 */
function getEnabledFeatureCount(permission: UserPermissionCategoryKey) {
return permissionFeatureOptions.value.filter(
feature => feature.permission === permission && isFeatureEnabled(feature.key),
).length
}
/** 返回指定分类下的功能总数。 */
function getFeatureTotalCount(permission: UserPermissionCategoryKey) {
return permissionFeatureOptions.value.filter(feature => feature.permission === permission).length
}
/** 判断指定分类是否处于功能部分勾选状态。 */
function isPermissionPartiallySelected(permission: UserPermissionCategoryKey) {
const enabledCount = getEnabledFeatureCount(permission)
const totalCount = getFeatureTotalCount(permission)
return enabledCount > 0 && enabledCount < totalCount
}
/** 批量设置当前分类下的所有功能项。 */
function setCategoryFeatures(permission: UserPermissionCategoryKey, enabled: boolean) {
const nextFeatures = { ...(userPermissions.value.features ?? {}) }
for (const feature of permissionFeatureOptions.value.filter(item => item.permission === permission)) {
nextFeatures[feature.key] = enabled
}
userPermissions.value = {
...userPermissions.value,
features: nextFeatures,
}
}
/** 将当前分类下的功能项恢复为默认全选状态。 */
function resetCategoryFeatures(permission: UserPermissionCategoryKey) {
setCategoryFeatures(permission, true)
}
/** 校验并读取用户上传的新头像。 */
function changeAvatar(file: Event) {
const fileReader = new FileReader()
const { files } = file.target as HTMLInputElement
@@ -182,19 +264,19 @@ function changeAvatar(file: Event) {
}
}
// 重置默认头像
/** 将头像恢复为系统默认头像。 */
function resetDefaultAvatar() {
currentAvatar.value = avatar1
$toast.success(t('dialog.userAddEdit.resetAvatarSuccess'))
}
// 还原当前头像
/** 还原为用户当前已保存的头像。 */
function restoreCurrentAvatar() {
currentAvatar.value = userForm.value.avatar
$toast.success(t('dialog.userAddEdit.restoreAvatarSuccess'))
}
// 查询用户信息
/** 从接口查询当前编辑用户信息并回填表单。 */
async function fetchUserInfo() {
try {
userForm.value = await api.get(`user/${props.username}`)
@@ -210,7 +292,7 @@ async function fetchUserInfo() {
}
}
// 调用API 新增用户
/** 调用接口创建新用户。 */
async function addUser() {
if (isAdding.value) {
$toast.error(t('dialog.userAddEdit.creatingUser', { name: userForm.value.name }))
@@ -256,7 +338,7 @@ async function addUser() {
isAdding.value = false
}
// 调用API更新用户信息
/** 调用接口更新当前用户信息。 */
async function updateUser() {
if (isUpdating.value) {
$toast.error(t('dialog.userAddEdit.updatingUser', { name: userForm.value.name }))
@@ -367,7 +449,7 @@ onMounted(() => {
</script>
<template>
<VDialog scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
<VDialog scrollable max-width="64rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem :class="props.oper === 'add' ? 'py-3' : 'py-2'">
<template #prepend>
@@ -572,43 +654,144 @@ onMounted(() => {
<span>{{ t('dialog.userAddEdit.permissions.title') }}</span>
</VDivider>
<!-- 权限设置 -->
<div v-if="canControl">
<VRow>
<VCol v-for="option in permissionOptions" :key="option.key" cols="6">
<VCard
:color="userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface'"
:variant="userPermissions[option.key as keyof UserPermissions] ? 'tonal' : 'outlined'"
class="cursor-pointer transition-all h-full"
@click="togglePermission(option.key as keyof UserPermissions)"
hover
<div v-if="canControl" class="user-permission-editor">
<div class="permission-section-header">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ t('dialog.userAddEdit.permissions.categoryTitle') }}
</div>
<div class="text-caption text-medium-emphasis">
{{ t('dialog.userAddEdit.permissions.categoryHint') }}
</div>
</div>
<VChip size="small" color="primary" variant="tonal">
{{
t('dialog.userAddEdit.permissions.summary', {
categories: enabledCategoryCount,
features: enabledFeatureCount,
})
}}
</VChip>
</div>
<div class="permission-category-grid">
<div
v-for="option in permissionOptions"
:key="option.key"
role="button"
tabindex="0"
class="permission-category-option"
:class="{
'is-active': activePermissionCategory === option.key,
'is-enabled': userPermissions[option.key],
'is-partial': isPermissionPartiallySelected(option.key),
}"
@click="selectPermissionCategory(option.key)"
@keydown.enter="selectPermissionCategory(option.key)"
@keydown.space.prevent="selectPermissionCategory(option.key)"
>
<span class="permission-category-option__icon">
<VIcon :icon="option.icon" size="20" />
</span>
<span class="permission-category-option__body">
<span class="permission-category-option__title-row">
<span class="permission-category-option__title">{{ option.title }}</span>
<span class="permission-category-option__count">
{{ getEnabledFeatureCount(option.key) }}/{{ getFeatureTotalCount(option.key) }}
</span>
</span>
<span class="permission-category-option__desc">{{ option.description }}</span>
</span>
<VBtn
:icon="userPermissions[option.key] ? 'mdi-check-circle' : 'mdi-circle-outline'"
:color="userPermissions[option.key] ? 'primary' : 'secondary'"
variant="text"
size="small"
class="permission-category-option__toggle"
@click.stop="togglePermission(option.key)"
/>
</div>
</div>
<div class="permission-feature-panel">
<div class="permission-feature-panel__header">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ activePermissionOption.title }}{{ t('dialog.userAddEdit.permissions.featureTitleSuffix') }}
</div>
<div class="text-caption text-medium-emphasis">
{{
userPermissions[activePermissionCategory]
? t('dialog.userAddEdit.permissions.featureHint')
: t('dialog.userAddEdit.permissions.disabledCategoryHint')
}}
</div>
</div>
<div class="permission-feature-panel__actions">
<VBtn
size="small"
color="primary"
variant="tonal"
:disabled="!userPermissions[activePermissionCategory]"
@click="setCategoryFeatures(activePermissionCategory, true)"
>
{{ t('dialog.userAddEdit.permissions.selectAll') }}
</VBtn>
<VBtn
size="small"
variant="outlined"
:disabled="!userPermissions[activePermissionCategory]"
@click="setCategoryFeatures(activePermissionCategory, false)"
>
{{ t('dialog.userAddEdit.permissions.clear') }}
</VBtn>
<VBtn
size="small"
variant="outlined"
:disabled="!userPermissions[activePermissionCategory]"
@click="resetCategoryFeatures(activePermissionCategory)"
>
{{ t('dialog.userAddEdit.permissions.default') }}
</VBtn>
</div>
</div>
<div class="permission-feature-list">
<div
v-for="feature in activePermissionFeatures"
:key="feature.key"
role="checkbox"
:aria-checked="isFeatureEnabled(feature.key)"
:aria-disabled="!userPermissions[activePermissionCategory]"
:tabindex="userPermissions[activePermissionCategory] ? 0 : -1"
class="permission-feature-item"
:class="{
'is-enabled': isFeatureEnabled(feature.key),
'is-disabled': !userPermissions[activePermissionCategory],
}"
@click="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
@keydown.enter="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
@keydown.space.prevent="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
>
<VCardText class="d-flex align-center pa-4">
<VAvatar
:color="userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface-variant'"
size="40"
class="me-3"
>
<VIcon :icon="option.icon" />
</VAvatar>
<div class="flex-grow-1">
<div class="text-subtitle-1 font-weight-medium d-flex align-center">
{{ option.title }}
<VIcon
v-if="userPermissions[option.key as keyof UserPermissions]"
icon="mdi-check-circle"
color="primary"
size="small"
class="ms-2"
/>
</div>
<div class="text-caption text-medium-emphasis">
{{ option.description }}
</div>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<VCheckboxBtn
:model-value="isFeatureEnabled(feature.key)"
:disabled="!userPermissions[activePermissionCategory]"
color="primary"
density="compact"
class="permission-feature-item__check"
@click.stop
@update:model-value="togglePermissionFeature(feature.key)"
/>
<span class="permission-feature-item__icon">
<VIcon :icon="feature.icon" size="20" />
</span>
<span class="permission-feature-item__body">
<span class="permission-feature-item__title">{{ feature.title }}</span>
<span class="permission-feature-item__desc">{{ feature.description }}</span>
</span>
</div>
</div>
</div>
</div>
</VForm>
</VCardText>
@@ -642,3 +825,273 @@ onMounted(() => {
</VCard>
</VDialog>
</template>
<style scoped lang="scss">
.user-permission-editor {
--permission-editor-radius: min(var(--app-surface-radius, 8px), 8px);
--permission-editor-border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
--permission-editor-muted-border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
--permission-editor-panel-bg: rgb(var(--v-theme-surface));
--permission-editor-hover-bg: rgba(var(--v-theme-primary), 0.04);
--permission-editor-active-bg: rgba(var(--v-theme-primary), 0.1);
--permission-editor-selected-bg: rgba(var(--v-theme-primary), 0.08);
display: flex;
flex-direction: column;
gap: 1rem;
}
.permission-section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.permission-category-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15.5rem, 1fr));
gap: 0.75rem;
}
.permission-category-option,
.permission-feature-item {
border: var(--permission-editor-muted-border);
border-radius: var(--permission-editor-radius);
background: var(--permission-editor-panel-bg);
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease, opacity 0.18s ease;
}
.permission-category-option {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: flex-start;
min-block-size: 5.75rem;
padding: 0.875rem;
text-align: start;
}
.permission-category-option:hover,
.permission-feature-item:hover:not(.is-disabled) {
border-color: rgba(var(--v-theme-primary), 0.36);
background: var(--permission-editor-hover-bg);
}
.permission-category-option:focus-visible,
.permission-feature-item:focus-visible {
outline: 2px solid rgba(var(--v-theme-primary), 0.72);
outline-offset: 2px;
}
.permission-category-option.is-active {
border-color: rgba(var(--v-theme-primary), 0.72);
background: var(--permission-editor-active-bg);
}
.permission-category-option.is-enabled {
border-color: rgba(var(--v-theme-primary), 0.34);
}
.permission-category-option.is-partial .permission-category-option__count {
color: rgb(var(--v-theme-warning));
background: rgba(var(--v-theme-warning), 0.12);
}
.permission-category-option__icon,
.permission-feature-item__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
border-radius: var(--app-control-radius, 6px);
background: rgba(var(--v-theme-primary), 0.1);
color: rgb(var(--v-theme-primary));
}
.permission-category-option__icon {
inline-size: 2.25rem;
block-size: 2.25rem;
margin-inline-end: 0.75rem;
}
.permission-category-option__body,
.permission-feature-item__body {
min-inline-size: 0;
}
.permission-category-option__title-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
min-inline-size: 0;
}
.permission-category-option__title,
.permission-feature-item__title {
display: block;
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.permission-category-option__count {
flex: 0 0 auto;
border-radius: var(--app-control-radius, 6px);
padding: 0.125rem 0.5rem;
background: rgba(var(--v-theme-primary), 0.1);
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 700;
line-height: 1.25rem;
}
.permission-category-option__desc,
.permission-feature-item__desc,
.permission-feature-item__scope {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
}
.permission-category-option__desc,
.permission-feature-item__desc {
display: block;
overflow: visible;
line-height: 1.35;
overflow-wrap: anywhere;
white-space: normal;
}
.permission-category-option__toggle {
margin-inline-start: 0.25rem;
}
.permission-feature-panel {
overflow: hidden;
border: var(--permission-editor-border);
border-radius: var(--permission-editor-radius);
background: rgb(var(--v-theme-surface));
}
.permission-feature-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
border-block-end: var(--permission-editor-muted-border);
}
.permission-feature-panel__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.permission-feature-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.625rem;
padding: 1rem;
}
.permission-feature-item {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
align-items: flex-start;
min-block-size: 4rem;
padding: 0.625rem 0.875rem;
text-align: start;
}
.permission-feature-item.is-disabled {
cursor: not-allowed;
}
.permission-feature-item.is-disabled .permission-feature-item__icon {
opacity: 0.72;
}
.permission-feature-item.is-disabled .permission-feature-item__title,
.permission-feature-item.is-disabled .permission-feature-item__desc {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
}
.permission-feature-item.is-enabled {
background: var(--permission-editor-selected-bg);
}
.permission-feature-item__check {
margin-inline-end: 0.5rem;
}
.permission-feature-item__icon {
inline-size: 2rem;
block-size: 2rem;
margin-inline-end: 0.75rem;
}
.permission-feature-item__body {
display: flex;
flex-direction: column;
}
.permission-feature-item__scope {
max-inline-size: 8rem;
margin-inline-start: 1rem;
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
}
@media (width <= 960px) {
.permission-category-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.permission-feature-list {
grid-template-columns: minmax(0, 1fr);
}
.permission-section-header,
.permission-feature-panel__header {
flex-direction: column;
align-items: stretch;
}
.permission-feature-panel__actions {
justify-content: flex-start;
}
}
@media (width <= 600px) {
.user-permission-editor {
gap: 0.875rem;
}
.permission-category-option {
grid-template-columns: minmax(0, 1fr) auto;
min-block-size: 3.75rem;
padding: 0.75rem;
}
.permission-category-option__icon,
.permission-category-option__desc {
display: none;
}
.permission-category-option__toggle {
margin-inline-start: 0.5rem;
}
.permission-feature-item {
grid-template-columns: auto minmax(0, 1fr);
}
.permission-feature-item__icon {
display: none;
}
}
</style>

View File

@@ -0,0 +1,451 @@
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import {
createSubscribe,
createSubscribeDirectory,
createSubscribeDownloader,
createSubscribeRuleGroup,
createSubscribeSite,
} from '@tests/support/factories/subscribe'
import {
defaultSubscribeConfigHandler,
deleteSubscribeByIdHandler,
saveDefaultSubscribeConfigHandler,
subscribeApiUrls,
subscribeDetailsHandler,
subscribeDialogOptionHandlers,
type SubscribeDialogOptions,
type SubscribeMediaType,
updateSubscribeHandler,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
interface DialogProps {
default?: boolean
subid?: number
type?: SubscribeMediaType
}
async function renderDialog(props: DialogProps, superUser = true) {
const events = {
close: vi.fn(),
remove: vi.fn(),
save: vi.fn(),
}
const result = await renderWithProviders(SubscribeEditDialog, {
initialState: {
user: {
superUser,
userName: superUser ? 'admin' : 'member',
},
},
props: {
modelValue: true,
...props,
onClose: events.close,
onRemove: events.remove,
onSave: events.save,
},
global: {
components: {
VDialogCloseBtn: DialogCloseBtn,
},
},
})
return { ...result, events }
}
function useDialogOptions(options: SubscribeDialogOptions = {}) {
server.use(...subscribeDialogOptionHandlers(options))
}
describe('SubscribeEditDialog', () => {
beforeEach(() => {
mocks.confirm.mockResolvedValue(true)
})
it('loads a TV subscription, normalizes flags, and exposes episode groups', async () => {
const record = createSubscribe({
best_version: 1,
best_version_full: 1,
id: 801,
name: '季度测试剧',
search_imdbid: 0,
season: 2,
tmdbid: 8010,
type: '电视剧',
})
const episodeGroupsRequested = vi.fn()
server.use(subscribeDetailsHandler(801, record))
useDialogOptions({
episodeGroups: [{ episode_count: 24, group_count: 2, id: 99, name: '官方特别排序' }],
onEpisodeGroups: episodeGroupsRequested,
tmdbId: 8010,
})
const user = userEvent.setup()
await renderDialog({ subid: 801 })
expect(await screen.findByText('季度测试剧 S02')).toBeInTheDocument()
expect(screen.getByLabelText('洗版')).toBeChecked()
expect(screen.getByLabelText('全集洗版')).toBeChecked()
expect(screen.getByLabelText('使用 ImdbID 搜索')).not.toBeChecked()
await waitFor(() => expect(episodeGroupsRequested).toHaveBeenCalledOnce())
await user.click(screen.getByRole('tab', { name: '进阶' }))
await user.click(screen.getByLabelText('指定剧集组'))
expect(await screen.findByText('官方特别排序')).toBeInTheDocument()
expect(screen.getByText('2 季 • 24 集')).toBeInTheDocument()
})
it('keeps movie titles free of season suffixes and skips episode groups', async () => {
const record = createSubscribe({ id: 802, name: '电影测试项', season: undefined, tmdbid: 8020, type: '电影' })
const episodeGroupsRequested = vi.fn()
server.use(subscribeDetailsHandler(802, record))
useDialogOptions({ onEpisodeGroups: episodeGroupsRequested, tmdbId: 8020 })
await renderDialog({ subid: 802 })
expect(await screen.findByText('电影测试项')).toBeInTheDocument()
expect(screen.queryByText(/电影测试项 S\d+/)).not.toBeInTheDocument()
expect(episodeGroupsRequested).not.toHaveBeenCalled()
})
it('shows enabled sites and stable downloader, directory, and rule options', async () => {
const activeSite = createSubscribeSite({ id: 1, is_active: true, name: '启用站点' })
const inactiveSite = createSubscribeSite({ id: 2, is_active: false, name: '停用站点' })
const requests = {
directories: vi.fn(),
downloaders: vi.fn(),
rules: vi.fn(),
sites: vi.fn(),
}
server.use(defaultSubscribeConfigHandler('电影', createSubscribe({ id: 0, type: '电影' })))
useDialogOptions({
directories: [
createSubscribeDirectory({ download_path: '/downloads', name: '目录一' }),
createSubscribeDirectory({ download_path: '/downloads', name: '目录二' }),
createSubscribeDirectory({ download_path: undefined, name: '空目录' }),
],
downloaders: [createSubscribeDownloader({ name: '下载器 A' })],
filterRuleGroups: [createSubscribeRuleGroup({ name: '高优先级' })],
onDirectories: requests.directories,
onDownloaders: requests.downloaders,
onFilterRuleGroups: requests.rules,
onSites: requests.sites,
sites: [activeSite, inactiveSite],
})
const user = userEvent.setup()
await renderDialog({ default: true, type: '电影' })
await waitFor(() => expect(requests.sites).toHaveBeenCalledOnce())
await waitFor(() => expect(requests.downloaders).toHaveBeenCalledOnce())
await waitFor(() => expect(requests.directories).toHaveBeenCalledOnce())
await waitFor(() => expect(requests.rules).toHaveBeenCalledOnce())
await user.click(screen.getByLabelText('订阅站点'))
expect(await screen.findByText('启用站点')).toBeInTheDocument()
expect(screen.queryByText('停用站点')).not.toBeInTheDocument()
await user.keyboard('{Escape}')
await user.click(screen.getByLabelText('下载器'))
expect(await screen.findByText('下载器 A')).toBeInTheDocument()
expect(screen.getAllByText('默认').length).toBeGreaterThanOrEqual(1)
await user.keyboard('{Escape}')
await user.click(screen.getByLabelText('保存路径'))
expect(await screen.findAllByText('/downloads')).toHaveLength(1)
expect(screen.queryByText('undefined')).not.toBeInTheDocument()
await user.keyboard('{Escape}')
await user.click(screen.getByRole('tab', { name: '进阶' }))
await user.click(screen.getByLabelText('优先级规则组'))
expect(await screen.findByText('高优先级')).toBeInTheDocument()
})
it('allows non-admin users to read public defaults but not private rules or save them', async () => {
const configRequested = vi.fn()
const rulesRequested = vi.fn()
const saved = vi.fn()
server.use(
defaultSubscribeConfigHandler('电视剧', createSubscribe({ id: 0, type: '电视剧' }), 200, configRequested),
saveDefaultSubscribeConfigHandler('电视剧', { success: true }, 200, saved),
)
useDialogOptions({ onFilterRuleGroups: rulesRequested })
const { events } = await renderDialog({ default: true, type: '电视剧' }, false)
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
expect(rulesRequested).not.toHaveBeenCalled()
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
expect(saved).not.toHaveBeenCalled()
expect(events.save).not.toHaveBeenCalled()
})
it.each(['电影', '电视剧'] as const)('loads and saves %s default configuration as an administrator', async type => {
const configRequested = vi.fn()
const saved = vi.fn()
server.use(
defaultSubscribeConfigHandler(type, createSubscribe({ id: 0, show_edit_dialog: false, type }), 200, configRequested),
saveDefaultSubscribeConfigHandler(type, { success: true }, 200, saved),
)
useDialogOptions()
const user = userEvent.setup()
const { events } = await renderDialog({ default: true, type })
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByLabelText('订阅时编辑更多规则')).not.toBeChecked())
await user.click(screen.getByLabelText('订阅时编辑更多规则'))
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
expect(saved.mock.calls[0][0]).toMatchObject({ show_edit_dialog: true, type })
expect(events.save).toHaveBeenCalledOnce()
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${type}订阅默认规则保存成功`)
})
it('submits the complete TV editing form and exposes the close action', async () => {
const record = createSubscribe({
best_version: 1,
best_version_full: 0,
id: 809,
name: '完整表单测试剧',
search_imdbid: 0,
season: 1,
tmdbid: 8090,
type: '电视剧',
})
const updated = vi.fn()
server.use(subscribeDetailsHandler(809, record), updateSubscribeHandler({ success: true }, 200, updated))
useDialogOptions({
directories: [createSubscribeDirectory({ download_path: '/完整目录' })],
downloaders: [createSubscribeDownloader({ name: '完整下载器' })],
episodeGroups: [{ episode_count: 12, group_count: 1, id: 8091, name: '完整剧集组' }],
filterRuleGroups: [createSubscribeRuleGroup({ name: '完整规则组' })],
sites: [createSubscribeSite({ id: 8092, name: '完整站点' })],
tmdbId: 8090,
})
const user = userEvent.setup()
const { events } = await renderDialog({ subid: 809 })
await screen.findByText('完整表单测试剧 S01')
const chooseOption = async (label: string, option: string) => {
await user.click(screen.getByLabelText(label))
await user.click(await screen.findByText(option, {}, { timeout: 2_000 }))
}
await user.type(screen.getByLabelText('总集数'), '24')
await user.type(screen.getByLabelText('开始集数'), '2')
await chooseOption('质量', 'Remux')
await chooseOption('分辨率', '1080p')
await chooseOption('特效', 'HDR')
await chooseOption('订阅站点', '完整站点')
await user.keyboard('{Escape}')
await chooseOption('下载器', '完整下载器')
await chooseOption('保存路径', '/完整目录')
await user.click(screen.getByLabelText('全集洗版'))
await user.click(screen.getByLabelText('使用 ImdbID 搜索'))
await user.click(screen.getByRole('tab', { name: '进阶' }))
await user.type(screen.getByLabelText('包含(关键字、正则式)'), '国语')
await user.type(screen.getByLabelText('排除(关键字、正则式)'), '预告')
await chooseOption('优先级规则组', '完整规则组')
await user.keyboard('{Escape}')
await chooseOption('指定剧集组', '完整剧集组')
await chooseOption('指定季', '第 2 季')
await user.type(screen.getByLabelText('自定义类别'), '纪录片')
await user.type(screen.getByLabelText('自定义识别词'), '测试词 => 正式词')
const closeButton = document.querySelector<HTMLButtonElement>('.v-card-item button')
expect(closeButton).not.toBeNull()
await user.click(closeButton as HTMLButtonElement)
expect(events.close).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
expect(updated.mock.calls[0][0]).toMatchObject({
best_version_full: true,
custom_words: '测试词 => 正式词',
downloader: '完整下载器',
effect: '[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+',
episode_group: 8091,
exclude: '预告',
filter_groups: ['完整规则组'],
include: '国语',
media_category: '纪录片',
quality: 'Remux',
resolution: '1080[pi]|x1080',
save_path: '/完整目录',
search_imdbid: true,
season: 2,
sites: [8092],
start_episode: '2',
total_episode: '24',
})
expect(events.save).toHaveBeenCalledWith(expect.objectContaining({ season: 2 }))
})
it.each([
['business failure', 200, { message: 'rejected', success: false }, '电影订阅默认规则保存失败rejected'],
['HTTP failure', 500, { message: 'server down', success: false }, '电影订阅默认规则保存失败server down'],
])('keeps a default dialog open after a %s', async (_case, status, response, expectedMessage) => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
server.use(
defaultSubscribeConfigHandler('电影', createSubscribe({ id: 0, type: '电影' })),
saveDefaultSubscribeConfigHandler('电影', response, status),
)
useDialogOptions()
const { events } = await renderDialog({ default: true, type: '电影' })
await screen.findByText('电影')
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage))
expect(events.save).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument()
consoleLog.mockRestore()
})
it('updates an edited subscription and clears full-season mode when versioning is disabled', async () => {
const record = createSubscribe({
best_version: 1,
best_version_full: 1,
id: 803,
keyword: '旧关键词',
name: '编辑测试剧',
season: 1,
tmdbid: 8030,
type: '电视剧',
})
const updated = vi.fn()
server.use(subscribeDetailsHandler(803, record), updateSubscribeHandler({ success: true }, 200, updated))
useDialogOptions({ tmdbId: 8030 })
const user = userEvent.setup()
const { events } = await renderDialog({ subid: 803 })
await screen.findByText('编辑测试剧 S01')
const keyword = screen.getByLabelText('搜索关键词')
await user.clear(keyword)
await user.type(keyword, '新关键词')
await user.click(screen.getByLabelText('洗版'))
await waitFor(() => expect(screen.queryByLabelText('全集洗版')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
expect(updated.mock.calls[0][0]).toMatchObject({
best_version: false,
best_version_full: false,
id: 803,
keyword: '新关键词',
})
expect(events.save).toHaveBeenCalledWith(expect.objectContaining({ id: 803, keyword: '新关键词' }))
expect(mocks.toastSuccess).toHaveBeenCalledWith('编辑测试剧 S01 更新成功!')
})
it.each([
['business failure', 200, { message: 'invalid', success: false }, '失败编辑项 更新失败invalid'],
['HTTP failure', 500, { message: 'server down', success: false }, '失败编辑项 更新失败server down'],
])('keeps an edit dialog usable after an update %s', async (_case, status, response, expectedMessage) => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const record = createSubscribe({ id: 804, name: '失败编辑项', tmdbid: 8040 })
server.use(subscribeDetailsHandler(804, record), updateSubscribeHandler(response, status))
useDialogOptions({ tmdbId: 8040 })
const { events } = await renderDialog({ subid: 804 })
await screen.findByText('失败编辑项')
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage))
expect(events.save).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument()
consoleLog.mockRestore()
})
it('does not delete when confirmation is cancelled', async () => {
const record = createSubscribe({ id: 805, name: '保留订阅', tmdbid: 8050 })
const deleted = vi.fn()
server.use(subscribeDetailsHandler(805, record), deleteSubscribeByIdHandler(805, { success: true }, 200, deleted))
useDialogOptions({ tmdbId: 8050 })
mocks.confirm.mockResolvedValue(false)
const { events } = await renderDialog({ subid: 805 })
await screen.findByText('保留订阅')
await fireEvent.click(screen.getByRole('button', { name: '取消订阅' }))
expect(deleted).not.toHaveBeenCalled()
expect(events.remove).not.toHaveBeenCalled()
})
it('emits remove only after a successful deletion', async () => {
const record = createSubscribe({ id: 806, name: '删除订阅', tmdbid: 8060 })
const deleted = vi.fn()
server.use(subscribeDetailsHandler(806, record), deleteSubscribeByIdHandler(806, { success: true }, 200, deleted))
useDialogOptions({ tmdbId: 8060 })
const { events } = await renderDialog({ subid: 806 })
await screen.findByText('删除订阅')
await fireEvent.click(screen.getByRole('button', { name: '取消订阅' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect(events.remove).toHaveBeenCalledOnce()
expect(mocks.toastSuccess).toHaveBeenCalledWith('删除订阅 已取消订阅!')
})
it.each([
['business failure', 200, { message: 'not allowed', success: false }, '删除失败项 取消订阅失败not allowed'],
['HTTP failure', 500, { message: 'server down', success: false }, '删除失败项 取消订阅失败server down'],
])('keeps the subscription after a delete %s', async (_case, status, response, expectedMessage) => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const record = createSubscribe({ id: 807, name: '删除失败项', tmdbid: 8070 })
server.use(subscribeDetailsHandler(807, record), deleteSubscribeByIdHandler(807, response, status))
useDialogOptions({ tmdbId: 8070 })
const { events } = await renderDialog({ subid: 807 })
await screen.findByText('删除失败项')
await fireEvent.click(screen.getByRole('button', { name: '取消订阅' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage))
expect(events.remove).not.toHaveBeenCalled()
expect(screen.getByText('删除失败项')).toBeInTheDocument()
consoleLog.mockRestore()
})
it('remains editable when an auxiliary options request fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const record = createSubscribe({ id: 808, keyword: '仍可编辑', name: '部分失败项', tmdbid: 8080 })
const updated = vi.fn()
server.use(subscribeDetailsHandler(808, record), updateSubscribeHandler({ success: true }, 200, updated))
useDialogOptions({ tmdbId: 8080 })
server.use(
http.get(subscribeApiUrls.downloaders, () =>
HttpResponse.json({ message: 'unavailable', success: false }, { status: 500 }),
),
)
await renderDialog({ subid: 808 })
expect(await screen.findByDisplayValue('仍可编辑')).toBeInTheDocument()
await waitFor(() => expect(consoleError).toHaveBeenCalled())
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
consoleError.mockRestore()
})
})

View File

@@ -8,6 +8,11 @@ const props = defineProps({
type: String,
default: '* * * * *',
},
/** 是否允许直接清空 CRON。 */
clearable: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['update:modelValue'])
@@ -39,7 +44,6 @@ function updateModelValue(value: string) {
:modelValue="innerValue"
@update:modelValue="updateModelValue"
v-bind="{ ...menuprops, ...propsWithoutModelValue }"
clearable
/>
</template>
</CronInput>

View File

@@ -30,6 +30,7 @@ const propsWithoutModelValue = computed(() => {
return { ...rest, ...attrs }
})
/** 同步路径输入值并向父组件派发更新。 */
function updateModelValue(value: string) {
innerValue.value = value
emit('update:modelValue', value)

View File

@@ -728,7 +728,7 @@ async function batchScrape() {
function handleProgressMessage(event: MessageEvent) {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressText.value = progress.text_i18n || progress.text
progressValue.value = progress.value
progressDialogController?.updateProps({ text: progressText.value, value: progressValue.value })
}

View File

@@ -722,7 +722,7 @@ onMounted(() => {
.filter-buttons-grid {
display: grid;
gap: 4px;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.filter-btn-mobile {
@@ -732,10 +732,23 @@ onMounted(() => {
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
block-size: auto;
min-block-size: 48px;
block-size: 56px;
min-block-size: 56px;
min-inline-size: 0;
padding-block: 4px;
padding-inline: 0;
padding-inline: 4px;
}
.filter-btn-mobile :deep(.v-btn__content) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
inline-size: 100%;
max-inline-size: 100%;
min-inline-size: 0;
line-height: 1.15;
white-space: normal;
}
.filter-icon {
@@ -744,20 +757,20 @@ onMounted(() => {
}
.filter-label {
display: -webkit-box;
overflow: hidden;
font-size: 0.8rem;
line-height: 1.15;
max-inline-size: 100%;
overflow-wrap: anywhere;
text-align: center;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@media (width <= 600px) {
.filter-buttons-grid {
gap: 6px;
}
.filter-label {
overflow: hidden;
max-inline-size: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@@ -7,6 +7,7 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue'])
const { locale } = useI18n()
const menu = ref(false)
const currentCron = ref(props.modelValue)
@@ -14,6 +15,27 @@ const menuRoot = ref<HTMLElement>()
const instance = getCurrentInstance()
const menuContentClass = `cron-input-menu-${instance?.uid ?? 'default'}`
const menuContentSelector = `.${menuContentClass}`
const normalizedLocale = computed(() => locale.value.toLowerCase().replace(/_/g, '-'))
const cronLocale = computed(() => normalizedLocale.value.startsWith('zh') ? 'zh-cn' : 'en')
// vue-js-cron 没有内置繁体中文,只需覆盖简体词典中会显示的 Cron 专有字词。
const cronCustomLocale = computed(() => normalizedLocale.value === 'zh-tw' ? {
'*': {
day: {
value: { text: '{{value.alt}}號' },
range: { text: '{{start.alt}}號-{{end.alt}}號' },
},
dayOfWeek: { empty: { text: '一週的每一天' } },
hour: { empty: { text: '每小時' } },
minute: { empty: { text: '每分鐘' } },
},
hour: {
text: '小時',
minute: { '*': { suffix: '分鐘' } },
},
week: { text: '週' },
'q-minute': { text: '分鐘' },
'q-hour': { text: '小時' },
} : undefined)
function isCronMenuTarget(target: EventTarget | null) {
if (!(target instanceof Element)) return false
@@ -72,7 +94,14 @@ watch(
</template>
<VList>
<VListItem>
<VCronVuetify v-model="currentCron" locale="zh-CN" :chip-props="{ color: 'success' }" class="mt-1" />
<VCronVuetify
:key="locale"
v-model="currentCron"
:chip-props="{ color: 'success' }"
class="mt-1"
:custom-locale="cronCustomLocale"
:locale="cronLocale"
/>
</VListItem>
</VList>
</VMenu>

View File

@@ -24,6 +24,7 @@ const asyncDashboardOptions = {
const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader> = {
storage: () => import('@/views/dashboard/AnalyticsStorage.vue'),
mediaStatistic: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'),
mediaRecommend: () => import('@/views/dashboard/MediaRecommend.vue'),
weeklyOverview: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'),
speed: () => import('@/views/dashboard/AnalyticsSpeed.vue'),
scheduler: () => import('@/views/dashboard/AnalyticsScheduler.vue'),
@@ -33,6 +34,9 @@ const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader>
library: () => import('@/views/dashboard/MediaServerLibrary.vue'),
playing: () => import('@/views/dashboard/MediaServerPlaying.vue'),
latest: () => import('@/views/dashboard/MediaServerLatest.vue'),
recentImports: () => import('@/views/dashboard/DashboardRecentImports.vue'),
quickActions: () => import('@/views/dashboard/DashboardQuickActions.vue'),
systemInfo: () => import('@/views/dashboard/DashboardSystemInfo.vue'),
}
const builtInDashboardComponentPromises = new Map<string, Promise<any>>()
@@ -65,6 +69,7 @@ function createAsyncDashboardComponent(id: string) {
// 内置仪表盘按需加载,关闭的卡片不再挤进 dashboard 首屏 chunk。
const AnalyticsStorage = createAsyncDashboardComponent('storage')
const AnalyticsMediaStatistic = createAsyncDashboardComponent('mediaStatistic')
const MediaRecommend = createAsyncDashboardComponent('mediaRecommend')
const AnalyticsWeeklyOverview = createAsyncDashboardComponent('weeklyOverview')
const AnalyticsSpeed = createAsyncDashboardComponent('speed')
const AnalyticsScheduler = createAsyncDashboardComponent('scheduler')
@@ -74,6 +79,9 @@ const AnalyticsNetwork = createAsyncDashboardComponent('network')
const MediaServerLibrary = createAsyncDashboardComponent('library')
const MediaServerPlaying = createAsyncDashboardComponent('playing')
const MediaServerLatest = createAsyncDashboardComponent('latest')
const DashboardRecentImports = createAsyncDashboardComponent('recentImports')
const DashboardQuickActions = createAsyncDashboardComponent('quickActions')
const DashboardSystemInfo = createAsyncDashboardComponent('systemInfo')
// 输入参数
const props = defineProps({
@@ -196,6 +204,7 @@ onUnmounted(() => {
<!-- 系统内置的仪表板 -->
<AnalyticsStorage v-if="config?.id === 'storage'" />
<AnalyticsMediaStatistic v-else-if="config?.id === 'mediaStatistic'" />
<MediaRecommend v-else-if="config?.id === 'mediaRecommend'" />
<AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" />
<AnalyticsSpeed v-else-if="config?.id === 'speed'" :allowRefresh="props.allowRefresh" />
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" />
@@ -205,6 +214,9 @@ onUnmounted(() => {
<MediaServerLibrary v-else-if="config?.id === 'library'" />
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
<MediaServerLatest v-else-if="config?.id === 'latest'" />
<DashboardRecentImports v-else-if="config?.id === 'recentImports'" />
<DashboardQuickActions v-else-if="config?.id === 'quickActions'" />
<DashboardSystemInfo v-else-if="config?.id === 'systemInfo'" :allow-refresh="props.allowRefresh" />
<!-- 插件仪表板 -->
<template v-else-if="!isNullOrEmptyObject(props.config)">
<!-- Vue 渲染模式 -->

View File

@@ -8,15 +8,22 @@ const props = defineProps({
})
interface TmdbItem {
// 媒体标题
title: string
// 媒体简介,包含类型标签
overview: string
// TMDB ID
tmdbid: number
// 豆瓣 ID
doubanid: string
// 海报地址
poster: string
// 媒体类型
type?: string
}
// update:modelValue 事件
const emit = defineEmits(['update:modelValue', 'close'])
const emit = defineEmits(['update:modelValue', 'select', 'close'])
const items = ref<TmdbItem[]>([])
@@ -29,9 +36,10 @@ const loading = ref(false)
// ref
const inputKeyword = ref<HTMLElement | null>(null)
// 选中条目
// 选中条目并通知父组件同步额外媒体信息。
function selectMedia(item: TmdbItem) {
emit('update:modelValue', item.tmdbid || item.doubanid)
emit('select', item)
emit('close')
}
@@ -66,6 +74,7 @@ async function searchMedias() {
tmdbid: item.tmdb_id || 0,
doubanid: item.douban_id || '',
poster: getW500Image(item.poster_path),
type: item.type,
title: `${item.title}${item.year}`,
overview: `<span class="text-primary">${item.type}</span> ${item.overview}`,
})

View File

@@ -0,0 +1,647 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import * as THREE from 'three'
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
import logoUrl from '@images/logo.png'
type LogoPoint = readonly [number, number]
interface LogoFacetDefinition {
points: readonly LogoPoint[]
color: number
cornerRadius?: number
}
interface LogoPieceDefinition {
points: readonly LogoPoint[]
faceColor: number
sideColor: number
depth: number
offsetZ: number
cornerRadius: number
facets: readonly LogoFacetDefinition[]
}
const LOGO_VIEWBOX_CENTER = 96
const LOGO_COORDINATE_SCALE = 1 / 80
const LOGO_BEVEL_SIZE = 0.08 // 倒角水平扩张尺寸,增大以捕获更宽的高光带
const LOGO_BEVEL_THICKNESS = 0.06 // 倒角绝对厚度
const AUTO_ROTATION_SPEED = 0.3
const MAX_TILT = 0.4
const INITIAL_ROTATION_X = -0.09
const INITIAL_ROTATION_Y = -0.16
const LOGO_BASE_Y = 0.1
const LOGO_PIECES: readonly LogoPieceDefinition[] = [
{
points: [
[96, 15],
[24, 57],
[24, 133],
[48, 147],
[48, 76],
[96, 48],
[120, 62],
[120, 35],
],
faceColor: 0x9652e6,
sideColor: 0x5c27ae,
depth: 0.2,
offsetZ: 0,
cornerRadius: 4.8,
facets: [
{
points: [
[96, 19],
[29, 58],
[48, 72],
[96, 44],
[116, 56],
[116, 38],
],
color: 0xb978ff,
cornerRadius: 2.4,
},
{
points: [
[29, 61],
[29, 130],
[44, 139],
[44, 78],
],
color: 0x7030ca,
cornerRadius: 2.2,
},
],
},
{
points: [
[144, 43],
[168, 57],
[168, 134],
[96, 176],
[72, 162],
[72, 135],
[96, 149],
[144, 121],
],
faceColor: 0x8140d5,
sideColor: 0x54229f,
depth: 0.21,
offsetZ: 0.006,
cornerRadius: 4.8,
facets: [
{
points: [
[148, 49],
[163, 59],
[163, 130],
[148, 121],
],
color: 0xa15cef,
cornerRadius: 2.2,
},
{
points: [
[162, 134],
[96, 171],
[77, 159],
[77, 141],
[96, 153],
[144, 125],
],
color: 0x722bd0,
cornerRadius: 2.4,
},
],
},
{
points: [
[76, 64],
[136, 96],
[76, 128],
],
faceColor: 0x9a50eb,
sideColor: 0x622cb4,
depth: 0.23,
offsetZ: 0.026,
cornerRadius: 3.6,
facets: [
{
points: [
[80, 70],
[130, 96],
[80, 94],
],
color: 0xb978ff,
cornerRadius: 1.8,
},
{
points: [
[80, 98],
[130, 96],
[80, 122],
],
color: 0x6f29d1,
cornerRadius: 1.8,
},
],
},
]
const rootRef = ref<HTMLDivElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const isReady = ref(false)
const hasWebGLError = ref(false)
const isDragging = ref(false)
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let logoGroup: THREE.Group | null = null
let environmentTexture: THREE.Texture | null = null
let glowTexture: THREE.CanvasTexture | null = null
let glowMaterial: THREE.SpriteMaterial | null = null
let resizeObserver: ResizeObserver | null = null
let intersectionObserver: IntersectionObserver | null = null
let reducedMotionQuery: MediaQueryList | null = null
let animationFrameId = 0
let previousFrameTime = 0
let targetRotationX = INITIAL_ROTATION_X
let targetRotationY = INITIAL_ROTATION_Y
let dragVelocityY = 0
let lastPointerX = 0
let lastPointerY = 0
let isIntersecting = true
let prefersReducedMotion = false
/** 将原 Logo 的二维坐标转换到以画布中心为原点的 Three.js 坐标系。 */
function convertLogoPoint([sourceX, sourceY]: LogoPoint) {
return new THREE.Vector2(
(sourceX - LOGO_VIEWBOX_CENTER) * LOGO_COORDINATE_SCALE,
(LOGO_VIEWBOX_CENTER - sourceY) * LOGO_COORDINATE_SCALE,
)
}
/** 根据多边形轮廓生成带圆角的二维 Logo 形状。 */
function createRoundedLogoShape(points: readonly LogoPoint[], sourceCornerRadius: number) {
const vertices = points.map(convertLogoPoint)
const cornerRadius = sourceCornerRadius * LOGO_COORDINATE_SCALE
const corners = vertices.map((current, index) => {
const previous = vertices[(index - 1 + vertices.length) % vertices.length]
const next = vertices[(index + 1) % vertices.length]
const incoming = previous.clone().sub(current)
const outgoing = next.clone().sub(current)
const entryDistance = Math.min(cornerRadius, incoming.length() * 0.32)
const exitDistance = Math.min(cornerRadius, outgoing.length() * 0.32)
return {
current,
entry: current.clone().add(incoming.normalize().multiplyScalar(entryDistance)),
exit: current.clone().add(outgoing.normalize().multiplyScalar(exitDistance)),
}
})
const shape = new THREE.Shape()
shape.moveTo(corners[0].exit.x, corners[0].exit.y)
for (let step = 1; step <= corners.length; step += 1) {
const corner = corners[step % corners.length]
shape.lineTo(corner.entry.x, corner.entry.y)
shape.quadraticCurveTo(corner.current.x, corner.current.y, corner.exit.x, corner.exit.y)
}
shape.closePath()
return shape
}
/** 创建紫色哑光金属的正面材质:去除清漆与虹彩等“油润感”因素,仅依靠适中的粗糙度与环境反射呈现哑光滞面金属质感。 */
function createFaceMaterial(color: number) {
return new THREE.MeshPhysicalMaterial({
color,
metalness: 1.0, // 物理纯金属
roughness: 0.32, // 哑光滞面,避免镜面般的过亮高光
envMapIntensity: 1.6, // 适度的环境反射强度,避免出现过曝的大面积亮班
})
}
/** 创建偏深紫的挤出侧面材质,比正面稍粗糙,与光洁正面形成自然层次对比。 */
function createSideMaterial(color: number) {
return new THREE.MeshPhysicalMaterial({
color,
metalness: 0.95, // 纯粹侧边金属
roughness: 0.42, // 侧面比正面更哑光,提升层次感
envMapIntensity: 1.8, // 增强侧面在旋转时对环境光的敏感度
})
}
/** 在挤出主体正面叠加略微内收的金属折面,复现设计图中的明暗分区。 */
function createFacetMesh(definition: LogoFacetDefinition, frontZ: number) {
const geometry = new THREE.ShapeGeometry(createRoundedLogoShape(definition.points, definition.cornerRadius ?? 1.8), 8)
geometry.translate(0, 0, frontZ)
const material = createFaceMaterial(definition.color)
material.polygonOffset = true
material.polygonOffsetFactor = -1
material.polygonOffsetUnits = -1
const mesh = new THREE.Mesh(geometry, material)
mesh.renderOrder = 2
return mesh
}
/** 创建一段带厚挤出、宽倒角和分区高光的紫色金属 Logo。 */
function createLogoPiece(definition: LogoPieceDefinition) {
const pieceGroup = new THREE.Group()
const geometry = new THREE.ExtrudeGeometry(createRoundedLogoShape(definition.points, definition.cornerRadius), {
depth: definition.depth,
steps: 1,
curveSegments: 12, // 提升折点平滑度
bevelEnabled: true,
bevelSegments: 12, // 大幅度提升倒角分段,打造极其圆润圆滑的边缘过渡
bevelSize: LOGO_BEVEL_SIZE,
bevelThickness: LOGO_BEVEL_THICKNESS,
bevelOffset: -0.016, // 微调倒角向内偏移,控制体积膨胀感
})
geometry.translate(0, 0, definition.offsetZ - definition.depth / 2)
geometry.computeVertexNormals()
const body = new THREE.Mesh(geometry, [
createFaceMaterial(definition.faceColor),
createSideMaterial(definition.sideColor),
])
body.renderOrder = 1
pieceGroup.add(body)
const frontZ = definition.offsetZ + definition.depth / 2 + LOGO_BEVEL_THICKNESS + 0.004
definition.facets.forEach(facet => pieceGroup.add(createFacetMesh(facet, frontZ)))
return pieceGroup
}
/** 组合断开六边形折带与双折面播放符号,形成完整 MoviePilot Logo。 */
function createLogoModel() {
const group = new THREE.Group()
LOGO_PIECES.forEach(definition => group.add(createLogoPiece(definition)))
group.position.y = LOGO_BASE_Y
group.rotation.set(targetRotationX, targetRotationY, 0)
return group
}
/** 生成透明椭圆光斑纹理,作为 Logo 下方的紫色悬浮投影。 */
function createGroundGlowTexture() {
const glowCanvas = document.createElement('canvas')
glowCanvas.width = 256
glowCanvas.height = 256
const context = glowCanvas.getContext('2d')
if (!context) return null
const gradient = context.createRadialGradient(128, 128, 0, 128, 128, 128)
gradient.addColorStop(0, 'rgba(177, 116, 255, 0.55)')
gradient.addColorStop(0.34, 'rgba(119, 48, 255, 0.28)')
gradient.addColorStop(1, 'rgba(65, 15, 132, 0)')
context.fillStyle = gradient
context.fillRect(0, 0, 256, 256)
const texture = new THREE.CanvasTexture(glowCanvas)
texture.colorSpace = THREE.SRGBColorSpace
return texture
}
/** 在场景中添加始终位于模型下方的柔和紫色光斑。 */
function addGroundGlow(activeScene: THREE.Scene) {
glowTexture = createGroundGlowTexture()
if (!glowTexture) return
glowMaterial = new THREE.SpriteMaterial({
map: glowTexture,
color: 0xb06dff,
opacity: 0.58,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
})
const glow = new THREE.Sprite(glowMaterial)
glow.position.set(0, -1.08, -0.7)
glow.scale.set(2.25, 0.34, 1)
glow.renderOrder = 0
activeScene.add(glow)
}
/** 配置突出紫色镜面、银紫倒角与背部轮廓的摄影棚布光。 */
function configureLighting(activeRenderer: THREE.WebGLRenderer, activeScene: THREE.Scene) {
const pmremGenerator = new THREE.PMREMGenerator(activeRenderer)
const roomEnvironment = new RoomEnvironment()
environmentTexture = pmremGenerator.fromScene(roomEnvironment, 0.035).texture
activeScene.environment = environmentTexture
activeScene.environmentIntensity = 1.15
roomEnvironment.dispose()
pmremGenerator.dispose()
const keyLight = new THREE.DirectionalLight(0xfff8ef, 4.6)
keyLight.position.set(-3.6, 4.7, 5.4)
activeScene.add(keyLight)
const coolFillLight = new THREE.DirectionalLight(0x9bc7ff, 1.35)
coolFillLight.position.set(-4.4, -1.2, 3.2)
activeScene.add(coolFillLight)
const rimLight = new THREE.DirectionalLight(0xe6b7ff, 4.1)
rimLight.position.set(3.8, 2.8, -4.8)
activeScene.add(rimLight)
const violetBounceLight = new THREE.PointLight(0x6422c9, 3.2, 7, 2)
violetBounceLight.position.set(2.5, -2.4, 2.4)
activeScene.add(violetBounceLight)
activeScene.add(new THREE.HemisphereLight(0xe9f2ff, 0x260441, 0.62))
}
/** 按容器实际尺寸与设备像素比同步渲染器。 */
function resizeRenderer() {
if (!renderer || !camera || !rootRef.value) return
const { width, height } = rootRef.value.getBoundingClientRect()
if (!width || !height) return
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
renderer.setSize(width, height, false)
camera.aspect = width / height
camera.updateProjectionMatrix()
}
/** 逐帧更新完整转台旋转、拖拽惯性与轻微悬浮位移。 */
function renderFrame(frameTime: number) {
animationFrameId = window.requestAnimationFrame(renderFrame)
if (!renderer || !scene || !camera || !logoGroup || !isIntersecting) return
const delta = previousFrameTime ? Math.min((frameTime - previousFrameTime) / 1000, 0.05) : 0
previousFrameTime = frameTime
if (!isDragging.value && !prefersReducedMotion) {
targetRotationY += (AUTO_ROTATION_SPEED + dragVelocityY) * delta
dragVelocityY *= Math.exp(-4.2 * delta)
}
if (prefersReducedMotion) {
logoGroup.rotation.set(targetRotationX, targetRotationY, 0)
logoGroup.position.y = LOGO_BASE_Y
} else {
const easing = 1 - Math.exp(-12 * delta)
logoGroup.rotation.x += (targetRotationX - logoGroup.rotation.x) * easing
logoGroup.rotation.y += (targetRotationY - logoGroup.rotation.y) * easing
logoGroup.position.y = LOGO_BASE_Y + Math.sin(frameTime * 0.0011) * 0.018
}
renderer.render(scene, camera)
}
/** 初始化 Three.js 场景;不支持 WebGL 时切换到静态 Logo。 */
function initializeScene() {
const canvas = canvasRef.value
if (!canvas) return
try {
renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
powerPreference: 'high-performance',
})
renderer.setClearColor(0x000000, 0)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.12
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(28, 1, 0.1, 100)
camera.position.set(0, 0.02, 5.05)
camera.lookAt(0, 0, 0)
configureLighting(renderer, scene)
addGroundGlow(scene)
logoGroup = createLogoModel()
scene.add(logoGroup)
resizeRenderer()
renderer.render(scene, camera)
isReady.value = true
animationFrameId = window.requestAnimationFrame(renderFrame)
} catch (error) {
console.warn('无法初始化登录页 3D Logo已回退到静态图标。', error)
hasWebGLError.value = true
isReady.value = false
disposeScene()
}
}
/** 处理拖拽开始并捕获指针,保证触屏滑动连续。 */
function handlePointerDown(event: PointerEvent) {
if (!isReady.value) return
isDragging.value = true
dragVelocityY = 0
lastPointerX = event.clientX
lastPointerY = event.clientY
rootRef.value?.setPointerCapture(event.pointerId)
}
/** 根据指针位移更新 Logo 水平旋转与受限俯仰角。 */
function handlePointerMove(event: PointerEvent) {
if (!isDragging.value) return
const deltaX = event.clientX - lastPointerX
const deltaY = event.clientY - lastPointerY
targetRotationY += deltaX * 0.012
targetRotationX = THREE.MathUtils.clamp(targetRotationX + deltaY * 0.008, -MAX_TILT, MAX_TILT)
dragVelocityY = THREE.MathUtils.clamp(deltaX * 0.08, -2.4, 2.4)
lastPointerX = event.clientX
lastPointerY = event.clientY
}
/** 结束指针拖拽并释放捕获。 */
function handlePointerUp(event: PointerEvent) {
if (!isDragging.value) return
isDragging.value = false
if (rootRef.value?.hasPointerCapture(event.pointerId)) rootRef.value.releasePointerCapture(event.pointerId)
}
/** 支持方向键旋转 Logo提供无鼠标交互能力。 */
function handleKeydown(event: KeyboardEvent) {
const rotationStep = Math.PI / 10
if (event.key === 'ArrowLeft') targetRotationY -= rotationStep
else if (event.key === 'ArrowRight') targetRotationY += rotationStep
else if (event.key === 'ArrowUp') targetRotationX = Math.max(targetRotationX - rotationStep / 2, -MAX_TILT)
else if (event.key === 'ArrowDown') targetRotationX = Math.min(targetRotationX + rotationStep / 2, MAX_TILT)
else return
event.preventDefault()
}
/** 同步系统减少动态偏好,关闭自动旋转但保留手动交互。 */
function handleReducedMotionChange(event?: MediaQueryListEvent) {
prefersReducedMotion = event?.matches ?? reducedMotionQuery?.matches ?? false
}
/** 仅在组件进入视口时持续渲染,降低后台 GPU 占用。 */
function handleIntersection(entries: IntersectionObserverEntry[]) {
isIntersecting = entries[0]?.isIntersecting ?? true
if (isIntersecting) previousFrameTime = performance.now()
}
/** WebGL 上下文丢失时停止渲染并显示静态回退 Logo。 */
function handleContextLost(event: Event) {
event.preventDefault()
if (animationFrameId) window.cancelAnimationFrame(animationFrameId)
animationFrameId = 0
hasWebGLError.value = true
isReady.value = false
}
/** 释放指定 3D 对象树中的几何体、材质与贴图资源。 */
function disposeObjectResources(root: THREE.Object3D) {
const disposedGeometries = new Set<THREE.BufferGeometry>()
const disposedMaterials = new Set<THREE.Material>()
const disposedTextures = new Set<THREE.Texture>()
root.traverse(object => {
if (!(object instanceof THREE.Mesh)) return
if (!disposedGeometries.has(object.geometry)) {
object.geometry.dispose()
disposedGeometries.add(object.geometry)
}
const materials = Array.isArray(object.material) ? object.material : [object.material]
materials.forEach(material => {
if (disposedMaterials.has(material)) return
const mappedMaterial = material as THREE.Material & { map?: THREE.Texture | null }
if (mappedMaterial.map && !disposedTextures.has(mappedMaterial.map)) {
mappedMaterial.map.dispose()
disposedTextures.add(mappedMaterial.map)
}
material.dispose()
disposedMaterials.add(material)
})
})
}
/** 释放场景中的几何体、材质、环境贴图和 WebGL 上下文。 */
function disposeScene() {
if (animationFrameId) window.cancelAnimationFrame(animationFrameId)
animationFrameId = 0
if (scene) disposeObjectResources(scene)
environmentTexture?.dispose()
glowMaterial?.dispose()
glowTexture?.dispose()
renderer?.dispose()
scene = null
camera = null
logoGroup = null
renderer = null
environmentTexture = null
glowTexture = null
glowMaterial = null
}
/** 注册尺寸、可见性和动态偏好监听并启动场景。 */
function handleMounted() {
reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
handleReducedMotionChange()
reducedMotionQuery.addEventListener('change', handleReducedMotionChange)
if (rootRef.value) {
resizeObserver = new ResizeObserver(resizeRenderer)
resizeObserver.observe(rootRef.value)
intersectionObserver = new IntersectionObserver(handleIntersection, { threshold: 0.05 })
intersectionObserver.observe(rootRef.value)
}
canvasRef.value?.addEventListener('webglcontextlost', handleContextLost)
initializeScene()
}
/** 移除监听器并完整销毁 Three.js 场景。 */
function handleBeforeUnmount() {
resizeObserver?.disconnect()
intersectionObserver?.disconnect()
reducedMotionQuery?.removeEventListener('change', handleReducedMotionChange)
canvasRef.value?.removeEventListener('webglcontextlost', handleContextLost)
disposeScene()
}
onMounted(handleMounted)
onBeforeUnmount(handleBeforeUnmount)
</script>
<template>
<div
ref="rootRef"
class="metal-logo-3d"
:class="{ 'metal-logo-3d--dragging': isDragging, 'metal-logo-3d--ready': isReady }"
role="img"
tabindex="0"
aria-label="MoviePilot 3D metal logo"
@keydown="handleKeydown"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="handlePointerUp"
>
<canvas ref="canvasRef" class="metal-logo-3d__canvas" aria-hidden="true" />
<img v-if="hasWebGLError" :src="logoUrl" class="metal-logo-3d__fallback" alt="MoviePilot" />
</div>
</template>
<style scoped lang="scss">
.metal-logo-3d {
position: relative;
display: block;
overflow: visible;
block-size: 112px;
cursor: grab;
inline-size: 112px;
outline: none;
touch-action: none;
&:focus-visible {
border-radius: 8px;
outline: 2px solid rgba(var(--v-theme-primary), 0.78);
outline-offset: 4px;
}
}
.metal-logo-3d--dragging {
cursor: grabbing;
}
.metal-logo-3d__canvas,
.metal-logo-3d__fallback {
display: block;
block-size: 100%;
inline-size: 100%;
}
.metal-logo-3d__canvas {
filter: drop-shadow(0 9px 9px rgba(16, 6, 34, 24%)) drop-shadow(0 0 7px rgba(132, 70, 255, 16%));
opacity: 0;
transition: opacity 350ms ease;
}
.metal-logo-3d--ready .metal-logo-3d__canvas {
opacity: 1;
}
.metal-logo-3d__fallback {
padding: 12px;
object-fit: contain;
}
@media (width <= 480px) {
.metal-logo-3d {
block-size: 104px;
inline-size: 104px;
}
}
@media (prefers-reduced-motion: reduce) {
.metal-logo-3d__canvas {
transition: none;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,247 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import logoUrl from '@images/logo.svg'
const props = withDefaults(
defineProps<{
animate?: boolean
intensity?: number
}>(),
{
animate: true,
intensity: 45,
},
)
const rootRef = ref<HTMLSpanElement | null>(null)
const logoMaskStyle = computed(() => ({
'--logo-mask': `url("${logoUrl}")`,
'--prism-intensity': Math.min(1, Math.max(0, props.intensity / 100)),
}))
let pointerFrame: number | null = null
let pendingPointerX = 0.5
let pendingPointerY = 0.42
/** 将指针位置映射为同一套棱镜反射和轻微空间倾角。 */
function renderPointerResponse() {
pointerFrame = null
const root = rootRef.value
if (!root) return
root.style.setProperty('--logo-light-x', `${(pendingPointerX * 100).toFixed(2)}%`)
root.style.setProperty('--logo-light-y', `${(pendingPointerY * 100).toFixed(2)}%`)
root.style.setProperty('--logo-tilt-x', `${((0.5 - pendingPointerY) * 7).toFixed(2)}deg`)
root.style.setProperty('--logo-tilt-y', `${((pendingPointerX - 0.5) * 9).toFixed(2)}deg`)
}
function queuePointerResponse() {
if (pointerFrame === null) pointerFrame = window.requestAnimationFrame(renderPointerResponse)
}
function handlePointerMove(event: PointerEvent) {
if (event.pointerType === 'touch') return
const bounds = rootRef.value?.getBoundingClientRect()
if (!bounds?.width || !bounds.height) return
pendingPointerX = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width))
pendingPointerY = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height))
queuePointerResponse()
}
function resetPointerResponse() {
pendingPointerX = 0.5
pendingPointerY = 0.42
queuePointerResponse()
}
onBeforeUnmount(() => {
if (pointerFrame !== null) window.cancelAnimationFrame(pointerFrame)
})
</script>
<template>
<span
ref="rootRef"
class="prismatic-logo"
:class="{ 'prismatic-logo--animated': props.animate }"
:style="logoMaskStyle"
role="img"
aria-label="MoviePilot"
@pointermove="handlePointerMove"
@pointerleave="resetPointerResponse"
>
<img :src="logoUrl" class="prismatic-logo__base" alt="" draggable="false" aria-hidden="true" />
<span class="prismatic-logo__spectrum" aria-hidden="true" />
<span class="prismatic-logo__specular" aria-hidden="true" />
<span class="prismatic-logo__reveal" aria-hidden="true" />
</span>
</template>
<style scoped lang="scss">
.prismatic-logo {
--logo-light-x: 50%;
--logo-light-y: 42%;
--logo-tilt-x: 0deg;
--logo-tilt-y: 0deg;
position: relative;
display: grid;
isolation: isolate;
block-size: 100%;
inline-size: 100%;
place-items: center;
transform: perspective(520px) rotateX(var(--logo-tilt-x)) rotateY(var(--logo-tilt-y));
transform-style: preserve-3d;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.prismatic-logo--animated {
animation: prismatic-logo-enter 620ms cubic-bezier(0.16, 1, 0.3, 1) 80ms backwards;
}
.prismatic-logo::before {
position: absolute;
z-index: -1;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(var(--v-theme-primary), 0.34),
rgba(var(--v-theme-primary), 0.1) 44%,
transparent 72%
);
content: '';
filter: blur(13px);
inset: 17%;
opacity: calc(0.36 + var(--prism-intensity) * 0.56);
transform: translate3d(0, 8px, -16px) scaleX(1.18);
}
.prismatic-logo__base,
.prismatic-logo__spectrum,
.prismatic-logo__specular,
.prismatic-logo__reveal {
position: absolute;
display: block;
block-size: 100%;
inline-size: 100%;
inset: 0;
pointer-events: none;
user-select: none;
}
.prismatic-logo__base {
filter:
drop-shadow(0 8px 12px rgba(24, 8, 52, 0.3))
drop-shadow(0 0 10px rgba(139, 92, 246, 0.22));
object-fit: contain;
padding: 7px;
transform: translateZ(10px);
}
.prismatic-logo__spectrum,
.prismatic-logo__specular,
.prismatic-logo__reveal {
-webkit-mask: var(--logo-mask) center / calc(100% - 14px) calc(100% - 14px) no-repeat;
mask: var(--logo-mask) center / calc(100% - 14px) calc(100% - 14px) no-repeat;
}
.prismatic-logo__spectrum {
background:
radial-gradient(
circle at var(--logo-light-x) var(--logo-light-y),
rgba(255, 255, 255, 0.95),
rgba(210, 188, 255, 0.66) 13%,
transparent 36%
),
conic-gradient(
from 218deg at var(--logo-light-x) var(--logo-light-y),
rgba(255, 105, 210, 0.72),
rgba(117, 212, 255, 0.72),
rgba(177, 139, 255, 0.82),
rgba(255, 214, 246, 0.7),
rgba(255, 105, 210, 0.72)
);
mix-blend-mode: screen;
opacity: calc(0.22 + var(--prism-intensity) * 0.58);
transform: translateZ(14px);
}
.prismatic-logo__specular {
background: radial-gradient(
ellipse 28% 20% at var(--logo-light-x) var(--logo-light-y),
rgba(255, 255, 255, 0.96),
rgba(232, 219, 255, 0.52) 28%,
transparent 72%
);
mix-blend-mode: screen;
opacity: calc(0.3 + var(--prism-intensity) * 0.66);
transform: translateZ(18px);
}
.prismatic-logo__reveal {
background: linear-gradient(
112deg,
transparent 30%,
rgba(255, 255, 255, 0.18) 39%,
rgba(255, 255, 255, 0.98) 48%,
rgba(125, 211, 252, 0.62) 54%,
transparent 66%
);
background-position: 100% 50%;
background-size: 300% 100%;
mix-blend-mode: screen;
opacity: 0;
transform: translateZ(20px);
}
.prismatic-logo--animated .prismatic-logo__reveal {
animation: prismatic-logo-reveal 840ms cubic-bezier(0.2, 0.76, 0.18, 1) 160ms both;
}
@keyframes prismatic-logo-enter {
0% {
opacity: 0;
transform: perspective(520px) translateY(10px) scale(0.82) rotateX(-8deg) rotateY(10deg);
}
62% {
opacity: 1;
transform: perspective(520px) translateY(-2px) scale(1.025) rotateX(1deg) rotateY(-1deg);
}
100% {
opacity: 1;
transform: perspective(520px) translateY(0) scale(1) rotateX(0) rotateY(0);
}
}
@keyframes prismatic-logo-reveal {
0% {
background-position: 100% 50%;
opacity: 0;
}
24% {
opacity: 1;
}
100% {
background-position: 0% 50%;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.prismatic-logo,
.prismatic-logo__reveal {
animation: none;
transform: none;
transition: none;
}
.prismatic-logo__reveal {
display: none;
}
}
</style>

View File

@@ -60,6 +60,9 @@ const trailingSpaceWidth = computed(() => {
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
})
/**
* 获取容器宽度不可用时的兜底视口宽度。
*/
function getFallbackViewportWidth() {
if (typeof window === 'undefined') {
return itemStep.value * Math.max(props.overscanItems, 1)
@@ -69,6 +72,9 @@ function getFallbackViewportWidth() {
return Math.max(window.innerWidth, itemStep.value * Math.max(props.overscanItems, 1))
}
/**
* 解析虚拟列表项的稳定 key。
*/
function resolveItemKey(item: any, index: number) {
if (props.getItemKey) {
return props.getItemKey(item, startIndex.value + index)
@@ -77,6 +83,9 @@ function resolveItemKey(item: any, index: number) {
return startIndex.value + index
}
/**
* 重置滚动状态指示计时器。
*/
function resetScrollIndicatorTimer() {
isScrolling.value = true
if (scrollTimeout) {
@@ -88,6 +97,9 @@ function resetScrollIndicatorTimer() {
}, scrollTimeoutDuration)
}
/**
* 根据当前滚动位置更新虚拟渲染范围。
*/
function updateVisibleRange() {
const element = slideContentRef.value
if (!element) {
@@ -113,6 +125,9 @@ function updateVisibleRange() {
endIndex.value = Math.max(firstVisible + 1, lastVisible)
}
/**
* 同步左右导航按钮的可用状态。
*/
function updateDisabledState() {
const element = slideContentRef.value
if (!element) return
@@ -130,11 +145,17 @@ function updateDisabledState() {
}
}
/**
* 同步虚拟列表布局与导航状态。
*/
function syncLayoutState() {
updateVisibleRange()
updateDisabledState()
}
/**
* 按当前可视范围向左或向右滚动一屏。
*/
function slideNext(next: boolean) {
const element = slideContentRef.value
if (!element) return
@@ -159,6 +180,9 @@ function slideNext(next: boolean) {
resetScrollIndicatorTimer()
}
/**
* 处理内容滚动并刷新滚动指示状态。
*/
function handleContentScroll() {
syncLayoutState()
resetScrollIndicatorTimer()
@@ -257,28 +281,20 @@ watch(
<VBtn
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
class="nav-button nav-button-left"
variant="text"
icon
color="secondary"
variant="tonal"
icon="mdi-chevron-left"
color="white"
@click.stop="slideNext(false)"
>
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
</svg>
</VBtn>
/>
<VBtn
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
class="nav-button nav-button-right"
variant="text"
icon
color="secondary"
variant="tonal"
icon="mdi-chevron-right"
color="white"
@click.stop="slideNext(true)"
>
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
</svg>
</VBtn>
/>
</div>
</div>
</template>
@@ -402,17 +418,18 @@ watch(
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 14%);
border-radius: 50%;
backdrop-filter: blur(8px);
background-color: rgba(var(--v-theme-background), 0.3);
block-size: 36px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
background: rgba(8, 18, 28, 52%) !important;
block-size: 40px;
box-shadow: 0 8px 22px rgba(0, 0, 0, 22%);
color: rgb(255, 255, 255);
cursor: pointer;
inline-size: 36px;
inline-size: 40px;
inset-block-start: 50%;
opacity: 0;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
transform: translateY(-50%);
transition:
opacity 0.3s ease,
@@ -421,21 +438,22 @@ watch(
box-shadow 0.3s ease,
border-color 0.3s ease;
svg {
block-size: 22px;
fill: currentcolor;
filter: none;
inline-size: 22px;
opacity: 0.7;
transition: all 0.3s ease;
:deep(.v-icon) {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 55%));
font-size: 28px;
opacity: 1;
transition: transform 0.3s ease;
}
&:hover {
color: rgb(var(--v-theme-primary));
border-color: rgba(255, 255, 255, 28%);
background: rgba(8, 18, 28, 68%) !important;
box-shadow: 0 10px 26px rgba(0, 0, 0, 28%);
color: rgb(255, 255, 255);
transform: translateY(-50%) scale(1.05);
svg {
opacity: 1;
:deep(.v-icon) {
transform: scale(1.08);
}
}
}

View File

@@ -56,6 +56,7 @@ function clearThemeCustomizerOpenState() {
syncThemeCustomizerOpenState(false)
}
// 处理固定面板的全局 Esc 关闭快捷键。
function handleGlobalKeydown(event: KeyboardEvent) {
// 固定侧栏不再依赖 Vuetify overlay手动补上常见的 Esc 关闭行为。
if (event.key === 'Escape') emit('close')
@@ -146,16 +147,19 @@ const showSemiDarkMenuOption = computed(() => {
)
})
// 打开原生颜色选择器。
function openColorPicker() {
customColorInput.value?.click()
}
// 处理原生颜色选择器的自定义主色输入。
function handleCustomColorInput(event: Event) {
const color = (event.target as HTMLInputElement).value
setPrimaryColor(color)
}
// 切换布局类型App 模式下保留移动端固定布局。
function handleLayoutChange(layout: ThemeCustomizerLayout) {
// App 模式固定使用移动端导航,避免切换桌面布局后破坏底部导航体验。
if (appMode.value) return
@@ -176,6 +180,7 @@ function handleShadowSliderChange(value: unknown) {
if (themeCustomizerShadowLevels.includes(shadow)) setShadow(shadow)
}
// 重置主题定制设置App 模式只恢复可调整的外观项。
async function handleResetSettings() {
if (!appMode.value) {
await resetSettings()
@@ -217,7 +222,7 @@ async function handleResetSettings() {
<VDivider />
<PerfectScrollbar class="theme-customizer-body" :options="{ wheelPropagation: false }">
<div class="theme-customizer-body">
<section class="theme-customizer-section">
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.primaryColor') }}</h3>
<div class="theme-customizer-color-grid">
@@ -277,14 +282,15 @@ async function handleResetSettings() {
:class="{ 'is-active': settings.skin === skin.value }"
@click="setSkin(skin.value)"
>
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${skin.value}`">
<span class="mini-sidebar">
<i />
<i />
<span class="theme-customizer-border-scene" :class="`theme-customizer-border-scene--${skin.value}`">
<span class="theme-customizer-border-scene__card">
<i />
<i />
</span>
<span class="mini-content">
<span class="theme-customizer-border-scene__dialog">
<i />
</span>
<span class="theme-customizer-border-scene__menu">
<i />
<i />
<i />
@@ -400,7 +406,7 @@ async function handleResetSettings() {
</div>
</div>
</section>
</PerfectScrollbar>
</div>
</div>
</aside>
</template>
@@ -438,11 +444,17 @@ async function handleResetSettings() {
position: relative;
display: flex;
flex-direction: column;
block-size: 100%;
block-size: 100vh;
inline-size: 100%;
min-block-size: 0;
}
@supports (block-size: 100dvh) {
.theme-customizer-panel {
block-size: 100dvh;
}
}
.theme-customizer-panel--dialog {
overflow: hidden;
block-size: 100%;
@@ -487,18 +499,15 @@ async function handleResetSettings() {
.theme-customizer-body {
flex: 1 1 auto;
min-block-size: 0;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: none;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
:deep(.ps__rail-x),
:deep(.ps__rail-y) {
display: none !important;
}
}
.theme-customizer-section {
@@ -641,6 +650,7 @@ async function handleResetSettings() {
box-shadow: none !important;
.theme-customizer-mini-layout,
.theme-customizer-border-scene,
.theme-customizer-radius-scene {
border-width: 2px;
border-color: rgb(var(--v-theme-primary));
@@ -696,6 +706,72 @@ async function handleResetSettings() {
}
}
.theme-customizer-border-scene {
position: relative;
display: block;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(var(--v-theme-on-surface), 0.02), rgba(var(--v-theme-on-surface), 0.05)),
rgb(var(--v-theme-surface));
block-size: 74px;
inline-size: 100%;
min-inline-size: 92px;
}
.theme-customizer-border-scene__card,
.theme-customizer-border-scene__dialog,
.theme-customizer-border-scene__menu {
position: absolute;
display: flex;
flex-direction: column;
border: 0 solid rgba(var(--v-theme-on-surface), 0.14);
border-radius: 6px;
background: rgba(var(--v-theme-surface), 0.92);
gap: 5px;
padding: 7px;
box-shadow: var(--app-elevation-0);
}
.theme-customizer-border-scene__card {
block-size: 36px;
inline-size: 46%;
inset-block-start: 12px;
inset-inline-start: 10px;
}
.theme-customizer-border-scene__dialog {
block-size: 42px;
inline-size: 38%;
inset-block-start: 18px;
inset-inline-end: 13px;
}
.theme-customizer-border-scene__menu {
block-size: 30px;
inline-size: 30%;
inset-block-end: 10px;
inset-inline-start: 34%;
}
.theme-customizer-border-scene__card i,
.theme-customizer-border-scene__dialog i,
.theme-customizer-border-scene__menu i {
display: block;
border-radius: 3px;
background: rgba(var(--v-theme-on-surface), 0.1);
block-size: 5px;
}
.theme-customizer-border-scene--bordered {
.theme-customizer-border-scene__card,
.theme-customizer-border-scene__dialog,
.theme-customizer-border-scene__menu {
border-width: 1px;
}
}
.mini-sidebar,
.mini-content {
display: flex;
@@ -721,14 +797,6 @@ async function handleResetSettings() {
block-size: 18px;
}
.theme-customizer-mini-layout--bordered {
.mini-content i,
.mini-sidebar i {
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
background: transparent;
}
}
.theme-customizer-radius-scene {
position: relative;
display: block;

View File

@@ -0,0 +1,598 @@
import type { MediaInfo, MediaSeason, Subscribe } from '@/api/types'
import {
getMediaSubscribeId,
getSubscribeMode,
type SeasonSubscribeModes,
type SubscribeMode,
useMediaSubscribe,
} from '@/composables/useMediaSubscribe'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import {
createSubscribe,
createSubscribeMovie,
createSubscribeTv,
} from '@tests/support/factories/subscribe'
import {
createSubscribeHandler,
defaultSubscribeConfigHandler,
deleteSubscribeByMediaHandler,
querySubscribeByMediaHandler,
updateSubscribeHandler,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
cacheStatus: vi.fn(),
confirm: vi.fn(),
doneProgress: vi.fn(),
onEditRemove: vi.fn(),
openSharedDialog: vi.fn(),
startProgress: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/api/nprogress', () => ({
configureNProgress: vi.fn(),
doneNProgress: mocks.doneProgress,
startNProgress: mocks.startProgress,
}))
vi.mock('@/utils/mediaStatusCache', () => ({
setCachedMediaSubscribeStatus: (...args: unknown[]) => mocks.cacheStatus(...args),
}))
interface MultiSeasonInput {
modes?: SubscribeMode | SeasonSubscribeModes
seasons?: MediaSeason[]
visible?: number[]
}
interface HarnessOptions {
actionSeason?: number | null
canSubscribe?: boolean
isExists?: boolean
isSubscribed?: boolean
media?: MediaInfo
modes?: SeasonSubscribeModes
multi?: MultiSeasonInput
seasonsMap?: Record<number, boolean>
subscribedSeasons?: number[]
useSeasonMap?: boolean
}
async function renderSubscribeHarness(options: HarnessOptions = {}) {
const media = options.media
const actionSeason = options.actionSeason ?? (media?.type === '电视剧' ? media.season ?? 1 : null)
const Harness = defineComponent({
name: 'MediaSubscribeHarness',
setup() {
const isSubscribed = ref(options.isSubscribed ?? false)
const seasonsSubscribed = ref<Record<number, boolean>>({ ...(options.seasonsMap ?? {}) })
const subscribedSeasons = ref([...(options.subscribedSeasons ?? [])])
const subscribedSeasonModes = ref<SeasonSubscribeModes>({ ...(options.modes ?? {}) })
const checkResult = ref('idle')
const actions = useMediaSubscribe({
canSubscribe: () => options.canSubscribe ?? true,
getSubscribeStatusKey: season => `status:${season ?? 'all'}`,
isExists: () => options.isExists ?? false,
isSubscribed,
media: () => media,
onEditRemove: mocks.onEditRemove,
primarySeason: () => media?.season ?? null,
seasonsSubscribed: options.useSeasonMap ? seasonsSubscribed : undefined,
subscribedSeasonModes,
subscribedSeasons,
})
async function check() {
try {
checkResult.value = (await actions.checkSubscribe(actionSeason)) ? 'subscribed' : 'missing'
} catch {
checkResult.value = 'error'
}
}
function alignSeasons() {
actions.subscribeSeasons(
options.multi?.seasons ?? [],
{},
'episode-group-1',
options.multi?.modes ?? 'normal',
options.multi?.visible ?? [],
)
}
return {
addBestFull: () => actions.addSubscribe(actionSeason, { best_version: 1, best_version_full: 1 }),
addNormal: () => actions.addSubscribe(actionSeason),
alignSeasons,
check,
checkResult,
handlePrimary: () => actions.handleSubscribe(),
handleSeason: () => actions.handleSubscribe(actionSeason, 'episode-group-entry'),
isSubscribed,
modes: subscribedSeasonModes,
openSeason: () => actions.openSubscribeSeasonDialog(actionSeason, 'episode-group-entry'),
remove: () => actions.removeSubscribe(actionSeason),
seasons: subscribedSeasons,
seasonsMap: seasonsSubscribed,
}
},
template: `
<button type="button" @click="handlePrimary">primary</button>
<button type="button" @click="handleSeason">season</button>
<button type="button" @click="addNormal">add-normal</button>
<button type="button" @click="addBestFull">add-best-full</button>
<button type="button" @click="remove">remove</button>
<button type="button" @click="check">check</button>
<button type="button" @click="openSeason">open-season</button>
<button type="button" @click="alignSeasons">align-seasons</button>
<output data-testid="subscribed">{{ String(isSubscribed) }}</output>
<output data-testid="seasons">{{ JSON.stringify(seasons) }}</output>
<output data-testid="season-map">{{ JSON.stringify(seasonsMap) }}</output>
<output data-testid="modes">{{ JSON.stringify(modes) }}</output>
<output data-testid="check-result">{{ checkResult }}</output>
`,
})
return renderWithProviders(Harness, {
initialState: {
user: {
superUser: false,
userName: 'tester',
},
},
})
}
function getDialogCall(index = 0) {
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
unknown,
Record<string, unknown>,
Record<string, (...args: any[]) => unknown>,
Record<string, unknown>,
]
return { events, options, props }
}
describe('media subscribe identifiers and modes', () => {
it.each([
['TMDB before all fallback identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: 10 }, 'tmdb:10'],
['Douban before Bangumi and generic identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: undefined }, 'douban:20'],
['Bangumi before a generic identifier', { bangumi_id: '30', douban_id: undefined, tmdb_id: undefined }, 'bangumi:30'],
[
'generic identifiers when provider ids are absent',
{ bangumi_id: undefined, douban_id: undefined, media_id: 'abc', mediaid_prefix: 'custom', tmdb_id: undefined },
'custom:abc',
],
])('uses %s', (_case, overrides, expected) => {
expect(getMediaSubscribeId(createSubscribeMovie(overrides))).toBe(expected)
})
it.each([
[{ best_version: false, best_version_full: true }, 'normal'],
[{ best_version: 0, best_version_full: 1 }, 'normal'],
[{ best_version: '0', best_version_full: '1' }, 'normal'],
[{ best_version: true, best_version_full: false }, 'best_version'],
[{ best_version: 1, best_version_full: 0 }, 'best_version'],
[{ best_version: '1', best_version_full: '1' }, 'best_version_full'],
] as const)('normalizes compatible mode flags %#', (subscribe, expected) => {
expect(getSubscribeMode(subscribe)).toBe(expected)
})
})
describe('useMediaSubscribe entry flows', () => {
beforeEach(() => {
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
})
it('creates a normal movie subscription and synchronizes public state', async () => {
const media = createSubscribeMovie({ title: '普通电影', tmdb_id: 101, year: '2025' })
const created = vi.fn()
server.use(
createSubscribeHandler({ data: { id: 501 }, success: true }, 200, created),
defaultSubscribeConfigHandler('电影', { show_edit_dialog: false }),
)
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await waitFor(() => expect(created).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
expect(created).toHaveBeenCalledWith({
bangumiid: undefined,
doubanid: undefined,
episode_group: '',
mediaid: '',
name: '普通电影',
season: null,
tmdbid: 101,
type: '电影',
year: '2025',
})
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', true)
expect(mocks.toastSuccess).toHaveBeenCalled()
expect(mocks.startProgress).toHaveBeenCalledOnce()
expect(mocks.doneProgress).toHaveBeenCalledOnce()
})
it('keeps a successful creation successful when default configuration loading fails', async () => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const media = createSubscribeMovie({ title: '辅助查询失败电影', tmdb_id: 111 })
const created = vi.fn()
const configQueried = vi.fn()
server.use(
createSubscribeHandler({ data: { id: 511 }, success: true }, 200, created),
defaultSubscribeConfigHandler('电影', {}, 500, configQueried),
)
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await waitFor(() => expect(created).toHaveBeenCalledOnce())
await waitFor(() => expect(configQueried).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', true)
expect(mocks.toastSuccess).toHaveBeenCalledOnce()
expect(mocks.toastError).not.toHaveBeenCalled()
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
consoleLog.mockRestore()
})
it('opens the mode chooser for an existing movie and creates the selected mode', async () => {
const media = createSubscribeMovie({ title: '已入库电影', tmdb_id: 102 })
const created = vi.fn()
server.use(
createSubscribeHandler({ data: { id: 502 }, success: true }, 200, created),
defaultSubscribeConfigHandler('电影', { show_edit_dialog: false }),
)
await renderSubscribeHarness({ isExists: true, media })
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
const modeDialog = getDialogCall()
expect(modeDialog.props).toMatchObject({ modes: ['normal', 'best_version'], type: '电影' })
modeDialog.events.choose('best_version')
await waitFor(() => expect(created).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
expect(created.mock.calls[0][0]).toMatchObject({ best_version: 1, best_version_full: 0, season: null })
})
it('opens the TV season chooser with current state and the default mode', async () => {
const media = createSubscribeTv({ season: 2, title: '季选择剧集', tmdb_id: 103 })
server.use(defaultSubscribeConfigHandler('电视剧', { best_version: '1', best_version_full: '1' }))
await renderSubscribeHarness({
media,
modes: { 1: 'normal', 2: 'best_version' },
subscribedSeasons: [1, 2],
})
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const dialog = getDialogCall()
expect(dialog.props).toMatchObject({
defaultSubscribeMode: 'best_version_full',
selectedSeason: undefined,
subscribedSeasonModes: { 1: 'normal', 2: 'best_version' },
subscribedSeasons: [1, 2],
})
expect(dialog.options).toEqual({ closeOn: ['close', 'subscribe'] })
})
it('opens the season chooser from an unsubscribed TV season entry', async () => {
const media = createSubscribeTv({ season: 2, title: '单季入口剧集', tmdb_id: 1031 })
server.use(defaultSubscribeConfigHandler('电视剧', { best_version: 0 }))
await renderSubscribeHarness({ actionSeason: 2, media })
await fireEvent.click(screen.getByRole('button', { name: 'season' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const dialog = getDialogCall()
expect(dialog.props).toMatchObject({
initialEpisodeGroup: 'episode-group-entry',
selectedSeason: 2,
})
})
it('cancels a subscribed movie from the primary entry', async () => {
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('tmdb:1032', { success: true }, 200, url => deleted(url)))
await renderSubscribeHarness({
isSubscribed: true,
media: createSubscribeMovie({ title: '主入口取消电影', tmdb_id: 1032 }),
})
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.has('season')).toBe(false)
expect(screen.getByTestId('subscribed')).toHaveTextContent('false')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', false)
})
it('cancels a subscribed TV season only after confirmation', async () => {
const media = createSubscribeTv({ season: 2, title: '取消季剧集', tmdb_id: 104 })
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('tmdb:104', { success: true }, 200, url => deleted(url)))
await renderSubscribeHarness({
isSubscribed: true,
media,
modes: { 1: 'normal', 2: 'best_version' },
seasonsMap: { 1: true, 2: true },
subscribedSeasons: [1, 2],
useSeasonMap: true,
})
mocks.confirm.mockResolvedValueOnce(false)
await fireEvent.click(screen.getByRole('button', { name: 'season' }))
expect(deleted).not.toHaveBeenCalled()
await fireEvent.click(screen.getByRole('button', { name: 'season' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false')
expect(screen.getByTestId('modes')).not.toHaveTextContent('"2"')
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:2', false)
})
it.each([
{
label: 'Douban',
media: createSubscribeTv({ douban_id: 'db-1', tmdb_id: undefined }),
mediaId: 'douban:db-1',
record: createSubscribe({ doubanid: 'db-1', season: 2, tmdbid: 0, type: '电视剧' }),
},
{
label: 'Bangumi',
media: createSubscribeTv({ bangumi_id: '42', tmdb_id: undefined }),
mediaId: 'bangumi:42',
record: createSubscribe({ bangumiid: 42 as unknown as string, season: 2, tmdbid: 0, type: '电视剧' }),
},
{
label: 'generic provider',
media: createSubscribeTv({ media_id: 'series-1', mediaid_prefix: 'custom', tmdb_id: undefined }),
mediaId: 'custom:series-1',
record: createSubscribe({ mediaid: 'custom:series-1', season: 2, tmdbid: 0, type: '电视剧' }),
},
])('queries $label subscriptions through the media endpoint', async ({ media, mediaId, record }) => {
const queried = vi.fn()
server.use(querySubscribeByMediaHandler(mediaId, record, 200, url => queried(url)))
await renderSubscribeHarness({ actionSeason: 2, media })
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('subscribed'))
expect(queried).toHaveBeenCalledOnce()
expect((queried.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
})
it('cancels a non-TMDB season through the media endpoint', async () => {
const media = createSubscribeTv({ douban_id: 'db-delete', tmdb_id: undefined })
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('douban:db-delete', { success: true }, 200, url => deleted(url)))
await renderSubscribeHarness({
actionSeason: 2,
isSubscribed: true,
media,
seasonsMap: { 2: true },
useSeasonMap: true,
})
await fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false')
})
it('aligns visible seasons while preserving hidden subscriptions', async () => {
const media = createSubscribeTv({ title: '多季剧集', tmdb_id: 105 })
const deleted = vi.fn()
const queried = vi.fn()
const updated = vi.fn()
const created = vi.fn()
server.use(
deleteSubscribeByMediaHandler('tmdb:105', { success: true }, 200, url => deleted(url)),
querySubscribeByMediaHandler(
'tmdb:105',
createSubscribe({ id: 605, season: 2, tmdbid: 105, type: '电视剧' }),
200,
url => queried(url),
),
updateSubscribeHandler({ success: true }, 200, updated),
createSubscribeHandler({ data: { id: 606 }, success: true }, 200, created),
)
await renderSubscribeHarness({
isSubscribed: true,
media,
modes: { 1: 'normal', 2: 'normal', 4: 'best_version' },
multi: {
modes: { 2: 'best_version', 3: 'best_version_full' },
seasons: [{ season_number: 2 }, { season_number: 3 }],
visible: [1, 2, 3],
},
subscribedSeasons: [1, 2, 4],
})
await fireEvent.click(screen.getByRole('button', { name: 'align-seasons' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
await waitFor(() => expect(created).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('1')
expect((queried.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
expect(updated.mock.calls[0][0]).toMatchObject({ best_version: 1, best_version_full: 0, id: 605, season: 2 })
expect(created.mock.calls[0][0]).toMatchObject({
best_version: 1,
best_version_full: 1,
episode_group: 'episode-group-1',
season: 3,
})
await waitFor(() => expect(screen.getByTestId('seasons')).toHaveTextContent('[2,3,4]'))
expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"')
expect(screen.getByTestId('modes')).toHaveTextContent('"3":"best_version_full"')
expect(screen.getByTestId('modes')).toHaveTextContent('"4":"best_version"')
})
it('synchronizes the created season after edit save and remove events', async () => {
const media = createSubscribeTv({ season: 2, title: '编辑后同步', tmdb_id: 106 })
server.use(
createSubscribeHandler({ data: { id: 701 }, success: true }),
defaultSubscribeConfigHandler('电视剧', { show_edit_dialog: true }),
)
await renderSubscribeHarness({
isSubscribed: true,
media,
modes: { 1: 'normal' },
subscribedSeasons: [1],
})
await fireEvent.click(screen.getByRole('button', { name: 'add-best-full' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
expect(screen.getByTestId('seasons')).toHaveTextContent('[1,2]')
const editDialog = getDialogCall()
expect(editDialog.props).toEqual({ subid: 701 })
editDialog.events.save(createSubscribe({ best_version: 1, best_version_full: 0, id: 701, season: 2, type: '电视剧' }))
await waitFor(() => expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"'))
editDialog.events.remove()
await waitFor(() => expect(screen.getByTestId('seasons')).toHaveTextContent('[1]'))
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(screen.getByTestId('modes')).not.toHaveTextContent('"2"')
expect(mocks.cacheStatus).toHaveBeenLastCalledWith('status:2', false)
expect(mocks.onEditRemove).toHaveBeenCalledOnce()
})
it.each([
['business failure', 200, { message: 'duplicate', success: false }],
['HTTP failure', 500, { message: 'server down', success: false }],
])('keeps state unchanged when create returns a %s', async (_case, status, response) => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
server.use(createSubscribeHandler(response, status))
await renderSubscribeHarness({ media: createSubscribeMovie({ tmdb_id: 107 }) })
await fireEvent.click(screen.getByRole('button', { name: 'add-normal' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
expect(screen.getByTestId('subscribed')).toHaveTextContent('false')
expect(mocks.cacheStatus).not.toHaveBeenCalled()
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
expect(mocks.doneProgress).toHaveBeenCalledOnce()
consoleError.mockRestore()
})
it.each([
['business failure', 200, { message: 'delete rejected', success: false }],
['HTTP failure', 500, { message: 'server down', success: false }],
])('keeps subscription state when removal returns a %s', async (_case, status, response) => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('tmdb:108', response, status, url => deleted(url)))
await renderSubscribeHarness({
actionSeason: 2,
isSubscribed: true,
media: createSubscribeTv({ season: 2, tmdb_id: 108 }),
modes: { 1: 'normal', 2: 'best_version' },
seasonsMap: { 1: true, 2: true },
subscribedSeasons: [1, 2],
useSeasonMap: true,
})
await fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(screen.getByTestId('seasons')).toHaveTextContent('[1,2]')
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":true')
expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"')
expect(mocks.cacheStatus).not.toHaveBeenCalled()
expect(mocks.doneProgress).toHaveBeenCalledOnce()
consoleError.mockRestore()
})
it.each([
['business failure', 200, { message: 'update rejected', success: false }],
['HTTP failure', 500, { message: 'server down', success: false }],
])('keeps the subscribed mode when an update returns a %s', async (_case, status, response) => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const media = createSubscribeTv({ title: '模式更新失败剧集', tmdb_id: 110 })
const updated = vi.fn()
server.use(
querySubscribeByMediaHandler(
'tmdb:110',
createSubscribe({ id: 710, season: 2, tmdbid: 110, type: '电视剧' }),
),
updateSubscribeHandler(response, status, updated),
)
await renderSubscribeHarness({
isSubscribed: true,
media,
modes: { 2: 'normal' },
multi: {
modes: { 2: 'best_version' },
seasons: [{ season_number: 2 }],
visible: [2],
},
subscribedSeasons: [2],
})
await fireEvent.click(screen.getByRole('button', { name: 'align-seasons' }))
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(screen.getByTestId('seasons')).toHaveTextContent('[2]')
expect(screen.getByTestId('modes')).toHaveTextContent('"2":"normal"')
expect(mocks.cacheStatus).not.toHaveBeenCalled()
expect(mocks.doneProgress).toHaveBeenCalledOnce()
consoleError.mockRestore()
})
it('maps a 404 query to missing and propagates other HTTP errors', async () => {
const media = createSubscribeMovie({ tmdb_id: 109 })
server.use(querySubscribeByMediaHandler('tmdb:109', {}, 404))
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('missing'))
server.use(querySubscribeByMediaHandler('tmdb:109', {}, 500))
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('error'))
})
it('does nothing when the current media is unavailable', async () => {
await renderSubscribeHarness()
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await fireEvent.click(screen.getByRole('button', { name: 'add-normal' }))
await fireEvent.click(screen.getByRole('button', { name: 'open-season' }))
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
expect(mocks.startProgress).not.toHaveBeenCalled()
})
})

View File

@@ -1,11 +1,26 @@
import { getCurrentInstance, onMounted, onUnmounted, ref, type Ref } from 'vue'
import { sseManagerSingleton, type SSEManagerOptions } from '@/utils/sseManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import { getCurrentLocale } from '@/plugins/i18n'
type UseSSEOptions = Partial<SSEManagerOptions> & {
connectDelay?: number
}
/** 为 SSE 请求补充当前前端语言,弥补 EventSource 不能设置自定义请求头的问题。 */
function appendLocaleParam(url: string) {
const locale = getCurrentLocale()
try {
const parsedUrl = new URL(url, window.location.origin)
parsedUrl.searchParams.set('locale', locale)
return parsedUrl.toString()
} catch {
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}locale=${encodeURIComponent(locale)}`
}
}
/**
* 后台任务组合函数
* 统一管理SSE连接和定时器减少后台常驻活动。
@@ -180,12 +195,15 @@ export function useBackground() {
listenerId: string,
isActive: Ref<boolean>,
) => {
const getManager = () =>
sseManagerSingleton.getIndependentManager(url, listenerId, {
let managerUrl = ''
const getManager = () => {
managerUrl = appendLocaleParam(url)
return sseManagerSingleton.getIndependentManager(managerUrl, listenerId, {
backgroundCloseDelay: 1000, // 进度SSE更快关闭
reconnectDelay: 1000,
maxReconnectAttempts: 5,
})
}
let manager: ReturnType<typeof getManager> | null = null
let isListening = false
@@ -207,8 +225,9 @@ export function useBackground() {
manager.removeMessageListener(listenerId)
if (destroyManager) {
sseManagerSingleton.closeIndependentManager(url, listenerId)
sseManagerSingleton.closeIndependentManager(managerUrl || appendLocaleParam(url), listenerId)
manager = null
managerUrl = ''
}
isListening = false

View File

@@ -0,0 +1,167 @@
import {
computed,
nextTick,
onActivated,
onMounted,
onUnmounted,
ref,
toValue,
watch,
type ComputedRef,
type MaybeRefOrGetter,
type Ref,
} from 'vue'
interface DashboardMediaGridCapacityOptions {
contentSelector?: string
gap?: number
horizontalPadding?: number
maxCount?: number
minItemWidth: number
rows?: MaybeRefOrGetter<number>
}
interface DashboardMediaGridCapacityState {
columnCount: ComputedRef<number>
containerRef: Ref<HTMLElement | null>
itemCount: ComputedRef<number>
refreshCapacity: () => void
}
const DEFAULT_DASHBOARD_MEDIA_GRID_GAP = 16
const DEFAULT_DASHBOARD_MEDIA_GRID_ROWS = 2
/**
* 根据仪表盘媒体卡片容器宽度和响应式行数计算请求数量。
*
* @param options 网格尺寸参数,需要与实际 ProgressiveCardGrid 参数保持一致
* @returns 容器引用、列数、请求数量和手动刷新方法
*/
export function useDashboardMediaGridCapacity(options: DashboardMediaGridCapacityOptions): DashboardMediaGridCapacityState {
const containerRef = ref<HTMLElement | null>(null)
const measuredWidth = ref(0)
let resizeObserver: ResizeObserver | null = null
let frameId: number | null = null
const safeGap = computed(() => Math.max(0, options.gap ?? DEFAULT_DASHBOARD_MEDIA_GRID_GAP))
const safeHorizontalPadding = computed(() => Math.max(0, options.horizontalPadding ?? 0))
const safeMaxCount = computed(() => Math.max(0, Math.floor(options.maxCount ?? Number.POSITIVE_INFINITY)))
const safeMinItemWidth = computed(() => Math.max(1, options.minItemWidth))
const safeRows = computed(() => {
const rows = options.rows === undefined ? DEFAULT_DASHBOARD_MEDIA_GRID_ROWS : toValue(options.rows)
return Math.max(1, Math.floor(rows))
})
const columnCount = computed(() => {
if (measuredWidth.value <= 0) return 0
return Math.max(1, Math.floor((measuredWidth.value + safeGap.value) / (safeMinItemWidth.value + safeGap.value)))
})
const itemCount = computed(() => {
if (columnCount.value <= 0) return 0
const count = columnCount.value * safeRows.value
return safeMaxCount.value > 0 ? Math.min(count, safeMaxCount.value) : count
})
/**
* 读取元素扣除内边距后的内容宽度。
*
* @param element 需要测量的元素
*/
function measureContentBoxWidth(element: HTMLElement) {
const style = window.getComputedStyle(element)
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0
const paddingRight = Number.parseFloat(style.paddingRight) || 0
return Math.max(0, element.clientWidth - paddingLeft - paddingRight)
}
/**
* 读取容器可用于网格布局的实际宽度。
*/
function measureContainerWidth() {
const element = containerRef.value
if (!element) return 0
const contentElement = options.contentSelector
? element.querySelector<HTMLElement>(options.contentSelector)
: null
if (contentElement) {
return measureContentBoxWidth(contentElement)
}
const elementWidth = element.getBoundingClientRect().width || element.clientWidth
return Math.max(0, elementWidth - safeHorizontalPadding.value)
}
/**
* 同步当前容器宽度到响应式状态。
*/
function refreshCapacity() {
measuredWidth.value = measureContainerWidth()
}
/**
* 合并同一帧内的尺寸变更,避免拖拽调整仪表盘时重复计算。
*/
function queueRefreshCapacity() {
if (typeof window === 'undefined' || frameId !== null) return
frameId = window.requestAnimationFrame(() => {
frameId = null
refreshCapacity()
})
}
/**
* 重新绑定当前容器的 ResizeObserver。
*/
function observeContainer() {
resizeObserver?.disconnect()
resizeObserver = null
if (typeof ResizeObserver !== 'undefined' && containerRef.value) {
resizeObserver = new ResizeObserver(queueRefreshCapacity)
resizeObserver.observe(containerRef.value)
}
queueRefreshCapacity()
}
watch(containerRef, observeContainer, { flush: 'post' })
onMounted(() => {
observeContainer()
window.addEventListener('resize', queueRefreshCapacity, { passive: true })
void nextTick(refreshCapacity)
})
onActivated(() => {
void nextTick(refreshCapacity)
})
onUnmounted(() => {
window.removeEventListener('resize', queueRefreshCapacity)
resizeObserver?.disconnect()
resizeObserver = null
if (frameId !== null) {
window.cancelAnimationFrame(frameId)
frameId = null
}
})
return {
columnCount,
containerRef,
itemCount,
refreshCapacity,
}
}

View File

@@ -12,7 +12,7 @@ import {
type ComputedRef,
type Ref,
} from 'vue'
import type { UserPermissionKey } from '@/utils/permission'
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
// 声明全局变量类型
declare global {
@@ -31,6 +31,7 @@ export interface DynamicButtonMenuItem {
icon?: string
color?: string
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
disabled?: boolean
action: () => void
}
@@ -61,11 +62,12 @@ export function useDynamicButton(options: {
onClick?: () => void
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
show?: MaybeRefValue<boolean>
autoRegister?: boolean // 是否自动注册默认为true
}) {
// 提取配置
const { icon, onClick, menuItems, permission, show, autoRegister = true } = options
const { icon, onClick, menuItems, permission, feature, show, autoRegister = true } = options
// 动态按钮相关
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
@@ -79,6 +81,7 @@ export function useDynamicButton(options: {
const resolvedShow = computed(() => resolveMaybeRef(show, true))
const resolvedMenuItems = computed(() => resolveMaybeRef(menuItems))
/** 根据当前响应式配置生成可注册的动态按钮对象。 */
function buildDynamicButton() {
const buttonMenuItems = resolvedMenuItems.value
@@ -86,12 +89,13 @@ export function useDynamicButton(options: {
icon: resolvedIcon.value,
action: onClick || (() => {}),
permission,
feature,
show: resolvedShow.value,
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
}
}
// 注册动态按钮
/** 在当前页面激活时注册动态按钮。 */
function setupDynamicButton() {
if (!componentActive.value) return
@@ -133,7 +137,7 @@ export function useDynamicButton(options: {
})
}
// 取消注册动态按钮
/** 清理当前页面注册过的动态按钮。 */
function cleanupDynamicButton() {
if (unregisterDynamicButton && dynamicButtonRegistered.value) {
unregisterDynamicButton()
@@ -147,7 +151,7 @@ export function useDynamicButton(options: {
}
}
// 暴露方法:手动打开对话框
/** 手动触发动态按钮主操作。 */
function openDialog() {
onClick?.()
}

View File

@@ -1,6 +1,6 @@
import type { ComputedRef, Ref } from 'vue'
import { useTabStateRestore } from '@/composables/useStateRestore'
import type { UserPermissionKey } from '@/utils/permission'
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
// 动态标签页相关类型
interface DynamicHeaderTabButton {
@@ -11,6 +11,7 @@ interface DynamicHeaderTabButton {
class?: string
action?: () => void
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string // 用于VMenu定位的data属性
@@ -20,6 +21,8 @@ interface DynamicHeaderTabItem {
title: string
icon?: string
tab: string
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
interface DynamicHeaderTabConfig {
@@ -30,6 +33,7 @@ interface DynamicHeaderTabConfig {
onUpdateModelValue?: (value: string) => void
}
/** 提供页面动态头部标签的注册、状态恢复和注销能力。 */
export function useDynamicHeaderTab() {
const route = useRoute()
@@ -37,7 +41,7 @@ export function useDynamicHeaderTab() {
const registerDynamicHeaderTab = inject<(tab: DynamicHeaderTabConfig) => void>('registerDynamicHeaderTab')
const unregisterDynamicHeaderTab = inject<() => void>('unregisterDynamicHeaderTab')
// 注册动态标签页
/** 注册当前页面的动态头部标签配置。 */
const registerHeaderTab = (config: {
items: DynamicHeaderTabItem[] | ComputedRef<DynamicHeaderTabItem[]> | Ref<DynamicHeaderTabItem[]>
modelValue: Ref<string>
@@ -184,7 +188,7 @@ export function useDynamicHeaderTab() {
})
}
// 取消注册
/** 取消当前页面注册的动态头部标签。 */
const unregisterHeaderTab = () => {
if (unregisterDynamicHeaderTab) {
unregisterDynamicHeaderTab()

View File

@@ -49,6 +49,7 @@ const SubscribeSeasonDialog = defineAsyncComponent(() => import('@/components/di
export type SeasonSubscribeModes = Record<number, SubscribeMode>
// 生成跨媒体源稳定的订阅媒体标识。
export function getMediaSubscribeId(media?: MediaInfo) {
if (media?.tmdb_id) return `tmdb:${media.tmdb_id}`
if (media?.douban_id) return `douban:${media.douban_id}`
@@ -56,6 +57,7 @@ export function getMediaSubscribeId(media?: MediaInfo) {
return `${media?.mediaid_prefix}:${media?.media_id}`
}
// 将订阅模式转换为后端订阅字段。
function getSubscribePayload(mode: SubscribeMode): SubscribePayload {
return {
best_version: mode === 'normal' ? 0 : 1,
@@ -63,16 +65,19 @@ function getSubscribePayload(mode: SubscribeMode): SubscribePayload {
}
}
// 兼容布尔值和数字、字符串形式的开关值。
function isEnabledFlag(value: unknown) {
return value === true || value === 1 || value === '1'
}
// 从订阅字段解析统一的订阅模式。
export function getSubscribeMode(subscribe: { best_version?: unknown; best_version_full?: unknown }): SubscribeMode {
if (!isEnabledFlag(subscribe.best_version)) return 'normal'
return isEnabledFlag(subscribe.best_version_full) ? 'best_version_full' : 'best_version'
}
// 从默认订阅配置解析订阅模式。
function getSubscribeConfigMode(config?: SubscribeConfig): SubscribeMode {
return getSubscribeMode({
best_version: config?.best_version,
@@ -80,30 +85,46 @@ function getSubscribeConfigMode(config?: SubscribeConfig): SubscribeMode {
})
}
// 获取订阅模式的本地化名称。
function getModeName(t: ReturnType<typeof useI18n>['t'], mode: SubscribeMode) {
if (mode === 'normal') return t('dialog.subscribeMode.normal')
if (mode === 'best_version') return t('dialog.subscribeMode.bestVersionEpisode')
return t('dialog.subscribeMode.bestVersionFull')
}
// 从变更请求异常中提取可展示消息,并为非标准错误提供稳定兜底。
function getRequestErrorMessage(error: unknown, fallback: string) {
if (typeof error === 'object' && error !== null) {
const responseMessage = (error as { response?: { data?: { message?: unknown } } }).response?.data?.message
if (typeof responseMessage === 'string' && responseMessage) return responseMessage
}
if (error instanceof Error && error.message) return error.message
return fallback
}
// 封装媒体卡片与详情页共用的订阅交互。
export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
const { t } = useI18n()
const $toast = useToast()
const createConfirm = useConfirm()
const episodeGroup = ref('')
// 获取调用方当前媒体,避免在异步流程中持有旧对象。
function currentMedia() {
return options.media()
}
// 获取当前媒体的统一订阅标识。
function getMediaId() {
return getMediaSubscribeId(currentMedia())
}
// 获取主订阅入口默认对应的季号。
function getPrimarySeason() {
return options.primarySeason?.() ?? currentMedia()?.season ?? null
}
// 同步调用方状态和订阅状态缓存。
function updateSubscribeStatus(season: number | null, subscribed: boolean, mode: SubscribeMode = 'normal') {
const media = currentMedia()
@@ -143,23 +164,27 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
}
}
function openSubscribeEditDialog(subid: number) {
// 打开已创建订阅的编辑弹窗。
function openSubscribeEditDialog(subid: number, season: number | null, mode: SubscribeMode) {
openSharedDialog(
SubscribeEditDialog,
{ subid },
{
save: (subscribe?: Subscribe) => {
const savedSeason = currentMedia()?.type === '电影' ? null : (subscribe?.season ?? season)
if (savedSeason !== season) updateSubscribeStatus(season, false)
updateSubscribeStatus(savedSeason, true, subscribe ? getSubscribeMode(subscribe) : mode)
},
remove: () => {
if (options.onEditRemove) {
options.onEditRemove()
} else if (options.isSubscribed) {
options.isSubscribed.value = false
}
updateSubscribeStatus(season, false)
options.onEditRemove?.()
},
},
{ closeOn: ['close', 'save', 'remove'] },
)
}
// 打开订阅模式选择弹窗并转换选择结果。
function openSubscribeModeDialog(
modes: SubscribeMode[],
choose: (payload: SubscribePayload, mode: SubscribeMode) => void,
@@ -174,7 +199,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
)
}
async function openSubscribeSeasonDialog(selectedSeason?: number | null) {
// 打开季订阅弹窗,并保留发起入口当前使用的剧集组。
async function openSubscribeSeasonDialog(selectedSeason?: number | null, initialEpisodeGroup = '') {
const media = currentMedia()
if (!media) return
const defaultSubscribeConfig = await queryDefaultSubscribeConfig()
@@ -184,6 +210,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
{
media,
selectedSeason,
initialEpisodeGroup,
subscribedSeasons: options.subscribedSeasons?.value ?? [],
subscribedSeasonModes: options.subscribedSeasonModes?.value ?? {},
defaultSubscribeMode: getSubscribeConfigMode(defaultSubscribeConfig),
@@ -195,6 +222,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
)
}
// 查询系统默认订阅配置。
async function queryDefaultSubscribeConfig(): Promise<SubscribeConfig | undefined> {
if (!options.canSubscribe()) return undefined
@@ -214,6 +242,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
return undefined
}
// 展示订阅新增结果通知。
function showSubscribeAddToast(
result: boolean,
title: string,
@@ -229,6 +258,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
else $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message })}`)
}
// 创建指定季和模式的订阅。
async function addSubscribe(
season: number | null = null,
payload: SubscribePayload = {},
@@ -252,21 +282,39 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
episode_group: episodeGroup.value,
})
if (result.success) updateSubscribeStatus(media.type === '电影' ? null : season, true, getSubscribeMode(payload))
const subscribeSeason = media.type === '电影' ? null : season
const subscribeMode = getSubscribeMode(payload)
if (result.success) updateSubscribeStatus(subscribeSeason, true, subscribeMode)
showSubscribeAddToast(result.success, media.title ?? '', season, result.message, payload.best_version ?? 0)
showSubscribeAddToast(
result.success,
media.title ?? '',
season,
result.message ?? t('subscribe.requestFailed'),
payload.best_version ?? 0,
)
if (result.success && (addOptions.openEditDialog ?? true)) {
const subscribeConfig = await queryDefaultSubscribeConfig()
if (subscribeConfig?.show_edit_dialog) openSubscribeEditDialog(result.data.id)
if (subscribeConfig?.show_edit_dialog && result.data?.id) {
openSubscribeEditDialog(result.data.id, subscribeSeason, subscribeMode)
}
}
} catch (error) {
console.error(error)
showSubscribeAddToast(
false,
media.title ?? '',
season,
getRequestErrorMessage(error, t('subscribe.requestFailed')),
payload.best_version ?? 0,
)
} finally {
doneNProgress()
}
}
// 删除指定季的订阅。
async function removeSubscribe(season: number | null = null, removeOptions: RemoveSubscribeOptions = {}) {
if (removeOptions.confirm ?? true) {
const confirmed = await createConfirm({
@@ -278,6 +326,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
const media = currentMedia()
if (!media) return
let title = media.title ?? ''
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
startNProgress()
try {
@@ -286,22 +336,26 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
season: media.type === '电影' ? null : season,
},
})
let title = media.title ?? ''
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
if (result.success) {
updateSubscribeStatus(media.type === '电影' ? null : season, false)
$toast.success(`${title} ${t('subscribe.cancelSuccess')}`)
} else {
$toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message })}`)
$toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message ?? t('subscribe.requestFailed') })}`)
}
} catch (error) {
console.error(error)
$toast.error(
`${title} ${t('subscribe.cancelFailed', {
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
})}`,
)
} finally {
doneNProgress()
}
}
// 检查当前媒体指定季是否已订阅。
async function checkSubscribe(season: number | null = null) {
try {
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
@@ -319,6 +373,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
}
}
// 查询当前媒体指定季的订阅记录。
async function querySubscribe(season: number | null = null) {
try {
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
@@ -336,9 +391,11 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
}
}
// 更新已有单季订阅的模式。
async function updateSubscribeMode(season: number, mode: SubscribeMode) {
const media = currentMedia()
if (!media) return
const title = `${media.title ?? ''} ${formatSeason(season.toString())}`
startNProgress()
try {
@@ -353,30 +410,42 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
...subscribe,
...payload,
})
const title = `${media.title ?? ''} ${formatSeason(season.toString())}`
if (result.success) {
updateSubscribeStatus(season, true, mode)
$toast.success(`${title} ${t('subscribe.modeUpdateSuccess', { mode: getModeName(t, mode) })}`)
} else {
$toast.error(`${title} ${t('subscribe.addFailed', { name: getModeName(t, mode), message: result.message })}`)
$toast.error(
`${title} ${t('subscribe.addFailed', {
name: getModeName(t, mode),
message: result.message ?? t('subscribe.requestFailed'),
})}`,
)
}
} catch (error) {
console.error(error)
$toast.error(
`${title} ${t('subscribe.addFailed', {
name: getModeName(t, mode),
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
})}`,
)
} finally {
doneNProgress()
}
}
function handleSeasonSubscribe(season: number) {
// 处理单季订阅入口,未订阅时将当前剧集组带入季选择弹窗。
function handleSeasonSubscribe(season: number, initialEpisodeGroup = '') {
if (options.seasonsSubscribed?.value[season]) {
removeSubscribe(season)
return
}
openSubscribeSeasonDialog(season)
openSubscribeSeasonDialog(season, initialEpisodeGroup)
}
// 处理媒体主订阅入口,电视剧统一进入季选择弹窗。
function handlePrimarySubscribe() {
const media = currentMedia()
if (!media) return
@@ -401,15 +470,17 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
addSubscribe(null)
}
function handleSubscribe(season?: number | null) {
// 根据是否指定季号分发主订阅或单季订阅操作。
function handleSubscribe(season?: number | null, initialEpisodeGroup = '') {
if (season !== undefined && season !== null) {
handleSeasonSubscribe(season)
handleSeasonSubscribe(season, initialEpisodeGroup)
return
}
handlePrimarySubscribe()
}
// 批量对齐弹窗中选择的季、订阅模式和当前订阅状态。
function subscribeSeasons(
seasons: MediaSeason[] = [],
seasonExistsStates: { [key: number]: number } = {},

View File

@@ -1,86 +1,82 @@
import { ref, computed } from 'vue'
import { computed, ref } from 'vue'
import { useOnline } from '@vueuse/core'
// 全局状态
const isAppOffline = ref(false)
const appOfflineReason = ref('')
const consecutiveNetworkErrors = ref(0)
const MAX_CONSECUTIVE_ERRORS = 3
export type ConnectionStatus = 'online' | 'checking' | 'offline'
export type ConnectionFailureReason = 'browser-offline' | 'network-error' | 'timeout' | 'server-unreachable'
// 全局离线状态管理
const browserOnline = useOnline()
const connectionStatus = ref<ConnectionStatus>('online')
const connectionReason = ref<ConnectionFailureReason | null>(null)
const connectionCheckRequestId = ref(0)
const serverSuccessSequence = ref(0)
/** 管理 MoviePilot 服务的全局连接状态。 */
export function useGlobalOfflineStatus() {
const isOnline = useOnline()
const isOnline = computed(() => connectionStatus.value === 'online')
const isChecking = computed(() => connectionStatus.value === 'checking')
const isOffline = computed(() => connectionStatus.value === 'offline')
const canPerformNetworkAction = computed(() => connectionStatus.value !== 'offline')
// 综合离线状态(网络离线 或 应用离线)
const isOffline = computed(() => !isOnline.value || isAppOffline.value)
// 是否可以执行网络操作
const canPerformNetworkAction = computed(() => isOnline.value && !isAppOffline.value)
// 设置应用离线状态
const setAppOffline = (offline: boolean, reason?: string) => {
isAppOffline.value = offline
appOfflineReason.value = reason || ''
// 如果设置为在线状态,重置连续错误计数
if (!offline) {
consecutiveNetworkErrors.value = 0
}
/** 记录任意 MoviePilot API 成功响应并恢复在线状态。 */
function markServerOnline() {
connectionStatus.value = 'online'
connectionReason.value = null
serverSuccessSequence.value += 1
}
// 记录网络错误
const recordNetworkError = (reason?: string) => {
consecutiveNetworkErrors.value++
// 只有连续出现三次网络错误时才设置为离线模式
if (consecutiveNetworkErrors.value >= MAX_CONSECUTIVE_ERRORS) {
setAppOffline(true, reason || `连续${MAX_CONSECUTIVE_ERRORS}次网络错误`)
}
/** 将连接状态标记为待确认,但不直接阻断页面操作。 */
function markConnectionChecking(reason?: ConnectionFailureReason) {
connectionStatus.value = 'checking'
if (reason) connectionReason.value = reason
}
// 重置连续错误计数
const resetConsecutiveErrors = () => {
consecutiveNetworkErrors.value = 0
/** 在权威探测失败后标记 MoviePilot 服务不可达。 */
function markServerOffline(reason: ConnectionFailureReason = 'server-unreachable') {
connectionStatus.value = 'offline'
connectionReason.value = reason
}
// 获取离线消息
const getOfflineMessage = () => {
if (!isOnline.value) {
return appOfflineReason.value
}
if (isAppOffline.value) {
return appOfflineReason.value
}
return ''
/** 将普通请求的网络错误降级为待确认状态,并请求一次去重后的服务探测。 */
function reportNetworkError(reason: ConnectionFailureReason = 'network-error') {
markConnectionChecking(reason)
connectionCheckRequestId.value += 1
}
/** 请求立即检查 MoviePilot 服务连接。 */
function requestConnectionCheck(reason?: ConnectionFailureReason) {
markConnectionChecking(reason)
connectionCheckRequestId.value += 1
}
return {
browserOnline,
connectionStatus,
connectionReason,
connectionCheckRequestId,
serverSuccessSequence,
isOnline,
isChecking,
isOffline,
canPerformNetworkAction,
setAppOffline,
recordNetworkError,
resetConsecutiveErrors,
getOfflineMessage,
consecutiveNetworkErrors: computed(() => consecutiveNetworkErrors.value),
markServerOnline,
markConnectionChecking,
markServerOffline,
reportNetworkError,
requestConnectionCheck,
}
}
// 单个组件的离线状态
export function useOfflineStatus(initialMessage?: string) {
const { isOnline, isOffline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
const message = computed(() => {
if (initialMessage) {
return initialMessage
}
return getOfflineMessage()
})
/** 为单个组件提供 MoviePilot 服务连接状态。 */
export function useOfflineStatus() {
const status = useGlobalOfflineStatus()
return {
isOnline,
isOffline,
canPerformNetworkAction,
message,
browserOnline: status.browserOnline,
isOnline: status.isOnline,
isChecking: status.isChecking,
isOffline: status.isOffline,
canPerformNetworkAction: status.canPerformNetworkAction,
connectionReason: status.connectionReason,
requestConnectionCheck: status.requestConnectionCheck,
}
}

View File

@@ -0,0 +1,106 @@
import api from '@/api'
import type { ApiResponse, ScheduleInfo, ScheduleProgress } from '@/api/types'
import { useBackground } from '@/composables/useBackground'
import type { Ref } from 'vue'
const SCHEDULE_PROGRESS_REFRESH_INTERVAL = 1000
const SCHEDULE_RUNNING_STATUS = '正在运行'
const SCHEDULE_WAITING_STATUS = '等待'
/** 判断定时服务是否仍在运行,兼容列表状态与进度详情两种后端信号。 */
export function isScheduleRunning(schedule: ScheduleInfo) {
return (
schedule.status === SCHEDULE_RUNNING_STATUS ||
schedule.progress_enable === true ||
schedule.progress_detail?.enable === true ||
schedule.progress_detail?.status === 'running'
)
}
/** 判断定时服务是否处于等待执行状态。 */
export function isScheduleWaiting(schedule: ScheduleInfo) {
return !isScheduleRunning(schedule) && schedule.status === SCHEDULE_WAITING_STATUS
}
/** 获取定时服务展示名称,优先使用后端按当前语言生成的文本。 */
export function getScheduleName(schedule: ScheduleInfo) {
return schedule.name_i18n || schedule.name || ''
}
/** 获取定时服务提供者展示名称,优先使用后端按当前语言生成的文本。 */
export function getScheduleProvider(schedule: ScheduleInfo) {
return schedule.provider_i18n || schedule.provider || ''
}
/** 获取定时服务状态展示文本,优先使用后端按当前语言生成的文本。 */
export function getScheduleStatusText(schedule: ScheduleInfo) {
return schedule.status_i18n || schedule.status || ''
}
/** 获取定时服务下次运行展示文本,优先使用后端按当前语言生成的文本。 */
export function getScheduleNextRunText(schedule: ScheduleInfo) {
return schedule.next_run_i18n || schedule.next_run || ''
}
/** 为定时服务列表提供仅针对运行中任务的实时进度轮询。 */
export function useScheduleProgress(schedules: Ref<ScheduleInfo[]>, refreshId: string) {
const { useDataRefresh } = useBackground()
const progressById = ref<Record<string, ScheduleProgress>>({})
/** 请求指定运行中任务的最新进度。 */
async function loadScheduleProgress(schedule: ScheduleInfo) {
const response = (await api.get(
`dashboard/schedule/${encodeURIComponent(schedule.id)}/progress`,
)) as ApiResponse<ScheduleProgress>
return response.success ? response.data : undefined
}
/** 刷新所有运行中任务的进度,并清理已经停止任务的缓存。 */
async function refreshRunningProgress() {
const runningSchedules = schedules.value.filter(schedule => schedule.id && isScheduleRunning(schedule))
const runningIds = new Set(runningSchedules.map(schedule => schedule.id))
const nextProgress = Object.fromEntries(Object.entries(progressById.value).filter(([id]) => runningIds.has(id)))
if (!runningSchedules.length) {
progressById.value = {}
return
}
const results = await Promise.allSettled(runningSchedules.map(loadScheduleProgress))
const currentRunningIds = new Set(
schedules.value.filter(schedule => schedule.id && isScheduleRunning(schedule)).map(schedule => schedule.id),
)
results.forEach((result, index) => {
const schedule = runningSchedules[index]
if (result.status === 'fulfilled' && result.value && currentRunningIds.has(schedule.id)) {
nextProgress[schedule.id] = result.value
}
})
progressById.value = Object.fromEntries(Object.entries(nextProgress).filter(([id]) => currentRunningIds.has(id)))
}
/** 获取任务当前应展示的百分比,并限制在合法范围内。 */
function getScheduleProgressValue(schedule: ScheduleInfo) {
const value = Number(progressById.value[schedule.id]?.value ?? schedule.progress ?? 0)
return Math.min(Math.max(value, 0), 100)
}
/** 获取任务当前应展示的进度说明。 */
function getScheduleProgressText(schedule: ScheduleInfo) {
const progress = progressById.value[schedule.id]
return progress?.text_i18n || progress?.text || schedule.progress_text_i18n || schedule.progress_text || ''
}
useDataRefresh(refreshId, refreshRunningProgress, SCHEDULE_PROGRESS_REFRESH_INTERVAL, true)
return {
getScheduleProgressText,
getScheduleProgressValue,
refreshRunningProgress,
}
}

View File

@@ -65,6 +65,7 @@ export interface WizardData {
baseUrlPreset: string
maxContextTokens: number
userAgent: string
temperature: number
audioInputProvider: string
audioInputApiKey: string
audioInputBaseUrl: string
@@ -253,6 +254,7 @@ const wizardData = ref<WizardData>({
baseUrlPreset: '',
maxContextTokens: 64,
userAgent: '',
temperature: 0.3,
audioInputProvider: 'openai',
audioInputApiKey: '',
audioInputBaseUrl: '',
@@ -1436,6 +1438,7 @@ export function useSetupWizard() {
// 保存智能助手设置
async function saveAgentSettings() {
try {
const agentTemperature = Number(wizardData.value.agent.temperature ?? 0.3)
const agentSettings = {
AI_AGENT_ENABLE: wizardData.value.agent.enabled,
AI_AGENT_GLOBAL: wizardData.value.agent.enabled ? wizardData.value.agent.global : false,
@@ -1452,6 +1455,7 @@ export function useSetupWizard() {
LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null,
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
LLM_USER_AGENT: wizardData.value.agent.userAgent || null,
LLM_TEMPERATURE: Number.isFinite(agentTemperature) ? agentTemperature : 0.3,
AUDIO_INPUT_PROVIDER: wizardData.value.agent.audioInputProvider || 'openai',
AUDIO_INPUT_API_KEY: wizardData.value.agent.audioInputApiKey || null,
AUDIO_INPUT_BASE_URL: wizardData.value.agent.audioInputBaseUrl || null,
@@ -1567,6 +1571,8 @@ export function useSetupWizard() {
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
wizardData.value.agent.userAgent = result.data.LLM_USER_AGENT || ''
const agentTemperature = Number(result.data.LLM_TEMPERATURE ?? 0.3)
wizardData.value.agent.temperature = Number.isFinite(agentTemperature) ? agentTemperature : 0.3
wizardData.value.agent.audioInputProvider = result.data.AUDIO_INPUT_PROVIDER || 'openai'
wizardData.value.agent.audioInputApiKey = result.data.AUDIO_INPUT_API_KEY || ''
wizardData.value.agent.audioInputBaseUrl = result.data.AUDIO_INPUT_BASE_URL || ''

View File

@@ -0,0 +1,158 @@
import type { Component } from 'vue'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useUserStore } from '@/stores'
import {
buildUserPermissionContext,
filterItemsByPermission,
hasItemPermission,
type PermissionProtectedItem,
} from '@/utils/permission'
import { useI18n } from 'vue-i18n'
const NameTestView = defineAsyncComponent(() => import('@/views/system/NameTestView.vue'))
const NetTestView = defineAsyncComponent(() => import('@/views/system/NetTestView.vue'))
const RuleTestView = defineAsyncComponent(() => import('@/views/system/RuleTestView.vue'))
const ModuleTestView = defineAsyncComponent(() => import('@/views/system/ModuleTestView.vue'))
const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vue'))
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue'))
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
// 定时服务在捷径与仪表板中共用的图标,避免两个入口的视觉语义漂移。
export const SCHEDULER_SHORTCUT_ICON = 'mdi-list-box'
export type ShortcutToolItem = PermissionProtectedItem & {
bodyClass?: string
cardClass?: string
component?: Component
customDialog?: Component
dialog: string
dialogSubtitle?: string
icon: string
maxWidth?: string
subtitle: string
title: string
titleText?: string
}
/** 提供顶部捷径与仪表板共用的工具定义和打开逻辑。 */
export function useShortcutTools() {
const { t } = useI18n()
const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const shortcuts: ShortcutToolItem[] = [
{
title: t('shortcut.recognition.title'),
subtitle: t('shortcut.recognition.subtitle'),
icon: 'mdi-text-recognition',
dialog: 'nameTest',
component: NameTestView,
maxWidth: '65rem',
titleText: t('shortcut.recognition.title'),
},
{
title: t('shortcut.rule.title'),
subtitle: t('shortcut.rule.subtitle'),
icon: 'mdi-filter-cog',
dialog: 'ruleTest',
component: RuleTestView,
maxWidth: '65rem',
titleText: t('shortcut.rule.subtitle'),
},
{
title: t('shortcut.log.title'),
subtitle: t('shortcut.log.subtitle'),
icon: 'mdi-file-document',
dialog: 'logging',
customDialog: ShortcutLogDialog,
},
{
title: t('shortcut.network.title'),
subtitle: t('shortcut.network.subtitle'),
icon: 'mdi-network',
dialog: 'netTest',
component: NetTestView,
titleText: t('shortcut.network.subtitle'),
},
{
title: t('shortcut.words.title'),
subtitle: t('shortcut.words.subtitle'),
icon: 'mdi-file-word-box',
dialog: 'words',
bodyClass: 'words-shortcut-dialog-body pa-0',
cardClass: 'words-shortcut-dialog-card',
component: WordsView,
maxWidth: '60rem',
titleText: t('shortcut.words.subtitle'),
},
{
title: t('shortcut.cache.title'),
subtitle: t('shortcut.cache.subtitle'),
icon: 'mdi-database',
dialog: 'cache',
bodyClass: 'cache-shortcut-dialog-body',
cardClass: 'cache-shortcut-dialog-card',
component: CacheView,
maxWidth: '90rem',
titleText: t('shortcut.cache.subtitle'),
},
{
title: t('shortcut.scheduler.title'),
subtitle: t('shortcut.scheduler.subtitle'),
icon: SCHEDULER_SHORTCUT_ICON,
dialog: 'scheduler',
bodyClass: 'scheduler-shortcut-dialog-body pa-0',
cardClass: 'scheduler-shortcut-dialog-card',
component: AccountSettingService,
maxWidth: '60rem',
titleText: t('shortcut.scheduler.subtitle'),
dialogSubtitle: t('setting.scheduler.subtitle'),
},
{
title: t('shortcut.system.title'),
subtitle: t('shortcut.system.subtitle'),
icon: 'mdi-cog',
dialog: 'systemTest',
bodyClass: 'system-health-dialog-body pa-0',
cardClass: 'system-health-dialog-card',
component: ModuleTestView,
titleText: t('shortcut.system.subtitle'),
},
].map(item => ({ ...item, permission: 'admin' }))
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
/** 打开工具对应的共享弹窗。 */
function openShortcutDialog(item: ShortcutToolItem) {
if (!hasItemPermission(item, userPermissions.value)) return
if (item.customDialog) {
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
return
}
if (!item.component) return
openSharedDialog(
ShortcutToolDialog,
{
bodyClass: item.bodyClass,
cardClass: item.cardClass,
icon: item.icon,
maxWidth: item.maxWidth ?? '35rem',
subtitle: item.dialogSubtitle,
title: item.titleText ?? item.title,
view: item.component,
},
{},
{ closeOn: ['close', 'update:modelValue'] },
)
}
return {
openShortcutDialog,
visibleShortcuts,
}
}

View File

@@ -1,24 +1,36 @@
import { computed, ref } from 'vue'
export type TransparencyGlassQuality = 'lightweight' | 'realtime'
export interface TransparencySettings {
backgroundBlur: number
backgroundPosterOpacity: number
blur: number
glassQuality: TransparencyGlassQuality
level: string
opacity: number
}
export const transparencyPresets = {
low: { opacity: 0.1, blur: 5 },
low: { opacity: 0.6, blur: 5 },
medium: { opacity: 0.3, blur: 10 },
high: { opacity: 0.6, blur: 15 },
high: { opacity: 0.1, blur: 15 },
}
export const TRANSPARENCY_SETTINGS_CHANGED_EVENT = 'transparency-settings-changed'
/** 将数值限制在指定范围内。 */
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
/** 读取玻璃质量档位,未知值回落到默认的轻量玻璃路径。 */
function readGlassQuality(): TransparencyGlassQuality {
const storedQuality = localStorage.getItem('transparency-glass-quality')
return storedQuality === 'realtime' ? 'realtime' : 'lightweight'
}
/** 从本地存储读取透明主题设置。 */
export function readTransparencySettings(): TransparencySettings {
return {
@@ -26,6 +38,7 @@ export function readTransparencySettings(): TransparencySettings {
blur: parseFloat(localStorage.getItem('transparency-blur') || '10'),
backgroundPosterOpacity: parseFloat(localStorage.getItem('transparency-background-poster-opacity') || '0'),
backgroundBlur: parseFloat(localStorage.getItem('transparency-background-blur') || '16'),
glassQuality: readGlassQuality(),
level: localStorage.getItem('transparency-level') || 'medium',
}
}
@@ -39,6 +52,7 @@ export function applyTransparencySettings(settings: TransparencySettings) {
? clamp(settings.backgroundPosterOpacity, 0, 1)
: 0,
backgroundBlur: Number.isFinite(settings.backgroundBlur) ? clamp(settings.backgroundBlur, 0, 30) : 16,
glassQuality: settings.glassQuality === 'realtime' ? 'realtime' : 'lightweight',
level: settings.level,
}
@@ -51,13 +65,20 @@ export function applyTransparencySettings(settings: TransparencySettings) {
root.style.setProperty('--transparent-blur-heavy', `${normalized.blur * 1.6}px`)
root.style.setProperty('--transparent-background-poster-opacity', (1 - normalized.backgroundPosterOpacity).toString())
root.style.setProperty('--transparent-background-blur', `${normalized.backgroundBlur}px`)
root.classList.toggle('transparent-blur-disabled', normalized.blur <= 0)
root.classList.toggle('transparent-background-blur-disabled', normalized.backgroundBlur <= 0)
root.classList.toggle('transparent-glass-lightweight', normalized.glassQuality === 'lightweight')
root.classList.toggle('transparent-glass-realtime', normalized.glassQuality === 'realtime')
localStorage.setItem('transparency-opacity', normalized.opacity.toString())
localStorage.setItem('transparency-blur', normalized.blur.toString())
localStorage.setItem('transparency-background-poster-opacity', normalized.backgroundPosterOpacity.toString())
localStorage.setItem('transparency-background-blur', normalized.backgroundBlur.toString())
localStorage.setItem('transparency-glass-quality', normalized.glassQuality)
localStorage.setItem('transparency-level', normalized.level)
window.dispatchEvent(new CustomEvent<TransparencySettings>(TRANSPARENCY_SETTINGS_CHANGED_EVENT, { detail: normalized }))
return normalized
}
@@ -73,6 +94,7 @@ export function useTransparencySettings() {
const transparencyBlur = ref(storedSettings.blur)
const backgroundPosterOpacity = ref(storedSettings.backgroundPosterOpacity)
const backgroundBlur = ref(storedSettings.backgroundBlur)
const transparencyGlassQuality = ref<TransparencyGlassQuality>(storedSettings.glassQuality)
const transparencyLevel = ref(storedSettings.level)
const currentPresetLevel = computed(() => {
@@ -95,6 +117,7 @@ export function useTransparencySettings() {
blur: transparencyBlur.value,
backgroundPosterOpacity: backgroundPosterOpacity.value,
backgroundBlur: backgroundBlur.value,
glassQuality: transparencyGlassQuality.value,
level: transparencyLevel.value,
})
@@ -102,6 +125,7 @@ export function useTransparencySettings() {
transparencyBlur.value = normalized.blur
backgroundPosterOpacity.value = normalized.backgroundPosterOpacity
backgroundBlur.value = normalized.backgroundBlur
transparencyGlassQuality.value = normalized.glassQuality
transparencyLevel.value = normalized.level
}
@@ -149,12 +173,18 @@ export function useTransparencySettings() {
syncTransparencySettings()
}
/** 处理玻璃质量档位变化。 */
function onGlassQualityChange() {
syncTransparencySettings()
}
/** 重置透明主题设置为默认值。 */
function resetTransparencySettings() {
transparencyOpacity.value = transparencyPresets.medium.opacity
transparencyBlur.value = transparencyPresets.medium.blur
backgroundPosterOpacity.value = 0
backgroundBlur.value = 16
transparencyGlassQuality.value = 'lightweight'
transparencyLevel.value = 'medium'
syncTransparencySettings()
}
@@ -167,10 +197,12 @@ export function useTransparencySettings() {
onBackgroundBlurChange,
onBackgroundPosterOpacityChange,
onBlurChange,
onGlassQualityChange,
onOpacityChange,
resetTransparencySettings,
syncTransparencySettings,
transparencyBlur,
transparencyGlassQuality,
transparencyOpacity,
transparencyLevel,
}

View File

@@ -4,7 +4,14 @@ import DefaultLayout from './default/components/DefaultLayout.vue'
const route = useRoute()
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
const routeCacheKey = computed(() => route.meta.keepAliveKey?.toString() || route.path)
const routeCacheKey = computed(() => {
if (route.meta.keepAliveKey) return route.meta.keepAliveKey.toString()
// 部分列表页的 query 会参与接口参数,缓存 key 需要保留完整路由避免串用旧数据。
if (route.meta.keepAliveByFullPath) return route.fullPath
return route.path
})
// 页面过渡按实际页面身份触发keep-alive 页面避免 query 变化时反复入场。
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))

View File

@@ -24,6 +24,7 @@ import {
filterMenusByPermission,
hasItemPermission,
hasPermission,
type UserPermissionFeatureKey,
type UserPermissionKey,
} from '@/utils/permission'
import { usePullDownGesture } from '@/composables/usePullDownGesture'
@@ -107,7 +108,7 @@ const mainContentPaddingTop = computed(() => {
const showPluginQuickAccess = ref(false)
// 离线状态管理
const { setAppOffline, isOffline } = useGlobalOfflineStatus()
const { isOffline } = useGlobalOfflineStatus()
// 动态标签页相关
// 定义动态标签页类型
@@ -119,6 +120,7 @@ interface DynamicHeaderTabButton {
class?: string
action?: () => void
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string
@@ -128,6 +130,8 @@ interface DynamicHeaderTabItem {
title: string
icon?: string
tab: string
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
interface DynamicHeaderTab {
@@ -143,7 +147,7 @@ const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
const openHorizontalNavGroup = ref<string | null>(null)
const pendingHorizontalTab = ref<{ path: string; tab: string } | null>(null)
// 提供一个方法让其他组件注册动态标签页
/** 注册页面动态标签页,并在页面可用后应用待切换的水平导航标签。 */
const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
// 保存注册标签页的路由路径
tab.routePath = route.path
@@ -152,14 +156,16 @@ const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
applyPendingHorizontalTab()
}
// 提供一个方法让其他组件取消注册动态标签页
/** 取消当前页面注册动态标签页。 */
const unregisterDynamicHeaderTab = () => {
dynamicHeaderTab.value = null
}
// 标签页值更新处理
/** 更新当前动态标签页选中值,并通知注册页面同步状态。 */
const handleTabChange = (newValue: string) => {
if (dynamicHeaderTab.value) {
if (!visibleDynamicHeaderTabItems.value.some(item => item.tab === newValue)) return
dynamicHeaderTab.value.modelValue = newValue
// 通知注册的页面更新值
if (dynamicHeaderTab.value.onUpdateModelValue) {
@@ -193,13 +199,15 @@ watch(
{ immediate: false },
)
// 当前路由是否注册了动态标签页。
const hasDynamicHeaderTab = computed(() => {
return (
dynamicHeaderTab.value && dynamicHeaderTab.value.items.length > 0 && dynamicHeaderTab.value.routePath === route.path
)
const visibleDynamicHeaderTabItems = computed(() => {
if (!dynamicHeaderTab.value || dynamicHeaderTab.value.routePath !== route.path) return []
return filterItemsByPermission(dynamicHeaderTab.value.items, userPermissions.value)
})
// 当前路由是否注册了动态标签页。
const hasDynamicHeaderTab = computed(() => visibleDynamicHeaderTabItems.value.length > 0)
// 水平布局下动态标签页会并入顶部导航三级菜单,不再额外显示标签页栏。
const showDynamicHeaderTab = computed(() => hasDynamicHeaderTab.value && !showHorizontalThemeNav.value)
@@ -227,18 +235,7 @@ onUnmounted(() => {
}
})
// 监听Service Worker消息
const handleServiceWorkerMessage = (event: MessageEvent) => {
if (event.data && event.data.type === 'OFFLINE_STATUS') {
if (event.data.offline) {
setAppOffline(true, t('common.serverConnectionFailed'))
} else {
setAppOffline(false)
}
}
}
// 检查是否可以使用下拉手势
/** 判断当前页面状态是否允许使用主界面下拉快捷入口手势。 */
const canUsePullGesture = () => {
// 检查是否在dashboard页面
const isDashboard = route.path === '/dashboard' || route.path === '/'
@@ -279,7 +276,7 @@ const {
},
})
// 根据分类获取菜单列表
/** 根据菜单分组标题获取当前用户可见的菜单项。 */
const getMenuList = (header: string) => {
// 使用国际化菜单
const menus = getNavMenus(t)
@@ -287,19 +284,22 @@ const getMenuList = (header: string) => {
return filteredMenus.filter((item: NavMenu) => item.header === header)
}
// 返回上一页
/** 返回浏览历史中的上一页。 */
function goBack() {
history.back()
}
/** 同步主题定制器变更后的布局模式。 */
function handleThemeCustomizerChange(event: Event) {
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
}
/** 打开主题定制器面板。 */
function handleThemeCustomizerOpen() {
showThemeCustomizer.value = true
}
/** 判断水平导航菜单项是否匹配当前路由。 */
function isHorizontalNavActive(item: NavMenu) {
const targetPath = normalizeMenuPath(item.to)
if (!targetPath) return false
@@ -309,18 +309,22 @@ function isHorizontalNavActive(item: NavMenu) {
return currentPath === targetPath || currentPath.startsWith(`${targetPath}/`)
}
/** 判断水平导航分组内是否存在当前路由激活项。 */
function isHorizontalNavGroupActive(group: { items: NavMenu[] }) {
return group.items.some(isHorizontalNavActive)
}
/** 判断菜单项是否存在可在水平导航中展示的动态标签。 */
function hasHorizontalDynamicTabs(item: NavMenu) {
return showHorizontalThemeNav.value && getHorizontalNavTabs(item).length > 0
}
/** 判断水平导航动态标签是否为当前选中项。 */
function isHorizontalDynamicTabActive(tab: DynamicHeaderTabItem) {
return dynamicHeaderTab.value?.modelValue === tab.tab
}
/** 切换水平导航中的动态标签,必要时先跳转到目标页面。 */
async function handleHorizontalDynamicTabSelect(item: NavMenu, tab: DynamicHeaderTabItem) {
const targetPath = normalizeMenuPath(item.to)
const currentPath = normalizeMenuPath(route.path)
@@ -336,28 +340,34 @@ async function handleHorizontalDynamicTabSelect(item: NavMenu, tab: DynamicHeade
openHorizontalNavGroup.value = null
}
/** 关闭当前展开的水平导航分组菜单。 */
function closeHorizontalNavGroup() {
openHorizontalNavGroup.value = null
}
/** 读取可能是 Ref 的值,空值时回落到默认值。 */
function resolveMaybeRefValue<T>(value: T | ComputedRef<T> | undefined, fallback: T): T {
return isRef(value) ? value.value : (value ?? fallback)
}
/** 解析动态头部按钮颜色。 */
function resolveHeaderButtonColor(button: DynamicHeaderTabButton) {
return resolveMaybeRefValue(button.color, 'gray')
}
/** 解析动态头部按钮加载状态。 */
function resolveHeaderButtonLoading(button: DynamicHeaderTabButton) {
return resolveMaybeRefValue(button.loading, false)
}
/** 校验权限后执行动态头部按钮动作。 */
function handleHeaderButtonClick(button: DynamicHeaderTabButton) {
if (!hasItemPermission(button, userPermissions.value)) return
button.action?.()
}
/** 获取水平导航动态标签图标,不可渲染时使用默认圆点图标。 */
function getHorizontalTabIcon(tab: DynamicHeaderTabItem) {
const icon = tab.icon?.trim()
@@ -370,45 +380,49 @@ function getHorizontalTabIcon(tab: DynamicHeaderTabItem) {
return icon
}
/** 标准化菜单路径,移除末尾斜杠并保留根路径。 */
function normalizeMenuPath(value: unknown) {
if (typeof value !== 'string') return ''
return value.replace(/\/$/, '') || '/'
}
/** 获取水平导航菜单项可展示的标签列表。 */
function getHorizontalNavTabs(item: NavMenu): DynamicHeaderTabItem[] {
const targetPath = normalizeMenuPath(item.to)
if (targetPath && isHorizontalNavActive(item) && hasDynamicHeaderTab.value) {
return dynamicHeaderTab.value?.items ?? []
return visibleDynamicHeaderTabItems.value
}
return item.tabs ?? []
return filterItemsByPermission(item.tabs ?? [], userPermissions.value)
}
/** 在目标页面注册动态标签后应用此前暂存的标签切换。 */
function applyPendingHorizontalTab() {
if (!pendingHorizontalTab.value || !hasDynamicHeaderTab.value) return
const pending = pendingHorizontalTab.value
if (normalizeMenuPath(route.path) !== pending.path) return
const tabExists = dynamicHeaderTab.value?.items.some(item => item.tab === pending.tab)
const tabExists = visibleDynamicHeaderTabItems.value.some(item => item.tab === pending.tab)
if (!tabExists) return
handleTabChange(pending.tab)
pendingHorizontalTab.value = null
}
// 关闭插件快速访问
/** 关闭插件快速访问面板。 */
function handleClosePluginQuickAccess() {
showPluginQuickAccess.value = false
}
// 点击插件后关闭
/** 点击插件入口后关闭插件快速访问面板。 */
function handlePluginClick() {
showPluginQuickAccess.value = false
}
/** 将插件侧边栏菜单合并到对应的导航分组中。 */
function appendPluginSidebarMenus() {
for (const { navMenu, section } of filterPluginSidebarNavEntries(
pluginSidebarNavStore.items,
@@ -451,18 +465,10 @@ onMounted(async () => {
await pluginSidebarNavStore.ensureSidebarNav()
appendPluginSidebarMenus()
// 监听Service Worker消息
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
}
// 组件卸载时清理监听
onBeforeUnmount(() => {
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
}
})
})
</script>
@@ -474,15 +480,15 @@ onMounted(async () => {
<!-- 👉 Pull Down Indicator -->
<div
v-if="appMode && showPullIndicator"
class="pull-indicator"
class="app-pull-indicator"
:style="{
'--pull-indicator-navbar-extra-height': navbarExtraHeight,
'--app-pull-indicator-navbar-extra-height': navbarExtraHeight,
opacity: indicatorOpacity,
transform: indicatorTransform,
}"
>
<div
class="indicator-icon"
class="app-pull-indicator__icon"
:style="{
transform: `scale(${
1 + Math.min((pullDistance - PULL_CONFIG.SHOW_INDICATOR) / PULL_CONFIG.MAX_PULL_DISTANCE, 0.5) * 0.3
@@ -523,8 +529,8 @@ onMounted(async () => {
class="theme-navbar-actions d-flex align-center"
:class="{ 'theme-navbar-actions--horizontal': showHorizontalThemeNav }"
>
<!-- 👉 Horizontal Search Icon -->
<SearchBar v-if="showHorizontalThemeNav" icon-only />
<!-- 👉 Horizontal Search Bar -->
<SearchBar v-if="showHorizontalThemeNav" />
<!-- 👉 Shortcuts -->
<ShortcutBar v-if="canAdmin" />
<!-- 👉 Notification -->
@@ -675,7 +681,7 @@ onMounted(async () => {
<template #dynamic-header-tab>
<div v-if="showDynamicHeaderTab">
<HeaderTab
:items="dynamicHeaderTab!.items"
:items="visibleDynamicHeaderTabItems"
:model-value="dynamicHeaderTab!.modelValue"
@update:model-value="handleTabChange"
>
@@ -847,72 +853,4 @@ onMounted(async () => {
margin-inline-start: auto;
}
.pull-indicator {
position: fixed;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 50%;
backdrop-filter: blur(20px);
background: rgba(var(--v-theme-surface), 0.3);
box-shadow:
0 1px 2px rgba(0, 0, 0, 10%),
0 1px 3px rgba(0, 0, 0, 6%);
inset-block-start: calc(
env(safe-area-inset-top, 0px) + 4rem + var(--pull-indicator-navbar-extra-height, 0rem) + 0.75rem
);
inset-inline-start: 50%;
pointer-events: none;
transform: translate3d(-50%, 0, 0);
transition:
opacity 0.2s ease,
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
will-change: opacity, transform;
}
.indicator-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(var(--v-theme-primary), 0.08);
block-size: 40px;
inline-size: 40px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 透明主题适配 */
html[class*='transparent'] .pull-indicator,
html[class*='mica'] .pull-indicator,
html[class*='acrylic'] .pull-indicator {
border: 1px solid rgba(255, 255, 255, 20%);
background: rgba(255, 255, 255, 95%);
box-shadow:
0 8px 32px rgba(0, 0, 0, 12%),
0 4px 16px rgba(0, 0, 0, 8%);
}
html[class*='transparent'] .indicator-icon,
html[class*='mica'] .indicator-icon,
html[class*='acrylic'] .indicator-icon {
background: rgba(var(--v-theme-primary), 0.12);
}
html[data-theme='dark'][class*='transparent'] .pull-indicator,
html[data-theme='dark'][class*='mica'] .pull-indicator,
html[data-theme='dark'][class*='acrylic'] .pull-indicator {
border: 1px solid rgba(255, 255, 255, 10%);
background: rgba(18, 18, 18, 95%);
box-shadow:
0 8px 32px rgba(0, 0, 0, 30%),
0 4px 16px rgba(0, 0, 0, 20%);
}
html[data-theme='dark'][class*='transparent'] .indicator-icon,
html[data-theme='dark'][class*='mica'] .indicator-icon,
html[data-theme='dark'][class*='acrylic'] .indicator-icon {
background: rgba(var(--v-theme-primary), 0.15);
}
</style>

View File

@@ -119,6 +119,7 @@ interface DynamicButton {
icon: string
action: () => void
permission?: DynamicButtonMenuItem['permission']
feature?: DynamicButtonMenuItem['feature']
show: boolean
routePath?: string // 添加路径属性,用于标识哪个路由注册的
menuItems?: DynamicButtonMenuItem[]

View File

@@ -1,66 +1,64 @@
<script setup lang="ts">
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
import { useToast } from 'vue-toastification'
const OfflineStatusDialog = defineAsyncComponent(() => import('@/components/dialog/OfflineStatusDialog.vue'))
const { t } = useI18n()
const toast = useToast()
const { connectionStatus, connectionReason } = useGlobalOfflineStatus()
const shownConnectionPromptKeys = new Set<string>()
interface Props {
type?: 'offline' | 'online'
}
const props = withDefaults(defineProps<Props>(), {
type: 'offline',
const isChecking = computed(() => connectionStatus.value === 'checking')
const statusTitle = computed(() => (isChecking.value ? t('app.connectionChecking') : t('app.serviceUnavailable')))
const statusMessage = computed(() => {
if (connectionReason.value === 'browser-offline') return t('app.browserOfflineMessage')
if (connectionReason.value === 'timeout') return t('app.serviceTimeoutMessage')
if (isChecking.value) return t('app.connectionCheckingMessage')
return t('app.serviceUnavailableMessage')
})
const { canPerformNetworkAction } = useGlobalOfflineStatus()
let offlineDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 拼接离线状态提示文案,供 Toast 或 Agent 助手气泡展示。 */
function buildConnectionPromptMessage() {
return `${statusTitle.value}${statusMessage.value}`
}
/** 打开离线状态共享弹窗。 */
function showOfflineDialog() {
if (offlineDialogController) {
offlineDialogController.updateProps({ type: props.type })
/** 根据当前连接状态选择 Toast 级别Agent 助手可用时会由全局 Toast 路由接管。 */
function showConnectionPrompt() {
const message = buildConnectionPromptMessage()
const options = {
timeout: isChecking.value ? 5000 : 7000,
}
if (isChecking.value) {
toast.warning(message, options)
return
}
offlineDialogController = openSharedDialog(
OfflineStatusDialog,
{
type: props.type,
},
{},
{ closeOn: false },
)
toast.error(message, options)
}
/** 关闭离线状态共享弹窗。 */
function closeOfflineDialog() {
offlineDialogController?.close()
offlineDialogController = null
/** 在同一轮连接异常内按状态去重提示,并在恢复在线后允许下一轮提示重新出现。 */
function handleConnectionStatusChange() {
if (connectionStatus.value === 'online') {
shownConnectionPromptKeys.clear()
return
}
const promptKey =
connectionStatus.value === 'checking'
? connectionStatus.value
: `${connectionStatus.value}:${connectionReason.value || 'unknown'}`
if (shownConnectionPromptKeys.has(promptKey)) return
shownConnectionPromptKeys.add(promptKey)
showConnectionPrompt()
}
watch(
() => canPerformNetworkAction.value,
canPerform => {
if (canPerform) {
closeOfflineDialog()
return
}
showOfflineDialog()
},
{ immediate: true },
)
watch(
() => props.type,
() => {
offlineDialogController?.updateProps({ type: props.type })
},
)
onUnmounted(() => {
closeOfflineDialog()
watch([connectionStatus, connectionReason], handleConnectionStatusChange, {
flush: 'post',
})
</script>
<template></template>
<template>
<span class="d-none" aria-hidden="true" />
</template>

View File

@@ -1,109 +1,31 @@
<script lang="ts" setup>
import * as Mousetrap from 'mousetrap'
import SearchBarDialog from '@/components/dialog/SearchBarDialog.vue'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const props = withDefaults(
defineProps<{
iconOnly?: boolean
}>(),
{
iconOnly: false,
},
)
const searchOpen = ref(false)
const searchBar = ref<InstanceType<typeof SearchBarDialog> | null>(null)
const display = useDisplay()
const { t } = useI18n()
// 注册快捷键
Mousetrap.bind(['command+k', 'ctrl+k'], openSearchDialog)
/** 打开全局共享搜索弹窗。 */
function openSearchDialog() {
openSharedDialog(SearchBarDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
/** 打开全局搜索,并在桌面端聚焦常驻搜索输入框。 */
function openSearch() {
searchOpen.value = true
nextTick(() => searchBar.value?.focusSearchInput())
return false
}
/** 检测操作系统是否是 Mac。 */
function isMac() {
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
/** 关闭当前搜索浮层。 */
function closeSearch() {
searchOpen.value = false
}
// 计算属性:根据操作系统显示不同的按键提示
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
const showIconOnly = computed(() => props.iconOnly || !display.mdAndUp.value)
onMounted(() => {
Mousetrap.bind(['command+k', 'ctrl+k'], openSearch)
})
onBeforeUnmount(() => {
Mousetrap.unbind(['command+k', 'ctrl+k'])
})
</script>
<template>
<!-- 小屏或水平导航右侧工具区仅显示搜索图标 -->
<IconBtn
v-if="showIconOnly"
class="search-icon-trigger"
:aria-label="t('dialog.searchBar.openSearch')"
@click="openSearchDialog"
>
<VIcon class="search-icon-trigger__icon" icon="mdi-magnify" />
</IconBtn>
<!-- 中屏及以上与用户头像同尺寸的搜索入口 -->
<VTooltip v-else :text="`${t('dialog.searchBar.openSearch')} (${metaKey})`">
<template #activator="{ props: tooltipProps }">
<button
v-bind="tooltipProps"
class="search-trigger"
type="button"
:aria-label="t('dialog.searchBar.openSearch')"
@click="openSearchDialog"
>
<VIcon icon="mdi-magnify" size="24" class="search-trigger-icon" />
</button>
</template>
</VTooltip>
<SearchBarDialog ref="searchBar" v-model="searchOpen" show-activator @close="closeSearch" />
</template>
<style scoped>
.search-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 50%;
background: rgba(var(--v-theme-surface), 0.44);
block-size: 40px;
color: rgba(var(--v-theme-on-surface), 0.72);
cursor: pointer;
flex: 0 0 auto;
inline-size: 40px;
padding: 0;
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.18);
background-color: rgba(var(--v-theme-surface), 0.62);
box-shadow: 0 4px 14px rgba(0, 0, 0, 6%);
}
.search-trigger-icon {
flex-shrink: 0;
}
html[data-theme='transparent'] .search-trigger,
.v-theme--transparent .search-trigger {
backdrop-filter: none;
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
}
.search-icon-trigger {
flex: 0 0 auto;
}
.search-icon-trigger__icon {
transform: scaleX(-1);
}
</style>

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