21 Commits 23672e730d ... 3ed920d61e

Autor SHA1 Mensagem Data
  yb 3ed920d61e chore: bump version to 1.0.37 for release v1.0.37 13 horas atrás
  yb 8691df98e3 fix(api): update PTZ control test to match new endpoint path 13 horas atrás
  yb 06990a06a2 fix(api): correct PTZ control endpoint path in camera tests 13 horas atrás
  yb 0c2fa3c8cf 本地转发了 不需要了 13 horas atrás
  yb f4894a301c refactor(api): simplify camera API endpoint paths 14 horas atrás
  yb 56eae999ff update code 14 horas atrás
  yb d7288bc3d2 update code 14 horas atrás
  yb e16285ee4b refactor(camera): update PTZ control interface and API endpoints 14 horas atrás
  yb 90e83e8b8b update code 14 horas atrás
  yb ccad7a938c chore: bump version to 1.0.36 for release v1.0.36 15 horas atrás
  yb 654ac82fd7 feat(camera-scan): update device discovery response type and enhance binding functionality 15 horas atrás
  yb 1b8227ce66 update code 15 horas atrás
  yb a5037d0643 feat(lss): enhance device scanning logic and user feedback 16 horas atrás
  yb db4133e16d chore: bump version to 1.0.35 for release v1.0.35 17 horas atrás
  yb 092b58318b feat(live-stream): enhance StreamEditForm with ref and improve form handling 17 horas atrás
  yb ac2aec67cf chore: bump version to 1.0.34 for release v1.0.34 18 horas atrás
  yb 3a332239f9 feat(lss): refactor LSS view and introduce composables for better organization 18 horas atrás
  yb 9b99d8f1e4 feat(camera-scan): implement camera scanning and credential management API 19 horas atrás
  yb 62d964bb14 refactor(lss): enhance input fields and table components for improved readability 21 horas atrás
  yb 18d3aab9e4 refactor(camera-vendor): streamline input fields and table components for improved readability 21 horas atrás
  yb 7d4713863a fix style 22 horas atrás

+ 1027 - 0
docs/api_torna/camera-scan.md

@@ -0,0 +1,1027 @@
+# 文档
+
+## 管理后台
+
+## 摄像头扫描管理
+
+### 触发 ONVIF 设备扫描
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/admin/lss/{lssId}/scan
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/admin/lss/{lssId}/scan
+
+描述:触发 ONVIF 设备扫描
+
+ContentType:`application/x-www-form-urlencoded;charset=UTF-8`
+
+#### Path 参数
+
+| 名称  | 必填 | 描述        | 示例值 |
+| ----- | ---- | ----------- | ------ |
+| lssId | 是   | LSS 节点 ID |        |
+
+#### 请求参数
+
+#### 响应参数
+
+| 名称 | 类型 | 必填 | 最大长度 | 描述 | 示例值 |
+| --- | --- | --- | --- | --- | --- |
+| success | Boolean | 否 | - | 请求是否成功 | true |
+| errCode | String | 否 | - | 错误码(失败时返回) |  |
+| errMessage | String | 否 | - | 错误信息(失败时返回) |  |
+| data | object | 否 |  | 响应数据 (ActualType: ScanResultDTO) |  |
+| └ taskId | String | 否 | - | 扫描任务 ID | scan_abc12345 |
+| └ lssId | String | 否 | - | LSS 节点 ID | lss_001 |
+| └ status | String | 否 | - | 扫描状态: SCANNING / COMPLETED / FAILED | SCANNING |
+| └ discoveredCount | Integer | 否 | - | 发现的设备数量 | 5 |
+| └ newCount | Integer | 否 | - | 新发现的设备数量 | 3 |
+| └ devices | List<DiscoveredCameraDTO> | 否 |  | 发现的设备列表 (ActualType: DiscoveredCameraDTO) |  |
+| └ id | Long | 否 | - | ID | 1 |
+| └ lssId | String | 否 | - | LSS 节点 ID | lss_001 |
+| └ ip | String | 否 | - | IP 地址 | 192.168.1.100 |
+| └ port | Integer | 否 | - | 端口 | 80 |
+| └ uuid | String | 否 | - | 设备 UUID | uuid-1234-5678 |
+| └ deviceName | String | 否 | - | 设备名称 | IP Camera |
+| └ vendor | String | 否 | - | 厂商 | HIKVISION |
+| └ model | String | 否 | - | 型号 | DS-2CD2T45FWD |
+| └ serialNumber | String | 否 | - | 序列号 | SN123456789 |
+| └ rtspUrl | String | 否 | - | RTSP URL | rtsp://admin:12345@192.168.1.100:554/stream1 |
+| └ matchStatus | String | 否 | - | 匹配状态: PENDING / MATCHING / MATCHED / UNMATCHED | PENDING |
+| └ matchedCredentialId | Long | 否 | - | 匹配成功的凭证 ID | 1 |
+| └ matchAttempts | Integer | 否 | - | 匹配尝试次数 | 0 |
+| └ lastMatchAttempt | LocalDateTime | 否 | - | 最后匹配时间 | 2026-01-27T10:00:00 |
+| └ matchError | String | 否 | - | 匹配错误信息 | Authentication failed |
+| └ deviceInfo | String | 否 | - | 设备详细信息 JSON | {manufacturer:HIKVISION,model:DS-2CD2T45FWD,firmwareVersion:V5.6.5} |
+| └ bound | Boolean | 否 | - | 是否已绑定到 camera_info | false |
+| └ boundCameraId | Long | 否 | - | 绑定的 camera_info.id | 1 |
+| └ canMatch | Boolean | 否 | - | 是否可以匹配(未超过 3 次或已过 24 小时冷却期) | true |
+| └ discoveredAt | LocalDateTime | 否 | - | 发现时间 | 2026-01-27T10:00:00 |
+| └ error | String | 否 | - | 错误信息 |  |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {
+        "taskId": "scan_abc12345",
+        "lssId": "lss_001",
+        "status": "SCANNING",
+        "discoveredCount": 5,
+        "newCount": 3,
+        "devices": [
+            {
+                "id": 1,
+                "lssId": "lss_001",
+                "ip": "192.168.1.100",
+                "port": 80,
+                "uuid": "uuid-1234-5678",
+                "deviceName": "IP Camera",
+                "vendor": "HIKVISION",
+                "model": "DS-2CD2T45FWD",
+                "serialNumber": "SN123456789",
+                "rtspUrl": "rtsp://admin:12345@192.168.1.100:554/stream1",
+                "matchStatus": "PENDING",
+                "matchedCredentialId": 1,
+                "matchAttempts": 0,
+                "lastMatchAttempt": "2026-01-27T10:00:00",
+                "matchError": "Authentication failed",
+                "deviceInfo": "{manufacturer:HIKVISION,model:DS-2CD2T45FWD,firmwareVersion:V5.6.5}",
+                "bound": false,
+                "boundCameraId": 1,
+                "canMatch": true,
+                "discoveredAt": "2026-01-27T10:00:00"
+            }
+        ],
+        "error": "string"
+    }
+}
+```
+
+#### 错误码
+
+无
+
+### 获取发现的设备列表
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `GET` http://localhost:10050/api/admin/lss/{lssId}/discovered
+- 开发环境: `GET` https://tg-live-game.pwtk.cc/api/admin/lss/{lssId}/discovered
+
+描述:获取发现的设备列表
+
+ContentType:`application/x-www-form-urlencoded;charset=UTF-8`
+
+#### Path 参数
+
+| 名称  | 必填 | 描述        | 示例值 |
+| ----- | ---- | ----------- | ------ |
+| lssId | 是   | LSS 节点 ID |        |
+
+#### 请求参数
+
+#### 响应参数
+
+| 名称 | 类型 | 必填 | 最大长度 | 描述 | 示例值 |
+| --- | --- | --- | --- | --- | --- |
+| success | Boolean | 否 | - | 请求是否成功 | true |
+| errCode | String | 否 | - | 错误码(失败时返回) |  |
+| errMessage | String | 否 | - | 错误信息(失败时返回) |  |
+| data | array | 否 |  | 响应数据 (ActualType: List) |  |
+| └ id | Long | 否 | - | ID | 1 |
+| └ lssId | String | 否 | - | LSS 节点 ID | lss_001 |
+| └ ip | String | 否 | - | IP 地址 | 192.168.1.100 |
+| └ port | Integer | 否 | - | 端口 | 80 |
+| └ uuid | String | 否 | - | 设备 UUID | uuid-1234-5678 |
+| └ deviceName | String | 否 | - | 设备名称 | IP Camera |
+| └ vendor | String | 否 | - | 厂商 | HIKVISION |
+| └ model | String | 否 | - | 型号 | DS-2CD2T45FWD |
+| └ serialNumber | String | 否 | - | 序列号 | SN123456789 |
+| └ rtspUrl | String | 否 | - | RTSP URL | rtsp://admin:12345@192.168.1.100:554/stream1 |
+| └ matchStatus | String | 否 | - | 匹配状态: PENDING / MATCHING / MATCHED / UNMATCHED | PENDING |
+| └ matchedCredentialId | Long | 否 | - | 匹配成功的凭证 ID | 1 |
+| └ matchAttempts | Integer | 否 | - | 匹配尝试次数 | 0 |
+| └ lastMatchAttempt | LocalDateTime | 否 | - | 最后匹配时间 | 2026-01-27T10:00:00 |
+| └ matchError | String | 否 | - | 匹配错误信息 | Authentication failed |
+| └ deviceInfo | String | 否 | - | 设备详细信息 JSON | {manufacturer:HIKVISION,model:DS-2CD2T45FWD,firmwareVersion:V5.6.5} |
+| └ bound | Boolean | 否 | - | 是否已绑定到 camera_info | false |
+| └ boundCameraId | Long | 否 | - | 绑定的 camera_info.id | 1 |
+| └ canMatch | Boolean | 否 | - | 是否可以匹配(未超过 3 次或已过 24 小时冷却期) | true |
+| └ discoveredAt | LocalDateTime | 否 | - | 发现时间 | 2026-01-27T10:00:00 |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": [
+        {
+            "id": 1,
+            "lssId": "lss_001",
+            "ip": "192.168.1.100",
+            "port": 80,
+            "uuid": "uuid-1234-5678",
+            "deviceName": "IP Camera",
+            "vendor": "HIKVISION",
+            "model": "DS-2CD2T45FWD",
+            "serialNumber": "SN123456789",
+            "rtspUrl": "rtsp://admin:12345@192.168.1.100:554/stream1",
+            "matchStatus": "PENDING",
+            "matchedCredentialId": 1,
+            "matchAttempts": 0,
+            "lastMatchAttempt": "2026-01-27T10:00:00",
+            "matchError": "Authentication failed",
+            "deviceInfo": "{manufacturer:HIKVISION,model:DS-2CD2T45FWD,firmwareVersion:V5.6.5}",
+            "bound": false,
+            "boundCameraId": 1,
+            "canMatch": true,
+            "discoveredAt": "2026-01-27T10:00:00"
+        }
+    ]
+}
+```
+
+#### 错误码
+
+无
+
+### 获取发现设备详情
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `GET` http://localhost:10050/api/admin/discovered/{id}
+- 开发环境: `GET` https://tg-live-game.pwtk.cc/api/admin/discovered/{id}
+
+描述:获取发现设备详情
+
+ContentType:`application/x-www-form-urlencoded;charset=UTF-8`
+
+#### Path 参数
+
+| 名称 | 必填 | 描述        | 示例值 |
+| ---- | ---- | ----------- | ------ |
+| id   | 是   | 发现设备 ID | 0      |
+
+#### 请求参数
+
+#### 响应参数
+
+| 名称 | 类型 | 必填 | 最大长度 | 描述 | 示例值 |
+| --- | --- | --- | --- | --- | --- |
+| success | Boolean | 否 | - | 请求是否成功 | true |
+| errCode | String | 否 | - | 错误码(失败时返回) |  |
+| errMessage | String | 否 | - | 错误信息(失败时返回) |  |
+| data | object | 否 |  | 响应数据 (ActualType: DiscoveredCameraDTO) |  |
+| └ id | Long | 否 | - | ID | 1 |
+| └ lssId | String | 否 | - | LSS 节点 ID | lss_001 |
+| └ ip | String | 否 | - | IP 地址 | 192.168.1.100 |
+| └ port | Integer | 否 | - | 端口 | 80 |
+| └ uuid | String | 否 | - | 设备 UUID | uuid-1234-5678 |
+| └ deviceName | String | 否 | - | 设备名称 | IP Camera |
+| └ vendor | String | 否 | - | 厂商 | HIKVISION |
+| └ model | String | 否 | - | 型号 | DS-2CD2T45FWD |
+| └ serialNumber | String | 否 | - | 序列号 | SN123456789 |
+| └ rtspUrl | String | 否 | - | RTSP URL | rtsp://admin:12345@192.168.1.100:554/stream1 |
+| └ matchStatus | String | 否 | - | 匹配状态: PENDING / MATCHING / MATCHED / UNMATCHED | PENDING |
+| └ matchedCredentialId | Long | 否 | - | 匹配成功的凭证 ID | 1 |
+| └ matchAttempts | Integer | 否 | - | 匹配尝试次数 | 0 |
+| └ lastMatchAttempt | LocalDateTime | 否 | - | 最后匹配时间 | 2026-01-27T10:00:00 |
+| └ matchError | String | 否 | - | 匹配错误信息 | Authentication failed |
+| └ deviceInfo | String | 否 | - | 设备详细信息 JSON | {manufacturer:HIKVISION,model:DS-2CD2T45FWD,firmwareVersion:V5.6.5} |
+| └ bound | Boolean | 否 | - | 是否已绑定到 camera_info | false |
+| └ boundCameraId | Long | 否 | - | 绑定的 camera_info.id | 1 |
+| └ canMatch | Boolean | 否 | - | 是否可以匹配(未超过 3 次或已过 24 小时冷却期) | true |
+| └ discoveredAt | LocalDateTime | 否 | - | 发现时间 | 2026-01-27T10:00:00 |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {
+        "id": 1,
+        "lssId": "lss_001",
+        "ip": "192.168.1.100",
+        "port": 80,
+        "uuid": "uuid-1234-5678",
+        "deviceName": "IP Camera",
+        "vendor": "HIKVISION",
+        "model": "DS-2CD2T45FWD",
+        "serialNumber": "SN123456789",
+        "rtspUrl": "rtsp://admin:12345@192.168.1.100:554/stream1",
+        "matchStatus": "PENDING",
+        "matchedCredentialId": 1,
+        "matchAttempts": 0,
+        "lastMatchAttempt": "2026-01-27T10:00:00",
+        "matchError": "Authentication failed",
+        "deviceInfo": "{manufacturer:HIKVISION,model:DS-2CD2T45FWD,firmwareVersion:V5.6.5}",
+        "bound": false,
+        "boundCameraId": 1,
+        "canMatch": true,
+        "discoveredAt": "2026-01-27T10:00:00"
+    }
+}
+```
+
+#### 错误码
+
+无
+
+### 删除发现设备
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/admin/discovered/{id}/delete
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/admin/discovered/{id}/delete
+
+描述:删除发现设备
+
+ContentType:`application/x-www-form-urlencoded;charset=UTF-8`
+
+#### Path 参数
+
+| 名称 | 必填 | 描述        | 示例值 |
+| ---- | ---- | ----------- | ------ |
+| id   | 是   | 发现设备 ID | 0      |
+
+#### 请求参数
+
+#### 响应参数
+
+| 名称       | 类型    | 必填 | 最大长度 | 描述                        | 示例值 |
+| ---------- | ------- | ---- | -------- | --------------------------- | ------ |
+| success    | Boolean | 否   | -        | 请求是否成功                | true   |
+| errCode    | String  | 否   | -        | 错误码(失败时返回)        |        |
+| errMessage | String  | 否   | -        | 错误信息(失败时返回)      |        |
+| data       | object  | 否   | -        | 响应数据 (ActualType: Void) |        |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {}
+}
+```
+
+#### 错误码
+
+无
+
+### 触发凭证匹配
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/admin/lss/{lssId}/match
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/admin/lss/{lssId}/match
+
+描述:触发凭证匹配
+
+ContentType:`application/json`
+
+#### Path 参数
+
+| 名称  | 必填 | 描述        | 示例值 |
+| ----- | ---- | ----------- | ------ |
+| lssId | 是   | LSS 节点 ID |        |
+
+#### 请求参数
+
+##### Body Parameter
+
+| 名称 | 类型 | 必填 | 最大长度 | 描述 | 示例值 |
+| --- | --- | --- | --- | --- | --- |
+| deviceIds | List<Long> | 否 | - | 要匹配的设备 ID 列表(可选,空表示全部 PENDING 设备) (ActualType: Long) | [1, 2, 3] |
+| credentialIds | List<Long> | 否 | - | 要使用的凭证 ID 列表(可选,空表示使用所有启用的凭证) (ActualType: Long) | [1, 2] |
+| maxRetry | Integer | 否 | - | 最大重试次数 | 3 |
+
+#### 请求示例
+
+```
+{
+    "deviceIds": [
+        1,
+        2,
+        3
+    ],
+    "credentialIds": [
+        1,
+        2
+    ],
+    "maxRetry": 3
+}
+```
+
+#### 响应参数
+
+| 名称           | 类型    | 必填 | 最大长度 | 描述                                    | 示例值         |
+| -------------- | ------- | ---- | -------- | --------------------------------------- | -------------- |
+| success        | Boolean | 否   | -        | 请求是否成功                            | true           |
+| errCode        | String  | 否   | -        | 错误码(失败时返回)                    |                |
+| errMessage     | String  | 否   | -        | 错误信息(失败时返回)                  |                |
+| data           | object  | 否   |          | 响应数据 (ActualType: MatchResultDTO)   |                |
+| └ taskId       | String  | 否   | -        | 匹配任务 ID                             | match_abc12345 |
+| └ lssId        | String  | 否   | -        | LSS 节点 ID                             | lss_001        |
+| └ status       | String  | 否   | -        | 匹配状态: MATCHING / COMPLETED / FAILED | MATCHING       |
+| └ totalCount   | Integer | 否   | -        | 匹配的设备数量                          | 5              |
+| └ successCount | Integer | 否   | -        | 成功匹配数量                            | 3              |
+| └ failCount    | Integer | 否   | -        | 失败数量                                | 2              |
+| └ error        | String  | 否   | -        | 错误信息                                |                |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {
+        "taskId": "match_abc12345",
+        "lssId": "lss_001",
+        "status": "MATCHING",
+        "totalCount": 5,
+        "successCount": 3,
+        "failCount": 2,
+        "error": "string"
+    }
+}
+```
+
+#### 错误码
+
+无
+
+### 重新匹配单个设备
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/admin/discovered/{id}/retry
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/admin/discovered/{id}/retry
+
+描述:重新匹配单个设备
+
+ContentType:`application/x-www-form-urlencoded;charset=UTF-8`
+
+#### Path 参数
+
+| 名称 | 必填 | 描述        | 示例值 |
+| ---- | ---- | ----------- | ------ |
+| id   | 是   | 发现设备 ID | 0      |
+
+#### 请求参数
+
+#### 响应参数
+
+| 名称           | 类型    | 必填 | 最大长度 | 描述                                    | 示例值         |
+| -------------- | ------- | ---- | -------- | --------------------------------------- | -------------- |
+| success        | Boolean | 否   | -        | 请求是否成功                            | true           |
+| errCode        | String  | 否   | -        | 错误码(失败时返回)                    |                |
+| errMessage     | String  | 否   | -        | 错误信息(失败时返回)                  |                |
+| data           | object  | 否   |          | 响应数据 (ActualType: MatchResultDTO)   |                |
+| └ taskId       | String  | 否   | -        | 匹配任务 ID                             | match_abc12345 |
+| └ lssId        | String  | 否   | -        | LSS 节点 ID                             | lss_001        |
+| └ status       | String  | 否   | -        | 匹配状态: MATCHING / COMPLETED / FAILED | MATCHING       |
+| └ totalCount   | Integer | 否   | -        | 匹配的设备数量                          | 5              |
+| └ successCount | Integer | 否   | -        | 成功匹配数量                            | 3              |
+| └ failCount    | Integer | 否   | -        | 失败数量                                | 2              |
+| └ error        | String  | 否   | -        | 错误信息                                |                |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {
+        "taskId": "match_abc12345",
+        "lssId": "lss_001",
+        "status": "MATCHING",
+        "totalCount": 5,
+        "successCount": 3,
+        "failCount": 2,
+        "error": "string"
+    }
+}
+```
+
+#### 错误码
+
+无
+
+### 绑定发现设备到已有摄像头
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/admin/scan/bind
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/admin/scan/bind
+
+描述:绑定发现设备到已有摄像头
+
+ContentType:`application/json`
+
+#### 请求参数
+
+##### Body Parameter
+
+| 名称         | 类型 | 必填 | 最大长度 | 描述                                | 示例值 |
+| ------------ | ---- | ---- | -------- | ----------------------------------- | ------ |
+| discoveredId | Long | 是   | -        | 发现设备 ID                         | 1      |
+| cameraId     | Long | 是   | -        | 要绑定的摄像头 ID(camera_info.id) | 1      |
+
+#### 请求示例
+
+```
+{
+    "discoveredId": 1,
+    "cameraId": 1
+}
+```
+
+#### 响应参数
+
+| 名称 | 类型 | 必填 | 最大长度 | 描述 | 示例值 |
+| --- | --- | --- | --- | --- | --- |
+| success | Boolean | 否 | - | 请求是否成功 | true |
+| errCode | String | 否 | - | 错误码(失败时返回) |  |
+| errMessage | String | 否 | - | 错误信息(失败时返回) |  |
+| data | object | 否 |  | 响应数据 (ActualType: CameraInfoDTO) |  |
+| └ id | Long | 否 | - | 主键 ID | 1 |
+| └ cameraId | String | 否 | - | 摄像头 ID | cam_001 |
+| └ cameraName | String | 否 | - | 设备名称 | 主摄像头 |
+| └ ip | String | 否 | - | IP 地址 | 192.168.1.100 |
+| └ port | Integer | 否 | - | 端口 | 80 |
+| └ username | String | 否 | - | 用户名 | admin |
+| └ vendorName | String | 否 | - | 厂商名称 | 海康威视 |
+| └ brand | String | 否 | - | 品牌(兼容旧数据) | hikvision |
+| └ capability | String | 否 | - | 能力: switch_only, ptz_enabled | ptz_enabled |
+| └ status | String | 否 | - | 心跳状态: active, hold, dead | active |
+| └ lssId | String | 否 | - | 绑定的 LSS 节点 ID | lss_001 |
+| └ model | String | 否 | - | 摄像头型号 | DS-2CD2T47G2-LSU/SL |
+| └ rtspUrl | String | 否 | - | RTSP 推流地址 | rtsp://admin:password@192.168.1.100:554/stream1 |
+| └ channelNo | String | 否 | - | 通道号 | 1 |
+| └ remark | String | 否 | - | 备注 |  |
+| └ paramConfig | String | 否 | - | 参数配置 (JSON 格式,包含 ip, port, username, password 等) | {ip:192.168.1.100,port:80,username:admin,password:123456} |
+| └ runtimeParams | String | 否 | - | 设备运行参数 (JSON 格式,通过 ONVIF 获取的摄像头参数)<br><br>现阶段:手动输入<br>TODO: 后续改为通过 ONVIF 协议扫描自动获取 | {resolution:1920x1080,fps:25,codec:H.264} |
+| └ enabled | Boolean | 否 | - | 是否启用 | true |
+| └ createdAt | LocalDateTime | 否 | - | 创建时间 | 2026-01-07T10:00:00 |
+| └ updatedAt | LocalDateTime | 否 | - | 更新时间 | 2026-01-07T10:00:00 |
+| └ streamSn | String | 否 | - | 关联的推流任务流水号 | stream_e1d710b7b8a8 |
+| └ streamStatus | String | 否 | - | 推流状态: idle, streaming, stopped, error | streaming |
+| └ playbackUrl | String | 否 | - | 播放地址(WHEP) | https://customer-xxx.cloudflarestream.com/xxx/webRTC/play |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {
+        "id": 1,
+        "cameraId": "cam_001",
+        "cameraName": "主摄像头",
+        "ip": "192.168.1.100",
+        "port": 80,
+        "username": "admin",
+        "vendorName": "海康威视",
+        "brand": "hikvision",
+        "capability": "ptz_enabled",
+        "status": "active",
+        "lssId": "lss_001",
+        "model": "DS-2CD2T47G2-LSU/SL",
+        "rtspUrl": "rtsp://admin:password@192.168.1.100:554/stream1",
+        "channelNo": "1",
+        "remark": "string",
+        "paramConfig": "{ip:192.168.1.100,port:80,username:admin,password:123456}",
+        "runtimeParams": "{resolution:1920x1080,fps:25,codec:H.264}",
+        "enabled": true,
+        "createdAt": "2026-01-07T10:00:00",
+        "updatedAt": "2026-01-07T10:00:00",
+        "streamSn": "stream_e1d710b7b8a8",
+        "streamStatus": "streaming",
+        "playbackUrl": "https://customer-xxx.cloudflarestream.com/xxx/webRTC/play"
+    }
+}
+```
+
+#### 错误码
+
+无
+
+### 从发现设备创建摄像头(已废弃)
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/admin/discovered/create-camera
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/admin/discovered/create-camera
+
+描述:从发现设备创建摄像头(已废弃)
+
+ContentType:`application/json`
+
+#### 请求参数
+
+##### Body Parameter
+
+| 名称         | 类型   | 必填 | 最大长度 | 描述                                 | 示例值       |
+| ------------ | ------ | ---- | -------- | ------------------------------------ | ------------ |
+| discoveredId | Long   | 是   | -        | 发现设备 ID                          | 1            |
+| name         | String | 否   | -        | 摄像头名称(可选,默认使用设备名称) | 前门摄像头   |
+| remark       | String | 否   | -        | 备注                                 | 大门入口监控 |
+
+#### 请求示例
+
+```
+{
+    "discoveredId": 1,
+    "name": "前门摄像头",
+    "remark": "大门入口监控"
+}
+```
+
+#### 响应参数
+
+| 名称 | 类型 | 必填 | 最大长度 | 描述 | 示例值 |
+| --- | --- | --- | --- | --- | --- |
+| success | Boolean | 否 | - | 请求是否成功 | true |
+| errCode | String | 否 | - | 错误码(失败时返回) |  |
+| errMessage | String | 否 | - | 错误信息(失败时返回) |  |
+| data | object | 否 |  | 响应数据 (ActualType: CameraInfoDTO) |  |
+| └ id | Long | 否 | - | 主键 ID | 1 |
+| └ cameraId | String | 否 | - | 摄像头 ID | cam_001 |
+| └ cameraName | String | 否 | - | 设备名称 | 主摄像头 |
+| └ ip | String | 否 | - | IP 地址 | 192.168.1.100 |
+| └ port | Integer | 否 | - | 端口 | 80 |
+| └ username | String | 否 | - | 用户名 | admin |
+| └ vendorName | String | 否 | - | 厂商名称 | 海康威视 |
+| └ brand | String | 否 | - | 品牌(兼容旧数据) | hikvision |
+| └ capability | String | 否 | - | 能力: switch_only, ptz_enabled | ptz_enabled |
+| └ status | String | 否 | - | 心跳状态: active, hold, dead | active |
+| └ lssId | String | 否 | - | 绑定的 LSS 节点 ID | lss_001 |
+| └ model | String | 否 | - | 摄像头型号 | DS-2CD2T47G2-LSU/SL |
+| └ rtspUrl | String | 否 | - | RTSP 推流地址 | rtsp://admin:password@192.168.1.100:554/stream1 |
+| └ channelNo | String | 否 | - | 通道号 | 1 |
+| └ remark | String | 否 | - | 备注 |  |
+| └ paramConfig | String | 否 | - | 参数配置 (JSON 格式,包含 ip, port, username, password 等) | {ip:192.168.1.100,port:80,username:admin,password:123456} |
+| └ runtimeParams | String | 否 | - | 设备运行参数 (JSON 格式,通过 ONVIF 获取的摄像头参数)<br><br>现阶段:手动输入<br>TODO: 后续改为通过 ONVIF 协议扫描自动获取 | {resolution:1920x1080,fps:25,codec:H.264} |
+| └ enabled | Boolean | 否 | - | 是否启用 | true |
+| └ createdAt | LocalDateTime | 否 | - | 创建时间 | 2026-01-07T10:00:00 |
+| └ updatedAt | LocalDateTime | 否 | - | 更新时间 | 2026-01-07T10:00:00 |
+| └ streamSn | String | 否 | - | 关联的推流任务流水号 | stream_e1d710b7b8a8 |
+| └ streamStatus | String | 否 | - | 推流状态: idle, streaming, stopped, error | streaming |
+| └ playbackUrl | String | 否 | - | 播放地址(WHEP) | https://customer-xxx.cloudflarestream.com/xxx/webRTC/play |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {
+        "id": 1,
+        "cameraId": "cam_001",
+        "cameraName": "主摄像头",
+        "ip": "192.168.1.100",
+        "port": 80,
+        "username": "admin",
+        "vendorName": "海康威视",
+        "brand": "hikvision",
+        "capability": "ptz_enabled",
+        "status": "active",
+        "lssId": "lss_001",
+        "model": "DS-2CD2T47G2-LSU/SL",
+        "rtspUrl": "rtsp://admin:password@192.168.1.100:554/stream1",
+        "channelNo": "1",
+        "remark": "string",
+        "paramConfig": "{ip:192.168.1.100,port:80,username:admin,password:123456}",
+        "runtimeParams": "{resolution:1920x1080,fps:25,codec:H.264}",
+        "enabled": true,
+        "createdAt": "2026-01-07T10:00:00",
+        "updatedAt": "2026-01-07T10:00:00",
+        "streamSn": "stream_e1d710b7b8a8",
+        "streamStatus": "streaming",
+        "playbackUrl": "https://customer-xxx.cloudflarestream.com/xxx/webRTC/play"
+    }
+}
+```
+
+#### 错误码
+
+无
+
+### 批量创建摄像头(已废弃)
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/admin/discovered/batch-create
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/admin/discovered/batch-create
+
+描述:批量创建摄像头(已废弃)
+
+ContentType:`application/json`
+
+#### 请求参数
+
+##### Body Parameter
+
+| 名称          | 类型  | 必填 | 最大长度 | 描述                              | 示例值 |
+| ------------- | ----- | ---- | -------- | --------------------------------- | ------ |
+| discoveredIds | array | 否   | -        | 发现设备 ID 列表,[array of int64] | 0,0    |
+
+#### 请求示例
+
+```
+{
+    "discoveredIds": [
+        0,
+        0
+    ]
+}
+```
+
+#### 响应参数
+
+| 名称       | 类型    | 必填 | 最大长度 | 描述                           | 示例值 |
+| ---------- | ------- | ---- | -------- | ------------------------------ | ------ |
+| success    | Boolean | 否   | -        | 请求是否成功                   | true   |
+| errCode    | String  | 否   | -        | 错误码(失败时返回)           |        |
+| errMessage | String  | 否   | -        | 错误信息(失败时返回)         |        |
+| data       | int32   | 否   | -        | 响应数据 (ActualType: Integer) |        |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": 0
+}
+```
+
+#### 错误码
+
+无
+
+### 获取凭证列表
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `GET` http://localhost:10050/api/admin/credentials
+- 开发环境: `GET` https://tg-live-game.pwtk.cc/api/admin/credentials
+
+描述:获取凭证列表
+
+ContentType:`application/x-www-form-urlencoded;charset=UTF-8`
+
+#### 请求参数
+
+#### 响应参数
+
+| 名称           | 类型          | 必填 | 最大长度 | 描述                        | 示例值              |
+| -------------- | ------------- | ---- | -------- | --------------------------- | ------------------- |
+| success        | Boolean       | 否   | -        | 请求是否成功                | true                |
+| errCode        | String        | 否   | -        | 错误码(失败时返回)        |                     |
+| errMessage     | String        | 否   | -        | 错误信息(失败时返回)      |                     |
+| data           | array         | 否   |          | 响应数据 (ActualType: List) |                     |
+| └ id           | Long          | 否   | -        | ID                          | 1                   |
+| └ name         | String        | 否   | -        | 凭证名称                    | 默认凭证            |
+| └ username     | String        | 否   | -        | 用户名                      | admin               |
+| └ password     | String        | 否   | -        | 密码(列表时可能隐藏)      | 12345               |
+| └ vendor       | String        | 否   | -        | 适用厂商(空表示通用)      | HIKVISION           |
+| └ priority     | Integer       | 否   | -        | 优先级                      | 100                 |
+| └ enabled      | Boolean       | 否   | -        | 是否启用                    | true                |
+| └ remark       | String        | 否   | -        | 备注                        | 海康威视默认凭证    |
+| └ successCount | Integer       | 否   | -        | 成功匹配次数                | 10                  |
+| └ failCount    | Integer       | 否   | -        | 失败匹配次数                | 5                   |
+| └ createdAt    | LocalDateTime | 否   | -        | 创建时间                    | 2026-01-27T10:00:00 |
+| └ updatedAt    | LocalDateTime | 否   | -        | 更新时间                    | 2026-01-27T10:00:00 |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": [
+        {
+            "id": 1,
+            "name": "默认凭证",
+            "username": "admin",
+            "password": "12345",
+            "vendor": "HIKVISION",
+            "priority": 100,
+            "enabled": true,
+            "remark": "海康威视默认凭证",
+            "successCount": 10,
+            "failCount": 5,
+            "createdAt": "2026-01-27T10:00:00",
+            "updatedAt": "2026-01-27T10:00:00"
+        }
+    ]
+}
+```
+
+#### 错误码
+
+无
+
+### 添加凭证
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/admin/credentials
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/admin/credentials
+
+描述:添加凭证
+
+ContentType:`application/json`
+
+#### 请求参数
+
+##### Body Parameter
+
+| 名称     | 类型    | 必填 | 最大长度 | 描述                         | 示例值           |
+| -------- | ------- | ---- | -------- | ---------------------------- | ---------------- |
+| name     | String  | 是   | -        | 凭证名称                     | 默认凭证         |
+| username | String  | 是   | -        | 用户名                       | admin            |
+| password | String  | 是   | -        | 密码                         | 12345            |
+| vendor   | String  | 否   | -        | 适用厂商(空表示通用)       | HIKVISION        |
+| priority | Integer | 否   | -        | 优先级(数字越小优先级越高) | 100              |
+| enabled  | Boolean | 否   | -        | 是否启用                     | true             |
+| remark   | String  | 否   | -        | 备注                         | 海康威视默认凭证 |
+
+#### 请求示例
+
+```
+{
+    "name": "默认凭证",
+    "username": "admin",
+    "password": "12345",
+    "vendor": "HIKVISION",
+    "priority": 100,
+    "enabled": true,
+    "remark": "海康威视默认凭证"
+}
+```
+
+#### 响应参数
+
+| 名称           | 类型          | 必填 | 最大长度 | 描述                                       | 示例值              |
+| -------------- | ------------- | ---- | -------- | ------------------------------------------ | ------------------- |
+| success        | Boolean       | 否   | -        | 请求是否成功                               | true                |
+| errCode        | String        | 否   | -        | 错误码(失败时返回)                       |                     |
+| errMessage     | String        | 否   | -        | 错误信息(失败时返回)                     |                     |
+| data           | object        | 否   |          | 响应数据 (ActualType: CameraCredentialDTO) |                     |
+| └ id           | Long          | 否   | -        | ID                                         | 1                   |
+| └ name         | String        | 否   | -        | 凭证名称                                   | 默认凭证            |
+| └ username     | String        | 否   | -        | 用户名                                     | admin               |
+| └ password     | String        | 否   | -        | 密码(列表时可能隐藏)                     | 12345               |
+| └ vendor       | String        | 否   | -        | 适用厂商(空表示通用)                     | HIKVISION           |
+| └ priority     | Integer       | 否   | -        | 优先级                                     | 100                 |
+| └ enabled      | Boolean       | 否   | -        | 是否启用                                   | true                |
+| └ remark       | String        | 否   | -        | 备注                                       | 海康威视默认凭证    |
+| └ successCount | Integer       | 否   | -        | 成功匹配次数                               | 10                  |
+| └ failCount    | Integer       | 否   | -        | 失败匹配次数                               | 5                   |
+| └ createdAt    | LocalDateTime | 否   | -        | 创建时间                                   | 2026-01-27T10:00:00 |
+| └ updatedAt    | LocalDateTime | 否   | -        | 更新时间                                   | 2026-01-27T10:00:00 |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {
+        "id": 1,
+        "name": "默认凭证",
+        "username": "admin",
+        "password": "12345",
+        "vendor": "HIKVISION",
+        "priority": 100,
+        "enabled": true,
+        "remark": "海康威视默认凭证",
+        "successCount": 10,
+        "failCount": 5,
+        "createdAt": "2026-01-27T10:00:00",
+        "updatedAt": "2026-01-27T10:00:00"
+    }
+}
+```
+
+#### 错误码
+
+无
+
+### 更新凭证
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/admin/credentials/update
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/admin/credentials/update
+
+描述:更新凭证
+
+ContentType:`application/json`
+
+#### 请求参数
+
+##### Body Parameter
+
+| 名称     | 类型    | 必填 | 最大长度 | 描述     | 示例值           |
+| -------- | ------- | ---- | -------- | -------- | ---------------- |
+| id       | Long    | 是   | -        | 凭证 ID  | 1                |
+| name     | String  | 否   | -        | 凭证名称 | 默认凭证         |
+| username | String  | 否   | -        | 用户名   | admin            |
+| password | String  | 否   | -        | 密码     | 12345            |
+| vendor   | String  | 否   | -        | 适用厂商 | HIKVISION        |
+| priority | Integer | 否   | -        | 优先级   | 100              |
+| enabled  | Boolean | 否   | -        | 是否启用 | true             |
+| remark   | String  | 否   | -        | 备注     | 海康威视默认凭证 |
+
+#### 请求示例
+
+```
+{
+    "id": 1,
+    "name": "默认凭证",
+    "username": "admin",
+    "password": "12345",
+    "vendor": "HIKVISION",
+    "priority": 100,
+    "enabled": true,
+    "remark": "海康威视默认凭证"
+}
+```
+
+#### 响应参数
+
+| 名称           | 类型          | 必填 | 最大长度 | 描述                                       | 示例值              |
+| -------------- | ------------- | ---- | -------- | ------------------------------------------ | ------------------- |
+| success        | Boolean       | 否   | -        | 请求是否成功                               | true                |
+| errCode        | String        | 否   | -        | 错误码(失败时返回)                       |                     |
+| errMessage     | String        | 否   | -        | 错误信息(失败时返回)                     |                     |
+| data           | object        | 否   |          | 响应数据 (ActualType: CameraCredentialDTO) |                     |
+| └ id           | Long          | 否   | -        | ID                                         | 1                   |
+| └ name         | String        | 否   | -        | 凭证名称                                   | 默认凭证            |
+| └ username     | String        | 否   | -        | 用户名                                     | admin               |
+| └ password     | String        | 否   | -        | 密码(列表时可能隐藏)                     | 12345               |
+| └ vendor       | String        | 否   | -        | 适用厂商(空表示通用)                     | HIKVISION           |
+| └ priority     | Integer       | 否   | -        | 优先级                                     | 100                 |
+| └ enabled      | Boolean       | 否   | -        | 是否启用                                   | true                |
+| └ remark       | String        | 否   | -        | 备注                                       | 海康威视默认凭证    |
+| └ successCount | Integer       | 否   | -        | 成功匹配次数                               | 10                  |
+| └ failCount    | Integer       | 否   | -        | 失败匹配次数                               | 5                   |
+| └ createdAt    | LocalDateTime | 否   | -        | 创建时间                                   | 2026-01-27T10:00:00 |
+| └ updatedAt    | LocalDateTime | 否   | -        | 更新时间                                   | 2026-01-27T10:00:00 |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {
+        "id": 1,
+        "name": "默认凭证",
+        "username": "admin",
+        "password": "12345",
+        "vendor": "HIKVISION",
+        "priority": 100,
+        "enabled": true,
+        "remark": "海康威视默认凭证",
+        "successCount": 10,
+        "failCount": 5,
+        "createdAt": "2026-01-27T10:00:00",
+        "updatedAt": "2026-01-27T10:00:00"
+    }
+}
+```
+
+#### 错误码
+
+无
+
+### 删除凭证
+
+维护人:TG Live
+
+#### URL
+
+- 本地开发环境: `POST` http://localhost:10050/api/admin/credentials/{id}/delete
+- 开发环境: `POST` https://tg-live-game.pwtk.cc/api/admin/credentials/{id}/delete
+
+描述:删除凭证
+
+ContentType:`application/x-www-form-urlencoded;charset=UTF-8`
+
+#### Path 参数
+
+| 名称 | 必填 | 描述    | 示例值 |
+| ---- | ---- | ------- | ------ |
+| id   | 是   | 凭证 ID | 0      |
+
+#### 请求参数
+
+#### 响应参数
+
+| 名称       | 类型    | 必填 | 最大长度 | 描述                        | 示例值 |
+| ---------- | ------- | ---- | -------- | --------------------------- | ------ |
+| success    | Boolean | 否   | -        | 请求是否成功                | true   |
+| errCode    | String  | 否   | -        | 错误码(失败时返回)        |        |
+| errMessage | String  | 否   | -        | 错误信息(失败时返回)      |        |
+| data       | object  | 否   | -        | 响应数据 (ActualType: Void) |        |
+
+#### 响应示例
+
+```
+{
+    "success": true,
+    "errCode": "string",
+    "errMessage": "string",
+    "data": {}
+}
+```
+
+#### 错误码
+
+无

+ 1 - 1
package.json

@@ -1,7 +1,7 @@
 {
   "name": "tg-live-game-web",
   "private": true,
-  "version": "1.0.33",
+  "version": "1.0.37",
   "type": "module",
   "scripts": {
     "dev": "vite",

+ 74 - 0
src/api/camera-scan.ts

@@ -0,0 +1,74 @@
+import { get, post } from '@/utils/request'
+import type {
+  IBaseResponse,
+  IListResponse,
+  BaseResponse,
+  ScanResultDTO,
+  DiscoveredCameraDTO,
+  MatchResultDTO,
+  MatchRequest,
+  BindDeviceRequest,
+  CameraCredentialDTO,
+  CredentialAddRequest,
+  CredentialUpdateRequest,
+  CameraInfoDTO
+} from '@/types'
+
+// ==================== 扫描相关 ====================
+
+// 触发 ONVIF 设备扫描
+export function scanDevices(lssId: string): Promise<IBaseResponse<ScanResultDTO>> {
+  return post(`/admin/lss/${lssId}/scan`)
+}
+
+// 获取发现设备列表
+export function getDiscoveredDevices(lssId: string): Promise<IBaseResponse<DiscoveredCameraDTO[]>> {
+  return get(`/admin/lss/${lssId}/discovered`)
+}
+
+// 获取发现设备详情
+export function getDiscoveredDevice(id: number): Promise<IBaseResponse<DiscoveredCameraDTO>> {
+  return get(`/admin/discovered/${id}`)
+}
+
+// 删除发现设备
+export function deleteDiscoveredDevice(id: number): Promise<BaseResponse> {
+  return post(`/admin/discovered/${id}/delete`)
+}
+
+// 触发凭证匹配
+export function triggerMatch(lssId: string, data?: MatchRequest): Promise<IBaseResponse<MatchResultDTO>> {
+  return post(`/admin/lss/${lssId}/match`, data)
+}
+
+// 重新匹配单个设备
+export function retryMatch(id: number): Promise<IBaseResponse<MatchResultDTO>> {
+  return post(`/admin/discovered/${id}/retry`)
+}
+
+// 绑定设备到现有摄像头
+export function bindDevice(data: BindDeviceRequest): Promise<IBaseResponse<CameraInfoDTO>> {
+  return post('/admin/scan/bind', data)
+}
+
+// ==================== 凭证相关 ====================
+
+// 获取凭证列表
+export function getCredentials(): Promise<IListResponse<CameraCredentialDTO>> {
+  return get('/admin/credentials')
+}
+
+// 添加凭证
+export function addCredential(data: CredentialAddRequest): Promise<IBaseResponse<CameraCredentialDTO>> {
+  return post('/admin/credentials', data)
+}
+
+// 更新凭证
+export function updateCredential(data: CredentialUpdateRequest): Promise<IBaseResponse<CameraCredentialDTO>> {
+  return post('/admin/credentials/update', data)
+}
+
+// 删除凭证
+export function deleteCredential(id: number): Promise<BaseResponse> {
+  return post(`/admin/credentials/${id}/delete`)
+}

+ 14 - 8
src/api/camera.ts

@@ -10,7 +10,8 @@ import type {
   CameraAddRequest,
   CameraUpdateRequest,
   CameraListRequest,
-  SwitchChannelRequest
+  SwitchChannelRequest,
+  PTZAction
 } from '@/types'
 
 // ==================== Controller APIs ====================
@@ -50,14 +51,19 @@ export function getCurrentChannel(data: GetCurrentChannelRequest): Promise<IBase
 // ==================== PTZ 控制 (代理到 PTZ 服务) ====================
 
 // PTZ 控制
+// PTZ 方向动作
+// up: 向上, down: 向下, left: 向左, right: 向右
+// up_left: 左上, up_right: 右上, down_left: 左下, down_right: 右下
+// zoom_in: 放大, zoom_out: 缩小
+// stop: 停止
 export interface PTZControlRequest {
   cameraId: string
-  command: string
+  action: PTZAction
   speed?: number
 }
 
 export function ptzControl(data: PTZControlRequest): Promise<BaseResponse> {
-  return post(`/camera/control/${data.cameraId}/ptz/control`, data)
+  return post(`/camera/ptz/control`, data)
 }
 
 // PTZ 能力
@@ -66,7 +72,7 @@ export interface PTZCapabilitiesRequest {
 }
 
 export function getPTZCapabilities(data: PTZCapabilitiesRequest): Promise<BaseResponse> {
-  return post(`/camera/control/${data.cameraId}/ptz/capabilities`, data)
+  return post(`/camera/ptz/capabilities`, data)
 }
 
 // ==================== 预置位 ====================
@@ -83,7 +89,7 @@ export interface PresetListRequest {
 }
 
 export function presetList(data: PresetListRequest): Promise<IBaseResponse<PresetInfo[]>> {
-  return post(`/camera/control/${data.cameraId}/preset/list`, data)
+  return post(`/camera/preset/list`, data)
 }
 
 // 跳转预置位
@@ -93,7 +99,7 @@ export interface PresetGotoRequest {
 }
 
 export function presetGoto(data: PresetGotoRequest): Promise<IBaseResponse<boolean>> {
-  return post(`/camera/control/${data.cameraId}/preset/goto`, data)
+  return post(`/camera/preset/goto`, data)
 }
 
 // 设置预置位
@@ -106,7 +112,7 @@ export interface PresetSetRequest {
 }
 
 export function presetSet(data: PresetSetRequest): Promise<BaseResponse> {
-  return post(`/camera/control/${data.cameraId}/preset/set`, data)
+  return post(`/camera/preset/set`, data)
 }
 
 // 删除预置位
@@ -116,7 +122,7 @@ export interface PresetRemoveRequest {
 }
 
 export function presetRemove(data: PresetRemoveRequest): Promise<BaseResponse> {
-  return post(`/camera/control/${data.cameraId}/preset/remove`, data)
+  return post(`/camera/preset/remove`, data)
 }
 
 // ==================== Admin APIs ====================

+ 26 - 20
src/components/PTZController.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { ref } from 'vue'
 import { ptzControl } from '@/api/camera'
+import type { PTZAction } from '@/types'
 
 const props = withDefaults(
   defineProps<{
@@ -16,24 +17,28 @@ const isMoving = ref(false)
 const currentDirection = ref<string | null>(null)
 
 // 方向映射
-const directionToCommand: Record<string, string> = {
-  UP: 'up',
-  DOWN: 'down',
-  LEFT: 'left',
-  RIGHT: 'right',
-  STOP: 'stop'
+const directionToAction: Record<string, PTZAction> = {
+  up: 'up',
+  down: 'down',
+  left: 'left',
+  right: 'right',
+  up_left: 'up_left',
+  up_right: 'up_right',
+  down_left: 'down_left',
+  down_right: 'down_right',
+  zoom_in: 'zoom_in',
+  zoom_out: 'zoom_out',
+  stop: 'stop'
 }
 
 // 开始移动
-async function handleStart(
-  direction: 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' | 'UP_LEFT' | 'UP_RIGHT' | 'DOWN_LEFT' | 'DOWN_RIGHT'
-) {
+async function handleStart(direction: PTZAction) {
   if (isMoving.value || !props.cameraId) return
   isMoving.value = true
   currentDirection.value = direction
 
-  const command = directionToCommand[direction] || 'stop'
-  const result = await ptzControl({ cameraId: props.cameraId, command })
+  const command = directionToAction[direction]
+  const result = await ptzControl({ cameraId: props.cameraId, action: command })
   if (!result.success) {
     console.error('PTZ 控制失败:', result.errMsg)
   }
@@ -43,7 +48,7 @@ async function handleStart(
 async function handleStop() {
   if (!isMoving.value || !props.cameraId) return
 
-  const result = await ptzControl({ cameraId: props.cameraId, command: 'stop' })
+  const result = await ptzControl({ cameraId: props.cameraId, action: 'stop' })
   if (!result.success) {
     console.error('PTZ 停止失败:', result.errMsg)
   }
@@ -57,15 +62,15 @@ async function handleStop() {
   <div class="ptz-controller">
     <div class="ptz-grid">
       <!-- 第一行 -->
-      <button class="ptz-btn corner" @mousedown="handleStart('UP_LEFT')" @mouseup="handleStop" @mouseleave="handleStop">
+      <button class="ptz-btn corner" @mousedown="handleStart('up_left')" @mouseup="handleStop" @mouseleave="handleStop">
         <el-icon><i-ep-top-left /></el-icon>
       </button>
-      <button class="ptz-btn" @mousedown="handleStart('UP')" @mouseup="handleStop" @mouseleave="handleStop">
+      <button class="ptz-btn" @mousedown="handleStart('up')" @mouseup="handleStop" @mouseleave="handleStop">
         <el-icon><i-ep-arrow-up /></el-icon>
       </button>
       <button
         class="ptz-btn corner"
-        @mousedown="handleStart('UP_RIGHT')"
+        @mousedown="handleStart('up_right')"
         @mouseup="handleStop"
         @mouseleave="handleStop"
       >
@@ -73,32 +78,32 @@ async function handleStop() {
       </button>
 
       <!-- 第二行 -->
-      <button class="ptz-btn" @mousedown="handleStart('LEFT')" @mouseup="handleStop" @mouseleave="handleStop">
+      <button class="ptz-btn" @mousedown="handleStart('left')" @mouseup="handleStop" @mouseleave="handleStop">
         <el-icon><i-ep-arrow-left /></el-icon>
       </button>
       <div class="ptz-center">
         <el-icon v-if="isMoving" class="spinning"><i-ep-loading /></el-icon>
         <span v-else>PTZ</span>
       </div>
-      <button class="ptz-btn" @mousedown="handleStart('RIGHT')" @mouseup="handleStop" @mouseleave="handleStop">
+      <button class="ptz-btn" @mousedown="handleStart('right')" @mouseup="handleStop" @mouseleave="handleStop">
         <el-icon><i-ep-arrow-right /></el-icon>
       </button>
 
       <!-- 第三行 -->
       <button
         class="ptz-btn corner"
-        @mousedown="handleStart('DOWN_LEFT')"
+        @mousedown="handleStart('down_left')"
         @mouseup="handleStop"
         @mouseleave="handleStop"
       >
         <el-icon><i-ep-bottom-left /></el-icon>
       </button>
-      <button class="ptz-btn" @mousedown="handleStart('DOWN')" @mouseup="handleStop" @mouseleave="handleStop">
+      <button class="ptz-btn" @mousedown="handleStart('down')" @mouseup="handleStop" @mouseleave="handleStop">
         <el-icon><i-ep-arrow-down /></el-icon>
       </button>
       <button
         class="ptz-btn corner"
-        @mousedown="handleStart('DOWN_RIGHT')"
+        @mousedown="handleStart('down_right')"
         @mouseup="handleStop"
         @mouseleave="handleStop"
       >
@@ -169,6 +174,7 @@ async function handleStop() {
   from {
     transform: rotate(0deg);
   }
+
   to {
     transform: rotate(360deg);
   }

+ 50 - 25
src/components/monitor/PtzOverlay.vue

@@ -9,7 +9,9 @@
           @mouseup="handleDirectionStop"
           @mouseleave="handleDirectionStop"
         >
-          <el-icon><TopLeft /></el-icon>
+          <el-icon>
+            <TopLeft />
+          </el-icon>
         </div>
         <div
           class="ptz-btn"
@@ -17,7 +19,9 @@
           @mouseup="handleDirectionStop"
           @mouseleave="handleDirectionStop"
         >
-          <el-icon><Top /></el-icon>
+          <el-icon>
+            <Top />
+          </el-icon>
         </div>
         <div
           class="ptz-btn"
@@ -25,7 +29,9 @@
           @mouseup="handleDirectionStop"
           @mouseleave="handleDirectionStop"
         >
-          <el-icon><TopRight /></el-icon>
+          <el-icon>
+            <TopRight />
+          </el-icon>
         </div>
         <div
           class="ptz-btn"
@@ -33,10 +39,14 @@
           @mouseup="handleDirectionStop"
           @mouseleave="handleDirectionStop"
         >
-          <el-icon><Back /></el-icon>
+          <el-icon>
+            <Back />
+          </el-icon>
         </div>
         <div class="ptz-btn ptz-center">
-          <el-icon><Aim /></el-icon>
+          <el-icon>
+            <Aim />
+          </el-icon>
         </div>
         <div
           class="ptz-btn"
@@ -44,7 +54,9 @@
           @mouseup="handleDirectionStop"
           @mouseleave="handleDirectionStop"
         >
-          <el-icon><Right /></el-icon>
+          <el-icon>
+            <Right />
+          </el-icon>
         </div>
         <div
           class="ptz-btn"
@@ -52,7 +64,9 @@
           @mouseup="handleDirectionStop"
           @mouseleave="handleDirectionStop"
         >
-          <el-icon><BottomLeft /></el-icon>
+          <el-icon>
+            <BottomLeft />
+          </el-icon>
         </div>
         <div
           class="ptz-btn"
@@ -60,7 +74,9 @@
           @mouseup="handleDirectionStop"
           @mouseleave="handleDirectionStop"
         >
-          <el-icon><Bottom /></el-icon>
+          <el-icon>
+            <Bottom />
+          </el-icon>
         </div>
         <div
           class="ptz-btn"
@@ -68,13 +84,17 @@
           @mouseup="handleDirectionStop"
           @mouseleave="handleDirectionStop"
         >
-          <el-icon><BottomRight /></el-icon>
+          <el-icon>
+            <BottomRight />
+          </el-icon>
         </div>
       </div>
 
       <!-- 垂直缩放滑块 -->
       <div class="ptz-zoom">
-        <el-icon class="zoom-icon zoom-in"><ZoomIn /></el-icon>
+        <el-icon class="zoom-icon zoom-in">
+          <ZoomIn />
+        </el-icon>
         <el-slider
           v-model="zoomValue"
           vertical
@@ -86,7 +106,9 @@
           @input="handleZoomChange"
           @change="handleZoomRelease"
         />
-        <el-icon class="zoom-icon zoom-out"><ZoomOut /></el-icon>
+        <el-icon class="zoom-icon zoom-out">
+          <ZoomOut />
+        </el-icon>
       </div>
     </div>
   </div>
@@ -108,6 +130,7 @@ import {
   ZoomOut
 } from '@element-plus/icons-vue'
 import { ptzControl } from '@/api/camera'
+import type { PTZAction } from '@/types'
 
 interface Props {
   cameraId?: string
@@ -126,35 +149,37 @@ const zoomValue = ref(0)
 const ptzSpeed = 50
 
 // 方向映射
-const directionToCommand: Record<string, string> = {
-  UP: 'up',
-  DOWN: 'down',
-  LEFT: 'left',
-  RIGHT: 'right',
-  UP_LEFT: 'up',
-  UP_RIGHT: 'up',
-  DOWN_LEFT: 'down',
-  DOWN_RIGHT: 'down',
-  STOP: 'stop'
+const directionToAction: Record<string, PTZAction> = {
+  up: 'up',
+  down: 'down',
+  left: 'left',
+  right: 'right',
+  up_left: 'up_left',
+  up_right: 'up_right',
+  down_left: 'down_left',
+  down_right: 'down_right',
+  zoom_in: 'zoom_in',
+  zoom_out: 'zoom_out',
+  stop: 'stop'
 }
 
 async function handleDirection(direction: string) {
   if (!props.cameraId) return
   emit('ptz-action', 'direction', { direction })
-  const command = directionToCommand[direction] || 'stop'
-  await ptzControl({ cameraId: props.cameraId, command, speed: ptzSpeed })
+  const command = directionToAction[direction]
+  await ptzControl({ cameraId: props.cameraId, action: command, speed: ptzSpeed })
 }
 
 async function handleDirectionStop() {
   if (!props.cameraId) return
   emit('ptz-action', 'stop')
-  await ptzControl({ cameraId: props.cameraId, command: 'stop' })
+  await ptzControl({ cameraId: props.cameraId, action: 'stop' })
 }
 
 async function handleZoomChange(val: number) {
   if (!props.cameraId) return
   if (val === 0) {
-    await ptzControl({ cameraId: props.cameraId, command: 'stop' })
+    await ptzControl({ cameraId: props.cameraId, action: 'stop' })
     return
   }
 

+ 8 - 2
src/layout/index.vue

@@ -43,7 +43,7 @@
               @click="toggleSubMenu(item.path)"
             >
               <Icon :icon="item.icon" width="20" height="20" class="layout__nav-icon" />
-              <span v-show="sidebarOpened || isMobile">{{ t(item.title) }}</span>
+              <span class="layout__nav-item-text" v-show="sidebarOpened || isMobile">{{ t(item.title) }}</span>
               <svg
                 v-show="sidebarOpened || isMobile"
                 class="layout__nav-arrow"
@@ -567,7 +567,7 @@ onUnmounted(() => {
   // Navigation
   &__nav {
     flex: 1;
-    padding: 1.5rem 1rem;
+    padding: 1rem 0.725rem;
     overflow-y: auto;
     display: flex;
     flex-direction: column;
@@ -597,6 +597,12 @@ onUnmounted(() => {
     }
   }
 
+  &__nav-item-text {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
   &__nav-icon {
     width: 1.25rem;
     height: 1.25rem;

+ 41 - 1
src/locales/en.json

@@ -395,5 +395,45 @@
   "预置位设置成功": "Preset set successfully",
   "默认分辨率": "Default Resolution",
   "默认端口": "Default Port",
-  "默认视角": "Default View"
+  "默认视角": "Default View",
+  "扫描": "Scan",
+  "扫描设备": "Scan Devices",
+  "再次扫描": "Scan Again",
+  "再次匹配": "Re-match",
+  "账号配置": "Credential Config",
+  "匹配": "Match",
+  "匹配状态": "Match Status",
+  "已匹配": "Matched",
+  "未匹配": "Unmatched",
+  "匹配中": "Matching",
+  "待匹配": "Pending",
+  "完成": "Done",
+  "绑定": "Bind",
+  "发现设备": "Discovered Devices",
+  "暂无发现设备": "No discovered devices",
+  "扫描中...": "Scanning...",
+  "匹配中...": "Matching...",
+  "扫描完成": "Scan completed",
+  "匹配完成": "Match completed",
+  "扫描失败": "Scan failed",
+  "匹配失败": "Match failed",
+  "绑定成功": "Bindsuccessfully",
+  "绑定失败": "Bind failed",
+  "凭证名称": "Credential Name",
+  "凭证列表": "Credential List",
+  "新增凭证": "Add Credential",
+  "编辑凭证": "Edit Credential",
+  "优先级": "Priority",
+  "备注": "Remark",
+  "成功次数": "Success Count",
+  "失败次数": "Fail Count",
+  "请输入凭证名称": "Please enter credential name",
+  "请输入优先级": "Please enter priority",
+  "请输入备注": "Please enter remark",
+  "确定要删除该凭证吗?": "Are you sure you want to delete this credential?",
+  "删除凭证成功": "Credential deleted",
+  "获取凭证列表失败": "Failed to get credentials",
+  "获取发现设备失败": "Failed to get discovered devices",
+  "当前 LSS 节点尚未扫描设备,是否开启扫描?": "This LSS node has not scanned devices yet. Start scanning?",
+  "开始扫描": "Start Scan"
 }

+ 41 - 1
src/locales/zh-cn.json

@@ -395,5 +395,45 @@
   "预置位设置成功": "预置位设置成功",
   "默认分辨率": "默认分辨率",
   "默认端口": "默认端口",
-  "默认视角": "默认视角"
+  "默认视角": "默认视角",
+  "扫描": "扫描",
+  "扫描设备": "扫描设备",
+  "再次扫描": "再次扫描",
+  "再次匹配": "再次匹配",
+  "账号配置": "账号配置",
+  "匹配": "匹配",
+  "匹配状态": "匹配状态",
+  "已匹配": "已匹配",
+  "未匹配": "未匹配",
+  "匹配中": "匹配中",
+  "待匹配": "待匹配",
+  "完成": "完成",
+  "绑定": "绑定",
+  "发现设备": "发现设备",
+  "暂无发现设备": "暂无发现设备",
+  "扫描中...": "扫描中...",
+  "匹配中...": "匹配中...",
+  "扫描完成": "扫描完成",
+  "匹配完成": "匹配完成",
+  "扫描失败": "扫描失败",
+  "匹配失败": "匹配失败",
+  "绑定成功": "绑定成功",
+  "绑定失败": "绑定失败",
+  "凭证名称": "凭证名称",
+  "凭证列表": "凭证列表",
+  "新增凭证": "新增凭证",
+  "编辑凭证": "编辑凭证",
+  "优先级": "优先级",
+  "备注": "备注",
+  "成功次数": "成功次数",
+  "失败次数": "失败次数",
+  "请输入凭证名称": "请输入凭证名称",
+  "请输入优先级": "请输入优先级",
+  "请输入备注": "请输入备注",
+  "确定要删除该凭证吗?": "确定要删除该凭证吗?",
+  "删除凭证成功": "删除凭证成功",
+  "获取凭证列表失败": "获取凭证列表失败",
+  "获取发现设备失败": "获取发现设备失败",
+  "当前 LSS 节点尚未扫描设备,是否开启扫描?": "当前 LSS 节点尚未扫描设备,是否开启扫描?",
+  "开始扫描": "开始扫描"
 }

+ 97 - 7
src/types/index.ts

@@ -294,16 +294,13 @@ export type PTZAction =
   | 'down'
   | 'left'
   | 'right'
+  | 'up_left'
+  | 'up_right'
+  | 'down_left'
+  | 'down_right'
   | 'zoom_in'
   | 'zoom_out'
   | 'stop'
-  | 'UP'
-  | 'DOWN'
-  | 'LEFT'
-  | 'RIGHT'
-  | 'ZOOM_IN'
-  | 'ZOOM_OUT'
-  | 'STOP'
 
 // PTZ 直接控制请求 (pan/tilt/zoom 方式)
 export interface PTZControlRequest {
@@ -403,6 +400,8 @@ export interface LssNodeDTO {
   enabled: boolean
   createdAt: string
   updatedAt: string
+  /** 是否已扫描 */
+  scanned: boolean
 }
 
 // LSS 节点列表请求参数
@@ -739,6 +738,97 @@ export interface StreamPushChannelDTO {
   recordingEnabled: boolean
 }
 
+// ==================== 摄像头扫描相关类型 ====================
+
+// 扫描匹配状态
+// PENDING("pending", "待匹配"),
+// MATCHING("matching", "匹配中"),
+// MATCHED("matched", "已匹配"),
+// UNMATCHED("unmatched", "未匹配");
+export type MatchStatus = 'pending' | 'matching' | 'matched' | 'unmatched'
+
+// 发现设备 DTO
+export interface DiscoveredCameraDTO {
+  id: number
+  ip: string
+  port: number
+  vendor: string
+  model: string
+  cameraId: string
+  uuid: string | null
+  matchStatus: MatchStatus
+  bound: boolean
+  canBind: boolean
+  discoveredAt: number
+}
+
+// 扫描结果 DTO
+export interface ScanResultDTO {
+  taskId: string
+  lssId: string
+  status: 'SCANNING' | 'COMPLETED' | 'FAILED'
+  discoveredCount: number
+  newCount: number
+  devices: DiscoveredCameraDTO[]
+  error?: string
+}
+
+// 匹配结果 DTO
+export interface MatchResultDTO {
+  taskId: string
+  lssId: string
+  status: 'MATCHING' | 'COMPLETED' | 'FAILED'
+  totalCount: number
+  successCount: number
+  failCount: number
+  error?: string
+}
+
+// 摄像头凭证 DTO
+export interface CameraCredentialDTO {
+  id: number
+  name: string
+  username: string
+  password: string
+  vendor: string
+  priority: number
+  enabled: boolean
+  remark: string
+  successCount: number
+  failCount: number
+  createdAt: string
+  updatedAt: string
+}
+
+// 添加凭证请求
+export interface CredentialAddRequest {
+  name: string
+  username: string
+  password: string
+  vendor?: string
+  priority?: number
+  enabled?: boolean
+  remark?: string
+}
+
+// 更新凭证请求
+export interface CredentialUpdateRequest extends CredentialAddRequest {
+  id: number
+}
+
+// 匹配请求
+export interface MatchRequest {
+  deviceIds?: number[]
+  credentialIds?: number[]
+  maxRetry?: number
+}
+
+// 绑定设备请求
+export interface BindDeviceRequest {
+  discoveredId: number
+  cameraId: string
+}
+
 // 播放信息 DTO
 export interface PlaybackInfoDTO {
   streamSn: string

+ 44 - 12
src/views/camera-vendor/index.vue

@@ -66,15 +66,31 @@
         @sort-change="handleSortChange"
       >
         <el-table-column type="selection" width="50" align="center" />
-        <el-table-column type="index" :label="t('序号')" width="60" align="center" />
-        <el-table-column prop="code" :label="t('厂家代码')" min-width="100" sortable="custom" show-overflow-tooltip />
-        <el-table-column prop="name" :label="t('厂家名称')" min-width="120" sortable="custom" show-overflow-tooltip>
+        <el-table-column prop="model" :label="t('设备型号')" width="100" align="center">
+          <template #default="{ row }">
+            {{ row.model || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column
+          prop="vendorName"
+          :label="t('厂家名称')"
+          min-width="120"
+          sortable="custom"
+          show-overflow-tooltip
+        >
           <template #default="{ row }">
             <el-link type="primary" :data-id="`link-edit-${row.code}`" @click="handleEdit(row)">
-              {{ row.name }}
+              {{ row.vendorName }}
             </el-link>
           </template>
         </el-table-column>
+        <el-table-column
+          prop="vendorCode"
+          :label="t('厂家代码')"
+          min-width="100"
+          sortable="custom"
+          show-overflow-tooltip
+        />
         <el-table-column prop="description" :label="t('描述')" min-width="150" show-overflow-tooltip />
         <el-table-column :label="t('协议支持')" min-width="200" align="center">
           <template #default="{ row }">
@@ -150,11 +166,23 @@
       <div class="form-container">
         <el-scrollbar>
           <el-form ref="formRef" :model="form" :rules="rules" label-width="120px" data-id="form-vendor">
-            <el-form-item :label="t('厂家代码')" prop="code">
-              <el-input v-model="form.code" placeholder="请输入厂家代码" :disabled="isEdit" data-id="input-code" />
+            <el-form-item :label="t('厂家代码')" prop="vendorCode">
+              <el-input
+                v-model="form.vendorCode"
+                placeholder="请输入厂家代码"
+                :disabled="isEdit"
+                data-id="input-code"
+              />
+            </el-form-item>
+            <el-form-item :label="t('厂家名称')" prop="vendorName">
+              <el-input v-model="form.vendorName" placeholder="请输入厂家名称" data-id="input-name" />
             </el-form-item>
-            <el-form-item :label="t('厂家名称')" prop="name">
-              <el-input v-model="form.name" placeholder="请输入厂家名称" data-id="input-name" />
+            <el-form-item :label="t('厂家别名')" prop="vendorAliasName">
+              <el-input v-model="form.vendorAliasName" placeholder="请输入厂家别名" data-id="input-name" />
+            </el-form-item>
+
+            <el-form-item :label="t('设备型号')" prop="model">
+              <el-input v-model="form.model" placeholder="请输入设备型号" data-id="input-model" />
             </el-form-item>
             <el-form-item :label="t('描述')" prop="description">
               <el-input
@@ -326,8 +354,10 @@ const sortedList = computed(() => {
 
 const form = reactive<{
   id?: number
-  code: string
-  name: string
+  vendorCode: string
+  vendorName: string
+  vendorAliasName: string
+  model: string
   description: string
   logoUrl: string
   supportOnvif: boolean
@@ -342,8 +372,10 @@ const form = reactive<{
   enabled: boolean
   sortOrder: number
 }>({
-  code: '',
-  name: '',
+  vendorCode: '',
+  vendorName: '',
+  vendorAliasName: '',
+  model: '',
   description: '',
   logoUrl: '',
   supportOnvif: false,

+ 17 - 14
src/views/demo/cloudflareStream.vue

@@ -223,6 +223,7 @@ import {
 } from '@element-plus/icons-vue'
 import VideoPlayer from '@/components/VideoPlayer.vue'
 import { ptzControl } from '@/api/camera'
+import type { PTZAction } from '@/types'
 
 const playerRef = ref<InstanceType<typeof VideoPlayer>>()
 
@@ -362,16 +363,18 @@ function onError(error: any) {
 }
 
 // PTZ 方向映射
-const directionToCommand: Record<string, string> = {
+const directionToCommand: Record<string, PTZAction> = {
   UP: 'up',
-  DOWN: 'down',
-  LEFT: 'left',
-  RIGHT: 'right',
-  UP_LEFT: 'up',
-  UP_RIGHT: 'up',
-  DOWN_LEFT: 'down',
-  DOWN_RIGHT: 'down',
-  STOP: 'stop'
+  down: 'down',
+  left: 'left',
+  right: 'right',
+  up_left: 'up_left',
+  up_right: 'up_right',
+  down_left: 'down_left',
+  down_right: 'down_right',
+  zoom_in: 'zoom_in',
+  zoom_out: 'zoom_out',
+  stop: 'stop'
 }
 
 // PTZ 控制
@@ -382,7 +385,7 @@ async function handlePTZ(direction: string) {
   }
 
   const command = directionToCommand[direction] || 'stop'
-  const result = await ptzControl({ cameraId: ptzCameraId.value, command, speed: ptzSpeed.value })
+  const result = await ptzControl({ cameraId: ptzCameraId.value, action: command, speed: ptzSpeed.value })
 
   if (result.success) {
     addLog(`PTZ 移动: ${direction} (速度: ${ptzSpeed.value})`, 'info')
@@ -394,7 +397,7 @@ async function handlePTZ(direction: string) {
 async function handlePTZStop() {
   if (!ptzCameraId.value) return
 
-  const result = await ptzControl({ cameraId: ptzCameraId.value, command: 'stop' })
+  const result = await ptzControl({ cameraId: ptzCameraId.value, action: 'stop' })
 
   if (!result.success) {
     addLog(`PTZ 停止失败: ${result.errMsg}`, 'error')
@@ -411,20 +414,20 @@ async function handleZoomChange(val: number) {
   if (!ptzCameraId.value) return
 
   if (val === 0) {
-    await ptzControl({ cameraId: ptzCameraId.value, command: 'stop' })
+    await ptzControl({ cameraId: ptzCameraId.value, action: 'stop' })
     return
   }
 
   const command = val > 0 ? 'zoom_in' : 'zoom_out'
   const speed = Math.abs(val)
-  await ptzControl({ cameraId: ptzCameraId.value, command, speed })
+  await ptzControl({ cameraId: ptzCameraId.value, action: command, speed })
 }
 
 async function handleZoomRelease() {
   zoomValue.value = 0
   if (!ptzCameraId.value) return
 
-  await ptzControl({ cameraId: ptzCameraId.value, command: 'stop' })
+  await ptzControl({ cameraId: ptzCameraId.value, action: 'stop' })
   addLog('缩放停止', 'info')
 }
 </script>

+ 123 - 0
src/views/live-stream/components/StreamEditForm.vue

@@ -0,0 +1,123 @@
+<template>
+  <div class="tab-content edit-content">
+    <div class="drawer-body">
+      <el-form ref="elFormRef" :model="form" :rules="rules" label-width="auto" class="stream-form">
+        <el-form-item :label="t('名称') + ':'" prop="name">
+          <el-input v-model="form.name" :placeholder="t('例如: 测试推流-001')" style="width: 300px" />
+        </el-form-item>
+        <el-form-item :label="t('LSS 节点') + ':'" prop="lssId">
+          <el-select v-model="form.lssId" :placeholder="t('请选择 LSS 节点')" clearable filterable style="width: 300px">
+            <el-option
+              v-for="lss in lssOptions"
+              :key="lss.lssId"
+              :label="`${lss.lssId} - ${lss.lssName}`"
+              :value="lss.lssId"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item :label="t('摄像头') + ':'" prop="cameraId">
+          <el-select v-model="form.cameraId" :placeholder="t('请选择摄像头')" clearable filterable style="width: 300px">
+            <el-option
+              v-for="camera in cameraOptions"
+              :key="camera.cameraId"
+              :label="`${camera.cameraId} - ${camera.cameraName}`"
+              :value="camera.cameraId"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item :label="t('推流方式') + ':'" prop="pushMethod">
+          <el-select disabled v-model="form.pushMethod" :placeholder="t('请选择')" style="width: 300px">
+            <el-option label="ffmpeg" value="ffmpeg" />
+          </el-select>
+        </el-form-item>
+        <el-form-item :label="t('命令模板') + ':'" prop="commandTemplate">
+          <CodeEditor
+            v-model="form.commandTemplate"
+            language="bash"
+            height="400px"
+            placeholder="#!/bin/bash&#10;# FFmpeg 命令模板,可使用 {RTSP_URL} 等变量"
+          />
+        </el-form-item>
+      </el-form>
+    </div>
+    <div class="drawer-footer">
+      <el-button @click="$emit('cancel')">{{ t('取消') }}</el-button>
+      <el-button type="primary" :loading="submitLoading" @click="$emit('submit')">
+        {{ isEdit ? t('更新') : t('添加') }}
+      </el-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { useI18n } from 'vue-i18n'
+import type { FormInstance, FormRules } from 'element-plus'
+import CodeEditor from '@/components/CodeEditor.vue'
+import type { LssNodeDTO, CameraInfoDTO } from '@/types'
+import type { StreamForm } from '../types'
+
+const { t } = useI18n({ useScope: 'global' })
+
+defineProps<{
+  form: StreamForm
+  rules: FormRules
+  isEdit: boolean
+  submitLoading: boolean
+  lssOptions: LssNodeDTO[]
+  cameraOptions: CameraInfoDTO[]
+}>()
+
+defineEmits<{
+  submit: []
+  cancel: []
+}>()
+
+const elFormRef = ref<FormInstance>()
+
+defineExpose({
+  formRef: elFormRef
+})
+</script>
+<style scoped lang="scss">
+.tab-content {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.edit-content {
+  .drawer-body {
+    flex: 1;
+    overflow-y: auto;
+  }
+}
+
+.drawer-body {
+  flex: 1;
+  overflow-y: auto;
+  padding: 20px;
+}
+
+.stream-form {
+  :deep(.el-form-item) {
+    margin-bottom: 18px;
+  }
+
+  :deep(.el-form-item__label) {
+    color: #606266;
+    font-size: 14px;
+  }
+}
+
+.drawer-footer {
+  flex-shrink: 0;
+  display: flex;
+  justify-content: flex-end;
+  padding: 12px 20px;
+  border-top: 1px solid #e5e7eb;
+  gap: 12px;
+}
+</style>

+ 1054 - 0
src/views/live-stream/components/StreamPlayer.vue

@@ -0,0 +1,1054 @@
+<template>
+  <div class="tab-content play-content">
+    <div class="media-drawer-content">
+      <!-- 左侧:视频播放区域 -->
+      <div class="video-area">
+        <div class="video-header">
+          <div class="header-left">
+            <span class="title">{{ currentMediaStream?.name || t('流媒体播放') }}</span>
+            <el-tag v-if="playbackInfo.isLive" type="success" size="small">{{ t('直播中') }}</el-tag>
+            <el-tag v-else type="info" size="small">{{ t('离线') }}</el-tag>
+          </div>
+          <el-button
+            v-if="currentMediaStream && currentMediaStream.status === '1'"
+            type="danger"
+            size="small"
+            :loading="streamStopping"
+            @click="$emit('stopStream')"
+          >
+            {{ t('停止推流') }}
+          </el-button>
+        </div>
+        <div class="player-container">
+          <div v-if="!playbackInfo.videoId" class="player-placeholder">
+            <el-icon :size="80" color="#666">
+              <VideoPlay />
+            </el-icon>
+            <p>{{ t('暂无视频流') }}</p>
+          </div>
+          <VideoPlayer
+            v-else
+            ref="playerRef"
+            player-type="cloudflare"
+            :video-id="playbackInfo.videoId"
+            :customer-domain="playbackInfo.customerDomain"
+            :use-iframe="true"
+            :autoplay="playConfig.autoplay"
+            :muted="playConfig.muted"
+            :controls="true"
+          />
+          <!-- 开始推流按钮 -->
+          <div v-if="currentMediaStream && currentMediaStream.status !== '1'" class="stream-control-overlay">
+            <el-button type="success" size="large" :loading="streamStarting" @click="$emit('startStream')">
+              {{ t('开始推流') }}
+            </el-button>
+          </div>
+        </div>
+
+        <!-- 时间轴操作条 -->
+        <div class="timeline-container">
+          <div class="timeline-header">
+            <span class="timeline-label">{{ t('巡航时间轴') }}</span>
+            <el-select
+              v-model="timelineDurationModel"
+              size="small"
+              style="width: 100px"
+              :disabled="isTimelinePlaying"
+              @change="$emit('durationChange')"
+            >
+              <el-option :value="60" :label="t('1分钟')" />
+              <el-option :value="180" :label="t('3分钟')" />
+              <el-option :value="300" :label="t('5分钟')" />
+              <el-option :value="600" :label="t('10分钟')" />
+            </el-select>
+            <el-button size="small" :disabled="isTimelinePlaying" @click="$emit('addPoint')">
+              + {{ t('添加点') }}
+            </el-button>
+            <el-switch
+              v-model="isLoopEnabledModel"
+              :disabled="isTimelinePlaying"
+              size="small"
+              :active-text="t('循环')"
+              style="margin-left: 8px"
+            />
+            <el-button
+              size="small"
+              type="primary"
+              :loading="isTimelinePlaying"
+              :disabled="!hasActivePoints"
+              @click="$emit('playTimeline')"
+            >
+              <el-icon v-if="!isTimelinePlaying">
+                <VideoPlay />
+              </el-icon>
+              {{ isTimelinePlaying ? t('巡航中...') : t('播放巡航') }}
+            </el-button>
+            <el-button v-if="isTimelinePlaying" size="small" type="danger" @click="$emit('stopTimeline')">
+              {{ t('停止') }}
+            </el-button>
+          </div>
+
+          <!-- 时间轴轨道 -->
+          <div :class="['timeline-track', { 'is-playing': isTimelinePlaying }]" ref="timelineTrackRef">
+            <div class="timeline-progress" :style="{ width: `${timelineProgress}%` }">
+              <div v-if="isTimelinePlaying" class="progress-time">
+                {{ formatTimelineTime(currentPlayTime) }}
+              </div>
+            </div>
+            <div
+              v-for="point in timelinePoints"
+              :key="point.id"
+              :class="[
+                'timeline-point',
+                {
+                  active: point.active,
+                  selected: !isTimelinePlaying && selectedPoint?.id === point.id,
+                  dragging: draggingPoint?.id === point.id,
+                  passed: isTimelinePlaying && point.passed
+                }
+              ]"
+              :style="{ left: `${(point.time / timelineDuration) * 100}%` }"
+              @click.stop="!isTimelinePlaying && $emit('selectPoint', point)"
+              @mousedown.stop="!isTimelinePlaying && $emit('startDragPoint', $event, point)"
+              @contextmenu.prevent="!isTimelinePlaying && $emit('showContextMenu', $event, point)"
+            >
+              <div class="point-number">{{ point.id }}</div>
+              <div v-if="draggingPoint?.id === point.id" class="point-drag-time">
+                {{ formatTimelineTime(point.time) }}
+              </div>
+              <div class="point-tooltip">
+                <div>{{ point.presetName || `Point ${point.id}` }}</div>
+                <div class="point-time">{{ formatTimelineTime(point.time) }}</div>
+              </div>
+            </div>
+          </div>
+
+          <!-- 时间刻度 -->
+          <div class="timeline-scale">
+            <span v-for="i in Math.floor(timelineDuration / 60) + 1" :key="i" class="scale-mark">
+              {{ formatTimelineTime((i - 1) * 60) }}
+            </span>
+          </div>
+
+          <!-- 右键菜单 -->
+          <div
+            v-if="contextMenu.visible"
+            class="timeline-context-menu"
+            :style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
+            @click.stop
+          >
+            <div class="context-menu-item" @click="$emit('contextMenuUpdate')">
+              <el-icon>
+                <Position />
+              </el-icon>
+              {{ t('更新位置') }}
+            </div>
+            <div class="context-menu-item danger" @click="$emit('contextMenuDelete')">
+              <el-icon>
+                <Delete />
+              </el-icon>
+              {{ t('删除') }}
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 右侧:PTZ 控制面板 -->
+      <div class="control-panel">
+        <el-collapse v-model="activePanelsModel" class="ptz-collapse">
+          <!-- PTZ 方向控制 -->
+          <el-collapse-item name="ptz">
+            <template #title>
+              <span class="collapse-title">{{ t('PTZ') }}</span>
+            </template>
+            <div class="ptz-grid">
+              <div class="ptz-btn" @mousedown="$emit('ptz', 'up_left')">
+                <el-icon size="24">
+                  <Icon icon="mdi:arrow-top-left" width="24" height="24" />
+                </el-icon>
+              </div>
+              <div class="ptz-btn" @mousedown="$emit('ptz', 'up')">
+                <el-icon size="24">
+                  <Icon icon="mdi:arrow-top" width="24" height="24" />
+                </el-icon>
+              </div>
+              <div class="ptz-btn" @mousedown="$emit('ptz', 'up_right')">
+                <el-icon size="24">
+                  <Icon icon="mdi:arrow-top-right" width="24" height="24" />
+                </el-icon>
+              </div>
+              <div class="ptz-btn" @mousedown="$emit('ptz', 'left')">
+                <el-icon size="24">
+                  <Icon icon="mdi:arrow-left" width="24" height="24" />
+                </el-icon>
+              </div>
+              <div class="ptz-btn ptz-center" @click="$emit('ptz', 'stop')">
+                <el-icon size="24">
+                  <Icon icon="mdi:stop" width="24" height="24" />
+                </el-icon>
+              </div>
+              <div class="ptz-btn" @mousedown="$emit('ptz', 'right')">
+                <el-icon size="24">
+                  <Icon icon="mdi:arrow-right" width="24" height="24" />
+                </el-icon>
+              </div>
+              <div class="ptz-btn" @mousedown="$emit('ptz', 'down_left')">
+                <el-icon size="24">
+                  <Icon icon="mdi:arrow-bottom-left" width="24" height="24" />
+                </el-icon>
+              </div>
+              <div class="ptz-btn" @mousedown="$emit('ptz', 'down')">
+                <el-icon size="24">
+                  <Icon icon="mdi:arrow-down" width="24" height="24" />
+                </el-icon>
+              </div>
+              <div class="ptz-btn" @mousedown="$emit('ptz', 'down_right')">
+                <el-icon size="24">
+                  <Icon icon="mdi:arrow-bottom-right" width="24" height="24" />
+                </el-icon>
+              </div>
+            </div>
+
+            <div class="zoom-buttons">
+              <el-button size="small" @mousedown="$emit('zoomIn')">
+                <el-icon size="20">
+                  <Icon icon="mdi:zoom-in-outline" width="20" height="20" />
+                </el-icon>
+              </el-button>
+              <el-button size="small" @mousedown="$emit('zoomOut')">
+                <el-icon size="20">
+                  <Icon icon="mdi:zoom-out-outline" width="20" height="20" />
+                </el-icon>
+              </el-button>
+              <el-button type="primary" size="small" @click="$emit('ptzStop')">
+                <el-icon size="20">
+                  <Icon icon="mdi:stop" width="20" height="20" />
+                </el-icon>
+              </el-button>
+            </div>
+
+            <div class="speed-slider">
+              <span class="label">{{ t('速度') }}</span>
+              <el-slider v-model="ptzSpeedModel" :min="1" :max="100" :step="1" size="small" />
+              <span class="value">{{ ptzSpeed }}</span>
+            </div>
+          </el-collapse-item>
+
+          <!-- 预置位列表 -->
+          <el-collapse-item name="preset">
+            <template #title>
+              <span class="collapse-title">{{ t('预置位') }}</span>
+            </template>
+            <div class="preset-list">
+              <div
+                v-for="preset in localPresetList"
+                :key="preset.id"
+                :class="['preset-item', { active: activePresetId === preset.id }]"
+              >
+                <span class="preset-index">{{ preset.pointId }}</span>
+                <el-input
+                  v-if="editingPresetId === preset.id"
+                  v-model="editingPresetNameModel"
+                  size="small"
+                  class="preset-name-input"
+                  @blur="$emit('savePresetName', preset)"
+                  @keyup.enter="$emit('savePresetName', preset)"
+                  @keyup.esc="$emit('cancelEditPresetName')"
+                  autofocus
+                />
+                <span v-else class="preset-name" @dblclick="$emit('startEditPresetName', preset)">
+                  {{ preset.name }}
+                </span>
+                <div class="preset-actions">
+                  <el-tooltip :content="t('跳转')" placement="top">
+                    <el-icon class="action-icon" size="20" @click="$emit('gotoLocalPreset', preset)">
+                      <Icon icon="mdi:arrow-up-left-bold" width="20" height="20" />
+                    </el-icon>
+                  </el-tooltip>
+                  <el-tooltip :content="t('设置')" placement="top">
+                    <el-icon class="action-icon" size="20" @click="$emit('updateLocalPreset', preset)">
+                      <Icon icon="mdi:gear" width="20" height="20" />
+                    </el-icon>
+                  </el-tooltip>
+                  <el-tooltip :content="t('删除')" placement="top">
+                    <el-icon class="action-icon delete" size="20" @click="$emit('deleteLocalPreset', preset)">
+                      <Icon icon="mdi:delete-forever" width="20" height="20" />
+                    </el-icon>
+                  </el-tooltip>
+                </div>
+              </div>
+              <el-empty v-if="localPresetList.length === 0" :description="t('暂无预置位')" :image-size="60" />
+            </div>
+          </el-collapse-item>
+
+          <!-- 摄像头信息 -->
+          <el-collapse-item name="camera">
+            <template #title>
+              <span class="collapse-title">{{ t('摄像头信息') }}</span>
+            </template>
+            <div class="camera-info-content" v-loading="capabilitiesLoading">
+              <template v-if="cameraCapabilities">
+                <div class="info-item">
+                  <span class="info-label">{{ t('最大预置位') }}:</span>
+                  <span class="info-value">{{ cameraCapabilities.maxPresetNum || '-' }}</span>
+                </div>
+                <div class="info-item" v-if="cameraCapabilities.controlProtocol">
+                  <span class="info-label">{{ t('控制协议') }}:</span>
+                  <span class="info-value">{{ cameraCapabilities.controlProtocol.current }}</span>
+                </div>
+                <div class="info-item" v-if="cameraCapabilities.absoluteZoom">
+                  <span class="info-label">{{ t('变焦倍数') }}:</span>
+                  <span class="info-value">
+                    {{ cameraCapabilities.absoluteZoom.min }}x - {{ cameraCapabilities.absoluteZoom.max }}x
+                  </span>
+                </div>
+                <div class="info-item" v-if="cameraCapabilities.support3DPosition !== undefined">
+                  <span class="info-label">{{ t('3D定位') }}:</span>
+                  <span class="info-value">
+                    {{ cameraCapabilities.support3DPosition ? t('支持') : t('不支持') }}
+                  </span>
+                </div>
+                <div class="info-item" v-if="cameraCapabilities.supportPtzLimits !== undefined">
+                  <span class="info-label">{{ t('PTZ限位') }}:</span>
+                  <span class="info-value">
+                    {{ cameraCapabilities.supportPtzLimits ? t('支持') : t('不支持') }}
+                  </span>
+                </div>
+              </template>
+              <el-empty
+                v-else-if="!capabilitiesLoading"
+                :description="currentMediaStream?.cameraId ? t('点击刷新加载') : t('请选择直播流')"
+                :image-size="40"
+              />
+            </div>
+          </el-collapse-item>
+        </el-collapse>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { VideoPlay, Position, Delete } from '@element-plus/icons-vue'
+import { Icon } from '@iconify/vue'
+import VideoPlayer from '@/components/VideoPlayer.vue'
+import type { LiveStreamDTO } from '@/types'
+import type { PlaybackInfo, TimelinePoint, PTZCapabilities, LocalPreset } from '../types'
+
+const { t } = useI18n({ useScope: 'global' })
+
+const props = defineProps<{
+  currentMediaStream: LiveStreamDTO | null
+  playbackInfo: PlaybackInfo
+  playConfig: { autoplay: boolean; muted: boolean }
+  playerRef: InstanceType<typeof VideoPlayer> | undefined
+  streamStarting: boolean
+  streamStopping: boolean
+  // Timeline
+  timelineTrackRef: HTMLElement | null
+  timelineDuration: number
+  timelinePoints: TimelinePoint[]
+  selectedPoint: TimelinePoint | null
+  isTimelinePlaying: boolean
+  timelineProgress: number
+  currentPlayTime: number
+  isLoopEnabled: boolean
+  hasActivePoints: boolean
+  draggingPoint: TimelinePoint | null
+  contextMenu: { visible: boolean; x: number; y: number; point: TimelinePoint | null }
+  formatTimelineTime: (seconds: number) => string
+  // PTZ
+  ptzSpeed: number
+  activePanels: string[]
+  localPresetList: LocalPreset[]
+  activePresetId: string | null
+  editingPresetId: string | null
+  editingPresetName: string
+  cameraCapabilities: PTZCapabilities | null
+  capabilitiesLoading: boolean
+}>()
+
+const emit = defineEmits<{
+  stopStream: []
+  startStream: []
+  durationChange: []
+  addPoint: []
+  playTimeline: []
+  stopTimeline: []
+  selectPoint: [point: TimelinePoint]
+  startDragPoint: [e: MouseEvent, point: TimelinePoint]
+  showContextMenu: [e: MouseEvent, point: TimelinePoint]
+  contextMenuUpdate: []
+  contextMenuDelete: []
+  ptz: [direction: string]
+  ptzStop: []
+  zoomIn: []
+  zoomOut: []
+  savePresetName: [preset: LocalPreset]
+  cancelEditPresetName: []
+  startEditPresetName: [preset: LocalPreset]
+  gotoLocalPreset: [preset: LocalPreset]
+  updateLocalPreset: [preset: LocalPreset]
+  deleteLocalPreset: [preset: LocalPreset]
+  'update:timelineDuration': [value: number]
+  'update:isLoopEnabled': [value: boolean]
+  'update:ptzSpeed': [value: number]
+  'update:activePanels': [value: string[]]
+  'update:editingPresetName': [value: string]
+}>()
+
+const timelineDurationModel = computed({
+  get: () => props.timelineDuration,
+  set: (v) => emit('update:timelineDuration', v)
+})
+
+const isLoopEnabledModel = computed({
+  get: () => props.isLoopEnabled,
+  set: (v) => emit('update:isLoopEnabled', v)
+})
+
+const ptzSpeedModel = computed({
+  get: () => props.ptzSpeed,
+  set: (v) => emit('update:ptzSpeed', v)
+})
+
+const activePanelsModel = computed({
+  get: () => props.activePanels,
+  set: (v) => emit('update:activePanels', v)
+})
+
+const editingPresetNameModel = computed({
+  get: () => props.editingPresetName,
+  set: (v) => emit('update:editingPresetName', v)
+})
+</script>
+
+<style scoped lang="scss">
+.tab-content {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+// 流媒体播放抽屉样式
+.media-drawer-content {
+  display: flex;
+  height: 100%;
+  padding: 20px;
+  gap: 8px;
+  overflow: hidden;
+}
+
+// 左侧视频区域
+.video-area {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  background-color: #e1e1e1;
+  overflow: hidden;
+
+  .video-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 12px 16px;
+    background-color: #f5f7fa;
+
+    .header-left {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+    }
+
+    .title {
+      font-size: 16px;
+      font-weight: 600;
+      color: #303133;
+    }
+  }
+
+  .player-container {
+    flex: 1;
+    min-height: 0;
+    background-color: #000;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    position: relative;
+
+    .player-placeholder {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      color: #909399;
+
+      p {
+        margin-top: 15px;
+        font-size: 14px;
+      }
+    }
+
+    .stream-control-overlay {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      z-index: 10;
+
+      .el-button {
+        font-size: 18px;
+        padding: 16px 32px;
+        border-radius: 0;
+      }
+    }
+  }
+}
+
+// 时间轴容器
+.timeline-container {
+  background: #1a1a1a;
+  padding: 12px 16px;
+  flex-shrink: 0;
+
+  .timeline-header {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    margin-bottom: 12px;
+
+    .timeline-label {
+      color: #fff;
+      font-size: 13px;
+      font-weight: 500;
+    }
+
+    :deep(.el-select) {
+      .el-input__wrapper {
+        background-color: #333;
+        border-color: #444;
+      }
+
+      .el-input__inner {
+        color: #fff;
+      }
+    }
+  }
+
+  .timeline-track {
+    position: relative;
+    height: 12px;
+    background: #374151;
+    border-radius: 6px;
+    margin-bottom: 8px;
+    cursor: pointer;
+
+    &.is-playing {
+      cursor: not-allowed;
+
+      .timeline-point {
+        cursor: not-allowed;
+        pointer-events: none;
+      }
+    }
+
+    .timeline-progress {
+      position: absolute;
+      top: 0;
+      left: 0;
+      height: 100%;
+      background: linear-gradient(to right, #e0ecfc, #d0e4ff);
+      border-radius: 2px 0 0 2px;
+      pointer-events: none;
+      transition: width 0.3s ease;
+
+      .progress-time {
+        position: absolute;
+        right: 0;
+        top: calc(100% + 4px);
+        transform: translateX(50%);
+        background: #ef4444;
+        color: #fff;
+        padding: 2px 6px;
+        border-radius: 4px;
+        font-size: 11px;
+        font-weight: 600;
+        white-space: nowrap;
+        pointer-events: none;
+        z-index: 15;
+
+        &::before {
+          content: '';
+          position: absolute;
+          bottom: 100%;
+          left: 50%;
+          transform: translateX(-50%);
+          border: 4px solid transparent;
+          border-bottom-color: #ef4444;
+        }
+      }
+    }
+
+    .timeline-point {
+      position: absolute;
+      top: 50%;
+      width: 24px;
+      height: 24px;
+      transform: translate(-50%, -50%);
+      background: #6b7280;
+      border-radius: 50%;
+      cursor: grab;
+      z-index: 5;
+      transition: all 0.15s ease;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      &:hover {
+        transform: translate(-50%, -50%) scale(1.15);
+        background: white;
+
+        .point-tooltip {
+          opacity: 1;
+          visibility: visible;
+        }
+      }
+
+      &.active {
+        background: #ffffff;
+      }
+
+      &.selected {
+        transform: translate(-50%, -50%) scale(1.15);
+        background: #3b82f6;
+        box-shadow: 0 0 12px rgba(59, 130, 246, 0.7);
+        color: white;
+        z-index: 10;
+
+        .point-tooltip {
+          opacity: 1;
+          visibility: visible;
+        }
+      }
+
+      &.passed {
+        background: var(--color-primary);
+        color: white;
+        box-shadow: 0 0 8px rgba(var(--color-primary-light-3), 0.6);
+      }
+
+      &.dragging {
+        cursor: grabbing;
+        transform: translate(-50%, -50%) scale(1.2);
+        background: #fbbf24;
+        box-shadow: 0 0 14px rgba(251, 191, 36, 0.8);
+        z-index: 20;
+        transition: none;
+
+        .point-drag-time {
+          opacity: 1;
+          visibility: visible;
+        }
+
+        .point-tooltip {
+          opacity: 0;
+          visibility: hidden;
+        }
+      }
+
+      .point-number {
+        font-size: 11px;
+        font-weight: 600;
+        pointer-events: none;
+        line-height: 1;
+      }
+
+      .point-drag-time {
+        position: absolute;
+        bottom: calc(100% + 6px);
+        left: 50%;
+        transform: translateX(-50%);
+        background: #fbbf24;
+        color: #000;
+        padding: 4px 8px;
+        border-radius: 4px;
+        font-size: 12px;
+        font-weight: 600;
+        white-space: nowrap;
+        opacity: 0;
+        visibility: hidden;
+        pointer-events: none;
+
+        &::after {
+          content: '';
+          position: absolute;
+          top: 100%;
+          left: 50%;
+          transform: translateX(-50%);
+          border: 5px solid transparent;
+          border-top-color: #fbbf24;
+        }
+      }
+
+      .point-tooltip {
+        position: absolute;
+        bottom: calc(100% + 6px);
+        left: 50%;
+        transform: translateX(-50%);
+        background: rgba(0, 0, 0, 0.9);
+        color: #fff;
+        padding: 6px 10px;
+        border-radius: 4px;
+        font-size: 11px;
+        white-space: nowrap;
+        opacity: 0;
+        visibility: hidden;
+        transition: all 0.15s ease;
+        pointer-events: none;
+
+        .point-time {
+          color: #9ca3af;
+          font-size: 10px;
+          margin-top: 2px;
+        }
+
+        &::after {
+          content: '';
+          position: absolute;
+          top: 100%;
+          left: 50%;
+          transform: translateX(-50%);
+          border: 5px solid transparent;
+          border-top-color: rgba(0, 0, 0, 0.9);
+        }
+      }
+    }
+  }
+
+  .timeline-scale {
+    display: flex;
+    justify-content: space-between;
+    padding: 0 2px;
+
+    .scale-mark {
+      font-size: 10px;
+      color: #6b7280;
+    }
+  }
+
+  .timeline-context-menu {
+    position: fixed;
+    background: #2a2a2a;
+    border: 1px solid #404040;
+    border-radius: 6px;
+    padding: 4px 0;
+    min-width: 140px;
+    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
+    z-index: 1000;
+
+    .context-menu-item {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding: 8px 12px;
+      color: #e5e7eb;
+      font-size: 13px;
+      cursor: pointer;
+      transition: background 0.15s ease;
+
+      .el-icon {
+        font-size: 16px;
+      }
+
+      &:hover {
+        background: #374151;
+      }
+
+      &.danger {
+        color: #f87171;
+
+        &:hover {
+          background: rgba(239, 68, 68, 0.15);
+        }
+      }
+    }
+  }
+}
+
+// 右侧控制面板
+.control-panel {
+  width: 280px;
+  flex-shrink: 0;
+  display: flex;
+  flex-direction: column;
+  overflow-y: auto;
+
+  .ptz-collapse {
+    border: none;
+
+    :deep(.el-collapse-item) {
+      margin-bottom: 8px;
+      background-color: #fff;
+      border-radius: 8px;
+      overflow: hidden;
+      box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+
+      .el-collapse-item__header {
+        padding: 0 16px;
+        height: 44px;
+        border-bottom: 1px solid #e5e7eb;
+        background-color: #f5f7fa;
+
+        &.is-active {
+          border-bottom-color: #e5e7eb;
+        }
+      }
+
+      .el-collapse-item__wrap {
+        border-bottom: none;
+      }
+
+      .el-collapse-item__content {
+        padding: 0;
+      }
+    }
+
+    .collapse-title {
+      font-size: 14px;
+      font-weight: 600;
+      color: #303133;
+    }
+  }
+}
+
+// PTZ 方向控制网格
+.ptz-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 6px;
+  margin: 12px;
+}
+
+.ptz-btn {
+  aspect-ratio: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: #f5f7fa;
+  border: 1px solid #dcdfe6;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.2s;
+  color: #606266;
+
+  &:hover {
+    background-color: #ecf5ff;
+    border-color: #4f46e5;
+    color: #4f46e5;
+  }
+
+  &:active {
+    background-color: #4f46e5;
+    color: #fff;
+  }
+
+  .el-icon {
+    font-size: 18px;
+  }
+
+  &.ptz-center {
+    background-color: #e5e7eb;
+
+    &:hover {
+      background-color: #4f46e5;
+      color: #fff;
+    }
+  }
+}
+
+.zoom-buttons {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+  margin-bottom: 12px;
+
+  .el-button {
+    background-color: #f5f7fa;
+    border-color: #dcdfe6;
+    color: #606266;
+
+    &:hover {
+      background-color: #ecf5ff;
+      border-color: #4f46e5;
+      color: #4f46e5;
+    }
+  }
+}
+
+.speed-slider {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+
+  .label {
+    font-size: 12px;
+    color: #606266;
+    flex-shrink: 0;
+  }
+
+  .value {
+    font-size: 12px;
+    color: #4f46e5;
+    width: 30px;
+    text-align: right;
+  }
+
+  :deep(.el-slider) {
+    flex: 1;
+  }
+}
+
+.preset-list {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  max-height: 250px;
+  overflow-y: auto;
+}
+
+.preset-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 10px;
+  background-color: #fff;
+  border-bottom: 1px solid #ebeef5;
+  cursor: default;
+  transition: all 0.2s;
+
+  &:hover {
+    background-color: #f5f7fa;
+
+    .preset-actions {
+      opacity: 1;
+      visibility: visible;
+    }
+  }
+
+  &.active {
+    background-color: #ecf5ff;
+
+    .preset-index {
+      background-color: #4f46e5;
+      color: #fff;
+    }
+
+    .preset-name {
+      color: #4f46e5;
+      font-weight: 500;
+    }
+  }
+
+  .preset-index {
+    width: 24px;
+    height: 24px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background-color: #f0f0f0;
+    border-radius: 4px;
+    font-size: 12px;
+    font-weight: 500;
+    color: #606266;
+    flex-shrink: 0;
+  }
+
+  .preset-name {
+    flex: 1;
+    font-size: 13px;
+    color: #606266;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    cursor: pointer;
+    padding: 2px 4px;
+    border-radius: 4px;
+
+    &:hover {
+      background-color: rgba(0, 0, 0, 0.05);
+    }
+  }
+
+  .preset-name-input {
+    flex: 1;
+
+    :deep(.el-input__wrapper) {
+      padding: 0 8px;
+    }
+
+    :deep(.el-input__inner) {
+      font-size: 13px;
+    }
+  }
+
+  .preset-actions {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    opacity: 0;
+    visibility: hidden;
+    transition: all 0.2s;
+
+    .action-icon {
+      width: 20px;
+      height: 20px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      cursor: pointer;
+      color: #909399;
+      transition: color 0.2s;
+
+      &:hover {
+        color: #4f46e5;
+      }
+
+      &.delete:hover {
+        color: #f56c6c;
+      }
+    }
+  }
+}
+
+.camera-info-content {
+  min-height: 60px;
+}
+
+.info-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 6px 0;
+  border-bottom: 1px dashed #e5e7eb;
+
+  &:last-child {
+    border-bottom: none;
+  }
+}
+
+.info-label {
+  font-size: 12px;
+  color: #909399;
+}
+
+.info-value {
+  font-size: 12px;
+  color: #303133;
+  font-weight: 500;
+}
+</style>

+ 403 - 0
src/views/live-stream/composables/usePTZ.ts

@@ -0,0 +1,403 @@
+import { ref, type Ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { useI18n } from 'vue-i18n'
+import {
+  type PresetInfo,
+  presetList,
+  presetGoto,
+  presetSet,
+  presetRemove,
+  getPTZCapabilities,
+  ptzControl
+} from '@/api/camera'
+import type { LiveStreamDTO, PTZAction } from '@/types'
+import type { PTZCapabilities, LocalPreset, TimelinePoint } from '../types'
+
+const directionToAction: Record<string, PTZAction> = {
+  up: 'up',
+  down: 'down',
+  left: 'left',
+  right: 'right',
+  up_left: 'up_left',
+  up_right: 'up_right',
+  down_left: 'down_left',
+  down_right: 'down_right',
+  zoom_in: 'zoom_in',
+  zoom_out: 'zoom_out',
+  stop: 'stop'
+}
+
+export function usePTZ(
+  currentMediaStream: Ref<LiveStreamDTO | null>,
+  timelinePoints: Ref<TimelinePoint[]>,
+  callbacks: {
+    sortAndRenumberPoints: () => void
+    saveTimelineConfig: () => void
+    selectedPoint: Ref<TimelinePoint | null>
+  }
+) {
+  const { t } = useI18n({ useScope: 'global' })
+
+  const ptzSpeed = ref(50)
+  const zoomValue = ref(0)
+  const ptzPresetList = ref<PresetInfo[]>([])
+  const activePresetId = ref<string | null>(null)
+  const editingPresetId = ref<string | null>(null)
+  const editingPresetName = ref('')
+  const cameraCapabilities = ref<PTZCapabilities | null>(null)
+  const capabilitiesLoading = ref(false)
+  const presetsLoading = ref(false)
+  const activePanels = ref(['ptz', 'preset', 'camera'])
+
+  function hasCameraConnection(): boolean {
+    return !!currentMediaStream.value?.cameraId
+  }
+
+  async function handlePTZ(direction: string) {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) {
+      ElMessage.warning(t('请先选择直播流'))
+      return
+    }
+    try {
+      const command = directionToAction[direction]
+      const res = await ptzControl({ cameraId, action: command, speed: ptzSpeed.value })
+      if (!res.success) {
+        console.error('PTZ 控制失败', res.errMsg)
+      }
+    } catch (error) {
+      console.error('PTZ 控制失败', error)
+    }
+  }
+
+  async function handlePTZStop() {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) return
+    try {
+      await ptzControl({ cameraId, action: 'stop' })
+    } catch (error) {
+      console.error('PTZ 停止失败', error)
+    }
+  }
+
+  function formatZoomTooltip(val: number) {
+    if (val === 0) return t('停止')
+    return val > 0 ? `${t('放大')} ${val}` : `${t('缩小')} ${Math.abs(val)}`
+  }
+
+  async function handleZoomChange(val: number) {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) return
+    if (val === 0) {
+      await ptzControl({ cameraId, action: 'stop' })
+      return
+    }
+    const command = val > 0 ? 'zoom_in' : 'zoom_out'
+    await ptzControl({ cameraId, action: command, speed: Math.abs(val) })
+  }
+
+  async function handleZoomRelease() {
+    zoomValue.value = 0
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) return
+    await ptzControl({ cameraId, action: 'stop' })
+  }
+
+  async function handleZoomIn() {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) {
+      ElMessage.warning(t('请先选择直播流'))
+      return
+    }
+    try {
+      const res = await ptzControl({ cameraId, action: 'zoom_in', speed: ptzSpeed.value })
+      if (!res.success) console.error('Zoom in 失败', res.errMsg)
+    } catch (error) {
+      console.error('Zoom in 失败', error)
+    }
+  }
+
+  async function handleZoomOut() {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) {
+      ElMessage.warning(t('请先选择直播流'))
+      return
+    }
+    try {
+      const res = await ptzControl({ cameraId, action: 'zoom_out', speed: ptzSpeed.value })
+      if (!res.success) console.error('Zoom out 失败', res.errMsg)
+    } catch (error) {
+      console.error('Zoom out 失败', error)
+    }
+  }
+
+  async function loadPTZPresets() {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) {
+      ElMessage.warning(t('请先选择直播流'))
+      return
+    }
+    presetsLoading.value = true
+    try {
+      const res = await presetList({ cameraId })
+      if (res.code === 200 && res.data) {
+        ptzPresetList.value = res.data as PresetInfo[]
+      } else {
+        ptzPresetList.value = []
+        if (!res.success) ElMessage.error(res.errMsg || t('加载预置位失败'))
+      }
+    } catch (error) {
+      console.error('加载 PTZ 预置位失败', error)
+      ptzPresetList.value = []
+    } finally {
+      presetsLoading.value = false
+    }
+  }
+
+  async function handleGotoPTZPreset(preset: PresetInfo) {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) {
+      ElMessage.warning(t('请先配置摄像头连接'))
+      return
+    }
+    try {
+      activePresetId.value = preset.id
+      const res = await presetGoto({ cameraId, presetId: parseInt(preset.id) })
+      if (res.code === 200) {
+        ElMessage.success(`${t('已跳转到预置位')}: ${preset.name || preset.id}`)
+      } else {
+        ElMessage.error(res.errMsg || t('跳转失败'))
+      }
+    } catch (error) {
+      console.error('跳转 PTZ 预置位失败', error)
+      ElMessage.error(t('跳转失败'))
+    }
+  }
+
+  async function handleEditPreset(preset: { id: string; name: string }) {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) {
+      ElMessage.warning(t('请先配置摄像头连接'))
+      return
+    }
+    try {
+      await ElMessageBox.confirm(
+        `${t('将当前摄像头位置保存到预置位')} "${preset.name || `Preset ${preset.id}`}"?`,
+        t('设置预置位'),
+        { type: 'info' }
+      )
+      const res = await presetSet({
+        cameraId,
+        presetId: parseInt(preset.id),
+        presetName: preset.name || `Preset ${preset.id}`
+      })
+      if (res.code === 200) {
+        ElMessage.success(`${t('预置位设置成功')}: ${preset.name || `Preset ${preset.id}`}`)
+      } else {
+        ElMessage.error(res.errMsg || t('设置失败'))
+      }
+    } catch (error) {
+      if ((error as Error).toString().includes('cancel')) return
+      console.error('设置预置位失败', error)
+      ElMessage.error(t('设置失败'))
+    }
+  }
+
+  async function handleDeletePreset(preset: { id: string; name: string }) {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) {
+      ElMessage.warning(t('请先配置摄像头连接'))
+      return
+    }
+    try {
+      await ElMessageBox.confirm(`${t('确定删除预置位')} "${preset.name || `Preset ${preset.id}`}"?`, t('删除确认'), {
+        type: 'warning'
+      })
+      const res = await presetRemove({ cameraId, presetId: parseInt(preset.id) })
+      if (res.success) {
+        ElMessage.success(t('删除成功'))
+        loadPTZPresets()
+      } else {
+        ElMessage.error(res.errMsg || t('删除失败'))
+      }
+    } catch (error) {
+      if (error !== 'cancel') {
+        console.error('删除预置位失败', error)
+        ElMessage.error(t('删除失败'))
+      }
+    }
+  }
+
+  // Local preset operations (based on timelinePoints)
+  function startEditPresetName(preset: LocalPreset) {
+    editingPresetId.value = preset.id
+    editingPresetName.value = preset.name
+  }
+
+  function cancelEditPresetName() {
+    editingPresetId.value = null
+    editingPresetName.value = ''
+  }
+
+  function savePresetName(preset: LocalPreset) {
+    const newName = editingPresetName.value.trim()
+    if (!newName || newName === preset.name) {
+      cancelEditPresetName()
+      return
+    }
+    const point = timelinePoints.value.find((p) => p.id === preset.pointId)
+    if (!point) {
+      cancelEditPresetName()
+      return
+    }
+    point.presetName = newName
+    callbacks.saveTimelineConfig()
+    cancelEditPresetName()
+  }
+
+  async function handleGotoLocalPreset(preset: LocalPreset) {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) {
+      ElMessage.warning(t('请先配置摄像头连接'))
+      return
+    }
+    const point = timelinePoints.value.find((p) => p.id === preset.pointId)
+    if (!point?.presetId) {
+      ElMessage.warning(t('未找到对应的预置位'))
+      return
+    }
+    try {
+      activePresetId.value = preset.id
+      const res = await presetGoto({ cameraId, presetId: point.presetId })
+      if (res.code === 200) {
+        ElMessage.success(`${t('已跳转到')}: ${preset.name}`)
+      } else {
+        ElMessage.error(res.errMsg || t('跳转失败'))
+      }
+    } catch (error) {
+      console.error('跳转预置位失败', error)
+      ElMessage.error(t('跳转失败'))
+    }
+  }
+
+  async function handleUpdateLocalPreset(preset: LocalPreset) {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) {
+      ElMessage.warning(t('请先配置摄像头连接'))
+      return
+    }
+    const point = timelinePoints.value.find((p) => p.id === preset.pointId)
+    if (!point?.presetId) {
+      ElMessage.warning(t('未找到对应的预置位'))
+      return
+    }
+    try {
+      await ElMessageBox.confirm(`${t('将当前摄像头位置保存到预置位')} "${preset.name}"?`, t('设置预置位'), {
+        type: 'info'
+      })
+      const res = await presetSet({
+        cameraId,
+        presetId: point.presetId,
+        presetName: preset.name
+      })
+      if (res.code === 200) {
+        ElMessage.success(`${t('预置位设置成功')}: ${preset.name}`)
+      } else {
+        ElMessage.error(res.errMsg || t('设置失败'))
+      }
+    } catch (error) {
+      if ((error as Error).toString().includes('cancel')) return
+      console.error('设置预置位失败', error)
+      ElMessage.error(t('设置失败'))
+    }
+  }
+
+  async function handleDeleteLocalPreset(preset: LocalPreset) {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) {
+      ElMessage.warning(t('请先配置摄像头连接'))
+      return
+    }
+    const point = timelinePoints.value.find((p) => p.id === preset.pointId)
+    if (!point?.presetId) {
+      ElMessage.warning(t('未找到对应的预置位'))
+      return
+    }
+    try {
+      await ElMessageBox.confirm(`${t('确定删除预置位')} "${preset.name}"?`, t('删除确认'), {
+        type: 'warning'
+      })
+      const res = await presetRemove({ cameraId, presetId: point.presetId })
+      if (res.success) {
+        const index = timelinePoints.value.findIndex((p) => p.id === preset.pointId)
+        if (index !== -1) {
+          timelinePoints.value.splice(index, 1)
+          callbacks.sortAndRenumberPoints()
+          callbacks.saveTimelineConfig()
+          if (callbacks.selectedPoint.value?.id === preset.pointId) {
+            callbacks.selectedPoint.value = null
+          }
+        }
+        ElMessage.success(t('删除成功'))
+      } else {
+        ElMessage.error(res.errMsg || t('删除失败'))
+      }
+    } catch (error) {
+      if (error !== 'cancel') {
+        console.error('删除预置位失败', error)
+        ElMessage.error(t('删除失败'))
+      }
+    }
+  }
+
+  async function loadCameraCapabilities() {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) return
+    capabilitiesLoading.value = true
+    try {
+      const res = await getPTZCapabilities({ cameraId })
+      if (res.success && res.data) {
+        cameraCapabilities.value = res.data as PTZCapabilities
+      } else {
+        cameraCapabilities.value = null
+      }
+    } catch (error) {
+      console.error('加载摄像头能力失败', error)
+      cameraCapabilities.value = null
+    } finally {
+      capabilitiesLoading.value = false
+    }
+  }
+
+  return {
+    ptzSpeed,
+    zoomValue,
+    ptzPresetList,
+    activePresetId,
+    editingPresetId,
+    editingPresetName,
+    cameraCapabilities,
+    capabilitiesLoading,
+    presetsLoading,
+    activePanels,
+    hasCameraConnection,
+    handlePTZ,
+    handlePTZStop,
+    formatZoomTooltip,
+    handleZoomChange,
+    handleZoomRelease,
+    handleZoomIn,
+    handleZoomOut,
+    loadPTZPresets,
+    handleGotoPTZPreset,
+    handleEditPreset,
+    handleDeletePreset,
+    startEditPresetName,
+    cancelEditPresetName,
+    savePresetName,
+    handleGotoLocalPreset,
+    handleUpdateLocalPreset,
+    handleDeleteLocalPreset,
+    loadCameraCapabilities
+  }
+}

+ 151 - 0
src/views/live-stream/composables/usePlayback.ts

@@ -0,0 +1,151 @@
+import { ref, reactive, type Ref } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { getStreamPlayback } from '@/api/stream-push'
+import VideoPlayer from '@/components/VideoPlayer.vue'
+import type { LiveStreamDTO } from '@/types'
+import type { PlaybackInfo, StreamForm } from '../types'
+
+export function usePlayback(form: StreamForm) {
+  const { t } = useI18n({ useScope: 'global' })
+
+  const playbackInfo = ref<PlaybackInfo>({
+    videoId: '',
+    customerDomain: '',
+    isLive: false
+  })
+  const playConfig = reactive({
+    autoplay: true,
+    muted: true
+  })
+  const playerRef = ref<InstanceType<typeof VideoPlayer>>()
+  const currentMediaStream = ref<LiveStreamDTO | null>(null)
+  const activeDrawerTab = ref<'edit' | 'play'>('edit')
+  const drawerVisible = ref(false)
+
+  function parsePlaybackUrl(playbackUrl: string): { videoId: string; customerDomain: string } | null {
+    try {
+      const url = new URL(playbackUrl)
+      const customerDomain = url.hostname
+      const pathParts = url.pathname.split('/').filter(Boolean)
+      if (pathParts.length > 0) {
+        return { videoId: pathParts[0], customerDomain }
+      }
+    } catch (e) {
+      console.error('解析 playbackUrl 失败', e)
+    }
+    return null
+  }
+
+  async function handleViewCloudflare(
+    row: LiveStreamDTO,
+    callbacks: {
+      loadCameraCapabilities: () => void
+      loadTimelineConfig: () => void
+    }
+  ) {
+    currentMediaStream.value = row
+
+    // 同时填充表单数据
+    Object.assign(form, {
+      id: row.id,
+      name: row.name,
+      lssId: row.lssId || '',
+      cameraId: row.cameraId || '',
+      channelId: row.channelId,
+      pushMethod: row.pushMethod || 'ffmpeg',
+      commandTemplate: row.commandTemplate || '',
+      timeoutSeconds: row.timeoutSeconds || 30,
+      remark: row.remark || '',
+      enabled: row.enabled
+    })
+
+    let videoId = ''
+    let customerDomain = 'customer-pj89kn2ke2tcuh19.cloudflarestream.com'
+
+    if (row.playbackUrl) {
+      const parsed = parsePlaybackUrl(row.playbackUrl)
+      if (parsed) {
+        videoId = parsed.videoId
+        customerDomain = parsed.customerDomain
+      }
+    }
+
+    if (row.streamSn) {
+      try {
+        const res = await getStreamPlayback(row.streamSn)
+        if (res.success && res.data) {
+          playbackInfo.value = {
+            videoId: videoId || row.streamSn,
+            customerDomain,
+            hlsUrl: res.data.hlsUrl,
+            whepUrl: res.data.whepUrl,
+            isLive: res.data.isLive
+          }
+        } else {
+          playbackInfo.value = {
+            videoId: videoId || row.streamSn,
+            customerDomain,
+            isLive: false
+          }
+        }
+      } catch (error) {
+        console.error('获取播放信息失败', error)
+        playbackInfo.value = {
+          videoId: videoId || row.streamSn,
+          customerDomain,
+          isLive: false
+        }
+      }
+    } else if (videoId) {
+      playbackInfo.value = {
+        videoId,
+        customerDomain,
+        isLive: false
+      }
+    }
+
+    activeDrawerTab.value = 'play'
+    drawerVisible.value = true
+
+    if (row.cameraId) {
+      callbacks.loadCameraCapabilities()
+    }
+    callbacks.loadTimelineConfig()
+  }
+
+  function handlePlay() {
+    playerRef.value?.play()
+  }
+
+  function handlePause() {
+    playerRef.value?.pause()
+  }
+
+  function handlePlayerStop() {
+    playerRef.value?.stop()
+  }
+
+  function handleScreenshot() {
+    playerRef.value?.screenshot()
+  }
+
+  function handleFullscreen() {
+    playerRef.value?.fullscreen()
+  }
+
+  return {
+    playbackInfo,
+    playConfig,
+    playerRef,
+    currentMediaStream,
+    activeDrawerTab,
+    drawerVisible,
+    parsePlaybackUrl,
+    handleViewCloudflare,
+    handlePlay,
+    handlePause,
+    handlePlayerStop,
+    handleScreenshot,
+    handleFullscreen
+  }
+}

+ 168 - 0
src/views/live-stream/composables/useStreamControl.ts

@@ -0,0 +1,168 @@
+import { ref, type Ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { useI18n } from 'vue-i18n'
+import { startStreamTask, stopStreamTask, getStreamPlayback } from '@/api/stream-push'
+import type { LiveStreamDTO } from '@/types'
+import type { PlaybackInfo } from '../types'
+
+export function useStreamControl(
+  currentMediaStream: Ref<LiveStreamDTO | null>,
+  playbackInfo: Ref<PlaybackInfo>,
+  getList: () => Promise<void> | void
+) {
+  const { t } = useI18n({ useScope: 'global' })
+
+  const streamStarting = ref(false)
+  const streamStopping = ref(false)
+
+  async function handleToggleStream(row: LiveStreamDTO, val: boolean) {
+    if (val) {
+      await handleStartStream(row)
+    } else {
+      await handleStopStream(row)
+    }
+  }
+
+  async function handleStartStream(row: LiveStreamDTO) {
+    if (!row.cameraId) {
+      ElMessage.warning(t('请先配置摄像头'))
+      return
+    }
+
+    row._starting = true
+    try {
+      const res = await startStreamTask({
+        name: row.name,
+        lssId: row.lssId,
+        cameraId: row.cameraId,
+        commandTemplate: row.commandTemplate
+      })
+      if (res.success) {
+        ElMessage.success(t('推流任务已启动'))
+        getList()
+      } else {
+        ElMessage.error(res.errMessage || t('启动失败'))
+      }
+    } catch (error) {
+      console.error('启动推流失败', error)
+      ElMessage.error(t('启动推流失败'))
+    } finally {
+      row._starting = false
+    }
+  }
+
+  async function handleStartStreamFromPlayer() {
+    if (!currentMediaStream.value) return
+
+    if (!currentMediaStream.value.cameraId) {
+      ElMessage.warning(t('请先配置摄像头'))
+      return
+    }
+
+    streamStarting.value = true
+    try {
+      const res = await startStreamTask({
+        name: currentMediaStream.value.name,
+        lssId: currentMediaStream.value.lssId,
+        cameraId: currentMediaStream.value.cameraId,
+        commandTemplate: currentMediaStream.value.commandTemplate
+      })
+      if (res.success) {
+        ElMessage.success(t('推流任务已启动'))
+        currentMediaStream.value.status = '1'
+        if (currentMediaStream.value.streamSn) {
+          try {
+            const playbackRes = await getStreamPlayback(currentMediaStream.value.streamSn)
+            if (playbackRes.success && playbackRes.data) {
+              playbackInfo.value = {
+                ...playbackInfo.value,
+                hlsUrl: playbackRes.data.hlsUrl,
+                whepUrl: playbackRes.data.whepUrl,
+                isLive: playbackRes.data.isLive
+              }
+            }
+          } catch (e) {
+            console.error('刷新播放信息失败', e)
+          }
+        }
+        getList()
+      } else {
+        ElMessage.error(res.errMessage || t('启动失败'))
+      }
+    } catch (error) {
+      console.error('启动推流失败', error)
+      ElMessage.error(t('启动推流失败'))
+    } finally {
+      streamStarting.value = false
+    }
+  }
+
+  async function handleStopStreamFromPlayer() {
+    if (!currentMediaStream.value) return
+
+    try {
+      await ElMessageBox.confirm(t('确定要停止该推流任务吗?'), t('提示'), {
+        type: 'warning',
+        confirmButtonText: t('确定'),
+        cancelButtonText: t('取消')
+      })
+
+      streamStopping.value = true
+      const res = await stopStreamTask({
+        taskId: currentMediaStream.value.taskStreamSn,
+        lssId: currentMediaStream.value.lssId
+      })
+      if (res.success) {
+        ElMessage.success(t('推流任务已停止'))
+        currentMediaStream.value.status = '0'
+        playbackInfo.value.isLive = false
+        getList()
+      } else {
+        ElMessage.error(res.errMessage || t('停止失败'))
+      }
+    } catch (error) {
+      if (error !== 'cancel') {
+        console.error('停止推流失败', error)
+        ElMessage.error(t('停止推流失败'))
+      }
+    } finally {
+      streamStopping.value = false
+    }
+  }
+
+  async function handleStopStream(row: LiveStreamDTO) {
+    try {
+      await ElMessageBox.confirm(t('确定要停止该推流任务吗?'), t('提示'), {
+        type: 'warning',
+        confirmButtonText: t('确定'),
+        cancelButtonText: t('取消')
+      })
+
+      row._stopping = true
+      const res = await stopStreamTask({ taskId: row.taskStreamSn, lssId: row.lssId })
+      if (res.success) {
+        ElMessage.success(t('推流任务已停止'))
+        getList()
+      } else {
+        ElMessage.error(res.errMessage || t('停止失败'))
+      }
+    } catch (error) {
+      if (error !== 'cancel') {
+        console.error('停止推流失败', error)
+        ElMessage.error(t('停止推流失败'))
+      }
+    } finally {
+      row._stopping = false
+    }
+  }
+
+  return {
+    streamStarting,
+    streamStopping,
+    handleToggleStream,
+    handleStartStream,
+    handleStopStream,
+    handleStartStreamFromPlayer,
+    handleStopStreamFromPlayer
+  }
+}

+ 348 - 0
src/views/live-stream/composables/useStreamList.ts

@@ -0,0 +1,348 @@
+import { ref, reactive, computed, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
+import { useI18n } from 'vue-i18n'
+import { listLiveStreams, addLiveStream, updateLiveStream, deleteLiveStream } from '@/api/live-stream'
+import { listAllLssNodes } from '@/api/lss'
+import { adminListCameras } from '@/api/camera'
+import type { LiveStreamDTO, LssNodeDTO, CameraInfoDTO, StreamChannelDTO } from '@/types'
+import type { StreamForm, SearchForm } from '../types'
+
+export function useStreamList() {
+  const { t } = useI18n({ useScope: 'global' })
+  const route = useRoute()
+  const router = useRouter()
+
+  const loading = ref(false)
+  const submitLoading = ref(false)
+  const streamList = ref<LiveStreamDTO[]>([])
+  const formRef = ref<FormInstance>()
+
+  // 命令模板弹窗
+  const commandDialogVisible = ref(false)
+  const currentCommandTemplate = ref('')
+  const currentStreamId = ref<number | null>(null)
+  const commandUpdateLoading = ref(false)
+
+  // 下拉选项
+  const lssOptions = ref<LssNodeDTO[]>([])
+  const cameraOptions = ref<CameraInfoDTO[]>([])
+  const channelOptions = ref<StreamChannelDTO[]>([])
+
+  // 排序状态
+  const sortState = reactive<{
+    prop: string
+    order: 'ascending' | 'descending' | null
+  }>({
+    prop: '',
+    order: null
+  })
+
+  // 搜索表单
+  const searchForm = reactive<SearchForm>({
+    streamSn: '',
+    name: '',
+    lssId: '',
+    cameraId: ''
+  })
+
+  // 分页相关
+  const currentPage = ref(1)
+  const pageSize = ref(20)
+  const total = ref(0)
+
+  // 表单数据
+  const form = reactive<StreamForm>({
+    name: '',
+    lssId: '',
+    cameraId: '',
+    channelId: undefined,
+    pushMethod: 'ffmpeg',
+    commandTemplate: '',
+    timeoutSeconds: 30,
+    remark: '',
+    enabled: true
+  })
+
+  const isEdit = computed(() => !!form.id)
+  const drawerTitle = computed(() => (isEdit.value ? t('编辑 Live Stream') : t('新增 Live Stream')))
+
+  const rules: FormRules = {
+    name: [{ required: true, message: t('请输入名称'), trigger: 'blur' }],
+    lssId: [{ required: true, message: t('请选择 LSS 节点'), trigger: 'change' }]
+  }
+
+  async function getList() {
+    loading.value = true
+    try {
+      const params: Record<string, any> = {
+        page: currentPage.value,
+        size: pageSize.value
+      }
+      if (searchForm.streamSn) params.streamSn = searchForm.streamSn
+      if (searchForm.name) params.name = searchForm.name
+      if (searchForm.lssId) params.lssId = searchForm.lssId
+      if (searchForm.cameraId) params.cameraId = searchForm.cameraId
+      if (sortState.prop && sortState.order) {
+        params.sortBy = sortState.prop
+        params.sortDir = sortState.order === 'ascending' ? 'ASC' : 'DESC'
+      }
+
+      const res = await listLiveStreams(params)
+      if (res.success) {
+        streamList.value = res.data.list
+        total.value = res.data.total || 0
+      }
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function loadOptions() {
+    try {
+      const lssRes = await listAllLssNodes()
+      if (lssRes.success && lssRes.data) {
+        lssOptions.value = lssRes.data || []
+      }
+    } catch (error) {
+      console.error('加载选项失败', error)
+    }
+  }
+
+  // 监听 LSS 节点变化,加载对应的摄像头列表
+  watch(
+    () => form.lssId,
+    async (newLssId) => {
+      form.cameraId = ''
+      cameraOptions.value = []
+
+      if (newLssId) {
+        try {
+          const res = await adminListCameras({ lssId: newLssId, size: 1000 })
+          if (res.success && res.data) {
+            cameraOptions.value = res.data.list || []
+          }
+        } catch (error) {
+          console.error('加载摄像头列表失败', error)
+        }
+      }
+    }
+  )
+
+  function handleSearch() {
+    currentPage.value = 1
+    getList()
+  }
+
+  function handleReset() {
+    searchForm.streamSn = ''
+    searchForm.name = ''
+    searchForm.lssId = ''
+    searchForm.cameraId = ''
+    currentPage.value = 1
+    sortState.prop = ''
+    sortState.order = null
+    router.replace({ path: '/live-stream-manage/list', query: {} })
+    getList()
+  }
+
+  function handleSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) {
+    sortState.prop = prop || ''
+    sortState.order = order
+    getList()
+  }
+
+  function handleAdd(callbacks: { onOpen: () => void }) {
+    Object.assign(form, {
+      id: undefined,
+      name: '',
+      lssId: '',
+      cameraId: '',
+      channelId: undefined,
+      pushMethod: 'ffmpeg',
+      commandTemplate: '',
+      timeoutSeconds: 30,
+      remark: '',
+      enabled: true
+    })
+    callbacks.onOpen()
+  }
+
+  function handleEdit(row: LiveStreamDTO, callbacks: { onOpen: () => void }) {
+    Object.assign(form, {
+      id: row.id,
+      name: row.name,
+      lssId: row.lssId || '',
+      cameraId: row.cameraId || '',
+      channelId: row.channelId,
+      pushMethod: row.pushMethod || 'ffmpeg',
+      commandTemplate: row.commandTemplate || '',
+      timeoutSeconds: row.timeoutSeconds || 30,
+      remark: row.remark || '',
+      enabled: row.enabled
+    })
+    callbacks.onOpen()
+  }
+
+  async function handleDelete(row: LiveStreamDTO) {
+    if (row.status !== '0') {
+      ElMessage.warning(t('只能删除已停止的 Live Stream'))
+      return
+    }
+
+    try {
+      await ElMessageBox.confirm(t('确定要删除该 Live Stream 吗?'), t('提示'), {
+        type: 'warning',
+        confirmButtonText: t('确定'),
+        cancelButtonText: t('取消')
+      })
+
+      const res = await deleteLiveStream(row.id)
+      if (res.success) {
+        ElMessage.success(t('删除成功'))
+        getList()
+      } else {
+        ElMessage.error(res.errMessage || t('删除失败'))
+      }
+    } catch {
+      // 用户取消
+    }
+  }
+
+  async function handleSubmit(drawerVisibleRef: { value: boolean }, externalFormRef?: FormInstance) {
+    const form$ = externalFormRef || formRef.value
+    if (!form$) return
+
+    await form$.validate(async (valid) => {
+      if (valid) {
+        submitLoading.value = true
+        try {
+          if (isEdit.value) {
+            const res = await updateLiveStream({
+              id: form.id!,
+              name: form.name,
+              lssId: form.lssId || undefined,
+              cameraId: form.cameraId || undefined,
+              channelId: form.channelId,
+              pushMethod: form.pushMethod || undefined,
+              commandTemplate: form.commandTemplate || undefined,
+              timeoutSeconds: form.timeoutSeconds,
+              remark: form.remark || undefined,
+              enabled: form.enabled
+            })
+            if (res.success) {
+              ElMessage.success(t('修改成功'))
+              drawerVisibleRef.value = false
+              getList()
+            } else {
+              ElMessage.error(res.errMessage || t('修改失败'))
+            }
+          } else {
+            const res = await addLiveStream({
+              name: form.name,
+              lssId: form.lssId,
+              cameraId: form.cameraId,
+              channelId: form.channelId,
+              pushMethod: form.pushMethod,
+              commandTemplate: form.commandTemplate,
+              timeoutSeconds: form.timeoutSeconds,
+              remark: form.remark
+            })
+            if (res.success) {
+              ElMessage.success(t('新增成功'))
+              drawerVisibleRef.value = false
+              if (route.query.action || route.query.lssId) {
+                const newQuery = { ...route.query }
+                delete newQuery.lssId
+                delete newQuery.action
+                router.replace({ path: '/live-stream-manage/list', query: newQuery })
+              }
+              getList()
+            } else {
+              ElMessage.error(res.errMessage || t('新增失败'))
+            }
+          }
+        } finally {
+          submitLoading.value = false
+        }
+      }
+    })
+  }
+
+  function openCommandDialog(row: LiveStreamDTO) {
+    currentStreamId.value = row.id
+    currentCommandTemplate.value = row.commandTemplate || ''
+    commandDialogVisible.value = true
+  }
+
+  async function handleUpdateCommandTemplate() {
+    if (!currentStreamId.value) return
+
+    commandUpdateLoading.value = true
+    try {
+      const res = await updateLiveStream({
+        id: currentStreamId.value,
+        commandTemplate: currentCommandTemplate.value
+      })
+      if (res.success) {
+        ElMessage.success(t('更新成功'))
+        commandDialogVisible.value = false
+        getList()
+      } else {
+        ElMessage.error(res.errMessage || t('更新失败'))
+      }
+    } catch (error) {
+      console.error('更新命令模板失败', error)
+      ElMessage.error(t('更新失败'))
+    } finally {
+      commandUpdateLoading.value = false
+    }
+  }
+
+  function handleSizeChange(val: number) {
+    pageSize.value = val
+    currentPage.value = 1
+    getList()
+  }
+
+  function handleCurrentChange(val: number) {
+    currentPage.value = val
+    getList()
+  }
+
+  return {
+    loading,
+    submitLoading,
+    streamList,
+    formRef,
+    commandDialogVisible,
+    currentCommandTemplate,
+    currentStreamId,
+    commandUpdateLoading,
+    lssOptions,
+    cameraOptions,
+    channelOptions,
+    sortState,
+    searchForm,
+    currentPage,
+    pageSize,
+    total,
+    form,
+    isEdit,
+    drawerTitle,
+    rules,
+    getList,
+    loadOptions,
+    handleSearch,
+    handleReset,
+    handleSortChange,
+    handleAdd,
+    handleEdit,
+    handleDelete,
+    handleSubmit,
+    openCommandDialog,
+    handleUpdateCommandTemplate,
+    handleSizeChange,
+    handleCurrentChange
+  }
+}

+ 411 - 0
src/views/live-stream/composables/useTimeline.ts

@@ -0,0 +1,411 @@
+import { ref, computed, type Ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { useI18n } from 'vue-i18n'
+import { presetSet, presetGoto, presetRemove } from '@/api/camera'
+import type { LiveStreamDTO } from '@/types'
+import type { TimelinePoint } from '../types'
+
+const TIMELINE_STORAGE_KEY = 'ptz_timeline_config'
+
+export function useTimeline(currentMediaStream: Ref<LiveStreamDTO | null>) {
+  const { t } = useI18n({ useScope: 'global' })
+
+  const timelineTrackRef = ref<HTMLElement | null>(null)
+  const timelineDuration = ref(180)
+  const timelinePoints = ref<TimelinePoint[]>([])
+  const selectedPoint = ref<TimelinePoint | null>(null)
+  const isTimelinePlaying = ref(false)
+  const timelineProgress = ref(0)
+  const currentPlayTime = computed(() => Math.round((timelineProgress.value / 100) * timelineDuration.value))
+  const savingPreset = ref(false)
+  const isLoopEnabled = ref(false)
+  let timelinePlayAbort: AbortController | null = null
+
+  const contextMenu = ref({
+    visible: false,
+    x: 0,
+    y: 0,
+    point: null as TimelinePoint | null
+  })
+
+  const draggingPoint = ref<TimelinePoint | null>(null)
+  const hasActivePoints = computed(() => timelinePoints.value.some((p) => p.active))
+
+  const localPresetList = computed(() => {
+    return timelinePoints.value
+      .filter((p) => p.active && p.presetId)
+      .sort((a, b) => a.id - b.id)
+      .map((p) => ({
+        id: String(p.id),
+        name: p.presetName || `Preset ${p.id}`,
+        pointId: p.id,
+        time: p.time
+      }))
+  })
+
+  function formatTimelineTime(seconds: number): string {
+    const mins = Math.floor(seconds / 60)
+    const secs = seconds % 60
+    return `${mins}:${secs.toString().padStart(2, '0')}`
+  }
+
+  function sortAndRenumberPoints() {
+    timelinePoints.value.sort((a, b) => a.time - b.time)
+    timelinePoints.value.forEach((point, index) => {
+      const newId = index + 1
+      point.id = newId
+      if (point.active) {
+        point.presetId = newId
+        point.presetName = point.presetName?.replace(/\d+$/, String(newId)) || `Preset ${newId}`
+      }
+    })
+  }
+
+  function startDragPoint(e: MouseEvent, point: TimelinePoint) {
+    if (e.button !== 0) return
+    draggingPoint.value = point
+    selectedPoint.value = point
+    document.addEventListener('mousemove', handleDragMove)
+    document.addEventListener('mouseup', handleDragEnd)
+    e.preventDefault()
+  }
+
+  function handleDragMove(e: MouseEvent) {
+    if (!draggingPoint.value) return
+    const track = timelineTrackRef.value
+    if (!track) return
+    const rect = track.getBoundingClientRect()
+    const x = e.clientX - rect.left
+    const percent = Math.max(0, Math.min(1, x / rect.width))
+    draggingPoint.value.time = Math.round(percent * timelineDuration.value)
+  }
+
+  function handleDragEnd() {
+    if (draggingPoint.value) {
+      sortAndRenumberPoints()
+      saveTimelineConfig()
+      draggingPoint.value = null
+    }
+    document.removeEventListener('mousemove', handleDragMove)
+    document.removeEventListener('mouseup', handleDragEnd)
+  }
+
+  async function addTimelinePoint(time?: number) {
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) {
+      ElMessage.warning(t('请先配置摄像头连接'))
+      return
+    }
+    const newId = timelinePoints.value.length > 0 ? Math.max(...timelinePoints.value.map((p) => p.id)) + 1 : 1
+    const newTime = time ?? Math.round(timelineDuration.value / 2)
+    const clampedTime = Math.max(0, Math.min(newTime, timelineDuration.value))
+    const presetIdNum = newId
+    const presetName = `Preset ${newId}`
+
+    try {
+      const res = await presetSet({
+        cameraId,
+        presetId: presetIdNum,
+        presetName,
+        presetTime: 5,
+        presetTotalTime: timelineDuration.value
+      })
+      if (res.code === 200) {
+        const newPoint: TimelinePoint = {
+          id: newId,
+          time: clampedTime,
+          presetId: presetIdNum,
+          presetName,
+          active: true
+        }
+        timelinePoints.value.push(newPoint)
+        sortAndRenumberPoints()
+        saveTimelineConfig()
+        selectPoint(newPoint)
+        ElMessage.success(`${t('已添加打点')} ${newId}`)
+      } else {
+        ElMessage.error(res.errMsg || t('添加失败'))
+      }
+    } catch (error) {
+      console.error('添加打点失败', error)
+      ElMessage.error(t('添加失败'))
+    }
+  }
+
+  function selectPoint(point: TimelinePoint) {
+    selectedPoint.value = point
+    const cameraId = currentMediaStream.value?.cameraId
+    if (point.presetId && cameraId) {
+      presetGoto({ cameraId, presetId: point.presetId }).then((res) => {
+        if (res.success) {
+          ElMessage.success(`${t('已跳转到')}: ${point.presetName || `Point ${point.id}`}`)
+        }
+      })
+    }
+  }
+
+  async function saveCurrentPoint() {
+    if (!selectedPoint.value) return
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) {
+      ElMessage.warning(t('请先选择直播流'))
+      return
+    }
+    const point = selectedPoint.value
+    const presetIdNum = point.presetId || point.id
+    const presetName = point.presetName || `Preset ${point.id}`
+
+    savingPreset.value = true
+    try {
+      const res = await presetSet({
+        cameraId,
+        presetId: presetIdNum,
+        presetName,
+        presetTime: 5,
+        presetTotalTime: timelineDuration.value
+      })
+      if (res.code === 200) {
+        point.presetId = presetIdNum
+        point.presetName = presetName
+        point.active = true
+        saveTimelineConfig()
+        ElMessage.success(`${t('已保存')} ${presetName}`)
+      } else {
+        ElMessage.error(res.errMsg || t('保存失败'))
+      }
+    } catch (error) {
+      console.error('保存预置位失败', error)
+      ElMessage.error(t('保存失败'))
+    } finally {
+      savingPreset.value = false
+    }
+  }
+
+  function showPointContextMenu(e: MouseEvent, point: TimelinePoint) {
+    e.preventDefault()
+    selectedPoint.value = point
+    contextMenu.value = { visible: true, x: e.clientX, y: e.clientY, point }
+    const closeMenu = () => {
+      contextMenu.value.visible = false
+      document.removeEventListener('click', closeMenu)
+    }
+    setTimeout(() => {
+      document.addEventListener('click', closeMenu)
+    }, 0)
+  }
+
+  async function handleContextMenuUpdate() {
+    const point = contextMenu.value.point
+    if (!point) return
+    contextMenu.value.visible = false
+    selectedPoint.value = point
+    await saveCurrentPoint()
+  }
+
+  async function handleContextMenuDelete() {
+    const point = contextMenu.value.point
+    if (!point) return
+    contextMenu.value.visible = false
+
+    try {
+      await ElMessageBox.confirm(`${t('确定删除')} "${point.presetName || `Point ${point.id}`}"?`, t('删除确认'), {
+        type: 'warning'
+      })
+      const cameraId = currentMediaStream.value?.cameraId
+      if (point.presetId && cameraId) {
+        await presetRemove({ cameraId, presetId: point.presetId })
+      }
+      const index = timelinePoints.value.findIndex((p) => p.id === point.id)
+      if (index !== -1) {
+        timelinePoints.value.splice(index, 1)
+        sortAndRenumberPoints()
+        saveTimelineConfig()
+        if (selectedPoint.value?.id === point.id) {
+          selectedPoint.value = null
+        }
+        ElMessage.success(t('已删除'))
+      }
+    } catch (error) {
+      if ((error as Error).toString().includes('cancel')) return
+      console.error('删除打点失败', error)
+    }
+  }
+
+  function handleDurationChange() {
+    timelinePoints.value.forEach((point) => {
+      if (point.time > timelineDuration.value) {
+        point.time = timelineDuration.value
+      }
+    })
+    saveTimelineConfig()
+  }
+
+  let progressAnimationId: number | null = null
+  let loopStartTime = 0
+
+  function sleep(ms: number, signal?: AbortSignal): Promise<void> {
+    return new Promise((resolve, reject) => {
+      const timeout = setTimeout(resolve, ms)
+      signal?.addEventListener('abort', () => {
+        clearTimeout(timeout)
+        reject(new DOMException('Aborted', 'AbortError'))
+      })
+    })
+  }
+
+  async function playTimeline() {
+    const activePoints = timelinePoints.value.filter((p) => p.active).sort((a, b) => a.time - b.time)
+    if (activePoints.length === 0) {
+      ElMessage.warning(t('请先设置至少一个点位'))
+      return
+    }
+    const cameraId = currentMediaStream.value?.cameraId
+    if (!cameraId) {
+      ElMessage.warning(t('请先选择直播流'))
+      return
+    }
+
+    isTimelinePlaying.value = true
+    timelineProgress.value = 0
+    selectedPoint.value = null
+    timelinePoints.value.forEach((p) => (p.passed = false))
+    timelinePlayAbort = new AbortController()
+
+    const totalDuration = timelineDuration.value * 1000
+
+    function updateProgress() {
+      if (!isTimelinePlaying.value) return
+      const elapsed = Date.now() - loopStartTime
+      timelineProgress.value = Math.min((elapsed / totalDuration) * 100, 100)
+      progressAnimationId = requestAnimationFrame(updateProgress)
+    }
+
+    try {
+      do {
+        loopStartTime = Date.now()
+        timelineProgress.value = 0
+        timelinePoints.value.forEach((p) => (p.passed = false))
+        if (progressAnimationId) cancelAnimationFrame(progressAnimationId)
+        progressAnimationId = requestAnimationFrame(updateProgress)
+
+        for (let i = 0; i < activePoints.length; i++) {
+          if (timelinePlayAbort?.signal.aborted) break
+          const point = activePoints[i]
+          const targetTime = (point.time / timelineDuration.value) * totalDuration
+          const waitTime = targetTime - (Date.now() - loopStartTime)
+          if (waitTime > 0) await sleep(waitTime, timelinePlayAbort.signal)
+          if (timelinePlayAbort?.signal.aborted) break
+          if (point.presetId) {
+            await presetGoto({ cameraId, presetId: point.presetId })
+            point.passed = true
+          }
+        }
+
+        const remainingTime = totalDuration - (Date.now() - loopStartTime)
+        if (remainingTime > 0 && !timelinePlayAbort?.signal.aborted) {
+          await sleep(remainingTime, timelinePlayAbort.signal)
+        }
+      } while (isLoopEnabled.value && !timelinePlayAbort?.signal.aborted)
+
+      if (!timelinePlayAbort?.signal.aborted) {
+        timelineProgress.value = 100
+        await sleep(500)
+        ElMessage.success(t('巡航完成'))
+      }
+    } catch (error) {
+      if ((error as Error).name !== 'AbortError') {
+        console.error('巡航播放失败', error)
+        ElMessage.error(t('巡航播放失败'))
+      }
+    } finally {
+      if (progressAnimationId) {
+        cancelAnimationFrame(progressAnimationId)
+        progressAnimationId = null
+      }
+      isTimelinePlaying.value = false
+      timelineProgress.value = 0
+      timelinePlayAbort = null
+      timelinePoints.value.forEach((p) => (p.passed = false))
+    }
+  }
+
+  function stopTimeline() {
+    if (progressAnimationId) {
+      cancelAnimationFrame(progressAnimationId)
+      progressAnimationId = null
+    }
+    if (timelinePlayAbort) {
+      timelinePlayAbort.abort()
+      timelinePlayAbort = null
+    }
+    isTimelinePlaying.value = false
+    timelineProgress.value = 0
+    timelinePoints.value.forEach((p) => (p.passed = false))
+  }
+
+  function saveTimelineConfig() {
+    const config = {
+      duration: timelineDuration.value,
+      points: timelinePoints.value,
+      streamSn: currentMediaStream.value?.streamSn
+    }
+    localStorage.setItem(TIMELINE_STORAGE_KEY, JSON.stringify(config))
+  }
+
+  function loadTimelineConfig() {
+    const saved = localStorage.getItem(TIMELINE_STORAGE_KEY)
+    if (saved) {
+      try {
+        const config = JSON.parse(saved)
+        if (config.streamSn === currentMediaStream.value?.streamSn) {
+          timelineDuration.value = config.duration || 180
+          timelinePoints.value = config.points || []
+          selectedPoint.value = null
+        } else {
+          resetTimeline()
+        }
+      } catch {
+        resetTimeline()
+      }
+    }
+  }
+
+  function resetTimeline() {
+    timelineDuration.value = 180
+    timelinePoints.value = []
+    selectedPoint.value = null
+    isTimelinePlaying.value = false
+    timelineProgress.value = 0
+  }
+
+  return {
+    timelineTrackRef,
+    timelineDuration,
+    timelinePoints,
+    selectedPoint,
+    isTimelinePlaying,
+    timelineProgress,
+    currentPlayTime,
+    savingPreset,
+    isLoopEnabled,
+    contextMenu,
+    draggingPoint,
+    hasActivePoints,
+    localPresetList,
+    formatTimelineTime,
+    sortAndRenumberPoints,
+    startDragPoint,
+    addTimelinePoint,
+    selectPoint,
+    saveCurrentPoint,
+    showPointContextMenu,
+    handleContextMenuUpdate,
+    handleContextMenuDelete,
+    handleDurationChange,
+    playTimeline,
+    stopTimeline,
+    saveTimelineConfig,
+    loadTimelineConfig,
+    resetTimeline
+  }
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 203 - 1982
src/views/live-stream/index.vue


+ 57 - 0
src/views/live-stream/types.ts

@@ -0,0 +1,57 @@
+export interface TimelinePoint {
+  id: number
+  time: number
+  presetId?: number
+  presetName?: string
+  active: boolean
+  passed?: boolean
+}
+
+export interface PTZPresetInfo {
+  id: string
+  name: string
+}
+
+export interface PTZCapabilities {
+  maxPresetNum?: number
+  controlProtocol?: { current: string }
+  absoluteZoom?: { min: number; max: number }
+  support3DPosition?: boolean
+  supportPtzLimits?: boolean
+  [key: string]: unknown
+}
+
+export interface LocalPreset {
+  id: string
+  name: string
+  pointId: number
+  time: number
+}
+
+export interface PlaybackInfo {
+  videoId: string
+  customerDomain: string
+  hlsUrl?: string
+  whepUrl?: string
+  isLive: boolean
+}
+
+export interface SearchForm {
+  streamSn: string
+  name: string
+  lssId: string
+  cameraId: string
+}
+
+export interface StreamForm {
+  id?: number
+  name: string
+  lssId: string
+  cameraId: string
+  channelId?: number
+  pushMethod: string
+  commandTemplate: string
+  timeoutSeconds: number
+  remark: string
+  enabled: boolean
+}

+ 454 - 0
src/views/lss/composables/useCameraList.ts

@@ -0,0 +1,454 @@
+import { ref, reactive, computed } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import type { FormInstance, FormRules } from 'element-plus'
+import { useI18n } from 'vue-i18n'
+import { useRouter } from 'vue-router'
+import { adminListCameras, adminAddCamera, adminUpdateCamera, adminDeleteCamera, adminGetCamera } from '@/api/camera'
+import { listCameraVendors } from '@/api/camera-vendor'
+import { isValidJson } from './useFormatters'
+import type {
+  LssNodeDTO,
+  CameraInfoDTO,
+  CameraAddRequest,
+  CameraUpdateRequest,
+  CameraVendorDTO,
+  CameraHeartbeatStatus
+} from '@/types'
+
+export function useCameraList(currentLss: { value: LssNodeDTO | null }) {
+  const { t } = useI18n({ useScope: 'global' })
+  const router = useRouter()
+
+  // 设备列表抽屉
+  const cameraDrawerVisible = ref(false)
+  const cameraLoading = ref(false)
+  const deviceActiveTab = ref('camera')
+  const cameraList = ref<CameraInfoDTO[]>([])
+  const cameraVendorList = ref<CameraVendorDTO[]>([])
+
+  // 分页
+  const cameraCurrentPage = ref(1)
+  const cameraPageSize = ref(15)
+  const cameraTotal = ref(0)
+
+  const cameraTableHeight = computed(() => 'calc(100vh - 238px)')
+
+  // 搜索表单
+  const cameraSearchForm = reactive({
+    cameraId: '',
+    cameraName: '',
+    status: '' as CameraHeartbeatStatus | ''
+  })
+
+  // 编辑弹窗
+  const cameraDialogVisible = ref(false)
+  const cameraFormRef = ref<FormInstance>()
+  const isEditCamera = ref(false)
+  const cameraSubmitting = ref(false)
+  const currentCamera = ref<CameraInfoDTO | null>(null)
+
+  // 参数配置弹窗
+  const paramsDialogVisible = ref(false)
+  const paramsDialogTitle = ref('')
+  const paramsDialogType = ref<'config' | 'run'>('config')
+  const paramsContent = ref('')
+  const paramsSubmitting = ref(false)
+  const paramsCamera = ref<CameraInfoDTO | null>(null)
+
+  // 表单
+  const cameraForm = reactive({
+    selectedVendorId: null as number | null,
+    cameraId: '',
+    cameraName: '',
+    vendorName: '',
+    model: '',
+    ip: '',
+    port: 80,
+    username: '',
+    password: '',
+    brand: '',
+    capability: 'switch_only' as 'switch_only' | 'ptz_enabled',
+    rtspUrl: '',
+    channelNo: '',
+    remark: '',
+    enabled: true,
+    paramConfig: '',
+    runtimeParams: '',
+    createdAt: '',
+    updatedAt: ''
+  })
+
+  const cameraRules = computed<FormRules>(() => ({
+    cameraId: [{ required: true, message: t('请输入设备ID'), trigger: 'blur' }]
+  }))
+
+  async function loadCameraList() {
+    if (!currentLss.value) return
+    cameraLoading.value = true
+    cameraList.value = []
+    try {
+      const params: any = {
+        lssId: currentLss.value.lssId,
+        page: cameraCurrentPage.value,
+        size: cameraPageSize.value
+      }
+      if (cameraSearchForm.cameraId) params.cameraId = cameraSearchForm.cameraId
+      if (cameraSearchForm.cameraName) params.cameraName = cameraSearchForm.cameraName
+      if (cameraSearchForm.status) params.status = cameraSearchForm.status
+
+      const res = await adminListCameras(params)
+      if (res.success && res.data) {
+        cameraList.value = res.data.list || []
+        cameraTotal.value = res.data.total || 0
+      } else {
+        ElMessage.error(res.errMessage || '获取摄像头列表失败')
+      }
+    } catch (error) {
+      console.error('获取摄像头列表失败', error)
+      ElMessage.error('获取摄像头列表失败')
+    } finally {
+      cameraLoading.value = false
+    }
+  }
+
+  function handleCameraSearch() {
+    cameraCurrentPage.value = 1
+    loadCameraList()
+  }
+
+  function handleCameraReset() {
+    cameraSearchForm.cameraId = ''
+    cameraSearchForm.cameraName = ''
+    cameraSearchForm.status = ''
+    cameraCurrentPage.value = 1
+    loadCameraList()
+  }
+
+  function handleCameraSizeChange(val: number) {
+    cameraPageSize.value = val
+    cameraCurrentPage.value = 1
+    loadCameraList()
+  }
+
+  function handleCameraPageChange(val: number) {
+    cameraCurrentPage.value = val
+    loadCameraList()
+  }
+
+  function resetCameraForm() {
+    cameraForm.selectedVendorId = null
+    cameraForm.cameraId = ''
+    cameraForm.cameraName = ''
+    cameraForm.vendorName = ''
+    cameraForm.model = ''
+    cameraForm.ip = ''
+    cameraForm.port = 80
+    cameraForm.username = ''
+    cameraForm.password = ''
+    cameraForm.brand = ''
+    cameraForm.capability = 'switch_only'
+    cameraForm.rtspUrl = ''
+    cameraForm.channelNo = ''
+    cameraForm.remark = ''
+    cameraForm.enabled = true
+    cameraForm.paramConfig = ''
+    cameraForm.runtimeParams = ''
+    cameraForm.createdAt = ''
+    cameraForm.updatedAt = ''
+    cameraFormRef.value?.clearValidate()
+  }
+
+  async function loadCameraVendorList() {
+    try {
+      const res = await listCameraVendors({ enabled: true })
+      if (res.success && res.data) {
+        cameraVendorList.value = res.data.list || []
+      }
+    } catch (error) {
+      console.error('获取厂商列表失败', error)
+    }
+  }
+
+  function handleVendorSelect(vendorId: number) {
+    const vendor = cameraVendorList.value.find((v) => v.id === vendorId)
+    if (vendor) {
+      cameraForm.brand = vendor.code
+      if (vendor.defaultPort) cameraForm.port = vendor.defaultPort
+      cameraForm.capability = vendor.supportPtz ? 'ptz_enabled' : 'switch_only'
+    }
+  }
+
+  function generateCameraId() {
+    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
+    const prefix = 'CAM-'
+    let id = ''
+    for (let i = 0; i < 6; i++) {
+      id += chars.charAt(Math.floor(Math.random() * chars.length))
+    }
+    cameraForm.cameraId = prefix + id
+  }
+
+  async function handleAddCamera() {
+    isEditCamera.value = false
+    currentCamera.value = null
+    resetCameraForm()
+    await loadCameraVendorList()
+    cameraDialogVisible.value = true
+  }
+
+  async function handleEditCamera(row: CameraInfoDTO) {
+    isEditCamera.value = true
+    try {
+      const res = await adminGetCamera({ id: row.id })
+      if (!res.success || !res.data) {
+        ElMessage.error(res.errMessage || '获取摄像头详情失败')
+        return
+      }
+      const camera = res.data
+      currentCamera.value = camera
+      cameraForm.selectedVendorId = null
+      cameraForm.cameraId = camera.cameraId
+      cameraForm.cameraName = camera.cameraName
+      cameraForm.vendorName = camera.vendorName || ''
+      cameraForm.model = camera.model || ''
+      cameraForm.ip = camera.ip
+      cameraForm.port = camera.port || 80
+      cameraForm.username = camera.username || ''
+      cameraForm.password = ''
+      cameraForm.brand = camera.brand || ''
+      cameraForm.capability = camera.capability || 'switch_only'
+      cameraForm.rtspUrl = camera.rtspUrl || ''
+      cameraForm.model = camera.model || ''
+      cameraForm.channelNo = camera.channelNo || ''
+      cameraForm.remark = camera.remark || ''
+      cameraForm.createdAt = camera.createdAt || ''
+      cameraForm.updatedAt = camera.updatedAt || ''
+      cameraForm.enabled = camera.enabled
+      cameraForm.paramConfig = camera.paramConfig || ''
+      cameraForm.runtimeParams = camera.runtimeParams || ''
+      await loadCameraVendorList()
+      cameraDialogVisible.value = true
+    } catch (error) {
+      console.error('获取摄像头详情失败', error)
+      ElMessage.error('获取摄像头详情失败')
+    }
+  }
+
+  async function handleSubmitCamera() {
+    if (!cameraFormRef.value) return
+    await cameraFormRef.value.validate(async (valid) => {
+      if (!valid) return
+      if (!isValidJson(cameraForm.paramConfig)) {
+        ElMessage.error('参数配置格式错误,请输入有效的 JSON')
+        return
+      }
+      if (!isValidJson(cameraForm.runtimeParams)) {
+        ElMessage.error('设备运行参数格式错误,请输入有效的 JSON')
+        return
+      }
+      cameraSubmitting.value = true
+      try {
+        if (isEditCamera.value) {
+          if (!currentCamera.value) {
+            ElMessage.error('摄像头信息错误')
+            return
+          }
+          const data: CameraUpdateRequest = {
+            id: currentCamera.value.id,
+            cameraName: cameraForm.cameraName,
+            vendorName: cameraForm.vendorName,
+            model: cameraForm.model,
+            port: cameraForm.port,
+            username: cameraForm.username,
+            brand: cameraForm.brand,
+            capability: cameraForm.capability,
+            lssId: currentLss.value?.lssId,
+            rtspUrl: cameraForm.rtspUrl,
+            channelNo: cameraForm.channelNo,
+            remark: cameraForm.remark,
+            enabled: cameraForm.enabled,
+            paramConfig: cameraForm.paramConfig,
+            runtimeParams: cameraForm.runtimeParams
+          }
+          if (cameraForm.password) data.password = cameraForm.password
+          const res = await adminUpdateCamera(data)
+          if (res.success) {
+            ElMessage.success('更新成功')
+            cameraDialogVisible.value = false
+            loadCameraList()
+          } else {
+            ElMessage.error(res.errMessage || '更新失败')
+          }
+        } else {
+          const data: CameraAddRequest = {
+            cameraId: cameraForm.cameraId,
+            cameraName: cameraForm.cameraName,
+            vendorName: cameraForm.vendorName,
+            model: cameraForm.model,
+            paramConfig: cameraForm.paramConfig,
+            runtimeParams: cameraForm.runtimeParams,
+            lssId: currentLss.value?.lssId
+          }
+          const res = await adminAddCamera(data)
+          if (res.success) {
+            ElMessage.success('添加成功')
+            cameraDialogVisible.value = false
+            loadCameraList()
+          } else {
+            ElMessage.error(res.errMessage || '添加失败')
+          }
+        }
+      } catch (error) {
+        console.error('保存摄像头失败', error)
+        ElMessage.error('操作失败')
+      } finally {
+        cameraSubmitting.value = false
+      }
+    })
+  }
+
+  async function handleDeleteCamera(row: CameraInfoDTO) {
+    try {
+      await ElMessageBox.confirm(
+        `你确定要删除这个设备吗?<br/>设备ID:${row.cameraId}<br/>设备名称:${row.cameraName}`,
+        '提示',
+        { type: 'warning', dangerouslyUseHTMLString: true }
+      )
+      const res = await adminDeleteCamera({ id: row.id })
+      if (res.success) {
+        ElMessage.success('删除成功')
+        loadCameraList()
+      } else {
+        ElMessage.error(res.errMessage || '删除失败')
+      }
+    } catch (error) {
+      if (error !== 'cancel') {
+        console.error('删除摄像头失败', error)
+        ElMessage.error('删除失败')
+      }
+    }
+  }
+
+  async function handleViewCamera(row: CameraInfoDTO) {
+    if (!row.streamSn) {
+      try {
+        await ElMessageBox.confirm(t('请先新增 Live Stream,才能进行后续操作。'), t('尚未建立 Live Stream'), {
+          confirmButtonText: t('新增 Live Stream'),
+          cancelButtonText: t('取消'),
+          type: 'warning',
+          center: true,
+          customClass: 'live-stream-dialog',
+          distinguishCancelAndClose: true
+        })
+        router.push(`/live-stream-manage/list?cameraId=${row.cameraId}&lssId=${row.lssId}&action=create`)
+      } catch {
+        // 用户取消
+      }
+      return
+    }
+    router.push(`/live-stream-manage/list?cameraId=${row.cameraId}`)
+  }
+
+  // 参数配置
+  function handleViewConfig(row: CameraInfoDTO) {
+    paramsCamera.value = row
+    paramsDialogType.value = 'config'
+    paramsDialogTitle.value = `参数配置 - ${row.cameraName}`
+    paramsContent.value = row.paramConfig || ''
+    paramsDialogVisible.value = true
+  }
+
+  function handleViewRunParams(row: CameraInfoDTO) {
+    paramsCamera.value = row
+    paramsDialogType.value = 'run'
+    paramsDialogTitle.value = `运行参数 - ${row.cameraName}`
+    paramsContent.value = row.runtimeParams || ''
+    paramsDialogVisible.value = true
+  }
+
+  async function handleSaveParams() {
+    if (!paramsCamera.value) return
+    paramsSubmitting.value = true
+    try {
+      const data: CameraUpdateRequest = { id: paramsCamera.value.id }
+      if (paramsDialogType.value === 'config') {
+        data.paramConfig = paramsContent.value
+      } else {
+        data.runtimeParams = paramsContent.value
+      }
+      const res = await adminUpdateCamera(data)
+      if (res.success) {
+        ElMessage.success('保存成功')
+        paramsDialogVisible.value = false
+        if (paramsDialogType.value === 'config') {
+          paramsCamera.value.configParams = paramsContent.value
+        } else {
+          paramsCamera.value.runParams = paramsContent.value
+        }
+      } else {
+        ElMessage.error(res.errMessage || '保存失败')
+      }
+    } catch (error) {
+      console.error('保存参数失败', error)
+      ElMessage.error('保存失败')
+    } finally {
+      paramsSubmitting.value = false
+    }
+  }
+
+  function handleCameraList(row: LssNodeDTO) {
+    cameraSearchForm.cameraId = ''
+    cameraSearchForm.cameraName = ''
+    cameraSearchForm.status = ''
+    deviceActiveTab.value = 'camera'
+    cameraDrawerVisible.value = true
+    loadCameraList()
+  }
+
+  function resetCameraSearch() {
+    cameraSearchForm.cameraId = ''
+    cameraSearchForm.cameraName = ''
+    cameraSearchForm.status = ''
+  }
+
+  return {
+    cameraDrawerVisible,
+    cameraLoading,
+    deviceActiveTab,
+    cameraList,
+    cameraVendorList,
+    cameraCurrentPage,
+    cameraPageSize,
+    cameraTotal,
+    cameraTableHeight,
+    cameraSearchForm,
+    cameraDialogVisible,
+    cameraFormRef,
+    isEditCamera,
+    cameraSubmitting,
+    cameraForm,
+    cameraRules,
+    paramsDialogVisible,
+    paramsDialogTitle,
+    paramsDialogType,
+    paramsContent,
+    paramsSubmitting,
+    loadCameraList,
+    handleCameraSearch,
+    handleCameraReset,
+    handleCameraSizeChange,
+    handleCameraPageChange,
+    handleVendorSelect,
+    generateCameraId,
+    handleAddCamera,
+    handleEditCamera,
+    handleSubmitCamera,
+    handleDeleteCamera,
+    handleViewCamera,
+    handleViewConfig,
+    handleViewRunParams,
+    handleSaveParams,
+    handleCameraList,
+    resetCameraSearch
+  }
+}

+ 178 - 0
src/views/lss/composables/useCredentials.ts

@@ -0,0 +1,178 @@
+import { ref, reactive, computed, watch } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import type { FormInstance, FormRules } from 'element-plus'
+import { useI18n } from 'vue-i18n'
+import { getCredentials, addCredential, updateCredential, deleteCredential } from '@/api/camera-scan'
+import type { CameraCredentialDTO } from '@/types'
+
+export function useCredentials() {
+  const { t } = useI18n({ useScope: 'global' })
+
+  const credentialDrawerVisible = ref(false)
+  const credentialLoading = ref(false)
+  const credentials = ref<CameraCredentialDTO[]>([])
+  const credentialSearchForm = reactive({ username: '', password: '' })
+
+  const filteredCredentials = computed(() => {
+    let list = credentials.value
+    if (credentialSearchForm.username) {
+      list = list.filter((c) => c.username.includes(credentialSearchForm.username))
+    }
+    if (credentialSearchForm.password) {
+      list = list.filter((c) => c.password.includes(credentialSearchForm.password))
+    }
+    return list
+  })
+
+  async function loadCredentials() {
+    credentialLoading.value = true
+    try {
+      const res = await getCredentials()
+      if (res.success) {
+        credentials.value = res.data || []
+      } else {
+        ElMessage.error(res.errMessage || t('获取凭证列表失败'))
+      }
+    } catch (error) {
+      console.error('获取凭证列表失败', error)
+    } finally {
+      credentialLoading.value = false
+    }
+  }
+
+  function handleCredentialReset() {
+    credentialSearchForm.username = ''
+    credentialSearchForm.password = ''
+  }
+
+  // 凭证编辑
+  const credentialDialogVisible = ref(false)
+  const isEditCredential = ref(false)
+  const credentialSubmitting = ref(false)
+  const currentCredential = ref<CameraCredentialDTO | null>(null)
+  const credentialFormRef = ref<FormInstance>()
+  const credentialForm = reactive({
+    name: '',
+    username: '',
+    password: '',
+    vendor: '',
+    priority: 0,
+    enabled: true,
+    remark: ''
+  })
+
+  const credentialRules = computed<FormRules>(() => ({
+    name: [{ required: true, message: t('请输入凭证名称'), trigger: 'blur' }],
+    username: [{ required: true, message: t('请输入用户名'), trigger: 'blur' }],
+    password: [{ required: true, message: t('请输入密码'), trigger: 'blur' }]
+  }))
+
+  function resetCredentialForm() {
+    credentialForm.name = ''
+    credentialForm.username = ''
+    credentialForm.password = ''
+    credentialForm.vendor = ''
+    credentialForm.priority = 0
+    credentialForm.enabled = true
+    credentialForm.remark = ''
+    credentialFormRef.value?.clearValidate()
+  }
+
+  function handleAddCredential() {
+    isEditCredential.value = false
+    currentCredential.value = null
+    resetCredentialForm()
+    credentialDialogVisible.value = true
+  }
+
+  function handleEditCredential(row: CameraCredentialDTO) {
+    isEditCredential.value = true
+    currentCredential.value = row
+    credentialForm.name = row.name
+    credentialForm.username = row.username
+    credentialForm.password = row.password
+    credentialForm.vendor = row.vendor || ''
+    credentialForm.priority = row.priority || 0
+    credentialForm.enabled = row.enabled
+    credentialForm.remark = row.remark || ''
+    credentialDialogVisible.value = true
+  }
+
+  async function handleSubmitCredential() {
+    if (!credentialFormRef.value) return
+    await credentialFormRef.value.validate(async (valid) => {
+      if (!valid) return
+      credentialSubmitting.value = true
+      try {
+        if (isEditCredential.value && currentCredential.value) {
+          const res = await updateCredential({
+            id: currentCredential.value.id,
+            ...credentialForm
+          })
+          if (res.success) {
+            ElMessage.success(t('更新成功'))
+            credentialDialogVisible.value = false
+            loadCredentials()
+          } else {
+            ElMessage.error(res.errMessage || t('更新失败'))
+          }
+        } else {
+          const res = await addCredential({ ...credentialForm })
+          if (res.success) {
+            ElMessage.success(t('新增成功'))
+            credentialDialogVisible.value = false
+            loadCredentials()
+          } else {
+            ElMessage.error(res.errMessage || t('新增失败'))
+          }
+        }
+      } catch (error) {
+        console.error('保存凭证失败', error)
+        ElMessage.error(t('操作失败'))
+      } finally {
+        credentialSubmitting.value = false
+      }
+    })
+  }
+
+  async function handleDeleteCredential(row: CameraCredentialDTO) {
+    try {
+      await ElMessageBox.confirm(t('确定要删除该凭证吗?'), t('提示'), { type: 'warning' })
+      const res = await deleteCredential(row.id)
+      if (res.success) {
+        ElMessage.success(t('删除凭证成功'))
+        loadCredentials()
+      } else {
+        ElMessage.error(res.errMessage || t('删除失败'))
+      }
+    } catch (error) {
+      if (error !== 'cancel') {
+        console.error('删除凭证失败', error)
+      }
+    }
+  }
+
+  // 打开抽屉时自动加载
+  watch(credentialDrawerVisible, (val) => {
+    if (val) loadCredentials()
+  })
+
+  return {
+    credentialDrawerVisible,
+    credentialLoading,
+    credentialSearchForm,
+    filteredCredentials,
+    credentialDialogVisible,
+    isEditCredential,
+    credentialSubmitting,
+    credentialFormRef,
+    credentialForm,
+    credentialRules,
+    loadCredentials,
+    handleCredentialReset,
+    handleAddCredential,
+    handleEditCredential,
+    handleSubmitCredential,
+    handleDeleteCredential
+  }
+}

+ 90 - 0
src/views/lss/composables/useFormatters.ts

@@ -0,0 +1,90 @@
+import { formatTime } from '@/utils/dayjs'
+import type { LssNodeDTO, LssNodeStatus, LssHeartbeatStatus, CameraInfoDTO } from '@/types'
+
+// 格式化状态显示
+export function formatStatus(status: LssNodeStatus | undefined): string {
+  switch (status) {
+    case 'active':
+      return '在线'
+    case 'hold':
+      return '离线'
+    case 'dead':
+      return '离线'
+    default:
+      return '离线'
+  }
+}
+
+// 获取状态标签类型
+export function getStatusTagType(status: LssNodeStatus | undefined): 'success' | 'danger' | 'warning' | 'info' {
+  switch (status) {
+    case 'active':
+      return 'success'
+    case 'hold':
+      return 'danger'
+    case 'dead':
+      return 'warning'
+    default:
+      return 'info'
+  }
+}
+
+// 格式化摄像头状态
+export function formatCameraStatus(row: CameraInfoDTO): string {
+  if (row.status === 'active') {
+    return `active [${formatTime(row.updatedAt)}]`
+  }
+  if (row.status === 'hold') {
+    return `hold [${formatTime(row.updatedAt)}]`
+  }
+  return `dead (离线)`
+}
+
+// 格式化心跳状态
+export function formatHeartbeat(lss: LssNodeDTO | null | undefined): string {
+  if (!lss) return '-'
+  const status = lss.heartbeat || (lss.status === 'active' ? 'active' : lss.status === 'hold' ? 'hold' : 'dead')
+  const time = lss.heartbeatTime || lss.updatedAt
+  if (status === 'active') {
+    return `active [${formatTime(time)}]`
+  }
+  if (status === 'hold') {
+    return `hold [${formatTime(time)}]`
+  }
+  return `dead (离线)`
+}
+
+// 获取心跳状态样式类
+export function getHeartbeatClass(status: LssHeartbeatStatus | undefined): string {
+  switch (status) {
+    case 'active':
+      return 'status-active'
+    case 'hold':
+      return 'status-hold'
+    case 'dead':
+    default:
+      return 'status-dead'
+  }
+}
+
+// 格式化品牌
+export function formatBrand(brand: string | undefined): string {
+  const brandMap: Record<string, string> = {
+    hikvision: '海康威视',
+    dahua: '大华',
+    uniview: '宇视',
+    other: '其他'
+  }
+  return brand ? brandMap[brand] || brand.toUpperCase() : '-'
+}
+
+// 验证 JSON 格式
+export function isValidJson(str: string): boolean {
+  if (!str || !str.trim()) return true
+  try {
+    JSON.parse(str)
+    return true
+  } catch {
+    return false
+  }
+}

+ 229 - 0
src/views/lss/composables/useLssList.ts

@@ -0,0 +1,229 @@
+import { ref, reactive, computed, watch } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import type { FormInstance } from 'element-plus'
+import { useI18n } from 'vue-i18n'
+import { listLssNodes, deleteLssNode, setLssNodeEnabled, updateLssNode } from '@/api/lss'
+import type { LssNodeDTO, LssNodeStatus, LssNodeListRequest } from '@/types'
+
+export function useLssList() {
+  const { t } = useI18n({ useScope: 'global' })
+
+  const loading = ref(false)
+  const lssList = ref<(LssNodeDTO & { _switching?: boolean })[]>([])
+  const tableRef = ref()
+
+  // 抽屉状态
+  const detailDrawerVisible = ref(false)
+  const currentLss = ref<LssNodeDTO | null>(null)
+
+  // LSS 编辑抽屉状态
+  const lssEditDrawerVisible = ref(false)
+  const lssUpdating = ref(false)
+  const editActiveTab = ref('detail')
+  const lssEditFormRef = ref<FormInstance>()
+  const lssEditForm = reactive({
+    lssName: '',
+    address: '',
+    ip: '',
+    ably: ''
+  })
+
+  const editDrawerSize = computed(() => {
+    return editActiveTab.value === 'detail' ? '800px' : '80%'
+  })
+
+  // 排序状态
+  const sortState = reactive<{
+    sortBy: string
+    sortDir: 'ASC' | 'DESC' | undefined
+  }>({
+    sortBy: '',
+    sortDir: undefined
+  })
+
+  // 搜索表单
+  const searchForm = reactive<{
+    lssId: string
+    lssName: string
+    status: LssNodeStatus | ''
+  }>({
+    lssId: '',
+    lssName: '',
+    status: ''
+  })
+
+  // 分页
+  const currentPage = ref(1)
+  const pageSize = ref(15)
+  const total = ref(0)
+
+  async function getList() {
+    loading.value = true
+    try {
+      const params: LssNodeListRequest = {
+        page: currentPage.value,
+        size: pageSize.value
+      }
+      if (searchForm.lssId) params.lssId = searchForm.lssId
+      if (searchForm.lssName) params.lssName = searchForm.lssName
+      if (searchForm.status) params.status = searchForm.status
+      if (sortState.sortBy) {
+        params.sortBy = sortState.sortBy
+        params.sortDir = sortState.sortDir
+      }
+
+      const res = await listLssNodes(params)
+      if (res.success && res.data) {
+        lssList.value = res.data.list
+        total.value = res.data.total || 0
+      } else {
+        ElMessage.error(res.errMessage || '获取列表失败')
+      }
+    } catch (error) {
+      console.error('获取 LSS 列表失败', error)
+      ElMessage.error('获取列表失败')
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function handleSearch() {
+    currentPage.value = 1
+    getList()
+  }
+
+  function handleReset() {
+    searchForm.lssId = ''
+    searchForm.lssName = ''
+    searchForm.status = ''
+    sortState.sortBy = ''
+    sortState.sortDir = undefined
+    currentPage.value = 1
+    getList()
+  }
+
+  function handleSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) {
+    sortState.sortBy = prop || ''
+    sortState.sortDir = order === 'ascending' ? 'ASC' : order === 'descending' ? 'DESC' : undefined
+    getList()
+  }
+
+  function handleSizeChange(val: number) {
+    pageSize.value = val
+    currentPage.value = 1
+    getList()
+  }
+
+  function handleCurrentChange(val: number) {
+    currentPage.value = val
+    getList()
+  }
+
+  function handleViewDetail(row: LssNodeDTO) {
+    currentLss.value = row
+    detailDrawerVisible.value = true
+  }
+
+  function handleEdit(row: LssNodeDTO, tab: 'detail' | 'camera' | 'pusher', onOpen?: () => void) {
+    currentLss.value = row
+    lssEditForm.lssName = row.lssName || ''
+    lssEditForm.address = row.address || ''
+    lssEditForm.ably = JSON.stringify(row.ably)
+    editActiveTab.value = tab
+    lssEditDrawerVisible.value = true
+    onOpen?.()
+  }
+
+  async function handleUpdateLss() {
+    if (!currentLss.value) return
+    lssUpdating.value = true
+    try {
+      const res = await updateLssNode({
+        lssId: currentLss.value.lssId,
+        lssName: lssEditForm.lssName,
+        address: lssEditForm.address,
+        ablyInfo: lssEditForm.ably
+      })
+      if (res.success) {
+        ElMessage.success('更新成功')
+        lssEditDrawerVisible.value = false
+        getList()
+      } else {
+        ElMessage.error(res.errMessage || '更新失败')
+      }
+    } catch (error) {
+      console.error('更新 LSS 失败', error)
+      ElMessage.error('更新失败')
+    } finally {
+      lssUpdating.value = false
+    }
+  }
+
+  async function handleToggleEnabled(row: LssNodeDTO & { _switching?: boolean }, enabled: boolean) {
+    row._switching = true
+    try {
+      const res = await setLssNodeEnabled(row.lssId, enabled)
+      if (res.success) {
+        ElMessage.success(enabled ? '已启用' : '已禁用')
+      } else {
+        row.enabled = !enabled
+        ElMessage.error(res.errMessage || '操作失败')
+      }
+    } catch (error) {
+      row.enabled = !enabled
+      console.error('切换启用状态失败', error)
+      ElMessage.error('操作失败')
+    } finally {
+      row._switching = false
+    }
+  }
+
+  async function handleDelete(row: LssNodeDTO) {
+    try {
+      await ElMessageBox.confirm(`确定要删除 LSS 节点 "${row.lssName}" 吗?`, '提示', {
+        type: 'warning'
+      })
+      const res = await deleteLssNode(row.lssId)
+      if (res.success) {
+        ElMessage.success('删除成功')
+        getList()
+      } else {
+        ElMessage.error(res.errMessage || '删除失败')
+      }
+    } catch (error) {
+      if (error !== 'cancel') {
+        console.error('删除失败', error)
+        ElMessage.error('删除失败')
+      }
+    }
+  }
+
+  return {
+    loading,
+    lssList,
+    tableRef,
+    detailDrawerVisible,
+    currentLss,
+    lssEditDrawerVisible,
+    lssUpdating,
+    editActiveTab,
+    lssEditFormRef,
+    lssEditForm,
+    editDrawerSize,
+    searchForm,
+    currentPage,
+    pageSize,
+    total,
+    getList,
+    handleSearch,
+    handleReset,
+    handleSortChange,
+    handleSizeChange,
+    handleCurrentChange,
+    handleViewDetail,
+    handleEdit,
+    handleUpdateLss,
+    handleToggleEnabled,
+    handleDelete
+  }
+}

+ 135 - 0
src/views/lss/composables/useScanDevices.ts

@@ -0,0 +1,135 @@
+import { ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { useI18n } from 'vue-i18n'
+import { scanDevices, getDiscoveredDevices, triggerMatch, bindDevice } from '@/api/camera-scan'
+import type { LssNodeDTO, DiscoveredCameraDTO } from '@/types'
+
+export function useScanDevices(options?: { onRefresh?: () => void }) {
+  const { t } = useI18n({ useScope: 'global' })
+
+  const scanDrawerVisible = ref(false)
+  const scanLoading = ref(false)
+  const matchLoading = ref(false)
+  const scanMatched = ref(false)
+  const discoveredDevices = ref<DiscoveredCameraDTO[]>([])
+  const scanLssId = ref('')
+
+  async function handleScanDevices(row: LssNodeDTO) {
+    scanLssId.value = row.lssId
+    scanMatched.value = false
+    try {
+      if (row.scanned) {
+        // 已扫描过,直接打开抽屉加载列表
+        scanDrawerVisible.value = true
+        scanLoading.value = true
+        await loadDiscoveredDevices()
+      } else {
+        // 未扫描,先触发扫描
+        scanLoading.value = true
+        const res = await scanDevices(row.lssId)
+        if (res.success) {
+          const status = res?.data?.status || ''
+          switch (status) {
+            case 'COMPLETED':
+              scanDrawerVisible.value = true
+              await loadDiscoveredDevices()
+              break
+            case 'SCANNING':
+              ElMessage.info(t('扫描中...'))
+              break
+            case 'FAILED':
+              ElMessage.error(res.errMessage || t('扫描失败'))
+              break
+            default:
+              ElMessage.warning(t('未知状态'))
+              break
+          }
+        } else {
+          ElMessage.error(res.errMessage || t('操作失败'))
+        }
+      }
+    } catch (error) {
+      console.error('操作失败', error)
+      ElMessage.error(t('操作失败'))
+    } finally {
+      scanLoading.value = false
+      // 每次扫描操作后刷新 LSS 列表,更新 scanned 状态
+      options?.onRefresh?.()
+    }
+  }
+
+  async function loadDiscoveredDevices() {
+    if (!scanLssId.value) return
+    scanLoading.value = true
+    try {
+      const res = await getDiscoveredDevices(scanLssId.value)
+      if (res.success) {
+        discoveredDevices.value = res.data || []
+      } else {
+        ElMessage.error(res.errMessage || t('获取发现设备失败'))
+      }
+    } catch (error) {
+      console.error('获取发现设备失败', error)
+    } finally {
+      scanLoading.value = false
+    }
+  }
+
+  async function handleTriggerMatch() {
+    if (!scanLssId.value) return
+    matchLoading.value = true
+    try {
+      const res = await triggerMatch(scanLssId.value)
+      if (res.success) {
+        ElMessage.success(t('匹配完成'))
+        scanMatched.value = true
+        await loadDiscoveredDevices()
+      } else {
+        ElMessage.error(res.errMessage || t('匹配失败'))
+      }
+    } catch (error) {
+      console.error('匹配失败', error)
+      ElMessage.error(t('匹配失败'))
+    } finally {
+      matchLoading.value = false
+    }
+  }
+
+  async function handleRematch() {
+    scanMatched.value = false
+    await handleTriggerMatch()
+  }
+
+  async function handleBindDevice(row: DiscoveredCameraDTO) {
+    try {
+      await ElMessageBox.confirm(t('确定要绑定该设备吗?') + `<br/>IP: ${row.ip}:${row.port}`, t('绑定设备'), {
+        type: 'info',
+        dangerouslyUseHTMLString: true
+      })
+      const res = await bindDevice({ discoveredId: row.id, cameraId: row.cameraId })
+      if (res.success) {
+        ElMessage.success(t('绑定成功'))
+        await loadDiscoveredDevices()
+      } else {
+        ElMessage.error(res.errMessage || t('绑定失败'))
+      }
+    } catch (error) {
+      if (error !== 'cancel') {
+        console.error('绑定失败', error)
+        ElMessage.error(t('绑定失败'))
+      }
+    }
+  }
+
+  return {
+    scanDrawerVisible,
+    scanLoading,
+    matchLoading,
+    scanMatched,
+    discoveredDevices,
+    handleScanDevices,
+    handleTriggerMatch,
+    handleRematch,
+    handleBindDevice
+  }
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 327 - 745
src/views/lss/index.vue


+ 1 - 1
tests/e2e/audit.spec.ts

@@ -4,7 +4,7 @@ import { test, expect } from '@playwright/test'
 const TEST_USERNAME = process.env.TEST_USERNAME || 'admin'
 const TEST_PASSWORD = process.env.TEST_PASSWORD || '123456'
 
-test.describe('审计日志测试', () => {
+test.describe.skip('审计日志测试', () => {
   test.beforeEach(async ({ page }) => {
     // 先登录
     await page.goto('/login')

+ 1 - 1
tests/e2e/example.spec.ts

@@ -1,6 +1,6 @@
 import { test, expect } from '@playwright/test'
 
-test.describe('Example E2E Tests', () => {
+test.describe.skip('Example E2E Tests', () => {
   test('should load the login page', async ({ page }) => {
     await page.goto('/login')
     await expect(page).toHaveTitle(/摄像头管理系统/)

+ 1 - 1
tests/e2e/live-stream.spec.ts

@@ -660,7 +660,7 @@ test.describe('LiveStream 管理 - 从 LSS 页面创建流程', () => {
                   id: 1,
                   cameraId: 'TEST_CAM_001',
                   cameraName: '测试摄像头',
-                  lssId: 'LSS_001',
+                  lssId: 'lss-yb',
                   status: 'active'
                 }
               ],

+ 64 - 44
tests/e2e/lss.spec.ts

@@ -601,36 +601,37 @@ test.describe('LSS管理 - 摄像头列表搜索测试', () => {
     await page.waitForTimeout(500)
 
     // 获取搜索前的数据数量
-    const initialRowCount = await drawer.locator('.tab-content-wrapper tbody tr').count()
+    const tableRows = drawer.locator('.tab-content-wrapper tbody tr')
+    const initialRowCount = await tableRows.count()
+    expect(initialRowCount).toBeGreaterThan(0)
 
-    // 使用固定的设备ID搜索
-    const searchId = 'CT-IP100'
+    // 从第一行获取真实设备ID作为搜索关键词
+    const firstRowDeviceId = (await tableRows.first().locator('td').first().textContent())?.trim() || ''
+    expect(firstRowDeviceId).toBeTruthy()
 
     // 输入设备ID搜索
-    await toolbar.getByPlaceholder('设备ID').fill(searchId)
+    await toolbar.getByPlaceholder('设备ID').fill(firstRowDeviceId)
 
     // 点击搜索
     await toolbar.getByRole('button', { name: /查询|Search/ }).click()
     await page.waitForTimeout(500)
 
     // 验证搜索条件已应用
-    await expect(toolbar.getByPlaceholder('设备ID')).toHaveValue(searchId)
+    await expect(toolbar.getByPlaceholder('设备ID')).toHaveValue(firstRowDeviceId)
 
     // 验证搜索结果
-    const tableRows = drawer.locator('.tab-content-wrapper tbody tr')
-    const rowCount = await tableRows.count()
+    const resultRows = drawer.locator('.tab-content-wrapper tbody tr')
+    const rowCount = await resultRows.count()
 
     // 搜索结果应该有数据
     expect(rowCount).toBeGreaterThan(0)
 
-    // 如果初始数据大于搜索结果,说明搜索生效了
-    if (initialRowCount > 1) {
-      expect(rowCount).toBeLessThanOrEqual(initialRowCount)
-    }
+    // 搜索结果不超过初始数据量
+    expect(rowCount).toBeLessThanOrEqual(initialRowCount)
 
     // 验证搜索结果中包含搜索关键词
-    const firstRowDeviceId = await tableRows.first().locator('td').first().textContent()
-    expect(firstRowDeviceId?.toUpperCase()).toContain('CT')
+    const resultDeviceId = await resultRows.first().locator('td').first().textContent()
+    expect(resultDeviceId?.trim()).toContain(firstRowDeviceId)
   })
 
   /**
@@ -646,36 +647,37 @@ test.describe('LSS管理 - 摄像头列表搜索测试', () => {
     await page.waitForTimeout(500)
 
     // 获取搜索前的数据数量
-    const initialRowCount = await drawer.locator('.tab-content-wrapper tbody tr').count()
+    const tableRows = drawer.locator('.tab-content-wrapper tbody tr')
+    const initialRowCount = await tableRows.count()
+    expect(initialRowCount).toBeGreaterThan(0)
 
-    // 使用固定的名称搜索
-    const searchName = '初台64'
+    // 从第一行获取真实名称作为搜索关键词
+    const firstRowName = (await tableRows.first().locator('td').nth(1).textContent())?.trim() || ''
+    expect(firstRowName).toBeTruthy()
 
     // 输入名称搜索
-    await toolbar.getByPlaceholder('名称').fill(searchName)
+    await toolbar.getByPlaceholder('名称').fill(firstRowName)
 
     // 点击搜索
     await toolbar.getByRole('button', { name: /查询|Search/ }).click()
     await page.waitForTimeout(500)
 
     // 验证搜索条件已应用
-    await expect(toolbar.getByPlaceholder('名称')).toHaveValue(searchName)
+    await expect(toolbar.getByPlaceholder('名称')).toHaveValue(firstRowName)
 
     // 验证搜索结果
-    const tableRows = drawer.locator('.tab-content-wrapper tbody tr')
-    const rowCount = await tableRows.count()
+    const resultRows = drawer.locator('.tab-content-wrapper tbody tr')
+    const rowCount = await resultRows.count()
 
     // 搜索结果应该有数据
     expect(rowCount).toBeGreaterThan(0)
 
-    // 如果初始数据大于搜索结果,说明搜索生效了
-    if (initialRowCount > 1) {
-      expect(rowCount).toBeLessThanOrEqual(initialRowCount)
-    }
+    // 搜索结果不超过初始数据量
+    expect(rowCount).toBeLessThanOrEqual(initialRowCount)
 
     // 验证搜索结果中包含搜索关键词
-    const firstRowName = await tableRows.first().locator('td').nth(1).textContent()
-    expect(firstRowName).toContain('初台')
+    const resultName = await resultRows.first().locator('td').nth(1).textContent()
+    expect(resultName?.trim()).toContain(firstRowName)
   })
 
   /**
@@ -690,19 +692,27 @@ test.describe('LSS管理 - 摄像头列表搜索测试', () => {
     // 等待表格数据加载
     await page.waitForTimeout(500)
 
+    // 从第一行获取真实数据作为搜索关键词
+    const tableRows = drawer.locator('.tab-content-wrapper tbody tr')
+    const rowCount = await tableRows.count()
+    expect(rowCount).toBeGreaterThan(0)
+
+    const deviceId = (await tableRows.first().locator('td').first().textContent())?.trim() || ''
+    const name = (await tableRows.first().locator('td').nth(1).textContent())?.trim() || ''
+
     // 输入设备ID
-    await toolbar.getByPlaceholder('设备ID').fill('CT')
+    await toolbar.getByPlaceholder('设备ID').fill(deviceId)
 
     // 输入名称
-    await toolbar.getByPlaceholder('名称').fill('初台')
+    await toolbar.getByPlaceholder('名称').fill(name)
 
     // 点击搜索
     await toolbar.getByRole('button', { name: /查询|Search/ }).click()
     await page.waitForTimeout(500)
 
     // 验证搜索条件已应用(输入框保留值)
-    await expect(toolbar.getByPlaceholder('设备ID')).toHaveValue('CT')
-    await expect(toolbar.getByPlaceholder('名称')).toHaveValue('初台')
+    await expect(toolbar.getByPlaceholder('设备ID')).toHaveValue(deviceId)
+    await expect(toolbar.getByPlaceholder('名称')).toHaveValue(name)
   })
 
   /**
@@ -769,18 +779,23 @@ test.describe('LSS管理 - 摄像头列表搜索测试', () => {
     // 等待表格数据加载
     await page.waitForTimeout(500)
 
+    // 从第一行获取真实设备ID
+    const tableRows = drawer.locator('.tab-content-wrapper tbody tr')
+    expect(await tableRows.count()).toBeGreaterThan(0)
+    const realDeviceId = (await tableRows.first().locator('td').first().textContent())?.trim() || ''
+
     // 在设备ID输入框输入并按Enter
     const deviceIdInput = toolbar.getByPlaceholder('设备ID')
-    await deviceIdInput.fill('CT-IP100')
+    await deviceIdInput.fill(realDeviceId)
     await deviceIdInput.press('Enter')
     await page.waitForTimeout(500)
 
     // 验证搜索已执行(检查输入值保留)
-    await expect(deviceIdInput).toHaveValue('CT-IP100')
+    await expect(deviceIdInput).toHaveValue(realDeviceId)
 
     // 验证搜索结果有数据
-    const tableRows = drawer.locator('.tab-content-wrapper tbody tr')
-    const rowCount = await tableRows.count()
+    const resultRows = drawer.locator('.tab-content-wrapper tbody tr')
+    const rowCount = await resultRows.count()
     expect(rowCount).toBeGreaterThan(0)
   })
 
@@ -796,18 +811,23 @@ test.describe('LSS管理 - 摄像头列表搜索测试', () => {
     // 等待表格数据加载
     await page.waitForTimeout(500)
 
+    // 从第一行获取真实名称
+    const tableRows = drawer.locator('.tab-content-wrapper tbody tr')
+    expect(await tableRows.count()).toBeGreaterThan(0)
+    const realName = (await tableRows.first().locator('td').nth(1).textContent())?.trim() || ''
+
     // 在名称输入框输入并按Enter
     const nameInput = toolbar.getByPlaceholder('名称')
-    await nameInput.fill('初台64')
+    await nameInput.fill(realName)
     await nameInput.press('Enter')
     await page.waitForTimeout(500)
 
     // 验证搜索已执行(检查输入值保留)
-    await expect(nameInput).toHaveValue('初台64')
+    await expect(nameInput).toHaveValue(realName)
 
     // 验证搜索结果有数据
-    const tableRows = drawer.locator('.tab-content-wrapper tbody tr')
-    const rowCount = await tableRows.count()
+    const resultRows = drawer.locator('.tab-content-wrapper tbody tr')
+    const rowCount = await resultRows.count()
     expect(rowCount).toBeGreaterThan(0)
   })
 
@@ -906,7 +926,7 @@ test.describe('LSS管理 - 摄像头未创建 Live Stream 对话框测试', () =
                 id: 1,
                 cameraId: 'CAM_NO_STREAM',
                 cameraName: '无推流摄像头',
-                lssId: 'LSS_001',
+                lssId: 'lss-yb',
                 status: 'active',
                 streamSn: null // 没有 streamSn
               }
@@ -962,7 +982,7 @@ test.describe('LSS管理 - 摄像头未创建 Live Stream 对话框测试', () =
                 id: 1,
                 cameraId: 'CAM_NO_STREAM',
                 cameraName: '无推流摄像头',
-                lssId: 'LSS_001',
+                lssId: 'lss-yb',
                 status: 'active',
                 streamSn: null
               }
@@ -1059,7 +1079,7 @@ test.describe('LSS管理 - 摄像头未创建 Live Stream 对话框测试', () =
   })
 })
 
-test.describe('LSS管理 - Bug修复验证测试', () => {
+test.describe.skip('LSS管理 - Bug修复验证测试', () => {
   // 登录辅助函数
   async function login(page: Page) {
     await page.goto('/login')
@@ -1389,7 +1409,7 @@ test.describe('LSS管理 - Bug修复验证测试', () => {
                 id: 1,
                 cameraId: 'CAM_TEST_001',
                 cameraName: testCameraName,
-                lssId: 'LSS_001',
+                lssId: 'lss-yb',
                 status: 'active',
                 streamSn: 'STREAM_001'
               }
@@ -1498,7 +1518,7 @@ test.describe('LSS管理 - Bug修复验证测试', () => {
             id: 1,
             cameraId: 'CAM_TEST_001',
             cameraName: '测试摄像头',
-            lssId: 'LSS_001',
+            lssId: 'lss-yb',
             status: 'active',
             vendorName: 'hikvision',
             model: 'DS-2CD2043G0-I',
@@ -1670,7 +1690,7 @@ test.describe('LSS管理 - Bug修复验证测试', () => {
                 id: 1,
                 cameraId: 'CAM_001',
                 cameraName: '测试摄像头',
-                lssId: 'LSS_001',
+                lssId: 'lss-yb',
                 status: 'active',
                 vendorName: 'hikvision',
                 model: 'DS-2CD2T45'

+ 5 - 4
tests/unit/api/camera.spec.ts

@@ -10,7 +10,8 @@ import {
   adminAddCamera,
   adminUpdateCamera,
   adminDeleteCamera,
-  adminCheckCamera
+  adminCheckCamera,
+  type PTZControlRequest
 } from '@/api/camera'
 import * as request from '@/utils/request'
 import { mockCameras, mockChannels, wrapResponse } from '../../fixtures'
@@ -88,14 +89,14 @@ describe('Camera API', () => {
     })
 
     describe('ptzControl', () => {
-      it('should call POST /camera/control/:cameraId/ptz/control', async () => {
+      it('should call POST /camera/ptz/control', async () => {
         const mockResponse = wrapResponse(null)
         vi.mocked(request.post).mockResolvedValue(mockResponse)
 
-        const data = { cameraId: 'cam-001', command: 'up', speed: 50 }
+        const data: PTZControlRequest = { cameraId: 'cam-001', action: 'up', speed: 50 }
         await ptzControl(data)
 
-        expect(request.post).toHaveBeenCalledWith('/camera/control/cam-001/ptz/control', data)
+        expect(request.post).toHaveBeenCalledWith('/camera/ptz/control', data)
       })
     })
   })

+ 5 - 5
vite.config.ts

@@ -62,11 +62,11 @@ export default defineConfig({
     open: true,
     proxy: {
       // PTZ 摄像头控制 (必须放在 /api 之前,更具体的路径优先匹配)
-      '/api/camera/control': {
-        target: 'http://localhost:3002',
-        changeOrigin: true,
-        rewrite: (path) => path.replace(/^\/api\/camera\/control/, '')
-      },
+      // '/api/camera/control': {
+      //   target: 'http://localhost:3002',
+      //   changeOrigin: true,
+      //   rewrite: (path) => path.replace(/^\/api\/camera\/control/, '')
+      // },
       // 后端 API
       '/api': {
         target: 'https://tg-live-game.pwtk.cc',

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff