refactor(ohos): DevicePage 560→307 行(入口列表/四个面板/模型拆分)

- 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。
This commit is contained in:
root
2026-09-13 22:34:20 +08:00
parent 8a9fc05451
commit e733d05a5e
4 changed files with 540 additions and 312 deletions

View File

@ -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<string, Object>): 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<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);
}
return list;
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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<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);
}
}
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;