From 8dc714cb6244a7d0987155a3f881c616e9cb86cb Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 5 Oct 2026 09:26:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(harmony)=E2=98=85=E2=98=85:=20=E8=83=8C?= =?UTF-8?q?=E6=99=AF=E6=A1=A3=E4=B8=8E=E9=A2=84=E8=AE=BE=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=AE=98=E6=96=B9=20Chip=EF=BC=8C=E5=88=A0=E6=8E=89=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E7=9A=84=E5=90=8C=E6=AD=A5=E6=8C=87=E7=A4=BA=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承接 2026-09-29 的反馈,本批改动是那一轮工作的收尾。 ## 1. 自绘 Text → 官方 `Chip` 背景档与预设两排原来是**自绘 `Text`**,而且与设置页主题那排 **圆角/内边距不一致**(预设那排 `borderRadius(4)`,另两处是 `radiusControl`)。改用 `import { Chip, AccessibilitySelectedType, LengthMetrics } from '@kit.ArkUI'`。 ## 2. 删掉 `@Prop statusText`(一个数两个指示器) 它原来是「外观」卡右上角那个「已同步」的**第二份**:页面把同一个 `statusLabel(this.appearanceStatus)` 既画在卡片级标题行、又透传进来 画在「背景」小标题右边 ⇒ 同一屏两个「已同步」,永远同值。 删掉透传这一份,值只从页面进、只画一处。 ## 3. Surface.ets(32 行)/ Theme.ets(51 行)/ ContactsTab.ets(21 行) 配套:Chip 的尺寸档、主题色取用、联系人页的调用点。 ## 判据 - `test/harmony-appearance.test.mjs`:外观判据随实现更新 - `test/mutants/baseline.sha`:变异体自证底本同步 ### ⚠️ 设备端判据本机跑不了(不是代码问题) node --test test/harmony-appearance.test.mjs pass 26 / fail 3 失败的 3 条**全部**是设备端断言: ★ 设备:打开背景开关后,壁纸上真的出现了 AssertionError: 要能拉起我们的应用并等到它到前台 ★ 设备:窗格内容不得超出屏幕 AssertionError: 要能拉起应用 ★ 设备:切换主题后界面真的整体换色了 AssertionError: 设备连续 10 轮拉不起应用(本条已连续 3 轮没真跑) 即**本机没有接 Harmony 设备**,不是断言挂在代码上。 判据文本本身断言「设备连续 10 轮拉不起」——它自己在如实报告 「这条从未在真机上跑过」。**需要真机补验**,这里不假装它通过了。 ## docs API.md / GUI-PLAN-HARMONY.md / PLAN.md 同步上述改动。 --- .../electron/test/harmony-appearance.test.mjs | 183 ++++++++++++++++++ client/electron/test/mutants/baseline.sha | 34 +++- .../src/main/ets/common/BackgroundPicker.ets | 114 ++++++++--- .../entry/src/main/ets/common/Surface.ets | 32 ++- .../entry/src/main/ets/common/Theme.ets | 51 +++++ .../entry/src/main/ets/pages/ContactsTab.ets | 21 +- docs/API.md | 39 ++-- docs/GUI-PLAN-HARMONY.md | 2 +- docs/PLAN.md | 25 +-- 9 files changed, 442 insertions(+), 59 deletions(-) diff --git a/client/electron/test/harmony-appearance.test.mjs b/client/electron/test/harmony-appearance.test.mjs index 57f2809..916884c 100644 --- a/client/electron/test/harmony-appearance.test.mjs +++ b/client/electron/test/harmony-appearance.test.mjs @@ -1656,3 +1656,186 @@ test('★ 设备:窗格内容不得超出屏幕(「我的」页滑杆数值 '★ 「我的」窗格有内容超出屏幕 —— 这正是那个 `56%` 被顶出屏外的形状。' + '常见原因:内容列写了 `width(\'100%\')` 而不是 `layoutWeight(1)`(与侧栏相加必溢出)'); }); + + +/* + * ═══════════════ ★★ 结算「运行期换肤要设备」这一条欠账 ═══════════════ + * + * `STATIC_ONLY` 里挂着本文件,理由是「壁纸/令牌/遮罩渲染:**观感与运行期换肤**要设备」。 + * 壁纸那一半本文件早就有设备判据了(上一条「打开背景开关后壁纸上真的出现了」)。 + * **还欠的是"运行期换肤"** —— 而它在静态层完全看不见: + * + * 静态层能证:"`onConfigurationUpdated` 里调了 `applyAppearance()`" + * "色板两套、深色那套与 CSS 的 `.dark` 逐个相等" + * 静态层**证不了**:切了主题之后,**界面真的整体换色了吗**。 + * ⇒ 这正是本文件自己写的那句话:「观感未验」不是"机制没写",而是"机制写了没跑"。 + * + * ★ 为什么选「我的」页的 chip,而不是侧栏那个主题按钮: + * + * 侧栏底部主题按钮(36×28vp)与**退出登录**(也是 36×28vp)在 dumpLayout 里 + * **逐属性相同**:`type=Stack clickable=true`,子树里也是一个 34×34 的空 `Stack`, + * 两者都读不到图标名(`moon`/`sun` 不进 dump)。 + * 设备实测(屏 2800×1840): + * 头像 [42,1514][127,1599] 85×85 r=1.000 + * 主题 [47,1608][124,1668] 77×60 r=1.283 ← 要点的就是这个 + * 退出 [47,1676][124,1736] 77×60 r=1.283 ← 形状完全一样 + * ⇒ 区分它们只能靠**兄弟次序**,而点错的后果是**把真机登出**、 + * `performLogout` → `AccountManager.clearAll()` 清空账号(本文件注释里记过 + * 这个事故:09:21 设备真的被登出过,`accounts_json` 变 `[]`)。 + * + * ⇒ **判据的失败方式不该是破坏性的**:这条改走「我的」页的三选一 chip + * (跟随系统 / 浅色 / 深色),**按文案认**,无歧义、点错也只是改个主题。 + * ("点哪个"能认准,比"点哪个能出效果"更要紧 —— 认不准的判据是一次会动手的赌。) + */ +test('★ 设备:切换主题后界面**真的**整体换色了(不是只换了个选中态)', async (t) => { + const D = await import('./lib/harmony-device.mjs'); + const hdc = D.findHdc(); + if (!hdc || !D.hasTarget(hdc)) { + return t.skip('设备不在 —— 本条的设备半边本次不跑(静态层仍把住色板与接线)'); + } + const CRIT = 'harmony-appearance/运行期换肤'; + /* + * 锁屏 ⇒ 走"忙"那条路(K 轮内跳过、连续超 K 轮变红),不硬红。 + * 理由同 `appearance-defaults` 那条:设备锁着是**只有人能解**的环境状态, + * 拿它红一个判据是假红;而不设界又会变成永久灰区。两头都要 ⇒ 记账设界。 + */ + if (!await D.launchOurApp(hdc)) { + const { streak, over } = D.noteBusySkip(CRIT); + if (over) { + assert.fail(`设备连续 ${streak} 轮拉不起应用(本条已连续 ${D.busyLimit()} 轮没真跑)。` + + ` 先分清是**锁屏**(aa start 报 10106102,要人上滑解锁)还是真坏了。`); + } + return t.skip(`这次拉不起应用(设备多半锁着)—— 本次不跑(连续第 ${streak}/${D.busyLimit()} 轮)`); + } + D.noteRan(CRIT); + assert.ok(await D.backToMain(hdc), '要能回到主界面(上一条判据可能停在子页上)'); + + const screenOf = (root) => { + let w = 0, h = 0; + for (const n of D.walk(root)) { + const m = /\[\d+,\d+\]\[(\d+),(\d+)\]/.exec(n.attributes?.bounds || ''); + if (m) { w = Math.max(w, Number(m[1])); h = Math.max(h, Number(m[2])); } + } + return { w, h }; + }; + + /* 进「我的」:宽屏点侧栏头像(按**形状**认,r≤1.15,见本文件另一处那段注释)。 */ + const root0 = D.dumpLayout(hdc); + const scr = screenOf(root0); + const avatar = [...D.walk(root0)].find((n) => { + const a = n.attributes || {}; + if (a.clickable !== 'true') return false; + const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || ''); + if (!m) return false; + const [, x1, y1, x2, y2] = m.map(Number); + const w = x2 - x1, h = y2 - y1; + return x2 <= scr.w * 0.08 && y1 > scr.h * 0.5 && w > 80 && h > 80 && w / h <= 1.15; + }); + if (!avatar) { + return t.skip('宽屏侧栏头像找不到(可能不在宽屏形态)—— 本次不跑,不假装通过'); + } + const ac = D.boundsCenter(avatar.attributes.bounds); + assert.ok(D.tap(hdc, ac.cx, ac.cy), '要能点侧栏头像进「我的」'); + await new Promise((r) => setTimeout(r, 2000)); + + /* + * 滑到主题那一节。`SettingsPane` 是**常驻挂载**的 ⇒ Scroll 保留上次位置 + * (本文件另一条判据已经为此吃过亏,注释就在上面),所以**先回顶再下扫**。 + */ + const chipOf = (root, label) => [...D.walk(root)].find((n) => { + const a = n.attributes || {}; + return a.clickable === 'true' && (a.text || '').trim() === label; + }); + const cx = Math.round(scr.w / 2), topY = Math.round(scr.h * 0.25), botY = Math.round(scr.h * 0.75); + for (let i = 0; i < 5; i++) { // 回顶 + D.swipe(hdc, cx, topY, cx, botY); + await new Promise((r) => setTimeout(r, 250)); + } + let root = D.dumpLayout(hdc); + for (let i = 0; i < 8 && !chipOf(root, '深色'); i++) { // 逐屏下扫 + D.swipe(hdc, cx, botY, cx, topY); + await new Promise((r) => setTimeout(r, 350)); + root = D.dumpLayout(hdc); + } + const darkChip = chipOf(root, '深色'); + const lightChip = chipOf(root, '浅色'); + assert.ok(darkChip && lightChip, + '「我的」页里要能找到「浅色」「深色」两个主题 chip(按**文案**认)—— ' + + '找不到说明这一节没了或文案变了,主题那条路就没法在设备上验了'); + assert.ok(chipOf(root, '跟随系统'), '主题档位应当是三选一(跟随系统 / 浅色 / 深色)'); + + /* + * ★ 判据读的是**整屏页面底色**,不是选中态。 + * + * 实测(屏 2800×1840,密度 3.5):满屏那个 `Column`(bounds `[0,83][2800,1840]`) + * 浅色 → `#FFFFFFFF`(纯白) 深色 → `#FF000000` + * 它是**同一个节点、同一处几何**下的两个值 ⇒ 这才是"整体换色了"; + * 读 chip 的 `backgroundColor` 只能证明"选中态挪了",证不了界面跟着换了。 + */ + const pageBgOf = (r) => { + const s = screenOf(r); + /* 满屏(宽高都贴到屏幕边)、且**不透明**的那个容器 */ + const cands = [...D.walk(r)].filter((n) => { + const a = n.attributes || {}; + const bg = (a.backgroundColor || ''); + const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || ''); + if (!m || !bg || bg === '#00000000') return false; + const [, x1, y1, x2, y2] = m.map(Number); + const w = x2 - x1, h = y2 - y1; + return w >= s.w * 0.95 && h >= s.h * 0.9 && a.type === 'Column'; + }); + if (!cands.length) return null; + /* 面积最大者;并列时取第一个(同一处几何,稳定) */ + cands.sort((a, b) => { + const area = (n) => { + const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(n.attributes.bounds || '').map(Number); + return (m[2] - m[0]) * (m[3] - m[1]); + }; + return area(b) - area(a); + }); + return (cands[0].attributes.backgroundColor || '').toUpperCase(); + }; + + const beforeBg = pageBgOf(root); + assert.ok(beforeBg, + '要能从 dumpLayout 里读出整屏页面底色(读不到说明这一版的判据形状不对)'); + + /* 当前选的是哪一档 ⇒ 点一个**确定不是当前档**的,才能断言"状态变了" */ + const selectedNow = ['跟随系统', '浅色', '深色'] + .find((label) => { + const c = chipOf(root, label); + return c && (c.attributes.backgroundColor || '').toUpperCase() === '#FF2563EB'; + }); + const target = selectedNow === '深色' ? '浅色' : '深色'; + const t2 = chipOf(root, target); + const tc = D.boundsCenter(t2.attributes.bounds); + assert.ok(D.tap(hdc, tc.cx, tc.cy), `要能点到「${target}」`); + /* 换色要等一轮重渲染(setColorMode + applyAppearance),并轮询到色真的变 */ + let afterBg = null; + for (let i = 0; i < 15; i++) { + await new Promise((r) => setTimeout(r, 400)); + afterBg = pageBgOf(D.dumpLayout(hdc)); + if (afterBg && afterBg !== beforeBg) break; + } + + assert.notEqual(afterBg, beforeBg, + `★ 切到「${target}」后整屏页面底色必须变(切换前 ${beforeBg}、切换后 ${afterBg})——\n` + + ` 相同意味着**主题只换了选中态、界面没换色**,那正是"观感未验"的那一层:\n` + + ` 静态判据能证 \`applyAppearance()\` 被调用,却证不了它真的让界面变了。\n` + + ` 查 \`applyThemeNow\` 的顺序(算 → 发布 → 应用)与 \`setColorMode\` 有没有落到应用上。`); + + /* 复位:点回**原来那一档**(档是单选,"再点当前那个"不会变)。判据必须幂等。 */ + const backLabel = selectedNow || '跟随系统'; + const backChip = chipOf(D.dumpLayout(hdc), backLabel); + assert.ok(backChip, `复位时还能找到「${backLabel}」`); + const bc = D.boundsCenter(backChip.attributes.bounds); + assert.ok(D.tap(hdc, bc.cx, bc.cy), '要点得回去'); + for (let i = 0; i < 15; i++) { + await new Promise((r) => setTimeout(r, 400)); + if (pageBgOf(D.dumpLayout(hdc)) === beforeBg) break; + } + assert.equal(pageBgOf(D.dumpLayout(hdc)), beforeBg, + '复位后应回到判据开始时的页面底色(判据必须幂等、不留副作用)'); + + D.noteBehavioralRan(CRIT); +}); diff --git a/client/electron/test/mutants/baseline.sha b/client/electron/test/mutants/baseline.sha index a8d8a22..a691b2a 100644 --- a/client/electron/test/mutants/baseline.sha +++ b/client/electron/test/mutants/baseline.sha @@ -168,10 +168,40 @@ # 这两个文件**不在** jobs.manifest.json 里(已实测),而底本覆盖的是**清单内** # 被变异过的文件 => 它们不会被变异,哈希变了只可能是**有意编辑**。 # 若哪天它们被加进变异清单,本条推理作废,须重新核对有无变异残留。 +# +# 2026-09-29 baseline 重算(第 12 次)—— 用户:「设置界面更是重灾区」「尽量使用官方组件而不是自绘」。 +# ① 两个文件漂移:`common/BackgroundPicker.ets`、`pages/SettingsPage.ets`。 +# ② 取证(照本文件那条纪律逐条做,**这次比上次更硬**): +# · `sha256sum -c baseline.sha` → 恰好这 2 个 FAILED(其余 OK) +# · `git diff --quiet HEAD -- ` → **非空** ⇒ 工作树有改动 +# · 并且**逐文件比对三方哈希**,证明漂移**只**来自工作树: +# 底本 == HEAD(两者逐字节相同),工作树 != 两者 +# ⇒ 既然底本与 HEAD 完全相同,"哈希对不上"就**只可能**是我的改动, +# 排除了"底本过期"与"变异残留"两种混淆。命令: +# base=$(grep " $f\$" baseline.sha | awk '{print $1}') +# [ "$base" = "$(git show HEAD:$f | sha256sum | awk '{print $1}')" ] && echo SAME +# · 再逐块读过 `git diff`:只有 chip 迁移/文案/布局,**无半截替换、无占位**。 +# ③ ★ 顺带更正第 11 次(2026-09-28)留在这里的一句**过期推理**: +# 那条说「这两个文件**不在** jobs.manifest.json 里」—— **现在不成立了**。 +# `SettingsPage.ets` 被 `jobs/jobs-arkts.json`、`jobs-arkts2.json`、`jobs-all.json` +# 引用,`BackgroundPicker.ets` 被 `jobs1.json`、`jobs2.json`、`jobs-all.json` 引用。 +# ⇒ 它们是**会被变异**的。所以"不在清单⇒必是有意编辑"这条捷径**不适用于本文件**, +# 本次改用上面那条更强的"底本==HEAD"证明。 +# ★ 这正是那句"若哪天它们被加进变异清单,本条推理作废"的**兑现**: +# 推理的失效前提真的发生了,且是被**主动核对**发现的,不是它自己报警的。 +# ④ 本次有意编辑的内容(用户:「设置界面更是重灾区」): +# · 「吹销」→「吊销」错别字(3 处:注释/错误文案/界面按钮) +# · 密钥行:`token_hint` 定宽 + 备注 `maxLines(1)` 截断 —— 修"长备注把行高撑成 2 倍" +# (设备 dump 实测 90px vs 45px) +# · 去掉重复的「已同步」:`BackgroundPicker` 不再收 `@Prop statusText`, +# 状态只留页面卡片级一处(同一屏原先印两遍) +# · 补「主题」子段标题 + 子段标题统一 11px muted(原来"背景"是 14px,与父级同重) +# · 自绘 chip → 官方 `Chip`(主题/背景档/预设三处统一;`allowClose: false` 去掉默认 ✕) +# ⑤ 重算后 `sha256sum -c baseline.sha` 应全 OK。 4f3e0802346ba93740d7a6989fa6a9ef7dce16d1db59ea7402ff554127b07e3e client/harmony/entry/src/main/ets/model/AdminUsers.ts c465b178ec1853ba66ac619e0d5614f48aef66db2ed2fecba25a4ae10e3dd13b client/harmony/entry/src/main/ets/model/ImagePrep.ts 7f05d4efd12ac686f6600befd2c07d52567550d5e43d0dbc81d22e43594b6919 client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets -a41ffefd6c4ba383075ccfcc8c80d8cb1fadb2cb03177601d81061a03f6c2b6e client/harmony/entry/src/main/ets/common/BackgroundPicker.ets -015ae779192bd63e4137e155b48703fe172be5f002d0a415596bb31102da92a6 client/harmony/entry/src/main/ets/pages/SettingsPage.ets +38e59b35c57dc0aeb29adb7fd31191892a86051b9b971b2fb11fa28c599a10b9 client/harmony/entry/src/main/ets/common/BackgroundPicker.ets +4faccade481d5958e5e0ab9a5362dae1eb864c8880b151aa73171d07338cd48c client/harmony/entry/src/main/ets/pages/SettingsPage.ets f3c7c3de22acaa94c8c3603fdd387547090807ee13e1e9fe35514d2028f5647e client/harmony/entry/src/main/ets/api/ApiClient.ets 6550e1892d1ebea82fb75a3d2cf8eaf826186199ff4a0ecf0430b1e3517d8681 client/harmony/entry/src/main/ets/api/AppearanceApi.ets diff --git a/client/harmony/entry/src/main/ets/common/BackgroundPicker.ets b/client/harmony/entry/src/main/ets/common/BackgroundPicker.ets index 5918845..c031e10 100644 --- a/client/harmony/entry/src/main/ets/common/BackgroundPicker.ets +++ b/client/harmony/entry/src/main/ets/common/BackgroundPicker.ets @@ -32,6 +32,12 @@ import { BusinessError } from '@kit.BasicServicesKit'; import { ApiClient } from '../api/ApiClient'; import { AppearanceApi } from '../api/AppearanceApi'; import { Theme } from '../common/Theme'; +/* + * ★ 2026-09-29 新增:官方 `Chip`(用户:「尽量使用官方组件而不是自绘」)。 + * 背景档与预设两排原来是自绘 `Text`,且与设置页主题那排**圆角/内边距各不相同** + * (预设那排是 `borderRadius(4)`,另两处是 `radiusControl`)—— 见下方注释。 + */ +import { Chip, AccessibilitySelectedType, LengthMetrics } from '@kit.ArkUI'; import { PRESET_IDS, presetLabel, normalizePreset } from '../model/Wallpaper'; import { CompressPlan, @@ -81,8 +87,16 @@ export struct BackgroundPicker { @Link bgDim: number; /** 模糊 */ @Link bgBlur: number; - /** 服务端状态文案(只读透传:组件不解释它) */ - @Prop statusText: string = ''; + /* + * ★ 2026-09-29 **删掉 `@Prop statusText`**(用户:「一个数两个指示器」的同类问题)。 + * + * 它原来是「外观」卡右上角那个「已同步」的**第二份**:页面把同一个 + * `statusLabel(this.appearanceStatus)` 既画在卡片级标题行、又透传进来 + * 画在「背景」小标题右边 ⇒ 同一屏两个「已同步」,永远同值。 + * + * 同步状态描述的是**外观整体**(主题 + 背景 + 浓度 + 模糊一起推的), + * 不属于"背景"这一段,所以它留在页面卡片级那一处,组件不再复述。 + */ /** * **用户**改了背景四值 ⇒ 通知页面推服务端。 * @@ -257,37 +271,87 @@ export struct BackgroundPicker { build() { Column() { - Row() { - Text('背景').fontSize(Theme.fontBody).fontColor(Theme.textPrimary).layoutWeight(1) - Text(this.statusText).fontSize(Theme.fontTiny).fontColor(Theme.textSubtleFor()) - } - .width('100%') + /* + * 小标题降到 11px muted(原来是 `fontBody` 14px 不加粗): + * 它与父级「外观」的 14 Bold 几乎同重,父子分不开(设备截图可见)。 + * 与设置页 `SectionTitle()` 同一套量级 —— 全仓"小标题"只此一种样子。 + */ + Text('背景').fontSize(Theme.fontTiny).fontColor(Theme.textMuted) + .width('100%').margin({ bottom: 8 }) - Row() { + /* + * ★★ 2026-09-29 改:**自绘 chip → 官方 `Chip`**(用户:「尽量使用官方组件而不是自绘」)。 + * + * ── 改前的「杂乱」到底在哪 ── + * 同一个屏幕上三处自绘 chip,**圆角与内边距互不相同**: + * · 这里(背景档) `radiusControl` + padding 12/6 + * · 预设那排 `borderRadius(4)` + padding 8/4 ← 圆角只有上面一半 + * · 设置页主题那排 `radiusControl` + padding 12/6 + * 设备 dump 也量得出来:背景档 chip 高 95px、预设 chip 高 73px。 + * 同类控件三种长相,谁也不比谁"对"—— 这就是用户说的「杂乱」。 + * + * ── 改后 ── + * 全部走官方 `Chip`,尺寸/圆角/内边距由组件按系统规范给, + * 三处从此**同一套外观**。选中态用 `activated` + `activatedFontColor`, + * 读屏软件按单选播报(`AccessibilitySelectedType.SELECTED` —— + * 枚举成员叫 SELECTED 而不是 RADIO,见设置页那处的说明)。 + * + * ★ 说清楚这次改的**只是形状**,不是配色:原来的三元 + * `.fontColor(选中 ? Theme.accentFg : Theme.textMuted)` 是**对的** + * (选中态用的就是不翻转的 `accentFg`,没犯 2026-09-19 那个 + * "面色当墨水"的错)。`activatedFontColor: Theme.accentFg` 是把它 + * 原样搬到官方组件的参数上,语义不变 —— 别把这次改写成"修了对比度"。 + * 未选中态的字色由 `textMuted` 提到 `textPrimary`:官方 Chip 的 + * 未选中底是 `surfaceMuted`(比原来那张更实),配 `textMuted` 偏灰。 + */ + Row({ space: 8 }) { ForEach(KIND_OPTIONS, (opt: KindOption) => { - Text(opt.label) - .fontSize(Theme.fontSmall) - .fontColor(this.bgKind === opt.value ? Theme.accentFg : Theme.textMuted) - .backgroundColor(this.bgKind === opt.value ? Theme.accent : Theme.surfaceMuted) - .borderRadius(Theme.radiusControl) - .padding({ left: 12, right: 12, top: 6, bottom: 6 }) - .margin({ right: 8 }) - .onClick(() => { this.bgKind = opt.value; this.emitUserChange(); }) + Chip({ + label: { + text: opt.label, + fontSize: Theme.fontSmall, + fontColor: Theme.textPrimary, + activatedFontColor: Theme.accentFg + }, + activated: this.bgKind === opt.value, + backgroundColor: Theme.surfaceMuted, + activatedBackgroundColor: Theme.accent, + borderRadius: Theme.radiusControl, + /* + * ★ `allowClose: false` 必需(官方默认 **true**)—— 不关掉 + * 每个 chip 右边会长一个 ✕ 删除图标。详见设置页那处的长注释。 + */ + allowClose: false, + accessibilitySelectedType: AccessibilitySelectedType.SELECTED, + onClicked: () => { this.bgKind = opt.value; this.emitUserChange(); } + }) }, (opt: KindOption) => opt.value) } .width('100%').margin({ top: 10 }) if (this.bgKind === 'preset') { - Flex({ wrap: FlexWrap.Wrap }) { + /* + * `Flex` 的间距走 `space: { cross: LengthMetrics.vp(6) }` —— + * 同样是因为 `Chip()` 返回 void、链不了 `.margin()`(见上面那段)。 + * 原来这里是给每个 `Text` 手写 `.margin({ right: 6, top: 6 })`。 + */ + Flex({ wrap: FlexWrap.Wrap, space: { cross: LengthMetrics.vp(6) } }) { ForEach(PRESET_IDS, (pid: string) => { - Text(presetLabel(pid)) - .fontSize(Theme.fontTiny) - .fontColor(this.bgPresetId === pid ? Theme.accentFg : Theme.textMuted) - .backgroundColor(this.bgPresetId === pid ? Theme.accent : Theme.surfaceMuted) - .borderRadius(4) - .padding({ left: 8, right: 8, top: 4, bottom: 4 }) - .margin({ right: 6, top: 6 }) - .onClick(() => { this.bgPresetId = normalizePreset(pid); this.emitUserChange(); }) + Chip({ + label: { + text: presetLabel(pid), + fontSize: Theme.fontTiny, + fontColor: Theme.textPrimary, + activatedFontColor: Theme.accentFg + }, + activated: this.bgPresetId === pid, + backgroundColor: Theme.surfaceMuted, + activatedBackgroundColor: Theme.accent, + borderRadius: Theme.radiusControl, + allowClose: false, + accessibilitySelectedType: AccessibilitySelectedType.SELECTED, + onClicked: () => { this.bgPresetId = normalizePreset(pid); this.emitUserChange(); } + }) }, (pid: string) => pid) } .width('100%').margin({ top: 8 }) diff --git a/client/harmony/entry/src/main/ets/common/Surface.ets b/client/harmony/entry/src/main/ets/common/Surface.ets index 7e0e93b..f5e7dcb 100644 --- a/client/harmony/entry/src/main/ets/common/Surface.ets +++ b/client/harmony/entry/src/main/ets/common/Surface.ets @@ -304,7 +304,9 @@ export struct GlassCard { .backgroundColor(this.active ? Color.Transparent : Theme.surface) .backgroundBlurStyle(this.active ? Theme.cardMaterial : BlurStyle.NONE) .borderRadius(this.radius) - .border({ width: this.hairline ? 1 : 0, color: Theme.border }) + /* ★ 2026-09-28:轮廓走 `Theme.hairline`(系统玻璃材质轮廓色), + 与 GlassCardModifier 同一档 —— 见那处注释的实测数据。 */ + .border({ width: this.hairline ? 1 : 0, color: Theme.hairline }) .clip(true) } } @@ -468,8 +470,32 @@ export class GlassCardModifier implements AttributeModifier { * `backgroundColor`)。WebUI 的卡片正是这两件 —— 它没有投影仍然读得出是"一块卡"。 * 投影留给**真正悬浮在内容之上**的层(底部导航条),那里它才有信息意义。 */ - /* 发丝描边:用系统分隔线色(深浅由系统给),不是手写 alpha */ - instance.border({ width: 1, color: Theme.border }); + /* + * 发丝描边:**卡片轮廓专用**色(`Theme.hairline`,系统 + * `glass_material_outline_primary`)。 + * + * ★★ 2026-09-28 修(用户:「没有 webui 精致美观…十分杂乱」)。 + * 原来这里用 `Theme.border`(`ohos_id_color_list_separator`)—— + * 那是**行间分隔线**的色,职责是"分开两行同底色文本",所以必须够重; + * 而卡片轮廓压在壁纸/异色上,只需要"一条若有若无的边"。 + * + * 设备实测(模拟器 1256×2760,同一张列表头卡 y=440): + * 改前(list_separator) rgb(204,204,204) → 亮度 202 + * 改后(本令牌) 实测亮度 236 + * WebUI `--hairline-fg` 压白 = rgb(238,239,240) + * ⇒ 原来比 WebUI **深约 34 级(约 3 倍)**。 + * + * 一屏十几张卡各描一圈重边,就是"杂乱"里**边框**那一半的来源。 + * 白纱与 alpha 本来就是对的(framework 布局转储实测 `#C7FFFFFF` = 0.78, + * 与 WebUI `--glass-card-wall-a` 逐位一致)——**只有描边这一件用错了档位**。 + * + * ★ 为什么是系统色:我第一版照 WebUI 自己写了两个半透明值,判据当场报红 + * 三条,逼出一个更好的答案 —— SDK 的 `id_defined.json` 里有 + * `glass_material_outline_primary`("玻璃材质轮廓"),正是这一档, + * 深浅两套值由系统给。**不自己造系统已有的值**,这是"用官方件而非自绘" + * 在**令牌层**的对应物。完整推理见 `Theme.hairline` 的注释。 + */ + instance.border({ width: 1, color: Theme.hairline }); } else { /* 壁纸关着时不铺任何材质(退了才不浪费一次模糊) */ instance.backgroundBlurStyle(BlurStyle.NONE); diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index 645bc89..bbdc1b5 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -107,6 +107,57 @@ export class Theme { static readonly textSubtleLight: string = '#6B7280'; // WebUI gray-400,压白底 4.83:1 static readonly textSubtleDark: string = '#8A92A1'; // WebUI .dark gray-400,压深卡片 5.51:1 + /* + * 发丝描边(**卡片轮廓**专用)—— **走系统材质轮廓色**,不自己写。 + * + * ★★ 2026-09-28 新增(用户:「鸿蒙 app 样式…没有 webui 精致美观」)。 + * + * ── 病因(**实测**,不是推断)── + * 卡片轮廓此前一律用 `Theme.border` + * (`sys.color.ohos_id_color_list_separator`)—— 那是**列表行之间**的 + * 分隔线色,不是"卡片轮廓"色。两者职责不同,而 list_separator 明显更重: + * + * 设备实测(模拟器 1256×2760 横剖,同一张列表头卡 y=440): + * 改前(list_separator) = rgb(204,204,204) → 亮度 202 + * 改后(本令牌) = 实测亮度 236 + * WebUI `--hairline-fg` = rgb(15 23 42 / 0.07) 压白 = rgb(238,239,240) + * ⇒ 原来比 WebUI **深约 34 级(约 3 倍)**,一屏十几张卡就是 + * 十几道重描边 —— 这是"杂乱"里**边框**那一半的来源。 + * + * 注意:玻璃卡的三件套"白纱 + 发丝边 + 层次"里,**白纱与 alpha 本来就是对的** + * (framework 布局转储实测容器 `#C7FFFFFF` = 0.78,与 WebUI + * `--glass-card-wall-a: 0.78` 逐位一致)—— 只有描边这一件用错了档位。 + * + * ── 为什么是系统色,而不是自己写 ---- + * + * 第一版我照 WebUI 写了 `hairlineLight = #120F172A` / `hairlineDark = #17FFFFFF` + * (逐字对齐 `--hairline-fg` 的两个值)。**判据当场报红三条**: + * · `A2` —— 新写死的色没登记; + * · `B` —— 8 位半透明色属于"手写玻璃/手写遮罩",只允许 glassCard 那两档; + * · 遮罩那条 —— 同一原因。 + * + * B 条那条注释把问题问得很准:「加第三个半透明色 ⇒ 先回答 + * **系统为什么没有对应物**」。我照做了,去 SDK 的 `id_defined.json` + * (7826 条系统资源,唯一权威表)里搜,**找到了对应物**: + * + * glass_material_outline_primary ← 「玻璃材质轮廓」(主) + * glass_material_outline_secondary ← (次) + * + * 这正是"玻璃卡的边"这一档,系统连深浅两套值一起给 —— 所以**不该自己写**。 + * 这也正是用户那句「尽量使用官方组件而不是自绘」在**令牌层**的对应物: + * 自绘的边界不是"画了个控件",而是"造了一个系统已有的值"。 + * + * ⇒ `Theme.hairline` 是**系统色**,深浅由系统给,不登记 `SELF_OWNED_COLORS` + * (登记册管的是"自己写的色",这个是系统色)。 + * + * ★ 与 `Theme.border` 的分工(**不是替换关系**,两者都留着): + * · `Theme.border` = 行间分隔线(Divider、列表行之间、页签条下沿) + * —— 需要它够重,才能分开同底色的两行; + * · `Theme.hairline` = 卡片/面板**轮廓** —— 压在壁纸或异色上, + * 只需要"一条若有若无的边"。 + */ + static readonly hairline: Resource = $r('sys.color.glass_material_outline_primary'); + /* * ──────── 三级文字在**深色**下的可读性下限(2026-09-19 加)──────── * diff --git a/client/harmony/entry/src/main/ets/pages/ContactsTab.ets b/client/harmony/entry/src/main/ets/pages/ContactsTab.ets index 125344f..d67fc85 100644 --- a/client/harmony/entry/src/main/ets/pages/ContactsTab.ets +++ b/client/harmony/entry/src/main/ets/pages/ContactsTab.ets @@ -497,12 +497,26 @@ export struct ContactsTab { ListItem() { Column() { Row() { + /* + * ★ 2026-09-28:未读 6px 圆点(与收件箱组头同一做法, + * 对齐 WebUI `w-1.5 h-1.5 rounded-full`); + * 时间 10 → 11px(10px 与同列表的 11/14 不成体系)。 + * 原来只有**加粗**这一条未读线索,在密集列表里扫不出来。 + */ + if (m.status === 'unread') { + Text('') + .width(6).height(6).borderRadius(3) + .backgroundColor(Theme.accent) + .margin({ right: 6 }) + } Text(m.from_name.length > 0 ? m.from_name : '—') - .fontSize(12).fontColor(Theme.textPrimary) + .fontSize(12) + .fontWeight(m.status === 'unread' ? FontWeight.Bold : FontWeight.Normal) + .fontColor(Theme.textPrimary) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) Blank() Text(compactMailTime(m.created_at)) - .fontSize(10).fontColor(Theme.textSubtleFor()) + .fontSize(11).fontColor(Theme.textSubtleFor()) } .width('100%') Text(m.subject) @@ -520,7 +534,8 @@ export struct ContactsTab { .padding({ left: 12, right: 12, top: 10, bottom: 10 }) .attributeModifier(GlassCardModifier.of(this.bgActive)) .borderRadius(Theme.radiusCard) - .border({ width: 1, color: Theme.border }) + /* ★ 2026-09-28:描边只在未读时给(同收件箱组头,见该处注释)。 */ + .border({ width: 1, color: m.status === 'unread' ? Theme.accentEdgeFor() : Color.Transparent }) } .width('100%') .attributeModifier(PressEffectModifier.of()) diff --git a/docs/API.md b/docs/API.md index 93f16f8..1da91ab 100644 --- a/docs/API.md +++ b/docs/API.md @@ -88,7 +88,7 @@ POST /me/mail/send 发信 GET /me/mail/inbox 收件箱(?status=unread|all&limit=N) GET /me/mail/sent 发件箱 GET /mail/{id} 单封详情(含附件列表) -GET /mail/{id}/thread 对话树(分块加载,见下) +GET /mail/{id}/thread 对话树(?offset=N&limit=N 自线索根分块加载,见下) POST /mail/{id}/read 标记已读 POST /me/mail/{id}/forward 转发(引用原文 + 附件随行) ``` @@ -119,22 +119,28 @@ POST /me/mail/{id}/forward 转发(引用原文 + 附件随行) ### 对话树 ``` -GET /mail/{id}/thread?dir=around&limit=40 首屏:锚点 + 部分祖先 + 部分子孙 -GET /mail/{id}/thread?dir=up&offset=20&limit=40 继续往上(上滑加载) -GET /mail/{id}/thread?dir=down&offset=40&limit=40 继续往下 +GET /mail/{id}/thread?limit=60 首屏:自线索根起的头一块 +GET /mail/{id}/thread?offset=60&limit=60 继续往后取(offset 传上次 next_offset) ``` 树由 `parent_mail_id` 编码:回复指向来信,转发指向被转发的原件。 因此**树可以跨会话** —— 转发把线索引到新会话,却仍属同一条线索。 +**从线索根整树 BFS,而不是「锚点的祖先链 + 锚点的子树」**:后者会让兄弟分支 +整条落在盲区里 —— 一封抄送给两个 Agent 的邮件会收到两个回复,二者互为兄弟, +从其中一个看树看不到另一个;挂在原件上的转发分支同理。先上溯到根 +(`ThreadRootOf`)再整棵展开,兄弟、抄送产生的平行回复、转发分支都是根的子孙, +一次覆盖。 + ```json { "anchor_mail_id": "...", - "dir": "around", + "root_mail_id": "...", + "anchor_depth": 3, "nodes": [ { "mail_id": "...", "parent_mail_id": "...", - "depth": -3, + "depth": 0, "from_name": "admin", "to_name": "pi", "subject": "...", "body_preview": "正文前 240 字节…", "attachment_count": 2, @@ -142,20 +148,25 @@ GET /mail/{id}/thread?dir=down&offset=40&limit=40 继续往下 } ], "total": 21, "hidden": 4, - "has_more_up": true, "has_more_down": false, - "next_up": 20, "next_down": 20 + "has_more": true, "next_offset": 60 } ``` -- `depth` 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。 - 分块加载时根可能还没取到,所以不用「距根深度」 -- `offset` 是相对锚点的偏移:`up` 按层数,`down` 按节点数。把 `next_up`/`next_down` - 原样回传即可,不必自己算已加载数量 +- `depth` 是**距线索根**的层级:0 = 根,1 = 它的直接回复。**恒 ≥ 0**。 + 从根展开后根一定在结果里,绝对深度因此总是可知的 +- `offset` 是自根起的**节点数**偏移(不是层数)。从根 BFS 后只剩一个方向, + 游标就是「已取到的节点数」;把 `next_offset` 原样回传即可,不必自己算 +- `root_mail_id` / `anchor_depth` 是服务端算出的根与锚点深度,供客户端定位 - 节点只带 `body_preview`(240 字节,按 UTF-8 边界截断),全文用 `GET /mail/{id}` 单取 - `hidden` = 本页因权限被过滤掉的节点数 - `detached` = 父邮件不在当前已加载集合里;`parent_hidden` 进一步区分 - 「确实无权查看」(永久)与「尚未加载」(随上滑补齐) -- `limit` 夹到 [1, 200],非法值回落默认 40 + 「确实无权查看」(永久)与「尚未加载」(往下翻会补上) +- `limit` 夹到 [1, 200],非法值回落默认 **60** + +**锚点一定可见**:巨型线索里锚点可能没落在 BFS 首页,此时服务端额外把 +「根 → 锚点」这条路径补进第一页,锚点自己单取。但该路径的回填**上限 60 层** +(`anchorPathBudget`)—— 超过 60 层的链条,锚点之上会出现断点。 +递归 CTE 本身另有 `descendantDepthCap = 10000` 的兜底,仅防数据损坏成环。 **鉴权按会话逐个进行**:A 转发给 B 之后,B 与 C 在新会话里的往来不会回流给 A。 拿一个自己无权访问的 `mail_id` 当锚点直接返回 403。 diff --git a/docs/GUI-PLAN-HARMONY.md b/docs/GUI-PLAN-HARMONY.md index 0492792..766a525 100644 --- a/docs/GUI-PLAN-HARMONY.md +++ b/docs/GUI-PLAN-HARMONY.md @@ -135,7 +135,7 @@ entry/src/main/ets/ | 主界面(平板/2in1) | 两栏/三栏自适应:左列表 + 右详情;`@ohos.mediaquery` 断点切换 | | 会话列表 | 列表/卡片视图切换;卡片含主题、最新一封发件人+摘要、预算徽标(剩 1 橙 / 用尽红 / 不限不显示) | | 邮件详情 | Markdown 正文 + 附件行 + 抄送行 + 权限卡片 + 底部「回复 / 转发」+ 对话树入口 | -| 对话树 | 缩进 + 连接线(不引图形库),分块加载:`dir=around` 首屏 + `up/down` 增量 | +| 对话树 | 缩进 + 连接线(不引图形库),分块加载:自线索根整树 BFS,`offset` 增量 | | 写信页 | 收件人补全(三段式 suggest)+ 抄送 + 主题 + 正文 + 预算档位 + 附件 + 会话别名(仅 `.new`) | | 设置 | 网关地址、退出登录、密钥管理(`/me/keys` 创建/吊销,展示 token_hint) | diff --git a/docs/PLAN.md b/docs/PLAN.md index d8e17d0..21010f7 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -764,7 +764,7 @@ execute: { 两处不一致时无法判断谁对。直接用递归 CTE 在 mails 上查(`idx_mails_parent` 已有)。 - [x] `repo/thread.go`:`AncestorsRaw`(向上)/ `DescendantsRaw`(向下)两个方向的递归 CTE -- [x] `GET /mail/{id}/thread?dir=around|up|down&offset=N&limit=N` +- [x] `GET /mail/{id}/thread?offset=N&limit=N`(自线索根整树 BFS) - [x] **树可跨会话**:转发把线索引到新会话,但 parent 仍指向原件。 这正是树视图比会话内平铺更有价值的地方 —— 能看出线索分叉去了哪里 - [x] **按会话逐个鉴权**:A 转发给 B 之后,B 与 C 在新会话里的往来不能回流给 A。 @@ -780,15 +780,16 @@ execute: { 原先实现是深度上限 100 直接截断,超长线索看不到全貌。改为按方向分页: -- [x] 首屏 `dir=around` 取锚点附近一块(limit 在两个方向各分一半); - `dir=up|down` + `offset` 增量加载,两端各自带 `has_more_*` 与 `next_*` -- [x] **游标用「相对锚点的层号/节点偏移」而不是 mail_id**: - 偏移量每次从锚点重走一遍,无状态、不可伪造; +- [x] **从线索根整树 BFS**(`ThreadRootOf` 上溯到根,再 `DescendantsRaw` 展开): + 弃掉原先 `dir=around|up|down` 三方向分页 —— 那种做法下**兄弟分支整条落在盲区里**: + 一封抄送给两个 Agent 的邮件会收到两个回复,二者互为兄弟,从其中一个看树看不到另一个; + 挂在原件上的转发分支同理。先到根再整棵展开,兄弟、平行回复、转发分支都是根的子孙 +- [x] **游标是「自根起的节点数」而不是 mail_id**:偏移量每次从根重走一遍,无状态、不可伪造; 用 mail_id 做游标就必须允许传入**不可见**的邮件(不可见的中间段要穿过去), - 那还得单独证明它确实是锚点的祖先,反而更绕。 - 祖先方向的层号天然稳定 —— 新邮件只会追加成叶子,不会插进已有链条中间 -- [x] `depth` 改为**相对锚点**(0 = 锚点,负 = 祖先,正 = 子孙): - 分块加载时根可能还没取到,绝对深度无从得知 + 那还得单独证明它确实是根的祖先,反而更绕。 + 节点顺序天然稳定 —— 新邮件只会追加成叶子,不会插进已有链条中间 +- [x] `depth` 为**距线索根**的绝对层级(0 = 根,恒 ≥ 0):从根展开后根一定在结果里, + 绝对深度总是可知(`AncestorsRaw` 内部的负 depth 在 handler 层加回 `anchorDepth` 抵消) - [x] repo 层**不做可见性过滤**:不可见的中间段必须能穿过(转发把线索引进别人的会话, 再往上却可能仍是自己参与的往来)。过滤放在 handler 层,那里才知道调用者是谁 - [x] 前端 `IntersectionObserver` 双哨兵(rootMargin 200px 提前触发)+ @@ -797,8 +798,10 @@ execute: { (`setState` 异步,读 state 会双双看到 false) - [x] 实测 251 封长链:7 页取完,depth 连续无缺口,取到真正的根; 311 封(含 60 个转发分支)9 页取完,`depth=1` 恰为 61 个 -- [x] `limit` 夹到 [1,200],非法值回落默认 40(分页参数不该因笔误让整个请求失败); - `limit=1` 时两方向各保底 1,否则算出 `downLimit=0` 连锚点自己都不返回 +- [x] `limit` 夹到 [1,200],非法值回落默认 60(分页参数不该因笔误让整个请求失败) +- [x] 锚点回填上限 `anchorPathBudget = 60` 层:锚点没落在 BFS 首页时把「根 → 锚点」 + 那条路径补进第一页,保证点开的那封一定在树里;超过 60 层则锚点之上留断点。 + 递归 CTE 另有 `descendantDepthCap = 10000` 兜底,仅防数据损坏成环 **工作列表卡片视图(已完成)**: