From d8277a5977efa946873c1103cf43d748cada1721 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 18 Sep 2026 13:30:38 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E9=A1=B9=E5=BE=BD=E6=A0=87=E4=B8=A4=E4=BE=A7=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=EF=BC=88=E6=88=91=E4=B8=8A=E6=AC=A1"=E6=92=A4=E5=9B=9E"?= =?UTF-8?q?=E9=94=99=E4=BA=86=20=E2=80=94=E2=80=94=20WebUI=20=E6=98=AF?= =?UTF-8?q?=E6=9C=89=E7=9A=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一笔我凭"两侧导航都没有徽标"把刚写好的徽标**撤回**了。那是错的: `client/electron/src/components/Sidebar.tsx:88-95,111-125` 明确有—— ``` const badge = isComm ? unread + pendingPerms : modes.includes('contacts') ? contacts.length : 0; const badgeTone = isComm && pendingPerms > 0 ? 'perm' : isComm ? 'unread' : 'plain'; ``` 并且是 `absolute top-0.5 right-1` 压在导航项右上角,`>99` 显示 `99+`。 我当时只看了底栏 `NavItem`(那里确实没有),就把结论推到了"两侧都没有"。 ## 补的东西 - 新增**纯逻辑** `model/NavItems.ts` 的 `navBadgeCount` / `navBadgeTone` / `navBadgeText`, 逐条对齐 WebUI 的口径: · **通信** = 未读 + 待决策(两类"要动手"合起来); · **联系人** = 联系人数;日历/「我的」= 0(没有徽标); · 色调:待决策**橙**(有人卡在那儿等)优先于未读**红**(只是还没看); · 负数当 0(计数来自网络,不假设它干净);`>99` → `99+`。 - **两侧**(底栏 `MainPage.NavItem` + 宽屏 `WideSidebar.NavItemBuilder`)都接上, 且读**同一组** AppStorage 键 —— 两处各算一套,数字迟早对不上,而用户同时看得到它们。 - 计数发布走 `AppStorage`(单向:窗格写、导航栏读),与 `KEY_WINDOW_INSETS` 同一套机制。 不把这两个数提到 `MainPage`:那样"从没进过通信页"也会去发请求。 - 徽标位置对齐 WebUI 的 `absolute top-0.5 right-1`(压在项的右上角)。 ★ 第一版我排在文字**下面**,截图一眼可见那颗 3 掉到了「联系人」标签底下、 还把 48vp 的项撑高了 —— 方阵节奏乱掉。 ## 判据(harmony-widescreen 6 → 7) 第 ⑦ 条**直接执行**鸿蒙侧的纯函数,且期望值在测试里**独立算一遍** (不复用被测函数,否则是"用实现验实现")。 ★ 接线部分我写错过一次,变异测试当场拆穿:第一版只判 `assert.match(src, /navBadgeCount\(/)` —— 把**渲染处**的调用换成 `0` (徽标永远不显示),文件里仍留着一处调用,判据照样全绿。 ⇒ 改成判**把值交给 Text 的那一行**,并且认出两侧写法不同(底栏走 helper `Text(this.navBadgeOf(key))`,侧栏就地内联 `Text(navBadgeText(navBadgeCount(...)))`)。 **4 个变异方向全咬**:通信漏算待决策 ⇒ 红;色调优先级写反 ⇒ 红; 侧栏渲染处换空 ⇒ 红;底栏渲染处换空 ⇒ 红。 设备实测:侧栏「联系人」显示红 **3**(3 个联系人),位置在图标右上角。 --- .../electron/test/harmony-widescreen.test.mjs | 75 ++++++++++++++++++- client/electron/test/run-all.mjs | 2 +- .../entry/src/main/ets/model/NavItems.ts | 56 ++++++++++++++ .../entry/src/main/ets/pages/MainPage.ets | 66 ++++++++++++++++ .../entry/src/main/ets/pages/WideSidebar.ets | 36 ++++++++- 5 files changed, 229 insertions(+), 6 deletions(-) diff --git a/client/electron/test/harmony-widescreen.test.mjs b/client/electron/test/harmony-widescreen.test.mjs index 2e16fec..eb10daa 100644 --- a/client/electron/test/harmony-widescreen.test.mjs +++ b/client/electron/test/harmony-widescreen.test.mjs @@ -202,4 +202,77 @@ test('⑤ 宽屏 app-shell 几何:padding/gap/radius 与 WebUI 同值(一比 assert.match(code_, /clip\(this\.isWide\)/, '面板内容要被圆角裁剪(clip 宽屏才开)'); // 容器 padding 宽屏 paneGap / 窄屏 0(壁纸从缝隙露出) assert.match(code_, /left: this\.isWide \? Theme\.paneGap : 0/, '宽屏容器左右 padding paneGap(窄屏 0 贴合全屏)'); -}); \ No newline at end of file +}); +test('⑦ 导航项徽标:取值/色调与 WebUI Sidebar 同口径(纯逻辑,不需要设备)', async () => { + /* + * WebUI `Sidebar.tsx:88-95` 的 `badge` / `badgeTone`: + * `isComm ? unread + pendingPerms : (contacts ? contacts.length : 0)` + * `isComm && pendingPerms > 0 ? 'perm' : isComm ? 'unread' : 'plain'` + * + * ★ 这条判据**直接执行**鸿蒙侧的纯函数(`model/NavItems.ts`,无 SDK 依赖), + * 并把期望值**在测试里独立算一遍**(不复用被测函数)—— 否则就是"用实现验实现"。 + * + * 为什么值得单列一条:底栏与侧栏都要显示徽标,而两处若各写一套判断, + * 同一时刻一个显示"3 / 未读"、另一个显示"3 / 待批",用户会以为哪里出错了。 + * 所以取值与色调都必须来自**同一组纯函数**。 + */ + const mod = await import(join(HARMONY_ETS, 'model', 'NavItems.ts')); + const { navBadgeCount, navBadgeTone, navBadgeText } = mod; + + // ① 通信 = 未读 + 待决策(两类"要动手"合起来,与 WebUI 同) + assert.equal(navBadgeCount('comm', 5, 2, 0), 7, '通信徽标 = 未读 5 + 待决策 2'); + // ② 联系人 = 联系人数 + assert.equal(navBadgeCount('contacts', 0, 0, 3), 3, '联系人徽标 = 联系人数'); + // ③ 日历 / 我的 没有徽标(日历的"有事"是格子上的点,不是导航栏该喊的) + assert.equal(navBadgeCount('calendar', 5, 2, 3), 0, '日历不该有徽标'); + assert.equal(navBadgeCount('me', 5, 2, 3), 0, '「我的」不该有徽标'); + // ④ 负数当 0(计数来自网络,不假设干净) + assert.equal(navBadgeCount('comm', -1, -1, -1), 0, '负数一律当 0'); + assert.equal(navBadgeCount('contacts', 0, 0, -5), 0, '联系人数为负也当 0'); + + // ⑤ 色调:待决策橙(更急)优先于未读红;非通信项是中性 + assert.equal(navBadgeTone('comm', 2), 'perm', '有人的授权在等我 ⇒ 橙(比"还没看"更急)'); + assert.equal(navBadgeTone('comm', 0), 'unread', '只有未读 ⇒ 红'); + assert.equal(navBadgeTone('contacts', 0), 'plain', '联系人数是中性色'); + + // ⑥ 文字:0 → 空串(页面据此不渲染)、>99 → 99+(与 WebUI 同一写法) + assert.equal(navBadgeText(0), '', '0 要变成空串(否则会渲染一个"0"徽标)'); + assert.equal(navBadgeText(99), '99'); + assert.equal(navBadgeText(100), '99+'); + + /* + * ⑦ 接线:**两侧**(底栏 NavItem + 宽屏侧栏)都要用这同一组函数。 + * 只在一处接的话,另一个视图里徽标就消失了 —— 而用户两个视图都会用到。 + */ + const main = stripComments(read('pages/MainPage.ets')); + const sidebar = stripComments(read('pages/WideSidebar.ets')); + /* + * ★ 判**徽标真的被渲染出来**,不是"文件里出现过 navBadgeCount"。 + * + * 第一版只 `assert.match(src, /navBadgeCount\(/)` —— 变异测试当场证明它不咬: + * 把渲染处的 `navBadgeCount(...)` 换成 `0`(徽标永远不显示), + * 文件里仍留着别处的调用,判据照样全绿。 + * + * 两侧的**写法不同**(都合法),所以按各自形状判: + * · 底栏 MainPage:走一个 helper —— `Text(this.navBadgeOf(key))`, + * helper 体内 `navBadgeText(navBadgeCount(...))`; + * · 宽屏 WideSidebar:就地内联 —— `Text(navBadgeText(navBadgeCount(...)))`。 + * 两边都要判到,且都要判底色来自 `navBadgeTone`。 + */ + assert.match(main, /Text\(this\.navBadgeOf\(/, + '底栏要把徽标交给 Text 渲染(光"文件里出现过 navBadgeCount"不算接线)'); + assert.match(main, /navBadgeText\(navBadgeCount\(/, + '底栏的 helper 要真的用 navBadgeText(navBadgeCount(...)) 算值'); + assert.match(main, /backgroundColor\(navBadgeTone\(/, + '底栏的徽标底色要由 navBadgeTone 决定'); + + assert.match(sidebar, /Text\(navBadgeText\(navBadgeCount\(/, + '侧栏要把徽标交给 Text 渲染(光"文件里出现过 navBadgeCount"不算接线)'); + assert.match(sidebar, /backgroundColor\(navBadgeTone\(/, + '侧栏的徽标底色要由 navBadgeTone 决定'); + // 两侧读**同一组** AppStorage 键(各算一套的话数字会不一致) + for (const key of ['agentmail.nav.unread', 'agentmail.nav.pending', 'agentmail.nav.contacts']) { + assert.ok(main.includes(key), `MainPage 要读 ${key}`); + assert.ok(sidebar.includes(key), `WideSidebar 要读 ${key}(与底栏同一组键)`); + } +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index f1705e2..8f32798 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -137,7 +137,7 @@ const SUITE = [ // 三维地址拼装:直接执行 `model/ReplyTarget.ts` 真逻辑(行为判据) ['test/harmony-reply-target.test.mjs', ['--experimental-strip-types', '--no-warnings'], 7], // 宽屏侧栏图标轨:`WideSidebar` 接线与常量(静态判据,无设备) - ['test/harmony-widescreen.test.mjs', [], 6] + ['test/harmony-widescreen.test.mjs', [], 7] ]; // 自检 1:清单里的文件必须真的存在(写错名字 = 那条判据永远不跑) diff --git a/client/harmony/entry/src/main/ets/model/NavItems.ts b/client/harmony/entry/src/main/ets/model/NavItems.ts index 740206d..63e8221 100644 --- a/client/harmony/entry/src/main/ets/model/NavItems.ts +++ b/client/harmony/entry/src/main/ets/model/NavItems.ts @@ -187,3 +187,59 @@ export function navKeyAt(index: number): string { export function navLabelAt(index: number): string { return NAV_CONTENT_ITEMS[normalizeNavIndex(index)].label; } + +/* ─────────────────── 导航项徽标 ─────────────────── */ + +/** + * 导航项徽标的**取值**(纯逻辑 —— 判据直接执行这一层,不需要设备)。 + * + * ★ 逐条对齐 WebUI `Sidebar.tsx:88-95` 的 `badge`: + * `isComm ? unread + pendingPerms : (contacts ? contacts.length : 0)` + * 三条口径都要跟: + * · **通信**把两类"要动手"合起来显示(未读 + 待决策)—— 用户同时看底栏/侧栏, + * 两处各算一套,数字迟早对不上; + * · **联系人**挂联系人数("有几个线索在手"); + * · **日历/我的**没有徽标(日历的"有事"是某天的点标记,不是导航栏该喊的东西)。 + */ +export function navBadgeCount(key: string, unread: number, pending: number, contactCount: number): number { + const k: string = key.trim(); + if (k === 'comm') { + // 负数当 0:计数来自网络响应,不假设它一定干净 + const u: number = unread > 0 ? unread : 0; + const p: number = pending > 0 ? pending : 0; + return u + p; + } + if (k === 'contacts') { + return contactCount > 0 ? contactCount : 0; + } + return 0; +} + +/** + * 导航项徽标的**色调**:`'perm'`(待决策,橙)/ `'unread'`(未读,红)/ `'plain'`(中性)。 + * + * 对齐 WebUI `Sidebar.tsx:95` 的 `badgeTone`: + * `isComm && pendingPerms > 0 ? 'perm' : isComm ? 'unread' : 'plain'` + * + * ★ 待决策用橙、未读用红**不是配色偏好**:未读是「有内容没看」, + * 待决策是「有 Agent 卡在那儿等我」—— 后者更急。WebUI 的注释把这条写明了, + * 两边用同一个优先级才不至于一个喊一个不喊。 + */ +export function navBadgeTone(key: string, pending: number): string { + const k: string = key.trim(); + if (k === 'comm') { + return pending > 0 ? 'perm' : 'unread'; + } + return 'plain'; +} + +/** 徽标文字:0 → 空串(页面据此不渲染),>99 → '99+'(与 WebUI 同一写法) */ +export function navBadgeText(n: number): string { + if (n <= 0) { + return ''; + } + if (n > 99) { + return '99+'; + } + return n + ''; +} diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 0045366..138090f 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -82,6 +82,9 @@ import { NAV_CONTENT_RESERVE, NAV_ITEM_MIN_HIT, NAV_ITEMS, + navBadgeCount, + navBadgeText, + navBadgeTone, NavItem, normalizeNavIndex } from '../model/NavItems'; @@ -1149,6 +1152,14 @@ struct CommPage { @State commTab: string = 'inbox'; @State unreadCount: number = 0; @State pendingCount: number = 0; + /* + * 徽标数的 AppStorage 键 —— 由刚才那个窗格写、由导航栏读(见发布处注释)。 + * 常量而不是字面量:拼错 `AppStorage.get('xxx')` 不报错、只会恒为 undefined, + * 而那正好是"徽标永远不出现"这个症状 —— 与 windowInsets 同一个坑。 + */ + private readonly KEY_NAV_BADGE_UNREAD: string = 'agentmail.nav.unread'; + private readonly KEY_NAV_BADGE_PENDING: string = 'agentmail.nav.pending'; + private readonly KEY_NAV_BADGE_CONTACTS: string = 'agentmail.nav.contacts'; /** Navigation 路由栈:宽屏 Split 时列表+详情并排,窄屏 Stack 时详情 push 覆盖 */ private navPathStack: NavPathStack = new NavPathStack(); @@ -1253,6 +1264,17 @@ struct CommPage { } this.unreadCount = unread; this.pendingCount = pending; + /* + * ★ 同时**发布到 AppStorage** —— 底栏与侧栏(MainPage 的兄弟分支)要读它。 + * + * 为什么不把这两个数提到 MainPage:它们是"通信"这个窗格的数据, + * 由这个窗格自己拉(每账号一次 inbox + pendingPermissions)。 + * 提到父级会让 MainPage 在**从没进过通信页**时也去发请求。 + * 而徽标必须在导航栏上可见(那是它的意义),所以走 AppStorage 单向发布: + * 窗格算 → 写 → 导航栏读。这与 `KEY_WINDOW_INSETS` 同一套机制。 + */ + AppStorage.setOrCreate(this.KEY_NAV_BADGE_UNREAD, unread); + AppStorage.setOrCreate(this.KEY_NAV_BADGE_PENDING, pending); } catch (e) { // 徽标拉不到就不显示(比显示一个错的数字好) } @@ -1462,6 +1484,10 @@ struct ContactsTab { try { const resp = await m.contacts(); this.contacts = resp.contacts; + /* 联系人数也发布给导航栏徽标(口径见 model/NavItems.ts 的 navBadgeCount) */ + /* 键名与 MainPage 的 KEY_NAV_BADGE_CONTACTS 同值('agentmail.nav.contacts')—— + * 两个 struct 不能共享私有常量,所以这里写字面量并在两处注释里互指。 */ + AppStorage.setOrCreate('agentmail.nav.contacts', resp.contacts.length); } catch (e) { const ae = e as ApiError; this.error = ae.message.length > 0 ? ae.message : '加载失败'; @@ -2066,6 +2092,16 @@ struct MainPage { * 那正好是本轮要修的病症(避让永远 0 = 黑边照旧),不能用会静默失效的写法。 */ @StorageLink(KEY_WINDOW_INSETS) @Watch('recomputeNavReserve') windowInsets: Insets = new Insets(); + /* + * 导航项徽标计数 —— 由 `CommPage.loadBadges` 经 AppStorage 发布(单向:窗格写、导航栏读)。 + * + * `@StorageProp` 而不是 `@StorageLink`:导航栏**只读**,不该往外写。 + * 键名与 `CommPage` 里的常量必须一致 —— 拼错不报错、只会恒为默认 0 + * (那正好是"徽标永远不出现"这个症状,与 windowInsets 同一个坑)。 + */ + @StorageProp('agentmail.nav.unread') navUnread: number = 0; + @StorageProp('agentmail.nav.pending') navPending: number = 0; + @StorageProp('agentmail.nav.contacts') navContacts: number = 0; @State bgPlan: BackgroundPlan = new BackgroundPlan(); /** * 背景是否开着 —— 传给每个页面,让它们把**页面底**让出来(变成透明)。 @@ -2340,6 +2376,34 @@ struct MainPage { } } + /** + * 导航项徽标文字(空串 = 不渲染)。 + * + * 计数来自 `@StorageProp`(`CommPage` 发布、导航栏只读)—— 单向: + * 窗格算,导航栏读。取值/色调的规则都在 `model/NavItems.ts`(纯逻辑)。 + */ + navBadgeOf(key: string): string { + return navBadgeText(navBadgeCount(key, this.navUnread, this.navPending, this.navContacts)); + } + + /** + * 导航项徽标(独立 `@Builder`,便于在 `Column` 里就地条件渲染)。 + * + * 取值/色调都在 `model/NavItems.ts`(纯逻辑,判据直接执行那一层)—— + * 与 WebUI `Sidebar.tsx:88-95` 的 `badge`/`badgeTone` 同口径。 + */ + @Builder + NavBadge(key: string) { + if (this.navBadgeOf(key).length > 0) { + Text(this.navBadgeOf(key)) + .fontSize(9).fontColor(Color.White) + .backgroundColor(navBadgeTone(key, this.navPending) === 'perm' ? Theme.warnFg : Theme.danger) + .borderRadius(8) + .padding({ left: 4, right: 4 }) + .margin({ top: 2 }) + } + } + @Builder NavItem(item: NavItem, index: number) { Column() { @@ -2368,6 +2432,8 @@ struct MainPage { .lineHeight(12) .fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg) .margin({ top: 3 }) + /* 徽标:见 NavBadge(口径与 WebUI Sidebar 的 badge/badgeTone 一致) */ + this.NavBadge(item.key) } /* * 命中区:**显式给下限**(44vp),不靠"看起来够大"。 diff --git a/client/harmony/entry/src/main/ets/pages/WideSidebar.ets b/client/harmony/entry/src/main/ets/pages/WideSidebar.ets index 2310e0f..7099727 100644 --- a/client/harmony/entry/src/main/ets/pages/WideSidebar.ets +++ b/client/harmony/entry/src/main/ets/pages/WideSidebar.ets @@ -29,7 +29,7 @@ import { Theme } from '../common/Theme'; import { AmIcon } from '../common/Icons'; -import { NAV_CONTENT_ITEMS, NavItem } from '../model/NavItems'; +import { NAV_CONTENT_ITEMS, NavItem, navBadgeCount, navBadgeText, navBadgeTone } from '../model/NavItems'; import { Insets, topInset } from '../model/WindowInsets'; /** 侧栏宽度(vp)。WebUI 的 `Sidebar` 用 `w-[60px]` */ @@ -47,6 +47,15 @@ export struct WideSidebar { * 窄屏的顶部窗格当时补了 `topInset`,侧栏这一支漏了;而宽屏此前从未真正运行过。 */ @Prop windowInsets: Insets = new Insets(); + /* + * 徽标计数 —— 由 `CommPage` / `MainPage` 经 AppStorage 发布(单向:页面写、导航栏读)。 + * + * `@StorageProp` 而不是 `@StorageLink`:侧栏**只读**,不该往外写。 + * 与底栏读的是**同一组键** —— 两处各算一套,数字迟早对不上(用户同时看得到它们)。 + */ + @StorageProp('agentmail.nav.unread') navUnread: number = 0; + @StorageProp('agentmail.nav.pending') navPending: number = 0; + @StorageProp('agentmail.nav.contacts') navContacts: number = 0; /** 背景是否开启:开着=玻璃面板(blur + 0.88 白),关着=不透明白 */ @Prop bgActive: boolean = false; /** 选中回调 */ @@ -59,7 +68,8 @@ export struct WideSidebar { */ @Builder NavItemBuilder(key: string, label: string, iconKey: string, index: number) { - Column() { + Stack({ alignContent: Alignment.TopEnd }) { + Column() { AmIcon({ iconName: iconKey, iconSize: 22, @@ -72,13 +82,31 @@ export struct WideSidebar { } .width(48) .height(48) - // 侧栏宽 60、项 48 ⇒ 两侧各 6vp,**不需要**再压缩;显式声明避免被父级按比例压掉 - .flexShrink(0) .borderRadius(12) .justifyContent(FlexAlign.Center) // 选中底块:WebUI `.nav-item[data-active='true']` 的 `--nav-active-bg` .backgroundColor(this.currentIndex === index ? Theme.navActiveBg : Color.Transparent) .onClick(() => { this.onSelect(index); }) + + /* + * 徽标(与底栏、与 WebUI `Sidebar.tsx:88-95` 同一套口径)。 + * 取值/色调都在 `model/NavItems.ts` 的纯函数里 —— 判据直接执行那一层。 + * + * ★ 位置对齐 WebUI 的 `absolute top-0.5 right-1`:**压在项的右上角**, + * 不是排在文字下面。放下面会把 48vp 的项撑高、方阵节奏乱掉 + * (第一版就是这么错的,截图一眼可见:那颗 3 掉到了"联系人"文字下面)。 + */ + if (navBadgeText(navBadgeCount(key, this.navUnread, this.navPending, this.navContacts)).length > 0) { + Text(navBadgeText(navBadgeCount(key, this.navUnread, this.navPending, this.navContacts))) + .fontSize(9).fontColor(Color.White) + .backgroundColor(navBadgeTone(key, this.navPending) === 'perm' ? Theme.warnFg : Theme.danger) + .borderRadius(8) + .padding({ left: 4, right: 4 }) + .margin({ top: 2, right: 2 }) + } + } + .width(48) + .height(48) } build() {