跨端: 顶栏不再自己铺白条 + 日历改左右两栏 + 常驻窗格动画真的会播(用户三处实测指出)

用户三条反馈,逐条对应:

① 「底栏数字为什么显示在图标下面?」
   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 依赖。
This commit is contained in:
2026-09-19 12:30:05 +08:00
parent 652316674a
commit 36ef15a8f2
15 changed files with 1216 additions and 331 deletions

View File

@ -3,9 +3,9 @@
*
* WebUI 的侧栏(60px 宽)实际是:
* · 品牌标(顶部,点它回收件箱)
* · 通信 / 日历 / 联系 —— **每项 = 图标 + 文字标签**
* · 通信 / 日历 / **联系** —— **三项**,每项 = 图标 + 文字标签
* · 选中态:**浅蓝底块**(`--nav-active-bg: 219 234 254`)+ 文字/图标变色
* · 底部一簇:账号头像(带连接状态点)/ 主题切换 / 退出
* · 底部一簇:**账号头像(带连接状态点)/ 主题切换 / 退出**
*
* ★★ 2026-09-18 修:这个文件的上一版写着「WebUI 的 Sidebar 是**纯图标轨**
* (无 label 文字)……选中态**不加背景块**」,并据此把侧栏做成了纯图标 + 只变色。
@ -20,16 +20,31 @@
* 而用户 2026-09-16 说的「底部导航栏不允许有文字」针对的是**底部导航栏**。
* ⇒ 并排截图一眼可见的差异,被一段自己写的注释盖住了。
*
* 保留的差异(**有理由的**,不是漏做):WebUI 底部那一簇(头像/主题/退出)
* 鸿蒙放在「我的」窗格与底栏里 —— 这里只放导航项,避免同一动作两个入口。
*
* 判据 `harmony-widescreen.test.mjs` 检查:60vp 宽、三项导航 + 设置、
* 选中态有底块、导航项带文字标签。
* ★★ 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 { NAV_CONTENT_ITEMS, NavItem, navBadgeCount, navBadgeText, navBadgeTone } from '../model/NavItems';
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]` */
@ -37,7 +52,7 @@ export const SIDEBAR_WIDTH: number = 60;
@Component
export struct WideSidebar {
/** 当前选中的导航下标 */
/** 当前选中的导航下标(= 内容窗格下标,四项那套) */
@Prop currentIndex: number = 0;
/**
* 窗口避让区(vp)。
@ -51,42 +66,104 @@ export struct WideSidebar {
* 徽标计数 —— 由 `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 => {};
/**
* 侧栏导航项 = **图标 + 文字标签 + 选中底块**(三项都与 WebUI `Sidebar` 一致)。
* SSE 连接状态 → 头像右下角那颗点(对齐 WebUI `ConnectionIndicator`)。
*
* 底块尺寸对齐 WebUI:`w-12 h-12 rounded-lg` = 48×48vp、圆角 8。
* 状态由 `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
NavItemBuilder(key: string, label: string, iconKey: string, index: number) {
SidebarItem(item: NavItem) {
Stack({ alignContent: Alignment.TopEnd }) {
Column() {
AmIcon({
iconName: iconKey,
iconName: item.iconKey,
iconSize: 22,
iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg
iconColor: this.currentIndex === sidebarContentIndex(item.key) ? Theme.navFgActive : Theme.navFg
})
Text(label)
Text(item.label)
.fontSize(10)
.fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)
.fontColor(this.currentIndex === sidebarContentIndex(item.key) ? Theme.navFgActive : Theme.navFg)
.margin({ top: 2 })
}
.width(48)
.height(48)
.borderRadius(12)
.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 === index ? Theme.navActiveBg : Color.Transparent)
.onClick(() => { this.onSelect(index); })
.backgroundColor(this.currentIndex === sidebarContentIndex(item.key) ? Theme.navActiveBg : Color.Transparent)
.onClick(() => { this.onSelect(sidebarContentIndex(item.key)); })
/*
* 徽标(与底栏、与 WebUI `Sidebar.tsx:88-95` 同一套口径)。
@ -96,17 +173,19 @@ export struct WideSidebar {
* 不是排在文字下面。放下面会把 48vp 的项撑高、方阵节奏乱掉
* (第一版就是这么错的,截图一眼可见:那颗 3 掉到了"联系人"文字下面)。
*/
if (navBadgeText(navBadgeCount(key, this.navUnread, this.navPending, this.navContacts)).length > 0) {
Text(navBadgeText(navBadgeCount(key, this.navUnread, this.navPending, this.navContacts)))
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(key, this.navPending) === 'perm' ? Theme.warnFg : Theme.danger)
.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(48)
.height(48)
.width(SIDEBAR_ITEM_SIZE)
.height(SIDEBAR_ITEM_SIZE)
}
build() {
@ -127,8 +206,13 @@ export struct WideSidebar {
.margin({ bottom: 8 })
.onClick(() => { this.onSelect(0); })
// 导航项:**只内容窗格**(前三项)—— 设置已单列在下面,
// 若遍历 NAV_ITEMS 会因第 4 项「我的」出现**两个 person 图标**。
/*
* 导航项 —— **三项**(`NAV_SIDEBAR_ITEMS`),不含「我的」。
*
* 「我的」在 WebUI 是**底部头像按钮**(`Sidebar.tsx:186`),
* 不是导航轨里的一项;把它放在这里会多出一个 person 图标
* (弹与 `NAV_CONTENT_ITEMS` 重复,两者当时就是同一个错)。
*/
/*
* 导航项**一簇贴顶**,不是等分铺满。
*
@ -138,8 +222,8 @@ export struct WideSidebar {
* 三个图标散在 1200px 里 —— 并排一看就知道不是同一个东西。
*/
Column({ space: 4 }) {
ForEach(NAV_CONTENT_ITEMS, (item: NavItem, index: number) => {
this.NavItemBuilder(item.key, item.label, item.iconKey, index)
ForEach(NAV_SIDEBAR_ITEMS, (item: NavItem) => {
this.SidebarItem(item)
}, (item: NavItem) => item.key)
}
.width('100%')
@ -147,19 +231,53 @@ export struct WideSidebar {
Blank()
/*
* ★★ 2026-09-18 修:下面那一簇(原「设置」单列项)删掉了。
* 底部一簇 —— 对齐 WebUI `Sidebar.tsx:183-212`:
* ① 账号头像(前两字,右下角带 SSE 连接状态点)→ 进「我的」窗格
* ② 主题切换(浅色/深色翻转)
* ③ 退出登录
*
* 它原来是 `onSettings → pushUrl('pages/SettingsPage')` —— 那正是用户
* 2026-09-17 报过的形状:「我的页面完全没有遵守 nav 的导航规则」
* (push 一个独立 @Entry 页 ⇒ 侧栏/底栏整条消失,要先「返回」才能再切窗格)。
* 底栏那一支当时改成了窗格(`currentIndex === 3`),**侧栏这一支漏了**,
* 于是宽屏下同一个毛病还在(而宽屏此前从未真正运行过,所以没人看见)。
*
* 现在两侧统一:`NAV_CONTENT_ITEMS` 是**全部四项**(含「我的」),
* 上面的 `ForEach` 已经把四项都渲染出来(含「我的」= `onSelect(3)` = 窗格),
* 所以这里**不需要**再单列一个入口 —— 单列那个会与第 4 项重复,
* 而重复的那一个还恰好是"推页"的那条老路。
* ★ 上一版把这一簇删了,理由是"鸿蒙放在「我的」窗格与底栏里,避免同一动作两个入口"
* —— 那条理由站不住: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 补一条**实测口径**(差点被我读错):