Files
MailUI4Agents/client/harmony/entry/src/main/ets/model/NavItems.ts
JianFeeeee 36ef15a8f2 跨端: 顶栏不再自己铺白条 + 日历改左右两栏 + 常驻窗格动画真的会播(用户三处实测指出)
用户三条反馈,逐条对应:

① 「底栏数字为什么显示在图标下面?」
   WebUI 的徽标是 `absolute top-1 right-[22%]`(脱离文档流、浮在图标右上角),
   我写成了 `Column` 的第三个子节点 ⇒ 参与竖向布局、掉到文字下面。
   改用 `Stack({ alignContent: Alignment.TopEnd })` 锚在**图标**上。
   (顺带撞了 skill 里明写的坑:Stack 没有 `.justifyContent()`。)

② 「一个横着过去的白条,我真的服了」/「期望:融进背景」
   WebUI 的顶栏**自身没有底色** —— 只有 `border-b border-gray-200`
   (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
   底色由所在面板给;壁纸开启时那层面板是玻璃色(`index.css:876`)。
   鸿蒙三个窗格顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
   顶上都是一条不通明白带。改成与**页面底**同一口径
   (`bgActive ? Transparent : surface`)+ 补下边框。

③ 「日历页面和webui布局完全不同」
   WebUI 是**左右两栏**(`CalendarView.tsx:452-457`):左 `flex-1` 网格、
   右 `400px` 常驻面板(日程 / 编辑器 / 小时网格三态互斥)。
   鸿蒙原来是**单栏竖堆**。重搭为两栏,`paneWide` 由 `.onAreaChange`
   量本页**自己的**宽度(不是屏幕宽度 —— 宽屏下这一页已被侧栏占掉一截);
   编辑器改占右栏位置(不再整页盖掉正在看的那个月)。

④ 「最严重的动画问题你一点也不该改」
   日历是**常驻挂载**(`visibility` 控制,因为它里面 today 要随时间重算、
   也要保住"正在看哪个月"),而 `.transition()` 只在**挂载/卸载**时触发
   (SDK 原话 \"when it **appears and disappears**\")⇒ 挂在它上面的
   `.transition(paneRiseIn())` **一帧也不会播**,切过去是硬弹。
   WebUI 踩过同一个坑并把错法写进了 `index.css:1305-1320`
   (「只挂了类,却没让触发窗口出现 ⇒ 类挂着、动画永远不播」),
   它的解法是 `html.view-switch` 重放窗口。ArkUI 对应物是
   `animateTo` + 显式 `calPaneIn` 属性(`opacity` + `translate`)。
   ★ reset 必须在 `animateTo` **外**:写进回调里会与同帧的 1 相抵,
     渲染层只看得见最终值 ⇒ 动画退化成一个瞬移。

顺带修:
· 宽屏侧栏 = 3 项(通信/日历/**联系**)+ 底部一簇(头像/主题/退出),
  与底栏的四项(含「我的」)**不是同一份清单** —— WebUI 的 Sidebar 与
  NarrowNav 本就不同(`Sidebar.tsx:26-44` vs `NarrowNav.tsx:37-40`+143)。
  新增 `NAV_SIDEBAR_ITEMS` / `ME_PANE_INDEX` / `SIDEBAR_ITEM_*`。
· 退出登录抽成 `api/Logout.ets` 的 `performLogout()`:侧栏底簇与「我的」页
  两个入口必须做同一件事(尤其"先注销推送 token"那一步),复制一份就会不一致。
· 徽标 `'plain'` 档底色:WebUI 是石板灰 `--c-chrome-600`(#475569),
  我写成与未读共用红色 ⇒ 「联系」的徽标看起来像"有未读"。
· 主题快捷开关(侧栏底簇):对齐 `ThemeToggleButton` —— 从 `system` 翻转时
  落到**当前生效值的反面**(不是回 system;那可能毫无变化、让按钮看起来坏了)。
· 「浓度」→「压暗」+ 数值带单位(原先屏上印 `56.000000`;WebUI 是 `suffix="%"`)。

判据(8 个套件全绿:logic 28 / nav 18 / widescreen 7 / window 9 /
arkts 5 / contacts 5 / calendar 30 / system-api 5):
· `harmony-widescreen` ② 重写:**回读 `Sidebar.tsx` 数 `short:` 的个数**
  要求鸿蒙同数,并断言底部一簇三键真的被调用(`this.onToggleTheme()` ——
  第一版写成 `/onToggleTheme/`,变异测试当场证明它不咬:属性**声明**还在,
  正则照样匹上)。新增 ⑧:三档色调各自底色,期望值从 `--c-chrome-600` 读出。
· `harmony-nav` 动画条重写:改判**机制真的存在且被驱动**
  (旧断言 `.visibility(...).transition(...)` 锁的正是那个 bug ——
  判据引自己写的注释当依据,就会把错误锁死)。新增 reset-在-animateTo-外
  这条断言(否则动画退化成瞬移)。
· `harmony-nav` 设备条:`navItemsOf` 的宽屏过滤从"左边缘靠左 1/6"
  改成"**整个盒子在侧栏轨道内**" —— 旧条件把日历网格的格子
  (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
  新增 `navRailItemsOf`:导航轨贴顶、底部簇在屏底,按位置切一刀。
· `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS`
  **各自的数组**里取 label —— 原先把全文件 `label:` 一网打尽,
  得到 7 个(4+3 混在一起),任何一边改对了它都会红。

设备实测(HATriple 三折叠,3184×2232):侧栏 3 项 + 底簇 / 底栏徽标回到
图标右上角 / 日历左右两栏与 WebUI 并排同构。

server/go.mod:补 2da38bb 漏提交的 lunar-go 依赖。
2026-09-19 12:30:05 +08:00

302 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 底部导航(P5:悬浮玻璃条)的**清单与尺寸**。
*
* 为什么数据放在 `.ts` 而不是写在 `MainPage.ets` 里:
* - 判据可以直接 `import` 它(`node --experimental-strip-types` 能跑纯逻辑 `.ts`),
* 于是"命中区 ≥44vp"这类要求判的是**数值本身**,而不是拿正则去源码里猜;
* - 「通信 → 联系人」的顺序与文案是信息架构,和 CommTabs 一样属于跨端要对齐的东西
* (WebUI 侧同样是两项,撤掉会话 tab 的时机见计划文档 §7.15)。
*/
/** 一个底部导航项。字段与 WebUI `Sidebar.tsx` 的项一一对应 */
export interface NavItem {
/** 稳定键(ForEach 的 key,也是判据认的名字) */
key: string;
/** 显示文案(与 WebUI 逐字一致) */
label: string;
/**
* 图标名 —— 指向 `common/Icons.ets` 的 `ICON_PATHS`,与 WebUI 的图标集**同几何**。
*
* 2026-09-15 之前这里是 emoji 字形(`✉️`/`📅`/`👤`),理由是"不用猜 sys.media 名字"。
* 那条理由只解释了"为什么不用系统资源名",却把 emoji 当成了唯一替代 —— 结果是:
* · WebUI 在 `icons.tsx` 第一行就写着「**纯 SVG 图标,全站不使用 emoji**」,
* 两边**同一个产品却两套图标语言**;
* · emoji 由系统字体渲染,颜色不受 `fontColor` 控制、随厂商与版本变形,
* 选中/未选中只能靠整体透明度示意,做不到 WebUI 那种"线性描边图标 + 品牌色"。
*
* 现在改用 ArkUI 原生 `Path`(`common/Icons.ets`,由 WebUI 的 TSX 自动生成),
* 既不需要猜 `sys.media` 名字,又能吃主题色(深色模式一处也不漏)。
*/
iconKey: string;
/**
* 外壳入口:点击后路由到独立 @Entry 页(`pages/xxx`),不在主界面挂内容。
* 留空 = 内容窗格(由 `MainPage` 的 currentIndex 分派挂内容)。
*
* 为什么单列这个字段而不是把「我的」塞进 currentIndex 分派:设置页是 **@Entry**
* (自带返回按钮、独立路由栈),不能当作可嵌入的 pane —— 在内容分派里给它
* 占一个 index 会出现"点了没内容、或每次重建都 pushUrl"的尴尬。外壳入口在
* NavItem 的 onClick 里直接路由,与 WideSidebar 底部那个设置按钮是同一行为。
*/
/**
* 外壳入口的路由目标。
*
* ★ 2026-09-17 之后**没有导航项再用它** —— 第 4 项「我的」已改为内容窗格
* (见 `NAV_ITEMS` 的说明)。字段保留是因为宽屏侧栏的“设置”按钮与
* `WideSidebar.onSettings` 仍走同一套路由语义;**新加的导航项不要再填它**,
* 否则又会出现“点了导航、导航条整个消失”的形状。
*/
route?: string;
}
/**
* 底栏四枚图标 = 三个内容窗格 + 一个外壳入口,与 WebUI `NarrowNav.tsx` 的四入口一一对应。
* 前**三项**是平级内容窗格:通信(内部三栏:收件箱/发件箱/授权)、日历、联系人。
* 第**四项**「我的」也是**内容窗格**(与前三项同一套机制,`currentIndex === 3`)——
* 它承载设置/主题/壁纸/账号。★ 2026-09-17 修正:原先它是外壳入口(`route` 走
* `pushUrl('pages/SettingsPage')` 推独立页),导致底部导航整条消失 —— 用户原话
* 「我的页面完全没有遵守 nav 的导航规则」。WebUI 的 `account` 只是一个 `viewMode`,
* 导航常驻,所以这里改成同一套窗格机制。
*
* 「会话」原先是个平级 tab,已撤 —— 它不是第三个地方,而是"同一批数据的另一种看法"
* (收件箱按会话折叠、联系人卡片视图就是会话的进度视角)。理由见 `MainPage.build()` 的注释。
*
* 「日历」原来写着"等 P6 内容做完再上入口 —— 不留点进去空着的页签"。P6 第一步
* (只读月视图:`pages/CalendarPage.ets`)已经做完,所以入口**现在**上:
* 空页签那条理由不再成立,而日历的纯逻辑(`model/Calendar.ts`)本来就是照着
* 「顶层是 通信/日历/联系人 三个平级 pane」写判据的。
*
* ★ `NAV_ITEMS` 是**底栏**的清单(四项)。宽屏侧栏的清单是 `NAV_SIDEBAR_ITEMS`
* (三项 + 底部一簇),两者**不可互相替代** —— 理由见那份的注释。
*/
export const NAV_ITEMS: NavItem[] = [
{ key: 'comm', label: '通信', iconKey: 'inbox' },
{ key: 'calendar', label: '日历', iconKey: 'calendar' },
{ key: 'contacts', label: '联系人', iconKey: 'contacts' },
/*
* ★ 2026-09-17:第 4 项**不再带 `route`** —— 它现在是**内容窗格**。
*
* 原形状是外壳入口:`route: 'pages/SettingsPage'`,点击 `pushUrl` 推一个
* 独立 @Entry 页 ⇒ 底部导航整条消失(用户:「我的页面完全没有遵守 nav 的导航规则」)。
* WebUI 的 `account` 只是一个 `viewMode`(与 inbox/calendar/contacts 同级),
* 底部导航**无条件渲染** —— 所以这里改成同一套窗格机制。
*/
{ key: 'me', label: '我的', iconKey: 'person' }
];
/** 命中区下限(vp)。**44 是可点区域的下限**,不是"看起来够大"的估计 */
export const NAV_ITEM_MIN_HIT: number = 44;
/** 浮动条自身高度(vp)。≥ NAV_ITEM_MIN_HIT,否则命中区内边距会互相挤压 */
export const NAV_BAR_HEIGHT: number = 56;
/** 悬浮:左右留白(vp)—— 贴边就不是"悬浮"了,WebUI 的底栏同样浮在内容之上 */
export const NAV_BAR_SIDE: number = 16;
/** 悬浮:离屏幕底部的留白(vp) */
export const NAV_BAR_BOTTOM: number = 12;
/** 圆角(vp)。用"胶囊"半径(= 高度一半),与 WebUI 的 `rounded-2xl` 观感一致 */
export const NAV_BAR_RADIUS: number = 28;
/**
* 内容与浮动条之间的余量(vp)。
*
* 单独起名而不是把 `8` 埋在算式里:它是这一族里**唯一**还需要人判断的数
* (条离底 12 + 条高 56 + 余量 8),命名之后"内容到底让多少"只剩一个数可争,
* 其余全部派生。pi 2026-09-14 提的"让位高度应从条高派生,不要写成并列常量",
* 落实在这两行上。
*/
export const NAV_CONTENT_GAP: number = 8;
/**
* 内容底部要让出的高度(vp):条高 + 离底留白 + 一点余量。
*
* **这是"点得到"的问题,不是美观问题**:条是浮在内容之上的,不让出这段高度,
* 列表最后一行就永远压在玻璃条底下 —— 看得见、点不到。
*
* ★ 让位方式:**加在滚动容器的内容末尾**(`contentEndOffset` / 末尾占位),
* **不是**加在窗格的 `padding({bottom})` 上。两者看起来等价,实际差很大:
* padding 会**缩短窗格本身** ⇒ 内容永远到不了条底下 ⇒
* 玻璃条背后只剩一张**已经被壁纸层模糊过**的壁纸,系统材质无东西可糊,
* 于是它看起来就是一块普通的浅色面板,而不是玻璃(2026-09-17 用户:
* 「底栏不是玻璃质感,滑动内容无法穿过底栏」)。
* WebUI 同构:`.narrow-shell` 是 `flex-col`,列表满高、`.narrow-nav` 作为兄弟
* 叠在上面(有自己的 margin),内容自然滑到条底下。
*
* **派生,不并列**:上面三个常量任何一个变大,这里自动跟着变;写死一个数(例如 76)
* 就会在改条高的那天悄悄失配。判据 `harmony-nav.test.mjs` 同时钉"派生关系"与
* "在算式中出现",防它退回字面量。
*/
export const NAV_CONTENT_RESERVE: number = NAV_BAR_HEIGHT + NAV_BAR_BOTTOM + NAV_CONTENT_GAP;
/** 内容窗格的数量(前三项:通信/日历/联系人)—— `MainPage` 的 currentIndex 分派按它穷尽,
* `NAV_CONTENT_ITEMS` 也按它切片 */
/** 内容窗格的数量(四项:通信/日历/联系人/我的)—— `MainPage` 的 currentIndex 分派按它穷尽 */
export const NAV_CONTENT_COUNT: number = 4;
/**
* 内容窗格的清单 —— 宽屏侧栏与 `currentIndex` 分派**都用它**。
*
* ★ 2026-09-18 修注释:这里原来写着「前三项……第 4 项「我的」是外壳入口,不在内」,
* 但代码写的是 `slice(0, NAV_CONTENT_COUNT)` 而 `NAV_CONTENT_COUNT = 4`
* ⇒ **四项全在**,注释与代码互相矛盾。宽屏侧栏因此多出一个「我的」,
* 与它下面那个"推页进设置"的入口并排(两个 person 图标),
* 而 `MainPage` 的 `currentIndex === 3` 分支本来就是窗格 —— 三处口径不一。
* 现在统一:**四项都是内容窗格**,注释按代码写。
*/
export const NAV_CONTENT_ITEMS: NavItem[] = NAV_ITEMS.slice(0, NAV_CONTENT_COUNT);
/* ─────────────────── 宽屏侧栏(与底栏**不是同一份清单**) ─────────────────── */
/**
* 宽屏**侧栏**的导航项 —— 三项,**不含「我的」**。
*
* ★★ 2026-09-19 修(用户:「你写的app和webui大面积不符,问题特别大」)。
*
* 之前侧栏直接用 `NAV_CONTENT_ITEMS`(四项,含「我的」)——
* **那是底栏的形状**,不是侧栏的。两套导航在 WebUI 里本来就不同,证据:
*
* · `Sidebar.tsx:26-44` 的 `navItems` 只有 **3 项**:通信 / 日历 / **联系**;
* 「我的」不在这里 —— 它是 `Sidebar.tsx:186` 底部那一簇里的**头像按钮**
* (`onClick={() => setViewMode('account')}`),与主题切换、退出并列;
* · `NarrowNav.tsx:37-40` 的 `items` 是 3 项 + 第 4 个「我的」按钮
* (`NarrowNav.tsx:143`),所以底栏**是**四项。
*
* 也就是说:**四项是对的,但只对底栏**。侧栏要三项 + 底部一簇。
* 我把底栏那份直接 `slice()` 给侧栏用,于是并排一看就多出一个「我的」,
* 而 WebUI 那一格是头像。
*
* ★ 第三项的文案也不同:侧栏是「**联系**」(`Sidebar.tsx:43` `short: '联系'`),
* 底栏是「联系人」(`NarrowNav.tsx:39`)。两处各是各的字面量,不是笔误。
*/
export const NAV_SIDEBAR_ITEMS: NavItem[] = [
{ key: 'comm', label: '通信', iconKey: 'inbox' },
{ key: 'calendar', label: '日历', iconKey: 'calendar' },
{ key: 'contacts', label: '联系', iconKey: 'contacts' }
];
/**
* 侧栏第三项在 `NAV_CONTENT_ITEMS` 里的下标(`contacts`)。
*
* 侧栏只画三项,但点击要选中 `currentIndex`(内容窗格的下标,四项那套)。
* 用具名常量而不是就地写 `2`:这一项的顺序要与 `NAV_SIDEBAR_ITEMS` 的构图一致,
* 改顺序时只改一处。
*/
export function sidebarContentIndex(key: string): number {
for (let i = 0; i < NAV_CONTENT_ITEMS.length; i++) {
if (NAV_CONTENT_ITEMS[i].key === key) {
return i;
}
}
return 0;
}
/** 「我的」内容窗格的下标(侧栏底部头像点它进账户页) */
export const ME_PANE_INDEX: number = 3;
/** 侧栏底色块宽度(vp)—— WebUI `Sidebar.tsx` 的 `w-12 h-12` */
export const SIDEBAR_ITEM_SIZE: number = 48;
/** 侧栏圆角(vp)—— WebUI 的 `rounded-lg` */
export const SIDEBAR_ITEM_RADIUS: number = 12;
/**
* 「我的」的路由目标(`SettingsPage` 仍然存在,供别处按需推页)。
*
* ★ 侧栏/底栏**不再**用它 —— 第 4 项是内容窗格(`currentIndex === 3`)。
* 留着这个常量不是死代码:`SettingsPage` 作为 @Entry 仍可被推到
* (例如将来做"从通知点开设置"),但**导航栏不许拿它当入口**
* (用户 2026-09-17:「我的页面完全没有遵守 nav 的导航规则」)。
*/
export const NAV_SETTINGS_ROUTE: string = 'pages/SettingsPage';
/**
* 滚动列表上下边缘的渐隐高度(vp)。
*
* 对应 WebUI `index.css` 的 `.overflow-y-auto { mask-image: linear-gradient(...) }`:
* 滚动条本身是“一刀切”,卡片滑到边缘被硬截断;加一层上下渐隐后,
* 切断处变成渐隐(2026-09-14 用户:「内容项上下滑动会直接被切断」)。
*
* ArkUI 的对应物是 `List.fadingEdge(true, { fadingEdgeLength })`(API 14+),
* **不要**手捧渐变色遮罩:那是拿背景色画一个不存在的“底色”,
* 在壁纸/玻璃主题下会露馅。
*
* 取 12:与 WebUI 的 `min(12px, 10%)` 同量级,也接近列表内边距(10vp),
* 静止时几乎看不出,滚动时才起作用。
*/
export const LIST_FADE_LENGTH: number = 12;
/** 把任意下标归一化到内容窗格的合法范围(点击/外部传值都过这里,别直接赋值) */
export function normalizeNavIndex(index: number): number {
if (index >= 0 && index < NAV_CONTENT_COUNT) {
return index;
}
return 0;
}
/** 第 index 项的键(内容按它挂载,判据也认它) —— 越界回 0(内容窗格,不含外壳入口) */
export function navKeyAt(index: number): string {
return NAV_CONTENT_ITEMS[normalizeNavIndex(index)].key;
}
/** 第 index 项的标签(给判据与无障碍文案用) —— 越界回 0(内容窗格,不含外壳入口) */
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 + '';
}