ke: 鸿蒙 UI 整改:emoji→AmIcon 图标、宽屏侧栏、Md 第三方库、通知可配置项

- common/Icons.ets(生成器产出):43 枚与 WebUI icons.tsx 同几何的 Path 图标
- NavItems.ts:icon(emoji) → iconKey,与 WebUI 导航同图标
- 全页面 emoji 清除(空态改单行灰字,按钮/头像/刷新等换 AmIcon)
- MailDetailPage 接入 @luvi/lv-markdown-in(第三方,替代手写解析器)
- WideSidebar + MainPage isWide(≥768vp):宽屏侧栏 60vp 图标轨、藏底部条、onAreaChange 断点
- 通知=可配置项(默认关):PushService.isEnabled/setEnabled + reportToken 最前 gate (关=不取token/不弹权限/不打网关)+ SettingsPage Toggle + 服务端通道状态提示
- EntryAbility.onCreate 解析 want:冷启点通知也能路由(原来只有 onNewWant)
- 判据:harmony-widescreen(4) + harmony-push-optin(4) 新增;harmony-nav 同步 iconKey/条件 padding
This commit is contained in:
2026-09-16 07:46:52 +08:00
parent 9c6e9c66ad
commit cfe7808af4
19 changed files with 766 additions and 89 deletions

View File

@ -0,0 +1,86 @@
// 通知开关判据 —— 钉「接入系统通知」**必须是可配置项**(用户强调多次,非可选)。
//
// 用户原话:「还有我强调多少遍了,接入系统通知是一个可配置项,因为用户可以自由的部署自己的
// agentmail」。
//
// 自部署常态:服务器没有配置华为推送凭证。关着 = 不取 token、不请求系统通知权限、
// 不上报、不打网关 —— 零开销、不弹任何东西、不阻塞启动。这条判据钉的就是这套"开关必须存在
// 且默认关、关着要走零路径"的契约。
//
// 四条:
// ① 默认关(契约常量 + PushService.isEnabled 读不到即 false);
// ② reportToken 在**最前** gate(关 ⇒ getToken 都不调 —— 否则零开销就不成立);
// ③ SettingsPage 有 Toggle 且接了回调(用户能看见开关);
// ④ EntryAbility 不因开关关而报错(gate 在 PushService 内部 ⇒ onCreate 不用改)。
import { code, prose, stripComments } from './lib/read.mjs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
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');
const read = p => prose(join(HARMONY_ETS, p));
const C = await import(join(HARMONY_ETS, 'model/PushContract.ts'));
test('① 契约默认关:DEFAULT_PUSH_ENABLED = false(自部署零开销的契约)', () => {
assert.equal(C.DEFAULT_PUSH_ENABLED, false,
'默认必须关 —— 自部署用户没配服务端推送凭证时不应被默认裹进推送链');
// 标签:关 ⇒ 'off'、开 ⇒ 'on'(口径稳定,给设置页用)
assert.equal(C.pushEnabledLabel(false), 'off');
assert.equal(C.pushEnabledLabel(true), 'on');
});
test('② PushService.isEnabled 读不到即 false(默认关落点) + setEnabled 存在', () => {
const svc = read('api/PushService.ets');
const code_ = stripComments(svc);
assert.match(code_, /static isEnabled\(context: common\.Context\): boolean \{/,
'要有静态 isEnabled(ability 阶段 onCreate 要读它,不能是实例方法)');
assert.match(code_, /return v === true;/,
'读到 true 才返回 true —— 其它(首次/异常)都 false(默认关)');
assert.match(code_, /static setEnabled\(context: common\.Context, enabled: boolean\): void \{/,
'要有静态 setEnabled(设置页 Toggle 回调用)');
});
test('③ reportToken 在最前 gate(关 ⇒ getToken 都不调 ⇒ 零开销)', () => {
const svc = read('api/PushService.ets');
const code_ = stripComments(svc);
/*
* 取 reportToken 的正文(从方法签名到下一个 async/方法声明),
* 判"开关检查在 getToken 之前" —— 这是"零开销"的关键:
* 关时根本不碰 Push Kit(不取 token、不触网络/权限路径)。
*/
const lines = code_.split('\n');
const start = lines.findIndex(l => /async reportToken\(/.test(l));
assert.ok(start > 0, '要能找到 reportToken');
let stop = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^\s*(async |private |static )\w/.test(lines[i]) || /^}\s*$/.test(lines[i])) {
// 粗略:下一段方法或闭合 — 取 200 行作截断
stop = Math.min(i + 1, start + 200);
break;
}
}
const body = lines.slice(start, stop).join('\n');
const gateAt = body.indexOf('PushService.isEnabled');
const getTokenAt = body.indexOf('this.getToken()');
assert.ok(gateAt > 0, 'reportToken 里要有 isEnabled gate');
assert.ok(getTokenAt > 0, 'reportToken 里要有 getToken');
assert.ok(gateAt < getTokenAt,
'开关 gate 必须在 getToken 之前 —— 否则关时仍会取 token(零开销不成立)');
});
test('④ SettingsPage 有 Toggle 且接了 togglePush 回调(用户能看见开关)', () => {
const settings = read('pages/SettingsPage.ets');
assert.match(settings, /Toggle\(\{ type: ToggleType\.Switch, isOn: this\.pushEnabled \}\)/,
'设置页要有 Toggle(Switch) 绑定 pushEnabled');
assert.match(settings, /togglePush\(isOn\)/, 'Toggle 的 onChange 要调 togglePush');
assert.match(settings, /PushService\.isEnabled/, 'aboutToAppear 要读一次开关回填');
assert.match(settings, /PushService\.setEnabled/, 'togglePush 要写持久化');
// 状态提示(服务器未配置推送时如实告诉用户)
assert.match(settings, /服务器未配置推送/, '服务器没配通道时要说清(否则用户开了以为有用)');
});