/* * 宽屏侧栏 —— 对齐 WebUI 的 `Sidebar.tsx`。 * * WebUI 的侧栏(60px 宽)实际是: * · 品牌标(顶部,点它回收件箱) * · 通信 / 日历 / **联系** —— **三项**,每项 = 图标 + 文字标签 * · 选中态:**浅蓝底块**(`--nav-active-bg: 219 234 254`)+ 文字/图标变色 * · 底部一簇:**账号头像(带连接状态点)/ 主题切换 / 退出** * * ★★ 2026-09-18 修:这个文件的上一版写着「WebUI 的 Sidebar 是**纯图标轨** * (无 label 文字)……选中态**不加背景块**」,并据此把侧栏做成了纯图标 + 只变色。 * **那段理由是编的**,两条都错: * · `Sidebar.tsx:110` 有 `{short}` * —— 通信 / 日历 / 联系 三个标签一直都在; * · `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`), * 剩下的空间由 `
` 吃掉。 * 我上一版给每项 `.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) } }