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 要归零(没有底部条了)');
|
||||
});
|
||||
77
client/harmony/entry/oh-package-lock.json5
Normal file
77
client/harmony/entry/oh-package-lock.json5
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,9 @@
|
||||
"main": "",
|
||||
"author": "",
|
||||
"license": "",
|
||||
"dependencies": {}
|
||||
}
|
||||
|
||||
"dependencies": {
|
||||
"@luvi/lv-markdown-in": "^3.4.6"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"dynamicDependencies": {}
|
||||
}
|
||||
@ -37,6 +37,14 @@ const DOMAIN: number = 0x0001;
|
||||
const TAG: string = 'PushService';
|
||||
const STORE: string = 'push_store';
|
||||
const MARKER_KEY: string = 'reported_marker';
|
||||
const SETTING_KEY: string = 'push_enabled';
|
||||
|
||||
/*
|
||||
* ★ 2026-09-15:接入系统通知是**可配置项**(用户强调多次)。
|
||||
* 默认 **关** —— 自部署的用户没有服务端推送凭证时,App 不取 token、不请求权限、不上报、不打网关。
|
||||
* 开了以后才走 getToken → requestEnableNotification → 上报这一整条。
|
||||
* 这条开关存在 PushService 里(而不是设置页 @State),是因为 onCreate(ability 阶段)要读它。
|
||||
*/
|
||||
|
||||
/** 点击跳转的目标(由通知 data 解析而来;只有两样东西要路由) */
|
||||
export interface PushRoute {
|
||||
@ -72,6 +80,31 @@ export class PushService {
|
||||
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、没登录华为账号、没权限……都是正常情况)。
|
||||
*/
|
||||
@ -132,6 +165,15 @@ export class PushService {
|
||||
* 放在启动后调用(登录成功之后、或已有账号时),调用方**不需要** catch —— 它自己内部全静默。
|
||||
*/
|
||||
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();
|
||||
if (token.length === 0) {
|
||||
return; // 没有 token 就没什么可报的(这不是错误)
|
||||
|
||||
102
client/harmony/entry/src/main/ets/common/Icons.ets
Normal file
102
client/harmony/entry/src/main/ets/common/Icons.ets
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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.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、失败全静默** —— 启动不能被网络/权限阻塞,也不能因为推送不可用而报错。
|
||||
* 冷启时还没登录 ⇒ POST 会 401,静默忽略;标记没写成功 ⇒ 下次启动会再试一次。
|
||||
* ★ reportToken 内部先查开关(PushService.isEnabled,默认关):关着就不取 token、
|
||||
* 不弹权限、不打网关(自部署零开销)。
|
||||
*/
|
||||
try {
|
||||
const push: PushService = PushService.getInstance(this.context);
|
||||
|
||||
@ -14,8 +14,20 @@ export interface NavItem {
|
||||
key: string;
|
||||
/** 显示文案(与 WebUI 逐字一致) */
|
||||
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」写判据的。
|
||||
*/
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ key: 'comm', label: '通信', icon: '✉️' },
|
||||
{ key: 'calendar', label: '日历', icon: '📅' },
|
||||
{ key: 'contacts', label: '联系人', icon: '👤' }
|
||||
{ key: 'comm', label: '通信', iconKey: 'inbox' },
|
||||
{ key: 'calendar', label: '日历', iconKey: 'calendar' },
|
||||
{ key: 'contacts', label: '联系人', iconKey: 'contacts' }
|
||||
];
|
||||
|
||||
/** 命中区下限(vp)。**44 是可点区域的下限**,不是"看起来够大"的估计 */
|
||||
|
||||
@ -12,6 +12,18 @@
|
||||
|
||||
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` */
|
||||
export interface PushRegisterBody {
|
||||
provider: string;
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
AdminUpdateUserInput
|
||||
} from '../model/Models';
|
||||
import { toggled, lastLoginLabel, messageOfApiError, isAdminRole, isRestricted } from '../model/AdminUsers';
|
||||
import { AmIcon } from '../common/Icons';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
@ -267,8 +268,11 @@ struct AdminUsersPage {
|
||||
this.Header()
|
||||
|
||||
if (this.roleKnown && !this.isAdmin) {
|
||||
Column() {
|
||||
Text('🔒 管理入口仅管理员可见').fontSize(Theme.fontBody).fontColor(Theme.textMuted)
|
||||
Column() {
|
||||
Row() {
|
||||
AmIcon({ iconName: 'lock', iconSize: 16, iconColor: Theme.textMuted })
|
||||
Text('管理入口仅管理员可见').fontSize(Theme.fontBody).fontColor(Theme.textMuted).margin({ left: 6 })
|
||||
}
|
||||
Text('当前账号的角色不是管理员。')
|
||||
.fontSize(Theme.fontSmall).fontColor(Theme.textSubtle).margin({ top: 6 })
|
||||
}
|
||||
@ -326,8 +330,7 @@ struct AdminUsersPage {
|
||||
Text('管理').fontSize(16).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
.layoutWeight(1)
|
||||
// 刷新:管理页的动作个个改服务端状态,看不到最新值会让人怀疑自己刚才点没点上
|
||||
Text('↻').fontSize(20).fontColor(Theme.accent).width(40).height(40)
|
||||
.textAlign(TextAlign.Center)
|
||||
AmIcon({ iconName: 'repeat', iconSize: 20, iconColor: Theme.accent }).width(40).height(40)
|
||||
.onClick(() => { this.load(); })
|
||||
}
|
||||
.width('100%').height(56).padding({ left: 8, right: 8 })
|
||||
|
||||
@ -11,6 +11,7 @@ import { SendMailRequest } from '../model/Models';
|
||||
import { ComposeParams } from '../model/RouteParams';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { picker } from '@kit.CoreFileKit';
|
||||
import { AmIcon } from '../common/Icons';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
@ -165,7 +166,7 @@ struct ComposePage {
|
||||
request.attachment_ids = this.attachmentIds;
|
||||
}
|
||||
await mailApi.send(request);
|
||||
this.getUIContext().getPromptAction().showToast({ message: '✅ 邮件已发送' });
|
||||
this.getUIContext().getPromptAction().showToast({ message: '邮件已发送' });
|
||||
this.getUIContext().getRouter().back();
|
||||
} catch (e) {
|
||||
const apiError = e as ApiError;
|
||||
@ -228,7 +229,7 @@ struct ComposePage {
|
||||
.fontWeight(this.selectedAccountId === acct.id ? FontWeight.Bold : FontWeight.Normal)
|
||||
.layoutWeight(1)
|
||||
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 })
|
||||
@ -279,12 +280,13 @@ struct ComposePage {
|
||||
Column() {
|
||||
ForEach(this.attachmentNames, (name: string, idx: number) => {
|
||||
Row() {
|
||||
Text('📎 ' + name)
|
||||
AmIcon({ iconName: 'paperclip', iconSize: 12, iconColor: Theme.textMuted })
|
||||
Text(name)
|
||||
.fontSize(12).fontColor(Theme.textPrimary)
|
||||
.margin({ left: 6 })
|
||||
.layoutWeight(1)
|
||||
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text('✕')
|
||||
.fontSize(14).fontColor(Theme.danger)
|
||||
AmIcon({ iconName: 'close', iconSize: 14, iconColor: Theme.danger })
|
||||
.onClick(() => { this.removeAttachment(idx); })
|
||||
}
|
||||
.width('100%').height(32)
|
||||
@ -297,9 +299,12 @@ struct ComposePage {
|
||||
|
||||
// 底部工具栏
|
||||
Row() {
|
||||
Text('📎 添加附件')
|
||||
.fontSize(13).fontColor(Theme.accent)
|
||||
.onClick(() => { this.pickAttachment(); })
|
||||
Row() {
|
||||
AmIcon({ iconName: 'paperclip', iconSize: 14, iconColor: Theme.accent })
|
||||
Text('添加附件')
|
||||
.fontSize(13).fontColor(Theme.accent).margin({ left: 6 })
|
||||
.onClick(() => { this.pickAttachment(); })
|
||||
}
|
||||
|
||||
if (this.uploading) {
|
||||
Text(this.status)
|
||||
|
||||
@ -7,6 +7,7 @@ import { Theme } from '../common/Theme';
|
||||
import { MailApi } from '../api/MailApi';
|
||||
import { MailSummary, Me } from '../model/Models';
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { AmIcon } from '../common/Icons';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
@ -97,7 +98,7 @@ struct InboxPage {
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else if (this.mails.length === 0) {
|
||||
Column() {
|
||||
Text('📭 收件箱为空').fontSize(16).fontColor(Theme.textSubtle)
|
||||
Text('收件箱为空').fontSize(16).fontColor(Theme.textSubtle)
|
||||
}
|
||||
.width('100%').height('80%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
@ -126,7 +127,7 @@ struct InboxPage {
|
||||
Column() {
|
||||
Row() {
|
||||
Column() {
|
||||
Text('📬').fontSize(20)
|
||||
AmIcon({ iconName: 'inbox', iconSize: 20, iconColor: Theme.accent })
|
||||
Text('收件箱').fontSize(11).fontColor(Theme.accent)
|
||||
}
|
||||
.layoutWeight(1).height('100%')
|
||||
@ -134,7 +135,7 @@ struct InboxPage {
|
||||
.backgroundColor(Theme.accentSoft)
|
||||
|
||||
Column() {
|
||||
Text('💬').fontSize(20)
|
||||
AmIcon({ iconName: 'chatBubble', iconSize: 20, iconColor: Theme.textMuted })
|
||||
Text('会话').fontSize(11).fontColor(Theme.textMuted)
|
||||
}
|
||||
.layoutWeight(1).height('100%')
|
||||
@ -144,7 +145,7 @@ struct InboxPage {
|
||||
})
|
||||
|
||||
Column() {
|
||||
Text('👤').fontSize(20)
|
||||
AmIcon({ iconName: 'contacts', iconSize: 20, iconColor: Theme.textMuted })
|
||||
Text('联系人').fontSize(11).fontColor(Theme.textMuted)
|
||||
}
|
||||
.layoutWeight(1).height('100%')
|
||||
@ -193,7 +194,7 @@ struct InboxPage {
|
||||
|
||||
// 附件图标
|
||||
if (mail.has_attachments) {
|
||||
Text('📎').fontSize(14).margin({ right: 8 })
|
||||
AmIcon({ iconName: 'paperclip', iconSize: 14, iconColor: Theme.textMuted }).margin({ right: 8 })
|
||||
}
|
||||
|
||||
// 权限档位徽标
|
||||
|
||||
@ -187,7 +187,7 @@ struct LoginPage {
|
||||
})
|
||||
|
||||
if (this.addrWarning.length > 0) {
|
||||
Text('⚠ ' + this.addrWarning)
|
||||
Text(this.addrWarning)
|
||||
.fontSize(11).fontColor(Theme.warnFg)
|
||||
.width('85%').margin({ bottom: 12 })
|
||||
}
|
||||
|
||||
@ -5,10 +5,13 @@
|
||||
*/
|
||||
import { ApiClient, ApiError } from '../api/ApiClient';
|
||||
import { Theme } from '../common/Theme';
|
||||
/* Markdown 渲染(第三方库,鸿蒙原生 ArkTS 引擎,不依赖 WebView)—— 正文用它,不再吐原始 Markdown */
|
||||
import { Markdown } from '@luvi/lv-markdown-in';
|
||||
import { MailApi } from '../api/MailApi';
|
||||
import { AccountManager, AccountInfo } from '../api/AccountManager';
|
||||
import { MailDetail, SendMailRequest } from '../model/Models';
|
||||
import { MailDetailParams } from '../model/RouteParams';
|
||||
import { AmIcon } from '../common/Icons';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
@ -207,12 +210,23 @@ struct MailDetailPage {
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 16, bottom: 8 })
|
||||
|
||||
Text(this.body)
|
||||
.fontSize(15)
|
||||
.fontColor(Theme.textPrimary)
|
||||
/*
|
||||
* 邮件正文:**用 Markdown 渲染**,不再直接吐原文。
|
||||
*
|
||||
* 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%')
|
||||
.padding({ left: 16, right: 16, bottom: 16 })
|
||||
.lineHeight(24)
|
||||
|
||||
Blank().height(60)
|
||||
}
|
||||
@ -223,7 +237,12 @@ struct MailDetailPage {
|
||||
|
||||
// 底部回复按钮
|
||||
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)
|
||||
.backgroundColor(Theme.accent)
|
||||
.fontSize(15)
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
*/
|
||||
import { ApiClient, ApiError } from '../api/ApiClient';
|
||||
import { Theme } from '../common/Theme';
|
||||
import { AmIcon } from '../common/Icons';
|
||||
import { WideSidebar } from './WideSidebar';
|
||||
import { MailApi, InboxResponse } from '../api/MailApi';
|
||||
import { AccountManager, AccountInfo } from '../api/AccountManager';
|
||||
import { SseService, SseEvent } from '../api/SseService';
|
||||
@ -140,7 +142,7 @@ struct InboxTab {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.getUIContext().getPromptAction().showToast({ message: sourceName.length > 0 ? '📨 ' + sourceName + ' 收到新邮件' : '📨 新邮件到达' });
|
||||
this.getUIContext().getPromptAction().showToast({ message: sourceName.length > 0 ? '新邮件:' + sourceName : '新邮件到达' });
|
||||
this.loadData();
|
||||
}
|
||||
};
|
||||
@ -382,7 +384,7 @@ struct InboxTab {
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else if (this.mails.length === 0) {
|
||||
Column() {
|
||||
Text('📭 收件箱为空').fontSize(16).fontColor(Theme.textSubtle)
|
||||
Text('收件箱为空').fontSize(16).fontColor(Theme.textSubtle)
|
||||
}
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else {
|
||||
@ -762,7 +764,7 @@ struct SentTab {
|
||||
Column() { LoadingProgress().width(32).height(32) }
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} 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)
|
||||
} else if (this.groups.length === 0) {
|
||||
/*
|
||||
@ -771,7 +773,7 @@ struct SentTab {
|
||||
* 副句说清这一栏是干什么的。
|
||||
*/
|
||||
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(emptyHint('sent')).fontSize(12).fontColor(Theme.textSubtle).margin({ top: 6 })
|
||||
}
|
||||
@ -1020,11 +1022,11 @@ struct PermissionTab {
|
||||
Column() { LoadingProgress().width(32).height(32) }
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} 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)
|
||||
} else if (this.requests.length === 0) {
|
||||
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(emptyHint('permissions')).fontSize(12).fontColor(Theme.textSubtle).margin({ top: 6 })
|
||||
}
|
||||
@ -1189,12 +1191,17 @@ struct CommPage {
|
||||
Row() {
|
||||
Text('通信').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
Blank()
|
||||
Text('⚙')
|
||||
.fontSize(20).fontColor(Theme.textMuted)
|
||||
.width(36).height(36).textAlign(TextAlign.Center)
|
||||
.onClick(() => {
|
||||
this.getUIContext().getRouter().pushUrl({ url: 'pages/SettingsPage' });
|
||||
})
|
||||
/*
|
||||
* 设置入口。
|
||||
* 图标用 `person`:与 WebUI 的「我的」(`NarrowNav.tsx` 的 `PersonIcon`)同一枚,
|
||||
* 而设置页里正是账号 / 外观 / 密钥这些“属于这个人的东西”。
|
||||
* 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 })
|
||||
.backgroundColor(Theme.surface)
|
||||
@ -1282,9 +1289,12 @@ struct ContactsTab {
|
||||
Row() {
|
||||
Text(contactViewTitle(this.contactView)).fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary)
|
||||
Blank()
|
||||
Text(this.contactView === 'list' ? '▦' : '☰')
|
||||
.fontSize(18).fontColor(Theme.textMuted)
|
||||
.width(40).height(40).textAlign(TextAlign.Center)
|
||||
AmIcon({
|
||||
iconName: this.contactView === 'list' ? 'cardView' : 'listView',
|
||||
iconSize: 18,
|
||||
iconColor: Theme.textMuted
|
||||
})
|
||||
.width(40).height(40)
|
||||
.onClick(() => { this.switchView(); })
|
||||
}
|
||||
.width('100%').height(56).padding({ left: 16, right: 8 })
|
||||
@ -1303,7 +1313,7 @@ struct ContactsTab {
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else if (this.contacts.length === 0) {
|
||||
Column() {
|
||||
Text('📭 暂无联系人').fontSize(16).fontColor(Theme.textSubtle)
|
||||
Text('暂无联系人').fontSize(16).fontColor(Theme.textSubtle)
|
||||
}
|
||||
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
|
||||
} else if (this.contactView === 'card') {
|
||||
@ -1384,8 +1394,12 @@ struct ContactsTab {
|
||||
|
||||
if (c.last_preview.length > 0) {
|
||||
Row() {
|
||||
Text(lastFromIsHuman(c.agent_name, c.last_from) ? '👤' : '🤖')
|
||||
.fontSize(11).margin({ right: 4 })
|
||||
AmIcon({
|
||||
iconName: lastFromIsHuman(c.agent_name, c.last_from) ? 'person' : 'bot',
|
||||
iconSize: 12,
|
||||
iconColor: Theme.textMuted
|
||||
})
|
||||
.margin({ right: 4 })
|
||||
Text(c.last_preview)
|
||||
.fontSize(11).fontColor(Theme.textMuted)
|
||||
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
@ -1488,6 +1502,8 @@ struct ContactsTab {
|
||||
@Component
|
||||
struct MainPage {
|
||||
@State currentIndex: number = 0;
|
||||
/** 宽屏模式(≥768vp):复刻 WebUI 的三栏布局(Sidebar | 内容,无底部导航条) */
|
||||
@State isWide: boolean = false;
|
||||
/*
|
||||
* 壁纸层(背景的**画法**在 `model/Wallpaper.ts` 里算好,这里只负责画)。
|
||||
*
|
||||
@ -1737,9 +1753,11 @@ struct MainPage {
|
||||
* 缺的是**图标这一半**:原来只有 label 上了色,图标一直是默认色,
|
||||
* 看上去像「选中了一半」。两处都过同一个三元式,避免以后只改一处。
|
||||
*/
|
||||
Text(item.icon)
|
||||
.fontSize(20)
|
||||
.fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)
|
||||
AmIcon({
|
||||
iconName: item.iconKey,
|
||||
iconSize: 22,
|
||||
iconColor: this.currentIndex === index ? Theme.navFgActive : Theme.navFg
|
||||
})
|
||||
Text(item.label)
|
||||
.fontSize(Theme.fontTiny)
|
||||
.fontColor(this.currentIndex === index ? Theme.navFgActive : Theme.navFg)
|
||||
@ -1849,39 +1867,56 @@ struct MainPage {
|
||||
* 内容按 `currentIndex` 挂载(原来这里是系统 `Tabs` 的 TabContent)。
|
||||
* 底部**必须让出** NAV_CONTENT_RESERVE 的高度:条是浮在内容之上的,
|
||||
* 不让出这一段,列表最后一行就永远压在玻璃条底下 —— 看得见、点不到。
|
||||
* 宽屏没有底部导航条,所以 padding 为 0。
|
||||
*/
|
||||
Column() {
|
||||
if (this.currentIndex === 0) {
|
||||
CommPage({ bgActive: this.bgActive })
|
||||
} else if (this.currentIndex === 2) {
|
||||
ContactsTab({ bgActive: this.bgActive })
|
||||
Row() {
|
||||
if (this.isWide) {
|
||||
WideSidebar({
|
||||
currentIndex: this.currentIndex,
|
||||
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() {
|
||||
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%')
|
||||
.height('100%')
|
||||
.visibility(this.currentIndex === 1 ? Visibility.Visible : Visibility.None)
|
||||
.padding({ bottom: this.isWide ? 0 : NAV_CONTENT_RESERVE })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding({ bottom: NAV_CONTENT_RESERVE })
|
||||
|
||||
// 浮动玻璃条浮在最上层(自绘,见 NavBar 的说明)
|
||||
this.NavBar()
|
||||
// 浮动玻璃条浮在最上层(自绘,见 NavBar 的说明)。宽屏没有底部导航。
|
||||
if (!this.isWide) {
|
||||
this.NavBar()
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.onAreaChange((oldValue: Area, newValue: Area) => {
|
||||
this.isWide = (newValue.width as number) >= 768;
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -72,7 +72,7 @@ struct SessionsPage {
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else if (this.sessions.length === 0) {
|
||||
Column() {
|
||||
Text('📭 暂无会话').fontSize(16).fontColor(Theme.textSubtle)
|
||||
Text('暂无会话').fontSize(16).fontColor(Theme.textSubtle)
|
||||
}
|
||||
.width('100%').layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
import { ApiClient, ApiError } from '../api/ApiClient';
|
||||
import { Theme } from '../common/Theme';
|
||||
import { AuthApi } from '../api/AuthApi';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { AccountManager, AccountInfo } from '../api/AccountManager';
|
||||
import { SseService } from '../api/SseService';
|
||||
import { AppearanceStore } from '../common/AppearanceStore';
|
||||
@ -15,6 +17,8 @@ import { AdminUser } from '../model/Models';
|
||||
import { isAdminRole } from '../model/AdminUsers';
|
||||
import { ApiBaseCheck, validateApiBase } from '../model/ApiBase';
|
||||
import { BackgroundPicker } from '../common/BackgroundPicker';
|
||||
import { PushService } from '../api/PushService';
|
||||
import { PushTokenList } from '../model/PushContract';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
@ -35,6 +39,12 @@ struct SettingsPage {
|
||||
@State appearanceStatus: string = 'local-only';
|
||||
/** 当前账号是不是管理员 —— 只用来决定「管理」入口显不显示(口径见 model/AdminUsers.ts 的 isAdminRole) */
|
||||
@State isAdmin: boolean = false;
|
||||
/*
|
||||
* ★ 2026-09-15:接入系统通知 = **可配置项**(用户强调多次:“用户可以自由部署自己的 agentmail”)。
|
||||
* 默认 **关** —— 没开就不取 token / 不弹权限 / 不打网关。开关持久化在 PushService 的偏好里。
|
||||
*/
|
||||
@State pushEnabled: boolean = false;
|
||||
@State pushServerStatus: string = 'n/a';
|
||||
/*
|
||||
* 背景四值(P4c):由选择器(`BackgroundPicker`,@Link 双向绑)改,
|
||||
* 用户改一次就回调 `onUserChanged` ⇒ 这里推服务端 —— 与 `setTheme` 同一形状。
|
||||
@ -63,6 +73,7 @@ struct SettingsPage {
|
||||
this.refreshList();
|
||||
this.loadAppearance();
|
||||
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`,判据跑那一份)。
|
||||
*
|
||||
@ -290,7 +348,7 @@ struct SettingsPage {
|
||||
const username: string = optionalUsername.length > 0 ? optionalUsername : user.username;
|
||||
const account: AccountInfo = await manager.addAccount(server, username, token, displayName);
|
||||
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.newDisplayName = '';
|
||||
this.newServer = '';
|
||||
@ -324,7 +382,7 @@ struct SettingsPage {
|
||||
|
||||
if (this.accounts.length === 0) {
|
||||
Column() {
|
||||
Text('📭 暂无账号').fontSize(16).fontColor(Theme.textMuted)
|
||||
Text('暂无账号').fontSize(16).fontColor(Theme.textMuted)
|
||||
Button('添加第一个账号')
|
||||
.margin({ top: 16 }).backgroundColor(Theme.accent)
|
||||
.onClick(() => { this.showAddDialog = true; })
|
||||
@ -407,6 +465,41 @@ struct SettingsPage {
|
||||
.backgroundColor(Theme.surface)
|
||||
.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)
|
||||
|
||||
/*
|
||||
* ── 管理入口(仅管理员可见)──
|
||||
*
|
||||
|
||||
80
client/harmony/entry/src/main/ets/pages/WideSidebar.ets
Normal file
80
client/harmony/entry/src/main/ets/pages/WideSidebar.ets
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user