跨端: 顶栏不再自己铺白条 + 日历改左右两栏 + 常驻窗格动画真的会播(用户三处实测指出)
用户三条反馈,逐条对应:
① 「底栏数字为什么显示在图标下面?」
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:
1
.gitignore
vendored
1
.gitignore
vendored
@ -76,3 +76,4 @@ plugins/homeagent-mail-bridge/build/
|
||||
# 构建产物(曾误提交)
|
||||
server/server
|
||||
plugins/zcode-mail-bridge/test/manual/gate-e2e-evidence.json
|
||||
.hvigor/
|
||||
|
||||
@ -911,6 +911,58 @@ rc (原返回处):blind/unreadable/baseline-unrunnable|unknown 排第一
|
||||
| ② 让自检断言 `staticDetail` **变量**内容完整 | 只删掉那句 `console.log` | **4b 照样报 ok**,而真实输出里播报段 **0 次** |
|
||||
| ③ 断言源码里有 `${staticDetail}` 这个形状 | 同上 | **仍报 ok** —— 因为**锚点写在这段自检自己的注释里**,`includes` 命中的是注释 |
|
||||
| ④ 剥注释后再数 `+ staticDetail);` 的命中 | 删 print | **4b 红 ✓**("可执行代码里没有任何一处把 `staticDetail` 拼进输出") |
|
||||
| ⑤ **同一处改成"记录那次调用真的送了什么"(4c)** | 删 print | **4c 红 ✓** |
|
||||
| ⑤ | **把 print 包进恒假条件**(`if (false)` / `while (false)` / `false ? … : 0`) | **4c 红 ✓,而 4b 仍报 ok**(三种写法都实测过) |
|
||||
|
||||
#### ★★ 4b 的漏格:它扫的是**文本**,而"那一跳"是**一次调用**(pi 报,dsh 2026-09-19 复现并修)
|
||||
|
||||
④ 只解决了"print 被删"。但 4b 的证据是**源码里存在那个锚文本** —— 于是**锚文本还在、
|
||||
而那次调用根本不发生**时,它照样报 ok。**pi 报的正是这一格**,我完整跑复现:
|
||||
|
||||
```
|
||||
baseline : 播报段出现 1 次 4b = ok
|
||||
变异体 : 播报段出现 0 次 4b = ok ← 红清单与 baseline **逐条相同**
|
||||
```
|
||||
|
||||
根因是**自检先于打印**:4b 在 `:1613` 跑(`staticDetail` 定义之后、打印之前),
|
||||
打印在 `:3129` ⇒ 它**物理上到不了**那次调用,只能读文本。
|
||||
⇒ **"验了那个形状"与"验了那件事发生"仍然是两件事**(§16.1 那条缝的又一格)。
|
||||
|
||||
**修法(4c)**:不再猜源码,改成**记录这次调用真的往 `console.log` 送了什么** ——
|
||||
锚点落在「**实际发生的那次调用**」上,这是源码文本够不着的东西:
|
||||
|
||||
```js
|
||||
const staticBroadcastSeen = [];
|
||||
{ const realLog = console.log;
|
||||
console.log = (...a) => { staticBroadcastSeen.push(a.join(' ')); realLog(...a); };
|
||||
console.log(`…` + staticDetail);
|
||||
console.log = realLog; }
|
||||
if (!staticBroadcastSeen.some(t => STATIC_ONLY.every(([f, why]) => t.includes(f) && t.includes(why))))
|
||||
reds.push('(自检)4c:…没有真的进 console.log…');
|
||||
```
|
||||
|
||||
实测(完整跑,每次一个变异体):
|
||||
|
||||
| 变异 | 播报段 | 4b | 4c |
|
||||
|---|---|---|---|
|
||||
| 无(对照) | 1 | ok | 不红 ✓(红清单与改前**逐条相同**) |
|
||||
| `if (false)` 包裹 | 0 | **仍 ok** | **红 ✓** |
|
||||
| `while (false)` 包裹 | 0 | **仍 ok** | **红 ✓** |
|
||||
| `false ? … : 0` | 0 | 红 | **红 ✓** |
|
||||
| 删掉 print | 0 | **红 ✓** | **红 ✓**(不回归) |
|
||||
|
||||
★ 两条可复用教训:
|
||||
|
||||
1. **"文本里有" ≠ "那次调用发生了"** —— 只要判据读源码,`if (false)` 就是一个
|
||||
**既保留文本、又取消行为**的通用逃逸。凡"必须真的发生"的那一跳,锚点要落在
|
||||
**运行时观测**上,不能落在源码形状上。
|
||||
2. **自检的位置本身就是判据的一部分**:4b 读的那个值定义在它之前、而**使用**在它之后
|
||||
⇒ 它对"使用"这一跳永远只能读文本。**"自检跑在它守的那件事之前"是一类结构性盲区**,
|
||||
不是这一条的偶然。
|
||||
|
||||
⚠️ 4c **不能**做成 `SELFTESTS` 的一条:快入口 `--only-selftest=<名>` 在
|
||||
`:2503` 就 `process.exit` 了,那条路径**永远到不了** `:3129` 的打印 ⇒
|
||||
它只会"看不见"而**不会红**(把它挂在那里等于又造一个 4b)。
|
||||
|
||||
★ 三条教训(都可复用):
|
||||
|
||||
@ -928,6 +980,8 @@ rc (原返回处):blind/unreadable/baseline-unrunnable|unknown 排第一
|
||||
(Y 变 0 而 X 仍是 6 ⇒ 一眼看得出)。
|
||||
- **每次运行都全表播报** `STATIC_ONLY`(文件 + 原因 + 是否到期**并排**)⇒ 第 2 列有了读者。
|
||||
- **自检 4b** 钉住"那一跳真的存在"(剥注释后数锚点 + 内容完整 + 反空转长度)。
|
||||
- **自检 4c** 钉住"**那一跳真的发生了**"(运行时记录 `console.log` 的实际参数)——
|
||||
4b 管"源码里有",4c 管"调用发生了";**两条合起来才等价于"到达"**。
|
||||
|
||||
★ **通用规则**(本节定稿):
|
||||
|
||||
|
||||
@ -243,11 +243,24 @@ test('底部是 通信 / 日历 / 联系人 三个平级页签,「会话」不
|
||||
const navLabels = [...pageCode.matchAll(/NAV_ITEMS\.map\([^)]*\.label\)|NAV_ITEMS/g)].length;
|
||||
assert.ok(navLabels > 0, '底栏要由 NAV_ITEMS 驱动');
|
||||
const navSource = code(join(HARMONY_ETS, 'model/NavItems.ts'));
|
||||
const labels = [...navSource.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
|
||||
// 2026-09-14:日历页(P6 第 1 步)落地后入口上架 ⇒ 三项。撤掉「会话」这条判断本身没变:
|
||||
// 它不是第三个地方,而是"同一批数据的另一种看法"(收件箱折叠 + 联系人卡片视图)。
|
||||
// 2026-09-17:与 WebUI NarrowNav 对齐 ⇒ 四项(前三内容窗格 + 外壳入口「我的」)。
|
||||
assert.deepEqual(labels, ['通信', '日历', '联系人', '我的'], `平级项应为 通信/日历/联系人/我的,实际:${labels.join('、')}`);
|
||||
/*
|
||||
* ★★ 2026-09-19 修:原先这里把 `NavItems.ts` 里**所有** `label:` 一网打尽
|
||||
* ⇒ 得到 7 个(底栏 4 项 **加上** 侧栏 3 项)—— 因为这两份清单现在是**分开的**:
|
||||
* · `NAV_ITEMS`(底栏)= 通信/日历/联系人/我的(对齐 WebUI `NarrowNav.tsx:37-40`+143)
|
||||
* · `NAV_SIDEBAR_ITEMS`(侧栏)= 通信/日历/**联系**(对齐 WebUI `Sidebar.tsx:26-44`,
|
||||
* 「我的」是底部头像,不在这份清单里)
|
||||
* 把它们混着数,会得到一个既不是底栏、也不是侧栏的“第三份清单”,
|
||||
* 于是任何一边改对了这一条都会红。⇒ 改成**分别从各自的数组里取**。
|
||||
*/
|
||||
const itemsOf = (name) => {
|
||||
const m = navSource.match(new RegExp(`export const ${name}: NavItem\\[\\] = \\[([\\s\\S]*?)\\];`));
|
||||
assert.ok(m, `NavItems.ts 要有 ${name}`);
|
||||
return [...m[1].matchAll(/label:\s*'([^']+)'/g)].map(x => x[1]);
|
||||
};
|
||||
assert.deepEqual(itemsOf('NAV_ITEMS'), ['通信', '日历', '联系人', '我的'],
|
||||
'底栏四项(对齐 WebUI NarrowNav:收件/发件/授权合并为「通信」,末项「我的」)');
|
||||
assert.deepEqual(itemsOf('NAV_SIDEBAR_ITEMS'), ['通信', '日历', '联系'],
|
||||
'侧栏三项(对齐 WebUI Sidebar.tsx 的 navItems:「我的」是底部头像,不在导航轨里)');
|
||||
assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里');
|
||||
assert.ok(!/sessions\(\)/.test(pageCode), '撤了入口就不该再拉 /me/sessions(否则是没人看的请求)');
|
||||
});
|
||||
@ -520,9 +533,16 @@ test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 +
|
||||
// 底部第一项的标签是「通信」而不是「收件箱」(信息架构变了,标签必须跟着变)
|
||||
// P5:底栏标签现在来自 NAV_ITEMS(自绘浮动条),不是 TabBarBuilder 的参数
|
||||
const navSource2 = code(join(HARMONY_ETS, 'model/NavItems.ts'));
|
||||
const tabLabels = [...navSource2.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
|
||||
/*
|
||||
* ★★ 2026-09-19 修(同文件另一处同一个错):从 `NAV_ITEMS`(**底栏那份**)里取 label,
|
||||
* 不能把全文件的 `label:` 一网打尽 —— 那样会把 `NAV_SIDEBAR_ITEMS`(三项)也数进来,
|
||||
* 得到 7 个。两份清单现在是分开的(底栏 4 / 侧栏 3),见 `harmony-widescreen` ②。
|
||||
*/
|
||||
const navItemsBlock = navSource2.match(/export const NAV_ITEMS: NavItem\[\] = \[([\s\S]*?)\];/);
|
||||
assert.ok(navItemsBlock, 'NavItems.ts 要有 NAV_ITEMS(底栏那份清单)');
|
||||
const tabLabels = [...navItemsBlock[1].matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
|
||||
// 2026-09-14:日历页(P6 第 1 步)做完后入口上架 —— 三项且顺序固定(通信/日历/联系人)
|
||||
// 2026-09-17:对齐 WebUI 四入口 —— 前三内容窗格不变,第 4 项「我的」是外壳入口(见 NavItem.route)
|
||||
// 2026-09-17:对齐 WebUI 四入口 —— 前三内容窗格不变,第 4 项「我的」也是内容窗格(currentIndex === 3)
|
||||
assert.deepEqual(tabLabels, ['通信', '日历', '联系人', '我的'], `底部应为通信/日历/联系人/我的四项,实际:${tabLabels.join('、')}`);
|
||||
// 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)——
|
||||
// 所以这里钉的是"带参数地渲染通信页",不是光有个名字
|
||||
|
||||
@ -460,9 +460,45 @@ test('★ 窗格切换有真的过场动画(transition 挂在会换的那棵
|
||||
assert.ok(branchRoots >= 2,
|
||||
`★ transition 要挂在会被换掉的子树根上(if/else 分支),实际只找到 ${branchRoots} 处。` +
|
||||
'挂在两级之上的稳定父容器上等于没挂(实测过:6 秒取样仍是硬切)。');
|
||||
// 日历那一支(常驻 + visibility)也要有
|
||||
assert.match(mainCode, /\.visibility\(this\.currentIndex === 1[\s\S]{0,120}\.transition\(Theme\.paneRiseIn\(\)\)/,
|
||||
'★ 日历窗格(常驻挂载 + visibility 控制)也要挂过场过渡');
|
||||
|
||||
/*
|
||||
* ★★ 2026-09-19 重写日历那一条(用户:「最严重的动画问题你一点也不该改」)。
|
||||
*
|
||||
* 原断言:`.visibility(...)` 后面跟 `.transition(Theme.paneRiseIn())` ——
|
||||
* 它锚的是**写法**,而不是“动画真的会播”。而那个写法**一帧也不会播**:
|
||||
* `.transition()` 只在**挂载/卸载**时触发(SDK 原话 "when it **appears and
|
||||
* disappears**"),可日历是**常驻**的(用 `visibility` 控制,因为它里面
|
||||
* `today` 要随时间重算、也要保住“正在看哪个月”)—— 永不重挂载。
|
||||
* 所以旧断言是**把 bug 锁住了**(与我上次给侧栏编理由同一个错法)。
|
||||
*
|
||||
* 新断言改成断**机制真的存在且被驱动**:
|
||||
* · 常驻窗格不能用 TransitionEffect,要用可插值的属性
|
||||
* (`opacity` + `translate`);
|
||||
* · 那个属性要有一个 @State 支撑;
|
||||
* · 切窗格时要**真的把它从 0 推到 1**(且 reset 在 animateTo 外 ——
|
||||
* 否则同一帧内 0→1,起点终点都是 1,动画退化成瞬移)。
|
||||
*/
|
||||
assert.ok(!/\.visibility\(this\.currentIndex === 1[\s\S]{0,120}\.transition\(Theme\.paneRiseIn\(\)\)/.test(mainCode),
|
||||
'★ 日历窗格是**常驻挂载**(visibility 控制)—— 它上面的 `.transition()` 永远不会触发。' +
|
||||
'那条断言锁的就是这个 bug,不得恢复。');
|
||||
// 日历容器的入场:用可插值属性,且由 @State 支撑
|
||||
assert.match(mainCode, /calPaneIn/,
|
||||
'★ 日历窗格的入场进度要有一个 @State(`calPaneIn`)支撑 —— 常驻窗格只能靠属性插值做入场');
|
||||
const calContainer = /\.visibility\(this\.currentIndex === 1[\s\S]{0,400}?\.opacity\(this\.calPaneIn\)/.exec(mainCode);
|
||||
assert.ok(calContainer,
|
||||
'★ 日历容器要真的用 `calPaneIn` 驱动透明度(这才是常驻窗格能播的入场)');
|
||||
assert.match(mainCode, /\.translate\(\{[^}]*calPaneIn[^}]*\}\)/,
|
||||
'★ 日历容器要同时驱动位移(对齐 WebUI `rise-in` 的 translateY)');
|
||||
// reset 必须在 animateTo **外** —— 否则起点=终点,动画退化
|
||||
const resetIdx = mainCode.indexOf('this.calPaneIn = 0');
|
||||
const animIdx = mainCode.indexOf('getUIContext().animateTo(');
|
||||
assert.ok(resetIdx > 0, '★ 切到日历前要先把 `calPaneIn` 瞬回 0(否则看不到“从无到有”)');
|
||||
assert.ok(resetIdx < animIdx || animIdx < 0,
|
||||
'★ `calPaneIn = 0` 必须在 `animateTo` **之前**:写进回调里会与同帧的 1 相抵,' +
|
||||
'渲染层只看得见最终值 ⇒ 动画退化成一次瞬移(等于没修)');
|
||||
const intoAnim = /getUIContext\(\)\.animateTo\([\s\S]{0,300}?this\.calPaneIn = 1/.exec(mainCode);
|
||||
assert.ok(intoAnim,
|
||||
'★ 推到 1 要在 `animateTo` 窗口里(与其余窗格的 transition 同时长、同曲线)');
|
||||
|
||||
// animateTo 走 getUIContext(全局 animateTo 已废弃,另行有判据钉)
|
||||
assert.match(mainCode, /getUIContext\(\)\.animateTo\(/,
|
||||
@ -933,24 +969,55 @@ function navItemsOf(root) {
|
||||
const boxW = x2 - x1;
|
||||
const boxH = y2 - y1;
|
||||
/*
|
||||
* ★ 阈值用**比例**而不是绝对 px,且必须容得下真实的 48vp 项。
|
||||
* 实测(密度 2.875):项 138×138px、屏 3184×2232 ⇒
|
||||
* x1=45 < 3184/6=531 ✓、boxW=138 < 3184/4=796 ✓、
|
||||
* 但 boxH=138 需要 > 2232*0.08=178 ✗ ⇒ 四项**全被滤掉**。
|
||||
* 也就是说这个阈值我第一版写大了,只是当时项被压成 39vp 才"恰好"通过
|
||||
* (那是别的 bug,不是阈值对)。
|
||||
* 改成 `screenH * 0.04`(=89px):48vp 项(138px)过得去,
|
||||
* 而内容区那些横向长条(高 43px < 89)仍然被排除。
|
||||
* ★★ 2026-09-19 修(被判据自己抳到):过滤条件从 “靠左 1/6” 改成
|
||||
* “**整个盒子在侧栏轨道内**”。
|
||||
*
|
||||
* ★ 还要**要求有文字标签**:品牌标也是左上的可点方块(实测 `Stack [57,174][172,289]`
|
||||
* 40vp、无文字),不加这一条它会混进来,而它不是导航项 ——
|
||||
* 它是"点它回家"的品牌按钮(WebUI 的 `brand-mark`)。
|
||||
* 原条件 `x1 < screenW / 6` 只要求**左边缘**靠左 —— 而日历网格的格子实测是
|
||||
* `Column [229,511][366,794]`:x1=229 < 531 ✓、高 283 > 89 ✓、带文字 ✓
|
||||
* ⇒ **被当成导航项**。于是一屏日历数出 11~12 个“导航项”,
|
||||
* 而侧栏实际只有 3 项。
|
||||
*
|
||||
* 换成 “`x2`(右边缘)也在轨道内”:
|
||||
* · 导航项 `[45,312][183,450]` ⇒ x2=183 ≤ 255 ✓
|
||||
* · 品牌标 `[57,174][172,289]` ⇒ x2=172 ✓(但无文字,另行排除)
|
||||
* · 日历格子 `[229,511][366,794]` ⇒ x2=366 > 255 ✗ **排除**
|
||||
*
|
||||
* 为什么用比例 `screenW * 0.08`(展开态 = 255px)而不是写死 183/200:
|
||||
* 侧栏是 60vp,px 值随密度变(教训:**密度是第二个真相**,见 `isWideLayout`)。
|
||||
* 0.08 在展开态的 3184px 上给 255,在单屏 1008px 上给 80(而单屏不是宽屏,不走这支)。
|
||||
*/
|
||||
return x1 < screenW / 6 && boxW < screenW / 4 && boxH > screenH * 0.04
|
||||
return x2 <= screenW * 0.08 && boxH > screenH * 0.04
|
||||
&& textsUnder(n).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 宽屏侧栏里的**导航轨**(不含底部那一簇)。
|
||||
*
|
||||
* ★★ 2026-09-19 新增(用户:「你写的app和webui大面积不符」)。
|
||||
*
|
||||
* 侧栏底部那一簇(头像 / 主题 / 退出)也是可点、也有文字/图标,形状与导航项相近 ——
|
||||
* `navItemsOf` 会把它们一起数进来(实测数出 12 个,而导航项应为 3 个)。
|
||||
* 而 WebUI 确实有这一簇(`Sidebar.tsx:183-212`),所以**不能拿它当“多出来的项”判红**;
|
||||
* 也不能因此放宽总项数(那会让“我的又回到侧栏”那个 bug 漏过去)。
|
||||
*
|
||||
* 所以改按**位置**切:导航项一簇**贴顶**(WebUI 实测 y = 70/122/174,`gap-1`),
|
||||
* 下面用 `Blank()` 推到屏幕底部才是那一簇。取两者之间的空档切一刀即可。
|
||||
*
|
||||
* ★ 阈值用“屏幕高度的一半”:导航项在顶部 1/3 以内,底部簇在最后 1/4 以内,
|
||||
* 中间有大段空白(实测展开态导航项在 y≈985-1380、底部簇在 y≈2000+)。
|
||||
* 用比例而不是绝对 px,避开密度那第二个真相(同 `isWideLayout` 的教训)。
|
||||
*/
|
||||
function navRailItemsOf(root) {
|
||||
const items = navItemsOf(root);
|
||||
const screenH = screenHeightOf(root);
|
||||
return items.filter(n => {
|
||||
const m = (n.attributes?.bounds || '').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
||||
if (!m) return false;
|
||||
return Number(m[2]) < screenH * 0.5;
|
||||
});
|
||||
}
|
||||
|
||||
test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout 实测,live ⊆ source)', async (t) => {
|
||||
const CRIT = 'harmony-nav/底栏可点项';
|
||||
const hdc = findHdc();
|
||||
@ -1041,8 +1108,33 @@ test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout
|
||||
*
|
||||
* 现在按两侧的**共同事实**判:侧栏项 = 图标(Path)+ 文字标签。
|
||||
* 图标画出 + 文字命中源码清单,两条都要。
|
||||
*
|
||||
* ★★ 2026-09-19 修第二处(与 harmony-widescreen ② 同一个错):
|
||||
* 这一段原来拿 `N.NAV_ITEMS`(**四项**,含「我的」)的 label 去比侧栏渲染出来的文字
|
||||
* —— 但侧栏是**三项**(通信/日历/**联系**),第三项对不上(“联系” ≠ “联系人”),
|
||||
* 而且它反过来证明不了“侧栏多了我的”那个 bug。
|
||||
* 侧栏要比的是 `NAV_SIDEBAR_ITEMS`;两项前两项相同只是巧合,不能因此混用。
|
||||
*/
|
||||
const sourceLabels = new Set(N.NAV_ITEMS.map(i => i.label));
|
||||
const sourceLabels = new Set(N.NAV_SIDEBAR_ITEMS.map(i => i.label));
|
||||
/*
|
||||
* 侧栏**总共**应该正好是 `NAV_SIDEBAR_ITEMS` 那个数。
|
||||
* 只判“每项命中”不够:多出来的一项(例如「我的」)也可能命中一个 label,
|
||||
* 而“多一个我的”本来就是这次报的 bug。所以同时判**个数**。
|
||||
*/
|
||||
/*
|
||||
* ★★ 2026-09-19:改判**导航轨**(`navRailItemsOf`)而不是全部可点项 ——
|
||||
* 侧栏底部那一簇(头像/主题/退出)是 WebUI 就有的(`Sidebar.tsx:183-212`),
|
||||
* 把它数进来会得到 12,而那并不是“多了一个我的”。
|
||||
* 数量仍要卡死(不然“我的回到侧栏”那个 bug 会漏过去),只是卡在**轨道**这一层。
|
||||
*/
|
||||
const railItems = navRailItemsOf(root);
|
||||
assert.equal(railItems.length, N.NAV_SIDEBAR_ITEMS.length,
|
||||
`宽屏侧栏导航轨应有 ${N.NAV_SIDEBAR_ITEMS.length} 项(与 WebUI Sidebar.tsx 的 navItems 同数),` +
|
||||
`实际 ${railItems.length} 项(多出来很可能就是「我的」又回到了侧栏);` +
|
||||
`(全部可点项 ${navItems.length} 个,含底部那一簇)`);
|
||||
/* 后续逐项断言作用在导航轨上 */
|
||||
navItems.length = 0;
|
||||
navItems.push(...railItems);
|
||||
for (const it of navItems) {
|
||||
const paths = [...walk(it)].filter(x => x.attributes?.type === 'Path');
|
||||
assert.ok(paths.length >= 1,
|
||||
@ -1051,7 +1143,7 @@ test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout
|
||||
const texts = textsUnder(it);
|
||||
const matched = texts.filter(l => sourceLabels.has(l));
|
||||
assert.ok(matched.length >= 1,
|
||||
`宽屏侧栏项要带文字标签且命中源码 NAV_ITEMS(WebUI Sidebar 有 <span>{short}</span>);` +
|
||||
`宽屏侧栏项要带文字标签且命中源码 NAV_SIDEBAR_ITEMS(WebUI Sidebar 有 <span>{short}</span>);` +
|
||||
`实际文字:${texts.join('、')}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
// 宽屏侧栏图标轨(WideSidebar)判据 —— 一比一复刻 WebUI 的 `Sidebar`(60px 图标轨)。
|
||||
// 宽屏侧栏(WideSidebar)判据 —— 逐项复刻 WebUI 的 `Sidebar`。
|
||||
//
|
||||
// WebUI 的 `Sidebar`(components/Sidebar.tsx)是 60px 宽的图标列:
|
||||
// 品牌标(顶) / 通信·日历·联系人(中) / 设置(底)
|
||||
// 选中态 = 图标+文字变色 + 品牌浅底,**不加背景块/指示条**。
|
||||
// ★★ 2026-09-19 重写本文件的头部说明(上面那一版是错的,与它判的东西矛盾):
|
||||
// 旧文写的是「60px 图标轨 / 通信·日历·联系人(中)/ 设置(底)/
|
||||
// 选中态只换颜色 + 品牌浅底,**不加背景块**」—— 那个描述是**编的**:
|
||||
// · `Sidebar.tsx:26-44` 是**三项**(通信/日历/**联系**)——「我的」不在导航轨里,
|
||||
// 而是 **底部头像按钮**(`Sidebar.tsx:186`);
|
||||
// · `Sidebar.tsx:110` 有 `<span className="text-3xs">{short}</span>`(有文字);
|
||||
// · `index.css:1590` 的 `.nav-item[data-active='true']` **有底色块**,
|
||||
// 而且 CSS 注释专门说明侧栏**必须有**(“图标底下那一块底色是它唯一的选中线索”)。
|
||||
// 更糟的是:判据当时**引用了这段自编的描述当依据**,于是把错误锁死 —— 全绿。
|
||||
// 现在的口径:每一项都回读 WebUI 源码取值,不再引“我上次写的那句话”。
|
||||
//
|
||||
// 鸿蒙侧原来是**没有**宽屏布局的(只有底部导航条),WideSidebar 补的就是
|
||||
// "宽屏模式"这半:≥768vp 时 MainPage 挂侧栏、藏底部条(见 MainPage.build 的 onAreaChange)。
|
||||
// 这条判据钉的是 Sidebar 自己的形状:宽 60vp、三项导航 + 设置、选中态只换颜色。
|
||||
// 鸿蒙侧原来**没有**宽屏布局(只有底部导航条),WideSidebar 补的就是"宽屏模式"这半:
|
||||
// ≥768vp 时 MainPage 挂侧栏、藏底部条(见 MainPage.build 的 onAreaChange)。
|
||||
|
||||
import { code, prose, stripComments } from './lib/read.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@ -18,8 +24,12 @@ import assert from 'node:assert/strict';
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..', '..');
|
||||
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
|
||||
/** WebUI 源码根:判据要**回读**它取值,而不是引注释里的话 */
|
||||
const WEBUI_SRC = join(ROOT, 'client/electron/src');
|
||||
|
||||
const read = p => prose(join(HARMONY_ETS, p));
|
||||
/** 读 WebUI 的源文件(**不去注释**:注释里就有真实理由) */
|
||||
const readWeb = p => readFileSync(join(WEBUI_SRC, p), 'utf8');
|
||||
|
||||
/** 取某个成员/方法正文(按行切到下一个成员声明) */
|
||||
function memberBody(src, signature) {
|
||||
@ -41,41 +51,84 @@ test('① 宽度:SIDEBAR_WIDTH = 60vp(WebUI 的 `w-[60px]` 是同一数字
|
||||
assert.match(main, /WideSidebar\(/, 'MainPage 要真正挂 WideSidebar');
|
||||
});
|
||||
|
||||
test('② 四项导航(与底栏同源):不再有单列的"设置"入口', () => {
|
||||
test('② 侧栏**三项**(通信/日历/联系)+ 底部一簇 —— 与底栏四项不是同一份清单', () => {
|
||||
/*
|
||||
* ★★ 2026-09-18 重写。原来这条判的是「三项导航 + 设置按钮」,
|
||||
* 依据是"设置单列在下面、遍历 NAV_ITEMS 会出现两个 person 图标"。
|
||||
* 那个依据**两半都错**:
|
||||
* · `NAV_CONTENT_ITEMS = NAV_ITEMS.slice(0, NAV_CONTENT_COUNT)` 而
|
||||
* `NAV_CONTENT_COUNT = 4` ⇒ **四项全在**(含「我的」),不存在"只有前三项";
|
||||
* · 单列的"设置"走 `onSettings → pushUrl('pages/SettingsPage')`,
|
||||
* 而那正是用户 2026-09-17 报过的「我的页面完全没有遵守 nav 的导航规则」
|
||||
* (底栏那一支改成了窗格,侧栏这一支漏了)。
|
||||
* ★★ 2026-09-19 重写(用户:「你写的app和webui大面积不符,问题特别大」)。
|
||||
*
|
||||
* WebUI `Sidebar.tsx` 的真实形状:`navItems` 三项 + **底部一簇**
|
||||
* (账号头像 / 主题切换 / 退出)。「我的」在 WebUI 是 `viewMode === 'account'`,
|
||||
* 由侧栏底部那个**头像按钮**进入 —— 也就是说它是**导航项**,不是"推出去的页"。
|
||||
* 鸿蒙的对应物是第 4 项「我的」窗格(与底栏一致)。
|
||||
* 上一版这条判的是「遍历 `NAV_CONTENT_ITEMS`」—— 而 `NAV_CONTENT_ITEMS` 是
|
||||
* **四项**(含「我的」)。那正是**错的形状**:四项只对**底栏**。
|
||||
*
|
||||
* WebUI 两套导航**本来就不同**(同一份源码里两个文件):
|
||||
* · `NarrowNav.tsx:37-40` items = 通信/日历/**联系人**,
|
||||
* 再加第 4 个「我的」按钮(`NarrowNav.tsx:143`)⇒ 底栏 **4 项**;
|
||||
* · `Sidebar.tsx:26-44` navItems = 通信/日历/**联系** ⇒ 侧栏 **3 项**,
|
||||
* 「我的」是底部**头像按钮**(`Sidebar.tsx:186`,旁边的注释就说它是
|
||||
* `title=...点击管理账号`)。
|
||||
*
|
||||
* 这条件判据的写法变了:不再只查鸿蒙源码的字符串,而是
|
||||
* **回读 `Sidebar.tsx` 数出 `short:` 的个数**,要求鸿蒙那一份也是同一数字 ——
|
||||
* 否则下次 WebUI 改了项数,这里只会继续绿。
|
||||
*/
|
||||
const sidebar = read('pages/WideSidebar.ets');
|
||||
const code_ = stripComments(sidebar);
|
||||
assert.match(code_, /ForEach\(NAV_CONTENT_ITEMS,/,
|
||||
'侧栏要遍历 NAV_CONTENT_ITEMS(与底栏同一个清单 —— 两处各留一份会漂移)');
|
||||
const sidebarSrc = readWeb('components/Sidebar.tsx');
|
||||
const narrowSrc = readWeb('components/NarrowNav.tsx');
|
||||
|
||||
// 从 WebUI 源码里数导航项:`short: 'xx'` 的行(两处各数一遍)
|
||||
const shortOf = (src) => [...src.matchAll(/short:\s*'([^']+)'/g)].map(m => m[1]);
|
||||
const webSidebarShort = shortOf(sidebarSrc);
|
||||
const webNarrowShort = shortOf(narrowSrc);
|
||||
|
||||
assert.deepEqual(webSidebarShort, ['通信', '日历', '联系'],
|
||||
`WebUI 侧栏应为 3 项(读到的是 ${JSON.stringify(webSidebarShort)})——若这里变了,下面的期望值要跟着改`);
|
||||
assert.deepEqual(webNarrowShort, ['通信', '日历', '联系人'],
|
||||
`WebUI 底栏内容项应为 3 项(读到的是 ${JSON.stringify(webNarrowShort)})——与侧栏的第三项措辞**故意不同**`);
|
||||
|
||||
// 鸿蒙侧:侧栏清单必须与 WebUI 侧栏逐字一致(项数与文案)
|
||||
const navItems = read('model/NavItems.ts');
|
||||
const sidebarList = navItems.match(/export const NAV_SIDEBAR_ITEMS[\s\S]*?\];/);
|
||||
assert.ok(sidebarList, 'model/NavItems.ts 要有 NAV_SIDEBAR_ITEMS(侧栏专用的三项清单)');
|
||||
const harmonySidebarShort = [...sidebarList[0].matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
|
||||
assert.deepEqual(harmonySidebarShort, webSidebarShort,
|
||||
`NAV_SIDEBAR_ITEMS 的 label 要与 WebUI 侧栏逐字一致(现在读到 ${JSON.stringify(harmonySidebarShort)})`);
|
||||
|
||||
// 侧栏**不许**用四项那份清单(那正是这次报的“多出一个我的”)
|
||||
const sidebar = stripComments(read('pages/WideSidebar.ets'));
|
||||
assert.match(sidebar, /ForEach\(NAV_SIDEBAR_ITEMS,/,
|
||||
'侧栏要遍历 NAV_SIDEBAR_ITEMS(三项)');
|
||||
assert.ok(!/ForEach\(NAV_CONTENT_ITEMS,/.test(sidebar),
|
||||
'侧栏不许再遍历 NAV_CONTENT_ITEMS(那是**底栏**的四项清单,含「我的」)');
|
||||
|
||||
// 品牌标:WebUI 是 `<button data-testid="brand-mark"><BrandMarkIcon/>` —— 可点 + brandMark 图标
|
||||
assert.match(code_, /iconName: 'brandMark'/, '要有品牌标(WebUI `brand-mark` 的对应物,图标是 brandMark)');
|
||||
// 「我的」必须在侧栏里作为一个**导航项**出现,且走 onSelect 而不是推页
|
||||
assert.ok(!/onSettings/.test(code_),
|
||||
assert.match(sidebar, /iconName: 'brandMark'/, '要有品牌标(WebUI `brand-mark` 的对应物,图标是 brandMark)');
|
||||
assert.ok(!/onSettings/.test(sidebar),
|
||||
'侧栏不许留 onSettings(它推 `pages/SettingsPage` ⇒ 侧栏整条消失,正是用户报过的形状)');
|
||||
/*
|
||||
* 「我的」由上面那个 `ForEach(NAV_CONTENT_ITEMS)` 覆盖(`NAV_CONTENT_ITEMS`
|
||||
* 含第 4 项)—— 所以这里判的是"没有另起一个推页入口",
|
||||
* 以及 onSelect 能把 index 传到第 4 项(`normalizeNavIndex` 不截到 3 项)。
|
||||
*/
|
||||
assert.match(code_, /onClick\(\(\) => \{ this\.onSelect\(index\); \}\)/,
|
||||
'导航项点击要原样传 index(第 4 项「我的」因此走 onSelect(3) = 窗格)');
|
||||
// 品牌标点它要回第一项(WebUI `onClick={() => setViewMode('inbox')}`)
|
||||
assert.match(code_, /onClick\(\(\) => \{ this\.onSelect\(0\); \}\)/,
|
||||
assert.match(sidebar, /onClick\(\(\) => \{ this\.onSelect\(0\); \}\)/,
|
||||
'品牌标点击要回收件箱(WebUI brand-mark 的一致习惯:点左上角 logo 回家)');
|
||||
|
||||
/*
|
||||
* 底部一簇 —— `Sidebar.tsx:183-212`:头像(含连接点)/ 主题切换 / 退出。
|
||||
* 三者漏一个都会在并排截图里缺一块。
|
||||
*
|
||||
* ★ 判**真的被调用**(`this.onToggleTheme()`),不是"文件里出现过这个名字":
|
||||
* 第一版就写了 `/onToggleTheme/` —— 变异测试当场证明它不咬:
|
||||
* 把 `this.onToggleTheme()` 换成 `this.onSelect(0)` 后,
|
||||
* 属性**声明**还在,正则照样匹上,判据全绿。同 `navBadgeCount` 那一课的同一个错。
|
||||
*/
|
||||
assert.match(sidebar, /this\.onToggleTheme\(\)/, '底部主题键要真的调 onToggleTheme()');
|
||||
assert.match(sidebar, /this\.onLogout\(\)/, '底部退出键要真的调 onLogout()');
|
||||
/* 头像:WebUI 取用户名前两字(`Sidebar.tsx:190` 的 `.slice(0, 2)`) */
|
||||
assert.match(sidebar, /\.slice\(0, 2\)/, '头像要取展示名前两字(WebUI `.slice(0, 2)`)');
|
||||
/* 头像右下角要带 SSE 连接状态点(WebUI 的 `<ConnectionIndicator/>`) */
|
||||
assert.match(sidebar, /sseColorOf\(/, '头像要带连接状态点(颜色由 SSE 状态决定)');
|
||||
const webInd = readWeb('components/ConnectionIndicator.tsx');
|
||||
for (const [status, hex] of [['connected', '#22C55E'], ['connecting', '#FACC15'], ['reconnecting', '#FB923C'], ['disconnected', '#F87171']]) {
|
||||
/* WebUI 用的是 tailwind 类名,这里判的是"四档都有"这个结构,不是具体色值 */
|
||||
assert.ok(new RegExp(`${status}:`).test(webInd), `WebUI 的连接指示器要有 ${status} 档`);
|
||||
assert.ok(sidebar.includes(hex), `鸿蒙的连接点要有 ${status} 对应的色值 ${hex}`);
|
||||
}
|
||||
/* 「我的」由**头像**进入(不是导航轨里的一项) */
|
||||
assert.match(sidebar, /this\.onSelect\(ME_PANE_INDEX\)/,
|
||||
'头像点击要进「我的」窗格(ME_PANE_INDEX)—— WebUI 是 setViewMode(\'account\')');
|
||||
});
|
||||
|
||||
test('③ 侧栏选中态 = **浅蓝底块** + 变色(与底栏"只变色"是两条纪律)', () => {
|
||||
@ -108,17 +161,29 @@ test('③ 侧栏选中态 = **浅蓝底块** + 变色(与底栏"只变色"是
|
||||
`Theme.navActiveBg 要等于 WebUI 的 --nav-active-bg(${hex})`);
|
||||
|
||||
// 选中态必须**有底块**(WebUI 侧栏唯一的选中线索)
|
||||
assert.match(code_, /backgroundColor\(this\.currentIndex === index \? Theme\.navActiveBg : Color\.Transparent\)/,
|
||||
/*
|
||||
* ★ 2026-09-19:表达式从 `this.currentIndex === index` 改成
|
||||
* `this.currentIndex === sidebarContentIndex(item.key)` ——
|
||||
* 侧栏现在只画三项,而 currentIndex 是**四项**那套(`me` = 3),
|
||||
* 所以不能拿构图下标直接比。两条都接受(比的是"这个项是不是选中项")。
|
||||
*/
|
||||
assert.match(code_, /backgroundColor\(this\.currentIndex === (index|sidebarContentIndex\(item\.key\)) \? Theme\.navActiveBg : Color\.Transparent\)/,
|
||||
'导航项选中要有底块(WebUI `.nav-item[data-active]` 的 --nav-active-bg)—— 侧栏不是底栏,不能只变色');
|
||||
|
||||
// 文字标签:WebUI `Sidebar.tsx:110` 有 <span>{short}</span>,所以侧栏**有** label
|
||||
assert.match(code_, /Text\(label\)/,
|
||||
/*
|
||||
* 文字标签:WebUI `Sidebar.tsx:110` 有 `<span>{short}</span>`,所以侧栏**有** label。
|
||||
* ★ 2026-09-19:侧栏改成 `Text(item.label)`(从清单取,不是参数传)——
|
||||
* 两种写法都接受,只要真的把 label 交给了 `Text`。
|
||||
*/
|
||||
assert.match(code_, /Text\((label|item\.label)\)/,
|
||||
'侧栏导航项要有文字标签(WebUI Sidebar 有 <span>{short}</span>)');
|
||||
|
||||
// ★ 自检:这三条都必须能判红
|
||||
assert.ok(!/backgroundColor\(this\.currentIndex === index \? Theme\.navActiveBg : Color\.Transparent\)/.test(
|
||||
'backgroundColor(this.currentIndex === index ? Color.Transparent : Theme.navActiveBg)'),
|
||||
'自检:三元式写反了必须判红');
|
||||
assert.ok(!/Text\((label|item\.label)\)/.test('Text(short)'),
|
||||
'自检:标签没交给 Text 必须判红');
|
||||
});
|
||||
|
||||
test('④ MainPage 接线:宽屏才挂侧栏、宽屏藏底部条、断点 768', () => {
|
||||
@ -275,4 +340,29 @@ test('⑦ 导航项徽标:取值/色调与 WebUI Sidebar 同口径(纯逻辑
|
||||
assert.ok(main.includes(key), `MainPage 要读 ${key}`);
|
||||
assert.ok(sidebar.includes(key), `WideSidebar 要读 ${key}(与底栏同一组键)`);
|
||||
}
|
||||
|
||||
/*
|
||||
* ⑧ 三档色调都要有**各自的底色**(不能把三档压成两档)。
|
||||
*
|
||||
* ★★ 2026-09-19 修 bug:底栏与侧栏两处原先都写
|
||||
* `tone === 'perm' ? warnFg : danger` —— 于是 `'plain'`(联系人数)走了**红**,
|
||||
* 看起来像"有未读"。并排截图一眼可见:WebUI 是石板灰的 4,鸿蒙是红的。
|
||||
*
|
||||
* ★ 期望值从 WebUI 的 CSS 里**读出来**,不写死:
|
||||
* `Sidebar.tsx:203-204` 的 `'plain'` 档是 `bg-chrome-600`,
|
||||
* 而 `--c-chrome-600: 71 85 105`(`index.css:92`)= #475569。
|
||||
*/
|
||||
const cssSrc = readWeb('index.css');
|
||||
const chromeM = cssSrc.match(/--c-chrome-600:\s*(\d+ \d+ \d+)/);
|
||||
assert.ok(chromeM, 'WebUI index.css 要有 --c-chrome-600(中性徽标底色)');
|
||||
const [cr, cg, cb] = chromeM[1].split(' ').map(Number);
|
||||
const plainHex = '#' + [cr, cg, cb].map(v => v.toString(16).padStart(2, '0').toUpperCase()).join('');
|
||||
const themeSrc = readFileSync(join(HARMONY_ETS, 'common', 'Theme.ets'), 'utf8');
|
||||
assert.match(themeSrc, new RegExp(`badgePlain: string = '${plainHex}'`, 'i'),
|
||||
`Theme.badgePlain 要等于 WebUI 的 --c-chrome-600(${plainHex})`);
|
||||
|
||||
for (const [name, src] of [['底栏', main], ['侧栏', sidebar]]) {
|
||||
assert.match(src, /Theme\.badgePlain/,
|
||||
`${name}的徽标底色要引用 Theme.badgePlain('plain' 档不能跟未读共用红色)`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -3126,8 +3126,36 @@ try {
|
||||
const staticDebtLine = `static=${STATIC_ONLY.length}(其中已到期 ${dueStatic.length} 条)`;
|
||||
// ★ 第 2 列的**读者**(【发现 1】的修法):每次运行都全表播报。
|
||||
// 构造在 `dueStatic` 之后(自检 4b 要读它),这里只负责打印。
|
||||
console.log(`\n---------- 只能静态验形态的判据(每次运行都列全,第 2 列不再是死重)----------\n`
|
||||
+ staticDetail);
|
||||
/*
|
||||
* ★★ 自检 4c(dsh 2026-09-19;补 4b 的漏格。pi 报,我同刻复现)。
|
||||
*
|
||||
* 4b 扫的是**源码文本**(剥注释后数 `+ staticDetail);` 的命中)。于是有一个它看不见的变异:
|
||||
* **把这条 `console.log` 包进恒假条件** —— 锚点文本还在源码里,而**播报 0 次**。
|
||||
* 实测(完整跑,同刻 A/B):baseline 播报段出现 **1** 次、变异后 **0** 次,
|
||||
* 而 **4b 两次都报 ok**、红清单**逐条相同**。
|
||||
*
|
||||
* 根因是**自检先于打印**:4b 在 `:1613` 跑(`staticDetail` 定义之后、打印之前),
|
||||
* 打印在 `:3129` ⇒ 它物理上到不了那次调用,只读得了文本。
|
||||
*
|
||||
* ⇒ 修法:这里不猜源码,改**记录这次调用真的往 `console.log` 送了什么**。
|
||||
* 锚点落在「**实际发生的那次调用**」上 —— 这是它的自述够不着的东西(本仓老纪律)。
|
||||
* 包进恒假条件 ⇒ 记录为空 ⇒ 4c 红。
|
||||
* ⚠️ 不能做成 `SELFTESTS` 的一条:快入口 `--only-selftest=` 在 `:2503` 就 `exit` 了,
|
||||
* 那条路径**永远到不了这里**,于是它只会"看不见"而不会红。
|
||||
*/
|
||||
const staticBroadcastSeen = [];
|
||||
{
|
||||
const realLog = console.log;
|
||||
console.log = (...a) => { staticBroadcastSeen.push(a.join(' ')); realLog(...a); };
|
||||
console.log(`\n---------- 只能静态验形态的判据(每次运行都列全,第 2 列不再是死重)----------\n`
|
||||
+ staticDetail);
|
||||
console.log = realLog;
|
||||
}
|
||||
if (!staticBroadcastSeen.some(t => STATIC_ONLY.every(([f, why]) => t.includes(f) && t.includes(why)))) {
|
||||
reds.push('(自检)4c:`STATIC_ONLY` 的播报段**没有真的进 `console.log`**'
|
||||
+ `(只记录到 ${staticBroadcastSeen.length} 次调用)⇒ 第 2 列**仍然没有读者**。`
|
||||
+ '4b 只扫源码文本,"包进恒假条件"它看不见 —— 这条就是补那一格的。');
|
||||
}
|
||||
console.log(`RESULT phase=${PHASE} ${staticDebtLine}${debtsLine} probe=${probeSummary}` +
|
||||
(unknownProbes.length ? `(探针跑不了:${unknownProbes.join('、')} —— 已按到期处理)`
|
||||
: '(只能验形态的判据:到期前提成立就自动变红)') + mutantsLine);
|
||||
|
||||
77
client/harmony/entry/src/main/ets/api/Logout.ets
Normal file
77
client/harmony/entry/src/main/ets/api/Logout.ets
Normal file
@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 退出登录 —— **一处实现,两处入口**。
|
||||
*
|
||||
* 为什么不留在 `SettingsPage.ets` 里:WebUI 的退出按钮在**两个地方**都有 ——
|
||||
* · 宽屏侧栏底部那一簇(`Sidebar.tsx:205-211`,`onClick={logout}`);
|
||||
* · 「我的」页的「登录状态」段(`AccountPage`)。
|
||||
*
|
||||
* 鸿蒙原来只有后一处(`SettingsPage.doLogout`),侧栏没有底簇、也就没有前一处。
|
||||
* 而这两个入口**必须做同一件事**,顺序还不能错:
|
||||
* ① 先注销本机推送 token(否则服务端会继续向这台设备推已登出账号的邮件);
|
||||
* ② 清凭证(`AuthApi.logout` + `clearAll`);
|
||||
* ③ 断开全部 SSE;
|
||||
* ④ `replaceUrl` 回登录页(不是 push —— 退出后不该还能"返回"到已登出的页)。
|
||||
*
|
||||
* 复制一份到侧栏会立刻出现"两个入口行为不一致"的经典问题(推送没注销、
|
||||
* 或残留旧账号导致退出后又被自动恢复)。所以抽到这里。
|
||||
*/
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { ApiClient } from './ApiClient';
|
||||
import { AuthApi } from './AuthApi';
|
||||
import { AccountManager } from './AccountManager';
|
||||
import { SseService } from './SseService';
|
||||
import { PushService } from './PushService';
|
||||
|
||||
/**
|
||||
* 执行完整退出流程并回到登录页。
|
||||
*
|
||||
* 每一步的失败都**不阻断**后面的步骤:退出的语义是"这台设备不再持有这个账号",
|
||||
* 任何一步失败都必须继续把它清干净 —— 中途 return 会留下半退出的状态
|
||||
* (最坏的是凭证没清、下次启动又自动登进去)。
|
||||
*/
|
||||
export async function performLogout(ctx: common.UIAbilityContext | common.Context | undefined): Promise<void> {
|
||||
const client: ApiClient | null = ctx === undefined ? null : ApiClient.getInstance(ctx);
|
||||
|
||||
/* ① 注销推送:失败不阻断退出(本地凭证该清还是要清) */
|
||||
if (ctx !== undefined && client !== null) {
|
||||
try {
|
||||
const push: PushService = PushService.getInstance(ctx as common.UIAbilityContext);
|
||||
await push.unregister(client);
|
||||
} catch (e) {
|
||||
hilog.info(0x0001, 'Logout', '退出时注销推送失败(继续退出):%{public}s', JSON.stringify(e));
|
||||
}
|
||||
}
|
||||
|
||||
/* ② 清凭证:先告诉服务端,再清本机 */
|
||||
if (client !== null) {
|
||||
try {
|
||||
await new AuthApi(client).logout();
|
||||
} catch (e) {
|
||||
hilog.info(0x0001, 'Logout', '服务端 logout 失败(继续清本机):%{public}s', JSON.stringify(e));
|
||||
}
|
||||
}
|
||||
/*
|
||||
* ★ 清**全部**账号,不是只清当前:`LoginPage.aboutToAppear()` 的快速登录
|
||||
* 路径是「`getActiveAccount() !== null` ⇒ 直接 pushUrl MainPage」,
|
||||
* 只要库里还留着一个旧账号,退出就会立刻被自动恢复 —— 退回不去。
|
||||
*/
|
||||
if (ctx !== undefined) {
|
||||
try {
|
||||
await AccountManager.getInstance(ctx).clearAll();
|
||||
} catch (e) {
|
||||
hilog.info(0x0001, 'Logout', 'clearAll 失败:%{public}s', JSON.stringify(e));
|
||||
}
|
||||
}
|
||||
|
||||
/* ③ 断开全部 SSE(留着会叫醒已经登出的账号) */
|
||||
try {
|
||||
SseService.getInstance().disconnectAll();
|
||||
} catch (e) {
|
||||
hilog.info(0x0001, 'Logout', 'disconnectAll 失败:%{public}s', JSON.stringify(e));
|
||||
}
|
||||
|
||||
/* ④ replaceUrl 而不是 pushUrl:退出后不该还能"返回"到已登出的页 */
|
||||
router.replaceUrl({ url: 'pages/LoginPage' });
|
||||
}
|
||||
@ -336,9 +336,23 @@ export struct BackgroundPicker {
|
||||
DimSlider() {
|
||||
Column() {
|
||||
Row() {
|
||||
Text('浓度').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
|
||||
/*
|
||||
* ★★ 2026-09-18 修两处(都是"看着就不对",只在设备上跑才看得见):
|
||||
*
|
||||
* ① 标签:原来是「浓度」,WebUI `BackgroundPicker.tsx:172` 写的是**「压暗」**
|
||||
* (`label="压暗"`,`hint="背景越花,正文越需要一层遮罩才读得动"`)。
|
||||
* "浓度"是个没主语的词,看不出在调什么;"压暗"直接说了这一层在干什么。
|
||||
*
|
||||
* ② 数值:原来 `Text('' + this.bgDim)` —— 屏上印的是 **`56.000000`**。
|
||||
* 原因是这个值来自服务端(或本地缓存)的数值字段,是 number,
|
||||
* 字符串拼接把浮点原样吐出来了。WebUI 的 `Slider` 带 `suffix="%"`,
|
||||
* 显示成 `56%`。⇒ 取整 + 单位。
|
||||
*
|
||||
* ★ 单位要跟 WebUI 一样:压暗是**百分比**(0-80 就是 0%-80%),模糊是 **px**。
|
||||
*/
|
||||
Text('压暗').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
|
||||
Blank()
|
||||
Text('' + this.bgDim).fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
Text(Math.round(this.bgDim) + '%').fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
}
|
||||
.width('100%')
|
||||
Slider({ value: this.bgDim, min: 0, max: 80, step: 1 })
|
||||
@ -354,7 +368,8 @@ export struct BackgroundPicker {
|
||||
Row() {
|
||||
Text('模糊').fontSize(Theme.fontTiny).fontColor(Theme.textMuted)
|
||||
Blank()
|
||||
Text('' + this.bgBlur).fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
/* 同 ①:原先印 `3.000000`;WebUI 是 `suffix="px"` ⇒ `3px` */
|
||||
Text(Math.round(this.bgBlur) + 'px').fontSize(Theme.fontTiny).fontColor(Theme.textSubtle)
|
||||
}
|
||||
.width('100%')
|
||||
Slider({ value: this.bgBlur, min: 0, max: 40, step: 1 })
|
||||
|
||||
@ -205,6 +205,22 @@ export class Theme {
|
||||
*/
|
||||
static readonly navBrandFg: string = '#475569';
|
||||
|
||||
/**
|
||||
* 导航项徽标的**中性档**底色(`navBadgeTone` 的 `'plain'` 档)。
|
||||
*
|
||||
* ★ 对齐 WebUI `Sidebar.tsx:203-204` 的 `bg-chrome-600 text-chrome-100`:
|
||||
* `chrome-600` 在 `index.css:92` 是 **#475569**(深色模式 #3A414E,`index.css:393`)。
|
||||
* `index.css:1097` 还有一条注释:`chrome-600/700` **刻意不透明** ——
|
||||
* 徽标是 15px 的小控件,再叠透明度会让数字掉到 4.46:1,低于 WCAG AA 的 4.5。
|
||||
* 与 `navBrandFg` 同值不是巧合:两者在 WebUI 里都是 `chrome-600`。
|
||||
*
|
||||
* ★★ 2026-09-19 修 bug:底栏与侧栏**两处**的徽标底色原先都写成
|
||||
* `tone === 'perm' ? warnFg : danger` —— 三档被压成两档,
|
||||
* 于是 `'plain'`(联系人数)走了**红**,看起来像“有未读”。
|
||||
* 并排截图一眼可见:WebUI 是石板灰的 4,鸿蒙是红的 3。
|
||||
*/
|
||||
static readonly badgePlain: string = '#475569';
|
||||
|
||||
// ─────────────── 业务语义色:系统没有对应物,继续自己写 ───────────────
|
||||
|
||||
/** 语义色:同意 / 拒绝(对应 WebUI 的 approve/danger) */
|
||||
@ -375,6 +391,19 @@ export class Theme {
|
||||
* 挂载即播,新旧两棵子树不会同时可见。而 ArkUI 这里是 `if/else` 换子树,
|
||||
* 两层会**同时半透明地叠着**,同长看起来就是“闪一下”(实测 6 秒取样确认),
|
||||
* 所以出场必须更快地让位。这是两端机制不同带来的**必要差异**,不是随手拍数。
|
||||
*
|
||||
* ★★ 2026-09-19 重要限定(**这是“最严重的动画问题”的根**):
|
||||
* 本方法给的是 **TransitionEffect**,而 ArkUI 的 `.transition()` 只在
|
||||
* **挂载/卸载**时触发(SDK 原话:"Set the transition effect of component when it
|
||||
* **appears and disappears**")。
|
||||
*
|
||||
* 所以它**只能用于 `if/else` 换子树的那种窗格**(通信/联系人/我的)。
|
||||
* 对**常驻挂载**的窗格(日历:用 `visibility` 控制),`.transition()` **永远不会触发**
|
||||
* —— 切过去就是硬弹、一帧动画都没有,而代码看上去是“挂了动画的”。
|
||||
*
|
||||
* 常驻窗格不能用 TransitionEffect,要用 `animateTo` + 显式的 `@State`
|
||||
* 透明度/位移属性去驱动(与 WebUI 的 `html.view-switch` 重放窗口同一思路)——
|
||||
* 见 `MainPage` 的 `calPaneOpacity` / `calPaneShift`。
|
||||
*/
|
||||
static paneRiseIn(): TransitionEffect {
|
||||
return TransitionEffect.asymmetric(
|
||||
|
||||
@ -50,7 +50,6 @@ export interface NavItem {
|
||||
|
||||
/**
|
||||
* 底栏四枚图标 = 三个内容窗格 + 一个外壳入口,与 WebUI `NarrowNav.tsx` 的四入口一一对应。
|
||||
*
|
||||
* 前**三项**是平级内容窗格:通信(内部三栏:收件箱/发件箱/授权)、日历、联系人。
|
||||
* 第**四项**「我的」也是**内容窗格**(与前三项同一套机制,`currentIndex === 3`)——
|
||||
* 它承载设置/主题/壁纸/账号。★ 2026-09-17 修正:原先它是外壳入口(`route` 走
|
||||
@ -65,6 +64,9 @@ export interface NavItem {
|
||||
* (只读月视图:`pages/CalendarPage.ets`)已经做完,所以入口**现在**上:
|
||||
* 空页签那条理由不再成立,而日历的纯逻辑(`model/Calendar.ts`)本来就是照着
|
||||
* 「顶层是 通信/日历/联系人 三个平级 pane」写判据的。
|
||||
*
|
||||
* ★ `NAV_ITEMS` 是**底栏**的清单(四项)。宽屏侧栏的清单是 `NAV_SIDEBAR_ITEMS`
|
||||
* (三项 + 底部一簇),两者**不可互相替代** —— 理由见那份的注释。
|
||||
*/
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ key: 'comm', label: '通信', iconKey: 'inbox' },
|
||||
@ -144,6 +146,60 @@ export const NAV_CONTENT_COUNT: number = 4;
|
||||
*/
|
||||
export const NAV_CONTENT_ITEMS: NavItem[] = NAV_ITEMS.slice(0, NAV_CONTENT_COUNT);
|
||||
|
||||
/* ─────────────────── 宽屏侧栏(与底栏**不是同一份清单**) ─────────────────── */
|
||||
|
||||
/**
|
||||
* 宽屏**侧栏**的导航项 —— 三项,**不含「我的」**。
|
||||
*
|
||||
* ★★ 2026-09-19 修(用户:「你写的app和webui大面积不符,问题特别大」)。
|
||||
*
|
||||
* 之前侧栏直接用 `NAV_CONTENT_ITEMS`(四项,含「我的」)——
|
||||
* **那是底栏的形状**,不是侧栏的。两套导航在 WebUI 里本来就不同,证据:
|
||||
*
|
||||
* · `Sidebar.tsx:26-44` 的 `navItems` 只有 **3 项**:通信 / 日历 / **联系**;
|
||||
* 「我的」不在这里 —— 它是 `Sidebar.tsx:186` 底部那一簇里的**头像按钮**
|
||||
* (`onClick={() => setViewMode('account')}`),与主题切换、退出并列;
|
||||
* · `NarrowNav.tsx:37-40` 的 `items` 是 3 项 + 第 4 个「我的」按钮
|
||||
* (`NarrowNav.tsx:143`),所以底栏**是**四项。
|
||||
*
|
||||
* 也就是说:**四项是对的,但只对底栏**。侧栏要三项 + 底部一簇。
|
||||
* 我把底栏那份直接 `slice()` 给侧栏用,于是并排一看就多出一个「我的」,
|
||||
* 而 WebUI 那一格是头像。
|
||||
*
|
||||
* ★ 第三项的文案也不同:侧栏是「**联系**」(`Sidebar.tsx:43` `short: '联系'`),
|
||||
* 底栏是「联系人」(`NarrowNav.tsx:39`)。两处各是各的字面量,不是笔误。
|
||||
*/
|
||||
export const NAV_SIDEBAR_ITEMS: NavItem[] = [
|
||||
{ key: 'comm', label: '通信', iconKey: 'inbox' },
|
||||
{ key: 'calendar', label: '日历', iconKey: 'calendar' },
|
||||
{ key: 'contacts', label: '联系', iconKey: 'contacts' }
|
||||
];
|
||||
|
||||
/**
|
||||
* 侧栏第三项在 `NAV_CONTENT_ITEMS` 里的下标(`contacts`)。
|
||||
*
|
||||
* 侧栏只画三项,但点击要选中 `currentIndex`(内容窗格的下标,四项那套)。
|
||||
* 用具名常量而不是就地写 `2`:这一项的顺序要与 `NAV_SIDEBAR_ITEMS` 的构图一致,
|
||||
* 改顺序时只改一处。
|
||||
*/
|
||||
export function sidebarContentIndex(key: string): number {
|
||||
for (let i = 0; i < NAV_CONTENT_ITEMS.length; i++) {
|
||||
if (NAV_CONTENT_ITEMS[i].key === key) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 「我的」内容窗格的下标(侧栏底部头像点它进账户页) */
|
||||
export const ME_PANE_INDEX: number = 3;
|
||||
|
||||
/** 侧栏底色块宽度(vp)—— WebUI `Sidebar.tsx` 的 `w-12 h-12` */
|
||||
export const SIDEBAR_ITEM_SIZE: number = 48;
|
||||
|
||||
/** 侧栏圆角(vp)—— WebUI 的 `rounded-lg` */
|
||||
export const SIDEBAR_ITEM_RADIUS: number = 12;
|
||||
|
||||
/**
|
||||
* 「我的」的路由目标(`SettingsPage` 仍然存在,供别处按需推页)。
|
||||
*
|
||||
|
||||
@ -80,6 +80,28 @@ const REMIND_LABELS: string[] = ['准点', '5 分钟', '15 分钟', '30 分钟',
|
||||
|
||||
/** 周表头高度(vp) */
|
||||
const WEEKDAY_HEIGHT: number = 24;
|
||||
|
||||
/**
|
||||
* 右栏(日程/编辑器)的宽度(vp)—— WebUI `CalendarView.tsx:454` 的 `lg:w-[400px]`。
|
||||
*
|
||||
* ★ 对齐取 400 而不是“看着差不多”:那一栏在 WebUI 里放的是日程卡片与事件表单,
|
||||
* 宽度决定标题能显示多少字。两边不一致会直接表现为“同一条日程一边能读完、一边被截”。
|
||||
*/
|
||||
const CAL_SIDE_WIDTH: number = 400;
|
||||
|
||||
/**
|
||||
* 切成两栏所需的最小可用宽度(vp):左网格(7 列 × `CELL_HEIGHT` 的合理下限)
|
||||
* + 右栏 400 + 分界线 1。
|
||||
*
|
||||
* 7 列每列至少 56vp(低于这个值日期数字与农历就开始挤掉),即 392;
|
||||
* `392 + 400 + 1 = 793`,取整到 **800**。
|
||||
*
|
||||
* ★ 为何不用 `MainPage` 那个 `isWide`(768):那个阈值判的是**整屏**,
|
||||
* 而本页拿到的是**扣掉侧栏与间距后**的宽度(宽屏下少 60+10)。
|
||||
* 两者差着约 70vp,直接用会在某个区间里做出“够宽但仍竖堆”的错误判断。
|
||||
* 真实值由 `.onAreaChange` 量出来,不靠猜。
|
||||
*/
|
||||
const CAL_TWO_PANE_MIN: number = 800;
|
||||
/**
|
||||
* 周起始:**1 = 周一**,与 WebUI 的 `startOfWeek()`(`lib/calendar.ts`,dow===0 时退到上周一)一致。
|
||||
* 两端必须同值 —— 否则同一天在两端的格子位置不同,是跨端最容易被一眼看出来的差异。
|
||||
@ -158,6 +180,27 @@ export struct CalendarPage {
|
||||
@State pickersOpen: boolean = false;
|
||||
/** .ics 导入/导出在飞:两个动作都碰文件与网络,防连点 */
|
||||
@State icsBusy: boolean = false;
|
||||
/**
|
||||
* 本页可用宽度是否够放下**两栏**(左网格 + 右 400vp 面板)。
|
||||
*
|
||||
* ★★ 2026-09-19 新增(用户:「日历页面和webui布局完全不同」)。
|
||||
*
|
||||
* WebUI `CalendarView.tsx:452-457` 的骨架是**左右两栏**:
|
||||
* · 左 `flex-1`:工具条 + 星期头 + 本月网格;
|
||||
* · 右 `w-full lg:w-[400px]`:**常驻面板** —— 默认显示选中那天的日程,
|
||||
* 点新建/编辑时**同一个位置**变成编辑器(不是另盖一页),
|
||||
* 日档时变成小时网格(`DayHoursPane`)。
|
||||
*
|
||||
* 而鸿蒙这边是**单栏竖堆**:工具条 / 星期头 / 网格 / 日程列表 全挤在一列,
|
||||
* 宽屏下右边一大片空白,新建时还把整张日历盖掉 —— 两处都是用户看出来的。
|
||||
*
|
||||
* ★ 为什么本页自己量宽度,而不是让 `MainPage` 把 `isWide` 传下来:
|
||||
* 判断的是**本页容器**的宽度(宽屏下它已经被侧栏 60vp + 间距占掉一截),
|
||||
* 不是屏幕宽度。两者在折叠/转屏时会不一致 —— 那是第二份真相。
|
||||
* 所以用 `.onAreaChange` 量自己的实际宽度(WebUI 的 `useIsNarrow` 量的是视口,
|
||||
* 而它是整页占满,两者等价)。
|
||||
*/
|
||||
@State paneWide: boolean = false;
|
||||
|
||||
private api: CalendarApi | null = null;
|
||||
private offsetMinutes: number = 0;
|
||||
@ -1063,200 +1106,280 @@ export struct CalendarPage {
|
||||
.height('100%')
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
Column() {
|
||||
/**
|
||||
* 左栏:工具条 + 星期头 + 本月网格(对齐 WebUI `CalendarView.tsx:289-448` 的 `gridPane`)。
|
||||
*
|
||||
* ★★ 2026-09-19 抽出来(用户:「日历页面和webui布局完全不同」)。
|
||||
*
|
||||
* WebUI 的日历是**左右两栏**(`CalendarView.tsx:452-457`):
|
||||
* · 左栏 `flex-1`:工具条 + 星期头 + 网格;
|
||||
* · 右栏 `w-full lg:w-[400px]`:**常驻面板**(选中那天的日程 / 事件编辑器)。
|
||||
* 鸿蒙这边原来是**单栏竖堆** —— 网格下面直接跟日程列表,宽屏右半边是空的。
|
||||
* 现在按 WebUI 拆成两个 @Builder,由 `build()` 按可用宽度决定并排还是竖堆
|
||||
* (窄屏仍竖堆 —— WebUI 窄屏走 `NarrowStack` 覆盖式,见 `CalendarView.tsx:444-447`)。
|
||||
*/
|
||||
@Builder
|
||||
GridPane() {
|
||||
/*
|
||||
* 头部:翻页 + 标题 + 视图档 + 新建。
|
||||
*
|
||||
* 对齐 WebUI `CalendarView.tsx:293-370` 那条工具条的分组与顺序:
|
||||
* ‹ › 今天 | 标题 | 月/周/日 | 新建
|
||||
* WebUI 工具条还有导入/导出 .ics 两个入口,鸿蒙这边 2026-09-18 补上
|
||||
* (走系统文件选择器 —— 鸿蒙没有"下载目录",`DocumentViewPicker` 是
|
||||
* 这个平台上唯一能让用户拿到/指定文件的路)。
|
||||
* 这里是**窄屏**,四段挤一行会溢出 —— 拆成两行:
|
||||
* 上行 = 翻页 + 标题;下行 = 今天 + 视图档 + 新建。
|
||||
*/
|
||||
Row() {
|
||||
Button('‹')
|
||||
.fontSize(Theme.fontBody)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.fontColor(Theme.textPrimary)
|
||||
.onClick(() => this.shiftMonth(-1))
|
||||
Text(this.rangeTitle())
|
||||
.fontSize(Theme.fontBody)
|
||||
.fontColor(Theme.textPrimary)
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.Center)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Button('›')
|
||||
.fontSize(Theme.fontBody)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.fontColor(Theme.textPrimary)
|
||||
.onClick(() => this.shiftMonth(1))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 6, right: 6, top: 4, bottom: 0 })
|
||||
|
||||
Row() {
|
||||
Button('今天')
|
||||
.fontSize(Theme.fontSmall)
|
||||
.backgroundColor(Theme.accentSoft)
|
||||
.fontColor(Theme.accentStrong)
|
||||
.onClick(() => this.goToday())
|
||||
Blank()
|
||||
/*
|
||||
* 头部:翻页 + 标题 + 视图档 + 新建。
|
||||
*
|
||||
* 对齐 WebUI `CalendarView.tsx:293-370` 那条工具条的分组与顺序:
|
||||
* ‹ › 今天 | 标题 | 月/周/日 | 新建
|
||||
* WebUI 工具条还有导入/导出 .ics 两个入口,鸿蒙这边 2026-09-18 补上
|
||||
* (走系统文件选择器 —— 鸿蒙没有"下载目录",`DocumentViewPicker` 是
|
||||
* 这个平台上唯一能让用户拿到/指定文件的路)。
|
||||
* 这里是**窄屏**,四段挤一行会溢出 —— 拆成两行:
|
||||
* 上行 = 翻页 + 标题;下行 = 今天 + 视图档 + 新建。
|
||||
* 视图档:**分段式**三个按钮(对齐 WebUI 的 `flex rounded border` 那一组)。
|
||||
* 选中态只换**底色与文字色**(与底栏导航同纪律:不引入额外的形状/指示条)。
|
||||
*/
|
||||
Row() {
|
||||
Button('‹')
|
||||
.fontSize(Theme.fontBody)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.fontColor(Theme.textPrimary)
|
||||
.onClick(() => this.shiftMonth(-1))
|
||||
Text(this.rangeTitle())
|
||||
.fontSize(Theme.fontBody)
|
||||
.fontColor(Theme.textPrimary)
|
||||
ForEach(SCALE_ORDER, (s: CalendarScale) => {
|
||||
Text(scaleLabel(s))
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(this.calScale === s ? Theme.accentFg : Theme.textPrimary)
|
||||
.backgroundColor(this.calScale === s ? Theme.accent : Theme.accentSoft)
|
||||
.textAlign(TextAlign.Center)
|
||||
.width(34)
|
||||
.height(26)
|
||||
.borderRadius(6)
|
||||
.margin({ left: 4 })
|
||||
.onClick(() => this.setScale(s))
|
||||
}, (s: CalendarScale) => `scale${s}`)
|
||||
/*
|
||||
* .ics 导入 / 导出(对齐 WebUI 工具条里那两个图标)。
|
||||
*
|
||||
* 用文字而不是图标:窄屏下这两个动作**低频且后果不可见**
|
||||
* (导出了什么、导进来什么,光看按钮看不出来),文字比一个需要猜的
|
||||
* 小图标省一次试错。WebUI 有 title 提示可悬停,手指没有悬停。
|
||||
*/
|
||||
Text('导入')
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(this.icsBusy ? Theme.textSubtle : Theme.accent)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => { this.importIcs(); })
|
||||
Text('导出')
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(this.icsBusy ? Theme.textSubtle : Theme.accent)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => { this.exportIcs(); })
|
||||
Button('+')
|
||||
.fontSize(Theme.fontBody)
|
||||
.backgroundColor(Theme.accent)
|
||||
.fontColor(Theme.accentFg)
|
||||
.height(26)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => this.openNew())
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 4 })
|
||||
|
||||
/* 周表头:顺序**必须**来自 weekdayLabels(WEEK_START),不许在这里硬编码 */
|
||||
Row() {
|
||||
ForEach(weekdayLabels(WEEK_START), (w: string) => {
|
||||
Text(w)
|
||||
.fontSize(Theme.fontTiny)
|
||||
.fontColor(Theme.textMuted)
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.Center)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Button('›')
|
||||
.fontSize(Theme.fontBody)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.fontColor(Theme.textPrimary)
|
||||
.onClick(() => this.shiftMonth(1))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 6, right: 6, top: 4, bottom: 0 })
|
||||
.height(WEEKDAY_HEIGHT)
|
||||
}, (w: string) => `w${w}`)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row() {
|
||||
Button('今天')
|
||||
.fontSize(Theme.fontSmall)
|
||||
.backgroundColor(Theme.accentSoft)
|
||||
.fontColor(Theme.accentStrong)
|
||||
.onClick(() => this.goToday())
|
||||
Blank()
|
||||
/*
|
||||
* 视图档:**分段式**三个按钮(对齐 WebUI 的 `flex rounded border` 那一组)。
|
||||
* 选中态只换**底色与文字色**(与底栏导航同纪律:不引入额外的形状/指示条)。
|
||||
*/
|
||||
ForEach(SCALE_ORDER, (s: CalendarScale) => {
|
||||
Text(scaleLabel(s))
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(this.calScale === s ? Theme.accentFg : Theme.textPrimary)
|
||||
.backgroundColor(this.calScale === s ? Theme.accent : Theme.accentSoft)
|
||||
.textAlign(TextAlign.Center)
|
||||
.width(34)
|
||||
.height(26)
|
||||
.borderRadius(6)
|
||||
.margin({ left: 4 })
|
||||
.onClick(() => this.setScale(s))
|
||||
}, (s: CalendarScale) => `scale${s}`)
|
||||
/*
|
||||
* .ics 导入 / 导出(对齐 WebUI 工具条里那两个图标)。
|
||||
*
|
||||
* 用文字而不是图标:窄屏下这两个动作**低频且后果不可见**
|
||||
* (导出了什么、导进来什么,光看按钮看不出来),文字比一个需要猜的
|
||||
* 小图标省一次试错。WebUI 有 title 提示可悬停,手指没有悬停。
|
||||
*/
|
||||
Text('导入')
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(this.icsBusy ? Theme.textSubtle : Theme.accent)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => { this.importIcs(); })
|
||||
Text('导出')
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(this.icsBusy ? Theme.textSubtle : Theme.accent)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => { this.exportIcs(); })
|
||||
Button('+')
|
||||
.fontSize(Theme.fontBody)
|
||||
.backgroundColor(Theme.accent)
|
||||
.fontColor(Theme.accentFg)
|
||||
.height(26)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => this.openNew())
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 4 })
|
||||
/*
|
||||
* 月网格:行 ← monthGrid,列 ← 7 格(嵌套 ForEach 的项名必须不同)。
|
||||
*
|
||||
* ★ 外面这层 `Column` 是**为了挂翻月过渡**而加的(原先网格是个裸 `ForEach`)。
|
||||
* 过渡必须挂在**整块网格**的外层容器上:挂在每行上会出现"7 行各滑各的"
|
||||
* 交错效果(每行入场时间几乎同步但微差,看起来像撕裂)。
|
||||
* 键带 `monthKey()` 前缀 ⇒ 月份一变所有键全变 ⇒ 节点重建 ⇒ 过渡必播。
|
||||
*/
|
||||
Column() {
|
||||
ForEach(this.rows(), (row: DayCell[], rowIndex: number) => {
|
||||
Row() {
|
||||
ForEach(row, (cell: DayCell) => {
|
||||
this.DayCellView(cell)
|
||||
}, (cell: DayCell) => `c${cell.iso}#${cell.label}`)
|
||||
}
|
||||
.width('100%')
|
||||
}, (row: DayCell[], rowIndex: number) => `r${this.monthKey()}#${rowIndex}`)
|
||||
}
|
||||
.width('100%')
|
||||
.transition(Theme.calendarSlide(this.slideForward))
|
||||
}
|
||||
|
||||
/* 周表头:顺序**必须**来自 weekdayLabels(WEEK_START),不许在这里硬编码 */
|
||||
Row() {
|
||||
ForEach(weekdayLabels(WEEK_START), (w: string) => {
|
||||
Text(w)
|
||||
.fontSize(Theme.fontTiny)
|
||||
.fontColor(Theme.textMuted)
|
||||
.layoutWeight(1)
|
||||
.textAlign(TextAlign.Center)
|
||||
.height(WEEKDAY_HEIGHT)
|
||||
}, (w: string) => `w${w}`)
|
||||
/**
|
||||
* 右栏:选中那天的日程(对齐 WebUI `DayAgendaPane`,`CalendarView.tsx:743-` ).
|
||||
*
|
||||
* 宽屏下它是**常驻**的(永远有东西显示 —— 至少显示“这一天没有日程”),
|
||||
* 所以点「+」时不会失去“我正在看哪个月”的上下文(WebUI 那条注释的原话)。
|
||||
*/
|
||||
@Builder
|
||||
AgendaPane() {
|
||||
Row() {
|
||||
Text(this.selectedIso)
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(Theme.textMuted)
|
||||
Blank()
|
||||
if (this.loading) {
|
||||
Text('加载中…')
|
||||
.fontSize(Theme.fontTiny)
|
||||
.fontColor(Theme.textSubtle)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 2 })
|
||||
|
||||
if (this.selectedEvents().length === 0) {
|
||||
/*
|
||||
* 月网格:行 ← monthGrid,列 ← 7 格(嵌套 ForEach 的项名必须不同)。
|
||||
*
|
||||
* ★ 外面这层 `Column` 是**为了挂翻月过渡**而加的(原先网格是个裸 `ForEach`)。
|
||||
* 过渡必须挂在**整块网格**的外层容器上:挂在每行上会出现"7 行各滑各的"
|
||||
* 交错效果(每行入场时间几乎同步但微差,看起来像撕裂)。
|
||||
* 键带 `monthKey()` 前缀 ⇒ 月份一变所有键全变 ⇒ 节点重建 ⇒ 过渡必播。
|
||||
* 空态也要 `layoutWeight(1)` —— 原来只有 `padding(16)`,
|
||||
* 于是周/日档下网格只有一行,"这一天没有日程"贴在上面、
|
||||
* 下面留出一大片空白壁纸(实测 09-18:周视图整个下半屏是空的)。
|
||||
* 占满剩余高度后空态居中,三种档位的观感才是同一个版式。
|
||||
*/
|
||||
Column() {
|
||||
ForEach(this.rows(), (row: DayCell[], rowIndex: number) => {
|
||||
Row() {
|
||||
ForEach(row, (cell: DayCell) => {
|
||||
this.DayCellView(cell)
|
||||
}, (cell: DayCell) => `c${cell.iso}#${cell.label}`)
|
||||
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)
|
||||
}
|
||||
.width('100%')
|
||||
}, (row: DayCell[], rowIndex: number) => `r${this.monthKey()}#${rowIndex}`)
|
||||
}, (e: CalendarEvent) => e.event_id)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.width('100%')
|
||||
.transition(Theme.calendarSlide(this.slideForward))
|
||||
/* 末尾让位:最后一条日程要能滚出悬浮条之下(不是缩短列表) */
|
||||
.contentEndOffset(this.navReserve)
|
||||
/* 上下边缘渐隐(2026-09-17 补:原先漏了,同一次滚动里只有部分是渐隐) */
|
||||
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })
|
||||
.padding({ left: 10, right: 10 })
|
||||
}
|
||||
}
|
||||
|
||||
if (this.error.length > 0) {
|
||||
Text(this.error)
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(Theme.danger)
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
/* 选中那天的日程 */
|
||||
Row() {
|
||||
Text(this.selectedIso)
|
||||
.fontSize(Theme.fontSmall)
|
||||
.fontColor(Theme.textMuted)
|
||||
Blank()
|
||||
if (this.loading) {
|
||||
Text('加载中…')
|
||||
.fontSize(Theme.fontTiny)
|
||||
.fontColor(Theme.textSubtle)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 2 })
|
||||
|
||||
if (this.selectedEvents().length === 0) {
|
||||
build() {
|
||||
Stack() {
|
||||
/*
|
||||
* ★★ 2026-09-19 重搭为**左右两栏**(用户:「日历页面和webui布局完全不同」)。
|
||||
*
|
||||
* WebUI `CalendarView.tsx:449-457`:
|
||||
* <div className="cal-panes flex-1 flex">
|
||||
* {gridPane} ← 左:flex-1
|
||||
* <div className="w-full lg:w-[400px] ..."> ← 右:400px 常驻
|
||||
* {sidePane}
|
||||
* </div>
|
||||
* </div>
|
||||
*
|
||||
* 窄屏放不下两栏 ⇒ 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;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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` 是
|
||||
* `<span className="absolute top-1 right-[22%] …">`
|
||||
* —— `absolute` 意味着**脱离文档流**,浮在图标右上角、不占布局位置。
|
||||
*
|
||||
* ★ 锚在**图标**上,不是锚在整个项上:项被 `layoutWeight(1)` 撑开、宽度随屏况变,
|
||||
* 而 WebUI 那个 `right-[22%]` 本就是为了落到图标肩上试出来的数。锚在图标上
|
||||
* 就不需要百分比了 —— 图标多宽,徽标就贴它多宽。
|
||||
*
|
||||
* ★ 用 `Stack({ alignContent })` 而不是 `.justifyContent()`:
|
||||
* **Stack 没有 justifyContent/alignItems**(对齐只能走构造参数),
|
||||
* 写在链上直接编译报错。这一条我刚刚撞过
|
||||
* (`arkts-grammar-standards` 的 Stack 那行写明了)。
|
||||
*/
|
||||
Stack({ alignContent: Alignment.TopEnd }) {
|
||||
AmIcon({
|
||||
iconName: item.iconKey,
|
||||
iconSize: 24,
|
||||
iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg
|
||||
})
|
||||
/* 徽标:见 NavBadge(口径与 WebUI Sidebar 的 badge/badgeTone 一致) */
|
||||
this.NavBadge(item.key)
|
||||
}
|
||||
.width(30).height(26)
|
||||
|
||||
Text(item.label)
|
||||
.fontSize(10)
|
||||
.lineHeight(12)
|
||||
.fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)
|
||||
.margin({ top: 3 })
|
||||
/* 徽标:见 NavBadge(口径与 WebUI Sidebar 的 badge/badgeTone 一致) */
|
||||
this.NavBadge(item.key)
|
||||
}
|
||||
/*
|
||||
* 命中区:**显式给下限**(44vp),不靠"看起来够大"。
|
||||
@ -2475,25 +2646,27 @@ struct MainPage {
|
||||
return;
|
||||
}
|
||||
/*
|
||||
* ★ 换窗格要**真的**有过渡:`animateTo` 只开了一个动画窗口,
|
||||
* 被换掉的那棵子树自己不声明 `.transition(...)` 的话什么都不会动
|
||||
* (实测:只包 `animateTo` 和硬切看不出差别)。
|
||||
* 过场动画挂在**内容容器**上(见 build 里各分支的 `.transition(Theme.paneRiseIn())`),
|
||||
* 这里只负责把 index 变掉;系统在动画窗口内跑那条 transition。
|
||||
* ★★ 2026-09-19:日历那一支要先把入场进度**瞬回 0**,再在动画窗口里推到 1。
|
||||
*
|
||||
* ★★ 2026-09-18 更正:这里原先用 `durBase(180)` + `easeOutSoft(0.22,1,0.36,1)`,
|
||||
* 并写着"与 WebUI 的 .pane-rise 同一根尺子" —— **那两个数都不是动画值**。
|
||||
* 实测 WebUI:`.rise-in`/`.pane-rise` 都是 **150ms + cubic-bezier(0.22, 0.61, 0.36, 1)**;
|
||||
* 180ms 与 `(0.22,1,0.36,1)` 只用在**壁纸淡入**与**控件变色 transition** 上。
|
||||
* 所以旧代码的窗格入场比 WebUI 慢 30ms 且曲线偏软。
|
||||
* 现在两边都用 `durRise` + `easeRise`,与 `paneRiseIn()` 内那份保持一致
|
||||
* (同一场过场被两个地方描述,数值必须同源 —— 所以都引令牌,不写数)。
|
||||
* ★ 顺序是这个修法的**全部关键**:reset 必须在 `animateTo` **外面**先做。
|
||||
* 写进 `animateTo` 的回调里是错的 —— 那两句在同一个 handler、同一帧里执行,
|
||||
* 渲染层只看得见最终值 1,起点也是 1 ⇒ **动画退化成一次瞬移**(等于没修)。
|
||||
* 先 reset(这一帧就会以 0 渲染)再 `animateTo` ⇒ 起点是 0、终点是 1 ⇒ 真的播。
|
||||
*
|
||||
* 其余三个 pane 是 `if/else` 换子树 —— `.transition(paneRiseIn())` 在换的那一刻
|
||||
* 自然就播了。日历是**常驻**的(`visibility` 控制),它的 `.transition()` 永远不触发,
|
||||
* 所以只有它需要这里显式驱动。
|
||||
*/
|
||||
if (target === 1) {
|
||||
this.calPaneIn = 0;
|
||||
}
|
||||
this.getUIContext().animateTo({
|
||||
duration: Theme.durRise,
|
||||
curve: Theme.easeRise
|
||||
}, () => {
|
||||
this.currentIndex = target;
|
||||
/* 日历:同一动画窗口内推到 1(与其余 pane 的 transition 同源同时长) */
|
||||
this.calPaneIn = 1;
|
||||
});
|
||||
})
|
||||
}
|
||||
@ -2630,12 +2803,26 @@ struct MainPage {
|
||||
currentIndex: this.currentIndex,
|
||||
bgActive: this.bgActive,
|
||||
windowInsets: this.windowInsets,
|
||||
accountLabel: this.accountLabel,
|
||||
isDark: this.isDarkNow,
|
||||
/*
|
||||
* 「我的」走 `onSelect(3)` 而不是推页 —— 与底栏同一套窗格机制。
|
||||
* 原先是 `onSettings: pushUrl('pages/SettingsPage')`,那正是用户
|
||||
* 2026-09-17 报过的"我的页面没有遵守 nav 导航规则"(侧栏这一支当时漏改)。
|
||||
*/
|
||||
onSelect: (index: number) => { this.currentIndex = normalizeNavIndex(index); }
|
||||
onSelect: (index: number) => { this.currentIndex = normalizeNavIndex(index); },
|
||||
/*
|
||||
* 侧栏底部的主题快捷开关:对齐 WebUI `ThemePicker.tsx:23-43` 的
|
||||
* `ThemeToggleButton` —— **在 light/dark 之间直接翻转**,
|
||||
* 从 `system` 翻转时落到"与当前生效值相反"的**显式值**
|
||||
* (不是回 system:那可能毫无变化,让人以为按钮坏了;
|
||||
* 这条理由 WebUI 注释里写明了,见 `themeStore.ts:79-88`)。
|
||||
*/
|
||||
onToggleTheme: () => { this.toggleTheme(); },
|
||||
/* 退出:与「我的」页那个按钮走**同一个** `performLogout` */
|
||||
onLogout: () => {
|
||||
performLogout(this.getUIContext().getHostContext()).catch(() => {});
|
||||
}
|
||||
})
|
||||
}
|
||||
Column() {
|
||||
@ -2679,7 +2866,22 @@ struct MainPage {
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.visibility(this.currentIndex === 1 ? Visibility.Visible : Visibility.None)
|
||||
.transition(Theme.paneRiseIn())
|
||||
/*
|
||||
* ★★ 2026-09-19 修(用户:「最严重的动画问题你一点也不该改」)。
|
||||
*
|
||||
* 这里原先写的是 `.transition(Theme.paneRiseIn())` —— **一帧也不会播**:
|
||||
* `.transition()` 只在**挂载/卸载**时触发(SDK 原话 "when it appears and
|
||||
* disappears"),而本窗格是**常驻**的(上面 `visibility` 那段说了两个理由),
|
||||
* 永不重挂载 ⇒ 切到日历永远硬弹。代码看着“挂了动画”,实际什么都没有。
|
||||
*
|
||||
* 改成用 `animateTo` 驱动两个显式属性(见 `calPaneIn` 的注释;
|
||||
* 这也是 WebUI 那个“重建触发窗口”思路在 ArkUI 上的对应物)。
|
||||
* `opacity` 与 `translate` 都走**合成器**,不触发 layout/paint ——
|
||||
* WebUI 那条 `rise-in` 的注释专门说了要“只动 opacity + transform
|
||||
* (否则 2026-09-15 用户报过动画卡顿)”。
|
||||
*/
|
||||
.opacity(this.calPaneIn)
|
||||
.translate({ y: (1 - this.calPaneIn) * Theme.riseInOffset })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
@ -27,6 +27,7 @@ import { isAdminRole } from '../model/AdminUsers';
|
||||
import { ApiBaseCheck, validateApiBase } from '../model/ApiBase';
|
||||
import { BackgroundPicker } from '../common/BackgroundPicker';
|
||||
import { PushService } from '../api/PushService';
|
||||
import { performLogout } from '../api/Logout';
|
||||
import { PushTokenList } from '../model/PushContract';
|
||||
|
||||
@Component
|
||||
@ -319,35 +320,14 @@ export struct SettingsPane {
|
||||
* 理由原话:「低频且不可逆的动作,与密码、密钥同属『账号自身』」。
|
||||
* 鸿蒙侧原来**全 App 没有退出口** —— `AuthApi.logout()` 写了但没人调。
|
||||
*
|
||||
* 顺序有讲究:先注销本机推送 token(否则服务端会继续向这台设备推
|
||||
* 已经登出的账号的邮件),再清凭证,最后回登录页。
|
||||
* ★ 2026-09-19:流程抽到 `api/Logout.ets` 的 `performLogout()` ——
|
||||
* 宽屏侧栏底部那一簇也有退出键(WebUI `Sidebar.tsx:205-211`),
|
||||
* 两个入口必须做的是**同一件事**(尤其是“先注销推送 token”那一步),
|
||||
* 复制一份到这里就立刻会出现“两个入口行为不一致”。
|
||||
*/
|
||||
async doLogout(): Promise<void> {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
const client: ApiClient | null = this.client;
|
||||
if (ctx !== undefined && client !== null) {
|
||||
/* 注销推送:失败不阻断退出(本地凭证该清还是要清) */
|
||||
try {
|
||||
const push: PushService = PushService.getInstance(ctx as common.UIAbilityContext);
|
||||
await push.unregister(client);
|
||||
} catch (e) {
|
||||
hilog.info(0x0001, 'SettingsPage', '退出时注销推送失败(继续退出):%{public}s', JSON.stringify(e));
|
||||
}
|
||||
}
|
||||
if (client !== null) {
|
||||
await new AuthApi(client).logout();
|
||||
}
|
||||
/*
|
||||
* ★ 清**全部**账号,不是只清当前:`LoginPage.aboutToAppear()` 的快速登录
|
||||
* 路径是「`getActiveAccount() !== null` ⇒ 直接 pushUrl MainPage」,
|
||||
* 只要库里还留着一个旧账号,退出就会立刻被自动恢复 —— 退回不去。
|
||||
*/
|
||||
if (this.acctMgr !== null) {
|
||||
await this.acctMgr.clearAll();
|
||||
}
|
||||
SseService.getInstance().disconnectAll();
|
||||
/* replaceUrl 而不是 pushUrl:退出后不该还能“返回”到已登出的设置页 */
|
||||
this.getUIContext().getRouter().replaceUrl({ url: 'pages/LoginPage' });
|
||||
await performLogout(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 补一条**实测口径**(差点被我读错):
|
||||
|
||||
@ -3,16 +3,17 @@ module github.com/agentmail/gateway
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/6tail/lunar-go v1.4.6
|
||||
github.com/go-chi/chi/v5 v5.3.2
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/sys v0.47.0
|
||||
modernc.org/sqlite v1.57.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/6tail/lunar-go v1.4.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@ -21,7 +22,6 @@ require (
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
Reference in New Issue
Block a user