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

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

View File

@ -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 })

View File

@ -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)

View File

@ -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 })
}
// 权限档位徽标

View File

@ -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 })
}

View File

@ -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)

View File

@ -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;
})
}
}

View File

@ -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)

View File

@ -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)
/*
* ── 管理入口(仅管理员可见)──
*

View File

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