chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
12
client/harmony/.gitignore
vendored
Normal file
12
client/harmony/.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
/node_modules
|
||||
/oh_modules
|
||||
/local.properties
|
||||
/.idea
|
||||
**/build
|
||||
/.hvigor
|
||||
.cxx
|
||||
/.clangd
|
||||
/.clang-format
|
||||
/.clang-tidy
|
||||
**/.test
|
||||
/.appanalyzer
|
||||
10
client/harmony/AppScope/app.json5
Normal file
10
client/harmony/AppScope/app.json5
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"app": {
|
||||
"bundleName": "com.agentmail.harmony",
|
||||
"vendor": "example",
|
||||
"versionCode": 1000000,
|
||||
"versionName": "1.0.0",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "app_name",
|
||||
"value": "AgentMailHarmony"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
client/harmony/AppScope/resources/base/media/background.png
Normal file
BIN
client/harmony/AppScope/resources/base/media/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 B |
BIN
client/harmony/AppScope/resources/base/media/foreground.png
Normal file
BIN
client/harmony/AppScope/resources/base/media/foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 B |
@ -0,0 +1,7 @@
|
||||
{
|
||||
"layered-image":
|
||||
{
|
||||
"background" : "$media:background",
|
||||
"foreground" : "$media:foreground"
|
||||
}
|
||||
}
|
||||
42
client/harmony/build-profile.json5
Normal file
42
client/harmony/build-profile.json5
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"app": {
|
||||
"signingConfigs": [],
|
||||
"products": [
|
||||
{
|
||||
"name": "default",
|
||||
"signingConfig": "default",
|
||||
"targetSdkVersion": "6.1.0(23)",
|
||||
"compatibleSdkVersion": "6.1.0(23)",
|
||||
"runtimeOS": "HarmonyOS",
|
||||
"buildOption": {
|
||||
"strictMode": {
|
||||
"caseSensitiveCheck": true,
|
||||
"useNormalizedOHMUrl": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildModeSet": [
|
||||
{
|
||||
"name": "debug",
|
||||
},
|
||||
{
|
||||
"name": "release"
|
||||
}
|
||||
]
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"name": "entry",
|
||||
"srcPath": "./entry",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
32
client/harmony/code-linter.json5
Normal file
32
client/harmony/code-linter.json5
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"files": [
|
||||
"**/*.ets"
|
||||
],
|
||||
"ignore": [
|
||||
"**/src/ohosTest/**/*",
|
||||
"**/src/test/**/*",
|
||||
"**/src/mock/**/*",
|
||||
"**/node_modules/**/*",
|
||||
"**/oh_modules/**/*",
|
||||
"**/build/**/*",
|
||||
"**/.preview/**/*"
|
||||
],
|
||||
"ruleSet": [
|
||||
"plugin:@performance/recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"@security/no-unsafe-aes": "error",
|
||||
"@security/no-unsafe-hash": "error",
|
||||
"@security/no-unsafe-mac": "warn",
|
||||
"@security/no-unsafe-dh": "error",
|
||||
"@security/no-unsafe-dsa": "error",
|
||||
"@security/no-unsafe-ecdsa": "error",
|
||||
"@security/no-unsafe-rsa-encrypt": "error",
|
||||
"@security/no-unsafe-rsa-sign": "error",
|
||||
"@security/no-unsafe-rsa-key": "error",
|
||||
"@security/no-unsafe-dsa-key": "error",
|
||||
"@security/no-unsafe-dh-key": "error",
|
||||
"@security/no-unsafe-3des": "error"
|
||||
}
|
||||
}
|
||||
6
client/harmony/entry/.gitignore
vendored
Normal file
6
client/harmony/entry/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/node_modules
|
||||
/oh_modules
|
||||
/.preview
|
||||
/build
|
||||
/.cxx
|
||||
/.test
|
||||
33
client/harmony/entry/build-profile.json5
Normal file
33
client/harmony/entry/build-profile.json5
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"apiType": "stageMode",
|
||||
"buildOption": {
|
||||
"resOptions": {
|
||||
"copyCodeResource": {
|
||||
"enable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"buildOptionSet": [
|
||||
{
|
||||
"name": "release",
|
||||
"arkOptions": {
|
||||
"obfuscation": {
|
||||
"ruleOptions": {
|
||||
"enable": false,
|
||||
"files": [
|
||||
"./obfuscation-rules.txt"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"name": "default"
|
||||
},
|
||||
{
|
||||
"name": "ohosTest",
|
||||
}
|
||||
]
|
||||
}
|
||||
7
client/harmony/entry/hvigorfile.ts
Normal file
7
client/harmony/entry/hvigorfile.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// @ts-nocheck – Template file, only used when copied into a project directory
|
||||
import { hapTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: hapTasks /* Built-in plugin of Hvigor. It cannot be modified. */,
|
||||
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */,
|
||||
};
|
||||
20
client/harmony/entry/obfuscation-rules.txt
Normal file
20
client/harmony/entry/obfuscation-rules.txt
Normal file
@ -0,0 +1,20 @@
|
||||
# Define project specific obfuscation rules here.
|
||||
# You can include the obfuscation configuration files in the current module's build-profile.json5.
|
||||
|
||||
# Obfuscation options:
|
||||
# -disable-obfuscation: disable all obfuscations
|
||||
# -enable-property-obfuscation: obfuscate the property names
|
||||
# -enable-toplevel-obfuscation: obfuscate the names in the global scope
|
||||
# -compact: remove unnecessary blank spaces and all line feeds
|
||||
# -remove-log: remove all console.* statements
|
||||
# -print-namecache: print the name cache that contains the mapping from the old names to new names
|
||||
# -apply-namecache: reuse the given cache file
|
||||
|
||||
# Keep options:
|
||||
# -keep-property-name: specifies property names that you want to keep
|
||||
# -keep-global-name: specifies names that you want to keep in the global scope
|
||||
|
||||
-enable-property-obfuscation
|
||||
-enable-toplevel-obfuscation
|
||||
-enable-filename-obfuscation
|
||||
-enable-export-obfuscation
|
||||
10
client/harmony/entry/oh-package.json5
Normal file
10
client/harmony/entry/oh-package.json5
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "entry",
|
||||
"version": "1.0.0",
|
||||
"description": "Please describe the basic information.",
|
||||
"main": "",
|
||||
"author": "",
|
||||
"license": "",
|
||||
"dependencies": {}
|
||||
}
|
||||
|
||||
191
client/harmony/entry/src/main/ets/api/AccountManager.ets
Normal file
191
client/harmony/entry/src/main/ets/api/AccountManager.ets
Normal file
@ -0,0 +1,191 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 多账号管理器
|
||||
* 支持多账号存储、切换、删除
|
||||
* 每个账号存储: server, username, token, displayName
|
||||
*/
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
|
||||
const DOMAIN = 0x0001;
|
||||
const TAG = 'AccountManager';
|
||||
const PREF_NAME = 'agentmail_accounts';
|
||||
const KEY_ACCOUNTS = 'accounts_json';
|
||||
const KEY_ACTIVE = 'active_account_id';
|
||||
|
||||
/** 单个账号信息 */
|
||||
export class AccountInfo {
|
||||
id: string = '';
|
||||
server: string = '';
|
||||
username: string = '';
|
||||
token: string = '';
|
||||
displayName: string = '';
|
||||
createdAt: number = 0;
|
||||
}
|
||||
|
||||
export class AccountManager {
|
||||
private static instance: AccountManager | null = null;
|
||||
private context: Context;
|
||||
private accounts: AccountInfo[] = [];
|
||||
private activeId: string = '';
|
||||
private loaded: boolean = false;
|
||||
|
||||
static getInstance(context: Context): AccountManager {
|
||||
if (AccountManager.instance === null || AccountManager.instance.context !== context) {
|
||||
AccountManager.instance = new AccountManager(context);
|
||||
}
|
||||
return AccountManager.instance;
|
||||
}
|
||||
|
||||
private constructor(context: Context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/** 从 preferences 加载账号列表 */
|
||||
async load(): Promise<void> {
|
||||
if (this.loaded) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pref = await preferences.getPreferences(this.context, PREF_NAME);
|
||||
const accountsJson: string = pref.getSync(KEY_ACCOUNTS, '[]') as string;
|
||||
this.accounts = JSON.parse(accountsJson) as AccountInfo[];
|
||||
this.activeId = pref.getSync(KEY_ACTIVE, '') as string;
|
||||
// 如果有账号但没有活跃账号,默认选第一个
|
||||
if (this.accounts.length > 0 && this.activeId.length === 0) {
|
||||
this.activeId = this.accounts[0].id;
|
||||
}
|
||||
hilog.info(DOMAIN, TAG, 'loaded %{public}d accounts, active: %{public}s', this.accounts.length, this.activeId);
|
||||
} catch (e) {
|
||||
this.accounts = [];
|
||||
this.activeId = '';
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
/** 持久化账号列表 */
|
||||
private async persist(): Promise<void> {
|
||||
try {
|
||||
const pref = await preferences.getPreferences(this.context, PREF_NAME);
|
||||
pref.putSync(KEY_ACCOUNTS, JSON.stringify(this.accounts));
|
||||
pref.putSync(KEY_ACTIVE, this.activeId);
|
||||
await pref.flush();
|
||||
} catch (e) {
|
||||
hilog.error(DOMAIN, TAG, 'persist failed');
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取所有账号。返回副本,避免页面直接改动内部持久化数组。 */
|
||||
getAccounts(): AccountInfo[] {
|
||||
return this.accounts.slice();
|
||||
}
|
||||
|
||||
/** 获取当前活跃账号 */
|
||||
getActiveAccount(): AccountInfo | null {
|
||||
for (let i = 0; i < this.accounts.length; i++) {
|
||||
if (this.accounts[i].id === this.activeId) {
|
||||
return this.accounts[i];
|
||||
}
|
||||
}
|
||||
if (this.accounts.length > 0) {
|
||||
return this.accounts[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 获取当前活跃账号 ID */
|
||||
getActiveId(): string {
|
||||
return this.activeId;
|
||||
}
|
||||
|
||||
/** 按 ID 获取账号;路由携带来源账号时使用。 */
|
||||
getAccount(accountId: string): AccountInfo | null {
|
||||
for (let i = 0; i < this.accounts.length; i++) {
|
||||
if (this.accounts[i].id === accountId) {
|
||||
return this.accounts[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 添加新账号 */
|
||||
async addAccount(server: string, username: string, token: string, displayName: string): Promise<AccountInfo> {
|
||||
const account: AccountInfo = new AccountInfo();
|
||||
account.id = this.generateId();
|
||||
account.server = server;
|
||||
account.username = username;
|
||||
account.token = token;
|
||||
account.displayName = displayName.length > 0 ? displayName : username;
|
||||
account.createdAt = Date.now();
|
||||
|
||||
this.accounts.push(account);
|
||||
// 如果是第一个账号,自动设为活跃
|
||||
if (this.accounts.length === 1) {
|
||||
this.activeId = account.id;
|
||||
}
|
||||
await this.persist();
|
||||
hilog.info(DOMAIN, TAG, 'added account: %{public}s', account.username);
|
||||
return account;
|
||||
}
|
||||
|
||||
/** 删除账号 */
|
||||
async removeAccount(accountId: string): Promise<boolean> {
|
||||
const idx: number = this.findIndex(accountId);
|
||||
if (idx < 0) {
|
||||
return false;
|
||||
}
|
||||
this.accounts.splice(idx, 1);
|
||||
// 如果删除的是活跃账号,切换到第一个
|
||||
if (this.activeId === accountId) {
|
||||
this.activeId = this.accounts.length > 0 ? this.accounts[0].id : '';
|
||||
}
|
||||
await this.persist();
|
||||
hilog.info(DOMAIN, TAG, 'removed account: %{public}s', accountId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 切换活跃账号 */
|
||||
async switchAccount(accountId: string): Promise<boolean> {
|
||||
const idx: number = this.findIndex(accountId);
|
||||
if (idx < 0) {
|
||||
return false;
|
||||
}
|
||||
this.activeId = accountId;
|
||||
await this.persist();
|
||||
hilog.info(DOMAIN, TAG, 'switched to: %{public}s', accountId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 更新账号信息(如 token 过期重新登录) */
|
||||
async updateAccount(accountId: string, token: string, displayName: string): Promise<boolean> {
|
||||
const idx: number = this.findIndex(accountId);
|
||||
if (idx < 0) {
|
||||
return false;
|
||||
}
|
||||
this.accounts[idx].token = token;
|
||||
if (displayName.length > 0) {
|
||||
this.accounts[idx].displayName = displayName;
|
||||
}
|
||||
await this.persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 获取账号数量 */
|
||||
getCount(): number {
|
||||
return this.accounts.length;
|
||||
}
|
||||
|
||||
private findIndex(accountId: string): number {
|
||||
for (let i = 0; i < this.accounts.length; i++) {
|
||||
if (this.accounts[i].id === accountId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
const now: number = Date.now();
|
||||
const rand: number = Math.floor(Math.random() * 10000);
|
||||
return 'acct_' + now.toString() + '_' + rand.toString();
|
||||
}
|
||||
}
|
||||
291
client/harmony/entry/src/main/ets/api/ApiClient.ets
Normal file
291
client/harmony/entry/src/main/ets/api/ApiClient.ets
Normal file
@ -0,0 +1,291 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 统一 API 客户端
|
||||
* 对应 WebUI src/api/client.ts:base + headers + 错误归一化 + 401 统一回落登录
|
||||
*/
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { DEFAULT_API_BASE, PREF_KEY_API_BASE, PREF_KEY_TOKEN } from '../common/Config';
|
||||
import { preferences } from '@kit.ArkData';
|
||||
|
||||
const DOMAIN = 0x0001;
|
||||
const TAG = 'AgentMailClient';
|
||||
|
||||
/** 统一错误类型:code = HTTP 状态码,message = 服务端中文文案 */
|
||||
export class ApiError extends Error {
|
||||
code: number = 0;
|
||||
message: string = '';
|
||||
|
||||
constructor(code: number, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
/** 空响应 2xx 的占位类型 */
|
||||
export class EmptyResult {
|
||||
ok: boolean = true;
|
||||
}
|
||||
|
||||
/** 请求选项 */
|
||||
export class RequestOptions {
|
||||
method: string = 'GET';
|
||||
path: string = '';
|
||||
body: string = '';
|
||||
query: string = '';
|
||||
useKeyAuth: boolean = true;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private static instance: ApiClient | null = null;
|
||||
private apiBase: string = DEFAULT_API_BASE;
|
||||
private token: string = '';
|
||||
private context: Context;
|
||||
/** 复用的 HTTP 请求实例:保持 Cookie 会话(登录后跨请求有效) */
|
||||
private httpRequest: http.HttpRequest | null = null;
|
||||
|
||||
static getInstance(context: Context): ApiClient {
|
||||
if (ApiClient.instance === null || ApiClient.instance.context !== context) {
|
||||
if (ApiClient.instance !== null && ApiClient.instance.context !== context) {
|
||||
// 不同 context 重建(测试环境),否则复用
|
||||
}
|
||||
ApiClient.instance = new ApiClient(context);
|
||||
}
|
||||
return ApiClient.instance;
|
||||
}
|
||||
|
||||
constructor(context: Context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/** 初始化:从 preferences 读 apiBase 与 token */
|
||||
async init(): Promise<void> {
|
||||
try {
|
||||
const pref = await preferences.getPreferences(this.context, 'agentmail');
|
||||
this.apiBase = pref.getSync(PREF_KEY_API_BASE, DEFAULT_API_BASE) as string;
|
||||
this.token = pref.getSync(PREF_KEY_TOKEN, '') as string;
|
||||
} catch (e) {
|
||||
this.apiBase = DEFAULT_API_BASE;
|
||||
this.token = '';
|
||||
}
|
||||
}
|
||||
|
||||
getBase(): string {
|
||||
return this.apiBase;
|
||||
}
|
||||
|
||||
setBase(base: string): void {
|
||||
this.apiBase = base;
|
||||
}
|
||||
|
||||
getToken(): string {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
setToken(token: string): void {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/** 持久化凭证 */
|
||||
async persistToken(token: string): Promise<void> {
|
||||
this.token = token;
|
||||
try {
|
||||
const pref = await preferences.getPreferences(this.context, 'agentmail');
|
||||
pref.putSync(PREF_KEY_TOKEN, token);
|
||||
await pref.flush();
|
||||
} catch (e) {
|
||||
// 持久化失败不阻断登录
|
||||
}
|
||||
}
|
||||
|
||||
async persistBase(base: string): Promise<void> {
|
||||
this.apiBase = base;
|
||||
try {
|
||||
const pref = await preferences.getPreferences(this.context, 'agentmail');
|
||||
pref.putSync(PREF_KEY_API_BASE, base);
|
||||
await pref.flush();
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一请求入口 */
|
||||
async request<T>(opts: RequestOptions): Promise<T> {
|
||||
const url: string = this.apiBase + opts.path + (opts.query.length > 0 ? '?' + opts.query : '');
|
||||
// 复用会话级 http 实例,保住 Cookie(login 后 set-cookie 才能用于后续请求)
|
||||
let httpRequest: http.HttpRequest;
|
||||
if (this.httpRequest === null) {
|
||||
httpRequest = http.createHttp();
|
||||
this.httpRequest = httpRequest;
|
||||
} else {
|
||||
httpRequest = this.httpRequest;
|
||||
}
|
||||
|
||||
try {
|
||||
const header: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (this.token.length > 0 && opts.useKeyAuth) {
|
||||
header['Authorization'] = 'Bearer ' + this.token;
|
||||
}
|
||||
|
||||
hilog.info(DOMAIN, TAG, '→ %{public}s %{public}s', opts.method, url);
|
||||
const response = await httpRequest.request(url, {
|
||||
method: opts.method as http.RequestMethod,
|
||||
header: header,
|
||||
extraData: opts.body.length > 0 ? opts.body : undefined,
|
||||
connectTimeout: 15000,
|
||||
readTimeout: 30000
|
||||
});
|
||||
|
||||
const code = response.responseCode;
|
||||
const rawText = response.result as string;
|
||||
|
||||
if (code >= 200 && code < 300) {
|
||||
if (rawText.length === 0) {
|
||||
const empty = new EmptyResult();
|
||||
return empty as T;
|
||||
}
|
||||
return JSON.parse(rawText) as T;
|
||||
}
|
||||
|
||||
// 错误归一化:从服务端 {"error": "..."} 取文案
|
||||
let message: string = 'HTTP ' + code;
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as Record<string, string>;
|
||||
if (parsed['error'] !== undefined) {
|
||||
message = parsed['error'];
|
||||
}
|
||||
} catch (e) {
|
||||
message = rawText.length > 0 ? rawText : ('HTTP ' + code);
|
||||
}
|
||||
|
||||
if (code === 401) {
|
||||
// 清除本地凭证,交由登录页处理
|
||||
this.clearAuth();
|
||||
}
|
||||
|
||||
throw new ApiError(code, message);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
throw e as ApiError;
|
||||
}
|
||||
const be = e as BusinessError;
|
||||
const msg: string = be.message !== undefined ? be.message : '网络错误';
|
||||
hilog.error(DOMAIN, TAG, '← %{public}s failed: %{public}s', url, msg);
|
||||
throw new ApiError(0, msg);
|
||||
}
|
||||
// 不复用销毁:会话级实例保留 Cookie
|
||||
}
|
||||
|
||||
/** GET 便捷 */
|
||||
async get<T>(path: string, query?: string): Promise<T> {
|
||||
const opts = new RequestOptions();
|
||||
opts.method = 'GET';
|
||||
opts.path = path;
|
||||
opts.query = query ?? '';
|
||||
return this.request<T>(opts);
|
||||
}
|
||||
|
||||
/** POST 便捷(JSON body) */
|
||||
async post<T>(path: string, bodyObj: Object, useKeyAuth?: boolean): Promise<T> {
|
||||
const opts = new RequestOptions();
|
||||
opts.method = 'POST';
|
||||
opts.path = path;
|
||||
opts.body = JSON.stringify(bodyObj);
|
||||
opts.useKeyAuth = useKeyAuth ?? true;
|
||||
return this.request<T>(opts);
|
||||
}
|
||||
|
||||
/** PUT 便捷 */
|
||||
async put<T>(path: string, bodyObj: Object): Promise<T> {
|
||||
const opts = new RequestOptions();
|
||||
opts.method = 'PUT';
|
||||
opts.path = path;
|
||||
opts.body = JSON.stringify(bodyObj);
|
||||
return this.request<T>(opts);
|
||||
}
|
||||
|
||||
/** DELETE 便捷 */
|
||||
async del<T>(path: string): Promise<T> {
|
||||
const opts = new RequestOptions();
|
||||
opts.method = 'DELETE';
|
||||
opts.path = path;
|
||||
return this.request<T>(opts);
|
||||
}
|
||||
|
||||
/** 上传文件(multipart/form-data)→ attachment_id */
|
||||
async uploadFile(path: string, filePath: string, fileName: string): Promise<string> {
|
||||
const url: string = this.apiBase + path;
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const header: Record<string, string> = {};
|
||||
const token: string = this.token;
|
||||
if (token.length > 0) {
|
||||
header['Authorization'] = 'Bearer ' + token;
|
||||
}
|
||||
|
||||
const multiFormData: http.MultiFormData = {
|
||||
name: 'file',
|
||||
contentType: 'application/octet-stream',
|
||||
remoteFileName: fileName,
|
||||
filePath: filePath
|
||||
};
|
||||
|
||||
const options: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.POST,
|
||||
header: header,
|
||||
multiFormDataList: [multiFormData],
|
||||
connectTimeout: 30000,
|
||||
readTimeout: 60000
|
||||
};
|
||||
|
||||
hilog.info(DOMAIN, TAG, '→ UPLOAD %{public}s', url);
|
||||
const response = await httpRequest.request(url, options);
|
||||
const code: number = response.responseCode;
|
||||
const rawText: string = response.result as string;
|
||||
|
||||
if (code >= 200 && code < 300) {
|
||||
// 服务端返回 {"attachment_id": "..."} 或直接返回 id 字符串
|
||||
if (rawText.length === 0) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as Record<string, string>;
|
||||
if (parsed['attachment_id'] !== undefined) {
|
||||
return parsed['attachment_id'];
|
||||
}
|
||||
if (parsed['id'] !== undefined) {
|
||||
return parsed['id'];
|
||||
}
|
||||
} catch (e) {
|
||||
// 可能直接返回 id 字符串
|
||||
}
|
||||
return rawText;
|
||||
}
|
||||
|
||||
throw new ApiError(code, rawText.length > 0 ? rawText : 'Upload failed');
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
throw e;
|
||||
}
|
||||
const be = e as BusinessError;
|
||||
throw new ApiError(0, be.message !== undefined ? be.message : 'Upload error');
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除本地认证态(401 时调用) */
|
||||
clearAuth(): void {
|
||||
this.token = '';
|
||||
try {
|
||||
const pref = preferences.getPreferencesSync(this.context, { name: 'agentmail' });
|
||||
pref.putSync(PREF_KEY_TOKEN, '');
|
||||
pref.flush();
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
106
client/harmony/entry/src/main/ets/api/AuthApi.ets
Normal file
106
client/harmony/entry/src/main/ets/api/AuthApi.ets
Normal file
@ -0,0 +1,106 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 认证 API
|
||||
* POST /auth/login / logout / GET /auth/me / POST /me/keys
|
||||
*/
|
||||
import { ApiClient, ApiError } from './ApiClient';
|
||||
import { Me, UserKey } from '../model/Models';
|
||||
|
||||
/** 登录请求体 */
|
||||
export class LoginPayload {
|
||||
username: string = '';
|
||||
password: string = '';
|
||||
}
|
||||
|
||||
/** 创建密钥请求体 */
|
||||
export class CreateKeyPayload {
|
||||
label: string = '';
|
||||
key_type: string = 'permanent';
|
||||
expires_hours: number = 0;
|
||||
}
|
||||
|
||||
/** 登录响应(含用户) */
|
||||
export class MeResponse {
|
||||
user: Me = new Me();
|
||||
}
|
||||
|
||||
/** 创建密钥响应 */
|
||||
export class CreateKeyResponse {
|
||||
key: UserKey = new UserKey();
|
||||
}
|
||||
|
||||
/** 密钥列表响应 */
|
||||
export class KeyListResponse {
|
||||
keys: UserKey[] = [];
|
||||
}
|
||||
|
||||
/** 空请求体(logout 等无 body 场景) */
|
||||
export class EmptyPayload {
|
||||
empty: boolean = true;
|
||||
}
|
||||
|
||||
export class AuthApi {
|
||||
private client: ApiClient;
|
||||
|
||||
constructor(client: ApiClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/** 账号密码登录(Cookie 模式) */
|
||||
async login(username: string, password: string): Promise<Me> {
|
||||
const payload: LoginPayload = new LoginPayload();
|
||||
payload.username = username;
|
||||
payload.password = password;
|
||||
// Cookie 由 http 模块自动管理;此处仍拿回 user
|
||||
const resp = await this.client.post<MeResponse>('/auth/login', payload, false);
|
||||
return resp.user;
|
||||
}
|
||||
|
||||
/** 用户密钥登录(Bearer 模式):直接用 key 调 /auth/me */
|
||||
async loginWithKey(key: string): Promise<Me> {
|
||||
this.client.setToken(key);
|
||||
try {
|
||||
const resp = await this.client.get<MeResponse>('/auth/me');
|
||||
await this.client.persistToken(key);
|
||||
return resp.user;
|
||||
} catch (e) {
|
||||
this.client.clearAuth();
|
||||
throw e as ApiError;
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前用户 */
|
||||
async me(): Promise<Me> {
|
||||
const resp = await this.client.get<MeResponse>('/auth/me');
|
||||
return resp.user;
|
||||
}
|
||||
|
||||
/** 退出登录 */
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
const empty: EmptyPayload = new EmptyPayload();
|
||||
await this.client.post<EmptyPayload>('/auth/logout', empty);
|
||||
} catch (e) {
|
||||
// 忽略退出失败,本地清 token
|
||||
}
|
||||
this.client.clearAuth();
|
||||
}
|
||||
|
||||
/** 创建客户端密钥 */
|
||||
async createKey(label: string): Promise<UserKey> {
|
||||
const payload: CreateKeyPayload = new CreateKeyPayload();
|
||||
payload.label = label;
|
||||
const resp = await this.client.post<CreateKeyResponse>('/me/keys', payload);
|
||||
return resp.key;
|
||||
}
|
||||
|
||||
/** 我的密钥列表 */
|
||||
async listKeys(): Promise<UserKey[]> {
|
||||
const resp = await this.client.get<KeyListResponse>('/me/keys');
|
||||
return resp.keys;
|
||||
}
|
||||
|
||||
/** 吊销密钥 */
|
||||
async revokeKey(keyId: string): Promise<void> {
|
||||
await this.client.del<Object>('/me/keys/' + keyId);
|
||||
}
|
||||
}
|
||||
155
client/harmony/entry/src/main/ets/api/MailApi.ets
Normal file
155
client/harmony/entry/src/main/ets/api/MailApi.ets
Normal file
@ -0,0 +1,155 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 邮件与会话 API
|
||||
* GET /me/inbox, /me/sessions, /me/contacts, /me/mail/{id}, /sessions/{id}/thread
|
||||
* POST /me/mail/send, /me/mail/{id}/forward
|
||||
* PUT /sessions/{id}/permission, /sessions/{id}/alias, /sessions/{id}/budget
|
||||
*/
|
||||
|
||||
import { ApiClient } from './ApiClient';
|
||||
import { MailSummary, Session, Contact, MailDetail, ThreadResponse, AttachmentInfo, SendMailRequest, SendMailResult } from '../model/Models';
|
||||
|
||||
/** 收件箱响应 */
|
||||
export class InboxResponse {
|
||||
mails: MailSummary[] = [];
|
||||
total: number = 0;
|
||||
unread: number = 0;
|
||||
}
|
||||
|
||||
/** 会话列表响应 */
|
||||
export class SessionListResponse {
|
||||
sessions: Session[] = [];
|
||||
total: number = 0;
|
||||
}
|
||||
|
||||
/** 联系人列表响应 */
|
||||
export class ContactListResponse {
|
||||
contacts: Contact[] = [];
|
||||
total: number = 0;
|
||||
}
|
||||
|
||||
/** 邮件详情响应 */
|
||||
export class MailDetailResponse {
|
||||
mail: MailDetail = new MailDetail();
|
||||
}
|
||||
|
||||
/** 对话树响应 */
|
||||
export class ThreadApiResponse {
|
||||
thread: ThreadResponse = new ThreadResponse();
|
||||
}
|
||||
|
||||
/** 发信响应 */
|
||||
export class SendMailResponse {
|
||||
result: SendMailResult = new SendMailResult();
|
||||
}
|
||||
|
||||
/** 附件列表响应 */
|
||||
export class AttachmentListResponse {
|
||||
attachments: AttachmentInfo[] = [];
|
||||
}
|
||||
|
||||
/** 地址补全响应 */
|
||||
export class AddressSuggestionResponse {
|
||||
suggestions: string[] = [];
|
||||
}
|
||||
|
||||
/** 改权限请求体 */
|
||||
export class PermissionModePayload {
|
||||
permission_mode: string = 'workspace';
|
||||
}
|
||||
|
||||
/** 改别名请求体 */
|
||||
export class AliasPayload {
|
||||
alias: string = '';
|
||||
}
|
||||
|
||||
/** 改预算请求体 */
|
||||
export class BudgetPayload {
|
||||
max_rounds: number = 0;
|
||||
}
|
||||
|
||||
export class MailApi {
|
||||
private client: ApiClient;
|
||||
|
||||
constructor(client: ApiClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/** 收件箱(status + limit) */
|
||||
async inbox(status: string, limit: number): Promise<InboxResponse> {
|
||||
const query: string = 'status=' + status + '&limit=' + limit;
|
||||
return this.client.get<InboxResponse>('/me/mail/inbox', query);
|
||||
}
|
||||
|
||||
/** 会话列表 */
|
||||
async sessions(): Promise<SessionListResponse> {
|
||||
return this.client.get<SessionListResponse>('/me/sessions');
|
||||
}
|
||||
|
||||
/** 联系人列表 */
|
||||
async contacts(): Promise<ContactListResponse> {
|
||||
return this.client.get<ContactListResponse>('/contacts');
|
||||
}
|
||||
|
||||
/** 邮件详情(API 返回裸对象) */
|
||||
async mailDetail(mailId: string): Promise<MailDetail> {
|
||||
return this.client.get<MailDetail>('/mail/' + mailId);
|
||||
}
|
||||
|
||||
/** 对话树 */
|
||||
async thread(mailId: string, dir?: string, limit?: number): Promise<ThreadApiResponse> {
|
||||
let query: string = '';
|
||||
const parts: string[] = [];
|
||||
if (dir !== undefined) {
|
||||
parts.push('dir=' + dir);
|
||||
}
|
||||
if (limit !== undefined) {
|
||||
parts.push('limit=' + limit);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
query = parts.join('&');
|
||||
}
|
||||
return this.client.get<ThreadApiResponse>('/mail/' + mailId + '/thread', query);
|
||||
}
|
||||
|
||||
/** 发信 */
|
||||
async send(req: SendMailRequest): Promise<SendMailResponse> {
|
||||
return this.client.post<SendMailResponse>('/me/mail/send', req);
|
||||
}
|
||||
|
||||
/** 转发 */
|
||||
async forward(mailId: string, req: SendMailRequest): Promise<SendMailResponse> {
|
||||
return this.client.post<SendMailResponse>('/me/mail/' + mailId + '/forward', req);
|
||||
}
|
||||
|
||||
/** 改权限档位 */
|
||||
async setPermissionMode(sessionId: string, mode: string): Promise<void> {
|
||||
const payload: PermissionModePayload = new PermissionModePayload();
|
||||
payload.permission_mode = mode;
|
||||
await this.client.put<Object>('/sessions/' + sessionId + '/permission', payload);
|
||||
}
|
||||
|
||||
/** 改会话别名 */
|
||||
async setAlias(sessionId: string, alias: string): Promise<void> {
|
||||
const payload: AliasPayload = new AliasPayload();
|
||||
payload.alias = alias;
|
||||
await this.client.put<Object>('/sessions/' + sessionId + '/alias', payload);
|
||||
}
|
||||
|
||||
/** 改预算 */
|
||||
async setBudget(sessionId: string, maxRounds: number): Promise<void> {
|
||||
const payload: BudgetPayload = new BudgetPayload();
|
||||
payload.max_rounds = maxRounds;
|
||||
await this.client.put<Object>('/sessions/' + sessionId + '/budget', payload);
|
||||
}
|
||||
|
||||
/** 归档联系人 */
|
||||
async archiveContact(name: string, path: string): Promise<void> {
|
||||
await this.client.del<Object>('/me/contacts/' + name + '/' + path);
|
||||
}
|
||||
|
||||
/** 上传附件(multipart/form-data,字段名 file)→ attachment_id */
|
||||
async uploadAttachment(filePath: string, fileName: string): Promise<string> {
|
||||
const resp = await this.client.uploadFile('/me/attachments', filePath, fileName);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
308
client/harmony/entry/src/main/ets/api/SseService.ets
Normal file
308
client/harmony/entry/src/main/ets/api/SseService.ets
Normal file
@ -0,0 +1,308 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — SSE 多账号实时推送服务
|
||||
* 每个账号各建一条 SSE 连接(各带自己的 user_key)
|
||||
* AccountManager 维护连接集合,按 accountId 分发事件
|
||||
*/
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { AccountManager, AccountInfo } from './AccountManager';
|
||||
|
||||
const DOMAIN = 0x0001;
|
||||
const TAG = 'SseService';
|
||||
|
||||
/** SSE 事件数据 */
|
||||
export class SseEvent {
|
||||
type: string = '';
|
||||
data: string = '';
|
||||
accountId: string = '';
|
||||
}
|
||||
|
||||
/** SSE 回调 */
|
||||
export type SseListener = (event: SseEvent) => void;
|
||||
|
||||
/** SSE 连接状态 */
|
||||
export type SseStatus = 'disconnected' | 'connecting' | 'connected';
|
||||
|
||||
/** SSE 状态回调 */
|
||||
export type SseStatusListener = (status: SseStatus) => void;
|
||||
|
||||
/** 单个账号的 SSE 连接状态(纯数据) */
|
||||
export class SseConnection {
|
||||
accountId: string = '';
|
||||
server: string = '';
|
||||
token: string = '';
|
||||
httpRequest: http.HttpRequest | null = null;
|
||||
listeners: SseListener[] = [];
|
||||
status: SseStatus = 'disconnected';
|
||||
reconnectTimer: number = 0;
|
||||
buffer: string = '';
|
||||
connected: boolean = false;
|
||||
}
|
||||
|
||||
export class SseService {
|
||||
private static instance: SseService | null = null;
|
||||
private connections: Map<string, SseConnection> = new Map();
|
||||
private globalListeners: SseListener[] = [];
|
||||
private globalStatusListeners: SseStatusListener[] = [];
|
||||
|
||||
static getInstance(): SseService {
|
||||
if (SseService.instance === null) {
|
||||
SseService.instance = new SseService();
|
||||
}
|
||||
return SseService.instance;
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/** 添加全局事件监听(所有账号的事件都会收到) */
|
||||
addListener(listener: SseListener): void {
|
||||
this.globalListeners.push(listener);
|
||||
}
|
||||
|
||||
/** 移除全局事件监听 */
|
||||
removeListener(listener: SseListener): void {
|
||||
const idx: number = this.globalListeners.indexOf(listener);
|
||||
if (idx >= 0) {
|
||||
this.globalListeners.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** 添加全局状态监听 */
|
||||
addStatusListener(listener: SseStatusListener): void {
|
||||
this.globalStatusListeners.push(listener);
|
||||
}
|
||||
|
||||
/** 移除全局状态监听 */
|
||||
removeStatusListener(listener: SseStatusListener): void {
|
||||
const idx: number = this.globalStatusListeners.indexOf(listener);
|
||||
if (idx >= 0) {
|
||||
this.globalStatusListeners.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** 为指定账号建立 SSE 连接 */
|
||||
connectForAccount(accountId: string, server: string, token: string): void {
|
||||
let conn: SseConnection | undefined = this.connections.get(accountId);
|
||||
if (conn !== undefined && conn.connected) {
|
||||
hilog.info(DOMAIN, TAG, 'account %{public}s already connected', accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (conn === undefined) {
|
||||
conn = new SseConnection();
|
||||
conn.accountId = accountId;
|
||||
this.connections.set(accountId, conn);
|
||||
}
|
||||
|
||||
conn.server = server;
|
||||
conn.token = token;
|
||||
conn.connected = true;
|
||||
this.setConnStatus(conn, 'connecting');
|
||||
this.doConnect(conn);
|
||||
}
|
||||
|
||||
/** 断开指定账号的 SSE 连接 */
|
||||
disconnectAccount(accountId: string): void {
|
||||
const conn: SseConnection | undefined = this.connections.get(accountId);
|
||||
if (conn === undefined) {
|
||||
return;
|
||||
}
|
||||
conn.connected = false;
|
||||
if (conn.reconnectTimer !== 0) {
|
||||
clearTimeout(conn.reconnectTimer);
|
||||
conn.reconnectTimer = 0;
|
||||
}
|
||||
if (conn.httpRequest !== null) {
|
||||
conn.httpRequest.destroy();
|
||||
conn.httpRequest = null;
|
||||
}
|
||||
this.setConnStatus(conn, 'disconnected');
|
||||
this.connections.delete(accountId);
|
||||
}
|
||||
|
||||
/** 断开所有连接 */
|
||||
disconnectAll(): void {
|
||||
const keys: string[] = [];
|
||||
this.connections.forEach((_conn: SseConnection, key: string) => {
|
||||
keys.push(key);
|
||||
});
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
this.disconnectAccount(keys[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据 AccountManager 连接所有账号 */
|
||||
async connectAll(acctMgr: AccountManager): Promise<void> {
|
||||
await acctMgr.load();
|
||||
const accounts: AccountInfo[] = acctMgr.getAccounts();
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const acct: AccountInfo = accounts[i];
|
||||
this.connectForAccount(acct.id, acct.server, acct.token);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取指定账号的连接状态 */
|
||||
getStatusForAccount(accountId: string): SseStatus {
|
||||
const conn: SseConnection | undefined = this.connections.get(accountId);
|
||||
if (conn === undefined) {
|
||||
return 'disconnected';
|
||||
}
|
||||
return conn.status;
|
||||
}
|
||||
|
||||
/** 给指定账号添加事件监听 */
|
||||
addListenerForAccount(accountId: string, listener: SseListener): void {
|
||||
let conn: SseConnection | undefined = this.connections.get(accountId);
|
||||
if (conn === undefined) {
|
||||
conn = new SseConnection();
|
||||
conn.accountId = accountId;
|
||||
this.connections.set(accountId, conn);
|
||||
}
|
||||
conn.listeners.push(listener);
|
||||
}
|
||||
|
||||
/** 移除指定账号的事件监听 */
|
||||
removeListenerForAccount(accountId: string, listener: SseListener): void {
|
||||
const conn: SseConnection | undefined = this.connections.get(accountId);
|
||||
if (conn !== undefined) {
|
||||
const idx: number = conn.listeners.indexOf(listener);
|
||||
if (idx >= 0) {
|
||||
conn.listeners.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setConnStatus(conn: SseConnection, status: SseStatus): void {
|
||||
if (conn.status !== status) {
|
||||
conn.status = status;
|
||||
hilog.info(DOMAIN, TAG, 'status[%{public}s]: %{public}s', conn.accountId, status);
|
||||
for (let i = 0; i < this.globalStatusListeners.length; i++) {
|
||||
this.globalStatusListeners[i](status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private doConnect(conn: SseConnection): void {
|
||||
if (!conn.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const httpRequest = http.createHttp();
|
||||
conn.httpRequest = httpRequest;
|
||||
|
||||
const url: string = conn.server + '/events/stream';
|
||||
const header: Record<string, string> = {};
|
||||
if (conn.token.length > 0) {
|
||||
header['Authorization'] = 'Bearer ' + conn.token;
|
||||
}
|
||||
|
||||
hilog.info(DOMAIN, TAG, 'connecting account %{public}s to %{public}s', conn.accountId, url);
|
||||
|
||||
httpRequest.on('dataReceive', (data: ArrayBuffer) => {
|
||||
const text: string = this.arrayBufferToString(data);
|
||||
conn.buffer += text;
|
||||
this.processBuffer(conn);
|
||||
});
|
||||
|
||||
httpRequest.on('dataEnd', () => {
|
||||
hilog.info(DOMAIN, TAG, 'dataEnd for account %{public}s', conn.accountId);
|
||||
this.setConnStatus(conn, 'disconnected');
|
||||
this.scheduleReconnect(conn);
|
||||
});
|
||||
|
||||
httpRequest.on('headersReceive', (_headers: Object) => {
|
||||
hilog.info(DOMAIN, TAG, 'headers received for account %{public}s', conn.accountId);
|
||||
});
|
||||
|
||||
const options: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.GET,
|
||||
header: header,
|
||||
connectTimeout: 10000,
|
||||
readTimeout: 0
|
||||
};
|
||||
|
||||
httpRequest.requestInStream(url, options, (err: BusinessError, code: number) => {
|
||||
if (err !== undefined && err !== null) {
|
||||
hilog.error(DOMAIN, TAG, 'requestInStream error account %{public}s: %{public}s', conn.accountId, err.message);
|
||||
this.setConnStatus(conn, 'disconnected');
|
||||
this.scheduleReconnect(conn);
|
||||
return;
|
||||
}
|
||||
hilog.info(DOMAIN, TAG, 'requestInStream code=%{public}d account=%{public}s', code, conn.accountId);
|
||||
if (code === 200) {
|
||||
this.setConnStatus(conn, 'connected');
|
||||
} else {
|
||||
hilog.error(DOMAIN, TAG, 'SSE failed code=%{public}d account=%{public}s', code, conn.accountId);
|
||||
this.setConnStatus(conn, 'disconnected');
|
||||
this.scheduleReconnect(conn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private processBuffer(conn: SseConnection): void {
|
||||
const lines: string[] = conn.buffer.split('\n');
|
||||
conn.buffer = lines.pop() ?? '';
|
||||
|
||||
let eventType: string = '';
|
||||
let eventData: string = '';
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line: string = lines[i];
|
||||
|
||||
if (line.length === 0) {
|
||||
if (eventType.length > 0 || eventData.length > 0) {
|
||||
const event: SseEvent = new SseEvent();
|
||||
event.type = eventType.length > 0 ? eventType : 'message';
|
||||
event.data = eventData;
|
||||
event.accountId = conn.accountId;
|
||||
this.dispatchEvent(conn, event);
|
||||
}
|
||||
eventType = '';
|
||||
eventData = '';
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventType = line.substring(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const newData: string = line.substring(5).trim();
|
||||
if (eventData.length > 0) {
|
||||
eventData += '\n' + newData;
|
||||
} else {
|
||||
eventData = newData;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchEvent(conn: SseConnection, event: SseEvent): void {
|
||||
hilog.info(DOMAIN, TAG, 'event[%{public}s]: %{public}s data: %{public}s', conn.accountId, event.type,
|
||||
event.data.substring(0, 100));
|
||||
// 分发给账号级监听
|
||||
for (let i = 0; i < conn.listeners.length; i++) {
|
||||
conn.listeners[i](event);
|
||||
}
|
||||
// 分发给全局监听
|
||||
for (let i = 0; i < this.globalListeners.length; i++) {
|
||||
this.globalListeners[i](event);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(conn: SseConnection): void {
|
||||
if (!conn.connected) {
|
||||
return;
|
||||
}
|
||||
const timer: number | undefined = setTimeout(() => {
|
||||
conn.reconnectTimer = 0;
|
||||
this.doConnect(conn);
|
||||
}, 3000);
|
||||
conn.reconnectTimer = timer ?? 0;
|
||||
}
|
||||
|
||||
private arrayBufferToString(buffer: ArrayBuffer): string {
|
||||
const uint8Array: Uint8Array = new Uint8Array(buffer);
|
||||
let result: string = '';
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
result += String.fromCharCode(uint8Array[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
15
client/harmony/entry/src/main/ets/common/Config.ets
Normal file
15
client/harmony/entry/src/main/ets/common/Config.ets
Normal file
@ -0,0 +1,15 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 全局配置
|
||||
* apiBase 可运行时修改(设置页/登录页),persist 到 preferences
|
||||
*/
|
||||
|
||||
/** 默认联调 Gateway(pi 提供,GUI 联调专用) */
|
||||
export const DEFAULT_API_BASE: string = 'http://192.168.2.60:8180/api/v1';
|
||||
|
||||
/** 模拟器 NAT 访问宿主机地址(备用,若 LAN 直连不通) */
|
||||
export const EMULATOR_HOST_BASE: string = 'http://10.0.2.2:8180/api/v1';
|
||||
|
||||
/** preferences 存储键 */
|
||||
export const PREF_KEY_API_BASE: string = 'api_base';
|
||||
export const PREF_KEY_TOKEN: string = 'user_token';
|
||||
export const PREF_KEY_USERNAME: string = 'username';
|
||||
@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { window } from '@kit.ArkUI';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
|
||||
export default class EntryAbility extends UIAbility {
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
try {
|
||||
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
|
||||
}
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
|
||||
}
|
||||
|
||||
onWindowStageCreate(windowStage: window.WindowStage): void {
|
||||
// Main window is created, set main page for this ability
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
|
||||
|
||||
windowStage.loadContent('pages/LoginPage', (err) => {
|
||||
if (err.code) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
|
||||
return;
|
||||
}
|
||||
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
|
||||
});
|
||||
}
|
||||
|
||||
onWindowStageDestroy(): void {
|
||||
// Main window is destroyed, release UI related resources
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy');
|
||||
}
|
||||
|
||||
onForeground(): void {
|
||||
// Ability has brought to foreground
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
|
||||
}
|
||||
|
||||
onBackground(): void {
|
||||
// Ability has back to background
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
|
||||
export default class EntryBackupAbility extends BackupExtensionAbility {
|
||||
async onBackup() {
|
||||
hilog.info(DOMAIN, 'testTag', 'onBackup ok');
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async onRestore(bundleVersion: BundleVersion) {
|
||||
hilog.info(DOMAIN, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion));
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
178
client/harmony/entry/src/main/ets/model/Models.ets
Normal file
178
client/harmony/entry/src/main/ets/model/Models.ets
Normal file
@ -0,0 +1,178 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 领域模型
|
||||
* 与 docs/API.md 字段一一对应(单一事实源)
|
||||
*/
|
||||
|
||||
/** 当前登录用户 */
|
||||
export class Me {
|
||||
user_id: string = '';
|
||||
username: string = '';
|
||||
display_name: string = '';
|
||||
role: string = '';
|
||||
}
|
||||
|
||||
/** 会话 */
|
||||
export class Session {
|
||||
session_id: string = '';
|
||||
session_alias: string = '';
|
||||
from_agent: string = '';
|
||||
subject: string = '';
|
||||
status: string = '';
|
||||
max_rounds: number = 0;
|
||||
used_rounds: number = 0;
|
||||
permission_mode: string = '';
|
||||
permission_enforcement: string = '';
|
||||
created_at: string = '';
|
||||
updated_at: string = '';
|
||||
mail_count: number = 0;
|
||||
unread_count: number = 0;
|
||||
}
|
||||
|
||||
/** 邮件摘要(收件箱/会话列表用) */
|
||||
export class MailSummary {
|
||||
mail_id: string = '';
|
||||
session_id: string = '';
|
||||
from_name: string = '';
|
||||
to_name: string = '';
|
||||
subject: string = '';
|
||||
body_preview: string = '';
|
||||
created_at: string = '';
|
||||
status: string = ''; // unread | read
|
||||
is_read: boolean = true;
|
||||
has_attachments: boolean = false;
|
||||
permission_mode: string = '';
|
||||
/** 客户端聚合字段:服务端不返回,由收件箱按来源账号填充。 */
|
||||
source_account_id: string = '';
|
||||
source_account_name: string = '';
|
||||
}
|
||||
|
||||
/** 附件元数据 */
|
||||
export class AttachmentInfo {
|
||||
attachment_id: string = '';
|
||||
filename: string = '';
|
||||
size: number = 0;
|
||||
content_type: string = '';
|
||||
}
|
||||
|
||||
/** 邮件详情 */
|
||||
export class MailDetail {
|
||||
mail_id: string = '';
|
||||
session_id: string = '';
|
||||
from_name: string = '';
|
||||
to_name: string = '';
|
||||
cc: string[] = [];
|
||||
subject: string = '';
|
||||
body: string = '';
|
||||
created_at: string = '';
|
||||
status: string = '';
|
||||
session_alias: string = '';
|
||||
from_human: boolean = false;
|
||||
to_human: boolean = false;
|
||||
is_read: boolean = true;
|
||||
attachments: AttachmentInfo[] = [];
|
||||
permission_mode: string = '';
|
||||
permission_enforcement: string = '';
|
||||
}
|
||||
|
||||
/** 对话树节点 */
|
||||
export class ThreadNode {
|
||||
mail_id: string = '';
|
||||
parent_mail_id: string = '';
|
||||
depth: number = 0;
|
||||
from_name: string = '';
|
||||
to_name: string = '';
|
||||
subject: string = '';
|
||||
body_preview: string = '';
|
||||
attachment_count: number = 0;
|
||||
detached: boolean = false;
|
||||
parent_hidden: boolean = false;
|
||||
}
|
||||
|
||||
/** 对话树响应 */
|
||||
export class ThreadResponse {
|
||||
anchor_mail_id: string = '';
|
||||
dir: string = '';
|
||||
nodes: ThreadNode[] = [];
|
||||
total: number = 0;
|
||||
hidden: number = 0;
|
||||
has_more_up: boolean = false;
|
||||
has_more_down: boolean = false;
|
||||
next_up: number = 0;
|
||||
next_down: number = 0;
|
||||
}
|
||||
|
||||
/** 联系人(= 一条三维地址) */
|
||||
export class Contact {
|
||||
session_id: string = '';
|
||||
agent_name: string = '';
|
||||
path: string = '';
|
||||
session_alias: string = '';
|
||||
address: string = '';
|
||||
status: string = '';
|
||||
mail_count: number = 0;
|
||||
unread_count: number = 0;
|
||||
last_activity: string = '';
|
||||
subject: string = '';
|
||||
max_rounds: number = 0;
|
||||
used_rounds: number = 0;
|
||||
permission_mode: string = '';
|
||||
permission_enforcement: string = '';
|
||||
last_from: string = '';
|
||||
last_preview: string = '';
|
||||
}
|
||||
|
||||
/** 权限请求 */
|
||||
export class PermissionRequest {
|
||||
mail_id: string = '';
|
||||
session_id: string = '';
|
||||
from_name: string = '';
|
||||
question: string = '';
|
||||
context: string = '';
|
||||
options: string[] = [];
|
||||
created_at: string = '';
|
||||
}
|
||||
|
||||
/** 用户密钥(token_hint 仅前 8 位) */
|
||||
export class UserKey {
|
||||
key_id: string = '';
|
||||
label: string = '';
|
||||
key_type: string = '';
|
||||
token_hint: string = '';
|
||||
status: string = '';
|
||||
created_at: string = '';
|
||||
}
|
||||
|
||||
/** 地址补全候选 */
|
||||
export class AddressSuggestion {
|
||||
value: string = '';
|
||||
kind: string = '';
|
||||
}
|
||||
|
||||
/** 发信请求体 */
|
||||
export class SendMailRequest {
|
||||
to: string = '';
|
||||
cc: string = '';
|
||||
subject: string = '';
|
||||
body: string = '';
|
||||
reply_to: string = '';
|
||||
session_alias: string = '';
|
||||
attachment_ids: string[] = [];
|
||||
max_rounds: number = 0;
|
||||
permission_mode: string = '';
|
||||
}
|
||||
|
||||
/** 发信响应(含预算) */
|
||||
export class SendMailResult {
|
||||
mail_id: string = '';
|
||||
session_id: string = '';
|
||||
session_alias: string = '';
|
||||
budget_used: number = 0;
|
||||
budget_max: number = 0;
|
||||
budget_remaining: number = 0;
|
||||
}
|
||||
|
||||
/** 登录响应 */
|
||||
export class LoginResult {
|
||||
token: string = '';
|
||||
user: Me = new Me();
|
||||
}
|
||||
13
client/harmony/entry/src/main/ets/model/RouteParams.ets
Normal file
13
client/harmony/entry/src/main/ets/model/RouteParams.ets
Normal file
@ -0,0 +1,13 @@
|
||||
/* AgentMail 页面路由参数:集中声明,避免各页重复定义或使用无类型对象字面量。 */
|
||||
|
||||
export interface MailDetailParams {
|
||||
mail_id: string;
|
||||
account_id: string;
|
||||
}
|
||||
|
||||
export interface ComposeParams {
|
||||
to: string;
|
||||
reply_to: string;
|
||||
session_alias: string;
|
||||
account_id: string;
|
||||
}
|
||||
321
client/harmony/entry/src/main/ets/pages/ComposePage.ets
Normal file
321
client/harmony/entry/src/main/ets/pages/ComposePage.ets
Normal 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')
|
||||
}
|
||||
}
|
||||
210
client/harmony/entry/src/main/ets/pages/InboxPage.ets
Normal file
210
client/harmony/entry/src/main/ets/pages/InboxPage.ets
Normal 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)
|
||||
}
|
||||
}
|
||||
38
client/harmony/entry/src/main/ets/pages/Index.ets
Normal file
38
client/harmony/entry/src/main/ets/pages/Index.ets
Normal 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%')
|
||||
}
|
||||
}
|
||||
213
client/harmony/entry/src/main/ets/pages/LoginPage.ets
Normal file
213
client/harmony/entry/src/main/ets/pages/LoginPage.ets
Normal 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)
|
||||
}
|
||||
}
|
||||
316
client/harmony/entry/src/main/ets/pages/MailDetailPage.ets
Normal file
316
client/harmony/entry/src/main/ets/pages/MailDetailPage.ets
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
648
client/harmony/entry/src/main/ets/pages/MainPage.ets
Normal file
648
client/harmony/entry/src/main/ets/pages/MainPage.ets
Normal 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%')
|
||||
}
|
||||
}
|
||||
153
client/harmony/entry/src/main/ets/pages/SessionsPage.ets
Normal file
153
client/harmony/entry/src/main/ets/pages/SessionsPage.ets
Normal 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)
|
||||
}
|
||||
}
|
||||
269
client/harmony/entry/src/main/ets/pages/SettingsPage.ets
Normal file
269
client/harmony/entry/src/main/ets/pages/SettingsPage.ets
Normal 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')
|
||||
}
|
||||
}
|
||||
55
client/harmony/entry/src/main/module.json5
Normal file
55
client/harmony/entry/src/main/module.json5
Normal file
@ -0,0 +1,55 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "entry",
|
||||
"type": "entry",
|
||||
"description": "$string:module_desc",
|
||||
"mainElement": "EntryAbility",
|
||||
"deviceTypes": [
|
||||
"phone"
|
||||
],
|
||||
"deliveryWithInstall": true,
|
||||
"installationFree": false,
|
||||
"pages": "$profile:main_pages",
|
||||
"abilities": [
|
||||
{
|
||||
"name": "EntryAbility",
|
||||
"srcEntry": "./ets/entryability/EntryAbility.ets",
|
||||
"description": "$string:EntryAbility_desc",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:EntryAbility_label",
|
||||
"startWindowIcon": "$media:startIcon",
|
||||
"startWindowBackground": "$color:start_window_background",
|
||||
"exported": true,
|
||||
"skills": [
|
||||
{
|
||||
"entities": [
|
||||
"entity.system.home"
|
||||
],
|
||||
"actions": [
|
||||
"ohos.want.action.home"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"requestPermissions": [
|
||||
{
|
||||
"name": "ohos.permission.INTERNET"
|
||||
}
|
||||
],
|
||||
"extensionAbilities": [
|
||||
{
|
||||
"name": "EntryBackupAbility",
|
||||
"srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
|
||||
"type": "backup",
|
||||
"exported": false,
|
||||
"metadata": [
|
||||
{
|
||||
"name": "ohos.extension.backup",
|
||||
"resource": "$profile:backup_config"
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"color": [
|
||||
{
|
||||
"name": "start_window_background",
|
||||
"value": "#FFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"float": [
|
||||
{
|
||||
"name": "page_text_font_size",
|
||||
"value": "50fp"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "module_desc",
|
||||
"value": "module description"
|
||||
},
|
||||
{
|
||||
"name": "EntryAbility_desc",
|
||||
"value": "description"
|
||||
},
|
||||
{
|
||||
"name": "EntryAbility_label",
|
||||
"value": "AgentMailHarmony"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 65 B |
Binary file not shown.
|
After Width: | Height: | Size: 65 B |
@ -0,0 +1,7 @@
|
||||
{
|
||||
"layered-image":
|
||||
{
|
||||
"background" : "$media:background",
|
||||
"foreground" : "$media:foreground"
|
||||
}
|
||||
}
|
||||
BIN
client/harmony/entry/src/main/resources/base/media/startIcon.png
Normal file
BIN
client/harmony/entry/src/main/resources/base/media/startIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 B |
@ -0,0 +1,3 @@
|
||||
{
|
||||
"allowToBackupRestore": true
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
{
|
||||
"src": [
|
||||
"pages/LoginPage",
|
||||
"pages/MainPage",
|
||||
"pages/MailDetailPage",
|
||||
"pages/ComposePage",
|
||||
"pages/SettingsPage",
|
||||
"pages/SessionsPage",
|
||||
"pages/Index"
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"color": [
|
||||
{
|
||||
"name": "start_window_background",
|
||||
"value": "#000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
client/harmony/hvigor/hvigor-config.json5
Normal file
23
client/harmony/hvigor/hvigor-config.json5
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"modelVersion": "6.1.0",
|
||||
"dependencies": {
|
||||
},
|
||||
"execution": {
|
||||
// "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | "ultrafine" | false ]. Default: "normal" */
|
||||
// "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */
|
||||
// "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */
|
||||
// "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */
|
||||
// "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */
|
||||
// "optimizationStrategy": "memory" /* Define the optimization strategy. Value: [ "memory" | "performance" ]. Default: "memory" */
|
||||
},
|
||||
"logging": {
|
||||
// "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */
|
||||
},
|
||||
"debugging": {
|
||||
// "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */
|
||||
},
|
||||
"nodeOptions": {
|
||||
// "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/
|
||||
// "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/
|
||||
}
|
||||
}
|
||||
7
client/harmony/hvigorfile.ts
Normal file
7
client/harmony/hvigorfile.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// @ts-nocheck – Template file, only used when copied into a project directory
|
||||
import { appTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: appTasks /* Built-in plugin of Hvigor. It cannot be modified. */,
|
||||
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */,
|
||||
};
|
||||
28
client/harmony/oh-package-lock.json5
Normal file
28
client/harmony/oh-package-lock.json5
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"meta": {
|
||||
"stableOrder": true,
|
||||
"enableUnifiedLockfile": false
|
||||
},
|
||||
"lockfileVersion": 3,
|
||||
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
|
||||
"specifiers": {
|
||||
"@ohos/hamock@1.0.0": "@ohos/hamock@1.0.0",
|
||||
"@ohos/hypium@1.0.25": "@ohos/hypium@1.0.25"
|
||||
},
|
||||
"packages": {
|
||||
"@ohos/hamock@1.0.0": {
|
||||
"name": "@ohos/hamock",
|
||||
"version": "1.0.0",
|
||||
"integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har",
|
||||
"registryType": "ohpm"
|
||||
},
|
||||
"@ohos/hypium@1.0.25": {
|
||||
"name": "@ohos/hypium",
|
||||
"version": "1.0.25",
|
||||
"integrity": "sha512-l6uO2pjl8HyEKdekLqQt7tUpWbDqX/42zoAzkagtUVZAW9jT6lMvbe54MVjoLxq/RwQGygRvi6j4GpypSMFSHw==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.25.har",
|
||||
"registryType": "ohpm"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
client/harmony/oh-package.json5
Normal file
10
client/harmony/oh-package.json5
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"modelVersion": "6.1.0",
|
||||
"description": "Please describe the basic information.",
|
||||
"dependencies": {
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ohos/hypium": "1.0.25",
|
||||
"@ohos/hamock": "1.0.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user