From e733d05a5e2b257bba98eb33538928569957b051 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 13 Sep 2026 22:34:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ohos):=20DevicePage=20560=E2=86=92307?= =?UTF-8?q?=20=E8=A1=8C=EF=BC=88=E5=85=A5=E5=8F=A3=E5=88=97=E8=A1=A8/?= =?UTF-8?q?=E5=9B=9B=E4=B8=AA=E9=9D=A2=E6=9D=BF/=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=8B=86=E5=88=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common/DeviceModel.ets(73):device_id 兜底(桥 id 优先 → 持久化 → 生成并落盘, 顺序与原 aboutToAppear 一致)、/device/online 响应解析、四个二级页路由 id - components/DeviceRootEntries.ets(92):一级入口(本机/通道两组 NavRow 列表); 宽屏高亮自己读 AppStorage 的 isWideScreen,页面只给 activeSub 与 onOpen - components/DevicePanes.ets(316):DeviceLocalPane(基本信息 + 授权开关)、 DeviceCapsPane(能力清单)、DeviceGatewayPane(网关信息 + 刷新)、 DeviceListPane(在线设备列表)、DeviceKvRow;每个面板自带 SubPageLayer 页面保留导航栈、openSub/closeSub、授权开关、toast、refreshDevices 与 SubDestination 分发。所有文案、图标、颜色、过渡与失败分支逐字保留; 面板内容多了一层无 padding 的 Column 根节点(@Component 的 build() 只允许 一个根),宽度 100% + Start 对齐,与 SubPageLayer 内容槽的布局一致。 验证:hvigorw assembleHap BUILD SUCCESSFUL。 --- .../entry/src/main/ets/common/DeviceModel.ets | 73 ++++ .../src/main/ets/components/DevicePanes.ets | 316 +++++++++++++++ .../main/ets/components/DeviceRootEntries.ets | 92 +++++ .../entry/src/main/ets/pages/DevicePage.ets | 371 +++--------------- 4 files changed, 540 insertions(+), 312 deletions(-) create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceModel.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/DevicePanes.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/DeviceRootEntries.ets diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceModel.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceModel.ets new file mode 100644 index 0000000..8814199 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceModel.ets @@ -0,0 +1,73 @@ +/** + * 设备页的纯逻辑:本机 device_id 兜底与在线设备列表解析。 + * + * 从 pages/DevicePage.ets 抽出(非 UI,可被其它页面/桥复用)。 + */ + +import { DeviceInfo } from '../model/Model'; +import { connStore } from './ConnStore'; + +// ===== 二级页面路由 id(页面与一级入口列表共用)===== +export const SUB_NONE: string = ''; +export const SUB_LOCAL: string = 'local'; +export const SUB_CAPS: string = 'caps'; +export const SUB_GATEWAY: string = 'gateway'; +export const SUB_LIST: string = 'list'; + +/** + * 本机 device_id:桥里已有就用桥的,其次读持久化,都没有则生成一个并落盘。 + * 生成后必须持久化,否则每次冷启动换 id,网关侧会累积成一堆幽灵设备。 + * + * 判定顺序与原 DevicePage.aboutToAppear 一致:桥的 id 优先于持久化的 id。 + */ +export function resolveDeviceId(bridgeId: string): string { + let id: string = bridgeId; + if (id.length === 0) { + id = connStore.getDeviceId(); + } + if (id.length === 0) { + id = 'ohos-' + Date.now().toString(36); + try { + connStore.saveDeviceId(id); + } catch (e) { + // ignore persist failure + } + } + return id; +} + +/** + * 解析 /device/online 响应体。 + * + * apiClient 已自动前置 /api/v1,调用方只写其后的部分 + * (否则会拼成 /api/v1/api/v1/device/online 并 404)。 + */ +export function parseOnlineDevices(parsed: Record): DeviceInfo[] { + const devs: Object = parsed['devices']; + const list: DeviceInfo[] = []; + if (devs === undefined || devs === null) { + return list; + } + const arr: Object[] = devs as Object[]; + for (let i = 0; i < arr.length; i++) { + const d: Record = arr[i] as Record; + 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); + } + return list; +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/DevicePanes.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/DevicePanes.ets new file mode 100644 index 0000000..0d5a643 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/DevicePanes.ets @@ -0,0 +1,316 @@ +/** + * 设备页的四个二级页面内容:本机设备 / 设备能力 / 设备通道 / 接入的设备。 + * + * 从 pages/DevicePage.ets 抽出(原来是 LocalDeviceContent / CapsContent / + * GatewayContent / OnlineDevicesContent 四个 @Builder + KvRow)。 + * 每个面板自带 SubPageLayer 外壳(标题、所属 Tab、返回、刷新), + * 页面只保留路由分发 —— 与 SettingsPage 拆出的三个 Pane 同一套做法。 + */ + +import { DeviceInfo } from '../model/Model'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, ANIM_NORMAL, + ANIM_ENTER } from '../common/Constants'; +import { LOCAL_DEVICE_CAPS } from '../common/DeviceBridgeSession'; +import { MotionBase } from './MotionBase'; +import { PlainCard, SubPageLayer } from './SubPage'; + +/** 通用 KV 行(面板之间共用) */ +@Component +export struct DeviceKvRow { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Prop label: string = ''; + @Prop value: string = ''; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + build() { + Row() { + Text(this.label) + .fontSize(13) + .fontColor(this.palette().textSecondary) + Blank() + Text(this.value) + .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 }) + } +} + +/** 二级:本机设备(基本信息 + 远程控制授权) */ +@Component +export struct DeviceLocalPane { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Prop deviceId: string = ''; + @Prop authorized: boolean = false; + onBack?: () => void; + onToggleAuth?: (on: boolean) => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + build() { + SubPageLayer({ + title: '本机设备', + tab: 2, + onBack: () => { + const cb: (() => void) | undefined = this.onBack; + if (cb !== undefined) { + cb(); + } + }, + }) { + PlainCard({ caption: '基本信息' }) { + DeviceKvRow({ label: '设备 ID', value: this.deviceId.length > 0 ? this.deviceId : '未注册' }) + DeviceKvRow({ label: '名称', value: 'HomeAgent OHOS' }) + DeviceKvRow({ label: '类型', value: '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) => { + const cb: ((on: boolean) => void) | undefined = this.onToggleAuth; + if (cb !== undefined) { + cb(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 }) + } + } + } +} + +/** 二级:设备能力(本机能被 agent 调用的能力清单) */ +@Component +export struct DeviceCapsPane { + @StorageProp('themeIsDark') private isDark: boolean = true; + onBack?: () => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + build() { + SubPageLayer({ + title: '设备能力', + tab: 2, + onBack: () => { + const cb: (() => void) | undefined = this.onBack; + if (cb !== undefined) { + cb(); + } + }, + }) { + PlainCard({ caption: '能力清单' }) { + Text('agent 通过设备桥可调用的本机能力:') + .fontSize(12) + .fontColor(this.palette().textMuted) + .margin({ bottom: 10 }) + + Flex({ wrap: FlexWrap.Wrap }) { + ForEach(LOCAL_DEVICE_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%') + } + } + } +} + +/** 二级:设备通道(网关地址 / Token / 连接状态) */ +@Component +export struct DeviceGatewayPane { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Prop bridgeUrl: string = ''; + @Prop bridgeToken: string = ''; + @Prop bridgeConnected: boolean = false; + onBack?: () => void; + onRefresh?: () => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + build() { + SubPageLayer({ + title: '设备通道', + tab: 2, + onBack: () => { + const cb: (() => void) | undefined = this.onBack; + if (cb !== undefined) { + cb(); + } + }, + }) { + PlainCard({ caption: '连接信息' }) { + DeviceKvRow({ label: '网关地址', value: this.bridgeUrl.length > 0 ? this.bridgeUrl : '-' }) + DeviceKvRow({ label: 'Token', value: this.bridgeToken.length > 0 ? '已从连接继承' : '未配置' }) + DeviceKvRow({ label: '状态', value: this.bridgeConnected ? '已连接' : '未连接' }) + } + + PlainCard({ caption: '操作' }) { + Row() { + 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(() => { + const cb: (() => void) | undefined = this.onRefresh; + if (cb !== undefined) { + cb(); + } + }) + } + } + .width('100%') + + Text('设备通道由应用前台生命周期统一管理;切换连接配置后会自动使用新地址和 Token。') + .fontSize(11) + .fontColor(this.palette().textMuted) + .margin({ top: 10 }) + } + } + } +} + +/** 二级:接入的设备(网关侧在线设备列表) */ +@Component +export struct DeviceListPane { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Prop devices: DeviceInfo[] = []; + onBack?: () => void; + onRefresh?: () => void; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + build() { + SubPageLayer({ + title: '接入的设备', + tab: 2, + showRefresh: true, + onBack: () => { + const cb: (() => void) | undefined = this.onBack; + if (cb !== undefined) { + cb(); + } + }, + onRefresh: () => { + const cb: (() => void) | undefined = this.onRefresh; + if (cb !== undefined) { + cb(); + } + }, + }) { + DeviceOnlineList({ devices: this.devices }) + } + } +} + +/** 在线设备列表本体(抽出来只是为了让 DeviceListPane 的 build 更短) */ +@Component +struct DeviceOnlineList { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Prop devices: DeviceInfo[] = []; + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } + + build() { + Column() { + 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()) + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/DeviceRootEntries.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/DeviceRootEntries.ets new file mode 100644 index 0000000..43c58f5 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/DeviceRootEntries.ets @@ -0,0 +1,92 @@ +/** + * 设备页一级入口列表(本机 / 通道两组)。 + * + * 从 pages/DevicePage.ets 抽出(原来是 RootEntries 一个 @Builder)。 + * 宽屏高亮由组件自己读 AppStorage 的 isWideScreen 决定, + * 页面只负责给 activeSub 与四个打开动作。 + */ + +import { LOCAL_DEVICE_CAPS } from '../common/DeviceBridgeSession'; +import { SUB_CAPS, SUB_GATEWAY, SUB_LIST, SUB_LOCAL } from '../common/DeviceModel'; +import { NavGroup, NavRow } from './SubPage'; + +@Component +export struct DeviceRootEntries { + /** 宽屏:左边一级界面(含底部导航栏),右边二级界面 */ + @StorageProp('isWideScreen') private isWide: boolean = false; + /** 当前右栏展示的二级页面 id,用于宽屏下高亮左侧入口行 */ + @Prop activeSub: string = ''; + @Prop deviceId: string = ''; + @Prop bridgeConnected: boolean = false; + @Prop bridgeUrl: string = ''; + @Prop loadingDevices: boolean = false; + @Prop deviceCount: number = 0; + onOpen?: (id: string) => void; + + private capsCount(): number { + return LOCAL_DEVICE_CAPS.length; + } + + private open(id: string): void { + const cb: ((id: string) => void) | undefined = this.onOpen; + if (cb !== undefined) { + cb(id); + } + } + + build() { + // 一级入口列表整体作为一个容器根节点:@Component 的 build() 只允许一个根, + // 页面侧仍是 `.padding(...)` 的 Column,逐项布局与拆分前一致。 + Column() { + 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.open(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.open(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.open(SUB_GATEWAY); + }, + }) + NavRow({ + icon: $r('app.media.ic_devices_multi'), + title: '接入的设备', + subtitle: this.loadingDevices ? '加载中...' : '当前在线', + value: this.deviceCount.toString() + ' 台', + showDivider: false, + selected: this.isWide && this.activeSub === SUB_LIST, + onTap: () => { + this.open(SUB_LIST); + }, + }) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets index 428c5bc..5353c03 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets @@ -4,19 +4,25 @@ import { apiClient } from '../common/ApiClient'; 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 { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, + WIDE_MIN_CONTENT, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; +import { deviceGatewayUrl } from '../common/DeviceBridgeSession'; +import { DeviceRootEntries } from '../components/DeviceRootEntries'; +import { DeviceLocalPane, DeviceCapsPane, DeviceGatewayPane, DeviceListPane } from '../components/DevicePanes'; import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar'; -import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage'; -import { LOCAL_DEVICE_CAPS, deviceGatewayUrl } from '../common/DeviceBridgeSession'; - -/** 二级页面标识 */ -const SUB_NONE: string = ''; -const SUB_LOCAL: string = 'local'; -const SUB_CAPS: string = 'caps'; -const SUB_GATEWAY: string = 'gateway'; -const SUB_LIST: string = 'list'; +import { markSubPageOpen, subPageParam } from '../components/SubPage'; +import { parseOnlineDevices, resolveDeviceId, SUB_CAPS, SUB_GATEWAY, SUB_LIST, + SUB_LOCAL, SUB_NONE } from '../common/DeviceModel'; +/** + * 设备页:只做"页面壳"。 + * + * 拆分后的分工(拆分前这里是 560 行的单文件): + * - 一级入口列表(本机/通道两组) → components/DeviceRootEntries.ets + * - 四个二级页面内容(本机/能力/通道/列表)→ components/DevicePanes.ets + * - device_id 兜底与在线设备解析 → common/DeviceModel.ets(路由 id 也在那) + * 本文件保留:导航栈与二级页分发、授权开关、toast、设备列表拉取与生命周期。 + */ @Component export struct DevicePage { @StorageProp('themeIsDark') private isDark: boolean = true; @@ -39,18 +45,8 @@ export struct DevicePage { private navStack: NavPathStack = new NavPathStack(); 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 - } - } + // id 判定顺序:桥里的 id 优先于持久化的 id,都没有才生成并落盘 + this.deviceId = resolveDeviceId(deviceBridge.getDeviceId()); this.authorized = connStore.getDeviceAuth(); // Gateway URL derives from current connection const cur = connStore.getCurrentConnection(); @@ -133,31 +129,7 @@ export struct DevicePage { const resp = await apiClient.get('/device/online'); if (resp.status >= 200 && resp.status < 300) { const parsed: Record = JSON.parse(resp.body) as Record; - 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 = arr[i] as Record; - 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); - } - } + const list: DeviceInfo[] = parseOnlineDevices(parsed); this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => { this.devices = list; }); @@ -177,7 +149,17 @@ export struct DevicePage { Column() { Scroll() { Column() { - this.RootEntries() + DeviceRootEntries({ + activeSub: this.activeSub, + deviceId: this.deviceId, + bridgeConnected: this.bridgeConnected, + bridgeUrl: this.bridgeUrl, + loadingDevices: this.loadingDevices, + deviceCount: this.devices.length, + onOpen: (id: string) => { + this.openSub(id); + }, + }) } .width('100%') .padding({ left: 16, right: 16, top: 76, bottom: 174 }) @@ -248,61 +230,58 @@ export struct DevicePage { }) } - /** 二级页面路由表 */ + /** + * 二级页面路由表。 + * 每个面板自带 SubPageLayer 外壳(标题/所属 Tab/返回/刷新),这里只做分发。 + */ @Builder SubDestination(name: string, param: object) { NavDestination() { if (name === SUB_LOCAL) { - SubPageLayer({ - title: '本机设备', - tab: 2, + DeviceLocalPane({ + deviceId: this.deviceId, + authorized: this.authorized, onBack: () => { this.closeSub(); }, - }) { - this.LocalDeviceContent() - } + onToggleAuth: (on: boolean) => { + this.toggleAuthorized(on); + }, + }) } else if (name === SUB_CAPS) { - SubPageLayer({ - title: '设备能力', - tab: 2, + DeviceCapsPane({ onBack: () => { this.closeSub(); }, - }) { - this.CapsContent() - } + }) } else if (name === SUB_GATEWAY) { - SubPageLayer({ - title: '设备通道', - tab: 2, + DeviceGatewayPane({ + bridgeUrl: this.bridgeUrl, + bridgeToken: this.bridgeToken, + bridgeConnected: this.bridgeConnected, onBack: () => { this.closeSub(); }, - }) { - this.GatewayContent() - } - } else if (name === SUB_LIST) { - SubPageLayer({ - title: '接入的设备', - tab: 2, - onBack: () => { - this.closeSub(); - }, - showRefresh: true, onRefresh: () => { this.refreshDevices(); }, - }) { - this.OnlineDevicesContent() - } + }) + } else if (name === SUB_LIST) { + DeviceListPane({ + devices: this.devices, + onBack: () => { + this.closeSub(); + }, + onRefresh: () => { + this.refreshDevices(); + }, + }) } } .hideTitleBar(true) .backgroundColor(Color.Transparent) } - @Builder Toast() { if (this.toastMsg.length > 0) { @@ -323,238 +302,6 @@ export struct DevicePage { .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_DEVICE_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_DEVICE_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 ? '已连接' : '未连接') - } - - PlainCard({ caption: '操作' }) { - Row() { - 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('设备通道由应用前台生命周期统一管理;切换连接配置后会自动使用新地址和 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;