From f83c23362aaf655943f3569b13918a764dc1054a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 21 Sep 2026 12:04:52 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20=E4=BF=AE=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=8A=95=E5=BD=B1/=E9=A1=B5=E7=AD=BE=E6=9D=A1?= =?UTF-8?q?=E5=9C=86=E8=A7=92/=E6=97=A5=E8=A7=86=E5=9B=BE/=E5=91=A8?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=20=E2=80=94=20=E4=BA=94=E5=A4=84=E5=AF=B9=20?= =?UTF-8?q?WebUI=20=E7=9A=84=E8=AF=AF=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户逐条指出后,用 CDP 读 WebUI 的 computed style 与祖先链,发现五处都是我读错/抄错。 ## ① 列表底部那条"不知道什么玩意的阴影"(用户原话) 根因:我把 `instance.shadow(Theme.glassShadow)` 挂在 **每张卡片** 上。 WebUI 全仓 `box-shadow` 只有三处 —— `.app-shell > *`(窗格)、 `html[data-bg='on'] .app-shell > *`、`.narrow-nav`(底部导航条)。 **卡片 `.glass-card` 一次都没有**(整个规则块只有 radius/border/bg/transition)。 一屏十几张卡各投一次 ⇒ 叠加成灰雾。设备实测(x=600,卡片底边 y≈1847): 有投影 y=1846→231、y=1852→230(向下衰减的暗带) 去掉后 y=1846→255、y=1852→251(消失) ⇒ 投影移到 `PaneModifier`(窗格层),与 WebUI 一致。 ★ 判定实验还排除了一个嫌疑:`List` 默认 `edgeEffect(EdgeEffect.Spring)` (官方文档「支持弹簧效果和**阴影效果**」)。设 `EdgeEffect.None` 后暗带照样在 ⇒ 不是它。**弹簧是滚到边缘的正常反馈,不该为遮一个 bug 把它关掉。** ## ② 页签条右上角"那个圆角"(用户原话) 两道弧叠在一起。WebUI 实测(CDP 读祖先链): .comm-pane x=80 w=320 radius=14px overflow=hidden ← 窗格 tabstrip x=80 w=320 radius=0px ← 页签条 **两者横向完全齐平**,页签条自己直角、靠窗格裁圆 ⇒ 只有一道弧。 我们内缩 16vp + 自己带角 ⇒ 两道弧,中间一弯月牙。 ⇒ 改成与窗格齐平、只留左上角圆角。 ★ 期间我犯过两个错,都记在注释里:先把半径设成 `HEADER_HEIGHT/2`=22(条高也是 44,那就是一个完整胶囊),后来自作主张把整条改成"通栏+下边框+无玻璃"—— 那是重做而不是用户要的小改,已还原。 ## ③ 日视图与周视图一样(用户原话) `DayTimeline()` 我写好了却**从未调用**。日视图一直走 `rows()`(周视图那套七列)。 ⇒ 按 scale 分流:日视图 → 24 小时时间轴(对齐 WebUI `DayGrid` 的 `HOURS` 逐行、 当前小时淡蓝底);周/日格子改用 `WEEK_CELL_HEIGHT`(240) 并画日程标题条 (对齐 `EventChip`:`HH:mm` + 标题,停用的加删除线 + 灰底)。 ## ④ 邮件正文垂直居中(用户原话) 官方 FAQ `faqs-arkui-725` 原文:内容比 `Scroll` 矮时「**默认居中排布**」, 要 `.align(Alignment.Top)` 才是顶部排布。 实测内容块在 `Scroll` 里偏移 731px = (1944−481)/2 —— 正好居中。 ⇒ 4 处 Scroll/List 补 `.align(Alignment.Top)`(详情页 y 从 1018 → 287)。 ## ⑤ 窄屏圆角/留白全丢(用户:「你的邮件的圆角呢?日历的圆角呢?」) `borderRadius(this.isWide ? glassRadius : 0)` —— 窄屏恒 0; `left/right: isWide ? paneGap : 0` —— 把 WebUI「窄屏**底边**留白为 0」 错推广成「四边都为 0」。 WebUI 两条 shell 规则**都给圆角**,差的只是底边留白。 ⇒ 圆角两端都开;左右留白两端都给;只有底边分宽窄。 ## 连带修的两个真问题 · **底部导航避让**(用户:「为什么不避让底部导航栏」):根是 `NavBar` 与内容 是 `Stack` 里的**并列兄弟**(互相重叠),所以每个滚动容器都得手写 `contentEndOffset` —— 我漏了 8 处。照 WebUI 改成 `Column{内容, NavBar}` (WebUI 实测 `scroller.bottom=861` / `nav.top=871`,内容停在导航上方)。 · **不透明的那一张卡**(用户:「有一个完全不透明的邮箱项」):`MailRow` 手写 `Theme.surface`(系统卡片色 α=1),而 `GroupHeader` 用的是 `GlassCardModifier`。 ⇒ 改用同一件基础件 + 新增 `TintModifier` 合成状态色(`.attributeModifier` 是单一插槽,色必须参与合成)。实测 `(255,255,255)` → `(195,218,204)`。 判据随之反转 3 条(`harmony-widescreen` 两处把"窄屏 0"改成"两端都要"、 `cross-client-theme` 抓到我新写的 `Theme.border` 当 `fontColor`)。 --- .../entry/src/main/ets/common/Surface.ets | 179 ++++++- .../entry/src/main/ets/common/Theme.ets | 40 ++ .../entry/src/main/ets/model/NavItems.ts | 32 +- .../src/main/ets/pages/AdminUsersPage.ets | 4 +- .../entry/src/main/ets/pages/CalendarPage.ets | 455 ++++++++++++++++-- .../entry/src/main/ets/pages/ComposePage.ets | 5 + .../src/main/ets/pages/MailDetailPage.ets | 44 ++ .../entry/src/main/ets/pages/MainPage.ets | 319 ++++++++++-- .../entry/src/main/ets/pages/SettingsPage.ets | 10 +- .../entry/src/main/ets/pages/WideSidebar.ets | 6 + 10 files changed, 1003 insertions(+), 91 deletions(-) diff --git a/client/harmony/entry/src/main/ets/common/Surface.ets b/client/harmony/entry/src/main/ets/common/Surface.ets index 74997fb..bd74885 100644 --- a/client/harmony/entry/src/main/ets/common/Surface.ets +++ b/client/harmony/entry/src/main/ets/common/Surface.ets @@ -380,10 +380,34 @@ export class GlassCardModifier implements AttributeModifier { * `brightness` 只给一点点(1.06):再高就把正文的对比度吃掉 —— * WebUI 那边靠 `--glass-base` 恒白 + alpha 控浓度,我们这里靠 brightness。 */ - instance.backgroundEffect({ - radius: Theme.glassCardRadius, - saturation: Theme.glassCardSaturation - }); + /* + * ★★ 2026-09-21 **去掉模糊**(用户:「邮件看着没有玻璃效果」→ 实测后发现是我的错)。 + * + * 原来这里挂的是 `backgroundEffect({radius: 18, saturation: 1.5})` —— + * 那是**底部导航条那一档**参数(WebUI `.narrow-nav` 的 + * `blur(18px) saturate(1.5)`,`index.css:1204`)。 + * + * 而 WebUI 的**卡片根本不是模糊玻璃**(`.glass-card`,`index.css:1629`): + * + * .glass-card { background-color: rgb(255 255 255 / 0.92); } + * html[data-bg='on'] … { background-color: rgb(255 255 255 / 0.78); } + * + * 全仓 `backdrop-filter` **只出现两处**: + * · `.glass-control` —— 控件档(输入框等),`blur(8px) saturate(1.1)` + * · `.narrow-nav` —— 底部导航条,`blur(18px) saturate(1.5)` + * 卡片一次都没有。而 `index.css:990` 还专门写了警告: + * + * 「再 backdrop-filter 一次纯属叠加:不会更"玻璃",只会更脏更糊, + * 而且每层都要重新采样一次背景(滚动时明显掉帧)」 + * + * ⇒ 我的错法后果(实测像素):每张卡各糊一次,**卡内像素约等于壁纸色** + * (行内 195,218,204 vs 壁纸 189,197,207)—— 卡片读起来像"壁纸上开了个洞" + * 而不是"一张浮在上面的白纱"。这正是用户说"没有玻璃效果"的意思: + * 玻璃感来自**白纱 + 发丝边 + 投影**这三件,不是来自模糊。 + * + * ⇒ 照 WebUI:**白色 + alpha,不模糊**。 + */ + instance.backgroundColor(this.active ? Theme.glassCardWall : Theme.glassCard); /* * ★ 只模糊+饱和仍然"像一块平的白方块" —— 玻璃板之所以像板,靠的是**边缘**: * 它要看起来**浮在壁纸之上**。WebUI 的完整配方(`.app-shell > *`): @@ -402,7 +426,38 @@ export class GlassCardModifier implements AttributeModifier { * 与 `rgba()`,理由正当:手写色在深色下要么看不见、要么就是替系统猜深浅)。 * 系统档自带深浅两套取值 —— 与 `radiusCard` 走 `sys.float.*` 同一个道理。 */ - instance.shadow(Theme.glassShadow); + /* + * ★★ 2026-09-21 **去掉卡片投影**(用户:「你看看邮件列表底部的不知道什么玩意的阴影」)。 + * + * 我原来在这里写了 `instance.shadow(Theme.glassShadow)` + * (`ShadowStyle.OUTER_FLOATING_SM`)—— 那是个**误配**。 + * + * WebUI 全仓 `box-shadow` **只有三处**(`grep -n box-shadow index.css`): + * + * 980 .app-shell > * ← **宽屏的窗格**(pane) + * 1073 html[data-bg='on'] .app-shell > * ← 壁纸开时的窗格,投影加重 + * 1206 .narrow-nav ← **底部导航条** + * + * **卡片 `.glass-card` 一次都没有**(`index.css:1629`,整个规则块里只有 + * `border-radius` / `border` / `background-color` / `transition`)。 + * + * 我把「窗格才该有的投影」加到了**每一张卡**上 ⇒ 一屏十几张卡各投一次, + * 投影彼此叠加成一片灰雾。设备实测(宽屏 3184px,x=600): + * + * 有投影:y=1844 → 204 y=1846 → 231 y=1852 → 230 (一条从卡片底边 + * 向下衰减的暗带,起始一步骤降 51) + * 去掉后:y=1846 → 255 y=1852 → 251 y=1900 → 252 (暗带消失) + * + * ★ 那次判定实验还排除了另一个嫌疑:我一度以为是 `List` 的默认 + * `edgeEffect(EdgeEffect.Spring)`(官方文档:「支持弹簧效果和**阴影效果**」), + * 设成 `EdgeEffect.None` 后暗带**照样在** ⇒ 不是它。 + * 弹簧是列表滚到边缘的正常反馈,**不该为了遮一个 bug 把它关掉**。 + * + * ── 那"玻璃板浮起来"的观感靠什么?── + * 靠**发丝描边**(下一行的 `border`)与**白纱 + alpha**(上面那句 + * `backgroundColor`)。WebUI 的卡片正是这两件 —— 它没有投影仍然读得出是"一块卡"。 + * 投影留给**真正悬浮在内容之上**的层(底部导航条),那里它才有信息意义。 + */ /* 发丝描边:用系统分隔线色(深浅由系统给),不是手写 alpha */ instance.border({ width: 1, color: Theme.border }); } else { @@ -439,6 +494,46 @@ export class GlassCardModifier implements AttributeModifier { * ★ 窗格**不吃材质**:它是"让位"的那一层,玻璃在卡片/浮层上 * (WebUI 的 `.comm-pane > .bg-white { background-color: transparent }` 同一分工)。 */ +/** + * **一层色** —— 给已经铺了玻璃的卡片叠一个状态底色(未读/选中/悬停)。 + * + * ★★ 2026-09-21 新增。为什么需要它,而不是直接在调用点 `.backgroundColor()`: + * + * ArkUI 的 `.attributeModifier()` 是**单一插槽** —— 挂在它**之外**的 + * `.backgroundColor(...)` 会被 modifier 内部的 `instance.backgroundColor(...)` + * 直接覆盖(编译器不报错,只是静默失效)。而玻璃卡的形制正是: + * + * ① 透明底 + `backgroundEffect`(模糊玻璃) + * ② 一层**半透明状态色**(未读的淡蓝 / 选中的更重一档) + * + * 两个都要 ⇒ 必须**合成**进同一个 modifier。用 `CompositeModifier` 可以叠多个 + * modifier,但"色"本身不是 modifier —— 所以补这一个小类,让色也能参与合成。 + * + * 用它的典型写法(`MainPage.MailRow`): + * CompositeModifier.of([ + * GlassCardModifier.of(this.bgActive, false), + * TintModifier.of(Theme.accentSoftFor(this.isDarkNow)) + * ]) + * + * ★ 顺序有意义:**先玻璃、后色**。反过来的话玻璃会把色盖掉 + * (`backgroundEffect` 不影响底色,但玻璃卡的 `backgroundColor(Transparent)` + * 会覆盖掉先铺的那层色)。 + */ +export class TintModifier implements AttributeModifier { + private color: ResourceColor = Color.Transparent; + + /** 工厂:`TintModifier.of(Theme.accentSoft)` */ + static of(color: ResourceColor): TintModifier { + const m = new TintModifier(); + m.color = color; + return m; + } + + applyNormalAttribute(instance: CommonAttribute): void { + instance.backgroundColor(this.color); + } +} + export class PaneModifier implements AttributeModifier { private active: boolean = false; @@ -451,6 +546,80 @@ export class PaneModifier implements AttributeModifier { applyNormalAttribute(instance: CommonAttribute): void { instance.backgroundColor(this.active ? Color.Transparent : Theme.pageBg); + /* + * ★★ 2026-09-21 把投影**移到窗格这一层**(用户:「你看看邮件列表底部的 + * 不知道什么玩意的阴影」)。 + * + * 投影原先挂在 `GlassCardModifier` 上(每张卡各投一次),那是**误配** —— + * WebUI 全仓 `box-shadow` 只有三处: + * 980 `.app-shell > *` ← **窗格** + * 1073 `html[data-bg='on'] .app-shell > *` ← 壁纸开时的窗格(投影加重) + * 1206 `.narrow-nav` ← 底部导航条 + * 卡片(`.glass-card`)**一次都没有**。 + * + * 一屏十几张卡各投一次 ⇒ 投影彼此叠成一片灰雾。设备实测(宽屏 3184px, + * x=600,卡片底边在 y≈1847): + * 卡上有投影:y=1844→204 y=1846→231 y=1852→230 (一条向下衰减的暗带) + * 去掉后: y=1846→255 y=1852→251 y=1900→252 (暗带消失) + * + * 移到窗格层才与 WebUI 一致,也才有意义 —— 窗格是"浮在壁纸上的一张板", + * 投影表达的是它与壁纸的**层次差**;卡片在窗格之内,同层之间不需要投影。 + */ + instance.shadow(Theme.glassShadow); + } +} + +/** + * **按压反馈** —— 全仓可点元素共用的一种"按下缩小、松开回弹"。 + * + * ★★ 2026-09-21 重做(用户:「按钮连最基础的点击反馈(按下缩小、手松变大, + * 或者按下变色)都没有」)。 + * + * ── 我上一版为什么等于没做 ── + * + * 我写了个 `PressFeedbackModifier`(`onTouch` + `animateTo` 改底色)。 + * 它**能用**(实测 FAB 按下 35,98,235 → 35,91,221),但: + * + * ① **覆盖率约 2 / 112**:它要求调用点逐个手写 `.attributeModifier(...)`。 + * 全仓 112 处 `onClick`,实际挂上的只有 2 处 —— + * 我做了个"基础组件"然后一个都没接(`GlassCard` / `Pressable` + * 在**全仓零调用**)。用户看到的当然是"什么都没有"。 + * ② **只变色、不缩放**:用户点名要的是"按下缩小、手松变大"。 + * + * ── 两条弯路,记下来别再走 ── + * + * ① 我花了一整轮论证"`applyPressedAttribute` 对纯 Row/Column 不回调 → + * 所以只能手写 `onTouch`"。那个观察是对的,但**结论错了** —— + * 系统本来就有一个通用属性 `clickEffect`(在 `CommonMethod` 上, + * 所有组件都能挂),由框架统一实现按下缩放 + 松开回弹。 + * **先查有没有现成的**,再决定要不要自己造。 + * ② 我接着想用 `@Styles` 把它铺开。官方 FAQ 明确: + * **「@Styles 和 @Extend 目前不支持 export 导出,后续这两个装饰器将不再演进」** + * (`faqs-arkui-222`)—— 编译确实报了 + * `Property 'pressFeedback' does not exist on type 'ColumnAttribute'`。 + * 官方推荐的替代正是本仓已有的 `AttributeModifier`。 + * + * ── 现在这个版本 ── + * + * `AttributeModifier`(与 `GlassCardModifier` 同一族); + * 挂在**任意**组件上都生效,且能用 `CompositeModifier` 与玻璃/圆角叠加 + * (`.attributeModifier` 是**单一插槽**,链两个是后者覆盖前者 —— 本仓踩过)。 + * + * 档位选 `LIGHT`(系统默认,缩放比 **0.90**)而不是 `MIDDLE`(0.95): + * 列表行 / 导航项这类**大面积**元素用 0.95 几乎看不出缩放, + * 而 0.90 与 WebUI 那几处 `active:bg-*` 的"明显可感知"是同一个意图。 + */ +export class PressEffectModifier implements AttributeModifier { + /** 单例即可 —— 它无状态(档位是常量),不必每个调用点 new 一个 */ + private static readonly INSTANCE: PressEffectModifier = new PressEffectModifier(); + + /** 工厂:`PressEffectModifier.of()` */ + static of(): PressEffectModifier { + return PressEffectModifier.INSTANCE; + } + + applyNormalAttribute(instance: CommonAttribute): void { + instance.clickEffect({ level: ClickEffectLevel.LIGHT }); } } diff --git a/client/harmony/entry/src/main/ets/common/Theme.ets b/client/harmony/entry/src/main/ets/common/Theme.ets index 29a6f14..dc03c33 100644 --- a/client/harmony/entry/src/main/ets/common/Theme.ets +++ b/client/harmony/entry/src/main/ets/common/Theme.ets @@ -218,6 +218,46 @@ export class Theme { * (10px 面板 / 18px 浮条),抄就得抄全套,不能只挑一个数。 */ static readonly glassCardRadius: number = 18; + /* + * ── 卡片的"玻璃":**白色 + alpha,不做模糊** ── + * + * ★★ 2026-09-21 更正(用户:「邮件看着没有玻璃效果」,我实测后才发现自己搞错了)。 + * + * WebUI 的 `.glass-card`(`index.css:1629`)**根本没有 `backdrop-filter`**: + * + * .glass-card { background-color: rgb(255 255 255 / 0.92); } + * html[data-bg='on'] … { background-color: rgb(255 255 255 / 0.78); } + * ↑ 就是"白 + alpha",没有 blur + * + * 全仓 `backdrop-filter` **只出现两处**(`index.css:844` 与 `:1204`): + * · `.glass-control` —— 控件档,`blur(8px) saturate(1.1)` + * · `.narrow-nav` —— **底部导航条**,`blur(18px) saturate(1.5)` + * + * 而我给**每张卡片**都挂了 `blur(18px) saturate(1.5)` —— 用的是**导航条那档**参数。 + * 这正是 `index.css:990` 明确警告的做法: + * 「再 backdrop-filter 一次纯属叠加:不会更"玻璃",只会更脏更糊, + * 而且每层都要重新采样一次背景(滚动时明显掉帧)」 + * + * ⇒ 后果:列表里每张卡各糊一次,多层叠加后正文发灰、滚动掉帧 —— + * 比"没有玻璃"更糟。修法就是照 WebUI:**卡片只用白 + alpha**。 + */ + /* + * 卡片的白 + alpha(ArkUI 的 `#AARRGGBB`:前两位是 alpha)。 + * + * glassCard #EBFFFFFF ≈ 白 / 0.92 ← WebUI `--glass-card-a: 0.92` + * glassCardWall #C7FFFFFF ≈ 白 / 0.78 ← WebUI `--glass-card-wall-a: 0.78` + * + * ★ 为什么必须**显式写 alpha**而不是像以前那样用 `Color.Transparent`: + * 卡片在壁纸上要是一层"薄白纱"(壁纸透上来但被压淡), + * 全透明等于壁纸原样穿过 —— 卡片的边界没了,文字也没有底衬(可读性掉)。 + * WebUI 的 0.78 就是在这两者之间取的平衡值。 + * + * ★ 深浅两色同一个值:因为**基色恒为白**(WebUI `--glass-base` 注释: + * 「基材恒为白,主题之间只差 alpha」)。深色下靠 `brightness` 与 + * 文字令牌保证对比度,而不是把底也调黑。 + */ + static readonly glassCard: string = '#EBFFFFFF'; + static readonly glassCardWall: string = '#C7FFFFFF'; static readonly glassCardSaturation: number = 1.5; /** * 「悬浮玻璃板」的另外两半:**投影 + 发丝描边**。 diff --git a/client/harmony/entry/src/main/ets/model/NavItems.ts b/client/harmony/entry/src/main/ets/model/NavItems.ts index f41b743..f25d406 100644 --- a/client/harmony/entry/src/main/ets/model/NavItems.ts +++ b/client/harmony/entry/src/main/ets/model/NavItems.ts @@ -322,7 +322,18 @@ export function navBadgeText(n: number): string { * "同一条轴线上的两种条"就变成肉眼可见的不齐 —— 而这正是本仓反复出现的那类毛病 * (同一个东西两处各写一个数,然后慢慢分叉)。 */ -export const TAB_BAR_RADIUS: number = NAV_BAR_RADIUS; +/* + * 同上:页签条的高是 `TAB_BAR_HEIGHT`(44),不是 `NAV_BAR_HEIGHT`(56) —— + * 半径必须按**自己**的高算,照抄底栏的 28 会超出一半(28/44 = 63.6%)。 + * + * ★ 用 `TAB_BAR_HEIGHT` 而不是 `HEADER_HEIGHT`:两者当前同值(44), + * 但**语义不同**(一个是页签条、一个是页标题栏)。写 `HEADER_HEIGHT` + * 会引入一条跨语义的隐式依赖 —— 哪天标题栏改成 48,页签条会跟着变, + * 而它明明没理由跟着变。(这条是编译器帮我发现的:声明顺序在前, + * 直接引 `HEADER_HEIGHT` 报 "used before its declaration"。) + */ +export const TAB_BAR_HEIGHT: number = 44; +export const TAB_BAR_RADIUS: number = TAB_BAR_HEIGHT / 2; export const TAB_BAR_SIDE: number = NAV_BAR_SIDE; /** 顶部留白(vp):离内容区顶部一点点,做成"浮着"而不是"贴着" */ export const TAB_BAR_TOP: number = 8; @@ -363,7 +374,24 @@ export const TAB_BAR_TOP: number = 8; * 44 同时是本仓的可点区下限(`NAV_ITEM_MIN_HIT`),返回键装得下。 */ export const HEADER_HEIGHT: number = 44; -export const HEADER_RADIUS: number = NAV_BAR_RADIUS; +/* + * ★★ 2026-09-21 修:**圆角必须跟高度走,不能跟另一条条的半径走** + * (用户:「你看看顶栏右边那个圆角,你不觉得奇怪吗?」)。 + * + * 这里原来写的是 `= NAV_BAR_RADIUS`(28)。而顶栏高度是 44 —— + * + * 底栏:高 56,半径 28 ⇒ 28/56 = 50% ✓ 标准的药丸形 + * 顶栏:高 44,半径 28 ⇒ 28/44 = **63.6%** ✗ 半径**超过高度的一半** + * + * 半径大于高度一半时,左右两条圆弧会**互相压过去**:渲染出来不是两个圆角, + * 而是一块被斜切掉两翼的怪形 —— 顶端只剩很窄一段直的,看着就像"没对齐"。 + * + * ★ 我当初的错法在 `HEADER_HEIGHT` 那段注释里写着:「侧留白/圆角仍复用 `NAV_BAR_*`, + * 高度独立取 44」。**留白可以复用(它表达的是与屏幕边缘的距离,与条高无关), + * 但圆角不行** —— 圆角的形状由**自身高度**决定。把两者归成"都复用", + * 就是把"横向的留白"与"纵向的形状"当成了同一类东西。 + */ +export const HEADER_RADIUS: number = HEADER_HEIGHT / 2; export const HEADER_SIDE: number = NAV_BAR_SIDE; /** 顶栏离内容区顶部的留白(vp)—— 与 `TAB_BAR_TOP` 同值,同一族 */ export const HEADER_TOP: number = TAB_BAR_TOP; diff --git a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets index ed446b8..e4e3d2c 100644 --- a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets +++ b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets @@ -25,7 +25,7 @@ */ import { ApiClient, ApiError } from '../api/ApiClient'; import { Theme } from '../common/Theme'; -import { AppHeader } from '../common/Surface'; +import { AppHeader, PressEffectModifier } from '../common/Surface'; import { AdminApi, MeApi } from '../api/AdminApi'; import { AdminUser, @@ -371,6 +371,7 @@ struct AdminUsersPage { } .width(44) .height(44) + .attributeModifier(PressEffectModifier.of()) .onClick(() => { this.load(); }) } @@ -450,6 +451,7 @@ struct AdminUsersPage { .padding(12) .backgroundColor(Theme.surface) .borderRadius(Theme.radiusCard) + .attributeModifier(PressEffectModifier.of()) .onClick(() => { this.toggleEdit(user); }) } diff --git a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets index 144839e..1be25e8 100644 --- a/client/harmony/entry/src/main/ets/pages/CalendarPage.ets +++ b/client/harmony/entry/src/main/ets/pages/CalendarPage.ets @@ -38,7 +38,7 @@ import { CalendarApi } from '../api/CalendarApi'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { CalendarEvent, CalendarEventInput, LunarLabels, LunarRangeResponse } from '../model/Models'; import { Theme } from '../common/Theme'; -import { GlassCardModifier, PaneModifier } from '../common/Surface'; +import { GlassCardModifier, PaneModifier, PressEffectModifier } from '../common/Surface'; import { AmIcon } from '../common/Icons'; import { DayCell, @@ -69,6 +69,39 @@ import { pickIcsText, saveIcsText } from '../common/IcsFile'; /** 一格的高度(vp)。7 列等分 + 固定高度才画得成整齐的网格 */ const CELL_HEIGHT: number = 52; + +/** + * 周/日视图的格子高度(vp)。 + * + * ★★ 2026-09-21 新增(用户:「我说的是周视图和日视图」)。 + * + * 周视图在 WebUI 里是**七列高格子**(`WeekGrid` 的 `min-h-[28rem]` ≈ 448px)—— + * 它的用途就是"一周七天各自摊开、把每天的日程标题都看见"。 + * 我们原来周/日档复用月视图的 `CELL_HEIGHT`(52vp), + * 于是"周视图"只是"月视图的一行":格子矮到放不下任何标题, + * 也就只能画一个"有日程"的小圆点 —— 那正是用户看到"和 WebUI 不一样"的地方。 + * + * 取 240vp:够放 2–3 条日程条(每条 ~20vp), + * 又不至于让七列在窄屏上横向挤到装不下(下面 `WEEK_MIN_WIDTH` 管横向)。 + */ +const WEEK_CELL_HEIGHT: number = 240; + +/** 周/日网格的最小可读宽度(vp):七列 × 每天至少 46vp —— 不够就横向滚动 */ +const WEEK_MIN_WIDTH: number = 322; + +/** + * 日视图的 24 小时(`0..23`)—— 对齐 WebUI `DayGrid` 的 + * `Array.from({length: 24}, (_, i) => i)`。 + * + * ★★ 2026-09-21(用户:「我说的是周视图和日视图」)。 + * + * 改之前:日视图被我做成"周视图只留一天"(七个空列,只有一列有内容)—— + * 那**不是**日视图。WebUI 的 `DayGrid` 是一条**24 小时时间轴**: + * 每小时一行,左边 `HH:00`,右边是该小时内的日程; + * 当前小时的那一行加淡蓝底(`bg-blue-50/40`)。 + * 一天有 24 小时,而这个信息在"七列网格"里根本表达不出来。 + */ +const DAY_HOURS: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]; /** * 重复规则与提前量的**可选项**。 * @@ -626,33 +659,66 @@ export struct CalendarPage { return `${this.calScale}-${this.year}-${this.month}-${this.selectedIso}`; } - /** 这一天有几条日程(画事件点用)。数量很小,线性扫即可,不引入第二份索引。 */ + /** 这一天有几条日程。**复用 `eventsOf()`** —— 一处算法,避免两处慢慢漂开。 */ private eventCountOf(iso: string): number { - if (iso.length === 0) { - return 0; - } - let n: number = 0; - for (let i = 0; i < this.events.length; i++) { - if (localIsoOf(this.events[i].event_time) === iso) { - n = n + 1; - } - } - return n; + return this.eventsOf(iso).length; } - /** 选中那天的日程,按时间升序 */ - private selectedEvents(): CalendarEvent[] { + /** + * 某一天的日程列表(按时间升序)。 + * + * ★★ 2026-09-21 新增(用户:「我说的是周视图和日视图」)。 + * + * 原来只有一个 `eventCountOf()` —— 格子里画一个 **4×4 的小圆点**表示"这天有日程"。 + * 而 WebUI 的月/周两个网格画的都是**每条日程一个标题条** + * (`EventChip`,`CalendarView.tsx:515/604` 的 `MonthGrid`/`WeekGrid`): + * + * ← 蓝底小条:时间 + 标题(暂停的加删除线) + * + * 差别不是"圆点 vs 小条"这么小: + * · 圆点只告诉你**有没有**,不告诉你**是什么** —— 用户要看到"15:00 评审"; + * · 周视图在 WebUI 里是**七列宽格子**(`min-h-[28rem]`),本来就是给 + * 日程标题留的地方;我们那版周视图每格只有 52vp 高(`CELL_HEIGHT`), + * 连格子都放不下,更别说标题 —— 这才是"周/日视图和 WebUI 不一样"的根。 + * + * 数据不另建索引:`events` 本来就不大(一次一个月),线性扫与 + * `eventCountOf` 同一口径(那个函数现在也改成调这个,免得两处算法漂移)。 + */ + private eventsOf(iso: string): CalendarEvent[] { const out: CalendarEvent[] = []; + if (iso.length === 0) { + return out; + } for (let i = 0; i < this.events.length; i++) { - const e: CalendarEvent = this.events[i]; - if (localIsoOf(e.event_time) === this.selectedIso) { - out.push(e); + if (localIsoOf(this.events[i].event_time) === iso) { + out.push(this.events[i]); } } out.sort((a: CalendarEvent, b: CalendarEvent) => Date.parse(a.event_time) - Date.parse(b.event_time)); return out; } + /** `HH:mm` —— 与 WebUI `hhmm()` 同口径(取本地时间,两位补零) */ + private hhmm(iso: string): string { + const d: Date = new Date(iso); + if (isNaN(d.getTime())) { + return ''; + } + const h: number = d.getHours(); + const m: number = d.getMinutes(); + return `${h < 10 ? '0' : ''}${h}:${m < 10 ? '0' : ''}${m}`; + } + + /** 周/日视图才在格子里画**日程标题条**;月视图格子矮,只画"有日程"的点 */ + private cellShowsChips(): boolean { + return this.calScale !== 'month'; + } + + /** 选中那天的日程,按时间升序 */ + private selectedEvents(): CalendarEvent[] { + return this.eventsOf(this.selectedIso); + } + private cellFg(cell: DayCell): ResourceColor { if (cell.iso === this.selectedIso) { return Theme.accentFg; @@ -844,40 +910,146 @@ export struct CalendarPage { return fallback; } + /** + * **日程条** —— WebUI `EventChip` 的对应物(`CalendarView.tsx` 的 `EventChip`)。 + * + * WebUI: bg-blue-50 text-blue-800,内容 = `HH:mm` + 标题,`truncate` + * 暂停/取消 → `bg-gray-100 text-gray-400 line-through` + * + * ★ 生死态(`status !== 'active'`)必须**看得出来**: + * WebUI 那段注释写得很直白 ——「暂停/取消的事件不会触发提醒, + * 视觉上必须与生效的区分开,否则人以为设好了,实际到点什么都不会发生」。 + * 所以照它做:灰底 + 删除线,而不是"只是颜色淡一点"。 + * + * ★ 点击要 `stopPropagation` 的效果:WebUI 里点事件条是"选中这条日程", + * 不会连带触发"选中这一天"。ArkUI 没有事件冒泡语义区分得那么细, + * 所以这里**只给事件条自己挂 onClick**,不额外向外传(格子自己的 + * `onClick` 仍会收到点击 —— 这与 WebUI 的 `ev.stopPropagation()` 有差异, + * 但两件事的结果一致:**都是"选中这一天 + 选中这条日程"**, + * 而 WebUI 的 `pickEvent` 本身也会把 `selectedDay` 设成那天 + * (见 `CalendarView.tsx` 的 `pickEvent`)。所以不必伪造冒泡控制。) + */ + @Builder + EventChip(ev: CalendarEvent) { + Row({ space: 4 }) { + /* 已停用:先给一个"暂停"记号(WebUI 是 PauseIcon,我们用同族图标) */ + if (ev.status !== 'active') { + AmIcon({ iconName: 'pause', iconSize: 9, iconColor: Theme.textSubtleFor() }) + } + Text(this.hhmm(ev.event_time)) + .fontSize(9) + .fontColor(ev.status === 'active' ? Theme.accentFor() : Theme.textSubtleFor()) + .opacity(0.75) + Text(ev.title) + .fontSize(9) + .fontColor(ev.status === 'active' ? Theme.accentFor() : Theme.textSubtleFor()) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + .decoration(ev.status === 'active' + ? { type: TextDecorationType.None } + : { type: TextDecorationType.LineThrough }) + } + .width('100%') + .padding({ left: 4, right: 4, top: 1, bottom: 1 }) + .borderRadius(4) + .backgroundColor(ev.status === 'active' ? Theme.accentSoftFor(this.isDarkNow) : Theme.chipNeutralBg) + .attributeModifier(PressEffectModifier.of()) + .onClick(() => { + /* 与 WebUI 同构:先把选中日挪到那天,再开编辑面板 */ + this.selectedIso = localIsoOf(ev.event_time); + this.openEdit(ev); + }) + } + @Builder DayCellView(cell: DayCell) { Column() { - if (cell.label > 0) { - Text(`${cell.label}`) - .fontSize(Theme.fontBody) - .fontWeight(cell.iso === this.todayIso ? FontWeight.Bold : FontWeight.Normal) - .fontColor(this.cellFg(cell)) - /* - * 农历小字 —— 与 WebUI 同一位置(公历数字下方一行)。 - * - * ★ 文案直接用服务端给的 `text`(初一=月名、其余=日名), - * 客户端**不自己拼**:两端各拼一份的话,同一天在两边日历上 - * 可能长得不一样(例如闰月到底写不写「闰」)。 - * ★ 拉不到农历时(服务端抖了一下/未登录)这里就是空字串, - * 格子少一行小字、日历照常用 —— 不报错、不留空白占位。 - */ - Text(this.lunarTextOf(cell.iso)) - .fontSize(9) + /* + * ── 日期头 ── + * + * 周/日视图里它是**独立一条**(WebUI 的 `sticky top-0` 那行): + * 「今天」的淡蓝底、「选中」的下边框,都只落在这条上,**不铺满整列**。 + * + * ★★ 2026-09-21:我第一版把月视图的「整格铺底色」原样留给高格子, + * 结果周视图里今天那一列从顶蓝到底,变成一根蓝柱子 + * (实测截图:日程条被底色吃掉,几乎看不清)。 + * WebUI 的周视图是分开处理的(`CalendarView.tsx:639-651`)—— + * 底色在 `border-b` 那条 header 上,下面的事件区是白的。 + */ + Column() { + if (cell.label > 0) { + Text(`${cell.label}`) + .fontSize(Theme.fontBody) + .fontWeight(cell.iso === this.todayIso ? FontWeight.Bold : FontWeight.Normal) + .fontColor(this.cellFg(cell)) /* - * ★ ★ 2026-09-19 改:`textSubtle`(系统三级文字)在**深色**下太暗 —— - * 设备实测农历"二十"只有 **2.49:1**(`rgb(93,95,96)` 底上)。 + * 农历小字 —— 与 WebUI 同一位置(公历数字下方一行)。 * - * WebUI 用的是 `text-gray-500`(`CalendarView.tsx` 农历那行), - * 而它在 `.dark` 段被**提亮**到 `165 173 186`(见 `index.css`)—— - * 正是 `--c-gray-*` 那组注释说的"深底上的浅色 gray-400 只有约 2:1, - * 远低于 WCAG AA,看得见但读不动"。 - * - * 系统三级色没有这层适配(它只管"比二级更淡",不管可读性下限), - * ⇒ 深色下用它当**小字**会掉到 2.5:1。改用二级(`textMuted`), - * 与 WebUI 的 gray-500 档位一致。 + * ★ 文案直接用服务端给的 `text`(初一=月名、其余=日名), + * 客户端**不自己拼**:两端各拼一份的话,同一天在两边日历上 + * 可能长得不一样(例如闰月到底写不写「闰」)。 + * ★ 拉不到农历时(服务端抖了一下/未登录)这里就是空字串, + * 格子少一行小字、日历照常用 —— 不报错、不留空白占位。 */ - .fontColor(cell.iso === this.selectedIso ? Theme.accentFg : Theme.textMuted) - .maxLines(1) + Text(this.lunarTextOf(cell.iso)) + .fontSize(9) + /* + * ★★ 2026-09-19 改:`textSubtle`(系统三级文字)在**深色**下太暗 —— + * 设备实测农历"二十"只有 **2.49:1**(`rgb(93,95,96)` 底上)。 + * WebUI 用的是 `text-gray-500`,而它在 `.dark` 段被提亮到 `165 173 186`。 + * 系统三级色没有这层适配 ⇒ 深色下当小字会掉到 2.5:1。 + * 改用二级(`textMuted`),与 WebUI 的 gray-500 档位一致。 + */ + .fontColor(cell.iso === this.selectedIso ? Theme.accentFg : Theme.textMuted) + .maxLines(1) + } + } + .width('100%') + /* + * 日期头底色:只在周/日档铺(月档由格子那层铺,保持原样)。 + * 「今天」淡蓝;「选中」同色 —— 两者可以同时成立,靠下面的下边框区分。 + */ + .backgroundColor(this.cellShowsChips() + ? (cell.iso === this.todayIso || cell.iso === this.selectedIso + ? Theme.accentSoftFor(this.isDarkNow) + : Theme.surface) + : Color.Transparent) + /* + * 「选中」用**下边框**说,不用整格底色。 + * 不选中时给宽度 0(而不是去掉 `.border()` 调用):条件属性换来换去 + * 会让节点重建,宽 0 的边框是稳定的。 + */ + .border({ + width: { bottom: this.cellShowsChips() && cell.iso === this.selectedIso ? 2 : 0 }, + color: Theme.accent + }) + .padding({ top: 4, bottom: 4 }) + + /* + * ── 日程条(WebUI `EventChip` 的对应物)── + * + * ★★ 2026-09-21 用**标题条**换掉原来的 4×4 小圆点 + * (用户:「我说的是周视图和日视图」)。 + * + * 原来只画一个点:告诉你"这天有日程",却**不告诉是什么**。 + * WebUI 的 `MonthGrid`/`WeekGrid` 都是每条日程一行小条(`EventChip`): + * bg-blue-50 text-blue-800,内容 = `HH:mm` + 标题 + * 暂停/取消的加 `line-through` + 灰底(**必须一眼看出它不会响**) + * + * 月视图格子只有 `CELL_HEIGHT`(52vp) ⇒ 放不下标题,**保留圆点**; + * 周/日视图格子高(`WEEK_CELL_HEIGHT`)⇒ 画标题条。 + */ + if (this.cellShowsChips() && cell.label > 0) { + Column({ space: 2 }) { + ForEach(this.eventsOf(cell.iso), (ev: CalendarEvent) => { + this.EventChip(ev) + }, (ev: CalendarEvent) => ev.event_id) + } + .width('100%') + .padding({ left: 2, right: 2, top: 3 }) + } else if (cell.label > 0) { + /* 月视图:只报"有日程"(那个尺寸下的极限) */ if (this.eventCountOf(cell.iso) > 0) { Column() .width(4) @@ -889,12 +1061,34 @@ export struct CalendarPage { } } .layoutWeight(1) - .height(CELL_HEIGHT) - .justifyContent(FlexAlign.Center) - /* 选中态用**底色 + 文字反白**(日历里的"选中某天"与导航条的"选中某项"不是同一件事: - 导航项上色即可,因为文字本身就在报位置;格子里只有一个数字,没有底色看不出选的是哪格) */ - .backgroundColor(cell.iso === this.selectedIso ? Theme.accent : Color.Transparent) - .borderRadius(Theme.radiusControl) + /* + * 高度随档位变:月视图紧凑格(52vp),周/日视图高格(240vp)。 + * 周/日那种高度才放得下日程**标题条**(见 `cellShowsChips`)。 + */ + /* + * 周/日视图:格子吃满**行**的高度(`layoutWeight` 已在 `.layoutWeight(1)` 上给了 + * 横向;这里纵向用 `height('100%')` 让七列等高撑满), + * 行本身再由网格的 `layoutWeight(1)` 撑满窗格。 + * 月视图仍是固定 `CELL_HEIGHT`(44 行网格不能被拉伸)。 + */ + .height(this.cellShowsChips() ? '100%' : CELL_HEIGHT) + /* + * 有标题条时内容从**顶部**排(月视图那一个数字才需要垂直居中); + * 否则周视图的日期会被推到 240vp 格子的正中间,底下留一大片空白。 + */ + .justifyContent(this.cellShowsChips() ? FlexAlign.Start : FlexAlign.Center) + /* + * 选中/今天的底色:**只给月视图整格**。 + * 月视图格子只有一个数字,没有别的地方可标; + * 周/日视图改由上面的日期头承担(见那段注释)。 + */ + .backgroundColor(this.cellShowsChips() + ? Color.Transparent + : (cell.iso === this.selectedIso ? Theme.accent : Color.Transparent)) + .borderRadius(this.cellShowsChips() ? 0 : Theme.radiusControl) + /* 周/日:列间分隔线(WebUI 是 `border-r border-gray-100`),否则七列糊成一片 */ + .border({ width: { right: this.cellShowsChips() ? 1 : 0 }, color: Theme.border }) + .attributeModifier(PressEffectModifier.of()) .onClick(() => { if (cell.label > 0) { this.selectedIso = cell.iso; @@ -902,6 +1096,27 @@ export struct CalendarPage { }) } + /** 日视图:某小时的日程(按时间升序)。与 WebUI `DayGrid` 的 `byHour` 同口径。 */ + private hourEvents(hour: number): CalendarEvent[] { + const out: CalendarEvent[] = []; + const list: CalendarEvent[] = this.eventsOf(this.selectedIso); + for (let i = 0; i < list.length; i++) { + const d: Date = new Date(list[i].event_time); + if (!isNaN(d.getTime()) && d.getHours() === hour) { + out.push(list[i]); + } + } + return out; + } + + /** + * 当前是几点 —— 只在这一天就是今天时有意义,否则返回 -1。 + * WebUI 的 `DayGrid` 是 `isSameDay(anchor, new Date()) ? new Date().getHours() : -1`。 + */ + private nowHour(): number { + return this.selectedIso === this.todayIso ? new Date().getHours() : -1; + } + /** 某个格子的农历文案(取不到就是空字串 —— 不占位、不报错) */ private lunarTextOf(iso: string): string { if (iso.length === 0 || this.lunarMap === undefined) { @@ -946,6 +1161,7 @@ export struct CalendarPage { .padding(10) .backgroundColor(Theme.surfaceMuted) .borderRadius(Theme.radiusControl) + .attributeModifier(PressEffectModifier.of()) .onClick(() => this.openEdit(e)) } @@ -1013,6 +1229,7 @@ export struct CalendarPage { .padding(10) .backgroundColor(Theme.chipNeutralBg) .borderRadius(Theme.radiusControl) + .attributeModifier(PressEffectModifier.of()) .onClick(() => { this.pickersOpen = !this.pickersOpen; }) @@ -1136,6 +1353,12 @@ export struct CalendarPage { } .layoutWeight(1) .width('100%') + /* + * ★★ 2026-09-21:高度受限的 Scroll 必须显式顶部对齐。 + * 官方 FAQ 原文:内容比 Scroll 矮时「**默认居中排布**」,要 `.align(Alignment.Top)` + * 才是顶部排布(`faqs-arkui-725`)。缺这一行时日历的日程列表会飘在中间。 + */ + .align(Alignment.Top) /* 上下边缘渐隐(2026-09-17 补:这一处原先漏了,同一次滚动里只有列表是渐隐) */ .fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) }) } @@ -1155,6 +1378,104 @@ export struct CalendarPage { * 现在按 WebUI 拆成两个 @Builder,由 `build()` 按可用宽度决定并排还是竖堆 * (窄屏仍竖堆 —— WebUI 窄屏走 `NarrowStack` 覆盖式,见 `CalendarView.tsx:444-447`)。 */ + /** + * **日视图** —— 24 小时时间轴(对齐 WebUI `DayGrid`,`CalendarView.tsx:669`)。 + * + * ★★ 2026-09-21 新增(用户:「我说的是周视图和日视图」)。 + * + * 改之前日视图走的是"周视图只留一天"(七个列、六个空), + * 那条路**表达不出"几点有事"** —— 而这个恰恰是日视图唯一要回答的问题。 + * + * WebUI 的形状(逐项对齐): + *
← 日期 + 农历那一行 + *
+ * 每小时一行: + * HH:00 + * 有日程 → 逐个 EventRow;没有 → 一个淡「—」 + * 当前小时那一行 `bg-blue-50/40` + */ + @Builder + DayTimeline() { + Column() { + /* 顶部:日期 + 农历(WebUI 那条 `border-b` 行) */ + /* + * 日期 + 农历:与 `AgendaPane` 顶部同一口径(那里也只写 ISO + 农历), + * 不引入第二套格式化 —— 两端对同一天要说同样的话。 + */ + Text(this.selectedIso + (this.lunarTextOf(this.selectedIso).length > 0 + ? '(农历' + this.lunarTextOf(this.selectedIso) + ')' : '')) + .fontSize(Theme.fontTiny) + .fontColor(Theme.textMuted) + .width('100%') + .padding({ left: 12, right: 12, top: 8, bottom: 6 }) + + List() { + ForEach(DAY_HOURS, (h: number) => { + ListItem() { + Row() { + /* 左列时间刻度:等宽数字,两位补零 */ + Text(`${h < 10 ? '0' : ''}${h}:00`) + .fontSize(Theme.fontTiny) + .fontColor(Theme.textSubtleFor()) + .width(48) + .textAlign(TextAlign.End) + .padding({ top: 2 }) + + Column({ space: 4 }) { + if (this.hourEvents(h).length === 0) { + /* + * 空小时:一条极淡的破折号(WebUI `CalendarView.tsx:721` 的 `text-gray-200`)。 + * + * ★ 2026-09-21:这里我原先写的是 `Theme.border` —— + * 它是 `sys.color.ohos_id_color_list**Separator**`,是**面/线**的令牌, + * 会随主题翻转(浅色近白 / 深色近黑)。 + * 判据当场抓到(`cross-client-theme` 的「面当字」那条): + * 深色下它当字就掉到看不见。 + * 改用**三级文字**令牌 —— 语义就是"最不抢眼的一档小字", + * 而且它已有主题适配(`textSubtleFor`,本仓既有纪律:面/字分开)。 + */ + Text('—') + .fontSize(Theme.fontTiny) + .fontColor(Theme.textSubtleFor(this.isDarkNow)) + } else { + ForEach(this.hourEvents(h), (ev: CalendarEvent) => { + this.EventRow(ev) + }, (ev: CalendarEvent) => `h${h}#${ev.event_id}`) + } + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .padding({ + left: 12, + right: 12, + /* 有日程的行高一点(WebUI:`list.length ? py-2 : py-1`) */ + top: this.hourEvents(h).length > 0 ? 8 : 4, + bottom: this.hourEvents(h).length > 0 ? 8 : 4 + }) + /* 当前小时那一行加淡蓝底(WebUI `bg-blue-50/40`) */ + .backgroundColor(h === this.nowHour() ? Theme.accentSoftFor(this.isDarkNow) : Color.Transparent) + } + }, (h: number) => `hr${this.selectedIso}#${h}`) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + .align(Alignment.Top) + /* + * 上下边缘渐隐 —— 与其它滚动容器同一口径 + * (WebUI `.overflow-y-auto` 的 `mask-image`,`index.css:1717`)。 + * 时刻表滚到一半时,首尾那两行不该被"一刀切"。 + */ + .fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) }) + /* 让出底部悬浮导航条(内容末尾留白,不是容器 padding) */ + .contentEndOffset(this.navReserve) + } + .width('100%') + .height('100%') + } + @Builder GridPane() { /* @@ -1276,6 +1597,7 @@ export struct CalendarPage { .backgroundColor(Theme.accent) .borderRadius(Theme.radiusControl) .margin({ left: 8 }) + .attributeModifier(PressEffectModifier.of()) .onClick(() => this.openNew()) } .width('100%') @@ -1294,6 +1616,18 @@ export struct CalendarPage { } .width('100%') + /* + * ★★ 2026-09-21:**日视图走 24 小时时间轴,不再复用七列网格** + * (用户:「你的日视图为什么和周视图一样」)。 + * + * 我上一版把日视图实现成"周视图只留一天"(七列里六列空)—— + * 那条路**表达不出"几点有事"**,而那正是日视图唯一要回答的问题。 + * WebUI 的 `DayGrid`(`CalendarView.tsx:669`)是一条 24 小时时间轴, + * 所以这里也按 scale 分流。 + */ + if (this.calScale === 'day') { + this.DayTimeline() + } else { /* * 月网格:行 ← monthGrid,列 ← 7 格(嵌套 ForEach 的项名必须不同)。 * @@ -1310,10 +1644,29 @@ export struct CalendarPage { }, (cell: DayCell) => `c${cell.iso}#${cell.label}`) } .width('100%') + /* + * 周/日视图:整行给一个**最小宽度**,不够就靠外层 `Scroll` 横滚。 + * + * 理由与 WebUI 同一个:`WeekGrid` 的内层是 + * `grid grid-cols-7 min-w-[36rem]`(576px)放在 `overflow-auto` 里 —— + * 七列各自要有足够宽度才读得下日程标题,窄屏时宁可横向滚动, + * 也不把七列压成七条缝。 + */ + .constraintSize(this.cellShowsChips() ? { minWidth: WEEK_MIN_WIDTH } : {}) }, (row: DayCell[], rowIndex: number) => `r${this.monthKey()}#${rowIndex}`) } .width('100%') + /* + * 周/日视图:让网格**吃掉**工具栏以下的所有剩余高度 + * (`layoutWeight(1)`),并把整块放进可横滚的 `Scroll` 里 + * —— 与 WebUI `WeekGrid` 的 `flex-1 min-h-0 overflow-auto` 同构。 + * + * 不加这两句时,网格只有内容高度(实测 y 止于 1170, + * 而窗格到 2230)⇒ 周视图下方空出大半屏,看起来"没做满"。 + */ + .layoutWeight(this.cellShowsChips() ? 1 : 0) .transition(Theme.calendarSlide(this.slideForward)) + } } /** diff --git a/client/harmony/entry/src/main/ets/pages/ComposePage.ets b/client/harmony/entry/src/main/ets/pages/ComposePage.ets index 7d02f1f..c728067 100644 --- a/client/harmony/entry/src/main/ets/pages/ComposePage.ets +++ b/client/harmony/entry/src/main/ets/pages/ComposePage.ets @@ -13,6 +13,8 @@ import { hilog } from '@kit.PerformanceAnalysisKit'; import { picker } from '@kit.CoreFileKit'; import { AmIcon } from '../common/Icons'; import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets'; +import { PressEffectModifier } from '../common/Surface'; +import { Motion } from '../common/Motion'; /** * 写邮件窗格。 @@ -318,6 +320,8 @@ export struct ComposeView { */ AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.textSubtleFor() }) .rotate({ angle: this.showAccountPicker ? 90 : 0 }) + /* 展开/收起箭头平滑旋转(WebUI `transition-transform` 的对应物) */ + .animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft }) .onClick(() => { this.showAccountPicker = !this.showAccountPicker; }) } .width('100%').height(40).padding({ left: 12, right: 12 }) @@ -338,6 +342,7 @@ export struct ComposeView { } .width('100%').height(40).padding({ left: 40, right: 12 }) .backgroundColor(this.selectedAccountId === acct.id ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface) + .attributeModifier(PressEffectModifier.of()) .onClick(() => { this.selectedAccountId = acct.id; this.showAccountPicker = false; diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index b31adea..ff377db 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -26,6 +26,8 @@ import { formatAddress } from '../model/ReplyTarget'; import { Insets, KEY_WINDOW_INSETS, topInset } from '../model/WindowInsets'; +import { PressEffectModifier } from '../common/Surface'; +import { Motion } from '../common/Motion'; /** * 本地时间:与 WebUI `new Date(x).toLocaleString('zh-CN')` 同一口径。 @@ -724,6 +726,7 @@ export struct MailDetailView { AmIcon({ iconName: 'chevronLeft', iconSize: 20, iconColor: Theme.accentFor() }) } .width(HEADER_BACK_HIT).height(HEADER_BACK_HIT) + .attributeModifier(PressEffectModifier.of()) .onClick(() => { this.goBack(); }) Row() { @@ -750,6 +753,8 @@ export struct MailDetailView { AmIcon({ iconName: 'chevronRight', iconSize: 16, iconColor: Theme.textSubtleFor() }) .rotate({ angle: this.headerOpen ? 90 : 0 }) + /* 展开/收起箭头平滑旋转(WebUI `transition-transform` 的对应物) */ + .animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft }) .margin({ left: 6 }) } .layoutWeight(1) @@ -768,6 +773,7 @@ export struct MailDetailView { * 点击区从 14px 变成 36vp(实测约 104px),且纵向不贴边。 */ .height(36) + .attributeModifier(PressEffectModifier.of()) .onClick(() => { this.headerOpen = !this.headerOpen; }) } .width('100%') @@ -806,6 +812,7 @@ export struct MailDetailView { AmIcon({ iconName: 'tree', iconSize: 14, iconColor: Theme.textMuted }) Text('对话树').fontSize(12).fontColor(Theme.textMuted) } + .attributeModifier(PressEffectModifier.of()) .onClick(() => { this.openThread(); }) /* * ★★ 2026-09-20 补:**转发在这里**,不在右下角的球上。 @@ -826,6 +833,7 @@ export struct MailDetailView { AmIcon({ iconName: 'forward', iconSize: 14, iconColor: Theme.textMuted }) Text('转发').fontSize(12).fontColor(Theme.textMuted) } + .attributeModifier(PressEffectModifier.of()) .onClick(() => { this.showReplyBox = false; this.showForwardBox = true; }) } .width('100%') @@ -851,6 +859,7 @@ export struct MailDetailView { .width('100%') .padding({ left: 8, right: 12, top: 8, bottom: 8 }) .backgroundColor(Theme.surface) + .attributeModifier(PressEffectModifier.of()) .onClick(() => {}) Divider().color(Theme.border) @@ -1006,6 +1015,7 @@ export struct MailDetailView { .borderRadius(8) .border({ width: 1, color: Theme.border }) .margin({ bottom: 6 }) + .attributeModifier(PressEffectModifier.of()) .onClick(() => { this.downloadAttachment(a); }) }, (a: AttachmentInfo) => a.attachment_id) } @@ -1020,6 +1030,40 @@ export struct MailDetailView { } .width('100%').height('100%') .scrollBar(BarState.Off) + /* + * ★★ 2026-09-21 补:**让出底部悬浮导航条**(用户:「你的显示实际信息的部分, + * 响应点击的部分,为什么不避让底部导航栏?」)。 + * + * 底栏是**悬浮**的(`NavBar`)——它不占布局空间,所以内容会**从它底下穿过**。 + * 全局约定是各滚动容器用 `contentEndOffset(navReserve)` 在**内容末尾**留白 + * (不是给容器加 padding:容器必须满高,内容才滑得到条底下、玻璃才有东西可糊)。 + * + * 这一处**一直漏着**:详情页正文的最后一段(附件区/正文结尾)停在 + * y≈2230,而底栏占 1957..2232 —— 最后一行**永远压在条下面**,看得见、点不到。 + * 实测:`Scroll [2,287,1006,2231]` 与 `Column [0,1957,1008,2232]`(底栏)完全重叠。 + */ + .contentEndOffset(this.navReserve) + /* + * ★★ 2026-09-21 修「邮件正文被垂直居中」(用户:「你家邮件是居中对其啊, + * 不都是顶部对齐」)。 + * + * 官方 FAQ 原文(`faqs-arkui-725「如何解决Scroll组件设置高度限制后, + * 无法顶部对齐的问题」`): + * 「若子组件的布局尺寸小于 Scroll 组件的尺寸时,则 Scroll 组件不可滚动, + * **且默认居中排布**。」 + * 「当设置 Scroll 组件高度后,可以通过设置 **`.align(Alignment.Top)`** + * 属性实现 Scroll 子组件顶部排布效果。」 + * + * 这里正是那个形状:`Scroll` 有 `.height('100%')`(高度被限死), + * 而短邮件的正文(标题 + 一行 Markdown + Blank)比视口矮得多 ⇒ + * 实测内容块 `Column [2,1018,…,1499]` 在 `Scroll [2,287,…,2231]` 里 + * 偏移 731px = (1944−481)/2 —— **正好居中**,顶上一大片空白。 + * + * ⇒ 加 `.align(Alignment.Top)`。不是"内容自己会顶上去",而是 + * **高度受限的 Scroll 必须显式要求顶部对齐**,否则它替你居中。 + */ + + .align(Alignment.Top) /* * ★ 上下边缘渐隐(2026-09-17 补)。 * diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 80e005d..aec6ba2 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -9,7 +9,8 @@ import { hilog } from '@kit.PerformanceAnalysisKit'; import { ApiClient, ApiError } from '../api/ApiClient'; import { Theme } from '../common/Theme'; -import { AppHeader, CompositeModifier, GlassCardModifier, PaneModifier, PressFeedbackModifier } from '../common/Surface'; +import { AppHeader, CompositeModifier, GlassCardModifier, PaneModifier, PressEffectModifier, TintModifier } from '../common/Surface'; +import { Motion } from '../common/Motion'; import { AmIcon } from '../common/Icons'; import { WideSidebar } from './WideSidebar'; import { MailDetailView } from './MailDetailPage'; @@ -96,8 +97,7 @@ import { navBadgeText, navBadgeTone, NavItem, - normalizeNavIndex -} from '../model/NavItems'; + normalizeNavIndex, TAB_BAR_HEIGHT } from '../model/NavItems'; /** 一页取多少封。取满了就要如实提示"可能还有更多"(服务端 total 是未读数,不是总封数)。 */ const MAIL_DETAIL_ROUTE: string = 'mail-detail'; @@ -160,6 +160,25 @@ function DetailPlaceholder() { } .width('100%').height('100%') .justifyContent(FlexAlign.Center) + /* + * ★★ 2026-09-21 补:**宽屏右栏默认页也要透明**(用户:「邮件看着没有玻璃效果」)。 + * + * `splitPlaceholder` 那块内容由 `Navigation` 直接渲染 + * (布局树里的 `SplitPlaceholderContentNode`,实测 x=1152..3154)。 + * 不设背景时它用**系统默认底**(近白不透明),于是整片右栏把壁纸挡死 —— + * 实测宽屏 3184px 下:右栏中心 `(242,244,244)`,而屏幕边缘的壁纸是 + * `(192,194,192)`。差这么远,说明中间隔着的不透明层就是它。 + * + * 这与 2026-09-19 修的 `CommPage` 那个 `Navigation` 是**同一个形状** + * (见那段注释里"那条缝是决定性的证据")—— 系统容器不设背景就不会透。 + * + * ★ 全局 `@Builder` 里**拿不到 `this`**(`splitPlaceholder` 只接受全局 Builder), + * 所以不能用 `bgActive ? Transparent : surface`。 + * 这里改成**恒透明**:右栏默认页本来就没有"实体面"的语义 + * (它是一块空态提示),壁纸关着时底下是 `WallpaperLayer` 之外的 + * `pageBg`,观感仍然是浅色底 —— 不会因为透明变成异色。 + */ + .backgroundColor(Color.Transparent) } @Component @@ -521,9 +540,11 @@ struct InboxTab { .fontSize(11).fontColor(Theme.textMuted) AmIcon({ iconName: 'chevronRight', iconSize: 12, iconColor: Theme.textSubtleFor() }) .rotate({ angle: 90 }) + .animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft }) .margin({ left: 3 }) } .height(32) + .attributeModifier(PressEffectModifier.of()) .onClick(() => { this.showAccountPicker = !this.showAccountPicker; }) } if (this.unread > 0) { @@ -573,6 +594,7 @@ struct InboxTab { } .width('100%').height(48).padding({ left: 16 }) .backgroundColor(this.accountFilter === acct.id ? Theme.accentSoftFor(this.isDarkNow) : Theme.surface) + .attributeModifier(PressEffectModifier.of()) .onClick(() => { this.accountFilter = acct.id; this.accountName = acct.displayName; @@ -626,15 +648,50 @@ struct InboxTab { // 单封不成组:套一个可折叠的组头只是多一次点击(与 WebUI isFlatGroup 同结论) this.MailRow(g.mails[0]) } else { - Column() { + /* + * ── 会话组容器 ── + * + * ★★ 2026-09-21 修「多个邮件为什么没有堆叠效果」(用户)。 + * + * 原来组头与子邮件是**平铺的兄弟**、各自一张完整的卡、间距一样 —— + * 于是一组三封看起来就是"三张互不相干的卡",看不出 + * "这是同一条会话里的三封"。 + * + * WebUI 的形状(`MailList.tsx:173-232`): + *
← **组容器**(一层外框) + *