# appPeripheral 快速说明

`appPeripheral` 是 `SaasAppPeripheral` 的源码目录，用于在应用运行态管理当前智能设备绑定的外设。它重点处理三类事情：云端外设连接关系、宿主端外设插件连接状态、本地 SQLite 外设绑定缓存。

这份文档面向后续维护人员和 AI，用来快速理解组件边界、数据流和修改注意事项。

## 先看哪里

- `index.tsx`：页面入口，读取当前设备、加载连接列表、组装 rows、串联新增/编辑/删除弹窗。
- `serve.ts`：后端接口封装，设备、型号、工作区和连接关系请求都从这里进入。
- `types.ts`：设备、连接关系、物理外设、SQLite 本地绑定、组件 props 等类型。
- `hooks/useConnectDeviceModal.ts`：新增已有外设、新增外设并连接、编辑连接配置的弹窗状态和 payload 组装。
- `hooks/usePeripheralRuntime.ts`：单张外设卡片的运行态连接逻辑，负责调用宿主 peripheral 插件。
- `services/peripheralConnectionService.ts`：云端连接状态接口、插件连接结果和 SQLite 缓存之间的服务层。
- `utils/peripheralRuntime.ts`：外设查找参数、物理设备解析、本地绑定 row 合并等纯工具。
- `components/`：连接弹窗、外设卡片、物理设备选择、用途选择、编辑设备弹窗等 UI。
- `locales.ts`：静态文案。后端多语言字段展示走 `translation(value)`，不要从这里取。

低代码引擎通过 `useEngineContext()` 注入 `appHelper.utils.request` 和 `app` 实例。入口组件会调用 `request.setRequest(utils.request)`；插件和 SQLite 从 `utils.getApp()` 获取。

## 当前功能

- 从 `app.storage.iotDevice` 读取当前智能设备 id，作为外设管理的 source device。
- 加载当前智能设备详情、工作区列表、哑设备型号、外设连接列表。
- 连接列表只展示 `type=dumb` 的设备连接关系。
- 支持连接已有外设，也支持新增一个哑设备外设并建立连接。
- 支持编辑外设设备基础信息，编辑连接配置和 profiles。
- 支持只删除连接关系，或连同目标外设设备一起删除。
- 每张外设卡片会自动检查一次物理设备连接状态，并支持手动连接、断开、选择多候选物理设备。
- 标签打印机 `label_printer` 支持选择业务用途：`label` 或 `wristband`。
- 对外暴露 `onTest(payload)`，用于业务侧拿到当前连接、目标设备、物理设备和用途后执行测试。
- 支持 responsive variant，`phone` 下弹窗走移动端样式。

## 核心数据模型

页面最终渲染的是 `PeripheralConnectionRow`：

```ts
interface PeripheralConnectionRow {
  id: number;
  peripheralId: number;
  connection: DeviceConnectionItem;
  peripheral?: DeviceItem | null;
  physical?: PeripheralDevice;
  usage?: PeripheralUsage;
}
```

字段含义：

- `connection` 是云端 `/tenant/device/connection` 返回的原始连接关系。
- `peripheral` 是 `connection.target_device`，即被连接的外设设备。
- `physical` 是宿主 peripheral 插件或本地 SQLite 缓存得到的物理设备状态。
- `usage` 是本地业务用途，只保存在 SQLite，不属于云端连接配置。

云端连接和本地运行态必须隔离。刷新接口时只替换 `connection/peripheral`，通过 `mergeConnectionRows()` 保留当前 row 已有的 `physical/usage`。

## 主要数据流

```mermaid
flowchart TD
  Storage["app.storage.iotDevice"] --> CurrentDevice["getDeviceDetail"]
  Storage --> ConnectionList["GET /tenant/device/connection?type=dumb"]
  ConnectionList --> LocalSync["syncLocalPeripheralBindings"]
  LocalSync --> Rows["PeripheralConnectionRow[]"]
  Rows --> Cards["PeripheralConnectionCardContainer"]
  Cards --> RuntimeHook["usePeripheralRuntime"]
  RuntimeHook --> Plugin["app.getPlugin('peripheral')"]
  RuntimeHook --> StatusApi["connected/disconnected API"]
  RuntimeHook --> SQLite["peripheral_bindings"]
  Plugin --> Rows
  SQLite --> Rows
```

首屏加载在 `index.tsx` 中并行执行：

- `getDeviceDetail(storedIotDevice.id)` 获取当前智能设备。
- `getWorkAreas()` 获取编辑外设时的工作区选项。
- `getDeviceTypes({ kind: 'dumb' })` 获取可新增的哑设备型号。
- `getDeviceConnections({ type: 'dumb', source_device_id })` 获取当前智能设备的外设连接。

## 连接关系接口契约

当前 `device_connection.type` 只启用一种业务类型：

```ts
export type DeviceConnectionType = 'dumb';
```

列表固定按 `type=dumb` 查询：

```ts
GET /tenant/device/connection?type=dumb&source_device_id={currentDeviceId}
```

创建连接必须提交 `type: 'dumb'`：

```ts
{
  type: 'dumb',
  source_device_id: smartDevice.id,
  target_device_action: 'existing',
  target_device_id: peripheral.id,
  config: {
    connection_type: 'usb'
  },
  profile_ids: []
}
```

新增外设并连接时不单独调用创建设备接口，而是通过连接接口嵌套创建 `target_device`：

```ts
{
  type: 'dumb',
  source_device_id: smartDevice.id,
  target_device_action: 'new',
  target_device: {
    workarea_id,
    model_code,
    name
  },
  config,
  profile_ids
}
```

编辑连接只提交 `config` 和 `profile_ids`，不要提交 `type`。后端创建后不允许变更连接业务类型。

## 连接弹窗

`ConnectExistingDeviceModal` 由 `useConnectDeviceModal` 驱动，支持三种模式：

- `existing`：选择已有目标设备，再填写连接配置。
- `new`：选择哑设备型号，填写新外设基础信息，再填写连接配置。
- `edit`：直接进入配置页，只编辑连接配置和 profiles。

连接配置规则：

- `connection_type` 必填。
- `ethernet` 和 `wifi` 需要 `connection_method`。
- `connection_method === 'manual'` 时才提交 `connection_ip`。
- `profile_ids` 是否必填由型号 `profile_structure.required` 决定。

当前页面以智能设备为主入口，因此 `source_device_id` 始终是当前智能设备，`target_device` 是外设。

## 物理外设运行态

`usePeripheralRuntime` 是每张卡片的运行态控制器：

- 自动探测：卡片首次挂载时静默执行一次 `handleConnectPeripheral(row, true)`。
- 查找物理设备：根据连接配置生成 `findList` 参数，同时查 `CONNECTED` 和 `NOT_CONNECT`。
- 连接优先级：已有 uuid -> 手动 ip -> 自动发现。
- 多候选：插件返回多个候选时进入 `select` 状态，打开 `PhysicalDeviceSelectModal`。
- 连接成功：调用插件 `connect`，再调用 `/connected` 接口写入 `_target_device.data`，最后写入 SQLite。
- 断开：调用 `/disconnected` 接口，并写入 `is_connect=false` 的本地绑定。
- 状态变化：宿主监听原生 `statusChange` 并发布 `peripheral.statusChange`，组件只订阅 pubsub，同步页面、云端和 SQLite。

云端接口只记录连接关系和目标设备 data；本地 SQLite 表 `peripheral_bindings` 记录物理设备、连接状态和用途，作为运行态补充。

## API 对照

- `GET /tenant/device/:id`：当前智能设备详情。
- `GET /tenant/device/workarea`：工作区列表，供编辑外设选择工作区。
- `GET /tenant/device/type/all?kind=dumb`：哑设备类型、型号、active profiles。
- `GET /tenant/device`：连接已有外设时按工作区和设备类型查询设备。
- `GET /tenant/device/connection`：连接列表，当前固定带 `type=dumb`。
- `GET /tenant/device/connection/:id`：连接详情，用于编辑连接配置回填。
- `POST /tenant/device/connection`：创建连接，必须提交 `type: 'dumb'`。
- `PUT /tenant/device/connection/:id`：编辑连接配置，不提交 `type`。
- `DELETE /tenant/device/connection/:id`：删除连接关系。
- `PUT /tenant/device/connection/:id/connected`：物理外设连接成功后同步云端状态。
- `PUT /tenant/device/connection/:id/disconnected`：物理外设断开后同步云端状态。
- `PUT /tenant/device/:id`：编辑目标外设基础信息。
- `DELETE /tenant/device/:id`：删除目标外设设备。

## 修改注意事项

- 修改连接创建 payload 时，优先检查 `hooks/useConnectDeviceModal.ts` 的 `handleSubmit()`。
- `type: 'dumb'` 是当前后端必填契约；新增业务类型前不要把它做成 UI 可选项。
- 编辑连接不要传 `type`，只更新 `config/profile_ids`。
- 不要把物理设备状态写进 `connection.config`；插件结果应通过 `/connected` 写入目标设备 data，并缓存到 SQLite。
- 接口刷新不要覆盖 `physical/usage`，保持 `mergeConnectionRows()` 的隔离语义。
- `label_printer` 的 usage 是本地业务选择，缺失时连接后会弹出用途选择。
- 多语言字段展示用 `translation(value)`；静态 UI 文案用 `getText(key)`。
- request client 不要在子模块重复创建，入口已通过低代码上下文注入。

## 最小回归路径

1. 打开页面，确认有当前 `iotDevice` 时能加载 `type=dumb` 外设连接列表。
2. 连接已有外设，确认创建请求包含 `type: 'dumb'`。
3. 新增外设并连接，确认嵌套创建目标设备且请求包含 `type: 'dumb'`。
4. 编辑连接配置，确认请求只包含 `config/profile_ids`。
5. 连接、断开物理外设，确认云端状态和本地 SQLite 状态同步。
6. 对 `label_printer` 选择 `label/wristband` 用途后刷新页面，确认用途来自本地缓存。
7. 删除连接关系和删除外设设备两条路径分别验证。
