mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 10:28:06 +00:00
## 动效统一 各组件重复实现按压反馈(@State pressed + scale + animation + onTouch 四件套), 时长各写魔数导致全局手感不一致。 - 新增 components/MotionBase.ets:通用动效的"父组件"。ArkUI V1 的 @Component struct 无法继承他人 build,改用组合表达继承——调用组件把内容经 @BuilderParam 内容插槽传入,MotionBase 在包装节点统一挂动效修饰器。 pressEnabled 默认关(纯展示容器零开销);fillWidth=false 供气泡内卡片按内容 自适应宽度;flexWeight 供等分排列按钮参与剩余空间分配。 onPress 只作按压瞬时轻量钩子,导航/提交语义仍由调用组件 onClick 负责, 避免"按下即触发"的手感偏差。 - Constants.ets 新增动效 token:ANIM_FAST(150) / ANIM_NORMAL(220) / ANIM_ENTER(280) / ANIM_SLOW(400) / PRESS_SCALE(0.97)。 取值依据:状态切换 150-250ms(>300ms 显拖沓),大位移进出场 300-400ms 才不突兀;曲线统一 EaseOut 起步快收尾缓。 - NavRow / StatusSummaryCard / AttachmentCard / 插件卡片等公共组件接入 MotionBase,移除各自的按压四件套。组件专属动效(聊天输入框上弹、加号菜单 浮起、折叠面板展开、toast 进出场)保留在各组件内,不塞进父组件。 ## 图标与启动页随主题切换 原先直接指向位图 app_icon.png,浅色底被烧进图标,深色模式下桌面与启动页跳脱。 - 改用分层图标 layered_image:foreground 为字形,background(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。app.json5 与 module.json5 的 icon 均指向 :layered_image。 - startWindowIcon 改用透明底 start_icon.png,配合 start_window_background 的 base(#F1F3F5) / dark(#000000) 两份取值,浅深模式遮罩与图标都能对上。 ## 验证 清空 entry/build 后全量重编:hvigorw assembleHap BUILD SUCCESSFUL(9.8s), 零 ArkTS 错误。提交内容已确认不含 build/ oh_modules/ .hap 与签名材料。
693 lines
23 KiB
Plaintext
693 lines
23 KiB
Plaintext
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, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||
import { MotionBase } from '../components/MotionBase';
|
||
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.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||
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.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||
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.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||
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 {
|
||
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
|
||
this.toastIsError = isError;
|
||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||
this.toastMsg = msg;
|
||
});
|
||
setTimeout(() => {
|
||
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||
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.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
|
||
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.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
|
||
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 })
|
||
.transition(TransitionEffect.OPACITY
|
||
.combine(TransitionEffect.translate({ y: 12 }))
|
||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||
}
|
||
}
|
||
|
||
// ===================== 一级入口列表 =====================
|
||
|
||
@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')
|
||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||
.margin({ right: 8 })
|
||
Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令')
|
||
.fontSize(12)
|
||
.fontColor(this.authorized ? '#17A964' : this.palette().textMuted)
|
||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
|
||
}
|
||
.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 })
|
||
.transition(TransitionEffect.OPACITY
|
||
.combine(TransitionEffect.translate({ y: -8 }))
|
||
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
|
||
}
|
||
|
||
PlainCard({ caption: '操作' }) {
|
||
Row() {
|
||
// 缩放反馈由 MotionBase 统一;连接态的底色/字色切换仍需按钮自己缓动,
|
||
// 因为父容器的 .animation() 到不了子节点。
|
||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||
Button(this.bridgeConnected ? '断开' : '连接网关')
|
||
.height(34)
|
||
.fontSize(12)
|
||
.backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
|
||
.fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
|
||
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
|
||
.border({
|
||
width: this.bridgeConnected ? 1 : 0,
|
||
color: this.palette().btnGhostBorder,
|
||
})
|
||
.onClick(() => {
|
||
if (this.bridgeConnected) {
|
||
deviceBridge.disconnect();
|
||
} else {
|
||
this.connectBridge();
|
||
}
|
||
})
|
||
}
|
||
Blank()
|
||
MotionBase({ pressEnabled: true, fillWidth: false }) {
|
||
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 })
|
||
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
|
||
}
|
||
|
||
ForEach(this.devices, (dev: DeviceInfo) => {
|
||
// PlainCard 是自定义组件,transition 不能直接挂在它上面(会生成 __Common__ 包装),
|
||
// 所以用一个无 padding、满宽的 Column 承载入场动画,布局不受影响。
|
||
Column() {
|
||
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%')
|
||
}
|
||
}
|
||
.width('100%')
|
||
.transition(TransitionEffect.OPACITY
|
||
.combine(TransitionEffect.translate({ y: 12 }))
|
||
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||
}, (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;
|