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

@ -203,7 +203,14 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
// 内容让位:让出的高度必须够(否则最后一行压在条底下 —— 看得见、点不到) // 内容让位:让出的高度必须够(否则最后一行压在条底下 —— 看得见、点不到)
assert.ok(N.NAV_CONTENT_RESERVE >= N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM, 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})`); `内容让位(${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 不解决遮挡)。 * 让位要生效在**内容**上,不是条上(条自己 padding 不解决遮挡)。
* *
@ -212,7 +219,8 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
* 判的是"同一件事",红的原因却只是"变长了",正是本仓库反复记的窗口式判据。 * 判的是"同一件事",红的原因却只是"变长了",正是本仓库反复记的窗口式判据。
* 改成**按花括号配对取正文**:内容层那个 Column 的 `{...}` 里必须出现让位。 * 改成**按花括号配对取正文**:内容层那个 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'); assert.ok(contentOpen > 0, '要能找到挂载内容的那层 Column');
// `.padding(...)` 是**链在 `}` 之后**的,所以不能只看 {} 里面 —— 从配对结束处往后取到 NavBar 之前 // `.padding(...)` 是**链在 `}` 之后**的,所以不能只看 {} 里面 —— 从配对结束处往后取到 NavBar 之前
const braceAt = main.indexOf('{', contentOpen); const braceAt = main.indexOf('{', contentOpen);
@ -224,8 +232,8 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
} }
assert.ok(end > 0, '内容层的花括号要闭合'); assert.ok(end > 0, '内容层的花括号要闭合');
const chain = main.slice(end, main.indexOf('this.NavBar()', end)); const chain = main.slice(end, main.indexOf('this.NavBar()', end));
assert.match(chain, /\.padding\(\{\s*bottom:\s*NAV_CONTENT_RESERVE\s*\}\)/, assert.match(chain, /\.padding\(\{\s*bottom:\s*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/,
'让位要加在挂载内容的那层上(链在那个 Column 的 } 之后)'); '让位要加在挂载内容的那层上(链在那个 Column 的 } 之后),宽屏变 0、窄屏取 NAV_CONTENT_RESERVE');
}); });
test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且平级项是 通信/日历/联系人', () => { test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且平级项是 通信/日历/联系人', () => {
@ -312,10 +320,10 @@ test('⑤ 选中态只换颜色:图标与文字都变色,且不引入背景/
const hits = itemCode.match(active) || []; const hits = itemCode.match(active) || [];
assert.equal(hits.length, 2, assert.equal(hits.length, 2,
`图标与文字都要过同一个选中三元式(现在 ${hits.length} 处)—— 只给文字上色就是"选中了一半"`); `图标与文字都要过同一个选中三元式(现在 ${hits.length} 处)—— 只给文字上色就是"选中了一半"`);
// 图标(item.icon)与文字(item.label)各自紧跟着那个三元式 // 图标(AmIcon 的 iconColor)与文字(Text 的 fontColor)各自紧跟着那个三元式
const icon = itemCode.slice(itemCode.indexOf('Text(item.icon)'), itemCode.indexOf('Text(item.label)')); const icon = itemCode.slice(itemCode.indexOf('AmIcon({'), itemCode.indexOf('Text(item.label)'));
assert.match(icon, /fontColor\(this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg\)/, assert.match(icon, /iconColor:\s*this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/,
'图标的 fontColor 必须跟着选中态走'); '图标的 iconColor 必须跟着选中态走');
// 反面:选中态不得靠形状表达(背景 / 圆角块 / 下划线元素) // 反面:选中态不得靠形状表达(背景 / 圆角块 / 下划线元素)
assert.ok(!/backgroundColor\([^)]*currentIndex/.test(itemCode), assert.ok(!/backgroundColor\([^)]*currentIndex/.test(itemCode),
'选中态不许改背景色("变色即可",形状是另一套语言)'); '选中态不许改背景色("变色即可",形状是另一套语言)');
@ -331,18 +339,18 @@ test('⑤ 选中态只换颜色:图标与文字都变色,且不引入背景/
/** ⑤ 的变异自检:把图标那行退回"只给文字上色",判据必须红。 */ /** ⑤ 的变异自检:把图标那行退回"只给文字上色",判据必须红。 */
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 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) || []; const hits = itemCode.match(/this\.currentIndex === index \? Theme\.navFgActive : Theme\.navFg/g) || [];
assert.equal(hits.length, 1, '旧写法只有 label 上色 ⇒ ⑤ 的"必须两处"会命中它'); assert.equal(hits.length, 1, '旧写法只有 label 上色 ⇒ ⑤ 的"必须两处"会命中它');
/* /*
* 判定窗口必须与 ⑤ **完全同一条规则**:从 `Text(item.icon)` 切到下一个 `Text(`。 * 判定窗口必须与 ⑤ **完全同一条规则**:从 `AmIcon({` 切到下一个 `Text(`。
* 我第一版在这里写的是 `Text\(item\.icon\)[\s\S]{0,80}fontColor` —— 那个窗口 * 我第一版在这里写的是 `Text\(item\.icon\)[\s\S]{0,80}fontColor` —— 那个窗口
* 把**下一行 label 的** fontColor 也圈进来了,于是"旧写法没有图标上色"这条 * 把**下一行 label 的** fontColor 也圈进来了,于是"旧写法没有图标上色"这条
* 自检自己绿了(正是本仓库反复记的"窗口式判据")。判据被它咬了一次。 * 自检自己绿了(正是本仓库反复记的"窗口式判据")。判据被它咬了一次。
*/ */
const icon = itemCode.slice(itemCode.indexOf('Text(item.icon)'), itemCode.indexOf('Text(', itemCode.indexOf('Text(item.icon)') + 1)); const icon = itemCode.slice(itemCode.indexOf('AmIcon({'), itemCode.indexOf('Text(', itemCode.indexOf('AmIcon({') + 1));
assert.ok(!/fontColor/.test(icon), `旧写法里图标没有 fontColor ⇒ ⑤ 的图标那一半会命中它(切片=${JSON.stringify(icon)})`); assert.ok(!/iconColor:/.test(icon), `旧写法里图标没有 iconColor ⇒ ⑤ 的图标那一半会命中它(切片=${JSON.stringify(icon)})`);
}); });
/** /**

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, /服务器未配置推送/, '服务器没配通道时要说清(否则用户开了以为有用)');
});

View 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 要归零(没有底部条了)');
});

View File

@ -0,0 +1,77 @@
{
"meta": {
"stableOrder": true,
"enableUnifiedLockfile": false
},
"lockfileVersion": 3,
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
"specifiers": {
"@cangjie-tpc/formula_hybrid@1.3.0": "@cangjie-tpc/formula_hybrid@1.3.0",
"@cangjie-tpc/prism_hybrid@1.2.6": "@cangjie-tpc/prism_hybrid@1.2.6",
"@luvi/html2md@^1.0.0": "@luvi/html2md@1.0.3",
"@luvi/lv-markdown-in@^3.4.6": "@luvi/lv-markdown-in@3.4.6",
"libark_interop_api_prism.so@../oh_modules/.ohpm/@cangjie-tpc+prism_hybrid@1.2.6/oh_modules/@cangjie-tpc/prism_hybrid/src/main/cangjie/ark_interop_api_prism": "libark_interop_api_prism.so@../oh_modules/.ohpm/@cangjie-tpc+prism_hybrid@1.2.6/oh_modules/@cangjie-tpc/prism_hybrid/src/main/cangjie/ark_interop_api_prism",
"libark_interop_loader.so@../oh_modules/.ohpm/@cangjie-tpc+formula_hybrid@1.3.0/oh_modules/@cangjie-tpc/formula_hybrid/src/main/cangjie/loader": "libark_interop_loader.so@../oh_modules/.ohpm/@cangjie-tpc+formula_hybrid@1.3.0/oh_modules/@cangjie-tpc/formula_hybrid/src/main/cangjie/loader",
"libark_interop_loader.so@../oh_modules/.ohpm/@cangjie-tpc+prism_hybrid@1.2.6/oh_modules/@cangjie-tpc/prism_hybrid/src/main/cangjie/loader": "libark_interop_loader.so@../oh_modules/.ohpm/@cangjie-tpc+prism_hybrid@1.2.6/oh_modules/@cangjie-tpc/prism_hybrid/src/main/cangjie/loader"
},
"packages": {
"@cangjie-tpc/formula_hybrid@1.3.0": {
"name": "@cangjie-tpc/formula_hybrid",
"version": "1.3.0",
"integrity": "sha512-aZgGXSIMnVA3Ti4SzxbRZlWPWtWHE9tegs+ZxAQgielqqcz73h6HhWa5crHwgASMaPYPj00dJg1cxOrcKWQRhw==",
"resolved": "https://ohpm.openharmony.cn/ohpm/@cangjie-tpc/formula_hybrid/-/formula_hybrid-1.3.0.har",
"registryType": "ohpm",
"dependencies": {
"libark_interop_loader.so": "file:./src/main/cangjie/loader"
}
},
"@cangjie-tpc/prism_hybrid@1.2.6": {
"name": "@cangjie-tpc/prism_hybrid",
"version": "1.2.6",
"integrity": "sha512-yq401p5rb4FQumFHDoXlKOYnaWfhQDNMRxDsJZV8peieTTl4vT/pNKDCT3dx7DrU15DOZ9X4lhr9kZbnczGPnw==",
"resolved": "https://ohpm.openharmony.cn/ohpm/@cangjie-tpc/prism_hybrid/-/prism_hybrid-1.2.6.har",
"registryType": "ohpm",
"dependencies": {
"libark_interop_loader.so": "file:./src/main/cangjie/loader",
"libark_interop_api_prism.so": "file:./src/main/cangjie/ark_interop_api_prism"
}
},
"@luvi/html2md@1.0.3": {
"name": "@luvi/html2md",
"version": "1.0.3",
"integrity": "sha512-BBa2FqQKQ48dBmG+n2z63IGpbIV4Gc/g5Qj1FGssZ6ki+DHqYknZ46FRVRJeKtrfmCjYoiiKx5FtBjOuqnwbtA==",
"resolved": "https://ohpm.openharmony.cn/ohpm/@luvi/html2md/-/html2md-1.0.3.har",
"registryType": "ohpm"
},
"@luvi/lv-markdown-in@3.4.6": {
"name": "@luvi/lv-markdown-in",
"version": "3.4.6",
"integrity": "sha512-DrxflX5qGPpnaj1sZm+AN2l0lxVY9sENhK770LnM0rEv+2oVUV8zuadDRH222ohy1o40pXTexU5mbnKvIeK57w==",
"resolved": "https://ohpm.openharmony.cn/ohpm/@luvi/lv-markdown-in/-/lv-markdown-in-3.4.6.har",
"registryType": "ohpm",
"dependencies": {
"@luvi/html2md": "^1.0.0",
"@cangjie-tpc/formula_hybrid": "1.3.0",
"@cangjie-tpc/prism_hybrid": "1.2.6"
}
},
"libark_interop_api_prism.so@../oh_modules/.ohpm/@cangjie-tpc+prism_hybrid@1.2.6/oh_modules/@cangjie-tpc/prism_hybrid/src/main/cangjie/ark_interop_api_prism": {
"name": "libark_interop_api_prism.so",
"version": "1.0.0",
"resolved": "../oh_modules/.ohpm/@cangjie-tpc+prism_hybrid@1.2.6/oh_modules/@cangjie-tpc/prism_hybrid/src/main/cangjie/ark_interop_api_prism",
"registryType": "local"
},
"libark_interop_loader.so@../oh_modules/.ohpm/@cangjie-tpc+formula_hybrid@1.3.0/oh_modules/@cangjie-tpc/formula_hybrid/src/main/cangjie/loader": {
"name": "libark_interop_loader.so",
"version": "1.0.0",
"resolved": "../oh_modules/.ohpm/@cangjie-tpc+formula_hybrid@1.3.0/oh_modules/@cangjie-tpc/formula_hybrid/src/main/cangjie/loader",
"registryType": "local"
},
"libark_interop_loader.so@../oh_modules/.ohpm/@cangjie-tpc+prism_hybrid@1.2.6/oh_modules/@cangjie-tpc/prism_hybrid/src/main/cangjie/loader": {
"name": "libark_interop_loader.so",
"version": "1.0.0",
"resolved": "../oh_modules/.ohpm/@cangjie-tpc+prism_hybrid@1.2.6/oh_modules/@cangjie-tpc/prism_hybrid/src/main/cangjie/loader",
"registryType": "local"
}
}
}

View File

@ -5,6 +5,9 @@
"main": "", "main": "",
"author": "", "author": "",
"license": "", "license": "",
"dependencies": {} "dependencies": {
} "@luvi/lv-markdown-in": "^3.4.6"
},
"devDependencies": {},
"dynamicDependencies": {}
}

View File

@ -37,6 +37,14 @@ const DOMAIN: number = 0x0001;
const TAG: string = 'PushService'; const TAG: string = 'PushService';
const STORE: string = 'push_store'; const STORE: string = 'push_store';
const MARKER_KEY: string = 'reported_marker'; const MARKER_KEY: string = 'reported_marker';
const SETTING_KEY: string = 'push_enabled';
/*
* ★ 2026-09-15:接入系统通知是**可配置项**(用户强调多次)。
* 默认 **关** —— 自部署的用户没有服务端推送凭证时,App 不取 token、不请求权限、不上报、不打网关。
* 开了以后才走 getToken → requestEnableNotification → 上报这一整条。
* 这条开关存在 PushService 里(而不是设置页 @State),是因为 onCreate(ability 阶段)要读它。
*/
/** 点击跳转的目标(由通知 data 解析而来;只有两样东西要路由) */ /** 点击跳转的目标(由通知 data 解析而来;只有两样东西要路由) */
export interface PushRoute { export interface PushRoute {
@ -72,6 +80,31 @@ export class PushService {
return PushService.instance; return PushService.instance;
} }
/**
* 读「接入系统通知」开关(**默认关** —— 自部署/无凭证时零开销)。
* 读不到(首次/异常)都返回 false:宁可不开,也不要"默认就把用户裹进来"。
*/
static isEnabled(context: common.Context): boolean {
try {
const store = preferences.getPreferencesSync(context, { name: STORE });
const v = store.getSync(SETTING_KEY, false);
return v === true;
} catch (e) {
return false;
}
}
/** 写开关(设置页 Toggle 回调;写入失败静默 —— 关着总比误开着安全,下次再试不迟) */
static setEnabled(context: common.Context, enabled: boolean): void {
try {
const store = preferences.getPreferencesSync(context, { name: STORE });
store.putSync(SETTING_KEY, enabled);
store.flush();
} catch (e) {
hilog.info(DOMAIN, TAG, '推送开关写不进去(静默):%{public}s', (e as BusinessError).message);
}
}
/** /**
* 取 Push Kit 的 token。**取不到就返回空串**(没装 HMS Core、没登录华为账号、没权限……都是正常情况)。 * 取 Push Kit 的 token。**取不到就返回空串**(没装 HMS Core、没登录华为账号、没权限……都是正常情况)。
*/ */
@ -132,6 +165,15 @@ export class PushService {
* 放在启动后调用(登录成功之后、或已有账号时),调用方**不需要** catch —— 它自己内部全静默。 * 放在启动后调用(登录成功之后、或已有账号时),调用方**不需要** catch —— 它自己内部全静默。
*/ */
async reportToken(api: ApiClient): Promise<void> { async reportToken(api: ApiClient): Promise<void> {
/*
* ★ 开关 gate(2026-09-15):"接入系统通知"是**可配置项**,默认关。
* 关 ⇒ 不取 token(不碰 Push Kit)、不申请权限(不弹系统弹窗)、不上报、不打网关 ——
* 自部署用户没有服务端推送凭证时,这一整条都是无谓的开销。
* 这条 gate 在**最前**:getToken/requestEnableNotification 都在它后面。
*/
if (!PushService.isEnabled(this.context)) {
return;
}
const token: string = await this.getToken(); const token: string = await this.getToken();
if (token.length === 0) { if (token.length === 0) {
return; // 没有 token 就没什么可报的(这不是错误) return; // 没有 token 就没什么可报的(这不是错误)

View File

@ -0,0 +1,102 @@
/*
* 图标集 —— **与 WebUI 的 `client/electron/src/components/icons.tsx` 同几何**。
*
* 由 `/root/gotmp/gen-icons.py` 从那份 TSX **自动生成**,不要手改:
* · 源是 24×24 描边 SVG(fill=none / stroke=currentColor / strokeWidth 1.8 / 圆头圆角);
* · 转成 ArkUI `Path` 的 commands,并**统一为绝对命令**、展开隐式重复
* (ArkUI 的解析器不认 SVG 的隐式 lineto);
* · `circle`/`rect` 也展开成弧与直线。
*
* 为什么不用 SVG 资源文件:WebUI 的图标是**描边**画法,而鸿蒙 `Image().fillColor()`
* 只改 fill、对 stroke 无效 ⇒ 直接放 SVG 图标就不跟主题走。用 `Path` 则 `.stroke()`
* 直接吃主题色(这是"深色模式一处也不漏"的前提)。
*
* 生成物共 43 个图标;生成时已自检:每条命令的参数个数与 SVG 语法一致。
*/
import { Theme } from './Theme';
/** 图标名 → ArkUI Path commands(24×24 坐标系,绝对命令) */
export const ICON_PATHS: Record<string, string> = {
'inbox': 'M 22 12 L 16 12 L 14 15 L 10 15 L 8 12 L 2 12 M 5.45 5.11 L 2 12 L 2 18 A 2 2 0 0 0 4 20 L 20 20 A 2 2 0 0 0 22 18 L 22 12 L 18.55 5.11 A 2 2 0 0 0 16.76 4 L 7.24 4 A 2 2 0 0 0 5.45 5.11 Z',
'sent': 'M 22 2 L 15 22 L 11 13 L 2 9 Z M 22 2 L 11 13',
'contacts': 'M 16 21 L 16 19 A 4 4 0 0 0 12 15 L 6 15 A 4 4 0 0 0 2 19 L 2 21 M 5 7 A 4 4 0 1 0 13 7 A 4 4 0 1 0 5 7 Z M 22 21 L 22 19 A 4 4 0 0 0 19 15.13 M 16 3.13 A 4 4 0 0 1 16 10.88',
'compose': 'M 12 20 L 21 20 M 16.5 3.5 A 2.12 2.12 0 0 1 19.5 6.5 L 7 19 L 3 20 L 4 16 Z',
'mail': 'M 4 4 L 20 4 A 2 2 0 0 1 22 6 L 22 18 A 2 2 0 0 1 20 20 L 4 20 A 2 2 0 0 1 2 18 L 2 6 A 2 2 0 0 1 4 4 Z M 22 7 L 13.03 12.7 A 1.94 1.94 0 0 1 10.97 12.7 L 2 7',
'shield': 'M 20 13 C 20 18 16.5 20.5 12.34 21.95 A 1 1 0 0 1 11.67 21.94 C 7.5 20.5 4 18 4 13 L 4 6 A 1 1 0 0 1 5 5 C 7 5 9.5 3.8 11.24 2.28 A 1.17 1.17 0 0 1 12.76 2.28 C 14.51 3.81 17 5 19 5 A 1 1 0 0 1 20 6 Z',
'person': 'M 19 21 L 19 19 A 4 4 0 0 0 15 15 L 9 15 A 4 4 0 0 0 5 19 L 5 21 M 8 7 A 4 4 0 1 0 16 7 A 4 4 0 1 0 8 7 Z',
'bot': 'M 5 11 L 19 11 A 2 2 0 0 1 21 13 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 L 3 13 A 2 2 0 0 1 5 11 Z M 10 5 A 2 2 0 1 0 14 5 A 2 2 0 1 0 10 5 Z M 12 7 L 12 11 M 8 16 L 8.01 16 M 16 16 L 16.01 16',
'check': 'M 20 6 L 9 17 L 4 12',
'close': 'M 18 6 L 6 18 M 6 6 L 18 18',
'trash': 'M 3 6 L 21 6 M 19 6 L 19 20 A 2 2 0 0 1 17 22 L 7 22 A 2 2 0 0 1 5 20 L 5 6 M 8 6 L 8 4 A 2 2 0 0 1 10 2 L 14 2 A 2 2 0 0 1 16 4 L 16 6',
'archive': 'M 3 3 L 21 3 A 1 1 0 0 1 22 4 L 22 7 A 1 1 0 0 1 21 8 L 3 8 A 1 1 0 0 1 2 7 L 2 4 A 1 1 0 0 1 3 3 Z M 4 8 L 4 19 A 2 2 0 0 0 6 21 L 18 21 A 2 2 0 0 0 20 19 L 20 8 M 10 12 L 14 12',
'chevronRight': 'M 9 18 L 15 12 L 9 6',
'logout': 'M 9 21 L 5 21 A 2 2 0 0 1 3 19 L 3 5 A 2 2 0 0 1 5 3 L 9 3 M 16 17 L 21 12 L 16 7 M 21 12 L 9 12',
'users': 'M 16 21 L 16 19 A 4 4 0 0 0 12 15 L 6 15 A 4 4 0 0 0 2 19 L 2 21 M 5 7 A 4 4 0 1 0 13 7 A 4 4 0 1 0 5 7 Z M 22 21 L 22 19 A 4 4 0 0 0 19 15.13 M 16 3.13 A 4 4 0 0 1 16 10.88',
'lock': 'M 5 11 L 19 11 A 2 2 0 0 1 21 13 L 21 20 A 2 2 0 0 1 19 22 L 5 22 A 2 2 0 0 1 3 20 L 3 13 A 2 2 0 0 1 5 11 Z M 7 11 L 7 7 A 5 5 0 0 1 17 7 L 17 11',
'brandMark': 'M 12 1.5 A 1.25 1.25 0 0 1 13.25 2.75 C 13.25 3.25 12.96 3.68 12.54 3.88 L 12.54 5 L 14.25 5 A 2.75 2.75 0 0 1 17 7.75 L 17 8 L 19.5 8 A 2.5 2.5 0 0 1 22 10.5 L 22 19.5 A 2.5 2.5 0 0 1 19.5 22 L 4.5 22 A 2.5 2.5 0 0 1 2 19.5 L 2 10.5 A 2.5 2.5 0 0 1 4.5 8 L 7 8 L 7 7.75 A 2.75 2.75 0 0 1 9.75 5 L 11.46 5 L 11.46 3.88 C 11.04 3.68 10.75 3.25 10.75 2.75 A 1.25 1.25 0 0 1 12 1.5 M 9.75 6.5 A 1.25 1.25 0 0 0 8.5 7.75 L 8.5 8 L 15.5 8 L 15.5 7.75 A 1.25 1.25 0 0 0 14.25 6.5 Z M 4.5 9.5 A 1 1 0 0 0 3.5 10.5 L 3.5 10.84 L 12 15.56 L 20.5 10.84 L 20.5 10.5 A 1 1 0 0 0 19.5 9.5 Z M 3.5 12.55 L 3.5 19.5 A 1 1 0 0 0 4.5 20.5 L 19.5 20.5 A 1 1 0 0 0 20.5 19.5 L 20.5 12.55 L 12 17.27 Z M 8.1 11.6 A 0.9 0.9 0 1 0 9.9 11.6 A 0.9 0.9 0 1 0 8.1 11.6 Z M 14.1 11.6 A 0.9 0.9 0 1 0 15.9 11.6 A 0.9 0.9 0 1 0 14.1 11.6 Z',
'spinner': 'M 2 12 A 10 10 0 1 0 22 12 A 10 10 0 1 0 2 12 Z M 12 2 A 10 10 0 0 1 22 12',
'key': 'M 3 15.5 A 4.5 4.5 0 1 0 12 15.5 A 4.5 4.5 0 1 0 3 15.5 Z M 10.7 12.3 L 21 2 M 17 6 L 20 9 M 14 9 L 17 12',
'copy': 'M 11 9 L 19 9 A 2 2 0 0 1 21 11 L 21 19 A 2 2 0 0 1 19 21 L 11 21 A 2 2 0 0 1 9 19 L 9 11 A 2 2 0 0 1 11 9 Z M 5 15 L 5 5 A 2 2 0 0 1 7 3 L 17 3',
'plus': 'M 12 5 L 12 19 M 5 12 L 19 12',
'forward': 'M 15 17 L 20 12 L 15 7 M 4 18 L 4 16 A 4 4 0 0 1 8 12 L 20 12',
'paperclip': 'M 13.2 2.8 A 5 5 0 0 1 20.2 9.8 L 11.7 18.3 A 3.2 3.2 0 0 1 7.2 13.8 L 15.2 5.8 A 1.4 1.4 0 0 1 17.2 7.8 L 9.7 15.3',
'download': 'M 12 3 L 12 15 M 7 11 L 12 16 L 17 11 M 4 20 L 20 20',
'file': 'M 14 3 L 7 3 A 2 2 0 0 0 5 5 L 5 19 A 2 2 0 0 0 7 21 L 17 21 A 2 2 0 0 0 19 19 L 19 8 Z M 14 3 L 14 8 L 19 8',
'tree': 'M 10 3 L 14 3 A 1 1 0 0 1 15 4 L 15 6 A 1 1 0 0 1 14 7 L 10 7 A 1 1 0 0 1 9 6 L 9 4 A 1 1 0 0 1 10 3 Z M 4 17 L 8 17 A 1 1 0 0 1 9 18 L 9 20 A 1 1 0 0 1 8 21 L 4 21 A 1 1 0 0 1 3 20 L 3 18 A 1 1 0 0 1 4 17 Z M 16 17 L 20 17 A 1 1 0 0 1 21 18 L 21 20 A 1 1 0 0 1 20 21 L 16 21 A 1 1 0 0 1 15 20 L 15 18 A 1 1 0 0 1 16 17 Z M 12 7 L 12 11 M 6 17 L 6 14 A 1 1 0 0 1 7 13 L 17 13 A 1 1 0 0 1 18 14 L 18 17',
'tag': 'M 20.6 13.4 L 13.4 20.6 A 2 2 0 0 1 10.6 20.6 L 3.4 13.4 A 2 2 0 0 1 3 12 L 3 4 A 1 1 0 0 1 4 3 L 12 3 A 2 2 0 0 1 13.4 3.6 L 20.6 10.8 A 2 2 0 0 1 20.6 13.6 Z M 6.5 7.5 A 1 1 0 1 0 8.5 7.5 A 1 1 0 1 0 6.5 7.5 Z',
'gauge': 'M 12 14 L 15.5 9 M 3.5 17 A 9 9 0 1 1 20.5 17 M 10.8 14 A 1.2 1.2 0 1 0 13.2 14 A 1.2 1.2 0 1 0 10.8 14 Z',
'chevronLeft': 'M 15 18 L 9 12 L 15 6',
'menu': 'M 4 7 L 20 7 M 4 12 L 20 12 M 4 17 L 20 17',
'listView': 'M 8 6 L 20 6 M 8 12 L 20 12 M 8 18 L 20 18 M 4 6 L 4.01 6 M 4 12 L 4.01 12 M 4 18 L 4.01 18',
'cardView': 'M 4.5 4 L 19.5 4 A 1.5 1.5 0 0 1 21 5.5 L 21 9.5 A 1.5 1.5 0 0 1 19.5 11 L 4.5 11 A 1.5 1.5 0 0 1 3 9.5 L 3 5.5 A 1.5 1.5 0 0 1 4.5 4 Z M 4.5 14 L 19.5 14 A 1.5 1.5 0 0 1 21 15.5 L 21 18.5 A 1.5 1.5 0 0 1 19.5 20 L 4.5 20 A 1.5 1.5 0 0 1 3 18.5 L 3 15.5 A 1.5 1.5 0 0 1 4.5 14 Z',
'cpu': 'M 6 4 L 18 4 A 2 2 0 0 1 20 6 L 20 18 A 2 2 0 0 1 18 20 L 6 20 A 2 2 0 0 1 4 18 L 4 6 A 2 2 0 0 1 6 4 Z M 9 9 L 15 9 L 15 15 L 9 15 Z M 9 2 L 9 4 M 15 2 L 15 4 M 9 20 L 9 22 M 15 20 L 15 22 M 2 9 L 4 9 M 2 15 L 4 15 M 20 9 L 22 9 M 20 15 L 22 15',
'calendar': 'M 5 5 L 19 5 A 2 2 0 0 1 21 7 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 L 3 7 A 2 2 0 0 1 5 5 Z M 3 10 L 21 10 M 8 3 L 8 7 M 16 3 L 16 7',
'bell': 'M 18 8 A 6 6 0 0 0 6 8 C 6 15 3 17 3 17 L 21 17 S 18 15 18 8 M 13.7 21 A 2 2 0 0 1 10.3 21',
'repeat': 'M 17 2 L 21 6 L 17 10 M 3 11 L 3 10 A 4 4 0 0 1 7 6 L 21 6 M 7 22 L 3 18 L 7 14 M 21 13 L 21 14 A 4 4 0 0 1 17 18 L 3 18',
'upload': 'M 21 15 L 21 19 A 2 2 0 0 1 19 21 L 5 21 A 2 2 0 0 1 3 19 L 3 15 M 17 8 L 12 3 L 7 8 M 12 3 L 12 15',
'pause': 'M 6 4 L 10 4 L 10 20 L 6 20 Z M 14 4 L 18 4 L 18 20 L 14 20 Z',
'play': 'M 5 3 L 19 12 L 5 21 L 5 3 Z',
'sun': 'M 8 12 A 4 4 0 1 0 16 12 A 4 4 0 1 0 8 12 Z M 12 2 L 12 4 M 12 20 L 12 22 M 4.9 4.9 L 6.3 6.3 M 17.7 17.7 L 19.1 19.1 M 2 12 L 4 12 M 20 12 L 22 12 M 4.9 19.1 L 6.3 17.7 M 17.7 6.3 L 19.1 4.9',
'moon': 'M 21 12.8 A 8.5 8.5 0 1 1 11.2 3 A 6.6 6.6 0 0 0 21 12.8 Z',
'monitor': 'M 4 4 L 20 4 A 2 2 0 0 1 22 6 L 22 15 A 2 2 0 0 1 20 17 L 4 17 A 2 2 0 0 1 2 15 L 2 6 A 2 2 0 0 1 4 4 Z M 8 21 L 16 21 M 12 17 L 12 21',
'chatBubble': 'M 21 11.5 A 8.4 8.4 0 0 1 12 19.9 A 9.4 9.4 0 0 1 8.7 19.3 L 3 21 L 4.7 16 A 8.4 8.4 0 0 1 4 12.6 A 8.4 8.4 0 0 1 12.4 4.2 L 13 4.2 A 8.4 8.4 0 0 1 21 12.2 Z',
};
/** 图标原始坐标系边长(所有图标都在这个方框内绘制) */
export const ICON_VIEWBOX: number = 24;
/**
* 图标组件:与 WebUI `<Icon className="w-5 h-5" />` 等价的用法。
*
* `size` 是**最终视觉尺寸**;内部按 24 坐标系绘制再等比缩放,
* 所以任何尺寸都不会出现"描边变粗/变细"以外的偏差(描边宽度按 WebUI 的 1.8 固定)。
*/
@Component
export struct AmIcon {
/** 图标名(见 ICON_PATHS 的键):inbox / sent / compose / mail / person … */
@Prop iconName: string = '';
/** 视觉尺寸(vp)。**不叫 `size`**:ArkUI 把 `size` 当通用属性名,撞名编译不过 */
@Prop iconSize: number = 20;
/** 描边颜色(默认取主题主文字色) */
@Prop iconColor: ResourceColor = Theme.textPrimary;
/** 描边宽度(WebUI 用 1.8) */
@Prop strokeWeight: number = 1.8;
build() {
Stack({ alignContent: Alignment.Center }) {
Path()
.commands(ICON_PATHS[this.iconName] ?? '')
.width(ICON_VIEWBOX)
.height(ICON_VIEWBOX)
.fill(Color.Transparent)
.stroke(this.iconColor)
.strokeWidth(this.strokeWeight)
.strokeLineCap(LineCapStyle.Round)
.strokeLineJoin(LineJoinStyle.Round)
.scale({ x: this.iconSize / ICON_VIEWBOX, y: this.iconSize / ICON_VIEWBOX })
}
.width(this.iconSize)
.height(this.iconSize)
}
}

View File

@ -33,9 +33,27 @@ export default class EntryAbility extends UIAbility {
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err)); hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
} }
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
/*
* ★ 2026-09-15:冷启点通知也要跳转。
* 之前只在 onNewWant 解析 want ⇒ 冷启动(进程不在)时点击通知 onCreate 拿到 want 却丢掉跳转目标。
* 这里与 onNewWant 同一付逻辑:解析出 route 就写 pendingRoute,页面起来后读它。
* 解析不出来就不做(一条格式不认识的通知最坏应当是"没反应",不是"跳到空页")。
*/
try {
const params: Record<string, Object> = want.parameters === undefined
? {} as Record<string, Object>
: want.parameters as Record<string, Object>;
const route = PushService.routeFromWant(params, this.ledger);
if (route !== undefined) {
PushService.pendingRoute = route;
}
} catch (err) {
hilog.info(DOMAIN, 'testTag', '冷启通知跳转解析失败(静默):%{public}s', JSON.stringify(err));
}
/* /*
* 推送上报:**不 await、失败全静默** —— 启动不能被网络/权限阻塞,也不能因为推送不可用而报错。 * 推送上报:**不 await、失败全静默** —— 启动不能被网络/权限阻塞,也不能因为推送不可用而报错。
* 冷启时还没登录 ⇒ POST 会 401,静默忽略;标记没写成功 ⇒ 下次启动会再试一次。 * ★ reportToken 内部先查开关(PushService.isEnabled,默认关):关着就不取 token、
* 不弹权限、不打网关(自部署零开销)。
*/ */
try { try {
const push: PushService = PushService.getInstance(this.context); const push: PushService = PushService.getInstance(this.context);

View File

@ -14,8 +14,20 @@ export interface NavItem {
key: string; key: string;
/** 显示文案(与 WebUI 逐字一致) */ /** 显示文案(与 WebUI 逐字一致) */
label: string; label: string;
/** 图标字形(鸿蒙侧用文本字形,不用猜 sys.media 名字) */ /**
icon: string; * 图标名 —— 指向 `common/Icons.ets` 的 `ICON_PATHS`,与 WebUI 的图标集**同几何**。
*
* 2026-09-15 之前这里是 emoji 字形(`✉️`/`📅`/`👤`),理由是"不用猜 sys.media 名字"。
* 那条理由只解释了"为什么不用系统资源名",却把 emoji 当成了唯一替代 —— 结果是:
* · WebUI 在 `icons.tsx` 第一行就写着「**纯 SVG 图标,全站不使用 emoji**」,
* 两边**同一个产品却两套图标语言**;
* · emoji 由系统字体渲染,颜色不受 `fontColor` 控制、随厂商与版本变形,
* 选中/未选中只能靠整体透明度示意,做不到 WebUI 那种"线性描边图标 + 品牌色"。
*
* 现在改用 ArkUI 原生 `Path`(`common/Icons.ets`,由 WebUI 的 TSX 自动生成),
* 既不需要猜 `sys.media` 名字,又能吃主题色(深色模式一处也不漏)。
*/
iconKey: string;
} }
/** /**
@ -30,9 +42,9 @@ export interface NavItem {
* 「顶层是 通信/日历/联系人 三个平级 pane」写判据的。 * 「顶层是 通信/日历/联系人 三个平级 pane」写判据的。
*/ */
export const NAV_ITEMS: NavItem[] = [ export const NAV_ITEMS: NavItem[] = [
{ key: 'comm', label: '通信', icon: '✉️' }, { key: 'comm', label: '通信', iconKey: 'inbox' },
{ key: 'calendar', label: '日历', icon: '📅' }, { key: 'calendar', label: '日历', iconKey: 'calendar' },
{ key: 'contacts', label: '联系人', icon: '👤' } { key: 'contacts', label: '联系人', iconKey: 'contacts' }
]; ];
/** 命中区下限(vp)。**44 是可点区域的下限**,不是"看起来够大"的估计 */ /** 命中区下限(vp)。**44 是可点区域的下限**,不是"看起来够大"的估计 */

View File

@ -12,6 +12,18 @@
export const PROVIDER_HMS: string = 'hms'; export const PROVIDER_HMS: string = 'hms';
/*
* ★ 2026-09-15:接入系统通知 = **可配置项**(用户强调多次——“用户可以自由部署自己的 agentmail”)。
* 默认 **关**:自部署/无服务端推送凭证时,客户端不取 token、不弹权限、不上报、不打网关。
* 这个常量是**契约**(判据跑它),实际落点在 PushService.isEnabled 的读不到即 false。
*/
export const DEFAULT_PUSH_ENABLED: boolean = false;
/** 签名:'on' | 'off'(开关的显示口径;无服务器状态时显示 'server-unknown') */
export function pushEnabledLabel(enabled: boolean): string {
return enabled ? 'on' : 'off';
}
/** 上报体:`POST /api/v1/me/devices/push-token` */ /** 上报体:`POST /api/v1/me/devices/push-token` */
export interface PushRegisterBody { export interface PushRegisterBody {
provider: string; provider: string;

View File

@ -33,6 +33,7 @@ import {
AdminUpdateUserInput AdminUpdateUserInput
} from '../model/Models'; } from '../model/Models';
import { toggled, lastLoginLabel, messageOfApiError, isAdminRole, isRestricted } from '../model/AdminUsers'; import { toggled, lastLoginLabel, messageOfApiError, isAdminRole, isRestricted } from '../model/AdminUsers';
import { AmIcon } from '../common/Icons';
@Entry @Entry
@Component @Component
@ -267,8 +268,11 @@ struct AdminUsersPage {
this.Header() this.Header()
if (this.roleKnown && !this.isAdmin) { if (this.roleKnown && !this.isAdmin) {
Column() { Column() {
Text('🔒 管理入口仅管理员可见').fontSize(Theme.fontBody).fontColor(Theme.textMuted) Row() {
AmIcon({ iconName: 'lock', iconSize: 16, iconColor: Theme.textMuted })
Text('管理入口仅管理员可见').fontSize(Theme.fontBody).fontColor(Theme.textMuted).margin({ left: 6 })
}
Text('当前账号的角色不是管理员。') Text('当前账号的角色不是管理员。')
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtle).margin({ top: 6 }) .fontSize(Theme.fontSmall).fontColor(Theme.textSubtle).margin({ top: 6 })
} }
@ -326,8 +330,7 @@ struct AdminUsersPage {
Text('管理').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) Text('管理').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
.layoutWeight(1) .layoutWeight(1)
// 刷新:管理页的动作个个改服务端状态,看不到最新值会让人怀疑自己刚才点没点上 // 刷新:管理页的动作个个改服务端状态,看不到最新值会让人怀疑自己刚才点没点上
Text('↻').fontSize(20).fontColor(Theme.accent).width(40).height(40) AmIcon({ iconName: 'repeat', iconSize: 20, iconColor: Theme.accent }).width(40).height(40)
.textAlign(TextAlign.Center)
.onClick(() => { this.load(); }) .onClick(() => { this.load(); })
} }
.width('100%').height(56).padding({ left: 8, right: 8 }) .width('100%').height(56).padding({ left: 8, right: 8 })

View File

@ -11,6 +11,7 @@ import { SendMailRequest } from '../model/Models';
import { ComposeParams } from '../model/RouteParams'; import { ComposeParams } from '../model/RouteParams';
import { hilog } from '@kit.PerformanceAnalysisKit'; import { hilog } from '@kit.PerformanceAnalysisKit';
import { picker } from '@kit.CoreFileKit'; import { picker } from '@kit.CoreFileKit';
import { AmIcon } from '../common/Icons';
@Entry @Entry
@Component @Component
@ -165,7 +166,7 @@ struct ComposePage {
request.attachment_ids = this.attachmentIds; request.attachment_ids = this.attachmentIds;
} }
await mailApi.send(request); await mailApi.send(request);
this.getUIContext().getPromptAction().showToast({ message: '✅ 邮件已发送' }); this.getUIContext().getPromptAction().showToast({ message: '邮件已发送' });
this.getUIContext().getRouter().back(); this.getUIContext().getRouter().back();
} catch (e) { } catch (e) {
const apiError = e as ApiError; const apiError = e as ApiError;
@ -228,7 +229,7 @@ struct ComposePage {
.fontWeight(this.selectedAccountId === acct.id ? FontWeight.Bold : FontWeight.Normal) .fontWeight(this.selectedAccountId === acct.id ? FontWeight.Bold : FontWeight.Normal)
.layoutWeight(1) .layoutWeight(1)
if (this.selectedAccountId === acct.id) { if (this.selectedAccountId === acct.id) {
Text('✓').fontSize(14).fontColor(Theme.accent) AmIcon({ iconName: 'check', iconSize: 14, iconColor: Theme.accent })
} }
} }
.width('100%').height(40).padding({ left: 40, right: 12 }) .width('100%').height(40).padding({ left: 40, right: 12 })
@ -279,12 +280,13 @@ struct ComposePage {
Column() { Column() {
ForEach(this.attachmentNames, (name: string, idx: number) => { ForEach(this.attachmentNames, (name: string, idx: number) => {
Row() { Row() {
Text('📎 ' + name) AmIcon({ iconName: 'paperclip', iconSize: 12, iconColor: Theme.textMuted })
Text(name)
.fontSize(12).fontColor(Theme.textPrimary) .fontSize(12).fontColor(Theme.textPrimary)
.margin({ left: 6 })
.layoutWeight(1) .layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text('✕') AmIcon({ iconName: 'close', iconSize: 14, iconColor: Theme.danger })
.fontSize(14).fontColor(Theme.danger)
.onClick(() => { this.removeAttachment(idx); }) .onClick(() => { this.removeAttachment(idx); })
} }
.width('100%').height(32) .width('100%').height(32)
@ -297,9 +299,12 @@ struct ComposePage {
// 底部工具栏 // 底部工具栏
Row() { Row() {
Text('📎 添加附件') Row() {
.fontSize(13).fontColor(Theme.accent) AmIcon({ iconName: 'paperclip', iconSize: 14, iconColor: Theme.accent })
.onClick(() => { this.pickAttachment(); }) Text('添加附件')
.fontSize(13).fontColor(Theme.accent).margin({ left: 6 })
.onClick(() => { this.pickAttachment(); })
}
if (this.uploading) { if (this.uploading) {
Text(this.status) Text(this.status)

View File

@ -7,6 +7,7 @@ import { Theme } from '../common/Theme';
import { MailApi } from '../api/MailApi'; import { MailApi } from '../api/MailApi';
import { MailSummary, Me } from '../model/Models'; import { MailSummary, Me } from '../model/Models';
import { router } from '@kit.ArkUI'; import { router } from '@kit.ArkUI';
import { AmIcon } from '../common/Icons';
@Entry @Entry
@Component @Component
@ -97,7 +98,7 @@ struct InboxPage {
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
} else if (this.mails.length === 0) { } else if (this.mails.length === 0) {
Column() { Column() {
Text('📭 收件箱为空').fontSize(16).fontColor(Theme.textSubtle) Text('收件箱为空').fontSize(16).fontColor(Theme.textSubtle)
} }
.width('100%').height('80%') .width('100%').height('80%')
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
@ -126,7 +127,7 @@ struct InboxPage {
Column() { Column() {
Row() { Row() {
Column() { Column() {
Text('📬').fontSize(20) AmIcon({ iconName: 'inbox', iconSize: 20, iconColor: Theme.accent })
Text('收件箱').fontSize(11).fontColor(Theme.accent) Text('收件箱').fontSize(11).fontColor(Theme.accent)
} }
.layoutWeight(1).height('100%') .layoutWeight(1).height('100%')
@ -134,7 +135,7 @@ struct InboxPage {
.backgroundColor(Theme.accentSoft) .backgroundColor(Theme.accentSoft)
Column() { Column() {
Text('💬').fontSize(20) AmIcon({ iconName: 'chatBubble', iconSize: 20, iconColor: Theme.textMuted })
Text('会话').fontSize(11).fontColor(Theme.textMuted) Text('会话').fontSize(11).fontColor(Theme.textMuted)
} }
.layoutWeight(1).height('100%') .layoutWeight(1).height('100%')
@ -144,7 +145,7 @@ struct InboxPage {
}) })
Column() { Column() {
Text('👤').fontSize(20) AmIcon({ iconName: 'contacts', iconSize: 20, iconColor: Theme.textMuted })
Text('联系人').fontSize(11).fontColor(Theme.textMuted) Text('联系人').fontSize(11).fontColor(Theme.textMuted)
} }
.layoutWeight(1).height('100%') .layoutWeight(1).height('100%')
@ -193,7 +194,7 @@ struct InboxPage {
// 附件图标 // 附件图标
if (mail.has_attachments) { if (mail.has_attachments) {
Text('📎').fontSize(14).margin({ right: 8 }) AmIcon({ iconName: 'paperclip', iconSize: 14, iconColor: Theme.textMuted }).margin({ right: 8 })
} }
// 权限档位徽标 // 权限档位徽标

View File

@ -187,7 +187,7 @@ struct LoginPage {
}) })
if (this.addrWarning.length > 0) { if (this.addrWarning.length > 0) {
Text('⚠ ' + this.addrWarning) Text(this.addrWarning)
.fontSize(11).fontColor(Theme.warnFg) .fontSize(11).fontColor(Theme.warnFg)
.width('85%').margin({ bottom: 12 }) .width('85%').margin({ bottom: 12 })
} }

View File

@ -5,10 +5,13 @@
*/ */
import { ApiClient, ApiError } from '../api/ApiClient'; import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme'; import { Theme } from '../common/Theme';
/* Markdown 渲染(第三方库,鸿蒙原生 ArkTS 引擎,不依赖 WebView)—— 正文用它,不再吐原始 Markdown */
import { Markdown } from '@luvi/lv-markdown-in';
import { MailApi } from '../api/MailApi'; import { MailApi } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager'; import { AccountManager, AccountInfo } from '../api/AccountManager';
import { MailDetail, SendMailRequest } from '../model/Models'; import { MailDetail, SendMailRequest } from '../model/Models';
import { MailDetailParams } from '../model/RouteParams'; import { MailDetailParams } from '../model/RouteParams';
import { AmIcon } from '../common/Icons';
@Entry @Entry
@Component @Component
@ -207,12 +210,23 @@ struct MailDetailPage {
.width('100%') .width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 8 }) .padding({ left: 16, right: 16, top: 16, bottom: 8 })
Text(this.body) /*
.fontSize(15) * 邮件正文:**用 Markdown 渲染**,不再直接吐原文。
.fontColor(Theme.textPrimary) *
* 2026-09-15 之前这里是 `Text(this.body)` —— 正文是 Markdown,
* 于是用户看到的是 `**加粗**`、`# 标题`、`| 表 |` 的**字面量**。
* WebUI 侧一直用 `react-markdown` + `remark-gfm` 渲染
*(`MailView.tsx` 的 `.markdown` 容器),这是两端**功能不对等**的一处硬缺口。
*
* 用第三方库 `@luvi/lv-markdown-in`(鸿蒙原生 ArkTS 渲染引擎,
* **不依赖 WebView**):解析与渲染全在原生层,`ohpm install` 一行接入。
* 我一度自己写了一个 GFM 子集解析器 —— 随后按用户指示换成第三方库:
* 自研那份只覆盖子集、还要自己养判据,而库里有 LaTeX / Prism 高亮 /
* 大文本懒渲染 / 70+ 样式 API,都是自研要重做的。
*/
Markdown({ text: this.body })
.width('100%') .width('100%')
.padding({ left: 16, right: 16, bottom: 16 }) .padding({ left: 16, right: 16, bottom: 16 })
.lineHeight(24)
Blank().height(60) Blank().height(60)
} }
@ -223,7 +237,12 @@ struct MailDetailPage {
// 底部回复按钮 // 底部回复按钮
Row() { Row() {
Button('↩ 回复') Button() {
Row() {
AmIcon({ iconName: 'forward', iconSize: 16, iconColor: Theme.accentFg })
Text('回复').fontSize(15).fontColor(Theme.accentFg).margin({ left: 6 })
}
}
.width('90%').height(44) .width('90%').height(44)
.backgroundColor(Theme.accent) .backgroundColor(Theme.accent)
.fontSize(15) .fontSize(15)

View File

@ -8,6 +8,8 @@
*/ */
import { ApiClient, ApiError } from '../api/ApiClient'; import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme'; import { Theme } from '../common/Theme';
import { AmIcon } from '../common/Icons';
import { WideSidebar } from './WideSidebar';
import { MailApi, InboxResponse } from '../api/MailApi'; import { MailApi, InboxResponse } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager'; import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SseService, SseEvent } from '../api/SseService'; import { SseService, SseEvent } from '../api/SseService';
@ -140,7 +142,7 @@ struct InboxTab {
break; break;
} }
} }
this.getUIContext().getPromptAction().showToast({ message: sourceName.length > 0 ? '📨 ' + sourceName + ' 收到新邮件' : '📨 新邮件到达' }); this.getUIContext().getPromptAction().showToast({ message: sourceName.length > 0 ? '新邮件:' + sourceName : '新邮件到达' });
this.loadData(); this.loadData();
} }
}; };
@ -382,7 +384,7 @@ struct InboxTab {
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.mails.length === 0) { } else if (this.mails.length === 0) {
Column() { Column() {
Text('📭 收件箱为空').fontSize(16).fontColor(Theme.textSubtle) Text('收件箱为空').fontSize(16).fontColor(Theme.textSubtle)
} }
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else { } else {
@ -762,7 +764,7 @@ struct SentTab {
Column() { LoadingProgress().width(32).height(32) } Column() { LoadingProgress().width(32).height(32) }
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) { } else if (this.error.length > 0) {
Column() { Text('⚠ ' + this.error).fontSize(13).fontColor(Theme.danger) } Column() { Text(this.error).fontSize(13).fontColor(Theme.danger) }
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.groups.length === 0) { } else if (this.groups.length === 0) {
/* /*
@ -771,7 +773,7 @@ struct SentTab {
* 副句说清这一栏是干什么的。 * 副句说清这一栏是干什么的。
*/ */
Column() { Column() {
Text('📤').fontSize(32).margin({ bottom: 8 }) AmIcon({ iconName: 'sent', iconSize: 36, iconColor: Theme.textSubtle }).margin({ bottom: 8 })
Text(emptyTitle('sent')).fontSize(15).fontColor(Theme.textMuted) Text(emptyTitle('sent')).fontSize(15).fontColor(Theme.textMuted)
Text(emptyHint('sent')).fontSize(12).fontColor(Theme.textSubtle).margin({ top: 6 }) Text(emptyHint('sent')).fontSize(12).fontColor(Theme.textSubtle).margin({ top: 6 })
} }
@ -1020,11 +1022,11 @@ struct PermissionTab {
Column() { LoadingProgress().width(32).height(32) } Column() { LoadingProgress().width(32).height(32) }
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) { } else if (this.error.length > 0) {
Column() { Text('⚠ ' + this.error).fontSize(13).fontColor(Theme.danger) } Column() { Text(this.error).fontSize(13).fontColor(Theme.danger) }
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.requests.length === 0) { } else if (this.requests.length === 0) {
Column() { Column() {
Text('✅').fontSize(32).margin({ bottom: 8 }) AmIcon({ iconName: 'shield', iconSize: 36, iconColor: Theme.textSubtle }).margin({ bottom: 8 })
Text(emptyTitle('permissions')).fontSize(15).fontColor(Theme.textMuted) Text(emptyTitle('permissions')).fontSize(15).fontColor(Theme.textMuted)
Text(emptyHint('permissions')).fontSize(12).fontColor(Theme.textSubtle).margin({ top: 6 }) Text(emptyHint('permissions')).fontSize(12).fontColor(Theme.textSubtle).margin({ top: 6 })
} }
@ -1189,12 +1191,17 @@ struct CommPage {
Row() { Row() {
Text('通信').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) Text('通信').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Blank() Blank()
Text('⚙') /*
.fontSize(20).fontColor(Theme.textMuted) * 设置入口。
.width(36).height(36).textAlign(TextAlign.Center) * 图标用 `person`:与 WebUI 的「我的」(`NarrowNav.tsx` 的 `PersonIcon`)同一枚,
.onClick(() => { * 而设置页里正是账号 / 外观 / 密钥这些“属于这个人的东西”。
this.getUIContext().getRouter().pushUrl({ url: 'pages/SettingsPage' }); * 2026-09-15 之前这里是 emoji `⚙`(理由与替换过程见 `model/NavItems.ts`)。
}) */
AmIcon({ iconName: 'person', iconSize: 20, iconColor: Theme.textMuted })
.width(36).height(36)
.onClick(() => {
this.getUIContext().getRouter().pushUrl({ url: 'pages/SettingsPage' });
})
} }
.width('100%').height(52).padding({ left: 16, right: 8 }) .width('100%').height(52).padding({ left: 16, right: 8 })
.backgroundColor(Theme.surface) .backgroundColor(Theme.surface)
@ -1282,9 +1289,12 @@ struct ContactsTab {
Row() { Row() {
Text(contactViewTitle(this.contactView)).fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) Text(contactViewTitle(this.contactView)).fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Blank() Blank()
Text(this.contactView === 'list' ? '▦' : '☰') AmIcon({
.fontSize(18).fontColor(Theme.textMuted) iconName: this.contactView === 'list' ? 'cardView' : 'listView',
.width(40).height(40).textAlign(TextAlign.Center) iconSize: 18,
iconColor: Theme.textMuted
})
.width(40).height(40)
.onClick(() => { this.switchView(); }) .onClick(() => { this.switchView(); })
} }
.width('100%').height(56).padding({ left: 16, right: 8 }) .width('100%').height(56).padding({ left: 16, right: 8 })
@ -1303,7 +1313,7 @@ struct ContactsTab {
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.contacts.length === 0) { } else if (this.contacts.length === 0) {
Column() { Column() {
Text('📭 暂无联系人').fontSize(16).fontColor(Theme.textSubtle) Text('暂无联系人').fontSize(16).fontColor(Theme.textSubtle)
} }
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.contactView === 'card') { } else if (this.contactView === 'card') {
@ -1384,8 +1394,12 @@ struct ContactsTab {
if (c.last_preview.length > 0) { if (c.last_preview.length > 0) {
Row() { Row() {
Text(lastFromIsHuman(c.agent_name, c.last_from) ? '👤' : '🤖') AmIcon({
.fontSize(11).margin({ right: 4 }) iconName: lastFromIsHuman(c.agent_name, c.last_from) ? 'person' : 'bot',
iconSize: 12,
iconColor: Theme.textMuted
})
.margin({ right: 4 })
Text(c.last_preview) Text(c.last_preview)
.fontSize(11).fontColor(Theme.textMuted) .fontSize(11).fontColor(Theme.textMuted)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis }) .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
@ -1488,6 +1502,8 @@ struct ContactsTab {
@Component @Component
struct MainPage { struct MainPage {
@State currentIndex: number = 0; @State currentIndex: number = 0;
/** 宽屏模式(≥768vp):复刻 WebUI 的三栏布局(Sidebar | 内容,无底部导航条) */
@State isWide: boolean = false;
/* /*
* 壁纸层(背景的**画法**在 `model/Wallpaper.ts` 里算好,这里只负责画)。 * 壁纸层(背景的**画法**在 `model/Wallpaper.ts` 里算好,这里只负责画)。
* *
@ -1737,9 +1753,11 @@ struct MainPage {
* 缺的是**图标这一半**:原来只有 label 上了色,图标一直是默认色, * 缺的是**图标这一半**:原来只有 label 上了色,图标一直是默认色,
* 看上去像「选中了一半」。两处都过同一个三元式,避免以后只改一处。 * 看上去像「选中了一半」。两处都过同一个三元式,避免以后只改一处。
*/ */
Text(item.icon) AmIcon({
.fontSize(20) iconName: item.iconKey,
.fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg) iconSize: 22,
iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg
})
Text(item.label) Text(item.label)
.fontSize(Theme.fontTiny) .fontSize(Theme.fontTiny)
.fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg) .fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)
@ -1849,39 +1867,56 @@ struct MainPage {
* 内容按 `currentIndex` 挂载(原来这里是系统 `Tabs` 的 TabContent)。 * 内容按 `currentIndex` 挂载(原来这里是系统 `Tabs` 的 TabContent)。
* 底部**必须让出** NAV_CONTENT_RESERVE 的高度:条是浮在内容之上的, * 底部**必须让出** NAV_CONTENT_RESERVE 的高度:条是浮在内容之上的,
* 不让出这一段,列表最后一行就永远压在玻璃条底下 —— 看得见、点不到。 * 不让出这一段,列表最后一行就永远压在玻璃条底下 —— 看得见、点不到。
* 宽屏没有底部导航条,所以 padding 为 0。
*/ */
Column() { Row() {
if (this.currentIndex === 0) { if (this.isWide) {
CommPage({ bgActive: this.bgActive }) WideSidebar({
} else if (this.currentIndex === 2) { currentIndex: this.currentIndex,
ContactsTab({ bgActive: this.bgActive }) onSelect: (index: number) => { this.currentIndex = normalizeNavIndex(index); },
onSettings: () => { this.getUIContext().getRouter().pushUrl({ url: 'pages/SettingsPage' }); }
})
} }
/*
* 日历与另外两个 pane 不同:**常驻挂载**,用 `visibility` 控制显示。
*
* 理由两条,都不是美观问题:
* ① `today` 是日历里**唯一随时间变**的输入,而日历是"放着不动的 pane"。
* 卸载重挂会重算(`aboutToAppear`),但"一直开着跨过午夜"不会 ——
* 常驻 + `@Watch(visible)` 才能在它重新可见时重算
* (`docs/DEBTS.json` 的 `calendar-today-recompute`)。
* ② 常驻顺带保住"正在看哪个月",切页签回来不会被重置回本月。
* 常驻 ≠ 常拉:首次**可见**时才发请求(见 `CalendarPage.onVisibleChanged`)。
*/
Column() { Column() {
CalendarPage({ bgActive: this.bgActive, visible: this.currentIndex === 1 }) if (this.currentIndex === 0) {
CommPage({ bgActive: this.bgActive })
} else if (this.currentIndex === 2) {
ContactsTab({ bgActive: this.bgActive })
}
/*
* 日历与另外两个 pane 不同:**常驻挂载**,用 `visibility` 控制显示。
*
* 理由两条,都不是美观问题:
* ① `today` 是日历里**唯一随时间变**的输入,而日历是"放着不动的 pane"。
* 卸载重挂会重算(`aboutToAppear`),但"一直开着跨过午夜"不会 ——
* 常驻 + `@Watch(visible)` 才能在它重新可见时重算
* (`docs/DEBTS.json` 的 `calendar-today-recompute`)。
* ② 常驻顺带保住"正在看哪个月",切页签回来不会被重置回本月。
* 常驻 ≠ 常拉:首次**可见**时才发请求(见 `CalendarPage.onVisibleChanged`)。
*/
Column() {
CalendarPage({ bgActive: this.bgActive, visible: this.currentIndex === 1 })
}
.width('100%')
.height('100%')
.visibility(this.currentIndex === 1 ? Visibility.Visible : Visibility.None)
} }
.width('100%') .width('100%')
.height('100%') .height('100%')
.visibility(this.currentIndex === 1 ? Visibility.Visible : Visibility.None) .padding({ bottom: this.isWide ? 0 : NAV_CONTENT_RESERVE })
} }
.width('100%') .width('100%')
.height('100%') .height('100%')
.padding({ bottom: NAV_CONTENT_RESERVE })
// 浮动玻璃条浮在最上层(自绘,见 NavBar 的说明) // 浮动玻璃条浮在最上层(自绘,见 NavBar 的说明)。宽屏没有底部导航。
this.NavBar() if (!this.isWide) {
this.NavBar()
}
} }
.width('100%') .width('100%')
.height('100%') .height('100%')
.onAreaChange((oldValue: Area, newValue: Area) => {
this.isWide = (newValue.width as number) >= 768;
})
} }
} }

View File

@ -72,7 +72,7 @@ struct SessionsPage {
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
} else if (this.sessions.length === 0) { } else if (this.sessions.length === 0) {
Column() { Column() {
Text('📭 暂无会话').fontSize(16).fontColor(Theme.textSubtle) Text('暂无会话').fontSize(16).fontColor(Theme.textSubtle)
} }
.width('100%').layoutWeight(1) .width('100%').layoutWeight(1)
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)

View File

@ -5,6 +5,8 @@
import { ApiClient, ApiError } from '../api/ApiClient'; import { ApiClient, ApiError } from '../api/ApiClient';
import { Theme } from '../common/Theme'; import { Theme } from '../common/Theme';
import { AuthApi } from '../api/AuthApi'; import { AuthApi } from '../api/AuthApi';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
import { AccountManager, AccountInfo } from '../api/AccountManager'; import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SseService } from '../api/SseService'; import { SseService } from '../api/SseService';
import { AppearanceStore } from '../common/AppearanceStore'; import { AppearanceStore } from '../common/AppearanceStore';
@ -15,6 +17,8 @@ import { AdminUser } from '../model/Models';
import { isAdminRole } from '../model/AdminUsers'; import { isAdminRole } from '../model/AdminUsers';
import { ApiBaseCheck, validateApiBase } from '../model/ApiBase'; import { ApiBaseCheck, validateApiBase } from '../model/ApiBase';
import { BackgroundPicker } from '../common/BackgroundPicker'; import { BackgroundPicker } from '../common/BackgroundPicker';
import { PushService } from '../api/PushService';
import { PushTokenList } from '../model/PushContract';
@Entry @Entry
@Component @Component
@ -35,6 +39,12 @@ struct SettingsPage {
@State appearanceStatus: string = 'local-only'; @State appearanceStatus: string = 'local-only';
/** 当前账号是不是管理员 —— 只用来决定「管理」入口显不显示(口径见 model/AdminUsers.ts 的 isAdminRole) */ /** 当前账号是不是管理员 —— 只用来决定「管理」入口显不显示(口径见 model/AdminUsers.ts 的 isAdminRole) */
@State isAdmin: boolean = false; @State isAdmin: boolean = false;
/*
* ★ 2026-09-15:接入系统通知 = **可配置项**(用户强调多次:“用户可以自由部署自己的 agentmail”)。
* 默认 **关** —— 没开就不取 token / 不弹权限 / 不打网关。开关持久化在 PushService 的偏好里。
*/
@State pushEnabled: boolean = false;
@State pushServerStatus: string = 'n/a';
/* /*
* 背景四值(P4c):由选择器(`BackgroundPicker`,@Link 双向绑)改, * 背景四值(P4c):由选择器(`BackgroundPicker`,@Link 双向绑)改,
* 用户改一次就回调 `onUserChanged` ⇒ 这里推服务端 —— 与 `setTheme` 同一形状。 * 用户改一次就回调 `onUserChanged` ⇒ 这里推服务端 —— 与 `setTheme` 同一形状。
@ -63,6 +73,7 @@ struct SettingsPage {
this.refreshList(); this.refreshList();
this.loadAppearance(); this.loadAppearance();
this.loadRole(); this.loadRole();
this.loadPushSetting();
}); });
} }
} }
@ -152,6 +163,53 @@ struct SettingsPage {
} }
} }
/**
* ★ 2026-09-15:接入系统通知 = **可配置项**(默认关)。
* 读一次持久化开关回填 @State,顺带看服务端有没有配推送(enabled 字段)。
* 服务端没配/读不到 → 明确告诉用户“服务器未配置推送” —— 但开关本身照常可用。
*/
async loadPushSetting(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.pushEnabled = PushService.isEnabled(ctx);
// 顺带读一次服务端 enabled(有账号时);读不到也是正常态(自部署没配凭证)
const client: ApiClient | null = this.client;
if (client === null) {
this.pushServerStatus = 'n/a';
return;
}
try {
const resp: PushTokenList = await client.get<PushTokenList>('/me/devices/push-token');
this.pushServerStatus = resp.enabled ? 'server-on' : 'server-off';
} catch (e) {
this.pushServerStatus = 'n/a';
}
}
/** 用户拨动开关:写持久化;开了就立刻补一次上报(不开就不碰推送) */
async togglePush(enabled: boolean): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.pushEnabled = enabled;
PushService.setEnabled(ctx, enabled);
if (!enabled) {
return;
}
// 开了:立刻取 token + 申请权限 + 上报(全部静默,失败下次启动再试)
const client: ApiClient | null = this.client;
if (client !== null) {
// 页面里的 `getHostContext()` 返回 `Context`;这里就是在 ability 里(UIAbilityContext 是其子类)
const push: PushService = PushService.getInstance(ctx as common.UIAbilityContext);
push.reportToken(client).catch((err: Object) => {
hilog.info(0x0001, 'SettingsPage', '开后立即上报失败(静默):%{public}s', JSON.stringify(err));
});
}
}
/** /**
* 拉一次外观:按合并规则落地(规则在 `model/Appearance.ts`,判据跑那一份)。 * 拉一次外观:按合并规则落地(规则在 `model/Appearance.ts`,判据跑那一份)。
* *
@ -290,7 +348,7 @@ struct SettingsPage {
const username: string = optionalUsername.length > 0 ? optionalUsername : user.username; const username: string = optionalUsername.length > 0 ? optionalUsername : user.username;
const account: AccountInfo = await manager.addAccount(server, username, token, displayName); const account: AccountInfo = await manager.addAccount(server, username, token, displayName);
SseService.getInstance().connectForAccount(account.id, account.server, account.token); SseService.getInstance().connectForAccount(account.id, account.server, account.token);
this.getUIContext().getPromptAction().showToast({ message: '✅ 添加成功: ' + account.displayName }); this.getUIContext().getPromptAction().showToast({ message: '添加成功:' + account.displayName });
this.showAddDialog = false; this.showAddDialog = false;
this.newDisplayName = ''; this.newDisplayName = '';
this.newServer = ''; this.newServer = '';
@ -324,7 +382,7 @@ struct SettingsPage {
if (this.accounts.length === 0) { if (this.accounts.length === 0) {
Column() { Column() {
Text('📭 暂无账号').fontSize(16).fontColor(Theme.textMuted) Text('暂无账号').fontSize(16).fontColor(Theme.textMuted)
Button('添加第一个账号') Button('添加第一个账号')
.margin({ top: 16 }).backgroundColor(Theme.accent) .margin({ top: 16 }).backgroundColor(Theme.accent)
.onClick(() => { this.showAddDialog = true; }) .onClick(() => { this.showAddDialog = true; })
@ -407,6 +465,41 @@ struct SettingsPage {
.backgroundColor(Theme.surface) .backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard) .borderRadius(Theme.radiusCard)
/*
* ── 系统通知(★ 2026-09-15:**可配置项**,默认关)──
*
* 用户可以自由部署自己的 agentmail:没有服务端推送凭证时,
* 关着 = 不取 token / 不弹权限 / 不打网关,零开销;
* 开了 = 取 token + 申请通知权限 + 上报服务端(全部静默,失败只写日志)。
*/
Column() {
Row() {
Column() {
Text('系统通知').fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
Text(this.pushServerStatus === 'server-off'
? '服务器未配置推送'
: (this.pushServerStatus === 'server-on' ? '服务器已开启推送通道' : '推送通道状态未知'))
.fontSize(11)
.fontColor(this.pushServerStatus === 'server-on' ? Theme.textMuted : Theme.warnFg)
.margin({ top: 2 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Toggle({ type: ToggleType.Switch, isOn: this.pushEnabled })
.onChange((isOn: boolean) => {
this.togglePush(isOn);
})
}
.width('100%')
Text('新邮件到达时由系统弹通知(点通知直达邮件)。未配置服务端推送时请保持关闭。')
.fontSize(11).fontColor(Theme.textSubtle).margin({ top: 6 })
}
.width('100%').alignItems(HorizontalAlign.Start)
.padding(16).margin({ top: 8 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
/* /*
* ── 管理入口(仅管理员可见)── * ── 管理入口(仅管理员可见)──
* *

View File

@ -0,0 +1,80 @@
/*
* 宽屏侧栏图标轨 —— **一比一复刻 WebUI 的 `Sidebar.tsx`**。
*
* WebUI 的 `Sidebar`(60px 宽)是一列图标按钮:
* · 品牌标(顶部)
* · 通信 / 日历 / 联系人(中部)
* · 设置(底部)
* 选中态:文字/图标变色 + 品牌浅底(`nav-active-bg`),**不加背景块、不加指示条**
* (用户 2026-09-14:「同时底部导航栏选中对应的文字和图标变色即可」)。
*
* 鸿蒙这一侧原先**完全没有宽屏布局**(只有底部导航条),所以这个文件同时承担
* "宽屏入口"的角色。判据 `harmony-widescreen.test.mjs` 会检查:
* · 60vp 宽(与 WebUI 的 60px 一致)
* · 三项导航 + 设置按钮
* · 选中态只换颜色(与底部导航同一套纪律)
*/
import { Theme } from '../common/Theme';
import { AmIcon } from '../common/Icons';
import { NAV_ITEMS, NavItem } from '../model/NavItems';
/** 侧栏宽度(vp)。WebUI 的 `Sidebar` 用 `w-[60px]` */
export const SIDEBAR_WIDTH: number = 60;
@Component
export struct WideSidebar {
/** 当前选中的导航下标 */
@Prop currentIndex: number = 0;
/** 选中回调 */
onSelect: (index: number) => void = (): void => {};
/** 设置回调 */
onSettings: () => void = (): void => {};
@Builder
NavItemBuilder(key: string, label: string, iconKey: string, index: number) {
Column() {
AmIcon({
iconName: iconKey,
iconSize: 22,
iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg
})
Text(label)
.fontSize(Theme.fontTiny)
.fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)
.margin({ top: 2 })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => { this.onSelect(index); })
}
build() {
Column() {
// 品牌标
AmIcon({ iconName: 'brandMark', iconSize: 26, iconColor: Theme.accent })
.margin({ bottom: 16 })
// 导航项
ForEach(NAV_ITEMS, (item: NavItem, index: number) => {
this.NavItemBuilder(item.key, item.label, item.iconKey, index)
}, (item: NavItem) => item.key)
Blank()
// 设置
Column() {
AmIcon({ iconName: 'person', iconSize: 22, iconColor: Theme.navFg })
Text('我的').fontSize(Theme.fontTiny).fontColor(Theme.navFg).margin({ top: 2 })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => { this.onSettings(); })
}
.width(SIDEBAR_WIDTH)
.height('100%')
.padding({ top: 12, bottom: 12 })
.backgroundColor(Theme.surface)
.borderRadius(Theme.radiusCard)
}
}