feat(ohos): 鸿蒙端聊天历史分段懒加载 + 首次提交完整工程
原有 cmd/ohos/HomeAgent 是未入库的鸿蒙原生 ArkTS 工程,本次随改动一并入库, 保证他人 clone 后可直接编译(含 .gitignore 排除 build/oh_modules/签名材料, 提供 build-profile.json5.example 模板)。 本次功能改动(与 WebUI / GUI 三端对齐): - /chat/history 首屏只拉最新 CHAT_PAGE_SIZE(40) 条,1.26MB → 48.5KB - 抽出 parseHistoryPayload() 复用解析,记录 chatOffset/chatHasMore - 新增 loadOlderChat():向上滚动触顶(yOffset<60)懒加载更早页 - 工具调用 args/result 与 reasoning_content 完整还原,不做裁剪 构建验证:hvigorw assembleHap BUILD SUCCESSFUL(7.8s,ChatPage 零告警)
2
cmd/ohos/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# 开发过程截图(体积大、非源码),不入库
|
||||
screenshots/
|
||||
19
cmd/ohos/HomeAgent/.gitignore
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
# 构建产物
|
||||
build/
|
||||
.hvigor/
|
||||
.cxx/
|
||||
|
||||
# 依赖
|
||||
oh_modules/
|
||||
node_modules/
|
||||
|
||||
# 本地 SDK / Node 路径,每台机器不同
|
||||
local.properties
|
||||
|
||||
# 签名材料:含 keyPassword / storePassword 明文与本机绝对路径,不入库
|
||||
build-profile.json5
|
||||
|
||||
# hvigorw 在本机是指向 /opt/huawei/command-line-tools/bin/hvigorw 的符号链接,
|
||||
# 绝对路径因机而异,入库后他人 clone 得到的是坏链接。
|
||||
# 请改用本机 DevEco command-line-tools 里的 hvigorw(见 README)。
|
||||
hvigorw
|
||||
10
cmd/ohos/HomeAgent/AppScope/app.json5
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"app": {
|
||||
"bundleName": "com.example.homeagent",
|
||||
"vendor": "HomeAgent",
|
||||
"versionCode": 1000000,
|
||||
"versionName": "1.0.0",
|
||||
"icon": "$media:app_icon",
|
||||
"label": "$string:app_name"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "app_name",
|
||||
"value": "HomeAgent"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
cmd/ohos/HomeAgent/AppScope/resources/base/media/app_icon.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
57
cmd/ohos/HomeAgent/build-profile.json5.example
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
app: {
|
||||
products: [
|
||||
{
|
||||
name: 'default',
|
||||
signingConfig: 'default',
|
||||
compileSdkVersion: '26.0.0',
|
||||
compatibleSdkVersion: '6.1.1(24)',
|
||||
runtimeOS: 'HarmonyOS',
|
||||
buildOption: {
|
||||
strictMode: {
|
||||
useNormalizedOHMUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
buildModeSet: [
|
||||
{
|
||||
name: 'debug',
|
||||
},
|
||||
{
|
||||
name: 'release',
|
||||
},
|
||||
],
|
||||
// 复制为 build-profile.json5 后,把下面四项换成本机 DevEco 生成的调试签名材料
|
||||
// (默认在 ~/.ohos/config/ 下),keyPassword / storePassword 用自己的值。
|
||||
signingConfigs: [
|
||||
{
|
||||
name: 'default',
|
||||
type: 'HarmonyOS',
|
||||
material: {
|
||||
certpath: 'REPLACE_WITH_YOUR_CER_PATH',
|
||||
keyAlias: 'debugKey',
|
||||
keyPassword: 'REPLACE_WITH_YOUR_KEY_PASSWORD',
|
||||
profile: 'REPLACE_WITH_YOUR_P7B_PATH',
|
||||
signAlg: 'SHA256withECDSA',
|
||||
storeFile: 'REPLACE_WITH_YOUR_P12_PATH',
|
||||
storePassword: 'REPLACE_WITH_YOUR_STORE_PASSWORD',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
modules: [
|
||||
{
|
||||
name: 'entry',
|
||||
srcPath: './entry',
|
||||
targets: [
|
||||
{
|
||||
name: 'default',
|
||||
applyToProducts: [
|
||||
'default',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
1
cmd/ohos/HomeAgent/entry/hvigorfile.ts
Normal file
@ -0,0 +1 @@
|
||||
export { hapTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
20
cmd/ohos/HomeAgent/entry/oh-package-lock.json5
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"meta": {
|
||||
"stableOrder": true,
|
||||
"enableUnifiedLockfile": false
|
||||
},
|
||||
"lockfileVersion": 3,
|
||||
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
|
||||
"specifiers": {
|
||||
"@ohos/hypium@1.0.21": "@ohos/hypium@1.0.21"
|
||||
},
|
||||
"packages": {
|
||||
"@ohos/hypium@1.0.21": {
|
||||
"name": "@ohos/hypium",
|
||||
"version": "1.0.21",
|
||||
"integrity": "sha512-iyKGMXxE+9PpCkqEwu0VykN/7hNpb+QOeIuHwkmZnxOpI+dFZt6yhPB7k89EgV1MiSK/ieV/hMjr5Z2mWwRfMQ==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.21.har",
|
||||
"registryType": "ohpm"
|
||||
}
|
||||
}
|
||||
}
|
||||
12
cmd/ohos/HomeAgent/entry/oh-package.json5
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "entry",
|
||||
"version": "1.0.0",
|
||||
"description": "HomeAgent HarmonyOS client entry module",
|
||||
"main": "",
|
||||
"author": "",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@ohos/hypium": "1.0.21"
|
||||
}
|
||||
}
|
||||
217
cmd/ohos/HomeAgent/entry/src/main/ets/common/ApiClient.ets
Normal file
@ -0,0 +1,217 @@
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
import { DEFAULT_API_TIMEOUT } from './Constants';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
body: string;
|
||||
|
||||
constructor(message: string, status: number, body: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiResponse {
|
||||
status: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** 二进制响应:附件预览需要原始字节来解码成 PixelMap */
|
||||
export interface ApiBinaryResponse {
|
||||
status: number;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private conn: ConnectionConfig | null = null;
|
||||
|
||||
setConnection(conn: ConnectionConfig): void {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
getConnection(): ConnectionConfig | null {
|
||||
return this.conn;
|
||||
}
|
||||
|
||||
hasConnection(): boolean {
|
||||
return this.conn !== null && this.conn.url.length > 0;
|
||||
}
|
||||
|
||||
private buildUrl(path: string): string {
|
||||
if (this.conn === null) {
|
||||
return path;
|
||||
}
|
||||
const base = this.conn.url.replace(/\/+$/, '');
|
||||
return base + '/api/v1' + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把附件的 url 字段解析成可直接请求的绝对地址。
|
||||
*
|
||||
* 后端给的是 `/files/<name>` 或 `/uploads/<name>`(注意:不带 /api/v1 前缀),
|
||||
* 远程附件则直接是 http(s) 绝对地址,原样返回。
|
||||
*/
|
||||
absoluteUrl(url: string): string {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
if (this.conn === null) {
|
||||
return url;
|
||||
}
|
||||
const base = this.conn.url.replace(/\/+$/, '');
|
||||
return url.startsWith('/') ? base + url : base + '/' + url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取附件字节。/files/ 与 /uploads/ 走 requireWeb,
|
||||
* 但后端对 API Key 客户端同等放行,所以带上同一套鉴权头即可。
|
||||
*/
|
||||
async getBinary(absUrl: string, timeoutMs: number): Promise<ApiBinaryResponse> {
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const options: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.GET,
|
||||
header: this.buildHeaders(),
|
||||
expectDataType: http.HttpDataType.ARRAY_BUFFER,
|
||||
readTimeout: timeoutMs,
|
||||
connectTimeout: Math.min(timeoutMs, 15000),
|
||||
};
|
||||
const resp = await httpRequest.request(absUrl, options);
|
||||
const statusCode = resp.responseCode;
|
||||
if (statusCode >= 400) {
|
||||
throw new ApiError('下载失败', statusCode, '');
|
||||
}
|
||||
const buf: ArrayBuffer = resp.result as ArrayBuffer;
|
||||
return { status: statusCode, data: buf };
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* multipart/form-data 上传。
|
||||
*
|
||||
* 后端 POST /api/v1/chat/file 要求 file 字段为文件本体,
|
||||
* message / device_id / device_name / client_msg_id 为普通文本字段。
|
||||
* ArkTS 侧用 http 的 multiFormDataList:文件走 filePath,文本走 data。
|
||||
*/
|
||||
async postMultipart(path: string, parts: http.MultiFormData[],
|
||||
timeoutMs: number): Promise<ApiResponse> {
|
||||
if (this.conn === null) {
|
||||
throw new ApiError('未选择连接', -1, '');
|
||||
}
|
||||
const url = this.buildUrl(path);
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const header: Record<string, string> = this.buildHeaders();
|
||||
// multipart 的 boundary 由底层生成,必须让出 Content-Type 的控制权
|
||||
header['Content-Type'] = 'multipart/form-data';
|
||||
const options: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.POST,
|
||||
header: header,
|
||||
expectDataType: http.HttpDataType.STRING,
|
||||
multiFormDataList: parts,
|
||||
readTimeout: timeoutMs,
|
||||
connectTimeout: Math.min(timeoutMs, 15000),
|
||||
};
|
||||
const resp = await httpRequest.request(url, options);
|
||||
const statusCode = resp.responseCode;
|
||||
const body = typeof resp.result === 'string' ? resp.result : '';
|
||||
if (statusCode >= 400) {
|
||||
let errMsg = body;
|
||||
try {
|
||||
const parsed: Record<string, string> = JSON.parse(body) as Record<string, string>;
|
||||
if (parsed['error'] !== undefined) {
|
||||
errMsg = parsed['error'];
|
||||
}
|
||||
} catch (e) {
|
||||
// keep raw body
|
||||
}
|
||||
throw new ApiError(errMsg, statusCode, body);
|
||||
}
|
||||
return { status: statusCode, body: body };
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
headers['Content-Type'] = 'application/json';
|
||||
if (this.conn !== null && this.conn.apiKey.length > 0) {
|
||||
// 后端 validAPIKey 认 X-API-Key,也认 Authorization: Bearer;
|
||||
// query string 形式会被判 401,不要用。
|
||||
headers['X-API-Key'] = this.conn.apiKey;
|
||||
headers['Authorization'] = 'Bearer ' + this.conn.apiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async request(path: string, method: string, bodyStr: string,
|
||||
timeoutMs: number): Promise<ApiResponse> {
|
||||
if (this.conn === null) {
|
||||
throw new ApiError('未选择连接', -1, '');
|
||||
}
|
||||
const url = this.buildUrl(path);
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const options: http.HttpRequestOptions = {
|
||||
method: (method === 'POST' ? http.RequestMethod.POST :
|
||||
method === 'PUT' ? http.RequestMethod.PUT :
|
||||
method === 'DELETE' ? http.RequestMethod.DELETE :
|
||||
http.RequestMethod.GET) as http.RequestMethod,
|
||||
header: this.buildHeaders(),
|
||||
expectDataType: http.HttpDataType.STRING,
|
||||
readTimeout: timeoutMs,
|
||||
connectTimeout: Math.min(timeoutMs, 15000),
|
||||
};
|
||||
if (bodyStr.length > 0) {
|
||||
options.extraData = bodyStr;
|
||||
}
|
||||
const resp = await httpRequest.request(url, options);
|
||||
const statusCode = resp.responseCode;
|
||||
const body = typeof resp.result === 'string' ? resp.result : '';
|
||||
if (statusCode >= 400) {
|
||||
let errMsg = body;
|
||||
try {
|
||||
const parsed: Record<string, string> = JSON.parse(body) as Record<string, string>;
|
||||
if (parsed['error'] !== undefined) {
|
||||
errMsg = parsed['error'];
|
||||
} else if (parsed['message'] !== undefined) {
|
||||
errMsg = parsed['message'];
|
||||
}
|
||||
} catch (e) {
|
||||
// keep raw body
|
||||
}
|
||||
throw new ApiError(errMsg, statusCode, body);
|
||||
}
|
||||
return { status: statusCode, body: body };
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async get(path: string): Promise<ApiResponse> {
|
||||
return this.request(path, 'GET', '', DEFAULT_API_TIMEOUT);
|
||||
}
|
||||
|
||||
async getWithTimeout(path: string, timeoutMs: number): Promise<ApiResponse> {
|
||||
return this.request(path, 'GET', '', timeoutMs);
|
||||
}
|
||||
|
||||
async post(path: string, bodyObj: object | null): Promise<ApiResponse> {
|
||||
const bodyStr = bodyObj === null ? '' : JSON.stringify(bodyObj);
|
||||
return this.request(path, 'POST', bodyStr, DEFAULT_API_TIMEOUT);
|
||||
}
|
||||
|
||||
async postWithTimeout(path: string, bodyObj: object | null,
|
||||
timeoutMs: number): Promise<ApiResponse> {
|
||||
const bodyStr = bodyObj === null ? '' : JSON.stringify(bodyObj);
|
||||
return this.request(path, 'POST', bodyStr, timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient: ApiClient = new ApiClient();
|
||||
195
cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets
Normal file
@ -0,0 +1,195 @@
|
||||
import { image } from '@kit.ImageKit';
|
||||
import { util } from '@kit.ArkTS';
|
||||
import { pasteboard } from '@kit.BasicServicesKit';
|
||||
import { deviceInfo } from '@kit.BasicServicesKit';
|
||||
import { textToSpeech } from '@kit.CoreSpeechKit';
|
||||
import { componentSnapshot } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
// ===== 能力结果 =====
|
||||
|
||||
export interface CapResult {
|
||||
status: string; // 'ok' | 'error'
|
||||
output: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
function okResult(output: string): CapResult {
|
||||
const r: CapResult = { status: 'ok', output: output, error: '' };
|
||||
return r;
|
||||
}
|
||||
|
||||
function errResult(errMsg: string): CapResult {
|
||||
const r: CapResult = { status: 'error', output: '', error: errMsg };
|
||||
return r;
|
||||
}
|
||||
|
||||
// ===== screensee:截取本应用当前画面(前台时为整屏可见内容)=====
|
||||
|
||||
const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root';
|
||||
|
||||
/** 根组件 id(Index 的根 Stack 设置同名 .id()),截屏时按此定位。 */
|
||||
export function snapshotComponentId(): string {
|
||||
return SNAPSHOT_COMPONENT_ID;
|
||||
}
|
||||
|
||||
async function captureScreenPixelMap(): Promise<image.PixelMap> {
|
||||
const pm: image.PixelMap = await componentSnapshot.get(SNAPSHOT_COMPONENT_ID);
|
||||
return pm;
|
||||
}
|
||||
|
||||
/**
|
||||
* screensee 实现:截本应用画面,缩放到最大宽度 720px 后压成 jpeg base64 data URL。
|
||||
* 说明:鸿蒙三方应用无法无弹窗截取整个系统屏幕(CUSTOM_SCREEN_CAPTURE 为系统权限),
|
||||
* 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。
|
||||
*/
|
||||
export async function capScreensee(): Promise<CapResult> {
|
||||
try {
|
||||
const full: image.PixelMap = await captureScreenPixelMap();
|
||||
const info: image.ImageInfo = await full.getImageInfo();
|
||||
const maxW: number = 720;
|
||||
let targetW: number = info.size.width;
|
||||
let targetH: number = info.size.height;
|
||||
if (targetW > maxW) {
|
||||
targetH = Math.floor(targetH * maxW / targetW);
|
||||
targetW = maxW;
|
||||
}
|
||||
let packed: ArrayBuffer;
|
||||
if (targetW !== info.size.width) {
|
||||
await full.scale(targetW / info.size.width, targetH / info.size.height);
|
||||
}
|
||||
const packer: image.ImagePacker = image.createImagePacker();
|
||||
const opt: image.PackingOption = { format: 'image/jpeg', quality: 70 };
|
||||
packed = await packer.packing(full, opt);
|
||||
packer.release();
|
||||
full.release();
|
||||
const helper: util.Base64Helper = new util.Base64Helper();
|
||||
const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
|
||||
return okResult('data:image/jpeg;base64,' + b64);
|
||||
} catch (e) {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('screensee failed: ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== clipboardsee / clipboardsue =====
|
||||
|
||||
export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> {
|
||||
// 说明:READ_PASTEBOARD 为受限权限,调试签名无法在真机安装时授予,
|
||||
// 这里直接尝试读取;系统拒绝时回错误信息。
|
||||
try {
|
||||
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
||||
const has: boolean = await clip.hasData();
|
||||
if (!has) {
|
||||
const empty: CapResult = { status: 'ok', output: '', error: '' };
|
||||
return empty;
|
||||
}
|
||||
const data: pasteboard.PasteData = await clip.getData();
|
||||
const txt: string = data.getPrimaryText();
|
||||
const out: CapResult = { status: 'ok', output: txt ?? '', error: '' };
|
||||
return out;
|
||||
} catch (e) {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('clipboardsee failed (需系统剪贴板授权): ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function capClipboardsue(text: string): Promise<CapResult> {
|
||||
try {
|
||||
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
|
||||
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
|
||||
await clip.setPasteData(data);
|
||||
return okResult('written ' + text.length + ' chars');
|
||||
} catch (e) {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('clipboardsue failed: ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== speakeruse:TTS 朗读 =====
|
||||
|
||||
class TtsSession {
|
||||
private engine: textToSpeech.TextToSpeechEngine | null = null;
|
||||
|
||||
async speak(text: string): Promise<CapResult> {
|
||||
try {
|
||||
if (this.engine === null) {
|
||||
const extra: Record<string, Object> = {
|
||||
'style': 'interaction-broadcast',
|
||||
'locate': 'CN',
|
||||
'name': 'EngineName',
|
||||
};
|
||||
const params: textToSpeech.CreateEngineParams = {
|
||||
language: 'zh-CN',
|
||||
person: 0,
|
||||
online: 1,
|
||||
};
|
||||
const eng: textToSpeech.TextToSpeechEngine = await textToSpeech.createEngine(params);
|
||||
this.engine = eng;
|
||||
}
|
||||
const sp: textToSpeech.SpeakParams = {
|
||||
requestId: 'spk-' + Date.now().toString(),
|
||||
};
|
||||
this.engine.speak(text, sp);
|
||||
return okResult('speaking');
|
||||
} catch (e) {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
return errResult('speakeruse failed: ' + msg);
|
||||
}
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
if (this.engine !== null) {
|
||||
try {
|
||||
this.engine.shutdown();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
this.engine = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ttsSession: TtsSession = new TtsSession();
|
||||
|
||||
export async function capSpeakerUse(text: string): Promise<CapResult> {
|
||||
return ttsSession.speak(text);
|
||||
}
|
||||
|
||||
// ===== deviceinfo =====
|
||||
|
||||
export function capDeviceInfo(): CapResult {
|
||||
const lines: string[] = [];
|
||||
lines.push('brand=' + deviceInfo.brand);
|
||||
lines.push('manufacturer=' + deviceInfo.manufacture);
|
||||
lines.push('model=' + deviceInfo.productModel);
|
||||
lines.push('series=' + deviceInfo.productSeries);
|
||||
lines.push('osFullName=' + deviceInfo.osFullName);
|
||||
lines.push('sdkApiVersion=' + deviceInfo.sdkApiVersion.toString());
|
||||
lines.push('securityPatch=' + deviceInfo.securityPatchTag);
|
||||
lines.push('abiList=' + deviceInfo.abiList);
|
||||
lines.push('deviceType=' + deviceInfo.deviceType);
|
||||
return okResult(lines.join('\n'));
|
||||
}
|
||||
|
||||
// ===== screensue 内容解析 =====
|
||||
// 服务端协议: screensue [秒] <内容>;0=常驻。
|
||||
|
||||
export interface ScreensuePayload {
|
||||
duration: number; // 秒;0 表示常驻直到用户关闭
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function parseScreensue(rawArgs: string): ScreensuePayload {
|
||||
const p: ScreensuePayload = { duration: 5, content: '' };
|
||||
let rest: string = rawArgs.trim();
|
||||
const tokens: string[] = rest.split(/\s+/);
|
||||
if (tokens.length > 1 && /^\d+$/.test(tokens[0])) {
|
||||
p.duration = parseInt(tokens[0], 10);
|
||||
rest = tokens.slice(1).join(' ');
|
||||
} else {
|
||||
rest = tokens.join(' ');
|
||||
}
|
||||
p.content = rest.trim();
|
||||
return p;
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import { deviceBridge, CmdReply } from './DeviceBridge';
|
||||
import {
|
||||
CapResult,
|
||||
capScreensee,
|
||||
capClipboardSee,
|
||||
capClipboardsue,
|
||||
capSpeakerUse,
|
||||
capDeviceInfo,
|
||||
parseScreensue,
|
||||
ScreensuePayload,
|
||||
} from './BridgeCaps';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
// screensue 展示回调由 UI 层注册(Index 挂全局悬浮层)
|
||||
export type ScreensueHandler = (payload: ScreensuePayload) => void;
|
||||
|
||||
let screensueHandler: ScreensueHandler | null = null;
|
||||
let appContext: common.UIAbilityContext | null = null;
|
||||
|
||||
export function registerScreensueHandler(handler: ScreensueHandler): void {
|
||||
screensueHandler = handler;
|
||||
}
|
||||
|
||||
export function setBridgeAppContext(ctx: common.UIAbilityContext): void {
|
||||
appContext = ctx;
|
||||
}
|
||||
|
||||
/** 解析 homeagent-* 命令:返回能力名与参数串。 */
|
||||
function splitCapability(command: string): string[] {
|
||||
const cmd: string = command.trim();
|
||||
const idx: number = cmd.indexOf(' ');
|
||||
if (idx < 0) {
|
||||
return [cmd];
|
||||
}
|
||||
const out: string[] = [cmd.substring(0, idx), cmd.substring(idx + 1)];
|
||||
return out;
|
||||
}
|
||||
|
||||
async function executeCommand(reqId: string, command: string): Promise<CapResult> {
|
||||
const parts: string[] = splitCapability(command);
|
||||
const name: string = parts[0];
|
||||
const args: string = parts.length > 1 ? parts[1] : '';
|
||||
|
||||
// screensee 截屏回传(data URL 走文本结果,服务端兼容)
|
||||
if (name === 'screensee') {
|
||||
return capScreensee();
|
||||
}
|
||||
if (name === 'screensue') {
|
||||
const payload: ScreensuePayload = parseScreensue(args);
|
||||
if (screensueHandler !== null) {
|
||||
screensueHandler(payload);
|
||||
return okRes('shown');
|
||||
}
|
||||
return errRes('screensue: display layer not ready');
|
||||
}
|
||||
if (name === 'clipboardsee') {
|
||||
if (appContext === null) {
|
||||
return errRes('clipboardsee: app context missing');
|
||||
}
|
||||
return capClipboardSee(appContext);
|
||||
}
|
||||
if (name === 'clipboardsue') {
|
||||
if (args.length === 0) {
|
||||
return errRes('clipboardsue: empty text');
|
||||
}
|
||||
return capClipboardsue(args);
|
||||
}
|
||||
if (name === 'speakeruse') {
|
||||
if (args.length === 0) {
|
||||
return errRes('speakeruse: empty text');
|
||||
}
|
||||
return capSpeakerUse(args);
|
||||
}
|
||||
if (name === 'deviceinfo' || name === 'status') {
|
||||
return capDeviceInfo();
|
||||
}
|
||||
if (name === 'camerasue') {
|
||||
return errRes('camerasue: camera capture not supported on this build');
|
||||
}
|
||||
if (name === 'computeruse') {
|
||||
return errRes('computeruse: not applicable to touch-only device');
|
||||
}
|
||||
return errRes('unsupported homeagent capability: ' + name);
|
||||
}
|
||||
|
||||
function okRes(output: string): CapResult {
|
||||
const r: CapResult = { status: 'ok', output: output, error: '' };
|
||||
return r;
|
||||
}
|
||||
|
||||
function errRes(errMsg: string): CapResult {
|
||||
const r: CapResult = { status: 'error', output: '', error: errMsg };
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 安装命令处理器到 bridge 单例。 */
|
||||
export function installCmdRouter(): void {
|
||||
deviceBridge.setCmdHandler(executeCommand);
|
||||
}
|
||||
240
cmd/ohos/HomeAgent/entry/src/main/ets/common/ConnStore.ets
Normal file
@ -0,0 +1,240 @@
|
||||
import { preferences } from '@kit.ArkData';
|
||||
import { Context } from '@kit.AbilityKit';
|
||||
import { deviceInfo } from '@kit.BasicServicesKit';
|
||||
import { ConnectionConfig, AppSettings, emptySettings, defaultConnection } from '../model/Model';
|
||||
|
||||
const PREF_NAME: string = 'homeagent_prefs';
|
||||
const KEY_CONNECTIONS: string = 'connections_json';
|
||||
const KEY_SETTINGS: string = 'settings_json';
|
||||
const KEY_DEVICE_ID: string = 'device_id';
|
||||
const KEY_DEVICE_NAME: string = 'device_name';
|
||||
const KEY_DEVICE_AUTH: string = 'device_authorized';
|
||||
|
||||
export class ConnStore {
|
||||
private prefs: preferences.Preferences | null = null;
|
||||
private connections: ConnectionConfig[] = [];
|
||||
private settings: AppSettings = emptySettings();
|
||||
|
||||
async init(context: Context): Promise<void> {
|
||||
this.prefs = await preferences.getPreferences(context, PREF_NAME);
|
||||
await this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
if (this.prefs === null) {
|
||||
return;
|
||||
}
|
||||
const connJson: string = await this.prefs.get(KEY_CONNECTIONS, '') as string;
|
||||
if (connJson.length > 0) {
|
||||
try {
|
||||
this.connections = JSON.parse(connJson) as ConnectionConfig[];
|
||||
} catch (e) {
|
||||
this.connections = [];
|
||||
}
|
||||
}
|
||||
const settingsJson: string = await this.prefs.get(KEY_SETTINGS, '') as string;
|
||||
if (settingsJson.length > 0) {
|
||||
try {
|
||||
this.settings = JSON.parse(settingsJson) as AppSettings;
|
||||
} catch (e) {
|
||||
this.settings = emptySettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 首次启动时预置默认连接(用户后端),避免所有页面空白
|
||||
async ensureDefaultConnection(): Promise<void> {
|
||||
await this.load();
|
||||
if (this.connections.length > 0) {
|
||||
return;
|
||||
}
|
||||
const conn: ConnectionConfig = defaultConnection();
|
||||
conn.id = 'default';
|
||||
this.connections.push(conn);
|
||||
this.settings.currentConnId = conn.id;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
getConnections(): ConnectionConfig[] {
|
||||
return this.connections;
|
||||
}
|
||||
|
||||
getCurrentConnection(): ConnectionConfig | null {
|
||||
if (this.settings.currentConnId.length === 0) {
|
||||
if (this.connections.length > 0) {
|
||||
return this.connections[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
for (let i = 0; i < this.connections.length; i++) {
|
||||
const c: ConnectionConfig = this.connections[i];
|
||||
if (c.id === this.settings.currentConnId) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
if (this.connections.length > 0) {
|
||||
return this.connections[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async addConnection(name: string, url: string, apiKey: string): Promise<ConnectionConfig> {
|
||||
const conn: ConnectionConfig = defaultConnection();
|
||||
conn.id = Date.now().toString(36) + Math.floor(Math.random() * 10000).toString(36);
|
||||
conn.name = name;
|
||||
conn.url = url;
|
||||
conn.apiKey = apiKey;
|
||||
conn.type = 'webui';
|
||||
this.connections.push(conn);
|
||||
if (this.settings.currentConnId.length === 0) {
|
||||
this.settings.currentConnId = conn.id;
|
||||
}
|
||||
await this.save();
|
||||
return conn;
|
||||
}
|
||||
|
||||
async updateConnection(id: string, name: string, url: string,
|
||||
apiKey: string): Promise<void> {
|
||||
for (let i = 0; i < this.connections.length; i++) {
|
||||
const c: ConnectionConfig = this.connections[i];
|
||||
if (c.id === id) {
|
||||
c.name = name;
|
||||
c.url = url;
|
||||
c.apiKey = apiKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async deleteConnection(id: string): Promise<void> {
|
||||
const next: ConnectionConfig[] = [];
|
||||
for (let i = 0; i < this.connections.length; i++) {
|
||||
const c: ConnectionConfig = this.connections[i];
|
||||
if (c.id !== id) {
|
||||
next.push(c);
|
||||
}
|
||||
}
|
||||
this.connections = next;
|
||||
if (this.settings.currentConnId === id) {
|
||||
if (this.connections.length > 0) {
|
||||
this.settings.currentConnId = this.connections[0].id;
|
||||
} else {
|
||||
this.settings.currentConnId = '';
|
||||
}
|
||||
}
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async setCurrent(id: string): Promise<void> {
|
||||
this.settings.currentConnId = id;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
getSettings(): AppSettings {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
async saveSettings(s: AppSettings): Promise<void> {
|
||||
this.settings = s;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/** Persisted local device id (stable across restarts). */
|
||||
getDeviceId(): string {
|
||||
if (this.prefs === null) {
|
||||
return '';
|
||||
}
|
||||
return this.prefs.getSync(KEY_DEVICE_ID, '') as string;
|
||||
}
|
||||
|
||||
saveDeviceId(id: string): void {
|
||||
if (this.prefs === null) {
|
||||
return;
|
||||
}
|
||||
this.prefs.putSync(KEY_DEVICE_ID, id);
|
||||
this.prefs.flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取本机设备 ID,没有就地生成并持久化。
|
||||
*
|
||||
* 聊天发送必须带 device_id:后端 handleChat 只有拿到它才会把来源编码成
|
||||
* webui/<device_id> 并往 stageCtx 注入"当前输入来自设备[...]",
|
||||
* 否则 source 恒为 "webui",agent 会以为消息是网页端发的。
|
||||
* 之前只有设备页在用时才生成 ID,聊天页拿到空串 → 身份丢失。
|
||||
*/
|
||||
ensureDeviceId(): string {
|
||||
const cur: string = this.getDeviceId();
|
||||
if (cur.length > 0) {
|
||||
return cur;
|
||||
}
|
||||
const id: string = 'ohos-' + Date.now().toString(36);
|
||||
this.saveDeviceId(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 设备显示名:优先用户自定义,否则按机型自动生成("HUAWEI Mate 60 (HarmonyOS)")。 */
|
||||
getDeviceName(): string {
|
||||
if (this.prefs !== null) {
|
||||
const saved: string = this.prefs.getSync(KEY_DEVICE_NAME, '') as string;
|
||||
if (saved.length > 0) {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
return ConnStore.autoDeviceName();
|
||||
}
|
||||
|
||||
saveDeviceName(name: string): void {
|
||||
if (this.prefs === null) {
|
||||
return;
|
||||
}
|
||||
this.prefs.putSync(KEY_DEVICE_NAME, name);
|
||||
this.prefs.flush();
|
||||
}
|
||||
|
||||
/** 由 deviceInfo 拼一个人看得懂的机器名;静态方法内不能用 this。 */
|
||||
private static autoDeviceName(): string {
|
||||
let name: string = '';
|
||||
try {
|
||||
const brand: string = deviceInfo.brand;
|
||||
const model: string = deviceInfo.productModel;
|
||||
if (model.length > 0) {
|
||||
name = brand.length > 0 && model.indexOf(brand) < 0 ? brand + ' ' + model : model;
|
||||
}
|
||||
} catch (e) {
|
||||
name = '';
|
||||
}
|
||||
if (name.length === 0) {
|
||||
name = 'HarmonyOS 设备';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Local authorization flag reported via hello; server stores nothing. */
|
||||
getDeviceAuth(): boolean {
|
||||
if (this.prefs === null) {
|
||||
return false;
|
||||
}
|
||||
return this.prefs.getSync(KEY_DEVICE_AUTH, false) as boolean;
|
||||
}
|
||||
|
||||
saveDeviceAuth(on: boolean): void {
|
||||
if (this.prefs === null) {
|
||||
return;
|
||||
}
|
||||
this.prefs.putSync(KEY_DEVICE_AUTH, on);
|
||||
this.prefs.flush();
|
||||
}
|
||||
|
||||
private async save(): Promise<void> {
|
||||
if (this.prefs === null) {
|
||||
return;
|
||||
}
|
||||
await this.prefs.put(KEY_CONNECTIONS, JSON.stringify(this.connections));
|
||||
await this.prefs.put(KEY_SETTINGS, JSON.stringify(this.settings));
|
||||
await this.prefs.flush();
|
||||
}
|
||||
}
|
||||
|
||||
export const connStore: ConnStore = new ConnStore();
|
||||
328
cmd/ohos/HomeAgent/entry/src/main/ets/common/Constants.ets
Normal file
@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Design tokens — mirror of gui/renderer/style.css `:root` (dark theme).
|
||||
* All values copied 1:1 from the web GUI to keep visual identity identical.
|
||||
*
|
||||
* Theme system:
|
||||
* - ThemePalette holds every color token used by the app.
|
||||
* - DARK_PALETTE mirrors style.css `:root`; LIGHT_PALETTE mirrors
|
||||
* `[data-theme="light"][data-color="sakura"]`.
|
||||
* - themeStore keeps a global AppStorage("themeIsDark") flag that every page
|
||||
* reads through @StorageProp, so the whole UI re-renders on switch.
|
||||
*/
|
||||
|
||||
// ===== network =====
|
||||
export const DEFAULT_API_TIMEOUT: number = 120000;
|
||||
export const SSE_RECONNECT_DELAY: number = 5000;
|
||||
export const DEFAULT_WS_PORT: number = 9890;
|
||||
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
|
||||
export const CHAT_PAGE_SIZE: number = 40;
|
||||
|
||||
// ===== sakura / frost palette (style.css :root) =====
|
||||
export const COLOR_SAKURA_100: string = 'rgba(10, 89, 247, 0.1)';
|
||||
export const COLOR_SAKURA_200: string = 'rgba(10, 89, 247, 0.16)';
|
||||
export const COLOR_SAKURA_300: string = '#5B93F8';
|
||||
export const COLOR_SAKURA_400: string = '#0A59F7';
|
||||
export const COLOR_SAKURA_500: string = '#0A59F7';
|
||||
export const COLOR_SAKURA_600: string = '#0A59F7';
|
||||
export const COLOR_SAKURA_700: string = '#0846C2';
|
||||
export const COLOR_FROST_300: string = '#4A90D9';
|
||||
export const COLOR_FROST_400: string = '#3A78B5';
|
||||
export const COLOR_FROST_500: string = '#2C5E8C';
|
||||
export const COLOR_SUCCESS: string = '#30B260';
|
||||
export const COLOR_WARNING: string = '#D99A2B';
|
||||
export const COLOR_ERROR: string = '#E84026';
|
||||
export const COLOR_INFO: string = '#3F6EF5';
|
||||
export const COLOR_CYAN: string = '#2DD4BF';
|
||||
export const COLOR_VIOLET: string = '#A78BFA';
|
||||
export const COLOR_EMERALD: string = '#34D399';
|
||||
export const COLOR_AMBER: string = '#FBBF24';
|
||||
export const COLOR_BLUE: string = '#60A5FA';
|
||||
|
||||
// ===== 宽屏(平板 / 折叠展开 / 分屏)适配 =====
|
||||
/**
|
||||
* 宽屏断点:窗口宽度 >= 600vp 视为宽屏。
|
||||
* 600vp = Navigation 分栏所需的 minNavBarWidth(240) + minContentWidth(360),
|
||||
* 与系统 NavigationMode.Auto 的切换阈值保持一致,避免自判与系统行为脱节。
|
||||
*/
|
||||
export const WIDE_MIN_WIDTH: number = 600;
|
||||
/** 宽屏下左侧一级界面栏宽度(vp):左边一级界面,右边二级界面 */
|
||||
export const WIDE_NAV_BAR_WIDTH: number = 420;
|
||||
/** 宽屏下右侧二级界面的最小宽度(vp),不足时左栏被压缩 */
|
||||
export const WIDE_MIN_CONTENT: number = 380;
|
||||
/** 悬浮导航胶囊的最大宽度(vp):宽屏下避免被拉成长条 */
|
||||
export const NAV_PILL_MAX_WIDTH: number = 620;
|
||||
|
||||
/** Every color token the app renders with. */
|
||||
export interface ThemePalette {
|
||||
bgPrimary: string;
|
||||
bgSecondary: string;
|
||||
bgCard: string;
|
||||
bgInput: string;
|
||||
bgHover: string;
|
||||
glassBgStrong: string;
|
||||
glassBorder: string;
|
||||
border: string;
|
||||
kvBorder: string;
|
||||
btnGhostBorder: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
textMuted: string;
|
||||
textTertiary: string;
|
||||
accent: string;
|
||||
accentBg: string;
|
||||
preBg: string;
|
||||
preText: string;
|
||||
msgUserBg: string;
|
||||
msgUserText: string;
|
||||
msgAssistantBg: string;
|
||||
msgAssistantText: string;
|
||||
msgBubbleBg: string;
|
||||
msgBubbleText: string;
|
||||
msgBubbleBorder: string;
|
||||
msgUserBubbleBg: string;
|
||||
msgUserBubbleBorder: string;
|
||||
msgAssistantBubbleBg: string;
|
||||
msgAssistantBubbleBorder: string;
|
||||
toastBg: string;
|
||||
toastText: string;
|
||||
toastErrorBg: string;
|
||||
toastErrorText: string;
|
||||
toastWarnBg: string;
|
||||
toastWarnText: string;
|
||||
successSoftBg: string;
|
||||
errorSoftBg: string;
|
||||
frostSoftBg: string;
|
||||
navBarBg: string;
|
||||
navBarBorder: string;
|
||||
navBarGradientStart: string;
|
||||
navBarGradientEnd: string;
|
||||
shadow: string;
|
||||
gradA: string; // sakura top-right glow (start color)
|
||||
gradB: string; // frost mid-left glow
|
||||
gradC: string; // sakura bottom glow
|
||||
gradAEnd: string; // transparent end color for radial fade
|
||||
gradBEnd: string;
|
||||
gradCEnd: string;
|
||||
}
|
||||
|
||||
/** Dark palette — HarmonyOS system-app style neutral dark. */
|
||||
export const DARK_PALETTE: ThemePalette = {
|
||||
bgPrimary: '#000000',
|
||||
bgSecondary: 'rgba(28, 28, 30, 0.72)',
|
||||
bgCard: 'rgba(28, 28, 30, 0.6)',
|
||||
bgInput: 'rgba(44, 44, 46, 0.92)',
|
||||
bgHover: 'rgba(255, 255, 255, 0.08)',
|
||||
glassBgStrong: 'rgba(28, 28, 30, 0.82)',
|
||||
glassBorder: 'rgba(255, 255, 255, 0.1)',
|
||||
border: 'rgba(255, 255, 255, 0.1)',
|
||||
kvBorder: 'rgba(255, 255, 255, 0.08)',
|
||||
btnGhostBorder: 'rgba(255, 255, 255, 0.18)',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#D1D1D6',
|
||||
textMuted: '#98989F',
|
||||
textTertiary: '#8E8E93',
|
||||
accent: '#0A59F7',
|
||||
accentBg: 'rgba(10, 89, 247, 0.18)',
|
||||
preBg: 'rgba(20, 20, 22, 0.9)',
|
||||
preText: '#D6E4FF',
|
||||
msgUserBg: 'rgba(10, 89, 247, 0.2)',
|
||||
msgUserText: '#9DC0FC',
|
||||
msgAssistantBg: 'rgba(120, 120, 128, 0.24)',
|
||||
msgAssistantText: '#E5E5EA',
|
||||
msgBubbleBg: '#1C1C1E',
|
||||
msgBubbleText: '#F2F2F7',
|
||||
msgBubbleBorder: 'rgba(255, 255, 255, 0.12)',
|
||||
msgUserBubbleBg: 'rgba(10, 89, 247, 0.18)',
|
||||
msgUserBubbleBorder: 'rgba(10, 89, 247, 0.25)',
|
||||
msgAssistantBubbleBg: 'rgba(58, 58, 60, 0.72)',
|
||||
msgAssistantBubbleBorder: 'rgba(255, 255, 255, 0.1)',
|
||||
toastBg: 'rgba(48, 178, 96, 0.18)',
|
||||
toastText: '#4CD47A',
|
||||
toastErrorBg: 'rgba(232, 64, 38, 0.2)',
|
||||
toastErrorText: '#FF6B4A',
|
||||
toastWarnBg: 'rgba(255, 159, 10, 0.16)',
|
||||
toastWarnText: '#FFCC66',
|
||||
successSoftBg: 'rgba(48, 178, 96, 0.16)',
|
||||
errorSoftBg: 'rgba(232, 64, 38, 0.18)',
|
||||
frostSoftBg: 'rgba(74, 144, 217, 0.14)',
|
||||
navBarBg: 'rgba(24, 24, 26, 0.45)',
|
||||
navBarBorder: 'rgba(255, 255, 255, 0.14)',
|
||||
navBarGradientStart: 'rgba(40, 40, 44, 0.35)',
|
||||
navBarGradientEnd: 'rgba(20, 20, 22, 0.5)',
|
||||
shadow: 'rgba(0, 0, 0, 0.5)',
|
||||
gradA: 'rgba(10, 89, 247, 0.1)',
|
||||
gradB: 'rgba(74, 144, 217, 0.07)',
|
||||
gradC: 'rgba(94, 92, 230, 0.06)',
|
||||
gradAEnd: 'rgba(10, 89, 247, 0)',
|
||||
gradBEnd: 'rgba(74, 144, 217, 0)',
|
||||
gradCEnd: 'rgba(94, 92, 230, 0)',
|
||||
};
|
||||
|
||||
/** Light palette — HarmonyOS system-app style neutral light. */
|
||||
export const LIGHT_PALETTE: ThemePalette = {
|
||||
bgPrimary: '#F1F3F5',
|
||||
bgSecondary: 'rgba(255, 255, 255, 0.85)',
|
||||
bgCard: 'rgba(255, 255, 255, 0.95)',
|
||||
bgInput: 'rgba(118, 118, 128, 0.28)',
|
||||
bgHover: 'rgba(0, 0, 0, 0.05)',
|
||||
glassBgStrong: 'rgba(255, 255, 255, 0.92)',
|
||||
glassBorder: 'rgba(60, 60, 67, 0.12)',
|
||||
border: 'rgba(60, 60, 67, 0.12)',
|
||||
kvBorder: 'rgba(60, 60, 67, 0.1)',
|
||||
btnGhostBorder: 'rgba(60, 60, 67, 0.2)',
|
||||
textPrimary: '#191919',
|
||||
textSecondary: '#494949',
|
||||
textMuted: '#777779',
|
||||
textTertiary: '#8A8A8E',
|
||||
accent: '#0A59F7',
|
||||
accentBg: 'rgba(10, 89, 247, 0.1)',
|
||||
preBg: 'rgba(118, 118, 128, 0.1)',
|
||||
preText: '#3C3C43',
|
||||
msgUserBg: 'rgba(10, 89, 247, 0.12)',
|
||||
msgUserText: '#0A59F7',
|
||||
msgAssistantBg: '#FFFFFF',
|
||||
msgAssistantText: '#333333',
|
||||
msgBubbleBg: '#FFFFFF',
|
||||
msgBubbleText: '#191919',
|
||||
msgBubbleBorder: 'rgba(60, 60, 67, 0.12)',
|
||||
msgUserBubbleBg: 'rgba(10, 89, 247, 0.1)',
|
||||
msgUserBubbleBorder: 'rgba(10, 89, 247, 0.18)',
|
||||
msgAssistantBubbleBg: 'rgba(255, 255, 255, 0.88)',
|
||||
msgAssistantBubbleBorder: 'rgba(60, 60, 67, 0.1)',
|
||||
toastBg: 'rgba(48, 178, 96, 0.14)',
|
||||
toastText: '#157347',
|
||||
toastErrorBg: 'rgba(232, 64, 38, 0.12)',
|
||||
toastErrorText: '#C0361F',
|
||||
toastWarnBg: 'rgba(255, 159, 10, 0.14)',
|
||||
toastWarnText: '#8F5A00',
|
||||
successSoftBg: 'rgba(48, 178, 96, 0.12)',
|
||||
errorSoftBg: 'rgba(232, 64, 38, 0.1)',
|
||||
frostSoftBg: 'rgba(74, 144, 217, 0.12)',
|
||||
navBarBg: 'rgba(250, 250, 252, 0.55)',
|
||||
navBarBorder: 'rgba(60, 60, 67, 0.15)',
|
||||
navBarGradientStart: 'rgba(255, 255, 255, 0.35)',
|
||||
navBarGradientEnd: 'rgba(240, 240, 245, 0.5)',
|
||||
shadow: 'rgba(0, 0, 0, 0.1)',
|
||||
gradA: 'rgba(10, 89, 247, 0.06)',
|
||||
gradB: 'rgba(74, 144, 217, 0.05)',
|
||||
gradC: 'rgba(94, 92, 230, 0.04)',
|
||||
gradAEnd: 'rgba(10, 89, 247, 0)',
|
||||
gradBEnd: 'rgba(74, 144, 217, 0)',
|
||||
gradCEnd: 'rgba(94, 92, 230, 0)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy single-theme constants kept for incremental migration.
|
||||
* New code should use tp() / ThemePalette instead.
|
||||
*/
|
||||
export const COLOR_BG_PRIMARY: string = '#000000';
|
||||
export const COLOR_BG_SECONDARY: string = 'rgba(17, 24, 44, 0.72)';
|
||||
export const COLOR_BG_CARD: string = 'rgba(17, 24, 44, 0.6)';
|
||||
export const COLOR_BG_INPUT: string = 'rgba(13, 18, 34, 0.75)';
|
||||
export const COLOR_BG_HOVER: string = 'rgba(255, 255, 255, 0.06)';
|
||||
export const COLOR_GLASS_BG: string = 'rgba(13, 18, 34, 0.6)';
|
||||
export const COLOR_GLASS_BG_STRONG: string = 'rgba(13, 18, 32, 0.82)';
|
||||
export const COLOR_GLASS_BORDER: string = 'rgba(255, 255, 255, 0.08)';
|
||||
export const COLOR_GLASS_HOVER: string = 'rgba(255, 255, 255, 0.05)';
|
||||
|
||||
export const COLOR_TEXT_PRIMARY: string = '#FFFFFF';
|
||||
export const COLOR_TEXT_SECONDARY: string = 'rgba(255, 255, 255, 0.6)';
|
||||
export const COLOR_TEXT_MUTED: string = 'rgba(255, 255, 255, 0.4)';
|
||||
export const COLOR_TEXT_TERTIARY: string = 'rgba(255, 255, 255, 0.45)';
|
||||
|
||||
export const COLOR_ACCENT: string = '#0A59F7';
|
||||
export const COLOR_ACCENT_BG: string = 'rgba(10, 89, 247, 0.14)';
|
||||
export const COLOR_BORDER: string = 'rgba(255, 255, 255, 0.09)';
|
||||
export const COLOR_KV_BORDER: string = 'rgba(255, 255, 255, 0.07)';
|
||||
export const COLOR_BTN_GHOST_BORDER: string = 'rgba(255, 255, 255, 0.14)';
|
||||
export const COLOR_SAVE_BTN_BORDER: string = '#D99A2B';
|
||||
|
||||
export const COLOR_TOAST_BG: string = 'rgba(23, 169, 100, 0.16)';
|
||||
export const COLOR_TOAST_TEXT: string = '#6EE7A8';
|
||||
export const COLOR_TOAST_ERROR_BG: string = 'rgba(232, 64, 38, 0.18)';
|
||||
export const COLOR_TOAST_ERROR_TEXT: string = '#F0865B';
|
||||
export const COLOR_TOAST_WARN_BG: string = 'rgba(217, 154, 43, 0.16)';
|
||||
export const COLOR_TOAST_WARN_TEXT: string = '#FCD9A0';
|
||||
|
||||
// ===== radius (style.css --radius-*) =====
|
||||
export const RADIUS_SM: number = 6;
|
||||
export const RADIUS_MD: number = 10;
|
||||
export const RADIUS_LG: number = 14;
|
||||
export const RADIUS_PILL: number = 999;
|
||||
|
||||
// ===== misc states =====
|
||||
export const COLOR_CONN_OFFLINE: string = '#77809A';
|
||||
export const COLOR_DOT_GRAY: string = '#475569';
|
||||
|
||||
// =====================================================================
|
||||
// Global reactive theme store.
|
||||
//
|
||||
// AppStorage keys:
|
||||
// "themeIsDark" boolean — current resolved dark/light state
|
||||
// "themeMode" string — 'system' | 'dark' | 'light'
|
||||
//
|
||||
// Pages read via @StorageProp("themeIsDark") and pick colors from
|
||||
// tp() so the whole tree re-renders when the mode flips.
|
||||
// EntryAbility seeds both on launch and updates "themeIsDark" on
|
||||
// onConfigurationUpdate (system dark-mode change).
|
||||
// =====================================================================
|
||||
|
||||
const KEY_THEME_IS_DARK: string = 'themeIsDark';
|
||||
const KEY_SYSTEM_IS_DARK: string = 'systemIsDark';
|
||||
const KEY_THEME_MODE: string = 'themeMode';
|
||||
|
||||
export function seedTheme(isDark: boolean): void {
|
||||
if (!AppStorage.has(KEY_THEME_IS_DARK)) {
|
||||
AppStorage.setOrCreate(KEY_THEME_IS_DARK, isDark);
|
||||
} else {
|
||||
AppStorage.set(KEY_THEME_IS_DARK, isDark);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the OS dark/light state. UI never binds to this directly, but the
|
||||
* 'system' mode resolves against it, so EntryAbility updates it on config
|
||||
* change and then flips themeIsDark when mode === 'system'.
|
||||
*/
|
||||
export function seedSystemIsDark(isDark: boolean): void {
|
||||
if (!AppStorage.has(KEY_SYSTEM_IS_DARK)) {
|
||||
AppStorage.setOrCreate(KEY_SYSTEM_IS_DARK, isDark);
|
||||
} else {
|
||||
AppStorage.set(KEY_SYSTEM_IS_DARK, isDark);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stored theme mode ('system'|'dark'|'light') against the current
|
||||
* system color mode. 'system' falls back to the systemIsDark flag.
|
||||
*/
|
||||
export function resolveIsDark(mode: string, sysDark: boolean): boolean {
|
||||
if (mode === 'dark') {
|
||||
return true;
|
||||
}
|
||||
if (mode === 'light') {
|
||||
return false;
|
||||
}
|
||||
return sysDark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a stored theme mode: persist the mode token and immediately flip the
|
||||
* reactive themeIsDark flag so the whole UI re-renders.
|
||||
*/
|
||||
export function applyThemeMode(mode: string): void {
|
||||
const sysDark: boolean = AppStorage.get<boolean>(KEY_SYSTEM_IS_DARK) ?? true;
|
||||
AppStorage.set(KEY_THEME_MODE, mode);
|
||||
seedTheme(resolveIsDark(mode, sysDark));
|
||||
}
|
||||
|
||||
/** Current resolved palette for @Builder / build() usage. */
|
||||
export function tp(): ThemePalette {
|
||||
const dark: boolean = AppStorage.get<boolean>(KEY_THEME_IS_DARK) ?? true;
|
||||
return dark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/** Raw read for non-UI code. */
|
||||
export function themeIsDark(): boolean {
|
||||
return AppStorage.get<boolean>(KEY_THEME_IS_DARK) ?? true;
|
||||
}
|
||||
394
cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets
Normal file
@ -0,0 +1,394 @@
|
||||
import { webSocket } from '@kit.NetworkKit';
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { CapResult } from './BridgeCaps';
|
||||
|
||||
// ===== 协议消息(与 remotedevice 插件对齐)=====
|
||||
|
||||
interface HelloDeviceInfo {
|
||||
hostname: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
os_release: string;
|
||||
version: string;
|
||||
cpus: number;
|
||||
}
|
||||
|
||||
interface HelloDevice {
|
||||
device_id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
authorized: boolean;
|
||||
caps: string[];
|
||||
info: HelloDeviceInfo;
|
||||
}
|
||||
|
||||
interface HelloMessage {
|
||||
op: string;
|
||||
device: HelloDevice;
|
||||
}
|
||||
|
||||
interface BindMessage {
|
||||
op: string;
|
||||
device_id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface CmdMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
command: string;
|
||||
cmd_type: string;
|
||||
}
|
||||
|
||||
export interface CmdReply {
|
||||
op: string; // 'cmd_result'
|
||||
req_id: string;
|
||||
status: string;
|
||||
output: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface DataStartMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
kind: string;
|
||||
mime: string;
|
||||
total: number;
|
||||
chunk_size: number;
|
||||
}
|
||||
|
||||
interface DataEndMessage {
|
||||
op: string;
|
||||
req_id: string;
|
||||
status: string;
|
||||
total?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE: number = 8192;
|
||||
|
||||
// ===== 命令处理器回调 =====
|
||||
// 返回 CapResult;二进制大结果通过 dataHandler 分块回传。
|
||||
export type BridgeCmdHandler = (reqId: string, command: string) => Promise<CapResult>;
|
||||
|
||||
export class DeviceBridgeClient {
|
||||
private ws: webSocket.WebSocket = webSocket.createWebSocket();
|
||||
private url: string = '';
|
||||
private token: string = '';
|
||||
private deviceId: string = '';
|
||||
private name: string = 'HomeAgent OHOS';
|
||||
private kind: string = 'phone';
|
||||
private caps: string[] = [];
|
||||
private hostname: string = 'ohos';
|
||||
private connected: boolean = false;
|
||||
private everConnected: boolean = false;
|
||||
private manualClose: boolean = false;
|
||||
private reconnectTimer: number = -1;
|
||||
private cmdHandler: BridgeCmdHandler | null = null;
|
||||
private onStateChange: ((open: boolean) => void) | null = null;
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
getDeviceId(): string {
|
||||
return this.deviceId;
|
||||
}
|
||||
|
||||
setCmdHandler(handler: BridgeCmdHandler): void {
|
||||
this.cmdHandler = handler;
|
||||
}
|
||||
|
||||
setStateListener(listener: (open: boolean) => void): void {
|
||||
this.onStateChange = listener;
|
||||
}
|
||||
|
||||
async connect(url: string, token: string, deviceId: string,
|
||||
caps: string[], hostname: string,
|
||||
authorized: boolean, name: string): Promise<void> {
|
||||
this.url = url;
|
||||
this.token = token;
|
||||
this.deviceId = deviceId;
|
||||
this.caps = caps;
|
||||
this.hostname = hostname;
|
||||
this.name = name;
|
||||
this.manualClose = false;
|
||||
await this.openAndRegister(authorized);
|
||||
}
|
||||
|
||||
private async openAndRegister(authorized: boolean): Promise<void> {
|
||||
// 每次连接使用新的 WebSocket 实例,避免旧实例事件残留
|
||||
try {
|
||||
this.ws.off('open');
|
||||
this.ws.off('message');
|
||||
this.ws.off('close');
|
||||
this.ws.off('error');
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
this.ws = webSocket.createWebSocket();
|
||||
this.bindWsEvents(authorized);
|
||||
// 鉴权必须走请求头,不能拼 ?token= :
|
||||
// 1) webui 的 /api/v1/device/* 反代包在 requireAPI 里,
|
||||
// validAPIKey 只认 X-API-Key 头或 Authorization: Bearer,
|
||||
// 查询参数一律视为未授权 → 握手被 401 顶掉,
|
||||
// 表现为 NETSTACK 日志 "Lws client connection error HS: ws upgrade unauthorized"。
|
||||
// 2) 反代到 remotedevice 时会自行注入网关的 ws_token;
|
||||
// 如果我们再带 ?token=<webui apiKey>,remotedevice 的 ServeWS
|
||||
// 会拿它和 ws_token 比对并 401。留空反而放行。
|
||||
const opts: webSocket.WebSocketRequestOptions = {
|
||||
header: this.authHeader(),
|
||||
};
|
||||
try {
|
||||
await this.ws.connect(this.url, opts);
|
||||
} catch (e) {
|
||||
this.connected = false;
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** 握手请求头:X-API-Key + Authorization 双写,兼容不同后端校验实现。 */
|
||||
private authHeader(): Record<string, string> {
|
||||
const h: Record<string, string> = {};
|
||||
if (this.token.length > 0) {
|
||||
h['X-API-Key'] = this.token;
|
||||
h['Authorization'] = 'Bearer ' + this.token;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
private bindWsEvents(authorized: boolean): void {
|
||||
this.ws.on('open', (err: Error, value: Object) => {
|
||||
this.connected = true;
|
||||
this.everConnected = true;
|
||||
this.cancelReconnect();
|
||||
this.sendHello(authorized);
|
||||
this.sendBind();
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(true);
|
||||
}
|
||||
});
|
||||
this.ws.on('message', (err: Error, value: string | ArrayBuffer) => {
|
||||
if (typeof value === 'string') {
|
||||
this.handleTextFrame(value);
|
||||
}
|
||||
});
|
||||
this.ws.on('close', (err: Error, value: webSocket.CloseResult) => {
|
||||
this.connected = false;
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(false);
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
this.ws.on('error', (err: Error) => {
|
||||
this.connected = false;
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(false);
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.manualClose || this.reconnectTimer >= 0) {
|
||||
return;
|
||||
}
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = -1;
|
||||
if (this.manualClose || this.url.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.openAndRegister(this.lastAuthorized);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private lastAuthorized: boolean = false;
|
||||
|
||||
private cancelReconnect(): void {
|
||||
if (this.reconnectTimer >= 0) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新本地授权状态并立即重新 hello 同步到服务端。 */
|
||||
updateAuthorized(authorized: boolean): void {
|
||||
this.lastAuthorized = authorized;
|
||||
if (this.connected) {
|
||||
this.sendHello(authorized);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.manualClose = true;
|
||||
this.cancelReconnect();
|
||||
this.connected = false;
|
||||
try {
|
||||
this.ws.off('open');
|
||||
this.ws.off('message');
|
||||
this.ws.off('close');
|
||||
this.ws.off('error');
|
||||
this.ws.close().catch(() => {
|
||||
// ignore
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
if (this.onStateChange !== null) {
|
||||
this.onStateChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
private sendHello(authorized: boolean): void {
|
||||
this.lastAuthorized = authorized;
|
||||
const info: HelloDeviceInfo = {
|
||||
hostname: this.hostname,
|
||||
platform: 'OpenHarmony',
|
||||
arch: '',
|
||||
os_release: '',
|
||||
version: '1.1.0',
|
||||
cpus: 0,
|
||||
};
|
||||
const device: HelloDevice = {
|
||||
device_id: this.deviceId,
|
||||
name: this.name,
|
||||
kind: this.kind,
|
||||
authorized: authorized,
|
||||
caps: this.caps,
|
||||
info: info,
|
||||
};
|
||||
const hello: HelloMessage = { op: 'hello', device: device };
|
||||
this.send(JSON.stringify(hello));
|
||||
}
|
||||
|
||||
private sendBind(): void {
|
||||
const bind: BindMessage = {
|
||||
op: 'bind',
|
||||
device_id: this.deviceId,
|
||||
token: this.token,
|
||||
};
|
||||
this.send(JSON.stringify(bind));
|
||||
}
|
||||
|
||||
// ===== 命令处理 =====
|
||||
|
||||
private handleTextFrame(text: string): void {
|
||||
let obj: Record<string, Object>;
|
||||
try {
|
||||
obj = JSON.parse(text) as Record<string, Object>;
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
const op: string = obj['op'] as string ?? '';
|
||||
if (op === 'cmd') {
|
||||
const reqId: string = obj['req_id'] as string ?? '';
|
||||
const command: string = obj['command'] as string ?? '';
|
||||
if (reqId.length === 0 || command.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!this.lastAuthorized) {
|
||||
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
|
||||
return;
|
||||
}
|
||||
this.dispatchCommand(reqId, command);
|
||||
} else if (op === 'hello_ack' || op === 'bind_ack') {
|
||||
if (this.onAck !== null) {
|
||||
this.onAck(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onAck: ((op: string) => void) | null = null;
|
||||
|
||||
private dispatchCommand(reqId: string, command: string): void {
|
||||
if (this.cmdHandler === null) {
|
||||
this.sendResult(reqId, 'error', '', 'no capability handler registered');
|
||||
return;
|
||||
}
|
||||
const handler: BridgeCmdHandler = this.cmdHandler;
|
||||
handler(reqId, command).then((res: CapResult) => {
|
||||
this.sendResult(reqId, res.status, res.output, res.error);
|
||||
}).catch((e: Object) => {
|
||||
const msg: string = e instanceof Error ? e.message : String(e);
|
||||
this.sendResult(reqId, 'error', '', msg);
|
||||
});
|
||||
}
|
||||
|
||||
sendResult(reqId: string, status: string, output: string, errMsg: string): void {
|
||||
const result: CmdReply = {
|
||||
op: 'cmd_result',
|
||||
req_id: reqId,
|
||||
status: status,
|
||||
output: output,
|
||||
error: errMsg,
|
||||
};
|
||||
this.send(JSON.stringify(result));
|
||||
}
|
||||
|
||||
// ===== 二进制分块回传(协议与 GUI 客户端一致)=====
|
||||
|
||||
sendDataChunked(reqId: string, kind: string, mime: string, bytes: Uint8Array): void {
|
||||
const startMsg: DataStartMessage = {
|
||||
op: 'cmd_data_start',
|
||||
req_id: reqId,
|
||||
kind: kind,
|
||||
mime: mime,
|
||||
total: bytes.byteLength,
|
||||
chunk_size: CHUNK_SIZE,
|
||||
};
|
||||
this.send(JSON.stringify(startMsg));
|
||||
for (let off: number = 0; off < bytes.byteLength; off += CHUNK_SIZE) {
|
||||
const end: number = Math.min(off + CHUNK_SIZE, bytes.byteLength);
|
||||
const view: Uint8Array = bytes.slice(off, end);
|
||||
const ab: ArrayBuffer = view.buffer as ArrayBuffer;
|
||||
try {
|
||||
this.ws.send(ab).catch(() => {
|
||||
// ignore per-chunk failure; end frame reports error below
|
||||
});
|
||||
} catch (e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const endMsg: DataEndMessage = {
|
||||
op: 'cmd_data_end',
|
||||
req_id: reqId,
|
||||
status: 'ok',
|
||||
};
|
||||
this.send(JSON.stringify(endMsg));
|
||||
}
|
||||
|
||||
sendEvent(eventType: string, detail: string): void {
|
||||
const payload: Record<string, string> = { 'detail': detail };
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'event',
|
||||
'device_id': this.deviceId,
|
||||
'type': eventType,
|
||||
'payload': payload,
|
||||
};
|
||||
this.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
sendStatus(status: string): void {
|
||||
const msg: Record<string, Object> = {
|
||||
'op': 'status',
|
||||
'device_id': this.deviceId,
|
||||
'status': status,
|
||||
};
|
||||
this.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
send(text: string): void {
|
||||
if (!this.connected) {
|
||||
return;
|
||||
}
|
||||
this.ws.send(text).catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
|
||||
|
||||
export function parseDevicesPayload(jsonStr: string): DeviceInfo[] {
|
||||
return [];
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 导航栏显隐控制器(跨页面共享单例)。
|
||||
* 规则:任何页面滚动中隐藏底部导航,滚动停止(松手/fling 结束)后重新显示。
|
||||
*/
|
||||
type NavListener = (visible: boolean) => void;
|
||||
|
||||
export class NavBarController {
|
||||
private static instance: NavBarController | null = null;
|
||||
private visible: boolean = true;
|
||||
private listeners: NavListener[] = [];
|
||||
|
||||
static shared(): NavBarController {
|
||||
if (NavBarController.instance === null) {
|
||||
NavBarController.instance = new NavBarController();
|
||||
}
|
||||
return NavBarController.instance;
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.visible;
|
||||
}
|
||||
|
||||
setVisible(v: boolean): void {
|
||||
if (this.visible !== v) {
|
||||
this.visible = v;
|
||||
AppStorage.setOrCreate<boolean>('navVisible', v);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
addListener(l: NavListener): void {
|
||||
this.listeners.push(l);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (let i = 0; i < this.listeners.length; i++) {
|
||||
this.listeners[i](this.visible);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const navBar: NavBarController = NavBarController.shared();
|
||||
|
||||
/**
|
||||
* 页面 Scroll.onDidScroll 的统一处理:
|
||||
* Scroll/Fling 状态(手指拖动或惯性滚动)隐藏导航栏,
|
||||
* Idle(松手或惯性结束)重新显示。
|
||||
*/
|
||||
export function handleNavOnScroll(state: ScrollState): void {
|
||||
if (state === ScrollState.Idle) {
|
||||
navBar.setVisible(true);
|
||||
} else {
|
||||
navBar.setVisible(false);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 各主 Tab 页面的二级导航栈登记处。
|
||||
*
|
||||
* 为什么需要它:四个一级页面各自持有一个 Navigation(宽屏要"左一级右二级",
|
||||
* 所以栈必须是页面局部的,不能提到 Index 里去)。但四个 Navigation 同时
|
||||
* 挂在 Swiper 里都是活的,系统返回事件落到哪一个并不确定 —— 表现就是
|
||||
* "有的页面返回手势能用、有的不能"。
|
||||
*
|
||||
* 解决办法:页面把自己的栈按 Tab 序号登记进来,@Entry 页在 onBackPress 里
|
||||
* 按当前 Tab 精确地 pop 对应的栈。这样返回手势/三键返回/无障碍返回
|
||||
* 在每个页面上的行为都是确定的。
|
||||
*/
|
||||
|
||||
interface StackEntry {
|
||||
stack: NavPathStack;
|
||||
/** pop 之后页面要同步自己的选中态(宽屏高亮、activeXxx 等) */
|
||||
onPopped: () => void;
|
||||
}
|
||||
|
||||
const registry: Map<number, StackEntry> = new Map<number, StackEntry>();
|
||||
|
||||
/** 页面 aboutToAppear 时登记;同一 Tab 重复登记以最后一次为准。 */
|
||||
export function registerNavStack(tab: number, stack: NavPathStack, onPopped: () => void): void {
|
||||
registry.set(tab, { stack: stack, onPopped: onPopped });
|
||||
}
|
||||
|
||||
export function unregisterNavStack(tab: number): void {
|
||||
registry.delete(tab);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回键/返回手势的统一处理。
|
||||
* 返回 true 表示已消费(弹出了一层二级页面),false 表示交回系统(退出应用)。
|
||||
*
|
||||
* 宽屏 Split 模式下右栏常驻,返回不该把它清空,所以那时直接不消费。
|
||||
*/
|
||||
export function handleBackPress(tab: number, isWide: boolean): boolean {
|
||||
if (isWide) {
|
||||
return false;
|
||||
}
|
||||
const e: StackEntry | undefined = registry.get(tab);
|
||||
if (e === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (e.stack.size() <= 0) {
|
||||
return false;
|
||||
}
|
||||
e.stack.pop();
|
||||
e.onPopped();
|
||||
return true;
|
||||
}
|
||||
258
cmd/ohos/HomeAgent/entry/src/main/ets/common/SseClient.ets
Normal file
@ -0,0 +1,258 @@
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
|
||||
export interface SseEvent {
|
||||
event: string;
|
||||
data: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type SseHandler = (ev: SseEvent) => void;
|
||||
export type SseCloseHandler = () => void;
|
||||
export type SseOpenHandler = () => void;
|
||||
|
||||
function decodeUtf8(bytes: Uint8Array): string {
|
||||
let result: string = '';
|
||||
let i: number = 0;
|
||||
while (i < bytes.length) {
|
||||
const b: number = bytes[i];
|
||||
if (b < 0x80) {
|
||||
result += String.fromCharCode(b);
|
||||
i++;
|
||||
} else if (b < 0xC0) {
|
||||
i++;
|
||||
} else if (b < 0xE0) {
|
||||
if (i + 1 < bytes.length) {
|
||||
result += String.fromCharCode(((b & 0x1F) << 6) | (bytes[i + 1] & 0x3F));
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else if (b < 0xF0) {
|
||||
if (i + 2 < bytes.length) {
|
||||
result += String.fromCharCode(
|
||||
((b & 0x0F) << 12) | ((bytes[i + 1] & 0x3F) << 6) | (bytes[i + 2] & 0x3F),
|
||||
);
|
||||
i += 3;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
if (i + 3 < bytes.length) {
|
||||
const cp: number =
|
||||
((b & 0x07) << 18) |
|
||||
((bytes[i + 1] & 0x3F) << 12) |
|
||||
((bytes[i + 2] & 0x3F) << 6) |
|
||||
(bytes[i + 3] & 0x3F);
|
||||
const adjusted: number = cp - 0x10000;
|
||||
result += String.fromCharCode(0xD800 + (adjusted >> 10));
|
||||
result += String.fromCharCode(0xDC00 + (adjusted & 0x3FF));
|
||||
i += 4;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class SseClient {
|
||||
private httpRequest: http.HttpRequest | null = null;
|
||||
private buffer: string = '';
|
||||
private lastEventId: string = '';
|
||||
private closed: boolean = false;
|
||||
private opened: boolean = false;
|
||||
private onEvent: SseHandler | null = null;
|
||||
private onClose: SseCloseHandler | null = null;
|
||||
private onOpen: SseOpenHandler | null = null;
|
||||
|
||||
setLastEventId(id: string): void {
|
||||
this.lastEventId = id;
|
||||
}
|
||||
|
||||
getLastEventId(): string {
|
||||
return this.lastEventId;
|
||||
}
|
||||
|
||||
async connect(conn: ConnectionConfig, path: string,
|
||||
onEvent: SseHandler, onClose: SseCloseHandler,
|
||||
onOpen: SseOpenHandler | null = null): Promise<void> {
|
||||
this.onEvent = onEvent;
|
||||
this.onClose = onClose;
|
||||
this.onOpen = onOpen;
|
||||
this.closed = false;
|
||||
this.opened = false;
|
||||
this.buffer = '';
|
||||
this.curEvent = '';
|
||||
this.curData = '';
|
||||
this.curId = '';
|
||||
|
||||
const base: string = conn.url.replace(/\/+$/, '');
|
||||
const url: string = base + '/api/v1' + path;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'text/event-stream',
|
||||
'Cache-Control': 'no-store',
|
||||
};
|
||||
if (conn.apiKey.length > 0) {
|
||||
// 后端 validAPIKey 两种都认;有些反代只放行 Authorization,两个都带更稳
|
||||
headers['X-API-Key'] = conn.apiKey;
|
||||
headers['Authorization'] = 'Bearer ' + conn.apiKey;
|
||||
}
|
||||
if (this.lastEventId.length > 0) {
|
||||
headers['Last-Event-ID'] = this.lastEventId;
|
||||
}
|
||||
|
||||
const req: http.HttpRequest = http.createHttp();
|
||||
this.httpRequest = req;
|
||||
|
||||
req.on('headersReceive', (header: Object) => {
|
||||
const ct: string = this.getHeaderValue(header, 'content-type');
|
||||
if (ct.indexOf('text/event-stream') >= 0) {
|
||||
this.markOpened();
|
||||
} else {
|
||||
this.finish();
|
||||
}
|
||||
});
|
||||
|
||||
req.on('dataReceive', (chunk: ArrayBuffer) => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.markOpened();
|
||||
const bytes: Uint8Array = new Uint8Array(chunk);
|
||||
const text: string = decodeUtf8(bytes);
|
||||
this.buffer += text;
|
||||
this.processBuffer();
|
||||
});
|
||||
|
||||
req.on('dataEnd', () => {
|
||||
this.finish();
|
||||
});
|
||||
|
||||
const options: http.HttpRequestOptions = {
|
||||
method: http.RequestMethod.GET,
|
||||
header: headers,
|
||||
expectDataType: http.HttpDataType.ARRAY_BUFFER,
|
||||
usingCache: false,
|
||||
readTimeout: 3600000,
|
||||
connectTimeout: 15000,
|
||||
};
|
||||
|
||||
try {
|
||||
await req.requestInStream(url, options);
|
||||
} catch (e) {
|
||||
this.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private getHeaderValue(header: Object, name: string): string {
|
||||
try {
|
||||
const rec: Record<string, string> = header as Record<string, string>;
|
||||
const lower: string = name.toLowerCase();
|
||||
const keys: string[] = Object.keys(rec);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (keys[i].toLowerCase() === lower) {
|
||||
const val: string | undefined = rec[keys[i]];
|
||||
return val !== undefined ? val : '';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 帧解析状态必须【跨 chunk 保持】。
|
||||
*
|
||||
* 之前把 eventType/data/id 作为 processBuffer 的局部变量,
|
||||
* 而 TCP 分片完全可能切在帧内部的换行处(服务端 16ms 批量 flush 时
|
||||
* 一次写入几十帧,尾部被切开是常态):
|
||||
* chunk1 = "...event: content_delta\n"
|
||||
* chunk2 = "data: {...}\n\n"
|
||||
* 于是 chunk1 解析出的 event 被丢掉,chunk2 只剩 data 而没有事件名,
|
||||
* 整帧被静默丢弃 —— 表现就是"工具调用和思考不显示、也不是流式"。
|
||||
*/
|
||||
private curEvent: string = '';
|
||||
private curData: string = '';
|
||||
private curId: string = '';
|
||||
|
||||
private processBuffer(): void {
|
||||
const lines: string[] = this.buffer.split('\n');
|
||||
this.buffer = lines.pop() ?? '';
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
// 兼容 CRLF:\r 会污染事件名与 JSON 尾部
|
||||
let line: string = lines[i];
|
||||
if (line.length > 0 && line.charAt(line.length - 1) === '\r') {
|
||||
line = line.substring(0, line.length - 1);
|
||||
}
|
||||
if (line.startsWith(':')) {
|
||||
// 注释行(心跳),忽略
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('id:')) {
|
||||
this.curId = line.substring(3).trim();
|
||||
if (this.curId.length > 0) {
|
||||
this.lastEventId = this.curId;
|
||||
}
|
||||
} else if (line.startsWith('event:')) {
|
||||
this.curEvent = line.substring(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
// SSE 规范:data: 后的单个空格属于分隔符,其余原样保留;
|
||||
// 多行 data 用换行拼接。
|
||||
let chunk: string = line.substring(5);
|
||||
if (chunk.startsWith(' ')) {
|
||||
chunk = chunk.substring(1);
|
||||
}
|
||||
this.curData = this.curData.length > 0 ? this.curData + '\n' + chunk : chunk;
|
||||
} else if (line === '') {
|
||||
if (this.curEvent.length > 0 && this.curData.length > 0) {
|
||||
const ev: SseEvent = { event: this.curEvent, data: this.curData, id: this.curId };
|
||||
if (this.onEvent !== null) {
|
||||
this.onEvent(ev);
|
||||
}
|
||||
}
|
||||
this.curEvent = '';
|
||||
this.curData = '';
|
||||
this.curId = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.opened = false;
|
||||
if (this.httpRequest !== null) {
|
||||
try {
|
||||
this.httpRequest.off('dataReceive');
|
||||
this.httpRequest.off('dataEnd');
|
||||
this.httpRequest.off('headersReceive');
|
||||
this.httpRequest.destroy();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
this.httpRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
private markOpened(): void {
|
||||
if (this.opened || this.closed) {
|
||||
return;
|
||||
}
|
||||
this.opened = true;
|
||||
if (this.onOpen !== null) {
|
||||
this.onOpen();
|
||||
}
|
||||
}
|
||||
|
||||
private finish(): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
if (this.onClose !== null) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
289
cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets
Normal file
@ -0,0 +1,289 @@
|
||||
import { apiClient } from './ApiClient';
|
||||
import { userMessage, noConnectionMessage } from './UserError';
|
||||
|
||||
/**
|
||||
* 运行状态数据源(单例)。
|
||||
*
|
||||
* 状态页已并入设置页:设置一级页顶部嵌一张摘要卡,明细走二级页。
|
||||
* 摘要卡与明细页是两个独立组件,但必须显示同一份数据、只请求一次,
|
||||
* 所以把请求与解析收拢到这里,标量通过 AppStorage 广播给两边。
|
||||
*
|
||||
* 字段口径与 WebGUI 的 概览/内核 两页一致:
|
||||
* - GET /status → status / version / startedAt / agents
|
||||
* - GET /kernel → agent_id / plugins / tools / llm / memory / documents /
|
||||
* text_memory / knowledge / runtime
|
||||
* 后端不提供 token 用量、配额、错误列表,所以这里也没有。
|
||||
*/
|
||||
|
||||
/** 一行明细 */
|
||||
export interface StatField {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** 一组明细卡 */
|
||||
export interface StatGroup {
|
||||
title: string;
|
||||
fields: StatField[];
|
||||
}
|
||||
|
||||
// ===== AppStorage 键:摘要卡与明细页共用 =====
|
||||
export const K_UP: string = 'statUp';
|
||||
export const K_VERSION: string = 'statVersion';
|
||||
export const K_STARTED: string = 'statStartedAt';
|
||||
export const K_AGENTS: string = 'statAgents';
|
||||
export const K_PLUGINS: string = 'statPlugins';
|
||||
export const K_TOOLS: string = 'statTools';
|
||||
export const K_ERR: string = 'statErr';
|
||||
export const K_LOADING: string = 'statLoading';
|
||||
export const K_REV: string = 'statRev';
|
||||
|
||||
class StatusStore {
|
||||
/** 明细分组:只有明细页读它,不进 AppStorage(数组同步语义太脆) */
|
||||
private groups: StatGroup[] = [];
|
||||
|
||||
init(): void {
|
||||
AppStorage.setOrCreate<boolean>(K_UP, false);
|
||||
AppStorage.setOrCreate<string>(K_VERSION, '-');
|
||||
AppStorage.setOrCreate<string>(K_STARTED, '');
|
||||
AppStorage.setOrCreate<number>(K_AGENTS, 0);
|
||||
AppStorage.setOrCreate<number>(K_PLUGINS, 0);
|
||||
AppStorage.setOrCreate<number>(K_TOOLS, 0);
|
||||
AppStorage.setOrCreate<string>(K_ERR, '');
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||
AppStorage.setOrCreate<number>(K_REV, 0);
|
||||
}
|
||||
|
||||
getGroups(): StatGroup[] {
|
||||
return this.groups;
|
||||
}
|
||||
|
||||
isUp(): boolean {
|
||||
return AppStorage.get<boolean>(K_UP) ?? false;
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (!apiClient.hasConnection()) {
|
||||
this.fail(noConnectionMessage());
|
||||
return;
|
||||
}
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, true);
|
||||
AppStorage.setOrCreate<string>(K_ERR, '');
|
||||
try {
|
||||
const resp = await apiClient.getWithTimeout('/status', 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
|
||||
const version: string = obj['version'] as string ?? '未知';
|
||||
const status: string = obj['status'] as string ?? 'unknown';
|
||||
const uptimeStr: string = obj['uptime'] as string ?? '';
|
||||
const agents: number = obj['agents'] as number ?? 0;
|
||||
const startedAt: string = obj['startedAt'] as string ?? '';
|
||||
|
||||
AppStorage.setOrCreate<boolean>(K_UP, true);
|
||||
AppStorage.setOrCreate<string>(K_VERSION, version);
|
||||
AppStorage.setOrCreate<number>(K_AGENTS, agents);
|
||||
AppStorage.setOrCreate<string>(K_STARTED, startedAt);
|
||||
|
||||
const systemFields: StatField[] = [
|
||||
{ label: '版本', value: version },
|
||||
{ label: '运行状态', value: statusText(status) },
|
||||
{ label: '运行时长', value: uptimeStr.length > 0 ? uptimeStr : '-' },
|
||||
];
|
||||
if (startedAt.length > 0) {
|
||||
systemFields.push({ label: '启动时间', value: formatTime(startedAt) });
|
||||
}
|
||||
|
||||
const groups: StatGroup[] = [
|
||||
{ title: '系统概览', fields: systemFields },
|
||||
];
|
||||
|
||||
await this.collectKernel(groups);
|
||||
this.groups = groups;
|
||||
this.bump();
|
||||
} catch (e) {
|
||||
this.fail(userMessage('status.refresh', e));
|
||||
}
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||
}
|
||||
|
||||
/** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */
|
||||
private async collectKernel(groups: StatGroup[]): Promise<void> {
|
||||
try {
|
||||
const kResp = await apiClient.getWithTimeout('/kernel', 8000);
|
||||
const k: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
|
||||
|
||||
const agentId: string = k['agent_id'] as string ?? 'main';
|
||||
const startTime: string = k['start_time'] as string ?? '';
|
||||
const pluginsArr: Object[] | undefined = k['plugins'] as Object[];
|
||||
const toolsArr: Object[] | undefined = k['tools'] as Object[];
|
||||
const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0;
|
||||
const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0;
|
||||
|
||||
AppStorage.setOrCreate<number>(K_PLUGINS, pluginCount);
|
||||
AppStorage.setOrCreate<number>(K_TOOLS, toolCount);
|
||||
|
||||
const kernelFields: StatField[] = [
|
||||
{ label: 'Agent ID', value: agentId },
|
||||
{ label: '已加载插件', value: pluginCount.toString() },
|
||||
{ label: '工具数', value: toolCount.toString() },
|
||||
];
|
||||
if (startTime.length > 0) {
|
||||
kernelFields.push({ label: '内核启动', value: formatTime(startTime) });
|
||||
}
|
||||
groups.push({ title: '内核', fields: kernelFields });
|
||||
|
||||
// LLM:provider / 可用源 / 是否可用
|
||||
const llm: Record<string, Object> | undefined = k['llm'] as Record<string, Object>;
|
||||
if (llm !== undefined && llm !== null) {
|
||||
const provider: string = llm['provider'] as string ?? '';
|
||||
const sources: number = llm['sources'] as number ?? 0;
|
||||
const available: boolean = llm['available'] as boolean ?? false;
|
||||
groups.push({
|
||||
title: '模型',
|
||||
fields: [
|
||||
{ label: 'Provider', value: provider.length > 0 ? provider : '未配置' },
|
||||
{ label: '可用源', value: sources.toString() },
|
||||
{ label: '状态', value: available ? '运行中' : '不可用' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 记忆:图 / 文档 / 文本 / 知识库
|
||||
const memFields: StatField[] = [];
|
||||
const mem: Record<string, Object> | undefined = k['memory'] as Record<string, Object>;
|
||||
if (mem !== undefined && mem !== null) {
|
||||
const ok: boolean = mem['available'] as boolean ?? false;
|
||||
const ent: number = mem['entity_count'] as number ?? 0;
|
||||
const rel: number = mem['relation_count'] as number ?? 0;
|
||||
memFields.push({
|
||||
label: '图记忆',
|
||||
value: ok ? ent.toString() + ' 实体 · ' + rel.toString() + ' 关系' : '未初始化',
|
||||
});
|
||||
}
|
||||
const docs: Record<string, Object> | undefined = k['documents'] as Record<string, Object>;
|
||||
if (docs !== undefined && docs !== null) {
|
||||
const ok: boolean = docs['available'] as boolean ?? false;
|
||||
const n: number = docs['doc_count'] as number ?? 0;
|
||||
memFields.push({ label: '文档记忆', value: ok ? n.toString() + ' 文档' : '未初始化' });
|
||||
}
|
||||
const tm: Record<string, Object> | undefined = k['text_memory'] as Record<string, Object>;
|
||||
if (tm !== undefined && tm !== null) {
|
||||
const ok: boolean = tm['available'] as boolean ?? false;
|
||||
const n: number = tm['file_count'] as number ?? 0;
|
||||
memFields.push({ label: '文本记忆', value: ok ? n.toString() + ' 文件' : '未初始化' });
|
||||
}
|
||||
const kb: Record<string, Object> | undefined = k['knowledge'] as Record<string, Object>;
|
||||
if (kb !== undefined && kb !== null) {
|
||||
const ok: boolean = kb['available'] as boolean ?? false;
|
||||
const n: number = kb['item_count'] as number ?? 0;
|
||||
memFields.push({ label: '知识库', value: ok ? n.toString() + ' 项' : '未初始化' });
|
||||
}
|
||||
if (memFields.length > 0) {
|
||||
groups.push({ title: '记忆', fields: memFields });
|
||||
}
|
||||
|
||||
// 运行时:goroutine / 内存 / Go 版本
|
||||
const rt: Record<string, Object> | undefined = k['runtime'] as Record<string, Object>;
|
||||
if (rt !== undefined && rt !== null) {
|
||||
const g: number = rt['goroutines'] as number ?? 0;
|
||||
const mb: number = rt['memory_mb'] as number ?? 0;
|
||||
const gov: string = rt['go_version'] as string ?? '';
|
||||
groups.push({
|
||||
title: '运行时',
|
||||
fields: [
|
||||
{ label: 'Goroutines', value: g.toString() },
|
||||
{ label: '内存占用', value: mb.toString() + ' MB' },
|
||||
{ label: 'Go 版本', value: gov.length > 0 ? gov : '-' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 工具清单:名称 → 归属插件
|
||||
if (toolsArr !== undefined && toolsArr.length > 0) {
|
||||
const toolFields: StatField[] = [];
|
||||
for (let i = 0; i < toolsArr.length; i++) {
|
||||
const t: Record<string, Object> = toolsArr[i] as Record<string, Object>;
|
||||
const name: string = t['name'] as string ?? '';
|
||||
const plugin: string = t['plugin'] as string ?? '';
|
||||
if (name.length > 0) {
|
||||
toolFields.push({ label: name, value: plugin });
|
||||
}
|
||||
}
|
||||
if (toolFields.length > 0) {
|
||||
groups.push({ title: '可用工具', fields: toolFields });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// /kernel 不可用时只保留 /status 的概览分组
|
||||
}
|
||||
}
|
||||
|
||||
private fail(msg: string): void {
|
||||
AppStorage.setOrCreate<string>(K_ERR, msg);
|
||||
AppStorage.setOrCreate<boolean>(K_UP, false);
|
||||
AppStorage.setOrCreate<boolean>(K_LOADING, false);
|
||||
this.groups = [];
|
||||
this.bump();
|
||||
}
|
||||
|
||||
/** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */
|
||||
private bump(): void {
|
||||
const cur: number = AppStorage.get<number>(K_REV) ?? 0;
|
||||
AppStorage.setOrCreate<number>(K_REV, cur + 1);
|
||||
}
|
||||
}
|
||||
|
||||
export const statusStore: StatusStore = new StatusStore();
|
||||
|
||||
export function statusText(s: string): string {
|
||||
if (s === 'running') {
|
||||
return '运行中';
|
||||
}
|
||||
if (s === 'stopped') {
|
||||
return '已停止';
|
||||
}
|
||||
if (s === 'starting') {
|
||||
return '启动中';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function formatTime(iso: string): string {
|
||||
const t: number = new Date(iso).getTime();
|
||||
if (isNaN(t) || t <= 0) {
|
||||
return iso;
|
||||
}
|
||||
const d = new Date(t);
|
||||
return d.getFullYear().toString() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) +
|
||||
' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
||||
}
|
||||
|
||||
/**
|
||||
* 紧凑运行时长:环心里只有 96vp 单行,必须用 12h48m 这种记法,
|
||||
* 「10时 49分 16秒」会折行。
|
||||
*/
|
||||
export function compactDuration(startedAt: string): string {
|
||||
if (startedAt.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
const t: number = new Date(startedAt).getTime();
|
||||
const now: number = Date.now();
|
||||
if (isNaN(t) || t <= 0 || now <= t) {
|
||||
return '-';
|
||||
}
|
||||
const sec: number = Math.floor((now - t) / 1000);
|
||||
const days: number = Math.floor(sec / 86400);
|
||||
const hours: number = Math.floor((sec % 86400) / 3600);
|
||||
const mins: number = Math.floor((sec % 3600) / 60);
|
||||
if (days > 0) {
|
||||
return days.toString() + 'd ' + hours.toString() + 'h';
|
||||
}
|
||||
if (hours > 0) {
|
||||
return hours.toString() + 'h ' + mins.toString() + 'm';
|
||||
}
|
||||
return mins.toString() + 'm ' + (sec % 60).toString() + 's';
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? '0' + n.toString() : n.toString();
|
||||
}
|
||||
178
cmd/ohos/HomeAgent/entry/src/main/ets/common/UserError.ets
Normal file
@ -0,0 +1,178 @@
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { ApiError } from './ApiClient';
|
||||
|
||||
/**
|
||||
* 面向用户的错误文案统一出口。
|
||||
*
|
||||
* 问题背景:之前各页面直接把 `e.message` 拼进 UI,于是屏幕上出现
|
||||
* "获取状态失败: Failed to connect to the server."、原始 JSON 报错体、
|
||||
* 甚至后端堆栈。这类文本对用户没有意义,还会泄露内网地址与实现细节。
|
||||
*
|
||||
* 约定:
|
||||
* - UI 只显示 userMessage() 返回的短句(人话、可行动、不含技术细节);
|
||||
* - 技术细节(原始 message / HTTP 状态码 / 响应体)只写进 hilog,
|
||||
* 通过 `hdc shell hilog | grep HomeAgent` 排查,不进 UI。
|
||||
*/
|
||||
|
||||
const DOMAIN: number = 0xA0A0;
|
||||
const TAG: string = 'HomeAgent';
|
||||
|
||||
/** 网络类错误的统一提示:不暴露主机名、端口、curl 错误码。 */
|
||||
const MSG_UNREACHABLE: string = '连接不上后端服务,请检查网络与服务地址';
|
||||
const MSG_TIMEOUT: string = '后端响应超时,请稍后重试';
|
||||
const MSG_AUTH: string = 'API Key 无效或已过期,请在设置里更新';
|
||||
const MSG_FORBIDDEN: string = '没有访问权限,请检查 API Key 的权限范围';
|
||||
const MSG_NOT_FOUND: string = '后端没有这个接口,可能版本不匹配';
|
||||
const MSG_SERVER: string = '后端服务内部出错,请查看服务端日志';
|
||||
const MSG_BAD_DATA: string = '后端返回的数据无法解析';
|
||||
const MSG_TLS: string = '证书校验失败,请检查 HTTPS 配置';
|
||||
const MSG_GENERIC: string = '操作失败,请稍后重试';
|
||||
const MSG_NO_CONN: string = '尚未配置后端连接,请先在设置里添加';
|
||||
|
||||
/** 一眼判定是否"网络层根本没连上",用于页面显示离线态而不是报错态。 */
|
||||
export function isOffline(e: Object): boolean {
|
||||
const code: number = businessCode(e);
|
||||
if (code === 2300006 || code === 2300007 || code === 2300005 ||
|
||||
code === 2300052 || code === 2300056 || code === 2300028) {
|
||||
return true;
|
||||
}
|
||||
if (e instanceof ApiError) {
|
||||
return e.status < 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为超时。聊天场景里超时不算失败——请求已经到后端,
|
||||
* 只是回复还没生成完,UI 要显示"等待回复"而不是报错。
|
||||
*/
|
||||
export function isTimeout(e: Object): boolean {
|
||||
const code: number = businessCode(e);
|
||||
if (code === 2300028) {
|
||||
return true;
|
||||
}
|
||||
if (e instanceof ApiError && (e.status === 408 || e.status === 504)) {
|
||||
return true;
|
||||
}
|
||||
const raw: string = rawMessage(e);
|
||||
return raw.indexOf('timeout') >= 0 || raw.indexOf('超时') >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把任意异常翻译成一句用户能看懂、且不含技术细节的话。
|
||||
* 同时把原始信息写入 hilog(scene 用于定位是哪个调用点)。
|
||||
*/
|
||||
export function userMessage(scene: string, e: Object): string {
|
||||
logDetail(scene, e);
|
||||
|
||||
if (e instanceof ApiError) {
|
||||
const st: number = e.status;
|
||||
if (st === 401) {
|
||||
return MSG_AUTH;
|
||||
}
|
||||
if (st === 403) {
|
||||
return MSG_FORBIDDEN;
|
||||
}
|
||||
if (st === 404) {
|
||||
return MSG_NOT_FOUND;
|
||||
}
|
||||
if (st === 408 || st === 504) {
|
||||
return MSG_TIMEOUT;
|
||||
}
|
||||
if (st >= 500) {
|
||||
return MSG_SERVER;
|
||||
}
|
||||
if (st < 0) {
|
||||
return MSG_NO_CONN;
|
||||
}
|
||||
if (st >= 400) {
|
||||
// 4xx 里后端通常给了业务原因,但不保证是人话,也可能带内部路径。
|
||||
// 只在明显短且不含技术噪音时透传,否则退回通用文案。
|
||||
return safeBackendReason(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const code: number = businessCode(e);
|
||||
if (code === 2300028) {
|
||||
return MSG_TIMEOUT;
|
||||
}
|
||||
if (code === 2300005 || code === 2300006 || code === 2300007 ||
|
||||
code === 2300052 || code === 2300055 || code === 2300056) {
|
||||
return MSG_UNREACHABLE;
|
||||
}
|
||||
if (code === 2300058 || code === 2300059 || code === 2300060 || code === 2300077) {
|
||||
return MSG_TLS;
|
||||
}
|
||||
if (code === 2300001 || code === 2300003) {
|
||||
return '服务地址格式不正确,请在设置里检查';
|
||||
}
|
||||
if (code === 2300009 || code === 2300094) {
|
||||
return MSG_AUTH;
|
||||
}
|
||||
if (code === 2300997 || code === 2300998) {
|
||||
return '系统禁止访问该地址,请改用 HTTPS 或放开域名白名单';
|
||||
}
|
||||
|
||||
if (e instanceof SyntaxError) {
|
||||
// JSON.parse 失败
|
||||
return MSG_BAD_DATA;
|
||||
}
|
||||
|
||||
const raw: string = rawMessage(e);
|
||||
if (raw.indexOf('timeout') >= 0 || raw.indexOf('超时') >= 0) {
|
||||
return MSG_TIMEOUT;
|
||||
}
|
||||
return MSG_GENERIC;
|
||||
}
|
||||
|
||||
/** 未配置连接时的统一文案,页面不要各写一份。 */
|
||||
export function noConnectionMessage(): string {
|
||||
return MSG_NO_CONN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 4xx 的后端说明只在"看起来是给人看的"时才透传:
|
||||
* 短、无换行、不含路径/括号异常/HTML/JSON 花括号。
|
||||
*/
|
||||
function safeBackendReason(msg: string): string {
|
||||
const s: string = msg.trim();
|
||||
if (s.length === 0 || s.length > 60) {
|
||||
return MSG_GENERIC;
|
||||
}
|
||||
if (s.indexOf('\n') >= 0 || s.indexOf('{') >= 0 || s.indexOf('<') >= 0 ||
|
||||
s.indexOf('/') >= 0 || s.indexOf('0x') >= 0 || s.indexOf('Exception') >= 0 ||
|
||||
s.indexOf('Error:') >= 0 || s.indexOf('panic') >= 0) {
|
||||
return MSG_GENERIC;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 取 BusinessError.code(ArkTS 不允许 in / 索引访问,用可选字段读取)。 */
|
||||
function businessCode(e: Object): number {
|
||||
const be = e as BusinessErrorLike;
|
||||
const c: number | undefined = be.code;
|
||||
return c !== undefined ? c : 0;
|
||||
}
|
||||
|
||||
function rawMessage(e: Object): string {
|
||||
if (e instanceof Error) {
|
||||
return e.message;
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
function logDetail(scene: string, e: Object): void {
|
||||
const code: number = businessCode(e);
|
||||
let detail: string = rawMessage(e);
|
||||
if (e instanceof ApiError) {
|
||||
detail = 'HTTP ' + e.status.toString() + ' ' + detail + ' body=' + e.body;
|
||||
}
|
||||
hilog.error(DOMAIN, TAG, '%{public}s failed: code=%{public}d detail=%{private}s',
|
||||
scene, code, detail);
|
||||
}
|
||||
|
||||
/** BusinessError 的最小结构(避免为了读 code 而 import 整个 kit)。 */
|
||||
interface BusinessErrorLike {
|
||||
code?: number;
|
||||
message?: string;
|
||||
}
|
||||
442
cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets
Normal file
@ -0,0 +1,442 @@
|
||||
import { image } from '@kit.ImageKit';
|
||||
import { fileIo } from '@kit.CoreFileKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage } from '../common/UserError';
|
||||
import { ChatAttachment } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD, RADIUS_SM } from '../common/Constants';
|
||||
import { COLOR_ERROR } from '../common/Constants';
|
||||
import { PlainCard } from './SubPage';
|
||||
|
||||
/**
|
||||
* 附件解析与展示。
|
||||
*
|
||||
* 后端 Attachment 只有四个字段:type / url / size / name
|
||||
* (internal/plugins/webui/handler.go),没有 mime、没有像素尺寸、没有本地路径。
|
||||
* 所以详情页里的"尺寸/格式"必须由客户端自己解码得出,不能假装后端给了。
|
||||
*
|
||||
* 字节走 GET <base>/files/<name> 或 /uploads/<name>(注意不带 /api/v1 前缀)。
|
||||
* 这两条路由在后端是 requireWeb,但对 API Key 客户端同等放行,
|
||||
* 所以带上和普通接口一样的鉴权头即可,无需 web 登录态。
|
||||
*/
|
||||
|
||||
/** 从后端 JSON 里解析 attachment 字段;缺字段或类型不对则返回 undefined。 */
|
||||
export function parseAttachment(raw: Object | undefined): ChatAttachment | undefined {
|
||||
if (raw === undefined || raw === null) {
|
||||
return undefined;
|
||||
}
|
||||
const o: Record<string, Object> = raw as Record<string, Object>;
|
||||
const url: string = o['url'] as string ?? '';
|
||||
if (url.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const t: string = o['type'] as string ?? 'file';
|
||||
const a: ChatAttachment = {
|
||||
type: t === 'image' ? 'image' : 'file',
|
||||
url: url,
|
||||
size: o['size'] as number ?? 0,
|
||||
name: o['name'] as string ?? fileNameOf(url),
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
/** 由 SSE channel_output 事件构造附件(字段名与 history 不同)。 */
|
||||
export function attachmentFromChannelOutput(
|
||||
outputType: string, url: string, size: number): ChatAttachment | undefined {
|
||||
if (url.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (outputType !== 'image' && outputType !== 'file') {
|
||||
return undefined;
|
||||
}
|
||||
const a: ChatAttachment = {
|
||||
type: outputType,
|
||||
url: url,
|
||||
size: size,
|
||||
name: fileNameOf(url),
|
||||
};
|
||||
return a;
|
||||
}
|
||||
|
||||
/** 取 URL 最后一段作为展示文件名,与后端 handler.go 的取名方式一致。 */
|
||||
export function fileNameOf(url: string): string {
|
||||
let s: string = url;
|
||||
const q: number = s.indexOf('?');
|
||||
if (q >= 0) {
|
||||
s = s.substring(0, q);
|
||||
}
|
||||
const i: number = s.lastIndexOf('/');
|
||||
const name: string = i >= 0 ? s.substring(i + 1) : s;
|
||||
return name.length > 0 ? name : '附件';
|
||||
}
|
||||
|
||||
/** 人类可读字节数,口径对齐后端 formatBytes(KB 以上保留一位小数)。 */
|
||||
export function formatBytes(n: number): string {
|
||||
if (n <= 0) {
|
||||
return '';
|
||||
}
|
||||
if (n < 1024) {
|
||||
return n.toString() + ' B';
|
||||
}
|
||||
const kb: number = n / 1024;
|
||||
if (kb < 1024) {
|
||||
return oneDecimal(kb) + ' KB';
|
||||
}
|
||||
const mb: number = kb / 1024;
|
||||
if (mb < 1024) {
|
||||
return oneDecimal(mb) + ' MB';
|
||||
}
|
||||
return oneDecimal(mb / 1024) + ' GB';
|
||||
}
|
||||
|
||||
function oneDecimal(v: number): string {
|
||||
return (Math.round(v * 10) / 10).toString();
|
||||
}
|
||||
|
||||
/** 由文件名后缀猜测类型标签。后端不返回 mime,只能这样标注。 */
|
||||
export function extLabel(name: string): string {
|
||||
const i: number = name.lastIndexOf('.');
|
||||
if (i < 0 || i === name.length - 1) {
|
||||
return '未知类型';
|
||||
}
|
||||
return name.substring(i + 1).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡内的附件卡:图片显示缩略图,文件显示一枚文件条。
|
||||
* 点击进入附件详情二级页面(WebGUI 是新开标签页,移动端改为二级页)。
|
||||
*/
|
||||
@Component
|
||||
export struct AttachmentCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop att: ChatAttachment;
|
||||
@Prop mine: boolean = false;
|
||||
onTap?: () => void;
|
||||
@State private pixel: image.PixelMap | undefined = undefined;
|
||||
@State private failed: boolean = false;
|
||||
|
||||
aboutToAppear(): void {
|
||||
if (this.att.type === 'image') {
|
||||
this.loadThumb();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadThumb(): Promise<void> {
|
||||
const pm: image.PixelMap | undefined = await loadPixelMap(this.att.url);
|
||||
if (pm === undefined) {
|
||||
this.failed = true;
|
||||
return;
|
||||
}
|
||||
this.pixel = pm;
|
||||
}
|
||||
|
||||
build() {
|
||||
Column({ space: 6 }) {
|
||||
Text(this.mine ? '你发送的' : '小宅发送的')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
|
||||
if (this.att.type === 'image') {
|
||||
if (this.pixel !== undefined) {
|
||||
Image(this.pixel)
|
||||
.width('100%')
|
||||
.constraintSize({ maxHeight: 220 })
|
||||
.objectFit(ImageFit.Cover)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.draggable(false)
|
||||
} else if (this.failed) {
|
||||
Row({ space: 6 }) {
|
||||
Image($r('app.media.ic_error'))
|
||||
.width(14)
|
||||
.height(14)
|
||||
.fillColor(COLOR_ERROR)
|
||||
.draggable(false)
|
||||
Text('图片加载失败')
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_ERROR)
|
||||
}
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
} else {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(20)
|
||||
.height(20)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width(120)
|
||||
.height(80)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
}
|
||||
} else {
|
||||
Row({ space: 8 }) {
|
||||
Image($r('app.media.ic_file'))
|
||||
.width(18)
|
||||
.height(18)
|
||||
.fillColor(this.palette().accent)
|
||||
.draggable(false)
|
||||
Column({ space: 2 }) {
|
||||
Text(this.att.name)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (this.att.size > 0) {
|
||||
Text(formatBytes(this.att.size))
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(14)
|
||||
.height(14)
|
||||
.fillColor(this.palette().textMuted)
|
||||
.draggable(false)
|
||||
}
|
||||
.constraintSize({ minWidth: 180 })
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.mine ? this.palette().accentBg : this.palette().bgHover)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件详情二级页面内容:大图预览 / 文件信息 + 保存到本地。
|
||||
*
|
||||
* 后端不提供尺寸与 mime,图片尺寸由本地解码得到,类型标签由后缀推断,
|
||||
* 界面上如实标注它们的来源,不谎称是服务端元数据。
|
||||
*/
|
||||
@Component
|
||||
export struct AttachmentDetailContent {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop att: ChatAttachment;
|
||||
@State private pixel: image.PixelMap | undefined = undefined;
|
||||
@State private pxWidth: number = 0;
|
||||
@State private pxHeight: number = 0;
|
||||
@State private loading: boolean = true;
|
||||
@State private err: string = '';
|
||||
@State private savedPath: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
if (this.att.type === 'image') {
|
||||
this.loadFull();
|
||||
} else {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFull(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.err = '';
|
||||
try {
|
||||
const abs: string = apiClient.absoluteUrl(this.att.url);
|
||||
const resp = await apiClient.getBinary(abs, 20000);
|
||||
const src: image.ImageSource = image.createImageSource(resp.data);
|
||||
const info: image.ImageInfo = await src.getImageInfo();
|
||||
this.pxWidth = info.size.width;
|
||||
this.pxHeight = info.size.height;
|
||||
this.pixel = await src.createPixelMap();
|
||||
await src.release();
|
||||
} catch (e) {
|
||||
this.err = userMessage('attachment.load', e);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
/** 保存到应用沙箱 files 目录(不申请媒体库权限,避免为了看一张图要授权相册)。 */
|
||||
private async saveToSandbox(): Promise<void> {
|
||||
try {
|
||||
const abs: string = apiClient.absoluteUrl(this.att.url);
|
||||
const resp = await apiClient.getBinary(abs, 30000);
|
||||
const ctx = getContext(this) as common.UIAbilityContext;
|
||||
const dest: string = ctx.filesDir + '/' + sanitize(this.att.name);
|
||||
const f = fileIo.openSync(dest,
|
||||
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
|
||||
fileIo.writeSync(f.fd, resp.data);
|
||||
fileIo.closeSync(f);
|
||||
this.savedPath = dest;
|
||||
} catch (e) {
|
||||
this.err = userMessage('attachment.save', e);
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (this.err.length > 0) {
|
||||
Row({ space: 8 }) {
|
||||
Image($r('app.media.ic_error'))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.fillColor(COLOR_ERROR)
|
||||
.draggable(false)
|
||||
Text(this.err)
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.margin({ bottom: 14 })
|
||||
}
|
||||
|
||||
// 预览:图片铺满宽度可缩放查看;文件给一个大图标占位
|
||||
Column() {
|
||||
if (this.att.type === 'image') {
|
||||
if (this.loading) {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(28)
|
||||
.height(28)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width('100%')
|
||||
.height(200)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
} else if (this.pixel !== undefined) {
|
||||
Image(this.pixel)
|
||||
.width('100%')
|
||||
.constraintSize({ maxHeight: 420 })
|
||||
.objectFit(ImageFit.Contain)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.draggable(false)
|
||||
}
|
||||
} else {
|
||||
Column({ space: 10 }) {
|
||||
Image($r('app.media.ic_file'))
|
||||
.width(46)
|
||||
.height(46)
|
||||
.fillColor(this.palette().accent)
|
||||
.draggable(false)
|
||||
Text(extLabel(this.att.name))
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 26, bottom: 26 })
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.margin({ bottom: 14 })
|
||||
|
||||
PlainCard({ caption: '信息' }) {
|
||||
this.kv('文件名', this.att.name)
|
||||
this.kv('类型', this.att.type === 'image' ? '图片' : '文件')
|
||||
this.kv('格式', extLabel(this.att.name))
|
||||
this.kv('大小', this.att.size > 0 ? formatBytes(this.att.size) : '未知(远程链接)')
|
||||
if (this.pxWidth > 0 && this.pxHeight > 0) {
|
||||
// 后端不返回像素尺寸,这一行来自本地解码
|
||||
this.kv('像素', this.pxWidth.toString() + ' × ' + this.pxHeight.toString())
|
||||
}
|
||||
this.kv('来源', this.att.url.startsWith('http') ? '远程链接' : '服务端中转')
|
||||
}
|
||||
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Button('保存到应用目录')
|
||||
.width('100%')
|
||||
.height(38)
|
||||
.fontSize(13)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor('#FFFFFF')
|
||||
.onClick(() => {
|
||||
this.saveToSandbox();
|
||||
})
|
||||
if (this.savedPath.length > 0) {
|
||||
Text('已保存:' + this.savedPath)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.width('100%')
|
||||
.margin({ top: 8 })
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
kv(label: string, value: string) {
|
||||
Row() {
|
||||
Text(label)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
Text(value)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: 210 })
|
||||
.margin({ left: 16 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载并解码成 PixelMap;任何一步失败都返回 undefined(调用方显示占位)。 */
|
||||
async function loadPixelMap(url: string): Promise<image.PixelMap | undefined> {
|
||||
try {
|
||||
// 本地待上传的图片:直接读沙箱文件,不走网络
|
||||
if (url.startsWith('file://')) {
|
||||
const path: string = url.substring(7);
|
||||
const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
|
||||
const localSrc: image.ImageSource = image.createImageSource(f.fd);
|
||||
const localPm: image.PixelMap = await localSrc.createPixelMap();
|
||||
await localSrc.release();
|
||||
fileIo.closeSync(f);
|
||||
return localPm;
|
||||
}
|
||||
const abs: string = apiClient.absoluteUrl(url);
|
||||
const resp = await apiClient.getBinary(abs, 15000);
|
||||
const src: image.ImageSource = image.createImageSource(resp.data);
|
||||
const pm: image.PixelMap = await src.createPixelMap();
|
||||
await src.release();
|
||||
return pm;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 去掉路径分隔符,避免附件名把文件写到 filesDir 之外。 */
|
||||
function sanitize(name: string): string {
|
||||
let s: string = name.replace(/[\/\\:*?"<>|]/g, '_');
|
||||
if (s.length === 0) {
|
||||
s = 'attachment';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { image } from '@kit.ImageKit';
|
||||
import { fileIo } from '@kit.CoreFileKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
|
||||
/**
|
||||
* 背景层:底色 + 三层径向渐变光斑(右上蓝 / 左中青 / 底部深),
|
||||
* 对应 WebGUI 的 grad-a / grad-b / grad-c。
|
||||
*
|
||||
* 独立成文件的原因:Navigation 的 NavDestination 在栈模式下会整屏盖住
|
||||
* 一级内容,必须自带同款背景(否则透出下层列表形成重影)。
|
||||
* 如果这个组件留在 Index.ets 里,二级页面 import 它会与
|
||||
* Index -> SettingsPage -> Index 形成循环依赖。
|
||||
*/
|
||||
@Component
|
||||
export struct GradientBackground {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp('bgImage') @Watch('onBgChanged') private bgImage: string = '';
|
||||
@StorageProp('bgOpacity') private bgOpacity: number = 0.25;
|
||||
/**
|
||||
* 背景图解码后的位图。
|
||||
*
|
||||
* 为什么不把路径字符串直接交给 Image:
|
||||
* 把沙箱路径交给 Image 要同时满足"带 file:// 协议头"和"带可识别的图片后缀"
|
||||
* 两个隐含前提,任何一条不满足都只是静默不显示 —— 前两轮"背景不生效"就是
|
||||
* 卡在这里,而且没有任何可观测的失败点。
|
||||
* 这里改成自己用 ImageSource 解码(与聊天图片附件同一条已验证通路),
|
||||
* 成功与失败都能落 hilog,Image 只负责画一张现成的 PixelMap。
|
||||
*/
|
||||
@State private bgPixel: image.PixelMap | undefined = undefined;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.decodeBg();
|
||||
}
|
||||
|
||||
private onBgChanged(): void {
|
||||
this.decodeBg();
|
||||
}
|
||||
|
||||
/** 解码沙箱/网络背景图为 PixelMap;失败时清空,只留渐变底。 */
|
||||
private async decodeBg(): Promise<void> {
|
||||
const src: string = this.bgImage;
|
||||
if (src.length === 0) {
|
||||
this.bgPixel = undefined;
|
||||
return;
|
||||
}
|
||||
// 只处理本地沙箱文件;http(s) 背景图不是本应用的场景(设置页只给图库选择)
|
||||
const path: string = src.startsWith('file://') ? src.substring(7) : src;
|
||||
if (!path.startsWith('/')) {
|
||||
hilog.error(0x0000, 'HomeAgent', 'bg: unsupported source');
|
||||
this.bgPixel = undefined;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const f = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
|
||||
const srcObj: image.ImageSource = image.createImageSource(f.fd);
|
||||
const pm: image.PixelMap = await srcObj.createPixelMap();
|
||||
await srcObj.release();
|
||||
fileIo.closeSync(f);
|
||||
this.bgPixel = pm;
|
||||
hilog.info(0x0000, 'HomeAgent', 'bg decoded ok');
|
||||
} catch (e) {
|
||||
// 失败必须留痕:否则表现就是"设置了但没生效",无从定位
|
||||
const err = e as BusinessError;
|
||||
hilog.error(0x0000, 'HomeAgent', 'bg decode failed code=%{public}d', err.code as number);
|
||||
this.bgPixel = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
// 底色
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(this.palette().bgPrimary)
|
||||
|
||||
// grad-a: 右上角蓝色光斑
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.radialGradient({
|
||||
center: ['88%', '-4%'],
|
||||
radius: 520,
|
||||
colors: [[this.palette().gradA, 0.0], [this.palette().gradAEnd, 1.0]],
|
||||
})
|
||||
|
||||
// grad-b: 左中部霜冻青光斑
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.radialGradient({
|
||||
center: ['-6%', '38%'],
|
||||
radius: 470,
|
||||
colors: [[this.palette().gradB, 0.0], [this.palette().gradBEnd, 1.0]],
|
||||
})
|
||||
|
||||
// grad-c: 底部深色光斑
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.radialGradient({
|
||||
center: ['50%', '110%'],
|
||||
radius: 560,
|
||||
colors: [[this.palette().gradC, 0.0], [this.palette().gradCEnd, 1.0]],
|
||||
})
|
||||
|
||||
// 自定义背景图(可选):叠在渐变光斑之上、页面内容之下
|
||||
if (this.bgPixel !== undefined) {
|
||||
Image(this.bgPixel)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.objectFit(ImageFit.Cover)
|
||||
.opacity(this.bgOpacity)
|
||||
.draggable(false)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
// 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
import { MarkdownStream, StreamingMarkdown } from '@ycj3/streaming-markdown';
|
||||
import type { StreamingMarkdownConfig } from '@ycj3/streaming-markdown';
|
||||
import { StaticMarkdownView } from './StaticMarkdown';
|
||||
import { DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* MarkdownView — unified markdown renderer for chat messages.
|
||||
*
|
||||
* - isStreaming=true → uses @ycj3/streaming-markdown live StreamingMarkdown
|
||||
* (incremental, animated — ideal for SSE output).
|
||||
* - isStreaming=false → uses StaticMarkdownView (parses once, no timers,
|
||||
* instant render — ideal for history / finalized messages).
|
||||
*/
|
||||
@Component
|
||||
export struct MarkdownView {
|
||||
@Prop content: string = '';
|
||||
@Prop isStreaming: boolean = false;
|
||||
@Prop isDark: boolean = true;
|
||||
|
||||
private stream: MarkdownStream = new MarkdownStream({ mode: 'word', interval: 18 });
|
||||
private seeded: boolean = false;
|
||||
private pushedLen: number = 0;
|
||||
|
||||
aboutToAppear(): void {
|
||||
if (this.isStreaming) {
|
||||
this.seedStream();
|
||||
this.seeded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Triggered by @Prop changes (streaming updates)
|
||||
private onContentChange(): void {
|
||||
if (this.isStreaming && this.seeded) {
|
||||
const c: string = this.content;
|
||||
const prev: number = this.pushedLen;
|
||||
if (c.length > prev) {
|
||||
const delta: string = c.substring(prev);
|
||||
this.stream.append(delta);
|
||||
this.pushedLen = c.length;
|
||||
return;
|
||||
}
|
||||
// non-prefix change → reseed
|
||||
this.seedStream();
|
||||
}
|
||||
}
|
||||
|
||||
private seedStream(): void {
|
||||
this.stream.reset();
|
||||
this.pushedLen = 0;
|
||||
const c: string = this.content;
|
||||
if (c.length > 0) {
|
||||
this.stream.append(c);
|
||||
this.pushedLen = c.length;
|
||||
}
|
||||
// Streaming message stays open; finish happens when isStreaming flips false.
|
||||
}
|
||||
|
||||
/**
|
||||
* When a streaming message finalizes, mark the stream complete.
|
||||
*/
|
||||
private onStreamingEnd(): void {
|
||||
if (this.seeded) {
|
||||
this.stream.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private getConfig(): StreamingMarkdownConfig {
|
||||
const p = this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
const config: StreamingMarkdownConfig = {
|
||||
heading: {
|
||||
sizes: [28, 24, 20, 18, 16, 14],
|
||||
color: p.textPrimary,
|
||||
topSpacing: 8,
|
||||
bottomSpacing: 6,
|
||||
},
|
||||
paragraph: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
color: p.msgBubbleText,
|
||||
bottomSpacing: 6,
|
||||
},
|
||||
list: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
itemBottomSpacing: 3,
|
||||
color: p.msgBubbleText,
|
||||
},
|
||||
blockquote: {
|
||||
textColor: p.textTertiary,
|
||||
bgColor: p.bgHover,
|
||||
borderColor: p.kvBorder,
|
||||
bottomSpacing: 6,
|
||||
},
|
||||
codeBlock: {
|
||||
borderColor: p.kvBorder,
|
||||
radius: 10,
|
||||
topSpacing: 8,
|
||||
bottomSpacing: 8,
|
||||
},
|
||||
table: {
|
||||
headerBgColor: p.bgHover,
|
||||
borderColor: p.kvBorder,
|
||||
stripeBgColor: p.bgHover,
|
||||
cellFontSize: 13,
|
||||
topSpacing: 6,
|
||||
bottomSpacing: 6,
|
||||
},
|
||||
horizontalRule: {
|
||||
color: p.kvBorder,
|
||||
topSpacing: 8,
|
||||
bottomSpacing: 8,
|
||||
},
|
||||
inline: {
|
||||
linkColor: p.accent,
|
||||
codeTextColor: p.preText,
|
||||
codeBgColor: p.preBg,
|
||||
mathTextColor: p.accent,
|
||||
mathBgColor: p.bgHover,
|
||||
monoFontFamily: 'monospace',
|
||||
},
|
||||
layout: {
|
||||
contentPadding: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
},
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
build() {
|
||||
// 布局说明(已由 uitest dumpLayout 实测确认):
|
||||
// 父气泡 BubbleBody 为内容自适应宽度(constraintSize maxWidth 78%)且带 12vp 左右 padding。
|
||||
// 在这种"内容自适应 + padding"的父节点下,后代节点的 width('100%') 会被解析成父气泡的
|
||||
// 外框宽度而不是内容框宽度,于是 markdown 内容整体右溢出 12vp 并被气泡 clip 裁掉。
|
||||
// 解决办法:用 Row + layoutWeight(1) 代替百分比宽度。layoutWeight 走的是"剩余约束分配"
|
||||
// 而不是百分比解析,能拿到正确的内容框宽度,再把这个确定宽度传给 StaticMarkdown,
|
||||
// 其内部各层的 width('100%') 就有了正确的解析基准。
|
||||
Row() {
|
||||
Column() {
|
||||
if (this.isStreaming) {
|
||||
StreamingMarkdown({
|
||||
stream: this.stream,
|
||||
config: this.getConfig(),
|
||||
})
|
||||
} else {
|
||||
StaticMarkdownView({
|
||||
content: this.content,
|
||||
isDark: this.isDark,
|
||||
})
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}
|
||||
}
|
||||
208
cmd/ohos/HomeAgent/entry/src/main/ets/components/PageTopBar.ets
Normal file
@ -0,0 +1,208 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* 顶栏高度(vp)。页面 padding-top 应略大于此值,避免内容被标题压住,
|
||||
* 同时不要留过多空白。
|
||||
*/
|
||||
export const TOP_BAR_HEIGHT: number = 68;
|
||||
|
||||
/**
|
||||
* 页面顶栏遮罩:仅标题文字,无按钮无状态。
|
||||
* 浮在滚动区之上(页面用 PageTopBarLayer 置顶),背景为 不透明 -> 透明 的线性渐变,
|
||||
* 滚动内容从其下方穿过时逐渐淡出,形成"逐渐加深"的柔化边界,而不是硬截断。
|
||||
*
|
||||
* 关键:自定义组件被外部施加 .position() 时,ArkUI 会生成一个 __Common__ 包裹节点,
|
||||
* 该节点默认铺满父约束并使用默认命中测试 —— 会吞掉整页触摸事件。
|
||||
* 因此必须在【调用点】同时显式给出尺寸与 hitTestBehavior(None),
|
||||
* 统一封装在 PageTopBarLayer 里,页面不要再手写 .position()。
|
||||
*/
|
||||
@Component
|
||||
export struct PageTopBar {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop title: string = '';
|
||||
|
||||
build() {
|
||||
// 外层撑满整页并顶部对齐,替代调用点的 .position():
|
||||
// 自定义组件一旦在调用点被施加 .position(),ArkUI 会生成铺满父约束的
|
||||
// __Common__ 包裹节点,该节点使用默认命中测试,会吞掉整页的滚动与点击。
|
||||
// 这里改为自身撑满 + 全链路 HitTestMode.None,触摸完全穿透到下层滚动区。
|
||||
Column() {
|
||||
Column() {
|
||||
Text(this.title)
|
||||
.fontSize(20)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.margin({ left: 16, top: 10 })
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
.width('100%')
|
||||
.height(TOP_BAR_HEIGHT)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.linearGradient({
|
||||
direction: GradientDirection.Bottom,
|
||||
colors: [
|
||||
[this.opaqueBg(), 0.0],
|
||||
[this.opaqueBg(), 0.45],
|
||||
[this.transparentBg(), 1.0],
|
||||
],
|
||||
})
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.justifyContent(FlexAlign.Start)
|
||||
.hitTestBehavior(HitTestMode.None)
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
private opaqueBg(): string {
|
||||
const bg: string = this.palette().bgPrimary;
|
||||
return '#FF' + bg.substring(1);
|
||||
}
|
||||
|
||||
private transparentBg(): string {
|
||||
const bg: string = this.palette().bgPrimary;
|
||||
return '#00' + bg.substring(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立气态玻璃节点:单个悬浮组件,与底部导航同款玻璃(半透明底 + 高光渐变)。
|
||||
* 尺寸自适应内容(宽度随内容,高度/圆角参数化),视觉上独立、不与其他组件共框。
|
||||
* 用于承载状态胶囊、图标按钮等单个悬浮元素。
|
||||
*/
|
||||
@Component
|
||||
export struct GlassShell {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop nodeHeight: number = 32;
|
||||
@Prop nodeRadius: number = 16;
|
||||
@BuilderParam content: () => void;
|
||||
|
||||
build() {
|
||||
Row() {
|
||||
this.content()
|
||||
}
|
||||
.height(this.nodeHeight)
|
||||
.padding({ left: 8, right: 8 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(this.nodeRadius)
|
||||
.border({
|
||||
width: { left: 1, top: 1, right: 1, bottom: 1 },
|
||||
color: this.palette().navBarBorder,
|
||||
})
|
||||
.shadow({
|
||||
radius: 16,
|
||||
color: this.palette().shadow,
|
||||
offsetY: 5,
|
||||
})
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬浮区圆形图标按钮的直径(vp)。徽标不使用它——徽标是竖向窄条。
|
||||
*/
|
||||
export const FLOAT_NODE_SIZE: number = 42;
|
||||
|
||||
/**
|
||||
* 悬浮图标按钮:与底部导航同一套玻璃语言的圆形按钮。
|
||||
* 统一 42vp 直径、同款玻璃底/描边/投影,图标 18vp。
|
||||
*/
|
||||
@Component
|
||||
export struct FloatIconButton {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop icon: Resource | undefined = undefined;
|
||||
@Prop accent: boolean = false;
|
||||
onTap?: () => void;
|
||||
|
||||
build() {
|
||||
Button() {
|
||||
Image(this.icon)
|
||||
.width(18)
|
||||
.height(18)
|
||||
.fillColor(this.accent ? this.palette().accent : this.palette().textSecondary)
|
||||
}
|
||||
.width(FLOAT_NODE_SIZE)
|
||||
.height(FLOAT_NODE_SIZE)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.border({
|
||||
width: { left: 1, top: 1, right: 1, bottom: 1 },
|
||||
color: this.palette().navBarBorder,
|
||||
})
|
||||
.shadow({
|
||||
radius: 24,
|
||||
color: this.palette().shadow,
|
||||
offsetY: 8,
|
||||
})
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬浮区的"一行":宽度自适应内容,内部组件从右向左排列(右边缘与导航栏平齐)。
|
||||
* 放在 NavFloatOverlay 内部使用;声明顺序越靠前越贴近导航栏,后声明的行叠在其上方。
|
||||
*/
|
||||
@Component
|
||||
export struct NavFloatRow {
|
||||
@BuilderParam content: () => void;
|
||||
|
||||
build() {
|
||||
Row({ space: 8 }) {
|
||||
this.content()
|
||||
}
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部悬浮容器:透明、无任何背景/边框(视觉上不包裹任何东西,组件彼此独立),
|
||||
* 仅负责 右侧对齐(与导航栏同宽 88%)、自下而上分行堆叠 与 随滚动渐隐/滑出动画。
|
||||
*
|
||||
* 布局约定:直接子节点是"一行"(通常是自适应宽度的 Row)。
|
||||
* 行按【从上到下】声明:先声明的行在上方,最后声明的行贴住导航栏,
|
||||
* 即"一行放不下时向上面再开一行"(把新行写在前面)。
|
||||
*
|
||||
* 关键:这里必须用 Column 而不是 Flex —— Flex 在 ArkUI 中默认铺满父约束,
|
||||
* 会形成一个覆盖整页的默认命中测试节点,吞掉页面滚动与点击;
|
||||
* Column 高度自适应内容。再加 HitTestMode.Transparent,
|
||||
* 让每行右侧之外的空白区域触摸穿透到下层滚动区。
|
||||
*/
|
||||
@Component
|
||||
export struct NavFloatOverlay {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp('navVisible') private navVisible: boolean = true;
|
||||
@StorageProp('currentTab') private currentTab: number = 0;
|
||||
@Prop tab: number = 0;
|
||||
@Prop alignEnd: boolean = true;
|
||||
@BuilderParam content: () => void;
|
||||
|
||||
build() {
|
||||
Column({ space: 8 }) {
|
||||
this.content()
|
||||
}
|
||||
.width('88%')
|
||||
.alignItems(this.alignEnd ? HorizontalAlign.End : HorizontalAlign.Start)
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
.margin({ bottom: 94 })
|
||||
.translate({ y: (!this.navVisible || this.currentTab !== this.tab) ? 140 : 0 })
|
||||
.opacity((!this.navVisible || this.currentTab !== this.tab) ? 0 : 1)
|
||||
.animation({ duration: 400, curve: Curve.EaseOut })
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,379 @@
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage, noConnectionMessage } from '../common/UserError';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { RADIUS_SM } from '../common/Constants';
|
||||
import { COLOR_ERROR } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* 可复用的后端配置编辑器(按 key 前缀取一段配置并就地编辑)。
|
||||
*
|
||||
* 抽出来的原因:配置项不应该全部堆在「设置 → 后端配置」一个平铺列表里。
|
||||
* - core.* 各分类归到「核心」下一级;
|
||||
* - plugin.<name>.* 属于插件本身,放进该插件的详情页 —— 就是这个组件的用处。
|
||||
*
|
||||
* 后端接口:
|
||||
* - GET /settings?prefix=<prefix> → { settings: {k:v}, meta: {k:{...}} }
|
||||
* - PUT /settings → { key, value }(value 一律字符串)
|
||||
*/
|
||||
|
||||
interface SettingMetaRaw {
|
||||
key: string;
|
||||
type: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
interface EditorEntry {
|
||||
key: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
type: string;
|
||||
options: string[];
|
||||
value: string;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
interface SaveBody {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct SettingsEditor {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** key 前缀,例如 'plugin.ai_image.' 或 'core.llm.' */
|
||||
@Prop @Watch('onPrefixChanged') prefix: string = '';
|
||||
/** 空列表时的提示语 */
|
||||
@Prop emptyHint: string = '暂无可配置项';
|
||||
@State private entries: EditorEntry[] = [];
|
||||
@State private loading: boolean = false;
|
||||
@State private err: string = '';
|
||||
@State private toast: string = '';
|
||||
@State private toastErr: boolean = false;
|
||||
private original: Record<string, string> = {};
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
/** 宽屏下同一个组件实例会被复用(切换插件只改 prefix),必须重新拉取 */
|
||||
private onPrefixChanged(): void {
|
||||
this.entries = [];
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用前缀。
|
||||
*
|
||||
* 后端 GET /settings?prefix= 对 plugin.* 走的是另一条分支:
|
||||
* 它把 prefix 当作 "plugin." + 插件名 来切表,再自己拼 prefix + "." + key。
|
||||
* 所以传 'plugin.browser.'(带尾点)会被解析成插件名 "browser.",查不到表,
|
||||
* settings 返回空 —— 这就是插件详情页配置卡片空白的原因。
|
||||
* 查询必须去掉尾点,返回的 key 仍是 'plugin.browser.timeout' 这种全名,
|
||||
* 所以本地过滤/短标签依旧用带尾点的 this.prefix。
|
||||
*/
|
||||
private queryPrefix(): string {
|
||||
const p: string = this.prefix;
|
||||
return p.endsWith('.') ? p.substring(0, p.length - 1) : p;
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
if (!apiClient.hasConnection()) {
|
||||
this.err = noConnectionMessage();
|
||||
return;
|
||||
}
|
||||
if (this.prefix.length === 0) {
|
||||
this.entries = [];
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.err = '';
|
||||
try {
|
||||
const resp = await apiClient.getWithTimeout('/settings?prefix=' + this.queryPrefix(), 15000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
|
||||
const metaStore: Record<string, SettingMetaRaw> = {};
|
||||
const rawMeta: Object | undefined = obj['meta'];
|
||||
if (rawMeta !== undefined && rawMeta !== null) {
|
||||
const mObj: Record<string, Object> = rawMeta as Record<string, Object>;
|
||||
for (const mk of Object.keys(mObj)) {
|
||||
const item: Record<string, Object> = mObj[mk] as Record<string, Object>;
|
||||
const opts: string[] = [];
|
||||
const optsRaw: Object | undefined = item['options'];
|
||||
if (optsRaw !== undefined && optsRaw !== null) {
|
||||
const oa: Object[] = optsRaw as Object[];
|
||||
for (let i = 0; i < oa.length; i++) {
|
||||
const s: string = oa[i] as string ?? '';
|
||||
if (s.length > 0) {
|
||||
opts.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
const entryMeta: SettingMetaRaw = {
|
||||
key: item['key'] as string ?? mk,
|
||||
type: item['type'] as string ?? 'string',
|
||||
displayName: item['display_name'] as string ?? '',
|
||||
description: item['description'] as string ?? '',
|
||||
category: item['category'] as string ?? '',
|
||||
options: opts,
|
||||
};
|
||||
// 两个索引都建:
|
||||
// - mk 是 map 的键,插件分支里后端会把它拼成 'plugin.<name>.' + 完整 key,
|
||||
// 于是变成 'plugin.ai_image.plugin.ai_image.api_key' 这种双前缀,对不上;
|
||||
// - entryMeta.key 是定义自带的真实全名('plugin.ai_image.api_key'),才是能匹配的那个。
|
||||
// 只用 mk 索引就会让插件配置全部退化成"无显示名、类型按 string"。
|
||||
metaStore[mk] = entryMeta;
|
||||
if (entryMeta.key.length > 0) {
|
||||
metaStore[entryMeta.key] = entryMeta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const list: EditorEntry[] = [];
|
||||
this.original = {};
|
||||
const rawVals: Object | undefined = obj['settings'];
|
||||
if (rawVals !== undefined && rawVals !== null) {
|
||||
const vObj: Record<string, Object> = rawVals as Record<string, Object>;
|
||||
const keys: string[] = Object.keys(vObj).filter((k: string): boolean => {
|
||||
return k.startsWith(this.prefix);
|
||||
});
|
||||
keys.sort((a: string, b: string): number => a.localeCompare(b));
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k: string = keys[i];
|
||||
const raw: Object = vObj[k];
|
||||
let sv: string;
|
||||
if (typeof raw === 'string') {
|
||||
sv = raw as string;
|
||||
} else if (typeof raw === 'boolean' || typeof raw === 'number') {
|
||||
sv = String(raw);
|
||||
} else {
|
||||
sv = JSON.stringify(raw);
|
||||
}
|
||||
this.original[k] = sv;
|
||||
const meta: SettingMetaRaw | undefined = metaStore[k];
|
||||
list.push({
|
||||
key: k,
|
||||
displayName: meta !== undefined && meta.displayName.length > 0
|
||||
? meta.displayName : shortLabel(k, this.prefix),
|
||||
description: meta !== undefined ? meta.description : '',
|
||||
type: meta !== undefined ? meta.type : 'string',
|
||||
options: meta !== undefined ? meta.options : [],
|
||||
value: sv,
|
||||
dirty: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.entries = list;
|
||||
} catch (e) {
|
||||
this.err = userMessage('settings.load', e);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private markEntry(key: string, value: string, dirty: boolean): void {
|
||||
const next: EditorEntry[] = [];
|
||||
for (let i = 0; i < this.entries.length; i++) {
|
||||
const e: EditorEntry = this.entries[i];
|
||||
if (e.key === key) {
|
||||
next.push({
|
||||
key: e.key,
|
||||
displayName: e.displayName,
|
||||
description: e.description,
|
||||
type: e.type,
|
||||
options: e.options,
|
||||
value: value,
|
||||
dirty: dirty,
|
||||
});
|
||||
} else {
|
||||
next.push(e);
|
||||
}
|
||||
}
|
||||
this.entries = next;
|
||||
}
|
||||
|
||||
private onEdit(key: string, value: string): void {
|
||||
const orig: string = this.original[key] ?? '';
|
||||
this.markEntry(key, value, value !== orig);
|
||||
}
|
||||
|
||||
private latestValue(key: string): string {
|
||||
for (let i = 0; i < this.entries.length; i++) {
|
||||
if (this.entries[i].key === key) {
|
||||
return this.entries[i].value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private async save(entry: EditorEntry, newValue: string): Promise<void> {
|
||||
let payload: string = newValue.trim();
|
||||
if (entry.type === 'bool') {
|
||||
payload = newValue === 'true' ? 'true' : 'false';
|
||||
}
|
||||
try {
|
||||
const body: SaveBody = { key: entry.key, value: payload };
|
||||
await apiClient.request('/settings', 'PUT', JSON.stringify(body), 10000);
|
||||
this.original[entry.key] = payload;
|
||||
this.markEntry(entry.key, payload, false);
|
||||
this.showToast('已保存', false);
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('settings.save', e), true);
|
||||
}
|
||||
}
|
||||
|
||||
private showToast(msg: string, isErr: boolean): void {
|
||||
this.toast = msg;
|
||||
this.toastErr = isErr;
|
||||
setTimeout(() => {
|
||||
this.toast = '';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (this.loading && this.entries.length === 0) {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(22)
|
||||
.height(22)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 14, bottom: 14 })
|
||||
}
|
||||
|
||||
if (this.err.length > 0) {
|
||||
Text(this.err)
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
if (!this.loading && this.err.length === 0 && this.entries.length === 0) {
|
||||
Text(this.emptyHint)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
ForEach(this.entries, (entry: EditorEntry) => {
|
||||
this.EntryRow(entry)
|
||||
}, (entry: EditorEntry) => entry.key + '|' + entry.value + '|' + (entry.dirty ? 'd' : 'c'))
|
||||
|
||||
if (this.toast.length > 0) {
|
||||
Text(this.toast)
|
||||
.fontSize(11)
|
||||
.fontColor(this.toastErr ? COLOR_ERROR : this.palette().accent)
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
EntryRow(entry: EditorEntry) {
|
||||
Column() {
|
||||
Text(entry.displayName)
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(entry.key)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (entry.description.length > 0) {
|
||||
Text(entry.description)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(3)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ top: 3 })
|
||||
}
|
||||
|
||||
if (entry.type === 'bool') {
|
||||
Row() {
|
||||
Text(entry.value === 'true' ? '已开启' : '已关闭')
|
||||
.fontSize(12)
|
||||
.fontColor(entry.value === 'true' ? this.palette().accent : this.palette().textMuted)
|
||||
Blank()
|
||||
Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' })
|
||||
.selectedColor(this.palette().accent)
|
||||
.onChange((on: boolean) => {
|
||||
this.save(entry, on ? 'true' : 'false');
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 6 })
|
||||
} else if (entry.type === 'select' && entry.options.length > 0) {
|
||||
Flex({
|
||||
direction: FlexDirection.Row,
|
||||
justifyContent: FlexAlign.Start,
|
||||
alignItems: ItemAlign.Center,
|
||||
wrap: FlexWrap.Wrap,
|
||||
}) {
|
||||
ForEach(entry.options, (opt: string) => {
|
||||
Button(opt)
|
||||
.height(26)
|
||||
.fontSize(11)
|
||||
.margin({ right: 6, bottom: 6 })
|
||||
.backgroundColor(entry.value === opt ? this.palette().accent : this.palette().bgHover)
|
||||
.fontColor(entry.value === opt ? Color.White : this.palette().textSecondary)
|
||||
.onClick(() => {
|
||||
this.save(entry, opt);
|
||||
})
|
||||
}, (opt: string) => opt)
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 6 })
|
||||
} else {
|
||||
Row() {
|
||||
TextInput({ text: entry.value })
|
||||
.height(36)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.placeholderColor(this.palette().textMuted)
|
||||
.backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.border({ width: 1, color: this.palette().border })
|
||||
.type(entry.type === 'password' ? InputType.Password : InputType.Normal)
|
||||
.layoutWeight(1)
|
||||
.onChange((v: string) => {
|
||||
this.onEdit(entry.key, v);
|
||||
})
|
||||
Button('保存')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.margin({ left: 8 })
|
||||
.onClick(() => {
|
||||
this.save(entry, this.latestValue(entry.key));
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 6 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 10, bottom: 10 })
|
||||
.border({ width: { bottom: 1 }, color: this.palette().kvBorder })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/** 去掉前缀后的短标签:plugin.ai_image.model → model */
|
||||
function shortLabel(key: string, prefix: string): string {
|
||||
const s: string = key.startsWith(prefix) ? key.substring(prefix.length) : key;
|
||||
return s.length > 0 ? s : key;
|
||||
}
|
||||
@ -0,0 +1,604 @@
|
||||
/**
|
||||
* Static Markdown → ArkUI renderer for COMPLETE (non-streaming) chat messages.
|
||||
*
|
||||
* Parses once in aboutToAppear, builds a component tree with no timers — instant rendering.
|
||||
* Uses Span children inside Text for inline bold/italic/code/link formatting.
|
||||
*
|
||||
* Covers: headings, paragraphs, code fences, unordered/ordered lists,
|
||||
* blockquotes, horizontal rules, tables, and inline bold/italic/code/links.
|
||||
*/
|
||||
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
|
||||
import { RADIUS_SM } from '../common/Constants';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MdBlock {
|
||||
type: string; // 'heading' | 'code' | 'list' | 'ol' | 'blockquote' | 'hr' | 'table' | 'para'
|
||||
level?: number;
|
||||
items?: string[];
|
||||
text?: string;
|
||||
lang?: string;
|
||||
codeLines?: string[];
|
||||
headers?: string[];
|
||||
rows?: string[][];
|
||||
}
|
||||
|
||||
export interface MdSpan {
|
||||
text: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
code?: boolean;
|
||||
link?: boolean;
|
||||
linkUrl?: string;
|
||||
}
|
||||
|
||||
// ── Inline parser ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function parseInline(text: string): MdSpan[] {
|
||||
const spans: MdSpan[] = [];
|
||||
let i: number = 0;
|
||||
while (i < text.length) {
|
||||
// Inline code (backtick)
|
||||
if (text[i] === '`') {
|
||||
const end: number = text.indexOf('`', i + 1);
|
||||
if (end > i) {
|
||||
spans.push({ text: text.substring(i + 1, end), code: true });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Bold: **text**
|
||||
if (text[i] === '*' && i + 1 < text.length && text[i + 1] === '*') {
|
||||
const end: number = text.indexOf('**', i + 2);
|
||||
if (end > i + 1) {
|
||||
spans.push({ text: text.substring(i + 2, end), bold: true });
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Italic: *text* (single asterisk)
|
||||
if (text[i] === '*' && (i + 1 >= text.length || text[i + 1] !== '*')) {
|
||||
const end: number = text.indexOf('*', i + 1);
|
||||
if (end > i) {
|
||||
spans.push({ text: text.substring(i + 1, end), italic: true });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Link: [text](url)
|
||||
if (text[i] === '[') {
|
||||
const cb: number = text.indexOf(']', i + 1);
|
||||
if (cb > i && cb + 1 < text.length && text[cb + 1] === '(') {
|
||||
const cp: number = text.indexOf(')', cb + 2);
|
||||
if (cp > cb + 1) {
|
||||
spans.push({ text: text.substring(i + 1, cb), link: true, linkUrl: text.substring(cb + 2, cp) });
|
||||
i = cp + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Plain run
|
||||
let j: number = i + 1;
|
||||
while (j < text.length && text[j] !== '`' && text[j] !== '*' && text[j] !== '[') {
|
||||
j++;
|
||||
}
|
||||
spans.push({ text: text.substring(i, j) });
|
||||
i = j;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
// ── Block parser helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function isHr(line: string): boolean {
|
||||
if (line.length < 3) {
|
||||
return false;
|
||||
}
|
||||
const ch: string = line[0];
|
||||
if (ch !== '-' && ch !== '*' && ch !== '_') {
|
||||
return false;
|
||||
}
|
||||
for (let k = 0; k < line.length; k++) {
|
||||
if (line[k] !== ch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isOlStart(line: string): boolean {
|
||||
if (line.length < 3) {
|
||||
return false;
|
||||
}
|
||||
let k: number = 0;
|
||||
while (k < line.length && line[k] >= '0' && line[k] <= '9') {
|
||||
k++;
|
||||
}
|
||||
return k > 0 && k + 1 < line.length && line[k] === '.' && line[k + 1] === ' ';
|
||||
}
|
||||
|
||||
function isTableSep(line: string): boolean {
|
||||
if (!line.includes('-')) {
|
||||
return false;
|
||||
}
|
||||
for (let k = 0; k < line.length; k++) {
|
||||
const c: string = line[k];
|
||||
if (c !== '|' && c !== '-' && c !== ':' && c !== ' ' && c !== '\t') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Block parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function parseBlocks(content: string): MdBlock[] {
|
||||
if (content.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const lines: string[] = content.split('\n');
|
||||
const blocks: MdBlock[] = [];
|
||||
let i: number = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line: string = lines[i];
|
||||
|
||||
// Empty line
|
||||
if (line.trim().length === 0) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code fence
|
||||
if (line.startsWith('```')) {
|
||||
const langEnd: number = line.indexOf('`', 3);
|
||||
const lang: string = langEnd > 3 ? line.substring(3, langEnd).trim() : '';
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].trimStart().startsWith('```')) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) {
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'code', lang: lang, codeLines: codeLines });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading
|
||||
if (line.startsWith('#')) {
|
||||
let level: number = 0;
|
||||
while (level < line.length && line[level] === '#') {
|
||||
level++;
|
||||
}
|
||||
if (level <= 6 && level < line.length && line[level] === ' ') {
|
||||
blocks.push({ type: 'heading', level: level, text: line.substring(level + 1).trim() });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (isHr(line.trim())) {
|
||||
blocks.push({ type: 'hr' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if ((line.startsWith('- ') || line.startsWith('* ')) && !line.startsWith('- [')) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && (lines[i].startsWith('- ') || lines[i].startsWith('* ')) && !lines[i].startsWith('- [')) {
|
||||
items.push(lines[i].substring(2));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'list', items: items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if (isOlStart(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && isOlStart(lines[i])) {
|
||||
const dotIdx: number = lines[i].indexOf('. ');
|
||||
items.push(lines[i].substring(dotIdx + 2));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'ol', items: items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith('> ')) {
|
||||
const qLines: string[] = [];
|
||||
while (i < lines.length && lines[i].startsWith('> ')) {
|
||||
qLines.push(lines[i].substring(2));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'blockquote', text: qLines.join('\n') });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table
|
||||
if (line.trimStart().startsWith('|') && !isTableSep(line)) {
|
||||
const tLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trimStart().startsWith('|')) {
|
||||
tLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (tLines.length >= 2) {
|
||||
const parseRow = (row: string): string[] => {
|
||||
const cells: string[] = [];
|
||||
const parts: string[] = row.split('|');
|
||||
for (let p = 0; p < parts.length; p++) {
|
||||
const c: string = parts[p].trim();
|
||||
if (c.length > 0) {
|
||||
cells.push(c);
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
};
|
||||
const headers: string[] = parseRow(tLines[0]);
|
||||
const rows: string[][] = [];
|
||||
for (let k = 1; k < tLines.length; k++) {
|
||||
if (!isTableSep(tLines[k].trim())) {
|
||||
rows.push(parseRow(tLines[k]));
|
||||
}
|
||||
}
|
||||
if (headers.length > 0) {
|
||||
blocks.push({ type: 'table', headers: headers, rows: rows });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph: collect consecutive non-special lines
|
||||
{
|
||||
const paraLines: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const ln: string = lines[i];
|
||||
if (ln.trim().length === 0) {
|
||||
break;
|
||||
}
|
||||
if (ln.startsWith('```') || ln.startsWith('#') || isHr(ln.trim())) {
|
||||
break;
|
||||
}
|
||||
if (ln.startsWith('- ') || ln.startsWith('* ') || isOlStart(ln) || ln.startsWith('> ')) {
|
||||
break;
|
||||
}
|
||||
if (ln.trimStart().startsWith('|') && !isTableSep(ln)) {
|
||||
break;
|
||||
}
|
||||
paraLines.push(ln);
|
||||
i++;
|
||||
}
|
||||
if (paraLines.length > 0) {
|
||||
blocks.push({ type: 'para', text: paraLines.join('\n') });
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Component
|
||||
export struct StaticMarkdownView {
|
||||
@Prop content: string = '';
|
||||
@Prop isDark: boolean = true;
|
||||
private blocks: MdBlock[] = [];
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.blocks = parseBlocks(this.content);
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
private headingSize(level: number): number {
|
||||
if (level === 1) {
|
||||
return 22;
|
||||
}
|
||||
if (level === 2) {
|
||||
return 19;
|
||||
}
|
||||
if (level === 3) {
|
||||
return 17;
|
||||
}
|
||||
if (level === 4) {
|
||||
return 15.5;
|
||||
}
|
||||
return 14;
|
||||
}
|
||||
|
||||
// ── Block builders ─────────────────────────────────────────────────────────
|
||||
|
||||
@Builder
|
||||
ParaBlock(text: string) {
|
||||
Column({ space: 1 }) {
|
||||
ForEach(this.splitNewlines(text), (ln: string, idx: number) => {
|
||||
// Render inline spans inside this line
|
||||
Text() {
|
||||
ForEach(parseInline(ln), (sp: MdSpan, si: number) => {
|
||||
Span(sp.text)
|
||||
.fontSize(15)
|
||||
.fontColor(sp.link === true ? this.palette().accent : sp.code === true ? this.palette().preText : this.palette().msgBubbleText)
|
||||
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
|
||||
.fontFamily(sp.code === true ? 'monospace' : '-')
|
||||
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
|
||||
.borderRadius(3)
|
||||
.padding(sp.code === true ? { left: 3, right: 3, top: 1, bottom: 1 } : {})
|
||||
.decoration(sp.link === true ? { type: TextDecorationType.Underline } : undefined)
|
||||
}, (sp: MdSpan, si: number) => idx.toString() + '_' + si.toString())
|
||||
}
|
||||
.fontSize(15)
|
||||
.lineHeight(24)
|
||||
.fontColor(this.palette().msgBubbleText)
|
||||
.width('100%')
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.textAlign(TextAlign.Start)
|
||||
}, (ln: string, idx: number) => 'p' + idx.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 2, bottom: 4 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
HeadingBlock(block: MdBlock) {
|
||||
Text(block.text ?? '')
|
||||
.fontSize(this.headingSize(block.level ?? 1))
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.lineHeight(this.headingSize(block.level ?? 1) + 8)
|
||||
.width('100%')
|
||||
.margin({ top: 6, bottom: 4 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
CodeBlock(block: MdBlock) {
|
||||
Column() {
|
||||
Row() {
|
||||
Text(block.lang !== undefined && block.lang.length > 0 ? block.lang : 'code')
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.fontFamily('monospace')
|
||||
Blank()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
|
||||
// Code body
|
||||
Column() {
|
||||
ForEach(block.codeLines ?? [], (ln: string, idx: number) => {
|
||||
Text(ln.length > 0 ? ln : ' ')
|
||||
.fontSize(12.5)
|
||||
.lineHeight(19)
|
||||
.fontFamily('monospace')
|
||||
.fontColor(this.palette().preText)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
}, (ln: string, idx: number) => 'c' + idx.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
}
|
||||
.width('100%')
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().preBg)
|
||||
.border({ width: 1, color: this.palette().kvBorder })
|
||||
.clip(true)
|
||||
.margin({ top: 4, bottom: 6 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
ListBlock(block: MdBlock) {
|
||||
Column({ space: 2 }) {
|
||||
ForEach(block.items ?? [], (item: string, idx: number) => {
|
||||
Row({ space: 6 }) {
|
||||
Text('•')
|
||||
.fontSize(15)
|
||||
.fontColor(this.palette().accent)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.margin({ top: 1 })
|
||||
Text() {
|
||||
ForEach(parseInline(item), (sp: MdSpan, si: number) => {
|
||||
Span(sp.text)
|
||||
.fontSize(15)
|
||||
.fontColor(sp.code === true ? this.palette().preText : this.palette().msgBubbleText)
|
||||
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
|
||||
.fontFamily(sp.code === true ? 'monospace' : '-')
|
||||
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
|
||||
.borderRadius(3)
|
||||
.padding(sp.code === true ? { left: 3, right: 3, top: 1, bottom: 1 } : {})
|
||||
}, (sp: MdSpan, si: number) => 'li' + idx.toString() + '_' + si.toString())
|
||||
}
|
||||
.fontSize(15)
|
||||
.lineHeight(23)
|
||||
.fontColor(this.palette().msgBubbleText)
|
||||
.layoutWeight(1)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}, (item: string, idx: number) => idx.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 2, bottom: 4 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
OlBlock(block: MdBlock) {
|
||||
Column({ space: 2 }) {
|
||||
ForEach(block.items ?? [], (item: string, idx: number) => {
|
||||
Row({ space: 6 }) {
|
||||
Text((idx + 1).toString() + '.')
|
||||
.fontSize(15)
|
||||
.fontColor(this.palette().accent)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.margin({ top: 1 })
|
||||
Text() {
|
||||
ForEach(parseInline(item), (sp: MdSpan, si: number) => {
|
||||
Span(sp.text)
|
||||
.fontSize(15)
|
||||
.fontColor(sp.code === true ? this.palette().preText : this.palette().msgBubbleText)
|
||||
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
|
||||
.fontFamily(sp.code === true ? 'monospace' : '-')
|
||||
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
|
||||
.borderRadius(3)
|
||||
.padding(sp.code === true ? { left: 3, right: 3, top: 1, bottom: 1 } : {})
|
||||
}, (sp: MdSpan, si: number) => 'oli' + idx.toString() + '_' + si.toString())
|
||||
}
|
||||
.fontSize(15)
|
||||
.lineHeight(23)
|
||||
.fontColor(this.palette().msgBubbleText)
|
||||
.layoutWeight(1)
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}, (item: string, idx: number) => idx.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 2, bottom: 4 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
BlockquoteBlock(text: string) {
|
||||
Column() {
|
||||
ForEach(this.splitNewlines(text), (ln: string, idx: number) => {
|
||||
Text() {
|
||||
ForEach(parseInline(ln), (sp: MdSpan, si: number) => {
|
||||
Span(sp.text)
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textTertiary)
|
||||
.fontStyle(sp.italic === true ? FontStyle.Italic : FontStyle.Normal)
|
||||
.fontWeight(sp.bold === true ? FontWeight.Bold : FontWeight.Normal)
|
||||
}, (sp: MdSpan, si: number) => 'bq' + idx.toString() + '_' + si.toString())
|
||||
}
|
||||
.fontSize(14)
|
||||
.lineHeight(22)
|
||||
.width('100%')
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
}, (ln: string, idx: number) => 'bq' + idx.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.margin({ top: 3, bottom: 5 })
|
||||
.borderRadius({ topLeft: 0, topRight: 6, bottomRight: 6, bottomLeft: 0 })
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({
|
||||
width: { left: 3, top: 0, right: 0, bottom: 0 },
|
||||
color: { left: this.palette().accent, top: Color.Transparent, right: Color.Transparent, bottom: Color.Transparent },
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
HrBlock() {
|
||||
Row()
|
||||
.width('100%')
|
||||
.height(1)
|
||||
.backgroundColor(this.palette().kvBorder)
|
||||
.margin({ top: 6, bottom: 6 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
TableBlock(block: MdBlock) {
|
||||
Column() {
|
||||
// Header
|
||||
Row() {
|
||||
ForEach(block.headers ?? [], (h: string, hi: number) => {
|
||||
Text() {
|
||||
ForEach(parseInline(h), (sp: MdSpan, si: number) => {
|
||||
Span(sp.text)
|
||||
.fontSize(12.5)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
}, (sp: MdSpan, si: number) => 'th' + hi.toString() + '_' + si.toString())
|
||||
}
|
||||
.fontSize(12.5)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 6, right: 6, top: 5, bottom: 5 })
|
||||
}, (h: string, hi: number) => 'th' + hi.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
|
||||
// Body
|
||||
ForEach(block.rows ?? [], (row: string[], ri: number) => {
|
||||
Row() {
|
||||
ForEach(row, (cell: string, ci: number) => {
|
||||
Text() {
|
||||
ForEach(parseInline(cell), (sp: MdSpan, si: number) => {
|
||||
Span(sp.text)
|
||||
.fontSize(12.5)
|
||||
.fontColor(this.palette().msgBubbleText)
|
||||
.fontFamily(sp.code === true ? 'monospace' : '-')
|
||||
.backgroundColor(sp.code === true ? this.palette().preBg : Color.Transparent)
|
||||
}, (sp: MdSpan, si: number) => 'td' + ri.toString() + '_' + ci.toString() + '_' + si.toString())
|
||||
}
|
||||
.fontSize(12.5)
|
||||
.fontColor(this.palette().msgBubbleText)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 6, right: 6, top: 4, bottom: 4 })
|
||||
.wordBreak(WordBreak.BREAK_ALL)
|
||||
.width('100%')
|
||||
}, (cell: string, ci: number) => 'td' + ri.toString() + '_' + ci.toString())
|
||||
}
|
||||
.width('100%')
|
||||
}, (row: string[], ri: number) => 'tr' + ri.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.borderRadius(RADIUS_SM)
|
||||
.border({ width: 1, color: this.palette().kvBorder })
|
||||
.clip(true)
|
||||
.margin({ top: 4, bottom: 6 })
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private splitNewlines(text: string): string[] {
|
||||
if (text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return text.split('\n');
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
ForEach(this.blocks, (b: MdBlock, idx: number) => {
|
||||
if (b.type === 'code') {
|
||||
this.CodeBlock(b)
|
||||
} else if (b.type === 'heading') {
|
||||
this.HeadingBlock(b)
|
||||
} else if (b.type === 'list') {
|
||||
this.ListBlock(b)
|
||||
} else if (b.type === 'ol') {
|
||||
this.OlBlock(b)
|
||||
} else if (b.type === 'blockquote') {
|
||||
this.BlockquoteBlock(b.text ?? '')
|
||||
} else if (b.type === 'hr') {
|
||||
this.HrBlock()
|
||||
} else if (b.type === 'table') {
|
||||
this.TableBlock(b)
|
||||
} else {
|
||||
this.ParaBlock(b.text ?? '')
|
||||
}
|
||||
}, (b: MdBlock, idx: number) => idx.toString() + b.type + ((b.text ?? '').substring(0, 12)))
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
307
cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets
Normal file
@ -0,0 +1,307 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM } from '../common/Constants';
|
||||
import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
|
||||
import {
|
||||
statusStore, StatGroup, StatField, compactDuration,
|
||||
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV,
|
||||
} from '../common/StatusStore';
|
||||
|
||||
/**
|
||||
* 运行状态摘要卡:嵌在设置一级页顶部(原「状态」Tab 已删除)。
|
||||
*
|
||||
* 只放一眼可读的东西:环形仪表 + 运行时长 + 三项 KPI + 版本,
|
||||
* 明细(内核 / 模型 / 记忆 / 运行时 / 工具清单)走二级页面。
|
||||
* 点击整卡进入明细,所以自身不放任何按钮,避免嵌套点击。
|
||||
*/
|
||||
@Component
|
||||
export struct StatusSummaryCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp(K_UP) private up: boolean = false;
|
||||
@StorageProp(K_VERSION) private version: string = '-';
|
||||
@StorageProp(K_STARTED) private startedAt: string = '';
|
||||
@StorageProp(K_AGENTS) private agents: number = 0;
|
||||
@StorageProp(K_PLUGINS) private plugins: number = 0;
|
||||
@StorageProp(K_TOOLS) private tools: number = 0;
|
||||
@StorageProp(K_ERR) private err: string = '';
|
||||
@StorageProp(K_LOADING) private loading: boolean = false;
|
||||
/** 秒级刷新的运行时长文本:环心不能用 startedAt 直接算,否则不会自动跳秒 */
|
||||
@State private uptime: string = '-';
|
||||
private timerId: number = -1;
|
||||
onTap?: () => void;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.uptime = compactDuration(this.startedAt);
|
||||
this.timerId = setInterval(() => {
|
||||
this.uptime = compactDuration(this.startedAt);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
if (this.timerId >= 0) {
|
||||
clearInterval(this.timerId);
|
||||
this.timerId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (this.err.length > 0) {
|
||||
// 连接失败:只显示一句人话,技术细节在 hilog 里
|
||||
Row() {
|
||||
Image($r('app.media.ic_error'))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.fillColor(COLOR_ERROR)
|
||||
.draggable(false)
|
||||
Text(this.err)
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.layoutWeight(1)
|
||||
.margin({ left: 10 })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
} else if (this.loading && !this.up) {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(26)
|
||||
.height(26)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 20, bottom: 20 })
|
||||
} else {
|
||||
// 状态主行:一枚状态图标(运行=脉搏,离线=断开)+ 状态词 + 运行时长。
|
||||
// 之前那个环形进度条表达的是"100% / 0%",而在线与否是布尔量,
|
||||
// 用百分比环表示只会让人以为有什么在加载;架构是单 agent 单 session,
|
||||
// "Agent 数" 恒为 1,也没有信息量,一并去掉。
|
||||
Row({ space: 12 }) {
|
||||
Stack({ alignContent: Alignment.Center }) {
|
||||
Image(this.up ? $r('app.media.ic_pulse') : $r('app.media.ic_offline'))
|
||||
.width(24)
|
||||
.height(24)
|
||||
.fillColor(this.up ? COLOR_SUCCESS : this.palette().textMuted)
|
||||
.draggable(false)
|
||||
}
|
||||
.width(48)
|
||||
.height(48)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.up ? 'rgba(23, 169, 100, 0.16)' : this.palette().bgHover)
|
||||
|
||||
Column({ space: 3 }) {
|
||||
Text(this.up ? '运行中' : '离线')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.up ? COLOR_SUCCESS : this.palette().textMuted)
|
||||
Text(this.up ? '已运行 ' + this.uptime : '未连接到后端服务')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)
|
||||
Row({ space: 10 }) {
|
||||
this.kpiTile($r('app.media.ic_plug'), '插件', this.plugins, COLOR_ACCENT)
|
||||
this.kpiTile($r('app.media.ic_tool'), '工具', this.tools, COLOR_CYAN)
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 14 })
|
||||
|
||||
// 版本 + 「查看明细」提示,与 KPI 之间用分割线断开
|
||||
Row() {
|
||||
Text('版本')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
Text(this.version)
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ left: 10 })
|
||||
Text('明细')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().accent)
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(14)
|
||||
.height(14)
|
||||
.fillColor(this.palette().accent)
|
||||
.draggable(false)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.margin({ top: 14 })
|
||||
.padding({ top: 11 })
|
||||
.border({ width: { top: 1 }, color: this.palette().kvBorder })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 18 })
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Builder
|
||||
kpiTile(icon: Resource, label: string, value: number, color: string) {
|
||||
Row({ space: 8 }) {
|
||||
Image(icon)
|
||||
.width(16)
|
||||
.height(16)
|
||||
.fillColor(color)
|
||||
.draggable(false)
|
||||
Column({ space: 1 }) {
|
||||
Text(value > 0 ? value.toString() : '-')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(label)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行状态明细:设置页「运行状态」二级页面的内容。
|
||||
*
|
||||
* 服务负载三条容量条 + 分组明细卡(系统概览 / 内核 / 模型 / 记忆 / 运行时 / 工具)。
|
||||
* 分组数组不放 AppStorage(数组同步语义不可靠),改为订阅版本号 K_REV,
|
||||
* 版本变化时从 statusStore 重取一次快照。
|
||||
*/
|
||||
@Component
|
||||
export struct StatusDetailContent {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp(K_UP) private up: boolean = false;
|
||||
@StorageProp(K_AGENTS) private agents: number = 0;
|
||||
@StorageProp(K_PLUGINS) private plugins: number = 0;
|
||||
@StorageProp(K_TOOLS) private tools: number = 0;
|
||||
@StorageProp(K_ERR) private err: string = '';
|
||||
@StorageProp(K_LOADING) private loading: boolean = false;
|
||||
@StorageProp(K_REV) @Watch('onRevChanged') private rev: number = 0;
|
||||
@State groups: StatGroup[] = [];
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.groups = statusStore.getGroups();
|
||||
}
|
||||
|
||||
private onRevChanged(): void {
|
||||
this.groups = statusStore.getGroups();
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (this.err.length > 0) {
|
||||
Row() {
|
||||
Image($r('app.media.ic_error'))
|
||||
.width(16)
|
||||
.height(16)
|
||||
.fillColor(COLOR_ERROR)
|
||||
.draggable(false)
|
||||
Text(this.err)
|
||||
.fontSize(13)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.layoutWeight(1)
|
||||
.margin({ left: 10 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
.margin({ bottom: 14 })
|
||||
}
|
||||
|
||||
if (this.loading && this.groups.length === 0) {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(28)
|
||||
.height(28)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 40, bottom: 40 })
|
||||
}
|
||||
|
||||
// 这里原本有一张"服务负载"卡,用两条容量条画插件/工具数量。
|
||||
// 后端并不存在"插件上限/工具上限"这种容量概念,分母是两者取大值,
|
||||
// 于是 31 个插件在 203 个工具旁边只剩一条短线 —— 读数没有意义。
|
||||
// 数量本身已在摘要卡上以图标+数字直观呈现,这里不再重复。
|
||||
|
||||
ForEach(this.groups, (g: StatGroup) => {
|
||||
Column() {
|
||||
Text(g.title)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.margin({ bottom: 6 })
|
||||
ForEach(g.fields, (f: StatField, idx: number) => {
|
||||
Row() {
|
||||
Text(f.label)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Text(f.value.length > 0 ? f.value : '-')
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: 200 })
|
||||
.margin({ left: 16 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 9, bottom: 9 })
|
||||
.border({
|
||||
width: { bottom: idx < g.fields.length - 1 ? 1 : 0 },
|
||||
color: this.palette().kvBorder,
|
||||
})
|
||||
}, (f: StatField) => g.title + f.label)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 14 })
|
||||
}, (g: StatGroup) => g.title)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
333
cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets
Normal file
@ -0,0 +1,333 @@
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG } from '../common/Constants';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from './PageTopBar';
|
||||
import { GradientBackground } from './GradientBackground';
|
||||
|
||||
/**
|
||||
* 二级页面开关标志(AppStorage)。
|
||||
* Index 读取它来锁住 Swiper 左右滑动——否则在二级页面里横滑会误切换主 Tab。
|
||||
* 由 Navigation.onNavBarStateChange 驱动,与真实导航栈严格同步。
|
||||
*/
|
||||
export const KEY_SUBPAGE_OPEN: string = 'subPageOpen';
|
||||
|
||||
export function markSubPageOpen(open: boolean): void {
|
||||
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
|
||||
}
|
||||
|
||||
/** pushPathByName 的参数载体:ArkTS 不允许把 string 断言成 object */
|
||||
export interface SubPageParam {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function subPageParam(id: string): SubPageParam {
|
||||
const p: SubPageParam = { id: id };
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级页面内容层:放在 NavDestination 里使用。
|
||||
*
|
||||
* 为什么二级页面走 Navigation / NavDestination 而不是自己用 @State 切换:
|
||||
* - NavDestination 由系统维护导航栈,侧滑返回、三键返回、
|
||||
* 以及无障碍返回都会自动 pop,不需要自己拦 onBackPress;
|
||||
* - 转场动画由系统提供,与其他系统应用一致。
|
||||
*
|
||||
* 视觉约定与一级页面完全一致:
|
||||
* - 自带同款渐变背景(NavDestination 会整屏盖住一级内容,
|
||||
* 不铺背景会透出下层列表形成重影);
|
||||
* - 顶栏只有标题,上边缘由不透明渐变到透明;
|
||||
* - 返回按钮不放顶栏,而是作为独立悬浮组件放在底部悬浮区(拇指可达)。
|
||||
*
|
||||
* 注意:@BuilderParam 只能有一个(trailing lambda 限制),
|
||||
* 所以悬浮区的刷新按钮用 showRefresh + onRefresh 两个普通属性表达,
|
||||
* 而不是第二个 @BuilderParam。
|
||||
*/
|
||||
@Component
|
||||
export struct SubPageLayer {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** 宽屏分栏时本层就是右侧栏,一级界面一直在左边可见,返回键无意义 */
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
@Prop title: string = '';
|
||||
/** 所属主 Tab 序号,供底部悬浮区的显隐动画使用 */
|
||||
@Prop tab: number = 0;
|
||||
/** 悬浮区是否附带刷新按钮 */
|
||||
@Prop showRefresh: boolean = false;
|
||||
onBack?: () => void;
|
||||
onRefresh?: () => void;
|
||||
@BuilderParam content: () => void;
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
// 二级页面自带背景:Stack 模式下整屏覆盖,必须自己铺底;
|
||||
// 宽屏分栏时右栏也需要同款背景,与左栏视觉连续。
|
||||
GradientBackground()
|
||||
|
||||
Scroll() {
|
||||
Column() {
|
||||
this.content()
|
||||
}
|
||||
.width('100%')
|
||||
// 宽屏右栏底部没有主导航胶囊,只保留悬浮键的空间
|
||||
.padding({ left: 16, right: 16, top: 76, bottom: this.isWide ? 108 : 174 })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
.align(Alignment.Top)
|
||||
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
||||
handleNavOnScroll(state);
|
||||
})
|
||||
|
||||
PageTopBar({ title: this.title })
|
||||
|
||||
// 底部悬浮区:返回键(+ 可选刷新键)。
|
||||
// 宽屏分栏时左栏常驻可见,一级页面自己的悬浮刷新键也还在屏幕上,
|
||||
// 这里再挂一个就成了"两个重加载按钮"——所以宽屏下本层不出刷新键,
|
||||
// 由一级页面那一个统一负责(它的回调会连带刷新右栏内容)。
|
||||
NavFloatOverlay({ tab: this.tab }) {
|
||||
NavFloatRow() {
|
||||
if (!this.isWide) {
|
||||
SubPageBackButton({
|
||||
onTap: () => {
|
||||
const cb = this.onBack;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
})
|
||||
if (this.showRefresh) {
|
||||
FloatIconButton({
|
||||
icon: $r('app.media.ic_refresh'),
|
||||
onTap: () => {
|
||||
const cb = this.onRefresh;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一级页面里的入口行:图标 + 标题 + 副标题 + 右侧摘要值 + 右尖角。
|
||||
* 点击进入二级页面。
|
||||
*/
|
||||
@Component
|
||||
export struct NavRow {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop icon: Resource | undefined = undefined;
|
||||
@Prop title: string = '';
|
||||
@Prop subtitle: string = '';
|
||||
@Prop value: string = '';
|
||||
@Prop tint: string = '';
|
||||
@Prop showDivider: boolean = true;
|
||||
/** 宽屏分栏时右栏正显示本行对应的二级页面 —— 高亮当前项 */
|
||||
@Prop selected: boolean = false;
|
||||
onTap?: () => void;
|
||||
|
||||
build() {
|
||||
Row() {
|
||||
if (this.icon !== undefined) {
|
||||
Row() {
|
||||
Image(this.icon)
|
||||
.width(17)
|
||||
.height(17)
|
||||
.fillColor(this.iconColor())
|
||||
.draggable(false)
|
||||
}
|
||||
.width(32)
|
||||
.height(32)
|
||||
.borderRadius(10)
|
||||
.backgroundColor(this.iconBg())
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.margin({ right: 12 })
|
||||
}
|
||||
|
||||
Column({ space: 2 }) {
|
||||
Text(this.title)
|
||||
.fontSize(15)
|
||||
.fontColor(this.selected ? this.palette().accent : this.palette().textPrimary)
|
||||
.fontWeight(this.selected ? FontWeight.Medium : FontWeight.Normal)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (this.subtitle.length > 0) {
|
||||
Text(this.subtitle)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
if (this.value.length > 0) {
|
||||
Text(this.value)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: 130 })
|
||||
.margin({ right: 8 })
|
||||
}
|
||||
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.selected ? this.palette().accent : this.palette().textMuted)
|
||||
.draggable(false)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.backgroundColor(this.selected ? this.palette().accentBg : Color.Transparent)
|
||||
.border({
|
||||
width: { bottom: this.showDivider ? 1 : 0 },
|
||||
color: this.palette().kvBorder,
|
||||
})
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private iconColor(): string {
|
||||
return this.tint.length > 0 ? this.tint : this.palette().accent;
|
||||
}
|
||||
|
||||
private iconBg(): string {
|
||||
return this.tint.length > 0 ? this.palette().bgHover : this.palette().accentBg;
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组卡:一个标题 + 一组 NavRow,圆角裁剪让行分割线不越出卡片。
|
||||
*/
|
||||
@Component
|
||||
export struct NavGroup {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop caption: string = '';
|
||||
@BuilderParam content: () => void;
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (this.caption.length > 0) {
|
||||
Text(this.caption)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ left: 4, bottom: 8 })
|
||||
}
|
||||
Column() {
|
||||
this.content()
|
||||
}
|
||||
.width('100%')
|
||||
.borderRadius(RADIUS_LG)
|
||||
.clip(true)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 18 })
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用内容卡(二级页面里承载表单/明细的容器)。
|
||||
*/
|
||||
@Component
|
||||
export struct PlainCard {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop caption: string = '';
|
||||
@BuilderParam content: () => void;
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
if (this.caption.length > 0) {
|
||||
Text(this.caption)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ left: 4, bottom: 8 })
|
||||
}
|
||||
Column() {
|
||||
this.content()
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 18 })
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级页面的返回按钮:与悬浮图标按钮同款玻璃,放在底部悬浮区最左侧。
|
||||
* 系统侧滑返回同样可用,这个按钮只是给单手拇指多一条路径。
|
||||
*/
|
||||
@Component
|
||||
export struct SubPageBackButton {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
onTap?: () => void;
|
||||
|
||||
build() {
|
||||
Button() {
|
||||
Row({ space: 4 }) {
|
||||
Image($r('app.media.ic_arrow_back'))
|
||||
.width(17)
|
||||
.height(17)
|
||||
.fillColor(this.palette().textSecondary)
|
||||
.draggable(false)
|
||||
}
|
||||
}
|
||||
.width(42)
|
||||
.height(42)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.border({
|
||||
width: { left: 1, top: 1, right: 1, bottom: 1 },
|
||||
color: this.palette().navBarBorder,
|
||||
})
|
||||
.shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
|
||||
.onClick(() => {
|
||||
const cb = this.onTap;
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
import { UIAbility, AbilityConstant, Configuration, ConfigurationConstant, Want } from '@kit.AbilityKit';
|
||||
import { window } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { themeIsDark, seedTheme, seedSystemIsDark, resolveIsDark, applyThemeMode } from '../common/Constants';
|
||||
|
||||
/** Read the persisted theme mode ('system'|'dark'|'light'), defaulting to 'system'. */
|
||||
function storedThemeMode(): string {
|
||||
try {
|
||||
const s = connStore.getSettings();
|
||||
if (typeof s.theme === 'string' && s.theme.length > 0) {
|
||||
return s.theme;
|
||||
}
|
||||
} catch (e) {
|
||||
// store not ready yet
|
||||
}
|
||||
return 'system';
|
||||
}
|
||||
|
||||
export default class EntryAbility extends UIAbility {
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
console.info('[HomeAgent] ability onCreate');
|
||||
const sysDark: boolean =
|
||||
this.context.config?.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
|
||||
// Seed the OS color state first, then resolve the persisted mode.
|
||||
// connStore may not be initialized here (mode falls back to 'system');
|
||||
// onWindowStageCreate re-applies the real stored mode after init.
|
||||
seedSystemIsDark(sysDark);
|
||||
seedTheme(resolveIsDark(storedThemeMode(), sysDark));
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
console.info('[HomeAgent] ability onDestroy');
|
||||
}
|
||||
|
||||
/** System dark/light switch — re-resolve when mode is 'system'. */
|
||||
onConfigurationUpdate(newConfig: Configuration): void {
|
||||
try {
|
||||
const sysDark: boolean = newConfig.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
|
||||
seedSystemIsDark(sysDark);
|
||||
const mode: string = storedThemeMode();
|
||||
if (mode === 'system') {
|
||||
AppStorage.set('themeIsDark', resolveIsDark('system', sysDark));
|
||||
this.applySystemBar();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private applySystemBar(): void {
|
||||
// 状态栏与窗口底色跟随主题,消除浅色模式下的暗色割裂
|
||||
const dark: boolean = themeIsDark();
|
||||
const bg: string = dark ? '#000000' : '#F1F3F5';
|
||||
const fg: string = dark ? '#FFFFFF' : '#191919';
|
||||
try {
|
||||
window.getLastWindow(this.context).then((win: window.Window) => {
|
||||
win.setWindowSystemBarProperties({
|
||||
statusBarColor: bg,
|
||||
statusBarContentColor: fg,
|
||||
navigationBarColor: bg,
|
||||
});
|
||||
win.setWindowBackgroundColor(bg);
|
||||
}).catch(() => {
|
||||
// ignore
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
onWindowStageCreate(windowStage: window.WindowStage): void {
|
||||
const startUI = (): void => {
|
||||
windowStage.loadContent('pages/Index', () => {
|
||||
console.info('[HomeAgent] main page loaded');
|
||||
});
|
||||
};
|
||||
|
||||
// 沉浸式窗口 + 主题化系统栏,消除真机顶部/底部白边
|
||||
windowStage.getMainWindow((err: BusinessError, win: window.Window) => {
|
||||
if (err.code !== 0) {
|
||||
connStore.init(this.context).then(async () => {
|
||||
await connStore.ensureDefaultConnection();
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.reapplyStoredTheme();
|
||||
startUI();
|
||||
}).catch(() => {
|
||||
startUI();
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 全屏沉浸:内容延伸到状态栏和导航栏区域
|
||||
win.setWindowLayoutFullScreen(true);
|
||||
} catch (e) {
|
||||
console.warn('[HomeAgent] setWindowLayoutFullScreen failed: ' + (e as Error).message);
|
||||
}
|
||||
connStore.init(this.context).then(async () => {
|
||||
await connStore.ensureDefaultConnection();
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
console.info('[HomeAgent] connStore initialized');
|
||||
// init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏
|
||||
this.reapplyStoredTheme();
|
||||
this.applySystemBar();
|
||||
startUI();
|
||||
}).catch((e: Error) => {
|
||||
console.error('[HomeAgent] connStore init failed: ' + e.message);
|
||||
startUI();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** After connStore.init, re-resolve themeIsDark from the persisted mode. */
|
||||
private reapplyStoredTheme(): void {
|
||||
try {
|
||||
applyThemeMode(storedThemeMode());
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
onWindowStageDestroy(): void {
|
||||
console.info('[HomeAgent] ability onWindowStageDestroy');
|
||||
}
|
||||
|
||||
onForeground(): void {
|
||||
console.info('[HomeAgent] ability onForeground');
|
||||
}
|
||||
|
||||
onBackground(): void {
|
||||
console.info('[HomeAgent] ability onBackground');
|
||||
}
|
||||
}
|
||||
175
cmd/ohos/HomeAgent/entry/src/main/ets/model/Model.ets
Normal file
@ -0,0 +1,175 @@
|
||||
export interface ConnectionConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
apiKey: string;
|
||||
type: string; // 'webui' | 'cli'
|
||||
}
|
||||
|
||||
export interface ToolCallInfo {
|
||||
name: string;
|
||||
args: string;
|
||||
result?: string;
|
||||
plugin?: string;
|
||||
status?: string; // 'ok' | 'error' | 'denied' | 'running'
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number;
|
||||
role: string; // 'user' | 'assistant'
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
reasoningOpen?: boolean;
|
||||
isStreaming?: boolean;
|
||||
isFinal?: boolean;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
/** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */
|
||||
source?: string;
|
||||
/** 图片/文件附件(后端 ChatMsg.attachment) */
|
||||
attachment?: ChatAttachment;
|
||||
}
|
||||
|
||||
export interface HistoryMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
source?: string;
|
||||
timestamp?: string;
|
||||
attachment?: ChatAttachment;
|
||||
}
|
||||
|
||||
export interface AgentStatus {
|
||||
version?: string;
|
||||
uptime_ms?: number;
|
||||
agent?: string;
|
||||
provider?: string;
|
||||
memory_events?: number;
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
online: boolean;
|
||||
authorized: boolean;
|
||||
caps: string[];
|
||||
hostname?: string;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
export interface PluginInfo {
|
||||
name: string;
|
||||
nameZh?: string;
|
||||
nameEn?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
deprecated?: boolean;
|
||||
builtin: boolean;
|
||||
loaded: boolean;
|
||||
disabled: boolean;
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the merged plugin list:
|
||||
* kernel.plugins ∪ installed(/plugins) ∪ disabled(/plugins/disabled),
|
||||
* 与 WebGUI renderPlugins 的合并口径一致。
|
||||
*/
|
||||
export interface PluginRow {
|
||||
name: string;
|
||||
/** /kernel.plugins 里出现且 loaded=true 才算已加载 */
|
||||
loaded: boolean;
|
||||
/** 出现在 /plugins/disabled 列表里 */
|
||||
disabled: boolean;
|
||||
/** 已安装的外部插件(/plugins 返回) */
|
||||
external: boolean;
|
||||
version?: string;
|
||||
description?: string;
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /plugins/{name} 返回的插件清单明细。
|
||||
* 字段与后端 pluginmgr 的 pluginInfo 一一对应;
|
||||
* WebGUI 只把它 JSON.stringify 到 <pre> 里,这里改为结构化展示。
|
||||
*/
|
||||
export interface PluginDetail {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: string;
|
||||
license: string;
|
||||
homepage: string;
|
||||
repository: string;
|
||||
entry: string;
|
||||
minVersion: string;
|
||||
tags: string[];
|
||||
deprecated: boolean;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export function emptyPluginDetail(): PluginDetail {
|
||||
const d: PluginDetail = {
|
||||
name: '',
|
||||
version: '',
|
||||
description: '',
|
||||
author: '',
|
||||
license: '',
|
||||
homepage: '',
|
||||
repository: '',
|
||||
entry: '',
|
||||
minVersion: '',
|
||||
tags: [],
|
||||
deprecated: false,
|
||||
files: [],
|
||||
};
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息附件。字段与后端 webui 的 Attachment 严格一致,只有四个:
|
||||
* type / url / size / name —— 后端不提供 mime、尺寸、本地路径。
|
||||
*/
|
||||
export interface ChatAttachment {
|
||||
/** 'image' | 'file' */
|
||||
type: string;
|
||||
/** /files/<name>、/uploads/<name> 或远程 http(s) 地址 */
|
||||
url: string;
|
||||
/** 字节数;远程 URL 为 0 */
|
||||
size: number;
|
||||
/** 展示用文件名 */
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
lang: string; // 'zh' | 'en'
|
||||
theme: string; // 'dark' | 'light' | 'system'
|
||||
currentConnId: string;
|
||||
/** 自定义背景图路径(应用沙箱内文件路径),空串表示未设置 */
|
||||
bgImage: string;
|
||||
/** 背景图不透明度 0..1 */
|
||||
bgOpacity: number;
|
||||
}
|
||||
|
||||
export function emptySettings(): AppSettings {
|
||||
const s: AppSettings = {
|
||||
lang: 'zh',
|
||||
theme: 'dark',
|
||||
currentConnId: '',
|
||||
bgImage: '',
|
||||
bgOpacity: 0.25,
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
export function defaultConnection(): ConnectionConfig {
|
||||
const c: ConnectionConfig = {
|
||||
id: '',
|
||||
name: 'HomeAgent',
|
||||
url: 'http://192.168.2.60:8080',
|
||||
apiKey: 'jinrui233719',
|
||||
type: 'webui',
|
||||
};
|
||||
return c;
|
||||
}
|
||||
1923
cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets
Normal file
652
cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets
Normal file
@ -0,0 +1,652 @@
|
||||
import { deviceBridge } from '../common/DeviceBridge';
|
||||
import { installCmdRouter, registerScreensueHandler, setBridgeAppContext } from '../common/BridgeRouter';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { noConnectionMessage } from '../common/UserError';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { DeviceInfo } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
|
||||
// 本机声明的能力(与 BridgeRouter 支持的命令一一对应)
|
||||
const LOCAL_CAPS: string[] = [
|
||||
'status',
|
||||
'deviceinfo',
|
||||
'screensee',
|
||||
'screensue',
|
||||
'clipboardsee',
|
||||
'clipboardsue',
|
||||
'speakeruse',
|
||||
];
|
||||
|
||||
/** 二级页面标识 */
|
||||
const SUB_NONE: string = '';
|
||||
const SUB_LOCAL: string = 'local';
|
||||
const SUB_CAPS: string = 'caps';
|
||||
const SUB_GATEWAY: string = 'gateway';
|
||||
const SUB_LIST: string = 'list';
|
||||
|
||||
@Component
|
||||
export struct DevicePage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp('navVisible') private navVisible: boolean = true;
|
||||
@StorageProp('currentTab') private currentTab: number = 0;
|
||||
/** 宽屏:左边一级界面(含底部导航栏),右边二级界面 */
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
/** 当前右栏展示的二级页面 id,用于宽屏下高亮左侧入口行 */
|
||||
@State activeSub: string = SUB_NONE;
|
||||
@State bridgeConnected: boolean = false;
|
||||
@State bridgeUrl: string = '';
|
||||
@State bridgeToken: string = '';
|
||||
@State deviceId: string = '';
|
||||
@State authorized: boolean = false;
|
||||
@State devices: DeviceInfo[] = [];
|
||||
@State loadingDevices: boolean = false;
|
||||
@State lastError: string = '';
|
||||
@State toastMsg: string = '';
|
||||
@State toastIsError: boolean = false;
|
||||
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
|
||||
private navStack: NavPathStack = new NavPathStack();
|
||||
private autoConnectTried: boolean = false;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.deviceId = deviceBridge.getDeviceId();
|
||||
if (this.deviceId.length === 0) {
|
||||
this.deviceId = connStore.getDeviceId();
|
||||
}
|
||||
if (this.deviceId.length === 0) {
|
||||
this.deviceId = 'ohos-' + Date.now().toString(36);
|
||||
try {
|
||||
connStore.saveDeviceId(this.deviceId);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this.authorized = connStore.getDeviceAuth();
|
||||
// Gateway URL derives from current connection
|
||||
const cur = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
this.bridgeUrl = this.gatewayUrlOf(cur.url);
|
||||
this.bridgeToken = cur.apiKey;
|
||||
}
|
||||
installCmdRouter();
|
||||
try {
|
||||
setBridgeAppContext(getContext(this) as common.UIAbilityContext);
|
||||
} catch (e) {
|
||||
// ignore context errors
|
||||
}
|
||||
deviceBridge.setStateListener((open: boolean) => {
|
||||
this.bridgeConnected = open;
|
||||
if (open) {
|
||||
this.lastError = '';
|
||||
this.showToast('设备网关已连接', false);
|
||||
this.refreshDevices();
|
||||
}
|
||||
});
|
||||
this.refreshDevices();
|
||||
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
|
||||
registerNavStack(2, this.navStack, () => {
|
||||
this.activeSub = SUB_NONE;
|
||||
});
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
unregisterNavStack(2);
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把后端 HTTP 地址转成设备桥的 WebSocket 地址。
|
||||
*
|
||||
* 关键:@ohos.net.webSocket 只接受 ws:// / wss:// 协议头,
|
||||
* 直接把 http:// 传进 connect() 会在 native 层报
|
||||
* "protocol failed" + "ParseUrl failed"(NETSTACK websocket_exec.cpp),
|
||||
* 表现为设备通道永远连不上。所以这里必须做协议替换。
|
||||
*/
|
||||
private gatewayUrlOf(base: string): string {
|
||||
let trimmed: string = base.trim();
|
||||
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
|
||||
trimmed = trimmed.substring(0, trimmed.length - 1);
|
||||
}
|
||||
let scheme: string = 'ws://';
|
||||
let rest: string = trimmed;
|
||||
if (trimmed.startsWith('https://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('https://'.length);
|
||||
} else if (trimmed.startsWith('http://')) {
|
||||
scheme = 'ws://';
|
||||
rest = trimmed.substring('http://'.length);
|
||||
} else if (trimmed.startsWith('wss://')) {
|
||||
scheme = 'wss://';
|
||||
rest = trimmed.substring('wss://'.length);
|
||||
} else if (trimmed.startsWith('ws://')) {
|
||||
scheme = 'ws://';
|
||||
rest = trimmed.substring('ws://'.length);
|
||||
}
|
||||
return scheme + rest + '/api/v1/device/ws';
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开二级页面。
|
||||
*
|
||||
* 窄屏(Stack 模式):push 一层,整屏覆盖,系统侧滑返回可退。
|
||||
* 宽屏(Split 模式):左栏一级列表常驻,右栏只应有一页,
|
||||
* 所以用 replace 换页而不是叠栈 —— 否则返回手势要一层层退回去。
|
||||
*/
|
||||
private openSub(id: string): void {
|
||||
this.activeSub = id;
|
||||
if (this.isWide && this.navStack.size() > 0) {
|
||||
this.navStack.replacePathByName(id, subPageParam(id), false);
|
||||
} else {
|
||||
this.navStack.pushPathByName(id, subPageParam(id), true);
|
||||
}
|
||||
}
|
||||
|
||||
/** 二级页面返回键:宽屏下左栏常驻可见,SubPageLayer 已隐藏返回键 */
|
||||
private closeSub(): void {
|
||||
this.navStack.pop();
|
||||
this.activeSub = SUB_NONE;
|
||||
}
|
||||
|
||||
/** Toggle local authorization flag; hello 同步到网关。 */
|
||||
private toggleAuthorized(on: boolean): void {
|
||||
this.authorized = on;
|
||||
try {
|
||||
connStore.saveDeviceAuth(on);
|
||||
} catch (e) {
|
||||
// ignore persist failure
|
||||
}
|
||||
deviceBridge.updateAuthorized(on);
|
||||
if (!this.bridgeConnected) {
|
||||
this.connectBridge();
|
||||
}
|
||||
this.showToast(on ? '已授权,agent 可下发能力命令' : '已取消授权', false);
|
||||
}
|
||||
|
||||
// ===================== gateway connection =====================
|
||||
|
||||
private async connectBridge(): Promise<void> {
|
||||
if (this.bridgeUrl.length === 0 || this.bridgeToken.length === 0) {
|
||||
this.lastError = noConnectionMessage();
|
||||
return;
|
||||
}
|
||||
this.lastError = '';
|
||||
if (this.deviceId.length === 0) {
|
||||
this.deviceId = 'ohos-' + Date.now().toString(36);
|
||||
}
|
||||
try {
|
||||
connStore.saveDeviceId(this.deviceId);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
const name: string = 'HomeAgent OHOS';
|
||||
await deviceBridge.connect(
|
||||
this.bridgeUrl, this.bridgeToken, this.deviceId,
|
||||
LOCAL_CAPS, 'ohos-phone', this.authorized, name);
|
||||
}
|
||||
|
||||
/** 首次进入自动尝试连接(静默,失败不打扰)。 */
|
||||
private maybeAutoConnect(): void {
|
||||
if (this.autoConnectTried || this.bridgeConnected) {
|
||||
return;
|
||||
}
|
||||
this.autoConnectTried = true;
|
||||
if (this.bridgeUrl.length > 0 && this.bridgeToken.length > 0) {
|
||||
this.connectBridge();
|
||||
}
|
||||
}
|
||||
|
||||
private showToast(msg: string, isError: boolean): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastIsError = isError;
|
||||
setTimeout(() => {
|
||||
this.toastMsg = '';
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
/** 拉取网关在线设备列表(经 webui 反代)。 */
|
||||
private async refreshDevices(): Promise<void> {
|
||||
this.loadingDevices = true;
|
||||
try {
|
||||
// apiClient 已自动前置 /api/v1,这里只写其后的部分,
|
||||
// 否则会拼成 /api/v1/api/v1/device/online 并 404。
|
||||
const resp = await apiClient.get('/device/online');
|
||||
if (resp.status >= 200 && resp.status < 300) {
|
||||
const parsed: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const devs: Object = parsed['devices'];
|
||||
const list: DeviceInfo[] = [];
|
||||
if (devs !== undefined && devs !== null) {
|
||||
const arr: Object[] = devs as Object[];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const d: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const capsArr: Object = d['caps'];
|
||||
const caps: string[] = [];
|
||||
if (capsArr !== undefined && capsArr !== null) {
|
||||
const cArr: Object[] = capsArr as Object[];
|
||||
for (let j = 0; j < cArr.length; j++) {
|
||||
caps.push(cArr[j] as string);
|
||||
}
|
||||
}
|
||||
const info: DeviceInfo = {
|
||||
deviceId: d['device_id'] as string ?? '',
|
||||
name: d['name'] as string ?? '',
|
||||
kind: d['kind'] as string ?? '',
|
||||
online: true,
|
||||
authorized: d['authorized'] as boolean ?? false,
|
||||
caps: caps,
|
||||
};
|
||||
list.push(info);
|
||||
}
|
||||
}
|
||||
this.devices = list;
|
||||
} else {
|
||||
this.devices = [];
|
||||
}
|
||||
} catch (e) {
|
||||
// 网络失败保持静默,不打扰用户
|
||||
}
|
||||
this.loadingDevices = false;
|
||||
}
|
||||
|
||||
build() {
|
||||
// Navigation 提供真实导航栈:系统侧滑返回 / 三键返回都会自动 pop
|
||||
Navigation(this.navStack) {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
this.RootEntries()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
.align(Alignment.Top)
|
||||
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
||||
handleNavOnScroll(state);
|
||||
})
|
||||
.onAppear(() => {
|
||||
this.maybeAutoConnect();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
PageTopBar({ title: '设备' })
|
||||
|
||||
// 一级悬浮区:只留刷新(网关状态徽标按用户要求去掉,
|
||||
// 状态已经在页面内的网关卡片里如实展示)
|
||||
NavFloatOverlay({ tab: 2 }) {
|
||||
NavFloatRow() {
|
||||
FloatIconButton({
|
||||
icon: $r('app.media.ic_refresh'),
|
||||
onTap: () => {
|
||||
this.refreshDevices();
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.Toast()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
.navDestination(this.SubDestination)
|
||||
// 宽屏(>=600vp)用 Split:navBar(一级列表 + 底部悬浮导航栏)常驻左栏,
|
||||
// NavDestination(二级页面)渲染在右栏,两栏同时可见。
|
||||
.mode(this.isWide ? NavigationMode.Split : NavigationMode.Stack)
|
||||
.navBarPosition(NavBarPosition.Start)
|
||||
.navBarWidth(WIDE_NAV_BAR_WIDTH)
|
||||
.minContentWidth(WIDE_MIN_CONTENT)
|
||||
.hideTitleBar(true)
|
||||
.hideToolBar(true)
|
||||
.hideBackButton(true)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
// Split 模式下 navBar 常驻,此回调不再触发;宽屏一律锁住主 Tab 横滑
|
||||
.onNavBarStateChange((isVisible: boolean) => {
|
||||
markSubPageOpen(this.isWide || !isVisible);
|
||||
})
|
||||
// 模式初始化与切换:Split 右栏不能空白,自动填入默认二级页面;
|
||||
// 退回 Stack 时清栈,否则会残留一个整屏覆盖的二级页面。
|
||||
.onNavigationModeChange((mode: NavigationMode) => {
|
||||
if (mode === NavigationMode.Split) {
|
||||
markSubPageOpen(true);
|
||||
if (this.navStack.size() === 0) {
|
||||
this.openSub(SUB_LOCAL);
|
||||
}
|
||||
} else {
|
||||
this.navStack.clear(false);
|
||||
this.activeSub = SUB_NONE;
|
||||
markSubPageOpen(false);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 二级页面路由表 */
|
||||
@Builder
|
||||
SubDestination(name: string, param: object) {
|
||||
NavDestination() {
|
||||
if (name === SUB_LOCAL) {
|
||||
SubPageLayer({
|
||||
title: '本机设备',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
}) {
|
||||
this.LocalDeviceContent()
|
||||
}
|
||||
} else if (name === SUB_CAPS) {
|
||||
SubPageLayer({
|
||||
title: '设备能力',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
}) {
|
||||
this.CapsContent()
|
||||
}
|
||||
} else if (name === SUB_GATEWAY) {
|
||||
SubPageLayer({
|
||||
title: '设备通道',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
}) {
|
||||
this.GatewayContent()
|
||||
}
|
||||
} else if (name === SUB_LIST) {
|
||||
SubPageLayer({
|
||||
title: '接入的设备',
|
||||
tab: 2,
|
||||
onBack: () => {
|
||||
this.closeSub();
|
||||
},
|
||||
showRefresh: true,
|
||||
onRefresh: () => {
|
||||
this.refreshDevices();
|
||||
},
|
||||
}) {
|
||||
this.OnlineDevicesContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
|
||||
|
||||
@Builder
|
||||
Toast() {
|
||||
if (this.toastMsg.length > 0) {
|
||||
Row() {
|
||||
Text(this.toastMsg)
|
||||
.fontSize(13)
|
||||
.fontColor(this.toastIsError ? this.palette().toastErrorText : this.palette().toastText)
|
||||
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.toastIsError ? this.palette().toastErrorBg : this.palette().toastBg)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.End)
|
||||
.padding({ right: 20 })
|
||||
.margin({ bottom: 166 })
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 一级入口列表 =====================
|
||||
|
||||
@Builder
|
||||
RootEntries() {
|
||||
NavGroup({ caption: '本机' }) {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_phone'),
|
||||
title: '本机设备',
|
||||
subtitle: this.deviceId.length > 0 ? this.deviceId : '未注册',
|
||||
value: this.bridgeConnected ? '在线' : '离线',
|
||||
selected: this.isWide && this.activeSub === SUB_LOCAL,
|
||||
onTap: () => {
|
||||
this.openSub(SUB_LOCAL);
|
||||
},
|
||||
})
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_bolt'),
|
||||
title: '设备能力',
|
||||
subtitle: this.capsCount().toString() + ' 项能力',
|
||||
value: '',
|
||||
showDivider: false,
|
||||
selected: this.isWide && this.activeSub === SUB_CAPS,
|
||||
onTap: () => {
|
||||
this.openSub(SUB_CAPS);
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
NavGroup({ caption: '通道' }) {
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_gateway'),
|
||||
title: '设备通道',
|
||||
subtitle: this.bridgeUrl.length > 0 ? '网关已配置' : '未配置',
|
||||
value: this.bridgeConnected ? '已连接' : '未连接',
|
||||
selected: this.isWide && this.activeSub === SUB_GATEWAY,
|
||||
onTap: () => {
|
||||
this.openSub(SUB_GATEWAY);
|
||||
},
|
||||
})
|
||||
NavRow({
|
||||
icon: $r('app.media.ic_devices_multi'),
|
||||
title: '接入的设备',
|
||||
subtitle: this.loadingDevices ? '加载中...' : '当前在线',
|
||||
value: this.devices.length.toString() + ' 台',
|
||||
showDivider: false,
|
||||
selected: this.isWide && this.activeSub === SUB_LIST,
|
||||
onTap: () => {
|
||||
this.openSub(SUB_LIST);
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private capsCount(): number {
|
||||
return LOCAL_CAPS.length;
|
||||
}
|
||||
|
||||
// ===================== 二级:本机设备 =====================
|
||||
|
||||
@Builder
|
||||
LocalDeviceContent() {
|
||||
PlainCard({ caption: '基本信息' }) {
|
||||
this.KvRow('设备 ID', this.deviceId.length > 0 ? this.deviceId : '未注册')
|
||||
this.KvRow('名称', 'HomeAgent OHOS')
|
||||
this.KvRow('类型', 'phone')
|
||||
}
|
||||
|
||||
PlainCard({ caption: '权限控制' }) {
|
||||
Row() {
|
||||
Column({ space: 2 }) {
|
||||
Text('允许 agent 控制本机')
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text('授权后 agent 可调用下方能力;截屏仅捕获本应用画面,剪贴板读取需系统弹窗确认。')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 4 })
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Toggle({ type: ToggleType.Switch, isOn: this.authorized })
|
||||
.selectedColor(this.palette().accent)
|
||||
.onChange((on: boolean) => {
|
||||
this.toggleAuthorized(on);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(this.authorized ? '#17A964' : '#E84026')
|
||||
.margin({ right: 8 })
|
||||
Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令')
|
||||
.fontSize(12)
|
||||
.fontColor(this.authorized ? '#17A964' : this.palette().textMuted)
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 12 })
|
||||
.padding({ left: 4 })
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 二级:设备能力 =====================
|
||||
|
||||
@Builder
|
||||
CapsContent() {
|
||||
PlainCard({ caption: '能力清单' }) {
|
||||
Text('agent 通过设备桥可调用的本机能力:')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(LOCAL_CAPS, (cap: string) => {
|
||||
Text(cap)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().accent)
|
||||
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
|
||||
.borderRadius(999)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.margin({ right: 6, bottom: 6 })
|
||||
}, (cap: string) => cap)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 二级:设备通道 =====================
|
||||
|
||||
@Builder
|
||||
GatewayContent() {
|
||||
PlainCard({ caption: '连接信息' }) {
|
||||
this.KvRow('网关地址', this.bridgeUrl.length > 0 ? this.bridgeUrl : '-')
|
||||
this.KvRow('Token', this.bridgeToken.length > 0 ? '已从连接继承' : '未配置')
|
||||
this.KvRow('状态', this.bridgeConnected ? '已连接' : '未连接')
|
||||
}
|
||||
|
||||
if (this.lastError.length > 0) {
|
||||
Text(this.lastError)
|
||||
.fontSize(12)
|
||||
.fontColor('#E84026')
|
||||
.padding({ left: 4 })
|
||||
}
|
||||
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Row() {
|
||||
Button(this.bridgeConnected ? '断开' : '连接网关')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
|
||||
.fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
|
||||
.border({
|
||||
width: this.bridgeConnected ? 1 : 0,
|
||||
color: this.palette().btnGhostBorder,
|
||||
})
|
||||
.onClick(() => {
|
||||
if (this.bridgeConnected) {
|
||||
deviceBridge.disconnect();
|
||||
} else {
|
||||
this.connectBridge();
|
||||
}
|
||||
})
|
||||
Blank()
|
||||
Button('刷新设备')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: this.palette().btnGhostBorder })
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.onClick(() => {
|
||||
this.refreshDevices();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Text('进入本页自动连接;断开后每 5 秒自动重连。hello 登记能力与授权状态,bind 携带 Token 完成身份绑定。')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.margin({ top: 10 })
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 二级:接入的设备 =====================
|
||||
|
||||
@Builder
|
||||
OnlineDevicesContent() {
|
||||
if (this.devices.length === 0) {
|
||||
Text('暂无其他设备。电脑 GUI 或 CLI 连接同一网关后会出现在这里。')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding({ left: 4 })
|
||||
}
|
||||
|
||||
ForEach(this.devices, (dev: DeviceInfo) => {
|
||||
PlainCard({ caption: '' }) {
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(dev.online ? '#17A964' : '#77809A')
|
||||
.margin({ right: 10 })
|
||||
Column() {
|
||||
Text(dev.name.length > 0 ? dev.name : dev.deviceId)
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(dev.kind + (dev.authorized ? ' · 已授权' : ' · 未授权'))
|
||||
.fontSize(11)
|
||||
.fontColor(dev.authorized ? '#17A964' : this.palette().textMuted)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Text(dev.caps.length.toString() + ' 能力')
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}, (dev: DeviceInfo) => dev.deviceId + dev.online.toString())
|
||||
}
|
||||
|
||||
// ===================== 通用 KV 行 =====================
|
||||
|
||||
@Builder
|
||||
KvRow(k: string, v: string) {
|
||||
Row() {
|
||||
Text(k)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
Blank()
|
||||
Text(v)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
.border({ width: { bottom: 1 }, color: this.palette().kvBorder })
|
||||
}
|
||||
}
|
||||
|
||||
const RADIUS_MD: number = 10;
|
||||
388
cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets
Normal file
@ -0,0 +1,388 @@
|
||||
import { ChatPage } from './ChatPage';
|
||||
import { PluginsPage } from './PluginsPage';
|
||||
import { DevicePage } from './DevicePage';
|
||||
import { SettingsPage } from './SettingsPage';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { navBar } from '../common/NavBarController';
|
||||
import { handleBackPress } from '../common/NavStackRegistry';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH } from '../common/Constants';
|
||||
import { GradientBackground } from '../components/GradientBackground';
|
||||
import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter';
|
||||
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
|
||||
import { window, display } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
interface TabItem {
|
||||
icon: Resource;
|
||||
label: string;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部导航单项:矢量图标 + 文字。
|
||||
* active 必须 @Prop 才能在父组件 currentTab 变化时刷新高亮。
|
||||
*/
|
||||
@Component
|
||||
export struct RailTab {
|
||||
icon: Resource = $r('app.media.ic_tab_chat');
|
||||
@Prop label: string = '';
|
||||
@Prop active: boolean = false;
|
||||
@Prop accent: string = '';
|
||||
@Prop muted: string = '';
|
||||
|
||||
build() {
|
||||
Column({ space: 3 }) {
|
||||
Image(this.icon)
|
||||
.width(22)
|
||||
.height(22)
|
||||
.fillColor(this.active ? this.accent : this.muted)
|
||||
.draggable(false)
|
||||
|
||||
Text(this.label)
|
||||
.fontSize(10)
|
||||
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(this.active ? this.accent : this.muted)
|
||||
.maxLines(1)
|
||||
}
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.animation({ duration: 200, curve: Curve.EaseOut })
|
||||
}
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct Index {
|
||||
@Watch('onTabChanged') @State currentTab: number = 0;
|
||||
@StorageProp('navVisible') private navVisible: boolean = true;
|
||||
/** 二级页面打开时锁住 Swiper 横滑,避免误切换主 Tab */
|
||||
@StorageProp('subPageOpen') private subPageOpen: boolean = false;
|
||||
/** 宽屏(>=600vp):一级界面在左、二级界面在右,导航栏跟随左侧一级界面 */
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
@State screensueVisible: boolean = false;
|
||||
@State screensueText: string = '';
|
||||
@State screensueCountdown: number = 0;
|
||||
/** 状态栏避让高度(vp),从窗口 avoid area 动态获取;默认 44 兜底 */
|
||||
@State topInset: number = 44;
|
||||
/** 底部手势条高度(vp) */
|
||||
@State bottomGesture: number = 16;
|
||||
@StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true;
|
||||
private swiper: SwiperController = new SwiperController();
|
||||
private screensueTimer: number = -1;
|
||||
private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点
|
||||
|
||||
// 「状态」Tab 已删除:运行状态并入设置页(顶部摘要卡 + 二级明细页),
|
||||
// 底部导航只保留四项,胶囊内每项更宽、点按更好命中。
|
||||
private tabs: TabItem[] = [
|
||||
{ icon: $r('app.media.ic_tab_chat'), label: '聊天', pageId: 'chat' },
|
||||
{ icon: $r('app.media.ic_tab_plugins'), label: '插件', pageId: 'plugins' },
|
||||
{ icon: $r('app.media.ic_tab_devices'), label: '设备', pageId: 'devices' },
|
||||
{ icon: $r('app.media.ic_tab_settings'), label: '设置', pageId: 'settings' },
|
||||
];
|
||||
|
||||
aboutToAppear(): void {
|
||||
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
// 种子化自定义背景图状态到 AppStorage,GradientBackground 响应读取
|
||||
const st = connStore.getSettings();
|
||||
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
|
||||
AppStorage.setOrCreate<number>('bgOpacity', st.bgOpacity ?? 0.25);
|
||||
AppStorage.setOrCreate<boolean>('navVisible', true);
|
||||
AppStorage.setOrCreate<boolean>('subPageOpen', false);
|
||||
AppStorage.setOrCreate<boolean>('isWideScreen', false);
|
||||
// 启动时按当前主题刷新一次系统栏(EntryAbility 已设过,这里兜底对齐)
|
||||
this.syncSystemBar();
|
||||
// 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44)
|
||||
this.resolveSafeArea();
|
||||
// 设备桥:注册命令路由与 screensue 悬浮层回调
|
||||
installCmdRouter();
|
||||
registerScreensueHandler((payload: ScreensuePayload) => {
|
||||
this.showScreensue(payload);
|
||||
});
|
||||
}
|
||||
|
||||
/** agent 下发的 screensue 内容展示(悬浮卡片,倒计时自动关闭;0=常驻)。 */
|
||||
private showScreensue(payload: ScreensuePayload): void {
|
||||
this.screensueText = payload.content;
|
||||
this.screensueCountdown = payload.duration;
|
||||
this.screensueVisible = true;
|
||||
if (this.screensueTimer >= 0) {
|
||||
clearInterval(this.screensueTimer);
|
||||
this.screensueTimer = -1;
|
||||
}
|
||||
if (payload.duration > 0) {
|
||||
this.screensueTimer = setInterval(() => {
|
||||
if (this.screensueCountdown <= 1) {
|
||||
clearInterval(this.screensueTimer);
|
||||
this.screensueTimer = -1;
|
||||
this.screensueVisible = false;
|
||||
} else {
|
||||
this.screensueCountdown = this.screensueCountdown - 1;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private closeScreensue(): void {
|
||||
if (this.screensueTimer >= 0) {
|
||||
clearInterval(this.screensueTimer);
|
||||
this.screensueTimer = -1;
|
||||
}
|
||||
this.screensueVisible = false;
|
||||
}
|
||||
|
||||
/** 主题翻转时同步系统栏前景/背景色,避免浅色页面配黑状态栏。 */
|
||||
private onThemeChanged(): void {
|
||||
this.syncSystemBar();
|
||||
}
|
||||
|
||||
/** 通过窗口 avoid area 计算状态栏与底部手势区高度(vp),失败时保留默认值。 */
|
||||
private resolveSafeArea(): void {
|
||||
try {
|
||||
const ctx = getContext(this) as common.UIAbilityContext;
|
||||
window.getLastWindow(ctx).then((win: window.Window) => {
|
||||
const prop = win.getWindowProperties();
|
||||
const isLayoutFull = prop.isLayoutFullScreen === true;
|
||||
if (!isLayoutFull) {
|
||||
return; // 非全屏模式下系统自动避让
|
||||
}
|
||||
const density = display.getDefaultDisplaySync().densityPixels > 0
|
||||
? display.getDefaultDisplaySync().densityPixels : 3;
|
||||
const topArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
|
||||
const bottomArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
|
||||
if (topArea.topRect.height > 0) {
|
||||
this.topInset = Math.ceil(topArea.topRect.height / density);
|
||||
}
|
||||
// 底部手势条:有值时取其高度的一半作为悬浮导航的下边距基准,最小 8 最大 24
|
||||
if (bottomArea.bottomRect.height > 0) {
|
||||
const h: number = Math.ceil(bottomArea.bottomRect.height / density);
|
||||
this.bottomGesture = Math.min(24, Math.max(8, h));
|
||||
} else {
|
||||
this.bottomGesture = 16;
|
||||
}
|
||||
}).catch(() => {
|
||||
// ignore, keep defaults
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
AppStorage.set<number>('currentTab', 0);
|
||||
}
|
||||
|
||||
private onTabChanged(): void {
|
||||
AppStorage.set<number>('currentTab', this.currentTab);
|
||||
}
|
||||
|
||||
private syncSystemBar(): void {
|
||||
const dark: boolean = this.isDark;
|
||||
const bg: string = dark ? '#000000' : '#F1F3F5';
|
||||
const fg: string = dark ? '#FFFFFF' : '#191919';
|
||||
try {
|
||||
window.getLastWindow(getContext(this) as common.UIAbilityContext).then((win: window.Window) => {
|
||||
win.setWindowSystemBarProperties({
|
||||
statusBarColor: bg,
|
||||
statusBarContentColor: fg,
|
||||
navigationBarColor: bg,
|
||||
});
|
||||
win.setWindowBackgroundColor(bg);
|
||||
}).catch(() => {
|
||||
// ignore
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
// 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/** 宽屏判定:窗口宽度 >= 600vp(与 Navigation Auto 模式的分栏阈值一致) */
|
||||
private updateWideScreen(w: number): void {
|
||||
const wide: boolean = w >= WIDE_MIN_WIDTH;
|
||||
if (wide !== this.isWide) {
|
||||
AppStorage.setOrCreate<boolean>('isWideScreen', wide);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的返回处理:把返回事件精确派发给【当前 Tab】的那个 Navigation 栈。
|
||||
*
|
||||
* 四个页面各自持有 Navigation(宽屏需要"左一级右二级"),它们在 Swiper 里
|
||||
* 同时存活,系统返回落到哪个栈并不确定 —— 这就是"有的页面无法使用返回手势"
|
||||
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
|
||||
*/
|
||||
onBackPress(): boolean {
|
||||
return handleBackPress(this.currentTab, this.isWide);
|
||||
}
|
||||
|
||||
build() {
|
||||
// alignContent Bottom:悬浮导航停靠底部,而不是被 Stack 居中。
|
||||
// 导航胶囊用左右 6% padding 保持"宽度占 88% 居中"的原样视觉;
|
||||
// 宽屏下胶囊自然被限制在左侧一级界面栏内,不会横跨二级界面。
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
// 渐变氛围背景
|
||||
GradientBackground()
|
||||
|
||||
// 页面内容:Swiper 支持左右滑动切换;$$ 双向同步索引
|
||||
Swiper(this.swiper) {
|
||||
ChatPage()
|
||||
PluginsPage()
|
||||
DevicePage()
|
||||
SettingsPage()
|
||||
}
|
||||
.index(this.currentTab)
|
||||
.indicator(false)
|
||||
.loop(false)
|
||||
.duration(300)
|
||||
.curve(Curve.FastOutSlowIn)
|
||||
.vertical(false)
|
||||
.cachedCount(2)
|
||||
.disableSwipe(this.subPageOpen)
|
||||
.onChange((idx: number) => {
|
||||
this.currentTab = idx;
|
||||
navBar.setVisible(true);
|
||||
})
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
// top 让出状态栏;bottom:所有页面统一为0,导航栏浮在内容之上
|
||||
.padding({
|
||||
top: this.topInset,
|
||||
bottom: 0
|
||||
})
|
||||
|
||||
// 悬浮气态玻璃底部导航(三层:半透明底 -> 高光渐变 -> 内容)
|
||||
// 外层 Row 负责水平定位:窄屏居中;宽屏靠左,只落在一级界面栏范围内。
|
||||
Row() {
|
||||
Stack() {
|
||||
// 层1:玻璃底 —— 半透明背景(无 backdropBlur 以避免矩形模糊伪影)
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
|
||||
// 层2:玻璃高光渐变,模拟光从上方照射的质感
|
||||
Row()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.linearGradient({
|
||||
direction: GradientDirection.Top,
|
||||
colors: [
|
||||
[this.palette().navBarGradientStart, 0.0],
|
||||
[this.palette().navBarGradientEnd, 1.0],
|
||||
],
|
||||
})
|
||||
|
||||
// 层3:导航项
|
||||
Row({ space: 2 }) {
|
||||
ForEach(this.tabs, (tab: TabItem, index: number) => {
|
||||
RailTab({
|
||||
icon: tab.icon,
|
||||
label: tab.label,
|
||||
active: this.currentTab === index,
|
||||
accent: this.palette().accent,
|
||||
muted: this.palette().textMuted,
|
||||
})
|
||||
.layoutWeight(1)
|
||||
.onClick(() => {
|
||||
this.currentTab = index;
|
||||
navBar.setVisible(true);
|
||||
})
|
||||
}, (item: TabItem, index: number) => item.pageId + index.toString())
|
||||
}
|
||||
.width('100%')
|
||||
.height(62)
|
||||
.padding({ left: 14, right: 14 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
// 宽屏:胶囊宽度锁在左侧一级界面栏内(左右各留 24vp 余量)
|
||||
.width(this.isWide ? WIDE_NAV_BAR_WIDTH - 48 : '88%')
|
||||
.height(62)
|
||||
.borderRadius(28)
|
||||
.clip(true)
|
||||
.border({
|
||||
width: { left: 1, top: 1, right: 1, bottom: 1 },
|
||||
color: this.palette().navBarBorder,
|
||||
})
|
||||
.shadow({
|
||||
radius: 24,
|
||||
color: this.palette().shadow,
|
||||
offsetY: 8,
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height(62)
|
||||
.padding({ left: this.isWide ? 24 : 0 })
|
||||
.justifyContent(this.isWide ? FlexAlign.Start : FlexAlign.Center)
|
||||
.margin({ bottom: this.bottomGesture + 6 })
|
||||
// 所有页面滑动时隐藏导航栏
|
||||
.translate({ y: !this.navVisible ? 130 : 0 })
|
||||
.opacity(!this.navVisible ? 0 : 1)
|
||||
.animation({ duration: 400, curve: Curve.EaseOut })
|
||||
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
|
||||
// screensue 悬浮层:agent 推送给用户看的内容(置顶展示)
|
||||
if (this.screensueVisible) {
|
||||
Column() {
|
||||
Row() {
|
||||
Circle({ width: 8, height: 8 })
|
||||
.fill(this.palette().accent)
|
||||
Text('agent 推送')
|
||||
.fontSize(12)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.margin({ left: 8 })
|
||||
Blank()
|
||||
if (this.screensueCountdown > 0) {
|
||||
Text(this.screensueCountdown.toString() + 's')
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
Text('关闭')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().accent)
|
||||
.padding({ left: 10, right: 2, top: 4, bottom: 4 })
|
||||
.onClick(() => {
|
||||
this.closeScreensue();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
Scroll() {
|
||||
Text(this.screensueText)
|
||||
.fontSize(15)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.width('100%')
|
||||
}
|
||||
.constraintSize({ maxHeight: 320 })
|
||||
.scrollBar(BarState.Auto)
|
||||
.align(Alignment.Top)
|
||||
}
|
||||
.width('86%')
|
||||
.padding(18)
|
||||
.borderRadius(18)
|
||||
.backgroundColor(this.palette().bgCard)
|
||||
|
||||
.border({ width: 1, color: this.palette().glassBorder })
|
||||
.shadow({ radius: 32, color: this.palette().shadow, offsetY: 10 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.id(snapshotComponentId())
|
||||
// 实时跟随窗口宽度:平板旋转、折叠展开、分屏、自由窗口拖拽都会回调,
|
||||
// 比启动时查一次 display 更可靠(不会漏掉运行中的尺寸变化)。
|
||||
.onAreaChange((oldValue: Area, newValue: Area) => {
|
||||
this.updateWideScreen(newValue.width as number);
|
||||
})
|
||||
}
|
||||
}
|
||||
941
cmd/ohos/HomeAgent/entry/src/main/ets/pages/PluginsPage.ets
Normal file
@ -0,0 +1,941 @@
|
||||
import { apiClient } from '../common/ApiClient';
|
||||
import { userMessage, noConnectionMessage } from '../common/UserError';
|
||||
import { handleNavOnScroll } from '../common/NavBarController';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
||||
import { RADIUS_LG, RADIUS_MD, RADIUS_SM, RADIUS_PILL, COLOR_ERROR } from '../common/Constants';
|
||||
import { PageTopBar, NavFloatOverlay, NavFloatRow, GlassShell, FloatIconButton } from '../components/PageTopBar';
|
||||
import { SubPageLayer, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { SettingsEditor } from '../components/SettingsEditor';
|
||||
|
||||
interface InstallBody {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** 二级页面标识:插件详情 */
|
||||
const SUB_NONE: string = '';
|
||||
const SUB_DETAIL: string = 'detail';
|
||||
|
||||
@Component
|
||||
export struct PluginsPage {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@StorageProp('navVisible') private navVisible: boolean = true;
|
||||
@StorageProp('currentTab') private currentTab: number = 0;
|
||||
/** 宽屏:左边插件列表(含底部导航栏),右边插件详情 */
|
||||
@StorageProp('isWideScreen') private isWide: boolean = false;
|
||||
@State plugins: PluginRow[] = [];
|
||||
@State loading: boolean = false;
|
||||
@State showInstallForm: boolean = false;
|
||||
@State installUrl: string = '';
|
||||
@State toastMsg: string = '';
|
||||
@State toastIsError: boolean = false;
|
||||
/** 当前查看详情的插件名,用于宽屏下高亮左侧列表项 */
|
||||
@State activeName: string = '';
|
||||
/** 详情页数据(GET /plugins/{name}) */
|
||||
@State detail: PluginDetail = emptyPluginDetail();
|
||||
@State detailLoading: boolean = false;
|
||||
@State detailError: string = '';
|
||||
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
|
||||
private navStack: NavPathStack = new NavPathStack();
|
||||
|
||||
aboutToAppear(): void {
|
||||
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
|
||||
registerNavStack(1, this.navStack, () => {
|
||||
this.activeName = '';
|
||||
});
|
||||
this.loadPlugins();
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
unregisterNavStack(1);
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
// 引用 this.isDark 建立响应式依赖:主题切换时整个组件树重渲染
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源对齐 WebGUI renderPlugins:
|
||||
* - GET /kernel → plugins[{name,loaded}](含全部内置插件)+ tools(按 plugin 归属)
|
||||
* - GET /plugins → 已安装外部插件元数据(version/description 等)
|
||||
* - GET /plugins/disabled → {disabled:[{name,...}]}
|
||||
* 三方按名称合并去重排序。
|
||||
*/
|
||||
private async loadPlugins(): Promise<void> {
|
||||
if (!apiClient.hasConnection()) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
// ---- kernel: loaded plugins + tool ownership ----
|
||||
const kResp = await apiClient.getWithTimeout('/kernel', 12000);
|
||||
const kernelObj: Record<string, Object> = JSON.parse(kResp.body) as Record<string, Object>;
|
||||
const loadedMap: Map<string, boolean> = new Map<string, boolean>();
|
||||
const kpRaw: Object | undefined = kernelObj['plugins'];
|
||||
if (kpRaw !== undefined && kpRaw !== null) {
|
||||
const kpArr: Object[] = kpRaw as Object[];
|
||||
for (let i = 0; i < kpArr.length; i++) {
|
||||
const item: Record<string, Object> = kpArr[i] as Record<string, Object>;
|
||||
const n: string = item['name'] as string ?? '';
|
||||
if (n.length === 0) {
|
||||
continue;
|
||||
}
|
||||
loadedMap.set(n, item['loaded'] as boolean ?? true);
|
||||
}
|
||||
}
|
||||
const toolsByPlugin: Map<string, string[]> = new Map<string, string[]>();
|
||||
const tRaw: Object | undefined = kernelObj['tools'];
|
||||
if (tRaw !== undefined && tRaw !== null) {
|
||||
const tArr: Object[] = tRaw as Object[];
|
||||
for (let i = 0; i < tArr.length; i++) {
|
||||
const item: Record<string, Object> = tArr[i] as Record<string, Object>;
|
||||
const tn: string = item['name'] as string ?? '';
|
||||
const owner: string = item['plugin'] as string ?? '';
|
||||
if (tn.length === 0 || owner.length === 0) {
|
||||
continue;
|
||||
}
|
||||
let list: string[] | undefined = toolsByPlugin.get(owner);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
toolsByPlugin.set(owner, list);
|
||||
}
|
||||
// 每插件最多展示 8 个工具名,避免卡片过长
|
||||
if (list.length < 8) {
|
||||
list.push(tn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- installed external plugins metadata ----
|
||||
const externalMeta: Map<string, Record<string, Object>> = new Map<string, Record<string, Object>>();
|
||||
try {
|
||||
const pResp = await apiClient.getWithTimeout('/plugins', 10000);
|
||||
const bodyTrim = pResp.body.trim();
|
||||
let arr: Object[] = [];
|
||||
if (bodyTrim.length > 0 && bodyTrim.charAt(0) === '[') {
|
||||
arr = JSON.parse(pResp.body) as Object[];
|
||||
} else {
|
||||
const obj: Record<string, Object> = JSON.parse(pResp.body) as Record<string, Object>;
|
||||
const rawList: Object = obj['plugins'] ?? obj['data'];
|
||||
if (rawList !== undefined && rawList !== null) {
|
||||
arr = rawList as Object[];
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const item: Record<string, Object> = arr[i] as Record<string, Object>;
|
||||
const n: string = item['name'] as string ?? '';
|
||||
if (n.length > 0) {
|
||||
externalMeta.set(n, item);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 外部列表失败不阻塞内置展示
|
||||
}
|
||||
|
||||
// ---- disabled list ----
|
||||
const disabledNames: Set<string> = new Set<string>();
|
||||
try {
|
||||
const dResp = await apiClient.getWithTimeout('/plugins/disabled', 8000);
|
||||
const dObj: Record<string, Object> = JSON.parse(dResp.body) as Record<string, Object>;
|
||||
const dArr: Object | undefined = dObj['disabled'];
|
||||
if (dArr !== undefined && dArr !== null) {
|
||||
const items: Object[] = dArr as Object[];
|
||||
for (let di = 0; di < items.length; di++) {
|
||||
const dItem: Record<string, Object> = items[di] as Record<string, Object>;
|
||||
const dn: string = dItem['name'] as string ?? '';
|
||||
if (dn.length > 0) {
|
||||
disabledNames.add(dn);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// disabled endpoint may not exist; ignore
|
||||
}
|
||||
|
||||
// ---- merge: allNames sorted(与 GUI 一致)----
|
||||
const allNames: Set<string> = new Set<string>();
|
||||
loadedMap.forEach((v: boolean, k: string) => {
|
||||
allNames.add(k);
|
||||
});
|
||||
externalMeta.forEach((v: Record<string, Object>, k: string) => {
|
||||
allNames.add(k);
|
||||
});
|
||||
disabledNames.forEach((n: string) => {
|
||||
allNames.add(n);
|
||||
});
|
||||
const names: string[] = Array.from(allNames);
|
||||
names.sort();
|
||||
|
||||
const rows: PluginRow[] = [];
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const name: string = names[i];
|
||||
const meta: Record<string, Object> | undefined = externalMeta.get(name);
|
||||
const tools: string[] | undefined = toolsByPlugin.get(name);
|
||||
const row: PluginRow = {
|
||||
name: name,
|
||||
loaded: loadedMap.get(name) ?? false,
|
||||
disabled: disabledNames.has(name),
|
||||
external: externalMeta.has(name),
|
||||
version: meta !== undefined ? meta['version'] as string ?? '' : '',
|
||||
description: meta !== undefined ? meta['description'] as string ?? '' : '',
|
||||
tools: tools,
|
||||
};
|
||||
rows.push(row);
|
||||
}
|
||||
this.plugins = rows;
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('plugins.load', e), true);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
/** 徽标状态:与 GUI 一致 —— 已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。 */
|
||||
private statusOf(plugin: PluginRow): string {
|
||||
if (plugin.loaded && !plugin.disabled) {
|
||||
return 'loaded'; // 已加载
|
||||
}
|
||||
if (plugin.loaded && plugin.disabled) {
|
||||
return 'pending'; // 运行中(禁用待生效)
|
||||
}
|
||||
if (plugin.disabled) {
|
||||
return 'disabled'; // 已禁用
|
||||
}
|
||||
return 'notloaded'; // 未加载
|
||||
}
|
||||
|
||||
private async togglePlugin(plugin: PluginRow): Promise<void> {
|
||||
const name: string = plugin.name;
|
||||
const action: string = plugin.disabled ? 'enable' : 'disable';
|
||||
try {
|
||||
await apiClient.post('/plugins/' + name + '/' + action, null);
|
||||
this.showToast('已' + (plugin.disabled ? '启用' : '禁用') + '插件: ' + name, false);
|
||||
this.loadPlugins();
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('plugins.toggle', e), true);
|
||||
}
|
||||
}
|
||||
|
||||
private async removePlugin(plugin: PluginRow): Promise<void> {
|
||||
const name: string = plugin.name;
|
||||
try {
|
||||
await apiClient.request('/plugins/' + name, 'DELETE', '', 8000);
|
||||
this.showToast('已卸载插件: ' + name, false);
|
||||
this.loadPlugins();
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('plugins.remove', e), true);
|
||||
}
|
||||
}
|
||||
|
||||
private async installPlugin(): Promise<void> {
|
||||
const url: string = this.installUrl.trim();
|
||||
if (url.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const bodyObj: InstallBody = { url: url };
|
||||
// 后端没有 /plugins/install 这个路由:安装就是 POST /plugins,body 带 url。
|
||||
await apiClient.post('/plugins', bodyObj);
|
||||
this.showToast('安装请求已发送', false);
|
||||
this.installUrl = '';
|
||||
this.showInstallForm = false;
|
||||
this.loadPlugins();
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('plugins.install', e), true);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 二级页面:插件详情 =====================
|
||||
|
||||
/**
|
||||
* 打开插件详情。
|
||||
*
|
||||
* 窄屏(Stack):push 一层整屏覆盖,系统侧滑返回可退。
|
||||
* 宽屏(Split):左栏列表常驻,右栏换页而不叠栈。
|
||||
*/
|
||||
private openDetail(name: string): void {
|
||||
this.activeName = name;
|
||||
this.loadDetail(name);
|
||||
if (this.isWide && this.navStack.size() > 0) {
|
||||
this.navStack.replacePathByName(SUB_DETAIL, subPageParam(SUB_DETAIL), false);
|
||||
} else {
|
||||
this.navStack.pushPathByName(SUB_DETAIL, subPageParam(SUB_DETAIL), true);
|
||||
}
|
||||
}
|
||||
|
||||
private closeDetail(): void {
|
||||
this.navStack.pop();
|
||||
this.activeName = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /plugins/{name} —— 后端返回插件清单字段。
|
||||
* WebGUI 只是把它 JSON.stringify 进 <pre>,这里逐字段结构化展示。
|
||||
* 内置插件不在 /plugins 里,取不到详情时退回用列表已有的信息。
|
||||
*/
|
||||
private async loadDetail(name: string): Promise<void> {
|
||||
this.detailLoading = true;
|
||||
this.detailError = '';
|
||||
try {
|
||||
const resp = await apiClient.getWithTimeout('/plugins/' + name, 10000);
|
||||
const o: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const d: PluginDetail = emptyPluginDetail();
|
||||
d.name = o['name'] as string ?? name;
|
||||
d.version = o['version'] as string ?? '';
|
||||
d.description = o['description'] as string ?? '';
|
||||
d.author = o['author'] as string ?? '';
|
||||
d.license = o['license'] as string ?? '';
|
||||
d.homepage = o['homepage'] as string ?? '';
|
||||
d.repository = o['repository'] as string ?? '';
|
||||
d.entry = o['entry'] as string ?? '';
|
||||
d.minVersion = o['min_version'] as string ?? '';
|
||||
d.deprecated = o['deprecated'] as boolean ?? false;
|
||||
d.tags = this.strArray(o['tags']);
|
||||
d.files = this.strArray(o['files']);
|
||||
this.detail = d;
|
||||
} catch (e) {
|
||||
// 内置插件没有清单,属于预期情况,不当成错误刷红
|
||||
const row: PluginRow | null = this.findRow(name);
|
||||
const d: PluginDetail = emptyPluginDetail();
|
||||
d.name = name;
|
||||
if (row !== null) {
|
||||
d.version = row.version ?? '';
|
||||
d.description = row.description ?? '';
|
||||
}
|
||||
this.detail = d;
|
||||
if (row !== null && row.external) {
|
||||
this.detailError = userMessage('plugins.detail', e);
|
||||
}
|
||||
}
|
||||
this.detailLoading = false;
|
||||
}
|
||||
|
||||
private strArray(raw: Object | undefined): string[] {
|
||||
const out: string[] = [];
|
||||
if (raw === undefined || raw === null) {
|
||||
return out;
|
||||
}
|
||||
const arr: Object[] = raw as Object[];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const s: string = arr[i] as string ?? '';
|
||||
if (s.length > 0) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private findRow(name: string): PluginRow | null {
|
||||
for (let i = 0; i < this.plugins.length; i++) {
|
||||
if (this.plugins[i].name === name) {
|
||||
return this.plugins[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 详情页正在展示的那一行(供二级页面里的操作按钮使用) */
|
||||
private activeRow(): PluginRow | null {
|
||||
return this.findRow(this.activeName);
|
||||
}
|
||||
|
||||
private async reloadPlugins(): Promise<void> {
|
||||
try {
|
||||
await apiClient.post('/plugins/reload', null);
|
||||
this.showToast('插件已重载', false);
|
||||
this.loadPlugins();
|
||||
} catch (e) {
|
||||
this.showToast(userMessage('plugins.reload', e), true);
|
||||
}
|
||||
}
|
||||
|
||||
private showToast(msg: string, isError: boolean): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastIsError = isError;
|
||||
setTimeout(() => {
|
||||
this.toastMsg = '';
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
build() {
|
||||
// 宽屏(>=600vp)走 Split:navBar(左栏 = 插件列表 + 底部导航栏)常驻,
|
||||
// NavDestination(右栏 = 插件详情)与之并列;窄屏则是整屏覆盖的二级页面。
|
||||
Navigation(this.navStack) {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
Column() {
|
||||
Scroll() {
|
||||
Column() {
|
||||
this.ListStates()
|
||||
this.PluginList()
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 76, bottom: 174 })
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Off)
|
||||
.align(Alignment.Top)
|
||||
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
|
||||
handleNavOnScroll(state);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
// 顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None,触摸完全穿透到滚动区)
|
||||
PageTopBar({ title: '插件' })
|
||||
|
||||
// 悬浮操作区:安装(悬浮主按钮)+ 重载。
|
||||
// 安装表单以悬浮卡形式浮在按钮上方,不再占用列表顶部一行。
|
||||
NavFloatOverlay({ tab: 1 }) {
|
||||
if (this.showInstallForm) {
|
||||
this.InstallForm()
|
||||
}
|
||||
NavFloatRow() {
|
||||
FloatIconButton({
|
||||
icon: $r('app.media.ic_reload'),
|
||||
onTap: () => {
|
||||
this.reloadPlugins();
|
||||
},
|
||||
})
|
||||
Button() {
|
||||
Image($r('app.media.ic_plus'))
|
||||
.width(20)
|
||||
.height(20)
|
||||
.fillColor(Color.White)
|
||||
}
|
||||
.width(50)
|
||||
.height(50)
|
||||
.type(ButtonType.Circle)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
|
||||
.onClick(() => {
|
||||
this.showInstallForm = !this.showInstallForm;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.Toast()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
.navDestination(this.SubDestination)
|
||||
.mode(this.isWide ? NavigationMode.Split : NavigationMode.Stack)
|
||||
.navBarPosition(NavBarPosition.Start)
|
||||
.navBarWidth(WIDE_NAV_BAR_WIDTH)
|
||||
.minContentWidth(WIDE_MIN_CONTENT)
|
||||
.hideTitleBar(true)
|
||||
.hideToolBar(true)
|
||||
.hideBackButton(true)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
// Split 模式下 navBar 常驻,此回调不再触发;宽屏一律锁住主 Tab 横滑
|
||||
.onNavBarStateChange((isVisible: boolean) => {
|
||||
markSubPageOpen(this.isWide || !isVisible);
|
||||
})
|
||||
// 模式初始化与切换:Split 右栏不能空白,自动选中第一个插件;
|
||||
// 退回 Stack 时清栈,否则会残留一个整屏覆盖的详情页。
|
||||
.onNavigationModeChange((mode: NavigationMode) => {
|
||||
if (mode === NavigationMode.Split) {
|
||||
markSubPageOpen(true);
|
||||
if (this.navStack.size() === 0 && this.plugins.length > 0) {
|
||||
this.openDetail(this.plugins[0].name);
|
||||
}
|
||||
} else {
|
||||
this.navStack.clear(false);
|
||||
this.activeName = '';
|
||||
markSubPageOpen(false);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 二级页面路由表:插件详情 */
|
||||
@Builder
|
||||
SubDestination(name: string, param: object) {
|
||||
NavDestination() {
|
||||
if (name === SUB_DETAIL) {
|
||||
SubPageLayer({
|
||||
title: this.activeName.length > 0 ? this.activeName : '插件详情',
|
||||
tab: 1,
|
||||
onBack: () => {
|
||||
this.closeDetail();
|
||||
},
|
||||
showRefresh: true,
|
||||
onRefresh: () => {
|
||||
this.loadDetail(this.activeName);
|
||||
},
|
||||
}) {
|
||||
this.DetailContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
.hideTitleBar(true)
|
||||
.backgroundColor(Color.Transparent)
|
||||
}
|
||||
|
||||
/** 安装表单:悬浮在安装按钮上方的一张玻璃卡(点悬浮按钮开合) */
|
||||
@Builder
|
||||
InstallForm() {
|
||||
Row() {
|
||||
TextInput({ placeholder: '.hmap 包下载 URL', text: this.installUrl })
|
||||
.layoutWeight(1)
|
||||
.height(36)
|
||||
.fontSize(14)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.placeholderColor(this.palette().textMuted)
|
||||
.backgroundColor(this.palette().bgInput)
|
||||
.borderRadius(RADIUS_SM)
|
||||
.border({ width: 1, color: this.palette().border })
|
||||
.onChange((v: string) => {
|
||||
this.installUrl = v;
|
||||
})
|
||||
Button('安装')
|
||||
.height(36)
|
||||
.fontSize(12)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor('#FFFFFF')
|
||||
.margin({ left: 6 })
|
||||
.onClick(() => {
|
||||
this.installPlugin();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(10)
|
||||
.margin({ bottom: 10 })
|
||||
.backgroundColor(this.palette().navBarBg)
|
||||
.borderRadius(RADIUS_MD)
|
||||
.border({ width: 1, color: this.palette().navBarBorder })
|
||||
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
/** 加载中 / 未配置 / 空列表三种占位态 */
|
||||
@Builder
|
||||
ListStates() {
|
||||
if (this.loading && this.plugins.length === 0) {
|
||||
LoadingProgress()
|
||||
.width(32)
|
||||
.height(32)
|
||||
.color(this.palette().accent)
|
||||
.margin({ top: 40 })
|
||||
}
|
||||
|
||||
if (!apiClient.hasConnection()) {
|
||||
Text(noConnectionMessage())
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
if (!this.loading && this.plugins.length === 0 && apiClient.hasConnection()) {
|
||||
Text('暂无已加载插件')
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一级列表:每个插件一张紧凑卡(名称 + 状态徽标 + 右尖角)。
|
||||
* 描述、工具清单、启停/卸载操作全部下沉到详情页 —— 列表只负责选择。
|
||||
*/
|
||||
@Builder
|
||||
PluginList() {
|
||||
ForEach(this.plugins, (plugin: PluginRow) => {
|
||||
Row() {
|
||||
Column({ space: 3 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text(plugin.name)
|
||||
.fontSize(15)
|
||||
.fontWeight(this.activeName === plugin.name ? FontWeight.Medium : FontWeight.Normal)
|
||||
.fontColor(this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().textPrimary)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (plugin.version !== undefined && plugin.version.length > 0) {
|
||||
Text('v' + plugin.version)
|
||||
.fontSize(10)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
}
|
||||
// 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达,
|
||||
// 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。
|
||||
Row({ space: 6 }) {
|
||||
Circle({ width: 6, height: 6 })
|
||||
.fill(this.statusDotColor(plugin))
|
||||
Text(this.rowSubtitle(plugin))
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
Image($r('app.media.ic_chevron_right'))
|
||||
.width(15)
|
||||
.height(15)
|
||||
.fillColor(this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().textMuted)
|
||||
.draggable(false)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.borderRadius(RADIUS_LG)
|
||||
.backgroundColor(this.activeName === plugin.name
|
||||
? this.palette().accentBg : this.palette().bgCard)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.activeName === plugin.name
|
||||
? this.palette().accent : this.palette().glassBorder,
|
||||
})
|
||||
.margin({ bottom: 10 })
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => {
|
||||
this.openDetail(plugin.name);
|
||||
})
|
||||
}, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* 二级页面:插件详情。
|
||||
* WebGUI 这里只有一个 JSON.stringify 的 <pre>,
|
||||
* 移植时改成结构化卡片:状态 / 清单字段 / 工具 / 操作。
|
||||
*/
|
||||
@Builder
|
||||
DetailContent() {
|
||||
if (this.detailLoading) {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(26)
|
||||
.height(26)
|
||||
.color(this.palette().accent)
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.padding({ top: 30, bottom: 30 })
|
||||
}
|
||||
|
||||
if (this.detailError.length > 0) {
|
||||
Text(this.detailError)
|
||||
.fontSize(12)
|
||||
.fontColor(COLOR_ERROR)
|
||||
.padding({ left: 4, bottom: 12 })
|
||||
}
|
||||
|
||||
// 概览卡:名称、版本、状态徽标、描述
|
||||
PlainCard({ caption: '概览' }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.detail.name.length > 0 ? this.detail.name : this.activeName)
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
if (this.detail.version.length > 0) {
|
||||
Text('v' + this.detail.version)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
this.DetailBadges()
|
||||
|
||||
if (this.detail.description.length > 0) {
|
||||
Text(this.detail.description)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.width('100%')
|
||||
.margin({ top: 10 })
|
||||
}
|
||||
}
|
||||
|
||||
// 清单卡:只有真拿到字段才出卡,否则会留一张空壳(内置插件没有清单文件)
|
||||
if (this.hasManifest()) {
|
||||
PlainCard({ caption: '清单' }) {
|
||||
this.KvRow('作者', this.detail.author)
|
||||
this.KvRow('许可证', this.detail.license)
|
||||
this.KvRow('主页', this.detail.homepage)
|
||||
this.KvRow('仓库', this.detail.repository)
|
||||
this.KvRow('入口', this.detail.entry)
|
||||
this.KvRow('最低内核版本', this.detail.minVersion)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.detail.tags.length > 0) {
|
||||
PlainCard({ caption: '标签' }) {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.detail.tags, (t: string) => {
|
||||
Text(t)
|
||||
.fontSize(10)
|
||||
.fontColor('#4A90D9')
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().frostSoftBg)
|
||||
.margin({ right: 5, bottom: 5 })
|
||||
}, (t: string) => t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.DetailTools()
|
||||
|
||||
// 插件配置:plugin.<name>.* 从后端 /settings?prefix= 取,就地编辑。
|
||||
// 这些 key 属于插件本身,之前被平铺在「设置 → 后端配置」里,
|
||||
// 现在归位到插件详情页 —— 「插件的设计页面就是插件的详情页」。
|
||||
if (this.activeName.length > 0) {
|
||||
PlainCard({ caption: '插件配置' }) {
|
||||
SettingsEditor({
|
||||
prefix: 'plugin.' + this.activeName + '.',
|
||||
emptyHint: '该插件没有暴露可配置项',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.detail.files.length > 0) {
|
||||
PlainCard({ caption: '文件 (' + this.detail.files.length.toString() + ')' }) {
|
||||
ForEach(this.detail.files, (f: string) => {
|
||||
Text(f)
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.width('100%')
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.margin({ bottom: 4 })
|
||||
}, (f: string) => f)
|
||||
}
|
||||
}
|
||||
|
||||
this.DetailActions()
|
||||
}
|
||||
|
||||
/** 详情页状态行:同样去掉徽标,一个状态点 + 一行纯文字 */
|
||||
@Builder
|
||||
DetailBadges() {
|
||||
Row({ space: 6 }) {
|
||||
Circle({ width: 7, height: 7 })
|
||||
.fill(this.activeStatusColor())
|
||||
Text(this.detailStatusLine())
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
private detailStatusLine(): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(this.activeStatusText());
|
||||
parts.push(this.activeIsBuiltin() ? '内置' : '外部');
|
||||
if (this.detail.deprecated) {
|
||||
parts.push('已废弃');
|
||||
}
|
||||
const n: number = this.activeTools().length;
|
||||
if (n > 0) {
|
||||
parts.push(n.toString() + ' 个工具');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** 工具清单来自一级列表已合并的 kernel.tools(按 plugin 归属) */
|
||||
@Builder
|
||||
DetailTools() {
|
||||
if (this.activeTools().length > 0) {
|
||||
PlainCard({ caption: '注册的工具 (' + this.activeTools().length.toString() + ')' }) {
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(this.activeTools(), (tool: string) => {
|
||||
Text(tool)
|
||||
.fontSize(11)
|
||||
.fontColor('#4A90D9')
|
||||
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().frostSoftBg)
|
||||
.margin({ right: 5, bottom: 5 })
|
||||
}, (tool: string) => tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
DetailActions() {
|
||||
if (this.activeName.length > 0) {
|
||||
PlainCard({ caption: '操作' }) {
|
||||
Row() {
|
||||
Button(this.activeIsDisabled() ? '启用' : '禁用')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.activeIsDisabled()
|
||||
? this.palette().btnGhostBorder : 'rgba(217, 154, 43, 0.5)',
|
||||
})
|
||||
.fontColor(this.activeIsDisabled()
|
||||
? this.palette().textSecondary : '#D99A2B')
|
||||
.onClick(() => {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r !== null) {
|
||||
this.togglePlugin(r);
|
||||
}
|
||||
})
|
||||
Blank()
|
||||
if (!this.activeIsBuiltin()) {
|
||||
Button('卸载')
|
||||
.height(34)
|
||||
.fontSize(12)
|
||||
.backgroundColor(Color.Transparent)
|
||||
.border({ width: 1, color: 'rgba(232, 64, 38, 0.45)' })
|
||||
.fontColor(COLOR_ERROR)
|
||||
.onClick(() => {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r !== null) {
|
||||
this.removePlugin(r);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 详情页取值助手:ArkTS 禁止非空断言,统一在这里做 null 收敛 ----
|
||||
|
||||
private hasManifest(): boolean {
|
||||
return this.detail.author.length > 0 || this.detail.license.length > 0 ||
|
||||
this.detail.homepage.length > 0 || this.detail.repository.length > 0 ||
|
||||
this.detail.entry.length > 0 || this.detail.minVersion.length > 0;
|
||||
}
|
||||
|
||||
private activeIsBuiltin(): boolean {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
return r !== null ? !r.external : false;
|
||||
}
|
||||
|
||||
private activeIsDisabled(): boolean {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
return r !== null ? r.disabled : false;
|
||||
}
|
||||
|
||||
private activeTools(): string[] {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
if (r === null) {
|
||||
return [];
|
||||
}
|
||||
return r.tools ?? [];
|
||||
}
|
||||
|
||||
private activeStatusText(): string {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
return r !== null ? this.statusBadgeText(r) : '未加载';
|
||||
}
|
||||
|
||||
private activeStatusColor(): string {
|
||||
const r: PluginRow | null = this.activeRow();
|
||||
return r !== null ? this.statusBadgeColor(r) : this.palette().textMuted;
|
||||
}
|
||||
|
||||
/** 明细行:值为空时整行不渲染,避免详情页出现一排 "-" */
|
||||
@Builder
|
||||
KvRow(label: string, value: string) {
|
||||
if (value.length > 0) {
|
||||
Row() {
|
||||
Text(label)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textSecondary)
|
||||
.layoutWeight(1)
|
||||
Text(value)
|
||||
.fontSize(13)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.textAlign(TextAlign.End)
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.constraintSize({ maxWidth: 220 })
|
||||
.margin({ left: 16 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
.alignItems(VerticalAlign.Top)
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
Toast() {
|
||||
if (this.toastMsg.length > 0) {
|
||||
Row() {
|
||||
Text(this.toastMsg)
|
||||
.fontSize(13)
|
||||
.fontColor(this.toastIsError ? this.palette().toastErrorText : this.palette().toastText)
|
||||
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
|
||||
.borderRadius(RADIUS_MD)
|
||||
.backgroundColor(this.toastIsError ? this.palette().toastErrorBg : this.palette().toastBg)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.toastIsError ? 'rgba(232, 64, 38, 0.3)' : 'rgba(23, 169, 100, 0.3)',
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.justifyContent(FlexAlign.End)
|
||||
.padding({ right: 20 })
|
||||
.margin({ bottom: 166 })
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态点颜色:绿=已加载,黄=待生效,红=已禁用,灰=未加载 */
|
||||
private statusDotColor(plugin: PluginRow): string {
|
||||
return this.statusBadgeColor(plugin);
|
||||
}
|
||||
|
||||
/** 列表行副标题:状态 + 内置/外部 + 工具数,一行灰字,不用徽标 */
|
||||
private rowSubtitle(plugin: PluginRow): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(this.statusBadgeText(plugin));
|
||||
parts.push(plugin.external ? '外部' : '内置');
|
||||
if (plugin.tools !== undefined && plugin.tools.length > 0) {
|
||||
parts.push(plugin.tools.length.toString() + ' 工具');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
private statusBadgeText(plugin: PluginRow): string {
|
||||
const s: string = this.statusOf(plugin);
|
||||
if (s === 'loaded') {
|
||||
return '已加载';
|
||||
}
|
||||
if (s === 'pending') {
|
||||
return '待生效';
|
||||
}
|
||||
if (s === 'disabled') {
|
||||
return '已禁用';
|
||||
}
|
||||
return '未加载';
|
||||
}
|
||||
|
||||
private statusBadgeColor(plugin: PluginRow): string {
|
||||
const s: string = this.statusOf(plugin);
|
||||
if (s === 'loaded') {
|
||||
return '#17A964';
|
||||
}
|
||||
if (s === 'pending') {
|
||||
return '#D99A2B';
|
||||
}
|
||||
if (s === 'disabled') {
|
||||
return '#E84026';
|
||||
}
|
||||
return this.palette().textMuted;
|
||||
}
|
||||
}
|
||||
1370
cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets
Normal file
45
cmd/ohos/HomeAgent/entry/src/main/module.json5
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "entry",
|
||||
"type": "entry",
|
||||
"description": "HomeAgent client entry module",
|
||||
"mainElement": "EntryAbility",
|
||||
"deviceTypes": [
|
||||
"phone",
|
||||
"tablet"
|
||||
],
|
||||
"deliveryWithInstall": true,
|
||||
"installationFree": false,
|
||||
"pages": "$profile:main_pages",
|
||||
"requestPermissions": [
|
||||
{
|
||||
"name": "ohos.permission.INTERNET"
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.GET_NETWORK_INFO"
|
||||
}
|
||||
],
|
||||
"abilities": [
|
||||
{
|
||||
"name": "EntryAbility",
|
||||
"srcEntry": "./ets/entryability/EntryAbility.ets",
|
||||
"description": "HomeAgent main entry",
|
||||
"icon": "$media:app_icon",
|
||||
"label": "$string:app_name",
|
||||
"startWindowIcon": "$media:app_icon",
|
||||
"startWindowBackground": "$color:start_window_background",
|
||||
"exported": true,
|
||||
"skills": [
|
||||
{
|
||||
"entities": [
|
||||
"entity.system.home"
|
||||
],
|
||||
"actions": [
|
||||
"action.system.home"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"color": [
|
||||
{
|
||||
"name": "start_window_background",
|
||||
"value": "#0B1020"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "app_name",
|
||||
"value": "HomeAgent"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||
|
After Width: | Height: | Size: 177 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M11 21h-1l1-7H7.5c-.88 0-.33-.75-.31-.78C8.48 10.94 10.42 7.54 13.01 3h1l-1 7h3.51c.4 0 .62.19.4.66C12.97 17.55 11 21 11 21z"/></svg>
|
||||
|
After Width: | Height: | Size: 241 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M9 16.2l-3.5-3.5L4 14.2 9 19.2 20 8.2l-1.4-1.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 164 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z"/></svg>
|
||||
|
After Width: | Height: | Size: 162 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>
|
||||
|
After Width: | Height: | Size: 161 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
|
After Width: | Height: | Size: 218 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M4 6h18V4H4c-1.1 0-2 .9-2 2v11H0v3h14v-3H4V6zm19 2h-6c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h6c.55 0 1-.45 1-1V9c0-.55-.45-1-1-1zm-1 9h-4v-7h4v7z"/></svg>
|
||||
|
After Width: | Height: | Size: 258 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" d="M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zm0 5v5m0 3v.5"/></svg>
|
||||
|
After Width: | Height: | Size: 220 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2.5L17.5 8H14V4.5zM8 13h8v1.6H8V13zm0 3.4h8V18H8v-1.6zM8 9.6h4v1.6H8V9.6z"/></svg>
|
||||
|
After Width: | Height: | Size: 256 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M19.35 10.04A7.49 7.49 0 0 0 12 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 0 0 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"/></svg>
|
||||
|
After Width: | Height: | Size: 266 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 14H4v-2.6l3.6-3.6 3 3 4.2-4.2L20 15v3zm0-5.3-5.2-5.2-4.2 4.2-3-3L4 13.2V6h16v6.7zM8.4 9.9a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||
|
After Width: | Height: | Size: 328 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zm0 2a7 7 0 0 0-5.3 11.5L17.5 6.7A7 7 0 0 0 12 5zm0 14a7 7 0 0 0 5.3-11.5L6.5 17.3A7 7 0 0 0 12 19z"/></svg>
|
||||
|
After Width: | Height: | Size: 251 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/></svg>
|
||||
|
After Width: | Height: | Size: 221 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M9 2v5H7V2h2zm8 0v5h-2V2h2zM5 9h14v3a7 7 0 0 1-6 6.9V22h-2v-3.1A7 7 0 0 1 5 12V9zm2 2v1a5 5 0 0 0 10 0v-1H7z"/></svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5z"/></svg>
|
||||
|
After Width: | Height: | Size: 151 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M3 12h3.3l2.1-5.4a1 1 0 0 1 1.9.1l2.6 9.1 1.9-4.4a1 1 0 0 1 .9-.6H21v2h-4.6l-2.6 6a1 1 0 0 1-1.9-.1L9.3 9.7 7.9 13.4a1 1 0 0 1-.9.6H3v-2z"/></svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
|
After Width: | Height: | Size: 318 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
|
||||
|
After Width: | Height: | Size: 332 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
|
||||
|
After Width: | Height: | Size: 154 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 2c.8 5.2 4.8 9.2 10 10-5.2.8-9.2 4.8-10 10-.8-5.2-4.8-9.2-10-10 5.2-.8 9.2-4.8 10-10z"/></svg>
|
||||
|
After Width: | Height: | Size: 206 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M7 7h10c.55 0 1 .45 1 1v8c0 .55-.45 1-1 1H7c-.55 0-1-.45-1-1V8c0-.55.45-1 1-1z"/></svg>
|
||||
|
After Width: | Height: | Size: 195 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12l-4-3-4 3V5h8v9z"/></svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.5 1h-8C6.12 1 5 2.12 5 3.5v17C5 21.88 6.12 23 7.5 23h8c1.38 0 2.5-1.12 2.5-2.5v-17C18 2.12 16.88 1 15.5 1zm-4.5 21c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5-4H7V4h9v14z"/></svg>
|
||||
|
After Width: | Height: | Size: 284 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>
|
||||
|
After Width: | Height: | Size: 371 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>
|
||||
|
After Width: | Height: | Size: 198 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM8 17H6v-6h2v6zm4 0h-2V9h2v8zm4 0h-2v-4h2v4z"/></svg>
|
||||
|
After Width: | Height: | Size: 199 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M12 22c5.52 0 10-4.48 10-10S17.52 2 12 2 2 6.48 2 12s4.48 10 10 10zm1-17.93c3.94.49 7 3.85 7 7.93s-3.05 7.44-7 7.93V4.07z"/></svg>
|
||||
|
After Width: | Height: | Size: 238 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.9 2.9-2.5-.6-.6-2.5z"/></svg>
|
||||
|
After Width: | Height: | Size: 203 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#FFFFFF" d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>
|
||||
|
After Width: | Height: | Size: 236 B |
@ -0,0 +1,5 @@
|
||||
{
|
||||
"src": [
|
||||
"pages/Index"
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
{
|
||||
"network-security-config": {
|
||||
"domain-config": [
|
||||
{
|
||||
"cleartextTraffic": true,
|
||||
"domain": {
|
||||
"include-domains": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
7
cmd/ohos/HomeAgent/hvigor/hvigor-config.json5
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"modelVersion": "5.0.0",
|
||||
"dependencies": {
|
||||
"@ohos/hvigor": "file:/opt/huawei/command-line-tools/hvigor/hvigor",
|
||||
"@ohos/hvigor-ohos-plugin": "file:/opt/huawei/command-line-tools/hvigor/hvigor-ohos-plugin"
|
||||
}
|
||||
}
|
||||
1
cmd/ohos/HomeAgent/hvigorfile.ts
Normal file
@ -0,0 +1 @@
|
||||
export { appTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
28
cmd/ohos/HomeAgent/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/hypium@1.0.21": "@ohos/hypium@1.0.21",
|
||||
"@ycj3/streaming-markdown@^2.1.1": "@ycj3/streaming-markdown@2.1.1"
|
||||
},
|
||||
"packages": {
|
||||
"@ohos/hypium@1.0.21": {
|
||||
"name": "@ohos/hypium",
|
||||
"version": "1.0.21",
|
||||
"integrity": "sha512-iyKGMXxE+9PpCkqEwu0VykN/7hNpb+QOeIuHwkmZnxOpI+dFZt6yhPB7k89EgV1MiSK/ieV/hMjr5Z2mWwRfMQ==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.21.har",
|
||||
"registryType": "ohpm"
|
||||
},
|
||||
"@ycj3/streaming-markdown@2.1.1": {
|
||||
"name": "@ycj3/streaming-markdown",
|
||||
"version": "2.1.1",
|
||||
"integrity": "sha512-YLofL0X0wcQ5ZWSO1/cHUjK5F4hQpE2atygm1hs+w3bsaaVae3dHte7OMbcyZ3zcwFPswSEOa3A4+geeIIMXAg==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ycj3/streaming-markdown/-/streaming-markdown-2.1.1.har",
|
||||
"registryType": "ohpm"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
cmd/ohos/HomeAgent/oh-package.json5
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "homeagent",
|
||||
"version": "1.0.0",
|
||||
"modelVersion": "5.0.0",
|
||||
"description": "HomeAgent HarmonyOS Client",
|
||||
"main": "",
|
||||
"author": "",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ycj3/streaming-markdown": "^2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ohos/hypium": "1.0.21"
|
||||
},
|
||||
"dynamicDependencies": {}
|
||||
}
|
||||
99
cmd/ohos/README.md
Normal file
@ -0,0 +1,99 @@
|
||||
# HomeAgent 鸿蒙客户端
|
||||
|
||||
HarmonyOS / OpenHarmony 原生客户端,用 ArkTS + ArkUI 实现(不是 WebView 套壳)。
|
||||
功能与 WebUI 对齐:SSE 流式对话、工具调用卡片、思考过程折叠、附件上传预览、
|
||||
设备桥、插件管理、设置编辑、宽屏双栏、深浅色主题。
|
||||
|
||||
## 工程结构
|
||||
|
||||
```
|
||||
HomeAgent/
|
||||
├── AppScope/ 应用级配置与图标
|
||||
├── entry/src/main/
|
||||
│ ├── ets/
|
||||
│ │ ├── common/ 通信与全局状态
|
||||
│ │ │ ├── ApiClient.ets REST 客户端(X-API-Key 鉴权、超时、二进制附件)
|
||||
│ │ │ ├── SseClient.ets SSE 长连接(Last-Event-ID 断线续传)
|
||||
│ │ │ ├── DeviceBridge.ets 设备桥:把本机能力暴露给 agent
|
||||
│ │ │ ├── BridgeRouter.ets 桥请求路由
|
||||
│ │ │ ├── BridgeCaps.ets 能力声明
|
||||
│ │ │ ├── ConnStore.ets 连接配置持久化
|
||||
│ │ │ ├── StatusStore.ets 运行状态缓存
|
||||
│ │ │ ├── NavBarController.ets / NavStackRegistry.ets 导航
|
||||
│ │ │ ├── Constants.ets 主题色板、圆角、超时、分页大小
|
||||
│ │ │ └── UserError.ets 错误转人类可读文案
|
||||
│ │ ├── components/ 可复用组件
|
||||
│ │ │ ├── MarkdownView.ets 流式 Markdown(增量渲染)
|
||||
│ │ │ ├── StaticMarkdown.ets 静态 Markdown(历史消息,一次成型)
|
||||
│ │ │ ├── Attachment.ets 附件卡片 + 详情
|
||||
│ │ │ ├── StatusCards.ets 状态卡片
|
||||
│ │ │ ├── SettingsEditor.ets 配置编辑器
|
||||
│ │ │ ├── PageTopBar.ets 顶栏 + 悬浮按钮
|
||||
│ │ │ ├── SubPage.ets 二级页容器
|
||||
│ │ │ └── GradientBackground.ets
|
||||
│ │ ├── model/Model.ets 共享类型定义
|
||||
│ │ ├── pages/ 页面
|
||||
│ │ │ ├── Index.ets Tab 容器(入口)
|
||||
│ │ │ ├── ChatPage.ets 对话
|
||||
│ │ │ ├── DevicePage.ets 设备
|
||||
│ │ │ ├── PluginsPage.ets 插件
|
||||
│ │ │ └── SettingsPage.ets 设置
|
||||
│ │ └── entryability/EntryAbility.ets
|
||||
│ ├── module.json5 权限、能力声明
|
||||
│ └── resources/ 字符串、颜色、图标、页面路由表
|
||||
├── build-profile.json5.example 构建/签名配置模板(复制后填本机签名材料)
|
||||
└── oh-package.json5 依赖
|
||||
```
|
||||
|
||||
## 编译
|
||||
|
||||
需要 DevEco Studio 或 [command-line-tools](https://developer.huawei.com/consumer/cn/deveco-studio/)。
|
||||
本工程用 `compatibleSdkVersion 6.1.1(24)` / `compileSdkVersion 26.0.0`。
|
||||
|
||||
1. **准备签名配置**(`build-profile.json5` 含密码明文,未入库):
|
||||
|
||||
```bash
|
||||
cd cmd/ohos/HomeAgent
|
||||
cp build-profile.json5.example build-profile.json5
|
||||
```
|
||||
|
||||
把 `REPLACE_WITH_YOUR_*` 换成本机 DevEco 生成的调试签名材料,
|
||||
默认在 `~/.ohos/config/` 下(`.cer` / `.p7b` / `.p12` 三件套 + 两个密码)。
|
||||
用 DevEco Studio 打开工程会自动生成,命令行可参考 `deveco-cli` 生成签名材料。
|
||||
|
||||
2. **构建 HAP**:
|
||||
|
||||
```bash
|
||||
# hvigorw 未入库(本机是符号链接),直接用 command-line-tools 里的
|
||||
/path/to/command-line-tools/bin/hvigorw \
|
||||
--mode module -p module=entry@default assembleHap --no-daemon
|
||||
```
|
||||
|
||||
产物在 `entry/build/default/outputs/default/entry-default-signed.hap`。
|
||||
|
||||
3. **安装到设备**:
|
||||
|
||||
```bash
|
||||
hdc install entry/build/default/outputs/default/entry-default-signed.hap
|
||||
```
|
||||
|
||||
## 连接 homed
|
||||
|
||||
首次启动在「设置」里填:
|
||||
|
||||
- **服务地址**:`http://<homed 主机>:8080`(WebUI 插件监听端口)
|
||||
- **API Key**:homed 的 `plugin.webui.api_key`
|
||||
|
||||
客户端所有请求走 `<服务地址>/api/v1/*`,带 `X-API-Key` 头。
|
||||
附件路径 `/files/` `/uploads/` 不带 `/api/v1` 前缀,同样携带鉴权头。
|
||||
|
||||
设备桥需要 homed 启用 `remotedevice` 插件(默认 9890),
|
||||
在「设备」页填 ws token 后本机能力即可被 agent 调用。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **聊天历史分页**:首屏只拉最新 `CHAT_PAGE_SIZE`(40)条,向上滚动触顶自动加载更早的。
|
||||
服务端 `/chat/history` 支持 `limit` / `before` 游标;工具调用详情与思考内容完整下发不裁剪。
|
||||
- **修改主题色**:改 `common/Constants.ets` 的 `DARK_PALETTE` / `LIGHT_PALETTE`,全局生效。
|
||||
- **新增页面**:同时在 `resources/base/profile/main_pages.json` 注册,且只有入口页带 `@Entry`。
|
||||
- 项目代码部分由 AI 辅助生成,改动请自行评估。
|
||||