chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,321 @@
/*
* AgentMail 鸿蒙客户端 — 写邮件页
* 收件人 / 主题 / 正文 / 附件选择 / 发送
* 路由参数可选: to, reply_to, session_alias
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { MailApi } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SendMailRequest } from '../model/Models';
import { ComposeParams } from '../model/RouteParams';
import { promptAction } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { picker } from '@kit.CoreFileKit';
@Entry
@Component
struct ComposePage {
@State to: string = '';
@State subject: string = '';
@State body: string = '';
@State replyTo: string = '';
@State sessionAlias: string = '';
@State attachmentIds: string[] = [];
@State attachmentNames: string[] = [];
@State sending: boolean = false;
@State uploading: boolean = false;
@State status: string = '';
// 发信账号选择
@State accountList: AccountInfo[] = [];
@State selectedAccountId: string = '';
@State showAccountPicker: boolean = false;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const params = this.getUIContext().getRouter().getParams() as ComposeParams;
const routeAccountId: string = params?.account_id ?? '';
this.to = params?.to ?? '';
this.replyTo = params?.reply_to ?? '';
this.sessionAlias = params?.session_alias ?? '';
const accountManager: AccountManager = AccountManager.getInstance(ctx);
accountManager.load().then(() => {
this.accountList = accountManager.getAccounts();
let selectedId: string = routeAccountId;
if (accountManager.getAccount(selectedId) === null) {
const active: AccountInfo | null = accountManager.getActiveAccount();
selectedId = active !== null ? active.id : '';
}
this.selectedAccountId = selectedId;
});
}
getSelectedAccount(): AccountInfo | null {
for (let i = 0; i < this.accountList.length; i++) {
if (this.accountList[i].id === this.selectedAccountId) {
return this.accountList[i];
}
}
return null;
}
createSelectedMailApi(ctx: Context): MailApi | null {
const account: AccountInfo | null = this.getSelectedAccount();
if (account === null) {
return null;
}
const accountClient: ApiClient = new ApiClient(ctx);
accountClient.setBase(account.server);
accountClient.setToken(account.token);
return new MailApi(accountClient);
}
/** 选择附件(通过文件选择器) */
async pickAttachment(): Promise<void> {
const ctx: Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined || this.uploading) {
return;
}
try {
const documentPicker = new picker.DocumentViewPicker(ctx);
const result: string[] = await documentPicker.select({
maxSelectNumber: 5,
fileSuffixFilters: ['*']
});
if (result.length === 0) {
return;
}
const mailApi: MailApi | null = this.createSelectedMailApi(ctx);
if (mailApi === null) {
promptAction.showToast({ message: '请先选择发信账号' });
return;
}
this.uploading = true;
this.status = '上传中…';
for (let i = 0; i < result.length; i++) {
const fileUri: string = result[i];
const nameStart: number = fileUri.lastIndexOf('/');
const name: string = nameStart >= 0 ? fileUri.substring(nameStart + 1) : fileUri;
try {
const attachmentId: string = await mailApi.uploadAttachment(fileUri, name);
this.attachmentIds.push(attachmentId);
this.attachmentNames.push(name);
hilog.info(0x0001, 'Compose', 'uploaded: %{public}s → %{public}s', name, attachmentId);
} catch (e) {
const apiError = e as ApiError;
promptAction.showToast({ message: '上传失败: ' + name + ' - ' + apiError.message });
}
}
this.status = this.attachmentIds.length + ' 个附件已上传';
} catch (e) {
this.status = '';
} finally {
this.uploading = false;
}
}
/** 移除附件 */
removeAttachment(index: number): void {
if (index >= 0 && index < this.attachmentIds.length) {
this.attachmentIds.splice(index, 1);
this.attachmentNames.splice(index, 1);
}
}
/** 发送邮件 */
async doSend(): Promise<void> {
if (this.sending) {
return;
}
if (this.to.length === 0) {
promptAction.showToast({ message: '请填写收件人' });
return;
}
if (this.subject.length === 0) {
promptAction.showToast({ message: '请填写主题' });
return;
}
const ctx: Context | undefined = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
const mailApi: MailApi | null = this.createSelectedMailApi(ctx);
if (mailApi === null) {
promptAction.showToast({ message: '请先选择发信账号' });
return;
}
this.sending = true;
try {
const request: SendMailRequest = new SendMailRequest();
request.to = this.to;
request.subject = this.subject;
request.body = this.body;
if (this.replyTo.length > 0) {
request.reply_to = this.replyTo;
}
if (this.sessionAlias.length > 0) {
request.session_alias = this.sessionAlias;
}
if (this.attachmentIds.length > 0) {
request.attachment_ids = this.attachmentIds;
}
await mailApi.send(request);
promptAction.showToast({ message: '✅ 邮件已发送' });
this.getUIContext().getRouter().back();
} catch (e) {
const apiError = e as ApiError;
promptAction.showToast({ message: '发送失败: ' + apiError.message });
} finally {
this.sending = false;
}
}
/** 获取选中的账号显示名 */
getSelectedAccountName(): string {
const account: AccountInfo | null = this.getSelectedAccount();
return account !== null ? account.displayName : '选择账号';
}
build() {
Column() {
// 顶栏
Row() {
Text('取消')
.fontSize(15).fontColor('#FF4444')
.onClick(() => { this.getUIContext().getRouter().back(); })
Blank()
Text('写邮件').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333')
Blank()
Button(this.sending ? '发送中…' : '发送')
.fontSize(13).height(32)
.backgroundColor('#1A73E8')
.enabled(!this.sending && !this.uploading)
.onClick(() => { this.doSend(); })
}
.width('100%').height(56)
.padding({ left: 12, right: 12 })
.backgroundColor('#FFFFFF')
Divider().color('#EEEEEE')
// 发信账号选择(多账号时显示)
if (this.accountList.length > 1) {
Row() {
Text('发信账号').fontSize(13).fontColor('#999999').width(72)
// 显示当前选中的账号名
Text(this.getSelectedAccountName())
.fontSize(13).fontColor('#1A73E8')
.layoutWeight(1)
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
Text(' ▾').fontSize(12).fontColor('#999999')
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
}
.width('100%').height(40).padding({ left: 12, right: 12 })
.backgroundColor('#FFFFFF')
// 账号选择下拉
if (this.showAccountPicker) {
ForEach(this.accountList, (acct: AccountInfo) => {
Row() {
Text(acct.displayName)
.fontSize(13)
.fontColor(this.selectedAccountId === acct.id ? '#1A73E8' : '#333333')
.fontWeight(this.selectedAccountId === acct.id ? FontWeight.Bold : FontWeight.Normal)
.layoutWeight(1)
if (this.selectedAccountId === acct.id) {
Text('✓').fontSize(14).fontColor('#1A73E8')
}
}
.width('100%').height(40).padding({ left: 40, right: 12 })
.backgroundColor(this.selectedAccountId === acct.id ? '#F0F7FF' : '#FFFFFF')
.onClick(() => {
this.selectedAccountId = acct.id;
this.showAccountPicker = false;
})
}, (acct: AccountInfo) => acct.id)
}
Divider().color('#F0F0F0')
}
// 收件人
Row() {
Text('收件人').fontSize(14).fontColor('#999999').width(60)
TextInput({ placeholder: 'name@path.session', text: this.to })
.layoutWeight(1).fontSize(14).backgroundColor('#00000000')
.onChange((v: string) => { this.to = v; })
}
.width('100%').height(48).padding({ left: 12, right: 12 })
.backgroundColor('#FFFFFF')
Divider().color('#F0F0F0')
// 主题
Row() {
Text('主题').fontSize(14).fontColor('#999999').width(60)
TextInput({ placeholder: '邮件主题', text: this.subject })
.layoutWeight(1).fontSize(14).backgroundColor('#00000000')
.onChange((v: string) => { this.subject = v; })
}
.width('100%').height(48).padding({ left: 12, right: 12 })
.backgroundColor('#FFFFFF')
Divider().color('#F0F0F0')
// 正文
TextArea({ placeholder: '输入邮件正文…' })
.layoutWeight(1).width('100%')
.fontSize(14)
.backgroundColor('#FFFFFF')
.onChange((v: string) => { this.body = v; })
// 附件区
if (this.attachmentNames.length > 0) {
Column() {
ForEach(this.attachmentNames, (name: string, idx: number) => {
Row() {
Text('📎 ' + name)
.fontSize(12).fontColor('#333333')
.layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text('✕')
.fontSize(14).fontColor('#FF4444')
.onClick(() => { this.removeAttachment(idx); })
}
.width('100%').height(32)
.padding({ left: 12, right: 12 })
.backgroundColor('#F8F9FA')
}, (_name: string, idx: number) => idx.toString())
}
.width('100%')
}
// 底部工具栏
Row() {
Text('📎 添加附件')
.fontSize(13).fontColor('#1A73E8')
.onClick(() => { this.pickAttachment(); })
if (this.uploading) {
Text(this.status)
.fontSize(11).fontColor('#666666')
.margin({ left: 12 })
} else if (this.status.length > 0) {
Text(this.status)
.fontSize(11).fontColor('#4CAF50')
.margin({ left: 12 })
}
}
.width('100%').height(44)
.padding({ left: 12, right: 12 })
.backgroundColor('#FFFFFF')
}
.width('100%').height('100%')
.backgroundColor('#F5F7FA')
}
}

View File

@ -0,0 +1,210 @@
/*
* AgentMail 鸿蒙客户端 — 收件箱页
* GET /me/inbox 分页,列表展示邮件摘要
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { MailApi } from '../api/MailApi';
import { MailSummary, Me } from '../model/Models';
import { promptAction, router } from '@kit.ArkUI';
@Entry
@Component
struct InboxPage {
@State mails: MailSummary[] = [];
@State loading: boolean = false;
@State limit: number = 20;
@State total: number = 0;
@State unread: number = 0;
@State me: Me = new Me();
@State error: string = '';
private client: ApiClient | null = null;
private mailApi: MailApi | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
const client: ApiClient = ApiClient.getInstance(ctx);
this.client = client;
this.mailApi = new MailApi(client);
this.loadInbox();
}
}
async loadInbox(): Promise<void> {
const c: ApiClient | null = this.client;
const m: MailApi | null = this.mailApi;
if (c === null || m === null) {
return;
}
this.loading = true;
this.error = '';
try {
const resp = await m.inbox('all', this.limit);
this.mails = resp.mails;
this.total = resp.total;
// 客户端统计未读(API 未提供顶层 unread)
let unreadCount: number = 0;
for (let i = 0; i < this.mails.length; i++) {
if (this.mails[i].status === 'unread') {
unreadCount++;
}
}
this.unread = unreadCount;
} catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '加载失败';
} finally {
this.loading = false;
}
}
build() {
Column() {
// 顶栏
Row() {
Text('收件箱')
.fontSize(20).fontWeight(FontWeight.Bold).fontColor('#333333')
Blank()
if (this.unread > 0) {
Text(this.unread + ' 未读')
.fontSize(13).fontColor('#FFFFFF')
.backgroundColor('#FF4444')
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor('#FFFFFF')
// 列表
if (this.loading) {
Column() {
LoadingProgress().width(40).height(40)
Text('加载中…').fontSize(14).fontColor('#999999').margin({ top: 8 })
}
.width('100%').height('80%')
.justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) {
Column() {
Text(this.error).fontSize(14).fontColor('#FF4444')
Button('重试').margin({ top: 12 }).onClick(() => { this.loadInbox(); })
}
.width('100%').height('80%')
.justifyContent(FlexAlign.Center)
} else if (this.mails.length === 0) {
Column() {
Text('📭 收件箱为空').fontSize(16).fontColor('#999999')
}
.width('100%').height('80%')
.justifyContent(FlexAlign.Center)
} else {
List({ space: 1 }) {
ForEach(this.mails, (mail: MailSummary) => {
ListItem() {
this.MailItem(mail)
}
.height(80)
.backgroundColor(mail.status === 'unread' ? '#F0F7FF' : '#FFFFFF')
.onClick(() => {
this.getUIContext().getRouter().pushUrl({
url: 'pages/MailDetailPage',
params: { mail_id: mail.mail_id }
});
})
}, (mail: MailSummary) => mail.mail_id)
}
.width('100%')
.layoutWeight(1)
.divider({ strokeWidth: 1, color: '#EEEEEE', startMargin: 16, endMargin: 16 })
}
// 底部 Tab 栏(预留系统导航栏空间)
Column() {
Row() {
Column() {
Text('📬').fontSize(20)
Text('收件箱').fontSize(11).fontColor('#1A73E8')
}
.layoutWeight(1).height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#E3F2FD')
Column() {
Text('💬').fontSize(20)
Text('会话').fontSize(11).fontColor('#666666')
}
.layoutWeight(1).height('100%')
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.getUIContext().getRouter().pushUrl({ url: 'pages/SessionsPage' });
})
Column() {
Text('👤').fontSize(20)
Text('联系人').fontSize(11).fontColor('#666666')
}
.layoutWeight(1).height('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%').height(56)
.backgroundColor('#FFFFFF')
}
.width('100%')
}
.width('100%').height('100%')
.backgroundColor('#F5F7FA')
}
@Builder
MailItem(mail: MailSummary) {
Row() {
// 未读圆点
Column() {
if (mail.status === 'unread') {
Circle({ width: 8, height: 8 }).fill('#1A73E8')
}
}
.width(20).height('100%')
.justifyContent(FlexAlign.Center)
// 正文
Column() {
Row() {
Text(mail.from_name).fontSize(14).fontWeight(mail.is_read ? FontWeight.Normal : FontWeight.Bold).fontColor('#333333')
Blank()
Text(mail.created_at).fontSize(11).fontColor('#999999')
}
.width('100%')
Text(mail.subject).fontSize(13).fontColor('#333333')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
Text(mail.body_preview).fontSize(12).fontColor('#999999')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
}
.layoutWeight(1).height('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
// 附件图标
if (mail.has_attachments) {
Text('📎').fontSize(14).margin({ right: 8 })
}
// 权限档位徽标
if (mail.permission_mode.length > 0) {
Text(mail.permission_mode)
.fontSize(10).fontColor('#666666')
.backgroundColor('#EEEEEE').borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
}
}
.width('100%').height('100%')
.padding({ left: 12, right: 12 })
.alignItems(VerticalAlign.Center)
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@Entry
@Component
struct Index {
@State message: string = 'Hello World';
build() {
RelativeContainer() {
Text(this.message)
.id('HelloWorld')
.fontSize($r('app.float.page_text_font_size'))
.fontWeight(FontWeight.Bold)
.alignRules({
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
.onClick(() => {
this.message = 'Welcome';
})
}
.height('100%')
.width('100%')
}
}

View File

@ -0,0 +1,213 @@
/*
* AgentMail 鸿蒙客户端 — 登录页
* 两种模式:账号密码(Cookie) / 用户密钥(Bearer)
* 服务器地址可配置(跨设备/模拟器场景)
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { AuthApi } from '../api/AuthApi';
import { AccountManager } from '../api/AccountManager';
import { SseService } from '../api/SseService';
import { Me } from '../model/Models';
import { DEFAULT_API_BASE, EMULATOR_HOST_BASE } from '../common/Config';
import { promptAction } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
@Entry
@Component
struct LoginPage {
@State serverAddr: string = DEFAULT_API_BASE;
@State username: string = '';
@State password: string = '';
@State userKey: string = '';
@State mode: number = 1; // 0 = 账号密码, 1 = 用户密钥
@State loading: boolean = false;
@State loggedIn: boolean = false;
@State me: Me = new Me();
private client: ApiClient | null = null;
private authApi: AuthApi | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
const client: ApiClient = ApiClient.getInstance(ctx);
this.client = client;
this.authApi = new AuthApi(client);
// 检查多账号管理器:已有账号则自动恢复
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
acctMgr.load().then(() => {
const active = acctMgr.getActiveAccount();
if (active !== null) {
// 恢复活跃账号的 token
client.setBase(active.server);
client.setToken(active.token);
this.serverAddr = active.server;
// 为所有账号建立 SSE 连接
SseService.getInstance().connectAll(acctMgr);
this.getUIContext().getRouter().pushUrl({ url: 'pages/MainPage' });
return;
}
// 无账号,走正常登录流程
client.init().then(() => {
const base: string = client.getBase();
this.serverAddr = base;
const token: string = client.getToken();
if (token.length > 0) {
this.tryRestore(token);
}
});
});
}
}
async tryRestore(token: string): Promise<void> {
this.loading = true;
try {
const c: ApiClient | null = this.client;
if (c === null) {
return;
}
const user = await new AuthApi(c).loginWithKey(token);
this.me = user;
this.loggedIn = true;
} catch (e) {
const c2: ApiClient | null = this.client;
if (c2 !== null) {
c2.clearAuth();
}
} finally {
this.loading = false;
}
}
async doLogin(): Promise<void> {
if (this.loading) {
return;
}
const c: ApiClient | null = this.client;
const a: AuthApi | null = this.authApi;
if (c === null || a === null) {
return;
}
this.loading = true;
try {
// 保存服务器地址(覆盖默认)
await c.setBase(this.stripTrailingSlash(this.serverAddr));
await c.persistBase(this.stripTrailingSlash(this.serverAddr));
hilog.info(0x0001, 'LoginPage', 'base saved, calling login');
let user: Me;
if (this.mode === 0) {
// 账号密码:调 login 拿 user,token 交给 cookie 管理
user = await a.login(this.username, this.password);
} else {
// 用户密钥
user = await a.loginWithKey(this.userKey.trim());
}
hilog.info(0x0001, 'LoginPage', 'login ok: %{public}s', user.username);
this.me = user;
this.loggedIn = true;
// 存入多账号管理器
const ctx: Context | undefined = this.getUIContext().getHostContext();
if (ctx !== undefined) {
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
await acctMgr.load();
const token: string = this.mode === 1 ? this.userKey.trim() : c.getToken();
const server: string = c.getBase();
const acct = await acctMgr.addAccount(server, user.username, token, user.display_name.length > 0 ? user.display_name : user.username);
// 为该账号建立 SSE 连接
SseService.getInstance().connectForAccount(acct.id, acct.server, acct.token);
}
// 跳转主界面(含 Tab 导航)
await this.getUIContext().getRouter().pushUrl({ url: 'pages/MainPage' });
} catch (e) {
const ae = e as ApiError;
const msg: string = ae.code === 0 ? ae.message : (ae.message.length > 0 ? ae.message : '登录失败');
hilog.error(0x0001, 'LoginPage', 'login failed: code=%{public}d msg=%{public}s', ae.code, msg);
promptAction.showToast({ message: msg });
} finally {
this.loading = false;
}
}
stripTrailingSlash(s: string): string {
let v: string = s.trim();
while (v.length > 0 && v.endsWith('/')) {
v = v.substring(0, v.length - 1);
}
return v;
}
build() {
Column() {
// 标题区
Column() {
Text('AgentMail')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#1A73E8')
Text('邮件驱动 · 多智能体协作平台')
.fontSize(14)
.fontColor('#666666')
.margin({ top: 6 })
}
.margin({ top: 100, bottom: 48 })
// 服务器地址
TextInput({ placeholder: '服务器地址', text: this.serverAddr })
.width('85%')
.height(48)
.margin({ bottom: 12 })
.onChange((v: string) => { this.serverAddr = v; })
// 切换登录方式
Row() {
Text('账号密码').fontSize(14).fontColor(this.mode === 0 ? '#1A73E8' : '#999999')
.onClick(() => { this.mode = 0; })
Text(' | ').fontSize(14).fontColor('#cccccc')
Text('用户密钥').fontSize(14).fontColor(this.mode === 1 ? '#1A73E8' : '#999999')
.onClick(() => { this.mode = 1; })
}
.margin({ bottom: 16 })
if (this.mode === 0) {
TextInput({ placeholder: '用户名', text: this.username })
.width('85%').height(48).margin({ bottom: 12 })
.onChange((v: string) => { this.username = v; })
TextInput({ placeholder: '密码', text: this.password })
.width('85%').height(48).margin({ bottom: 24 })
.type(InputType.Password)
.onChange((v: string) => { this.password = v; })
} else {
TextInput({ placeholder: '粘贴用户密钥 (Bearer token)', text: this.userKey })
.width('85%').height(48).margin({ bottom: 24 })
.onChange((v: string) => { this.userKey = v; })
}
Button(this.loading ? '登录中…' : '登 录')
.width('85%').height(48)
.backgroundColor('#1A73E8')
.fontSize(16)
.enabled(!this.loading)
.onClick(() => {
hilog.info(0x0001, 'LoginPage', 'onClick fired, loading=%{public}s', this.loading.toString());
this.doLogin();
})
// 模拟器备选地址提示
Text('模拟器 NAT 不通时用 ' + EMULATOR_HOST_BASE)
.fontSize(11).fontColor('#aaaaaa').margin({ top: 20 })
if (this.loggedIn) {
// 登录成功 → 进入主界面(占位,后续 M2 替换)
Blank()
Text('登录成功:' + this.me.username)
.fontSize(16).fontColor('#1A73E8').margin({ bottom: 60 })
}
}
.width('100%')
.height('100%')
.backgroundColor('#F5F7FA')
.alignItems(HorizontalAlign.Center)
}
}

View File

@ -0,0 +1,316 @@
/*
* AgentMail 鸿蒙客户端 — 邮件详情页
* GET /mail/{id} 展示单封完整内容 + 回复按钮
* 路由参数:mail_id
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { MailApi } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { MailDetail, SendMailRequest } from '../model/Models';
import { MailDetailParams } from '../model/RouteParams';
import { promptAction } from '@kit.ArkUI';
@Entry
@Component
struct MailDetailPage {
@State mailId: string = '';
@State accountId: string = '';
@State subject: string = '';
@State fromName: string = '';
@State toName: string = '';
@State body: string = '';
@State createdAt: string = '';
@State permissionMode: string = '';
@State sessionAlias: string = '';
@State loading: boolean = true;
@State error: string = '';
@State showReplyBox: boolean = false;
@State replyBody: string = '';
@State sending: boolean = false;
@State sessionId: string = '';
@State switchingPerm: boolean = false;
private mailApi: MailApi | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
this.loading = false;
this.error = '无法获取应用上下文';
return;
}
const params = this.getUIContext().getRouter().getParams() as MailDetailParams;
this.mailId = params?.mail_id ?? '';
this.accountId = params?.account_id ?? '';
const accountManager: AccountManager = AccountManager.getInstance(ctx);
accountManager.load().then(() => {
let account: AccountInfo | null = accountManager.getAccount(this.accountId);
if (account === null) {
account = accountManager.getActiveAccount();
}
if (account === null) {
this.loading = false;
this.error = '找不到邮件所属账号';
return;
}
this.accountId = account.id;
const accountClient: ApiClient = new ApiClient(ctx);
accountClient.setBase(account.server);
accountClient.setToken(account.token);
this.mailApi = new MailApi(accountClient);
if (this.mailId.length === 0) {
this.loading = false;
this.error = '缺少邮件 ID';
return;
}
this.loadMail(this.mailId);
});
}
async loadMail(mailId: string): Promise<void> {
const m: MailApi | null = this.mailApi;
if (m === null) {
return;
}
this.loading = true;
this.error = '';
try {
const mail: MailDetail = await m.mailDetail(mailId);
this.subject = mail.subject;
this.fromName = mail.from_name;
this.toName = mail.to_name;
this.body = mail.body;
this.createdAt = mail.created_at;
this.permissionMode = mail.permission_mode;
this.sessionAlias = mail.session_alias;
this.sessionId = mail.session_id;
} catch (e) {
const ae = e as ApiError;
this.error = ae.code === 0 ? ae.message : '加载失败';
} finally {
this.loading = false;
}
}
async switchPermission(mode: string): Promise<void> {
const m: MailApi | null = this.mailApi;
const sid: string = this.sessionId;
if (m === null || sid.length === 0 || this.switchingPerm) {
return;
}
this.switchingPerm = true;
try {
await m.setPermissionMode(sid, mode);
this.permissionMode = mode;
promptAction.showToast({ message: '权限已切换为 ' + mode });
} catch (e) {
const ae = e as ApiError;
promptAction.showToast({ message: '切换失败: ' + ae.message });
} finally {
this.switchingPerm = false;
}
}
build() {
Column() {
// 顶栏
Row() {
Text('‹')
.fontSize(24).fontColor('#1A73E8')
.width(40).height(40)
.textAlign(TextAlign.Center)
.onClick(() => {
this.getUIContext().getRouter().back();
})
Text(this.subject.length > 20 ? this.subject.substring(0, 20) + '…' : this.subject)
.fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333')
.layoutWeight(1)
Text(this.permissionMode)
.fontSize(11).fontColor('#666666')
.backgroundColor(this.permissionMode === 'full' ? '#E8F5E9' : '#FFF3E0')
.borderRadius(4).padding({ left: 6, right: 6, top: 2, bottom: 2 })
}
.width('100%').height(56)
.padding({ left: 8, right: 12 })
.backgroundColor('#FFFFFF')
if (this.loading) {
Column() {
LoadingProgress().width(40).height(40)
Text('加载中…').fontSize(14).fontColor('#999999').margin({ top: 8 })
}
.width('100%').layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) {
Column() {
Text(this.error).fontSize(14).fontColor('#FF4444')
Button('重试').margin({ top: 12 }).onClick(() => { this.loadMail(this.mailId); })
}
.width('100%').layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
Scroll() {
Column() {
// 元信息卡片
Column() {
Row() {
Text('发件人').fontSize(12).fontColor('#999999').width(60)
Text(this.fromName).fontSize(14).fontColor('#333333')
}.width('100%').margin({ bottom: 6 })
Row() {
Text('收件人').fontSize(12).fontColor('#999999').width(60)
Text(this.toName).fontSize(14).fontColor('#333333')
}.width('100%').margin({ bottom: 6 })
if (this.sessionAlias.length > 0) {
Row() {
Text('会话').fontSize(12).fontColor('#999999').width(60)
Text(this.sessionAlias).fontSize(13).fontColor('#1A73E8')
}.width('100%').margin({ bottom: 6 })
}
Row() {
Text('时间').fontSize(12).fontColor('#999999').width(60)
Text(this.createdAt).fontSize(13).fontColor('#666666')
}.width('100%').margin({ bottom: 6 })
Row() {
Text('权限').fontSize(12).fontColor('#999999').width(60)
if (this.switchingPerm) {
LoadingProgress().width(16).height(16)
} else {
ForEach(['plan', 'workspace', 'full'], (mode: string) => {
Text(mode)
.fontSize(11)
.fontColor(this.permissionMode === mode ? '#FFFFFF' : '#666666')
.backgroundColor(this.permissionMode === mode ? '#1A73E8' : '#F0F0F0')
.borderRadius(4)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ right: 6 })
.onClick(() => { this.switchPermission(mode); })
}, (mode: string) => mode)
}
}.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor('#F8F9FA')
.borderRadius(8)
.margin({ left: 12, right: 12, top: 8 })
// 邮件正文
Text(this.subject)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 8 })
Text(this.body)
.fontSize(15)
.fontColor('#444444')
.width('100%')
.padding({ left: 16, right: 16, bottom: 16 })
.lineHeight(24)
Blank().height(60)
}
.width('100%')
}
.layoutWeight(1)
.scrollBar(BarState.Off)
// 底部回复按钮
Row() {
Button('↩ 回复')
.width('90%').height(44)
.backgroundColor('#1A73E8')
.fontSize(15)
.onClick(() => {
this.showReplyBox = true;
})
}
.width('100%').height(56)
.justifyContent(FlexAlign.Center)
.backgroundColor('#FFFFFF')
// 回复弹层
if (this.showReplyBox) {
Column() {
// 遮罩
Column()
.width('100%').layoutWeight(1)
.backgroundColor('#80000000')
.onClick(() => { this.showReplyBox = false; })
// 回复框
Column() {
Text('回复给 ' + this.fromName)
.fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333')
.margin({ bottom: 12 })
TextArea({ placeholder: '输入回复内容…' })
.layoutWeight(1).width('100%')
.fontSize(14)
.onChange((v: string) => { this.replyBody = v; })
Row() {
Button('取消')
.width(80).height(36)
.backgroundColor('#F5F5F5')
.fontColor('#666666')
.fontSize(13)
.onClick(() => { this.showReplyBox = false; })
Blank()
Button(this.sending ? '发送中…' : '发送')
.width(80).height(36)
.backgroundColor('#1A73E8')
.fontSize(13)
.enabled(!this.sending && this.replyBody.length > 0)
.onClick(() => { this.doReply(); })
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.height('60%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius({ topLeft: 12, topRight: 12 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 })
}
}
}
.width('100%').height('100%')
.backgroundColor('#F5F7FA')
}
async doReply(): Promise<void> {
const m: MailApi | null = this.mailApi;
if (m === null || this.replyBody.length === 0) {
return;
}
this.sending = true;
try {
const req: SendMailRequest = new SendMailRequest();
req.to = this.fromName + '@';
req.subject = 'Re: ' + this.subject;
req.body = this.replyBody;
req.reply_to = this.mailId;
await m.send(req);
this.showReplyBox = false;
this.replyBody = '';
promptAction.showToast({ message: '回复已发送' });
} catch (e) {
const ae = e as ApiError;
promptAction.showToast({ message: '发送失败: ' + ae.message });
} finally {
this.sending = false;
}
}
}

View File

@ -0,0 +1,648 @@
/*
* AgentMail 鸿蒙客户端 — 主框架(底部 Tab 导航)
* 收件箱 / 会话 / 联系人 三个 TabContent
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { MailApi, InboxResponse } from '../api/MailApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SseService, SseEvent } from '../api/SseService';
import { MailSummary, Session, Contact } from '../model/Models';
import { MailDetailParams, ComposeParams } from '../model/RouteParams';
import { promptAction } from '@kit.ArkUI';
@Component
struct InboxTab {
@State mails: MailSummary[] = [];
@State loading: boolean = false;
@State total: number = 0;
@State unread: number = 0;
@State error: string = '';
@State accountName: string = '';
@State accountFilter: string = 'all'; // 'all' 或 accountId
@State showAccountPicker: boolean = false;
@State accountList: AccountInfo[] = [];
private sseService: SseService | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.sseService = SseService.getInstance();
// 加载账号列表
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
acctMgr.load().then(() => {
const active = acctMgr.getActiveAccount();
if (active !== null) {
this.accountName = active.displayName;
}
this.accountList = acctMgr.getAccounts();
if (this.accountList.length <= 1) {
this.accountFilter = 'all';
}
});
this.loadData();
// 全局 SSE 监听(所有账号的事件都会收到);保存同一函数引用以便页面退出时移除。
this.sseService.addListener(this.onSseEvent);
}
}
aboutToDisappear(): void {
if (this.sseService !== null) {
this.sseService.removeListener(this.onSseEvent);
}
}
private onSseEvent = (event: SseEvent): void => {
if (event.type === 'new_mail') {
let sourceName: string = '';
for (let i = 0; i < this.accountList.length; i++) {
if (this.accountList[i].id === event.accountId) {
sourceName = this.accountList[i].displayName;
break;
}
}
promptAction.showToast({ message: sourceName.length > 0 ? '📨 ' + sourceName + ' 收到新邮件' : '📨 新邮件到达' });
this.loadData();
}
};
async loadData(): Promise<void> {
const ctx = this.getUIContext().getHostContext();
if (ctx === undefined) {
return;
}
this.loading = true;
this.error = '';
try {
const acctMgr: AccountManager = AccountManager.getInstance(ctx);
await acctMgr.load();
const allAccounts: AccountInfo[] = acctMgr.getAccounts();
this.accountList = allAccounts;
let filterExists: boolean = this.accountFilter === 'all';
for (let i = 0; i < allAccounts.length; i++) {
if (allAccounts[i].id === this.accountFilter) {
filterExists = true;
this.accountName = allAccounts[i].displayName;
break;
}
}
if (!filterExists) {
this.accountFilter = 'all';
this.accountName = '全部邮箱';
}
const mergedMails: MailSummary[] = [];
let mergedTotal: number = 0;
for (let i = 0; i < allAccounts.length; i++) {
const acct: AccountInfo = allAccounts[i];
if (this.accountFilter !== 'all' && this.accountFilter !== acct.id) {
continue;
}
try {
const accountClient: ApiClient = new ApiClient(ctx);
accountClient.setBase(acct.server);
accountClient.setToken(acct.token);
const response: InboxResponse = await new MailApi(accountClient).inbox('all', 50);
for (let j = 0; j < response.mails.length; j++) {
const mail: MailSummary = response.mails[j];
mail.source_account_id = acct.id;
mail.source_account_name = acct.displayName;
mergedMails.push(mail);
}
mergedTotal += response.total;
} catch (e) {
if (this.accountFilter !== 'all') {
throw e as ApiError;
}
}
}
mergedMails.sort((a: MailSummary, b: MailSummary) => {
if (a.created_at > b.created_at) {
return -1;
}
if (a.created_at < b.created_at) {
return 1;
}
return 0;
});
this.mails = mergedMails;
this.total = mergedTotal;
let unreadCount: number = 0;
for (let i = 0; i < this.mails.length; i++) {
if (this.mails[i].status === 'unread') {
unreadCount++;
}
}
this.unread = unreadCount;
} catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '加载失败';
} finally {
this.loading = false;
}
}
openCompose(): void {
let accountId: string = this.accountFilter === 'all' ? '' : this.accountFilter;
if (accountId.length === 0) {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
accountId = AccountManager.getInstance(ctx).getActiveId();
}
}
const params: ComposeParams = {
to: '',
reply_to: '',
session_alias: '',
account_id: accountId
};
this.getUIContext().getRouter().pushUrl({ url: 'pages/ComposePage', params: params });
}
build() {
Column() {
Row() {
Column() {
Row() {
Text('收件箱').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#333333')
if (this.accountList.length > 1) {
Text(' ▾').fontSize(14).fontColor('#999999')
.onClick(() => { this.showAccountPicker = !this.showAccountPicker; })
}
}
Text(this.accountFilter === 'all' ? '全部邮箱' : this.accountName)
.fontSize(11).fontColor('#999999').margin({ top: 2 })
.onClick(() => { if (this.accountList.length > 1) { this.showAccountPicker = !this.showAccountPicker; } })
}
.alignItems(HorizontalAlign.Start)
Blank()
if (this.unread > 0) {
Text(this.unread + ' 未读')
.fontSize(13).fontColor('#FFFFFF')
.backgroundColor('#FF4444')
.borderRadius(10)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
}
Text('⚙')
.fontSize(20).fontColor('#666666')
.width(36).height(36).textAlign(TextAlign.Center)
.onClick(() => {
this.getUIContext().getRouter().pushUrl({ url: 'pages/SettingsPage' });
})
}
.width('100%').height(56).padding({ left: 16, right: 8 })
.backgroundColor('#FFFFFF')
// 账号选择器下拉
if (this.showAccountPicker && this.accountList.length > 1) {
Column() {
Text('全部邮箱')
.fontSize(14).fontColor(this.accountFilter === 'all' ? '#1A73E8' : '#333333')
.fontWeight(this.accountFilter === 'all' ? FontWeight.Bold : FontWeight.Normal)
.width('100%').height(40).padding({ left: 16 })
.backgroundColor(this.accountFilter === 'all' ? '#F0F7FF' : '#FFFFFF')
.onClick(() => {
this.accountFilter = 'all';
this.accountName = '全部邮箱';
this.showAccountPicker = false;
this.loadData();
})
ForEach(this.accountList, (acct: AccountInfo) => {
Row() {
Column() {
Text(acct.displayName)
.fontSize(14)
.fontColor(this.accountFilter === acct.id ? '#1A73E8' : '#333333')
.fontWeight(this.accountFilter === acct.id ? FontWeight.Bold : FontWeight.Normal)
Text(acct.username)
.fontSize(11).fontColor('#999999')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%').height(48).padding({ left: 16 })
.backgroundColor(this.accountFilter === acct.id ? '#F0F7FF' : '#FFFFFF')
.onClick(() => {
this.accountFilter = acct.id;
this.accountName = acct.displayName;
this.showAccountPicker = false;
this.loadData();
})
}, (acct: AccountInfo) => acct.id)
}
.width('100%')
.backgroundColor('#FFFFFF')
.border({ width: { bottom: 1 }, color: '#EEEEEE' })
}
if (this.loading) {
Column() {
LoadingProgress().width(40).height(40)
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) {
Column() {
Text(this.error).fontSize(14).fontColor('#FF4444')
Button('重试').margin({ top: 12 }).onClick(() => { this.loadData(); })
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.mails.length === 0) {
Column() {
Text('📭 收件箱为空').fontSize(16).fontColor('#999999')
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
List({ space: 1 }) {
ForEach(this.mails, (mail: MailSummary) => {
ListItem() {
this.MailItem(mail)
}
.height(80)
.backgroundColor(mail.status === 'unread' ? '#F0F7FF' : '#FFFFFF')
.onClick(() => {
const params: MailDetailParams = {
mail_id: mail.mail_id,
account_id: mail.source_account_id
};
this.getUIContext().getRouter().pushUrl({
url: 'pages/MailDetailPage',
params: params
});
})
}, (mail: MailSummary) => mail.source_account_id + ':' + mail.mail_id)
}
.width('100%').layoutWeight(1)
.divider({ strokeWidth: 1, color: '#EEEEEE', startMargin: 16, endMargin: 16 })
}
// 底部信息栏 + 悬浮写邮件按钮
Stack({ alignContent: Alignment.BottomEnd }) {
Row() {
Text('共 ' + this.total + ' 封').fontSize(12).fontColor('#999999')
Blank()
Text('未读 ' + this.unread).fontSize(12).fontColor('#999999')
}
.width('100%').height(36).padding({ left: 16, right: 16 })
.backgroundColor('#FFFFFF')
Text('+')
.fontSize(28).fontColor('#FFFFFF')
.width(56).height(56)
.borderRadius(28)
.backgroundColor('#1A73E8')
.textAlign(TextAlign.Center)
.margin({ right: 20, bottom: 44 })
.onClick(() => {
this.openCompose();
})
}
.width('100%')
}
.width('100%').height('100%')
.backgroundColor('#F5F7FA')
.bindSheet($$this.composeVisible, this.ComposeSheet())
}
@State composeVisible: boolean = false;
@Builder
ComposeSheet() {
Column() {
Text('写邮件').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333')
.margin({ top: 16, bottom: 8 })
Button('新建邮件')
.width('90%').height(44).backgroundColor('#1A73E8')
.onClick(() => {
this.composeVisible = false;
this.openCompose();
})
Button('取消')
.width('90%').height(40).backgroundColor('#F5F5F5').fontColor('#666666')
.margin({ top: 8 })
.onClick(() => { this.composeVisible = false; })
}
.width('100%').height(180).padding(16)
}
@Builder
MailItem(mail: MailSummary) {
Row() {
Column() {
if (mail.status === 'unread') {
Circle({ width: 8, height: 8 }).fill('#1A73E8')
}
}
.width(20).height('100%').justifyContent(FlexAlign.Center)
Column() {
Row() {
Text(mail.from_name).fontSize(14).fontWeight(mail.status === 'unread' ? FontWeight.Bold : FontWeight.Normal).fontColor('#333333')
if (this.accountFilter === 'all' && mail.source_account_name.length > 0) {
Text(mail.source_account_name)
.fontSize(10).fontColor('#1A73E8')
.backgroundColor('#E8F0FE').borderRadius(4)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.margin({ left: 6 })
}
Blank()
Text(mail.created_at).fontSize(11).fontColor('#999999')
}
.width('100%')
Text(mail.subject).fontSize(13).fontColor('#333333')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
Text(mail.body_preview).fontSize(12).fontColor('#999999')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 2 })
}
.layoutWeight(1).height('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
if (mail.permission_mode.length > 0) {
Text(mail.permission_mode)
.fontSize(10).fontColor('#666666')
.backgroundColor('#EEEEEE').borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
}
}
.width('100%').height('100%')
.padding({ left: 12, right: 12 })
.alignItems(VerticalAlign.Center)
}
}
@Component
struct SessionsTab {
@State sessions: Session[] = [];
@State loading: boolean = false;
@State error: string = '';
private mailApi: MailApi | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.mailApi = new MailApi(ApiClient.getInstance(ctx));
this.loadData();
}
}
async loadData(): Promise<void> {
const m: MailApi | null = this.mailApi;
if (m === null) {
return;
}
this.loading = true;
try {
const resp = await m.sessions();
this.sessions = resp.sessions;
} catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '加载失败';
} finally {
this.loading = false;
}
}
build() {
Column() {
Row() {
Text('会话').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#333333')
}
.width('100%').height(56).padding({ left: 16 })
.backgroundColor('#FFFFFF')
if (this.loading) {
Column() {
LoadingProgress().width(40).height(40)
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) {
Column() {
Text(this.error).fontSize(14).fontColor('#FF4444')
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.sessions.length === 0) {
Column() {
Text('📭 暂无会话').fontSize(16).fontColor('#999999')
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
List({ space: 1 }) {
ForEach(this.sessions, (s: Session, idx: number) => {
ListItem() {
this.SessionItem(s, idx)
}
.height(90)
}, (_s: Session, idx: number) => idx.toString())
}
.width('100%').layoutWeight(1)
.divider({ strokeWidth: 1, color: '#EEEEEE', startMargin: 16, endMargin: 16 })
}
}
.width('100%').height('100%')
.backgroundColor('#F5F7FA')
}
@Builder
SessionItem(s: Session, idx: number) {
Row() {
Column() {
Row() {
Text(s.session_alias.length > 0 ? s.session_alias : ('会话 #' + (idx + 1)))
.fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (s.max_rounds > 0) {
Text(s.used_rounds + '/' + s.max_rounds)
.fontSize(11).fontColor(s.used_rounds >= s.max_rounds ? '#FF4444' : '#666666')
.backgroundColor(s.used_rounds >= s.max_rounds ? '#FFEBEE' : '#F5F5F5')
.borderRadius(4).padding({ left: 4, right: 4, top: 1, bottom: 1 })
}
}
.width('100%')
Text('对方: ' + s.from_agent).fontSize(12).fontColor('#666666').margin({ top: 4 })
Row() {
Text(s.permission_mode.length > 0 ? s.permission_mode : '—')
.fontSize(11).fontColor('#666666')
.backgroundColor('#F0F0F0').borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
Blank()
Text(s.mail_count + ' 封').fontSize(11).fontColor('#999999')
Text(' · ' + s.status).fontSize(11).fontColor(s.status === 'active' ? '#4CAF50' : '#999999')
}
.width('100%').margin({ top: 4 })
}
.layoutWeight(1).height('100%')
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.SpaceBetween)
}
.width('100%').height('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.alignItems(VerticalAlign.Center)
}
}
@Component
struct ContactsTab {
@State contacts: Contact[] = [];
@State loading: boolean = false;
@State error: string = '';
private mailApi: MailApi | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.mailApi = new MailApi(ApiClient.getInstance(ctx));
this.loadData();
}
}
async loadData(): Promise<void> {
const m: MailApi | null = this.mailApi;
if (m === null) {
return;
}
this.loading = true;
try {
const resp = await m.contacts();
this.contacts = resp.contacts;
} catch (e) {
const ae = e as ApiError;
this.error = ae.message.length > 0 ? ae.message : '加载失败';
} finally {
this.loading = false;
}
}
build() {
Column() {
Row() {
Text('联系人').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#333333')
}
.width('100%').height(56).padding({ left: 16 })
.backgroundColor('#FFFFFF')
if (this.loading) {
Column() {
LoadingProgress().width(40).height(40)
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) {
Column() {
Text(this.error).fontSize(14).fontColor('#FF4444')
Button('重试').margin({ top: 12 }).onClick(() => { this.loadData(); })
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else if (this.contacts.length === 0) {
Column() {
Text('📭 暂无联系人').fontSize(16).fontColor('#999999')
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
List({ space: 1 }) {
ForEach(this.contacts, (c: Contact, idx: number) => {
ListItem() {
this.ContactItem(c, idx)
}
.height(85)
}, (_c: Contact, idx: number) => idx.toString())
}
.width('100%').layoutWeight(1)
.divider({ strokeWidth: 1, color: '#EEEEEE', startMargin: 16, endMargin: 16 })
}
}
.width('100%').height('100%')
.backgroundColor('#F5F7FA')
}
@Builder
ContactItem(c: Contact, idx: number) {
Row() {
Column() {
Row() {
Text(c.agent_name).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333')
Blank()
if (c.unread_count > 0) {
Text(c.unread_count + '')
.fontSize(11).fontColor('#FFFFFF')
.backgroundColor('#FF4444')
.borderRadius(10).width(20).height(20)
.textAlign(TextAlign.Center)
}
}
.width('100%')
Text(c.subject.length > 0 ? c.subject : c.session_alias)
.fontSize(12).fontColor('#666666')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
Row() {
Text(c.permission_mode.length > 0 ? c.permission_mode : '—')
.fontSize(10).fontColor('#666666')
.backgroundColor('#F0F0F0').borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
Blank()
Text(c.last_preview.length > 0 ? c.last_preview : '—')
.fontSize(11).fontColor('#999999')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
.width('100%').margin({ top: 3 })
}
.layoutWeight(1).height('100%')
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.SpaceBetween)
}
.width('100%').height('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.alignItems(VerticalAlign.Center)
}
}
@Entry
@Component
struct MainPage {
@State currentIndex: number = 0;
@Builder
TabBarBuilder(title: string, icon: string) {
Column() {
Text(icon).fontSize(20)
Text(title).fontSize(11).fontColor(this.currentIndex === 0 ? '#1A73E8' : '#666666')
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
build() {
Tabs({ barPosition: BarPosition.End }) {
TabContent() {
InboxTab()
}
.tabBar(this.TabBarBuilder('收件箱', '📬'))
TabContent() {
SessionsTab()
}
.tabBar(this.TabBarBuilder('会话', '💬'))
TabContent() {
ContactsTab()
}
.tabBar(this.TabBarBuilder('联系人', '👤'))
}
.onChange((index: number) => {
this.currentIndex = index;
})
.width('100%')
.height('100%')
}
}

View File

@ -0,0 +1,153 @@
/*
* AgentMail 鸿蒙客户端 — 会话列表页
* GET /me/sessions 展示所有会话摘要
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { MailApi } from '../api/MailApi';
import { Session } from '../model/Models';
@Entry
@Component
struct SessionsPage {
@State sessions: Session[] = [];
@State loading: boolean = false;
@State error: string = '';
private client: ApiClient | null = null;
private mailApi: MailApi | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
const client: ApiClient = ApiClient.getInstance(ctx);
this.client = client;
this.mailApi = new MailApi(client);
this.loadSessions();
}
}
async loadSessions(): Promise<void> {
const m: MailApi | null = this.mailApi;
if (m === null) {
return;
}
this.loading = true;
this.error = '';
try {
const resp = await m.sessions();
this.sessions = resp.sessions;
} catch (e) {
const ae = e as ApiError;
this.error = ae.code === 0 ? ae.message : '加载失败';
} finally {
this.loading = false;
}
}
build() {
Column() {
// 顶栏
Row() {
Text('会话')
.fontSize(20).fontWeight(FontWeight.Bold).fontColor('#333333')
}
.width('100%').height(56)
.padding({ left: 16 })
.backgroundColor('#FFFFFF')
if (this.loading) {
Column() {
LoadingProgress().width(40).height(40)
Text('加载中…').fontSize(14).fontColor('#999999').margin({ top: 8 })
}
.width('100%').layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else if (this.error.length > 0) {
Column() {
Text(this.error).fontSize(14).fontColor('#FF4444')
Button('重试').margin({ top: 12 }).onClick(() => { this.loadSessions(); })
}
.width('100%').layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else if (this.sessions.length === 0) {
Column() {
Text('📭 暂无会话').fontSize(16).fontColor('#999999')
}
.width('100%').layoutWeight(1)
.justifyContent(FlexAlign.Center)
} else {
List({ space: 1 }) {
ForEach(this.sessions, (s: Session) => {
ListItem() {
this.SessionItem(s)
}
.height(90)
}, (s: Session) => s.session_id)
}
.width('100%').layoutWeight(1)
.divider({ strokeWidth: 1, color: '#EEEEEE', startMargin: 16, endMargin: 16 })
}
// 底栏
Row() {
Text('共 ' + this.sessions.length + ' 个会话').fontSize(12).fontColor('#999999')
}
.width('100%').height(40).padding({ left: 16 })
.backgroundColor('#FFFFFF')
}
.width('100%').height('100%')
.backgroundColor('#F5F7FA')
}
@Builder
SessionItem(s: Session) {
Row() {
Column() {
Row() {
Text(s.session_alias.length > 0 ? s.session_alias : s.subject)
.fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333')
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
// 预算进度
if (s.max_rounds > 0) {
Text(s.used_rounds + '/' + s.max_rounds)
.fontSize(11).fontColor(s.used_rounds >= s.max_rounds ? '#FF4444' : '#666666')
.backgroundColor(s.used_rounds >= s.max_rounds ? '#FFEBEE' : '#F5F5F5')
.borderRadius(4).padding({ left: 4, right: 4, top: 1, bottom: 1 })
}
// 未读
if (s.unread_count > 0) {
Text(s.unread_count + '')
.fontSize(11).fontColor('#FFFFFF')
.backgroundColor('#FF4444')
.borderRadius(10).width(20).height(20)
.textAlign(TextAlign.Center)
.margin({ left: 6 })
}
}
.width('100%')
Text('对方: ' + s.from_agent).fontSize(12).fontColor('#666666').margin({ top: 4 })
Row() {
Text(s.permission_mode.length > 0 ? s.permission_mode : '—')
.fontSize(11).fontColor('#666666')
.backgroundColor('#F0F0F0').borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
Blank()
Text(s.mail_count + ' 封').fontSize(11).fontColor('#999999')
Text(' · ' + s.status).fontSize(11).fontColor(s.status === 'active' ? '#4CAF50' : '#999999')
}
.width('100%').margin({ top: 4 })
}
.layoutWeight(1).height('100%')
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.SpaceBetween)
}
.width('100%').height('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.alignItems(VerticalAlign.Center)
}
}

View File

@ -0,0 +1,269 @@
/*
* AgentMail 鸿蒙客户端 — 设置页(账号管理)
* 多账号列表 / 添加新账号 / 删除 / 切换默认账号
*/
import { ApiClient, ApiError } from '../api/ApiClient';
import { AuthApi } from '../api/AuthApi';
import { AccountManager, AccountInfo } from '../api/AccountManager';
import { SseService } from '../api/SseService';
import { promptAction } from '@kit.ArkUI';
@Entry
@Component
struct SettingsPage {
@State accounts: AccountInfo[] = [];
@State activeId: string = '';
@State showAddDialog: boolean = false;
@State newDisplayName: string = '';
@State newServer: string = '';
@State newUsername: string = '';
@State newToken: string = '';
@State adding: boolean = false;
private client: ApiClient | null = null;
private acctMgr: AccountManager | null = null;
aboutToAppear(): void {
const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) {
this.client = ApiClient.getInstance(ctx);
this.acctMgr = AccountManager.getInstance(ctx);
this.acctMgr.load().then(() => {
this.refreshList();
});
}
}
refreshList(): void {
const manager: AccountManager | null = this.acctMgr;
if (manager === null) {
return;
}
this.accounts = manager.getAccounts();
this.activeId = manager.getActiveId();
}
normalizeServer(server: string): string {
let normalized: string = server.trim();
while (normalized.length > 0 && normalized.endsWith('/')) {
normalized = normalized.substring(0, normalized.length - 1);
}
return normalized;
}
async switchTo(accountId: string): Promise<void> {
const manager: AccountManager | null = this.acctMgr;
const client: ApiClient | null = this.client;
if (manager === null || client === null) {
return;
}
const switched: boolean = await manager.switchAccount(accountId);
if (!switched) {
return;
}
const account: AccountInfo | null = manager.getActiveAccount();
if (account !== null) {
client.setBase(account.server);
client.setToken(account.token);
promptAction.showToast({ message: '默认发信账号已设为 ' + account.displayName });
}
this.refreshList();
}
async removeAccount(accountId: string): Promise<void> {
const manager: AccountManager | null = this.acctMgr;
const client: ApiClient | null = this.client;
if (manager === null) {
return;
}
const removed: boolean = await manager.removeAccount(accountId);
if (!removed) {
return;
}
SseService.getInstance().disconnectAccount(accountId);
const active: AccountInfo | null = manager.getActiveAccount();
if (client !== null) {
if (active !== null) {
client.setBase(active.server);
client.setToken(active.token);
} else {
client.setToken('');
}
}
this.refreshList();
promptAction.showToast({ message: '账号已删除' });
}
async addNewAccount(): Promise<void> {
const manager: AccountManager | null = this.acctMgr;
const ctx = this.getUIContext().getHostContext();
if (manager === null || ctx === undefined || this.adding) {
return;
}
const displayName: string = this.newDisplayName.trim();
const server: string = this.normalizeServer(this.newServer);
const token: string = this.newToken.trim();
const optionalUsername: string = this.newUsername.trim();
if (displayName.length === 0 || server.length === 0 || token.length === 0) {
promptAction.showToast({ message: '请填写显示名称、Gateway 地址和 user_key' });
return;
}
this.adding = true;
try {
const validationClient: ApiClient = new ApiClient(ctx);
validationClient.setBase(server);
validationClient.setToken(token);
const user = await new AuthApi(validationClient).me();
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);
promptAction.showToast({ message: '✅ 添加成功: ' + account.displayName });
this.showAddDialog = false;
this.newDisplayName = '';
this.newServer = '';
this.newUsername = '';
this.newToken = '';
this.refreshList();
} catch (e) {
const apiError = e as ApiError;
promptAction.showToast({ message: '验证失败: ' + apiError.message });
} finally {
this.adding = false;
}
}
build() {
Column() {
Row() {
Text('‹').fontSize(24).fontColor('#1A73E8').width(40).height(40)
.textAlign(TextAlign.Center)
.onClick(() => { this.getUIContext().getRouter().back(); })
Text('账号管理').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333')
.layoutWeight(1)
Text('+').fontSize(24).fontColor('#1A73E8').width(40).height(40)
.textAlign(TextAlign.Center)
.onClick(() => { this.showAddDialog = true; })
}
.width('100%').height(56).padding({ left: 8, right: 8 })
.backgroundColor('#FFFFFF')
Divider().color('#EEEEEE')
if (this.accounts.length === 0) {
Column() {
Text('📭 暂无账号').fontSize(16).fontColor('#777777')
Button('添加第一个账号')
.margin({ top: 16 }).backgroundColor('#1A73E8')
.onClick(() => { this.showAddDialog = true; })
}
.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
} else {
List({ space: 1 }) {
ForEach(this.accounts, (account: AccountInfo) => {
ListItem() {
this.AccountItem(account)
}
.height(72)
}, (account: AccountInfo) => account.id)
}
.width('100%').layoutWeight(1)
.divider({ strokeWidth: 1, color: '#EEEEEE', startMargin: 16, endMargin: 16 })
}
if (this.showAddDialog) {
Column() {
Column()
.width('100%').layoutWeight(1)
.backgroundColor('#80000000')
.onClick(() => { this.showAddDialog = false; })
Column() {
Text('添加新账号').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333333')
.margin({ bottom: 16 })
TextInput({ placeholder: '显示名称(必填)', text: this.newDisplayName })
.width('100%').height(44).margin({ bottom: 10 })
.onChange((value: string) => { this.newDisplayName = value; })
TextInput({ placeholder: 'Gateway 地址(必填)', text: this.newServer })
.width('100%').height(44).margin({ bottom: 10 })
.onChange((value: string) => { this.newServer = value; })
TextInput({ placeholder: '用户名(可选)', text: this.newUsername })
.width('100%').height(44).margin({ bottom: 10 })
.onChange((value: string) => { this.newUsername = value; })
TextInput({ placeholder: 'user_key(必填)', text: this.newToken })
.width('100%').height(44).margin({ bottom: 16 })
.type(InputType.Password)
.onChange((value: string) => { this.newToken = value; })
Row() {
Button('取消')
.width(80).height(36).backgroundColor('#F5F5F5').fontColor('#555555')
.onClick(() => { this.showAddDialog = false; })
Blank()
Button(this.adding ? '验证中…' : '添加')
.width(80).height(36).backgroundColor('#1A73E8')
.enabled(!this.adding)
.onClick(() => { this.addNewAccount(); })
}
.width('100%')
}
.width('100%').height('68%')
.padding(16).backgroundColor('#FFFFFF')
.borderRadius({ topLeft: 12, topRight: 12 })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 })
}
}
.width('100%').height('100%')
.backgroundColor('#F5F7FA')
}
@Builder
AccountItem(account: AccountInfo) {
Row() {
Column() {
Row() {
Text(account.displayName)
.fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(account.id === this.activeId ? '#1A73E8' : '#333333')
if (account.id === this.activeId) {
Text(' 默认')
.fontSize(11).fontColor('#1A73E8')
.backgroundColor('#E3F2FD').borderRadius(4)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.margin({ left: 6 })
}
}
.width('100%')
Text((account.username.length > 0 ? account.username + ' · ' : '') + account.server)
.fontSize(12).fontColor('#777777')
.margin({ top: 3 })
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1).height('100%')
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
if (account.id !== this.activeId) {
Text('设为默认')
.fontSize(12).fontColor('#1A73E8')
.margin({ right: 12 })
.onClick(() => { this.switchTo(account.id); })
}
Text('删除')
.fontSize(12).fontColor('#D93025')
.onClick(() => { this.removeAccount(account.id); })
}
.width('100%').height('100%')
.padding({ left: 16, right: 16 })
.alignItems(VerticalAlign.Center)
.backgroundColor(account.id === this.activeId ? '#F0F7FF' : '#FFFFFF')
}
}