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:
@ -203,7 +203,14 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
|
||||
// 内容让位:让出的高度必须够(否则最后一行压在条底下 —— 看得见、点不到)
|
||||
assert.ok(N.NAV_CONTENT_RESERVE >= N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM,
|
||||
`内容让位(${N.NAV_CONTENT_RESERVE})必须 ≥ 条高 + 离底留白(${N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM})`);
|
||||
assert.match(main, /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/, '内容底部要让出这段高度');
|
||||
/*
|
||||
* ★ 2026-09-15:padding 现在是**条件式**(宽屏变 0、窄屏取 NAV_CONTENT_RESERVE)。
|
||||
* 宽屏有侧栏图标轨、没有底部条,所以内容不再需要让位 —— 但窄屏那条老规矩仍然在。
|
||||
* 断言匹配两种形态:旧的直接取值、新的三元式(宽屏取 0)。
|
||||
*/
|
||||
const padOk = /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/.test(main)
|
||||
|| /\.padding\(\{\s*bottom:\s*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/.test(main);
|
||||
assert.ok(padOk, '内容底部要让出这段高度(窄屏取 NAV_CONTENT_RESERVE;宽屏可变 0)');
|
||||
/*
|
||||
* 让位要生效在**内容**上,不是条上(条自己 padding 不解决遮挡)。
|
||||
*
|
||||
@ -212,7 +219,8 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
|
||||
* 判的是"同一件事",红的原因却只是"变长了",正是本仓库反复记的窗口式判据。
|
||||
* 改成**按花括号配对取正文**:内容层那个 Column 的 `{...}` 里必须出现让位。
|
||||
*/
|
||||
const contentOpen = main.indexOf('Column() {\n if (this.currentIndex === 0)');
|
||||
const contentM = /Column\(\) \{\n\s*if \(this\.currentIndex === 0\)/.exec(main);
|
||||
const contentOpen = contentM ? contentM.index : -1;
|
||||
assert.ok(contentOpen > 0, '要能找到挂载内容的那层 Column');
|
||||
// `.padding(...)` 是**链在 `}` 之后**的,所以不能只看 {} 里面 —— 从配对结束处往后取到 NavBar 之前
|
||||
const braceAt = main.indexOf('{', contentOpen);
|
||||
@ -224,8 +232,8 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
|
||||
}
|
||||
assert.ok(end > 0, '内容层的花括号要闭合');
|
||||
const chain = main.slice(end, main.indexOf('this.NavBar()', end));
|
||||
assert.match(chain, /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/,
|
||||
'让位要加在挂载内容的那层上(链在那个 Column 的 } 之后)');
|
||||
assert.match(chain, /\.padding\(\{\s*bottom:\s*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/,
|
||||
'让位要加在挂载内容的那层上(链在那个 Column 的 } 之后),宽屏变 0、窄屏取 NAV_CONTENT_RESERVE');
|
||||
});
|
||||
|
||||
test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且平级项是 通信/日历/联系人', () => {
|
||||
@ -312,10 +320,10 @@ test('⑤ 选中态只换颜色:图标与文字都变色,且不引入背景/
|
||||
const hits = itemCode.match(active) || [];
|
||||
assert.equal(hits.length, 2,
|
||||
`图标与文字都要过同一个选中三元式(现在 ${hits.length} 处)—— 只给文字上色就是"选中了一半"`);
|
||||
// 图标(item.icon)与文字(item.label)各自紧跟着那个三元式
|
||||
const icon = itemCode.slice(itemCode.indexOf('Text(item.icon)'), itemCode.indexOf('Text(item.label)'));
|
||||
assert.match(icon, /fontColor\(this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg\)/,
|
||||
'图标的 fontColor 必须跟着选中态走');
|
||||
// 图标(AmIcon 的 iconColor)与文字(Text 的 fontColor)各自紧跟着那个三元式
|
||||
const icon = itemCode.slice(itemCode.indexOf('AmIcon({'), itemCode.indexOf('Text(item.label)'));
|
||||
assert.match(icon, /iconColor:\s*this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/,
|
||||
'图标的 iconColor 必须跟着选中态走');
|
||||
// 反面:选中态不得靠形状表达(背景 / 圆角块 / 下划线元素)
|
||||
assert.ok(!/backgroundColor\([^)]*currentIndex/.test(itemCode),
|
||||
'选中态不许改背景色("变色即可",形状是另一套语言)');
|
||||
@ -331,18 +339,18 @@ test('⑤ 选中态只换颜色:图标与文字都变色,且不引入背景/
|
||||
|
||||
/** ⑤ 的变异自检:把图标那行退回"只给文字上色",判据必须红。 */
|
||||
test('★ ⑤ 变异自检:图标不上色(改造前的写法)必须被判红', () => {
|
||||
const oldIconLine = 'Text(item.icon).fontSize(20)';
|
||||
const oldIconLine = 'AmIcon({ iconName: item.iconKey, iconSize: 22 })';
|
||||
const itemCode = `Column() {\n ${oldIconLine}\n Text(item.label)\n .fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)\n }`;
|
||||
const hits = itemCode.match(/this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g) || [];
|
||||
assert.equal(hits.length, 1, '旧写法只有 label 上色 ⇒ ⑤ 的"必须两处"会命中它');
|
||||
/*
|
||||
* 判定窗口必须与 ⑤ **完全同一条规则**:从 `Text(item.icon)` 切到下一个 `Text(`。
|
||||
* 判定窗口必须与 ⑤ **完全同一条规则**:从 `AmIcon({` 切到下一个 `Text(`。
|
||||
* 我第一版在这里写的是 `Text\(item\.icon\)[\s\S]{0,80}fontColor` —— 那个窗口
|
||||
* 把**下一行 label 的** fontColor 也圈进来了,于是"旧写法没有图标上色"这条
|
||||
* 自检自己绿了(正是本仓库反复记的"窗口式判据")。判据被它咬了一次。
|
||||
*/
|
||||
const icon = itemCode.slice(itemCode.indexOf('Text(item.icon)'), itemCode.indexOf('Text(', itemCode.indexOf('Text(item.icon)') + 1));
|
||||
assert.ok(!/fontColor/.test(icon), `旧写法里图标没有 fontColor ⇒ ⑤ 的图标那一半会命中它(切片=${JSON.stringify(icon)})`);
|
||||
const icon = itemCode.slice(itemCode.indexOf('AmIcon({'), itemCode.indexOf('Text(', itemCode.indexOf('AmIcon({') + 1));
|
||||
assert.ok(!/iconColor:/.test(icon), `旧写法里图标没有 iconColor ⇒ ⑤ 的图标那一半会命中它(切片=${JSON.stringify(icon)})`);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
86
client/electron/test/harmony-push-optin.test.mjs
Normal file
86
client/electron/test/harmony-push-optin.test.mjs
Normal 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, /服务器未配置推送/, '服务器没配通道时要说清(否则用户开了以为有用)');
|
||||
});
|
||||
81
client/electron/test/harmony-widescreen.test.mjs
Normal file
81
client/electron/test/harmony-widescreen.test.mjs
Normal file
@ -0,0 +1,81 @@
|
||||
// 宽屏侧栏图标轨(WideSidebar)判据 —— 一比一复刻 WebUI 的 `Sidebar`(60px 图标轨)。
|
||||
//
|
||||
// WebUI 的 `Sidebar`(components/Sidebar.tsx)是 60px 宽的图标列:
|
||||
// 品牌标(顶) / 通信·日历·联系人(中) / 设置(底)
|
||||
// 选中态 = 图标+文字变色 + 品牌浅底,**不加背景块/指示条**。
|
||||
//
|
||||
// 鸿蒙侧原来是**没有**宽屏布局的(只有底部导航条),WideSidebar 补的就是
|
||||
// "宽屏模式"这半:≥768vp 时 MainPage 挂侧栏、藏底部条(见 MainPage.build 的 onAreaChange)。
|
||||
// 这条判据钉的是 Sidebar 自己的形状:宽 60vp、三项导航 + 设置、选中态只换颜色。
|
||||
|
||||
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));
|
||||
|
||||
/** 取某个成员/方法正文(按行切到下一个成员声明) */
|
||||
function memberBody(src, signature) {
|
||||
const lines = src.split('\n');
|
||||
const start = lines.findIndex(l => l.includes(signature));
|
||||
assert.ok(start > 0, `要能找到 ${signature}`);
|
||||
let stop = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^ (@Builder|build\(|NavItemBuilder\()/.test(lines[i])) { stop = i; break; }
|
||||
}
|
||||
return lines.slice(start, stop).join('\n');
|
||||
}
|
||||
|
||||
test('① 宽度:SIDEBAR_WIDTH = 60vp(WebUI 的 `w-[60px]` 是同一数字)', () => {
|
||||
const sidebar = read('pages/WideSidebar.ets');
|
||||
const code_ = stripComments(sidebar);
|
||||
assert.match(code_, /export const SIDEBAR_WIDTH: number = 60\s*;/, '要导出 60 这个常量');
|
||||
const main = read('pages/MainPage.ets');
|
||||
assert.match(main, /WideSidebar\(/, 'MainPage 要真正挂 WideSidebar');
|
||||
});
|
||||
|
||||
test('② 三项导航 + 设置按钮:内容与 WebUI Sidebar 对齐', () => {
|
||||
const sidebar = read('pages/WideSidebar.ets');
|
||||
// NAV_ITEMS 遍历(与底部导航同一份清单 —— 加项两边同时出现)
|
||||
assert.match(sidebar, /ForEach\(NAV_ITEMS,/, '要遍历 NAV_ITEMS(与底部导航同一清单)');
|
||||
// 品牌标 + 三项导航 + 设置(与 WebUI:brand / nav / settings 的分组一致)
|
||||
assert.match(sidebar, /品牌标/, '要有品牌标(注释里的分组依据)');
|
||||
assert.match(sidebar, /iconName: 'person'/, '设置按钮要用 person 图标(WebUI 侧沿用的同一枚)');
|
||||
assert.match(sidebar, /onSettings/, '设置按钮要回调 onSettings(点击后由 MainPage 路由)');
|
||||
});
|
||||
|
||||
test('③ 选中态只换颜色(与底部导航同一套纪律)', () => {
|
||||
const sidebar = read('pages/WideSidebar.ets');
|
||||
const code_ = stripComments(sidebar);
|
||||
// 图标与文字都过同一个选中三元式
|
||||
const active = /this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g;
|
||||
const hits = code_.match(active) || [];
|
||||
assert.equal(hits.length, 2,
|
||||
`图标与文字都要过同一个选中三元式(现在 ${hits.length} 处)`);
|
||||
// 不加背景块 / 指示条
|
||||
assert.ok(!/currentIndex[^{;]{0,40}backgroundColor/.test(code_),
|
||||
'选中态不许改背景色(与底部导航同一纪律:变色即可)');
|
||||
});
|
||||
|
||||
test('④ MainPage 接线:宽屏才挂侧栏、宽屏藏底部条、断点 768', () => {
|
||||
const main = read('pages/MainPage.ets');
|
||||
const code_ = stripComments(main);
|
||||
// 宽屏分支:isWide 时才渲染 WideSidebar
|
||||
assert.match(main, /if \(this\.isWide\) \{/, '侧栏要包在 isWide 分支里');
|
||||
assert.match(code_, /WideSidebar\(\{/, '侧栏要真挂在 build 里(剥注释后仍存在)');
|
||||
// 窄屏 x 宽屏:底部条只要窄屏有 —— 两半都是条件式的,不是同时出现
|
||||
assert.match(main, /if \(!this\.isWide\) \{/, '底部导航条要包在 !isWide 分支里');
|
||||
const both = /if \(this\.isWide\) \{[\s\S]*?if \(!this\.isWide\)/.test(main);
|
||||
assert.ok(both, 'isWide 与 !isWide 两分支要在同一个结构里(互斥)');
|
||||
// 断点检测
|
||||
assert.match(code_, /onAreaChange/, '要用 onAreaChange 实时检测窗口宽度');
|
||||
assert.match(code_, />=\s*768/s, '断点是 768(与 WebUI 的 lg 断点同一档)');
|
||||
// let为0(宽屏没有底部条,内容 padding 归零)
|
||||
assert.match(code_, /bottom:\s*this\.isWide \? 0 : NAV_CONTENT_RESERVE/, '宽屏内容 padding 要归零(没有底部条了)');
|
||||
});
|
||||
Reference in New Issue
Block a user