Compare commits

...

772 Commits

Author SHA1 Message Date
jxxghp
dc6c0462bd fix(site): 静默处理站点图标缺失 2026-08-26 21:44:59 +08:00
InfinityPacer
c840286a8d fix(plugin): 明确插件仓库绑定与换仓确认 (#724) 2026-08-26 21:35:50 +08:00
InfinityPacer
4be29cc02f fix(plugin): 完善市场安装状态与官方来源选择 (#723) 2026-08-26 18:01:09 +08:00
jxxghp
f9ad7bc204 feat(settings): expose data retention controls 2026-08-26 12:45:51 +08:00
InfinityPacer
06c711bb03 feat(plugin): 增加插件来源绑定与换源界面 (#722) 2026-08-26 07:51:38 +08:00
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
jxxghp
2a2ed9fc6a fix: 修复背景任务图标显示逻辑,优化任务状态展示 2026-07-13 13:58:08 +08:00
jxxghp
0921932601 feat: 优化定时服务状态展示,增强用户体验 2026-07-13 13:45:16 +08:00
jxxghp
50b8ea1bc9 fix: 移除固定表头,优化移动端缓存面板样式 2026-07-13 13:27:51 +08:00
jxxghp
f88ae5643c feat: 使用 ProgressiveCardGrid 组件优化移动端缓存和历史记录展示 2026-07-13 13:19:58 +08:00
jxxghp
cf5f4e5b6a feat: 优化移动端缓存管理界面,增强用户体验 2026-07-13 12:57:11 +08:00
jxxghp
a8db14a069 feat: 支持多数据源识别缓存管理 2026-07-13 12:33:45 +08:00
jxxghp
a20977e522 fix: 优化识别缓存移动端滚动 2026-07-13 11:03:09 +08:00
jxxghp
7efc32637b feat: 支持多类缓存管理 2026-07-13 09:48:08 +08:00
秋澪Akimio
5d1445fe64 fix: 修复识别词编辑器滚动闪烁 (#525) 2026-07-12 14:06:53 +08:00
秋澪Akimio
2070240b50 feat: highlight word list comments (#524) 2026-07-12 12:23:13 +08:00
InfinityPacer
1d130c99a3 docs(plugin): document federation watch workflow (#523) 2026-07-11 19:52:16 +08:00
InfinityPacer
7861afb2d7 feat(plugin): support responsive federated config controls (#521) 2026-07-11 18:16:31 +08:00
jxxghp
5c4511f935 Deduplicate offline connection prompts by status 2026-07-10 07:40:25 +08:00
jxxghp
212ba94e11 chore: update version to 2.14.3 2026-07-09 19:34:35 +08:00
jxxghp
f863a09212 Refine dashboard layout drafts and episode status badges 2026-07-09 19:33:27 +08:00
jxxghp
728ced475b feat: add computed dialog subtitle for download confirmation 2026-07-08 17:19:47 +08:00
InfinityPacer
a192e00db5 ci(pr-agent): simplify review workflow (#518) 2026-07-08 12:51:49 +08:00
jxxghp
85e6b17db7 feat: add agent MCP settings 2026-07-08 08:44:33 +08:00
InfinityPacer
156d835d6d ci(pr-agent): align inline review workflow (#517) 2026-07-08 07:05:24 +08:00
jxxghp
b27eeea6c0 Improve frontend dialog and layout behavior 2026-07-07 08:54:09 +08:00
jxxghp
39a9fe0541 fix: wrap words guide hint 2026-07-06 23:17:35 +08:00
jxxghp
92d153760b fix: sync media type when selecting media 2026-07-06 22:38:10 +08:00
jxxghp
ff9dc26040 feat: support backend i18n responses 2026-07-06 19:13:25 +08:00
jxxghp
269da6eefe fix(login): remove required attribute from stay logged in checkbox 2026-07-06 13:50:41 +08:00
jxxghp
817bc1019d Improve episode group carousel navigation 2026-07-05 20:39:06 +08:00
InfinityPacer
de8c22c180 fix(ui): improve fab layering and cookiecloud hint (#515) 2026-07-05 16:36:55 +08:00
jxxghp
8f074fb381 feat: add llm temperature setting 2026-07-05 13:04:51 +08:00
jxxghp
e16870859d fix: 优化媒体详情按钮布局 2026-07-05 11:56:11 +08:00
InfinityPacer
e1ba13abf2 feat(settings): expose cookiecloud upload secret (#514) 2026-07-05 09:46:14 +08:00
jxxghp
8d1f226035 Delete .codex-qa directory 2026-07-03 21:46:15 +08:00
jxxghp
5367a07a90 fix: remove redundant episode status icon 2026-07-03 21:40:52 +08:00
jxxghp
8e681dbbe7 fix: prevent nested scrolling in words shortcut 2026-07-03 20:32:16 +08:00
jxxghp
c26c3c4284 Remove playing backdrop status badge 2026-07-03 19:51:35 +08:00
jxxghp
461ed560f0 Improve logging dialogs and word rules layout 2026-07-03 19:45:58 +08:00
jxxghp
dd27bcdaca 更新 package.json 2026-07-03 18:47:06 +08:00
jxxghp
c076be868a feat: 桌面端日志级别改为按钮组 2026-07-03 18:06:57 +08:00
jxxghp
d2634095e4 feat: 增加仪表盘配置的更新时间戳和保存队列功能 2026-07-03 17:13:44 +08:00
jxxghp
a5a3e1e1e4 Add new animations and styles for robot actions
- Introduced new SCSS files for various robot actions including shy, sit, sleep, spin-cheer, stretch, and wave.
- Implemented keyframe animations for each action to enhance the robot's interactivity.
- Created a shared stylesheet for common styles and transitions.
- Updated the main CSS file to include all new action styles.
2026-07-03 14:15:10 +08:00
jxxghp
09a20967c0 feat: 增加宠物随机动作和相应的动画效果 2026-07-03 13:32:39 +08:00
jxxghp
9a2af0a412 feat: add agent pet types and state management for idle actions
- Introduced new types for agent pet actions and intents in `types.ts`.
- Implemented `useAgentPetMachine` to manage idle action scheduling and execution.
- Added functionality to handle random actions based on interaction states.
2026-07-03 13:19:30 +08:00
jxxghp
6bbac7db7d refactor: 重新设计词表页面 2026-07-03 08:37:44 +08:00
jxxghp
b212e066b6 添加 .codex-qa 到 .gitignore 2026-07-02 20:29:31 +08:00
jxxghp
f7d354fca4 Refine mobile subscribe cards and grouped list styling 2026-07-02 20:28:03 +08:00
jxxghp
693b081ecf 删除设计 QA 文档 2026-07-02 19:33:14 +08:00
jxxghp
d5f0fed954 Polish app center grouped menu styling 2026-07-02 19:32:39 +08:00
jxxghp
67ef98bfee Hide dashboard recommendation detail panel 2026-07-02 19:08:07 +08:00
jxxghp
1094d80b9c Refine episode group subscriptions and mobile carousel 2026-07-02 16:34:38 +08:00
jxxghp
98e6481812 Refine dashboard recommendations and transparent cards 2026-07-02 15:47:47 +08:00
jxxghp
7db088eb79 更新 package.json 2026-07-02 14:35:38 +08:00
jxxghp
dec154c042 Rename playback status label to continue watching 2026-07-02 12:42:56 +08:00
jxxghp
d06ce5d984 Refine offline status toasts and theme styling 2026-07-02 12:36:49 +08:00
jxxghp
af604d0c5c 增强离线状态管理,添加服务探测功能和连接状态提示 2026-07-02 11:56:34 +08:00
jxxghp
fa39c3750c Limit dashboard recommendations to TMDB sources 2026-07-02 10:51:43 +08:00
jxxghp
ce64fb03ce Add media recommendations dashboard and episode groups 2026-07-01 17:35:35 +08:00
jxxghp
764f14a7f4 Highlight today in full calendar with primary color 2026-07-01 10:37:13 +08:00
jxxghp
621200b3b2 Refine subscribe files dialog layout 2026-07-01 09:57:53 +08:00
jxxghp
60c46ebbaf Make search site dialog fullscreen on small screens 2026-07-01 09:48:41 +08:00
InfinityPacer
4fe65b770e perf(app): throttle inactive realtime blur rendering (#511) 2026-07-01 06:01:50 +08:00
jxxghp
c010162e54 fix subscribe files duplicate stats 2026-06-30 22:26:11 +08:00
jxxghp
5cc4ea7740 fix dashboard auto height growth 2026-06-30 22:22:47 +08:00
jxxghp
8a19fbc584 fix(subscribe): 调整统计卡片移动端布局 2026-06-30 21:33:57 +08:00
jxxghp
24412989bb fix(subscribe): 修复文件弹窗移动端布局 2026-06-30 21:20:41 +08:00
jxxghp
55d0693e41 fix(subscribe): 优化文件统计弹窗移动端样式 2026-06-30 20:49:52 +08:00
jxxghp
0459a2d108 feat(subscribe): 更新下拉菜单项并添加媒体详情和文件统计本地化字符串 2026-06-30 20:31:27 +08:00
jxxghp
624b55576d Refine login glass effect and plugin menu icon 2026-06-30 20:23:23 +08:00
jxxghp
c848b90599 Split movie release dates and polish poster radius 2026-06-30 20:13:37 +08:00
jxxghp
79bc99e624 feat(theme): 使用按钮切换替代标签,优化订阅文件选项卡样式 2026-06-30 20:02:32 +08:00
jxxghp
850f7ee9be Update subscription file terminology and add new localization strings
- Changed title from 'Downloaded Files' to 'Subscription Files' in English, Simplified Chinese, and Traditional Chinese locales.
- Added new localization strings related to subscription files, including status messages and file management terms across all three language files.
2026-06-30 20:02:32 +08:00
InfinityPacer
4cc105287f fix(auth): decouple webpush subscription from login (#510) 2026-06-30 19:59:51 +08:00
InfinityPacer
527c0845bc fix(theme): stabilize transparent background transitions (#509) 2026-06-30 19:16:31 +08:00
jxxghp
2a3ef1a1f6 fix theme border surfaces 2026-06-30 19:05:26 +08:00
jxxghp
7539fa26aa feat(theme): 更新主题定制器边框样式,优化视觉效果
fix(locales): 修改英文和中文边框相关文本
style(app): 调整应用界面边框和阴影样式
2026-06-30 18:42:39 +08:00
jxxghp
3547c146a8 Use overlay shadow for pull indicator 2026-06-30 18:03:32 +08:00
jxxghp
da630b0966 feat(theme): 添加主题定制器的圆角和阴影示例,优化透明主题下的视觉效果 2026-06-30 17:43:07 +08:00
InfinityPacer
cff4c1fecc fix(theme): restore transparent glass overlays (#508) 2026-06-30 14:47:42 +08:00
jxxghp
3f525c6457 fix: adjust dialog fullscreen breakpoint by width 2026-06-30 14:17:04 +08:00
jxxghp
8c1eb3e981 feat: add fullscreen support to TransparencySettingsDialog based on display size 2026-06-30 13:59:53 +08:00
InfinityPacer
effc83b9a1 feat: add transparent glass quality options (#507)
* feat(theme): add lightweight transparent glass mode

* perf(theme): reduce realtime glass backdrop filters

* fix(theme): align transparency presets
2026-06-30 13:39:22 +08:00
jxxghp
39ce8d93dd feat: add rounded corners to weekly bar chart for improved aesthetics and Safari compatibility 2026-06-30 13:37:00 +08:00
jxxghp
fa32ac9f48 feat: enhance compact FAB styles with backdrop filter and improved hover effects 2026-06-30 13:15:46 +08:00
jxxghp
ab243bbbb7 refactor: streamline transfer queue logic and improve styling 2026-06-30 12:45:10 +08:00
jxxghp
abcd3f5e81 refactor: simplify transfer queue header layout and styles 2026-06-30 12:41:53 +08:00
jxxghp
92bf1ca790 refine transfer queue layout 2026-06-30 12:07:36 +08:00
jxxghp
faadf39b80 feat: enhance transfer queue localization and UI improvements 2026-06-30 11:30:48 +08:00
jxxghp
878c3c98b2 fix: use native login inputs 2026-06-30 06:51:57 +08:00
jxxghp
2d8ae9f462 Refine folded FAB appearance 2026-06-30 06:24:36 +08:00
jxxghp
00051a0eac Fix dashboard settings and FAB folding 2026-06-30 06:21:37 +08:00
jxxghp
88726c0f12 Update dashboard localization labels 2026-06-29 22:37:44 +08:00
jxxghp
79bc9f0ff0 Make dashboard media grids responsive to screen size 2026-06-29 22:20:31 +08:00
jxxghp
4492e211bb 添加重新加载仪表板网格小部件的功能,以清理 GridStack 内部响应式布局缓存 2026-06-29 22:09:13 +08:00
jxxghp
bb5299c812 Fix dashboard media grid layout persistence and capacity 2026-06-29 22:07:31 +08:00
jxxghp
f54a5285d6 Polish library cards and storage dashboard 2026-06-29 21:47:37 +08:00
jxxghp
03b80d6434 添加 PlayingBackdropCard 组件并更新相关视图,优化媒体服务器设置和播放列表的加载逻辑 2026-06-29 21:20:00 +08:00
jxxghp
a916a1569d Fix dashboard task status and recent imports count 2026-06-29 20:34:03 +08:00
jxxghp
5c27c32914 Use dynamic viewport height for panels 2026-06-29 20:06:42 +08:00
jxxghp
c57a8b7b48 Switch panels to svh and native theme scrolling 2026-06-29 19:56:18 +08:00
jxxghp
0aa7d37e45 Fix panel viewport sizing on mobile 2026-06-29 19:54:06 +08:00
jxxghp
d3b3b08318 Preserve full path cache key for browse page 2026-06-29 19:40:31 +08:00
jxxghp
5127ab6c08 优化探索标签排序弹窗样式 2026-06-29 19:12:48 +08:00
jxxghp
a3810fb9e2 style(NotificationTemplateEditorDialog): adjust overflow property and update min-block-size for template editor
style(SearchBarDialog): change search icon and adjust dropdown width
style(common.scss): refine hover effects and update shadow settings for card components
style(AccountSettingNotification): improve layout and spacing for notification settings
2026-06-29 18:02:38 +08:00
InfinityPacer
f05eac0b93 fix(agent): stabilize assistant entry dragging (#505) 2026-06-29 15:50:29 +08:00
jxxghp
ef170b54c6 style(SearchBarDialog): adjust search desktop activator dimensions and disable specificity warning 2026-06-29 13:55:05 +08:00
jxxghp
536844d291 refactor(dashboard): remove unused color management logic 2026-06-29 13:48:59 +08:00
jxxghp
21d4f9fd73 Guard dashboard profile switches and tighten chart axis padding 2026-06-29 09:36:18 +08:00
jxxghp
2aae0f08ad 删除设计 QA 文档 2026-06-29 08:50:47 +08:00
jxxghp
248984261e Refine dashboard scheduler task layout 2026-06-29 08:49:52 +08:00
jxxghp
82ab594e0c fix(charts): disable zoom interactions 2026-06-29 08:08:53 +08:00
jxxghp
5ca374349d fix(dashboard): refine scheduler task list 2026-06-29 07:54:35 +08:00
jxxghp
7452e18fc3 feat(dashboard): refine network and scheduler visuals 2026-06-29 07:37:03 +08:00
jxxghp
75dffdb7c2 更新 package.json 2026-06-29 06:58:57 +08:00
jxxghp
a3d74929b3 feat(dashboard): add memory and scheduler progress interfaces, enhance dashboard layout and responsiveness 2026-06-28 22:59:36 +08:00
jxxghp
fd9c314d55 Refine card hover borders across subtitle and torrent cards 2026-06-28 21:39:29 +08:00
jxxghp
d02e98736f feat(calendar): enhance loading behavior and mobile layout for subscriptions 2026-06-28 21:22:43 +08:00
jxxghp
2e46ebe353 更新 package.json 2026-06-28 17:54:28 +08:00
jxxghp
fffb526a87 feat(dashboard): enhance media statistics and memory analytics
- Added monthly media counts (movies, TV shows, episodes) to the AnalyticsMediaStatistic component.
- Improved layout and styling for better responsiveness in the dashboard summary.
- Updated AnalyticsMemory component to display total and available memory with enhanced visuals.
- Refactored AnalyticsScheduler to include transfer queue management and improved task display.
- Enhanced AnalyticsSpeed component with a more structured speed overview.
- Introduced new components for quick actions and recent imports in the dashboard.
- Added system information display with runtime and version details in DashboardSystemInfo.
- Created a new composable for shortcut tools to streamline dialog management.
2026-06-28 17:48:50 +08:00
jxxghp
98769a313f Refine dashboard profile persistence and radius tokens 2026-06-28 16:28:25 +08:00
jxxghp
1c28d98d70 Update expired translation labels to aired 2026-06-28 15:33:42 +08:00
jxxghp
7a430b095a Limit mobile calendar history and simplify episode tags 2026-06-28 15:32:00 +08:00
jxxghp
864af36b1c Use theme primary border for selected subscribe card 2026-06-28 13:59:25 +08:00
jxxghp
e3fa0b9dae Add batch selection support for subscriptions and history 2026-06-28 13:47:13 +08:00
jxxghp
ed28832484 Fix selected subscribe card border 2026-06-28 13:13:43 +08:00
jxxghp
e692a91113 Refine mobile cache and scheduler layouts 2026-06-28 12:46:47 +08:00
jxxghp
7574719e04 Refactor frontend dialogs and add missing annotations 2026-06-28 12:08:24 +08:00
jxxghp
ca800f7ae7 Refine transfer history mobile list and search styling 2026-06-28 11:09:47 +08:00
jxxghp
7d0550825b fix: adjust header margins for consistency in resource and site card list views 2026-06-28 10:56:33 +08:00
jxxghp
59abda63e3 Format season episodes in calendar tags 2026-06-28 09:58:11 +08:00
jxxghp
0776bc9fa0 fix: adjust dashboard item rows and improve storage display styling for better layout 2026-06-28 09:44:58 +08:00
jxxghp
8c78f9ca90 feat: update dashboard components to use 'dashboard-grid-fill' class for improved layout handling 2026-06-28 09:35:50 +08:00
jxxghp
1889d5d1e3 feat: enhance dashboard layout with fixed height components and improved styling for media cards 2026-06-28 09:05:31 +08:00
jxxghp
165247b263 fix: update storage used percentage display to use div elements for better layout 2026-06-28 08:30:24 +08:00
jxxghp
9a2384bf02 Refine subscribe calendar mobile filters and bump version 2026-06-27 22:17:47 +08:00
jxxghp
71c4f587d3 Enable hover open for horizontal nav submenus 2026-06-27 21:44:48 +08:00
jxxghp
8edbfa0b0e Adjust assistant entry anchor for mobile FAB 2026-06-27 21:39:05 +08:00
jxxghp
ee187a1c75 refactor: remove unused episode subtitle search functionality 2026-06-27 18:21:11 +08:00
jxxghp
afb617724c fix: recover web agent stream after background 2026-06-27 15:09:41 +08:00
InfinityPacer
05bbecba53 fix(subscribe): refine season mode defaults (#503) 2026-06-27 06:55:45 +08:00
jxxghp
deb9b103da Improve assistant input and choice handling 2026-06-26 13:43:47 +08:00
jxxghp
2d92315e17 Enhance command prompts in localization files
- Updated placeholder text in English, Simplified Chinese, and Traditional Chinese to guide users on using commands.
- Added loading and load failed messages for commands in all three languages.
2026-06-26 12:42:49 +08:00
jxxghp
077f0c1366 fix: improve login autofill compatibility 2026-06-25 23:26:57 +08:00
jxxghp
eab4caa279 fix: disable MediaCard ripple feedback 2026-06-25 23:15:40 +08:00
jxxghp
3bba73abf1 fix: remove unused formatSeason function and subscribedSeasonText for cleaner code 2026-06-25 22:59:29 +08:00
jxxghp
8edf1f686a fix: refactor media card interaction for improved touch handling and state management 2026-06-25 22:45:12 +08:00
jxxghp
3440b377ee fix: improve mobile interaction handling for media cards 2026-06-25 21:33:37 +08:00
jxxghp
9161e5e300 fix: replace VBottomSheet with VDialog for improved layout and accessibility 2026-06-25 20:11:52 +08:00
jxxghp
25fdb09c61 fix: correct ios safe area in subscribe sheet 2026-06-25 17:39:35 +08:00
jxxghp
25119b224d fix: refactor SubscribeSeasonDialog for improved readability and style consistency 2026-06-25 17:22:29 +08:00
jxxghp
76f79e1656 fix: align bubbles after assistant undocks 2026-06-25 16:11:39 +08:00
jxxghp
b5230cef79 fix: match agent bubble arrow style 2026-06-25 16:02:25 +08:00
jxxghp
f871746efb fix: reposition agent assistant bubbles 2026-06-25 15:53:52 +08:00
jxxghp
d17ca69a28 fix: expand subscribe season sheet on mobile 2026-06-25 15:12:50 +08:00
jxxghp
e445437129 fix: hide agent assistant message scrollbar 2026-06-25 13:56:46 +08:00
jxxghp
090c5476c6 feat: unify media subscription flow 2026-06-25 13:52:13 +08:00
jxxghp
646c7b33dd Tune pull and swipe gesture thresholds 2026-06-25 12:33:49 +08:00
jxxghp
0902f16f84 fix ui 2026-06-25 11:39:52 +08:00
InfinityPacer
ec4eaed1df fix: refine subscribe mode selection (#501)
* fix(subscribe): refine subscribe mode selection

* fix(subscribe): prompt mode for single existing season
2026-06-25 11:30:24 +08:00
jxxghp
175f610524 Animate dashboard metrics and standardize card sizing 2026-06-25 11:28:36 +08:00
jxxghp
530fe9d35b fix: 修复卡片 hover 上浮抖动 2026-06-24 21:34:38 +08:00
jxxghp
48ed396a19 Refresh notifications when opening the menu 2026-06-24 20:58:46 +08:00
jxxghp
b0356c217d feat: add welcome back message to login page and enhance styles 2026-06-24 17:38:03 +08:00
jxxghp
55eed1ecb5 Fix notification read and clear state 2026-06-24 16:58:08 +08:00
jxxghp
50ae739a4d fix: resolve stylelint warnings and enhance bubble layout for agent assistant 2026-06-24 14:33:55 +08:00
jxxghp
d9cbcc2991 Increase agent assistant bubble text size 2026-06-24 13:44:09 +08:00
jxxghp
ad12701fe2 chore: update version to 2.13.15 2026-06-24 12:55:32 +08:00
jxxghp
2b426a47c6 Route toast notifications to agent assistant bubbles 2026-06-24 12:55:01 +08:00
jxxghp
7f0f12ac41 fix: adjust wave action duration and refine animation keyframes 2026-06-24 12:01:24 +08:00
jxxghp
6789d63ca1 Add happy-jump animation to agent assistant FAB 2026-06-24 08:45:44 +08:00
jxxghp
3202251f55 Add disassemble animation to assistant FAB 2026-06-24 08:33:59 +08:00
jxxghp
8e99ad9cf9 Fix agent assistant arm animation 2026-06-24 07:39:29 +08:00
jxxghp
83dde400e7 fix: refine agent wave animation 2026-06-24 07:28:06 +08:00
jxxghp
1b57f925ee Add random FAB actions and global pointer tracking 2026-06-24 07:14:52 +08:00
InfinityPacer
16428066b9 ci: review PRs with PR-Agent (#500) 2026-06-24 06:10:12 +08:00
InfinityPacer
e211a80cf4 ci: add PR-Agent review workflow (#499) 2026-06-23 23:21:55 +08:00
jxxghp
ea0b5b62d9 Remove obsolete frontend code 2026-06-23 20:16:39 +08:00
jxxghp
62dc2c4f66 feat(agent-assistant): enhance FAB interactive bounds calculation for improved drag functionality 2026-06-23 19:58:13 +08:00
jxxghp
b2a2c7080e feat(agent-assistant): add active prop to AgentAssistantEntry for visibility control 2026-06-23 17:56:53 +08:00
jxxghp
05c2e7855a feat: add agent assistant notification bubble functionality
- Introduced a new utility for managing agent assistant notifications.
- Created functions to emit and listen for notification bubbles using custom events.
- Defined types for notification payloads to ensure type safety.
2026-06-23 17:37:48 +08:00
jxxghp
8d9c622dc5 Improve agent assistant message scrolling 2026-06-23 16:24:23 +08:00
jxxghp
bf0b17c314 style(agent-assistant): update FAB styles for transparency and layout adjustments 2026-06-23 16:13:00 +08:00
jxxghp
37f31f6554 style(agent-assistant): update FAB and mini-bot styles with new color variables 2026-06-23 15:57:11 +08:00
jxxghp
3de409fb07 style(agent-assistant): update FAB styles for transparency and bubble background 2026-06-23 15:52:07 +08:00
jxxghp
7e9c0fd206 feat(notification): support scoped message clearing 2026-06-23 13:43:33 +08:00
jxxghp
fb4f5658a8 更新 package.json 2026-06-23 11:46:32 +08:00
jxxghp
a9f4ec963b fix(plugin): align market detail actions on small screens 2026-06-22 13:25:23 +08:00
jxxghp
542e33d7b4 fix(discover): stabilize media card hover 2026-06-22 12:51:48 +08:00
jxxghp
39c250ba09 更新 package.json 2026-06-21 16:17:11 +08:00
jxxghp
924fcef403 fix(setting): 将LLM最大上下文令牌数从64增加到128 2026-06-19 19:41:10 +08:00
InfinityPacer
e586342b19 fix(pwa): prevent stale service worker caching (#498) 2026-06-19 18:52:23 +08:00
Album
c795de9b2d feat(reorganize): 优化手动整理预览的分页、排序与样式显示 (#497) 2026-06-19 16:05:39 +08:00
jxxghp
6fa1cf28f4 fix(calendar): 优化日历组件中的代码格式和样式 2026-06-19 14:32:33 +08:00
jxxghp
3f70aafdad feat(theme): 添加阴影级别支持并优化主题定制器的阴影设置 2026-06-19 14:12:58 +08:00
jxxghp
f8ceee39b3 fix(plugin): 优化插件市场详情对话框的按钮布局和样式 2026-06-19 11:31:46 +08:00
jxxghp
0a22f33e34 fix(package): 更新版本号至2.13.12 2026-06-19 10:22:01 +08:00
jxxghp
cf88ed9a58 fix(notification): 调整通知标题和计数样式以改善可读性 2026-06-19 10:17:10 +08:00
jxxghp
49dfd794c1 feat(notification): add support for grouped notification display and localization 2026-06-19 10:08:03 +08:00
jxxghp
68f2f010d1 fix(notification): improve notification expansion handling and styling 2026-06-19 10:02:16 +08:00
jxxghp
9eed2fea87 feat: optimize notification center controls 2026-06-19 09:49:22 +08:00
jxxghp
1f170030ee fix: harden plugin market list rendering 2026-06-18 19:45:21 +08:00
jxxghp
e78ed20936 fix: 更新 viewport meta 标签,移除不必要的 interactive-widget 属性 2026-06-18 19:30:52 +08:00
InfinityPacer
b1787b207d fix(plugin): sanitize version history markdown (#496) 2026-06-18 19:30:24 +08:00
InfinityPacer
fdb34732cc fix(plugin): refine release version history UI (#495) 2026-06-18 18:52:54 +08:00
jxxghp
fc1f163a94 fix: 更新 SiteResourceDialog 组件样式,优化移动端显示和用户交互体验 2026-06-18 17:54:25 +08:00
InfinityPacer
a771dc5354 feat(plugin): add release version install actions (#494) 2026-06-18 15:48:09 +08:00
jxxghp
d28360a161 feat: load agent assistant history 2026-06-18 11:45:50 +08:00
jxxghp
a730abc437 refactor: 优化通知组件的样式和逻辑,移除不必要的状态,改善用户体验 2026-06-17 22:23:04 +08:00
jxxghp
5b72eda4fc fix: keep notification read action available 2026-06-17 18:17:38 +08:00
jxxghp
6c49d7a59e feat: 添加日历事件展开功能,优化事件显示和用户交互体验 2026-06-17 18:05:40 +08:00
jxxghp
8900366faf fix: 修复订阅卡片的待定状态样式,确保正确应用 pending tint 2026-06-17 17:21:56 +08:00
jxxghp
e8e0ac9084 refactor: remove scoped global css escapes 2026-06-17 17:19:09 +08:00
jxxghp
c66ee881b1 refactor: remove ShortcutMessageDialog and MessageView components; update ShortcutBar and UserNotification for improved notification handling 2026-06-17 16:32:00 +08:00
jxxghp
c055740926 feat: 优化错误处理逻辑,避免重复提示并改善用户体验 2026-06-17 12:36:34 +08:00
PKC278
a5bc4e6baf fix: restore search filter chip colors (#493) 2026-06-17 12:03:20 +08:00
jxxghp
15b4ee5893 feat: 隐藏主题定制器的滚动条,改善用户界面体验 2026-06-17 12:00:11 +08:00
jxxghp
8868403ff3 feat: 在组件挂载时同步主题定制器状态,确保全局 FAB 预留右侧空间 2026-06-17 11:29:26 +08:00
jxxghp
3abff72e25 feat: 添加语音录制功能,支持录音和相关提示信息 2026-06-17 11:11:55 +08:00
jxxghp
0c56cf0be7 feat: 优化消息列表样式,增加内容存在时的底部填充,改善滚动体验 2026-06-17 08:31:23 +08:00
jxxghp
ce12d04648 feat: 添加新会话时自动滚动到顶部,优化空态展示 2026-06-17 08:15:30 +08:00
jxxghp
efc0ae4df6 fix: keep agent composer floating without early scroll 2026-06-17 07:15:36 +08:00
jxxghp
2530c3bcd9 fix: stabilize mobile panel height 2026-06-17 07:01:33 +08:00
jxxghp
60e2402aff feat: 优化 AgentAssistantWidget 组件的样式和结构,增强可读性和用户体验 2026-06-16 23:29:58 +08:00
jxxghp
1a478f97fb feat: 添加历史会话功能,支持会话恢复和删除,更新多语言文本 2026-06-16 23:18:14 +08:00
jxxghp
33666703af feat: 添加选择功能和附件上传支持,更新多语言文本 2026-06-16 22:55:26 +08:00
jxxghp
cd69172a99 feat: 更新 AgentAssistantWidget 组件的空状态样式和文本内容 2026-06-16 22:23:37 +08:00
jxxghp
61749e3595 feat: Web Agent 透明主题磨砂效果 (#492) 2026-06-16 21:52:45 +08:00
jxxghp
b658533262 更新 package.json 2026-06-16 21:20:58 +08:00
jxxghp
d8015b7def feat: add Agent Assistant component and integrate with theme customizer
- Implemented Agent Assistant widget with chat functionality, including message handling and streaming responses.
- Added new localization strings for Agent Assistant in English, Simplified Chinese, and Traditional Chinese.
- Updated DefaultLayout to include the Agent Assistant and Theme Customizer components.
- Enhanced UserProfile to manage the opening of the Theme Customizer through global events.
- Adjusted CSS styles for the Agent Assistant and its interactions with other components.
- Introduced new events for opening the Theme Customizer and managing its state.
2026-06-16 20:44:00 +08:00
jxxghp
33599cc21d fix(DefaultLayout): improve code formatting and style consistency 2026-06-16 14:36:13 +08:00
jxxghp
bf22a4809d fix: add breathing room below header tabs 2026-06-16 14:35:36 +08:00
jxxghp
4a6f7390e6 fix: keep navbar row height stable with tabs 2026-06-16 14:32:26 +08:00
jxxghp
405e460ad6 fix: stabilize iOS Safari mobile navigation 2026-06-16 14:20:12 +08:00
jxxghp
18566c0e9d revert: rollback dependency upgrades 2026-06-16 13:46:13 +08:00
jxxghp
2c471a936f fix(MediaCard): add variant to VAvatar for improved styling 2026-06-16 12:41:03 +08:00
jxxghp
2efb07402f Refactor code structure for improved readability and maintainability 2026-06-16 12:35:53 +08:00
jxxghp
9434ef71e4 fix(login): adjust login header spacing 2026-06-15 22:26:23 +08:00
jxxghp
e06b9537ff feat(login): add tagline and copyright information to login page 2026-06-15 22:10:30 +08:00
InfinityPacer
2829e3b082 fix(subscribe): display special season labels (#491) 2026-06-15 15:54:08 +08:00
jxxghp
1a0fc10559 优化全局智能助手提示信息,简化指令使用说明 2026-06-15 15:52:16 +08:00
jxxghp
5a1aec3323 更新全局智能助手提示信息,明确指令使用方式 2026-06-15 15:41:34 +08:00
jxxghp
48913b8811 优化订阅日历入库状态展示 2026-06-15 10:05:35 +08:00
jxxghp
0a7d53b5c7 修复消息中心滚动显示 2026-06-14 21:32:14 +08:00
jxxghp
da0cd14af8 调整未读消息入口提示 2026-06-14 21:11:36 +08:00
jxxghp
342c62c085 docs: update README files to include Module Federation documentation 2026-06-14 16:35:57 +08:00
jxxghp
891274cc0e refactor(dialogs): unify button styles and layout in dialog components
- Updated VCardActions in multiple dialog components to use a consistent class `app-dialog-actions` for styling.
- Changed button variants to `flat` for primary actions and `tonal` for secondary actions across various dialogs.
- Adjusted padding and spacing for buttons to improve layout consistency.
- Enhanced responsiveness for dialog actions in mobile views.
2026-06-14 14:47:06 +08:00
jxxghp
889a4b744a chore: update version to 2.13.9 in package.json 2026-06-14 13:28:51 +08:00
jxxghp
7fc5b74851 feat: add wiki sync to plugin market settings 2026-06-14 12:57:40 +08:00
jxxghp
785cbcf81d fix manual transfer default auto target 2026-06-13 22:21:54 +08:00
InfinityPacer
364b660390 feat: add log download actions (#489) 2026-06-13 20:35:55 +08:00
jxxghp
599ca912f4 feat: 增强仪表板布局响应式支持,添加布局档位管理和本地存储功能 2026-06-13 20:34:42 +08:00
jxxghp
2f66f0f1fc feat: 增加仪表板配置的归一化和本地存储支持,优化用户配置加载 2026-06-13 20:00:02 +08:00
jxxghp
cd2f561194 chore: update version to 2.13.8 in package.json 2026-06-13 18:40:14 +08:00
jxxghp
c59a555a2d feat: enhance plugin tags display with improved styling and layout 2026-06-13 18:03:26 +08:00
jxxghp
4413fedec5 fix manual transfer auto target options 2026-06-13 10:42:34 +08:00
jxxghp
d7562ea506 feat: add downloader suffix toggles 2026-06-13 08:43:37 +08:00
jxxghp
951d76481b feat: add system uptime tracking and localization support 2026-06-12 15:52:22 +08:00
jxxghp
68b0071009 refactor: remove unnecessary dashboard reveal state and simplify reveal logic 2026-06-12 14:19:59 +08:00
jxxghp
0594d1d5b2 feat: implement launch loading state management and footer navigation visibility control 2026-06-12 13:59:59 +08:00
jxxghp
4f328add1b feat: enhance page transition animations and overlay effects 2026-06-12 10:42:41 +08:00
jxxghp
62c9a10377 fix: improve search loading state handling and UI feedback 2026-06-11 08:10:44 +08:00
jxxghp
d3d0d847f6 fix: show subtitle episode metadata 2026-06-10 00:54:59 +08:00
jxxghp
b7dd397664 style: shrink header search trigger 2026-06-09 23:01:46 +08:00
jxxghp
c0276fca9f chore: update version to 2.13.7 2026-06-09 22:10:51 +08:00
jxxghp
4691d12faa fix: enforce permission-aware navigation 2026-06-09 21:45:51 +08:00
jxxghp
d0cac34d08 fix: show subtitle search in site resources 2026-06-09 17:25:52 +08:00
jxxghp
2f46c19826 feat: add subtitle search actions 2026-06-09 17:04:17 +08:00
jxxghp
b1cb07ae8c feat: add subtitle search functionality and download feature
- Added subtitle search results support in zh-TW locale.
- Enhanced resource page to handle subtitle search results, including new computed properties and methods for managing subtitle data.
- Introduced SubtitleCard and SubtitleItem components for displaying subtitle information.
- Created AddSubtitleDownloadDialog for managing subtitle downloads with directory selection and media ID options.
- Implemented subtitle download caching mechanism to track downloaded subtitles.
2026-06-09 06:47:09 +08:00
jxxghp
19710a5f0f feat: 添加管理员白名单配置,支持多个通知类型的管理员设置 2026-06-08 13:41:36 +08:00
jxxghp
4362bbed42 refactor: 优化日志视图样式,添加边框和阴影,提升视觉效果 2026-06-07 22:03:05 +08:00
jxxghp
89cf7070bb refactor: 在仪表板中添加加载横幅组件,提升用户体验 2026-06-07 21:40:32 +08:00
jxxghp
369afd6674 refactor: 移除列表组件的边框样式,提升视觉一致性 2026-06-07 20:32:23 +08:00
jxxghp
c4fd8f5631 refactor: 统一导航栏阴影样式,提升视觉一致性 2026-06-07 20:04:00 +08:00
jxxghp
c9ebf23977 refactor: 统一组件样式,优化圆角和阴影效果,提升视觉一致性 2026-06-07 19:49:15 +08:00
jxxghp
e239c0c5ea refactor: 优化代码格式,简化部分逻辑,提升可读性 2026-06-07 18:10:10 +08:00
jxxghp
87d780d985 refactor: remove unnecessary border and border-radius styles across various components
- Removed border and border-radius styles from SubscribeCard, CategoryEditDialog, ContentToggleSettingsDialog, DiscoverTabOrderDialog, ForkWorkflowDialog, OfflineStatusDialog, PluginMarketSettingDialog, ReorganizeDialog, SearchBarDialog, SiteImportDialog, SiteResourceDialog, SiteStatisticsDialog, WorkflowActionsDialog, TorrentFilterBar, and several setup views.
- Updated common.scss to introduce new variables for surface radius and border styles.
- Adjusted component styles to utilize new app surface styles for consistency.
2026-06-07 15:43:42 +08:00
ui_beam
f51b253c83 fix(pluginSidebarNav): 修复首次登录后插件侧栏菜单不显示的问题 (#486) 2026-06-07 14:27:23 +08:00
jxxghp
bf05cd0697 fix: wrap button in a div for better layout structure 2026-06-07 11:05:36 +08:00
jxxghp
8e5b8f7207 feat(dashboard): 优化仪表盘组件加载逻辑,支持异步加载和状态管理 2026-06-07 08:57:25 +08:00
jxxghp
5e3e106d91 fix: sync login autofill fields 2026-06-06 23:40:45 +08:00
jxxghp
c5fa6aad68 feat(transfer-queue): enhance empty state display and add hint for no tasks 2026-06-06 23:17:42 +08:00
jxxghp
989e8b4c5e feat(dashboard): 增加媒体组件的内边距以改善布局 2026-06-06 23:04:45 +08:00
jxxghp
a865aa433c fix: exit dashboard edit mode after reset
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-06 21:18:51 +08:00
jxxghp
df8e6016cd feat(dashboard): improve layout and styling for media components 2026-06-06 20:54:46 +08:00
jxxghp
75da7d35b4 fix(dashboard): stabilize editable layout controls 2026-06-06 18:22:36 +08:00
jxxghp
e2722801e4 feat(dashboard): enhance layout and responsiveness of dashboard components 2026-06-06 17:56:53 +08:00
jxxghp
63e28b76c8 更新 dashboard.vue 2026-06-06 11:32:38 +08:00
jxxghp
9dc63e2c21 feat(dashboard): integrate GridStack for enhanced layout management
- Added GridStack for dynamic dashboard layout with drag-and-drop functionality.
- Introduced new properties for DashboardItem to support row configuration.
- Enhanced ContentToggleSettingsDialog with reset functionality.
- Updated localization files for new dashboard features.
- Refactored dashboard components to utilize GridStack for layout rendering.
- Improved responsiveness and styling for dashboard elements.
- Removed deprecated draggable component in favor of GridStack.
2026-06-06 08:45:45 +08:00
jxxghp
08a2741c06 fix: order radius option labels 2026-06-05 23:36:35 +08:00
jxxghp
0dd95508b5 fix: refine theme radius options 2026-06-05 23:29:11 +08:00
jxxghp
f3c524b6b5 feat: add theme radius customization 2026-06-05 23:06:10 +08:00
jxxghp
a73c28c1f7 fix vlist scroll 2026-06-05 13:30:17 +08:00
jxxghp
b3fb7e1de1 feat: 增强主题管理,支持动态主题切换和持久化设置 2026-06-05 09:06:06 +08:00
jxxghp
3620b2a979 fix: 调整布局和样式,增加卡片网格的内边距以避免溢出 2026-06-05 08:24:16 +08:00
jxxghp
1046cb276f 更新 _default-layout.scss 2026-06-05 06:53:32 +08:00
jxxghp
6dfda4807c fix: 简化 showPasskeyLogin 计算属性,调整登录卡片内边距 2026-06-05 00:26:36 +08:00
jxxghp
8d13f3e5ca fix: 调整布局和样式,修复滚动和间距问题 2026-06-04 23:33:20 +08:00
jxxghp
9ebe740c69 更新 DownloadingListView.vue 2026-06-04 22:53:06 +08:00
jxxghp
73673820f1 fix: prevent download card shadow clipping 2026-06-04 22:42:43 +08:00
jxxghp
c6d0116e0f fix: apply theme shadow to workflow and download cards 2026-06-04 22:22:55 +08:00
jxxghp
6a06001dae fix: 修复横向滑块标题按钮点击区域 2026-06-04 22:07:02 +08:00
jxxghp
ad75b50a0c fix: 修复主题定制器顶部安全区 2026-06-04 21:54:52 +08:00
jxxghp
2a68aa05f6 fix: 修复主题定制器移动端滚动高度 2026-06-04 21:45:21 +08:00
jxxghp
fa90411c7a feat: 添加滑块阴影缓冲区,优化横向滚动效果 2026-06-04 21:25:08 +08:00
jxxghp
643ddcef07 feat: enhance theme customizer with shadow options and update styles
- Added shadow customization options to the theme customizer, allowing users to select from 'none', 'low', 'medium', and 'high'.
- Updated the theme customizer settings interface and default values to include shadow settings.
- Enhanced the CSS variables for shadows in common.scss to support different shadow levels based on user selection.
- Modified the VirtualSlideView component styles to improve layout and scrolling behavior.
- Updated localization files for English, Simplified Chinese, and Traditional Chinese to include new shadow-related terms.
- Adjusted various components to ensure consistent application of shadow styles across the application.
2026-06-04 21:20:08 +08:00
jxxghp
addc0838c0 feat: 添加工作流执行配置和并行数设置,优化工作流管理功能 2026-06-04 15:57:08 +08:00
jxxghp
8b43e0a754 更新 package.json 2026-06-04 08:45:25 +08:00
jxxghp
f81a9f0929 fix: 移除无用的认证提供方加载状态,简化登录逻辑 2026-06-04 08:37:19 +08:00
jxxghp
3cf5cc24cd feat: 添加插件认证支持,优化登录流程 2026-06-04 08:24:10 +08:00
jxxghp
841e9479af refactor: unify header tab menu definitions 2026-06-03 07:21:34 +08:00
jxxghp
8c3380e8f5 fix: 更新主题定制器翻译,优化用户界面文本 2026-06-02 23:03:19 +08:00
jxxghp
0ac42f0a76 fix: 添加订阅排序方式的本地存储功能,优化用户体验 2026-06-02 22:48:42 +08:00
jxxghp
caef6eca67 fix: 同步主题定制器状态到根节点,优化全局悬浮按钮位置 2026-06-02 22:44:53 +08:00
jxxghp
0867236b68 fix: 更新主题定制器图标样式,提升可读性 2026-06-02 21:26:30 +08:00
jxxghp
09dfdbaf67 fix: 简化主题定制器按钮样式,提升界面整洁度 2026-06-02 21:11:13 +08:00
jxxghp
57224e15fb fix: 调整主题定制器和对话框样式,优化用户体验 2026-06-02 21:06:24 +08:00
jxxghp
200500a060 fix: use dialog theme customizer in app mode 2026-06-02 19:14:56 +08:00
jxxghp
a4731aade1 feat: enable app mode theme customizer 2026-06-02 19:00:48 +08:00
jxxghp
7d21eabf1a fix: prevent theme customizer startup crash 2026-06-02 18:35:05 +08:00
jxxghp
b639737bd6 feat: refine theme customizer and horizontal navigation 2026-06-02 17:56:41 +08:00
jxxghp
d02ece234c fix: 修改主题定制器标题,提升语言一致性 2026-06-02 17:07:16 +08:00
jxxghp
889a5c9e51 fix: 优化主题定制器按钮样式和布局,提升用户体验 2026-06-02 17:02:23 +08:00
jxxghp
880a34f508 fix: 调整主题定制器面板的透明度设置显示逻辑 2026-06-02 16:18:00 +08:00
jxxghp
50b0148ed6 feat: add theme customizer component and functionality
- Introduced a new ThemeCustomizer component for real-time theme customization.
- Updated UserProfile.vue to integrate the ThemeCustomizer and manage theme settings.
- Enhanced theme management with new settings for layout, primary color, and skin.
- Added support for semi-dark menu and responsive design adjustments.
- Implemented local storage persistence for theme settings.
- Updated localization files to include new theme customizer strings in English, Simplified Chinese, and Traditional Chinese.
- Modified styles to support new bordered skin and theme customizer layout.
- Refactored existing components (HeaderTab, SearchBar) to accommodate new theme features.
2026-06-02 16:16:20 +08:00
jxxghp
285ddab45a fix: 调整插件项目主页链接逻辑,优化下拉菜单项顺序 2026-06-02 13:10:14 +08:00
jxxghp
aa12f4b6b6 fix: 优化插件项目主页链接解析逻辑 2026-06-02 12:54:54 +08:00
jxxghp
9bbb060073 fix: 修复插件项目主页跳转被拦截 2026-06-02 08:18:12 +08:00
jxxghp
3b0623628c fix: 优化插件卡片项目主页与版本历史入口 2026-06-02 07:53:10 +08:00
jxxghp
b45c147452 fix: 调整插件更新说明菜单行为 2026-06-02 07:31:17 +08:00
jxxghp
25bc7c4b3c feat: 添加插件更新历史功能及相关国际化支持 2026-06-02 07:16:05 +08:00
jxxghp
d6b7b6d813 fix: close plugin filter menus after selection 2026-06-01 21:32:24 +08:00
jxxghp
a3ac46c891 更新 package.json 2026-06-01 12:00:14 +08:00
Album
b6e824246b 优化文件管理多选操作体验 (#483) 2026-05-31 21:31:59 +08:00
Album
5191f6780d 恢复识别词应用详情标题文案 (#482) 2026-05-31 18:13:53 +08:00
Album
261aaf17ad 优化识别词应用详情显示 (#481) 2026-05-31 17:51:05 +08:00
jxxghp
258e64bca7 fix: 更新站点 Cookie 处理逻辑,添加请求失败提示,优化服务工作者缓存策略 2026-05-31 09:16:52 +08:00
jxxghp
e905df014e fix: add precomposed apple touch icon 2026-05-31 08:39:21 +08:00
jxxghp
b93f8f2bff fix: 消息中心首次打开时SSE与数据库消息重复显示
SSE消息只有date字段、note为null,数据库消息只有reg_time、note为{},
原getMessageKey将reg_time和date作为两个独立字段拼接签名导致同一条消息签名不同。
归一化时间字段(reg_time||date)和note字段后去重恢复正常。
2026-05-30 19:18:55 +08:00
jxxghp
9aa0a5e1b7 更新 package.json 2026-05-30 08:58:34 +08:00
jxxghp
ee9f41d015 更新 package.json 2026-05-30 08:58:22 +08:00
jxxghp
ad6a664cbe fix: proxy bangumi images 2026-05-30 08:54:40 +08:00
Album
3387067636 fix: handle episode group values in preview transfer (#480) 2026-05-30 08:28:00 +08:00
jxxghp
07dc3c3e9a fix: build Emby app deep links with server ids 2026-05-28 15:06:30 +08:00
jxxghp
262b4bebd4 更新 package.json 2026-05-28 14:37:07 +08:00
jxxghp
6e50cf31de fix: correct media server card links 2026-05-28 14:33:50 +08:00
jxxghp
14aa75dfae fix: format version install statistics 2026-05-27 17:48:58 +08:00
jxxghp
348aa4757b fix: normalize search site selection 2026-05-27 15:21:44 +08:00
jxxghp
6e6819acc1 fix: auto match manual transfer target path 2026-05-27 13:26:01 +08:00
jxxghp
51a58aaae0 fix: show manual transfer recognition details 2026-05-27 11:03:55 +08:00
jxxghp
fbde99389e 更新 package.json 2026-05-27 07:11:01 +08:00
jxxghp
5a4e345529 feat: add LLM proxy toggle 2026-05-27 06:57:09 +08:00
jxxghp
b446afb6d8 fix: improve plugin market editor layout 2026-05-26 17:39:14 +08:00
jxxghp
8580af36d1 fix: compact plugin market settings dialog 2026-05-26 17:16:19 +08:00
jxxghp
95ca092117 feat: optimize plugin market repository settings 2026-05-26 16:30:31 +08:00
jxxghp
ba200cae5c fix: move LLM user agent after max context 2026-05-26 08:30:33 +08:00
jxxghp
87c73e0253 feat: add llm user agent setting 2026-05-26 08:20:02 +08:00
jxxghp
d4d7f635f5 fix: allow rust acceleration re-enable 2026-05-25 23:48:09 +08:00
jxxghp
729db1510e 更新 package.json 2026-05-25 23:11:22 +08:00
jxxghp
8a12ecf918 fix: render OTP QR code reliably 2026-05-25 23:07:45 +08:00
jxxghp
cacc2602df fix: initialize OTP dialog on open 2026-05-25 19:49:30 +08:00
jxxghp
8c6cfa7fc5 feat: add MiniMax audio provider option 2026-05-25 19:10:21 +08:00
jxxghp
0113f28d8c 更新 package.json 2026-05-25 18:20:49 +08:00
jxxghp
d870b788bc feat: add usage version statistics dialog 2026-05-25 18:16:35 +08:00
722 changed files with 162426 additions and 15812 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

52
.github/workflows/pr-agent.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: PR-Agent
on:
pull_request_target:
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
types:
- opened
- reopened
- ready_for_review
- review_requested
- synchronize
issue_comment:
types:
- created
- edited
permissions:
contents: read
pull-requests: write
issues: write
jobs:
pr-agent:
if: >-
github.event.sender.type != 'Bot' &&
(
github.event_name == 'pull_request_target' ||
(
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)
)
)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Run PR Review
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
env:
GITHUB_TOKEN: ${{ github.token }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
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

3
.gitignore vendored
View File

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

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`
## 特性
@@ -11,15 +11,6 @@
- 支持多语言(中文/英文)
- 完整的插件系统支持,包括远程组件动态加载
## 模块联邦功能
MoviePilot 现已支持模块联邦Module Federation功能允许插件开发者创建可动态加载的远程组件实现更丰富的插件用户界面。
### 相关文档
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目
## 开发部署
@@ -49,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`
@@ -58,3 +60,12 @@ yarn build
```shell
node dist/service.js
```
### 模块联邦功能
MoviePilot 现已支持模块联邦Module Federation功能允许插件开发者创建可动态加载的远程组件实现更丰富的插件用户界面。
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目

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
@@ -11,22 +12,13 @@ Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS
- Multi-language support (Chinese/English)
- Complete plugin system with dynamic remote component loading
## Module Federation
MoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.
### Documentation
- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions
- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components
## Development
### Recommended IDE Setup
[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/).
@@ -48,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.
@@ -56,4 +59,11 @@ yarn build
```shell
node dist/service.js
```
```
### Module Federation
MoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.
- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions
- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components

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. 调试与排错
### 常见问题
@@ -449,24 +647,19 @@ shared: {
}
```
### 8.3 开发环境测试
### 8.3 本地监听构建
开发期间可以使用以下配置在本地测试
插件前端可使用 Vite 的监听构建模式
```typescript
// vite.config.ts
export default defineConfig({
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
}
})
```bash
yarn dev
```
`dev` 脚本配置为 `vite build --watch` 后,源码变化会自动重新构建。使用本地插件仓并启用 `DEV``PLUGIN_AUTO_RELOAD`MoviePilot 会同步新的构建产物;刷新页面即可看到修改。
## 9. 示例代码
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
- [插件远程组件示例](../examples/plugin-component/) - 开发插件组件的完整示例项目
- [模块联邦问题排查指南](./federation-troubleshooting.md) - 常见问题排查
## 10. 参考资料
@@ -476,4 +669,4 @@ export default defineConfig({
---
如有问题请提交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, includeIgnoreFile } from 'eslint/config'
import { fileURLToPath } from 'node:url'
const gitignorePath = fileURLToPath(new URL('.gitignore', import.meta.url))
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([
includeIgnoreFile(gitignorePath, {
gitignoreResolution: true,
name: 'moviepilot/gitignore',
}),
globalIgnores(
['**/.worktrees/**', '**/vite.config.*.timestamp-*.mjs', 'src/@iconify/**', '**/*.d.ts'],
'moviepilot/eslint-only-ignores',
),
{
...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,
],
},
},
])

1384
index.html

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,28 @@
{
"name": "moviepilot",
"version": "2.13.0",
"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",
@@ -49,6 +65,8 @@
"dayjs": "^1.11.13",
"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",
@@ -56,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",
@@ -72,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",
@@ -81,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",
@@ -90,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",
@@ -113,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",
@@ -122,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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -35,6 +35,23 @@ http {
try_files $uri $uri/ /index.html;
}
location = /service-worker.js {
# Service Worker 必须保持稳定 URL 并每次重新验证,避免前端更新后继续注册旧版本。
expires off;
add_header Cache-Control "no-cache, must-revalidate";
root html;
try_files $uri =404;
}
location = /manifest.webmanifest {
# Web App Manifest 参与 PWA 安装与资源发现,不能跟普通静态资源一起长缓存。
expires off;
default_type application/manifest+json;
add_header Cache-Control "no-cache, must-revalidate";
root html;
try_files $uri =404;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
# 静态资源
expires 1y;
@@ -44,18 +61,18 @@ http {
location /assets {
# 静态资源
expires 1y;
add_header Cache-Control "public";
add_header Cache-Control "public, max-age=31536000, immutable";
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

View File

@@ -0,0 +1,12 @@
import assert from 'node:assert/strict'
import { formatSeasonLabel } from '../src/@core/utils/season.ts'
assert.equal(formatSeasonLabel(0, '特别篇'), '特别篇')
assert.equal(formatSeasonLabel('0', 'Specials'), 'Specials')
assert.equal(formatSeasonLabel(1, '特别篇'), 'S01')
assert.equal(formatSeasonLabel('12', '特别篇'), 'S12')
assert.equal(formatSeasonLabel(null, '特别篇'), '')
assert.equal(formatSeasonLabel(undefined, '特别篇'), '')
console.log('season label checks passed')

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

@@ -4,6 +4,8 @@ import { computed } from 'vue'
interface Props {
modelValue: boolean
type?: 'info' | 'warn' | 'error'
/** 覆盖确认类型的默认图标,用于表达更具体的操作语义。 */
icon?: string
title?: string
content?: string
confirmText?: string
@@ -64,15 +66,15 @@ function handleCancel() {
<VCardItem>
<div class="d-flex align-center justify-start mt-3">
<VAvatar :color="currentType.color" variant="text" size="x-large">
<VIcon size="x-large" :icon="currentType.icon" />
<VIcon size="x-large" :icon="icon || currentType.icon" />
</VAvatar>
<div class="mx-3">
<p class="font-weight-bold text-xl text-high-emphasis">{{ title }}</p>
<p>{{ content }}</p>
<p class="app-confirm-dialog-content">{{ content }}</p>
</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>
@@ -84,3 +86,9 @@ function handleCancel() {
</VCard>
</VDialog>
</template>
<style scoped>
.app-confirm-dialog-content {
white-space: pre-line;
}
</style>

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

@@ -22,8 +22,8 @@ code {
%blurry-bg {
position: relative;
isolation: isolate;
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
isolation: isolate;
@media (width >= 1280px) and (hover: hover) {
background: rgba(var(--v-theme-background), 1);

View File

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

View File

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

View File

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

@@ -5,13 +5,13 @@
// Vertical nav scrolled sticky elevated nav
%default-layout-vertical-nav-scrolled-sticky-elevated-nav {
background-color: rgb(var(--v-theme-surface));
box-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
box-shadow: var(--app-surface-shadow);
}
// Floating navbar and sticky elevated navbar scrolled
%default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled {
background-color: rgb(var(--v-theme-surface));
box-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
box-shadow: var(--app-surface-shadow);
}
// Floating navbar overlay

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

@@ -66,6 +66,43 @@ export const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : valu
// 格式化为Sxx
export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
/**
* 格式化为 SxxExx 季集标识,多个连续集会合并为范围。
*/
export function formatSeasonEpisode(
season: number | string | null | undefined,
episodeNumbers: number[],
): string {
const seasonText = season === null || season === undefined || season === '' ? '' : formatSeason(String(season))
const normalizedNumbers = [...new Set(episodeNumbers)]
.map(number => Number(number))
.filter(number => Number.isFinite(number) && number > 0)
.sort((first, second) => first - second)
if (!normalizedNumbers.length) return seasonText
const formatEpisode = (number: number) => `E${String(number).padStart(2, '0')}`
const ranges: string[] = []
let start = normalizedNumbers[0]
let end = normalizedNumbers[0]
for (let index = 1; index < normalizedNumbers.length; index++) {
const currentNumber = normalizedNumbers[index]
if (currentNumber === end + 1) {
end = currentNumber
} else {
ranges.push(start === end ? formatEpisode(start) : `${formatEpisode(start)}-${formatEpisode(end)}`)
start = currentNumber
end = currentNumber
}
}
ranges.push(start === end ? formatEpisode(start) : `${formatEpisode(start)}-${formatEpisode(end)}`)
return `${seasonText}${ranges.join('、')}`
}
// 格式化为xx[TGMK]B
export function formatFileSize(bytes: number, decimals = 2, prefix = 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)
}
})
}

15
src/@core/utils/season.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* 格式化用户可见的季标签。
*
* TMDB 使用季号 0 表示特别季;调用方传入当前语言的特别季名称,
* 其余季号保持 MoviePilot 现有的 Sxx 展示口径。
*/
export function formatSeasonLabel(
season: number | string | null | undefined,
specialsLabel: string,
): string {
if (season === null || season === undefined || season === '') return ''
if (Number(season) === 0) return specialsLabel
return `S${String(season).padStart(2, '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,7 +1,14 @@
<script lang="ts">
import { Transition } from 'vue'
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({
setup(props, { slots }) {
@@ -11,6 +18,12 @@ 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')
const isHorizontalLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'horizontal')
// This is alternative to below two commented watcher
// We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa.
@@ -25,6 +38,10 @@ export default defineComponent({
scrollDistance.value = window.scrollY
}
const handleThemeCustomizerChange = (event: Event) => {
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
}
// 监听弹窗状态变化
const checkDialogState = () => {
const wasDialogOpen = isDialogOpen.value
@@ -32,12 +49,13 @@ export default defineComponent({
// 当弹窗刚打开时,记录当前的滚动状态
if (!wasDialogOpen && isDialogOpen.value) {
wasScrolledBeforeDialog.value = scrollDistance.value > 0
wasScrolledBeforeDialog.value = scrollDistance.value > 10
}
}
onMounted(() => {
window.addEventListener('scroll', handleScroll)
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
// 初始检查弹窗状态
checkDialogState()
@@ -52,11 +70,14 @@ export default defineComponent({
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
dialogObserver?.disconnect()
dialogObserver = null
})
return () => {
const hasFixedShellBackplate = fixedShellBackplate.layers.value.length > 0
// 👉 Vertical nav
const verticalNav = h(
VerticalNav,
@@ -69,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',
@@ -93,13 +123,12 @@ export default defineComponent({
const main = h(
'main',
{ class: 'layout-page-content' },
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
h('section', { class: 'page-content-container' }, slots.default?.()),
),
h('section', { class: 'page-content-container' }, slots.default?.()),
)
// 👉 根据路由 meta 决定 footer 高度
const shouldShowFooter = !route.meta.hideFooter
const isNavbarScrolled = scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)
// 👉 Footer
const footer = h('footer', { class: 'layout-footer' }, [
@@ -127,11 +156,20 @@ export default defineComponent({
'layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid',
'layout-navbar-fixed',
mdAndDown.value && 'layout-overlay-nav',
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,
(scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)) && 'window-scrolled',
!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,
],
)
}
},
@@ -139,6 +177,8 @@ export default defineComponent({
</script>
<style lang="scss">
/* stylelint-disable no-descending-specificity */
@use '@configured-variables' as variables;
@use '@layouts/styles/placeholders';
@use '@layouts/styles/mixins';
@@ -150,6 +190,10 @@ export default defineComponent({
}
.layout-wrapper.layout-nav-type-vertical {
--layout-navbar-block-size: calc(
env(safe-area-inset-top, 0px) + #{variables.$layout-vertical-nav-navbar-height} + var(--navbar-tab-height)
);
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
min-block-size: 100%;
@@ -165,13 +209,16 @@ export default defineComponent({
.layout-navbar {
position: fixed;
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
// iOS Safari 在地址栏收起和惯性滚动时可能把 fixed 顶栏和页面滚动层合成到一起,
// 单独提升顶栏图层可避免导航栏短暂上移到安全区下方。
backface-visibility: hidden;
block-size: var(--layout-navbar-block-size);
inline-size: calc(100% - variables.$layout-vertical-nav-width);
inset-block-start: 0;
transform: translate3d(0, 0, 0);
.navbar-content-container {
block-size: calc(
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height + var(--navbar-tab-height)
);
block-size: var(--layout-navbar-block-size);
}
@at-root {
@@ -223,6 +270,202 @@ export default defineComponent({
// Adjust right column pl when vertical nav is collapsed
&.layout-vertical-nav-collapsed .layout-content-wrapper {
padding-inline-start: variables.$layout-vertical-nav-collapsed-width;
.page-content-container > div:first-child {
inline-size: calc(100vw - variables.$layout-vertical-nav-collapsed-width - 1rem);
}
}
&.layout-vertical-nav-collapsed .layout-navbar {
inline-size: calc(100% - variables.$layout-vertical-nav-collapsed-width);
}
&.layout-vertical-nav-collapsed .layout-vertical-nav:not(.overlay-nav) {
.nav-header {
justify-content: center;
margin-inline: 0;
padding-inline: 0;
}
.app-logo {
justify-content: center;
inline-size: 100%;
transform: none !important;
}
.app-logo .theme-logo-mark {
block-size: 2.5rem;
inline-size: 2.5rem;
}
.app-logo h1,
.nav-item-title,
.nav-section-title {
display: none;
}
.nav-link > a {
justify-content: center;
border-radius: 0.75rem !important;
block-size: 2.75rem;
margin-inline: 0.75rem;
padding-inline: 0;
}
.nav-item-icon {
margin-inline-end: 0 !important;
}
}
&.layout-horizontal-nav-active {
.layout-vertical-nav:not(.overlay-nav) {
pointer-events: none;
transform: translateX(-100%);
visibility: hidden;
}
.layout-content-wrapper {
padding-inline-start: 0;
}
.layout-navbar {
background: rgb(var(--v-theme-background));
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
inline-size: 100%;
max-inline-size: none;
padding-inline: 0;
}
.navbar-content-container {
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
inline-size: 100%;
margin-inline: auto;
max-inline-size: variables.$layout-boxed-content-width;
padding-inline: 1.5rem;
}
.layout-page-content {
inline-size: 100%;
margin-inline: auto;
max-inline-size: variables.$layout-boxed-content-width;
padding-inline: 1rem;
}
.page-content-container > div:first-child {
inline-size: 100%;
}
}
@at-root {
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed .layout-navbar {
backdrop-filter: blur(12px) saturate(1.2);
background: rgb(var(--v-theme-surface)) !important;
box-shadow:
0 1px 3px rgba(0, 0, 0, 4%),
0 1px 2px rgba(0, 0, 0, 2%);
}
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.navbar-content-container {
backdrop-filter: none !important;
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
filter: none !important;
padding-inline: 1.5rem !important;
&::before {
display: none !important;
backdrop-filter: none !important;
background: transparent !important;
background-color: transparent !important;
content: none !important;
filter: none !important;
}
}
html[data-theme='transparent'] .layout-wrapper.layout-horizontal-nav-active .layout-navbar,
.v-theme--transparent .layout-wrapper.layout-horizontal-nav-active .layout-navbar {
backdrop-filter: none !important;
background: transparent !important;
border-block-end-color: rgba(var(--v-theme-on-surface), 0.04);
box-shadow: none !important;
}
html[data-theme='transparent'] .layout-wrapper.layout-horizontal-nav-active .navbar-content-container,
.v-theme--transparent .layout-wrapper.layout-horizontal-nav-active .navbar-content-container {
backdrop-filter: none !important;
background: transparent !important;
box-shadow: none !important;
}
// 透明主题的水平导航不叠加滚动磨砂层,避免中间区域出现一块更深的背景。
html[data-theme='transparent']
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.layout-navbar,
.v-theme--transparent
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.layout-navbar {
backdrop-filter: blur(var(--transparent-blur-light, 6px)) !important;
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2)) !important;
box-shadow: none !important;
}
// 透明主题滚动时只让外层导航栏承载整屏背景,避免内部最大宽度容器单独变深。
html[data-theme='transparent']
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.navbar-content-container,
.v-theme--transparent
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
.navbar-content-container {
backdrop-filter: none !important;
background: transparent !important;
background-color: transparent !important;
box-shadow: none !important;
filter: none !important;
padding-inline: 1.5rem !important;
&::before {
display: none !important;
backdrop-filter: none !important;
background: transparent !important;
background-color: transparent !important;
content: none !important;
filter: none !important;
}
}
html[data-theme='light'][data-theme-semi-dark-menu='true'][data-theme-layout='vertical']
.layout-wrapper.layout-nav-type-vertical:not(.layout-horizontal-nav-active)
.layout-vertical-nav:not(.overlay-nav),
html[data-theme='light'][data-theme-semi-dark-menu='true'][data-theme-layout='collapsed']
.layout-wrapper.layout-nav-type-vertical:not(.layout-horizontal-nav-active)
.layout-vertical-nav:not(.overlay-nav) {
background: #2f3349;
color: #e7e3fc;
.app-logo h1,
.nav-section-title,
.nav-link > a,
.nav-item-icon {
color: rgba(231, 227, 252, 78%) !important;
}
.nav-link > a:hover {
background-color: rgba(231, 227, 252, 6%);
}
.nav-link > .router-link-exact-active {
color: #fff !important;
.nav-item-icon,
.nav-item-title {
color: #fff !important;
}
}
}
}
// 👉 Content height fixed
@@ -233,9 +476,7 @@ export default defineComponent({
.layout-page-content {
// display: flex;
// 使用 clip 替代 hidden避免 Chrome 144+ 滚动锁定问题
overflow-x: clip;
overflow-y: auto;
overflow: auto;
.page-content-container {
inline-size: 100%;

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')
})
})

View File

@@ -11,12 +11,11 @@ html {
}
body {
overflow: visible !important;
background: rgb(var(--v-theme-background));
overscroll-behavior-y: contain;
// Chrome 144+ 兼容性:覆盖 Vuetify 的内联 overflow: hidden 样式
overflow: visible !important;
--webkit-overflow-scrolling: touch;
-webkit-overflow-scrolling: touch;
}
body,
@@ -37,10 +36,8 @@ body,
.layout-page-content {
@include mixins.boxed-content(true);
// Chrome 144+ 兼容性:使用 clip 替代 hidden避免滚动锁定问题
// overflow: hidden 在新版 Chrome 中可能意外阻止垂直滚动
overflow: clip;
flex-grow: 1;
overflow: clip visible;
// TODO: Use grid gutter variable here;
padding-block: 1.5rem;

View File

@@ -1,5 +1,6 @@
import type { Component, Ref, VNode } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
import { ContentWidth, FooterType, NavbarType } from './enums'
export interface UserConfig {
@@ -112,13 +113,27 @@ export interface NavLinkProps {
rel?: ATagRelAttrValues
}
export type NavMenuIconColor = 'primary' | 'info' | 'success' | 'warning' | 'secondary'
export interface NavLink extends NavLinkProps, Partial<AclProperties> {
title: string
full_title?: string
icon?: unknown
iconColor?: NavMenuIconColor
badgeContent?: string
badgeClass?: string
disable?: boolean
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
export interface NavMenuTabItem {
title: string
icon?: string
tab: string
description?: string
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
export interface NavMenu extends NavLink {
@@ -126,6 +141,8 @@ export interface NavMenu extends NavLink {
description?: string
admin?: boolean
footer?: boolean
// 水平三级菜单和页面动态标签页共用的静态标签定义。
tabs?: NavMenuTabItem[]
}
// 👉 Vertical nav group

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,278 @@ 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',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
const WordListHighlightRules = function (this: any, options?: { syntax?: boolean }) {
this.$rules = buildWordListRules(options?.syntax === true)
this.normalizeRules()
}
oop.inherits(WordListHighlightRules, TextHighlightRules)
exports.WordListHighlightRules = WordListHighlightRules
},
)
aceModule.define?.(
'ace/mode/word_list',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextMode = require('./text').Mode
const WordListHighlightRules = require('./word_list_highlight_rules').WordListHighlightRules
const Mode = function (this: any, options?: { syntax?: boolean }) {
TextMode.call(this)
this.HighlightRules = WordListHighlightRules
this.$highlightRuleConfig = options || {}
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.$id = 'ace/mode/word_list'
}).call(Mode.prototype)
exports.Mode = Mode
},
)
}
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
@@ -555,4 +827,5 @@ ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
registerJinja2Mode()
registerWordListMode()
ace.require('ace/ext/language_tools')

View File

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

View File

@@ -0,0 +1,80 @@
import { changePluginSource, getPluginSourceOptions, installPluginFromSource } from '@/api/pluginSource'
import { ApiRequestError } from '@/api/client'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}))
vi.mock('@/api', () => ({
default: createDataApiMock({
get: (...args: unknown[]) => mocks.apiGet(...args),
post: (...args: unknown[]) => mocks.apiPost(...args),
}),
}))
describe('plugin source API adapters', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
mocks.apiGet.mockResolvedValue({
plugin_id: 'DemoPlugin',
inventory_complete: true,
selection_status: 'selected',
selection_reason: '',
identity: null,
candidates: [],
})
mocks.apiPost.mockResolvedValue(undefined)
})
it('查询来源候选时编码插件 ID并按需传递强制刷新参数', async () => {
await getPluginSourceOptions('Demo Plugin', true)
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/source/Demo%20Plugin/options', { params: { force: true } })
})
it('默认查询不伪造 force 参数', async () => {
await getPluginSourceOptions('DemoPlugin')
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/source/DemoPlugin/options')
})
it('按明确来源安装和 CAS 换源分别发送对应请求体', async () => {
const installRequest = {
repo_url: 'https://github.com/example/plugins',
release_version: '1.2.3',
force: true,
}
const changeRequest = {
repo_url: 'https://github.com/other/plugins',
expected_revision: 7,
release_version: null,
}
await installPluginFromSource('DemoPlugin', installRequest)
await changePluginSource('DemoPlugin', changeRequest)
expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'plugin/source/DemoPlugin/install', installRequest)
expect(mocks.apiPost).toHaveBeenNthCalledWith(2, 'plugin/source/DemoPlugin', changeRequest)
})
it('不吞掉后端业务失败,调用方仍可捕获 ApiRequestError', async () => {
mocks.apiPost.mockResolvedValueOnce({
success: false,
message: '来源 revision 已变化,请重新确认',
data: null,
})
const error = await installPluginFromSource('DemoPlugin', {
repo_url: 'https://github.com/example/plugins',
}).catch(reason => reason)
expect(error).toBeInstanceOf(ApiRequestError)
expect(error).toMatchObject({
message: '来源 revision 已变化,请重新确认',
businessFailure: true,
})
})
})

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

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