Skip to content

Feat/UI color unification - #19

Merged
jeeinn merged 17 commits into
masterfrom
feat/ui-color-unification
Sep 23, 2026
Merged

jeeinn merged 17 commits into
masterfrom
feat/ui-color-unification

Conversation

@jeeinn

@jeeinn jeeinn commented Sep 23, 2026

Copy link
Copy Markdown
Owner

No description provided.

界面配色此前分散使用蓝/绿/琥珀/黄/玫红多组颜色,同一语义(例如"主操作按钮")在不同
组件里取色不一致:启动环境是绿、重启环境是琥珀、应用配置是绿、保存是蓝。部分色值还只
写了单主题取值(如 text-rose-400),在明亮主题下对比度不足。

本次把配色收敛为三组:
- 主色调 蓝:正向主操作、提示框/横幅、标签徽章、tooltip 触发图标的悬停强调
- 辅助色 黑/中性:取消、关闭、配置等次级操作与中性徽章
- 语义色:红=错误与危险操作,绿=成功,沿用既有认知不改

在 src/style.css 新增 ui-btn-* / ui-hint-* / ui-tag-* 语义 token 作为唯一规范来源,
并让每个 token 自带 dark 变体,消除只适配单一主题的硬编码色值。状态指示器(加载中、
容器运行状态、步骤条、进度条)与 Toast 的错误红/成功绿保持原样,日志警告级别色亦保留。
vue-tsc 长期报 7 个错误,此前一直被视为类型噪音。逐个核查后发现其中 3 个是真实缺陷:
同一文件里局部声明的函数名与从 api 导入的同名函数互相遮蔽,函数体内本意调用后端的那行
代码,实际调用的是它自己。三处后果各不相同:

- App.vue:checkDocker 包装函数调用自身,形成同步无限递归并抛 RangeError,被 catch 当作
  "Docker 不可用" 吞掉。check_docker 与随后的 list_containers 从未执行——仪表盘启动即判定
  Docker 不可用。已用产物交叉验证:修复前 dist 中不含 "check_docker" 字符串。
- EnvConfigPage.vue:存放预览结果的 ref 覆盖了 previewCompose 函数,
  previewCompose(config) 变成对 Ref 的调用并抛 TypeError,「预览配置」必然失败。
- SoftwareSettings.vue:「重置所有自定义」内部 await 调用自身,每次迭代重新弹确认框,
  形成无限异步循环,reset_all_overrides 永不执行且 loading 永不复位。

统一改用别名导入(checkDockerApi / previewComposeApi / resetAllOverridesApi)消除遮蔽。

另修两处噪音错误:
- App.vue 的 WorkspaceInfo 导入未被使用,改为 Pick<WorkspaceInfo, ...> 复用于
  workspaceMissingInfo,与后端序列化契约单一来源。
- WorkspaceMissingDialog.vue 的 open prop 曾被 plugin-dialog 的同名导入遮蔽,模板
  v-if="open && info" 解析到恒为真的函数而非 prop,open=false 时对话框照样渲染;同时移除
  模板未使用(模板走 $t)的 useI18n/t。已用 SFC 编译产物确认该处现编译为
  __props.open && __props.info。

配套改动:
- src/test/setup.ts 补 matchMedia stub。jsdom 未实现它,而 useTheme 在模块导入期就调用,
  缺少时任何间接引入 useTheme 的组件连测试文件都加载不了(App.vue → SettingsPage.vue)。
- 新增 4 条回归测试,并已验证它们在修复前全部失败:
  App.spec.ts 断言挂载后真实调用 check_docker 与 list_containers;
  EnvConfigPage.spec.ts 断言点预览真实调用 preview_compose 且弹窗渲染返回值;
  SoftwareSettings.spec.ts 断言确认后真实调用 reset_all_overrides;
  WorkspaceMissingDialog.spec.ts 断言 open=false / info=null 时不渲染。

校验:vue-tsc -b 由 7 个错误降为 0;vite build 通过;vitest 226/226 通过
(原 221 + 新增 5)。
- zh-CN:dashboard.refresh 由「手动刷新」改为「刷新」,与英文侧既有的 Refresh 对齐
  (该 key 全库仅此一处引用,无其他联动)。
- 按钮由 ui-btn-primary 改为 ui-btn-dark,落到上一轮引入的黑色辅助色 token。
  选择复用既有 token 而非新写死色值:该 token 自带双主题变体——亮色为 slate-900
  实心黑,暗色反转为 slate-100,避免深色背景上放纯黑按钮导致按钮与背景融为一体。

校验:check:i18n 通过(zh-CN/en 各 511 key,引用 426 key);vue-tsc -b 0 错;
vite build 通过,产物 CSS 含 ui-btn-dark 的四条规则(base/hover/dark/dark:hover),
产物 JS 中文侧为「刷新」、英文侧为 Refresh;vitest 226/226 通过。
守卫脚本此前报告 85 条「未被源码引用」的死文案。逐一核查后只有 12 条是真的 ——
其余 70 条若照单全删,会打断仪表盘全部 toast、备份/恢复进度步骤、时区下拉框、
日志级别切换器。四类根因全部出在脚本的判定逻辑上:

1. 扫描范围只有 src/,漏掉 src-tauri/src。Rust 引擎会把 i18n key 直接当事件
   载荷发出(backup_engine / restore_engine 的 emit_progress("…"),以及
   commands/paths.rs 返回的 fallback_reason),前端再用 $t(变量) 渲染。影响 16 条。
2. 只认 t('literal') 形态,漏掉「key 当数据传递」的引用:App.vue 的
   addLogKey('dashboard.toast.…')、BackupPage 的 { step: '…' }、EnvConfigPage 的
   labelKey 字段等。影响 52 条。
3. 子串匹配:common.select 因为 common.selectPlaceholder 的存在被判定为「在用」,
   反而漏掉一条真死文案。
4. 模板字符串动态拼 key:AboutPage 的 $t(`about.level.${level}`),取值来自
   logLevels = 'info'|'warn'|'error',整棵子树不可静态枚举。

对应改动:
- SOURCE_DIRS 增加 src-tauri/src,并纳入 index.html;.rs 列入可扫描扩展名。
- 新增 extractKeyLikeLiterals:整段被引号包裹的点分字面量、且首段命中语言包顶级
  命名空间即计为在用,因要求整段包裹而天然带词边界。extractUsedKeys 保留原语义。
- 新增 extractDynamicKeyPrefixes:t(`prefix.${…}`) 命中前缀时,把该前缀下整棵子树
  保守视为在用 —— 宁可不报,也不误删运行时可达的文案。
- 新增 stripComments:注释里作为示例出现的 key(如 VersionHelpModal.spec.ts 中
  写到的 "envConfig.versionHelp.xxx")不再被当成真实引用。
- 「引用但缺失」报告排除文件名(backup.zip / workspace.json)与命名空间引用
  (测试里 getByPath(zhCN, 'envConfig.versionHelp'))。

同批新增第 5 类检查(仅提示、不阻断):<template> 中硬编码中文。这类问题是本轮
死文案的病根,实测 9 处 —— 其中 3 处正是被硬编码顶掉的那三条 key:
- EnvConfigPage.vue 的 redis/nginx 空态原本写死中文,仅内嵌 $t('envConfig.addVersion'),
  英文界面因此显示中文。改为 $t('envConfig.redis.empty', { action: … }) 走 i18n,
  key 值同步改为带 {action} 占位,避免按钮文案两处各写一份而漂移。
- SoftwareSettings.vue 复制按钮 :title 原本拼死字符串 '点击复制: ' + tag,
  改为 t('software.toast.copyTooltip', { tag })。
因此这三条 key 保留而非删除:删掉会让英文界面继续显示中文,且失去修复线索。
净删除 12 条,语言包 511 → 499 key。剩余 6 处硬编码(EnvConfigPage 的时区
placeholder 与启动确认弹窗三个按钮、MirrorPanel 四个 title)属「缺 i18n」而非
「死文案」,需新增 key 或重新斟酌 tooltip 措辞,留待下一轮。

校验:
- check:i18n 通过:死文案 85 → 0,中英各 499 key
- 同一份语言包上跑 HEAD 版脚本仍报 70 条死文案,A/B 对比确认误报已消除
- 破坏性验证:把 SOURCE_DIRS 退回仅 src/ 后,两条新增回归测试精确失败(正是
  Rust 侧的三条 key),证明测试有负载能力;随后从备份还原并校验 sha256 一致
- 新增 17 条测试(脚本 spec 10 → 27),vitest 243/243 通过
- vue-tsc -b 0 错;vite build 通过;产物核对:已删 key 的独有取值消失、接回的
  3 条 key 取值与 {action} 占位存在、about.level 动态子树结构完整

注:产物核对不能用点分路径做断言 —— 语言包在产物里是嵌套对象,
backup.progress.steps.envConfig 只以层级形式存在,不含点分字面量;动态拼接点
则编译为 $t(`about.level.${t}`)。
上一轮把「死文案」判定为「症状」、硬编码才是病根后,脚本持续列出 9 处漏网中文。
本轮处理 8 处,第 9 处(语言切换器的「中文」)属正常用法,改为显式豁免。

一、接入 i18n 的 8 处(新增 5 个 key,中英双语)
- EnvConfigPage:自定义时区输入框 placeholder(原「例如:Europe/Moscow」)、
  启动确认弹窗的三个按钮。后三者复用现成 key —— common.cancel 与
  dashboard.startConfirm.{goMirror,directStart},该弹窗本就在用这个命名空间,
  此前只有标题与正文走了 i18n,按钮却写死中文。
- MirrorPanel:表格操作列四个按钮的 tooltip。新增 mirror.tooltips 命名空间而非
  直接复用 mirror.actions.*:tooltip 措辞本就比按钮标签更具体(「选择此镜像源」
  vs 按钮「选择」、「测试连接」vs「测试」、「删除自定义」vs「删除」),复用会丢信息;
  且四个都新建可保持结构一致,避免后来者困惑「为何只有 edit 复用」。

二、脚本新增 i18n-exempt 显式豁免,并修掉一处漏报
- 豁免:门槛是「连续 2 个汉字」,但语言切换器的母语自称「中文」本就该显示为中文
  (任何界面语言下都是「中文」,这是语言列表的通行做法)。调高门槛会漏掉真文案、
  写死白名单会随文件漂移,故改为就近写 `<!-- i18n-exempt: 原因 -->`。支持行内与
  独占一行两种写法(后者豁免其下 10 行内首个含中文的行)。豁免项也会打印出来 ——
  否则标记拼错或放错位置会静默失效,表现为「某处中文一直不在报告里」,更难发现。
- 漏报:原先判定「本行含 `<!--` 就整行跳过」,于是
  `<span>请选择镜像源</span>  <!-- TODO -->` 这类「文案 + 行尾注释」的行被整体放过。
  改为先剥离 HTML 注释(stripHtmlComments,保持行数以便行号对齐)再扫描。

三、脚本新增第 6 类检查:语言包文案里的不可见字符损坏
- 孤立变体选择符 U+FE0E/U+FE0F(缺 emoji 基字符)与零宽字符 U+200B/U+200C。
  刻意不查 U+200D —— 它在 emoji 组合序列(👨‍👩‍👧)中合法且必需。
- 归为阻断(exit 1)而非仅提示:这不是风格问题,是确定的字符损坏,且进入语言包后
  无法靠阅读发现 —— ⚠️(U+26A0 U+FE0F)与 ️(孤立 U+FE0F)在编辑器里都只占一个空格宽。
- 顺带修掉一处真实损坏:en.json 的 mirror.dockerRegistry.warning 以孤立 U+FE0F 开头、
  丢了基字符 U+26A0,英文界面该警告条目前面缺图标(上方的 diff 里能看出这行「看起来
  一样却变了」—— 正是这类损坏的隐蔽性)。zh 侧正常,属既有问题。
- 判定 emoji 基字符用 `\p{Emoji}` 而非手写码点区间:后者实测把 🛡(U+1F6E1)判成非法,
  产生 2 处误报。这个教训写进了函数注释。

四、.gitattributes 补 *.mjs / *.cjs 与自身行尾规则
原先只有 `*.js text eol=lf`,导致 scripts 下 6 个 ES module 落到兜底 `* text=auto`,
在 core.autocrlf=true 的 Windows 环境检出为 CRLF(仓库内仍是 LF)。影响不只是行尾
不统一:任何拿 LF 锚点做字符串匹配的工具都会静默失配 —— 本轮写破坏性验证脚本时即踩到,
表现为「锚点明明存在却匹配不到」。同时把 .gitattributes 自身也固定为 LF,避免其编辑后
整文件行尾抖动。已把 6 个 .mjs 工作区文件归一化为 LF(仓库内内容未变,renormalize 后
暂存区为空可证)。

五、测试
新增 16 条(脚本 spec 27 → 43),并对本轮四处修复逐条做了破坏性验证:
- 退回行内注释漏报逻辑 → 恰好「行尾注释不再掩盖同行文案」1 条失败
- 令 collectI18nExemptions 永远返回空 → 恰好 4 条失败(2 条单元 + 2 条契约)
- 删掉 SettingsPage 的真实豁免标记 → 恰好 2 条契约测试失败
- 把 ⚠️ 退回孤立 U+FE0F → 脚本 exit 1 并精确报出该 key,2 条测试失败
每次验证后均从备份还原并 sha256sum -c 校验字节一致。

校验:
- check:i18n 通过:硬编码中文 9 → 0,语言包 499 → 504 key(中英各 504)
- vue-tsc -b 0 错;vite build 通过;vitest 259/259 通过(原 243 + 新增 16)
- 产物核对:12 条文案「出现次数 == 语言包条数」(多 1 即说明模板里还有硬编码副本)、
  6 种旧硬编码属性形态 0 次、i18n-exempt 未泄漏到产物、孤立 U+FE0F 归零

遗留(未改,需决策):mirror.hints.testConnection 在语言包内嵌了按钮名,且中英指代
已经分裂 —— zh 是「点击"测试连接"验证…」(指向 tooltip)、en 是 'Click "Test" to verify…'
(指向按钮标签),二者说的不是同一个对象。建议改为 {action} 占位符由模板传入
mirror.actions.test,与 envConfig.redis.empty 的写法对齐;但这会改动用户可见文案
(zh「测试连接」→「测试」),故未擅自处理。同族问题还有 mirror.hints.autoApply。
提示类文案里写死按钮名(点击"测试连接"、Clicking "Select")与按钮本身
没有任何约束:按钮改名后提示不跟着变,中英两侧还容易各写各的 ——
mirror.hints.testConnection 的 zh 写"测试连接"、en 写"Test",而实际按钮
是"测试"/"Test",指代已经分裂。

统一按 envConfig.redis.empty 的写法解耦:文案只留占位符,真实文案由模板
从按钮自己的 key 传入。

- mirror.hints.autoApply      <- mirror.actions.select
- mirror.hints.testConnection <- mirror.actions.test
- software.hints.editCustom   <- common.edit
- restore.confirm.message     <- dashboard.startEnv
- envConfig.pullConfirm.tip   <- envConfig.startEnv(en 侧原本写的是
  "One-click start",与实际按钮 "Start All" 早已漂移)
- dashboard.log.panelHint     <- dashboard.log.export
- dashboard.empty.description <- sidebar.envConfig(页面名用 {page})

同时给 check-i18n-keys.mjs 加第 7 类检查 findEmbeddedKeyValues:语言包文案
里的引号片段若恰好等于另一个 key 的值即提示改占位符。中英引号形态都覆盖
(中文「」、英文“”),否则只报一侧。第三方 UI 原文(Docker Desktop 的
"Apply & restart")不是本项目的 key,不会命中。

验证:vue-tsc 0 错、vite build 通过、vitest 265/265,check:i18n 通过且
第 7 类报告归零。
统一配色时把 Toast 的 warning 从 amber 改成了蓝,结果与 info 返回完全相同
的配色与边框 —— 「类型名有四个、视觉只有三种」。调用方以为传 'warning' 会
更醒目,界面上却没有任何区别,这是个误导性的死分支。

按 Q1-B 收敛:类型体系与真实视觉对齐,只保留 success/error/info。

- useToast.ts: ToastType 去掉 'warning'(LogLevel 的 'warn' 是日志面板级别,
  文字仍为 amber 且可过滤,属另一套语义,未改动)
- Toast.vue: 移除 getToastClass / getIcon 里两个重复的 warning 分支
- 7 处调用改为 'info':工作区缺失提示、日志为空、镜像拉取全部/部分失败、
  镜像源必填校验 ×2、恢复部分失败
- EnvConfigPage.applyFlow.spec.ts: 断言从 `c[1] === 'warning'` 改为 'info',
  并把意图改写为「部分失败必须给出提示」——断言的是行为,不是类型名

影响:这 7 条文案现在显示为蓝色 ℹ 图标的信息提示(此前是蓝色 ⚠)。
需要强调的严重程度由文案本身承载(如「恢复部分失败」会带明细),
不再依赖颜色。

验证:vue-tsc 0 错,vitest 265/265。
审查报告第二轮的处理项(Q1/Q2/Q3 的配色语义决策已在上一提交完成,
Q2 ConfirmDialog warning 与 Q3 预览区配色按决策维持现状,本提交不含)。

1. ui-btn-dark → ui-btn-emphasis
   该 token 在 dark 模式下是浅底深字(slate-100 底 + slate-900 字),
   名字里的 "dark" 会被读成主题模式名,与实际观感相反。
   使用点 3 处均为并列操作里的强调性次级按钮(App.vue 665/682/949)。

2. ui-btn-danger 补 dark:bg-rose-600
   此前只写了 dark:hover,dark 默认态未指定;与其它 token 的规格
   (基础态与 light 同色、hover 更亮一档)不一致。渲染上 rose-600 在深色
   背景仍可见,故这是纯一致性修复,非可用性问题。

3. EXEMPT_LOOKAHEAD_LINES 注释补充
   说明超过 10 行会静默失效,以及正确做法是把标记写在文案所在行
   (而不是调大数字)。

4. findBrokenGlyphs 补 2 条边界测试
   核实报告第 6 条时发现其方向说反了:实测 👐🏽 + FE0F 的前一码点是肤色
   修饰符 U+1F3FD,\p{Emoji} 为真 → 不报(漏报方向,非误报);只有前一位
   是 ZWJ(U+200D)时才会报。当前行为是刻意的「宁漏报不误报」,
   故不改逻辑,只把行为钉进测试。

验证:vue-tsc 0 错、vite build 通过、vitest 267/267、check:i18n 通过。
原应用图标是满幅正方形(直角顶边),在任务栏/磁贴/ dock 等非裁切场景
观感生硬。改为圆角矩形风格:

- 圆角比例:内容边长的 22%(接近 iOS 连续圆角的观感)
- 边距处理:内容占画布 82%,四周各留 9% 透明边距 —— 在系统自行裁切的
  平台(macOS dock 遮罩)与不裁切的场景(Windows 任务栏、EXE 文件图标)
  下均能完整显示圆角,各尺寸形态一致
- 背景:透明,仅圆角矩形本身承载底色 #2563EB(blue-600,与 v0.3 UI
  统一后的主色一致)

实现:以 git 原始 icon.png(512) 为唯一源,圆角蒙版合成 512 master 后
LANCZOS 派生全部尺寸。两个关键细节:
1. 透明区 RGB 预填底色(alpha=0),避免缩小到 32px 级别时透明区黑色
   RGB 参与 LANCZOS 插值产生黑色毛边
2. 生成脚本从 git 对象库读源,避免上一轮产物覆盖源文件造成边缘黑环

替换清单(17 个二进制 + 1 个 SVG):
- PNG:32x32 / 64x64 / 128x128 / 128x128@2x / icon.png(512)
- Windows 磁贴:Square30/44/71/89/107/142/150/284/310 Logo + StoreLogo(50)
- icon.ico(16-256 七档)、icon.icns(16-512 六档)
- public/favicon.svg:原为 Tauri 默认紫色闪电设计,与应用图标完全不符,
  重写为同款圆角 PS(rect rx=22%),dev 窗口标签页与桌面图标统一
容器启停接口返回与容器状态刷新之间存在时间差,此前按钮只有文案切换
("启动中...")没有视觉指示器,点击后按钮静止不动,用户容易误以为
点击无效而反复点击。

三个按钮(启动/重启/停止)在对应操作进行中时(operationType 匹配),
静态图标替换为 animate-spin 加载圈,与文案切换同步出现/消失。

生命周期无需改动,现有逻辑已满足要求:starting 在确认后立即置位,
三个操作均 await 接口 → 等待 Docker API 状态更新 → await
refreshContainers() 后才在 finally 复位;loading 期间三个按钮互相
禁用(disabled 含 starting),不会重复触发。

验证:vue-tsc 0 错,vitest 267/267。
按需求移除各页面多余的 emoji 图标。判定标准:彩色 emoji 呈现的装饰性
前缀(🚀 ✅ ❌ ⚠️ 💡 📦 等)一律清除;文本符号 ✓(恢复向导步骤完成态、
Toast 成功图标)、✕(关闭按钮)、→(步骤引导箭头)保留 —— 它们是
单色文本渲染的功能符号,不属 emoji 图标。

模板层(9 处):
- App.vue:日志面板 复制/清空/导出 按钮的 📋 🗑️ 💾 前缀
- ImagePullConfirmModal:标题 📦、摘要 ⚠️/✅、提示 💡
- VersionHelpModal:步骤标题 ⚠️、提示 💡
- MigrationPage:标签页 💾 ⬇️ icon 字段及渲染(按钮改纯文本)
- SettingsPage:标签页 🌐 🔧 icon 字段及渲染
- MirrorPanel:分类覆盖标记 ✏️ → 纯文本 "(已覆盖)"/"(edited)",
  新增 i18n key mirror.overriddenBadge 并附 title 提示,
  语义信息不因去 emoji 而丢失

语言包(zh/en 各 72 处前缀 + 3 处孤立变体选择符):
- 仅清除字符串值开头或换行行首的 emoji 前缀及紧随空格
- 保留两类句中 emoji:zh/en 各 1 处 ⚙️(指代 Docker Desktop 界面
  中的齿轮图标,删了反而误导);✓("预览完成" 等文本符号)

按需求保留:App.vue 侧边栏 5 个 emoji(🏠 🛠️ ⚙️ 📦 ℹ️)、
SettingsPage 主题模式 3 个 emoji(💻 ☀️ 🌙)。

验证:vue-tsc 0 错、vite build 通过、vitest 267/267、check:i18n 通过
(zh/en 各 505 key,中英一致,不可见字符检查通过)。
生产包未启用 devtools(Cargo.toml 里 tauri features 为空),安装后一旦前端
没跑起来,用户看到的是纯白窗口,日志里只有一行 `PHP-Stack started`——
既无 Console 也无任何 invoke 记录,无从下手。

三件事:捕获 → 上报 → 兜底渲染。

1. public/boot-guard.js(新增)
   - 捕获 window.onerror / unhandledrejection / console.error(生产无
     devtools,console 是唯一能看到 Vue 错误的通道)
   - **捕获资源加载失败**:script/link 404 不抛 JS 异常,只在元素上触发
     error 事件且必须走捕获阶段。白屏首因正是 assets 没加载,漏了这条
     兜底就形同虚设
   - 挂载宽限 600ms 后 #app 仍为空 → 直接渲染错误面板(DOM API 构建,
     不拼 innerHTML),并列出日志文件路径(Windows / macOS 双路径,
     bundle 目标是 all)
   - 已挂载或调用过 markMounted 则只上报、不覆盖界面
   - 上报走 window.__TAURI_INTERNALS__.invoke:不依赖 @tauri-apps/api,
     因为加载失败的可能正是它;invoke 失败一律静默
   - 必须是外部文件而非内联:CSP 未声明 script-src,回退 default-src
     'self',内联脚本会被拦。public/ 下的文件原样拷进 dist,同源加载

2. src-tauri/src/commands/app.rs:新增 log_frontend_error
   - 字段全部可选:启动失败时能拿到的信息本就不完整,缺字段不应丢整条
   - 按**字符**截断(非字节)——按字节切片会在多字节字符中间断开并 panic,
     而错误文案里出现中文是常态
   - 去重与条数上限在前端(同错误会同时触发 onerror 和 console.error)

3. src/main.ts:Vue errorHandler 上报并 markMounted
   Vue 内部渲染/生命周期错误不触发 window.onerror,只有 errorHandler 能拿到

测试:boot-guard 是普通脚本无法 import,用 node:vm 在自建 context 执行后
断言(17 条,含资源加载失败、载荷形状、去重、上限、面板渲染时机);
Rust 侧 6 条(含多字节截断不 panic)。测试不能放 public/ —— 会被一起
拷进 dist。

验证:vue-tsc 0 错、vite build 通过(dist 含 boot-guard.js 且无测试目录)、
vitest 284/284、cargo test 6/6、cargo fmt --check 与 clippy -D warnings 通过、
check:i18n 通过。
根因(见 %APPDATA%\com.php-stack.dev\php-stack.log):
  EvalError: Evaluating a string as JavaScript violates CSP because
  'unsafe-eval' is not an allowed source of script: script-src 'self' 'sha256-...'

vue-i18n 9.14 在运行时用 new Function 编译消息,而 CSP 未声明 script-src,
回退 default-src 'self' 后不含 unsafe-eval —— 首条消息编译即抛 EvalError,
Vue 渲染失败,#app 为空 → 白屏。

不是 default-src 'self' 拦了脚本:脚本本身加载正常(日志里的堆栈来自
http://tauri.localhost/assets/index-*.js,说明 bundle 执行到了渲染阶段)。
拦的是 eval。

显式声明 script-src 而不是继续依赖 default-src 回退:Tauri 会展开用户 CSP
并注入自身脚本的 sha256,实际生效的指令与配置文本并不一致,显式写出便于
后续核对。

代价:放宽了 CSP。当前所有资源都是编译期嵌入的本地资源,没有远程脚本来源,
风险可控。若要恢复严格 CSP,需引入 @intlify/unplugin-vue-i18n 做消息预编译
(构建期生成 AST,运行时不再 eval)——改动涉及新依赖 + vite 配置 + i18n 入口,
作为独立优化项另议。
上一提交加的兜底其实生效了(日志里有 15 条 [frontend] 上报),但面板没显示
—— 被 main.ts 自己关掉了。

Vue 的渲染错误会被 app.config.errorHandler 吞掉,mount() 仍正常返回,此时
#app 是空的。而 markMounted() 是无条件调用的,一旦置位,boot-guard 的
isMounted() 恒为 true,兜底面板永不渲染。结果就是:错误照常写进日志,界面
仍然纯白,用户看不到任何提示。

改为按实际情况判定:#app 有子节点才认为挂载成功。渲染失败时保留兜底,
600ms 后由 boot-guard 把错误直接渲染到界面。

验证:vue-tsc 0 错、vite build 通过、vitest 284/284。
用户实测反馈两个问题,都在容器卡片的单容器启停路径上 —— 上一轮的需求 2
只覆盖了顶部「一键启动/重启/停止」,漏了卡片按钮:

1. 卡片「启动/停止」按钮无任何 busy 状态:连点会并发触发多次
   startContainer/stopContainer;操作进行中也没有指示器,容器状态刷新
   前界面毫无变化,用户会以为点击无效。

2. 失败(如端口被占用,docker 报 port is already allocated)只写实时日志
   面板 —— 对不主动展开日志的用户等于没有反馈。

修复:

- busyContainers(容器名 -> 'start' | 'stop'):支持多容器并行操作互不
  干扰;按钮 disabled + animate-spin 指示器;finally 前先 await
  refreshContainers(true),确保「接口返回且容器状态真实更新」后才恢复
  按钮状态(与需求原文的验收口径一致)
- 失败时在保留日志记录的同时弹 error toast(6 秒),文案复用现有
  dashboard.toast.serviceStartFailed / serviceStopFailed,零新增 key
- 顺带补上一轮遗漏的一键启动按钮 spinner(restart/stop 已有,start
  当时的编辑实际未生效,静态图标一直在)

测试(App.spec.ts +2):
- 启动失败弹出含原始错误(port is already allocated)的 error toast
- 操作进行中按钮 disabled,完成并刷新状态后恢复
按钮定位按文本匹配中英文(locale 跟随 jsdom 的 navigator.language)。

验证:vue-tsc 0 错、vite build 通过、vitest 286/286、check:i18n 通过。
CI 失败(runs/35681593266,ubuntu-22.04):
  engine::restore_engine::tests::test_is_safe_entry_name_rejects_traversal
  assertion failed: !is_safe_entry_name("C:\Windows\evil.txt")

原实现用 std::path 的平台语义做 zip-slip 防护,而备份包是跨平台流转的:
  Windows: "C:\Windows\evil.txt" -> Prefix(Disk)   -> 拒绝(本地测试通过)
  Linux:   同上                   -> Normal("C:\...") -> 放行(CI 断言失败)
即同一份恶意备份包的安全边界随解压机器漂移,等于防护只在部分平台生效。
测试断言的意图是对的,错的是实现依赖了平台。

改为按 ZIP 规范(APPNOTE 4.4.17:条目名以 `/` 分隔,不含盘符与反斜杠)
自行判定,纯字符串处理,三平台一致:
- 拒绝反斜杠(C:\...、\server\share\...)
- 拒绝 Windows 盘符前缀(C:/...、C:evil.txt)
- 拒绝绝对路径与 UNC(/etc/passwd、//server/share)
- 拒绝父目录引用(../)

冒号只在开头按盘符判定,不整体禁止:Unix 文件名允许含冒号
(report:v2.txt),一刀切会让合法备份无法恢复 —— 已加断言钉住,避免
日后为"更安全"而误伤。

测试 +3:
- test_is_safe_entry_name_is_platform_independent(本次回归主测)
- test_is_safe_entry_name_accepts_zip_conventional_names(防误伤)
- 原有两处断言保留

验证:cargo test 137 passed / 0 failed、cargo fmt --check 通过、
cargo clippy --all-targets -- -D warnings 通过。
CI 日志警告:
  Node.js 20 is deprecated. The following actions target Node.js 20 but are
  being forced to run on Node.js 24: actions/checkout@v4, actions/setup-node@v4

目前只是警告不影响构建,但 runner 已强制用 Node 24 执行,后续会转错误。
两个 workflow 各 2 处,共 4 处 v4 -> v5。

兼容性已核对:
- 两个 workflow 的 setup-node 均用明确版本号 `node-version: 22` 与 `cache: npm`,
  v5 保留这两项输入,且仓库有 package-lock.json,cache 能命中
- checkout 未使用子模块等 v5 变更项
- 其余 action(dtolnay/rust-toolchain、swatinem/rust-cache、tauri-action)
  不在本次弃用范围内,未动

与上一提交(restore 的 zip-slip 判定修复)分开提交,避免 CI 健康度调整
掩盖真正的缺陷修复。
@jeeinn
jeeinn merged commit fbb8cc2 into master Sep 23, 2026
1 check passed
@jeeinn
jeeinn deleted the feat/ui-color-unification branch September 23, 2026 05:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant