Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/WideSidebar.ets
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

305 lines
14 KiB
Plaintext
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.

/*
* 宽屏侧栏 —— 对齐 WebUI 的 `Sidebar.tsx`。
*
* WebUI 的侧栏(60px 宽)实际是:
* · 品牌标(顶部,点它回收件箱)
* · 通信 / 日历 / **联系** —— **三项**,每项 = 图标 + 文字标签
* · 选中态:**浅蓝底块**(`--nav-active-bg: 219 234 254`)+ 文字/图标变色
* · 底部一簇:**账号头像(带连接状态点)/ 主题切换 / 退出**
*
* ★★ 2026-09-18 修:这个文件的上一版写着「WebUI 的 Sidebar 是**纯图标轨**
* (无 label 文字)……选中态**不加背景块**」,并据此把侧栏做成了纯图标 + 只变色。
* **那段理由是编的**,两条都错:
* · `Sidebar.tsx:110` 有 `<span className="text-3xs leading-none">{short}</span>`
* —— 通信 / 日历 / 联系 三个标签一直都在;
* · `index.css:1590` 的 `.nav-item[data-active='true'] { background-color: … }`
* 就是底块,而且 CSS 注释**专门**说了侧栏必须有它:
* 「宽屏侧栏是 48px 宽的竖条,图标底下那一块底色是它**唯一的选中线索**,
* 所以"只变色"不能无差别推广到所有 `.nav-item`」。
* 我把底栏那条纪律(用户 2026-09-14「选中对应的文字和图标变色即可」)套到了侧栏上,
* 而用户 2026-09-16 说的「底部导航栏不允许有文字」针对的是**底部导航栏**。
* ⇒ 并排截图一眼可见的差异,被一段自己写的注释盖住了。
*
* ★★ 2026-09-19 修第二处(**同类错误的第二次**,用户:
* 「你写的app和webui大面积不符,问题特别大」):
* 上一版把**底栏的四项**(含「我的」)直接给侧栏用了。四項是对的,
* 但**只对底栏** —— `NarrowNav.tsx:37-40` 才是四项;
* 宽屏侧栏(`Sidebar.tsx:26-44`)是**三项**,
* 「我的」是底部那个**头像按钮**(`Sidebar.tsx:186`),与主题切换/退出并列。
* 并排截图里鸿蒙多出一个「我的」导航项、而 WebUI 那一格是头像 —— 同一个错法。
* ⇒ 两份清单现在是分开的:`NAV_SIDEBAR_ITEMS`(3) 与 `NAV_ITEMS`(4)。
*/
import { Theme } from '../common/Theme';
import { AmIcon } from '../common/Icons';
import { SseService } from '../api/SseService';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import {
NAV_SIDEBAR_ITEMS,
NavItem,
ME_PANE_INDEX,
SIDEBAR_ITEM_SIZE,
SIDEBAR_ITEM_RADIUS,
navBadgeCount,
navBadgeText,
navBadgeTone,
sidebarContentIndex
} from '../model/NavItems';
import { Insets, topInset } from '../model/WindowInsets';
/** 侧栏宽度(vp)。WebUI 的 `Sidebar` 用 `w-[60px]` */
export const SIDEBAR_WIDTH: number = 60;
@Component
export struct WideSidebar {
/** 当前选中的导航下标(= 内容窗格下标,四项那套) */
@Prop currentIndex: number = 0;
/**
* 窗口避让区(vp)。
*
* ★★ 2026-09-18 修:侧栏原先**完全没有避让** —— 全屏之后它的画布也从 y=0 开始,
* 于是品牌标被状态栏时钟压住(并排截图里时钟正好落在信封上)。
* 窄屏的顶部窗格当时补了 `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;
/** 当前账号展示名(底部头像里的前两个字)—— 对齐 WebUI `Sidebar.tsx:190` */
@Prop accountLabel: string = '';
/** 当前是否深色(主题切换按钮的图标)—— 对齐 WebUI `ThemeToggleButton` */
@Prop isDark: boolean = false;
/** 选中回调 */
onSelect: (index: number) => void = (): void => {};
/** 主题切换(顶部快捷开关) */
onToggleTheme: () => void = (): void => {};
/** 退出登录 */
onLogout: () => void = (): void => {};
/**
* SSE 连接状态 → 头像右下角那颗点(对齐 WebUI `ConnectionIndicator`)。
*
* 状态由 `SseService` 提供(`getStatusForAccount`)。这里不做轮询:
* `aboutToAppear` 时读一次 + 订阅变化就够 —— 转圈反而会在状态没变时
* 让界面脏得比状态本身快。
*/
@State private sseStatus: string = 'disconnected';
aboutToAppear(): void {
this.refreshSseStatus();
}
/** 当前活跃账号的连接状态(没有账号时就是断开) */
private refreshSseStatus(): void {
let accountId: string = '';
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
try {
const active: AccountInfo | null = AccountManager.getInstance(ctx).getActiveAccount();
accountId = active === null ? '' : active.id;
} catch (e) {
accountId = '';
}
}
this.sseStatus = SseService.getInstance().getStatusForAccount(accountId);
}
/**
* 状态 → 颜色。与 WebUI `ConnectionIndicator.tsx:26-31` 逐档对齐:
* connecting 黄 / connected 绿 / reconnecting 橙 / disconnected 红。
*
* ★ 取近似色而不是新建一组常量:这四个是"状态点"专用的语义色,
* 与正文/边框不是一族;Theme 里没有它们的位置就就地取,
* 而不是把 `Theme.warnFg`(#B45309 深橙)拿来当"连接中"——那是文字色。
*/
private sseColorOf(status: string): string {
if (status === 'connected') {
return '#22C55E';
}
if (status === 'connecting') {
return '#FACC15';
}
if (status === 'reconnecting') {
return '#FB923C';
}
return '#F87171';
}
/**
* 侧栏导航项 = **图标 + 文字标签 + 选中底块**(三项,与 WebUI `Sidebar` 一致)。
*
* 底块尺寸对齐 WebUI:`w-12 h-12 rounded-lg` = 48×48vp、圆角 8(`Sidebar.tsx:112`)。
*
* ★ 点击传**内容窗格下标**(`sidebarContentIndex`),不是侧栏的下标:
* 侧栏只画三项,而 `currentIndex` 是四项那套(`me` = 3 由底部头像点)。
* 直接把构图的 `index` 传出去会在「联系」以后错位 —— 这里恰好第三项都对,
* 但依赖"两份清单前两项相同"是暗器,不能留。
*/
@Builder
SidebarItem(item: NavItem) {
Stack({ alignContent: Alignment.TopEnd }) {
Column() {
AmIcon({
iconName: item.iconKey,
iconSize: 22,
iconColor: this.currentIndex === sidebarContentIndex(item.key) ? Theme.navFgActive : Theme.navFg
})
Text(item.label)
.fontSize(10)
.fontColor(this.currentIndex === sidebarContentIndex(item.key) ? Theme.navFgActive : Theme.navFg)
.margin({ top: 2 })
}
.width(SIDEBAR_ITEM_SIZE)
.height(SIDEBAR_ITEM_SIZE)
.borderRadius(SIDEBAR_ITEM_RADIUS)
.justifyContent(FlexAlign.Center)
// 选中底块:WebUI `.nav-item[data-active='true']` 的 `--nav-active-bg`
.backgroundColor(this.currentIndex === sidebarContentIndex(item.key) ? Theme.navActiveBg : Color.Transparent)
.onClick(() => { this.onSelect(sidebarContentIndex(item.key)); })
/*
* 徽标(与底栏、与 WebUI `Sidebar.tsx:88-95` 同一套口径)。
* 取值/色调都在 `model/NavItems.ts` 的纯函数里 —— 判据直接执行那一层。
*
* ★ 位置对齐 WebUI 的 `absolute top-0.5 right-1`:**压在项的右上角**,
* 不是排在文字下面。放下面会把 48vp 的项撑高、方阵节奏乱掉
* (第一版就是这么错的,截图一眼可见:那颗 3 掉到了"联系人"文字下面)。
*/
if (navBadgeText(navBadgeCount(item.key, this.navUnread, this.navPending, this.navContacts)).length > 0) {
Text(navBadgeText(navBadgeCount(item.key, this.navUnread, this.navPending, this.navContacts)))
.fontSize(9).fontColor(Color.White)
.backgroundColor(navBadgeTone(item.key, this.navPending) === 'perm'
? Theme.warnFg
: (navBadgeTone(item.key, this.navPending) === 'plain' ? Theme.badgePlain : Theme.danger))
.borderRadius(8)
.padding({ left: 4, right: 4 })
.margin({ top: 2, right: 2 })
}
}
.width(SIDEBAR_ITEM_SIZE)
.height(SIDEBAR_ITEM_SIZE)
}
build() {
Column() {
/*
* 品牌标(点它回收件箱 —— 与 WebUI 的 `brand-mark` 一致的习惯)。
*
* ★ 颜色用 `navBrandFg`(#475569 中性石板灰),**不是** `Theme.accent`:
* WebUI 的品牌标走 `.nav-item` 默认色 `--nav-fg-muted`,像素取证见 Theme 的注释。
* ★ 尺寸/圆角对齐 WebUI:`w-10 h-10 rounded-xl` = 40×40vp、圆角 16,
* 图标 20vp。原来写的是裸 30vp 图标无底盒。
*/
Stack({ alignContent: Alignment.Center }) {
AmIcon({ iconName: 'brandMark', iconSize: 20, iconColor: Theme.navBrandFg })
}
.width(40).height(40)
.borderRadius(16)
.margin({ bottom: 8 })
.onClick(() => { this.onSelect(0); })
/*
* 导航项 —— **三项**(`NAV_SIDEBAR_ITEMS`),不含「我的」。
*
* 「我的」在 WebUI 是**底部头像按钮**(`Sidebar.tsx:186`),
* 不是导航轨里的一项;把它放在这里会多出一个 person 图标
* (弹与 `NAV_CONTENT_ITEMS` 重复,两者当时就是同一个错)。
*/
/*
* 导航项**一簇贴顶**,不是等分铺满。
*
* WebUI 实测:三项各 48px 高、y = 70 / 122 / 174,间距 4(`gap-1`),
* 剩下的空间由 `<div className="flex-1" />` 吃掉。
* 我上一版给每项 `.layoutWeight(1)` ⇒ 在三折叠展开态下每项被拉成 395px 高,
* 三个图标散在 1200px 里 —— 并排一看就知道不是同一个东西。
*/
Column({ space: 4 }) {
ForEach(NAV_SIDEBAR_ITEMS, (item: NavItem) => {
this.SidebarItem(item)
}, (item: NavItem) => item.key)
}
.width('100%')
Blank()
/*
* 底部一簇 —— 对齐 WebUI `Sidebar.tsx:183-212`:
* ① 账号头像(前两字,右下角带 SSE 连接状态点)→ 进「我的」窗格
* ② 主题切换(浅色/深色翻转)
* ③ 退出登录
*
* ★ 上一版把这一簇删了,理由是"鸿蒙放在「我的」窗格与底栏里,避免同一动作两个入口"
* —— 那条理由站不住:WebUI 同时有这两处(侧栏底部 **和** 「我的」页)。
* “侧栏没有它”不是取舍,是没做。
*/
Column({ space: 4 }) {
/* ① 头像(带连接状态点) */
Stack({ alignContent: Alignment.BottomEnd }) {
Text(this.accountLabel.length > 0 ? this.accountLabel.slice(0, 2) : '?')
.fontSize(11)
.fontColor(Theme.navFg)
.width(36).height(36)
.borderRadius(18)
.backgroundColor(this.currentIndex === ME_PANE_INDEX ? Theme.navActiveBg : Theme.border)
.textAlign(TextAlign.Center)
/* 连接状态点:不遮挡文字,贴在右下角(WebUI `-bottom-0.5 -right-0.5`) */
Circle({ width: 8, height: 8 })
.fill(this.sseColorOf(this.sseStatus))
.margin({ right: 1, bottom: 1 })
}
.width(40).height(40)
.onClick(() => { this.onSelect(ME_PANE_INDEX); })
/* ② 主题切换 */
Stack({ alignContent: Alignment.Center }) {
AmIcon({ iconName: this.isDark ? 'moon' : 'sun', iconSize: 16, iconColor: Theme.navFg })
}
.width(36).height(28)
.borderRadius(8)
.onClick(() => { this.onToggleTheme(); })
/* ③ 退出 */
Stack({ alignContent: Alignment.Center }) {
AmIcon({ iconName: 'logout', iconSize: 16, iconColor: Theme.navFg })
}
.width(36).height(28)
.borderRadius(8)
.onClick(() => { this.onLogout(); })
}
.width('100%')
.padding({ top: 8 })
.border({ width: { top: 1 }, color: Theme.border })
.margin({ top: 8 })
}
/*
* ★ 2026-09-18 补一条**实测口径**(差点被我读错):
* 三折叠展开态 dumpLayout 给的是**像素**,而这块设备的密度是 **2.875**(不是 3.5)。
* 侧栏 173px ÷ 2.875 = **60.2vp**,导航项 138px ÷ 2.875 = **48.0vp** ——
* 与声明完全一致。我一开始按 3.5 除,算出"60vp 被压成 49.4",差点去查
* flex 压缩、去加 `.flexShrink(0)`(**都是假的**:没有压缩,是我除错了)。
* 密度从声明值与实测值反推最稳:`138px / 48vp = 2.875`。
*/
.width(SIDEBAR_WIDTH)
.height('100%')
.padding({ top: 12 + topInset(this.windowInsets), bottom: 12 + this.windowInsets.navIndicator })
/*
* ★ 2026-09-16:玻璃面板(对齐 WebUI app-shell)。
* bgActive(自定义壁纸开)时:系统材质 BlurStyle(半透明由系统给,不许手写 alpha)
* + 不透明系统色 surface;关着:纯 surface(普通面板)。
* 登记:GLASS_REGISTRY(cross-client-theme.test.mjs)—— 背后是会滚动的导航内容。
*/
.backgroundBlurStyle(this.bgActive ? Theme.navMaterial : BlurStyle.NONE)
.backgroundColor(Theme.surface)
.borderRadius(Theme.glassRadius)
.clip(true)
}
}