用户三条反馈,逐条对应:
① 「底栏数字为什么显示在图标下面?」
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 依赖。
305 lines
14 KiB
Plaintext
305 lines
14 KiB
Plaintext
/*
|
||
* 宽屏侧栏 —— 对齐 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)
|
||
}
|
||
}
|