|
|
b4610f1539
|
fix(安全)★: index.html 加 CSP —— 渲染层的最后一道防线(实测过不误伤)
**为什么现在没有 XSS**:全库 `dangerouslySetInnerHTML` / `innerHTML` /
`insertAdjacentHTML` 命中 **0**,`react-markdown` 无 `rehype-raw`,
`test/markdown-xss.test.mjs` 钉着。⇒ 这条 CSP **不是**为了堵今天的洞,
而是防「有人加一行 `dangerouslySetInnerHTML` 之后那道防线消失」。
★ 而 `sandbox: true` + `contextIsolation: true` **替不了这一层**:
那两条只挡 node 与跨上下文,**不挡渲染层注入**。
## ★★ 不是照抄模板 —— 每一档都按仓库真实用法定的
· `script-src` **必须**含 `'unsafe-inline'`:上方那段首帧防闪屏脚本要求
**同步**执行(早于任何外链,见 index.html 头部注释),无法外链化。
⇒ 因此 CSP **挡不住注入型 XSS**,只挡 `javascript:` URL / 外部脚本 / `eval`
(`script-src` 不含 `'unsafe-eval'`)。★ 这一点已写进 meta 上方的注释 ——
否则下一个人会以为这是严格 CSP。要去掉就得外链化那段脚本,而那会**引入闪屏**。
· `style-src` 同理 + `img-src`/`font-src` 含 `data:`:
`backgroundStore.ts:252-262` 用 `root.style.setProperty('--bg-image', url("data:image/…"))`
⇒ 背景图就是 data URL。
· `connect-src` **只能是 `'self'` + 宽松一档**:网关地址**用户可填**
(`AccountList` 的 placeholder 就是「Gateway 地址,如 http://192.168.2.60:8180」),
可以是**任意 host:port** ⇒ 这里没法写成 allow-list。
真正的防线在渲染层与主进程(导航拦截),不在这一行。
· `object-src 'none'` / `base-uri 'self'` / `frame-ancestors 'none'` / `form-action 'self'`
是纯收紧,**零兼容代价**。
## ★ 真的用 chromium 实测过它不误伤(不是推理)
无头加载一份带同款 CSP 的页面,结果写进 DOM:
```
id="R"> pending | inline-ran | cssom-ran | eval-BLOCKED
```
⇒ 内联脚本跑(防闪屏有效)、CSSOM 设 data: 背景有效、`eval` 被拦。
且构建产物 `dist/index.html` 里 CSP 确实在位(`npm run build` 后 grep 得到)。
## 判据(markdown-xss 9 → 19,已接线)
新增 10 格。变异测试 5 个全部抓住:删整条 CSP / 去掉 `object-src 'none'` /
去掉 `frame-ancestors` / 去掉 `img-src` 的 `data:` / 加 `'unsafe-eval'`。
★ 顺带把「防闪屏脚本必须是单引号 `classList.add('dark')`」也钉在这里 ——
`index.html` 的注释声称 `test/theme.test.mjs` 在逐字符断言它,
**实测 theme.test.mjs 并不断言引号**(它只切 `.dark {}` 色块)⇒ 那条注释是**过期的**,
历史上被 prettier 改写过一次的事件其实**没有判据在挡**。现已由本条补上。
★ 取 CSP 值时踩了两个坑(都记在判据注释里):`[^>]*` 会在策略里的
`'self'>` 处提前结束;按行匹配会因 `<meta>` 跨行而只取到第一行。
⇒ 判据不匹配标签,只确认 meta 存在再单独取 `content="…"`。
## 边界 / 未做
· CSP 只覆盖**浏览器/Web 侧**;Electron 的 `file://` 加载下 meta CSP 仍生效,
但主进程那道防线是 `will-navigate` / `setWindowOpenHandler`
(`test/main-process-security.test.mjs` 钉着)。
· **没在真机上验过 CSP** —— Electron `loadFile()` 那条路未实测
(只验了 chromium 下的 `file://`)。下次装机时顺手看一眼首帧有没有闪。
|
2026-10-03 21:21:55 +08:00 |
|
|
|
6a8e868d31
|
fix(harmony)★★: MailStore 收/发共用一份快照 + 壁纸 PixelMap 泄漏 —— 两处 HIGH
★★ 前置事实修正:报告写的「本机无 hvigorw / 无 SDK / HarmonyOS 侧没有编译过」
**已不成立** —— `/opt/huawei/command-line-tools/bin/hvigorw` 6.26.2 可用,
`hvigorw assembleHap --no-daemon` 出 **BUILD SUCCESSFUL**(约 26 s)。
⇒ 本次两条 HIGH 都是**真编译过**的,不是静态推断。这是本次最大的认知变化:
「改 ArkTS 无法验证 ⇒ 只能推给下一个人」这个前提可以取消了。
## ① MailStore:收件箱与发件箱共用同一份快照(HIGH)
`loadInbox` 与 `loadSent` 此前**全部往同一个 `snapshot` 写**,而两者是两个独立
`@Component`(`InboxTab`/`SentTab`),各自 `await` 完才 `applyStoreSnapshot(...)`
⇒ 切页签 / SSE 交错时**发件箱那次把收件箱那份覆盖掉**:`unread = 0`、
`mails` 换成发件箱的、`loading` 互相关闭 ⇒ 症状是「收件箱未读被清零 /
列表短暂空白 / 转圈停了但列表是空的」。
★ 为什么此前没人动(旧报告标 CRITICAL 却长期未修):它当时的理由是
「Navigation 分栏下两个 pane 同时在屏」这个**必然场景**;`commTab` 后来改成
同一时刻只 mount 一个 ⇒ 那个"必然"没了 ⇒ 从"每次都坏"降级成"交错时坏"。
**这是判据缺失导致缺陷降级**——本条判据就是为了不让它再降级。
**修法(两份快照 + 代号守卫,各挡一半,都要有)**:
· 新增 `sentSnapshot`,`loadSent`/`paintSentFromCache` 只写它;
`SentTab` 读 `store.sentSnapshot`(收件箱侧零改动)。
· `inboxGen` / `sentGen` 代号:同一栏**自己**的两次 load 也可能乱序到达
(SSE 叫醒一次、切页签又触发一次)⇒ 旧的**后**到会盖掉新的。
⚠️ 守卫**只拦写快照**,`loading = false` 仍要执行 —— 否则新请求的 spinner 被挂住。
· `clear()` 清两份,**并把两个代号都 +1 作废**:否则**正在飞**的旧请求回来时
`myGen` 仍等于旧值 ⇒ 会把清空后的快照重新填上旧数据(登出/切账号正是此时)。
★★ 顺带修一个**我差点引入的回归**:拆开之前两份共用一份 ⇒ 归档一个会话会
同时抹掉两边的它。拆开后若只动收件箱,归档完**发件箱仍显示该会话**(而服务端已删)。
⇒ `dropSession` 拆成 `dropSessionFromInbox` / `dropSessionFromSent`,
**两份都要剔**;且发件箱那份**不能用** `splitByPermission(...).normal`
(发件箱不做权限分流,那一筛会抹掉「我发出的授权请求」——09-20 修过的
「发件箱一片空白」同一族)。
## ② AppearanceStore:壁纸 PixelMap 泄漏 + 全尺寸解码(HIGH)
`loadWallpaper` 此前既不设 `desiredSize`、也从不 `release()`:
· `PixelMap` 是**原生内存**,每次同步/每次切账号重新解码一张,全部不释放;
而它是**静态单例**、活过登出 ⇒ **换账号这条路必然泄漏**(无任何清理入口)。
· 不带 `desiredSize` ⇒ 按原图尺寸解。`BackgroundPicker` 只把上传边长卡在
`MAX_EDGE = 2560` ⇒ 一张 2560×2560 ARGB ≈ **26 MB** 常驻,
而它永远被合成器降采样着全屏画。
**修法**(照 `BackgroundPicker` 既有形状,不另创一套):
· `desiredSize` 取 `display.getDefaultDisplaySync()` 的 width/height,
不写死魔数(随屏变)。★ 它**会抛**(SDK 注释 1400001)⇒ 必须 catch,
取不到就退回"不限制尺寸",而不是把壁纸整条路断掉。
· 所有权:先 `this.wallpaper = next` 再释放**旧的**,并把局部变量置空 ——
★ 若在 `finally` 里释放 `this.wallpaper`,会把**刚装上的那张**释放掉,
这是最容易写反的一处。
· 新增 `releaseWallpaper()`,并在 `Logout.ets` 里与 `MailStore.clear()` 并排调用
—— 后者是该方法**唯一**的调用点,没有它它就只是"写好了没人用"。
## 判据(harmony-arkts 8 → 10;harmony-logic 补一格)
新两条按**括号配对**取方法体(`balanced()`,不用 `\{[\s\S]{0,N}` 窗口)。
**变异测试 5 个全部抓住**:loadSent 写回 snapshot / SentTab 读错快照 /
归档不剔发件箱 / 释放的是新图而非旧的 / 去掉 desiredSize。
★ 顺带修一条**HEAD 上就在红的判据**(不是本次引入):`harmony-logic` 那条
「先分家、后折叠」只认字面量 `groupMailsBySession(split.normal)`,
而源码**本来就是** `const inboxMails: MailLike[] = split.normal;` 后
`groupMailsBySession(inboxMails)` ⇒ 恒红。
⇒ 补上"经中间变量"这一支;变异验证:把 `inboxMails` 换成 `mergedMails`
(权限邮件混进收件箱)仍**照红** ✓ —— 放宽的是写法假设,不是判定。
## 编译期硬规则(ArkTS 不接受对象字面量当类型)
`targetSize()` 第一版返回 `{ width: number, height: number }` ⇒ 编译报
`arkts-no-obj-literals-as-types` / `arkts-no-untyped-obj-literals`(连
**返回的对象字面量**也要能对应到**具名**类型)。⇒ 必须先声明 interface,
且每个字面量先赋给**显式类型的局部变量**再 return。
★ 这条**编译期硬规则**在本仓无判据覆盖(harmony-arkts 判的是 import 位置那一类)
⇒ 只能靠真编译抓;而它能抓,再次证明「ArkTS 本机无法验证」已不成立。
## 边界 / 未做
· **本条判据证明不了运行时症状**:切页签交错那条要设备才能造,本仓无那种探针。
静态层只把住「两栏各写各的」。设备那半**仍未验**。
· `releaseWallpaper` 的**实际内存回收**未在真机验证(memory profiler)。
· 离屏解码(`@Concurrent`/taskpool)**本轮不做** —— 全仓 0 先例,
单独引入会扩大风险面。
|
2026-10-03 12:11:25 +08:00 |
|
|
|
4a01297dfe
|
fix(安全)★★: 主进程零导航拦截 —— 加 will-navigate / 新窗口拒绝 + 单实例锁
**主进程此前没有任何守卫**(实测 grep 0 命中:setWindowOpenHandler、
will-navigate、requestSingleInstanceLock 全无)。
在 `loadFile(dist/index.html)` 下,邮件正文里的链接(react-markdown 会渲染
`<a href>`)点下去会**在应用窗口里导航走** —— 一个 `href="https://…"` 就足以
把整个应用窗口变成浏览器,而窗口标题与 preload 注入的 API base/token
全部暴露在那个站点上。
★ **本条防的不是 XSS**:`markdown-xss` 守的是渲染层(无 rehype-raw、
defaultUrlTransform 中和 javascript:),**目前没有 XSS 面**。
这里防的是**导航逃逸** —— 让外部站点**借用**这个窗口与 preload 上下文。
两者失效方式不同,所以分开钉。
**① setWindowOpenHandler** ⇒ 一律 `deny`,地址交系统浏览器。
`allow` 会给那个站点一个**带 preload 的窗口**。
**② will-navigate** ⇒ 拦下并 preventDefault。
★ 但必须**放行本应用自己的加载**(prod 的 `file://` / dev 的 `DEV_URL`)
—— 只会 preventDefault 的实现会把应用自己锁死,首屏进不去。
这是「过严的守卫同样是缺陷」,判据专门为它加了一格。
**③ 外跳只放行 http/https**:file: / javascript: / 自定义协议交给
`shell.openExternal` 意图不可控;`new URL()` 对畸形输入会抛,必须 catch。
**④ requestSingleInstanceLock**:双击图标此前会起**两个进程** ——
两条 SSE 连接、两套 `accounts.json` 并发写入(那个文件是 tmp+rename 原子写,
并发即「后写的赢」),而用户以为只有一扇窗。
**同时修的两处双提交**(形状与 ④ 同源,都是「busy/state 要到提交后才为真」):
· PermissionPanel.submit:审批是本工程**唯一带副作用且不可撤销**的动作
⇒ 同帧两次激活会发出**两条** decidePermission(服务端记两次账)。
照同文件 ForwardBar 的 `inFlight` 形状改。
· Attachments.handleFiles:`uploading` 只加在按钮的 disabled 上,
`<input type=file>` 本身无闸门 ⇒ 上传期间重入会拿到**上一次的 items 闭包**
⇒ onChange 把上一次结果整批覆盖,表现为「附件少了」且**无任何提示**。
★ 清 `input.value` 必须与闸门**成对**提前:只提前清而不加闸门,
会亲手制造「上传中重选同一文件 ⇒ value 已空 ⇒ change 照触发 ⇒ 二次上传」。
**判据(新建 main-process-security.test.mjs,14 格,已接线)**
按括号配对取函数体/实参,**不用** `\{[\s\S]{0,80}` 窗口(§1 第三次露头)。
变异测试 **10 个全部抓住**:去掉 deny / 去掉 preventDefault / 守卫写死不放行自己 /
放开所有 scheme / 拿不到锁不退出 / sandbox:false / contextIsolation:false /
second-instance 删 show() / 删整个 if / 删 restore()。
★ PermissionPanel 那条新判据第一版是**假绿**:`fireEvent.click` 连发两次
(不在 act 里)**删掉闸门也照样绿** —— 两次 fireEvent 之间 React 提交了一次,
第二次点到的是已 disabled 的按钮。必须放进**同一个 act**(同批次、不提交)
才复现。`userEvent.click` 每次都 await 一轮 ⇒ 它测不到同帧。
这条已写进判据注释,免得下一个人再写一次。
**边界 / 未做**
· 这些是**静态**断言,证明守卫被写下来了,**不证明运行时生效**(那要真起窗口点链接)。
· 没有把 webPreferences 的值当"够不够安全"来评审 —— 那属于安全评审,不属可机检;
本条只钉「不许被放松」。
· X-2(index.html 无 CSP)没做:首帧防闪屏那段内联脚本要求 'unsafe-inline',
加 CSP 是在**降低**强度的前提下加一层,值得单独一轮 + 真机验闪屏,不夹在本次。
|
2026-10-03 11:33:51 +08:00 |
|
|
|
9d40816230
|
docs(判据): ★★ 「重构建」是**两步** —— 只做第一步,红会从 build-stamp 搬到 packaging
**实测(2026-10-03,每步退出码取自不接管道的运行)**:
提交 ⇒ HEAD 前进 ⇒ build-stamp 红(产物记旧 rev)
① npm run build ⇒ build-stamp 绿、**packaging 红**
② npx electron-builder --linux … ⇒ 两条同时绿
★ 只做①的人会以为「修好了」(前一条确实绿了),而红只是**换了个位置**。
**为什么①会让 packaging 转红**:packaging 比的是「asar 里的 dist 文件名」
vs「当前 dist/index.html 引用的文件名」,而 Vite 输出带**内容哈希**
⇒ 重构建一换文件名,asar 立刻过期。两条判据是**同一条链的两环**,
而 package.json 里**没有** beforeBuild 钩子把两者串起来 ⇒ 必须人记得做两次。
**改了三个地方,因为「报错文案」比文档更容易被看到(§14 同一条道理)**:
· build-stamp 的报错文案**自带第二步**。原文只写「正确修法只有一个:重跑构建」——
实测证明那句话**只完成了一半**,而只看文案的人会照做然后以为好了。
已用变异测试确认:把产物 gitRev 改成 0000000 ⇒ 变红且两条步骤都出现,
改回 ⇒ 绿。
· CRITERIA.md 新增 §6.0.6「同一个红会自己搬家时,要把它变成有界的」。
它讲的是**读数的信噪比**:一条结构性长期红与真缺陷**同屏**,
会把整屏红的价值抵消("看到了 ⇒ 当没看见")。
明确**不能**靠加跳过名单解决(那是把它变成看不见),
正解是到期条件可机检 + 报错文案自带下一步。
· DEBTS.json 的 build-stamp-stale-artifact-blocks-verification:
把 due/where/note 补上「两步」的实测事实,并把**第二环 packaging**
补进 where —— 原笔只记了 build-stamp 那一环。
**边界 / 未做**
· 没有把两步串进 npm 脚本或 beforeBuild —— 那是**构建流程**的改动,
而本工作区正被多个会话并发使用;由人决定。
· 这笔债**仍未结算**:它会在**下一次提交**时重现(结构性的,与代码无关)。
|
2026-10-03 11:17:18 +08:00 |
|
|
|
6df3c356d6
|
test(判据): ★★★ 套件从 10-02 10:46 起一条都没跑过 —— 补接线 + 结算登记数
**零读数 24 小时,仓库里没有任何记录**(DEBTS.json 58 条里 grep「没接进」0 命中)。
漏接线的三个(都来自 aeb1f41 / 2f17f62,都比 run-all.mjs 最后一次改动晚):
test/inbox-fallback-poll.test.mjs ← SSE 兜底轮询(探测 total 变化)
test/sse-credentials.test.mjs ← SSE 订阅跟账号凭证走
test/web-comment-only.test.mjs
**为什么严重(三层放大)**:
1. 自检 2「每个 *.test.mjs 都要在清单里」在跑任何判据**之前** exit(1)
⇒ 实测 RESULT 行数 = **0**
2. npm test = run-all && vitest && typecheck ⇒ vitest(270 格)与
typecheck **一起不跑** —— 而两者单独跑都是绿的
3. 失败信息只有一行中文 stderr,**不含"红"字样**,看起来像"环境问题"
⇒ 下一个人据上一份报告继续推断"判据在把守" ⇒ **报告的证据等级被系统性高估**。
守卫本身**不删**(漏接线绝不静默是真价值),代价靠「新增即接线」这条义务兜。
**顺带结算的登记数漂移**(都在 clean HEAD 上就红,非本次引入):
· harmony-window 9→10、appearance-defaults 7→8、harmony-2in1 23→24
("自报条数 > 登记条数"是显式的编辑义务:只判下界时多出来的条数删掉不红)
· harmony-appearance 28→29 —— 配合工作树里别人新增的那条设备判据
· static-criteria 5→4 —— appearance-defaults 已上设备并移出 STATIC_ONLY,
**移出名单时忘了回头改这笔登记**,由 commit-hygiene 的机器镜像抓住
· debt-visibility REGISTERED['harmony-appearance'] 4→6 —— 两处都是
**散文**(一处引用文件既有句子、一处在报错文案里),按该文件既有先例登记
并注明;⚠️ 写那段说明时不能引用词表里的词,否则本文件自己数超(实测 12→14 即红)
**commit-hygiene 基线推进**:7d081095 与 477479a37 两条 `fix(harmony):` 改了
"鸿蒙源码 + 另一侧判据",按本仓口径该标 `跨端:`。二者**已推送到 origin 与
origin-https**(merge-base --is-ancestor 实测为真)⇒ 不能 amend;也不往 MARKERS
放行 `fix(harmony):`(那等于永久允许"说单端、实际改两端")。唯一正确处置是
推进基线 + 具名记下。已验证:COMMIT_HYGIENE_BASELINE 覆盖后 pass=4 fail=0。
**CRITERIA.md 新增 §6.0「怎么读判据的数」** —— 原有 17 节全在讲「怎么写」,
缺的就是这一半,而今天两起独立事件都出在它:
· 6.0.1 接线守卫的失效形状是「全停」不是「那一条不跑」;ran 必须 == SUITE 条数
· 6.0.2 退出码只能来自不接管道的运行(`cmd >file 2>&1; echo $?`)。
**本会话我连踩 5 次** `cmd | tail -N` ⇒ 报的是 tail 的码。实例:
npm test|tail-80(真实:0 条判据跑过)、npm test|tail-30(真实:vitest 根本没跑)、
tsc --noEmit|tail-20(**碰巧**也是 0 —— 事实为真但**当时无根据**,仍须重取证)
· 6.0.3 && 链里「全绿」要问**真跑到那一环了吗**(red 之后的东西根本没跑,
而日志里「有 RESULT 行」与「无下游输出」可以同时出现)
· 6.0.4 判据变红先问「判据用的工具本身可信吗」(读取器的缺陷是**静默**的)
· 6.0.5 **当你就是改工具的人**:第一假设是「我弄坏了它」不是「代码回归」——
「判据过期了」这个反应本身就错,它默认了「我改的是正确的东西」。
附本次三次改错的下游依赖表,以及"下游依赖是**行为依赖**,
codegraph 那类符号图看不见"。
★ 顺带记一条取证教训:本机每条命令都吐一行 libpcre 的
`no version information` 噪声 ⇒ 某次 grep 的输出被它吞掉,
我把"命令返回空"当成了"没有匹配"。**空输出要连退出码一起看**,
这与 6.0.2 是同一族,只是这次发生在我自己的取证上。
|
2026-10-03 10:56:47 +08:00 |
|
|
|
93697c4061
|
test(工具链): ★★★ stripComments 两趟正则把真代码当注释吃掉 137 行 —— 改单趟扫描
**现象**:harmony-admin 那条「服务端要注册 GET /auth/me」报红,而
server/cmd/server/main.go:194 **明明写着** r.Get("/auth/me", handler.Me)。
**真因**(不是服务端写错,是读取器错了):
main.go:78 // 与 /api/v1/agent/* 完全同一份代码
↑ 这个 /* 在 // 里面
stripComments 原来是**两趟正则**(先块 {/\*[\s\S]*?\*\//g}、后行),
两趟**互相看不见对方** ⇒ 块注释那趟在**还没删行注释**的文本上看到那个 /*,
当块注释开头,一路找下一个 */(在 :214)⇒ **137 行 / 37 条路由注册**
被当注释抹掉,含它正在断言的 r.Get("/auth/me", …)。
全仓另有 12 处同样写法(/me/*、/assets/*、plugins/*…)。
⇒ 失效形状是「**读取器静默少给一段真代码**」(不抛错、不警告),
症状却出现在**被测对象**上 —— 看起来像"服务端把路由删了"。
**修法**:单趟字符扫描,且状态只用源码(注释内部不参与字符串状态)。
并**补上正则字面量**这一条 —— 漏认的方向是**假绿**:
harmony-device.mjs:59 的 /"bundleName"\s*:\s*"([^"]+)"/ 有 4 个引号(奇数),
打开的"字符串"永不闭合 ⇒ 后面所有注释被当字符串跳过。
五条性质逐条实测:① 行号不变 ② 'http://…' 字符串不被腰斩
③ 注释里的引号不污染状态 ④ 正则字面量被当正则 ⑤ 块注释连文本一起删。
变异测试:把旧实现放回去 ⇒ harmony-admin 确实变红(确认真修好了,
而不是"改的东西恰好没人用")。
**顺带修的三处判据自身缺陷**(都不是源码问题):
· inbox-fallback-poll:原断言钉的是**行尾注释里的字**
(删掉注释照样绿、塞进 await fetchInbox() 也照样绿)⇒ 改为取
if (lastTotal === null) { … } 整个分支做结构断言
· inbox-fallback-poll / sse-credentials:裸 readFileSync ⇒ 具名 code()
(换成更严格的读取后变红,暴露的是判据本来就在判错的对象)
· appearance-defaults:写死包名 ⇒ ourBundle()(deviceprobe 那条在盯这个)
· criteria-hygiene:自检样本「以 // 开头的字面量」被判成写死路径 ⇒
按**形状**排除,**不按文件/变量名豁免**(该文件自己的注释已写过
「豁免按名字或目录裁 = 给逃逸指路」)。变异验证:改成真路径仍红。
边界 / 未做:本函数**不区分模板串里的 ${…} 与字符类里的 /**,
失效方向是假绿(少剥注释),与 stripStrings 记的方向一致。
|
2026-10-03 10:43:11 +08:00 |
|
|
|
2f17f62871
|
feat(deploy): 前端源码更新「仅为注释」时不再卡住部署
## 起因(实测,同一天两次)
`redeploy-gateway.sh` 用 mtime 比对源码与 dist:
newer=$(find src -type f -newer dist/index.html)
[ -n "$newer" ] && exit 1
mtime 只说「这个文件被碰过」,不说「它变了什么」。于是共享工作树里**任何人
改一行注释就会拦下部署** —— 那行注释不进 bundle,重建产物与现有 dist
逐字节相同。
2026-10-02 实测被拦两次,都是 `client/electron/src/api/client.ts` 的线程树
说明注释(另一会话的工作树在制品,未提交),每次多花一轮 `npm run build`。
## 为什么不是拆掉那道闸
2026-09-14 踩过它的来历:改了 `src/lib/appearance.ts` 的请求路径却没跑
vite build,部署照样「同步成功」,出去的还是旧 bundle —— 表现为接口 404
(`/api/v1/api/v1/…` 双前缀),而**所有单测都是绿的**。那种失败是沉默的,
所以闸不能拆。
本改动是在闸**前面**加一层精化:先问「差异是否只是注释」,是则放行并说明,
否则维持拦截。拿不准时一律偏向拦截:
误拦的代价 = 多构建一次(几十秒)
误放的代价 = 把旧界面打进二进制(接口 404,且没人立刻归因)
## 判定口径(deploy/web-comment-only.mjs)
对每个「比 dist 新」的文件取它相对 **HEAD** 的 diff,去掉 `---`/`+++` 头、
diff 元信息与整行注释后若还剩内容 ⇒ 真改动 ⇒ 拦截。
- **只看未提交差异**(`git diff HEAD --`)。已提交改动早于本次部署决策。
- **未跟踪的新文件**按真改动处理 —— 判不出就别放行。
- 滤的是「整行都是注释」的行;行尾注释(`code(); // 注释`)算真改动(保守)。
- 块注释中间行(` * …`)与结尾也算注释。
## 判据(7 格)
`client/electron/test/web-comment-only.test.mjs`。判据本身必须能区分
「仅注释」与「真代码」,所以每格都给**两侧**对照。其中「只有注释差异」
那格用的 diff **逐字取自当天实测的 git diff**。
**变异验证**:
去掉注释分支(所有行算真改动) → 红 7(部署会被那一行注释继续拦)
isCodeChange 恒 false → 红 7(把 2026-09-14 的静默失败放回来)
第二条是关键:它证明这套判据不会为了「少拦一次」而牺牲那道沉默失败的闸。
## 真实场景验证(不是只跑单测)
把 dist/index.html 时间戳改早 → 5 个源文件「比 dist 新」
⇒ commentOnly=true,理由写明「差异仅为注释」
临时往 sse.ts 追加一行真代码
⇒ commentOnly=false,理由点名那行 `+const __probe = 1;`
bash deploy/redeploy-gateway.sh --dry-run
⇒ [WARN] 前端源码被更新,但差异**仅为注释** ⇒ 不重建
## 顺带
`find … | head -20`(原 head -3):文件多时不至于只看到前 3 个就下结论。
|
2026-10-02 13:58:42 +08:00 |
|
|
|
aeb1f4116b
|
fix(WebUI): SSE 订阅跟着账号凭证走 + 断线重放 + 兜底轮询
用户报:**页面停留不动,新邮件不自动同步**(手动刷新能看到)。
## 根因一(主因):SSE 连接不跟着账号走
`App.tsx` 的 effect 依赖是 `[phase]`,而切号(`accountStore.setActive`)
只换 `api/config` 的 base/token、**不改 phase** ⇒ SSE 连接仍绑旧账号的凭证:
旧账号的新邮件照收,新账号的一封都不推。而 `fetchInbox` 走**新**凭证 ⇒ 数据是新的。
⇒ 表现正是「不自动同步,但手动刷新能看到」。
修法:effect 依赖加上「当前凭证身份」(base + token)。
不在切号处显式重建订阅 —— 那要改所有调用点、漏一处就不刷新;
凭证变化的**唯一发生地**是 api/config,从那里取身份更可靠。
★ 身份**不含 user**:同一账号重新登录 token 变了,那个账号的邮件仍该收
(服务端按 user/agent 绑通道,见 sse.bufferKey);
按 base+token 判只会让「同账号换令牌」多触发一次重连(无害)。
## 根因二:断线重连不重放
服务端一直支持按 Last-Event-ID 回放(ring.replay,500 条缓冲),
EventSource 断线后**本来会自己重连并带该头**。但这里的 onerror 主动
`close(false)` 再 `open()` —— **换了 EventSource 对象**,
而 Last-Event-ID 是浏览器为**那个对象**记的 ⇒ 服务端拿不到 ⇒ 不回放。
EventSource 不能设请求头 ⇒ 游标只能进 query,服务端相应要读
`?lastEventId=`(**两侧都要改,缺一半都不生效且没有任何东西会红**)。
服务端写成 query 优先、header 兜底 —— header 保留给 Agent 侧(curl/SDK)。
⚠ query 会进访问日志;游标是自增数字(不是令牌),与「令牌不进日志」的约定不同级。
`onerror` 区分两种重连:
- 断线(凭证没变)⇒ 带游标,服务端回放断线期间的事件
- 切号(凭证变了)⇒ **必须不带** —— 拿旧账号的 id 去问新账号会搅乱事件流
## 根因三:连接静默但不再收数据
SSE 只在**真的断开**时触发 onerror。有一类故障它看不见:
连接还在、TCP 没断、却不再收数据(代理静默丢包 / NAT 超时 /
中间设备挂死长连接)。两端都认为正常 ⇒ 不重连 ⇒ 页面停留就再也不同步。
补 `lib/inboxFallbackPoll.ts` 作为冗余通道:
- 探针 `getInbox('all', 1)` **只要 total**(全量重拉会让接口与渲染无谓抖动)
- 首轮只建基线不触发;探针失败**不重置基线**(否则一次抖动会变成「下一轮假装有变化」)
- inFlight 去重,慢网络下不叠请求
- 页面隐藏时暂停,恢复可见**立刻探一次**(用户往往正是「切回来发现没更新」才报的)
- 切号时 resetPollBaseline:新账号 total 与旧账号无关,不丢会白拉一次
- 间隔 30s:远大于 SSE 的秒级延迟(正常时纯冗余),又短到挂死最多 30s 被发现
## 判据
12 格(sse-credentials 5 + inbox-fallback-poll 7)。九个变异全部经得起:
依赖退回 [phase] / 重连不带游标 / 切号也带旧游标 / 服务端不读 query /
catch 重置基线 / cleanup 漏停轮询 / 凭证依赖丢失 / 探针拉全量 / 恢复可见不立即探。
★ 一处判据自身缺陷被变异抓出来并修掉:第 3 格原先只查
`url += \`${sep}lastEventId=…\`` 这行**文本存在**,把 `if (lastEventId)`
改成 `if (false)` 后照样绿 —— 正则匹配文本,缺陷在控制流。
补了条件本身的断言才红。与「catch 里不得重置基线」是同一类教训。
|
2026-10-02 10:46:45 +08:00 |
|
|
|
b2a0a42c91
|
docs(harmony): ★★ 推送根因定位到"**签错了证书**",不是"debug 证书不被接受"
平板复测抓到一条上一轮漏掉的华为侧日志,它把根因从推断变成实测:
E cloudinterfaceauth/AuthService: cert finger empty, clientId: 2039846327155747840
I PushService: push token 取不到:code=1000900010 Illegal application identity
★ `cert finger empty` = 设备报上来的是**空指纹**,不是"指纹对不上"。
与 `bm dump` 完全吻合:appSignType=none、signatureKey=""。
⇒ 包里根本没有应用签名身份。
三个指纹实测对比(本机 openssl 算出):
build-profile.json5 的 certpath → DF:21:A3:C0:…:DF:3A:37 CN=Huawei CBG Root CA G2
.p7b 内嵌 development-cert → FD:89:AC:53:…:FC:9D:09 CN=靳睿(…)\,Development
⇒ 工程签的是 **CA 根证书**,profile 绑的是**开发者应用证书**,两者根本不是同一张。
profile 侧其余要素已逐项排除为无关:bundle-name 对、type=debug、validity 未过期、
平板 UDID(bm get -u)逐字出现在 device-ids 里。
★★ 顺带记下走不通的那条路,免得下次重走:
把 certpath 改成 profile 内嵌的单张 dev cert → hvigor 报
`11013004 Profile cert must a cert chain`。
**certpath 要的是一条链**,而那张 dev cert 的签发者
`Huawei CBG Developer Relations CA G2` 本机没有(5 个 p7b 里都没有,
material/ 里也没有任何证书)。
⇒ 给用户要材料时要说准:要**能构成链的整套**,单张 .cer 装不上;
最省事是 DevEco 里 Project Structure → Signing Configs 直接同步签名。
服务端侧本轮复验仍正常:与 hms.go 同形(不带 scope)请求华为换到
access_token,长度 104、有效期 3600s ⇒ 断点确实只在设备侧签名。
build-profile.json5 的 certpath 保持原值并加了注释:改成单张 dev cert 会编译不过,
留着编不过的值只会连应用都装不上。
|
2026-10-02 00:32:21 +08:00 |
|
|
|
62b7c94c6e
|
test(harmony): ★★ appearance-defaults 上设备 ⇒ 落盘键真的带账号段,并结算出 STATIC_ONLY
到期前提(设备可用)已成立 ⇒ 这条欠账必须当场还,而不是继续躺在
STATIC_ONLY 的点名范围里(与 harmony-admin 那次同一处置)。
新增设备判据(test 8):
拉起应用 → find 定位真实的 agentmail_appearance 落盘位置(不写死路径:
haps 层级会变,两处都见过)→ cat 出 XML → 从**落盘的键**里取账号段,
断言非空且像个 id。
★ 为什么不从 /me 拿账号再比对键:那样掺进了"当前登录的是谁",
验的就不是"键里有账号"了。键本身是唯一可靠的观察点。
红绿已在真机(MRDI-W00)上验过,且验的是**该补的洞**:
把设备上的键改成 key="appearance." ⇒ 本条红("账号段是空的 ⇒
全局键退化,换账号会串味"),而原有的静态判据 2 **仍然绿**
—— 正好证明静态层看不见这个形状,这层是必需的。
★★ 顺带修一个**真假红**(本条自己撞到的):平板息屏后再跑时,
launchOurApp 返回 false,原来那句硬 assert 直接红 —— 而设备锁着
跟外观缓存毫无关系,且只有人能解(10106102,developer mode
不能自动解锁)。改为走"忙"那条路:K 轮内礼貌跳过、连续超 K 轮
仍变红(并说清"先分清是锁屏还是真坏了"),不许无界礼貌。
判据去红一个与自己无关、且无人能自动修复的环境状态,会把到期闸的
名声搞坏——真出事时没人当真。
结算:移出 STATIC_ONLY,登记进 SETTLED_FROM_STATIC(闸 ii 要出示行为层
绿跑记录 .tmp/harmony-behavioral-ran.json,本机已留下 appearance-cache-key-on-device)。
真机实测:# pass 8 # fail 0
|
2026-10-02 00:14:01 +08:00 |
|
|
|
7d08109583
|
fix(harmony): ★★ 顶部文案用 windowDecor 判 2in1 ⇒ 平板上浮在系统状态栏上(真机实测)
用户:「不是没显示,而是顶部与三键和状态栏重合,我觉得只应该在 2in1」。
## 根因:一个**假信号**
顶部那行文案(一言 + 签名,`TopbarStore`)的渲染条件写的是
if (this.topbarTexts().length > 0 && this.windowInsets.windowDecor > 0)
本意"有装饰带才显示",在模拟器(真 2in1)上一直成立。**真机平板把它证伪了**:
实测 insets: statusBar=38.588235 navIndicator=27.764706 windowDecor=37
param get const.product.devicetype = tablet
`getWindowDecorHeight()` 在平板上**照样返回 37vp** —— 官方给的是"全屏悬浮态
固定 37vp"这个下限值,它衡量的是"系统浮层厚度",**不是"有没有三键区"**。
平板没有三键区,但 windowDecor 恒为 37 ⇒ 条件成立 ⇒ 文案渲染出来,
位置又按"三键在右边"算(`.height(this.windowInsets.windowDecor)`),
于是整行浮在**系统状态栏**上,与时钟/电量重叠。
## 改法
条件换成形态真值:
if (this.topbarTexts().length > 0 && deviceInfo.deviceType === '2in1')
`deviceInfo` 取自 `@kit.BasicServicesKit`,与 `EntryAbility.ets:310` 判 2in1
用的是同一个来源 —— 形态判据全仓只此一处口径,不让两处各判一套。
`.height(this.windowInsets.windowDecor)` 保留不动:2in1 上它是对的,
非 2in1 上整块不渲染、根本走不到(`harmony-2in1` 那条仍钉着它)。
## 判据
`harmony-2in1.test.mjs` 新增一条:顶部文案必须挂在 `deviceType === '2in1'` 上,
且**不许再出现 `windowInsets.windowDecor` 与 0 的比较**(那是假信号)。
红绿已验:退回 `windowDecor > 0` ⇒ 红;恢复 ⇒ 绿。
★ 这条判据第一版也犯了同类错:用 `prose`(含注释)会把**我自己写进注释里的**
旧写法 `windowDecor > 0` 判红。改用 `lib/read.mjs` 的 `code()`(读时剥注释)。
与上一条(`harmony-window` 判据 10)同形——同一天犯两次,已成惯例性陷阱。
## 环境
`devecocli` 的 npm 包在本会话中途被卸载(`/usr/bin/devecocli` 成断链),
hvigor 直跑缺它注入的 SDK 环境 ⇒ 三个 SDK 路径都报 "SDK component missing"。
已 `npm i -g @deveco/deveco-cli` 装回(6s,252 包),`devecocli build` 恢复可用。
## 真机验证
装 `entry-default-signed.hap`(26.0.0 Beta2,debug 签名)后截图:
顶部只剩系统状态栏(18:34 / 浏览器 / 信号 / 电量),其下是干净的壁纸带,
再下方才是页签条「收件箱 20 / 发件箱 / 授权1」—— 重叠消失。
## 回归
`run-all.mjs`:files=35 checks=584 pass=580 fail=2 skip=2 red=4。
与本改动前的基线(checks=583 fail=2 red=3)比,**fail/red 未增加**:
那 2 个 fail 是设备判据并行争用(单独跑全绿),3 个 red 是「静态判据到期」
的既有债务(5 个文件在真机出现后被判到期,本轮未处理)。
|
2026-10-01 20:37:19 +08:00 |
|
|
|
477479a370
|
fix(harmony): ★★ 三页 AppHeader 顶栏避让硬编码 0 ⇒ 顶栏压进系统状态栏(真机实测)
设备:HUAWEI MatePad Pro(MRDI-W00),HarmonyOS NEXT,API 26,
`hdc tconn 192.168.2.87:43679`(此前一直无真机,本条挂了 5 天)。
## 症状(用户报:「左上角全屏状态下不应该显示全屏,会与顶部系统顶栏冲突」)
截图像看是状态栏压住顶栏。**实测证伪了这个读法**:像素扫描 + `uitest dumpLayout`
显示顶栏文字在 y=134..166、系统状态栏止于 y=82,**两者不重叠**。
真正被切掉的是**列表第一封邮件的标题**(y=294..319,只剩一条细线)。
⇒ 两处独立问题,第二个(窗格头 y=222..320 与列表首行 y=294..319 重叠)
**不是顶栏避让**造成的,本次未修,见下。
## 已修:AppHeader 那一半的避让确实是坏的
`EntryAbility` 早就把避让读到了(真机日志 `insets: statusBar=38.588235
navIndicator=27.764706 windowDecor=37`),`CommPage`/`MainPage` 内容层也用了
(`top: max(statusBar, windowDecor) + paneGap`)。**但 `AppHeader` 是另一个消费者,
三处都写死了 `topInsetPx: 0`**:
· `SentTab`(发件箱) MainPage.ets:1658
· `SettingsPage`(我的) SettingsPage.ets:687
· `PermissionTab`(授权) PermissionTab.ets:487
只有 `AdminUsersPage` 是对的(`topInset(this.windowInsets)`)—— 抄它。
## 为什么已有的判据没抓到(这才是关键)
`harmony-window.test.mjs` 接线⑤「每一个 @Entry 页都要消费避让」是**绿的**,
因为它的 `consumes()` 认两种形状,其中一种只要**文件里出现过**
`this.windowInsets.statusBar` 就算过 —— 而 `MainPage` 的**内容层**正好读了它。
⇒ 页面上有**两个**避让消费者,判据只问"页里有没有出现过那个字段",
于是 `AppHeader` 里那个 0 被完全放过。
新判据(判据 10)改成**逐个消费者问**:任何传给 `AppHeader` 的 `topInsetPx`
不得是字面量 0。自检要求扫到 ≥4 处,防"遍历写错 ⇒ 永远绿"。
★ 这条判据自己先犯过一次同类错并当场被抓:第一版用 `prose()`(含注释),
把**我自己写进注释里的**「原先 topInsetPx: 0」抓成了红 ——
判据在判自己的注释。改用本文件已有的 `stripped()`(其注释原话:
「判据的锚不能落在被守对象的自述上」)。红绿已验:把 SettingsPage 退回
缺陷版 ⇒ 红;恢复 ⇒ 绿。
## 编译期抓到的一个坑
`MainPage.ets:1658` 的 AppHeader 在 **`SentTab`** 里,而 `windowInsets` 原本
只声明在 `CommPage` 上。ArkTS 报 `Property 'windowInsets' does not exist on
type 'SentTab'`。⇒ `@StorageLink` 是**每个组件各自**订阅 `AppStorage` 的,
父组件的不会自动传给子组件;直接各自订阅同一把键(比"父传子"少一层)。
## 真机验证
装机后重跑 dumpLayout:发件箱顶栏 `y=134..166`(状态栏底 y=82,间隙 52px),
截图确认「发件箱」完整显示、不再被压。
## 未修(诚实登记)
列表第一行标题被窗格头盖住(`List` 首项 y=294..319 落在窗格头 y=222..320 内),
与顶栏避让**无关**,本次未动。根因待查:`MailRow` 是 `.height(64)` 定高,
而 List 容器从 y=320 起算,首项被画到容器上方。
|
2026-10-01 18:49:06 +08:00 |
|
|
|
018d5b3bd8
|
test(桥): 四个桥的 relay-policy 必须逐字相同 + 钉住 in_reply_to 缺方向判据
## 新增 client/electron/test/cross-bridge-prompt.test.mjs(5 条,登记进 SUITE)
`relay-policy.js` 是 pi/dsh/zcode/opencode **各存一份的手抄副本**(当前
四份 md5 相同),它直接决定提示词里对模型说的话。修 `in_reply_to` 那一族
要改四个地方,**漏一个就会让分叉活到线上**。本判据就是防那个。
与 `cross-client-logic` 的分工:那边比**行为**(electron/harmony 两套类型
系统,只能跑同一张表比结果);这边比**字节**(同一个 node 运行时下的四份
JS 拷贝,没有任何语言差异要归一 ⇒ 字节相等是最便宜也最严格的判据)。
枚举挡实例、行为判据挡漂移,两者配对。
## 5 条的形状
· 4 条绿:四份 `relay-policy.js` + 四份 `relay-policy.test.mjs` 逐字相同,
且四个桥都存在(少一个即部署事故,当场红)。
★ 已实测它**有牙**:往 dsh 那份尾部加一行注释,判据立刻红并指名
`✗ dsh sha12=…`(不是笼统说"有分叉"),随后已还原、四份 md5 复验一致。
· 1 条**故意红**:`inboundHeadline` 不得只凭 `inReplyTo` 非空就宣称
「你上一封信的回复到了」—— 按形状断言(找方向判据字段),不点名实现。
这条红的就是 `docs/DEBTS.json` 的 `in-reply-to-ignores-direction`:
压测线索 `stress-thread-21863-15348` 里 8 封全是 `opencode → pi`,
投递通知却逐封宣称「回的是你那封:<上一封的 id>」,而没有任何一封是
pi 发出的。单向续信链同样满足「有父邮件」⇒ 纯单向的链被读成双向对话。
★ 判据先写好、修完转绿,不写就永远没人知道还欠着 —— 与本仓
「先钉判据再修」一致;到期动作不是「在提示词里写清楚」(本次已证明
写清楚没用:通知里逐字写着那句,模型照样每封去核一遍再被带着走)。
## 验证
· `node test/run-all.mjs`:files=35 ran=35 checks=582 pass=571 fail=2
**skip=9(不是通过)** red=2。
两个红:① 本文件那条故意红的;② `build-stamp`(BUILD_INFO 记 87c55ac
vs HEAD 359cb43)—— ★ **已在干净 HEAD 上复现,不是本次引入**,
与 `shared-workspace-unserialized-deploy` 同一形状(产物与源码分家)。
· 手改 SUITE 登记数字 5(套件只判下界,不手改将来删掉就不红)。
· go 侧 16/16 包绿(须 `GOCACHE=.tmp/gocache`,默认 `/root/.cache/go-build`
权限被拒)。
## 我自己踩的一个坑(第一次跑就撞上,已修)
初版裸 `readFileSync` ⇒ `criteria-hygiene` 第 2 条红
(「判据目录里不得出现裸 readFileSync」)。已改走 `prose()`。
★ 讽刺处:**本判据主题正是"手抄副本会分叉"**,而我第一版就制造了
一处新分叉(多写一个 import)。这条债说的就是这类形状。
|
2026-09-28 10:17:09 +08:00 |
|
|
|
21332de5e7
|
test(判据): criteria-hygiene 的 AGC 探针自检改用临时仓合成对照
## 为什么要改
那条判据("AGC 真身从未进过远端历史")的自检原本要求
**本地可达历史里确实有该路径**,用它证明 `git log -- <路径>` 这套查法可用。
★ 那个前提**已经不成立**了:真身**从未被提交过**
(`client/harmony/.gitignore:26` 一直在挡它),所以本地历史里
本来就查不到 ⇒ 这条判据**永久红、且无法自查**。
(注释里引用的 `7647c24` / `320c93f` 在本树也**不存在**。)
而判据的分诊早已确认:真身确实从未进过历史(被 ignore 正确挡住),
红的是**探针的假设**失效,不是缺陷存在。
## 改法:合成阳性 + 阴性对照
在**临时仓**(`mkdtemp`)里造两个提交 —— 一个含待查路径、一个不含 ——
对两者跑同一套查法,断言**双向有分辨力**。临时仓不碰本仓任何状态
(`GIT_CONFIG_GLOBAL=/dev/null` 避免读用户配置),造完即删。
为什么**必须**有阳性对照:一条用来抓泄露的判据,
**正确工作**时恰好永远看到"空"(没泄露 ⇒ 查不到)。
**"真值恰好是空"与"查法坏了"在输出上同形**(都是空串 + exit 0)
⇒ 真实历史里没有阳性样本可用,只能现造。
## ★ 阴性对照第一版写错了(变异测试打出来才发现)
我先写成查一个**真实存在**的无关文件 `unrelated.txt`。
变异把它改成查阳性那个 `leaf.json`,判据**照样绿** ——
因为查 unrelated 本来就该命中,那不叫"查法在乱报"。
⇒ 阴性对照要证明的是「**不存在的**目标查不到」,也就是**查法有边界**。
改成查 `no-such-file-ever.json`,并**额外**验一次 sanity
(真实但无关的文件**应当**查得到)—— 两个方向都对才算有分辨力。
改后双向变异都能打红(阳性查不到 ⇒ 红;阴性恒命中 ⇒ 红)。
|
2026-09-28 08:46:02 +08:00 |
|
|
|
18b148e476
|
跨端: fix(客户端) 换身份必须清全部账号数据 + 鸿蒙管理台门禁改三态
两份审查报告(`docs/reviews/electron-gui-review.md` /
`harmony-client-review.md`)里两条**数据隔离**缺陷。
## ① 换身份不清数据 ⇒ 在新账号的界面下显示旧账号的邮件
`setActive` 之后 `api/config` 单例里的 API_BASE 与 bearer 就翻到了新账号,
于是此后每个请求都带**新账号**的凭证。而各 store 里还留着**旧**账号的:
· mailStore.sent / currentMail
· sessionStore.sessions / currentSession / currentSessionMails
· contactStore.contacts / archivedContacts
⇒ 肉眼完全看不出来(不报错、不空屏),而**从旧视图发出的写操作**
(归档 / 转发 / 批准权限)改的是**新账号**。
新增 `src/lib/resetAccountData.ts` —— **一处实现,三个入口都调它**:
① 主动切账号(AccountSwitcher.pick)
② 登出(authStore.logout)
③ 任意接口 401(api/client.ts 的 unauthorized 回调 → markAnonymous)
只在 ① 里清是最容易漏的那种做法:② 和 ③ 各自还会重新泄露一次,
而它们都不在切换账号的代码路径上,grep 也找不到。
**身份变化有三条路径,清空也该有三条。**
★ 只碰**数据** store;`uiStore` 的 reset 仍由 App.tsx 负责
(它还要复位窄屏分栏、写信态那些纯界面状态)。
判据:`test/stores/resetAccountData.test.ts`(4 格)。
## ② 鸿蒙管理台门禁**失败开放**(fail-open)
`AdminUsersPage.ets` 原来的条件是 `roleKnown && !this.isAdmin`,
于是 `roleKnown === false`(loadRole() 失败、**身份还没读到**)
落进 else 分支 ⇒ **把完整管理台整个渲染出来**。
一次网络抖动 = 管理入口对所有人可见。
讽刺的是该文件自己的头注释写的就是正确规则
(「不能把读不到当成是管理员」)—— 代码做的正是这条注释禁止的事。
⇒ 改三态:`!roleKnown` 显示「正在确认身份…」、`!isAdmin` 显示墙、
否则管理台。
服务端 `middleware/user.go` 的 `AdminOnly` 仍在,所以**不是越权**;
但非管理员会看到完整用户列表、建号表单、改密入口 ——
属于客户端信息泄露 + 无意义的失败请求风暴。
|
2026-09-28 08:46:01 +08:00 |
|
|
|
667d368a48
|
refactor(repo): workspace 谓词抽成共享构造器 + 删一个死函数
用户 2026-09-26:「审查一下服务端,我觉得现在还是有大量不符合设计的地方与冗余代码」。
# 先说审查结论:**"大量冗余"核不出来**
| 检查项 | 读数 |
| --- | --- |
| 99 个 handler | **全部注册,零死路由** |
| 死函数 | 2 个(本次删 1,另 1 个被测试用、保留) |
| 注释占比 | 23%(这个仓每个非显然决定都记"为什么",是有意的) |
| 测试 | 13644 行 = 源的 41% |
# 但找到一处真问题:`workspace` 谓词手抄了三遍
同一件事在三处各写一遍:
args := []any{agentName}
if strings.TrimSpace(workspace) != "" {
args = append(args, workspace)
q += fmt.Sprintf(` AND s.workspace = $%d`, len(args))
}
★ 代价不是"多几行",是**加参数要改三处、漏一处不会编译报错**。
本次给三个函数加 workspace 参数(`ListInboxScoped`/`CountUnreadScoped`/
`MarkAllInboxReadForSession`)就是手抄了三遍。
同仓有同类先例:`quota.go` 里那条 `★★★ 判据自检` 记的
「占位符编号错位导致静默少行」—— 根因完全一样(同一个模板抄多处,
靠人肉保持一致)。
⇒ 抽 `workspaceScope(q, args, workspace) (string, []any)`,三处各变成一行。
# 为什么"必需"这条不在 repo 层
`checkWorkspace` **允许空**:空 = 不过滤 = 人类侧(一个人跨工作区,WebUI
按 session_workspace 分组显示)。"Agent 侧必须带"是**接口契约**,放在 Handler。
抽出来的函数注释里把这层分工写死了,免得后来者以为 repo 层该拒绝空值。
# 与 `FindOrCreateDefaultSession` 里那套**故意不共用**
那里要的是「工作区为空时从 mails 反推」(历史会话兼容),语义更宽。
合并前要先确认那是不是想要的行为 —— 现在保持分开。
# 删 `SessionMailCount`
全仓零调用(连测试都没有)。`GetSessionMailByID` 也只被两个测试用,
但它是那两个测试的被测对象,**不删**(测试专用包装与死代码不是一回事)。
# 验证
· 变异:把 `workspaceScope` 改成永远不过滤 ⇒
`TestInboxListIsScopedByWorkspace` + `TestMarkAllReadIsScopedByWorkspace` 判红
· 12 个包通过;`internal/repo` 唯一的 FAIL
(`TestReplacePlatformSessionsKeepsOtherWorkspaces`)**改动前就红** ——
已用 `git stash` 式回退验证,它是 `DEBTS.json` 里记的 platform_sessions
PK 缺陷那条判据,与本次无关。
# 顺带记一笔(对我自己的)
本机 `go` 是 1.24.4 而 `go.mod` 要求 1.25.0,**`go build` 会去下载 toolchain
并因离线失败**(exit=1)。我前面几轮用 `go build ./... | head -5 && echo "编译 ok"`
判断,把 `head` 的 exit 0 当成了编译成功 —— **那是假的**。本轮才发现,
改用本地已有的 `toolchain@v0.0.1-go1.26.7` 才拿到可信结果。
⇒ 判据里凡用 `cmd | head && echo ok` 的形状,退出码被管道最后一道吞掉,
之后一律用 `cmd >/dev/null 2>&1; echo $?` 或显式检查 `${PIPESTATUS[0]}`。
|
2026-09-26 14:08:48 +08:00 |
|
|
|
ab1c856ffe
|
跨端: 鸿蒙顶栏 —— 兜底改品牌名、一言只留句子、整批轮播(修"轮播不转")
用户三条裁定,逐条落地:
① 兜底文案「暂无待办」→「AgentMail」
触发条件只是"三个计数为零",而"暂无待办"读起来像"没事可做"——
是在**替用户下结论**,结论与事实不等价。品牌名不带判断。
② 一言**只显示句子,不显示出处**
原来 `quote + ' —— ' + source`。出处占近 1/3 宽度、把句子本身
挤到省略号;且 hitokoto 的出处格式杂(动漫角色/诗词/网名),
窄带上排起来脏。⇒ 与签名对齐:都是"当前状态的一句话"。
`topQuoteSource` 状态随之删除(只写不读的死字段)。
③ ★★ 修一个**不报错、不崩溃**的静默 bug:轮播根本不转
`this.topQuote = content.quotes[0].text` —— 注释还振振有词
「批次的意义是少请求,不是一次全显示」。那句话本身没错,
但**只取第一句** ⇒ 本地永远只有 1 句 ⇒
`startTopbarRotation` 里 `texts.length <= 1` 的守卫直接 `return`。
实测:连拍 6 张(18 秒)**全是同一句**。
★ 更坏的是它先前是**假象**:看着"在转",靠的是兜底占了轮播位
(一言 ↔ 暂无待办 交替)。等 ① 把兜底改成不占轮播位,真相立刻暴露。
⇒ 教训:**"看起来在工作"可能是另一个东西在工作**。
做 ① 时顺手连拍验证,才撞出这个真 bug。
修法:整批存进本地状态、本地在批内逐句轮。服务端一批给 10 句
(每次顺序还随机),这正好落实用户最初那句「app 本地缓存一部分」
——缓存的是**一批**,不是一个。
★ 顺带:兜底**不占轮播位**(它只做"唯一候选")
摘要要有真计数(unread/pending/contacts 至少一个 > 0)才占位。
实测四张连拍得到 `一言 → 兜底 → 兜底 → 一言`,
等于用户看到的内容里一半是废话。
实测凭据(2in1 模拟器 3120×2080)
· 连拍 7 张(21s):6 句不同一言,全不带出处
· 连拍 5 张(15s):4 句不同,无兜底占位
· 顶栏文案与三键垂直中心差 0.0px(上一提交已校准)
判据(harmony-2in1,共 23 条)
· 兜底必须存在(退回空串会让整块消失),且**不含判断词**
("暂无/没有/无"开头——正则断言,改回「暂无待办」即判红)
· 兜底不占轮播位:`summaryIsReal` 守卫 + `out.length === 0` 才 push(两半都钉)
· 一言存**整批**、逐句进候选、不许有单句形态 `topQuote`
· 出处不许出现在代码里
★ 判据自己踩的坑,记下来免得重犯:
一言那条我第一版写成反向正则 `/this\.topQuote \+ ' —+ ' \+ this\.topQuoteSource/`
—— **只匹配单引号**。变异时我把拼接写成双引号 `" —— "`,正则没命中
⇒ **假绿**。变异验证当场抓到。改成**正向断言**
`out.push(this.topQuote);`(必须原样推进、不许在此处拼接):
正向比反向窄,且不依赖引号风格。
全量:577/577 绿。
|
2026-09-25 19:09:48 +08:00 |
|
|
|
9d50352e7e
|
跨端: 鸿蒙顶栏文案(摘要/一言/签名轮播)在三键左边 —— 位置与字号按实测校准
用户四条需求,逐条落地:
· 「可以在服务器集成一言与签名,同时 app 本地缓存一部分」
· 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」
· 「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」
· 「我要的效果是在退出,最大化,最小化三个按键的左边」
客户端(服务端那半见 31939f2)
· 新增 `common/TopbarStore.ets`:一言 + 签名的取数与**账号级**缓存
(键 = 前缀 + accountId,本仓纪律;共用一份会让多账号串台)。
本地缓存先出(秒开、离线可用),再后台拉一次更新;
拉失败**保留缓存**、不抛异常 —— 装饰性内容不该成为失败点。
· 轮播:摘要 / 一言 / 签名三选一轮着显示,5.5s 一条、
淡出淡入各 260ms(停顿明显长于动画,否则观感是"一直在闪")。
· 纯文字:不设 background、不加玻璃(用户点名「不要加底」),
`hitTestBehavior(None)` 不吃事件。
★ 位置:为什么自绘而不 `setWindowTitle`
官方 `setWindowTitle` **实测确实**能在那一行显示文字(截图验过),
但它三条硬伤:① 必须保持窗口装饰可见 ⇒ 标题栏横带回来,与刚修好的
「顶栏沉浸」冲突;② 瞬时替换,做不了用户要的消失出现动画;
③ 字号颜色跟随系统。⇒ 装饰仍隐藏,文字自绘在装饰带原位。
★★ 两个"按实测校准"的修正(都是用户看出来的)
1. **位置**:我先做成"右对齐、贴住三键左缘"——用户纠正
「我要求的是与三键同行,但是在左边啊」。改成靠左(FlexAlign.Start)。
2. **对齐与字号**(用户:「行没有对齐,大小也偏小」):
实测(1px ≈ 1.91vp):
三键 y 290..343 高 53px、中心 316.5
我原来 y 296..323 高 27px、中心 309.5 ⇒ **中心差 7px、字号小一档**
改法:字号 12 → 14vp;垂直对齐从写死的 `y: 8` 改成
`height(windowInsets.windowDecor)` + `VerticalAlign.Center`。
改后实测:**中心差 0.0px**、文案高 31px(与三键图标同量级)。
★ 顺带修掉一个逻辑漏洞
摘要三项计数都是 0 时我返回了空串 ⇒ 整块**不渲染**,顶栏右上什么都没有。
而"没有未读"恰恰是常态(收件箱清干净了)。加兜底文案「暂无待办」。
那句"三项都是 0 就不显示"是我按"有信息才显示"想当然写的,
没考虑"零"本身也是信息。
判据(harmony-2in1 新增 4 条 → 19→23,全部变异验证过)
· 在三键左边且不破坏沉浸 —— 钉的是**两个约束同时成立**
(只看一件会放过错误的那版:为了三键左边而恢复标题栏)
· 纯文字:不许 backgroundColor / backgroundBlurStyle,且不吃事件
· 摘要为零也要有文案(把兜底改回空串即判红)
· 一言/签名缓存要账号级、失败要吞掉
★ 判据自己的两个坑(都在注释里记了)
① 切片锚点不能用常量的**名字**:`TOPBAR_STRIP_VPAD` 先在文件顶部常量区
出现一次,从那里往后切会一路包进 `InboxTab`(那里有 backgroundColor),
于是报"文案加了底色"——报的其实是**别人的代码**。改成锚**使用点**。
② 位置断言跟着事实改过一轮:第一版给"贴三键"那个错版背书,
用户纠正后改成断言靠左。
实测凭据(2in1 模拟器 3120×2080)
· 文案 x 545..652、y 301..332;三键 x 2362..2567、y 290..343
· 垂直中心差 0.0px
· 沉浸保留(装饰仍隐藏)
|
2026-09-25 18:24:08 +08:00 |
|
|
|
1436fd1fb1
|
跨端: 鸿蒙 2in1 键盘派发重构 + 右键菜单(补上一轮的实测修正)
上一轮提交(e54dc39)的快捷键实测后发现**详情页的回车开错了东西**,
这一轮是修正 + 补齐。
① 详情页回车开出的是"写信"而不是"回复"(实测截图硬证)
根因:详情页自己的 `onKeyEvent` **从不触发** —— 官方要求组件**获得焦点**
才响应(common.d.ts:19510),而页面根 Stack 默认不可聚焦,
加 `.focusable(true)` 也没人 requestFocus。于是键直接冒到主页根,
被那条"Enter=写信"抢先。
⇒ 改成**根上按状态派发**(与 WebUI 同构:它也是一处全局监听 + 按状态分派):
· 发布 `KEY_OPEN_MAIL_ID`(CommPage.openMail 写、NavDestinations 返回时清)
⇒ 判断"此刻是不是在看某封邮件"
· 发布 `KEY_COMM_STACK_DEPTH`(navPathStack.size())
⇒ 判断 Esc 还有没有层可退(写信也占一层)
· 根上据此决定:Enter = 回复 or 写信;Esc = 弹一层 or 交还系统
新增 `ReplyIntent` / `PopIntent`,与既有 `ComposeIntent` **同一"两半"形状**
(有人听就当场给、没人听就存着)——根组件够不着那两处的实例。
② 右键菜单(用户选「右键菜单」)
· 邮件行挂 `bindContextMenu(…, ResponseType.RightClick)`;官方枚举只有
RightClick / LongPress 两项 —— 长按是触屏语义,且左键单击已被
"打开邮件"占用,只剩右键可用。
· 菜单项**只放列表层能当场完成**的:标记已读 / 归档会话 / 复制主题。
★ **不放**回复/转发:那两个要详情页的表单,在列表行上做只能"先跳详情",
那不是菜单项该有的语义(点了当场就该有结果)。
· 归档走系统确认框(破坏性操作,与联系人页同一分寸)。
实测(2in1 模拟器 3120×2080,xdotool 注入真实键鼠)
· 列表 Enter → 写信页 ✅ 截图
· 写信页 Esc → 回列表 ✅ 截图
· 详情页 Enter → 回复框("回复给 pi@…")✅ 截图
· 邮件行右键 → 菜单(归档会话/复制主题)✅ 截图
· 复制主题 → 无报错、菜单关闭
★ 一个重要的自我更正
我先前说"2in1 模拟器上键盘注入不生效、属环境限制"——**那是错的**。
xdotool 的键事件一直都能到 App(探针日志明确显示
`Node Stack/68 handle KeyEvent` + handler 被调用)。误判的原因是我当时
在**登录页**测 Ctrl+N(那页本来就没实现它,当然没反应)。
教训:探针打进去之前,不要把"没反应"归因于环境。
判据(harmony-2in1 新增 7 条 → 12→19)
· 三页用同一套键判定(不许各写一遍 KeyCode 比较)
· 登录页回车提交(WebUI 靠 <form> 天然有,鸿蒙原来一行监听都没有)
· 详情页 Enter/Esc + 弹层开着时 Esc 先关弹层
· 右手菜单:挂了 bindContextMenu、类型是 RightClick、
菜单项只用当场能完成的动作(不放回复/转发)、归档要先确认
★ 两条判据第一版是**我自己判红了自己**,都是判据比事实严格:
① 详情页确实有 KEYCODE_ENTER —— 那是**输入框内的候选导航**(onFwdKey),
与页面级快捷键是两件事 ⇒ 改成只查页面级 onKeyEvent 那一段;
② isEscapeKey 先在 import 行出现,从那里切片取到的是注释 ⇒
改成从页面级 onKeyEvent 内部起切。
|
2026-09-25 16:30:45 +08:00 |
|
|
|
e54dc39f8f
|
跨端: 鸿蒙 2in1 键盘可达 + 悬停 + 沉浸顶栏 + 三键避让 + 修叠栈
用户四条:
①「2in1 手势」(选了 悬停/右键菜单/触控板 + 快捷键:主页回车写信、
详情页回车回复、Esc 返回)
②「宽屏状态一个邮件被反复点击会被多次填充到右侧」
③「你在登陆页是不是没有做 enter 等键的监听」——**确实漏了**
④「app 顶栏为什么不沉浸」+「右侧三键应当有独立避让」
② 叠栈(实测复现 → 修 → 实测通过)
根因是框架语义用错:pushPath 默认 LaunchMode.STANDARD 每次入栈 ⇒
重建详情组件 + 重拉数据 + 重放入场动画;返回还要按多次。
而 WebUI 是 `set({currentMail})` 幂等赋值(mailStore.ts:115)。
改用 LaunchMode.MOVE_TO_TOP_SINGLETON(官方:同名已在栈里就移上去、
不新建),MainPage + ContactsTab 两处 push 点都改(只改一边=换栏点
又不正常)。
实测:连点同一封 3 次 → **点一次返回就回占位**(修复前要按 3 次)。
③ 登录页回车(用户点出来的真实缺失)
WebUI 是 `<form onSubmit={submit}>`(LoginPage.tsx:92)——浏览器里
输入框按回车就提交;鸿蒙登录页**一行键盘监听都没有**。
补上,走**已有的** doLogin()(不另写一条登录路,免得与按钮的条件分叉)。
① 快捷键:新增 model/KeyboardShortcuts.ts(规则集中一处,三页共用)
· 主页根 Stack:Enter → 写信(与 Ctrl+N 同一个 ComposeIntent.request)
· 详情页:Enter → 开回复(复用 openReplyWithMorph,连动画都不另开);
Esc → 返回,且**弹层开着时先关弹层**再按才返回(否则用户想关回复框
却被踢回列表,输入到一半的内容全没)
· 用键事件**冒泡**:子组件先拿到、未消费才到页面根 ⇒ "详情优先、
主页兜底"由框架保证,不是我自己排的优先级
★ 为何不用 keyboardShortcut:它只收组合键;不带修饰键时只认 FunctionKey,
而 FunctionKey 枚举(enums.d.ts:3444)**没有 Enter**(只有 ESC/F1-F12/
TAB/方向键)⇒ 单按回车表达不出来。
① 悬停反馈:MailRow/SentRow 挂 onHover + Theme.surfaceMuted
(该令牌此前**零使用**,注释本就写着"列表行 hover",正好归位)。
不用 .hoverEffect():系统叠层会与选中/未读的 accentSoft 叠成第三种颜色。
★ 状态存 mail_id 而不是布尔:行本体是 @Builder(无自身状态),
布尔会变成"悬停一行、同栏全亮",所以状态放栏上、存"是哪一封"。
④ 沉浸顶栏:EntryAbility 加 setWindowDecorVisible(false)
实测(2in1 截图硬证):标题栏(AgentMailHarmony)下面**还有一条白条**,
内容从第二条下面才开始。根因是**从未调过装饰接口**⇒用系统默认(PC 带标题栏)。
setWindowLayoutFullScreen(true) 管的是"内容铺到**屏幕**四边",
**不包含**"窗口自己的标题栏是否隐藏"——两件不同的事。
④ 三键避让:Insets 加 windowDecor + getWindowDecorHeight()
隐掉标题栏白条后,系统仍在右上角**浮着**三键(官方:全屏悬浮态固定 37vp)。
而 2in1 **没有状态栏** ⇒ TYPE_SYSTEM.topRect 是 0 ⇒ 只看 statusBar 就
认定"顶部无需避让",内容(右上是「授权」页签)被三键压住。
AvoidAreaType 六种里**没有**"标题栏/三键"这一类,只能单独读
getWindowDecorHeight()(它直接返回 vp)。
避让取**较大者**不加:两者互斥(有状态栏的形态没装饰,反之亦然)。
实测日志:`statusBar=0 navIndicator=0 windowDecor=37`,页签条下移。
判据(13 条新增/改,全部变异验证过)
- 新增 5 条「2in1 快捷键」:单一出处(三页都不得自己比 KEYCODE_ENTER)、
登录页回车、详情页 Enter/Esc + 先关弹层、窗口装饰必须隐掉。
★ 两条第一版是**我自己判红了自己**,都是判据比事实严格:
① 详情页确实有 KEYCODE_ENTER —— 那是**输入框内的候选导航**(onFwdKey),
与页面级快捷键是两件事 ⇒ 改成只查页面级 onKeyEvent 那一段;
② isEscapeKey 先在 import 行出现,从那里切片取到的是注释 ⇒ 改成
从页面级 onKeyEvent 内部起切。
★ 窗口装饰那条第一版写 `/setWindowDecorVisible\(false\)/` —— 紧邻两行**日志**
也含这个串,删掉真正的调用后判据**照样绿**(变异实测没红)。
改成匹配调用形态 `win.setWindowDecorVisible(false)` 后才真会红。
这是"判据匹配到的是关于这件事的文字、不是这件事"的形状。
- harmony-widescreen ⑥ 原来钉精确串
`pushPath({ name: MAIL_DETAIL_ROUTE, param: params })`,加了 launchMode
参数后判红 —— 那是**判据写死了写法**。改成按结构匹配(不变式:选中邮件
要经 navPathStack.pushPath 进 MAIL_DETAIL_ROUTE,带不带 options 是实现细节)。
- harmony-2in1 登记数 12 → 16。
环境(这次为了真验 2in1 专门搭的)
- 下载 2in1 镜像 HarmonyOS 6.1.0(23)(与 target 一致),建实例 HA2in1
(3120×2080,14.2" 笔记本),设备 127.0.0.1:5557,形态确认为 `2in1`。
- 带窗口启动要 Qt xcb:补了 5 个 xcb 库 + Xvfb :99(`-noWindow` 下 2in1 起不了 App)。
- ★ 多设备并存时设备判据会自己挑目标 ⇒ 必须 `AGENTMAIL_HARMONY_TARGET=127.0.0.1:5555`
才跑手机那台;不指定时判据连到未登录的 2in1 上会假红。
这正是 harmony-device.mjs 里 targetKey() 注释写明的已知行为。
★ 未验(要说清楚,不能算过)
- Enter/Esc/Ctrl+N 三个快捷键**仍未在设备上端到端验过**:2in1 模拟器 + Xvfb 下
键盘事件送不进 App(xdotool 的文本能进 TextInput 走输入法通道,但键事件不达;
hdc 的 uinput/uitest keyEvent 同样不生效)。日志显示 SubscribeKeyEvent
被调用 ⇒ 订阅注册成功,纯粹是键送达不了。属环境限制。
- 悬停同理(要有鼠标 hover 事件注入,xdotool mousemove 到窗口不一定转成
ArkUI 的 onHover)。
- 登录页回车:同一限制。
沉浸顶栏与三键避让是**截图硬证过**的(不依赖键盘)。
|
2026-09-25 12:21:00 +08:00 |
|
|
|
c4279a95ce
|
feat(criteria): 文件名不得是"句子里的一段" —— 我为**自己制造的**那个化石加守
## 我制造了什么(本会话 `34bcd3e9` 那轮,实测复现)
```
我在 bash 里写了一句带嵌套未转义双引号 + 裸 `>` 的说明:
echo " ⇒ 所以"mtime > 动作时刻是**必要但不充分**,且会被**后人无关的写**覆盖""
bash 把 `>` 读成**重定向** ⇒ **在仓库根建了一个文件**:
文件名 = `动作时刻是**必要但不充分**,且会被**后人无关的写**覆盖`
内容 = ` ⇒ 所以mtime`(18 字节)
⇒ 我在检查工作树时看到 `?? "动作时刻是…"` 才发现的。
用 /tmp 里的最小复现验证到**同名同内容** ⇒ 确认是我的 shell 引号事故,不是别人的。
```
## 为什么值得一条判据(它会被提交)
```
· 未跟踪文件**不会**被普通 `git add <path>` 带上 ⇒ 平时看不见、不会被门禁提醒;
· 但本仓**明文记录过 `git add -A` 事故**(`docs/DEV-TOOLING.md`: 含把约 7000 行重排扫进功能提交那次)
⇒ 下一次 `-A` 就会把这个"句子文件"带进历史,而**进了历史就永远删不干净**;
· 它的**名字本身就说明它是无意的** —— 没有人的文件名会是"…且会被**后人无关的写**覆盖"。
```
## 判据(零先例实测 ⇒ 不误伤)
```
仓库内(排除 node_modules/.git/build/release/dist/.hvigor/.codegraph/oh_modules)
不得有名字含**中文标点**(,。、!?;:()「」“”《》【】)、**markdown 粗体 `**`**、或**换行**的文件
★ 判**名字的形态**,不判目录 —— 按目录裁豁免正是本仓记过的"给逃逸指路"。
实测先例: 加这条之前全仓命中 **0**(根目录 0、全仓 0)⇒ 不误伤任何现存文件。
```
## 双向变异
```
① 重建那个句子文件名 ⇒ **not ok 10**,逐个点名 ✓(上面就是它抓到的输出)
② 删掉 ⇒ 10/10 绿 ✓
```
## 顺带
```
run-all.mjs 注册条数 9 → **10**(新增 test 必须登记精确条数,本仓约定)
```
★ 这条与我这轮前两条(`fc54a81` 两处排除、`b16c38a` "提到≠发现路径")是**同一族**:
**"看起来像在表达一件事"的东西被当成了"它就是那件事"** ——
前两条在判据的**匹配口径**里,这条在**文件名**上(一段散文被当成了路径)。
验证: criteria-hygiene 10/10;drift 自检 64/0;两文件 `node --check` 通过;
现场: 探针已删(`git status` 只剩本提交的两个文件)。
|
2026-09-25 07:58:36 +08:00 |
|
|
|
b16c38ad83
|
fix(criteria): 第三个洞 —— "提到"不等于"发现路径";判据分不清"分析散文"与"可照走的指引"
pi `cf5d9b18` 的 ⑯″("域还须报'是对象本身还是关于对象的文本'")我先当它是**它自己**的坑,
实测发现**我的判据正踩着同一个洞** —— 而且是**假绿**(判据存在的理由反过来)。
## 实测(构造,不碰真工具)
```
造 `deploy/zz-prose-only.sh`(无文档专节、无任何指引)+ 只在 `docs/API.md` 追加一行
"分析随笔:zz-prose-only.sh 这次只是个例子"
⇒ 原判据 **9/9 全绿** —— 孤儿没被报出 ⇒ **假绿** ✗
根因: 原判据只问 `prose(p).includes(t)`(**这个名字出现过吗**)⇒
**"提到"就算"发现路径"**,而本仓 `docs/API.md` 是 5420 行**逐轮分析日志**,全是"提到"。
```
## 修法: 要求引用**可定位**(`deploy/<t>`),而不是裸名
```
理由: 「发现路径」的本义是"读者能**照着走到**那个工具" —— 裸名不告诉他在哪。
判据: isDiscoveryRef(text, tool) = text.includes(`deploy/${tool}`)
```
★ **不误伤任何现存工具**(这是先决条件,我逐条实测,不是推断):
```
本仓 5/5 个非门禁工具的现有发现路径**本来就都是路径限定的**:
docs/DEV-TOOLING.md 的专节标题与表格(`deploy/prune-deploy-artifacts.sh` 等)
deploy/redeploy-gateway.sh、deploy/prune-deploy-artifacts.sh 的注释
docs/DEBTS.json 的 where 字段
⇒ 收紧后 9/9 仍绿,且 5 个工具全部保住
```
⚠️ 它**不是**"排除 `docs/API.md` 这个文件" —— 那是**按文件名裁豁免**(给逃逸指路)。
判据落在**引用的形态**上: 任何文件里的路径限定引用都算数,任何文件里的裸名都不算。
## 判据自身也要有守(否则这行以后退回裸名,症状还是假绿)
```
assert isDiscoveryRef('见 deploy/archive-stale-sessions.sh', …) === true
assert isDiscoveryRef('见 archive-stale-sessions.sh', …) === false
```
## 双向变异
```
① 孤儿 + 裸名提到(API.md) ⇒ not ok 8,点名 zz-prose-only.sh ✓(原版这里假绿)
② 同一孤儿 + 改成路径限定引用 ⇒ 9/9 绿 ✓(没紧过头)
③ 真孤儿(连裸名都没有) ⇒ not ok 8 ✓(前面 fc54a81 那轮已验)
```
验证: 正常树 9/9;drift 自检 64/0;`run-all.mjs:141` 注册条数 9 未变;
`TestDebtLedgerMatchesMeasurement`/`TestDebtSummaryReadsAuthoritativeLedger` 通过。
现场已清: 探针文件已删、`docs/API.md` 逐字还原(`git diff` 空)。
|
2026-09-25 07:46:41 +08:00 |
|
|
|
fc54a816e8
|
fix(criteria): 两处"排除"职责不同 —— pi 的诊断错在把 t 当成观察者;但它"按名字不够"那半成立,改按**身份**
pi `661416f3` §二 报: 「`:765` 用 `basename(p) === t`(按名字)而 `:745` 用 `resolve(...) !== SELF`
(按身份)⇒ 你今天把同一件事做了两遍、两种判据 ⇒ 建议 `:765` 也改成排除观察者」。
★ **它把两处 `continue` 认成了同一件事,而它们职责不同** —— 我先复现它的建议,结果是**回归**:
```
:t 来自 `tools = readdirSync(deploy)` 的非门禁 *.sh ⇒ **是被测工具**,不是观察者
:745(SCAN 那层) 排除**观察者**(本判据自己)—— 防"描述缺陷"被当成"存在引用"
:765(内层) 排除**工具自己的文件** —— 每个工具头注释都写自己名字(实测 5/5 各 1 处),
不排除 ⇒ **"自名"被算成"发现路径"** ⇒ 每个孤儿自证可达 ⇒ 永久假绿
```
## 双向变异实测(同一棵树,只改这一行)
```
① 应用 pi 的建议(:765 → 排除观察者)+ 造真孤儿 `deploy/zz-orphan-probe.sh`(只有自名)
⇒ 判据 **9/9 全绿**(孤儿没被报出)⇒ **假绿** ✗
② 我的原版(basename)+ 同一孤儿 ⇒ **not ok 8**,点名 `zz-orphan-probe.sh` ✓
```
★ 但 pi 那封里**有一半成立**(我一开始也差点整条驳掉 —— 这是本仓记过的形状:
"用一个真机制去驳掉整条建议"):
```
若别处出现与工具**同 basename** 的文件(如 `docs/archive-stale-sessions.sh`),
`basename(p) === t` 会**把它也跳过** ⇒ 那处引用白算 ⇒ **假红**。
实测: 把 `archive-stale-sessions.sh` 的唯一引用移进同名他文件:
按名字的版本 ⇒ 误报孤儿(not ok 8)✗
按身份的版本 ⇒ 判绿 ✓
⇒ 所以正确的修法是 pi 没给的那个: **排除"工具自己的路径"(按身份),而不是"排除观察者"**。
两边都保住: 自名仍被排除(不假绿)、同名他文件仍被计入(不假红)。
```
## 落地
- `:765` → `resolve(p) === resolve(join(DEPLOY, t))`
- 删掉因本次改动而成为**死 import** 的 `basename`(本仓唯一一处 import-未用)
- 把"两处排除职责不同 + 按名字不够"写进注释,并加**反空真断言**:
`assert.notEqual(resolve(join(DEPLOY, tools[0])), SELF_FILE)`
—— 防"两处排除被合并/退化成一个"(那样就等于删掉其中一个,而删哪个都会假绿)
验证: 变异①(真孤儿)红、变异②(同名他文件)绿;正常树 9/9;drift 自检 64/0;
`run-all.mjs:141` 注册条数 9 未变(本次是加断言+注释,未新增 test)。
|
2026-09-25 07:44:06 +08:00 |
|
|
|
b1eb0ab0c9
|
feat(criteria): ⑤b 负向清单的**两处副本**都要有守 —— 关掉我在 4598095 里明确留下的那条尾巴
`4598095` 结尾我写了「§7 那条同类无守…不在这条提交里改」—— 这条把它关掉。
## 形状(pi `7ec0044a` 指出,我逐条验证)
「已装二进制 = 当前 HEAD —— 不覆盖: ①脏树构建 ②部署后手工替换」这句话有**两份副本**:
```
deploy/check-deploy-drift.mjs `negative` 常量 + 自检格 ← **有守**
deploy/redeploy-gateway.sh:480 注释 + `ok` 文案(§7) ← **无守**
```
实测:
```
grep '已装二进制 = 当前 HEAD' 全仓 ⇒ **只命中 redeploy-gateway.sh 那一行**
redeploy-gateway.sh 的 `--self-check` 出现次数 = **0**(参数只有 --skip-tests/--skip-web/--dry-run/--help)
⇒ 同一个动作、同一句"不覆盖"的声明,**一处有守、一处没有**
```
★ 而**无守的那份恰是部署时打印到屏幕上的那份** —— 读者看到的就是它。
## 判据
`criteria-hygiene` 新增:两份副本都必须同时点名同一对失败类(`脏树` / `手工替换`)。
**变异验证**(两侧都验):
```
shell 那份删掉"手工替换"(注释与 ok 文案都删)⇒ 该判据 **红**,点名
`redeploy-gateway.sh 未点名「手工替换」` ✓
恢复 ⇒ 绿 ✓
```
固定 8 → **9**,已同步 `run-all.mjs`。
## 归族
与上一条(发现路径)**同族**: 都防「**声明的副本**没有守卫」。
区别: 上一条防"没人知道工具存在",这一条防"**声明漂了没人知道**"。
★ pi 的 ⑬″ 判法在这里的用法:先问"这句声明**在 R 内还是 R 外**"——
「不覆盖②」属于 R 内(该判据确实回答不了内容替换)⇒ 它不是"划出宣称"就能了事,
而是**必须说出来**,所以两处副本都要保住这句话。
验证: `criteria-hygiene` 9/9;`bash -n deploy/redeploy-gateway.sh` 未受影响(本提交没动它)。
|
2026-09-25 07:16:26 +08:00 |
|
|
|
0c6c506d7a
|
feat(criteria): 新建「非门禁工具必须有**发现路径**」判据 —— 补上改名制造的盲区(pi a6dd501c)
pi 指出、我复现的一个**盲区**:`check-*.sh` 族靠命名约定被 `readdirSync` 强制接线
(上一条判据管的就是"判据在但走不到")。★ 而这条约定的**代价**是:
**为了躲开它而改名之后,没有任何判据管"改名后还找不找得到"**。
```
实测(我独立复现):
recount-relay-counts.sh 非自身引用 = 1(只有 docs/DEBTS.json)
archive-stale-sessions.sh 非自身引用 = **0**(全仓只命中它自己)
对照: prune-deploy-artifacts.sh 在 docs/DEV-TOOLING.md:71 有**专节** ⇒ 那才是它被找到的原因
⇒ 两个机制不同、都要有:
check-* 族: 「判据在但**走不到**」(**执行**路径)—— readdirSync 强制
按需工具 : 「工具在但**没人知道它存在**」(**发现**路径)—— 本判据
改名正好绕开前者 ⇒ 后者必须独立存在
```
## 判据
`criteria-hygiene` 新增:每个非门禁 `deploy/*.sh` 至少要有**一处非自身引用**
(扫 docs/deploy/test/.githooks/scripts 的 .md/.mjs/.sh/.json,用 `prose()` —— 判的是散文)。
含反空真护栏(工具数 <2 报红)。
固定 7 → **8**,已同步 `run-all.mjs` 的登记数。
## ★★★ 建这条判据时我自己先假绿了一次(已修,值得记)
第一版判绿了,而 `archive-stale-sessions.sh` **明明是零引用**。原因:
```
我的判据注释里写着「archive-stale-sessions.sh —— 非自身引用 = **0**」
⇒ 判据扫"文件里有没有出现这个名字" ⇒ **它自己那句描述**被数成 1 个引用
⇒ 一个零引用的孤儿**因为被描述成孤儿**而看起来有引用 ⇒ 判绿
```
修法:扫描时**排除观察者自身**(`resolve(p) !== resolve(fileURLToPath(import.meta.url))`)。
⚠️ 我第一版排除的是 `SELF`,而本文件里 `SELF` 指的是 `lib/read.mjs`(另一个文件的变量)
⇒ 排错了对象,仍然假绿。**"我知道要排除自己"和"我排除的是自己"是两件事。**
泛化:**任何"扫全仓找引用"的判据都必须排除观察者本身**,
否则"描述缺陷"与"存在引用"不可区分(与 `stripComments` 那条同源)。
## 变异验证(两侧都验,不只验绿)
```
删掉 DEV-TOOLING 的"按需工具"整节 ⇒ 该判据 **红**(报出 archive-stale-sessions.sh)✓
恢复 ⇒ 绿 ✓
```
## 顺带
`docs/DEV-TOOLING.md` 加「按需工具」一节,逐个列出发现路径(5 个工具)+ 两个机制的对照表。
验证: `criteria-hygiene` 8/8;`run-all --test criteria-hygiene` 该文件不在红名单。
(另 4 个红文件为**既有**、与本改动无关: build-stamp 是前端产物未重建(expected 9e05322 /
actual 6c98ac2)、commit-hygiene 是 AGC 配置、cross-client-gesture 需设备、cross-client-theme
单独跑 21/21 绿 —— run-all 里那次红来自环境差异。)
|
2026-09-25 07:13:04 +08:00 |
|
|
|
6c98ac2d03
|
跨端: commit-hygiene 基线推进(记录四条「鸿蒙|」实际改了双端的疏漏,不往 MARKERS 里放水)
|
2026-09-24 16:44:03 +08:00 |
|
|
|
187609480f
|
跨端: 鸿蒙修收信/自动已读/发件箱点开 + 页签条通透(用户报的四个问题)
用户报了四个问题,逐个实测复现 → 定位根因 → 修 → 设备复验:
① 「邮件点进去自动已读的能力不正常」
根因:鸿蒙只有「标记已读」按钮(doMarkRead,对照 WebUI MailView.tsx:522
那个手动按钮),缺 WebUI 的**自动**路径(MailView.tsx:55-65 的 useEffect:
可见且 unread 就 markRead)。⇒ 点开邮件不变已读,必须再点按钮。
修:MailDetailPage.loadMail 成功尾端按 `status==='unread'` 触发 doMarkRead
(复用按钮那条路,因而天然带上「就地改 status」+「失败弹 toast」)。
★ 加 autoReadMailId 守卫:WebUI 靠 useEffect 依赖数组天然只跑一次,
鸿蒙 loadMail 是显式调用的(下拉刷新会重跑),不守会重复打接口。
② 「接收邮件的能力也有点不正常」
三个独立缺口,每个都会单独造成"收不到":
a) **SSE 监听寿命**:原挂在 InboxTab.aboutToAppear,而三个 tab 是
if/else 条件挂载的 ⇒ 切到发件箱/授权时 InboxTab 被销毁、监听跟着
移除 ⇒ 在那两栏时收不到任何新邮件。搬到 MainPage(@Entry,全程在)。
b) **只处理 new_mail**:WebUI 监听 5 种事件(sse.ts:7-13 的 EVENTS);
服务端权限决策后发的是 session_update(permission.go:387)⇒
"授权栏里处理过的申请,收件箱还是旧的样子"。补齐四种。
c) **UTF-8 解码错**:arrayBufferToString 逐字节 String.fromCharCode
(Latin-1 语义)⇒ 中文解成乱码。改用 util.TextDecoder + stream:true,
且**每连接一个实例**(stream 会把半截汉字存在解码器内部,
共享实例会把两个账号的半个字拼在一起)。
③ 「发件箱内容点不开」(第二轮;5483140 补了 onClick 仍点不开)
根因:发件箱对单封组**既画组头又画行**——
this.SentGroupHeader(g); if (isFlatGroup(g)) { this.SentRow(...) }
组头那半张卡没有任何点击处理 ⇒ 点卡片上半部完全无反应。
而 isFlatGroup 自己的注释写着「单封不成组:套一个可折叠的组头只是
多一次点击」—— 实现与注释**直接相反**。收件箱一直是正确形状
(if (isFlatGroup) { MailRow } else { 组头 + 子行 })。
修:与收件箱取同形,单封组只画 SentRow。
④ 「通信页面我觉得没有 webui 那么通透」
这是我自己上一轮改错的:把 WebUI 的「页签条无背景」实现成了
`backgroundColor(Theme.surface)` 实心白。WebUI 的真实层叠是
.comm-pane 是玻璃卡、CommTabs 在它内部且**自己无背景**(透出卡的白)。
铺实心白 = 把玻璃卡换成横条白 ⇒ 壁纸再也透不过来。
⇒ 改回玻璃族(Theme.navMaterial,与底部导航条同档)+ 通栏 +
只左上圆角(右上 0,与窗格那道弧重合)+ 底边线。
判据 harmony-nav.test.mjs:732 在我改错时当场判红,是它先抓到的。
判据(变异验证:还原 bug → 必须判红)
- 新增「单封组不许既画组头又画行」:两栏的 (header, row) 对必须在
else/三目里二选一。
★ 第一版写弱了(用 isFlatGroup(g) 作锚点往后切片,组头在切片之前
⇒ 变异测不红)。改成以**行调用**作锚点往两边开窗后,删掉修复
即判红(实测已验)。
- 新增「列表行必须把 onClick 挂在自己身上」(MailRow/SentRow)。
- harmony-logic 34→37、harmony-nav 22(新增后仍绿)、
harmony-appearance 27→28、animation-audit 12→15 的登记数同步。
设备复验(全部有实测凭据,不是推断)
- 自动已读:点开前 unread → 点开 4s 后服务端 read(连验两封)。
列表组头 4→2、侧栏徽标 4→2,三处数字一致。
- 实时收信:App 保持前台不重启,从 gateway 发信 ⇒ 8s 内自动出现
(新卡片 + 侧栏 2→3 + 页签 2→3 + 3 组 8 封)。
- 发件箱点开:点原先点不动的卡片上半区 ⇒ 右栏出正文 + 蓝色选中态。
- 页签条:截图确认为玻璃通透(不再是实心白横条)。
|
2026-09-24 16:34:44 +08:00 |
|
|
|
548314013a
|
鸿蒙|修发件箱点不开(SentRow 缺 onClick)+ 补列表行判据
用户报的三件事,逐条实测后的结论:
## ① 发件箱内容点不开 —— 真 bug,本次修复
复现:点发件箱「致 pi」那一行
· 日志里**没有** `GET /mail/{id}`(收件箱同样操作是会发的)
· 右栏仍是占位(「选择一封邮件查看…」)
根因:点击挂在**外层** `Column` 上,而 `SentRow` 自己**一个 `.onClick` 都没有**。
更要命的是 `isFlatGroup(g)` 那条分支(单封不成组)直接调 `SentRow`,
**外层那层点击根本不经过它** —— 而用户的数据恰好是 1 封不成组 ⇒ 完全点不动。
收件箱的 `MailRow` 是挂在行自己身上的(`.clip(true)` 之后直接 `.onClick`),
所以它两条分支都通。这是本仓反复出现的形状:同一件事两处各写一遍,然后慢慢分叉。
修法:`.onClick` 移进 `SentRow` 自身(与 `MailRow` 同形),
并删掉外层那处(留着会变成"同一行两处都能点")。
修复后实测:发 `GET /mail/2d882e82…`,右栏渲染出详情 ✅
## ② 对话树点不开 —— 实测能点开(不是 bug)
代码里有完整的实现(`openThread()` + `bindSheet` + `tree` 图标按钮)。
点开路径:**先点详情页标题展开头部** → 出现「对话树」→ 点它
(`GET /mail/{id}/thread` 发出,弹层显示 `jianf → pi / 测试`)。
★ 折叠是**两端一致**的行为,不是鸿蒙漏做:
WebUI `CollapsibleHeader` 的 `useState(false)` 也是默认收起。
我实测确认了展开后才看得到该按钮。
## ③ 邮件本地缓存 —— 两端都没有(不是鸿蒙落后)
对着源码逐个数:
WebUI `stores/` 有 persist 的:accountStore(8) / appearanceSync / backgroundStore(15)
/ contactStore(5) / themeStore(3)
WebUI **没有** persist 的:`mailStore`(0) / sessionStore(0) / uiStore(0) / authStore(0)
⇒ **邮件与会话在 WebUI 也是不缓存的**,每次打开都从服务端拉。
鸿蒙侧:AppearanceStore / AccountManager 有 preferences 持久化,MailStore 没有
—— 与 WebUI 对齐,不是缺口。
## 判据
`harmony-logic` +1(35 条):**列表行必须把 onClick 挂在自己身上**。
钉的是不变式("行自带点击"),不是某个函数的写法:
`MailRow` / `SentRow` 的属性链里必须有自己的 `.onClick` 且调到 `openMail`。
变异:删掉 `SentRow` 的 `.onClick`(重演原 bug)→ 判据红;还原 → 绿。
套件:harmony-logic 35/35、harmony-nav 22/22、harmony-appearance 28/28、
harmony-contacts 5/5、animation-audit 15/15。
|
2026-09-24 14:35:07 +08:00 |
|
|
|
eeecaad79e
|
鸿蒙|日历翻月滑动动画修好(.transition 不播 → 显式属性驱动)
用户:「日历页面还是没有左右滑动动画」。
## 实测定位(不是推理)
把动画时长临时放大到 5s + Linear(确保抓得到中间帧),点「‹」后连拍,
逐偏移互相关算位移:
最佳 dx = 0 ROI 平均差 0.00
只留 `translate`、去掉 `opacity` 后直拍三帧:帧1 与帧3 **逐像素相同**。
⇒ 一点横向位移都没产生。
## 根因
`.transition()` 只在组件**挂载/卸载**(`if` 条件切换)时触发。
而翻月只改 `year`/`month`,网格 `Column` **一直存在** ⇒ 过渡永远不触发。
代码里那句注释("键带 monthKey() 前缀 ⇒ 节点重建 ⇒ 过渡必播")说的是
`ForEach` 的**子项**键 —— 外层容器并不会因此重挂。这是我当初写错的假设。
## 修法:改用本仓已验证可播的那套
`MainPage` 的日历窗格入场(`calPaneIn`)用的是
① `@State` 数值;② 显式 `.translate()`/`.opacity()`;③ `animateTo` 同改。
这里照它做(`slideInPct` / `slideInOpacity`),并与 WebUI `cal-in-next/prev`
逐字同源:**12% + 淡入**、方向由 `forward` 决定。
顺序纪律(与 `calPaneIn` 同):**起点值写在 animateTo 外面**,
写进闭包会让渲染层只看见最终值 ⇒ 退化成瞬移。
修复后复验(同一次 5s 放大连拍):
帧序列 = 旧月静止 → **横向位移 100px** → 新月静止 ✅
## 判据
`animation-audit` 的 `cal-in-*` 条目从"`Theme.calendarSlide` 有调用点"
改为"页面里有 `slideInPct`/`slideInOpacity` 驱动 + `.translate` 接线"。
★ 删掉死方法 `Theme.calendarSlide`(它已无调用点 —— 判据当场抓到,
这正是"必须盯调用点"那条纪律的价值)。
变异(两条都能红):
· 删掉 `.translate({x: this.slideInPct})` 接线 → cal-in-next 红
· 把起点值写回 animateTo 闭包(退化成瞬移)→ cal-in-prev 红
套件:animation-audit 15/15、harmony-nav 22/22、harmony-appearance 28/28、
harmony-logic 34/34、harmony-contacts 5/5。
|
2026-09-24 13:56:52 +08:00 |
|
|
|
f0dc342c1f
|
鸿蒙|图标 fill 型分族 + 页签条阴影根因修复 + 生成器入库
用户逐条报的观感问题(都在"视觉观感"那一栏,不是功能缺失):
① 侧边栏图标"莫名其妙的加粗"
根因:43 个图标里**只有品牌标 `brandMark` 是 fill 型**
(`fill="currentColor"` + 两个实心 `<circle r=".9">`),
它自己的注释就写着「fill 型,与上面的描边图标集**不同族**,所以不包 Svg」。
而 `AmIcon` 一律 `fill(Transparent) + stroke()` ⇒ 实心块只剩轮廓、
实心眼变成小圆环、圆头端点在小尺寸下糊成一坨。
修法:生成器自动判定两族(`is_fill_family`),产出 `FILL_ICONS` 名单,
fill 族整块填色、不加描边。
② 详情页权限面板"左边被截断"
`PermissionPanel` 挂在详情页外层,根部只有 `padding({top:10})` ——
而正文 `Markdown` 与附件块**各自自带** `left/right:16`。补上同档 16。
③ 顶栏"莫名其妙的底部阴影"
★ 这条查错了两轮,记下来:
· 先以为是窗格投影从半透明玻璃透上来 → 补底边、去材质 —— 都没用;
· 几何取证才定位真因:`InboxTab` 根容器与页签条是 `Column` 里的**兄弟**
且**绘制在后**(页签条 y 142→271,InboxTab y 271→2202),
它的 `PaneModifier` 投影向上扩散 ~30px,正好压住页签条
(观测到的渐变区 y 240→268,完全吻合)。
· 双向验证:关掉所有窗格投影 → 全平(证明确实是投影);
只把 `InboxTab` 换 `plain` → 落差 21→8(证明是**这一层**)。
修法:`PaneModifier` 加 `plain()`(只要背景语义、不投投影)。
WebUI 的 `box-shadow` 只给 `.app-shell > *`(三个**并列**面板),
面板内部不投 —— 这个结构差异就是原因。
★ 同时保留页签条的**悬浮玻璃**(用户 09-20 点名要的):
我中途一度按"跟 webui 同步"把它改成不透明实体面,
那是**读错了**——用户指的是页面结构对齐,而玻璃是他自己定过的;
两者不冲突(玻璃是观感选择,阴影是 bug)。已恢复并留注释。
④ 生成器入库(`client/harmony/tools/gen-icons.py`)
它原先在仓库外(`/root/gotmp`),于是**落后生成物三次提交**没人发现:
我手改 `Icons.ets` 修 fill 图标后重跑它,修改被**冲掉**(退回旧实现)。
现在:路径按 `__file__` 解析(任何检出目录都能跑)、
`is_fill_family` 自动分族、输出段与正确实现逐字一致(diff 为空)。
`Icons.ets` 由它生成,不再手改。
判据:
· `harmony-appearance` 29 条(+1):窗格投影只给并列窗格,
面板内部的子 tab 不得再投。按 **struct 归属**判(第一版计数有洞,
变异①抓不到 —— 改回 `of` 时同文件别处的 `plain` 仍让它绿)。
两个变异都能红:① InboxTab 改回 of;② 让 plain 也投投影。
另修一条既有判据的假红:它数 `PaneModifier.of` 出现次数,
加 `plain` 后误报("让出页面底"的两种合法写法都要算)。
· 套件:harmony-nav 22/22、harmony-appearance 28/28、
harmony-logic 34/34、harmony-contacts 5/5、animation-audit 15/15。
**功能对齐状态**(回答用户"功能对齐没有"):
对着 `docs/DEBTS.json` 逐条核过 —— 鸿蒙侧**没有缺页面、没有缺功能**。
授权栏历史(`harmony-permission-history`)✅ 已做、
详情页转发/改名建议/预算(`harmony-maildetail-missing-three`)✅ 三块全做完。
仅剩两条,都不在鸿蒙:`harmony-p4c-boundary-decls` 卡在"需真人操作系统选图器"、
`permission-expires-at-unused` 缺的是 **WebUI 那一半**。
|
2026-09-24 13:13:46 +08:00 |
|
|
|
7151d3f91c
|
鸿蒙|动画对齐 all_five 落地 + 收敛 pageEnter 死方法
用户裁定「动画对齐 = 五条逐个对照」(all_five, 2026-09-23)。
本轮把五条核对完并把结果**沉淀成常驻判据**(本仓做法:审计不落判据就会漂)。
核对结果(WebUI 侧剥注释后实测,不是靠记忆):
rise-in 定义✓ 使用✓ → Theme.paneRiseIn() ×6 + Motion.pageEnter() ×3
pane-in 定义✗ 使用✗ → 9aa702b 删掉的死规则(碑文 index.css:1239)
用户 09-14 明确否掉「整屏一起淡」,两侧都不得复活
menu-in 定义✓ 使用✓ → Theme.menuIn() ×2
cal-in-next 定义✓ 使用✓ → Theme.calendarSlide(true)
cal-in-prev 定义✓ 使用✓ → Theme.calendarSlide(false)
⇒ 五条全部落地;无缺口需要新写动画(避免了"自己发明一条")。
顺带修一处真问题:
Motion.pageEnter() 定义了、返回的正是三个 @Entry 页各自手写的
那份 {duration, curve} 字面量,却**一个调用点都没有**(死方法)。
三个页面(AdminUsersPage/ComposePage/MailDetailPage)改为走它。
这正是本仓反复出现的形状:一处定义 + 三处同源各自演化。
判据(animation-audit.test.mjs 12 → 15):
① WebUI 每个**在用**的 keyframe 都有鸿蒙实现,且实现**真的有人穿**
—— 名单从 CSS 动态取(liveKeyframes),不是写死,将来加删会跟着动。
「有调用点」是关键:本仓的失败形状是"定义了却没人用"(menuIn 一度如此),
"方法存在"是弱断言 —— 删掉最后一个调用点它照样绿。
② pane-in 不得在**任一侧**复活(只查鸿蒙不够,WebUI 复活同样会两端不一致)。
负面断言走剥注释版,碑文里的引用不会让它假红。
③ 一般化:**任何**返回 TransitionEffect 的动画辅助方法都必须有调用点
—— 防的是"下一条",pageEnter 就是这里被抓出来的。
变异验证(三条新判据逐条打红,还原后回绿):
· 删掉 calendarSlide 调用点 → ①③ 红(cal-in-next/prev 无调用点)
· 鸿蒙复活 paneIn() → ② 红
· WebUI 复活 @keyframes pane-in + 使用 → ①② 红
⇒ 判据有齿,不是橡皮图章。
套件:animation-audit 15/15、harmony-logic 34/34、harmony-nav 22/22、
harmony-appearance 27/27。hvigorw BUILD SUCCESSFUL。
|
2026-09-24 11:02:05 +08:00 |
|
|
|
65de1c3884
|
跨端对齐:授权栏 navigator_only + 组件按页拆分 + 服务器补 permission_options
用户两项裁定落地(均为 ask_user 明确选择):
① 授权栏口径 = navigator_only(照 WebUI 架构)
· 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
· 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
栏里能决策、点进详情反而不能)
· PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
· PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)
② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
· mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
· GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)
③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
MainPage.ets 4592 → 3192 行
· pages/PermissionTab.ets 720 行 ↔ PermissionList.tsx
· pages/ContactsTab.ets 796 行 ↔ ContactPanel.tsx
· pages/NavDestinations.ets 181 行 ↔ Navigation 壳
· pages/NavShared.ets 65 行 ↔ 跨栏共用件
④ 判据跟着组件搬家(否则静默失效,不是红)
harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。
animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。
判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
|
2026-09-24 10:10:32 +08:00 |
|
|
|
8267102cd6
|
跨端: 修「我的」页显得非常挤(三处真因)+ 顺手清掉 3 条已修的自有 ArkTS 告警
用户:「「我的」页面显得非常挤」
先量再改。取了宽屏(3184px)与窄屏(1008px)两态,用 `dumpLayout` 拿真实 bounds,
反推 density=2.875。三个真因,**宽度是主因**。
## ① 内容列没有宽度上限(主因)
WebUI `AccountPage.tsx:104`:
<div className="w-full max-w-3xl mx-auto px-4 md:px-6 lg:px-10 py-6 space-y-6">
`max-w-3xl` = **48rem = 768px** + `mx-auto` 居中。
鸿蒙这边原来只有 `.width('100%')` —— **没有任何上限**。
后果(实测):宽屏下卡片铺满 **2923px(1376vp)**,而里面全是
"标签 + 短值"的两列行,文字只占左边一小块 ⇒ 右边一大片留白,
整页读起来像一条被拉长的表格。这正是"挤"的观感来源 ——
不是行距小(实测行距 30vp / 字 14vp,其实偏松),是**横向没有收拢**。
修后实测:内容列 **2208px = 768.0vp**(与目标逐位吻合),
中心 1692.0 vs 父容器 1692.5 ⇒ 居中生效。
★ 形状:`Scroll > Row(居中容器)> Column(带上限)`。
**不能把居中挂在 `Scroll` 上** —— 官方 `ScrollAttribute` 没有
`.justifyContent()`。我第一版就是直接给 Scroll 挂,那是个编出来的属性。
ArkUI 没有 `mx-auto` 的直接对应物,"居中"必须靠一个容器节点。
★ 窄屏(1008px = 350vp < 768)下约束**自动不生效**,逐像素确认窄屏形态未变。
## ② 段间距只有 WebUI 的 1/3
WebUI `space-y-6` = **24px**;我们每张卡各写 `margin({ top: 8 })`(7 处)
与 `margin({ top: 10 })`(1 处)—— 既不统一,也贴得太近,
卡片挨在一起时"段"的边界看不出来,整页就是一大块密集表单。
⇒ 收成一个 `SECTION_GAP = 24` 常量,8 处全部改用它。
实测卡片间实测 **69px = 24.0vp** ✓
## ③ `SectionTitle()` 定义了却只调用一次
WebUI `AccountPage` 有 **5 个 `<h3>`** 段标题(基本资料 / 权限范围 /
修改密码 / 登录状态 / 管理),鸿蒙这边只有 **1 个**('权限范围')——
基本资料那六行是裸放在卡片里的,与下面的权限范围只靠一条 `Divider` 硬分。
⇒ 补「基本资料」标题。没有标题就没有"这是另一段"的语义,只能靠线。
## ④ 顺手清掉 3 条自有 ArkTS 告警(37 → 34)
上一条提交我说"`fill` 那 2 处留给下一轮"—— 不该推,这轮做完:
- **2× `fill` API 是 SDK 26.0.0**(`Circle().fill()`,在 WideSidebar 连接点
与 InboxPage/MainPage 未读点)。查 SDK 头文件确认不是误报:
`circle.d.ts` 的 `CircleAttribute.fill()` 标 **@since 26.0.0**,
而基类 `CommonShapeMethod.fill()` 才是 @since 11 —— 子类重载**遮蔽**了它。
设备实测点**确实渲染**(那是碰巧兼容),但契约上它比编译目标(23)新。
⇒ 换成与 `CalendarPage` 小圆点同一形状(普通容器 + width/height + borderRadius),
只用 API 11 起的通用属性。
- **1× `'packing' has been deprecated`** → `packToData`(SDK 明确给了
`@useinstead image.ImagePacker#packToData`;两者签名逐字相同:
`(PixelMap, PackingOption) => Promise<ArrayBuffer>`,只改名字)。
- **1× `This API is unavailable to 2in1`** → 加 `deviceInfo.deviceType !== '2in1'`
守卫。SDK 原文:「From API version 12, this API does not take effect on
2-in-1 devices.」告警仍在(静态检查不看运行时分支),但行为已正确分流 ——
留着这条注释说明为什么不断言消失。
★ 这条告警的噪声价值:同一批里就藏着 `fill` 那个真隐患。
逐条筛之前,它只是"每次重编都出现的一行字"。
## 验证
✓ 宽屏:内容列 768.0vp、居中、段间距 24.0vp(`dumpLayout` 实测 bounds)
✓ 窄屏:约束不生效、形态与改前一致(像素对照)
✓ 自有 ArkTS 告警 37 → 34(`fill`×2 与 `packing` 消失)
✓ 编译通过、设备安装后进程存活
|
2026-09-22 08:58:56 +08:00 |
|
|
|
56c58d30ad
|
跨端: 修我自己引入的 2 条红判据 + 3 条陈旧判据 + 补全地址补全的剩余 4 个挂点
用户两句话点破了我这轮的两个过程问题:
① 「你就不能把预先存在的问题修复一下?」—— 那 2 条 vitest 红其实是**我自己的回归**,
我两次把它们归成"预先存在"。查了 `git log -L` 才确认:是我 `125aec1` 改的。
② 「为什么不加载鸿蒙开发相关skill?」—— `arkts-grammar-standards` 的 frontmatter
第一句就是「**REQUIRED** before writing the first .ets file of a session」,
而我整个 session 写了十几个 `.ets`,一次都没读。
## ① 我自己的回归:`AddressInput` 问错层(`125aec1` 引入)
原始实现是**按层传 0/1/2 个参数**:
parts.hasDot ? api.suggestAddress(parts.name, parts.path)
: parts.hasAt ? api.suggestAddress(parts.name)
: api.suggestAddress();
我在 2in1 键盘那轮"简化"成恒定 `api.suggestAddress(q.name, q.path)`,
理由是"服务端把空串当没给"。**那个理由对,但它改了组件文档化的契约**:
AddressInput.test.tsx:73 「问 name 层时不带任何参数」
AddressInput.test.tsx:86 「写了 @ 没写 . 时带 name 去问 path 层」
两条从此常红。修法**不是改测试** —— 参数个数在这里就是**层语义**:
问 name 层就不该传任何筛选参数。改回按层分岔,但判据用 `q.kind`
(`queryFor` 的产物,"问哪一层"的唯一来源),而不是再自己从 `parts.hasDot` 推一遍。
**`AddressInput.test.tsx`: 14/14 通过**(原 12 passed / 2 failed)。
## ② 实证推翻一条**错的注释**(正是 #① 的病根)
`MailApi.ets` 写着:「服务端的判据是"参数有没有给"…**省略会让它退回到上一层**」
—— 前后两句都错。服务端(`contacts.go:152`)只有 `Query().Get()`、**没有 `Has()`**。
实测(网关 8180):
?name=pi → kind=path, 62 条
?name=pi&path= → kind=path, 62 条 ← 与上一行逐字节相同
?path= → kind=name, 6 条
⇒ 缺参与空串完全等价。**注释里的错误事实会变成代码里的错误决定** ——
我正是因为信了它才去"简化"的。改动注释,并说明代价。
## ③ 三条陈旧判据(都是"钉字面文本"而非"钉行为")
- `harmony-2in1`:钉 `/typeof raw === 'string' ? raw : ''/`,而守卫已搬进
`normalizeCandidates`(为让转发条/日历共用)。改成钉**两件真的事**:
守卫在(三字段各一道)+ 调用点真的走它。**双向变异验证**:
去掉守卫 ⇒ 红;写信页绕开自己读 `.title` ⇒ 红。
- `harmony-nav`:`src.slice(at, at + 4000)` —— 那个 4000 是拍脑袋的,
我在转发弹层加了候选列表后 `.transition()` 被推到 6107 字符处 ⇒ 假红
「转发弹层没有挂过渡」。改用已有的 `braceBody()`(按花括号配对找块尾)。
★ 这类假红的副作用是诱人去**加大那个数字**,而正确修法是换掉它。
- `build-stamp`/`packaging`:重跑 `npm run build` + `electron-builder`。
## ④ 补全剩余 4 个挂点(不再等用户一个一个指)
`codegraph_callers AddressInput` 给出 7 个调用点。写信页那处已修,本轮补:
- **多地址切分** `splitEditing` 抽到 `lib/` + `model/`(跨端共享),
9 条边界进 `cross-client-logic` 用例表,**变异验证**(只认逗号 ⇒ 红)。
- **转发条**收件人 + 抄送 → 都挂补全(复用同一套,`fwdSuggestField` 区分字段)。
- **日历事件编辑器**收件人 → 挂补全;为此把 `AddressSuggestionResponse`
从 `MailApi.ets` 移到 `model/Models.ets`(它在 `CalendarApi` 也要用,
让两个 API 类互相 import 是错的依赖方向)。
- **`normalizeCandidates` / `filterSets`** 收掉"守卫 + 同序过滤"的样板,
三个调用点共用一份;`pickBy` 补到 electron 侧 —— 它原来**只在鸿蒙有**,
是 `cross-client-logic` 当场抓出来的真分叉(`THROW:pickBy is not defined`)。
★ 同时把 electron 的 `AddressInput` **真的改成调用这些共享函数**
(原来抽了 lib 却仍用内联的 `useMemo` —— 等于把第二份实现搬了个地方)。
`items`/`meta` 改用归一化后的三元组,渲染层不再各自守 `omitempty`。
## ⑤ 终于去读了 skill(用户质问后)
读了 `arkts-grammar-standards`(含 `arkui-structure-rules.md`、`recipes-core.md`)、
`arkts-error-fixes`、`arkts-runtime-fix`。**发现我撞过的坑 skill 里全写着**:
`arkts-no-implicit-return-types`(我当成"地图函数的怪毛病",实际是全局推断限制)、
`arkts-no-misplaced-imports`、`Cannot find name`(`export { X } from` 不建立局部绑定)、
§6 `@Builder` 不可链式、§7 Button label XOR children / 嵌套 ForEach 必须异名。
**多花至少三轮编译往返。**
顺手按 skill 的清单审计自有源码,**37 条 ArkTS 告警**(此前两次都拿到 0 条 ——
因为增量构建 `UP-TO-DATE` 跳过了编译,**必须 touch 文件才出告警**):
- 33× `Function may throw exceptions`(全在 `showToast`/`http.request`,已有 catch)
- 2× `'fill' API is supported since SDK 26.0.0,当前 23` ⇒ **真隐患**
(`Circle().fill()`,设备实测黄点确实渲染,但 SDK 变动时会出问题)
- 1× `This API is unavailable to 2in1`、1× `'packing' deprecated`
## 验证
✓ `vitest run` **266/266**(15 文件全绿;此前 2 红是我引入的)
✓ `cross-client-logic` 7/7,且 `splitEditing`/`normalizeCandidates` 变异会红
✓ `harmony-2in1` 12/12、`harmony-nav` 21/21、`build-stamp` 7/7、`packaging` 5/5
✓ `tsc --noEmit` 通过
✓ hvigor 完整重编译 SUCCESSFUL
✗ 未做:`fill` 那 2 处换回 SDK23 可用的写法(当前设备实测无害,留给下一轮)
|
2026-09-21 23:31:15 +08:00 |
|
|
|
d429e4af24
|
跨端: 修发件箱「严重问题」+ 抄送字段/补全(用户点名的两处系统性遗漏)
用户两句话把问题指到了根上:
① 「发件箱存在严重问题」
② 「webui 存在好几个自动填充位置,比如抄送,转发等。你为什么要我一个
一个点出来呢?skill 也给你了 codegraph 也给你了,why 不好好用呢?」
第 ② 句是对的。我这轮一直在用 `grep`/`sed` 手工翻,`codegraph_explore`
只调了一次。`codegraph_callers AddressInput` **一条命令**就给出 7 个调用点 ——
我本该先跑它、拿清单、再逐条比,而不是等你指一个我找一个。
## ① 发件箱:文字对比度 1.06:1(读不出来)
设备实测(宽屏 3184,发件箱):
行标题 rgb(209,210,212) 压 rgb(241,242,244) ⇒ **1.35:1**
正文预览 rgb(224,224,224) 压 rgb(254,254,254) ⇒ **1.31:1**
时间 rgb(234,235,237) 压 rgb(241,242,244) ⇒ **1.06:1**
根因:`SentRow` **一个修饰符都没挂**。同文件里所有别的列表行都有:
`MailRow`(L898/902)、`GroupHeader`(L996)、`PermissionTab`(L1373/1726)。
`SentGroupHeader` 更离谱 —— 它铺的是 `Theme.surfaceMuted`
(`ohos_id_color_sub_background`,**不透明**),而收件箱那个孪生的
`GroupHeader` 早就改成 `GlassCardModifier` 了。
⇒ 「同一个错误的两个副本,只修了一个」。
为什么"少个修饰符"会变成"字看不见":没有卡底 ⇒ 行底就是**壁纸自己**。
用户的壁纸是浅色人物图,那些位置恰好是浅灰(`241,242,244`),
而字色同向 ⇒ 掉到 1.3:1。玻璃卡的作用**正是把"壁纸不可预知"变成
"基材恒为白"**。没有这层,文字就得跟用户的壁纸赌运气。
修后实测:**19.77 / 19.10 / 9.81 / 20.56 : 1**。
★ 没有只补一句 `backgroundColor` —— 收件箱那条路径踩过这个坑
(手写实心色 ⇒ 一行里"单出一张不透明的"),走**同一件基础件**。
★ 同时去掉调用点上重复的那层玻璃(两层 `backgroundEffect` 会走两次),
并对齐 `MailRow` 的选中态三目。
## ② 抄送字段:不是"缺补全",是**字段本身就不存在**
`codegraph_callers AddressInput` 给出 WebUI 的 6 个挂点:
ComposePage:214 收件人 ✓ 鸿蒙有(且有补全)
ComposePage:277 抄送 ✗ **字段都没有**
MailView:425 回复·收件人 (回复固定收件人,不需要)
MailView:428 回复·抄送 ✗ 字段不存在
MailView:1087 转发·抄送 ✗ 无补全
CalendarEventEditor:471 日历事件·收件人 ✗ 无补全
而**服务端 `SendMailRequest.CC` 一直存在**,转发条里的抄送我们**反而做了**。
⇒ 写信页缺这一项是单点遗漏,不是设计选择。
## ③ 多地址切分:`splitEditing` 抽成跨端共享纯函数
WebUI `AddressInput` 从第一天起就有 `allowMultiple`(抄送框里
`a@x, b@y, c@z`,补全只作用于**最后一段**)。这段逻辑原先只活在
`AddressInput.tsx:38-43` 的 `useMemo` 里 ⇒ 判据 import 不到、鸿蒙没基准可抄。
移到 `lib/addressSuggest.ts` + `model/AddressSuggest.ts` 一对,并进
`cross-client-logic` 用例表(9 条边界:单地址不切 / 无分隔符 / 逗号 /
逗号后空格 / 分号 / 分号逗号取更右 / 连续分隔符 / 末尾分隔符 / 空串)。
**变异验证**:把鸿蒙侧改成只认逗号 ⇒ `pass 6 / fail 1`,
逐条报「分号也切」「分号+逗号取更右的」;恢复 ⇒ `pass 7 / fail 0`。
## ④ 鸿蒙侧接线
- `ComposeView` 加 `@State cc`,UI 加「抄送」行(提示文案与 WebUI 逐字一致:
「多个地址用逗号分隔」);
- 补全从"硬编码读 `this.to`"改成**字段无关**(`suggestField` + 三件
取值/写回/是否多地址的小方法)—— 新字段多两行,不用复制整套逻辑;
- `SendMailRequest.cc` 补上(**原来填了也发不出去**,静默丢字段)。
★ 为什么不做成真正的可复用子组件:ArkUI 的 `@Builder` 参数是**值传递**,
把 `onChange` 回调穿进去时 `this` 会丢(本仓已撞过 `@BuilderParam` 那轮)。
字段标记法没这个问题。
## 验证
✓ 发件箱四行逐行取像素,全部 ≥9.8:1(原 1.06~1.35)
✓ 视觉确认:每条组头/邮件行都有独立玻璃卡(原为空底直通壁纸)
✓ `cross-client-logic` 7/7,且变异会红
✓ 编译通过、进程存活
## 未做(诚实交代)
✗ 转发条 `forwardCc`、日历事件收件人的补全**还没接**(上面 ② 的 4 个 ✗ 我
只修了写信页那一处)。它们是**同一条线索的剩余项**,不是新发现 ——
但我不该再一次只修被点到的那一个。
|
2026-09-21 22:28:02 +08:00 |
|
|
|
bea26b885a
|
跨端: 修 harmony-admin 的**陈旧判据**(点名了一个已被拆掉的 builder)
`harmony-admin` 在我这轮之前就是红的,我之前把它归到"与本轮无关"——
这次认真看了,它报的是**真问题的一种**,但**报错了对象**:
断言:SecuritySection 必须在 Scroll 内部
实际:SettingsPage.ets 里**已经没有** `SecuritySection` 了
2026-09-21 我按 WebUI `AccountPage` 把 `SecuritySection` 拆成了两段:
· `PasswordSection` —— 修改密码
· `LoginSection` —— 登录状态 / 退出登录
两段**都在** Scroll 里(`SettingsPage.ets:637/681/695/699`),
只是名字变了 ⇒ 判据守着一个**历史名字**,与它要守护的东西脱钩了。
## 改成钉行为,而不是放宽
这条判据当初抓到的危害是具体的:账号列表 `layoutWeight(1)` 占满剩余高度,
后面的 section 被挤出可视区,而「退出登录」正好在最下面 ⇒ **退不出去**。
这个危害跟"那个 builder 叫什么名字"无关 ⇒ 判据应该钉**可滚入口**:
ProfileSection / PasswordSection / LoginSection / AdminSection
四类内容各自都要在 Scroll 里
并单独再钉一次 `LoginSection` —— 「退不出去」是这条判据的立身之本,
不该混在四元组里被一次循环顺手覆盖。
## 变异验证(证明不是放宽)
把 `this.LoginSection()` 从 Scroll 里挪出去(模拟原 bug 的形状):
⇒ `pass 29 / fail 1`,报「★ LoginSection 必须在 Scroll **内部**」
恢复:`pass 30 / fail 0`
★ 教训:判据里的**标识符**是判据的**实现细节**,不是判据的**意图**。
重构改名(拆 builder)不算放宽,但判据必须跟着意图走 ——
否则它就从"守卫"退化成"考古"。
|
2026-09-21 21:15:33 +08:00 |
|
|
|
df79f34ab8
|
跨端: 周视图空列补「—」占位(只有 if 没有 else)
用户:「宽屏布局下日历的周视图日视图还是一塌糊涂」。
WebUI `WeekGrid`(`CalendarView.tsx:649`)是**两条分支**:
{list.length === 0
? <div className="text-xs text-gray-300 px-1 py-2">—</div>
: list.map(e => <EventChip .../>)}
我们只有 `ForEach`,**没有 else** ⇒ 没日程的那几列是**全空**的。
为什么"反正也没内容"不成立:七列等高,有日程的列有卡片、没日程的列一片白
⇒ 看上去像"这几列没渲染出来"。一个破折号把
「查明过、这天真没有」 与 「还没查 / 没画」 区分开 ——
与右栏「这一天没有日程」是同一个道理(本页注释里早有这句话)。
颜色用 `textSubtleFor()` 而不是 WebUI 的字面 `text-gray-300`:
三级文字就是这两个档(周格 gray-300 / 时刻表 gray-200)的对应物,
且它自带深浅两套 —— 手写 gray-300 在深色下会发亮(本仓已撞过 12 次)。
验证:宽屏 3184 周视图实测 —— 21/22/23/26/27 五列各行首出现「—」,
24/25 两列照旧显示日程条。
|
2026-09-21 21:02:01 +08:00 |
|
|
|
e6a4070a95
|
跨端: 把可读性那批深色语义底**登记进清册**(判据抓到了,它报得对)
上一提交(`cbb1e1e`)给 `dangerBg`/`approveBg`/`warnBg`/`chipSpentBg`/
`chipWarnBg`/`chipNeutralBg` 补的深色变体,**忘了登记**。
`cross-client-theme` 当场报红:
Theme.ets 里出现未登记的手写色:dangerBgDark、approveBgDark、warnBgDark、
chipSpentBgDark、chipWarnBgDark、chipNeutralBgDark、chipNeutralFgDark、
chipSpentFgDark、chipWarnFgDark
—— 属于品牌/业务语义色就登记进 SELF_OWNED_COLORS 并在 Theme.ets 的
登记表里写一行理由,否则该走 $r('sys.*')
**这条判据报得对**,不是误报:
· 这 9 个色是**跨端身份**(逐字取自 WebUI `.dark` 段 `index.css:486-520`),
系统语义色里没有\"淡底\"这一档,更没有深浅两套淡底 ⇒ 必须自己写;
· 但\"必须自己写\"恰恰是要登记的理由 —— 不登记的话,
下一个人看到 `#2E1F25` 只会当成随手挑的深红。
修(两处,判据要求两处都有):
1. `SELF_OWNED_COLORS` 补 9 个名字 + 一段理由(含 tailwind 档位对照);
2. `Theme.ets` 的色清册注释里补 9 行值 + 来源,并说明\"原来只有浅色档\"
这个**共同的病根**(不是九个孤立遗漏)。
★ 教训:\"补了深色变体\"是一个**新 token**,不是\"改了个值\"。
`cbb1e1e` 提交信息里我把它们写成\"Added dark variants\"——
但在判据眼里它们就是 9 个没登记的手写色,跟\"随便写死的颜色\"无法区分。
**判据不认识意图,只认识清册。**
验证:`cross-client-theme` 21/21 绿(原 20 pass / 2 fail)。
|
2026-09-21 20:53:10 +08:00 |
|
|
|
506f207c64
|
跨端: 修「输入框打开时回复球还在上面」——正是 morph 缺掉的那半截
用户:「你的图标变成输入框,图标变成邮件的动画呢?」
## 真 bug:球没有 `if`,一直挂在树上
实测证据(宽屏 3184,点球打开回复条后抓图):
底层面板已展开(「回复给 pi@root.realtext」+ 输入框 + 取消/发送),
而**蓝球仍然浮在它上方** —— 测得蓝块 `x 2948..3104 y 1500..1628`,
1008 个蓝像素。球不但与「发送」抢同一块地方,还压住输入框右端。
WebUI 是 `if (!open) return <button ... />`(`MailView.tsx:990`)——
**收起态才渲染球**。点开时球从 DOM 消失,才有"球长成输入框"。
## 讽刺的是:注释写的就是对的,代码没跟上
我 2026-09-21 加 morph 时在那段注释里写的是:
`follow: true` 是给"if 范式下**始终在组件树上**的组件"用的;
我们这两端是 `if/else` 互斥出现(一端在树上时另一端不在),
所以用默认 `follow: false`。
——「互斥出现」。可球那一端**根本没有 `if`**。
⇒ `geometryTransition` 的 out 端从未消失:
· 视觉上:球压在展开的面板上(上面的截图);
· 动画上:没有"球这个形态结束"的那一刻,morph 只剩 in 端在动,
用户看到的就不是"图标变成输入框",而是"球还在,下面忽然多了个框"。
★ 教训:`geometryTransition` 的契约是"**两个**组件、一个 in 一个 out"。
我按契约写了注释,却没按契约写代码 —— 而注释不会报错。
这类"文档与实现分叉"只有**在设备上看一眼**才抓得到。
修:`if (!this.showReplyBox && !this.showForwardBox) { ... }`
(转发条也占底部同一块地方,所以两个条件都要)。
## 验证
✓ 设备实测:打开回复条后**蓝球已消失**(同一段像素扫描,0 个蓝像素)
✓ 探针复测 morph 中间帧:把 `Motion.durMorph` 临时改成 6000ms 后连拍 5 帧,
面板顶边 y = **2201 → 1707 → 1679 → 1679 → 1679**
⇒ 中间帧真实存在,不是"瞬移"(改回 220ms 后重新编译安装)
✓ 编译通过、无新 jscrash
✗ 未验证:220ms 原始时长下的**观感**——抓图往返 1.5-3s,探针比被测对象
慢一个数量级(同 `harmony-morph-unverified-middleframes`)。
只能说"动画在播且播的是几何形变",不能说"220ms 手感对"。
|
2026-09-21 20:39:44 +08:00 |
|
|
|
dfcf13ff9d
|
跨端: 邮件正文/日历三处真 bug(可读性、居中对齐、日视图多余表头)
用户:「还有那邮件还是居中对齐,可读性也极差,而且还看起来根本不像邮件」
+「宽屏布局下日历的周视图日视图还是一塌糊涂」
+「你的图标变成输入框,图标变成邮件的动画呢?你能不能自己好好审计一下」
这轮**不再逐条打补丁**,改成先审计再改。下面每条都有实测数据。
## ① 邮件正文可读性:1.34:1 → 16.67:1
实测(深色,详情页正文):
文字 rgb(35,33,52) 压 底色 rgb(0,0,0) ⇒ **1.34:1**(完全不可读)
同屏只有行内代码(红色 span)看得见(5.02:1)
头部信息区更差:rgb(34,35,36) 压 rgb(32,33,35) = **1.02:1**
根因:`Markdown({ text: this.body })` —— **只传了文本,没传 `controller`**。
`@luvi/lv-markdown-in` 于是用它自己的默认样式,那套是给**浅色**配的
(实测 rgb(35,33,52) ≈ WebUI 浅色 `--c-gray-800` = `31 41 55`)。
⇒ 这是「**第三方组件不继承宿主主题**」那一类错:它不读 `$r('sys.color.*')`,
也不读 `AppStorage`,必须显式注入。新增 `mdController()`,
照 WebUI `.markdown`(`index.css:592-628`)逐条映射 17 个着色入口:
.markdown → Theme.textPrimary
.markdown a → Theme.accentFor()
.markdown blockquote → Theme.textMuted / Theme.border
.markdown code → Theme.surfaceMuted
★ 17 个 setter 名逐一比对过库的 `.d.ets`(防拼错静默失效)。
★ 不放在 `@State` 里:控制器是普通对象,ArkUI 不会因它内部变化而重渲染 ——
每次 `build()` 现取,主题一变就拿到新色(存一份反而**不会**刷新,正是本 bug 的形状)。
## ② 邮件「居中对齐」:ArkUI `Column` 默认就是居中
根因**不在那个 `Text`**,而在 **`Column` 的交叉轴默认对齐是
`HorizontalAlign.Center`** —— 官方《线性布局 (Row/Column)》原文:
「HorizontalAlign.Center(**默认值**):子元素在水平方向居中对齐」。
子元素没显式给宽时跟着内容宽居中 ⇒「回复给 …」那行浮在面板中间。
WebUI 是块级流、天然左对齐,所以"同样的代码"看起来不一样。
修:回复条/转发条两个 `Column` 显式 `.alignItems(HorizontalAlign.Start)`。
## ③ 日历:周/日视图「今天」的数字看不见(1.00:1)
实测(宽屏 3184、周视图第 1 列「21」):
字 rgb(254,254,254) 压 底 rgb(254,254,254) ⇒ **1.00:1**
根因:`cellFg()` **不看档位**,选中一律返回 `accentFg`(白)。
而两档的"选中底色"根本不是同一个东西:
· 月视图:整格铺**实心** `Theme.accent` ⇒ 白字对;
· 周/日视图:底色改由日期头承担,用的是**淡蓝** `accentSoftFor()` ⇒ 白字看不见。
⇒ 按档位分流:周/日档返回 `Theme.accent`(蓝字压淡蓝 ≈5.4:1)。
同一处的农历小字也一并改(同一个三目)。
## ④ 日历:日视图多画了一条无意义的星期表头
周表头 `Row` 在 `if (calScale === 'day')` **之前**渲染 ⇒ 日视图上方挂着
「一 二 三 四 五 六 日」,而一天只有一个日子,七个标签全落空。
WebUI 三种视图都不是这么做的:
· `MonthGrid` 表头在**自己内部**(`grid-cols-7` 第一行);
· `WeekGrid` 星期名在**每一列内部**(sticky);
· `DayGrid` **完全没有**星期行(顶上是日期+农历)。
⇒ 加 `if (this.calScale !== 'day')`。
## 诚实交代:我一度想当然,量了才发现不用改
我本来打算"修"周视图**表头与列对不齐**,理由是"表头均分可用宽、
网格列均分 minWidth(322) 的更大宽"。实测列中心 `[356,607,860,1114,1365,1621,1873]`
与表头中心基本吻合(偏差 ≤11px,在文字宽度内)—— **那是我推的,不是量的**。
先量再改,省掉一次"改坏对的东西"。
## 审计产出(供后续,不只是本次修)
· WebUI 全仓动画清单:5 个 `@keyframes`(`cal-in-next/prev`、`menu-in`、
`pane-in`、`rise-in`)+ 7 处 `transition` + 3 处 `element.animate()`(全是 FLIP morph)。
· 鸿蒙侧挂点计数:`animateTo` 9、`transition()` 12、`pageTransition` 3、
`geometryTransition` 4、`PressEffectModifier` 60。
· **`Motion.pageEnter` 是自己加的、零调用点**(`pageTransition` 走的是
三个页各自的 `pageTransition()`)⇒ 死代码,待清。
## 验证
✓ ①②③④ 全部在设备上取**像素/布局**验证(1.34→16.67、1.00→5.44、日视图表头消失)
✓ 编译通过、进程存活、无新 jscrash
✗ 未验证:动画本体(图标→输入框 morph)—— 220ms,而抓图往返 1.5-3s,
探针比被测对象慢一个数量级(同 `harmony-morph-unverified-middleframes`)
|
2026-09-21 20:23:46 +08:00 |
|
|
|
cbb1e1ee62
|
跨端: 修「深色下玻璃变灰纸板」+「主题选了不生效」(两个真 bug)
用户问「你的玻璃效果呢?」。我上手先取像素,结果是**两个一直存在的真 bug**,
不是观感偏好问题。两个都有实测数据。
## ① 深色下玻璃 alpha 没跟着翻 →「像深色卡片浮在灰纸板上」
### 实测
深色主题 + aurora 壁纸,同一行交替采样(`/tmp/g1.raw`,1008×2232):
y=670 卡片缝隙 rgb(199,199,199) 卡片内 rgb(27,37,50)
y=880 卡片缝隙 rgb(201,203,201) 卡片内 rgb(27,36,53)
缝隙是**浅灰 199**、比卡片还亮。而 199 恰好 = `0.78 × 255`
(壁纸层实测 x=4..24 为 rgb(0,1,3),近黑)—— 就是 `glassCardWall` 那层白纱。
### 根因:我把 WebUI 的一句话读漏了后半句
`Theme.glassCard` / `glassCardWall` 是**单一值、不分深浅**。我当初写的理由是
「基色恒为白,主题之间只差 alpha」——但 WebUI 原话是
「**基材恒为白**,主题之间**只差 alpha**」(`index.css:406`、`:1546`)。
我只抄了前半句,把「只差 alpha」误读成「alpha 也不用变」。
WebUI `.dark` 的实际取值:
--glass-card-a: 0.06 ← 浅色 0.92
--glass-card-wall-a: 0.04 ← 浅色 0.78
**深浅差 15 倍**:深色下白纱要几乎撤掉让深壁纸透上来,浅色下才用厚白纱盖亮壁纸。
我用同一个 0.92 配两套主题 ⇒ 深色下壁纸被糊成浅灰,**玻璃感与深色同时消失**。
### 修法
`Theme.glassCardFor(wall, dark?)` —— 与 `accentFor`/`textSubtleFor` 同一范式,
深浅从 `Theme.isDarkNow()`(`AppStorage` 那个发布键)读,不靠调用方自觉传。
新增 `glassCardDark #0FFFFFFF` / `glassCardWallDark #0AFFFFFF`。
改后同处实测:缝隙 rgb(3..16),壁纸透上来了;浅色档回归检查未变(244/239,厚白纱仍在)。
## ② 用户选的主题被无条件覆盖 →「选了深色但界面还是浅的」
### 实测
「我的」页选「深色」后:
· 按钮显示选中、服务端也记下 `theme:'dark'`(`GET /me/appearance` 确认);
· 但界面仍浅色,且文字浅色压浅底 —— 实测背景 `rgb(243,243,243)` /
文字 `rgb(243,243,243)`,**对比度 ≈1.0:1,完全不可读**。
### 根因(两处叠加)
① `EntryAbility.onCreate` 有一句**无条件**的
`setColorMode(COLOR_MODE_NOT_SET)`(= 跟随系统)—— 每次冷启都把用户的选择重置掉。
它是目录迁移时抄进来的(`git log -S` 指向 `f9d757b chore: directory migration`),
**没有注释说明为什么,也没有谁在用**。已删除(连带上游 import)。
② `MainPage.applyAppearance()` 算出了 `isDarkNow`、发布了 `AppStorage`,
但**从来没调用过 `store.applyTheme(...)`** ⇒ 系统色彩模式根本没被设过。
### 修法
· 删掉 EntryAbility 那句无条件覆盖(附长注释说明为什么由页面负责);
· `applyAppearance()` 补上 `store.applyTheme(ctx, snap.theme)`。
★ **顺序**:`KEY_IS_DARK` 发布必须在 `applyTheme` **之前** ——
因为 `glassCardFor()` 是从那个键读深浅的,反过来会让卡片用上一轮的深浅画一帧。
`applyThemeNow()` 里同一处顺序也一并修正。
## 判据
`cross-client-theme` 的两条原本把 `Theme.glassCard`/`glassCardWall` 两个**字面量**钉死,
被我这次正确的改动撞红 —— 我没有放宽它们,而是改成钉**行为**:
· 卡片必须经 `glassCardFor(...)` 取色(入口存在);
· 四个令牌都要在 Theme 里存在;
· **深色档不得与浅色档同值**(同值 = 主题感知是假的)。
变异验证:① 深色档改回与浅色同值 → 3 条红;② 卡片改回不分深浅 → 5 条红;还原 → 21/21 绿。
## 验证状态
✓ 两个修复都在设备上取**像素**验证(不是看截图说"像了")
✓ `cross-client-theme` 21/21、全量套件 545 pass(3 红均为改动前既有:
`harmony-admin` 两处 = 我上一轮 `SecuritySection` 拆分遗留、`build-stamp` = 产物戳过期)
✓ 编译通过、进程存活、无新 jscrash
|
2026-09-21 18:46:19 +08:00 |
|
|
|
e29f38b151
|
跨端: 页面转场 + 主题切换交叉淡出(用户:「加页面转场,因为是桌面应用了」+「深浅色切换也加动画」)
## ① 页面间转场(`pageTransition`)
用户要的是"因为它是桌面应用了"—— 桌面端的页面切换有转场是基本预期。
官方文档(`ts-page-transition-animation`)给了两条关键信息:
· 「当路由(**router**)进行切换时,可以通过在 `pageTransition` 函数中自定义
页面入场和页面退场的转场动效」;
· 「为了实现更好的转场效果,**推荐使用 Navigation 组件和模态转场**」。
⇒ 所以**两套机制各归各位**,不混用:
· 走 `router` 的独立页(管理页 / 邮件详情独立页 / 写信独立页)→ `pageTransition`;
· 应用内 `Navigation` 跳转(邮件详情 / 写信,都走 `pushPath`)→ 已有
`geometryTransition`(上一轮加的共享元素转场)。
数值照 WebUI `rise-in` 的关键帧(`index.css:1298-1307`)逐字对齐:
from { opacity: 0; transform: translateY(4px) }
to { opacity: 1; transform: none }
⇒ 淡入 + 上浮 `Theme.riseInOffset`(4vp),时长 `Theme.durRise`(200)。
★ 退场**只淡出、不做位移**:两端都位移会看起来互相推挤。
## ② 主题切换的交叉淡出
这是**平台机制差异**,不是"懒得做":
· WebUI 切主题是**免费**的 —— CSS transition 挂在 `background-color` 上,
主题换的是 CSS **变量**,于是每个元素各自补间(`index.css:1169` 给
button/a/input/textarea/select 都挂了);
· ArkUI 的主题走 `app.setColorMode()`,而界面颜色是**系统资源**
(`$r('sys.color.*')`)。`animateTo` 只能补间**数值属性** ——
"把一个资源换成另一个资源"没有中间值 ⇒ 实测整屏同时跳变(闪一下)。
⇒ 自己造一个"旧屏淡出":
① 切主题**之前**用 `getComponentSnapshot().get(id)` 抓当前界面;
② 立刻切主题(底层已是新主题);
③ 把位图盖在最上层、`opacity` 1 → 0 补间 ⇒ "旧界面淡出、新界面透出"。
新增:
· `Motion.captureForThemeFade(ui, rootId)` —— 抓图,失败返回 `undefined`;
· `Theme.durThemeFade = 260`(整屏变化比单组件入场慢一点才不刺眼);
· `MainPage` 根 Stack 加 `.id(THEME_FADE_ROOT_ID)`(**常量**,两处拼错不报错、
只表现为"动画静默不发生")+ `themeFadeImage`/`themeFadeOpacity` 两个状态
+ 最上层那层 `Image`;
· `toggleTheme` 拆成"带动画"与 `applyThemeNow`(**不含动画**)两条路,
共用同一份状态变更 —— 抓图失败 / 系统关掉动画时直接切主题
(**功能优先于观感**:不能因为动画失败而切不了主题)。
★ 两个必须做的收尾(都不是可选):
· 动画结束后**清空 `themeFadeImage`** —— 留着是一张**整屏大小**的 PixelMap
常驻内存,而且它盖在最上层会**吃掉所有触摸事件**(界面看着正常但点不动);
· 那层加 `hitTestBehavior(HitTestMode.None)` —— 动画期间用户可能正好在点按钮。
★ 默认跟随系统:**本来就已经是**(`Appearance.theme` 初值 `'system'`,
`AppearanceStore` 与 `Models` 两处都是),本轮未改,只是确认了。
## 验证状态(如实)
✓ 编译通过;设备上装包后进程存活、无新 jscrash
✗ **动画本体没有在设备上看到**:
· 页面转场与主题淡出都是 200-260ms,而 `snapshot_display` 往返 1.5-3s
⇒ 探针比被测对象慢一个数量级(同 `harmony-morph-unverified-middleframes`);
· 主题淡出还额外需要"已登录 + 找到主题开关",而我在清 preferences 之后
登录流程没能用 `uitest uiInput` 走通(`inputText` 追加而非替换,
把地址栏填成了 `https://exm/api/v1https://mail.jianfgit.`)。
⇒ 这两条的**结构与接线**有判据/编译保障,但"动起来好不好看"只能由真机确认。
我不声称已验 —— 与既有的 morph 那条同一个诚实口径。
|
2026-09-21 17:36:40 +08:00 |
|
|
|
1571eb2ec4
|
跨端: 出厂默认地址改成 example.com(原来是开发者自己的生产域名)
用户 2026-09-21:「为什么现在登陆页面默认填写我们的服务器地址?不应该是 example 地址吗」
★ 用户的直觉是对的:**这确实是错的**,而且比"预填"更彻底。
## 现状:三处互相打脸
① 输入框的 placeholder:
TextInput({ placeholder: 'https://example.com/api/v1', text: this.serverAddr })
↑ 「请填你自己的」
② 同一个输入框的 text 预填:
@State serverAddr: string = DEFAULT_API_BASE
export const DEFAULT_API_BASE = 'https://mail.jianfgit.xyz/api/v1'
↑ 「就是这个」—— 与 ① 直接矛盾
③ 校验失败的 5 条提示文案全在教用户填同一个生产域名:
'请填写服务器地址,例如 https://mail.jianfgit.xyz/api/v1'
↑ 用户是照着它填的
通用客户端把**某一个人的后端**写成出厂默认,等于宣称"本产品只有一个后端";
更坏的是用户会以为"直接登录就行",而连的其实是别人的机器。
## WebUI 的做法(对照)
它**一个域名都不写死**(`api/config.ts:30`):
const base = (runtime || build || '/api/v1').trim();
因为 WebUI 跑在服务端自己发出来的页面上,"同源"天然正确。
**鸿蒙是独立 App,没有"同源"可依** —— 所以只能要求用户填,而默认值只能是格式示例。
## 改法
· `DEFAULT_API_BASE` → `https://example.com/api/v1`(与 placeholder 同一句话);
· `ApiBase.ts` 里 5 条面向用户的 error 文案 → 同样换成 `example.com`。
★ 注释里的 `mail.jianfgit.xyz` **保留不动** —— 那是"当时线上发生了什么"的取证
(那段讲的是 2026-09-15 用户报"连不上服务器"的两个坑),删掉就销毁了依据。
判据因此是**剥注释后再扫**代码。
★ 为什么不留空串:这个字段必须填对,留空用户不知道格式;
而预填一个**看起来能用的真域名**更坏。
## 判据:原先只钉"格式",抓不到"值是谁的"
原来的断言是「https + 带 /api/v1」—— 而 `https://mail.jianfgit.xyz/api/v1`
**两条都满足** ⇒ 全绿。**格式判据抓不到语义事故**:那个值可以完全合法却仍然是错的。
⇒ 新增两条**语义**判据:
① 出厂默认必须落在 **RFC 2606 保留域名**(`example.com/net/org`、`.test`、`.invalid`);
② 面向用户的**提示文案**里不许出现真实域名(剥注释后扫)。
变异逐条验过会红:
默认值改回生产域名 → 红 ✓
把某条 error 文案换成 my-real-server.com → 红 ✓
两者都在 → 13/13 全绿 ✓
★ 期间修了判据自己的一个 bug:我用了 `code('model/ApiBase.ts')`,
而 `code()` 是相对 **electron 包**解析的 ⇒ `ENOENT .../client/electron/model/ApiBase.ts`
—— **判据自己崩了**(整条不跑、退出码 1)。已改用本文件既有的 `read(rel)`。
## 设备验证
清掉 preferences 里的旧地址后冷启,`uitest dumpLayout`:
text 'https://example.com/api/v1'
hint 'https://example.com/api/v1'
⇒ 预填与提示**同话**,不再互相矛盾。
(老装机 preferences 里已有的地址**保持不动** —— 那是用户自己配的服务器,不该被清。)
|
2026-09-21 17:26:58 +08:00 |
|
|
|
f4d5a75976
|
跨端: 补 MailSummary 的解析边界兜底(列表这条主流的 omitempty 一直是敞的)
判决书来自 `harmony-arkts` 那条判据,它在我加了授权栏历史之后报出两处调用点:
MainPage.ets: parts.push(e.reason.length > 0 ? …) ← 假阳性
MainPage.ets: if (m.mail_type === 'permission_request' && m.permission_result.length > 0) ← 真 bug
## 真 bug:`MailSummary` 没有解析边界兜底
本仓早就为这个形状付过代价,**而且修法只修了一半**:
· `MailDetail` 有 `normalize()`,并在 `MailApi.mailDetail()` 接了(当时那次是**整页白屏**);
· 而列表用的 `MailSummary` **没有** —— 于是 `mail_type` / `permission_result` /
`session_alias` 这些带 `omitempty` 的字段在缺失时是 `undefined`,
而三处调用点直接读 `.length`。
服务端 `omitempty` 的语义是**整个 key 不出现**(不是给空串),
ArkTS 裸 cast(`JSON.parse(raw) as T`)缺键给 `undefined`、**不会**应用类里那个 `= ''`。
⇒ 这是同一形状的**第四次**(前三次:`MailDetail` 白屏、`participantAddress` 的 trim、
`AddressSuggestion.title`)。前三次都是"读的人临时守一下",
**而列表这条主流一直敞着**。
修法(与 `MailDetail` 同一处、同一纪律):给 `MailSummary` 加 `normalize()`,
在 `MailApi.inbox()` / `sent()` / `sessionMails()` **三处**解析边界接上。
★ 为什么不在调用点加 `??`:`MailSummary` 上还有 `mail_type`/`session_alias`
同样带 omitempty —— 逐个调用点加就是"每加一处就得记得做一次",
本仓反复在消的形状。归一化做一次、覆盖全部字段。
## 假阳性:同名词撞车
`e.reason` 的 `e` 是 **`AccountError`** —— 鸿蒙**本地类**(`MailStore.ets:73`),
只经 `AccountError.of(account, reason)` 构造 ⇒ `reason` 恒为 string。
而判据收集的那个 `json:"reason,omitempty"` 属于 `RenameProposal`
(`rename_proposal.go:39`,**完全不同的**接口)。已按既有
「已逐个核实过的豁免」格式加 ⑤,附取证。
## 期间修了判据自己的一个洞(变异实测)
我给 ⑥ 加豁免时第一版写的是**无条件**正则 —— 变异实测
(把 `MailSummary.normalize` 里那行 `m.permission_result = str(...)` 删掉)
**判据照样全绿**:豁免把那一行永久致盲了。
这正是本仓反复消的形状:**豁免口自己没人管**。
⇒ 改成"**有前提**的豁免":先断言 `MailSummary.normalize` 里确实有那两行,
命中才豁免。变异复验:
删掉 normalize 里 permission_result → 判据红 ✓
删掉 normalize 里 mail_type → 判据红 ✓
两者都在 → 全绿 ✓
⇒ 豁免表达的是"**因为上游归一了**,所以这里可以不写 ?? ",
而不是"这一行不用管"。
|
2026-09-21 17:06:19 +08:00 |
|
|
|
d857352e29
|
跨端: 闭合 harmony-permission-history —— 授权栏补上「已决策的历史」
这是 `docs/DEBTS.json` 里登记的一条,它的到期条件原文是
「做『授权栏与 WebUI 对齐』时」—— 就是现在这一轮。
## 原缺口
鸿蒙的 `PermissionTab` 只调 `GET /permission/pending`(服务端
`ListPendingPermissionsFor`,SQL 带 `WHERE pr.result IS NULL`)
⇒ **只拿得到待决的**,于是"这条会话批过哪些事"完全看不到;
而 WebUI 有(`PermissionList.tsx:182` 的「历史 {n}」)。
## 关键判断:**不照抄 WebUI 的 inbox 分组**
我先把 `PermissionTab.load` 整个改成读 inbox + `groupPermissions`,
**改到一半发现行不通**(编译报 `question`/`context`/`agent_name` 找不到):
· WebUI 从 inbox 分组,但它的 `PermissionRow` **只渲染**
`subject`/`created_at`/`permission_result`/`permission_expires_at`
(逐字段 grep 过,全文件没有 `question`/`options`/`context`);
· 而**待决**那一段我们要显示 `question`/`options`/`context`/`kind`
—— 那四个字段在 `permission_requests` **表**里,
inbox 回包(`models.Mail`)**没有它们**(模型逐条核过,只有 `permission_result`
与 `permission_options`)。
⇒ 两条来源各有各的信息量,不是二选一:
· **待决**继续走专用端点(信息更全、能直接决策);
· **历史**走 inbox 补上。
代价是每账号多一次请求 —— 这是**有意的取舍**,写在代码注释里。
(半成品已 `git checkout` 撤掉,没有把它留在提交里。
撤掉的原因如实记在注释里,免得下一个人以为"照着 WebUI 改"就行。)
## 落地
· `model/MailGrouping.ts`:加 `groupPermissions` + `PermissionGroup` +
`isPendingPermission`,逐条对齐 WebUI 的 `groupPermissions`,
含它那**三步排序**(有待决的先来 → 待决多的更靠前 → 最新一封倒序)。
· `PermissionTab`:从 inbox 取 `mail_type=permission_request &&
permission_result != ''` 的,分组后渲染「历史 n 条」(只读、不可操作)。
· 空态判据从 `requests.length === 0` 改成**两者都空**才显示 ——
否则"有待决的历史"会被误报成"没有待决策的请求"。
## 判据自己抓到了我
`cross-client-logic.test.mjs` 的「缺口只减不增」在我补上 `groupPermissions`
之后立刻变红,并给出准确指引:
减少(harmony 补上了功能)→ 请把 gaps 里对应的名字删掉
⇒ 已清空 `gaps`。**这条判据在这轮里三次发挥作用**:
第一次报出这个缺口(09-20),第二次在我半成品时红了,
第三次确认闭合。`pass=7 fail=0`。
`docs/DEBTS.json` 的 `count` 已改 0、`due` 记完成、`note` 写明修法与取舍。
## 设备验证(如实)
✓ 授权页正常渲染,进程存活(23343),无新 jscrash
✓ 空态文案正确(本机确实没有权限邮件)
✗ **"历史 n 条"真的显示出来**这条路径没能实测:
本机没有已决策的权限请求(服务端实测 `permission_request` 0 封)。
逻辑逐条对齐 WebUI、编译通过,但我不声称已看到它渲染。
|
2026-09-21 16:45:23 +08:00 |
|
|
|
1869924507
|
跨端: 接上聚合失败横幅(数据一直在收集,界面从来没读过 —— 安全网是断的)
逐页对齐 WebUI 时发现的**不是观感问题,是安全网断线**。
## 事实
`MailStore` 一直在收集"聚合模式下哪个账号取失败了":
MailStore.ets:142 const failed: AccountError[] = [];
MailStore.ets:208 failed.push(AccountError.of(acct.displayName, reason));
MailStore.ets:226 snap.accountErrors = failed;
(收件箱与发件箱两处 load 都写)
而**界面一次都没读过 `snap.accountErrors`**(`MainPage` 里 grep 为 0 处)。
⇒ 后果:某个账号拉不到邮件时,列表**静默少一整份**,界面看起来完全正常。
用户会据此得出错误结论 —— "没人给我发信"。
WebUI 把这条写成了显式警告(`MailList.tsx:85-91` 原注释):
「聚合时**某个账号取不到**必须说出来:静默丢掉它,列表会少一整份邮件,
而界面看起来完全正常 —— 这正是『聚合』最容易骗人的失败方式」
## 改动
· `InboxTab` 加 `@State accountErrors`,在 `applyStoreSnapshot` 里接上 `snap.accountErrors`;
· 卡片下方渲染琥珀色横幅,文案照 WebUI:
「有 {n} 个账号没取到:{账号}({原因});…」
(`bg-amber-50 border-amber-200 text-amber-800` → `warnBg` / `warnFgDark` / 11 号字);
· 新增 `accountErrorText()` 做拼接(`「a(原因);b(原因)」`)。
★ 只接了**收件箱**那一处。发件箱 `SentTab` 有自己的 `applyStoreSnapshot`,
本轮不动它 —— 它的聚合语义与收件箱不同(见 `SentTab.load`),
要不要显示同一张横幅需要单独判断,不顺手加(顺手加是本仓反复出现的错法)。
## 设备验证(如实)
✓ 收件箱正常渲染(`9 组 · 9 封`),进程存活(22242),无新 jscrash
✓ 横幅**正确不出现** —— 当前所有账号都取得到(这是应该的行为)
✗ **横幅"出现"的那条路径没能实测到**:要触发它需要一个取失败的账号,
而本机没有可用的失败账号构造。逻辑与 WebUI 逐条对齐、编译通过,
但"真的会显示成那样"我不声称已验 —— 与"动画中间帧看不到"同样的诚实口径。
|
2026-09-21 16:32:49 +08:00 |
|
|
|
877fda5829
|
跨端: 联系人页标题补计数(WebUI ContactPanel.tsx:68 有、我们没有)
逐页对齐时逐项比对发现的漏项。
## 事实
WebUI `ContactPanel.tsx:63-68` 的头部是三段:
<h2>{view === 'card' ? '工作列表' : '联系人'}</h2>
<span className="ml-2 text-xs text-gray-400">{contacts.length}</span> ← 这一段我们没有
<div className="flex-1" /> + 视图切换按钮
## 改动
标题右侧补 `contacts.length`,字号/颜色照 WebUI:
`text-xs`(12) → `Theme.fontSmall`,`gray-400` → `Theme.textSubtleFor()`。
★ 用 `textSubtleFor()` 而非写死灰:深色下三级文字要提亮 ——
本仓 2026-09-18 实测过「三级文字浅色也一样不够」(`110de79`)。
★ 取值与 WebUI 同一个来源(`contacts`),**两个视图共用同一份** ——
所以计数不随列表/卡片视图切换而变(WebUI 也是这样)。
## 设备验证
点「联系人」后 `uitest dumpLayout`:
'联系人' y=189..256
'9' y=203..243 ← 同一行、右侧,正是 WebUI 的位置
(改前该位置为空。)
|
2026-09-21 16:26:20 +08:00 |
|
|
|
f16ebf4942
|
跨端: 「我的」页段顺序对齐 WebUI(拆掉那两个焊在一起的 section)
用户「都做啊」的第二件 = 继续逐页对齐 WebUI。这一轮做「我的」页。
## 先比对,再动手(不是凭印象说"八段一一对应")
文件头注释一直写着「八个 section 与 WebUI 一一对应」,实测逐段列出后
**顺序并不对应**:
WebUI(AccountPage.tsx,按出现位置)
基本资料 → 权限范围 → 修改密码 → 多账号 → 客户端连接密钥 → 外观
→ 登录状态 → 管理
鸿蒙(改前)
ProfileSection(基本资料+权限范围) → 多账号 → 外观 → 系统通知
→ 密钥 → SecuritySection(修改密码+退出) → 管理
⇒ 三处实质差异:
① **「修改密码」与「登录状态」被焊成一个 `SecuritySection`**
—— WebUI 那边它们是**两个独立 `<section>`**,中间还夹着三段。
顺序错位的**根因就是这一处**:焊在一起就没法插东西进去。
② 「外观」被排到「密钥」**之前**(WebUI 是密钥在前)。
③ 「多账号」在「修改密码」之后、而不是之前。
## 改动
· `SecuritySection` → 拆成 `PasswordSection` + `LoginSection`(各带自己的圆角卡)。
· 顺序改成与 WebUI 逐段相同。
## 「系统通知」保留,但**标明它是鸿蒙独有**
`PushSection`(HarmonyOS Push Kit:新邮件由系统弹通知、点通知直达)
在 WebUI 侧**没有对应物** —— 全仓 grep「推送/通知设置」为 0 处。
浏览器里没有等价能力,所以**这不是分叉,是平台差异**。
已就地写明理由,并把它放在外观之后、登录状态之前(与"账号自身"那几段分开)。
★ 这类"我方多一段"的情况,本仓的处理口径是**写清楚它不是漏做的**,
而不是删掉去凑一致 —— 与 §7.12「有意差异表」同一形状。
## 设备验证(不是只看代码)
改前: 我的 → 用户名 → … → 修改密码 → 保存 → **多账号 → 外观 → 系统通知 → 密钥 → (改密+退出)**
改后: 我的 → 用户名 → … → **修改密码 → 多账号 → 客户端连接密钥 → 外观 → 系统通知 → 登录状态 → 管理**
第二屏实测(`uitest dumpLayout` + fling):
「多账号 → jianf → 默认 → 删除 → 客户端连接密钥 → 新建 → … → 外观 → 已同步 → 跟随系统」
⇒ 与 WebUI 的 `多账号 → 密钥 → 外观` 逐段一致。进程存活(20970),无新 jscrash。
|
2026-09-21 16:23:34 +08:00 |
|
|
|
2f80e1102d
|
跨端: 写信 FAB → 写信页 共享元素转场 + morph 收成单一入口(判据两版错法都记了)
用户 2026-09-21:「webui 行为是按钮变成对应的写邮件页面或输入框吧,你做的啥?」
「都做啊」—— 两处 morph 现在都在了。
## ① 写信 FAB → 写信页(跨 NavDestination)
官方 FAQ `faqs-arkui-991` 给的正是"在 NavDestination 子页面里做共享元素转场"
的完整步骤,逐步照做:
· 两端绑同一 id `compose-morph`(FAB / ComposeDestination 的 NavDestination);
· **`pushPath` 放进 `animateTo` 闭包**(FAQ 步骤 3 原文就是这个形状);
· `follow: false`(两端互斥出现,不是"始终在树上跟随")。
## ② 把 morph 收成**单一入口** `Motion.morph(ui, mutate)`
这一步不是为了少写代码,是为了**让判据能判**。过程值得记:
**第一版判据** —— 全仓 `any()`:
/animateTo\(/.test(allHarmony) && /durMorph/.test(allHarmony) && …
变异实测(把 morph 那处的 `animateTo` 改名、把 `Theme.durMorph` 就地写 `220`)
**三条全绿** —— 因为全仓**别处**还有这些名字,"删掉这一处"永远命中不了。
**第二版判据** —— 逐站点取"文本邻域"看有没有 animateTo:
**全假红**。因为 `geometryTransition(id)` 绑在**组件树**上,而 `animateTo`
写在**另一个方法**里,文本邻域取不到隔壁的方法。
⇒ 结论不是"把判据写得更聪明",而是**把结构改成可判的**:
把"带 morph 的状态切换"收进 `Motion.morph(ui, mutate)` 一处
(`animateTo` + 时长 + 曲线都在里面),调用点只剩「我要改哪个状态」。
这与本仓既有解法同型(`PressEffectModifier` / `GlassCardModifier`:
把"每处都得记得写"收敛成"一处定义、处处引用")。
3 个调用点已全部改走它(`MainPage.openComposeWithMorph`、
`MailDetailPage.openReplyWithMorph` / `closeReplyWithMorph`)。
★ `Motion.morph` 必须收 `UIContext`:全局 `animateTo` **已废弃**
(编译器告警 `'animateTo' has been deprecated`),而静态方法里拿不到
`this.getUIContext()`(本仓纪律:静态方法里不用 `this`)。
## 判据:6 条,三条变异逐个验过
通过 每个 id 恰好绑两处(一 in 一 out) [变异:删一端 → 红 ✓]
通过 morph 只有一个入口且内部有 animateTo [变异:换成普通调用 → 红 ✓]
通过 用 ui.animateTo 而非废弃的全局 animateTo
通过 每个用 geometryTransition 的文件都走 helper [变异:自己写 animateTo → 红 ✓]
通过 页面里不再直接出现 Theme.durMorph
通过 Theme.durMorph 存在且 = 220 [变异:改成 450 → 红 ✓]
★ 期间还抓到一个**判据自己的 bug**:我重写那一段时把 `themeSrc` 的定义
一起删了 ⇒ 第 6 条抛 `ReferenceError`、**整条判据根本没跑**
(而其余 9 条照常打印"通过",退出码 1 但没人看得到那条)。
这与"守具有齿但不在位"同形:**判据崩了不会显示成失败**。
已补回定义并重跑确认。
计数棘轮 4 → 10(显式编辑,理由写在 `run-all.mjs` 里)。
## 设备验证
✓ 点 FAB → 写信页到场、取消 → 回列表,进程存活(17827),无新 jscrash
(`faultlogger` 里最新仍是 15:08 那条,即修复前的)
✗ 220ms 的**中间帧**仍看不到(`snapshot_display` 往返 1.5-3s 慢一个数量级)——
与上一条提交同样的诚实交代:动画本体只能由用户在真机上看
|
2026-09-21 15:50:00 +08:00 |
|
|
|
ba16230a63
|
跨端: 回复球 → 回复条 加共享元素转场(WebUI 的 FLIP「按钮长成面板」)
用户 2026-09-21:「我记得 webui 行为是按钮变成对应的写邮件页面或输入框吧,你做的啥?」
## WebUI 的真实行为(读它的实现,不是凭印象)
`MailView.tsx:990-1010` + `ComposePage.tsx:57-79` 做的是 **FLIP**
(First-Last-Invert-Play):
const from = 球的 getBoundingClientRect(); // ① 起点
const to = 面板的 getBoundingClientRect(); // ② 终点
const scale = Math.max(0.04, from.width / to.width);
const dx = from.left + from.width/2 - (to.left + to.width/2);
const dy = from.top + from.height/2 - (to.top + to.height/2);
el.animate([
{ transform:`translate(${dx}px,${dy}px) scale(${scale})`,
opacity: 0.3, borderRadius: '28px' }, // ← 起点圆角 = 球的半径
{ transform:'none', opacity:1, borderRadius:'0px' }
], { duration: 220, easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)' });
观感就是**球从自己的位置和半径"长"成一整幅面板**。用户说的
「按钮变成输入框」正是这一条 —— 而且它有**两处**:回复球→回复条、
以及写信 FAB→写信页。
★ 它还显式尊重无障碍:`prefers-reduced-motion` 时直接 return(不打动画)。
## 鸿蒙的对应能力:`geometryTransition`
官方文档(`ts-transition-animation-geometrytransition`)原文:
· 「通过安排绑定的 in/out 组件的 frame、position,将视觉焦点由旧视图位置
引导到新视图位置」—— 就是 FLIP 的系统实现;
· 「**必须配合 `animateTo` 使用**才有动画效果,动效时长、曲线跟随
`animateTo` 中的配置,**不支持 `animation` 动画**」;
· 「同一个 id 只能有**两个**组件绑定,且分别作为 in(新视图)和
out(旧视图)两种不同类型角色,**不能多个组件绑定同一个 id**」。
⇒ 三条约束逐条满足:
· 两端绑同一个 id `reply-morph`(且**只有**这两处,判据可查);
· 状态切换写在 `animateTo` 的**闭包内**(不是 `.animation()`);
· 用默认 `follow: false`(两端是 `if/else` 互斥出现,不是"始终在树上")。
## 时长/曲线的取值(不是我拍的)
**220ms + `cubic-bezier(0.22, 0.61, 0.36, 1)`** —— 逐字取 WebUI 那行 FLIP 的参数。
那个贝塞尔恰好就是本仓 `Theme.easeRise` 的定义(`easeRise` 当初就是从这行抄来的),
所以代码里直接引它,没有新写一条曲线。
新增令牌 `Theme.durMorph = 220`,**没有**复用 `durRise`(200):两者是不同动效
(元素出入场 vs 共享元素续接),WebUI 那边也是两个值(150 / 220)——
合成一个的话以后想单独调其中一个就得先拆开,拆的时候必漏调用点。
## 设备验证(做了什么 / 没做成什么)
✓ 点球之后**回复条确实展开**(`dumpLayout` 命中「回复给 pi@root.realtest」
与「输入回复内容」两处)——功能链路通。
✗ **没能在设备上看到中间帧**:`snapshot_display` 一次往返 ~1.5-3s,
而这条动画 220ms ⇒ 探针比被测对象慢一个数量级。连拍 4 张
y=1600 的白区跨度全是 `(30,1007)`(即每张都已是终态)。
★ 这正是此前"连测七八轮没有中间帧、并编出三个错误理论"的同一个坑
(那次的结论记在本仓:**探针对被测变化不敏感时,量的是噪声**)。
⇒ 这一条的**动画本体**只能由用户在真机上看,我不声称已验。
顺带把「取消」键改成反向 morph(`closeReplyWithMorph()`)——
WebUI `MailView.tsx:945` 专门记过这个不对称:「打开有 morph、收起是瞬间消失 —— 不对称」。
|
2026-09-21 15:39:44 +08:00 |
|