diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index c6ecf02..3e2a961 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -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)})`); }); /** diff --git a/client/electron/test/harmony-push-optin.test.mjs b/client/electron/test/harmony-push-optin.test.mjs new file mode 100644 index 0000000..01aca63 --- /dev/null +++ b/client/electron/test/harmony-push-optin.test.mjs @@ -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, /服务器未配置推送/, '服务器没配通道时要说清(否则用户开了以为有用)'); +}); \ No newline at end of file diff --git a/client/electron/test/harmony-widescreen.test.mjs b/client/electron/test/harmony-widescreen.test.mjs new file mode 100644 index 0000000..ab84808 --- /dev/null +++ b/client/electron/test/harmony-widescreen.test.mjs @@ -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 要归零(没有底部条了)'); +}); \ No newline at end of file diff --git a/client/harmony/entry/oh-package-lock.json5 b/client/harmony/entry/oh-package-lock.json5 new file mode 100644 index 0000000..3cce798 --- /dev/null +++ b/client/harmony/entry/oh-package-lock.json5 @@ -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" + } + } +} \ No newline at end of file diff --git a/client/harmony/entry/oh-package.json5 b/client/harmony/entry/oh-package.json5 index 248c3b7..d2d9a6b 100644 --- a/client/harmony/entry/oh-package.json5 +++ b/client/harmony/entry/oh-package.json5 @@ -5,6 +5,9 @@ "main": "", "author": "", "license": "", - "dependencies": {} -} - + "dependencies": { + "@luvi/lv-markdown-in": "^3.4.6" + }, + "devDependencies": {}, + "dynamicDependencies": {} +} \ No newline at end of file diff --git a/client/harmony/entry/src/main/ets/api/PushService.ets b/client/harmony/entry/src/main/ets/api/PushService.ets index f866e84..609e47f 100644 --- a/client/harmony/entry/src/main/ets/api/PushService.ets +++ b/client/harmony/entry/src/main/ets/api/PushService.ets @@ -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 { + /* + * ★ 开关 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 就没什么可报的(这不是错误) diff --git a/client/harmony/entry/src/main/ets/common/Icons.ets b/client/harmony/entry/src/main/ets/common/Icons.ets new file mode 100644 index 0000000..968add0 --- /dev/null +++ b/client/harmony/entry/src/main/ets/common/Icons.ets @@ -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 = { + '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 `` 等价的用法。 + * + * `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) + } +} diff --git a/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets b/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets index 628c45a..c622ac9 100644 --- a/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets +++ b/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets @@ -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 = want.parameters === undefined + ? {} as Record + : want.parameters as Record; + 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); diff --git a/client/harmony/entry/src/main/ets/model/NavItems.ts b/client/harmony/entry/src/main/ets/model/NavItems.ts index dc19877..88c61aa 100644 --- a/client/harmony/entry/src/main/ets/model/NavItems.ts +++ b/client/harmony/entry/src/main/ets/model/NavItems.ts @@ -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 是可点区域的下限**,不是"看起来够大"的估计 */ diff --git a/client/harmony/entry/src/main/ets/model/PushContract.ts b/client/harmony/entry/src/main/ets/model/PushContract.ts index 01ccd1b..7453059 100644 --- a/client/harmony/entry/src/main/ets/model/PushContract.ts +++ b/client/harmony/entry/src/main/ets/model/PushContract.ts @@ -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; diff --git a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets index 8df20ec..f12bdbb 100644 --- a/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets +++ b/client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets @@ -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 }) diff --git a/client/harmony/entry/src/main/ets/pages/ComposePage.ets b/client/harmony/entry/src/main/ets/pages/ComposePage.ets index 4e37f6c..5adcd5d 100644 --- a/client/harmony/entry/src/main/ets/pages/ComposePage.ets +++ b/client/harmony/entry/src/main/ets/pages/ComposePage.ets @@ -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) diff --git a/client/harmony/entry/src/main/ets/pages/InboxPage.ets b/client/harmony/entry/src/main/ets/pages/InboxPage.ets index 3387487..9a68c51 100644 --- a/client/harmony/entry/src/main/ets/pages/InboxPage.ets +++ b/client/harmony/entry/src/main/ets/pages/InboxPage.ets @@ -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 }) } // 权限档位徽标 diff --git a/client/harmony/entry/src/main/ets/pages/LoginPage.ets b/client/harmony/entry/src/main/ets/pages/LoginPage.ets index d12020e..6a22172 100644 --- a/client/harmony/entry/src/main/ets/pages/LoginPage.ets +++ b/client/harmony/entry/src/main/ets/pages/LoginPage.ets @@ -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 }) } diff --git a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets index 492c5bb..2795c68 100644 --- a/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MailDetailPage.ets @@ -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) diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index acc13c2..1ff71fb 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -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; + }) } } \ No newline at end of file diff --git a/client/harmony/entry/src/main/ets/pages/SessionsPage.ets b/client/harmony/entry/src/main/ets/pages/SessionsPage.ets index 59e2431..80b406a 100644 --- a/client/harmony/entry/src/main/ets/pages/SessionsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SessionsPage.ets @@ -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) diff --git a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets index 39718a2..78c00de 100644 --- a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets @@ -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 { + 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('/me/devices/push-token'); + this.pushServerStatus = resp.enabled ? 'server-on' : 'server-off'; + } catch (e) { + this.pushServerStatus = 'n/a'; + } + } + + /** 用户拨动开关:写持久化;开了就立刻补一次上报(不开就不碰推送) */ + async togglePush(enabled: boolean): Promise { + 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) + /* * ── 管理入口(仅管理员可见)── * diff --git a/client/harmony/entry/src/main/ets/pages/WideSidebar.ets b/client/harmony/entry/src/main/ets/pages/WideSidebar.ets new file mode 100644 index 0000000..8a15e21 --- /dev/null +++ b/client/harmony/entry/src/main/ets/pages/WideSidebar.ets @@ -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) + } +}