+ * {gridPane} ← 左:flex-1
+ *
← 右:400px 常驻
+ * {sidePane}
+ *
+ *
+ *
+ * 窄屏放不下两栏 ⇒ WebUI 退回 `NarrowStack`(右栏覆盖在网格上,滑入滑出)。
+ * 鸿蒙这边窄屏**竖堆**(网格在上、日程在下)—— 这不只是“放不下”的妥协:
+ * 底部有悬浮导航条,覆盖式的右栏会与它抢同一块位置。竖堆是这两种约束下
+ * 唯一都能满足的形态,且信息一条不少。
+ *
+ * 宽度判据用**本页自己的**实测宽度(`paneWide`),不是屏幕宽度 ——
+ * 宽屏下这一页已被侧栏 60vp + 间距占掉一截,两者不相等。
+ */
+ Column() {
+ if (this.paneWide) {
/*
- * 空态也要 `layoutWeight(1)` —— 原来只有 `padding(16)`,
- * 于是周/日档下网格只有一行,"这一天没有日程"贴在上面、
- * 下面留出一大片空白壁纸(实测 09-18:周视图整个下半屏是空的)。
- * 占满剩余高度后空态居中,三种档位的观感才是同一个版式。
+ * 宽屏:左右两栏。
+ *
+ * ★★ 右栏是**三态互斥**的,与 WebUI 的 `sidePane` 同结构
+ * (`CalendarView.tsx:400-445`):编辑器 / 看某天日程。
+ * 这比“编辑器当浮层盖在右栏上”干净得多:
+ * · 不会出现“浮层宽度写死、与右栏对不齐”的错位;
+ * · 不需要 `.position({right:0,top:0})` 这种拿右/上边去定位的写法
+ * (`Position` 接口只有 `x`/`y`,写 `right`/`top` 在别的版本上会静默失效)。
+ * 而且它正好是 WebUI 的原话:「新建/编辑时**同一个位置**变成编辑器」——
+ * 左栏那个月一直看得见。
*/
- Column() {
- Text('这一天没有日程')
- .fontSize(Theme.fontSmall)
- .fontColor(Theme.textSubtle)
- }
- .width('100%')
- .layoutWeight(1)
- .justifyContent(FlexAlign.Center)
- } else {
- List({ space: 8 }) {
- ForEach(this.selectedEvents(), (e: CalendarEvent) => {
- ListItem() {
- this.EventRow(e)
+ Row() {
+ Column() {
+ this.GridPane()
+ }
+ .layoutWeight(1)
+ .height('100%')
+ /* 右栏有左边框(WebUI `border-l border-gray-200`),两栏才有分界 */
+ .border({ width: { right: 1 }, color: Theme.border })
+
+ Column() {
+ if (this.editorOpen) {
+ this.Editor()
+ } else {
+ this.AgendaPane()
}
- }, (e: CalendarEvent) => e.event_id)
+ }
+ .width(CAL_SIDE_WIDTH)
+ .height('100%')
+ /* 右栏是**不透明**的:编辑器表单下面透出月网格会让输入框读不清 */
+ .backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
}
- .layoutWeight(1)
.width('100%')
- /* 末尾让位:最后一条日程要能滚出悬浮条之下(不是缩短列表) */
- .contentEndOffset(this.navReserve)
- /* 上下边缘渐隐(2026-09-17 补:原先漏了,同一次滚动里只有部分是渐隐) */
- .fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
- .padding({ left: 10, right: 10 })
+ .height('100%')
+ .alignItems(VerticalAlign.Top)
+ } else {
+ /*
+ * 窄屏:竖堆(网格在上、日程/编辑器在下)。
+ *
+ * WebUI 窄屏走 `NarrowStack`(右栏覆盖在网格上、滑入滑出,
+ * `CalendarView.tsx:444-447`)—— 那是"右栏没有导航条"的前提。
+ * 鸿蒙窄屏底部有**悬浮导航条**,覆盖式的右栏会与它抢同一块位置,
+ * 所以竖堆(信息一条不少,不需要"返回"才能看到网格)。
+ */
+ if (this.editorOpen) {
+ this.Editor()
+ } else {
+ this.GridPane()
+ this.AgendaPane()
+ }
}
}
.width('100%')
.height('100%')
.backgroundColor(this.bgActive ? Color.Transparent : Theme.pageBg)
-
- /*
- * 写侧面板:**盖在日历之上**(不是 CustomDialog —— 那要另起一套控制器与生命周期,
- * 而"正在编辑哪条事件"本来就是这一页的状态)。
- * 表单**必须不透明**:底下的月网格透出来会让输入框读不清。
- */
- if (this.editorOpen) {
- Column() {
- this.Editor()
- }
- .width('100%')
- .height('100%')
- .backgroundColor(Theme.pageBg)
- }
}
.width('100%')
.height('100%')
+ .onAreaChange((oldValue: Area, newValue: Area) => {
+ /* 够放下「网格 + 400vp 右栏」才并排;否则竖堆(阈值见常量注释) */
+ this.paneWide = (newValue.width as number) >= CAL_TWO_PANE_MIN;
+ })
}
}
+
diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets
index 138090f..d1afae2 100644
--- a/client/harmony/entry/src/main/ets/pages/MainPage.ets
+++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets
@@ -15,6 +15,8 @@ import { MailApi, InboxResponse } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SseService, SseEvent } from '../api/SseService';
import { AppearanceStore } from '../common/AppearanceStore';
+import { AppearanceApi } from '../api/AppearanceApi';
+import { performLogout } from '../api/Logout';
import { Configuration, ConfigurationConstant, EnvironmentCallback } from '@kit.AbilityKit';
import { image } from '@kit.ImageKit';
import { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance';
@@ -828,7 +830,28 @@ struct SentTab {
}
}
.width('100%').height(56).padding({ left: 16, right: 16 })
- .backgroundColor(Theme.surface)
+ /*
+ * 顶栏底:**不自己铺一层实心白** —— 与 WebUI 一致。
+ *
+ * ★★ 2026-09-19 修(用户:「一个横着过去的白条,我真的服了」+
+ * 「期望:融进背景」)。
+ *
+ * WebUI 的顶栏**自身没有底色** —— 它只有 `border-b border-gray-200`
+ * (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
+ * 底色由它所在的**面板**提供。壁纸开启时那层面板是玻璃色
+ * (`index.css:876` `html[data-bg='on'] .bg-white`),顶栏就跟着变玻璃。
+ *
+ * 鸿蒙这边顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
+ * 顶上都是**一条不通明的白带**,横贯屏幕、与下面的玻璃内容脱开 ——
+ * 就是用户说的那条白条。
+ *
+ * 改成与**页面底**同一套口径(`bgActive ? Transparent : surface`):
+ * · 壁纸关:仍是不透明白(与下面内容同色,看不出这条带的边界);
+ * · 壁纸开:透明,壁纸从顶栏透上来 —— 这才是"融进背景"。
+ */
+ .backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
+ /* 下边框对齐 WebUI:顶栏与内容的分界靠这条线,不靠底色差 */
+ .border({ width: { bottom: 1 }, color: Theme.border })
if (this.loading) {
Column() { LoadingProgress().width(32).height(32) }
@@ -1090,7 +1113,28 @@ struct PermissionTab {
}
}
.width('100%').height(56).padding({ left: 16, right: 16 })
- .backgroundColor(Theme.surface)
+ /*
+ * 顶栏底:**不自己铺一层实心白** —— 与 WebUI 一致。
+ *
+ * ★★ 2026-09-19 修(用户:「一个横着过去的白条,我真的服了」+
+ * 「期望:融进背景」)。
+ *
+ * WebUI 的顶栏**自身没有底色** —— 它只有 `border-b border-gray-200`
+ * (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
+ * 底色由它所在的**面板**提供。壁纸开启时那层面板是玻璃色
+ * (`index.css:876` `html[data-bg='on'] .bg-white`),顶栏就跟着变玻璃。
+ *
+ * 鸿蒙这边顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
+ * 顶上都是**一条不通明的白带**,横贯屏幕、与下面的玻璃内容脱开 ——
+ * 就是用户说的那条白条。
+ *
+ * 改成与**页面底**同一套口径(`bgActive ? Transparent : surface`):
+ * · 壁纸关:仍是不透明白(与下面内容同色,看不出这条带的边界);
+ * · 壁纸开:透明,壁纸从顶栏透上来 —— 这才是"融进背景"。
+ */
+ .backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
+ /* 下边框对齐 WebUI:顶栏与内容的分界靠这条线,不靠底色差 */
+ .border({ width: { bottom: 1 }, color: Theme.border })
if (this.loading) {
Column() { LoadingProgress().width(32).height(32) }
@@ -1634,7 +1678,28 @@ struct ContactsTab {
.onClick(() => { this.switchView(); })
}
.width('100%').height(56).padding({ left: 16, right: 8 })
- .backgroundColor(Theme.surface)
+ /*
+ * 顶栏底:**不自己铺一层实心白** —— 与 WebUI 一致。
+ *
+ * ★★ 2026-09-19 修(用户:「一个横着过去的白条,我真的服了」+
+ * 「期望:融进背景」)。
+ *
+ * WebUI 的顶栏**自身没有底色** —— 它只有 `border-b border-gray-200`
+ * (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
+ * 底色由它所在的**面板**提供。壁纸开启时那层面板是玻璃色
+ * (`index.css:876` `html[data-bg='on'] .bg-white`),顶栏就跟着变玻璃。
+ *
+ * 鸿蒙这边顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
+ * 顶上都是**一条不通明的白带**,横贯屏幕、与下面的玻璃内容脱开 ——
+ * 就是用户说的那条白条。
+ *
+ * 改成与**页面底**同一套口径(`bgActive ? Transparent : surface`):
+ * · 壁纸关:仍是不透明白(与下面内容同色,看不出这条带的边界);
+ * · 壁纸开:透明,壁纸从顶栏透上来 —— 这才是"融进背景"。
+ */
+ .backgroundColor(this.bgActive ? Color.Transparent : Theme.surface)
+ /* 下边框对齐 WebUI:顶栏与内容的分界靠这条线,不靠底色差 */
+ .border({ width: { bottom: 1 }, color: Theme.border })
/*
* ★ 会话视图:点联系人卡片后**在本 pane 内**展示那条会话的邮件。
@@ -2103,6 +2168,18 @@ struct MainPage {
@StorageProp('agentmail.nav.pending') navPending: number = 0;
@StorageProp('agentmail.nav.contacts') navContacts: number = 0;
@State bgPlan: BackgroundPlan = new BackgroundPlan();
+ /**
+ * 侧栏底部头像要显示的账号标签(取前两字,对齐 WebUI `Sidebar.tsx:190`
+ * 的 `(user?.display_name || user?.username || '?').slice(0, 2)`)。
+ *
+ * 与各 pane 的 `accountName` 分开存:那些是**收件箱筛选器**当前选中的账号
+ * (可能是"全部邮箱"),而头像要的是**当前活跃账号**——多账号下不同。
+ * 放在 `MainPage` 而不是侧栏自己查:侧栏是无状态展示组件,
+ * 而账号列表只有主框架持有(与徽标走同一套"父算子读")。
+ */
+ @State accountLabel: string = '';
+ /** 当前是否深色(侧栏主题按钮的图标);由 `applyAppearance` 算出来 */
+ @State isDarkNow: boolean = false;
/**
* 背景是否开着 —— 传给每个页面,让它们把**页面底**让出来(变成透明)。
*
@@ -2127,6 +2204,29 @@ struct MainPage {
* 只挂在 ① 上的话,转屏后手势条高度变了而 reserve 没变)。
*/
@State navReserve: number = NAV_CONTENT_RESERVE;
+ /**
+ * 日历窗格(**常驻挂载**那一个)的入场进度:0 = 刚出现,1 = 到位。
+ *
+ * ★★ 2026-09-19 新增(用户:「最严重的动画问题你一点也不该改」)。
+ *
+ * 日历 pane 是**常驻**的(用 `visibility` 控制显示,因为它里面 `today` 要随时间重算、
+ * 也要保住“正在看哪个月”)。而 `.transition()` 只在**挂载/卸载**时触发
+ * (SDK 原话:"when it appears and disappears")——
+ * 所以原先挂在它上面的 `.transition(Theme.paneRiseIn())` **一次也不会播**:
+ * 代码看着挂了动画,实际切过去是硬弹。
+ *
+ * WebUI 踩过**同一个坑**,并在 `index.css:1305-1320` 把自己的错法记下来了:
+ * 「我上一版只给回复框/转发面板挂了类,却没让**触发窗口**在那两个动作上出现
+ * —— 于是类挂着、动画永远不播」。它用 `html.view-switch` 重放窗口解决。
+ *
+ * ArkUI 这边的对应做法:**不靠 TransitionEffect**,用 `animateTo` + 两个显式
+ * `@State`(透明度 + 上浮位移)去驱动。切到日历时:先把 `calPaneIn` 置 0(瞬回未入场态),
+ * 再在 `animateTo` 里置 1 —— 系统根据这两个值算出插值。
+ *
+ * ★ 为何不“换个 key 让节点重挂”:日历**故意常驻**(保住月份/选中日/只读缓存),
+ * 重挂就把那两个理由一起丢了。所以只能动属性,不能动挂载。
+ */
+ @State calPaneIn: number = 1;
/** 环境变化回调 id(-1 = 没订阅);`lastColorMode` 用来只在真的换向时重算 */
private envCallbackId: number = -1;
/**
@@ -2168,6 +2268,35 @@ struct MainPage {
this.navReserve = this.isWide ? 0 : (NAV_CONTENT_RESERVE + this.windowInsets.navIndicator);
}
+ /**
+ * 侧栏底部的主题快捷开关(对齐 WebUI `ThemeToggleButton` → `themeStore.toggle`)。
+ *
+ * ★ 从 `system` 翻转时落到**当前生效值的反面**(显式 light/dark),
+ * **不是**回 `system` —— WebUI `themeStore.ts:79-88` 把这条写明了:
+ * 人点这个按钮的意图是"现在换个样子",变成 system→light(可能毫无变化)
+ * 会让按钮看起来坏了。
+ *
+ * ★ 走的是与「我的」页三选一**同一条写入路径**(`AppearanceStore` + 服务端 put),
+ * 不是直接 `setColorMode`:直接切系统色彩模式只改本机、换设备不跟着走,
+ * 而外观是账号级偏好。
+ */
+ private toggleTheme(): void {
+ const ctx = this.getUIContext().getHostContext();
+ if (ctx === undefined) {
+ return;
+ }
+ const next: string = this.isDarkNow ? 'light' : 'dark';
+ const store: AppearanceStore = AppearanceStore.getInstance();
+ const snap: AppearanceSnapshot = store.current();
+ snap.theme = next;
+ /* 本机先生效(不等人看着);服务端写失败只影响"换设备带不带着走" */
+ store.applyTheme(ctx, next);
+ store.saveLocal(ctx, snap);
+ this.isDarkNow = next === 'dark';
+ const client: ApiClient = ApiClient.getInstance(ctx);
+ new AppearanceApi(client).put(snap, store.wallpaper !== null).catch(() => {});
+ }
+
/**
* 订阅系统环境变化(深浅色切换),**重算我们自己算出来的那部分**。
*
@@ -2258,6 +2387,18 @@ struct MainPage {
*/
const systemMode: number = ctx.resourceManager.getConfigurationSync().colorMode;
const dark: boolean = isDarkMode(snap.theme, systemMode);
+ /* 侧栏底部那个主题按钮的图标跟着它走(WebUI `ThemeToggleButton` 同口径) */
+ this.isDarkNow = dark;
+ /*
+ * 侧栏头像的前两字:当前**活跃账号**的展示名(不是 `accountName`,
+ * 那是收件箱筛选器选的)。拿不到就让它空着 —— 侧栏会显示 `?`。
+ */
+ try {
+ const active: AccountInfo | null = AccountManager.getInstance(ctx).getActiveAccount();
+ this.accountLabel = active === null ? '' : active.displayName;
+ } catch (e) {
+ this.accountLabel = '';
+ }
// 模糊强度是**服务端给的 px 原值**:计划只搬运它,映射成系统材质档在画的那一层做
this.bgPlan = resolveBackground(snap.bgKind, snap.bgPresetId, scrimOpacity(snap.bgDim), store.wallpaper !== null, dark, snap.bgBlur);
this.bgActive = this.bgPlan.kind !== 'none';
@@ -2397,10 +2538,16 @@ struct MainPage {
if (this.navBadgeOf(key).length > 0) {
Text(this.navBadgeOf(key))
.fontSize(9).fontColor(Color.White)
- .backgroundColor(navBadgeTone(key, this.navPending) === 'perm' ? Theme.warnFg : Theme.danger)
+ .backgroundColor(navBadgeTone(key, this.navPending) === 'perm'
+ ? Theme.warnFg
+ : (navBadgeTone(key, this.navPending) === 'plain' ? Theme.badgePlain : Theme.danger))
.borderRadius(8)
.padding({ left: 4, right: 4 })
- .margin({ top: 2 })
+ /*
+ * 偏移对齐 WebUI `absolute top-1 right-[22%]`:压在**图标右上角**,
+ * 而不是排在文字下面(那次正是把绝对定位写成了 Column 的子节点)。
+ */
+ .translate({ x: 4, y: -2 })
}
}
@@ -2422,18 +2569,42 @@ struct MainPage {
* 选中态:**图标与文字一起换色**,不引入背景/指示条 ——
* 与 WebUI 同一套表达(用户 2026-09-14:「选中对应的文字和图标变色即可」)。
*/
- AmIcon({
- iconName: item.iconKey,
- iconSize: 24,
- iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg
- })
+ /*
+ * ★★ 2026-09-19 修 bug(用户:「底栏数字为什么显示在图标下面?」)。
+ *
+ * 图标外面再包一层 `Stack`,**只为把徽标锚在图标上**。
+ *
+ * 徽标原先写成这个 `Column` 的**第三个子节点** —— 于是它参与竖向布局,
+ * 被排在图标、文字**之后**,看起来就是"数字掉到图标下面"。
+ * WebUI 不是那样:`NarrowNav.tsx:87-89` 是
+ * `