# appPeripheral 外设管理说明

`appPeripheral` 是 `SaasAppPeripheral` 的源码目录，用于在 POS 运行态管理当前智能设备绑定的外设。它只负责外设管理 UI、插件调用结果回写、本地 SQLite 缓存和业务用途选择；云端 connected / disconnected 状态上报由宿主 `saas_shop_pos` 的全局 `statusChange` 监听统一处理。

## 模块边界

- `index.tsx`：页面入口。读取当前智能设备、加载连接列表、合并本地 SQLite 状态、订阅 `peripheral.statusChange` 刷新页面，并负责页面级渲染编排。
- `serve.ts`：后端接口封装。当前物料侧只保留设备、工作区、型号、连接列表、创建连接、删除连接、编辑外设基础信息等接口。
- `types.ts`：云端设备、连接关系、插件返回外设、本地 SQLite 绑定、设备事实状态、设置流程和组件 props 等类型。
- `hooks/useConnectDeviceModal.ts`：新增已有外设 / 新增外设并连接的弹窗状态和 payload 组装。
- `hooks/usePeripheralLogger.ts`：本模块统一日志 hook，内部通过 `useEngineContext()` 获取 app 并调用 `app.logger?.addLog`。
- `hooks/usePeripheralRuntime.ts`：单张外设卡片的手动动作控制器，负责 `connect/remove`、保存已连接候选、更新打印机用途，并把结果写入 SQLite。
- `hooks/usePeripheralSetupFlow.ts`：页面级设置流程控制器，负责 `findListListener`、`CONNECTED` 校准、`NOT_CONNECT` 搜索、候选设备回填和新增后自动打开选择弹窗。
- `services/peripheralDiscoveryService.ts`：封装 `findList` 的两种契约：`CONNECTED` 一次性校准读取返回值，`NOT_CONNECT` 持续发现只启动搜索。
- `services/peripheralConnectionService.ts`：插件结果与 SQLite 之间的服务层，不再直接调用 connected / disconnected 云端接口。
- `utils/peripheralRuntime.ts`：连接参数构造、`connection.data` 解析、设备匹配、状态推导、渲染行合并、本地 binding 创建等纯工具。
- `components/PeripheralConnectionCard.tsx`：外设列表卡片。
- `components/PeripheralActionModal.tsx`：点击卡片后的操作弹窗。
- `components/PhysicalDeviceSelectModal.tsx`：多个物理设备候选时的选择弹窗。
- `components/EditDeviceModal.tsx`：编辑外设设备基础信息。
- `locales.ts`：静态文案。

低代码运行时通过 `useEngineContext()` 注入 `appHelper.utils.request` 和 `app` 实例。入口组件会调用 `request.setRequest(utils.request)`；插件、SQLite、pubsub 都通过 `utils.getApp()` 获取。

## 数据来源

### 云端连接关系

连接列表来自：

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

每条连接关系保留后端原始字段：

```ts
interface DeviceConnectionItem {
  id: number;
  source_device_id: number;
  target_device_id: number;
  data?: Record<string, unknown> | null;
  config?: DeviceConnectionConfig | null;
  target_device?: DeviceItem | null;
  profiles?: DeviceProfileItem[];
}
```

关键约定：

- `connection.data` 与 `connection.config` 平级，保存 `statusChange` 得到的连接状态信息，例如 `uuid/ip/mac/port/is_connect/connect_type/status_code`。
- `connection.config` 保存人工配置，例如 `connection_type`、`connection_method`、`connection_ip`。
- `connection.target_device.data` 保存 `getPeripheralData({ uuid })` 返回的设备自身遥测数据，不作为连接状态来源。

### 本地 SQLite

本地表名是 `peripheral_bindings`。它保存页面运行态需要的补充信息：

```ts
interface LocalPeripheralBinding {
  id: string;
  connection_id: number;
  source_device_id: number;
  target_device_id: number;
  physical_uuid?: string;
  physical_device?: PeripheralDevice;
  peripheral_usage?: 'label' | 'wristband';
  is_connect: boolean;
  payload: DeviceConnectionItem;
}
```

SQLite 的职责：

- 缓存插件返回的物理设备状态，避免刷新页面后丢失运行态。
- 保存标签打印机的业务角色 `label/wristband`。
- 首屏加载时清理云端已经不存在的 connection 对应的本地旧 binding。

### 页面渲染行

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

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

组装规则：

- `connection/peripheral` 来自云端连接列表。
- `physical` 初始优先来自 `connection.data`，其次来自 SQLite。
- `usage` 只来自 SQLite。
- 刷新云端连接列表时，`mergeConnectionRows()` 只替换云端字段，保留已有 `physical/usage`。

## 初始化与发现流程

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

1. 从 `app.storage.iotDevice` 读取当前智能设备 id。
2. 调用 `getDeviceDetail()` 获取当前智能设备详情。
3. 调用 `getWorkAreas()` 和 `getDeviceTypes({ kind: 'dumb' })`，用于新增和编辑弹窗。
4. 调用 `getDeviceConnections()` 获取当前设备绑定的外设连接。
5. 调用 `syncLocalPeripheralBindings()` 清理本地 stale binding。
6. 通过 `mergeConnectionRows()` 生成 `PeripheralConnectionRow[]`。

首屏会等待云端连接关系和 SQLite binding 合并后再渲染，避免临时 `empty` 状态误触发搜索。

设备事实状态和设置流程分开维护：

- `empty`：业务连接存在，但没有绑定物理设备。
- `connected`：已绑定物理设备，且原生确认已连接。
- `disconnected`：已绑定物理设备，但原生确认未连接或连接失败。
- `idle`：没有正在执行的设置流程。
- `searching`：通过 `NOT_CONNECT` 持续发现候选设备。
- `connecting`：正在调用原生 `connect`。
- `configuring`：物理连接完成，但还需要补充业务设置，例如标签打印机用途。

发现流程由 `usePeripheralSetupFlow()` 统一管理：

1. 页面挂载时注册 `findListListener`，退出时只移除本页面监听并调用 `stopFindList()`。
2. 首次渲染行稳定后执行 `findList({ deviceBrand: 'other', connectStatus: 'CONNECTED' })` 做一次性已连接校准。
3. `CONNECTED` 校准只修正已绑定设备，不把未知物理设备自动绑定到 `empty` 行。
4. `empty + config.connection_method === 'manual' + config.connection_ip` 会直接进入 `connecting` 并按 IP 连接。
5. 其他 `empty` 行会调用 `findList({ deviceBrand, deviceType, connectType, connectStatus: 'NOT_CONNECT' })` 启动持续发现。
6. `findList(NOT_CONNECT)` 的返回值不参与渲染，候选设备只从 `findListListener` 获取。
7. `findListListener` 返回的全量未连接设备，会按当前行的品牌、设备类型、连接方式匹配后写入 `setupFlowMap[row.id].candidates`。
8. 新增自动发现类连接后，会在对应行进入 `searching` 时自动打开 `PhysicalDeviceSelectModal`。

## 插件调用流程

物料侧只依赖宿主注册的 `peripheral` 插件，当前使用的方法：

- `findList(params)`：按品牌、类型、连接方式、连接状态查找设备。
- `addListener('findListListener', cb)`：监听未连接设备持续发现结果。
- `stopFindList()`：停止原生持续发现，不应清空其他监听。
- `connect(params)`：连接指定物理设备。
- `remove({ uuid })`：忽略连接时清理原生侧记录。

连接参数由 `buildPeripheralConnectParams()` 生成：

```ts
{
  uuid?: string;
  ip?: string;
  deviceBrand: string;
  deviceType: string;
  connectType: 'USB' | 'ETHERNET' | 'WIFI' | 'BLUETOOTH';
}
```

`deviceBrand/deviceType/connectType` 来自 `connection.target_device` 和 `connection.config`。不要从未知字段推断品牌或类型。

连接失败文案会根据连接方式区分：

- `ETHERNET/WIFI`：提示检查网络连通性和设备 IP。
- `BLUETOOTH`：提示检查蓝牙开关、配对状态和距离。
- `USB`：提示检查线缆、接口和设备授权。
- 未识别连接方式时使用通用失败文案。

## 状态同步流程

### 物料侧

物料侧订阅：

```ts
peripheral.statusChange;
```

收到事件后：

1. 通过 `binding.connection_id` 或 `uuid` 找到当前 row。
2. 调用 `savePeripheralConnectionStatus()` 写 SQLite。
3. 回写 row 的 `physical/usage`。

物料侧不再调用：

- `PUT /tenant/device/connection/:id/connected`
- `PUT /tenant/device/connection/:id/disconnected`

### 宿主侧

宿主 `saas_shop_pos` 的全局监听负责云端同步：

1. Native `statusChange` 触发。
2. 宿主读取 `peripheral_bindings`，查找本地 binding。
3. 如果本地 binding 不存在，记录日志并跳过云端接口。
4. 如果存在 binding，先更新 SQLite，再根据 `is_connect` 调 connected / disconnected。
5. connected 时额外调用 `getPeripheralData({ uuid })`，把结果写入 `target_device.data`。
6. 心跳 `connected_target_devices` 也通过 `getPeripheralData` 获取设备自身信息。

这个职责迁移的目的是保证即使外设设置页没有打开，连接状态也能由宿主统一上报。

## 卡片交互

卡片右侧只展示两类按钮：

- `Set Up`：未绑定物理设备，或已连接的 `label_printer` 缺少 usage 时展示。
- `Connect`：仅在断连状态展示，点击后调用插件连接。

卡片点击行为：

- 如果当前是未绑定物理设备，点击卡片直接打开物理设备选择弹窗。
- 其他状态点击卡片打开 `PeripheralActionModal`。

物理设备选择弹窗规则：

- 候选为空且仍在搜索时，弹窗中间展示 loading 和查找中文案。
- 候选非空且仍在搜索时，loading 展示在标题右侧，列表保持稳定不抖动。
- 蓝牙设备如果 `metadata.rssi` 存在，会在设备信息下方展示 RSSI。
- `metadata.possibly_associated === true` 的候选设备展示“可能已被关联”标识；点击后先二次确认，取消不触发连接，确认后才继续现有连接流程。
- 弹窗底部提供 `Forget Connection` 和 `Close`；点击 `Forget Connection` 会打开二次确认弹窗，但不主动关闭选择设备弹窗。

操作弹窗包含：

- 顶部设备摘要和连接状态。
- 详情区，只展示 `Connection Type` 和可识别的 `IP/Mac/Port`。
- `label_printer` 的打印机角色选择：`label` / `wristband`。
- `Test`：如果当前设备是可测试打印机且 usage 已满足要求，则优先展示。
- `Connect`。
- `Edit`：编辑外设设备基础信息。
- `Forget Connection`：二次确认后删除云端连接关系，并调用插件 `remove({ uuid })` 清理原生侧记录。

## usage 规则

只有 `label_printer` 需要业务用途：

- `label`：标签打印。
- `wristband`：手环打印。

usage 只保存到 SQLite，不写云端 connection，也不写 target device。缺少 usage 时：

- 卡片右侧展示 `Set Up`。
- 卡片提示只展示“完成功能设置”，不会提示选择物理设备。
- 操作弹窗中打印机角色区域标红并提示选择角色。
- `Test` 按钮不展示，避免业务侧拿到不完整打印上下文。

## 新增连接流程

### 连接已有外设

1. 点击页面右上角 Add -> Connect Existing Peripheral。
2. 选择工作区。
3. 选择一个未连接到当前设备的外设。
4. 填写连接配置和 profiles。
5. 提交：

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

新增成功后：

- 如果连接是手动 IP 模式，不自动打开选择设备弹窗。
- 如果连接需要自动发现物理设备，等待连接列表刷新并进入 `searching` 后，自动打开该行的选择设备弹窗。
- 如果接口没有返回新 connection id，不会猜测匹配，避免误打开其他行。

连接配置里的 `Connection Type` 不是固定展示全部。规则如下：

- 已有外设：优先用外设 `model_code` 在完整型号列表中找到对应型号，再读取 `model.data.connection_types`。
- 新增外设：读取已选择型号的 `selectedModel.data.connection_types`。
- 如果型号未配置 `connection_types`，展示全部连接方式。
- 如果配置了 `connection_types` 但过滤后没有命中当前支持的连接方式，也回退展示全部连接方式。
- 切换外设或型号时会重置表单里的 `connection_type/connection_method/connection_ip/profile_ids`，避免沿用上一台设备不支持的连接方式。

### 新增外设并连接

1. 点击页面右上角 Add -> Add New Peripheral。
2. 选择外设型号。
3. 填写外设名称、工作区、连接配置和 profiles。
4. 提交：

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

## 编辑外设

编辑入口在操作弹窗内。当前只编辑目标外设设备基础信息：

- 工作区。
- 名称。
- profile 相关字段保持与原有编辑弹窗一致。

编辑成功后刷新连接列表，但不主动覆盖运行态 `physical/usage`。

## 删除与忽略连接

当前只保留“忽略连接 / Forget Connection”：

1. 操作弹窗点击 `Forget Connection`。
2. 弹出确认弹窗。
3. 确认后调用 `DELETE /tenant/device/connection/:id`。
4. 再调用插件 `remove({ uuid })`。
5. 删除本地 SQLite binding。
6. 刷新云端连接列表。

不再提供：

- 主动断开连接入口。
- 删除目标外设设备入口。

## 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`。
- `POST /tenant/device/connection`：创建连接，必须提交 `type: 'dumb'`。
- `DELETE /tenant/device/connection/:id`：忽略连接时删除连接关系。
- `PUT /tenant/device/:id`：编辑目标外设基础信息。

connected / disconnected 接口由宿主全局 statusChange 监听调用，不在本目录调用。

## 日志

本目录统一使用 `usePeripheralLogger()` 或服务层内部 helper 写日志。日志入口最终调用：

```ts
app.logger?.addLog({
  type,
  title,
  metadata,
});
```

当前覆盖的关键节点：

- 页面初始化、连接列表加载、pubsub 状态回写。
- Add 菜单点击、创建连接、编辑外设、删除连接。
- 连接弹窗打开、基础数据加载、设备列表加载、选择设备、选择型号、提交配置。
- 卡片点击、打开物理设备选择、操作弹窗打开/关闭、编辑、连接、重连、测试、选择打印机角色、忘记连接。
- 插件交互：`findList/connect/remove` 的开始、成功、失败。
- SQLite 写入、删除、同步本地 binding。
- `CONNECTED` 校准、`NOT_CONNECT` 持续发现、候选设备回填、手动 IP 自动连接、设置流程切换。

日志 metadata 尽量带 `connectionId`、`targetDeviceId`、`uuid`、`connectType`、`usage`、`error` 等字段，便于跨页面和宿主日志排查。

## Web mock

宿主 `saas_shop_pos/src/plugins/NativePeripheralPlugin/web.ts` 暴露真实插件方法：

- `findList`
- `connect`
- `disconnect`
- `remove`
- `getPeripheralData`
- `statusChange`
- `findListListener`
- `stopFindList`

Web mock 设备池按矩阵生成：

- `xprinter/sunmi/gprinter`：覆盖 `receipt_printer/label_printer/scanner` 和 `USB/ETHERNET/WIFI/BLUETOOTH` 的组合。
- `zebra`：当前只覆盖 `scanner` 和 `USB/ETHERNET/WIFI/BLUETOOTH`，不 mock Zebra 打印机。

Web mock 连接状态保存在 localStorage 的 `NativePeripheralPluginWeb:connections` 中，开发调试需要重置时可手动清理该 key。

- `findList(CONNECTED)` 返回 localStorage 中已连接的设备。
- `findList(NOT_CONNECT)` 启动持续发现，并通过 `findListListener` 推送未连接设备。
- `connect` 会写入 localStorage，并触发 `statusChange`。
- `disconnect/remove` 会从 localStorage 移除连接状态，并触发 `statusChange`。
- 蓝牙 mock 设备会在 `metadata.rssi` 中返回信号强度。

`findList` 会按 `deviceBrand/deviceType/connectType/connectStatus/uuid` 过滤，`connect` 会校验品牌、类型和连接方式是否匹配。

## 修改注意事项

- 连接状态读取只从 `connection.data` 或 SQLite 获取，不要回退到 `target_device.data`。
- `target_device.data` 是 `getPeripheralData` 的结果，表示设备自身信息。
- 手动 IP 连接必须使用 `connection.config.connection_ip`，不要进入自动发现流程。
- `CONNECTED` 和 `NOT_CONNECT` 是两个不同契约：前者读取 `findList` 返回值，后者只启动持续搜索并从 `findListListener` 读候选。
- 自动发现候选时不要自动选择设备，必须让用户确认。
- usage 只写 SQLite，不写云端。
- 删除连接后不要删除目标外设设备。
- 输入框类控件需要支持清空；搜索框、手动 IP 输入框都应带 `allowClear`。
- 多语言输入框使用 `BaseTranslation allowClear`。清空后不会变成 `undefined/null`，而是保留多语言对象结构并把对应字段置为空字符串。
- 多语言字段展示用 `translation(value)`；静态 UI 文案用 `getText(key)`。
- 记录日志不要为了日志往组件层层传 `getAppInstance`；组件内优先使用 `usePeripheralLogger()`。只有插件/SQLite 业务调用链才保留 `getAppInstance` 参数。

## 最小回归路径

1. 打开页面，确认能从 `connection.data` 或 SQLite 恢复初始状态。
2. `connection.data` 为空但 `config.connection_ip` 存在时，确认能自动连接且不打开设备选择弹窗。
3. 自动发现设备时，点击卡片和 `Set Up` 都能打开设备选择弹窗。
4. 新增自动发现外设后，确认连接列表刷新并自动打开对应行的设备选择弹窗。
5. 蓝牙候选设备展示 RSSI，非蓝牙设备不展示 RSSI。
6. 断连状态点击 `Connect`，失败后状态能回到 disconnected，弹窗可关闭。
7. 已连接状态点击卡片，确认操作弹窗展示 `Connection Type` 和 `IP/Mac/Port`。
8. `label_printer` 未选择角色时，确认角色区域提示并隐藏 `Test`。
9. 选择 `label/wristband` 后刷新页面，确认 usage 从 SQLite 恢复。
10. 点击 `Test`，确认按钮 loading 与业务侧 `onTest` 结果一致。
11. 在选择设备弹窗点击 `Forget Connection`，确认不会先关闭选择弹窗，二次确认后再删除连接、插件 remove 和刷新列表。
12. 选择带 `data.connection_types` 的型号，确认连接方式只展示该型号支持的类型；空配置或过滤为空时展示全部。
13. 触发一次连接失败，确认不同连接方式展示对应排查建议。
14. 查看日志，确认点击、插件调用、状态同步和 SQLite 节点有对应 `app.logger` 记录。
