Compare commits

..

408 Commits
v2.14.3 ... v3

Author SHA1 Message Date
jxxghp
bb25732cfc feat(music): 完善歌词设置与专辑整理 2026-08-25 21:02:36 +08:00
InfinityPacer
dfa15ab489 perf(dashboard): stabilize automatic grid sizing (#720) 2026-08-25 18:20:21 +08:00
jxxghp
d28fab0fe5 feat(filter): 支持音乐优先级规则组 2026-08-25 16:33:21 +08:00
InfinityPacer
746d6b945c style(settings): format database backup panel (#719) 2026-08-25 13:37:05 +08:00
InfinityPacer
5b5daf26d0 fix(download): confirm destructive task deletion (#718) 2026-08-25 13:36:41 +08:00
jxxghp
618c56461a fix(auth): 静默处理会话失效错误 2026-08-25 13:05:25 +08:00
InfinityPacer
94eac98207 feat: 完善数据库备份管理界面 (#717)
* feat(settings): add database backup management

* feat(settings): complete database backup controls
2026-08-25 13:00:10 +08:00
InfinityPacer
62b2eae120 fix(shortcuts): scope dynamic close listeners (#716) 2026-08-25 12:11:47 +08:00
InfinityPacer
99d306fa96 fix(agent): identify assistant input controls (#715) 2026-08-25 12:11:02 +08:00
InfinityPacer
fcddcda4b9 fix: 修正下载器卡片属性透传 (#714) 2026-08-25 12:10:33 +08:00
InfinityPacer
852c682278 docs: refresh frontend quality baselines (#713) 2026-08-25 12:10:07 +08:00
jxxghp
c2b21f7e23 fix(discover): default music chart to albums 2026-08-24 22:32:45 +08:00
InfinityPacer
f6a5d1dfa0 test(workflow): cover dynamic action options (#712) 2026-08-24 21:58:34 +08:00
InfinityPacer
cf61a42749 test(workflow): cover plugin action configuration (#711) 2026-08-24 21:46:51 +08:00
jxxghp
3a45d7e9de feat(system): add release update prompt 2026-08-24 17:58:03 +08:00
InfinityPacer
486704c6d2 fix(workflow): recover shared list requests (#709) 2026-08-24 17:27:52 +08:00
InfinityPacer
c55e97b74e feat: 展示 V3t 运行时状态并锁定 Rust 加速 (#710)
* feat(settings): warn when V3t enables GIL

* feat(runtime): expose V3t status in the interface
2026-08-24 17:27:32 +08:00
InfinityPacer
7c02bf7c5c fix(workflow): align share response contracts (#708) 2026-08-24 11:26:00 +08:00
InfinityPacer
30b5c6445a feat(settings): expose required Rust acceleration (#707) 2026-08-24 10:46:26 +08:00
InfinityPacer
b8214c7f7a fix(workflow): 收口侧栏拖放生命周期 (#706)
* fix(workflow): guard sidebar drag lifecycle

* test: isolate music progress side effects
2026-08-24 10:45:48 +08:00
InfinityPacer
c8b1047923 fix: 隔离工作流编辑草稿并保留动作契约 (#705) 2026-08-24 10:22:37 +08:00
InfinityPacer
058b8f2299 test: 统一前端测试夹具与发布工具链 (#704) 2026-08-24 06:43:36 +08:00
InfinityPacer
5847f9e936 test(workflow): cover workflow form contracts (#703) 2026-08-24 06:43:21 +08:00
InfinityPacer
72dc35ff41 fix(workflow): recover list refresh failures (#702) 2026-08-23 21:04:13 +08:00
InfinityPacer
f2ecc6af73 fix: prevent background timer registration after stop (#701) 2026-08-23 19:22:50 +08:00
InfinityPacer
cec2169c13 fix: preserve background timer lifecycle on replacement (#700) 2026-08-23 18:48:27 +08:00
InfinityPacer
abdf1bf29c fix: preserve pending KeepAlive refresh context (#699) 2026-08-23 18:25:58 +08:00
InfinityPacer
ca2f813810 test(vuetify): cover responsive input adapters (#698) 2026-08-23 17:54:50 +08:00
InfinityPacer
5277f148a1 fix(dashboard): reconcile plugin dashboard lifecycle (#697) 2026-08-23 17:03:04 +08:00
jxxghp
17d4dbed1d fix: 使用钉钉官方通知图标 2026-08-22 13:39:23 +08:00
jxxghp
2d02106a7b feat(notification): configure DingTalk robot channel 2026-08-22 12:02:24 +08:00
jxxghp
3455d91f4b feat(download): add media type and source selection 2026-08-22 11:53:55 +08:00
jxxghp
95bdeb6ebf feat(settings): configure transfer failure aggregation 2026-08-22 11:46:41 +08:00
jxxghp
19e8e06798 feat(plugin): scope virtual instance frontend 2026-08-22 09:40:38 +08:00
InfinityPacer
9b747c67a9 fix(plugins): reconcile runtime changes after tab switch (#695) 2026-08-21 18:29:23 +08:00
InfinityPacer
ea6d0c8178 fix: 完善插件运行态与安装交互 (#694) 2026-08-21 18:10:51 +08:00
sokouu87
4f1c40af12 feat: 记住资源搜索结果的排序选择 (#691) 2026-08-21 06:21:05 +08:00
InfinityPacer
3d1361281d perf: 并行化前端单测并缩短 CI 关键路径 (#690) 2026-08-20 20:02:24 +08:00
InfinityPacer
aef379f3ec fix(settings): clarify data management hints (#689) 2026-08-20 12:56:59 +08:00
jxxghp
78298f288e fix(transfer-queue): 文件进度列表撑满弹窗剩余高度,移除固定 23rem 上限
- queue-detail 改为纵向 flex 布局,任务列表 flex 填充剩余高度并在内部滚动
- 移动端保持原有整页滚动行为
2026-08-20 08:22:03 +08:00
jxxghp
281e36c6d3 fix(media-detail): 季集数为 0 时不渲染存在状态标签,避免误导性显示已入库
- 剧集详情季列表:当季 episode_count 为 0 时隐藏已入库/缺失状态标签
- 补充回归测试
2026-08-20 08:19:37 +08:00
jxxghp
dab2b9d5bd fix(resource): 修复被 keep-alive 缓存的资源页在后台触发搜索并清空详情页参数的问题
- 路由查询参数监听增加前台页面判断,避免详情页携带的媒体身份参数触发后台资源搜索
- 搜索完成后仅在仍停留本页时清理地址栏参数,防止误删其他页面的 query
- 补充回归测试
2026-08-20 07:18:44 +08:00
InfinityPacer
38139d95eb feat(settings): configure migration backups (#687) 2026-08-19 18:34:37 +08:00
InfinityPacer
fcc6f68519 feat: 增加数据库备份策略设置 (#686) 2026-08-19 17:32:27 +08:00
fliu2476
104a314c4c feat(download): 未识别资源确认后继续下载 (#683)
Co-authored-by: liulang <liulang@25qp.cn>
2026-08-19 13:41:08 +08:00
InfinityPacer
3b758ea543 fix(system): harden scheduler and cache operations (#685) 2026-08-19 12:51:04 +08:00
InfinityPacer
0355ce5fdb fix(dashboard): avoid duplicate status card refreshes (#684) 2026-08-19 12:22:56 +08:00
InfinityPacer
dabdf7c5f7 fix(connection): ignore probe failures during restart (#682) 2026-08-19 08:34:54 +08:00
InfinityPacer
68ad3ec77c fix(offline): reset connection prompts after recovery (#681) 2026-08-19 05:55:33 +08:00
jxxghp
a4d253e103 fix(reorganize): 切换类型时数据源自动回退自动并隐藏媒体ID输入框,补充 mediaId 翻译 2026-08-18 22:37:50 +08:00
jxxghp
c98542b9bb fix(api): 修复无效 envelope 响应刷屏并加固 SW API 缓存
- 去重缓存仅在 envelope 校验通过后清空,避免坏响应命中时逐条弹窗
- SW API 缓存只写入 JSON 响应,排除 opaque 响应与连接探测心跳
2026-08-18 18:50:49 +08:00
jxxghp
89da6be8bb feat(media-source): 支持插件注册的媒体数据源,各来源选择器与全局搜索适配扩展来源 2026-08-18 15:19:56 +08:00
jxxghp
b8b8fabe15 fix: 兼容插件自由 API 响应 2026-08-18 00:29:14 +08:00
jxxghp
0518817dd0 fix(types): next_run 改为可选,适配手动任务无排期场景 2026-08-17 13:42:40 +08:00
jxxghp
03c1b893b8 fix(api): 技术错误提示增加15秒去重缓存,避免并发失败刷屏 2026-08-17 11:11:40 +08:00
jxxghp
635f32a6c4 fix 登录界面提示频率 2026-08-17 10:45:00 +08:00
jxxghp
415600c123 feat(workflow): 简化工作流卡片并适配编辑器主题
- 工作流卡片去掉按节点进度,改为底部通栏全局进度条,已执行次数合并到执行状态行
- 组件选择栏与边设置面板改为主题变量契约,适配透明/玻璃主题毛玻璃材质
2026-08-17 06:31:18 +08:00
InfinityPacer
774ef77639 test(agent): cover LLM provider directory (#680) 2026-08-16 16:54:01 +08:00
InfinityPacer
0acb604466 fix(setup): protect wizard step orchestration (#679) 2026-08-16 16:32:14 +08:00
jxxghp
4f6537400d chore: 版本号恢复为3.0.0 2026-08-16 14:09:06 +08:00
jxxghp
fdc663e5ad feat(theme): 默认主题改为紫色并升级版本至3.0.2触发打包 2026-08-16 14:06:15 +08:00
jxxghp
aaa92d9673 chore: 版本号升至3.0.1触发覆盖打包 2026-08-16 09:05:33 +08:00
jxxghp
5bb93bd26b test: 主题相关测试显式固定浅色环境避免默认玻璃主题影响断言 2026-08-16 09:05:02 +08:00
jxxghp
7b1765e43a Update package.json 2026-08-16 08:38:04 +08:00
jxxghp
485f599489 feat(theme): 默认主题改为玻璃并升级版本至3.0.1触发打包 2026-08-16 08:33:52 +08:00
jxxghp
b65682da53 refactor(api): LLM 提供商管理调用适配后端通用 manage 接口
- manage.ts 扩展 manageLlmProvider,端点联合类型纳入 llm/manage
- useLlmProviderDirectory 的目录/模型查询、授权启动/轮询/断开
  全部改走 POST /llm/manage(target+action+params 透传)
- 系统设置页 LLM 测试调用改走 manage,provider 字段转为 target
- 同步更新 composable 与系统设置页测试断言(GET mock 转 POST)
2026-08-16 07:50:39 +08:00
jxxghp
f8f43058c5 refactor(api): 渠道与存储管理调用适配后端通用 manage 接口
- 新增 src/api/manage.ts 通用封装:ManageRequest(target+action+params)
  与 manageNotificationChannel/manageStorage 两个调用入口
- 存储类调用全部改走 POST /storage/manage:用量、整理方式、
  配置保存/重置、二维码与 OAuth 登录确认(smb/alist/rclone/u115/alipan)
- ClawBot 状态查询、二维码刷新、登出与缓存迁移改走
  POST /notification/manage,原 query 参数转入 body params
- 同步更新 AccountSettingNotification 单测断言,prune 失效 lint 抑制项
2026-08-16 07:19:05 +08:00
jxxghp
6bd684d1bd style(reorganize): 整理历史移动端卡片与海报改用全局圆角token 2026-08-15 13:42:37 +08:00
jxxghp
937d28b944 fix(cache): 小屏识别缓存改为单列表切换避免影视音乐重叠 2026-08-15 12:42:03 +08:00
jxxghp
17f26408eb style(no-data): 重做 404 空状态视觉
- 移除 44KB 外链插画 SVG,改为纯内联 SVG + CSS 绘制,消除加载延迟
- 中间 "0" 设计为对称电影胶卷盘,呼应影视主题
- 数字采用主题色渐变,明暗主题自动适配
- 加入入场动画、柔光呼吸晕与漂浮胶片帧装饰,适配 prefers-reduced-motion
- 删除无引用的 404.svg / 404.png 死资源
2026-08-15 12:07:28 +08:00
jxxghp
8ecb4a6203 fix: 健康检查未启用模块恢复未启用展示 2026-08-14 22:50:47 +08:00
jxxghp
3bc1030a41 fix: 压缩目录卡片小屏录入行高度 2026-08-14 22:38:17 +08:00
jxxghp
2757f8d72b feat: 新增音乐信息转简体开关 2026-08-14 21:02:33 +08:00
jxxghp
e2eaf54433 fix(api): 重启期间抑制离线提示并收敛连接类错误反馈
- 网关错误(502/503/504)归为连接失败上报离线探测,不再逐请求弹Toast
- 仅成功响应标记服务在线,避免重启期间干扰离线阈值累计
- 新增全局重启状态,重启期间不累计离线阈值且不弹统一离线提示
2026-08-14 07:47:04 +08:00
jxxghp
3dfc835cb6 fix: 对齐站点音乐搜索交互 2026-08-13 21:17:38 +08:00
jxxghp
2754db6ccb feat: 新增站点音乐资源直搜入口 2026-08-13 21:14:38 +08:00
InfinityPacer
fd21968dc9 test(api): fix cancellation error fixture (#677) 2026-08-13 20:38:41 +08:00
InfinityPacer
4559524190 test(settings): cover advanced system configuration (#676) 2026-08-13 19:32:05 +08:00
jxxghp
6a0217d18c fix(reorganize): refine transfer dialog layouts 2026-08-13 15:22:03 +08:00
jxxghp
4aaeac360e feat(plugin): support extensible sources and dialog teardown 2026-08-13 13:30:21 +08:00
InfinityPacer
b2a57c6fb0 test(settings): cover system basics and LLM flows (#675) 2026-08-13 11:13:11 +08:00
InfinityPacer
9654bd9852 test(settings): cover notification configuration (#674) 2026-08-13 10:08:58 +08:00
InfinityPacer
b3b9248af6 fix(agent): recover pre-header stream disconnects (#673) 2026-08-13 10:08:21 +08:00
jxxghp
509dca37ce fix(api): repair typecheck for client tests 2026-08-13 08:21:32 +08:00
InfinityPacer
96d3991579 fix(agent): defer secret control history (#672) 2026-08-13 08:20:39 +08:00
Aqr-K
e4b1ba34e1 feat(setting): 实验室新增目录监控与本地文件操作隔离相关配置 (#670)
* feat(setting): 实验室新增网络存储快速监控开关

在高级设置->实验室暴露后端 MONITOR_NETWORK_FAST_MODE 配置:允许
网络/FUSE 挂载目录使用内核通知的快速监控模式。附风险提示文案
(挂载不支持文件变更通知时开启会漏文件,修改后重启生效),
三语言文案同步。

* docs(setting): 快速监控开关文案更新为保存即时生效

* feat(setting): 实验室新增整理失败重试次数

在高级设置->实验室暴露后端 TRANSFER_MAX_FAILED_RETRIES:自动整理失败后
允许重试的最大次数,避免一次网络或识别抖动让文件永久漏整理;次数用尽后
需手动整理或删除整理记录。取值 1-10,整理成功或删除记录时计数清零。
三语言文案同步。

* feat(setting): 实验室新增本地文件操作隔离相关配置

配合后端把本地文件的读取、复制、移动与删除放进可强杀的子进程执行:
CloudDrive2 等 FUSE 挂载失去响应时,这类系统调用会永久卡住且无法中断,
进而拖死整理队列与目录监控。

- FS_PROXY_ENABLED:隔离开关,关闭后退回直接调用,行为与旧版一致
- FS_PROXY_TIMEOUT:读属性/列目录/删除/重命名等快操作的超时
- FS_PROXY_STALL_TIMEOUT:复制时允许「完全没有进度」的最长时间

两个超时项随开关 v-if 显隐——隔离关闭时它们不生效,继续显示只会误导。
停滞超时的提示特意说明判据是「进度是否推进」而非总耗时,避免用户以为
复制大文件会被 120 秒打断。

---------

Co-authored-by: Your Name <you@example.com>
Co-authored-by: Aqr-K <Aqr-K@users.noreply.github.com>
2026-08-13 08:16:20 +08:00
InfinityPacer
2c4b859729 fix(agent): scope secret confirmation controls (#671) 2026-08-13 07:39:47 +08:00
jxxghp
2b0325e02b fix(ui): stabilize authenticated page requests 2026-08-13 07:20:49 +08:00
jxxghp
0ea12f75f7 refactor(api): adopt unified responses and restore media config hints 2026-08-12 23:15:13 +08:00
jxxghp
4fce4cd171 refactor(media): unify frontend media identity 2026-08-12 18:32:29 +08:00
InfinityPacer
1355e3460e feat(agent): render protected stream deliveries (#669) 2026-08-12 14:35:44 +08:00
jxxghp
f5f7a35e29 fix(music): remove Douban explore mode switch 2026-08-12 13:13:54 +08:00
jxxghp
d7534ecba4 feat(music): refine source browsing and subscriptions 2026-08-12 12:56:40 +08:00
jxxghp
a423a2ce04 feat(music): expand source discovery and history 2026-08-12 10:50:13 +08:00
jxxghp
6768f4e8a7 feat(settings): add AcoustID API key setting 2026-08-12 08:55:50 +08:00
InfinityPacer
5e58d904fe test(setting): cover directory and rule settings (#668) 2026-08-12 07:17:46 +08:00
jxxghp
155ecffd89 feat: complete music entity workflows 2026-08-12 06:51:58 +08:00
InfinityPacer
d29033c233 test(setting): cover search subscribe and site settings (#667) 2026-08-11 23:37:49 +08:00
jxxghp
26fcda661d feat(music): 支持多源识别与刮削交互 2026-08-11 23:34:00 +08:00
InfinityPacer
df4d697845 fix(user): harden profile and MFA workflows (#666) 2026-08-11 23:17:58 +08:00
jxxghp
6c384bf1ee fix(search): 搜索框媒体数据源支持多选并默认跟随全局配置 (#6270)
- 搜索来源由单选改为多选,多个来源以逗号分隔传给后端合并展示
- 默认勾选跟随基础设置-媒体搜索数据源,用户手动调整后不再被覆盖
- 更新来源选择相关测试
2026-08-11 21:07:01 +08:00
jxxghp
247fb80e3c test(name-test): remove obsolete title history persistence test 2026-08-11 20:20:30 +08:00
jxxghp
e21fe705b5 fix NameTestView 2026-08-11 20:01:54 +08:00
jxxghp
c46aef725d fix 识别标题输入框大小 2026-08-11 19:59:30 +08:00
LS
8e88a1ba15 fix(history): 桌面端媒体整理表格显示海报缩略图 (#665)
桌面端分组模式与列表模式的标题列此前硬编码类型图标,从不展示真实海报。
现复用移动端卡片已有的 getHistoryPosterUrl helper 渲染海报缩略图,
海报加载失败时通过 VImg 的 error 插槽回退到原有的类型图标结构。
2026-08-11 19:48:36 +08:00
InfinityPacer
3631cd1f22 test(user): cover user management workflows (#664) 2026-08-11 15:29:48 +08:00
jxxghp
6e76de293f fix(music):优化音乐识别缓存管理UI 2026-08-11 12:58:35 +08:00
jxxghp
5db3ccc6a1 feat(music): 音乐订阅刷新与识别缓存 2026-08-11 11:39:27 +08:00
InfinityPacer
202235d88a fix(auth): prevent stale login attempts from replacing sessions (#663) 2026-08-11 11:33:00 +08:00
InfinityPacer
bf0e57a92f fix(auth): reset account state on logout (#662) 2026-08-11 06:25:18 +08:00
InfinityPacer
e14b39c574 test(plugin): cover market settings lifecycle (#661) 2026-08-11 06:25:03 +08:00
jxxghp
bd691bb404 build: rebuild frontend release v3.0.0 2026-08-10 23:30:35 +08:00
jxxghp
59a296d429 feat(music): support MusicBrainz in recognition test
- add MusicBrainz to recognition source options with music result display
- hide custom words input when MusicBrainz is selected (music ignores words)
- link native MusicBrainz recording/release-group detail page
2026-08-10 23:30:31 +08:00
jxxghp
b8e454114a fix(resource): 候选资源全部被过滤时显示友好提示而非 404 空态
搜索流追踪过滤前候选资源数(candidate_items 或搜索阶段 total_items),最终结果为空且存在候选时显示"找到 N 个资源,但均不符合过滤规则"。Closes #646
2026-08-10 21:41:19 +08:00
jxxghp
70d6bd1f52 fix(discover): TheTvDb 链接优先使用 slug,修复数字 ID 直达 404
TVDB 已弃用数字 ID 直达 URL,优先使用后端下发的 tvdb_slug 拼接链接,无 slug 时回退 tvdb_id。Closes #643
2026-08-10 21:41:19 +08:00
jxxghp
b9dd74aa7b feat(dashboard): show monthly added music count in media statistics
- read music_count_month from dashboard statistic API and snapshot
- replace the music library placeholder with the monthly addition text
- drop unused dashboard.musicLibrary locale keys
2026-08-10 20:33:41 +08:00
InfinityPacer
1a303b4323 fix(glass): port material accents to v3 (#660) 2026-08-10 15:01:08 +08:00
jxxghp
6a4af66683 fix(glass): preserve content clarity across transparency (#656) (#657)
Co-authored-by: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com>
2026-08-10 12:49:05 +08:00
jxxghp
bb53a181a9 feat(music): add audio quality controls 2026-08-10 10:23:02 +08:00
jxxghp
c5f7924484 fix(music): label subscription entity types 2026-08-10 09:06:56 +08:00
jxxghp
cf5b95ec04 fix(music): increase search result density 2026-08-10 08:43:14 +08:00
jxxghp
b62e8dc1a6 Merge branch 'v2' into v3 2026-08-10 07:42:11 +08:00
InfinityPacer
eb3b7b63b6 fix(glass): unify content surfaces and renderer eligibility (#653) 2026-08-10 06:54:29 +08:00
InfinityPacer
309ce3c92e fix(a11y): label dialog close buttons (#654) 2026-08-10 06:53:56 +08:00
jxxghp
ce0b0d6330 fix(music): preserve subscription entity semantics 2026-08-09 23:30:31 +08:00
jxxghp
2a01a2ffe0 feat(discover): persist tab visibility settings 2026-08-09 23:27:41 +08:00
jxxghp
6ca606a864 fix(images): honor global cache across cards 2026-08-09 22:20:51 +08:00
jxxghp
73840e3480 feat(music): add lyrics scraping setting 2026-08-09 22:20:07 +08:00
jxxghp
98a9f87768 feat(music): support album subscription semantics 2026-08-09 20:43:33 +08:00
jxxghp
dd6f644f05 fix: 避免音乐专辑副标题重复标题 2026-08-09 18:34:19 +08:00
jxxghp
6db19ee6f7 feat: 优化音乐卡片与搜索站点筛选 2026-08-09 18:12:29 +08:00
cosini
d1d46353dd fix: 修复智能助手回答内容超出界面宽度的问题 (#652) 2026-08-09 17:58:07 +08:00
jxxghp
cfd08059a4 feat: 优化正在下载卡片信息层级 2026-08-09 17:50:19 +08:00
jxxghp
278a7587c1 feat: 完善音乐识别结果弹窗 2026-08-09 17:05:36 +08:00
jxxghp
b2c4a69173 feat: support fullscreen AI assistant 2026-08-09 16:36:04 +08:00
jxxghp
a98271f8a1 style: 对齐 AList 存储前端格式 2026-08-09 14:53:13 +08:00
okatu-loli
eabe8f6951 feat: 添加 AList 存储选项 2026-08-09 14:48:18 +08:00
千石
e6b26cc831 feat: 添加 AList 存储选项 (#645) 2026-08-09 14:40:54 +08:00
jxxghp
e242b881de fix tests 2026-08-09 14:12:11 +08:00
jxxghp
e23d9a6f41 feat: 调整站点资源对话框布局,优化输入框和选择框的列宽 2026-08-09 13:01:39 +08:00
jxxghp
f21274ebca feat: 更新音乐页面加载状态显示,使用 LoadingBanner 替代 VSkeletonLoader 2026-08-09 12:56:05 +08:00
jxxghp
139721d62a fix: 修正订阅卡片占位符样式和媒体 ID 校验逻辑 2026-08-09 12:49:54 +08:00
LLL001a
6bf9cf9d18 feat: 系统设置增加音乐封面代理地址配置项 (#651) 2026-08-09 12:43:00 +08:00
jxxghp
f080da1ebb feat: 更新多个组件的背景样式为透明度背景 2026-08-09 12:35:09 +08:00
jxxghp
4b70b98ffc feat: 为音乐类型添加图标支持 2026-08-09 11:56:54 +08:00
jxxghp
fe442fda4b feat: 添加媒体占位图标并优化无封面处理逻辑 2026-08-09 11:40:08 +08:00
jxxghp
ede4fe94ce fix: 修正搜索栏音乐入口测试断言文案
音乐搜索动作的描述文案已更新为 music.searchDescription(歌曲、专辑或艺术家),
同步修正过时的测试断言。
2026-08-09 10:58:29 +08:00
jxxghp
20fcd699f2 fix: 影视识别结果卡片不再渲染音乐专属标签
音乐标签(艺术家、专辑、发行日期、风格、时长等)增加 isMusic 保护,
避免电影/电视剧识别结果的 genres/release_date 等字段被误展示;
补充影视场景的回归测试。
2026-08-09 10:54:05 +08:00
jxxghp
009ce6173b feat: 音乐搜索与识别前端联动统一
- 音乐搜索从 music/search 切换为 media/search?type=music
- MediaInfoCard 支持音乐卡片(方形封面、时长、音乐实体类型、详情外链)
- 订阅与资源选择器适配音乐元数据
- 同步中英繁语言包
2026-08-09 10:18:42 +08:00
jxxghp
b9194ffb74 更新 en-US.ts 2026-08-08 15:49:29 +08:00
jxxghp
942a44cde6 更新 zh-TW.ts 2026-08-08 15:48:54 +08:00
jxxghp
3bf52231f7 更新 zh-CN.ts 2026-08-08 15:48:21 +08:00
jxxghp
109a0b1af5 Delete unused files 2026-08-08 15:41:25 +08:00
jxxghp
777c4ec7e3 feat: 优化媒体卡片与版本标志 (#650)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-08-08 15:33:55 +08:00
jxxghp
1064156062 Trae/music subscribe card cover fallback (#648)
* feat: 优化音乐搜索与详情页面

Co-authored-by: traeagent <traeagent@users.noreply.github.com>

* feat: 优化音乐搜索与详情页面

Co-authored-by: traeagent <traeagent@users.noreply.github.com>

---------

Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-08-08 14:58:45 +08:00
jxxghp
693e6fb352 feat: 优化音乐搜索与详情页面 (#647)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-08-08 14:36:07 +08:00
jxxghp
70bb6e1040 feat(music): add MusicAlbumView, MusicArtistView, and MusicDetailView components
- Implement MusicAlbumView for displaying album details, including tracks and artist links.
- Create MusicArtistView to show artist information and their albums.
- Develop MusicDetailView for individual music tracks, integrating album details and related artists.
- Introduce MusicDetailLayout for consistent layout across music-related views.
- Add MusicArtistSlideView for displaying related artists in a slide format.
- Enhance user experience with loading states and error handling in NoDataFound component.
2026-08-08 12:56:22 +08:00
jxxghp
02e3314853 fix(v3): finish music discovery and detail flows 2026-08-07 23:46:54 +08:00
jxxghp
d48ee55bab fix(v3): route music search through global search 2026-08-07 22:54:39 +08:00
jxxghp
d72bb75996 chore(v3): prune stale eslint suppression 2026-08-07 22:16:40 +08:00
jxxghp
95674fa47a ci(v3): publish frontend from v3 2026-08-07 22:08:37 +08:00
jxxghp
6afa2bdc8a feat(v3): add music search and subscription UI 2026-08-07 21:50:22 +08:00
jxxghp
7ea14bc919 fix confirm dialog button text color 2026-08-07 17:33:02 +08:00
jxxghp
1dfb51f3c4 Improve agent input handling and tool summary grouping 2026-08-07 13:06:01 +08:00
jxxghp
871a9a072b fix(agent): preserve tool and content ordering 2026-08-07 13:01:47 +08:00
jxxghp
0eb81b4ecb perf(agent): smooth SSE streaming updates 2026-08-07 12:42:57 +08:00
jxxghp
4814f53c4e fix(download): place metadata chips below title 2026-08-07 07:35:07 +08:00
秋澪Akimio
0703a99e41 feat: words editor replacement highlight (#644) 2026-08-07 07:01:41 +08:00
jxxghp
21b485ca84 fix(api): type avatar upload response data 2026-08-06 23:36:34 +08:00
jxxghp
fc6313b79e fix(agent): preserve streamed message order 2026-08-06 22:24:54 +08:00
jxxghp
8badfb0da9 fix(download): keep torrent size visible 2026-08-06 17:45:56 +08:00
jxxghp
bfd61cd479 fix(api): read unified error messages 2026-08-06 17:31:00 +08:00
jxxghp
ac29818fd3 Refine assistant layering and plugin source settings 2026-08-06 10:33:20 +08:00
jxxghp
8d18f82e68 Refine responsive input action visibility 2026-08-06 08:21:49 +08:00
jxxghp
fd1bf8df7f test(site): cover chart tooltip formatters 2026-08-06 08:01:10 +08:00
jxxghp
6fd259427e refactor(cache): manage TMDB recognition cache only 2026-08-06 07:48:43 +08:00
jxxghp
0045ecf5d9 fix(site): keep chart dates compact 2026-08-05 22:13:22 +08:00
jxxghp
50fa751eb5 feat(site): enhance user data dashboard 2026-08-05 21:03:46 +08:00
jxxghp
aa6dfd8569 fix(plugin): clear update marker after refresh 2026-08-05 20:47:43 +08:00
jxxghp
b5ee946919 fix(test): align version statistic threshold fixture 2026-08-05 20:38:50 +08:00
jxxghp
6a21bb55c3 fix(test): avoid super-linear glass overlay regex 2026-08-05 19:52:25 +08:00
jxxghp
737d464950 更新 AboutDialog.vue 2026-08-05 19:45:23 +08:00
jxxghp
2c467335fa 更新 AboutDialog.vue 2026-08-05 19:44:16 +08:00
jxxghp
24f1580501 feat: 增加服务端联网搜索设置 2026-08-05 19:19:26 +08:00
jxxghp
1129d63fcc fix: 恢复识别结果媒体分类展示 2026-08-05 06:56:52 +08:00
jxxghp
b1f3d3eacb Refine shared workflow and subscription dialogs 2026-08-04 21:37:04 +08:00
InfinityPacer
487e567d4c fix(glass): define signed coverage square (#642) 2026-08-04 19:48:44 +08:00
jxxghp
872d46132e fix MediaInfoCard chip background 2026-08-04 19:29:31 +08:00
InfinityPacer
c65b79f4b8 fix(glass): refine fluid response coverage (#641) 2026-08-04 19:11:34 +08:00
jxxghp
09bbeda108 fix(glass): preserve chip variants with colored glass 2026-08-04 18:58:55 +08:00
InfinityPacer
afc95ad990 fix(glass): reuse CORS wallpaper sources (#640) 2026-08-04 16:35:17 +08:00
InfinityPacer
a545f88b3c feat(glass): add selectable dynamics modes (#639) 2026-08-04 16:34:39 +08:00
jxxghp
084b950fec feat: 修复玻璃主题下Chip标签颜色 (#638)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-08-04 15:18:30 +08:00
jxxghp
2728896a1e Propagate plugin rating updates and preserve rating snapshots 2026-08-04 13:39:27 +08:00
jxxghp
b4afba99bd Improve plugin card statuses and compact market details 2026-08-04 12:51:35 +08:00
jxxghp
df1e72e871 Improve plugin market detail layout and rating sorting 2026-08-04 12:34:02 +08:00
jxxghp
7e46428721 style(workflow): adapt card headers to material themes 2026-08-04 10:12:32 +08:00
jxxghp
14a4d9dc9c Redesign workflow task cards 2026-08-04 09:20:56 +08:00
jxxghp
3ac798ac5e Disable mobile layout for plugin search field 2026-08-04 06:42:40 +08:00
InfinityPacer
e3ee714732 fix(plugin): align lifecycle action state handling (#637) 2026-08-04 06:33:00 +08:00
jxxghp
41d5f579f7 Fix movie wash subscription card visuals 2026-08-03 22:42:15 +08:00
jxxghp
102abf7df9 Highlight best-version subscriptions on mobile cards 2026-08-03 22:26:04 +08:00
jxxghp
47b9633a86 Document test helpers and component stubs 2026-08-03 22:17:56 +08:00
jxxghp
e2902adba8 Improve card badge layouts and expand card tests 2026-08-03 22:16:28 +08:00
jxxghp
cd993e3cf4 Refine mobile subscription card layout 2026-08-03 22:00:10 +08:00
jxxghp
4097fd74dd Codex/compact downloading poster (#635)
* fix(download): compact poster cards

* fix(download): use poster artwork consistently

* Improve plugin and media ID search dialogs
2026-08-03 21:23:20 +08:00
jxxghp
37e645ed9c fix(download): compact poster cards (#634) 2026-08-03 20:09:05 +08:00
jxxghp
fa254499bc fix(download): redesign downloading card (#633) 2026-08-03 19:48:45 +08:00
jxxghp
be8b20da8d 更新 package.json 2026-08-03 18:50:09 +08:00
jxxghp
cef44692a8 feat(download): redesign active download cards (#632) 2026-08-03 18:49:23 +08:00
jxxghp
a627d63107 fix(dialog): align mobile site search actions 2026-08-03 18:19:42 +08:00
InfinityPacer
b13674d0e7 test(plugin): cover market list state workflows (#631) 2026-08-03 16:40:48 +08:00
InfinityPacer
9a533cd103 test(plugin): cover plugin host surfaces (#630) 2026-08-03 15:21:36 +08:00
InfinityPacer
1298a5f72c test(plugin): cover dynamic sidebar navigation (#629) 2026-08-03 13:30:20 +08:00
InfinityPacer
b2c2cacc4c test(plugin): cover federation host lifecycle (#628) 2026-08-03 12:45:33 +08:00
jxxghp
d27b402b74 style(download): improve history item hierarchy 2026-08-03 11:54:41 +08:00
InfinityPacer
5ca6c520ae fix(pwa): recover CORS image cache reads (#627) 2026-08-03 10:07:00 +08:00
jxxghp
aa63782645 feat(download): add download history dialog 2026-08-03 08:45:35 +08:00
jxxghp
a7df9c432e 更新 package.json 2026-08-03 07:56:27 +08:00
InfinityPacer
422aba8d0e fix(layout): align scrollbar and navbar behavior (#626) 2026-08-03 07:02:18 +08:00
InfinityPacer
245a1f8a5c fix(dashboard): remeasure async card content (#625) 2026-08-02 07:42:13 +08:00
jxxghp
777e30d137 fix(agent): recover PWA background replies 2026-08-01 08:13:58 +08:00
jxxghp
ed5d955ae7 fix(glass): align toast bubbles with menu material 2026-08-01 07:51:25 +08:00
InfinityPacer
bcc5543f4c fix(glass): preserve explicit optical boundaries (#624) 2026-08-01 07:28:13 +08:00
InfinityPacer
eedf038a70 fix(glass): disable mobile motion dynamics (#623) 2026-08-01 07:27:58 +08:00
InfinityPacer
88f81ecce4 perf(list): prefetch infinite scroll pages (#622) 2026-08-01 07:27:36 +08:00
InfinityPacer
9e35378ab4 test(filebrowser): cover file list operations (#621) 2026-08-01 07:26:59 +08:00
InfinityPacer
36b901f717 test(subscribe): cover popular media deduplication (#620) 2026-07-31 20:32:52 +08:00
InfinityPacer
cfbbd89b1b perf(grid): stage virtual card mounting (#619) 2026-07-31 20:32:32 +08:00
InfinityPacer
5acf6ee84c test(filebrowser): cover shell workflows (#618) 2026-07-31 19:43:25 +08:00
InfinityPacer
985dafe45f fix(glass): align frosted fixed and scroll materials (#617) 2026-07-31 18:16:53 +08:00
InfinityPacer
21558372d5 test(transfer): cover queue dialog workflows (#616) 2026-07-31 18:16:35 +08:00
InfinityPacer
53f4ba23be test(reorganize): cover manual transfer workflows (#615)
* test(reorganize): cover manual transfer workflows

* fix(reorganize): show fallback transfer errors
2026-07-31 17:11:52 +08:00
InfinityPacer
9b3388f45f fix(router): keep route reveal timeline monotonic (#614) 2026-07-31 16:40:00 +08:00
InfinityPacer
eaf6b5427c test(history): cover transfer history workflows (#613) 2026-07-31 16:39:48 +08:00
InfinityPacer
39395ca6b6 test(download): cover downloading task workflows (#612) 2026-07-31 15:25:31 +08:00
jxxghp
38fe17ab99 Improve type safety in name test form and theme test 2026-07-31 15:14:38 +08:00
jxxghp
c957264b72 Enhance name recognition results with source metadata 2026-07-31 15:12:41 +08:00
InfinityPacer
41a36e2279 fix(glass): preserve material during route transitions (#611) 2026-07-31 14:35:08 +08:00
jxxghp
246b8ff54b feat(plugin): add ratings and about dialog 2026-07-31 13:41:29 +08:00
InfinityPacer
3f414afff4 test(download): cover download dialog workflows (#610) 2026-07-31 12:49:59 +08:00
InfinityPacer
7f2bd5b3ae test(utils): cover request and SSE lifecycle (#609) 2026-07-31 12:33:40 +08:00
InfinityPacer
2c2f55b6d7 test(resource): cover torrent card interactions (#608) 2026-07-31 12:20:05 +08:00
InfinityPacer
a1802f7f93 test(resource): cover search filtering and lifecycle (#607) 2026-07-31 10:59:35 +08:00
InfinityPacer
b85d412308 fix(glass): stabilize fixed shell and initial frosted material (#606) 2026-07-31 06:19:00 +08:00
InfinityPacer
999f8bf18c build: enable modern API for indented Sass (#605) 2026-07-31 06:18:36 +08:00
InfinityPacer
544acc0758 test: clean up expected console output (#604) 2026-07-31 06:17:52 +08:00
InfinityPacer
e67edd9feb fix(glass): scope fixed shell backplate (#603) 2026-07-30 22:47:22 +08:00
InfinityPacer
bf77c8ee04 fix(glass): stabilize frosted fixed shells (#602) 2026-07-30 21:42:06 +08:00
InfinityPacer
6d5550f198 fix(glass): preserve collapsed interaction clips (#601) 2026-07-30 21:01:15 +08:00
InfinityPacer
097beb6f63 perf(glass): reduce long-list interaction overhead (#600) 2026-07-30 18:58:49 +08:00
jxxghp
55e3a8cba2 feat: 显示共享媒体识别统计 2026-07-30 17:50:16 +08:00
jxxghp
ca2f3da7a5 feat(settings): configure mounted directory cleanup 2026-07-30 17:45:13 +08:00
InfinityPacer
32e4a9b817 fix(glass): support pointer input in Safari (#599) 2026-07-30 17:16:27 +08:00
jxxghp
7e3e95fbcd Theme agent robot colors dynamically 2026-07-30 16:42:43 +08:00
jxxghp
4362e76ca0 Refine glass navbar and file browser surface materials 2026-07-30 15:25:45 +08:00
jxxghp
f761c59637 Unify MoviePilot wordmark styling across layouts 2026-07-30 14:51:50 +08:00
InfinityPacer
d29a008e13 fix(glass): stabilize scroll material and shared dynamics (#598) 2026-07-30 14:46:09 +08:00
jxxghp
715da1929b Fix LLM provider fields and OpenAI-compatible protocol detection 2026-07-30 14:41:26 +08:00
jxxghp
6104fb861e feat: 后端修改前端智能助手设置 (#597) 2026-07-30 13:32:19 +08:00
jxxghp
01e7dcee56 fix(editor): unify Ace editor padding 2026-07-29 13:48:12 +08:00
jxxghp
9ef45918b8 fix WordsView 2026-07-29 13:19:56 +08:00
jxxghp
502a593985 更新 package.json 2026-07-29 11:19:32 +08:00
InfinityPacer
287cbc76ae feat(glass): refine material response and rendering lifecycle (#596) 2026-07-29 11:19:12 +08:00
jxxghp
90d626e7c6 chore(lint): 清理失效抑制项 2026-07-29 10:12:05 +08:00
jxxghp
8a3b99ae51 feat(pwa): 完善 iOS 启动与主题恢复 2026-07-29 10:06:14 +08:00
jxxghp
e5339d524b fix(cache): use fixed poster corner radius 2026-07-29 08:31:53 +08:00
jxxghp
e218508c2d fix(editor): Ace 输入框玻璃质感与圆角内边距对齐
- 玻璃主题下让 .ace_editor 与普通输入框一致的染色玻璃、描边、聚焦态
- 重命名格式编辑器改用全局圆角 token,并通过 renderer 补齐内边距

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 20:07:05 +08:00
jxxghp
e7bfc4ec1c fix(glass): 捷径卡片补齐玻璃描边与高光
弹层内嵌套卡片被通用折叠规则抹平 border 与 box-shadow,只补回背景会显得与其它玻璃卡片不一致,这里补齐完整表面并加上 hover 态。同时带上搜索弹窗的空状态间距与样式顺序整理。
2026-07-28 19:30:05 +08:00
jxxghp
5c63fe2313 feat(mediaserver): 飞牛影视增加访问码配置项
配置弹窗与初始化向导的飞牛表单增加访问码输入项,未开启时留空。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:09:17 +08:00
jxxghp
8d86bd6e92 feat(glass): 插件卡片改为染色玻璃
将插件卡片与市场卡片头部的实色背景改为变量驱动的染色层:
图标主色只作为透光色相,玻璃主题下叠加吸收遮罩与镜面高光,
并按 frosted/tinted 材质与透明度调整强度;文件夹卡片渐变底
同步降低不透明度让卡片本体玻璃透出。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:52:50 +08:00
InfinityPacer
8a4ca2482c fix(glass): stabilize optical rendering and page presentation (#595) 2026-07-28 10:41:41 +08:00
InfinityPacer
3daa031a04 fix(glass): stabilize optical surfaces and page transitions (#594) 2026-07-27 17:33:05 +08:00
jxxghp
15ef332ea2 fix(pwa): 面板打开时隐藏底部导航
底部导航 Teleport 到 body(z-index 1999),而主题定制器(2102)与
Agent 面板(2601)渲染在 VApp 内部。玻璃主题给 .v-application 设置了
position: relative + z-index: 1,形成层叠上下文,两个面板的数值被限制在
该层内部,无法压过 body 层的底部导航。

移动端这两个面板均为全屏,改为在根节点开关标记打开时隐藏底部导航。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:33:27 +08:00
InfinityPacer
57448ca609 fix(glass): load cross-origin wallpapers directly (#593) 2026-07-27 13:49:09 +08:00
InfinityPacer
0f7c95cfaf fix(glass): 收敛壁纸预加载与失效恢复 (#592)
* fix(glass): streamline wallpaper loading

* docs(glass): remove stale compatibility comment

* docs(glass): remove obsolete texture fallback note
2026-07-27 11:26:29 +08:00
InfinityPacer
3886909e9e feat(glass): unify login and app optical presentation (#591) 2026-07-27 10:26:46 +08:00
jxxghp
0f8a817413 fix tests 2026-07-27 10:20:30 +08:00
jxxghp
eb47545b76 fix #6199 2026-07-27 10:05:32 +08:00
jxxghp
6fc123e0f6 fix: simplify media key generation to prevent duplicates
- Refactor getMediaItemKey function with clear priority chain
- Replace complex nested ternary with readable if-else structure
- Ensure consistent key generation for same media items
- Prioritize tmdb_id > douban_id > bangumi_id > anilist_id > media_id

Fixes duplicate items in popular subscribe list
Related to jxxghp/MoviePilot#6199
2026-07-27 09:43:00 +08:00
jxxghp
5bdef2ba8f fix(transfer): 显示并确认重新整理 2026-07-26 08:58:49 +08:00
jxxghp
42ca5b8f94 fix(search): assemble batched SSE results (#6186) 2026-07-26 08:55:34 +08:00
InfinityPacer
ba48a25982 fix(glass): stabilize optical surfaces after scrolling (#590) 2026-07-25 19:14:52 +08:00
InfinityPacer
76c524eb9b fix(dashboard): show media empty and retry states (#589) 2026-07-25 12:57:28 +08:00
InfinityPacer
48f8e8fc71 fix(glass): reset renderer state on unmount (#588) 2026-07-25 10:44:51 +08:00
InfinityPacer
2a0f15c1bd test(subscribe): stabilize calendar and season coverage (#587) 2026-07-25 10:31:54 +08:00
InfinityPacer
e94ade364e test(subscribe): stabilize mobile calendar filter (#586) 2026-07-25 10:31:04 +08:00
InfinityPacer
27f6c33782 feat(glass): improve optical quality and controls (#585) 2026-07-25 10:30:29 +08:00
jxxghp
b3e596c761 fix(subscribe): preserve source identity across season flows 2026-07-25 08:51:30 +08:00
jxxghp
0ee9e466c3 fix(subscribe): improve season metadata display (#584) 2026-07-25 08:01:43 +08:00
jxxghp
5325d813b4 Restore clear theme navbar surface after scrolling 2026-07-24 18:57:29 +08:00
InfinityPacer
dc3f0abf9b fix(auth): separate passkey and two-step verification (#583) 2026-07-24 06:18:43 +08:00
InfinityPacer
e63fc9a8bd fix(theme): refine mobile glass controls (#582) 2026-07-24 06:18:08 +08:00
InfinityPacer
eb5366f129 fix(grid): refresh scroll target after overlay unlock (#581) 2026-07-23 23:18:25 +08:00
jxxghp
77c4a0f84c Add glass theme backdrop treatment to media details 2026-07-23 21:58:43 +08:00
InfinityPacer
1bf6c1f1d8 perf(glass): complete phase three optimization (#580) 2026-07-23 20:26:28 +08:00
jxxghp
31caf8f5f8 feat(plugin): expose native subscribe action (#502) 2026-07-23 17:29:36 +08:00
InfinityPacer
787076355b perf(app): add activity lifecycle management (#579) 2026-07-23 15:59:50 +08:00
jxxghp
4da764ddef Flatten glass-theme modal surfaces 2026-07-23 15:58:18 +08:00
InfinityPacer
590755169f feat(theme): refine glass and transparency settings (#578) 2026-07-23 14:06:46 +08:00
jxxghp
f7ef9dd116 Unify glass overlay material tokens across floating panels 2026-07-23 13:57:20 +08:00
jxxghp
4bc16eb220 Fix clear glass mobile overlays 2026-07-23 08:58:56 +08:00
jxxghp
032bda61f6 Fix mobile clear glass surfaces 2026-07-23 08:12:39 +08:00
jxxghp
91ce2c8aee Refine glass responsive surfaces 2026-07-23 07:51:49 +08:00
jxxghp
9b230d14b4 Lighten clear glass overlays 2026-07-23 07:19:00 +08:00
jxxghp
6201e01914 Fix glass overlay readability 2026-07-23 07:17:24 +08:00
jxxghp
441f2ecc06 Unify glass theme backdrop filters 2026-07-23 07:05:43 +08:00
InfinityPacer
1ed69ccc64 feat: add glass materials and quality profiles (#577) 2026-07-23 06:08:39 +08:00
zangse
80519e6839 fix(mobile): 修复移动端 TMDB 搜索图标不可见及弹窗交互问题 (#576) 2026-07-23 06:05:36 +08:00
zangse
b4be18e71f feat(calendar): add movie/TV series filter to subscription calendar (#575) 2026-07-23 06:04:55 +08:00
InfinityPacer
4a196731f1 perf(dashboard): restore additional cards from snapshots (#574) 2026-07-23 06:03:42 +08:00
jxxghp
b32a4e7c6a Optimize glass theme rendering performance 2026-07-22 22:48:25 +08:00
jxxghp
dc8becb41a Integrate logging and scheduler surfaces with glass theme 2026-07-22 22:39:11 +08:00
jxxghp
d2896d5936 Disable transitions that disrupt glass theme rendering 2026-07-22 22:13:13 +08:00
jxxghp
0315481eb6 Style mobile scheduler cards with glass theme 2026-07-22 21:18:47 +08:00
jxxghp
ca955b48e2 Fix glass theme shadows and slider overflow 2026-07-22 20:25:12 +08:00
jxxghp
19e93edca0 fix(glass-theme): adjust background filter and opacity for improved contrast 2026-07-22 20:10:17 +08:00
jxxghp
d039efbefe Brighten glass theme background visuals 2026-07-22 18:19:28 +08:00
InfinityPacer
103472404f perf(dashboard): restore media cards from snapshots (#573) 2026-07-22 17:55:54 +08:00
jxxghp
4405a69d59 fix: refine glass search field 2026-07-22 15:42:53 +08:00
jxxghp
c5f3a4db44 fix: honor selected glass theme color 2026-07-22 15:12:35 +08:00
jxxghp
b66bc353b3 fix: hide unsupported glass theme controls 2026-07-22 15:07:09 +08:00
jxxghp
17cdfe6885 feat: add glass theme 2026-07-22 15:00:11 +08:00
jxxghp
5cf0dc116a feat: add AniList browsing and source-aware search 2026-07-22 12:53:58 +08:00
InfinityPacer
c1bb54a31e ci(test): run frontend checks on v2 pushes (#572) 2026-07-22 06:59:27 +08:00
InfinityPacer
0fb5bbebc6 修复仪表盘首屏布局跳动与响应式错位 (#571) 2026-07-22 06:59:12 +08:00
jxxghp
9f375e8413 feat: 调整文件重命名输入框宽度 (#570)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-21 22:35:12 +08:00
jxxghp
1d7cfbfed8 fix: refine subscribe files dialog layout 2026-07-21 20:25:14 +08:00
jxxghp
0cc254c07d feat: propagate media source identities (#6129) 2026-07-21 20:21:17 +08:00
InfinityPacer
65d93b70e4 fix(pwa): coordinate service worker lifecycle (#569) 2026-07-21 17:30:08 +08:00
InfinityPacer
8dc36a2007 fix(pwa): isolate service worker development mode (#568) 2026-07-21 16:41:55 +08:00
jxxghp
42ba550bb4 feat: support manual media selection for file scraping 2026-07-21 12:55:14 +08:00
jxxghp
1aa8c9122d v2.15.0 2026-07-21 11:56:28 +08:00
jxxghp
225d1d2ec0 fix: complete media server field hints 2026-07-21 11:38:42 +08:00
jxxghp
00c0c86345 feat: support selectable anime metadata sources 2026-07-21 11:32:16 +08:00
jxxghp
eee83542df fix(scheduler): move mobile status to subtitle row 2026-07-21 11:25:15 +08:00
InfinityPacer
4da856fb6d fix(plugin): resolve remote entry URLs from document base (#566) 2026-07-20 15:05:58 +08:00
jxxghp
03126d32c1 feat(plugin): expose host toast to federated views 2026-07-20 12:35:14 +08:00
InfinityPacer
ad555b3b36 fix(site): preserve dialog failure states (#565) 2026-07-19 19:24:45 +08:00
InfinityPacer
7f8e6952df test(site): cover site user data dialog (#564) 2026-07-19 17:33:53 +08:00
InfinityPacer
2542339733 test(site): cover site resource dialog (#563) 2026-07-19 17:05:56 +08:00
InfinityPacer
0d094b01b5 test(site): cover site maintenance dialogs (#562) 2026-07-19 16:12:17 +08:00
InfinityPacer
47f007480f test(site): cover site card and icon cache (#561) 2026-07-19 12:39:53 +08:00
InfinityPacer
374a0866be test(site): cover site list flows (#560) 2026-07-19 08:57:44 +08:00
InfinityPacer
126a204b2a test(media): cover media detail flows (#559) 2026-07-18 20:46:38 +08:00
InfinityPacer
c491195b19 test(media): cover cards and virtual slides (#558) 2026-07-18 18:03:39 +08:00
InfinityPacer
395401e493 ci: check changed-file formatting (#557) 2026-07-18 16:48:25 +08:00
InfinityPacer
52479ebbd5 test(discover): cover source and media list flows (#556) 2026-07-18 15:41:40 +08:00
InfinityPacer
f6d8ab8aeb chore: add incremental Prettier tooling (#555) 2026-07-18 15:41:23 +08:00
jxxghp
67e78d0eb1 Fix transfer history mobile record layout 2026-07-18 11:45:12 +08:00
jxxghp
ef7f346e7a feat: 优化历史记录页面小屏幕显示 (#554)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-18 11:17:13 +08:00
jxxghp
707bfc87a1 feat: 优化历史记录页面小屏幕显示 (#553)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-18 10:58:06 +08:00
jxxghp
3416c879de feat: 优化历史记录页面小屏幕显示 (#552)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-07-18 10:42:08 +08:00
jxxghp
bbed5bf0ae feat: 优化历史记录页面小屏幕显示 (#551) 2026-07-18 10:24:46 +08:00
InfinityPacer
985874506e ci: add observational ESLint check (#550) 2026-07-18 10:24:00 +08:00
InfinityPacer
8434b75af3 chore: migrate frontend linting to ESLint 10 (#549) 2026-07-18 09:42:34 +08:00
InfinityPacer
8a1c10bbf7 test(recommend): cover source lifecycle and slides (#548) 2026-07-18 09:37:45 +08:00
jxxghp
e4e5bce234 更新 package.json 2026-07-18 08:06:59 +08:00
jxxghp
ae4f304cc2 feat: 优化小屏幕订阅日历界面 (#547) 2026-07-18 08:05:31 +08:00
秋澪Akimio
9a838647fe feat: 为词表编辑器增加可选行号与语法高亮 (#546) 2026-07-17 23:34:49 +08:00
InfinityPacer
435e9ecfdd test(subscribe): cover popular and share workflows (#545) 2026-07-17 19:54:24 +08:00
InfinityPacer
2b310c9a97 test(subscribe): cover season, files and history dialogs (#544) 2026-07-17 18:25:28 +08:00
InfinityPacer
7d2c98c5ae fix(layout): register cleanup hook synchronously (#543) 2026-07-17 18:25:05 +08:00
InfinityPacer
ff871e7049 feat(login): add hidden logo lab access (#542) 2026-07-17 16:47:11 +08:00
InfinityPacer
20f80d0f1f test(subscribe): cover subscription calendar (#541) 2026-07-17 16:14:24 +08:00
jxxghp
36bb99cf06 Improve plugin market filter menu layout 2026-07-17 15:45:41 +08:00
jxxghp
24c2e3b49c Fix login layout on short mobile screens 2026-07-17 14:47:19 +08:00
jxxghp
486a471c0c 更新 package.json 2026-07-17 14:18:55 +08:00
InfinityPacer
ce32da9176 fix(theme): preserve logo detail across theme colors (#540) 2026-07-17 14:18:35 +08:00
InfinityPacer
39dd1b9e00 feat: 让应用 Logo 跟随主题色 (#538)
* fix(login): show form with logo

* feat(theme): sync logos with primary color

* test(theme): cover favicon color sync

* chore(login): remove unused metal logo component

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-07-17 13:01:25 +08:00
InfinityPacer
807711cae8 test(subscribe): cover card actions (#539) 2026-07-17 12:57:28 +08:00
jxxghp
00feeb86ca Fix agent assistant layering and login card visibility 2026-07-17 12:53:01 +08:00
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
631 changed files with 122525 additions and 12607 deletions

1
.env.test Normal file
View File

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

View File

@@ -1,69 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: ['@antfu/eslint-config-vue', 'plugin:sonarjs/recommended'],
ignorePatterns: ['src/@iconify/*.js', 'node_modules', 'dist', '*.d.ts'],
plugins: ['regex'],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'sonarjs/no-duplicate-string': 'warn',
'vue/valid-v-slot': ['error', {
allowModifiers: true,
}],
// https://github.com/gmullerb/eslint-plugin-regex
'regex/invalid': [
'error',
[
{
regex: '@/assets/images',
replacement: '@images',
message: 'Use \'@images\' path alias for image imports',
},
{
regex: '@/styles',
replacement: '@styles',
message: 'Use \'@styles\' path alias for importing styles from \'src/styles\'',
},
// {
// id: 'Disallow icon of icon library',
// regex: 'tabler-\\w',
// message: 'Only \'mdi\' icons are allowed',
// },
{
regex: '@core/\\w',
message: 'You can\'t use @core when you are in @layouts module',
files: {
inspect: '@layouts/.*',
},
},
{
regex: 'useLayouts\\(',
message:
'`useLayouts` composable is only allowed in @layouts & @core directory. Please use `useThemeConfig` composable instead.',
files: {
inspect: '^(?!.*(@core|@layouts)).*',
},
},
],
// Ignore files
'.eslintrc.js',
],
},
settings: {
'import/resolver': {
node: {
extensions: ['.ts', '.js', '.tsx', '.jsx', '.mjs', '.png', '.jpg'],
},
typescript: {},
},
},
}

View File

@@ -1,19 +1,23 @@
name: Build Moviepilot-Frontend v2
name: Build Moviepilot-Frontend v3
on:
workflow_dispatch:
push:
branches:
- v2
- v3
paths:
- 'package.json'
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v7
- name: Release version
id: release_version
@@ -22,22 +26,30 @@ jobs:
echo "frontend_version=v$frontend_version" >> $GITHUB_ENV
- name: Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v7
with:
node-version: '20'
cache: 'yarn'
node-version: '24'
cache: yarn
cache-dependency-path: yarn.lock
- name: Enable project Yarn version
run: |
corepack enable
corepack install --global yarn@1.22.22
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Download Icons
run: |
pwd
curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp -
curl -fsSL --retry 3 "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp -
mv /tmp/MoviePilot-Plugins-main/icons public/plugin_icon
rm -rf /tmp/MoviePilot-Plugins-main
- name: Build frontend
id: build_frontend
run: |
yarn
yarn build
echo "$frontend_version" > dist/version.txt
zip -r dist.zip dist

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,23 +10,17 @@ on:
- review_requested
- synchronize
issue_comment:
# 手动命令如 "/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 inline review
if: >-
github.event.sender.type != 'Bot' &&
(
@@ -35,549 +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 == '/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 || github.ref }}
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: Detect PR review language
id: pr_language
- name: Run PR Review
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
set -euo pipefail
pr_info="$(mktemp)"
gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pr_info}"
python3 - "${pr_info}" >> "${GITHUB_OUTPUT}" <<'PY'
import json
import re
import sys
from pathlib import Path
pr = json.loads(Path(sys.argv[1]).read_text())
title = pr.get("title") or ""
body = pr.get("body") or ""
labels = {item.get("name", "") for item in pr.get("labels") or []}
skip_pr_agent = "true" if "skip pr-agent" in labels or re.search(r"^(?:\[Auto\]|Auto)", title) else "false"
head_sha = pr.get("head", {}).get("sha") or ""
body = re.sub(
r"\n*##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*"
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->",
" ",
body,
flags=re.IGNORECASE | re.DOTALL,
)
body = re.sub(
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->",
" ",
body,
flags=re.DOTALL,
)
body = re.sub(r"```.*?```", " ", body, flags=re.DOTALL)
text = f"{title}\n{body}"
cjk_count = len(re.findall(r"[\u4e00-\u9fff]", text))
latin_words = len(re.findall(r"\b[A-Za-z][A-Za-z]{2,}\b", text))
if cjk_count >= 4:
response_language = "zh-CN"
summary_heading = "PR-Agent 摘要"
summary_language = "中文"
elif latin_words >= 8:
response_language = "en-US"
summary_heading = "PR-Agent Summary"
summary_language = "English"
else:
response_language = "zh-CN"
summary_heading = "PR-Agent 摘要"
summary_language = "中文"
print(f"response_language={response_language}")
print(f"summary_heading={summary_heading}")
print(f"summary_language={summary_language}")
print(f"skip_pr_agent={skip_pr_agent}")
print(f"head_sha={head_sha}")
PY
- name: Prepare PR-Agent description markers
id: prepare_description
if: >-
steps.pr_language.outputs.skip_pr_agent != 'true' &&
(
github.event_name == 'pull_request_target' ||
(
github.event_name == 'issue_comment' &&
(
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ')
)
)
)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
SUMMARY_HEADING: ${{ steps.pr_language.outputs.summary_heading }}
run: |
set -euo pipefail
current_body="$(mktemp)"
next_body="$(mktemp)"
payload="$(mktemp)"
body_backup="${RUNNER_TEMP}/pr-agent-body-before-describe.md"
placeholder_body="${RUNNER_TEMP}/pr-agent-body-with-placeholder.md"
gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""' > "${current_body}"
cp "${current_body}" "${body_backup}"
python3 - "${current_body}" "${next_body}" <<'PY'
import os
import re
import sys
from pathlib import Path
current_path = Path(sys.argv[1])
next_path = Path(sys.argv[2])
body = current_path.read_text()
start = "<!-- pr-agent-summary:start -->"
end = "<!-- pr-agent-summary:end -->"
placeholder = "pr_agent:summary"
summary_heading = os.environ.get("SUMMARY_HEADING") or "PR-Agent 摘要"
agent_block = f"## {summary_heading}\n\n{start}\n{placeholder}\n{end}\n"
body = re.sub(
r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*(?=<!-- pr-agent-summary:start -->)",
f"## {summary_heading}\n\n",
body,
)
start_index = body.find(start)
end_index = body.find(end)
if start_index >= 0 and end_index > start_index:
next_body = body[: start_index + len(start)] + f"\n{placeholder}\n" + body[end_index:]
else:
separator = "\n\n" if body.strip() else ""
next_body = body.rstrip() + separator + agent_block
next_path.write_text(next_body)
PY
cp "${next_body}" "${placeholder_body}"
body_changed=false
if ! cmp -s "${current_body}" "${next_body}"; then
python3 - "${next_body}" "${payload}" <<'PY'
import json
import sys
from pathlib import Path
body = Path(sys.argv[1]).read_text()
Path(sys.argv[2]).write_text(json.dumps({"body": body}, ensure_ascii=False))
PY
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
body_changed=true
fi
echo "body_changed=${body_changed}" >> "${GITHUB_OUTPUT}"
- name: Snapshot PR-Agent inline comments
id: inline_state_before
if: >-
steps.pr_language.outputs.skip_pr_agent != 'true' &&
(
github.event_name == 'pull_request_target' ||
(
github.event_name == 'issue_comment' &&
(
github.event.comment.body == '/improve' ||
startsWith(github.event.comment.body, '/improve ')
)
)
)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
set -euo pipefail
inline_ids="$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/comments?per_page=100" --jq '.[] | select(.user.login == "github-actions[bot]") | .id' | jq -sc '.')"
inline_ids_b64="$(printf '%s' "${inline_ids}" | base64 -w0)"
echo "inline_ids_b64=${inline_ids_b64}" >> "${GITHUB_OUTPUT}"
- name: Run PR-Agent
id: pragent
if: steps.pr_language.outputs.skip_pr_agent != 'true'
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
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: ${{ steps.pr_language.outputs.response_language }}
config.large_patch_policy: "clip"
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
config.ignore_pr_labels: '["skip pr-agent"]'
# PR 初次进入评审或后续 push 时,更新 PR 摘要并发布 GitHub Review 行内建议。
github_action_config.auto_review: "false"
github_action_config.auto_describe: "true"
github_action_config.auto_improve: "true"
# synchronize 由 push_commands 单独处理;每次 push 更新摘要和行内 Review旧行评由 GitHub 标记 outdated。
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]'
github_action_config.handle_push_trigger: "true"
github_action_config.push_commands: '["/describe", "/improve"]'
# 保留 action outputs便于后续 workflow 编排或排查。
github_action_config.enable_output: "true"
# /describe 行为控制;只更新 PR body 中的 PR-Agent 摘要占位符。
pr_description.generate_ai_title: "false"
pr_description.publish_labels: "false"
pr_description.publish_description_as_comment: "false"
pr_description.publish_description_as_comment_persistent: "false"
pr_description.enable_pr_diagram: "false"
pr_description.enable_pr_type: "false"
pr_description.enable_help_text: "false"
pr_description.enable_help_comment: "false"
pr_description.enable_semantic_files_types: "false"
pr_description.collapsible_file_list: "adaptive"
pr_description.add_original_user_description: "true"
pr_description.use_description_markers: "true"
pr_description.final_update_message: "false"
pr_description.extra_instructions: |
Match the configured response language.
Generate a moderately detailed PR summary covering the change goal, key implementation details, configuration or compatibility impact, tests, and notable risks.
Use 2-4 bullets for small PRs; use 4-8 bullets for feature or multi-file PRs.
Avoid low-value file lists and local command transcripts.
# /improve 以 GitHub 内联建议呈现,便于像正式 review discussion 一样逐条处理。
pr_code_suggestions.extra_instructions: |
Match the configured response language.
Only provide substantive issues that maintainers should address; avoid style-only, preference-only, or low-value suggestions.
For prioritized issues, start the suggestion body with one of these Markdown prefixes: 🔴 **High Risk**:, 🟡 **Medium Risk**:, or 🔵 **Low Risk**:.
pr_code_suggestions.focus_only_on_problems: "true"
pr_code_suggestions.suggestions_score_threshold: "3"
pr_code_suggestions.num_code_suggestions_per_chunk: "3"
pr_code_suggestions.commitable_code_suggestions: "true"
pr_code_suggestions.publish_output_no_suggestions: "false"
pr_questions.use_conversation_history: "true"
# 可选成本和噪音控制:
# github_action_config.auto_improve: "true"
# config.verbosity_level: "1"
- name: Publish PR-Agent code review summary
if: >-
steps.pr_language.outputs.skip_pr_agent != 'true' &&
(
github.event_name == 'pull_request_target' ||
(
github.event_name == 'issue_comment' &&
(
github.event.comment.body == '/improve' ||
startsWith(github.event.comment.body, '/improve ')
)
)
)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
BEFORE_INLINE_IDS_B64: ${{ steps.inline_state_before.outputs.inline_ids_b64 }}
RUN_HEAD_SHA: ${{ steps.pr_language.outputs.head_sha }}
SUMMARY_LANGUAGE: ${{ steps.pr_language.outputs.summary_language }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
SUMMARY_MODEL: gpt-5.5
run: |
set -euo pipefail
before_ids="$(printf '%s' "${BEFORE_INLINE_IDS_B64:-W10=}" | base64 -d)"
pr_info="$(mktemp)"
comments="$(mktemp)"
review_data="$(mktemp)"
payload="$(mktemp)"
gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pr_info}"
CURRENT_HEAD_SHA="$(jq -r '.head.sha' "${pr_info}")"
HEAD_SHA="${RUN_HEAD_SHA:-${CURRENT_HEAD_SHA}}"
if [ "${CURRENT_HEAD_SHA}" != "${HEAD_SHA}" ]; then
echo "PR head changed from ${HEAD_SHA} to ${CURRENT_HEAD_SHA}; skip stale code review summary."
exit 0
fi
short_sha="${HEAD_SHA:0:7}"
pr_url="https://github.com/${REPO}/pull/${PR_NUMBER}"
commit_url="https://github.com/${REPO}/commit/${HEAD_SHA}"
gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/comments?per_page=100" --jq '.[] | @json' > "${comments}"
python3 - "${before_ids}" "${comments}" "${review_data}" "${HEAD_SHA}" "${pr_url}" "${commit_url}" "${short_sha}" <<'PY'
import json
import sys
from pathlib import Path
before_ids = set(json.loads(sys.argv[1] or "[]"))
comments_path = Path(sys.argv[2])
output_path = Path(sys.argv[3])
head_sha = sys.argv[4]
pr_url = sys.argv[5]
commit_url = sys.argv[6]
short_sha = sys.argv[7]
comments = []
for line in comments_path.read_text().splitlines():
if line.strip():
comments.append(json.loads(line))
new_comments = [
item for item in comments
if item.get("user", {}).get("login") == "github-actions[bot]"
and item.get("id") not in before_ids
and item.get("commit_id") == head_sha
]
suggestions = []
for item in new_comments:
body = (item.get("body") or "").strip()
first_line = next((line.strip() for line in body.splitlines() if line.strip()), "")
suggestions.append({
"path": item.get("path"),
"line": item.get("line") or item.get("start_line"),
"url": item.get("html_url"),
"summary": first_line[:500],
"body": body[:1500],
})
output_path.write_text(json.dumps({
"pr_url": pr_url,
"commit_url": commit_url,
"short_sha": short_sha,
"suggestions": suggestions,
}, ensure_ascii=False))
PY
python3 - "${review_data}" "${payload}" <<'PY'
import json
import os
import textwrap
import urllib.error
import urllib.request
from pathlib import Path
review_data = json.loads(Path(os.sys.argv[1]).read_text())
payload_path = Path(os.sys.argv[2])
suggestions = review_data["suggestions"]
marker = "<!-- pr-agent-code-review-summary -->"
summary_language = os.environ.get("SUMMARY_LANGUAGE") or "中文"
use_chinese = summary_language != "English"
def fallback_summary() -> str:
if not suggestions:
if use_chinese:
return "已审查本次变更,未发现需要进一步反馈或调整的问题。"
return "Reviewed this change and found no further feedback or required adjustments."
if use_chinese:
lines = [f"本轮代码审查新增 {len(suggestions)} 条行内建议,建议优先查看以下位置:"]
else:
noun = "suggestion" if len(suggestions) == 1 else "suggestions"
lines = [f"This review added {len(suggestions)} inline {noun}. Consider reviewing these locations first:"]
for item in suggestions[:5]:
location = f"{item.get('path')}:{item.get('line')}" if item.get("line") else str(item.get("path"))
summary = item.get("summary") or "查看行内建议"
url = item.get("url")
if use_chinese:
lines.append(f"- [{location}]({url}){summary}" if url else f"- {location}{summary}")
else:
lines.append(f"- [{location}]({url}): {summary}" if url else f"- {location}: {summary}")
if len(suggestions) > 5:
if use_chinese:
lines.append(f"- 其余 {len(suggestions) - 5} 条请在 Files changed 的行内评论中查看。")
else:
lines.append(f"- Review the remaining {len(suggestions) - 5} inline comments in Files changed.")
return "\n".join(lines)
def llm_summary() -> str | None:
if not suggestions:
return None
api_base = (os.environ.get("OPENAI_API_BASE") or "").rstrip("/")
api_key = os.environ.get("OPENAI_KEY") or ""
model = os.environ.get("SUMMARY_MODEL") or "gpt-5.5"
if not api_base or not api_key:
return None
if use_chinese:
task = (
"你是独立的代码审查摘要助手。只基于本轮已经发布的行内审查意见做简短汇总,"
"不新增审查结论,不替维护者判断 PR 是否可以合并。请用中文 Markdown 输出:"
"第一段说明本轮审查已完成,并已在行内留下需要关注的建议;如有建议,"
"概括 1-3 个主要关注点,使用“建议关注”“可能影响”“可优先查看”等中立表述。"
"不要输出表格,不要复述所有文件,不要使用“可以合并”“不建议合并”“阻塞合并”"
"“先处理后再合入”等合并裁决措辞。"
)
system_prompt = "你是严谨、中立的代码审查摘要助手。输出中文 Markdown简洁自然。"
else:
task = (
"You are an independent code review summarizer. Summarize only the inline review comments "
"already posted in this run; do not add new review conclusions or decide whether the PR should merge. "
"Write concise Markdown in English. Start by noting that review completed and inline suggestions "
"were left; then summarize 1-3 main points using neutral phrasing such as \"consider\", "
"\"may affect\", or \"worth reviewing\". Do not output tables, list every file, or use merge-gate "
"wording such as \"ready to merge\", \"do not merge\", \"blocks merging\", or \"must be fixed before merge\"."
)
system_prompt = "You are a rigorous, neutral code review summarizer. Write concise English Markdown."
user_content = {
"task": task,
"suggestions": suggestions[:10],
}
request_body = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(user_content, ensure_ascii=False)},
],
"temperature": 0.2,
}
request = urllib.request.Request(
f"{api_base}/chat/completions",
data=json.dumps(request_body).encode("utf-8"),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
data = json.loads(response.read().decode("utf-8"))
content = data["choices"][0]["message"]["content"].strip()
return content or None
except (KeyError, TimeoutError, urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError):
return None
summary = llm_summary() or fallback_summary()
commit_label = "审查提交:" if use_chinese else "Reviewed commit:"
body = textwrap.dedent(f"""\
{marker}
## Code Review
{summary}
{commit_label} [{review_data["short_sha"]}]({review_data["commit_url"]})
""")
payload_path.write_text(json.dumps({"body": body}, ensure_ascii=False))
PY
new_comment_id="$(gh api --method POST "repos/${REPO}/issues/${PR_NUMBER}/comments" --input "${payload}" --jq '.id')"
comment_ids="$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" --jq ".[] | select(.id != ${new_comment_id} and .user.login == \"github-actions[bot]\" and ((.body | startswith(\"<!-- pr-agent-code-review-summary -->\")) or (.body | startswith(\"<!-- pr-agent-update-notification -->\")))) | .id")"
if [ -z "${comment_ids}" ]; then
echo "No previous PR-Agent code review summary to clean."
exit 0
fi
while IFS= read -r comment_id; do
[ -z "${comment_id}" ] && continue
gh api --method DELETE "repos/${REPO}/issues/comments/${comment_id}" >/dev/null
done <<< "${comment_ids}"
- name: Restore PR-Agent description markers on failure
if: >-
failure() &&
steps.prepare_description.outputs.body_changed == 'true' &&
steps.pr_language.outputs.skip_pr_agent != 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
set -euo pipefail
body_backup="${RUNNER_TEMP}/pr-agent-body-before-describe.md"
placeholder_body="${RUNNER_TEMP}/pr-agent-body-with-placeholder.md"
current_body="$(mktemp)"
payload="$(mktemp)"
if [ ! -s "${body_backup}" ] || [ ! -s "${placeholder_body}" ]; then
echo "No PR body backup found."
exit 0
fi
gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""' > "${current_body}"
python3 - "${body_backup}" "${placeholder_body}" "${current_body}" "${payload}" <<'PY'
import json
import re
import sys
from pathlib import Path
backup_body = Path(sys.argv[1]).read_text()
placeholder_body = Path(sys.argv[2]).read_text()
current_body = Path(sys.argv[3]).read_text()
payload_path = Path(sys.argv[4])
start = "<!-- pr-agent-summary:start -->"
end = "<!-- pr-agent-summary:end -->"
heading_re = re.compile(r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*")
def find_block(body: str) -> tuple[int, int] | None:
start_index = body.find(start)
end_index = body.find(end)
if start_index < 0 or end_index <= start_index:
return None
end_index += len(end)
heading_start = start_index
prefix = body[:start_index]
matches = list(heading_re.finditer(prefix))
if matches:
last = matches[-1]
if prefix[last.end():].strip() == "":
heading_start = last.start()
return heading_start, end_index
current_block = find_block(current_body)
placeholder_block = find_block(placeholder_body)
backup_block = find_block(backup_body)
if not current_block or not placeholder_block:
print("No PR-Agent summary block to restore.")
raise SystemExit(0)
current_section = current_body[current_block[0]:current_block[1]]
placeholder_section = placeholder_body[placeholder_block[0]:placeholder_block[1]]
if "pr_agent:summary" not in current_section:
print("No visible PR-Agent summary placeholder to restore.")
raise SystemExit(0)
if current_section != placeholder_section:
print("Current PR-Agent summary block changed; skip restore.")
raise SystemExit(0)
restored_section = backup_body[backup_block[0]:backup_block[1]] if backup_block else ""
next_body = current_body[:current_block[0]] + restored_section + current_body[current_block[1]:]
next_body = re.sub(r"\n{4,}", "\n\n\n", next_body).rstrip() + "\n"
payload_path.write_text(json.dumps({"body": next_body}, ensure_ascii=False))
PY
if [ -s "${payload}" ]; then
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
fi
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

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

@@ -0,0 +1,93 @@
name: Frontend Tests
on:
pull_request:
branches:
- v3
push:
branches:
- v3
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
format:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- 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: Check changed-file formatting
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: yarn format:check --base "$BASE_SHA" --head "$HEAD_SHA"
lint:
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: Lint
run: yarn lint
- name: Typecheck
run: yarn typecheck
typecheck-and-tests:
runs-on: ubuntu-latest
name: Unit tests (${{ matrix.shard }})
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard:
- 1/2
- 2/2
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: Unit tests
run: yarn test:run --shard=${{ matrix.shard }} --silent=passed-only

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ node_modules
dist
dist-ssr
dev-dist
coverage
*.local
package-lock.json

1
.node-version Normal file
View File

@@ -0,0 +1 @@
24

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

View File

@@ -1,2 +1,9 @@
dist
node_modules
node_modules
/.worktrees/
# 自动生成文件的格式由各自生成器决定。
auto-imports.d.ts
components.d.ts
eslint-suppressions.json
/vite.config.*.timestamp-*.mjs

View File

@@ -3,7 +3,7 @@
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"bracketSameLine": false,
"jsxSingleQuote": true,
"printWidth": 120,
"proseWrap": "preserve",

View File

@@ -2,7 +2,7 @@
*中文 | [English](README_EN.md)*
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目,NodeJS版本>= `v20.12.1`
[MoviePilot](https://github.com/jxxghp/MoviePilot) 的前端项目,最低支持 Node.js `20.19`,推荐使用 Node.js `24`
## 特性
@@ -40,6 +40,17 @@ yarn dev
yarn build
```
### 单元测试
```sh
yarn test:run
yarn test:coverage
```
测试文件组织、共享测试设施、HTTP mock、覆盖率门禁和新增用例规范见[单元测试架构](docs/testing.md)。
ESLint、Prettier、Node 兼容范围及渐进式 CI 门禁见[前端代码质量工具链演进](docs/code-quality.md)。
### 静态运行
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件nginx配置参考 `public/nginx.conf`

View File

@@ -1,8 +1,9 @@
# MoviePilot-Frontend
*[中文](README.md) | English*
_[中文](README.md) | English_
Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS version required: >= `v20.12.1`.
Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot). The minimum supported Node.js version is
`20.19`; Node.js `24` is recommended.
## Features
@@ -17,7 +18,7 @@ Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (disable Vetur).
### Configure Vite
### Configure Vite
See [Vite Configuration Reference](https://vitejs.dev/config/).
@@ -39,6 +40,17 @@ yarn dev
yarn build
```
### Unit Tests
```sh
yarn test:run
yarn test:coverage
```
See the [unit testing architecture](docs/testing.md) for test organization, shared facilities, HTTP fixtures, and
coverage conventions. See the [frontend code quality evolution](docs/code-quality.md) for ESLint, Prettier, Node.js
compatibility, and incremental CI gates.
### Static Deployment
1. Host the `dist` static files using a web server like `nginx`. Refer to `public/nginx.conf` for nginx configuration.
@@ -47,7 +59,7 @@ yarn build
```shell
node dist/service.js
```
```
### Module Federation

191
docs/code-quality.md Normal file
View File

@@ -0,0 +1,191 @@
# 前端代码质量工具链演进
本文档定义 MoviePilot-Frontend 的代码质量工具链、运行入口和渐进式演进路线。目标是让人工开发者和自动化 Agent 都通过同一组项目命令完成格式化、静态检查、类型检查、测试和构建,并由 CI 在干净环境中复现结果;编辑器及其扩展只提供即时反馈,不作为项目行为的事实源。
## 工具职责
| 工具 | 职责 | 不负责 |
| ---------------------- | ------------------------------------------------------------ | -------------------------------------- |
| ESLint | JavaScript、TypeScript、Vue 代码质量,框架约束和项目模块边界 | 缩进、换行、引号、属性布局等代码格式 |
| Prettier | 可确定、可重复的代码格式 | 未使用变量、Vue 规则、复杂度和模块边界 |
| TypeScript / `vue-tsc` | TypeScript 与 Vue 模板类型检查 | 代码格式和业务行为测试 |
| Vitest | 单元测试、组件测试和按需覆盖率报告 | 生产构建与真实浏览器行为 |
| Vite | 生产构建和构建期集成验证 | 类型完整性和代码质量规则 |
ESLint 配置由仓库显式维护,不继承 Antfu 等覆盖面较大的个人风格预设。JavaScript、TypeScript 与 Vue 使用各自面向正确性的 recommended/essential 基线SonarJS 不整包展开 recommended而只显式启用安全和正确性的高信号规则避免插件升级隐式扩大检查范围。
历史规则迁移前必须核对当前源码和运行职责,不能仅因为旧配置出现过就写入 baseline。当前保留 Vuetify 点号 slot 兼容、`@layouts` 不反向依赖 `@core`、浏览器与 Node globals 分域及统一禁止 `debugger`。图片/样式 alias 强制只约束 import 拼写且已与当前实践脱节;`useLayouts` 目录限制所针对的 API 从未存在于源码;重复字符串属于高噪声风格治理;伪随机数限制会把界面效果误判为安全问题,因此这四类历史规则不再作为门禁。
Prettier 独立运行ESLint 中与格式重叠的规则保持关闭。任何编辑器都可以调用项目内 ESLint 和 Prettier但保存时是否自动执行不影响 CI 结果。
## Node 与依赖边界
- 最低兼容目标为 Node.js 20.19Node.js 20.19、22 和 24 的开发环境可以安装依赖并运行项目命令。
- 推荐开发环境和 CI 主环境使用 Node.js 24。
- CI 若继续声明兼容 Node.js 20.19,应至少在该版本验证 frozen lockfile 安装、lint、typecheck 和 build主测试 job 使用 Node.js 24 执行单元测试。
- Yarn 1 与 `yarn.lock` 继续作为依赖安装事实源CI 使用 `yarn --frozen-lockfile`
- Node 只用于依赖安装、开发、测试和前端构建MoviePilot 正式 Docker 镜像使用预构建前端产物,不因开发工具链升级而增加 Node 运行时。
最低 Node 版本变化属于开发环境兼容性变更,必须独立说明并在 CI 验证。不得仅提高 `package.json``engines.node` 而不更新 README、版本文件和 workflow。
## 统一命令
工具链完成演进后,仓库提供以下入口:
```sh
yarn lint # 只读 ESLint 检查,不修改文件
yarn lint:fix # 显式执行 ESLint 安全修复
yarn lint:suppressions:prune # 修复存量问题后裁剪过期 baseline
yarn format # 格式化当前分支、暂存区、工作区和未跟踪文件中的受支持变更文件
yarn format:check # 检查相同变更文件,不修改文件
yarn format:all:check # 只读测量全仓格式收敛情况
yarn typecheck
yarn test:run # 本地默认并行执行两个 Vitest shard
yarn test:run --serial
yarn test:coverage
yarn build
```
`yarn lint` 不得包含 `--fix`。CI 只运行只读命令,任何自动修改都由开发者或 Agent 在提交前显式执行。
## ESLint 演进:先恢复可执行门禁
ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业务代码修复。
第一阶段完成以下事项:
1. 使用 ESLint 10 flat config移除旧 `.eslintrc`
2. 不恢复已删除的 `@antfu/eslint-config-vue`
3. 显式组合 JavaScript、TypeScript、Vue 和经过审计的 SonarJS 规则。
4. 使用 ESLint 原生 AST 限制维护仍成立的模块边界,并删除只约束 import 拼写或已无现实对象的历史 regex 规则。
5. 只检查 JavaScript、TypeScript、Vue 与相关测试文件Markdown、JSON、YAML、TOML、构建产物和生成文件不在首阶段扩展范围内。
6. 默认 lint 只读,并提供单独的 `lint:fix`
7. 对确有价值但存在存量的问题使用明确基线或分阶段启用,不通过全仓自动修复制造大面积混合变更。
8. 项目边界与新增问题门禁使用 Vitest 配置契约测试保护,并随现有 `test:run` CI 自动执行。
`eslint-suppressions.json` 只冻结迁移时已经确认的文件、规则和数量。新增问题不得加入 baseline修复存量问题后运行 `yarn lint:suppressions:prune`,并把裁剪结果与代码修复一同提交。日常开发和 CI 不得使用 `--suppress-all`
baseline 是刻意设计的渐进治理机制不等于被抑制的违规已被认可为长期编码规范。2026-08-24 的基线快照为
141 个文件、564 项受控违规、23 条规则,其中 `@typescript-eslint/no-explicit-any` 300 项、
`vue/no-mutating-props` 82 项、`@typescript-eslint/no-unused-vars` 64 项。生产 PR 修改到某个受控文件时,
应审计并偿还该文件内不改变业务语义且可安全处理的存量,随后 prune纯测试、文档或 CI 变更不为清理
未触及生产文件扩大 diff。
迁移完成的最低验收为:
```sh
yarn --frozen-lockfile
yarn lint
yarn typecheck
yarn test:run
yarn build
```
若新配置导致大量与业务目标无关的格式、排序或额外文件类型问题,应停止扩大规则范围,先调整配置职责;不得以一次性自动修复数百个文件作为通过迁移的手段。
## ESLint CI先观察再强制
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job
1. `lint` job 集中执行 ESLint 与 typecheck单元测试使用独立的两分片 job使静态检查与测试关键路径并行本地默认并行执行两个 Vitest shard并与 CI 共用 `yarn test:run` 入口。
2. 初始阶段作为普通 check 运行,不立即配置 required check。
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint` / `yarn typecheck`,不执行自动修复或更新 baseline。
4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
5. 连续多个 PR 稳定通过后,再由维护者决定是否设为 required。
ESLint CI 必须检查全仓受管源码,而不是只检查 PR 变更文件。已有问题通过规则选择或受控基线管理,保证新问题不能借由“只检查改动行”绕过项目约束。
## Prettier 演进:改到即格式化
Prettier 采用与后端 `unittest → pytest` 相同的渐进原则:不为转换而转换,修改到存量文件时再纳入统一格式。
- **新文件**:必须通过当前 Prettier 配置。
- **修改文件**Agent 或开发者在提交前对本次触及的受支持文件运行 Prettier。
- **未修改存量文件**:暂时保持原样,不建立一次性全仓格式化任务。
- **大型或高冲突文件**:若格式化产生大量机械差异,格式调整与业务修改拆分为独立 commit必要时暂缓该文件并记录原因。
- **生成文件**:按生成器事实源管理,不直接用 Prettier 修补生成结果;需要统一格式时修改生成器或生成配置。
Prettier 初始 CI 只检查 Pull Request 新增或修改的受支持文件,并作为变更范围门禁运行。检查脚本应:
1. 从 PR base 与 head 计算文件集合。
2. 排除已删除文件、构建产物、覆盖率报告和生成文件。
3. 仅把 Prettier 支持且由仓库管理的文件传给 `prettier --check`
4. 文件集合为空时正常通过。
本地直接运行 `yarn format``yarn format:check` 时,脚本按 `refs/remotes/upstream/v3``refs/remotes/origin/v3``refs/heads/v3` 的顺序选择与当前提交具有共同祖先的基线并合并当前工作区与未跟踪文件。完整引用可避免同名标签造成歧义CI 使用显式提交 SHA 保证输入可复现:
```sh
yarn format:check --base <base-sha> --head <head-sha>
```
文件名通过 Git 的 NUL 分隔输出和 Node 参数数组传递,不经过 shell 字符串拼接重命名只检查新路径删除文件、ignore 文件和 Prettier 无法推断 parser 的文件不进入格式检查。
该门禁不要求未修改的存量文件通过 Prettier但所有进入 PR diff 的受支持文件都必须通过。Prettier 是文件级格式化工具因此修改存量文件时检查的是整个文件而不是仅检查变更行。Agent 或开发者应在提交前运行同一项目脚本完成格式化CI 只验证结果,不自动提交修改。
禁止在普通业务 PR 中直接运行 `prettier . --write` 或其他全仓格式写入命令。`yarn format` 只处理选择器返回的变更文件;单个存量文件被触及时仍可能整体重排,差异过大时应拆分 commit便于 reviewer 区分机械格式与业务逻辑。
具体交付拆分为:
1. Prettier 基础设施 PR安装项目 CLI定义配置、命令、变更文件选择脚本和回归测试不修改 workflow。
2. 变更文件门禁 PR复用已合并脚本接入 Pull Request workflow验证 fork PR、文件重命名、删除文件、空文件集和特殊文件名。初期作为普通 check 观察,稳定后再由维护者通过仓库规则独立配置为 required。
3. 全仓门禁 PR仅在存量收敛条件满足后择机实施不与变更文件门禁绑定。
## 全仓格式门禁的启用条件
不单独安排大爆炸式格式化阶段。存量通过日常“改到即格式化”逐步收敛,并定期执行全仓 `yarn format:all:check` 观察剩余范围。
Prettier 3.9.5 基础设施接入时,全仓只读检查在 `v2` 基线报告 203 个存量文件需要格式化。该数字是收敛起点而不是忽略白名单;变更文件仍必须完整通过检查,存量数量随日常修改逐步下降。
变更文件 CI 接入前在 `v2` 再次测量为 206 个文件,较基础设施合并后的 201 个文件出现回升。这说明仅提供本地命令不足以阻止新改动扩大存量;变更文件 check 用于守住新增 diff不因此要求在本阶段批量格式化已有文件。
2026-08-25 在 `upstream/v3@c2b21f7e` 使用仓库锁定的 Prettier 3.9.5 运行
`yarn format:all:check`,剩余 112 个存量文件不符合格式Vue 36 个、TypeScript 29 个、SCSS 26 个、JSON 5 个、
YAML 4 个、代码片段 4 个、JavaScript 3 个、Markdown 3 个、HTML 2 个。该快照用于观察渐进收敛趋势,
不改变“只检查和格式化本次触及文件”的日常边界,也不触发全仓格式化提交。
变更文件 required check 不需要等待全仓收敛。只有同时满足以下条件,才把 Prettier 从变更文件 required check 切换为全仓 required check
- 全仓 `yarn format:all:check` 已通过,或仅剩少量可在独立机械提交中安全处理的文件。
- 最近的活跃分支已合并或完成同步,避免集中格式变化制造冲突。
- `yarn lint``yarn typecheck``yarn test:run``yarn build` 在格式收敛后全部通过。
- Prettier 与 ESLint 不存在反复改写同一文件的规则冲突。
- workflow 已以非 required 状态稳定运行多个 PR。
最终 Pull Request 门禁目标为:
```sh
yarn --frozen-lockfile
yarn format:all:check
yarn lint
yarn typecheck
yarn test:run
yarn build
```
PR-Agent、编辑器诊断和人工 review 可以补充判断,但不能替代可重复执行的项目命令。
## Agent 与开发者提交规范
无论改动由 Agent 还是人工完成,提交前遵循同一顺序:
1. 只对本次新增或修改的文件执行 Prettier。
2. 运行全仓只读 lint。
3. 运行 typecheck。
4. 按影响面运行聚焦测试;覆盖率报告按需本地运行,不作为 Actions 门禁。
5. 涉及构建配置、依赖、Vue SFC 或模块联邦时运行生产 build。
编辑器设置不得成为通过验证的前提。文档、Agent 指令和 PR 说明应引用 `yarn` 命令,而不是“在 VS Code 保存一次”或依赖某个 IDE 扩展完成修复。
## 风险与回滚
| 风险 | 控制方式 | 回滚边界 |
| ---------------------------- | ------------------------------------------ | ------------------------------ |
| ESLint 自动修复改变语义 | 默认 lint 只读;基础设施迁移不批量 fix | 独立回退规则或依赖提交 |
| 格式化制造大面积冲突 | 改到即格式化;大型文件拆分机械 commit | 回退单文件格式 commit |
| 新规则让所有 PR 变红 | 先测量、再启用;使用受控基线 | 暂停单条规则,不回退整套工具链 |
| 检查范围扩展到生成物或文档 | 首阶段限制文件类型并显式 ignore | 收窄 flat config 文件匹配 |
| Node 最低版本阻断安装 | 最低保持 20.19Node 24 作为推荐和主 CI | 保持兼容 job版本提升独立处理 |
| 全仓 required check 配置过早 | 先强制变更文件,持续观察全仓剩余范围 | 保留变更文件门禁,暂缓全仓切换 |
| ESLint 与 Prettier 循环改写 | ESLint 关闭格式规则Prettier 单独负责格式 | 关闭冲突规则并增加回归样例 |
依赖迁移、规则迁移、存量代码修复、格式化和 GitHub required checks 应保持可独立评审、验证和回退,禁止合并成一个难以定位风险的大型变更。

View File

@@ -6,7 +6,6 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
关联阅读后端插件开发文档:[第三方插件开发说明](https://github.com/jxxghp/MoviePilot-Plugins/blob/main/README.md)
## 2. 技术要求
- Node.js 20+
@@ -18,13 +17,13 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
每个 Vue 联邦插件需要提供下列标准组件(`AppPage` 为可选,用于主界面侧栏全页入口):
| 组件名称 | 暴露名 | 文件名 | 用途 |
|---------|--------|--------|------|
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
| Config | `./Config` | Config.vue | 插件配置页面 |
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
| 组件名称 | 暴露名 | 文件名 | 用途 |
| --------- | ---------------- | ---------------------- | --------------------------------------------- |
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
| Config | `./Config` | Config.vue | 插件配置页面 |
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
主应用在侧栏全页路由中按 `nav_key` 解析暴露名(如 `AppPageSettings`),再回退 `AppPage``Page``nav_key``main` 时仅尝试 `AppPage``Page`
@@ -79,55 +78,52 @@ export default defineConfig({
singleton: true,
},
},
format: 'esm'
})
format: 'esm',
}),
],
build: {
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: true, // 改为true以便能分离样式文件
},
css: {
preprocessorOptions: {
scss: {
additionalData: '/* 覆盖vuetify样式 */',
}
},
},
postcss: {
plugins: [
{
postcssPlugin: 'internal:charset-removal',
AtRule: {
charset: (atRule) => {
charset: atRule => {
if (atRule.name === 'charset') {
atRule.remove();
atRule.remove()
}
}
}
},
},
},
{
postcssPlugin: 'vuetify-filter',
Root(root) {
// 过滤掉所有vuetify相关的CSS
root.walkRules(rule => {
if (rule.selector && (
rule.selector.includes('.v-') ||
rule.selector.includes('.mdi-'))) {
rule.remove();
if (rule.selector && (rule.selector.includes('.v-') || rule.selector.includes('.mdi-'))) {
rule.remove()
}
});
}
}
]
}
})
},
},
],
},
},
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001',
},
})
})
```
## 5. 组件开发规范
@@ -139,12 +135,18 @@ export default defineConfig({
// 自定义事件,用于通知主应用刷新数据
const emit = defineEmits(['action', 'switch', 'close'])
// 接收API对象
// 接收主应用能力
const props = defineProps({
api: {
type: Object,
default: () => {}
}
default: () => {},
},
pluginId: { type: String, default: '' },
sourcePluginId: { type: String, default: '' },
nativeSubscribe: {
type: Function,
default: null,
},
})
// 页面逻辑代码...
@@ -179,20 +181,26 @@ function notifyClose() {
```vue
<script setup lang="ts">
// 接收初始配置和API对象
// 接收初始配置和主应用能力
const props = defineProps({
initialConfig: {
type: Object,
default: () => ({})
default: () => ({}),
},
api: {
type: Object,
default: () => {}
}
default: () => {},
},
pluginId: { type: String, default: '' },
sourcePluginId: { type: String, default: '' },
nativeSubscribe: {
type: Function,
default: null,
},
})
// 配置数据
const config = ref({...props.initialConfig})
const config = ref({ ...props.initialConfig })
// 自定义事件,用于保存配置
const emit = defineEmits(['save', 'close', 'switch'])
@@ -217,7 +225,7 @@ function notifyClose() {
<div class="plugin-config">
<!-- 配置表单示例 -->
<v-text-field v-model="config.someField" label="配置项"></v-text-field>
<!-- 保存按钮示例 -->
<v-btn color="primary" @click="saveConfig">保存配置</v-btn>
@@ -234,16 +242,23 @@ function notifyClose() {
```vue
<script setup lang="ts">
// 接收配置刷新控制
// 接收配置刷新控制和主应用能力
const props = defineProps({
config: {
type: Object,
default: () => ({})
default: () => ({}),
},
allowRefresh: {
type: Boolean,
default: true
}
default: true,
},
api: { type: Object, default: () => ({}) },
pluginId: { type: String, default: '' },
sourcePluginId: { type: String, default: '' },
nativeSubscribe: {
type: Function,
default: null,
},
})
// 仪表板逻辑...
@@ -276,18 +291,22 @@ const props = defineProps({
主应用传入的 props
| 属性 | 说明 |
|------|------|
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
| `pluginId` | 当前插件 ID |
| 属性 | 说明 |
| ----------------- | ----------------------------------------------------- |
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
| `nativeSubscribe` | 打开主应用原生订阅交互 |
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
| `pluginId` | 当前插件 ID |
| `sourcePluginId` | 虚拟分身共享资源的源插件 ID普通插件为空 |
```vue
<script setup lang="ts">
const props = defineProps({
api: { type: Object, default: () => ({}) },
nativeSubscribe: { type: Function, default: null },
navKey: { type: String, default: 'main' },
pluginId: { type: String, default: '' },
sourcePluginId: { type: String, default: '' },
})
const emit = defineEmits(['action'])
</script>
@@ -300,18 +319,198 @@ const emit = defineEmits(['action'])
</template>
```
### 5.5 主应用宿主能力
登录后的联邦组件宿主会向插件开放以下能力:
| 能力 | Page | Config | Dashboard | AppPage | 调用方式 |
| ---------------- | ---- | ------ | --------- | ------- | ---------------------------------------------------------------- |
| 认证 API | ✓ | ✓ | ✓ | ✓ | `api` prop |
| 当前实例 ID | ✓ | ✓ | ✓ | ✓ | `pluginId` prop |
| 共享源码 ID | ✓ | ✓ | ✓ | ✓ | `sourcePluginId` prop普通插件为空 |
| 原生订阅交互 | ✓ | ✓ | ✓ | ✓ | `nativeSubscribe` prop 或 `inject('moviepilot:nativeSubscribe')` |
| 主应用统一 Toast | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:toast')` |
| 主应用公共弹窗 | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:dialog')` |
| 主应用确认弹窗 | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:confirm')` |
`nativeSubscribe`、Toast、公共弹窗和确认弹窗都由主应用宿主提供。插件不应复制主程序订阅弹窗、创建另一套 Toast 容器或自行挂载全局弹窗。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。
V3 新建插件分身会复用源插件的同一份联邦产物。宿主传入的 `api` 已绑定当前
`pluginId`:即使旧组件仍调用 `plugin/<sourcePluginId>/...`,也会被映射到实例 API。
新组件应直接用 `pluginId` 拼接路径,并始终优先使用 `api` prop只读取全局
`window.MoviePilotAPI` 会绕过实例作用域,不适合多实例插件。
### 5.6 玻璃光学表面
主应用的 `Page``Config``AppPage` 宿主在玻璃主题下默认采用 `static-material` 光学模式:保留壁纸透射、材质色调和方向反射,但不响应指针流场、局部折射、拖尾或动态焦散。插件列表与 `Dashboard` 继续使用完整动态光学。视觉型插件可以在自己控制的 DOM 区域显式恢复完整动态光学:
```html
<div data-glass-optical-surface data-glass-optical-mode="dynamic">
<!-- 插件自己的视觉内容 -->
</div>
```
使用时需同时声明 `data-glass-optical-surface``data-glass-optical-mode="dynamic"`。模式会从最近的祖先容器继承,因此显式声明的动态子表面不会沿用宿主的静态模式。该合同适用于插件在 `Page``Config``AppPage` 中自行渲染并控制的区域;主应用生成的插件列表、插件市场卡片、`Dashboard` 及其他宿主 DOM 不属于插件的修改边界。
动态模式只在主应用启用玻璃主题和实时光学能力时生效。其他主题、降低动态效果或光学能力不可用时,插件必须保持内容与交互正常,不应依赖动态光学表达业务状态或必要反馈。
### 5.7 调用主应用原生订阅
`Page``Config``Dashboard``AppPage` 都会收到 `nativeSubscribe(mediaInfo)` prop。插件传入媒体信息后电视剧会打开主应用的选季抽屉电影会进入现有电影订阅流程。宿主也会用 `moviepilot:nativeSubscribe` 键提供同一个方法,深层子组件可以使用 `inject`,无需逐层传递 prop。
媒体信息必须包含:
- `type``电影` / `电视剧`,也兼容 `movie` / `tv`
- `title`
- 至少一个有效媒体标识:`tmdb_id` / `tmdbid``douban_id` / `doubanid``bangumi_id` / `bangumiid``anilist_id` / `anilistid`,或者 `media_id``mediaid_prefix` / `source` / `media_source` 的组合。
```vue
<script setup lang="ts">
import { inject } from 'vue'
type NativeSubscribeResult =
{ success: true } | { success: false; code: 'INVALID_MEDIA' | 'PERMISSION_DENIED'; message: string }
const props = defineProps<{
nativeSubscribe?: (mediaInfo: Record<string, unknown>) => Promise<NativeSubscribeResult>
}>()
const nativeSubscribe = inject('moviepilot:nativeSubscribe', props.nativeSubscribe)
/** 使用主应用订阅交互,宿主不接受时保留插件自己的 fallback。 */
async function subscribeMedia(mediaInfo: Record<string, unknown>) {
const result = await nativeSubscribe?.(mediaInfo)
if (!result?.success) {
// 插件可在这里执行自己的 fallback宿主已同时显示明确错误提示。
}
}
</script>
```
`success: true` 表示主应用已接受调用并启动原生交互,不表示用户已经完成订阅。字段无效或当前用户没有订阅权限时返回 `success: false`,插件可以依据 `code` 执行 fallback。
### 5.8 调用主应用 Toast
`Page``Config``Dashboard``AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器:
```vue
<script setup lang="ts">
import { inject } from 'vue'
interface HostToast {
error(message: string): unknown
info(message: string): unknown
success(message: string): unknown
warning(message: string): unknown
}
const toast = inject<HostToast | null>('moviepilot:toast', null)
// 保存完成后调用主应用的统一通知。
function saveComplete() {
toast?.success('保存成功')
}
</script>
```
可用方法与主项目 `vue-toastification` 一致,包括 `success``info``warning``error`。注入不存在时应静默降级,关键错误仍需保留页面内状态提示。
### 5.9 调用主应用公共弹窗
`Page``Config``Dashboard``AppPage` 的宿主容器会通过固定键提供主应用公共弹窗函数。该函数会将插件组件挂载到主应用 `App.vue``SharedDialogHost`,因此弹窗不会受插件页面、卡片或父级容器的层叠上下文限制。远程组件应复用该入口,不要自行创建额外的弹窗容器:
```vue
<script setup lang="ts">
import { inject, type Component } from 'vue'
interface DialogController {
id: number
close(): void
updateProps(props: Record<string, unknown>): void
}
type HostDialog = (
component: Component,
props?: Record<string, unknown>,
events?: Record<string, (...args: unknown[]) => unknown>,
options?: { closeOn?: string[] | false; replace?: boolean },
) => DialogController
const openDialog = inject<HostDialog | null>('moviepilot:dialog', null)
function openPluginDialog(DialogComponent: Component, props: Record<string, unknown>) {
return openDialog?.(
DialogComponent,
props,
{
close: () => {
// 处理插件弹窗关闭后的业务逻辑。
},
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
</script>
```
公共弹窗函数签名为 `openDialog(component, props, events, options)`,返回控制器:
- `closeOn`:收到指定事件后自动从公共层移除,默认监听 `close`;传 `false` 表示不自动关闭;
- `replace`:是否替换当前公共弹窗栈;
- `props``events`:也可以使用 `openDialogWithOptions` 的对象参数形式传入;
- `close()`:主动关闭当前弹窗;
- `updateProps(props)`:合并更新已打开弹窗的 props。
插件弹窗组件应提供 `close``update:modelValue` 事件,并自行处理组件内部交互。旧版主应用未提供该注入时,插件应保留页面内弹窗或其他 fallback。
### 5.10 调用主应用确认弹窗
确认弹窗使用独立的固定键,适合不需要自定义组件内容的确认场景:
```vue
<script setup lang="ts">
import { inject } from 'vue'
interface ConfirmOptions {
type?: 'info' | 'warn' | 'error'
title?: string
content?: string
confirmText?: string
cancelText?: string
width?: string | number
}
type HostConfirm = (options?: ConfirmOptions) => Promise<boolean>
const confirm = inject<HostConfirm | null>('moviepilot:confirm', null)
async function removeItem() {
const confirmed = await confirm?.({
type: 'warn',
title: '确认删除',
content: '删除后无法恢复,是否继续?',
})
if (!confirmed) return
// 执行删除请求。
}
</script>
```
确认弹窗返回 `Promise<boolean>`:用户点击确认时为 `true`,点击取消或关闭按钮时为 `false`;注入能力不存在时返回 `undefined`,插件应按未确认处理。可用配置项包括 `type``info` / `warn` / `error`)、`title``content``confirmText``cancelText``width`
#### 后端:注册侧栏入口
插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致:
| 字段 | 说明 |
|------|------|
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
| `title` | 侧栏显示标题 |
| `icon` | MDI 图标名,如 `mdi-rss` |
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
| 字段 | 说明 |
| ------------ | ------------------------------------------------------------------------------------- |
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
| `title` | 侧栏显示标题 |
| `icon` | MDI 图标名,如 `mdi-rss` |
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
| `permission` | 可选:`subscribe` / `discovery` / `search` / `manage` / `admin`,与主应用菜单权限一致 |
| `order` | 可选:同组内排序,数值越小越靠前 |
| `order` | 可选:同组内排序,数值越小越靠前 |
```python
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
@@ -333,16 +532,16 @@ def get_sidebar_nav(self) -> List[Dict[str, Any]]:
前端加载远程组件的顺序为:
| `nav_key` | 依次尝试的联邦暴露名 |
|-----------|----------------------|
| `main` 或省略 | `./AppPage``./Page` |
| `nav_key` | 依次尝试的联邦暴露名 |
| -------------------------------- | ------------------------------------------------ |
| `main` 或省略 | `./AppPage``./Page` |
| 其它(如 `settings``my_tool` | `./AppPage{PascalCase}``./AppPage``./Page` |
`PascalCase` 规则:按 `-``_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings``my_tool``./AppPageMyTool`
**两种实现方式(二选一或混用):**
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
2. **多文件**:为某个入口单独暴露 `./AppPageSettings.vue` 等,主应用会优先加载对应模块,失败再回退到 `AppPage`
`vite.config` 多暴露示例:
@@ -365,8 +564,7 @@ yarn build
- 将生成的dist文件夹上传到插件后端目录下默认为`dist/assets`
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*``_plugin-vue_export-helper-*``remoteEntry.js`
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*``_plugin-vue_export-helper-*``remoteEntry.js`
- 在插件的后端python代码中实现以下方法来集成远程组件
@@ -380,10 +578,11 @@ def get_render_mode() -> Tuple[str, str]:
return "vue", "dist/assets"
```
- 需要在插件前端页面调用后端接口时通过传入的api模块发起调用后端api接口声明认证类型为`bear`
- 需要在插件前端页面调用后端接口时通过传入的api模块发起调用后端api接口声明认证类型为`bear`
```typescript
// 演示使用api模块调用插件接口
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
// 使用宿主传入的当前实例 ID普通插件和虚拟分身使用同一份组件代码
recentItems.value = await props.api.get(`plugin/${props.pluginId}/history`)
```
```python
@@ -402,7 +601,6 @@ def get_api(self) -> List[Dict[str, Any]]:
]
```
## 7. 调试与排错
### 常见问题
@@ -461,7 +659,7 @@ yarn dev
## 9. 示例代码
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
- [模块联邦问题排查指南](./federation-troubleshooting.md) - 常见问题排查
## 10. 参考资料
@@ -471,4 +669,4 @@ yarn dev
---
如有问题请提交Issue。
如有问题请提交Issue。

119
docs/testing.md Normal file
View File

@@ -0,0 +1,119 @@
# 单元测试架构
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 实例。
- `tests/support/msw/response.ts` 显式构造主程序普通 API 的成功或业务失败 envelope。
- 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 恢复。
- 不使用大面积快照或覆盖率占位用例。
## HTTP 响应夹具
主程序普通 API 使用固定的 `{ success, message, data }` envelope。MSW handler 必须通过
`apiJson(data)``apiFailureJson(message, data)` 显式声明成功或业务失败,不得在全局 setup 中改写
`HttpResponse.json()`、自动包装裸数据或补齐缺失字段。这样测试夹具与当前后端协议不一致时会直接失败,
不会由兼容层掩盖。
HTTP 4xx/5xx 的原始错误体、插件自定义端点和其他明确不使用主程序 envelope 的协议继续直接调用
`HttpResponse.json()`。是否使用 envelope 由端点契约决定,不根据状态码或载荷形状自动猜测。
## 测试性能
- 根据逐文件和逐用例耗时日志识别热点,不按文件行数或用例数量机械拆分。
- 业务测试可以对与断言无关的第三方重型 UI 边界使用局部 test double但必须保留 props、emits、
`v-model`、可访问名称及被测业务使用的值类型;不得以直接赋值组件私有状态替代用户交互。
- 优先减少动画、布局、定位和重复挂载等 jsdom 无法证明的成本。真实组件集成仍由代表性组件测试或浏览器回归负责。
- 优化前后使用同一命令和 reporter 比较 focused 文件与热点用例;只有保持断言和业务契约后取得稳定收益才保留优化。
## 新增测试
1. 业务测试在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`;工具链配置契约测试放在 `tests/config/`
2. 纯函数、store 和无渲染模块直接使用 VitestVue 组件使用标准渲染入口。
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
主程序普通 API 响应使用 `apiJson()` / `apiFailureJson()`;只有明确的非 envelope 协议才直接使用
`HttpResponse.json()`
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
5. 核心覆盖范围发生变化时,同步更新 `vite.config.ts``coverage.include`
6. 提交前按影响面运行测试、类型检查、lint 和生产构建;覆盖率报告按需本地执行。
## 配置边界
Vitest 收集 `src/**/__tests__/**/*.spec.ts``tests/config/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。配置契约测试随全量测试执行,但不加入业务源码覆盖率统计范围。
当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护,覆盖率报告写入 `coverage/`。覆盖率配置保留给本地质量分析,不作为 GitHub Actions 的单元测试门禁。
## 命令与 CI
```sh
yarn test # watch 模式
yarn test:run # 默认并行两个 shard 单次运行
yarn test:run --serial # 单进程运行,适合排查顺序或共享状态问题
yarn test:coverage # 单次运行并检查覆盖率
yarn test:lint-config # 聚焦执行 ESLint 配置契约测试
yarn typecheck
yarn lint
yarn build
```
`yarn test:run` 默认并行执行两个 Vitest shard本地全量测试和 CI 使用同一个入口。传入 `--shard=1/2` 之类的显式分片参数时只执行对应 shard`yarn test:run --serial` 强制单进程运行。传入测试文件或名称过滤条件时自动使用单进程,避免聚焦测试因文件数不足而无法分片。
`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile在面向 `v3` 的 Pull Request 和推送到 `v3` 时运行。`lint` job 依次执行全仓只读 ESLint 和类型检查;单元测试 matrix 通过统一入口分别执行两个 Vitest shard成功用例的标准输出默认静默失败详情仍保留。CI 不要求覆盖率达标;覆盖率可按需在本地运行 `yarn test:coverage`。变更文件格式检查依赖 Pull Request 的 base/head SHA因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。

911
eslint-suppressions.json Normal file
View File

@@ -0,0 +1,911 @@
{
"examples/plugin-component/src/components/AppPage.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"examples/plugin-component/src/components/AppPageSettings.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/@core/libs/apex-chart/apexCharConfig.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/@core/utils/formatters.ts": {
"sonarjs/super-linear-regex": {
"count": 1
}
},
"src/@core/utils/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
},
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/@core/utils/navigator.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/@core/utils/theme.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/@core/utils/workflow.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
}
},
"src/@validators/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/App.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"sonarjs/no-ignored-exceptions": {
"count": 1
}
},
"src/ace-config.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 24
},
"no-useless-escape": {
"count": 4
},
"sonarjs/empty-string-repetition": {
"count": 1
},
"sonarjs/super-linear-regex": {
"count": 9
}
},
"src/api/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 38
},
"@typescript-eslint/no-wrapper-object-types": {
"count": 2
}
},
"src/components/agent/AgentAssistantEntry.vue": {
"no-useless-escape": {
"count": 1
},
"sonarjs/super-linear-regex": {
"count": 1
}
},
"src/components/agent/AgentAssistantPanel.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 7
},
"@typescript-eslint/no-unused-vars": {
"count": 10
},
"sonarjs/no-ignored-exceptions": {
"count": 5
}
},
"src/components/cards/DirectoryCard.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"sonarjs/super-linear-regex": {
"count": 1
},
"vue/no-mutating-props": {
"count": 27
}
},
"src/components/cards/FilterRuleCard.vue": {
"vue/no-mutating-props": {
"count": 1
}
},
"src/components/cards/FilterRuleGroupCard.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/cards/MediaCard.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"no-useless-assignment": {
"count": 1
}
},
"src/components/cards/PersonCard.vue": {
"no-useless-assignment": {
"count": 1
}
},
"src/components/cards/PluginMixedSortCard.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/cards/SubscribeCard.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"vue/no-v-text-v-html-on-component": {
"count": 2
}
},
"src/components/dialog/AboutDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 8
},
"vue/no-parsing-error": {
"count": 1
},
"vue/no-v-text-v-html-on-component": {
"count": 1
}
},
"src/components/dialog/AlistConfigDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"vue/no-mutating-props": {
"count": 4
}
},
"src/components/dialog/AliyunAuthDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialog/CategoryEditDialog.vue": {
"@typescript-eslint/ban-ts-comment": {
"count": 16
},
"@typescript-eslint/no-explicit-any": {
"count": 5
}
},
"src/components/dialog/ContentToggleSettingsDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialog/FilterRuleGroupInfoDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"prefer-const": {
"count": 1
}
},
"src/components/dialog/MediaInfoDialog.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/components/dialog/NotificationChannelInfoDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialog/RcloneConfigDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"vue/no-mutating-props": {
"count": 4
}
},
"src/components/dialog/ReorganizeDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 5
},
"sonarjs/super-linear-regex": {
"count": 1
}
},
"src/components/dialog/SharedDialogHost.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 4
}
},
"src/components/dialog/SiteAddEditDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialog/SiteCookieUpdateDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialog/SiteImportDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/components/dialog/SiteStatisticsDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialog/SmbConfigDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"vue/no-mutating-props": {
"count": 5
}
},
"src/components/dialog/SubscribeEditDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/components/dialog/SubscribeHistoryDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"vue/no-v-text-v-html-on-component": {
"count": 1
}
},
"src/components/dialog/SubscribeSeasonDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialog/SubscribeShareStatisticsDialog.vue": {
"vue/no-side-effects-in-computed-properties": {
"count": 1
}
},
"src/components/dialog/U115AuthDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/components/dialog/UserAuthDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/components/dialog/WorkflowActionsDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 17
}
},
"src/components/dialog/WorkflowAddEditDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/dialog/__tests__/ForkSubscribeDialog.spec.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/components/dialog/__tests__/SubscribeSeasonDialog.spec.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 2
}
},
"src/components/field/CronField.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/components/field/PathField.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/components/filebrowser/FileList.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"vue/no-v-text-v-html-on-component": {
"count": 2
}
},
"src/components/input/PathInput.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/components/misc/DashboardElement.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/components/misc/MediaIdSelector.vue": {
"vue/no-v-text-v-html-on-component": {
"count": 1
}
},
"src/components/misc/OpticalLogoLab.vue": {
"no-useless-assignment": {
"count": 1
}
},
"src/components/misc/ProgressiveCardGrid.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 4
},
"@typescript-eslint/no-unused-expressions": {
"count": 1
}
},
"src/components/pwa/PWAInstallPrompt.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 3
},
"sonarjs/no-ignored-exceptions": {
"count": 1
}
},
"src/components/render/DashboardRender.vue": {
"vue/no-v-text-v-html-on-component": {
"count": 1
}
},
"src/components/render/FormRender.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 17
},
"sonarjs/code-eval": {
"count": 4
}
},
"src/components/render/PageRender.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"prefer-const": {
"count": 1
},
"vue/no-v-text-v-html-on-component": {
"count": 1
}
},
"src/components/slide/SlideViewTitle.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/components/slide/VirtualSlideView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 4
},
"no-useless-assignment": {
"count": 1
}
},
"src/components/workflow/AddDownloadAction.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"vue/no-mutating-props": {
"count": 4
}
},
"src/components/workflow/FetchDownloadsAction.vue": {
"vue/no-mutating-props": {
"count": 2
}
},
"src/components/workflow/FetchMediasAction.vue": {
"vue/no-mutating-props": {
"count": 3
}
},
"src/components/workflow/FetchRssAction.vue": {
"vue/no-mutating-props": {
"count": 5
}
},
"src/components/workflow/FetchTorrentsAction.vue": {
"vue/no-mutating-props": {
"count": 7
}
},
"src/components/workflow/FilterMediasAction.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"vue/no-mutating-props": {
"count": 3
}
},
"src/components/workflow/FilterTorrentsAction.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"vue/no-mutating-props": {
"count": 7
}
},
"src/components/workflow/InvokePluginAction.vue": {
"vue/no-mutating-props": {
"count": 4
}
},
"src/components/workflow/NoteAction.vue": {
"vue/no-mutating-props": {
"count": 1
}
},
"src/components/workflow/ScanFileAction.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"vue/no-mutating-props": {
"count": 2
}
},
"src/components/workflow/SendMessageAction.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"vue/no-mutating-props": {
"count": 2
}
},
"src/components/workflow/TransferFileAction.vue": {
"vue/no-mutating-props": {
"count": 1
}
},
"src/composables/__tests__/useMediaSubscribe.spec.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/composables/useCacheManager.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/composables/useCardAccentColor.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/composables/useConfirm.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/composables/useDynamicButton.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/composables/useDynamicHeaderTab.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
},
"no-useless-assignment": {
"count": 1
},
"sonarjs/no-dead-store": {
"count": 1
}
},
"src/composables/useMediaSubscribe.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/composables/usePWA.ts": {
"no-async-promise-executor": {
"count": 1
}
},
"src/composables/usePWAInstall.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/composables/useSetupWizard.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/composables/useSharedDialog.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/composables/useStateRestore.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/composables/useTorrentFilter.ts": {
"no-useless-assignment": {
"count": 1
}
},
"src/layouts/default/components/Footer.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 6
},
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/layouts/default/components/QuickAccess.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 2
},
"no-useless-assignment": {
"count": 1
},
"sonarjs/no-ignored-exceptions": {
"count": 2
}
},
"src/layouts/default/components/UserNotification.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/layouts/default/components/UserProfile.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 5
},
"sonarjs/no-ignored-exceptions": {
"count": 2
}
},
"src/pages/__tests__/subscribe.spec.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/pages/credits.vue": {
"prefer-const": {
"count": 1
}
},
"src/pages/resource.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 3
},
"@typescript-eslint/no-unused-vars": {
"count": 3
},
"sonarjs/no-duplicated-branches": {
"count": 2
}
},
"src/pages/setting.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/plugins/i18n.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/plugins/stateRestore.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/plugins/vuetify/AppDialog.ts": {
"sonarjs/super-linear-regex": {
"count": 1
}
},
"src/plugins/vuetify/icons.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/router/index.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 7
}
},
"src/service-worker.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unused-vars": {
"count": 3
},
"no-useless-assignment": {
"count": 1
},
"sonarjs/no-ignored-exceptions": {
"count": 2
}
},
"src/stores/global.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/stores/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/utils/agentAssistantBubble.ts": {
"@typescript-eslint/no-empty-object-type": {
"count": 1
}
},
"src/utils/apexCharts.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/utils/appDeepLink.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 3
},
"no-case-declarations": {
"count": 1
},
"no-useless-escape": {
"count": 10
},
"sonarjs/no-dead-store": {
"count": 1
}
},
"src/utils/backgroundManager.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 2
}
},
"src/utils/badge.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 2
},
"sonarjs/no-ignored-exceptions": {
"count": 2
}
},
"src/utils/globalSetting.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/utils/loadingStateManager.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/utils/permission.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 9
}
},
"src/utils/requestOptimizer.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
},
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/utils/themeLogo.ts": {
"no-useless-assignment": {
"count": 1
}
},
"src/views/dashboard/AnalyticsCpu.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/views/dashboard/AnalyticsMemory.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/views/dashboard/AnalyticsSpeed.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/views/dashboard/MediaServerLibrary.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/views/dashboard/MediaServerPlaying.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/views/discover/MediaCardSlideView.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/views/discover/PersonCardListView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/views/discover/PersonDetailView.vue": {
"no-useless-assignment": {
"count": 1
}
},
"src/views/reorganize/TransferHistoryView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 5
},
"vue/no-v-text-v-html-on-component": {
"count": 3
}
},
"src/views/setting/AccountSettingDirectory.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 4
},
"sonarjs/super-linear-regex": {
"count": 1
}
},
"src/views/setting/AccountSettingRule.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 8
}
},
"src/views/setting/AccountSettingSearch.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/views/setting/AccountSettingSite.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/views/setting/AccountSettingSubscribe.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 3
}
},
"src/views/setting/AccountSettingSystem.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/views/setup/MediaServerSettingsStep.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/views/setup/PreferencesSettingsStep.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/views/subscribe/FullCalendarView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/views/subscribe/SubscribeListView.vue": {
"prefer-const": {
"count": 1
}
},
"src/views/subscribe/SubscribePopularView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"prefer-const": {
"count": 1
}
},
"src/views/subscribe/SubscribeShareView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"prefer-const": {
"count": 1
}
},
"src/views/system/CacheView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/views/system/LoggingView.vue": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"no-control-regex": {
"count": 1
},
"sonarjs/super-linear-regex": {
"count": 8
}
},
"src/views/system/ModuleTestView.vue": {
"vue/no-unused-vars": {
"count": 1
}
},
"src/views/system/NameTestView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/views/system/RuleTestView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/views/system/WordsView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"src/views/workflow/WorkflowShareView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"prefer-const": {
"count": 1
}
},
"vite.config.ts": {
"@typescript-eslint/ban-ts-comment": {
"count": 1
}
}
}

195
eslint.config.js Normal file
View File

@@ -0,0 +1,195 @@
import js from '@eslint/js'
import sonarjs from 'eslint-plugin-sonarjs'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
const javascriptFiles = ['**/*.{js,mjs,cjs,jsx}']
const typescriptFiles = ['**/*.{ts,tsx,mts,cts}', '**/*.vue']
const managedScriptExtensions = 'js,mjs,cjs,jsx,ts,tsx,mts,cts'
const managedFiles = [...javascriptFiles, ...typescriptFiles]
const browserFiles = [
`src/**/*.{${managedScriptExtensions},vue}`,
`examples/**/src/**/*.{${managedScriptExtensions},vue}`,
]
const nodeFiles = ['**/*.config.{js,mjs,cjs,ts,mts,cts}', 'public/service.js', 'scripts/**/*.{js,mjs,cjs,ts,mts,cts}']
// TypeScript 关闭核心 no-undef显式禁止浏览器域中的 Node-only globals保证 JS、TS 与 Vue 使用同一运行时边界。
const browserGlobalNames = new Set(Object.keys(globals.browser))
const nodeOnlyGlobalRestrictions = Object.keys(globals.node)
.filter(name => !browserGlobalNames.has(name))
.sort()
.map(name => ({
name,
message: `'${name}' is only available in Node.js code.`,
}))
const restrictedCoreSourcePattern = '/(?:^|\\/)@core(?:\\/|$)/'
// @layouts 是 @core 的下游依赖;禁止反向引用,避免两层形成直接循环。
const layoutCoreImportRestrictions = [
'ImportDeclaration',
'ExportNamedDeclaration',
'ExportAllDeclaration',
'ImportExpression',
].map(nodeType => ({
selector: `${nodeType}[source.value=${restrictedCoreSourcePattern}]`,
message: "The '@layouts' module must not import from '@core'.",
}))
const nonLiteralLayoutDynamicImportRestriction = {
selector: "ImportExpression:not([source.type='Literal'])",
message: 'Dynamic imports in @layouts must use a literal path so module boundaries remain statically verifiable.',
}
const viteGlobCallSelectors = [
"CallExpression[callee.object.type='MetaProperty'][callee.object.meta.name='import'][callee.object.property.name='meta'][callee.property.name=/^glob(?:Eager)?$/]",
"CallExpression[callee.object.type='MetaProperty'][callee.object.meta.name='import'][callee.object.property.name='meta'][callee.property.value=/^glob(?:Eager)?$/]",
]
const layoutGlobRestrictions = viteGlobCallSelectors.map(callSelector => ({
selector: callSelector,
message:
'import.meta.glob is not allowed in @layouts because wildcard targets cannot be proven to stay within the module boundary.',
}))
const sonarRules = {
'sonarjs/array-callback-without-return': 'error',
'sonarjs/code-eval': 'error',
'sonarjs/empty-string-repetition': 'error',
'sonarjs/no-all-duplicated-branches': 'error',
'sonarjs/no-dead-store': 'error',
'sonarjs/no-duplicated-branches': 'error',
'sonarjs/no-element-overwrite': 'error',
'sonarjs/no-hardcoded-passwords': 'error',
'sonarjs/no-hardcoded-secrets': 'error',
'sonarjs/no-identical-conditions': 'error',
'sonarjs/no-identical-expressions': 'error',
'sonarjs/no-ignored-exceptions': 'error',
'sonarjs/no-unthrown-error': 'error',
'sonarjs/no-use-of-empty-return-value': 'error',
'sonarjs/reduce-initial-value': 'error',
'sonarjs/slow-regex': 'error',
'sonarjs/stateful-regex': 'error',
'sonarjs/super-linear-regex': 'error',
}
const typescriptConfigs = tseslint.configs.recommended.map(config => ({
...config,
files: typescriptFiles,
}))
const vueConfigs = pluginVue.configs['flat/essential'].map(config => ({
...config,
files: ['**/*.vue'],
}))
export default defineConfig([
globalIgnores([
'**/node_modules/**',
'**/dist/**',
'**/dev-dist/**',
'**/coverage/**',
'**/.worktrees/**',
'**/vite.config.*.timestamp-*.mjs',
'public/plugin_icon/**',
'src/@iconify/**',
'**/*.d.ts',
]),
{
...js.configs.recommended,
name: 'moviepilot/javascript',
files: managedFiles,
},
...typescriptConfigs,
...vueConfigs,
{
name: 'moviepilot/vue-contracts',
files: ['**/*.vue'],
rules: {
'vue/multi-word-component-names': 'off',
'vue/valid-v-slot': [
'error',
{
allowModifiers: true,
},
],
},
},
{
name: 'moviepilot/language-options',
files: managedFiles,
linterOptions: {
reportUnusedDisableDirectives: 'off',
},
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
parser: tseslint.parser,
},
},
},
{
name: 'moviepilot/browser-globals',
files: browserFiles,
languageOptions: {
globals: globals.browser,
},
rules: {
'no-restricted-globals': ['error', ...nodeOnlyGlobalRestrictions],
},
},
{
name: 'moviepilot/node-globals',
files: nodeFiles,
languageOptions: {
globals: globals.node,
},
// Node 配置可能位于浏览器源码目录;显式覆盖浏览器专用限制,避免 flat config 合并后误报。
rules: {
'no-restricted-globals': 'off',
},
},
{
name: 'moviepilot/sonarjs',
files: managedFiles,
plugins: {
sonarjs,
},
rules: sonarRules,
},
{
name: 'moviepilot/sonarjs-locales',
files: [`src/locales/**/*.{${managedScriptExtensions}}`],
rules: {
'sonarjs/no-hardcoded-passwords': 'off',
'sonarjs/no-hardcoded-secrets': 'off',
},
},
{
name: 'moviepilot/source-hygiene',
files: managedFiles,
rules: {
'no-debugger': 'error',
},
},
{
name: 'moviepilot/layout-boundary',
files: [`src/@layouts/**/*.{${managedScriptExtensions},vue}`],
rules: {
'no-restricted-syntax': [
'error',
...layoutCoreImportRestrictions,
...layoutGlobRestrictions,
nonLiteralLayoutDynamicImportRestriction,
],
},
},
])

1450
index.html

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,28 @@
{
"name": "moviepilot",
"version": "2.14.3",
"version": "3.0.0",
"private": true,
"type": "module",
"bin": "dist/service.js",
"scripts": {
"dev": "vite --host",
"prebuild": "npm run build:icons",
"dev:pwa": "vite --host --port 5174",
"prebuild": "npm run build:icons && npm run generate:pwa-splash",
"build": "vite build",
"generate:pwa-splash": "node scripts/generate-pwa-splash.mjs",
"preview": "vite preview --port 5050",
"test": "vitest",
"test:run": "node scripts/run-tests.mjs",
"test:coverage": "vitest run --coverage",
"test:lint-config": "vitest run tests/config/eslint-config.spec.ts",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
"lint": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix",
"lint:suppressions:prune": "eslint . --prune-suppressions",
"format": "node scripts/format-changed.mjs --write",
"format:check": "node scripts/format-changed.mjs --check",
"format:all:check": "prettier . --check",
"test:format-config": "vitest run tests/config/format-changed.spec.ts tests/config/frontend-workflow.spec.ts",
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
"postinstall": "npm run build:icons",
"pkg": "pkg . -t node18-win-x64 -o MoviePilot-Frontend.exe"
@@ -20,6 +32,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 +66,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 +74,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",
@@ -73,6 +91,7 @@
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@iconify-json/line-md": "^1.2.13",
"@iconify-json/lucide": "^1.2.85",
"@iconify-json/material-symbols": "^1.2.51",
@@ -82,7 +101,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,22 +115,24 @@
"@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",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-regex": "^1.10.0",
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-vue": "^9.12.0",
"eslint": "^10.7.0",
"eslint-plugin-sonarjs": "^4.2.0",
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.3.0",
"jsdom": "26.1.0",
"msw": "2.15.0",
"postcss": "^8.5.1",
"postcss-html": "^1.5.0",
"prettier": "^3.9.5",
"sharp": "^0.33.5",
"stylelint": "^16.13.2",
"stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-config-standard-scss": "^14.0.0",
@@ -114,6 +140,7 @@
"terser": "^5.36.0",
"type-fest": "^4.15.0",
"typescript": "^5.0.4",
"typescript-eslint": "^8.64.0",
"unplugin-auto-import": "^19.0.0",
"unplugin-vue-components": "^28.0.0",
"unplugin-vue-define-options": "^1.5.3",
@@ -123,10 +150,15 @@
"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-eslint-parser": "^10.4.1",
"vue-shepherd": "^4.1.0",
"vue-tsc": "^2.0.10",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0"
},
"packageManager": "yarn@1.22.18"
"packageManager": "yarn@1.22.22",
"engines": {
"node": ">=20.19"
}
}

View File

@@ -65,13 +65,14 @@ http {
root html;
}
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$|message/agent/stream$) {
# SSE MIME类型设置
default_type text/event-stream;
# 禁用缓存
add_header Cache-Control no-cache;
add_header Cache-Control "no-cache, no-transform";
add_header X-Accel-Buffering no;
gzip off;
proxy_buffering off;
proxy_cache off;

View File

@@ -7,7 +7,7 @@
<link rel="icon" href="/favicon.ico">
<style>
:root {
--primary-color: #9155FD;
--primary-color: #8D51F9;
--surface-color: #FFFFFF;
--text-color: #333333;
--border-color: rgba(0, 0, 0, 0.12);
@@ -52,7 +52,7 @@
width: 120px;
height: 120px;
margin: 0 auto 32px;
background: rgba(145, 85, 253, 0.1);
background: rgba(141, 81, 249, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
@@ -100,7 +100,7 @@
gap: 8px;
margin-top: 24px;
padding: 8px 16px;
background: rgba(145, 85, 253, 0.1);
background: rgba(141, 81, 249, 0.1);
border-radius: 20px;
font-size: 0.875rem;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 16 KiB

208
scripts/format-changed.mjs Normal file
View File

@@ -0,0 +1,208 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, lstatSync } from 'node:fs'
import { resolve } from 'node:path'
import process from 'node:process'
import { fileURLToPath, pathToFileURL } from 'node:url'
import * as prettier from 'prettier'
const DEFAULT_BASE_REFS = ['refs/remotes/upstream/v3', 'refs/remotes/origin/v3', 'refs/heads/v3']
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024
const PRETTIER_ARGUMENT_BUDGET = 24_000
const prettierCliPath = fileURLToPath(new URL('../node_modules/prettier/bin/prettier.cjs', import.meta.url))
/** 解析 Git 的 NUL 分隔输出,保留文件名中的空格、换行和通配符字符。 */
function parseNullDelimited(output) {
return output.toString('utf8').split('\0').filter(Boolean)
}
/** 运行只返回仓库相对路径的 Git 查询。 */
function gitPaths(cwd, args) {
return parseNullDelimited(
execFileSync('git', args, {
cwd,
encoding: 'buffer',
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
}),
)
}
/** 尝试把引用解析为提交 SHA完整引用可避免分支与标签同名时产生歧义。 */
function tryResolveCommit(cwd, ref) {
const result = spawnSync('git', ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], {
cwd,
encoding: 'utf8',
})
return result.status === 0 ? result.stdout.trim() : undefined
}
/** 把外部引用解析为提交 SHA后续 Git diff 不再解释用户提供的选项样式字符串。 */
function resolveCommit(cwd, ref) {
const commit = tryResolveCommit(cwd, ref)
if (commit) return commit
throw new Error(`无法解析 Git 提交引用:${ref}`)
}
/** 判断两个提交能否进行三点比较。 */
function hasMergeBase(cwd, baseCommit, headCommit) {
return spawnSync('git', ['merge-base', baseCommit, headCommit], { cwd, stdio: 'ignore' }).status === 0
}
/** 为本地命令选择与当前提交具有共同祖先的 v3 基线。 */
function resolveDefaultBase(cwd, headCommit) {
for (const ref of DEFAULT_BASE_REFS) {
const commit = tryResolveCommit(cwd, ref)
if (commit && hasMergeBase(cwd, commit, headCommit)) return commit
}
throw new Error('无法定位可比较的 v3 基线,请通过 --base <ref> 显式指定比较起点。')
}
/**
* 收集当前分支相对基线的文件集合。
* 显式 base/head 模式只读取提交区间,默认本地模式还会合并暂存区、工作区和未跟踪文件。
*/
function collectChangedPaths({ cwd, base, head }) {
if (Boolean(base) !== Boolean(head)) {
throw new Error('--base 与 --head 必须一起使用。')
}
const explicitRange = Boolean(base)
const headCommit = resolveCommit(cwd, head ?? 'HEAD')
const baseCommit = base ? resolveCommit(cwd, base) : resolveDefaultBase(cwd, headCommit)
if (!hasMergeBase(cwd, baseCommit, headCommit)) {
throw new Error('Git 比较起点与终点没有共同祖先,无法执行三点比较。')
}
const paths = new Set(
gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', `${baseCommit}...${headCommit}`]),
)
if (!explicitRange) {
for (const filePath of gitPaths(cwd, ['diff', '--name-only', '--diff-filter=ACMRT', '-z', 'HEAD'])) {
paths.add(filePath)
}
for (const filePath of gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z'])) {
paths.add(filePath)
}
}
return [...paths]
.filter(filePath => {
const absolutePath = resolve(cwd, filePath)
if (!existsSync(absolutePath)) return false
try {
return lstatSync(absolutePath).isFile()
} catch {
return false
}
})
.sort()
}
/** 使用仓库 ignore 文件和 Prettier parser 推断筛出真正受格式化工具管理的文件。 */
async function selectPrettierFiles(filePaths, cwd) {
const ignorePaths = ['.gitignore', '.prettierignore'].map(filePath => resolve(cwd, filePath)).filter(existsSync)
const selected = []
for (const filePath of filePaths) {
const absolutePath = resolve(cwd, filePath)
const fileInfo = await prettier.getFileInfo(absolutePath)
if (!fileInfo.inferredParser) continue
let ignored = false
for (const ignorePath of ignorePaths) {
if ((await prettier.getFileInfo(absolutePath, { ignorePath })).ignored) {
ignored = true
break
}
}
if (!ignored) selected.push(filePath)
}
return selected
}
/** 按保守的参数长度预算拆批,避免大型 PR 超过操作系统命令行上限。 */
function batchPaths(filePaths) {
const batches = []
let batch = []
let length = 0
for (const filePath of filePaths) {
if (batch.length > 0 && length + filePath.length + 1 > PRETTIER_ARGUMENT_BUDGET) {
batches.push(batch)
batch = []
length = 0
}
batch.push(filePath)
length += filePath.length + 1
}
if (batch.length > 0) batches.push(batch)
return batches
}
/** 通过参数数组调用项目内 Prettier避免 shell 展开或拼接特殊文件名。 */
function runPrettier(filePaths, mode, cwd) {
if (filePaths.length === 0) {
console.log('没有需要 Prettier 处理的变更文件。')
return
}
for (const batch of batchPaths(filePaths)) {
const result = spawnSync(process.execPath, [prettierCliPath, mode, '--ignore-unknown', '--', ...batch], {
cwd,
stdio: 'inherit',
})
if (result.status !== 0) {
throw new Error(`Prettier ${mode} 失败,退出码:${result.status ?? 'unknown'}`)
}
}
}
/** 解析面向开发者和 CI 的稳定命令行契约。 */
function parseArguments(args) {
const options = { action: undefined, base: undefined, head: undefined }
for (let index = 0; index < args.length; index += 1) {
const argument = args[index]
if (['--check', '--write', '--list'].includes(argument)) {
if (options.action) throw new Error('只能指定 --check、--write 或 --list 中的一种操作。')
options.action = argument
continue
}
if (argument === '--base' || argument === '--head') {
const value = args[index + 1]
if (!value) throw new Error(`${argument} 缺少引用值。`)
options[argument.slice(2)] = value
index += 1
continue
}
throw new Error(`未知参数:${argument}`)
}
if (!options.action) throw new Error('必须指定 --check、--write 或 --list。')
return options
}
/** 执行变更文件选择与格式化;显式引用模式为 CI 提供确定输入。 */
async function main() {
const cwd = process.cwd()
const { action, base, head } = parseArguments(process.argv.slice(2))
const changedPaths = collectChangedPaths({ cwd, base, head })
const selectedPaths = await selectPrettierFiles(changedPaths, cwd)
if (action === '--list') {
process.stdout.write(`${JSON.stringify(selectedPaths)}\n`)
return
}
runPrettier(selectedPaths, action, cwd)
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
main().catch(error => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}

View File

@@ -0,0 +1,68 @@
import { mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
import appleSplashSpecs from './pwa-splash-specs.json' with { type: 'json' }
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const logoPath = path.join(projectRoot, 'public', 'logo.svg')
const outputDirectory = path.join(projectRoot, 'public', 'splash')
const background = '#0E1116'
async function createSplash(width, height, scaleFactor, outputPath, format) {
// Match the DOM loader's `min(160px, 36vw)` in CSS pixels, then convert it
// to physical pixels for the selected Apple launch image.
const logoSize = Math.round(Math.min(160, (width / scaleFactor) * 0.36) * scaleFactor)
const logo = await sharp(logoPath).resize(logoSize, logoSize, { fit: 'contain' }).png().toBuffer()
const image = sharp({
create: {
width,
height,
channels: 4,
background,
},
}).composite([
{
input: logo,
left: Math.round((width - logoSize) / 2),
top: Math.round((height - logoSize) / 2),
},
])
if (format === 'png') {
await image.png({ compressionLevel: 9 }).toFile(outputPath)
return
}
await image.flatten({ background }).jpeg({ quality: 88, progressive: true }).toFile(outputPath)
}
await mkdir(outputDirectory, { recursive: true })
for (const { width: portraitWidth, height: portraitHeight, scaleFactor } of appleSplashSpecs) {
const landscapeWidth = portraitHeight
const landscapeHeight = portraitWidth
await createSplash(
portraitWidth,
portraitHeight,
scaleFactor,
path.join(outputDirectory, `apple-splash-${portraitWidth}-${portraitHeight}.jpg`),
'jpg',
)
await createSplash(
landscapeWidth,
landscapeHeight,
scaleFactor,
path.join(outputDirectory, `apple-splash-${landscapeWidth}-${landscapeHeight}.jpg`),
'jpg',
)
}
// Keep the previous fallback filename for older deployments and bookmarked
// entries that may still reference it. Its palette matches the new assets.
await createSplash(750, 1334, 2, path.join(outputDirectory, 'apple-splash.png'), 'png')
console.log(
`Generated ${appleSplashSpecs.length * 2 + 1} PWA splash assets in ${path.relative(projectRoot, outputDirectory)}`,
)

294
scripts/pwa-development.ts Normal file
View File

@@ -0,0 +1,294 @@
import type { Plugin } from 'vite'
const PWA_DEVELOPMENT_SCRIPT = 'dev:pwa'
export const DEV_SW_CLEANUP_PATH = '/__moviepilot_dev_sw_cleanup__'
const devEntryScriptTag = '<script type="module" src="/src/main.ts"></script>'
const devEntryScriptUrl = '/src/main.ts'
const moviePilotWorkerScripts = ['dev-sw.js?dev-sw', 'service-worker.js']
const moviePilotIdentityMessage = 'GET_UNREAD_COUNT'
const moviePilotIdentityTimeoutMs = 1500
const moviePilotIdentityAttempts = 2
/** 普通 development mode 仅在显式 dev:pwa 脚本中启用开发 Service Worker。 */
export const isPwaDevelopmentEnabled = (mode: string, lifecycleEvent?: string) =>
mode === 'development' && lifecycleEvent === PWA_DEVELOPMENT_SCRIPT
/** 普通开发服务器才执行历史 Service Worker 清理production preview 保持构建产物行为。 */
export const shouldEnableDevServiceWorkerCleanup = (
command: 'build' | 'serve',
mode: string,
isPreview: boolean | undefined,
lifecycleEvent?: string,
) =>
command === 'serve' && mode === 'development' && isPreview !== true && !isPwaDevelopmentEnabled(mode, lifecycleEvent)
/** 解析当前页面所属的应用根目录,兼容根路径和子路径部署。 */
export function resolveDevAppScope(pageUrl: string): URL {
return new URL('./', new URL(pageUrl))
}
/** Worker 的 scope 和脚本 URL 都必须精确落在当前应用根目录。 */
export function isManagedServiceWorkerRegistration(
scriptUrl: string,
registrationScope: string,
pageUrl: string,
): boolean {
const appScope = resolveDevAppScope(pageUrl)
const scope = new URL(registrationScope)
const script = new URL(scriptUrl)
return (
scope.href === appScope.href &&
moviePilotWorkerScripts.some(workerScript => script.href === new URL(workerScript, appScope).href)
)
}
/** 复用现有轻量消息协议确认 Worker 确由 MoviePilot 提供。 */
export function isMoviePilotServiceWorkerIdentityResponse(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
return typeof (value as { count?: unknown }).count === 'number'
}
/** 身份探测允许有限次重试;持续失败时保持现有注册,避免误清理未知 Worker。 */
export async function retryMoviePilotIdentityVerification(
verifyOnce: () => Promise<boolean>,
attempts: number,
): Promise<boolean> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (await verifyOnce()) return true
}
return false
}
/** 删除当前 origin 的全部 Cache Storage调用方必须先确认该 dev origin 由 MoviePilot Worker 管理。 */
export async function deleteCurrentOriginCaches(cacheStorage: Pick<CacheStorage, 'delete' | 'keys'>): Promise<void> {
const cacheNames = await cacheStorage.keys()
await Promise.all(cacheNames.map(cacheName => cacheStorage.delete(cacheName)))
}
/** 清理完成后只允许返回当前 origin避免开发中间页形成开放重定向。 */
export function resolveDevCleanupReturnUrl(requested: string | null, origin: string): URL {
if (!requested) return new URL('/', origin)
const target = new URL(requested, origin)
return target.origin === origin ? target : new URL('/', origin)
}
/**
* 普通开发服务器不应被历史 Service Worker 控制;受控页面先进入独立清理页,避免旧缓存模块抢先执行。
*/
export function createDevServiceWorkerCleanupPlugin(): Plugin {
const workerScripts = JSON.stringify(moviePilotWorkerScripts)
const identityMessage = JSON.stringify(moviePilotIdentityMessage)
const identityTimeoutMs = JSON.stringify(moviePilotIdentityTimeoutMs)
const identityAttempts = JSON.stringify(moviePilotIdentityAttempts)
const retryIdentityVerification = retryMoviePilotIdentityVerification.toString()
const deleteOriginCaches = deleteCurrentOriginCaches.toString()
const entryScriptUrl = JSON.stringify(devEntryScriptUrl)
const cleanupPath = JSON.stringify(DEV_SW_CLEANUP_PATH)
const cleanupAttemptKeyPrefix = JSON.stringify('moviepilot:dev-sw-cleanup')
const redirectScript = `
(() => {
const entryScriptUrl = ${entryScriptUrl}
const deleteCurrentOriginCaches = ${deleteOriginCaches}
let appStarted = false
const startApp = () => {
if (appStarted) return
appStarted = true
const entry = document.createElement('script')
entry.type = 'module'
entry.src = entryScriptUrl
document.head.appendChild(entry)
}
if (!('serviceWorker' in navigator)) {
startApp()
return
}
const workerScripts = ${workerScripts}
const identityMessage = ${identityMessage}
const identityTimeoutMs = ${identityTimeoutMs}
const identityAttempts = ${identityAttempts}
const retryIdentityVerification = ${retryIdentityVerification}
const cleanupPath = ${cleanupPath}
const appScope = new URL('./', location.href)
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
const cleanupState = sessionStorage.getItem(cleanupAttemptKey)
const getCandidateWorker = registration => {
if (new URL(registration.scope).href !== appScope.href) return null
return [registration.active, registration.waiting, registration.installing].find(worker => {
if (!worker) return false
const scriptUrl = new URL(worker.scriptURL).href
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
}) || null
}
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
const channel = new MessageChannel()
const finish = result => {
window.clearTimeout(timeout)
channel.port1.close()
resolve(result)
}
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
try {
worker.postMessage({ type: identityMessage }, [channel.port2])
} catch {
finish(false)
}
})
const verifyMoviePilotWorker = worker =>
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
const hasVerifiedRegistration = async () => {
const registrations = await navigator.serviceWorker.getRegistrations()
for (const registration of registrations) {
const worker = getCandidateWorker(registration)
if (worker && await verifyMoviePilotWorker(worker)) return true
}
return false
}
const redirectToCleanup = () => {
sessionStorage.setItem(cleanupAttemptKey, 'pending')
const target = new URL(cleanupPath.slice(1), appScope)
target.searchParams.set('return', location.href)
location.replace(target.href)
}
// unregister 不会立即解除当前 document 的 controller应用模块加载前需再导航一次以脱离旧 Worker。
if (cleanupState === 'complete') {
void (async () => {
// 旧 Worker 可能在注销后的首次导航中重新创建缓存;脱离控制后再清理一次。
if ('caches' in window) await deleteCurrentOriginCaches(caches)
sessionStorage.removeItem(cleanupAttemptKey)
location.reload()
})().catch(error => {
console.error('[PWA] Failed to finish stale development cache cleanup', error)
document.body.textContent = 'Failed to finish stale development cache cleanup. Reload to retry.'
})
return
}
void hasVerifiedRegistration().then(hasRegistration => {
if (hasRegistration) {
redirectToCleanup()
return
}
sessionStorage.removeItem(cleanupAttemptKey)
startApp()
}).catch(error => {
console.warn('[PWA] Failed to inspect historical Service Worker state', error)
startApp()
})
})()
`
const cleanupDocument = `<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>MoviePilot Dev Cleanup</title></head>
<body>
<script>
(() => {
const workerScripts = ${workerScripts}
const identityMessage = ${identityMessage}
const identityTimeoutMs = ${identityTimeoutMs}
const identityAttempts = ${identityAttempts}
const retryIdentityVerification = ${retryIdentityVerification}
const deleteCurrentOriginCaches = ${deleteOriginCaches}
const appScope = new URL('./', location.href)
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
const resolveReturnUrl = () => {
const requested = new URLSearchParams(location.search).get('return')
if (!requested) return appScope
const target = new URL(requested, appScope)
return target.href.startsWith(appScope.href) ? target : appScope
}
const returnUrl = resolveReturnUrl()
const getCandidateWorker = registration => {
if (new URL(registration.scope).href !== appScope.href) return null
return [registration.active, registration.waiting, registration.installing].find(worker => {
if (!worker) return false
const scriptUrl = new URL(worker.scriptURL).href
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
}) || null
}
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
const channel = new MessageChannel()
const finish = result => {
window.clearTimeout(timeout)
channel.port1.close()
resolve(result)
}
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
try {
worker.postMessage({ type: identityMessage }, [channel.port2])
} catch {
finish(false)
}
})
const verifyMoviePilotWorker = worker =>
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
const cleanup = async () => {
if (sessionStorage.getItem(cleanupAttemptKey) !== 'pending') {
throw new Error('Missing Service Worker cleanup context for current application scope')
}
const registrations = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistrations() : []
const managedRegistrations = []
for (const registration of registrations) {
const worker = getCandidateWorker(registration)
if (worker && await verifyMoviePilotWorker(worker)) managedRegistrations.push(registration)
}
if (!managedRegistrations.length) throw new Error('MoviePilot Service Worker identity verification failed')
await Promise.allSettled(managedRegistrations.map(registration => registration.unregister()))
// Vite dev server 使用独立 origin仅在确认 MoviePilot Worker 后清除其遗留模块响应。
if ('caches' in window) await deleteCurrentOriginCaches(caches)
// 回跳入口后由 head-prepend 脚本完成第二次导航,避免同一 client 继续复用旧模块响应。
sessionStorage.setItem(cleanupAttemptKey, 'complete')
location.replace(returnUrl.href)
}
void cleanup().catch(error => {
console.error('[PWA] Failed to clean stale development Service Worker state', error)
document.body.textContent = 'Failed to clean stale development Service Worker state. Reload to retry.'
})
})()
</script>
</body>
</html>`
return {
name: 'moviepilot:dev-service-worker-cleanup',
apply: 'serve',
configureServer(server) {
server.middlewares.use((request, response, next) => {
const pathname = new URL(request.url || '/', 'http://localhost').pathname
if (!pathname.endsWith(DEV_SW_CLEANUP_PATH)) {
next()
return
}
response.statusCode = 200
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.setHeader('Cache-Control', 'no-store')
response.end(cleanupDocument)
})
},
transformIndexHtml: {
order: 'pre',
handler(html) {
if (!html.includes(devEntryScriptTag)) {
throw new Error(`Expected development entry tag: ${devEntryScriptTag}`)
}
return {
html: html.replace(devEntryScriptTag, ''),
tags: [{ tag: 'script', children: redirectScript, injectTo: 'head-prepend' }],
}
},
},
}
}

View File

@@ -0,0 +1,22 @@
[
{ "width": 2048, "height": 2732, "scaleFactor": 2 },
{ "width": 1668, "height": 2388, "scaleFactor": 2 },
{ "width": 1536, "height": 2048, "scaleFactor": 2 },
{ "width": 1640, "height": 2360, "scaleFactor": 2 },
{ "width": 1668, "height": 2224, "scaleFactor": 2 },
{ "width": 1620, "height": 2160, "scaleFactor": 2 },
{ "width": 1488, "height": 2266, "scaleFactor": 2 },
{ "width": 1320, "height": 2868, "scaleFactor": 3 },
{ "width": 1206, "height": 2622, "scaleFactor": 3 },
{ "width": 1260, "height": 2736, "scaleFactor": 3 },
{ "width": 1290, "height": 2796, "scaleFactor": 3 },
{ "width": 1179, "height": 2556, "scaleFactor": 3 },
{ "width": 1170, "height": 2532, "scaleFactor": 3 },
{ "width": 1284, "height": 2778, "scaleFactor": 3 },
{ "width": 1125, "height": 2436, "scaleFactor": 3 },
{ "width": 1242, "height": 2688, "scaleFactor": 3 },
{ "width": 828, "height": 1792, "scaleFactor": 2 },
{ "width": 1242, "height": 2208, "scaleFactor": 3 },
{ "width": 750, "height": 1334, "scaleFactor": 2 },
{ "width": 640, "height": 1136, "scaleFactor": 2 }
]

56
scripts/run-tests.mjs Normal file
View File

@@ -0,0 +1,56 @@
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const defaultShards = ['1/2', '2/2']
const serialFlag = '--serial'
const vitestCliPath = fileURLToPath(import.meta.resolve('vitest/vitest.mjs'))
/**
* 生成一次 test:run 所需的 Vitest 参数组。全量测试默认并行;显式分片、串行模式和聚焦过滤保持单进程。
*
* @param {string[]} args test:run 接收的命令行参数
* @returns {string[][]} 每个 Vitest 进程接收的一组参数
*/
export function planTestRuns(args) {
const vitestArgs = args.filter(arg => arg !== serialFlag)
const hasExplicitShard = vitestArgs.some(arg => arg === '--shard' || arg.startsWith('--shard='))
const hasPositionalFilter = vitestArgs.some(arg => !arg.startsWith('-'))
if (args.includes(serialFlag) || hasExplicitShard || hasPositionalFilter) return [vitestArgs]
return defaultShards.map(shard => [...vitestArgs, `--shard=${shard}`])
}
function runVitest(args) {
return new Promise(resolveRun => {
const child = spawn(process.execPath, [vitestCliPath, 'run', ...args], {
env: process.env,
stdio: 'inherit',
})
child.on('error', error => {
console.error(`无法启动 Vitest: ${error.message}`)
resolveRun(1)
})
child.on('close', (code, signal) => {
if (signal) {
console.error(`Vitest 被信号 ${signal} 终止`)
resolveRun(1)
return
}
resolveRun(code ?? 1)
})
})
}
async function main() {
const runs = planTestRuns(process.argv.slice(2))
const exitCodes = await Promise.all(runs.map(runVitest))
process.exitCode = exitCodes.find(code => code !== 0) ?? 0
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
await main()
}

View File

@@ -72,7 +72,7 @@ function handleCancel() {
</div>
</div>
</VCardItem>
<VCardActions class="mx-auto">
<VCardActions class="app-confirm-dialog-actions mx-auto">
<VBtn variant="tonal" color="secondary" class="px-5" @click="handleCancel">
{{ cancelText }}
</VBtn>

View File

@@ -1,12 +1,13 @@
<script lang="ts" setup>
// 定义输入参数
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
// 是否显示
/** 覆盖关闭按钮默认的右上角定位 class。 */
innerClass: String,
})
// 定义触发的自定义事件
const emit = defineEmits(['click', 'update:modelValue'])
// 按钮点击
function onClick() {
emit('update:modelValue', false)
emit('click')
@@ -15,6 +16,7 @@ function onClick() {
<template>
<IconBtn
:aria-label="t('common.close')"
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'"
@click.stop="onClick"
>

View File

@@ -0,0 +1,37 @@
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import i18n from '@/plugins/i18n'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { afterEach, describe, expect, it } from 'vitest'
afterEach(() => {
i18n.global.locale.value = 'zh-CN'
})
describe('DialogCloseBtn', () => {
it('keeps its visual and event contracts while exposing a localized name', async () => {
const user = userEvent.setup()
const { container, emitted, rerender } = await renderWithProviders(DialogCloseBtn)
const button = screen.getByRole('button', { name: '关闭' })
expect(button).toHaveClass('absolute', 'right-3', 'top-3', 'z-10')
const icon = button.querySelector('svg.v-icon')
expect(container.querySelectorAll('svg.v-icon')).toHaveLength(1)
expect(icon).toHaveAttribute('aria-hidden', 'true')
expect(icon).toHaveAttribute('role', 'img')
expect(icon).toHaveAttribute('height', '1em')
expect(icon).toHaveAttribute('width', '1em')
i18n.global.locale.value = 'en-US'
await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toBe(button))
await user.click(button)
expect(emitted()['update:modelValue']).toEqual([[false]])
expect(emitted().click).toEqual([[]])
await rerender({ innerClass: 'dialog-close-custom' })
expect(button).toHaveClass('dialog-close-custom')
expect(button).not.toHaveClass('absolute', 'right-3', 'top-3', 'z-10')
})
})

View File

@@ -96,7 +96,8 @@
// 👉 Nav Link
.nav-link {
overflow: hidden;
// 交互项已预留下边距承接激活阴影,父级不能再次裁切这段视觉溢出。
overflow: visible;
> :first-child {
@extend %vertical-nav-item;

View File

@@ -33,11 +33,3 @@ $ps-track-size: 0.5rem;
.ps__rail-y:hover > .ps__thumb-y {
inline-size: $ps-hover-size;
}
// fix bug
@media(hover: none) {
.ps > .ps__rail-x,
.ps > .ps__rail-y {
opacity: 0.6;
}
}

View File

@@ -0,0 +1,115 @@
import { vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getColor: vi.fn(),
}))
vi.mock('colorthief', () => ({
default: class ColorThief {
getColor(image: HTMLImageElement, quality?: number) {
return mocks.getColor(image, quality)
}
},
}))
import { extractDominantColor, getDominantColor, preloadCorsImage } from '@/@core/utils/image'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
function createImage(cacheKey: string) {
const image = document.createElement('img')
Object.defineProperty(image, 'currentSrc', { configurable: true, value: `https://image.example/${cacheKey}.png` })
return image
}
describe('dominant color extraction', () => {
beforeEach(() => {
mocks.getColor.mockReset()
})
it('shares a pending extraction and reuses only the successful result', async () => {
const image = createImage('shared-success')
mocks.getColor.mockReturnValue([18, 52, 86])
await expect(Promise.all([extractDominantColor(image), extractDominantColor(image)])).resolves.toEqual([
'#123456',
'#123456',
])
await expect(extractDominantColor(image)).resolves.toBe('#123456')
expect(mocks.getColor).toHaveBeenCalledOnce()
})
it('does not cache failures or let one caller fallback pollute another', async () => {
const image = createImage('retry-after-failure')
mocks.getColor.mockImplementation(() => {
throw new Error('tainted canvas')
})
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await expect(getDominantColor(image, { fallback: '#111111' })).resolves.toBe('#111111')
await expect(getDominantColor(image, { fallback: '#222222' })).resolves.toBe('#222222')
expect(mocks.getColor).toHaveBeenCalledTimes(2)
})
it('keeps the existing default fallback contract for callers such as QuickAccess', async () => {
await expect(getDominantColor(null)).resolves.toBe('#28A9E1')
expect(mocks.getColor).not.toHaveBeenCalled()
})
it('retains the bounded FIFO success cache', async () => {
mocks.getColor.mockReturnValue([1, 2, 3])
for (let index = 0; index <= 100; index += 1) await extractDominantColor(createImage(`fifo-${index}`))
expect(mocks.getColor).toHaveBeenCalledTimes(101)
await extractDominantColor(createImage('fifo-50'))
expect(mocks.getColor).toHaveBeenCalledTimes(101)
await extractDominantColor(createImage('fifo-0'))
expect(mocks.getColor).toHaveBeenCalledTimes(102)
})
})
describe('preloadCorsImage', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('uses a CORS-clean cached response without reloading it', async () => {
const fetchMock = vi.fn().mockResolvedValue({
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
ok: true,
})
vi.stubGlobal('fetch', fetchMock)
await expect(preloadCorsImage('https://image.example/wallpaper.jpg')).resolves.toBe(true)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
new URL('https://image.example/wallpaper.jpg'),
expect.objectContaining({ cache: 'force-cache', credentials: 'omit', mode: 'cors' }),
)
})
it('reloads a response when an earlier non-CORS cache entry blocks the first request', async () => {
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockResolvedValueOnce({
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
ok: true,
})
vi.stubGlobal('fetch', fetchMock)
await expect(preloadCorsImage('/wallpaper.jpg')).resolves.toBe(true)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
new URL('/wallpaper.jpg', window.location.href),
expect.objectContaining({ cache: 'reload', credentials: 'same-origin', mode: 'cors' }),
)
})
it('returns false when the source cannot be read with CORS', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
await expect(preloadCorsImage('https://image.example/wallpaper.jpg')).resolves.toBe(false)
})
})

View File

@@ -0,0 +1,199 @@
import useDragAndDrop from '@core/utils/workflow'
import { mount } from '@vue/test-utils'
import { defineComponent, h, nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
addNodes: vi.fn(),
screenToFlowCoordinate: vi.fn(),
onNodesInitialized: vi.fn(),
updateNode: vi.fn(),
off: vi.fn(),
onNodesInitializedCallback: undefined as (() => void) | undefined,
}))
vi.mock('@vue-flow/core', () => ({
useVueFlow: () => ({
addNodes: mocks.addNodes,
screenToFlowCoordinate: mocks.screenToFlowCoordinate,
onNodesInitialized: mocks.onNodesInitialized,
updateNode: mocks.updateNode,
}),
}))
type DragAndDrop = ReturnType<typeof useDragAndDrop>
let current: DragAndDrop | undefined
let wrapper: ReturnType<typeof mount> | undefined
const Harness = defineComponent({
name: 'WorkflowDragAndDropHarness',
setup() {
current = useDragAndDrop()
return () => h('div')
},
})
function mountHarness() {
wrapper = mount(Harness)
return current!
}
function createDataTransfer() {
return {
effectAllowed: '',
dropEffect: '',
setData: vi.fn(),
}
}
describe('useDragAndDrop', () => {
beforeEach(() => {
mocks.addNodes.mockReset()
mocks.screenToFlowCoordinate.mockReset().mockImplementation(({ x, y }: { x: number; y: number }) => ({ x, y }))
mocks.onNodesInitialized.mockReset().mockImplementation((callback: () => void) => {
mocks.onNodesInitializedCallback = callback
return { off: mocks.off }
})
mocks.updateNode.mockReset()
mocks.off.mockReset()
mocks.onNodesInitializedCallback = undefined
document.body.style.userSelect = ''
})
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
current = undefined
document.body.style.userSelect = ''
})
it('does not enter drag-over or create a node before a drag starts', async () => {
const dnd = mountHarness()
const preventDefault = vi.fn()
dnd.onDragOver({ preventDefault, dataTransfer: createDataTransfer() })
dnd.onDrop({ clientX: 10, clientY: 20 })
await nextTick()
expect(preventDefault).toHaveBeenCalledOnce()
expect(dnd.isDragOver.value).toBe(false)
expect(mocks.addNodes).not.toHaveBeenCalled()
})
it('tracks drag state, prevents selection, and clears everything on document drop', async () => {
const dnd = mountHarness()
const dataTransfer = createDataTransfer()
const data = { type: 'ScanFileAction', name: '扫描目录' }
const addEventListener = vi.spyOn(document, 'addEventListener')
const removeEventListener = vi.spyOn(document, 'removeEventListener')
dnd.onDragStart({ dataTransfer }, data)
await nextTick()
expect(dataTransfer.setData).toHaveBeenCalledWith('application/vueflow', data)
expect(dataTransfer.effectAllowed).toBe('move')
expect(dnd.draggedData.value).toEqual(data)
expect(dnd.isDragging.value).toBe(true)
expect(document.body.style.userSelect).toBe('none')
expect(addEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
expect(addEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
const dragOverTransfer = createDataTransfer()
const preventDefault = vi.fn()
dnd.onDragOver({ preventDefault, dataTransfer: dragOverTransfer })
await nextTick()
expect(dnd.isDragOver.value).toBe(true)
expect(dragOverTransfer.dropEffect).toBe('move')
dnd.onDragLeave()
expect(dnd.isDragOver.value).toBe(false)
document.dispatchEvent(new Event('drop'))
await nextTick()
expect(dnd.draggedData.value).toBeNull()
expect(dnd.isDragging.value).toBe(false)
expect(dnd.isDragOver.value).toBe(false)
expect(document.body.style.userSelect).toBe('')
expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
})
it('clears the drag state when the browser cancels the native drag', async () => {
const dnd = mountHarness()
const removeEventListener = vi.spyOn(document, 'removeEventListener')
dnd.onDragStart({ dataTransfer: createDataTransfer() }, { type: 'NoteAction', name: '备注' })
await nextTick()
dnd.onDragOver({ preventDefault: vi.fn(), dataTransfer: createDataTransfer() })
await nextTick()
document.dispatchEvent(new Event('dragend'))
await nextTick()
expect(dnd.draggedData.value).toBeNull()
expect(dnd.isDragging.value).toBe(false)
expect(dnd.isDragOver.value).toBe(false)
expect(document.body.style.userSelect).toBe('')
expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
})
it('deep clones dropped data and centers the node after Vue Flow initializes it', async () => {
const dnd = mountHarness()
const data = {
type: 'ScanFileAction',
name: '扫描目录',
description: '扫描媒体目录',
data: { nested: { enabled: true } },
}
dnd.onDragStart({ dataTransfer: createDataTransfer() }, data)
dnd.onDrop({ clientX: 80, clientY: 120 })
expect(mocks.screenToFlowCoordinate).toHaveBeenCalledWith({ x: 80, y: 120 })
expect(mocks.addNodes).toHaveBeenCalledOnce()
const node = mocks.addNodes.mock.calls[0][0]
expect(node).toMatchObject({
type: 'ScanFileAction',
name: '扫描目录',
description: '扫描媒体目录',
position: { x: 80, y: 120 },
data: { nested: { enabled: true } },
})
expect(node.id).toMatch(/^act_/)
expect(node.data).not.toBe(data.data)
data.data.nested.enabled = false
expect(node.data.nested.enabled).toBe(true)
expect(mocks.onNodesInitialized).toHaveBeenCalledOnce()
mocks.onNodesInitializedCallback?.()
expect(mocks.updateNode).toHaveBeenCalledWith(node.id, expect.any(Function))
const update = mocks.updateNode.mock.calls[0][1] as (node: {
position: { x: number; y: number }
dimensions: { width: number; height: number }
}) => { position: { x: number; y: number } }
expect(update({ position: { x: 80, y: 120 }, dimensions: { width: 40, height: 20 } })).toEqual({
position: { x: 60, y: 110 },
})
expect(mocks.off).toHaveBeenCalledOnce()
})
it('cleans the global selection style and drop listener when unmounted during a drag', async () => {
const dnd = mountHarness()
const removeEventListener = vi.spyOn(document, 'removeEventListener')
dnd.onDragStart({ dataTransfer: createDataTransfer() }, { type: 'NoteAction', name: '备注' })
await nextTick()
expect(document.body.style.userSelect).toBe('none')
wrapper!.unmount()
await nextTick()
expect(document.body.style.userSelect).toBe('')
expect(dnd.isDragging.value).toBe(false)
expect(dnd.isDragOver.value).toBe(false)
expect(dnd.draggedData.value).toBeNull()
expect(removeEventListener).toHaveBeenCalledWith('drop', expect.any(Function))
expect(removeEventListener).toHaveBeenCalledWith('dragend', expect.any(Function))
})
})

View File

@@ -0,0 +1,27 @@
/** 建立带 CORS 响应头的浏览器缓存,必要时修复旧的非 CORS 缓存条目。 */
export async function preloadCorsImage(url: string): Promise<boolean> {
const request = async (cache: RequestCache) => {
const source = new URL(url, window.location.href)
const response = await fetch(source, {
cache,
credentials: source.origin === window.location.origin ? 'same-origin' : 'omit',
mode: 'cors',
})
if (!response.ok) return false
await response.blob()
return true
}
try {
if (await request('force-cache')) return true
} catch {
// 缓存中的非 CORS 响应可能使首次读取失败,重新验证后再决定是否回退。
}
try {
return await request('reload')
} catch {
return false
}
}

View File

@@ -1,9 +1,11 @@
import ColorThief from 'colorthief'
export { preloadCorsImage } from './corsImage'
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
const DOMINANT_COLOR_CACHE_LIMIT = 100
const colorThief = new ColorThief()
const dominantColorCache = new Map<string, Promise<string>>()
const dominantColorCache = new Map<string, string>()
const pendingDominantColorRequests = new Map<string, Promise<string | undefined>>()
interface DominantColorOptions {
fallback?: string
@@ -28,64 +30,89 @@ function getImageCacheKey(image: HTMLImageElement) {
return image.currentSrc || image.src || ''
}
function rememberDominantColor(key: string, colorPromise: Promise<string>) {
if (!key) return colorPromise
function rememberDominantColor(key: string, color: string) {
if (!key) return
if (dominantColorCache.size >= DOMINANT_COLOR_CACHE_LIMIT) {
const firstKey = dominantColorCache.keys().next().value
if (firstKey) dominantColorCache.delete(firstKey)
}
dominantColorCache.set(key, colorPromise)
dominantColorCache.set(key, color)
}
/** 提取真实主色;失败不写入成功缓存,允许后续请求重试。 */
export async function extractDominantColor(
image: HTMLImageElement | undefined | null,
options: Pick<DominantColorOptions, 'quality'> = {},
): Promise<string | undefined> {
if (!image) return undefined
const cacheKey = getImageCacheKey(image)
const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined
if (cachedColor) return cachedColor
const pendingRequest = cacheKey ? pendingDominantColorRequests.get(cacheKey) : undefined
if (pendingRequest) return pendingRequest
const colorPromise = Promise.resolve()
.then(() => {
const dominantColor = colorThief.getColor(image, options.quality ?? 20)
const color = rgbStringToHex(dominantColor)
rememberDominantColor(cacheKey, color)
return color
})
.catch(error => {
console.warn('Failed to extract dominant color:', error)
return undefined
})
.finally(() => {
if (cacheKey) pendingDominantColorRequests.delete(cacheKey)
})
if (cacheKey) pendingDominantColorRequests.set(cacheKey, colorPromise)
return colorPromise
}
// 提取主要颜色
/** 提取主色并在失败时解析调用方 fallback保持既有调用合同。 */
export async function getDominantColor(
image: HTMLImageElement | undefined | null,
options: DominantColorOptions = {},
): Promise<string> {
const fallback = options.fallback ?? DEFAULT_DOMINANT_COLOR
if (!image) return fallback
const cacheKey = getImageCacheKey(image)
const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined
if (cachedColor) return cachedColor
const colorPromise = Promise.resolve()
.then(() => {
const dominantColor = colorThief.getColor(image, options.quality ?? 20)
return rgbStringToHex(dominantColor)
})
.catch(error => {
console.warn('Failed to extract dominant color:', error)
return fallback
})
return rememberDominantColor(cacheKey, colorPromise)
return (await extractDominantColor(image, options)) ?? fallback
}
// 预加载图片
export async function preloadImage(url: string): Promise<boolean> {
return new Promise(resolve => {
const img = new Image()
let settled = false
const finish = (available: boolean) => {
if (settled) return
img.onload = () => resolve(true)
img.onerror = () => resolve(false)
settled = true
clearTimeout(timeout)
resolve(available)
}
img.onload = () => finish(true)
img.onerror = () => finish(false)
// 设置超时,防止图片长时间加载
const timeout = setTimeout(() => {
img.src = ''
resolve(false)
finish(false)
}, 5000) // 5秒超时
img.src = url
// 如果图片已经缓存onload可能不会触发
if (img.complete) {
clearTimeout(timeout)
resolve(true)
finish(img.naturalWidth > 0)
}
})
}

View File

@@ -3,4 +3,9 @@ export function saveLocalTheme(name: string, theme: any) {
localStorage.setItem('theme', name)
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)
// 自动主题下次恢复时需要一个稳定的首帧明暗结果,避免媒体查询短暂返回浅色。
if (name === 'auto') {
localStorage.setItem('materio-initial-resolved-theme', theme.current.value.dark ? 'dark' : 'light')
}
}

View File

@@ -1,5 +1,5 @@
import { useVueFlow } from '@vue-flow/core'
import { ref, watch } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import { cloneDeep } from 'lodash-es'
/**
@@ -18,7 +18,7 @@ const state = {
/**
* The type of the node being dragged.
*/
draggedData: ref<any | null>({}),
draggedData: ref<any | null>(null),
isDragOver: ref(false),
isDragging: ref(false),
}
@@ -27,6 +27,7 @@ export default function useDragAndDrop() {
const { draggedData, isDragOver, isDragging } = state
const { addNodes, screenToFlowCoordinate, onNodesInitialized, updateNode } = useVueFlow()
let ownsDragListeners = false
watch(isDragging, dragging => {
document.body.style.userSelect = dragging ? 'none' : ''
@@ -42,6 +43,8 @@ export default function useDragAndDrop() {
isDragging.value = true
document.addEventListener('drop', onDragEnd)
document.addEventListener('dragend', onDragEnd)
ownsDragListeners = true
}
/**
@@ -70,6 +73,8 @@ export default function useDragAndDrop() {
isDragOver.value = false
draggedData.value = null
document.removeEventListener('drop', onDragEnd)
document.removeEventListener('dragend', onDragEnd)
ownsDragListeners = false
}
/**
@@ -78,6 +83,8 @@ export default function useDragAndDrop() {
* @param {DragEvent} event
*/
function onDrop(event: any) {
if (!draggedData.value) return
const position = screenToFlowCoordinate({
x: event.clientX,
y: event.clientY,
@@ -110,6 +117,12 @@ export default function useDragAndDrop() {
addNodes(newNode)
}
onUnmounted(() => {
if (!ownsDragListeners) return
onDragEnd()
document.body.style.userSelect = ''
})
return {
draggedData,
isDragOver,

View File

@@ -1,7 +1,9 @@
<script lang="ts" setup>
import type { Component } from 'vue'
import { useDisplay } from 'vuetify'
import logo from '@images/logo.svg?raw'
import { useI18n } from 'vue-i18n'
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
import { useGlobalSettingsStore } from '@/stores'
interface Props {
tag?: string | Component
@@ -14,6 +16,17 @@ const props = withDefaults(defineProps<Props>(), {
})
const { mdAndDown } = useDisplay()
const { t } = useI18n()
const globalSettingsStore = useGlobalSettingsStore()
const pythonFreeThreaded = computed(() => globalSettingsStore.get('PYTHON_FREE_THREADED') === true)
const runtimeVersion = computed(() => (pythonFreeThreaded.value ? 'v3t' : 'v3'))
const runtimeGilFallback = computed(
() => pythonFreeThreaded.value && globalSettingsStore.get('PYTHON_GIL_ENABLED') === true,
)
const runtimeStatusIcon = computed(() => (runtimeGilFallback.value ? 'mdi-alert-circle-outline' : 'mdi-flask-outline'))
const runtimeStatusHint = computed(() =>
t(runtimeGilFallback.value ? 'app.freeThreadedGilFallbackWarning' : 'app.freeThreadedExperimentalHint'),
)
const refNav = ref()
const route = useRoute()
@@ -51,10 +64,23 @@ function handleNavScroll(evt: Event) {
<div class="nav-header">
<slot name="nav-header">
<RouterLink to="/" class="app-logo d-flex align-center app-title-wrapper">
<div class="d-flex" v-html="logo" />
<ThemeLogoMark />
<h1 class="font-weight-bold leading-normal text-xl">
MOVIEPILOT <span class="text-sm text-gray-500">v2</span>
<h1 class="leading-normal text-xl">
<span class="moviepilot-wordmark">MOVIEPILOT</span>
<span
class="runtime-version text-sm text-gray-500 d-inline-flex align-center"
:class="{
'runtime-version--free-threaded': pythonFreeThreaded,
'runtime-version--degraded': runtimeGilFallback,
}"
>
{{ runtimeVersion }}
<VIcon v-if="pythonFreeThreaded" :icon="runtimeStatusIcon" size="13" :aria-label="runtimeStatusHint" />
<VTooltip v-if="pythonFreeThreaded" activator="parent" location="bottom">
{{ runtimeStatusHint }}
</VTooltip>
</span>
</h1>
</RouterLink>
</slot>
@@ -92,7 +118,10 @@ function handleNavScroll(evt: Event) {
inline-size: variables.$layout-vertical-nav-width;
inset-block-start: 0;
inset-inline-start: 0;
transition: transform 0.25s ease-in-out, inline-size 0.25s ease-in-out, box-shadow 0.25s ease-in-out;
transition:
transform 0.25s ease-in-out,
inline-size 0.25s ease-in-out,
box-shadow 0.25s ease-in-out;
visibility: hidden;
will-change: transform, inline-size;
@@ -113,6 +142,20 @@ function handleNavScroll(evt: Event) {
margin-inline-end: auto;
}
.runtime-version {
margin-inline-start: 0.25rem;
font-weight: 600;
line-height: 1;
}
.runtime-version--free-threaded {
gap: 0.2rem;
}
.runtime-version--degraded {
color: rgb(var(--v-theme-warning)) !important;
}
.nav-items {
block-size: 100%;

View File

@@ -1,11 +1,13 @@
<script lang="ts">
import { useDisplay } from 'vuetify'
import VerticalNav from '@layouts/components/VerticalNav.vue'
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
import {
readThemeCustomizerSettings,
THEME_CUSTOMIZER_CHANGE_EVENT,
type ThemeCustomizerSettings,
} from '@/composables/useThemeCustomizer'
import { useGlassFixedShellBackplate } from '@/composables/useGlassFixedShellBackplate'
import { usePWA } from '@/composables/usePWA'
export default defineComponent({
@@ -17,6 +19,7 @@ export default defineComponent({
const route = useRoute()
const { mdAndDown } = useDisplay()
const { appMode } = usePWA()
const fixedShellBackplate = useGlassFixedShellBackplate()
const themeLayout = ref(readThemeCustomizerSettings().layout)
const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value)
const isCollapsedLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'collapsed')
@@ -73,6 +76,8 @@ export default defineComponent({
})
return () => {
const hasFixedShellBackplate = fixedShellBackplate.layers.value.length > 0
// 👉 Vertical nav
const verticalNav = h(
VerticalNav,
@@ -85,6 +90,15 @@ export default defineComponent({
},
)
const fixedShellBackplateNode = hasFixedShellBackplate
? h(GlassFixedShellBackplate, {
isOverlayNav: mdAndDown.value,
isOverlayNavActive: isOverlayNavActive.value,
layers: fixedShellBackplate.layers.value,
transitionDurationMs: fixedShellBackplate.transitionDurationMs,
})
: null
// 👉 Navbar
const navbar = h(
'header',
@@ -145,11 +159,17 @@ export default defineComponent({
isCollapsedLayout.value && 'layout-vertical-nav-collapsed',
isHorizontalLayout.value && 'layout-horizontal-nav-active',
isHorizontalLayout.value && isNavbarScrolled && 'layout-horizontal-nav-scrolled',
hasFixedShellBackplate && 'layout-fixed-shell-backplate-active',
route.meta.layoutWrapperClasses,
!isHorizontalLayout.value && isNavbarScrolled && 'window-scrolled',
],
},
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
[
fixedShellBackplateNode,
verticalNav,
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
layoutOverlay,
],
)
}
},
@@ -193,7 +213,7 @@ export default defineComponent({
// 单独提升顶栏图层可避免导航栏短暂上移到安全区下方。
backface-visibility: hidden;
block-size: var(--layout-navbar-block-size);
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
inline-size: calc(100% - variables.$layout-vertical-nav-width);
inset-block-start: 0;
transform: translate3d(0, 0, 0);
@@ -257,7 +277,7 @@ export default defineComponent({
}
&.layout-vertical-nav-collapsed .layout-navbar {
inline-size: calc(100vw - variables.$layout-vertical-nav-collapsed-width - 0.5rem);
inline-size: calc(100% - variables.$layout-vertical-nav-collapsed-width);
}
&.layout-vertical-nav-collapsed .layout-vertical-nav:not(.overlay-nav) {
@@ -273,16 +293,7 @@ export default defineComponent({
transform: none !important;
}
.app-logo > div {
display: flex;
overflow: hidden;
align-items: center;
justify-content: center;
block-size: 2.75rem;
inline-size: 2.75rem;
}
.app-logo svg {
.app-logo .theme-logo-mark {
block-size: 2.5rem;
inline-size: 2.5rem;
}

View File

@@ -0,0 +1,85 @@
import VerticalNav from '@/@layouts/components/VerticalNav.vue'
import { shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
freeThreaded: false,
gilEnabled: true,
}))
vi.mock('@/stores', () => ({
useGlobalSettingsStore: () => ({
get: (key: string) => {
if (key === 'PYTHON_FREE_THREADED') return mocks.freeThreaded
if (key === 'PYTHON_GIL_ENABLED') return mocks.gilEnabled
return undefined
},
}),
}))
vi.mock('vue-i18n', async importOriginal => ({
...(await importOriginal<typeof import('vue-i18n')>()),
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vue-router', async importOriginal => ({
...(await importOriginal<typeof import('vue-router')>()),
useRoute: () => ({ path: '/' }),
}))
vi.mock('vuetify', async importOriginal => ({
...(await importOriginal<typeof import('vuetify')>()),
useDisplay: () => ({ mdAndDown: { value: false } }),
}))
function renderNavigation() {
return shallowMount(VerticalNav, {
props: {
isOverlayNavActive: false,
toggleIsOverlayNavActive: vi.fn(),
},
global: {
stubs: {
PerfectScrollbar: { template: '<ul><slot /></ul>' },
RouterLink: { template: '<a><slot /></a>' },
ThemeLogoMark: true,
VIcon: { props: ['ariaLabel'], template: '<span>{{ ariaLabel }}</span>' },
VTooltip: { template: '<span><slot /></span>' },
},
},
})
}
describe('VerticalNav runtime version', () => {
beforeEach(() => {
mocks.freeThreaded = false
mocks.gilEnabled = true
})
it('shows v3 for the standard runtime', () => {
const navigation = renderNavigation()
expect(navigation.text()).toContain('MOVIEPILOT')
expect(navigation.get('.runtime-version').text()).toBe('v3')
})
it('shows v3t for the free-threaded runtime', () => {
mocks.freeThreaded = true
mocks.gilEnabled = false
const navigation = renderNavigation()
expect(navigation.get('.runtime-version--free-threaded').text()).toContain('v3t')
expect(navigation.get('.runtime-version--free-threaded').classes()).not.toContain('runtime-version--degraded')
expect(navigation.text()).toContain('app.freeThreadedExperimentalHint')
})
it('marks v3t when the runtime has enabled the GIL', () => {
mocks.freeThreaded = true
const navigation = renderNavigation()
expect(navigation.get('.runtime-version--degraded').text()).toContain('v3t')
expect(navigation.text()).toContain('app.freeThreadedGilFallbackWarning')
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,214 @@
import ace from 'ace-builds'
import { describe, expect, it } from 'vitest'
import '@/ace-config'
interface AceToken {
type: string
value: string
}
interface WordListMode {
getTokenizer: () => {
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
}
}
interface JinjaMode {
getTokenizer: () => {
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
}
}
const WordListMode = ace.require('ace/mode/word_list').Mode as new (options?: { syntax?: boolean }) => WordListMode
const Jinja2Mode = ace.require('ace/mode/jinja2').Mode as new () => JinjaMode
const Jinja2JsonMode = ace.require('ace/mode/jinja2_json').Mode as new () => JinjaMode
const syntaxTokenizer = new WordListMode({ syntax: true }).getTokenizer()
const plainTokenizer = new WordListMode().getTokenizer()
function tokenize(line: string) {
return syntaxTokenizer.getLineTokens(line, 'start').tokens
}
function tokenizePlain(line: string) {
return plainTokenizer.getLineTokens(line, 'start').tokens
}
function findToken(mode: new () => JinjaMode, line: string, value: string) {
return new mode()
.getTokenizer()
.getLineTokens(line, 'start')
.tokens.find(token => token.value === value)
}
describe('Jinja context variables', () => {
it('keeps source-specific IDs and excludes unified identity from rename formats', () => {
expect(findToken(Jinja2Mode, '{{ tmdbid }}', 'tmdbid')?.type).toBe('variable.language.jinja2')
expect(findToken(Jinja2Mode, '{{ media_source }}', 'media_source')?.type).toBe('identifier')
expect(findToken(Jinja2Mode, '{{ media_id }}', 'media_id')?.type).toBe('identifier')
})
it('keeps unified identity available in notification JSON templates', () => {
const template = '{"source": "{{ media_source }}", "id": "{{ media_id }}"}'
expect(findToken(Jinja2JsonMode, template, 'media_source')?.type).toBe('variable.language.jinja2')
expect(findToken(Jinja2JsonMode, template, 'media_id')?.type).toBe('variable.language.jinja2')
})
})
describe('word list syntax mode', () => {
it('highlights a block word as one field', () => {
expect(tokenize('屏蔽词')).toEqual([{ type: 'word_list_block', value: '屏蔽词' }])
})
it('parses valid replacement parameters', () => {
expect(tokenize('旧名.* => 新名 {[tmdbid=123;type=tv]}')).toEqual([
{ type: 'word_list_replaced', value: '旧名.*' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 ' },
{ type: 'word_list_parameter_syntax', value: '{[' },
{ type: 'word_list_parameter_key', value: 'tmdbid' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '123' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'type' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: 'tv' },
{ type: 'word_list_parameter_syntax', value: ']}' },
])
})
it('rejects unified media identity parameters in custom identifiers', () => {
const invalidKeys = tokenize('旧名 => {[media_source=douban;media_id=1295644]}')
.filter(token => token.type === 'invalid.word-list')
.map(token => token.value)
expect(invalidKeys).toEqual(['media_source', 'media_id'])
})
it('marks invalid replacement parameter keys and values', () => {
expect(tokenize('旧名 => 新名 {[unknown=1;tmdbid=abc;type=anime;g=group;s=2;e=3]}')).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 ' },
{ type: 'word_list_parameter_syntax', value: '{[' },
{ type: 'invalid.word-list', value: 'unknown' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '1' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'tmdbid' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'invalid.word-list', value: 'abc' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'type' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'invalid.word-list', value: 'anime' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'g' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: 'group' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 's' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '2' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'e' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '3' },
{ type: 'word_list_parameter_syntax', value: ']}' },
])
})
it('marks an unknown replacement parameter key as invalid', () => {
expect(tokenize('旧名 => {[unknown=1]}')).toContainEqual({ type: 'invalid.word-list', value: 'unknown' })
})
it('marks visible syntax for missing or malformed replacement parameters', () => {
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123;broken;;]}')
.filter(token => token.type === 'invalid.word-list')
.map(token => token.value)
expect(invalidTokenValues).toEqual(['tmdbid=', '=123', 'broken;;'])
expect(tokenize('旧名 => {[]}')).toContainEqual({ type: 'invalid.word-list', value: '{[]}' })
})
it('marks the second semicolon when it creates an empty parameter', () => {
const semicolonTokens = tokenize('旧名 => {[tmdbid=1;;type=tv]}').filter(token => token.value.includes(';'))
expect(semicolonTokens).toEqual([{ type: 'invalid.word-list', value: ';;' }])
})
it('marks both sides of missing keys and values', () => {
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123]}')
.filter(token => token.type === 'invalid.word-list')
.map(token => token.value)
expect(invalidTokenValues).toEqual(['tmdbid=', '=123'])
})
it('treats an unclosed parameter block as ordinary replacement text', () => {
expect(tokenize('旧名 => 新名 {[tmdbid=1')).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 {[tmdbid=1' },
])
})
it('accepts every supported replacement parameter type', () => {
expect(
tokenize('旧名 => {[tmdbid=1;doubanid=2;bangumiid=3;anilistid=4;type=movie;g=group;s=5;e=6]}'),
).not.toContainEqual(expect.objectContaining({ type: 'invalid.word-list' }))
})
it('accepts season and episode number ranges', () => {
expect(tokenize('旧名 => {[s=1-2;e=3-5]}')).not.toContainEqual(
expect.objectContaining({ type: 'invalid.word-list' }),
)
})
it('separates front, back, and episode offset fields', () => {
expect(tokenize('第 <> 集 >> EP+1')).toEqual([
{ type: 'word_list_front', value: '第' },
{ type: 'keyword.operator.word-list', value: ' <> ' },
{ type: 'word_list_back', value: '集' },
{ type: 'keyword.operator.word-list', value: ' >> ' },
{ type: 'word_list_offset', value: 'EP+1' },
])
})
it('uses all six field types in combined and standalone rules', () => {
const combinedTokens = tokenize('旧名 => 新名 && 第 <> 集 >> 2*EP-1')
expect(combinedTokens).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名' },
{ type: 'keyword.operator.word-list', value: ' && ' },
{ type: 'word_list_front', value: '第' },
{ type: 'keyword.operator.word-list', value: ' <> ' },
{ type: 'word_list_back', value: '集' },
{ type: 'keyword.operator.word-list', value: ' >> ' },
{ type: 'word_list_offset', value: '2*EP-1' },
])
expect(tokenize('屏蔽词')[0].type).toBe('word_list_block')
})
it('highlights comment lines', () => {
expect(tokenize('# 这是一个注释')).toEqual([{ type: 'comment.word-list', value: '# 这是一个注释' }])
})
})
describe('word list plain mode', () => {
it('does not highlight comment lines', () => {
expect(tokenizePlain('# 这是一个注释')).toEqual([{ type: 'text', value: '# 这是一个注释' }])
})
it('does not highlight word list fields', () => {
expect(tokenizePlain('旧名 => 新名 && 第 <> 集 >> 2*EP-1')).toEqual([
{ type: 'text', value: '旧名 => 新名 && 第 <> 集 >> 2*EP-1' },
])
})
it('returns no tokens for empty lines', () => {
expect(tokenizePlain('')).toEqual([])
})
})

View File

@@ -70,7 +70,7 @@ function registerJinja2Mode() {
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
const operators = 'and|in|is|not|or'
const contextVariables =
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
const keywordMapper = this.createKeywordMapper(
{
@@ -282,7 +282,7 @@ function registerJinja2Mode() {
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
const operators = 'and|in|is|not|or'
const contextVariables =
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|bangumiid|anilistid|media_source|media_id|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
const keywordMapper = this.createKeywordMapper(
{
@@ -530,6 +530,234 @@ function registerJinja2Mode() {
)
}
interface WordListToken {
type: string
value: string
}
const wordListReplacementParametersPattern = /\{\[([^\]]*)\]\}/g
const wordListUnsignedIntegerPattern = /^\d+$/
const wordListUnsignedIntegerOrRangePattern = /^\d+(?:-\d+)?$/
const wordListParameterTypes = {
tmdbid: 'uint',
doubanid: 'uint',
bangumiid: 'uint',
anilistid: 'uint',
type: 'media-type',
g: 'string',
s: 'uint-or-range',
e: 'uint-or-range',
} as const
function appendWordListToken(tokens: WordListToken[], type: string, value: string) {
if (!value) return
const previousToken = tokens.at(-1)
if (previousToken?.type === type) previousToken.value += value
else tokens.push({ type, value })
}
function isValidWordListParameterValue(key: keyof typeof wordListParameterTypes, value: string) {
switch (wordListParameterTypes[key]) {
case 'uint':
return wordListUnsignedIntegerPattern.test(value)
case 'uint-or-range':
return wordListUnsignedIntegerOrRangePattern.test(value)
case 'media-type':
return value === 'movie' || value === 'tv'
case 'string':
return value.length > 0
}
}
function tokenizeWordListParameters(parameters: string): WordListToken[] {
const tokens: WordListToken[] = []
const parameterList = parameters.split(';')
parameterList.forEach((parameter, index) => {
const isEmptyParameter = parameter.length === 0
if (index > 0) {
appendWordListToken(
tokens,
parameterList[index - 1] === '' || isEmptyParameter ? 'invalid.word-list' : 'word_list_parameter_syntax',
';',
)
}
if (isEmptyParameter) return
const equalsIndex = parameter.indexOf('=')
if (equalsIndex === -1) {
appendWordListToken(tokens, 'invalid.word-list', parameter)
return
}
const key = parameter.slice(0, equalsIndex)
const value = parameter.slice(equalsIndex + 1)
const hasKey = key.length > 0
const hasValue = value.length > 0
const isKnownKey = hasKey && Object.hasOwn(wordListParameterTypes, key)
appendWordListToken(tokens, isKnownKey && hasValue ? 'word_list_parameter_key' : 'invalid.word-list', key)
appendWordListToken(tokens, hasKey && hasValue ? 'word_list_parameter_syntax' : 'invalid.word-list', '=')
const isValidValue =
isKnownKey &&
!value.includes('=') &&
isValidWordListParameterValue(key as keyof typeof wordListParameterTypes, value)
appendWordListToken(
tokens,
isValidValue || (!isKnownKey && hasKey) ? 'word_list_parameter_value' : 'invalid.word-list',
value,
)
})
return tokens
}
function tokenizeWordListReplacement(replacement: string): WordListToken[] {
const tokens: WordListToken[] = []
let replacementStart = 0
for (const match of replacement.matchAll(wordListReplacementParametersPattern)) {
const parameterStart = match.index ?? 0
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart, parameterStart))
if (!match[1]) appendWordListToken(tokens, 'invalid.word-list', match[0])
else {
appendWordListToken(tokens, 'word_list_parameter_syntax', '{[')
tokenizeWordListParameters(match[1]).forEach(token => appendWordListToken(tokens, token.type, token.value))
appendWordListToken(tokens, 'word_list_parameter_syntax', ']}')
}
replacementStart = parameterStart + match[0].length
}
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart))
return tokens
}
function splitWordListField(value: string, operator: string) {
let operatorIndex = value.indexOf(operator)
while (operatorIndex !== -1) {
const operatorEnd = operatorIndex + operator.length
if (value[operatorIndex - 1] === ' ' && value[operatorEnd] === ' ') {
let operatorStart = operatorIndex
while (value[operatorStart - 1] === ' ') operatorStart -= 1
let operatorWithSpacesEnd = operatorEnd
while (value[operatorWithSpacesEnd] === ' ') operatorWithSpacesEnd += 1
return {
before: value.slice(0, operatorStart),
operator: value.slice(operatorStart, operatorWithSpacesEnd),
after: value.slice(operatorWithSpacesEnd),
}
}
operatorIndex = value.indexOf(operator, operatorEnd)
}
}
function getWordListLineWhitespace(value: string) {
const leadingSpaceLength = value.length - value.trimStart().length
const trailingSpaceLength = value.length - value.trimEnd().length
return {
leadingSpace: value.slice(0, leadingSpaceLength),
content: value.slice(leadingSpaceLength, value.length - trailingSpaceLength),
trailingSpace: value.slice(value.length - trailingSpaceLength),
}
}
function tokenizeWordListReplacementLine(value: string): WordListToken[] {
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
const replacementField = splitWordListField(content, '=>')
if (!replacementField) return [{ type: 'text', value }]
const tokens: WordListToken[] = []
appendWordListToken(tokens, 'text', leadingSpace)
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
tokenizeWordListReplacement(replacementField.after).forEach(token =>
appendWordListToken(tokens, token.type, token.value),
)
appendWordListToken(tokens, 'text', trailingSpace)
return tokens
}
function tokenizeCombinedWordListLine(value: string): WordListToken[] {
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
const replacementField = splitWordListField(content, '=>')
const frontField = replacementField && splitWordListField(replacementField.after, '&&')
const backField = frontField && splitWordListField(frontField.after, '<>')
const offsetField = backField && splitWordListField(backField.after, '>>')
if (!replacementField || !frontField || !backField || !offsetField) return [{ type: 'text', value }]
const tokens: WordListToken[] = []
appendWordListToken(tokens, 'text', leadingSpace)
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
tokenizeWordListReplacement(frontField.before).forEach(token => appendWordListToken(tokens, token.type, token.value))
appendWordListToken(tokens, 'keyword.operator.word-list', frontField.operator)
appendWordListToken(tokens, 'word_list_front', backField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', backField.operator)
appendWordListToken(tokens, 'word_list_back', offsetField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', offsetField.operator)
appendWordListToken(tokens, 'word_list_offset', offsetField.after)
appendWordListToken(tokens, 'text', trailingSpace)
return tokens
}
function buildWordListRules(syntax: boolean) {
if (!syntax) {
return {
start: [
{
token: 'empty_line',
regex: '^$',
},
{
defaultToken: 'text',
},
],
}
}
return {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
{
token: 'text',
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
onMatch: tokenizeCombinedWordListLine,
},
{
token: 'text',
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
onMatch: tokenizeWordListReplacementLine,
},
{
token: [
'text',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_block', 'text'],
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
},
],
}
}
function registerWordListMode() {
aceModule.define?.(
'ace/mode/word_list_highlight_rules',
@@ -538,15 +766,8 @@ function registerWordListMode() {
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: /^#.*/,
},
],
}
const WordListHighlightRules = function (this: any, options?: { syntax?: boolean }) {
this.$rules = buildWordListRules(options?.syntax === true)
this.normalizeRules()
}
@@ -564,9 +785,10 @@ function registerWordListMode() {
const TextMode = require('./text').Mode
const WordListHighlightRules = require('./word_list_highlight_rules').WordListHighlightRules
const Mode = function (this: any) {
const Mode = function (this: any, options?: { syntax?: boolean }) {
TextMode.call(this)
this.HighlightRules = WordListHighlightRules
this.$highlightRuleConfig = options || {}
}
oop.inherits(Mode, TextMode)

View File

@@ -0,0 +1,457 @@
import axios, {
AxiosError,
AxiosHeaders,
CanceledError,
type AxiosAdapter,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios'
import { ApiRequestError, createApiClients, type ApiFeedbackNotifier } from '@/api/client'
import type { ApiResponse } from '@/api/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const notifier: ApiFeedbackNotifier = {
error: vi.fn(),
success: vi.fn(),
}
/** 构造 Axios 适配器响应,避免客户端单测依赖真实网络。 */
function createResponse<T>(
config: InternalAxiosRequestConfig,
data: T,
status = 200,
headers: Record<string, string> = {},
): AxiosResponse<T> {
return {
config,
data,
headers: new AxiosHeaders(headers),
status,
statusText: status >= 400 ? 'Error' : 'OK',
}
}
/** 创建始终成功返回指定数据的 Axios 适配器。 */
function resolveWith<T>(data: T, headers: Record<string, string> = {}): AxiosAdapter {
return async config => createResponse(config, data, 200, headers)
}
/** 创建带 Axios 响应元数据的 HTTP 失败适配器。 */
function rejectWith<T>(data: T, status: number, headers: Record<string, string> = {}): AxiosAdapter {
return async config => {
const response = createResponse(config, data, status, headers)
throw new AxiosError('Request failed', AxiosError.ERR_BAD_RESPONSE, config, undefined, response)
}
}
/** 断言并收窄统一 API 错误,避免测试绕过 unknown 错误边界。 */
function requireApiRequestError(reason: unknown): ApiRequestError {
expect(reason).toBeInstanceOf(ApiRequestError)
if (!(reason instanceof ApiRequestError)) throw reason
return reason
}
describe('MoviePilot API client', () => {
beforeEach(() => {
vi.mocked(notifier.error).mockReset()
vi.mocked(notifier.success).mockReset()
})
it.each([
['object', { id: 7 }],
['array', ['one', 'two']],
['scalar', 42],
['null', null],
])('仅向内部调用方返回成功 envelope 的 data%s', async (_label, data) => {
const envelope: ApiResponse<typeof data> = { success: true, message: '', data }
const { api } = createApiClients({ adapter: resolveWith(envelope), notifier })
await expect(api.get<typeof data>('/resource')).resolves.toEqual(data)
expect(notifier.error).not.toHaveBeenCalled()
})
it('只在 feedback=all 时展示后端成功消息', async () => {
const envelope: ApiResponse<{ saved: boolean }> = {
success: true,
message: 'Saved',
data: { saved: true },
}
const { api } = createApiClients({ adapter: resolveWith(envelope), notifier })
await api.post('/setting', {}, { feedback: 'default' })
await api.post('/setting', {}, { feedback: 'all' })
expect(notifier.success).toHaveBeenCalledOnce()
expect(notifier.success).toHaveBeenCalledWith('Saved')
})
it('将 success=false 转为保留完整响应元数据的 ApiRequestError', async () => {
const envelope: ApiResponse<{ reason: string }> = {
success: false,
message: 'Cannot save',
data: { reason: 'locked' },
}
const { api } = createApiClients({
adapter: resolveWith(envelope, { 'x-request-id': 'request-1' }),
notifier,
})
const error = requireApiRequestError(await api.post('/setting', {}).catch(reason => reason))
expect(axios.isAxiosError(error)).toBe(true)
expect(error).toMatchObject({
message: 'Cannot save',
payload: envelope,
status: 200,
})
expect(error.response?.data).toEqual(envelope)
expect(AxiosHeaders.from(error.headers as AxiosHeaders | undefined).get('x-request-id')).toBe('request-1')
expect(notifier.error).toHaveBeenCalledWith('Cannot save')
})
it('将 HTTP 错误统一转换为 ApiRequestError 并保留 payload、status 和 headers', async () => {
const envelope: ApiResponse<{ mfa_methods: string[] }> = {
success: false,
message: 'Additional verification required',
data: { mfa_methods: ['otp'] },
}
const { api } = createApiClients({
adapter: rejectWith(envelope, 403, { 'x-mfa-required': 'true' }),
notifier,
})
const error = requireApiRequestError(await api.post('/login/access-token', {}).catch(reason => reason))
expect(error.message).toBe('Additional verification required')
expect(error.status).toBe(403)
expect(error.payload).toEqual(envelope)
expect(error.response?.data).toEqual(envelope)
expect(AxiosHeaders.from(error.response?.headers as AxiosHeaders | undefined).get('x-mfa-required')).toBe('true')
})
it('silent 关闭失败 Toast但不丢失错误元数据', async () => {
const envelope: ApiResponse<null> = { success: false, message: 'Invalid credentials', data: null }
const { api } = createApiClients({ adapter: rejectWith(envelope, 401), notifier })
const error = requireApiRequestError(
await api.post('/login/access-token', {}, { feedback: 'silent' }).catch(reason => reason),
)
expect(error.status).toBe(401)
expect(error.payload).toEqual(envelope)
expect(notifier.error).not.toHaveBeenCalled()
})
it('插件客户端保留完整 envelope同时沿用统一失败反馈', async () => {
const envelope: ApiResponse<{ plugin: string }> = {
success: false,
message: 'Plugin rejected request',
data: { plugin: 'demo' },
}
const { pluginApi } = createApiClients({ adapter: resolveWith(envelope), notifier })
await expect(pluginApi.get<unknown, ApiResponse<{ plugin: string }>>('/plugin/demo')).resolves.toEqual(envelope)
expect(notifier.error).toHaveBeenCalledWith('Plugin rejected request')
})
it.each([
['object', { plugin: 'demo', count: 2 }],
['array', [{ id: 1 }, { id: 2 }]],
['scalar', 'ready'],
['null', null],
])('插件客户端原样交付非 envelope payload%s', async (_label, payload) => {
const { pluginApi } = createApiClients({ adapter: resolveWith(payload), notifier })
await expect(pluginApi.get('/plugin/demo')).resolves.toEqual(payload)
expect(notifier.error).not.toHaveBeenCalled()
expect(notifier.success).not.toHaveBeenCalled()
})
it('Blob 成功响应绕过 envelope 解包', async () => {
const blob = new Blob(['moviepilot'], { type: 'application/octet-stream' })
const { api } = createApiClients({ adapter: resolveWith(blob), notifier })
await expect(api.get<Blob>('/download', { responseType: 'blob' })).resolves.toBe(blob)
})
it('解析 Blob 中的 JSON HTTP 错误后再构造 ApiRequestError', async () => {
const envelope: ApiResponse<{ file: string }> = {
success: false,
message: 'File is unavailable',
data: { file: 'movie.mkv' },
}
const blob = new Blob([JSON.stringify(envelope)], { type: 'application/json' })
const { api } = createApiClients({
adapter: rejectWith(blob, 404, { 'content-type': 'application/json' }),
notifier,
})
const error = requireApiRequestError(await api.get('/download', { responseType: 'blob' }).catch(reason => reason))
expect(error.message).toBe('File is unavailable')
expect(error.payload).toEqual(envelope)
expect(error.response?.data).toEqual(envelope)
})
it('相同技术错误在去重窗口内只提示一次,避免并发失败刷屏', async () => {
const { api } = createApiClients({
adapter: rejectWith({ message: 'Server exploded' }, 500),
notifier,
})
await api.get('/a').catch(reason => reason)
await api.get('/b').catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(1)
expect(notifier.error).toHaveBeenCalledWith('Server exploded')
})
it('去重窗口过期后相同技术错误可以再次提示', async () => {
vi.useFakeTimers()
try {
const { api } = createApiClients({
adapter: rejectWith({ message: 'Server exploded' }, 500),
notifier,
})
await api.get('/a').catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(1)
// 窗口15 秒)过后,新一轮相同错误应能再次提示。
vi.advanceTimersByTime(15000)
await api.get('/b').catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('成功响应后清空去重缓存,让后续技术错误可以再次提示', async () => {
vi.useFakeTimers()
try {
const { api } = createApiClients({
adapter: rejectWith({ message: 'Server exploded' }, 500),
notifier,
})
await api.get('/a').catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(1)
// 服务恢复后缓存应被清空,即使仍在窗口内,相同错误也可再次提示。
api.defaults.adapter = resolveWith<ApiResponse<{ ok: boolean }>>({
success: true,
message: '',
data: { ok: true },
})
await api.get('/ping')
api.defaults.adapter = rejectWith({ message: 'Server exploded' }, 500)
await api.get('/c').catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('连续无效 envelope 响应只提示一次,避免坏缓存命中时刷屏', async () => {
// 无效 envelope 是 HTTP 200 但响应体不是标准三键结构,模拟 SW 缓存回退出的坏响应。
const { api } = createApiClients({
adapter: resolveWith('<html>legacy app shell</html>'),
notifier,
})
await api.get('/a').catch(reason => reason)
await api.get('/b').catch(reason => reason)
await api.get('/c').catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(1)
expect(notifier.error).toHaveBeenCalledWith('Invalid API response envelope')
})
it('无效 envelope 响应不清空去重缓存,窗口内其他技术错误保持收敛', async () => {
const adapter: AxiosAdapter = async config => {
if (config.url === '/bad') return createResponse(config, '<html></html>', 200)
const response = createResponse(config, { message: 'Server exploded' }, 500)
throw new AxiosError('Request failed', AxiosError.ERR_BAD_RESPONSE, config, undefined, response)
}
const { api } = createApiClients({ adapter, notifier })
await api.get('/a').catch(reason => reason)
await api.get('/bad').catch(reason => reason)
await api.get('/b').catch(reason => reason)
// 500 技术错误在窗口内只提示一次;无效 envelope 属于不同消息单独提示一次,
// 且它不得清空去重缓存,否则第 3 个相同 500 错误会被再次弹出。
expect(notifier.error).toHaveBeenCalledTimes(2)
expect(notifier.error).toHaveBeenCalledWith('Server exploded')
expect(notifier.error).toHaveBeenCalledWith('Invalid API response envelope')
})
it('不同消息的技术错误互不影响,分别提示', async () => {
const adapter: AxiosAdapter = async config => {
const message = config.url === '/a' ? 'First failure' : 'Second failure'
const response = createResponse(config, { message }, 500)
throw new AxiosError('Request failed', AxiosError.ERR_BAD_RESPONSE, config, undefined, response)
}
const { api } = createApiClients({ adapter, notifier })
await api.get('/a').catch(reason => reason)
await api.get('/b').catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(2)
expect(notifier.error).toHaveBeenCalledWith('First failure')
expect(notifier.error).toHaveBeenCalledWith('Second failure')
})
it('业务失败不参与技术错误去重,始终逐条提示', async () => {
const envelope: ApiResponse<null> = { success: false, message: 'Cannot save', data: null }
const { api } = createApiClients({ adapter: resolveWith(envelope), notifier })
await api.post('/setting', {}).catch(reason => reason)
await api.post('/setting', {}).catch(reason => reason)
expect(notifier.error).toHaveBeenCalledTimes(2)
expect(notifier.error).toHaveBeenCalledWith('Cannot save')
})
it('取消请求保持原始 CanceledError且不提示或触发离线探测', async () => {
const reportConnectionFailure = vi.fn()
const adapter: AxiosAdapter = async () => {
throw new CanceledError('Request cancelled')
}
const { api } = createApiClients({ adapter, hooks: { reportConnectionFailure }, notifier })
const error = await api.get('/slow').catch(reason => reason)
expect(error).toBeInstanceOf(CanceledError)
expect(error).not.toBeInstanceOf(ApiRequestError)
expect(notifier.error).not.toHaveBeenCalled()
expect(reportConnectionFailure).not.toHaveBeenCalled()
})
it.each([502, 503, 504])('网关错误 %d 上报连接失败且不弹请求层 Toast', async status => {
const reportConnectionFailure = vi.fn()
const { api } = createApiClients({
adapter: rejectWith({ message: 'Gateway unavailable' }, status),
hooks: { reportConnectionFailure },
notifier,
})
const error = requireApiRequestError(await api.get('/resource').catch(reason => reason))
expect(error.status).toBe(status)
expect(reportConnectionFailure).toHaveBeenCalledWith('server-unreachable')
expect(notifier.error).not.toHaveBeenCalled()
})
it('网络错误仅上报离线状态,不弹请求层 Toast', async () => {
const reportConnectionFailure = vi.fn()
const adapter: AxiosAdapter = async () => {
throw new AxiosError('Network Error', AxiosError.ERR_NETWORK)
}
const { api } = createApiClients({ adapter, hooks: { reportConnectionFailure }, notifier })
await api.get('/resource').catch(reason => reason)
expect(reportConnectionFailure).toHaveBeenCalledWith('network-error')
expect(notifier.error).not.toHaveBeenCalled()
})
it('网关错误不算服务在线证据,仅成功响应恢复在线状态', async () => {
const markServerOnline = vi.fn()
const reportConnectionFailure = vi.fn()
const { api } = createApiClients({
adapter: rejectWith({ message: 'Bad Gateway' }, 502),
hooks: { markServerOnline, reportConnectionFailure },
notifier,
})
await api.get('/resource').catch(reason => reason)
expect(markServerOnline).not.toHaveBeenCalled()
expect(reportConnectionFailure).toHaveBeenCalledWith('server-unreachable')
})
it('401 被 onUnauthorized 接管时不逐条弹请求层 Toast', async () => {
const onUnauthorized = vi.fn(() => true)
const { api } = createApiClients({
adapter: rejectWith({ detail: 'Not authenticated' }, 401),
hooks: { onUnauthorized },
notifier,
})
const error = requireApiRequestError(await api.get('/resource').catch(reason => reason))
expect(error.status).toBe(401)
expect(onUnauthorized).toHaveBeenCalledWith(error)
expect(notifier.error).not.toHaveBeenCalled()
})
it('403 被 onForbidden 接管时不逐条弹请求层 Toast', async () => {
const onForbidden = vi.fn(() => true)
const { api } = createApiClients({
adapter: rejectWith({ detail: 'token校验不通过' }, 403),
hooks: { onForbidden },
notifier,
})
const error = requireApiRequestError(await api.get('/resource').catch(reason => reason))
expect(error.status).toBe(403)
expect(onForbidden).toHaveBeenCalledWith(error)
expect(notifier.error).not.toHaveBeenCalled()
})
it('401 未被 onUnauthorized 接管时保留逐条错误提示', async () => {
const onUnauthorized = vi.fn(() => false)
const { api } = createApiClients({
adapter: rejectWith({ detail: 'Not authenticated' }, 401),
hooks: { onUnauthorized },
notifier,
})
const error = requireApiRequestError(await api.get('/resource').catch(reason => reason))
expect(error.status).toBe(401)
expect(notifier.error).toHaveBeenCalledWith('Not authenticated')
})
it('拒绝缺少标准字段的普通 JSON 响应', async () => {
const { api } = createApiClients({ adapter: resolveWith({ value: 1 }), notifier })
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
expect(error.message).toBe('Invalid API response envelope')
})
it('拒绝带旧顶层 message_i18n 的 envelope', async () => {
const legacyEnvelope = {
success: false,
message: 'Failed',
message_i18n: '旧版错误',
data: null,
}
const { api } = createApiClients({ adapter: resolveWith(legacyEnvelope), notifier })
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
expect(error.message).toBe('Invalid API response envelope')
expect(error.payload).toEqual(legacyEnvelope)
})
it.each([
['zh-CN', '服务器返回了无效响应'],
['en-US', 'The server returned an invalid response'],
])('使用应用注入的 %s fallback 文案提示协议异常', async (_locale, translatedMessage) => {
const { api } = createApiClients({
adapter: resolveWith({ value: 1 }),
notifier,
resolveFallbackMessage: key => (key === 'invalid-envelope' ? translatedMessage : key),
})
const error = requireApiRequestError(await api.get('/legacy').catch(reason => reason))
expect(error.message).toBe(translatedMessage)
expect(notifier.error).toHaveBeenCalledWith(translatedMessage)
})
})

View File

@@ -0,0 +1,131 @@
import { AxiosError, AxiosHeaders, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
i18nT: vi.fn((key: string) => `translated:${key}`),
authState: { token: null as string | null },
logout: vi.fn(),
routerPush: vi.fn(),
toastError: vi.fn(),
}))
vi.mock('@/router', () => ({
default: { push: mocks.routerPush },
}))
vi.mock('@/stores', () => ({
useAuthStore: () => ({
get token() {
return mocks.authState.token
},
// 与真实 store 一致:登出时清空 token后续在途请求按无 token 处理。
logout: mocks.logout.mockImplementation(() => {
mocks.authState.token = null
}),
}),
}))
vi.mock('@/utils/requestOptimizer', () => ({
initializeRequestOptimizer: vi.fn(),
}))
vi.mock('@/composables/useOfflineStatus', () => ({
useGlobalOfflineStatus: () => ({
markServerOnline: vi.fn(),
reportNetworkError: vi.fn(),
}),
}))
vi.mock('@/plugins/i18n', () => ({
default: { global: { t: mocks.i18nT } },
getCurrentLocale: () => 'zh-CN',
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: vi.fn() }),
}))
/** 安装始终返回指定 HTTP 失败的适配器。 */
async function installFailingAdapter(status: number, data: unknown) {
const module = await import('@/api')
module.default.defaults.adapter = async config => {
const response: AxiosResponse = {
config: config as InternalAxiosRequestConfig,
data,
headers: new AxiosHeaders(),
status,
statusText: 'Error',
}
throw new AxiosError(
'Request failed',
AxiosError.ERR_BAD_RESPONSE,
config as InternalAxiosRequestConfig,
undefined,
response,
)
}
return module
}
describe('API application wiring', () => {
beforeEach(() => {
mocks.authState.token = null
mocks.logout.mockClear()
mocks.routerPush.mockClear()
mocks.toastError.mockClear()
})
it('向 window 暴露插件最终 payload 客户端,而内部默认导出数据客户端', async () => {
const module = await import('@/api')
expect(window.MoviePilotAPI).toBe(module.pluginApi)
expect(module.default).not.toBe(module.pluginApi)
})
it('通过 i18n 实例解析请求层 fallback 文案', async () => {
const module = await import('@/api')
module.default.defaults.adapter = async config => ({
config: config as InternalAxiosRequestConfig,
data: { legacy: true },
headers: new AxiosHeaders(),
status: 200,
statusText: 'OK',
})
const error = await module.default.get('/legacy').catch(reason => reason)
expect(error).toBeInstanceOf(module.ApiRequestError)
expect((error as Error).message).toBe('translated:common.invalidApiResponse')
expect(mocks.i18nT).toHaveBeenCalledWith('common.invalidApiResponse')
})
it('已登录时并发 401 只统一登出并静默返回登录页', async () => {
mocks.authState.token = 'expired-token'
const module = await installFailingAdapter(401, { detail: 'Not authenticated' })
await Promise.allSettled([module.default.get('/dashboard'), module.default.get('/subscribe')])
expect(mocks.logout).toHaveBeenCalledOnce()
expect(mocks.routerPush).toHaveBeenCalledWith('/login')
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('登出后的在途 401 持续静默,不在登录页暴露认证错误', async () => {
mocks.authState.token = 'expired-token'
const module = await installFailingAdapter(401, { detail: 'Not authenticated' })
await module.default.get('/dashboard').catch(() => {})
await module.default.get('/subscribe').catch(() => {})
await module.default.get('/resource').catch(() => {})
expect(mocks.logout).toHaveBeenCalledOnce()
expect(mocks.routerPush).toHaveBeenCalledOnce()
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('token 校验失败的 403 完成签退后不弹技术错误', async () => {
mocks.authState.token = 'invalid-token'
const module = await installFailingAdapter(403, { detail: 'token校验不通过' })
await Promise.allSettled([module.default.get('/dashboard'), module.default.get('/subscribe')])
expect(mocks.logout).toHaveBeenCalledOnce()
expect(mocks.routerPush).toHaveBeenCalledWith('/login')
expect(mocks.toastError).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,55 @@
import { manageLlmProvider, manageNotificationChannel, manageStorage } from '@/api/manage'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiPost: vi.fn(),
}))
vi.mock('@/api', () => ({
default: createDataApiMock({
post: (...args: unknown[]) => mocks.apiPost(...args),
}),
}))
describe('manage API adapters', () => {
beforeEach(() => {
mocks.apiPost.mockReset()
mocks.apiPost.mockResolvedValue({ ok: true })
})
it('透传通知渠道的目标、动作、参数和请求配置', async () => {
const config = { timeout: 1200 }
await expect(manageNotificationChannel('telegram', 'refresh', { force: true }, config)).resolves.toEqual({
ok: true,
})
expect(mocks.apiPost).toHaveBeenCalledWith(
'notification/manage',
{ target: 'telegram', action: 'refresh', params: { force: true } },
config,
)
})
it('为网盘存储动作补充空参数对象', async () => {
await manageStorage('alist', 'disconnect')
expect(mocks.apiPost).toHaveBeenCalledWith(
'storage/manage',
{ target: 'alist', action: 'disconnect', params: {} },
undefined,
)
})
it('透传 LLM Provider 管理动作并返回已解包业务数据', async () => {
mocks.apiPost.mockResolvedValueOnce([{ id: 'openai' }])
await expect(manageLlmProvider('', 'list_providers')).resolves.toEqual([{ id: 'openai' }])
expect(mocks.apiPost).toHaveBeenCalledWith(
'llm/manage',
{ target: '', action: 'list_providers', params: {} },
undefined,
)
})
})

View File

@@ -0,0 +1,57 @@
import { createScopedPluginApi } from '@/api/pluginInstance'
import type { PluginApiClient } from '@/api/client'
import { describe, expect, it, vi } from 'vitest'
/** 创建可调用且带 Axios 风格方法的最小插件客户端。 */
function createClient() {
const client = Object.assign(vi.fn(), {
get: vi.fn(),
post: vi.fn(),
request: vi.fn(),
})
return client as unknown as PluginApiClient
}
describe('createScopedPluginApi', () => {
it('rewrites only the source plugin dynamic API namespace', () => {
const client = createClient()
const scoped = createScopedPluginApi(client, 'DemoPluginwork', 'DemoPlugin')
void scoped.get('plugin/DemoPlugin/items')
void scoped.post('/api/v1/plugin/demoplugin/action?sync=1', { value: 1 })
void scoped.get('system/config')
expect(client.get).toHaveBeenNthCalledWith(1, 'plugin/DemoPluginwork/items')
expect(client.post).toHaveBeenCalledWith(
'/api/v1/plugin/DemoPluginwork/action?sync=1',
{ value: 1 },
)
expect(client.get).toHaveBeenNthCalledWith(2, 'system/config')
})
it('supports callable and request-config forms without mutating input', () => {
const client = createClient()
const scoped = createScopedPluginApi(client, 'DemoPluginhome', 'DemoPlugin')
const config = { url: '/plugin/DemoPlugin/status', method: 'GET' }
void scoped(config)
void scoped.request(config)
expect(client).toHaveBeenCalledWith({
url: '/plugin/DemoPluginhome/status',
method: 'GET',
})
expect(client.request).toHaveBeenCalledWith({
url: '/plugin/DemoPluginhome/status',
method: 'GET',
})
expect(config.url).toBe('/plugin/DemoPlugin/status')
})
it('returns the original client for ordinary plugins', () => {
const client = createClient()
expect(createScopedPluginApi(client, 'DemoPlugin')).toBe(client)
expect(createScopedPluginApi(client, 'DemoPlugin', 'DemoPlugin')).toBe(client)
})
})

417
src/api/client.ts Normal file
View File

@@ -0,0 +1,417 @@
import axios, {
AxiosError,
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type CreateAxiosDefaults,
} from 'axios'
import type { ApiResponse } from './types'
/** 控制请求层是否展示成功或失败反馈。 */
export type ApiFeedbackMode = 'silent' | 'default' | 'all'
/** 请求层无法从后端取得 message 时使用的本地化文案类别。 */
export type ApiFallbackMessageKey = 'invalid-envelope' | 'network-error' | 'request-failed' | 'timeout'
/** 将请求层 fallback 类别解析为当前语言文案,避免基础客户端依赖 Vue i18n。 */
export type ApiFallbackMessageResolver = (key: ApiFallbackMessageKey) => string
declare module 'axios' {
interface AxiosRequestConfig {
feedback?: ApiFeedbackMode
skipNavigationCancellation?: boolean
skipConnectionTracking?: boolean
}
}
/** 普通 API 客户端在响应拦截器解包后返回业务数据,而不是 AxiosResponse。 */
export interface DataApiClient extends Omit<
AxiosInstance,
'delete' | 'get' | 'head' | 'options' | 'patch' | 'patchForm' | 'post' | 'postForm' | 'put' | 'putForm' | 'request'
> {
<T = unknown, R = T, D = AxiosRequestConfig['data']>(config: AxiosRequestConfig<D>): Promise<R>
<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
request<T = unknown, R = T, D = AxiosRequestConfig['data']>(config: AxiosRequestConfig<D>): Promise<R>
get<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
delete<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
head<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
options<T = unknown, R = T, D = AxiosRequestConfig['data']>(url: string, config?: AxiosRequestConfig<D>): Promise<R>
post<T = unknown, R = T, D = AxiosRequestConfig['data']>(
url: string,
data?: D,
config?: AxiosRequestConfig<D>,
): Promise<R>
put<T = unknown, R = T, D = AxiosRequestConfig['data']>(
url: string,
data?: D,
config?: AxiosRequestConfig<D>,
): Promise<R>
patch<T = unknown, R = T, D = AxiosRequestConfig['data']>(
url: string,
data?: D,
config?: AxiosRequestConfig<D>,
): Promise<R>
postForm<T = unknown, R = T, D = AxiosRequestConfig['data']>(
url: string,
data?: D,
config?: AxiosRequestConfig<D>,
): Promise<R>
putForm<T = unknown, R = T, D = AxiosRequestConfig['data']>(
url: string,
data?: D,
config?: AxiosRequestConfig<D>,
): Promise<R>
patchForm<T = unknown, R = T, D = AxiosRequestConfig['data']>(
url: string,
data?: D,
config?: AxiosRequestConfig<D>,
): Promise<R>
}
/** 插件与联邦组件接收 endpoint 最终 payload方法签名不暴露 AxiosResponse 外壳。 */
export type PluginApiClient = DataApiClient
/** 请求层使用的反馈出口,由应用入口接到全局 Toast。 */
export interface ApiFeedbackNotifier {
error(message: string): void
success(message: string): void
}
/** 请求生命周期钩子用于隔离认证和离线状态等应用级副作用。 */
export interface ApiClientHooks {
markServerOnline?(): void
/** 返回 true 表示认证失败已由应用层接管,请求层不再弹出逐条错误提示。 */
onForbidden?(error: ApiRequestError): boolean | void
onUnauthorized?(error: ApiRequestError): boolean | void
reportConnectionFailure?(reason: 'network-error' | 'timeout' | 'server-unreachable'): void
}
/** 创建内部数据客户端与插件最终 payload 客户端时所需的配置。 */
export interface CreateApiClientsOptions extends CreateAxiosDefaults {
hooks?: ApiClientHooks
notifier?: ApiFeedbackNotifier
resolveFallbackMessage?: ApiFallbackMessageResolver
setupInstance?(instance: AxiosInstance): void
}
const defaultFallbackMessages: Record<ApiFallbackMessageKey, string> = {
'invalid-envelope': 'Invalid API response envelope',
'network-error': 'Network connection failed',
'request-failed': 'API request failed',
timeout: 'Request timeout',
}
/** 技术类失败提示的去重窗口:后端异常时并发请求会得到相同错误,窗口内只提示一次避免刷屏。 */
const TECHNICAL_ERROR_DEDUP_MS = 15000
interface ApiRequestErrorOptions<T> {
businessFailure?: boolean
cause?: unknown
code?: string
config?: AxiosRequestConfig
payload?: T
request?: unknown
response?: AxiosResponse<T>
}
/**
* 统一表示业务失败、HTTP 失败和请求协议错误。
*
* 该类型继承 AxiosError确保认证流程和既有调用方仍可读取 response、status、headers 与 config。
*/
export class ApiRequestError<T = unknown> extends AxiosError<T> {
readonly businessFailure: boolean
readonly headers?: AxiosResponse<T>['headers']
readonly payload?: T
/** 保留原始 Axios 元数据,并额外暴露已解析的响应载荷。 */
constructor(message: string, options: ApiRequestErrorOptions<T> = {}) {
super(
message,
options.code,
options.config as AxiosResponse<T>['config'] | undefined,
options.request,
options.response,
)
this.name = 'ApiRequestError'
this.businessFailure = options.businessFailure ?? false
this.headers = options.response?.headers
this.payload = options.payload
this.status = options.response?.status
if (options.cause instanceof Error) this.cause = options.cause
Object.setPrototypeOf(this, new.target.prototype)
}
}
/** 仅返回后端在成功 HTTP 响应中声明的业务失败消息。 */
export function getApiBusinessErrorMessage(error: unknown): string | undefined {
if (!(error instanceof ApiRequestError) || !error.businessFailure) return undefined
const payload = error.payload
if (!payload || typeof payload !== 'object') return undefined
const message = (payload as { message?: unknown }).message
return typeof message === 'string' && message.trim() ? message : undefined
}
/** 判断错误是否来自 HTTP 200 响应中的业务失败。 */
export function isApiBusinessFailure(error: unknown): error is ApiRequestError {
return error instanceof ApiRequestError && error.businessFailure
}
/** 判断未知值是否为 MoviePilot 标准响应 envelope。 */
export function isApiResponse<T = unknown>(value: unknown): value is ApiResponse<T> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const record = value as Record<string, unknown>
const keys = Object.keys(record)
if (keys.length !== 3 || !keys.every(key => key === 'success' || key === 'message' || key === 'data')) return false
return typeof record.success === 'boolean' && typeof record.message === 'string' && 'data' in record
}
/**
* 将 Axios 连接错误归类为全局服务探测可识别的原因。
*
* 网关不可用状态码502/503/504同样视为服务不可达后端重启或崩溃时网关
* 会返回这类响应,若只按“无响应”判断会漏掉重启场景的离线检测。
*/
export function resolveConnectionFailureReason(
error: AxiosError,
): 'network-error' | 'timeout' | 'server-unreachable' | 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'
}
const status = error.response?.status
if (status === 502 || status === 503 || status === 504) return 'server-unreachable'
return null
}
/** 创建普通数据客户端和保持插件自由响应 ABI 的最终 payload 客户端。 */
export function createApiClients(options: CreateApiClientsOptions = {}): {
api: DataApiClient
pluginApi: PluginApiClient
} {
const { hooks, notifier, resolveFallbackMessage, setupInstance, ...axiosConfig } = options
const api = axios.create(axiosConfig)
const pluginApi = axios.create(axiosConfig)
// 技术类失败提示的去重缓存:同一消息在窗口内只提示一次,避免后端异常时大量并发请求刷屏。
// 两个客户端共用同一份缓存,保证重复提示被整体收敛。
const technicalErrorDedup = new Map<string, number>()
// 优化器等底层拦截器必须先安装,确保它们在业务数据解包前仍能看到完整 AxiosResponse。
setupInstance?.(api)
setupInstance?.(pluginApi)
installResponseInterceptors(api, 'data', hooks, notifier, resolveFallbackMessage, technicalErrorDedup)
installResponseInterceptors(pluginApi, 'envelope', hooks, notifier, resolveFallbackMessage, technicalErrorDedup)
return {
api: api as DataApiClient,
pluginApi: pluginApi as PluginApiClient,
}
}
/** 为客户端安装统一的可达性、反馈、解包和错误标准化处理。 */
function installResponseInterceptors(
instance: AxiosInstance,
responseMode: 'data' | 'envelope',
hooks?: ApiClientHooks,
notifier?: ApiFeedbackNotifier,
resolveFallbackMessage?: ApiFallbackMessageResolver,
technicalErrorDedup?: Map<string, number>,
) {
instance.interceptors.response.use(
response => {
hooks?.markServerOnline?.()
if (isBinarySuccess(response)) return response.data
const payload: unknown = response.data
if (!isApiResponse(payload)) {
// 联邦插件自行声明 API 响应;非宿主 envelope 必须原样交给插件调用方。
if (responseMode === 'envelope') return payload
const error = new ApiRequestError(resolveFallback('invalid-envelope', resolveFallbackMessage), {
code: AxiosError.ERR_BAD_RESPONSE,
config: response.config,
payload,
request: response.request,
response,
})
// 无效 envelope 本身就是技术错误,必须留在去重窗口内收敛;
// 此处绝不能清空去重缓存,否则每个坏响应都会重置窗口并逐条弹 Toast 刷屏。
notifyFailure(response.config.feedback, notifier, error.message, technicalErrorDedup)
return Promise.reject(error)
}
// 只有通过 envelope 校验的真实响应才算“服务恢复在线”的证据,
// 此时清空技术错误去重缓存,让新一轮故障可以再次提示,避免永久静默。
technicalErrorDedup?.clear()
if (!payload.success) {
notifyFailure(response.config.feedback, notifier, payload.message)
if (responseMode === 'envelope') return payload
return Promise.reject(
new ApiRequestError(payload.message || resolveFallback('request-failed', resolveFallbackMessage), {
businessFailure: true,
code: AxiosError.ERR_BAD_RESPONSE,
config: response.config,
payload,
request: response.request,
response,
}),
)
}
notifySuccess(response.config.feedback, notifier, payload.message)
return responseMode === 'envelope' ? payload : payload.data
},
async (reason: unknown) => {
if (isCancellation(reason)) return Promise.reject(reason)
// fulfilled 分支生成的协议错误已经携带完整上下文,不应再次按响应 message 包装。
if (reason instanceof ApiRequestError) return Promise.reject(reason)
const original = reason instanceof AxiosError ? reason : undefined
const response = original?.response ? await normalizeErrorResponse(original.response) : undefined
// 只有成功响应才算服务在线证据;网关错误响应说明后端当前不可达,不能恢复在线状态。
if (response && response.status >= 200 && response.status < 300) hooks?.markServerOnline?.()
const payload = response?.data
const error = new ApiRequestError(resolveErrorMessage(payload, original, resolveFallbackMessage), {
cause: reason,
code: original?.code,
config: original?.config,
payload,
request: original?.request,
response,
})
const requestConfig = original?.config
const failureReason = original ? resolveConnectionFailureReason(original) : null
if (!requestConfig?.skipConnectionTracking && failureReason) {
hooks?.reportConnectionFailure?.(failureReason)
}
// 认证失败由应用层统一签退或交给登录流程处理,避免在登录页暴露技术错误。
const authenticationHandled =
(response?.status === 401 && hooks?.onUnauthorized?.(error) === true) ||
(response?.status === 403 && hooks?.onForbidden?.(error) === true)
if (authenticationHandled) {
return Promise.reject(error)
}
// 连接类失败(无响应、超时、网关不可用)统一交给离线状态系统按阈值提示,
// 不在请求层逐个弹出,避免后端重启时刷屏。
if (!failureReason) notifyFailure(requestConfig?.feedback, notifier, error.message, technicalErrorDedup)
return Promise.reject(error)
},
)
}
/** 二进制成功响应不参与 JSON envelope 校验和解包。 */
function isBinarySuccess(response: AxiosResponse): boolean {
if (typeof Blob !== 'undefined' && response.data instanceof Blob) return true
return response.config.responseType === 'arraybuffer' || response.config.responseType === 'stream'
}
/** 将 Blob 形式的 JSON 错误响应解析为结构化载荷,便于调用方读取错误元数据。 */
async function normalizeErrorResponse(response: AxiosResponse): Promise<AxiosResponse> {
if (typeof Blob === 'undefined' || !(response.data instanceof Blob) || !isJsonBlob(response)) return response
try {
const data = JSON.parse(await readBlobText(response.data)) as unknown
return { ...response, data }
} catch {
return response
}
}
/** 优先使用现代 Blob API并为测试环境和旧浏览器保留 FileReader 回退。 */
function readBlobText(blob: Blob): Promise<string> {
if (typeof blob.text === 'function') return blob.text()
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error ?? new Error('Failed to read response Blob'))
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
reader.readAsText(blob)
})
}
/** 根据响应头或 Blob 自身类型识别 JSON 错误载荷。 */
function isJsonBlob(response: AxiosResponse): boolean {
const blobType = response.data instanceof Blob ? response.data.type : ''
const headers = response.headers as AxiosResponse['headers'] & { get?: (name: string) => unknown }
const rawContentType = typeof headers.get === 'function' ? headers.get('content-type') : headers['content-type']
const contentType = typeof rawContentType === 'string' ? rawContentType : ''
return blobType.includes('json') || contentType.includes('json')
}
/** 取消属于正常控制流,保持原始取消对象且不触发 Toast 或离线探测。 */
function isCancellation(reason: unknown): boolean {
if (axios.isCancel(reason)) return true
if (!(reason instanceof Error)) return false
return reason.name === 'AbortError' || reason.name === 'CanceledError'
}
/** 优先使用后端 message并兼容尚未进入统一 envelope 的 detail 错误。 */
function resolveErrorMessage(
payload: unknown,
error?: AxiosError,
resolveFallbackMessage?: ApiFallbackMessageResolver,
): string {
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
const record = payload as Record<string, unknown>
if (typeof record.message === 'string' && record.message) return record.message
if (typeof record.detail === 'string' && record.detail) return record.detail
}
if (error?.code === 'ECONNABORTED' || error?.code === 'ETIMEDOUT') {
return resolveFallback('timeout', resolveFallbackMessage)
}
if (error?.code === 'NETWORK_ERROR' || error?.code === 'ERR_NETWORK') {
return resolveFallback('network-error', resolveFallbackMessage)
}
if (error?.message) return error.message
return resolveFallback('request-failed', resolveFallbackMessage)
}
/** 调用应用注入的本地化 resolver并为独立使用保留稳定默认值。 */
function resolveFallback(key: ApiFallbackMessageKey, resolver?: ApiFallbackMessageResolver): string {
return resolver?.(key) || defaultFallbackMessages[key]
}
/** 默认模式只提示失败silent 模式完全关闭请求层反馈。 */
function notifyFailure(
mode: ApiFeedbackMode | undefined,
notifier: ApiFeedbackNotifier | undefined,
message: string,
technicalErrorDedup?: Map<string, number>,
) {
if (mode !== 'silent' && message && !isTechnicalErrorCached(message, technicalErrorDedup)) {
notifier?.error(message)
}
}
/**
* 技术错误去重:相同消息在窗口内只提示一次。
*
* 后端异常或重启时大量并发请求会携带同一技术错误协议错误、HTTP 5xx 等),
* 逐条弹出会占满屏幕;此处以消息为键缓存最近提示时间,窗口内重复消息不再提示。
*/
function isTechnicalErrorCached(message: string, dedup?: Map<string, number>): boolean {
if (!dedup) return false
const now = Date.now()
const lastShownAt = dedup.get(message)
dedup.set(message, now)
if (lastShownAt !== undefined && now - lastShownAt < TECHNICAL_ERROR_DEDUP_MS) {
return true
}
// 顺带清理过期缓存项,避免长时间运行后缓存无限增长。
if (dedup.size > 64) {
for (const [key, at] of dedup) {
if (now - at >= TECHNICAL_ERROR_DEDUP_MS) dedup.delete(key)
}
}
return false
}
/** all 模式用于显式要求请求层展示后端成功消息。 */
function notifySuccess(mode: ApiFeedbackMode | undefined, notifier: ApiFeedbackNotifier | undefined, message: string) {
if (mode === 'all' && message) notifier?.success(message)
}

View File

@@ -26,6 +26,11 @@ export const storageAttributes = [
icon: 'mdi-server-network-outline',
remote: true,
},
{
type: 'alistgo',
icon: 'mdi-server-network-outline',
remote: true,
},
{
type: 'smb',
icon: 'mdi-folder-network-outline',
@@ -33,15 +38,21 @@ export const storageAttributes = [
},
]
export const storageIconDict = storageAttributes.reduce((dict, item) => {
dict[item.type] = item.icon
return dict
}, {} as Record<string, string>)
export const storageIconDict = storageAttributes.reduce(
(dict, item) => {
dict[item.type] = item.icon
return dict
},
{} as Record<string, string>,
)
export const storageRemoteDict = storageAttributes.reduce((dict, item) => {
dict[item.type] = item.remote
return dict
}, {} as Record<string, boolean>)
export const storageRemoteDict = storageAttributes.reduce(
(dict, item) => {
dict[item.type] = item.remote
return dict
},
{} as Record<string, boolean>,
)
export const downloaderOptions = [
{
@@ -58,10 +69,13 @@ export const downloaderOptions = [
},
]
export const downloaderDict = downloaderOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const downloaderDict = downloaderOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
export const mediaServerOptions = [
{
@@ -88,12 +102,19 @@ export const mediaServerOptions = [
value: 'ugreen',
title: i18n.global.t('setting.system.ugreen'),
},
{
value: 'navidrome',
title: i18n.global.t('setting.system.navidrome'),
},
]
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const mediaServerDict = mediaServerOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
export const innerFilterRules = [
{ title: i18n.global.t('filterRules.specSub'), value: ' SPECSUB ' },
@@ -104,6 +125,19 @@ export const innerFilterRules = [
{ title: i18n.global.t('filterRules.hkVoi'), value: ' HKVOI ' },
{ title: i18n.global.t('filterRules.notHkVoi'), value: ' !HKVOI ' },
{ title: i18n.global.t('filterRules.free'), value: ' FREE ' },
{ title: i18n.global.t('filterRules.audioHires'), value: ' HIRES ' },
{ title: i18n.global.t('filterRules.audioLossless'), value: ' LOSSLESS ' },
{ title: i18n.global.t('filterRules.audioFlac'), value: ' FLAC ' },
{ title: i18n.global.t('filterRules.audioAlac'), value: ' ALAC ' },
{ title: i18n.global.t('filterRules.audioApe'), value: ' APE ' },
{ title: i18n.global.t('filterRules.audioWav'), value: ' WAV ' },
{ title: i18n.global.t('filterRules.audioDsd'), value: ' DSD ' },
{ title: i18n.global.t('filterRules.audioMp3'), value: ' MP3 ' },
{ title: i18n.global.t('filterRules.audioAac'), value: ' AAC ' },
{ title: i18n.global.t('filterRules.audioOpus'), value: ' OPUS ' },
{ title: i18n.global.t('filterRules.bitrate320'), value: ' BITRATE320 ' },
{ title: i18n.global.t('filterRules.bitrate256'), value: ' BITRATE256 ' },
{ title: i18n.global.t('filterRules.bitrate192'), value: ' BITRATE192 ' },
{ title: i18n.global.t('filterRules.resolution4k'), value: ' 4K ' },
{ title: i18n.global.t('filterRules.resolution1080p'), value: ' 1080P ' },
{ title: i18n.global.t('filterRules.resolution720p'), value: ' 720P ' },
@@ -226,6 +260,46 @@ export const effectOptions = ref([
},
])
// 音乐音质等级选择框数据
export const audioQualityOptions = ref([
{ title: i18n.global.t('audioQualityOptions.all'), value: '' },
{ title: i18n.global.t('audioQualityOptions.hires'), value: 'hires' },
{ title: i18n.global.t('audioQualityOptions.lossless'), value: 'hires|lossless' },
{ title: i18n.global.t('audioQualityOptions.lossy'), value: 'lossy' },
])
// 音频格式选择框数据
export const audioFormatOptions = ref([
{ title: i18n.global.t('audioFormatOptions.all'), value: '' },
{ title: i18n.global.t('audioFormatOptions.lossless'), value: 'DSD|FLAC|ALAC|APE|WAV|AIFF|PCM' },
...['DSD', 'FLAC', 'ALAC', 'APE', 'WAV', 'AIFF', 'PCM', 'MP3', 'AAC', 'OGG', 'OPUS', 'WMA'].map(value => ({
title: value,
value,
})),
])
export const audioBitrateOptions = [
{ title: '128 kbps', value: 128000 },
{ title: '192 kbps', value: 192000 },
{ title: '256 kbps', value: 256000 },
{ title: '320 kbps', value: 320000 },
]
export const audioBitDepthOptions = [
{ title: '16-bit', value: 16 },
{ title: '24-bit', value: 24 },
{ title: '32-bit', value: 32 },
]
export const audioSampleRateOptions = [
{ title: '44.1 kHz', value: 44100 },
{ title: '48 kHz', value: 48000 },
{ title: '88.2 kHz', value: 88200 },
{ title: '96 kHz', value: 96000 },
{ title: '176.4 kHz', value: 176400 },
{ title: '192 kHz', value: 192000 },
]
// 媒体类型选项
export const mediaTypeOptions = [
{
@@ -236,6 +310,10 @@ export const mediaTypeOptions = [
title: i18n.global.t('mediaType.tv'),
value: '电视剧',
},
{
title: i18n.global.t('mediaType.music'),
value: '音乐',
},
{
title: i18n.global.t('mediaType.anime'),
value: '动漫',
@@ -251,10 +329,13 @@ export const mediaTypeOptions = [
]
// 媒体类型字典
export const mediaTypeDict = mediaTypeOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const mediaTypeDict = mediaTypeOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
// 通知开关选项
export const notificationSwitchOptions = [
@@ -297,10 +378,13 @@ export const notificationSwitchOptions = [
]
// 通知开关字典
export const notificationSwitchDict = notificationSwitchOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const notificationSwitchDict = notificationSwitchOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)
// 操作步骤选项
export const actionStepOptions = [
@@ -367,7 +451,10 @@ export const actionStepOptions = [
]
// 操作步骤字典
export const actionStepDict = actionStepOptions.reduce((dict, item) => {
dict[item.value] = item.title
return dict
}, {} as Record<string, string>)
export const actionStepDict = actionStepOptions.reduce(
(dict, item) => {
dict[item.value] = item.title
return dict
},
{} as Record<string, string>,
)

37
src/api/databaseBackup.ts Normal file
View File

@@ -0,0 +1,37 @@
import api from './index'
/** Web 管理端可见的受管数据库备份摘要。 */
export interface DatabaseBackupArtifact {
name: string
db_type: string
created_at: string
size: number
}
/** 受管数据库备份的脱敏校验结果。 */
export interface DatabaseBackupVerification {
valid: boolean
method: string
}
/** 查询当前备份目录中的正式制品。 */
export function listDatabaseBackups(): Promise<DatabaseBackupArtifact[]> {
return api.get('system/database/backups', { feedback: 'silent' })
}
/** 创建、校验并发布当前活动数据库的一致快照。 */
export function createDatabaseBackup(): Promise<DatabaseBackupArtifact> {
return api.post('system/database/backups', undefined, { feedback: 'silent' })
}
/** 重新校验指定受管备份。 */
export function verifyDatabaseBackup(name: string): Promise<DatabaseBackupVerification> {
return api.post(`system/database/backups/${encodeURIComponent(name)}/verify`, undefined, {
feedback: 'silent',
})
}
/** 删除指定受管备份文件。 */
export function deleteDatabaseBackup(name: string): Promise<void> {
return api.delete(`system/database/backups/${encodeURIComponent(name)}`, { feedback: 'silent' })
}

View File

@@ -1,124 +1,108 @@
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios'
import type { AxiosInstance, AxiosRequestConfig } from 'axios'
import { useToast } from 'vue-toastification'
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,
})
import i18n, { getCurrentLocale } from '@/plugins/i18n'
import {
ApiRequestError,
createApiClients,
getApiBusinessErrorMessage,
isApiBusinessFailure,
isApiResponse,
type ApiFeedbackMode,
type ApiFallbackMessageKey,
type DataApiClient,
type PluginApiClient,
} from './client'
import { createScopedPluginApi } from './pluginInstance'
/** 带连接探测和反馈策略的 MoviePilot 请求配置。 */
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
feedback?: ApiFeedbackMode
skipConnectionTracking?: boolean
}
// 声明全局变量类型
const globalOfflineStatus = useGlobalOfflineStatus()
const toast = useToast()
const fallbackMessageKeys: Record<ApiFallbackMessageKey, string> = {
'invalid-envelope': 'common.invalidApiResponse',
'network-error': 'common.networkConnectionFailed',
'request-failed': 'common.apiRequestFailed',
timeout: 'common.requestTimeout',
}
/** 认证失效只负责代码签退;原始异常继续交给发起请求的业务界面处理。 */
function handleAuthenticationFailure(): true {
const authStore = useAuthStore()
if (authStore.token) {
authStore.logout()
void router.push('/login')
}
return true
}
const { api, pluginApi } = createApiClients({
baseURL: import.meta.env.VITE_API_BASE_URL,
setupInstance: initializeClient,
hooks: {
markServerOnline: globalOfflineStatus.markServerOnline,
reportConnectionFailure: globalOfflineStatus.reportNetworkError,
onForbidden: handleAuthenticationFailure,
onUnauthorized: handleAuthenticationFailure,
},
notifier: {
error: message => toast.error(message),
success: message => toast.success(message),
},
resolveFallbackMessage: key => i18n.global.t(fallbackMessageKeys[key]),
})
const pluginInstanceApis = new Map<string, PluginApiClient>()
declare global {
interface Window {
MoviePilotAPI: typeof api
MoviePilotAPI: PluginApiClient
}
}
// 将 API 实例暴露到全局,供插件使用
window.MoviePilotAPI = api
/** 为两个客户端安装同一套取消、认证和语言请求头。 */
function initializeClient(instance: AxiosInstance | DataApiClient) {
initializeRequestOptimizer(instance)
instance.interceptors.request.use(config => {
const authStore = useAuthStore()
if (authStore.token) config.headers.Authorization = `Bearer ${authStore.token}`
// 初始化请求优化器(必须在其他拦截器之前)
initializeRequestOptimizer(api)
// 添加请求拦截器
api.interceptors.request.use(config => {
// 认证 Store
const authStore = useAuthStore()
// 在请求头中添加token
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
const locale = getCurrentLocale()
config.headers['X-MoviePilot-Locale'] = locale
config.headers['Accept-Language'] = locale
return config
})
}
interface LocalizedApiPayload {
detail?: unknown
detail_i18n?: unknown
message?: unknown
message_i18n?: unknown
/** 返回复用同一拦截器链、但把源插件 API 映射到实例命名空间的客户端。 */
function createPluginInstanceApi(instanceId: string, sourcePluginId?: string): PluginApiClient {
if (!sourcePluginId || instanceId === sourcePluginId) return pluginApi
const cacheKey = `${instanceId}\u0000${sourcePluginId}`
let scopedApi = pluginInstanceApis.get(cacheKey)
if (!scopedApi) {
scopedApi = createScopedPluginApi(pluginApi, instanceId, sourcePluginId)
pluginInstanceApis.set(cacheKey, scopedApi)
}
return scopedApi
}
/** 前端展示默认使用后端提供的多语言消息,同时不改变后端接口兼容字段。 */
function normalizeLocalizedMessage(payload: any): any {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload
// 插件远程组件接收 endpoint 的最终 payload内部页面默认使用严格 envelope 解包客户端。
if (typeof window !== 'undefined') window.MoviePilotAPI = pluginApi
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
export {
ApiRequestError,
createPluginInstanceApi,
getApiBusinessErrorMessage,
isApiBusinessFailure,
isApiResponse,
pluginApi,
}
// 添加响应拦截器
api.interceptors.response.use(
response => {
// 任意 API 成功响应都可以证明 MoviePilot 服务当前可达。
globalOfflineStatus.markServerOnline()
return normalizeLocalizedMessage(response.data)
},
(error: AxiosError) => {
if (!error.response) {
const requestConfig = error.config as ConnectionAwareRequestConfig | undefined
const failureReason = resolveConnectionFailureReason(error)
// 普通请求失败只触发权威探测;探测请求自身失败由心跳管理器处理,避免递归。
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' || error.code === 'ETIMEDOUT') {
// 请求超时
return Promise.reject(new Error('Request timeout, please try again later'))
} else if (error.name === 'AbortError') {
// 请求被中止(路由切换等)
return Promise.reject(new Error('Request cancelled'))
}
// 其他网络错误
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)
},
)
export type { ApiFeedbackMode, DataApiClient, PluginApiClient }
export default api

53
src/api/manage.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { AxiosRequestConfig } from 'axios'
import api from './index'
/** 通用管理请求:目标标识 + 管理动作 + 透传参数,与后端 ManageRequest 一致。 */
export interface ManageRequest {
target: string
action: string
params?: Record<string, unknown>
}
/**
* 调用统一管理端点(通知渠道 / 网盘存储 / LLM 提供商)
*
* 端点层不定义任何目标特定的名称与参数,
* 目标标识、管理动作与表单参数原样透传给后端模块
*/
function manageTarget<T = Record<string, unknown>>(
endpoint: 'notification/manage' | 'storage/manage' | 'llm/manage',
request: ManageRequest,
config?: AxiosRequestConfig,
): Promise<T> {
return api.post<T>(endpoint, { params: {}, ...request }, config)
}
/** 对指定通知渠道执行管理动作,返回响应中的业务数据。 */
export function manageNotificationChannel<T = Record<string, unknown>>(
channel: string,
action: string,
params: Record<string, unknown> = {},
config?: AxiosRequestConfig,
): Promise<T> {
return manageTarget<T>('notification/manage', { target: channel, action, params }, config)
}
/** 对指定网盘存储执行管理动作,返回响应中的业务数据。 */
export function manageStorage<T = Record<string, unknown>>(
storage: string,
action: string,
params: Record<string, unknown> = {},
config?: AxiosRequestConfig,
): Promise<T> {
return manageTarget<T>('storage/manage', { target: storage, action, params }, config)
}
/** 对指定 LLM 提供商执行管理动作,返回响应中的业务数据。 */
export function manageLlmProvider<T = Record<string, unknown>>(
provider: string,
action: string,
params: Record<string, unknown> = {},
config?: AxiosRequestConfig,
): Promise<T> {
return manageTarget<T>('llm/manage', { target: provider, action, params }, config)
}

83
src/api/pluginInstance.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { AxiosRequestConfig } from 'axios'
import type { PluginApiClient } from './client'
const URL_METHODS = new Set([
'delete',
'get',
'head',
'options',
'patch',
'patchForm',
'post',
'postForm',
'put',
'putForm',
])
/** 转义插件 ID避免其进入正则表达式后改变路径匹配语义。 */
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** 把源插件动态 API 路径映射到当前虚拟实例的服务端命名空间。 */
function rewritePluginUrl(url: string, instanceId: string, sourcePluginId: string): string {
if (!instanceId || !sourcePluginId || instanceId === sourcePluginId) return url
const sourcePattern = new RegExp(
`(^|/)plugin/${escapeRegExp(sourcePluginId)}(?=/|[?#]|$)`,
'i',
)
return url.replace(sourcePattern, `$1plugin/${instanceId}`)
}
/** 复制请求配置并改写其中的 URL避免修改远程插件持有的原对象。 */
function rewriteRequestConfig(
config: AxiosRequestConfig,
instanceId: string,
sourcePluginId: string,
): AxiosRequestConfig {
if (typeof config.url !== 'string') return config
return {
...config,
url: rewritePluginUrl(config.url, instanceId, sourcePluginId),
}
}
/**
* 创建实例作用域 API。
*
* 仅改写插件自己的动态 API 前缀,其余系统 API 和 Axios 能力保持原合同,
* 因而无需要求存量联邦插件改造路径拼接方式。
*/
export function createScopedPluginApi(
client: PluginApiClient,
instanceId: string,
sourcePluginId?: string,
): PluginApiClient {
if (!sourcePluginId || instanceId === sourcePluginId) return client
return new Proxy(client, {
apply(target, thisArg, argumentList: unknown[]) {
const [request, ...rest] = argumentList
const rewritten =
typeof request === 'string'
? rewritePluginUrl(request, instanceId, sourcePluginId)
: request && typeof request === 'object'
? rewriteRequestConfig(request as AxiosRequestConfig, instanceId, sourcePluginId)
: request
return Reflect.apply(target, thisArg, [rewritten, ...rest])
},
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver)
if (typeof property !== 'string' || typeof value !== 'function') return value
if (property === 'request') {
return (config: AxiosRequestConfig) =>
value.call(target, rewriteRequestConfig(config, instanceId, sourcePluginId))
}
if (URL_METHODS.has(property)) {
return (url: string, ...args: unknown[]) =>
value.call(target, rewritePluginUrl(url, instanceId, sourcePluginId), ...args)
}
return value.bind(target)
},
}) as PluginApiClient
}

View File

@@ -1,3 +1,41 @@
/** 主程序内置的媒体来源常量;插件可以注册额外的稳定来源标识。 */
export enum MediaSource {
TMDB = 'themoviedb',
Douban = 'douban',
Bangumi = 'bangumi',
AniList = 'anilist',
IMDb = 'imdb',
TVDB = 'tvdb',
MusicBrainz = 'musicbrainz',
TheAudioDB = 'theaudiodb',
DoubanMusic = 'doubanmusic',
Bilibili = 'bilibili',
MangoTV = 'mangguodiscover',
MiguVideo = 'migu',
TencentVideo = 'tencentvideodiscover',
}
export type MediaDataSource = `${MediaSource}` | (string & {})
/** 后端返回的媒体来源注册描述,供各类来源选择器共享。 */
export interface MediaSourceInfo {
name: string
media_source: MediaDataSource
media_types: string[]
}
// 手动刮削选项
export interface ManualScrapeOptions {
// 媒体数据源
media_source: MediaDataSource
// 数据源原生ID
media_id?: string
// 媒体类型
type_name?: string
// 音乐实体类型
music_type?: MusicEntityType
}
// 订阅
export interface Subscribe {
// 订阅ID
@@ -6,18 +44,18 @@ export interface Subscribe {
name: string
// 订阅年份
year: string
// 订阅类型 电影/电视剧
// 订阅类型 电影/电视剧/音乐
type: string
// 搜索关键字
keyword?: string
// TMDB ID
tmdbid: number
// 豆瓣ID
doubanid?: string
// Bangumi ID
bangumiid?: string
// 其它媒体ID
mediaid?: string
// 主媒体数据源
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string
// 音乐实体类型recording 单曲、album 专辑、artist 艺术家
music_type?: MusicEntityType
// 专辑总曲目数
total_tracks?: number
// 季号
season?: number
// 海报
@@ -40,6 +78,16 @@ export interface Subscribe {
resolution?: string
// 特效
effect?: string
// 音乐音质等级正则
audio_quality?: string
// 音频格式正则
audio_format?: string
// 最低码率bps
min_bitrate?: number
// 最低位深bit
min_bit_depth?: number
// 最低采样率Hz
min_sample_rate?: number
// 总集数
total_episode?: number
// 开始集数
@@ -66,6 +114,14 @@ export interface Subscribe {
search_imdbid?: any
// 当前优先级
current_priority: number
// 当前音乐版本格式
current_audio_format?: string
// 当前音乐版本码率bps
current_bitrate?: number
// 当前音乐版本位深bit
current_bit_depth?: number
// 当前音乐版本采样率Hz
current_sample_rate?: number
// 洗版时已下载剧集的优先级状态
episode_priority?: Record<string, number>
// 保存目录
@@ -110,10 +166,14 @@ export interface SubscribeShare {
type?: string
// 搜索关键字
keyword?: string
// TMDB ID
tmdbid?: number
// 豆瓣ID
doubanid?: string
// 主媒体数据源
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string
// 音乐实体类型recording 单曲、album 专辑、artist 艺术家
music_type?: MusicEntityType
// 专辑总曲目数
total_tracks?: number
// 季号
season?: number
// 海报
@@ -208,14 +268,10 @@ export interface TransferHistory {
title?: string
// 年份
year?: string
// TMDBID
tmdbid?: number
// IMDBID
imdbid?: string
// TVDBID
tvdbid?: number
// 豆瓣ID
doubanid?: string
// 媒体数据源
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string
// 季Sxx
seasons?: string
// 集Exx
@@ -230,20 +286,82 @@ export interface TransferHistory {
errmsg?: string
// 日期
date?: string
// 音乐实体类型
music_type?: MusicEntityType
// 专辑预期总曲目数
total_tracks?: number
// 实际音频格式
audio_format?: string
// 是否无损音频
audio_lossless?: boolean
// 实际位深bit
bit_depth?: number
// 实际采样率Hz
sample_rate?: number
// 实际码率bps
bitrate?: number
// 源文件项
src_fileitem?: FileItem
}
// 媒体信息
export interface MediaInfo {
// 来源themoviedb、douban、bangumi
source?: string
// 类型 电影、电视剧、合集
// 下载历史记录
export interface DownloadHistory {
// ID
id: number
// 保存路径
path?: string
// 类型:电影、电视剧
type?: string
// 媒体标题
// 标题
title?: string
// 年份
year?: string
// 媒体数据源
media_source?: MediaDataSource
// 数据源原生 ID
media_id?: string
// 季 Sxx
seasons?: string
// 集 Exx
episodes?: string
// 背景图
image?: string
// 海报
poster?: string
// 下载器 Hash
download_hash?: string
// 种子名称
torrent_name?: string
// 种子描述
torrent_description?: string
// 站点
torrent_site?: string
// 下载用户 ID
userid?: string
// 下载用户名或插件名
username?: string
// 下载渠道
channel?: string
// 创建时间
date?: string
// 附加信息
note?: unknown
// 自定义媒体类别
media_category?: string
// 自定义剧集组
episode_group?: string
}
// 媒体信息
export interface MediaInfo {
// 主媒体数据源
media_source?: MediaDataSource
// 类型 电影、电视剧、音乐、合集
type?: string
// 媒体标题
title?: string
// 年份(音乐等数据源返回数字,订阅接口要求字符串)
year?: string | number
// 标题(年)
title_year?: string
// 季号
@@ -254,15 +372,19 @@ export interface MediaInfo {
imdb_id?: string
// TVDB ID
tvdb_id?: string
// TVDB Slug别名用于构建 TheTvDb 直达链接)
tvdb_slug?: string
// 豆瓣ID
douban_id?: string
// Bangumi ID
bangumi_id?: string
bangumi_id?: string | number
// AniList ID
anilist_id?: number
// AniDB ID
anidb_id?: number
// 合集ID
collection_id?: number
// 其它媒体ID前缀
mediaid_prefix?: string
// 其它媒体ID值
// 主媒体数据源的原生 ID
media_id?: string
// 媒体原语种
original_language?: string
@@ -338,6 +460,193 @@ export interface MediaInfo {
names?: string[]
// 剧集组
episode_group?: string
// 音乐艺术家列表
artists?: string[]
// 音乐艺术家展示文本
artist?: string
// 音乐实体类型recording 单曲、album 专辑、artist 艺术家
music_type?: MusicEntityType
// 音乐艺术家标准ID顺序与 artists 一致
artist_ids?: string[]
// 专辑
album?: string
// 专辑艺术家
album_artist?: string
// 所属专辑标准ID
album_id?: string
// 专辑主类型Album、EP、Single 等
album_type?: string
// 发行版本
version?: string
// 音轨号
track_number?: number
// 碟号
disc_number?: number
// 总音轨数
total_tracks?: number
// 音轨时长(秒)
duration?: number
// ISRC
isrc?: string
// 音乐封面
cover_url?: string
// ListenBrainz 收听次数
listen_count?: number
// 音频格式
audio_format?: string
// 是否无损音频
audio_lossless?: boolean
// 音质等级
audio_quality?: 'hires' | 'lossless' | 'lossy'
// 音质洗版分数
audio_quality_score?: number
// 格式化音频参数
audio_specs?: string
// 音频位深
bit_depth?: number
// 音频采样率
sample_rate?: number
// 音频码率
bitrate?: number
}
// 音乐可浏览实体类型
export type MusicEntityType = 'recording' | 'album' | 'artist'
// 音乐专辑下的单个发行版本
export interface MusicRelease {
// 发行版本标准ID
media_id?: string
// 发行版本名称
title?: string
// 发行日期
date?: string
// 发行年份
year?: number
// 发行地区
country?: string
// 发行状态Official、Promotion 等
status?: string
// 包装形式
packaging?: string
// 介质格式CD、Vinyl 等
formats?: string[]
// 音轨总数
track_count?: number
// 封面
cover_url?: string
}
// 音乐专辑详情
export interface MusicAlbumInfo {
// 类型,固定为音乐
type?: string
// 实体类型,固定为 album
music_type?: MusicEntityType
// 媒体数据源
media_source?: MediaDataSource
// 专辑标准ID
media_id?: string
// 专辑名称
title?: string
// 艺术家列表
artists?: string[]
// 艺术家展示文本
artist?: string
// 艺术家标准ID顺序与 artists 一致
artist_ids?: string[]
// 专辑名称,与 title 一致,便于复用音乐展示组件
album?: string
// 专辑主类型
album_type?: string
// 专辑副类型
secondary_types?: string[]
// 首次发行年份
year?: number
// 首次发行日期
release_date?: string
// 音轨总数
total_tracks?: number
// 专辑总时长(秒)
duration?: number
// 封面
cover_url?: string
// 风格
genres?: string[]
// 标签
tags?: string[]
// 主类型与副类型组合文本
category?: string
// 10 分制评分
rating?: number
// 评分人数
rating_votes?: number
// 详情页面
detail_link?: string
// 专辑内的音乐
tracks?: MediaInfo[]
// 同一专辑的其它发行版本
releases?: MusicRelease[]
// 海报
poster_path?: string
// 背景图
backdrop_path?: string
// 摘要
overview?: string
}
// 音乐艺术家详情
export interface MusicArtistInfo {
// 类型,固定为音乐
type?: string
// 实体类型,固定为 artist
music_type?: MusicEntityType
// 媒体数据源
media_source?: MediaDataSource
// 艺术家标准ID
media_id?: string
// 艺术家名称
name?: string
// 名称,与 name 一致,便于复用通用展示组件
title?: string
// 排序名称
sort_name?: string
// 消歧义说明
disambiguation?: string
// 艺术家类型Person、Group 等
artist_type?: string
// 性别
gender?: string
// 国家代码
country?: string
// 地区
area?: string
// 出道或成立日期
begin_date?: string
// 解散或去世日期
end_date?: string
// 是否已结束活动
ended?: boolean
// 活跃时间区间文本
life_span?: string
// 风格
genres?: string[]
// 标签
tags?: string[]
// 别名
aliases?: string[]
// 与当前艺术家的关系文本
relation?: string
// 艺术家图片
image_url?: string
// 详情页面
detail_link?: string
// 外部站点链接
external_links?: Record<string, string>
// 海报
poster_path?: string
// 摘要
overview?: string
}
// 季信息
@@ -414,7 +723,7 @@ export interface TmdbEpisode {
// TMDB人物信息
export interface Person {
// 来源themoviedb、douban、bangumi
// 来源themoviedb、douban、bangumi、anilist
source?: string
// ID
id?: number
@@ -646,6 +955,8 @@ export interface Plugin {
installed?: boolean
// 运行状态
state?: boolean
// 插件源码、依赖和运行时加载状态
runtime_status?: 'source_missing' | 'dependency_pending' | 'ready' | 'active' | 'blocked_by_policy' | 'load_failed'
// 是否有详情页面
has_page?: boolean
// 是否有新版本
@@ -668,6 +979,37 @@ export interface Plugin {
add_time?: number
// 页面打开状态
page_open?: boolean
// 平均评分
average_rating?: number
// 评分人数
rating_count?: number
// 当前安装实例评分
user_rating?: number | null
// 共享代码和联邦资源的源插件 ID
source_plugin_id?: string
// 是否为共享源码的虚拟实例
is_instance?: boolean
// 实例实现模式
instance_mode?: 'virtual'
}
export interface PluginRuntimeSummary {
// 本轮插件源码、依赖和加载是否已收敛
ready: boolean
// 插件运行状态变化代次
generation: number
// 仍处于准备阶段的插件数量
pending_count: number
// 加载失败或被策略阻止的插件数量
failed_count: number
}
// 插件评分结果
export interface PluginRating {
plugin_id: string
average_rating: number
rating_count: number
user_rating?: number | null
}
// 插件 Release 可安装版本
@@ -742,6 +1084,8 @@ export interface DashboardItem {
elements: RenderProps[]
// 渲染方式
render_mode?: string
// 共享联邦资源的源插件 ID
source_plugin_id?: string
}
// 种子信息
@@ -764,8 +1108,10 @@ export interface TorrentInfo {
title?: string
// 种子副标题
description?: string
// IMDB ID
imdbid: string
// 种子页面声明的媒体来源
media_source?: MediaDataSource
// 种子页面声明的数据源原生 ID
media_id?: string
// 种子链接
enclosure?: string
// 详情页面
@@ -871,7 +1217,25 @@ export interface MetaInfo {
// 识别的英文名
en_name?: string
// 年份
year?: string
year?: string | number
// 音乐艺术家列表
artists?: string[]
// 音乐艺术家展示文本
artist?: string
// 音乐专辑
album?: string
// 音乐专辑艺术家
album_artist?: string
// 音乐碟号
disc_number?: number
// 音乐音轨号
track_number?: number
// 音乐总音轨数
total_tracks?: number
// 音乐时长(秒)
duration?: number
// 音乐 ISRC
isrc?: string
// 总季数
total_season: number
// 识别的开始季 数字
@@ -898,6 +1262,22 @@ export interface MetaInfo {
video_encode?: string
// 音频编码
audio_encode?: string
// 音频格式
audio_format?: string
// 是否无损音频
audio_lossless?: boolean
// 音质等级
audio_quality?: 'hires' | 'lossless' | 'lossy'
// 音质洗版分数
audio_quality_score?: number
// 格式化音频参数
audio_specs?: string
// 音频位深
bit_depth?: number
// 音频采样率
sample_rate?: number
// 音频码率
bitrate?: number
// 名称(自动中英文)
name: string
// SXX-SXX
@@ -952,8 +1332,8 @@ export interface User {
id: number
// 用户名称
name: string
// 用户密码
password: string
// 用户密码仅用于写入,查询响应不返回该字段
password?: string
// 用户邮箱
email: string
// 是否激活
@@ -962,7 +1342,7 @@ export interface User {
is_superuser: boolean
// 头像
avatar: string
// 是否开启双重验证
// 是否开启二次验证
is_otp: boolean
// 用户权限 json
permissions: { [key: string]: any }
@@ -972,6 +1352,11 @@ export interface User {
nickname?: string
}
// 头像上传响应数据
export interface AvatarUploadData {
filename: string
}
// 通行密钥
export interface PassKey {
id: number
@@ -998,14 +1383,18 @@ export interface MediaStatistic {
tv_count: number
// 电视剧总集数,未获取时为 null
episode_count: number | null
// 用户数量
user_count: number
// 音乐总数
music_count: number
// 用户数量,仪表板已改为展示音乐数量,仅保留接口字段兼容
user_count?: number
// 本月新增电影数量
movie_count_month: number
// 本月新增电视剧数量
tv_count_month: number
// 本月新增剧集数量
episode_count_month: number
// 本月新增音乐数量
music_count_month: number
}
// 后台进程
@@ -1119,7 +1508,7 @@ export interface ScheduleInfo {
// 多语言状态
status_i18n?: string
// 下次运行时间
next_run: string
next_run?: string
// 多语言下次运行时间
next_run_i18n?: string
// 当前完成百分比
@@ -1343,7 +1732,7 @@ export interface StorageConf {
export interface MediaServerConf {
// 名称
name: string
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen/navidrome
type: string
// 配置
config: { [key: string]: any }
@@ -1351,6 +1740,8 @@ export interface MediaServerConf {
enabled: boolean
// 同步媒体体库列表
sync_libraries?: string[]
// 自动同步间隔(小时),为空时使用旧全局配置
sync_interval?: number | null
}
// 文件整理目录配置
@@ -1419,12 +1810,34 @@ export interface FilterRuleGroup {
name: string
// 规则串
rule_string?: string
// 适用媒体类型 None-全部 电影/电视剧
// 适用媒体类型 None-全部 电影/电视剧/音乐
media_type?: string
// # 适用媒体类别 None-全部 对应二级分类
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 {
// 种子名称
@@ -1445,6 +1858,12 @@ export interface SubscribeLibraryFileInfo {
storage?: string
// 文件路径
file_path?: string
// 媒体服务器名称
server?: string
// 媒体服务器类型emby、jellyfin、plex 等
server_type?: string
// 媒体服务器条目 ID
itemid?: string
}
// 订阅集详情
@@ -1479,10 +1898,12 @@ export interface TransferForm {
target_storage: string | null
// 目标路径
target_path: string | null
// TMDB ID
tmdbid?: number
// 豆瓣 ID
doubanid?: string
// 媒体数据源
media_source?: MediaDataSource | null
// 数据源原生ID
media_id?: string | null
// 音乐实体类型
music_type?: Exclude<MusicEntityType, 'artist'> | null
// 季号
season?: number
// 类型
@@ -1511,6 +1932,8 @@ export interface TransferForm {
episode_group?: string | null
// 预览模式
preview?: boolean
// 清理已有成功记录后重新整理
reorganize: boolean
}
// 手动整理请求
@@ -1537,6 +1960,14 @@ export interface ManualTransferTargetPathData {
library_category_folder?: boolean | null
}
// 手动整理命中的成功历史摘要
export interface ManualTransferHistoryInfo {
// 是否应执行重新整理
reorganize: boolean
// 命中的成功历史数量
history_count: number
}
// 手动整理预览统计
export interface ManualTransferPreviewSummary {
// 总数
@@ -1612,6 +2043,8 @@ export interface TransferQueue {
export interface DiscoverSource {
// 数据源名称
name: string
// 内置或插件扩展媒体来源
media_source?: MediaDataSource
// 媒体ID的前缀不含:
mediaid_prefix: string
// 媒体数据源API地址
@@ -1699,6 +2132,12 @@ export interface TorrentCacheItem {
media_year?: string
// 识别的媒体类型
media_type?: string
// 识别结果的数据源
media_source?: MediaDataSource
// 数据源原生媒体 ID
media_id?: string
// 音乐实体类型
music_type?: Exclude<MusicEntityType, 'artist'>
// 季集信息
season_episode?: string
// 资源信息
@@ -1729,9 +2168,7 @@ export interface RecognitionCacheItem {
key: string
// TMDB ID0 表示未识别
tmdb_id?: number
// 豆瓣 ID0 表示未识别
douban_id?: string | number
// 当前识别数据源对应的统一 ID
// 识别缓存对应的字符串 ID
recognition_id?: string
// 识别后的标题
title: string
@@ -1753,10 +2190,46 @@ export interface RecognitionCacheData {
recognized: number
// 未识别数量
unrecognized: number
// 共享识别成功命中数量
shared_recognized: number
// 是否开启共享识别
shared_recognize_enabled: boolean
// 缓存数据
data: RecognitionCacheItem[]
}
// 音乐识别缓存项
export interface MusicRecognitionCacheItem {
// 缓存键
key: string
// MusicBrainz ID空字符串表示未识别
media_id?: string
// 识别后的曲名/专辑名
title: string
// 艺术家列表
artists?: string[]
// 所属专辑
album?: string
// 发行年份
year?: string | number
// 音乐实体类型recording/album/artist
music_type?: string
// 封面图片地址
cover_url?: string
}
// 音乐识别缓存数据
export interface MusicRecognitionCacheData {
// 缓存总数
count: number
// 已识别数量
recognized: number
// 未识别数量
unrecognized: number
// 缓存数据
data: MusicRecognitionCacheItem[]
}
// 订阅分享统计
export interface SubscribeShareStatistics {
// 分享人
@@ -1767,12 +2240,31 @@ export interface SubscribeShareStatistics {
total_reuse_count?: number
}
// 通用API响应
export interface ApiResponse<T = any> {
/** 后端 API 的固定 envelope失败及无返回值操作允许 data 为 null。 */
export type SystemUpdateState = 'idle' | 'available' | 'downloading' | 'ready' | 'installing' | 'failed'
/** 后端后台更新状态机快照。 */
export interface SystemUpdateStatus {
state: SystemUpdateState
current_version: string
version?: string | null
frontend_version?: string | null
release_name?: string | null
release_notes?: string | null
published_at?: string | null
checked_at?: string | null
downloaded_bytes: number
total_bytes: number
progress: number
error?: string | null
can_update: boolean
can_install: boolean
}
export interface ApiResponse<T = unknown> {
success: boolean
message?: string
message_i18n?: string
data: T
message: string
data: T | null
}
// 分类规则

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n'
import AgentPetStage from './pet/AgentPetStage.vue'
import type { AgentPetActionName, AgentPetIntent } from './pet/types'
import { useAgentPetMachine } from './pet/useAgentPetMachine'
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
interface AgentAssistantEntryBubble {
id: string
@@ -35,14 +36,19 @@ interface AgentAssistantEntryBubbleInput {
const props = withDefaults(
defineProps<{
active?: boolean
/** 是否允许入口的随机动作、指针跟随和自动贴边,不影响 thinking 等业务状态。 */
motionActive?: boolean
thinking?: boolean
}>(),
{
active: true,
motionActive: true,
thinking: false,
},
)
const ASSISTANT_PREVIEW_MAX_LENGTH = 480
const emit = defineEmits<{
open: []
}>()
@@ -160,6 +166,7 @@ const fabPositionStyle = computed(() => {
...fabPointerStyle.value,
'--agent-assistant-fab-x': `${position.x}px`,
'--agent-assistant-fab-y': `${position.y}px`,
zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.entry,
}
})
const fabBubblePlacement = ref<FabBubblePlacement>('top')
@@ -214,7 +221,7 @@ const {
playAction: playAgentPetAction,
scheduleRandomAction: scheduleFabRandomAction,
} = useAgentPetMachine({
active: () => props.active,
active: () => props.active && props.motionActive,
docked: fabDocked,
dragging: fabDragging,
pressed: fabPressed,
@@ -237,8 +244,7 @@ function getViewportSize() {
// 取布局视口和可见视口的较小值,避免两者短暂不同步时把入口计算到屏幕外。
return {
height:
visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight,
height: visualHeight > 0 && layoutHeight > 0 ? Math.min(visualHeight, layoutHeight) : visualHeight || layoutHeight,
width: visualWidth > 0 && layoutWidth > 0 ? Math.min(visualWidth, layoutWidth) : visualWidth || layoutWidth,
}
}
@@ -826,17 +832,18 @@ function updateFabPointerFromPoint(point: FabPointerPoint) {
// 使用 requestAnimationFrame 合并高频指针事件,降低全局跟随的渲染开销。
function queueFabPointerUpdate(clientX: number, clientY: number) {
if (!props.active) return
if (!props.active || !props.motionActive) return
fabPendingPointerPoint = { clientX, clientY }
if (fabPointerFrame) return
fabPointerFrame = window.requestAnimationFrame(() => {
fabPointerFrame = 0
if (!fabPendingPointerPoint) return
updateFabPointerFromPoint(fabPendingPointerPoint)
const point = fabPendingPointerPoint
fabPendingPointerPoint = null
if (!point || !props.active || !props.motionActive) return
updateFabPointerFromPoint(point)
})
}
@@ -850,22 +857,6 @@ function updateFabPointer(event: PointerEvent) {
queueFabPointerUpdate(event.clientX, event.clientY)
}
// 重置机器人按压状态和眼神跟随位移。
function resetFabPointer() {
fabPressed.value = false
fabPointerStyle.value = {
'--agent-assistant-body-x': '0px',
'--agent-assistant-body-y': '0px',
'--agent-assistant-eye-x': '0px',
'--agent-assistant-eye-y': '0px',
'--agent-assistant-head-x': '0px',
'--agent-assistant-head-y': '0px',
'--agent-assistant-pointer-x': '0px',
'--agent-assistant-pointer-y': '0px',
'--agent-assistant-robot-tilt': '0deg',
}
}
// 清理入口自动贴边计时器。
function clearFabIdleTimer() {
if (fabIdleTimer === null) return
@@ -895,11 +886,20 @@ function suppressNextFabClick() {
// 在入口靠近右侧边缘且空闲时安排自动贴边收起。
function scheduleFabAutoDock() {
clearFabIdleTimer()
if (fabDocked.value || hasKeepOpenFabBubbles.value || fabRandomAction.value || !shouldFabAutoDock()) return
if (
!props.active ||
!props.motionActive ||
fabDocked.value ||
hasKeepOpenFabBubbles.value ||
fabRandomAction.value ||
!shouldFabAutoDock()
)
return
fabIdleTimer = window.setTimeout(() => {
fabIdleTimer = null
if (fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock()) return
if (!props.active || !props.motionActive || fabDocked.value || hasKeepOpenFabBubbles.value || !shouldFabAutoDock())
return
if (fabRandomAction.value) {
scheduleFabAutoDock()
@@ -915,14 +915,36 @@ function pauseFabAutoDock() {
clearFabIdleTimer()
}
// 取消挂起的全局指针帧并移除监听器
function teardownFabPointerTracking() {
// 取消挂起的全局指针帧,避免状态切换后迟到回调重新写入位移
function cancelFabPointerUpdate() {
if (fabPointerFrame) {
window.cancelAnimationFrame(fabPointerFrame)
fabPointerFrame = 0
}
fabPendingPointerPoint = null
}
// 重置机器人按压状态和眼神跟随位移。
function resetFabPointer() {
cancelFabPointerUpdate()
fabPressed.value = false
fabPointerStyle.value = {
'--agent-assistant-body-x': '0px',
'--agent-assistant-body-y': '0px',
'--agent-assistant-eye-x': '0px',
'--agent-assistant-eye-y': '0px',
'--agent-assistant-head-x': '0px',
'--agent-assistant-head-y': '0px',
'--agent-assistant-pointer-x': '0px',
'--agent-assistant-pointer-y': '0px',
'--agent-assistant-robot-tilt': '0deg',
}
}
// 取消挂起的全局指针帧并移除监听器。
function teardownFabPointerTracking() {
cancelFabPointerUpdate()
window.removeEventListener('pointermove', handleGlobalFabPointer)
window.removeEventListener('pointerdown', handleGlobalFabPointer)
}
@@ -994,6 +1016,15 @@ function scheduleFabBubbleRemoval(id: string, duration = FAB_NOTIFICATION_BUBBLE
function upsertFabBubble(bubble: AgentAssistantEntryBubble, options: { autoClose?: boolean; duration?: number } = {}) {
if (!props.active || !bubble.text) return
const existingIndex = fabBubbles.value.findIndex(item => item.id === bubble.id)
if (existingIndex >= 0) {
fabBubbles.value[existingIndex] = bubble
setFabDocked(false)
nextTick(scheduleFabBubblePositionUpdate)
if (options.autoClose) scheduleFabBubbleRemoval(bubble.id, options.duration)
return
}
const hadBubbles = hasFabBubbles.value
const wasDocked = fabDocked.value
const existingBubbles = fabBubbles.value.filter(item => item.id !== bubble.id)
@@ -1041,7 +1072,7 @@ function showAssistantReplyPreview(value: string) {
showBubble({
id: 'assistant-preview',
kind: 'assistant',
text: value,
text: value.slice(0, ASSISTANT_PREVIEW_MAX_LENGTH),
})
}
@@ -1432,6 +1463,19 @@ watch(
},
)
watch(
() => props.motionActive,
motionActive => {
if (motionActive) {
if (props.active && shouldFabAutoDock()) scheduleFabAutoDock()
return
}
clearFabIdleTimer()
resetFabPointer()
},
)
onScopeDispose(clearFabIdleTimer)
onScopeDispose(clearFabSuppressNextClickTimer)
onScopeDispose(resetFabBubbles)
@@ -1539,20 +1583,18 @@ defineExpose({
.agent-assistant-fab {
position: fixed;
/* 保持高于菜单浮层,但低于 agent 会话面板2101。 */
z-index: 2100;
--agent-assistant-robot-outline: #5b00c5;
--agent-assistant-robot-outline-soft: #7432df;
--agent-assistant-robot-shell-start: #d3bbff;
--agent-assistant-robot-shell-mid: #a576ff;
--agent-assistant-robot-shell-end: #8d51f9;
--agent-assistant-robot-face-start: #24124e;
--agent-assistant-robot-face-end: #100525;
--agent-assistant-robot-eye: #f1dcff;
--agent-assistant-robot-play: #fff;
--agent-assistant-robot-shadow: rgba(54, 0, 126, 28%);
--agent-assistant-robot-shadow-strong: rgba(54, 0, 126, 34%);
--agent-assistant-robot-outline: color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, #090510 28%);
--agent-assistant-robot-outline-soft: color-mix(in srgb, rgb(var(--v-theme-primary)) 84%, #090510 16%);
--agent-assistant-robot-shell-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 38%, white 62%);
--agent-assistant-robot-shell-mid: color-mix(in srgb, rgb(var(--v-theme-primary)) 74%, white 26%);
--agent-assistant-robot-shell-end: rgb(var(--v-theme-primary));
--agent-assistant-robot-face-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 30%, #080a12 70%);
--agent-assistant-robot-face-end: color-mix(in srgb, rgb(var(--v-theme-primary)) 14%, #03050a 86%);
--agent-assistant-robot-eye: color-mix(in srgb, rgb(var(--v-theme-primary)) 12%, white 88%);
--agent-assistant-robot-play: rgb(var(--v-theme-on-primary));
--agent-assistant-robot-shadow: color-mix(in srgb, rgb(var(--v-theme-primary)) 28%, transparent);
--agent-assistant-robot-shadow-strong: color-mix(in srgb, rgb(var(--v-theme-primary)) 36%, transparent);
--agent-assistant-robot-shade: color-mix(in srgb, rgb(var(--v-theme-primary)) 25%, transparent);
--agent-assistant-bubble-bg: rgba(var(--v-theme-surface), 0.92);
--agent-assistant-bot-scale: 1;
--agent-assistant-bot-pressed-scale: 0.96;
@@ -1752,12 +1794,8 @@ defineExpose({
.agent-assistant-fab__bubbles--arrow-notification::before {
--agent-assistant-bubble-arrow-border: rgba(var(--v-theme-primary), 0.22);
--agent-assistant-bubble-arrow-bg: linear-gradient(
135deg,
rgba(var(--v-theme-primary), 0.1),
transparent 48%
),
rgba(var(--v-theme-surface), 0.94);
--agent-assistant-bubble-arrow-bg:
linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1), transparent 48%), rgba(var(--v-theme-surface), 0.94);
}
.agent-assistant-fab__bubbles--arrow-success::before {
@@ -1778,11 +1816,8 @@ defineExpose({
.agent-assistant-fab__bubbles--arrow-toast::before {
--agent-assistant-bubble-arrow-border: rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.3);
--agent-assistant-bubble-arrow-bg: linear-gradient(
135deg,
rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12),
transparent 54%
),
--agent-assistant-bubble-arrow-bg:
linear-gradient(135deg, rgba(var(--agent-assistant-bubble-arrow-accent-rgb), 0.12), transparent 54%),
rgba(var(--v-theme-surface), 0.95);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,82 @@
<script setup lang="ts">
import AgentAssistantEntry from './AgentAssistantEntry.vue'
import AgentAssistantPanel from './AgentAssistantPanel.vue'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
import { useTheme } from 'vuetify'
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
const panelOpen = ref(false)
const thinking = ref(false)
const entryRef = ref<AgentAssistantEntryRef | null>(null)
const { allowsDecorativeMotion } = useAppActivityLifecycle()
const { themeClasses } = useTheme()
const ASSISTANT_PREVIEW_INTERVAL = 125
let assistantPreviewTimer: number | null = null
let assistantPreviewPendingValue = ''
let assistantPreviewLastShownAt = 0
let assistantPreviewHasShown = false
function clearAssistantPreviewTimer() {
if (assistantPreviewTimer === null) return
window.clearTimeout(assistantPreviewTimer)
assistantPreviewTimer = null
}
function showPendingAssistantPreview() {
assistantPreviewTimer = null
if (panelOpen.value || !assistantPreviewPendingValue) return
entryRef.value?.showAssistantReplyPreview(assistantPreviewPendingValue)
assistantPreviewLastShownAt = performance.now()
assistantPreviewHasShown = true
}
// 打开 Agent 面板并清空入口预览气泡。
function openPanel() {
panelOpen.value = true
assistantPreviewPendingValue = ''
clearAssistantPreviewTimer()
entryRef.value?.clearBubbles()
}
// 面板关闭时展示助手回复预览
// 面板关闭时限制预览更新频率,避免每个流式 token 都触发气泡布局
function handleAssistantPreview(value: string) {
if (panelOpen.value) return
entryRef.value?.showAssistantReplyPreview(value)
assistantPreviewPendingValue = value
const elapsed = performance.now() - assistantPreviewLastShownAt
if (!assistantPreviewHasShown || elapsed >= ASSISTANT_PREVIEW_INTERVAL) {
clearAssistantPreviewTimer()
showPendingAssistantPreview()
return
}
if (assistantPreviewTimer !== null) return
assistantPreviewTimer = window.setTimeout(showPendingAssistantPreview, ASSISTANT_PREVIEW_INTERVAL - elapsed)
}
onScopeDispose(clearAssistantPreviewTimer)
</script>
<template>
<AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" />
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" />
<!-- 脱离 .v-application 的层叠上下文确保弹窗打开时入口消息气泡和面板仍在最上层 -->
<Teleport to="body">
<div class="agent-assistant-layer" :class="themeClasses">
<AgentAssistantEntry
ref="entryRef"
:active="!panelOpen"
:motion-active="allowsDecorativeMotion"
:thinking="thinking"
@open="openPanel"
/>
<AgentAssistantPanel
v-model="panelOpen"
:motion-active="allowsDecorativeMotion"
@assistant-preview="handleAssistantPreview"
@thinking-change="thinking = $event"
/>
</div>
</Teleport>
</template>

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